mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-07-11 08:22:09 +02:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf6a3cac4a | |||
| fce90cf88a | |||
| 3346221569 | |||
| 9355a0cef1 | |||
| 45915ca560 | |||
| 78920ef486 | |||
| 7d58fb502a | |||
| e2308387fa | |||
| e3b1f4f93b | |||
| 08484e055d | |||
| 05da27331f | |||
| 0087b871a1 | |||
| 8469ce4984 | |||
| a24a8986e1 | |||
| 28c5510417 | |||
| 2cd35a7e77 | |||
| 4cff153402 | |||
| 01e43a8e29 | |||
| e8b0e4538b | |||
| c7778e15be | |||
| 6ccd068da8 | |||
| 9893181498 | |||
| 61ff89e114 | |||
| d9f7e84daa | |||
| c9abe2a167 |
@@ -1,42 +1,73 @@
|
|||||||
|
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 = 36
|
compileSdk = 37
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "me.kavishdevar.librepods"
|
applicationId = "me.kavishdevar.librepods"
|
||||||
minSdk = 33
|
minSdk = 33
|
||||||
targetSdk = 36
|
targetSdk = 37
|
||||||
versionCode = 9
|
versionCode = 28
|
||||||
versionName = "0.2.0"
|
versionName = "0.2.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
arguments += "-DCMAKE_BUILD_TYPE=Release"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildConfigField("Boolean", "PLAY_BUILD", "false")
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
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_1_8
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
}
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "1.8"
|
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
viewBinding = true
|
viewBinding = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
androidResources {
|
androidResources {
|
||||||
generateLocaleConfig = true
|
generateLocaleConfig = true
|
||||||
@@ -49,18 +80,41 @@ android {
|
|||||||
}
|
}
|
||||||
sourceSets {
|
sourceSets {
|
||||||
getByName("main") {
|
getByName("main") {
|
||||||
res.srcDirs("src/main/res", "src/main/res-apple")
|
res.directories+="src/main/res-apple"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ndkVersion = "30.0.14904198"
|
||||||
|
|
||||||
|
flavorDimensions += "env"
|
||||||
|
|
||||||
|
productFlavors {
|
||||||
|
create("normal") {
|
||||||
|
dimension = "env"
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
arguments += "-DIS_XPOSED=OFF"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
create("xposed") {
|
||||||
|
dimension = "env"
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
arguments += "-DIS_XPOSED=ON"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
@@ -72,15 +126,18 @@ dependencies {
|
|||||||
implementation(libs.haze.materials)
|
implementation(libs.haze.materials)
|
||||||
implementation(libs.androidx.dynamicanimation)
|
implementation(libs.androidx.dynamicanimation)
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
|
implementation(libs.androidx.compose.material.icons.core)
|
||||||
|
implementation(libs.billing)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
implementation(libs.androidx.compose.foundation.layout)
|
implementation(libs.androidx.compose.foundation.layout)
|
||||||
implementation(libs.aboutlibraries)
|
implementation(libs.aboutlibraries)
|
||||||
implementation(libs.aboutlibraries.compose.m3)
|
implementation(libs.aboutlibraries.compose.m3)
|
||||||
// compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
|
implementation(libs.backdrop)
|
||||||
// implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.aar"))))
|
// implementation(libs.hilt)
|
||||||
compileOnly(files("libs/libxposed-api-100.aar"))
|
// implementation(libs.hilt.compiler)
|
||||||
debugImplementation(files("libs/backdrop-debug.aar"))
|
add("xposedCompileOnly", libs.libxposed.api)
|
||||||
releaseImplementation(files("libs/backdrop-release.aar"))
|
add("xposedImplementation", libs.libxposed.service)
|
||||||
|
add("playReleaseImplementation", libs.billing)
|
||||||
}
|
}
|
||||||
|
|
||||||
aboutLibraries {
|
aboutLibraries {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Vendored
+3
@@ -19,3 +19,6 @@
|
|||||||
# 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.**
|
||||||
|
|||||||
@@ -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,15 +26,17 @@
|
|||||||
<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"
|
||||||
@@ -45,10 +47,9 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.LibrePods"
|
android:theme="@style/Theme.LibrePods"
|
||||||
android:description="@string/app_description"
|
android:description="@string/app_description"
|
||||||
tools:ignore="UnusedAttribute"
|
tools:ignore="UnusedAttribute" >
|
||||||
tools:targetApi="31">
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widgets.NoiseControlWidget"
|
android:name=".presentation.widgets.NoiseControlWidget"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
@@ -60,7 +61,7 @@
|
|||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widgets.BatteryWidget"
|
android:name=".presentation.widgets.BatteryWidget"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
@@ -114,17 +115,17 @@
|
|||||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
<service
|
<!-- <service-->
|
||||||
android:name=".services.AppListenerService"
|
<!-- android:name=".services.AppListenerService"-->
|
||||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
|
<!-- android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"-->
|
||||||
android:exported="true">
|
<!-- android:exported="true">-->
|
||||||
<intent-filter>
|
<!-- <intent-filter>-->
|
||||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
<!-- <action android:name="android.accessibilityservice.AccessibilityService" />-->
|
||||||
</intent-filter>
|
<!-- </intent-filter>-->
|
||||||
<meta-data
|
<!-- <meta-data-->
|
||||||
android:name="android.accessibilityservice"
|
<!-- android:name="android.accessibilityservice"-->
|
||||||
android:resource="@xml/app_listener_service_config" />
|
<!-- android:resource="@xml/app_listener_service_config" />-->
|
||||||
</service>
|
<!-- </service>-->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".receivers.BootReceiver"
|
android:name=".receivers.BootReceiver"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
|
|||||||
@@ -3,10 +3,61 @@ cmake_minimum_required(VERSION 3.22.1)
|
|||||||
project("l2c_fcr_hook")
|
project("l2c_fcr_hook")
|
||||||
set(CMAKE_CXX_STANDARD 23)
|
set(CMAKE_CXX_STANDARD 23)
|
||||||
|
|
||||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
option(IS_XPOSED "Build Xposed components" OFF)
|
||||||
l2c_fcr_hook.cpp
|
|
||||||
l2c_fcr_hook.h)
|
|
||||||
|
|
||||||
target_link_libraries(${CMAKE_PROJECT_NAME}
|
add_library(bluetooth_socket SHARED
|
||||||
|
bluetooth_socket.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_compile_options(bluetooth_socket PRIVATE
|
||||||
|
-O2
|
||||||
|
-fvisibility=hidden
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_options(bluetooth_socket PRIVATE
|
||||||
|
-Wl,--strip-all
|
||||||
|
-Wl,--gc-sections
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(bluetooth_socket
|
||||||
android
|
android
|
||||||
log)
|
log
|
||||||
|
)
|
||||||
|
|
||||||
|
if(IS_XPOSED)
|
||||||
|
|
||||||
|
set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp)
|
||||||
|
|
||||||
|
add_library(l2c_fcr_hook SHARED
|
||||||
|
${XPOSED_SRC_DIR}/l2c_fcr_hook.cpp
|
||||||
|
|
||||||
|
${XPOSED_SRC_DIR}/xz/xz_crc32.c
|
||||||
|
${XPOSED_SRC_DIR}/xz/xz_crc64.c
|
||||||
|
${XPOSED_SRC_DIR}/xz/xz_sha256.c
|
||||||
|
${XPOSED_SRC_DIR}/xz/xz_dec_stream.c
|
||||||
|
${XPOSED_SRC_DIR}/xz/xz_dec_lzma2.c
|
||||||
|
${XPOSED_SRC_DIR}/xz/xz_dec_bcj.c
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(l2c_fcr_hook PRIVATE
|
||||||
|
${XPOSED_SRC_DIR}
|
||||||
|
${XPOSED_SRC_DIR}/xz
|
||||||
|
)
|
||||||
|
|
||||||
|
target_compile_definitions(l2c_fcr_hook PRIVATE
|
||||||
|
XZ_DEC_X86
|
||||||
|
XZ_DEC_ARM
|
||||||
|
XZ_DEC_ARMTHUMB
|
||||||
|
XZ_DEC_ARM64
|
||||||
|
XZ_DEC_ANY_CHECK
|
||||||
|
XZ_USE_CRC64
|
||||||
|
XZ_USE_SHA256
|
||||||
|
XZ_DEC_CONCATENATED
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(l2c_fcr_hook
|
||||||
|
android
|
||||||
|
log
|
||||||
|
)
|
||||||
|
|
||||||
|
endif()
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#include <jni.h>
|
||||||
|
#include <pthread.h>
|
||||||
|
#include <array>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
static JavaVM* gVm = nullptr;
|
||||||
|
|
||||||
|
template<size_t N>
|
||||||
|
constexpr auto encryptString(const char (&str)[N], char key) {
|
||||||
|
std::array<char, N> encrypted{};
|
||||||
|
for (size_t i = 0; i < N; i++) {
|
||||||
|
encrypted[i] = str[i] ^ key;
|
||||||
|
}
|
||||||
|
return encrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
template<size_t N>
|
||||||
|
static std::string decryptString(const std::array<char, N>& encrypted, char key) {
|
||||||
|
std::string result(N - 1, '\0');
|
||||||
|
for (size_t i = 0; i < N - 1; i++) {
|
||||||
|
result[i] = encrypted[i] ^ key;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
#define ENC(str) encryptString(str, 0x47)
|
||||||
|
#define DEC(arr) decryptString(arr, 0x47).c_str()
|
||||||
|
|
||||||
|
__attribute__((visibility("hidden")))
|
||||||
|
static JavaVM* getVm() { return gVm; }
|
||||||
|
|
||||||
|
__attribute__((visibility("default")))
|
||||||
|
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
|
||||||
|
gVm = vm;
|
||||||
|
|
||||||
|
auto fn = [](void*) -> void* {
|
||||||
|
constexpr auto c1 = ENC("dalvik/system/VMRuntime");
|
||||||
|
constexpr auto c2 = ENC("getRuntime");
|
||||||
|
constexpr auto c3 = ENC("()Ldalvik/system/VMRuntime;");
|
||||||
|
constexpr auto c4 = ENC("setHiddenApiExemptions");
|
||||||
|
constexpr auto c5 = ENC("([Ljava/lang/String;)V");
|
||||||
|
constexpr auto c6 = ENC("java/lang/String");
|
||||||
|
constexpr auto c7 = ENC("Landroid/bluetooth/BluetoothSocket;");
|
||||||
|
constexpr auto c8 = ENC("Landroid/bluetooth/BluetoothDevice;");
|
||||||
|
|
||||||
|
JNIEnv* env;
|
||||||
|
getVm()->AttachCurrentThread(&env, nullptr);
|
||||||
|
|
||||||
|
jclass vmRuntime = env->FindClass(DEC(c1));
|
||||||
|
jmethodID getRuntime = env->GetStaticMethodID(vmRuntime, DEC(c2), DEC(c3));
|
||||||
|
jmethodID setExemptions = env->GetMethodID(vmRuntime, DEC(c4), DEC(c5));
|
||||||
|
|
||||||
|
jobject runtime = env->CallStaticObjectMethod(vmRuntime, getRuntime);
|
||||||
|
jobjectArray prefixes = env->NewObjectArray(
|
||||||
|
2, env->FindClass(DEC(c6)), nullptr);
|
||||||
|
env->SetObjectArrayElement(prefixes, 0, env->NewStringUTF(DEC(c7)));
|
||||||
|
env->SetObjectArrayElement(prefixes, 1, env->NewStringUTF(DEC(c8)));
|
||||||
|
|
||||||
|
env->CallVoidMethod(runtime, setExemptions, prefixes);
|
||||||
|
getVm()->DetachCurrentThread();
|
||||||
|
return nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
pthread_t t;
|
||||||
|
pthread_create(&t, nullptr, fn, nullptr);
|
||||||
|
pthread_join(t, nullptr);
|
||||||
|
return JNI_VERSION_1_6;
|
||||||
|
}
|
||||||
@@ -1,491 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <cstring>
|
|
||||||
#include <dlfcn.h>
|
|
||||||
#include <android/log.h>
|
|
||||||
#include <fstream>
|
|
||||||
#include <string>
|
|
||||||
#include <sys/system_properties.h>
|
|
||||||
#include "l2c_fcr_hook.h"
|
|
||||||
#include <cerrno>
|
|
||||||
#include <cstdlib>
|
|
||||||
|
|
||||||
#define LOG_TAG "AirPodsHook"
|
|
||||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
|
||||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
|
||||||
|
|
||||||
static HookFunType hook_func = nullptr;
|
|
||||||
#define L2CEVT_L2CAP_CONFIG_REQ 4
|
|
||||||
#define L2CEVT_L2CAP_CONFIG_RSP 15
|
|
||||||
|
|
||||||
struct t_l2c_lcb;
|
|
||||||
typedef struct _BT_HDR {
|
|
||||||
uint16_t event;
|
|
||||||
uint16_t len;
|
|
||||||
uint16_t offset;
|
|
||||||
uint16_t layer_specific;
|
|
||||||
uint8_t data[];
|
|
||||||
} BT_HDR;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
uint8_t mode;
|
|
||||||
uint8_t tx_win_sz;
|
|
||||||
uint8_t max_transmit;
|
|
||||||
uint16_t rtrans_tout;
|
|
||||||
uint16_t mon_tout;
|
|
||||||
uint16_t mps;
|
|
||||||
} tL2CAP_FCR;
|
|
||||||
|
|
||||||
// Flow spec structure
|
|
||||||
typedef struct {
|
|
||||||
uint8_t qos_present;
|
|
||||||
uint8_t flow_direction;
|
|
||||||
uint8_t service_type;
|
|
||||||
uint32_t token_rate;
|
|
||||||
uint32_t token_bucket_size;
|
|
||||||
uint32_t peak_bandwidth;
|
|
||||||
uint32_t latency;
|
|
||||||
uint32_t delay_variation;
|
|
||||||
} FLOW_SPEC;
|
|
||||||
|
|
||||||
// Configuration info structure
|
|
||||||
typedef struct {
|
|
||||||
uint16_t result;
|
|
||||||
uint16_t mtu_present;
|
|
||||||
uint16_t mtu;
|
|
||||||
uint16_t flush_to_present;
|
|
||||||
uint16_t flush_to;
|
|
||||||
uint16_t qos_present;
|
|
||||||
FLOW_SPEC qos;
|
|
||||||
uint16_t fcr_present;
|
|
||||||
tL2CAP_FCR fcr;
|
|
||||||
uint16_t fcs_present;
|
|
||||||
uint16_t fcs;
|
|
||||||
uint16_t ext_flow_spec_present;
|
|
||||||
FLOW_SPEC ext_flow_spec;
|
|
||||||
} tL2CAP_CFG_INFO;
|
|
||||||
|
|
||||||
// Basic L2CAP link control block
|
|
||||||
typedef struct {
|
|
||||||
bool wait_ack;
|
|
||||||
// Other FCR fields - not needed for our specific hook
|
|
||||||
} tL2C_FCRB;
|
|
||||||
|
|
||||||
// Forward declarations for needed types
|
|
||||||
struct t_l2c_rcb;
|
|
||||||
struct t_l2c_lcb;
|
|
||||||
|
|
||||||
typedef struct t_l2c_ccb {
|
|
||||||
struct t_l2c_ccb* p_next_ccb; // Next CCB in the chain
|
|
||||||
struct t_l2c_ccb* p_prev_ccb; // Previous CCB in the chain
|
|
||||||
struct t_l2c_lcb* p_lcb; // Link this CCB belongs to
|
|
||||||
struct t_l2c_rcb* p_rcb; // Registration CB for this Channel
|
|
||||||
uint16_t local_cid; // Local CID
|
|
||||||
uint16_t remote_cid; // Remote CID
|
|
||||||
uint16_t p_lcb_next; // For linking CCBs to an LCB
|
|
||||||
uint8_t ccb_priority; // Channel priority
|
|
||||||
uint16_t tx_mps; // MPS for outgoing messages
|
|
||||||
uint16_t max_rx_mtu; // Max MTU we will receive
|
|
||||||
// State variables
|
|
||||||
bool in_use; // True when channel active
|
|
||||||
uint8_t chnl_state; // Channel state
|
|
||||||
uint8_t local_id; // Transaction ID for local trans
|
|
||||||
uint8_t remote_id; // Transaction ID for remote
|
|
||||||
uint8_t timer_entry; // Timer entry
|
|
||||||
uint8_t is_flushable; // True if flushable
|
|
||||||
// Configuration variables
|
|
||||||
uint16_t our_cfg_bits; // Bitmap of local config bits
|
|
||||||
uint16_t peer_cfg_bits; // Bitmap of peer config bits
|
|
||||||
uint16_t config_done; // Configuration bitmask
|
|
||||||
uint16_t remote_config_rsp_result; // Remote config response result
|
|
||||||
tL2CAP_CFG_INFO our_cfg; // Our saved configuration options
|
|
||||||
tL2CAP_CFG_INFO peer_cfg; // Peer's saved configuration options
|
|
||||||
// Additional control fields
|
|
||||||
uint8_t remote_credit_count; // Credits sent to peer
|
|
||||||
tL2C_FCRB fcrb; // FCR info
|
|
||||||
bool ecoc; // Enhanced Credit-based mode
|
|
||||||
} tL2C_CCB;
|
|
||||||
|
|
||||||
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void* p_ccb) = nullptr;
|
|
||||||
static void (*original_l2cu_process_our_cfg_req)(tL2C_CCB* p_ccb, tL2CAP_CFG_INFO* p_cfg) = nullptr;
|
|
||||||
static void (*original_l2c_csm_config)(tL2C_CCB* p_ccb, uint8_t event, void* p_data) = nullptr;
|
|
||||||
static void (*original_l2cu_send_peer_info_req)(tL2C_LCB* p_lcb, uint16_t info_type) = nullptr;
|
|
||||||
|
|
||||||
// Add original pointer for BTA_DmSetLocalDiRecord
|
|
||||||
static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD* p_device_info, uint32_t* p_handle) = nullptr;
|
|
||||||
|
|
||||||
uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) {
|
|
||||||
LOGI("l2c_fcr_chk_chan_modes hooked, returning true.");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
void fake_l2cu_process_our_cfg_req(tL2C_CCB* p_ccb, tL2CAP_CFG_INFO* p_cfg) {
|
|
||||||
original_l2cu_process_our_cfg_req(p_ccb, p_cfg);
|
|
||||||
p_ccb->our_cfg.fcr.mode = 0x00;
|
|
||||||
LOGI("Set FCR mode to Basic Mode in outgoing config request");
|
|
||||||
}
|
|
||||||
|
|
||||||
void fake_l2c_csm_config(tL2C_CCB* p_ccb, uint8_t event, void* p_data) {
|
|
||||||
// Call the original function first to handle the specific code path where the FCR mode is checked
|
|
||||||
original_l2c_csm_config(p_ccb, event, p_data);
|
|
||||||
|
|
||||||
// Check if this happens during CONFIG_RSP event handling
|
|
||||||
if (event == L2CEVT_L2CAP_CONFIG_RSP) {
|
|
||||||
p_ccb->our_cfg.fcr.mode = p_ccb->peer_cfg.fcr.mode;
|
|
||||||
LOGI("Forced compatibility in l2c_csm_config: set our_mode=%d to match peer_mode=%d",
|
|
||||||
p_ccb->our_cfg.fcr.mode, p_ccb->peer_cfg.fcr.mode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replacement function that does nothing
|
|
||||||
void fake_l2cu_send_peer_info_req(tL2C_LCB* p_lcb, uint16_t info_type) {
|
|
||||||
LOGI("Intercepted l2cu_send_peer_info_req for info_type 0x%04x - doing nothing", info_type);
|
|
||||||
// Just return without doing anything
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// New loader for SDP hook offset (persist.librepods.sdp_offset)
|
|
||||||
uintptr_t loadSdpOffset() {
|
|
||||||
const char* property_name = "persist.librepods.sdp_offset";
|
|
||||||
char value[PROP_VALUE_MAX] = {0};
|
|
||||||
|
|
||||||
int len = __system_property_get(property_name, value);
|
|
||||||
if (len > 0) {
|
|
||||||
LOGI("Read sdp offset from property: %s", value);
|
|
||||||
uintptr_t offset;
|
|
||||||
char* endptr = nullptr;
|
|
||||||
|
|
||||||
const char* parse_start = value;
|
|
||||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
|
||||||
parse_start = value + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
errno = 0;
|
|
||||||
offset = strtoul(parse_start, &endptr, 16);
|
|
||||||
|
|
||||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
|
||||||
LOGI("Parsed sdp offset: 0x%x", offset);
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGE("Failed to parse sdp offset from property value: %s", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGI("No sdp offset property present - skipping SDP hook");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fake BTA_DmSetLocalDiRecord: set vendor/vendor_id_source then call original
|
|
||||||
tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD* p_device_info, uint32_t* p_handle) {
|
|
||||||
LOGI("BTA_DmSetLocalDiRecord hooked - forcing vendor fields");
|
|
||||||
if (p_device_info) {
|
|
||||||
p_device_info->vendor = 0x004C;
|
|
||||||
p_device_info->vendor_id_source = 0x0001;
|
|
||||||
}
|
|
||||||
LOGI("Set vendor=0x%04x, vendor_id_source=0x%04x", p_device_info->vendor, p_device_info->vendor_id_source);
|
|
||||||
if (original_BTA_DmSetLocalDiRecord) {
|
|
||||||
return original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGE("Original BTA_DmSetLocalDiRecord not available");
|
|
||||||
return BTA_FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
uintptr_t loadHookOffset([[maybe_unused]] const char* package_name) {
|
|
||||||
const char* property_name = "persist.librepods.hook_offset";
|
|
||||||
char value[PROP_VALUE_MAX] = {0};
|
|
||||||
|
|
||||||
int len = __system_property_get(property_name, value);
|
|
||||||
if (len > 0) {
|
|
||||||
LOGI("Read hook offset from property: %s", value);
|
|
||||||
uintptr_t offset;
|
|
||||||
char* endptr = nullptr;
|
|
||||||
|
|
||||||
const char* parse_start = value;
|
|
||||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
|
||||||
parse_start = value + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
errno = 0;
|
|
||||||
offset = strtoul(parse_start, &endptr, 16);
|
|
||||||
|
|
||||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
|
||||||
LOGI("Parsed offset: 0x%x", offset);
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGE("Failed to parse offset from property value: %s", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGI("Using hardcoded fallback offset");
|
|
||||||
return 0x00a55e30;
|
|
||||||
}
|
|
||||||
|
|
||||||
uintptr_t loadL2cuProcessCfgReqOffset() {
|
|
||||||
const char* property_name = "persist.librepods.cfg_req_offset";
|
|
||||||
char value[PROP_VALUE_MAX] = {0};
|
|
||||||
|
|
||||||
int len = __system_property_get(property_name, value);
|
|
||||||
if (len > 0) {
|
|
||||||
LOGI("Read l2cu_process_our_cfg_req offset from property: %s", value);
|
|
||||||
uintptr_t offset;
|
|
||||||
char* endptr = nullptr;
|
|
||||||
|
|
||||||
const char* parse_start = value;
|
|
||||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
|
||||||
parse_start = value + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
errno = 0;
|
|
||||||
offset = strtoul(parse_start, &endptr, 16);
|
|
||||||
|
|
||||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
|
||||||
LOGI("Parsed l2cu_process_our_cfg_req offset: 0x%x", offset);
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGE("Failed to parse l2cu_process_our_cfg_req offset from property value: %s", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return 0 if not found - we'll skip this hook
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
uintptr_t loadL2cCsmConfigOffset() {
|
|
||||||
const char* property_name = "persist.librepods.csm_config_offset";
|
|
||||||
char value[PROP_VALUE_MAX] = {0};
|
|
||||||
|
|
||||||
int len = __system_property_get(property_name, value);
|
|
||||||
if (len > 0) {
|
|
||||||
LOGI("Read l2c_csm_config offset from property: %s", value);
|
|
||||||
uintptr_t offset;
|
|
||||||
char* endptr = nullptr;
|
|
||||||
|
|
||||||
const char* parse_start = value;
|
|
||||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
|
||||||
parse_start = value + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
errno = 0;
|
|
||||||
offset = strtoul(parse_start, &endptr, 16);
|
|
||||||
|
|
||||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
|
||||||
LOGI("Parsed l2c_csm_config offset: 0x%x", offset);
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGE("Failed to parse l2c_csm_config offset from property value: %s", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return 0 if not found - we'll skip this hook
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
uintptr_t loadL2cuSendPeerInfoReqOffset() {
|
|
||||||
const char* property_name = "persist.librepods.peer_info_req_offset";
|
|
||||||
char value[PROP_VALUE_MAX] = {0};
|
|
||||||
|
|
||||||
int len = __system_property_get(property_name, value);
|
|
||||||
if (len > 0) {
|
|
||||||
LOGI("Read l2cu_send_peer_info_req offset from property: %s", value);
|
|
||||||
uintptr_t offset;
|
|
||||||
char* endptr = nullptr;
|
|
||||||
|
|
||||||
const char* parse_start = value;
|
|
||||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
|
||||||
parse_start = value + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
errno = 0;
|
|
||||||
offset = strtoul(parse_start, &endptr, 16);
|
|
||||||
|
|
||||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
|
||||||
LOGI("Parsed l2cu_send_peer_info_req offset: 0x%x", offset);
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGE("Failed to parse l2cu_send_peer_info_req offset from property value: %s", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return 0 if not found - we'll skip this hook
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
uintptr_t getModuleBase(const char *module_name) {
|
|
||||||
FILE *fp;
|
|
||||||
char line[1024];
|
|
||||||
uintptr_t base_addr = 0;
|
|
||||||
|
|
||||||
fp = fopen("/proc/self/maps", "r");
|
|
||||||
if (!fp) {
|
|
||||||
LOGE("Failed to open /proc/self/maps");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (fgets(line, sizeof(line), fp)) {
|
|
||||||
if (strstr(line, module_name)) {
|
|
||||||
char *start_addr_str = line;
|
|
||||||
char *end_addr_str = strchr(line, '-');
|
|
||||||
if (end_addr_str) {
|
|
||||||
*end_addr_str = '\0';
|
|
||||||
base_addr = strtoull(start_addr_str, nullptr, 16);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fclose(fp);
|
|
||||||
return base_addr;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool findAndHookFunction(const char *library_name) {
|
|
||||||
if (!hook_func) {
|
|
||||||
LOGE("Hook function not initialized");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
uintptr_t base_addr = getModuleBase(library_name);
|
|
||||||
if (!base_addr) {
|
|
||||||
LOGE("Failed to get base address of %s", library_name);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load all offsets from system properties - no hardcoding
|
|
||||||
uintptr_t l2c_fcr_offset = loadHookOffset(nullptr);
|
|
||||||
uintptr_t l2cu_process_our_cfg_req_offset = loadL2cuProcessCfgReqOffset();
|
|
||||||
uintptr_t l2c_csm_config_offset = loadL2cCsmConfigOffset();
|
|
||||||
uintptr_t l2cu_send_peer_info_req_offset = loadL2cuSendPeerInfoReqOffset();
|
|
||||||
uintptr_t sdp_offset = loadSdpOffset();
|
|
||||||
|
|
||||||
bool success = false;
|
|
||||||
|
|
||||||
// Hook l2c_fcr_chk_chan_modes - this is our primary hook
|
|
||||||
if (l2c_fcr_offset > 0) {
|
|
||||||
void* target = reinterpret_cast<void*>(base_addr + l2c_fcr_offset);
|
|
||||||
LOGI("Hooking l2c_fcr_chk_chan_modes at offset: 0x%x, base: %p, target: %p",
|
|
||||||
l2c_fcr_offset, (void*)base_addr, target);
|
|
||||||
|
|
||||||
int result = hook_func(target, (void*)fake_l2c_fcr_chk_chan_modes, (void**)&original_l2c_fcr_chk_chan_modes);
|
|
||||||
if (result != 0) {
|
|
||||||
LOGE("Failed to hook l2c_fcr_chk_chan_modes, error: %d", result);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
LOGI("Successfully hooked l2c_fcr_chk_chan_modes");
|
|
||||||
success = true;
|
|
||||||
} else {
|
|
||||||
LOGE("No valid offset for l2c_fcr_chk_chan_modes found, cannot proceed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook l2cu_process_our_cfg_req if offset is available
|
|
||||||
if (l2cu_process_our_cfg_req_offset > 0) {
|
|
||||||
void* target = reinterpret_cast<void*>(base_addr + l2cu_process_our_cfg_req_offset);
|
|
||||||
LOGI("Hooking l2cu_process_our_cfg_req at offset: 0x%x, base: %p, target: %p",
|
|
||||||
l2cu_process_our_cfg_req_offset, (void*)base_addr, target);
|
|
||||||
|
|
||||||
int result = hook_func(target, (void*)fake_l2cu_process_our_cfg_req, (void**)&original_l2cu_process_our_cfg_req);
|
|
||||||
if (result != 0) {
|
|
||||||
LOGE("Failed to hook l2cu_process_our_cfg_req, error: %d", result);
|
|
||||||
// Continue even if this hook fails
|
|
||||||
} else {
|
|
||||||
LOGI("Successfully hooked l2cu_process_our_cfg_req");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LOGI("Skipping l2cu_process_our_cfg_req hook as offset is not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook l2c_csm_config if offset is available
|
|
||||||
if (l2c_csm_config_offset > 0) {
|
|
||||||
void* target = reinterpret_cast<void*>(base_addr + l2c_csm_config_offset);
|
|
||||||
LOGI("Hooking l2c_csm_config at offset: 0x%x, base: %p, target: %p",
|
|
||||||
l2c_csm_config_offset, (void*)base_addr, target);
|
|
||||||
|
|
||||||
int result = hook_func(target, (void*)fake_l2c_csm_config, (void**)&original_l2c_csm_config);
|
|
||||||
if (result != 0) {
|
|
||||||
LOGE("Failed to hook l2c_csm_config, error: %d", result);
|
|
||||||
// Continue even if this hook fails
|
|
||||||
} else {
|
|
||||||
LOGI("Successfully hooked l2c_csm_config");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LOGI("Skipping l2c_csm_config hook as offset is not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook l2cu_send_peer_info_req if offset is available
|
|
||||||
if (l2cu_send_peer_info_req_offset > 0) {
|
|
||||||
void* target = reinterpret_cast<void*>(base_addr + l2cu_send_peer_info_req_offset);
|
|
||||||
LOGI("Hooking l2cu_send_peer_info_req at offset: 0x%x, base: %p, target: %p",
|
|
||||||
l2cu_send_peer_info_req_offset, (void*)base_addr, target);
|
|
||||||
|
|
||||||
int result = hook_func(target, (void*)fake_l2cu_send_peer_info_req, (void**)&original_l2cu_send_peer_info_req);
|
|
||||||
if (result != 0) {
|
|
||||||
LOGE("Failed to hook l2cu_send_peer_info_req, error: %d", result);
|
|
||||||
// Continue even if this hook fails
|
|
||||||
} else {
|
|
||||||
LOGI("Successfully hooked l2cu_send_peer_info_req");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LOGI("Skipping l2cu_send_peer_info_req hook as offset is not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sdp_offset > 0) {
|
|
||||||
void* target = reinterpret_cast<void*>(base_addr + sdp_offset);
|
|
||||||
LOGI("Hooking BTA_DmSetLocalDiRecord at offset: 0x%x, base: %p, target: %p",
|
|
||||||
sdp_offset, (void*)base_addr, target);
|
|
||||||
|
|
||||||
int result = hook_func(target, (void*)fake_BTA_DmSetLocalDiRecord, (void**)&original_BTA_DmSetLocalDiRecord);
|
|
||||||
if (result != 0) {
|
|
||||||
LOGE("Failed to hook BTA_DmSetLocalDiRecord, error: %d", result);
|
|
||||||
} else {
|
|
||||||
LOGI("Successfully hooked BTA_DmSetLocalDiRecord (SDP)");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LOGI("Skipping BTA_DmSetLocalDiRecord hook as sdp offset is not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
void on_library_loaded(const char *name, [[maybe_unused]] void *handle) {
|
|
||||||
if (strstr(name, "libbluetooth_jni.so")) {
|
|
||||||
LOGI("Detected Bluetooth JNI library: %s", name);
|
|
||||||
|
|
||||||
bool hooked = findAndHookFunction("libbluetooth_jni.so");
|
|
||||||
if (!hooked) {
|
|
||||||
LOGE("Failed to hook Bluetooth JNI library function");
|
|
||||||
}
|
|
||||||
} else if (strstr(name, "libbluetooth_qti.so")) {
|
|
||||||
LOGI("Detected Bluetooth QTI library: %s", name);
|
|
||||||
|
|
||||||
bool hooked = findAndHookFunction("libbluetooth_qti.so");
|
|
||||||
if (!hooked) {
|
|
||||||
LOGE("Failed to hook Bluetooth QTI library function");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C" [[gnu::visibility("default")]] [[gnu::used]]
|
|
||||||
NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) {
|
|
||||||
LOGI("L2C FCR Hook module initialized");
|
|
||||||
|
|
||||||
hook_func = entries->hook_func;
|
|
||||||
|
|
||||||
return on_library_loaded;
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
typedef int (*HookFunType)(void *func, void *replace, void **backup);
|
|
||||||
|
|
||||||
typedef int (*UnhookFunType)(void *func);
|
|
||||||
|
|
||||||
typedef void (*NativeOnModuleLoaded)(const char *name, void *handle);
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
uint32_t version;
|
|
||||||
HookFunType hook_func;
|
|
||||||
UnhookFunType unhook_func;
|
|
||||||
} NativeAPIEntries;
|
|
||||||
|
|
||||||
[[maybe_unused]] typedef NativeOnModuleLoaded (*NativeInit)(const NativeAPIEntries *entries);
|
|
||||||
|
|
||||||
typedef struct t_l2c_ccb tL2C_CCB;
|
|
||||||
typedef struct t_l2c_lcb tL2C_LCB;
|
|
||||||
|
|
||||||
uintptr_t loadHookOffset(const char* package_name);
|
|
||||||
uintptr_t getModuleBase(const char *module_name);
|
|
||||||
uintptr_t loadL2cuProcessCfgReqOffset();
|
|
||||||
uintptr_t loadL2cCsmConfigOffset();
|
|
||||||
uintptr_t loadL2cuSendPeerInfoReqOffset();
|
|
||||||
bool findAndHookFunction(const char *library_path);
|
|
||||||
|
|
||||||
#define SDP_MAX_ATTR_LEN 400
|
|
||||||
|
|
||||||
typedef struct t_sdp_di_record {
|
|
||||||
uint16_t vendor;
|
|
||||||
uint16_t vendor_id_source;
|
|
||||||
uint16_t product;
|
|
||||||
uint16_t version;
|
|
||||||
bool primary_record;
|
|
||||||
char client_executable_url[SDP_MAX_ATTR_LEN];
|
|
||||||
char service_description[SDP_MAX_ATTR_LEN];
|
|
||||||
char documentation_url[SDP_MAX_ATTR_LEN];
|
|
||||||
} tSDP_DI_RECORD;
|
|
||||||
|
|
||||||
typedef enum : uint8_t {
|
|
||||||
BTA_SUCCESS = 0, /* Successful operation. */
|
|
||||||
BTA_FAILURE = 1, /* Generic failure. */
|
|
||||||
BTA_PENDING = 2, /* API cannot be completed right now */
|
|
||||||
BTA_BUSY = 3,
|
|
||||||
BTA_NO_RESOURCES = 4,
|
|
||||||
BTA_WRONG_MODE = 5,
|
|
||||||
} tBTA_STATUS;
|
|
||||||
@@ -20,6 +20,9 @@
|
|||||||
|
|
||||||
package me.kavishdevar.librepods
|
package me.kavishdevar.librepods
|
||||||
|
|
||||||
|
// import me.kavishdevar.librepods.screens.Onboarding
|
||||||
|
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||||
|
//import dagger.hilt.android.AndroidEntryPoint
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
@@ -27,13 +30,11 @@ import android.content.Context
|
|||||||
import android.content.Context.MODE_PRIVATE
|
import android.content.Context.MODE_PRIVATE
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
@@ -51,7 +52,9 @@ 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
|
||||||
@@ -61,6 +64,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.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
|
||||||
@@ -73,11 +77,12 @@ 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
|
||||||
@@ -88,8 +93,9 @@ 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
|
||||||
@@ -103,6 +109,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
@@ -112,55 +119,65 @@ import com.google.accompanist.permissions.isGranted
|
|||||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
import kotlinx.coroutines.delay
|
||||||
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
|
import me.kavishdevar.librepods.billing.BillingManager
|
||||||
import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen
|
import me.kavishdevar.librepods.billing.BillingProviderFactory
|
||||||
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
|
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.screens.AppSettingsScreen
|
import me.kavishdevar.librepods.data.ControlCommandRepository
|
||||||
import me.kavishdevar.librepods.screens.CameraControlScreen
|
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
|
||||||
import me.kavishdevar.librepods.screens.DebugScreen
|
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
||||||
import me.kavishdevar.librepods.screens.HeadTrackingScreen
|
import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen
|
||||||
import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen
|
import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen
|
||||||
import me.kavishdevar.librepods.screens.HearingAidScreen
|
import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreen
|
||||||
import me.kavishdevar.librepods.screens.HearingProtectionScreen
|
import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
|
||||||
import me.kavishdevar.librepods.screens.LongPress
|
import me.kavishdevar.librepods.presentation.screens.CameraControlScreen
|
||||||
import me.kavishdevar.librepods.screens.Onboarding
|
import me.kavishdevar.librepods.presentation.screens.DebugScreen
|
||||||
import me.kavishdevar.librepods.screens.OpenSourceLicensesScreen
|
import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen
|
||||||
import me.kavishdevar.librepods.screens.RenameScreen
|
import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen
|
||||||
import me.kavishdevar.librepods.screens.TransparencySettingsScreen
|
import me.kavishdevar.librepods.presentation.screens.HearingAidScreen
|
||||||
import me.kavishdevar.librepods.screens.TroubleshootingScreen
|
import me.kavishdevar.librepods.presentation.screens.HearingProtectionScreen
|
||||||
import me.kavishdevar.librepods.screens.UpdateHearingTestScreen
|
import me.kavishdevar.librepods.presentation.screens.LongPress
|
||||||
import me.kavishdevar.librepods.screens.VersionScreen
|
import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.PurchaseScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.RenameScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.TransparencySettingsScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.VersionScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.utils.isSupported
|
||||||
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 {
|
||||||
System.loadLibrary("l2c_fcr_hook")
|
if (BuildConfig.FLAVOR == "xposed") {
|
||||||
|
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 {
|
||||||
LibrePodsTheme {
|
_root_ide_package_.me.kavishdevar.librepods.presentation.theme.LibrePodsTheme {
|
||||||
Main()
|
Main()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleIncomingIntent(intent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
@@ -195,82 +212,166 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
super.onStop()
|
super.onStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
|
||||||
super.onNewIntent(intent)
|
|
||||||
setIntent(intent)
|
|
||||||
handleIncomingIntent(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleIncomingIntent(intent: Intent) {
|
|
||||||
val data: Uri? = intent.data
|
|
||||||
|
|
||||||
if (data != null && data.scheme == "librepods") {
|
|
||||||
when (data.host) {
|
|
||||||
"add-magic-keys" -> {
|
|
||||||
val queryParams = data.queryParameterNames
|
|
||||||
queryParams.forEach { param ->
|
|
||||||
val value = data.getQueryParameter(param)
|
|
||||||
Log.d("LibrePods", "Parameter: $param = $value")
|
|
||||||
}
|
|
||||||
|
|
||||||
handleAddMagicKeys(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleAddMagicKeys(uri: Uri) {
|
|
||||||
val sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
|
|
||||||
|
|
||||||
val irkHex = uri.getQueryParameter("irk")
|
|
||||||
val encKeyHex = uri.getQueryParameter("enc_key")
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (irkHex != null && validateHexInput(irkHex)) {
|
|
||||||
val irkBytes = hexStringToByteArray(irkHex)
|
|
||||||
val irkBase64 = Base64.encode(irkBytes)
|
|
||||||
sharedPreferences.edit {putString("IRK", irkBase64)}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (encKeyHex != null && validateHexInput(encKeyHex)) {
|
|
||||||
val encKeyBytes = hexStringToByteArray(encKeyHex)
|
|
||||||
val encKeyBase64 = Base64.encode(encKeyBytes)
|
|
||||||
sharedPreferences.edit { putString("ENC_KEY", encKeyBase64)}
|
|
||||||
}
|
|
||||||
|
|
||||||
Toast.makeText(this, "Magic keys added successfully!", Toast.LENGTH_SHORT).show()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Toast.makeText(this, "Error processing magic keys: ${e.message}", Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun validateHexInput(input: String): Boolean {
|
|
||||||
val hexPattern = Regex("^[0-9a-fA-F]{32}$")
|
|
||||||
return hexPattern.matches(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hexStringToByteArray(hex: String): ByteArray {
|
|
||||||
val result = ByteArray(16)
|
|
||||||
for (i in 0 until 16) {
|
|
||||||
val hexByte = hex.substring(i * 2, i * 2 + 2)
|
|
||||||
result[i] = hexByte.toInt(16).toByte()
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
|
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
|
||||||
@OptIn(ExperimentalPermissionsApi::class)
|
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun Main() {
|
fun Main() {
|
||||||
val isConnected = remember { mutableStateOf(false) }
|
|
||||||
val isRemotelyConnected = remember { mutableStateOf(false) }
|
|
||||||
val hookAvailable = RadareOffsetFinder(LocalContext.current).isHookOffsetAvailable()
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
|
||||||
|
if (!isSupported(sharedPreferences)) {
|
||||||
|
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) }
|
||||||
|
|
||||||
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
|
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
|
||||||
val overlaySkipped = remember { mutableStateOf(context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("overlay_permission_skipped", false)) }
|
val overlaySkipped = remember {
|
||||||
|
mutableStateOf(
|
||||||
|
context.getSharedPreferences("settings", MODE_PRIVATE)
|
||||||
|
.getBoolean("overlay_permission_skipped", false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
@@ -297,23 +398,33 @@ fun Main() {
|
|||||||
val permissionState = rememberMultiplePermissionsState(
|
val permissionState = rememberMultiplePermissionsState(
|
||||||
permissions = allPermissions
|
permissions = allPermissions
|
||||||
)
|
)
|
||||||
|
|
||||||
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
|
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
|
||||||
|
|
||||||
|
val airPodsViewModel = remember(airPodsService.value) {
|
||||||
|
airPodsService.value?.let { service ->
|
||||||
|
AirPodsViewModel(
|
||||||
|
service = service,
|
||||||
|
sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE),
|
||||||
|
controlRepo = ControlCommandRepository(service.aacpManager),
|
||||||
|
appContext = context.applicationContext
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
canDrawOverlays = Settings.canDrawOverlays(context)
|
canDrawOverlays = Settings.canDrawOverlays(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) {
|
if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) {
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
|
||||||
Box (
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize()
|
||||||
.fillMaxSize()
|
) {
|
||||||
){
|
|
||||||
val backButtonBackdrop = rememberLayerBackdrop()
|
val backButtonBackdrop = rememberLayerBackdrop()
|
||||||
Box (
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
|
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
|
||||||
@@ -321,129 +432,130 @@ fun Main() {
|
|||||||
) {
|
) {
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = if (hookAvailable) "settings" else "onboarding",
|
startDestination = "settings",
|
||||||
enterTransition = {
|
enterTransition = {
|
||||||
slideInHorizontally(
|
slideInHorizontally(
|
||||||
initialOffsetX = { it },
|
initialOffsetX = { it }, animationSpec = tween(durationMillis = 300)
|
||||||
animationSpec = tween(durationMillis = 300)
|
)
|
||||||
) // + fadeIn(animationSpec = tween(durationMillis = 300))
|
|
||||||
},
|
},
|
||||||
exitTransition = {
|
exitTransition = {
|
||||||
slideOutHorizontally(
|
slideOutHorizontally(
|
||||||
targetOffsetX = { -it/4 },
|
targetOffsetX = { -it / 4 }, animationSpec = tween(durationMillis = 300)
|
||||||
animationSpec = tween(durationMillis = 300)
|
)
|
||||||
) // + fadeOut(animationSpec = tween(durationMillis = 150))
|
|
||||||
},
|
},
|
||||||
popEnterTransition = {
|
popEnterTransition = {
|
||||||
slideInHorizontally(
|
slideInHorizontally(
|
||||||
initialOffsetX = { -it/4 },
|
initialOffsetX = { -it / 4 },
|
||||||
animationSpec = tween(durationMillis = 300)
|
animationSpec = tween(durationMillis = 300)
|
||||||
) // + fadeIn(animationSpec = tween(durationMillis = 300))
|
)
|
||||||
},
|
},
|
||||||
popExitTransition = {
|
popExitTransition = {
|
||||||
slideOutHorizontally(
|
slideOutHorizontally(
|
||||||
targetOffsetX = { it },
|
targetOffsetX = { it }, animationSpec = tween(durationMillis = 300)
|
||||||
animationSpec = tween(durationMillis = 300)
|
)
|
||||||
) // + fadeOut(animationSpec = tween(durationMillis = 150))
|
}) {
|
||||||
}
|
|
||||||
) {
|
|
||||||
composable("settings") {
|
composable("settings") {
|
||||||
if (airPodsService.value != null) {
|
if (airPodsViewModel != null) AirPodsSettingsScreen(airPodsViewModel, navController)
|
||||||
AirPodsSettingsScreen(
|
|
||||||
dev = airPodsService.value?.device,
|
|
||||||
service = airPodsService.value!!,
|
|
||||||
navController = navController,
|
|
||||||
isConnected = isConnected.value,
|
|
||||||
isRemotelyConnected = isRemotelyConnected.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
composable("debug") {
|
composable("debug") {
|
||||||
DebugScreen(navController = navController)
|
DebugScreen(navController = navController)
|
||||||
}
|
}
|
||||||
composable("long_press/{bud}") { navBackStackEntry ->
|
composable("long_press/{bud}") { navBackStackEntry ->
|
||||||
LongPress(
|
if (airPodsViewModel != null) LongPress(
|
||||||
navController = navController,
|
viewModel = airPodsViewModel,
|
||||||
name = navBackStackEntry.arguments?.getString("bud")!!
|
name = navBackStackEntry.arguments?.getString("bud")!!,
|
||||||
|
navController = navController
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable("rename") {
|
composable("rename") {
|
||||||
RenameScreen(navController)
|
if (airPodsViewModel != null) RenameScreen(airPodsViewModel)
|
||||||
}
|
}
|
||||||
composable("app_settings") {
|
composable("app_settings") {
|
||||||
AppSettingsScreen(navController)
|
val appSettingsViewModel: AppSettingsViewModel = viewModel()
|
||||||
|
AppSettingsScreen(navController, appSettingsViewModel)
|
||||||
}
|
}
|
||||||
composable("troubleshooting") {
|
composable("troubleshooting") {
|
||||||
TroubleshootingScreen(navController)
|
TroubleshootingScreen(navController)
|
||||||
}
|
}
|
||||||
composable("head_tracking") {
|
composable("head_tracking") {
|
||||||
HeadTrackingScreen()
|
if (airPodsViewModel != null) HeadTrackingScreen(airPodsViewModel, navController)
|
||||||
}
|
|
||||||
composable("onboarding") {
|
|
||||||
Onboarding(navController, context)
|
|
||||||
}
|
}
|
||||||
composable("accessibility") {
|
composable("accessibility") {
|
||||||
AccessibilitySettingsScreen(navController)
|
if (airPodsViewModel != null) AccessibilitySettingsScreen(airPodsViewModel, navController)
|
||||||
}
|
}
|
||||||
composable("transparency_customization") {
|
composable("transparency_customization") {
|
||||||
TransparencySettingsScreen(navController)
|
if (airPodsViewModel != null) TransparencySettingsScreen(airPodsViewModel)
|
||||||
}
|
}
|
||||||
composable("hearing_aid") {
|
composable("hearing_aid") {
|
||||||
HearingAidScreen(navController)
|
if (airPodsViewModel != null) HearingAidScreen(airPodsViewModel, navController)
|
||||||
}
|
}
|
||||||
composable("hearing_aid_adjustments") {
|
composable("hearing_aid_adjustments") {
|
||||||
HearingAidAdjustmentsScreen(navController)
|
if (airPodsViewModel != null) HearingAidAdjustmentsScreen(airPodsViewModel)
|
||||||
}
|
}
|
||||||
composable("adaptive_strength") {
|
composable("adaptive_strength") {
|
||||||
AdaptiveStrengthScreen(navController)
|
if (airPodsViewModel != null) AdaptiveStrengthScreen(airPodsViewModel, navController)
|
||||||
}
|
}
|
||||||
composable("camera_control") {
|
composable("camera_control") {
|
||||||
CameraControlScreen(navController)
|
if (airPodsViewModel != null) CameraControlScreen(airPodsViewModel)
|
||||||
}
|
}
|
||||||
composable("open_source_licenses") {
|
composable("open_source_licenses") {
|
||||||
OpenSourceLicensesScreen(navController)
|
OpenSourceLicensesScreen(navController)
|
||||||
}
|
}
|
||||||
composable("update_hearing_test") {
|
composable("update_hearing_test") {
|
||||||
UpdateHearingTestScreen(navController)
|
if (airPodsViewModel != null) UpdateHearingTestScreen()
|
||||||
}
|
}
|
||||||
composable("version_info") {
|
composable("version_info") {
|
||||||
VersionScreen(navController)
|
if (airPodsViewModel != null) VersionScreen(airPodsViewModel)
|
||||||
}
|
}
|
||||||
composable("hearing_protection") {
|
composable("hearing_protection") {
|
||||||
HearingProtectionScreen(navController)
|
if (airPodsViewModel != null) HearingProtectionScreen(airPodsViewModel)
|
||||||
|
}
|
||||||
|
composable("purchase_screen") {
|
||||||
|
val purchaseViewModel: PurchaseViewModel = viewModel()
|
||||||
|
PurchaseScreen(purchaseViewModel, navController)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val showBackButton = remember{ mutableStateOf(false) }
|
val showBackButton = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(navController) {
|
LaunchedEffect(navController) {
|
||||||
navController.addOnDestinationChangedListener { _, destination, _ ->
|
navController.addOnDestinationChangedListener { _, destination, _ ->
|
||||||
showBackButton.value = destination.route != "settings" && destination.route != "onboarding"
|
showBackButton.value =
|
||||||
Log.d("MainActivity", "Navigated to ${destination.route}, showBackButton: ${showBackButton.value}")
|
destination.route != "settings" // && destination.route != "onboarding"
|
||||||
|
Log.d(
|
||||||
|
"MainActivity",
|
||||||
|
"Navigated to ${destination.route}, showBackButton: ${showBackButton.value}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showBackButton.value,
|
visible = showBackButton.value,
|
||||||
enter = fadeIn(animationSpec = tween()) + scaleIn(initialScale = 0f, animationSpec = tween()),
|
enter = fadeIn(animationSpec = tween()) + scaleIn(
|
||||||
exit = fadeOut(animationSpec = tween()) + scaleOut(targetScale = 0.5f, animationSpec = tween(100)),
|
initialScale = 0f,
|
||||||
|
animationSpec = tween()
|
||||||
|
),
|
||||||
|
exit = fadeOut(animationSpec = tween()) + scaleOut(
|
||||||
|
targetScale = 0.5f,
|
||||||
|
animationSpec = tween(100)
|
||||||
|
),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
.padding(
|
.padding(
|
||||||
start = 8.dp,
|
start = 8.dp, top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
|
||||||
top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
StyledIconButton(
|
StyledIconButton(
|
||||||
onClick = { navController.popBackStack() },
|
onClick = { navController.popBackStack() },
|
||||||
icon = "",
|
icon = "",
|
||||||
darkMode = isSystemInDarkTheme(),
|
backdrop = backButtonBackdrop
|
||||||
backdrop = backButtonBackdrop
|
)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context.startForegroundService(Intent(context, AirPodsService::class.java))
|
||||||
|
|
||||||
serviceConnection = remember {
|
serviceConnection = remember {
|
||||||
object : ServiceConnection {
|
object : ServiceConnection {
|
||||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
@@ -457,17 +569,20 @@ fun Main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE)
|
context.bindService(
|
||||||
|
Intent(context, AirPodsService::class.java),
|
||||||
|
serviceConnection,
|
||||||
|
Context.BIND_AUTO_CREATE
|
||||||
|
)
|
||||||
|
|
||||||
if (airPodsService.value?.isConnectedLocally == true) {
|
if (airPodsService.value?.isConnected() == true) {
|
||||||
isConnected.value = true
|
isConnected.value = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
PermissionsScreen(
|
PermissionsScreen(
|
||||||
permissionState = permissionState,
|
permissionState = permissionState,
|
||||||
canDrawOverlays = canDrawOverlays,
|
canDrawOverlays = canDrawOverlays,
|
||||||
onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) }
|
onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,13 +605,9 @@ fun PermissionsScreen(
|
|||||||
|
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||||
val pulseScale by infiniteTransition.animateFloat(
|
val pulseScale by infiniteTransition.animateFloat(
|
||||||
initialValue = 1f,
|
initialValue = 1f, targetValue = 1.05f, animationSpec = infiniteRepeatable(
|
||||||
targetValue = 1.05f,
|
animation = tween(1000), repeatMode = RepeatMode.Reverse
|
||||||
animationSpec = infiniteRepeatable(
|
), label = "pulse scale"
|
||||||
animation = tween(1000),
|
|
||||||
repeatMode = RepeatMode.Reverse
|
|
||||||
),
|
|
||||||
label = "pulse scale"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
@@ -504,18 +615,15 @@ fun PermissionsScreen(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
|
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(180.dp),
|
.height(180.dp), contentAlignment = Alignment.Center
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "\uDBC2\uDEB7",
|
text = "\uDBC2\uDEB7", style = TextStyle(
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 48.sp,
|
fontSize = 48.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
@@ -551,29 +659,25 @@ fun PermissionsScreen(
|
|||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Permission Required",
|
text = "Permission Required", style = TextStyle(
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 24.sp,
|
fontSize = 24.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
color = textColor,
|
color = textColor,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
),
|
), modifier = Modifier.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.permissions_required),
|
text = stringResource(R.string.permissions_required), style = TextStyle(
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
color = textColor.copy(alpha = 0.7f),
|
color = textColor.copy(alpha = 0.7f),
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
),
|
), modifier = Modifier.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
@@ -746,8 +850,7 @@ fun PermissionCard(
|
|||||||
if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(
|
if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(
|
||||||
alpha = 0.15f
|
alpha = 0.15f
|
||||||
)
|
)
|
||||||
),
|
), contentAlignment = Alignment.Center
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
@@ -763,8 +866,7 @@ fun PermissionCard(
|
|||||||
.padding(start = 16.dp)
|
.padding(start = 16.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title, style = TextStyle(
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
@@ -773,8 +875,7 @@ fun PermissionCard(
|
|||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = description,
|
text = description, style = TextStyle(
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
@@ -791,11 +892,8 @@ fun PermissionCard(
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (isGranted) "✓" else "!",
|
text = if (isGranted) "✓" else "!", style = TextStyle(
|
||||||
style = TextStyle(
|
fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color.White
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = Color.White
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,15 +85,15 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush
|
import me.kavishdevar.librepods.presentation.components.AdaptiveRainbowBrush
|
||||||
import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton
|
import me.kavishdevar.librepods.presentation.components.ControlCenterNoiseControlSegmentedButton
|
||||||
import me.kavishdevar.librepods.composables.IconAreaSize
|
import me.kavishdevar.librepods.presentation.components.IconAreaSize
|
||||||
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
|
import me.kavishdevar.librepods.presentation.components.VerticalVolumeSlider
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
import me.kavishdevar.librepods.data.NoiseControlMode
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package me.kavishdevar.librepods.billing
|
||||||
|
|
||||||
|
object BillingManager {
|
||||||
|
lateinit var provider: BillingProvider
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.billing
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
interface BillingProvider {
|
||||||
|
val isPremium: StateFlow<Boolean>
|
||||||
|
val price: StateFlow<String>
|
||||||
|
fun purchase(activity: Activity)
|
||||||
|
fun queryPurchases()
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.billing
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
|
|
||||||
|
object BillingProviderFactory {
|
||||||
|
|
||||||
|
fun create(context: Context): BillingProvider {
|
||||||
|
return if (BuildConfig.PLAY_BUILD) {
|
||||||
|
PlayBillingProvider(context)
|
||||||
|
} else {
|
||||||
|
FOSSBillingProvider(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.billing
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.billing
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import com.android.billingclient.api.AcknowledgePurchaseParams
|
||||||
|
import com.android.billingclient.api.BillingClient
|
||||||
|
import com.android.billingclient.api.BillingClientStateListener
|
||||||
|
import com.android.billingclient.api.BillingFlowParams
|
||||||
|
import com.android.billingclient.api.BillingResult
|
||||||
|
import com.android.billingclient.api.PendingPurchasesParams
|
||||||
|
import com.android.billingclient.api.ProductDetails
|
||||||
|
import com.android.billingclient.api.Purchase
|
||||||
|
import com.android.billingclient.api.PurchasesUpdatedListener
|
||||||
|
import com.android.billingclient.api.QueryProductDetailsParams
|
||||||
|
import com.android.billingclient.api.QueryPurchasesParams
|
||||||
|
import com.android.billingclient.api.acknowledgePurchase
|
||||||
|
import com.android.billingclient.api.queryProductDetails
|
||||||
|
import com.android.billingclient.api.queryPurchasesAsync
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
const val TAG = "PlayBillingProvider"
|
||||||
|
|
||||||
|
private const val PREMIUM_PRODUCT_ID = "librepods.advanced_features.v2"
|
||||||
|
|
||||||
|
class PlayBillingProvider(
|
||||||
|
context: Context
|
||||||
|
) : BillingProvider, PurchasesUpdatedListener {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
private val _isPremium = MutableStateFlow(false)
|
||||||
|
override val isPremium: StateFlow<Boolean> = _isPremium
|
||||||
|
|
||||||
|
private val _price = MutableStateFlow("unknown")
|
||||||
|
override val price: StateFlow<String> = _price
|
||||||
|
|
||||||
|
|
||||||
|
private var productDetails: ProductDetails? = null
|
||||||
|
|
||||||
|
private val billingClient = BillingClient.newBuilder(context)
|
||||||
|
.setListener(this)
|
||||||
|
.enablePendingPurchases(
|
||||||
|
PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
init {
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connect() {
|
||||||
|
billingClient.startConnection(object : BillingClientStateListener {
|
||||||
|
override fun onBillingSetupFinished(result: BillingResult) {
|
||||||
|
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||||
|
scope.launch {
|
||||||
|
queryProductDetails()
|
||||||
|
queryExistingPurchases()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Billing setup failed: ${result.debugMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBillingServiceDisconnected() {
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun queryProductDetails() {
|
||||||
|
val params = QueryProductDetailsParams.newBuilder()
|
||||||
|
.setProductList(
|
||||||
|
listOf(
|
||||||
|
QueryProductDetailsParams.Product.newBuilder()
|
||||||
|
.setProductId(PREMIUM_PRODUCT_ID)
|
||||||
|
.setProductType(BillingClient.ProductType.INAPP)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
).build()
|
||||||
|
|
||||||
|
val result = billingClient.queryProductDetails(params)
|
||||||
|
if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||||
|
productDetails = result.productDetailsList?.firstOrNull()
|
||||||
|
Log.d(TAG, "Product loaded: ${productDetails?.name}")
|
||||||
|
val priceString = productDetails
|
||||||
|
?.oneTimePurchaseOfferDetails
|
||||||
|
?.formattedPrice
|
||||||
|
|
||||||
|
if (priceString != null) {
|
||||||
|
_price.value = priceString
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "queryProductDetails failed: ${result.billingResult.debugMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun queryExistingPurchases() {
|
||||||
|
val result = billingClient.queryPurchasesAsync(
|
||||||
|
QueryPurchasesParams.newBuilder()
|
||||||
|
.setProductType(BillingClient.ProductType.INAPP)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
processPurchases(result.purchasesList)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun purchase(activity: Activity) {
|
||||||
|
val details = productDetails ?: run {
|
||||||
|
Log.e(TAG, "Product details not loaded yet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val billingFlowParams = BillingFlowParams.newBuilder()
|
||||||
|
.setProductDetailsParamsList(
|
||||||
|
listOf(
|
||||||
|
BillingFlowParams.ProductDetailsParams.newBuilder()
|
||||||
|
.setProductDetails(details)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
).build()
|
||||||
|
|
||||||
|
val result = billingClient.launchBillingFlow(activity, billingFlowParams)
|
||||||
|
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||||
|
Log.e(TAG, "launchBillingFlow failed: ${result.debugMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPurchasesUpdated(result: BillingResult, purchases: List<Purchase>?) {
|
||||||
|
when (result.responseCode) {
|
||||||
|
BillingClient.BillingResponseCode.OK -> purchases?.let { processPurchases(it) }
|
||||||
|
BillingClient.BillingResponseCode.USER_CANCELED -> Log.d(TAG, "User cancelled")
|
||||||
|
else -> Log.w(TAG, "Purchase error ${result.responseCode}: ${result.debugMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processPurchases(purchases: List<Purchase>) {
|
||||||
|
val hasPremium = purchases.any {
|
||||||
|
it.products.contains(PREMIUM_PRODUCT_ID) &&
|
||||||
|
it.purchaseState == Purchase.PurchaseState.PURCHASED
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// val 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+159
-151
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.bluetooth
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
@@ -55,13 +55,13 @@ class AACPManager {
|
|||||||
const val TIPI_3: Byte = 0x0C // Don't know this one
|
const val TIPI_3: Byte = 0x0C // Don't know this one
|
||||||
const val SMART_ROUTING_RESP: Byte = 0x11
|
const val SMART_ROUTING_RESP: Byte = 0x11
|
||||||
const val SEND_CONNECTED_MAC: Byte = 0x14
|
const val SEND_CONNECTED_MAC: Byte = 0x14
|
||||||
|
const val AUDIO_SOURCE_2: Byte = 0x0C // seems redundant?
|
||||||
}
|
}
|
||||||
|
|
||||||
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
|
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
|
||||||
|
|
||||||
data class ControlCommandStatus(
|
data class ControlCommandStatus(
|
||||||
val identifier: ControlCommandIdentifiers,
|
val identifier: ControlCommandIdentifiers, val value: ByteArray
|
||||||
val value: ByteArray
|
|
||||||
) {
|
) {
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
@@ -84,42 +84,31 @@ class AACPManager {
|
|||||||
|
|
||||||
// @Suppress("unused")
|
// @Suppress("unused")
|
||||||
enum class ControlCommandIdentifiers(val value: Byte) {
|
enum class ControlCommandIdentifiers(val value: Byte) {
|
||||||
MIC_MODE(0x01),
|
MIC_MODE(0x01), BUTTON_SEND_MODE(0x05), VOICE_TRIGGER(0x12), SINGLE_CLICK_MODE(0x14), DOUBLE_CLICK_MODE(
|
||||||
BUTTON_SEND_MODE(0x05),
|
0x15
|
||||||
VOICE_TRIGGER(0x12),
|
),
|
||||||
SINGLE_CLICK_MODE(0x14),
|
CLICK_HOLD_MODE(0x16), DOUBLE_CLICK_INTERVAL(0x17), CLICK_HOLD_INTERVAL(0x18), LISTENING_MODE_CONFIGS(
|
||||||
DOUBLE_CLICK_MODE(0x15),
|
0x1A
|
||||||
CLICK_HOLD_MODE(0x16),
|
),
|
||||||
DOUBLE_CLICK_INTERVAL(0x17),
|
ONE_BUD_ANC_MODE(0x1B), CROWN_ROTATION_DIRECTION(0x1C), LISTENING_MODE(0x0D), AUTO_ANSWER_MODE(
|
||||||
CLICK_HOLD_INTERVAL(0x18),
|
0x1E
|
||||||
LISTENING_MODE_CONFIGS(0x1A),
|
),
|
||||||
ONE_BUD_ANC_MODE(0x1B),
|
CHIME_VOLUME(0x1F), VOLUME_SWIPE_INTERVAL(0x23), CALL_MANAGEMENT_CONFIG(0x24), VOLUME_SWIPE_MODE(
|
||||||
CROWN_ROTATION_DIRECTION(0x1C),
|
0x25
|
||||||
LISTENING_MODE(0x0D),
|
),
|
||||||
AUTO_ANSWER_MODE(0x1E),
|
ADAPTIVE_VOLUME_CONFIG(0x26), SOFTWARE_MUTE_CONFIG(0x27), CONVERSATION_DETECT_CONFIG(
|
||||||
CHIME_VOLUME(0x1F),
|
0x28
|
||||||
VOLUME_SWIPE_INTERVAL(0x23),
|
),
|
||||||
CALL_MANAGEMENT_CONFIG(0x24),
|
SSL(0x29), HEARING_AID(0x2C), AUTO_ANC_STRENGTH(0x2E), HPS_GAIN_SWIPE(0x2F), HRM_STATE(
|
||||||
VOLUME_SWIPE_MODE(0x25),
|
0x30
|
||||||
ADAPTIVE_VOLUME_CONFIG(0x26),
|
),
|
||||||
SOFTWARE_MUTE_CONFIG(0x27),
|
IN_CASE_TONE_CONFIG(0x31), SIRI_MULTITONE_CONFIG(0x32), HEARING_ASSIST_CONFIG(0x33), ALLOW_OFF_OPTION(
|
||||||
CONVERSATION_DETECT_CONFIG(0x28),
|
0x34
|
||||||
SSL(0x29),
|
),
|
||||||
HEARING_AID(0x2C),
|
STEM_CONFIG(0x39), SLEEP_DETECTION_CONFIG(0x35), ALLOW_AUTO_CONNECT(0x36), // not sure what this does, AUTOMATIC_CONNECTION is the only one used, but this is newer... so ¯\_(ツ)_/¯
|
||||||
AUTO_ANC_STRENGTH(0x2E),
|
EAR_DETECTION_CONFIG(0x0A), AUTOMATIC_CONNECTION_CONFIG(0x20), OWNS_CONNECTION(0x06), PPE_TOGGLE_CONFIG(
|
||||||
HPS_GAIN_SWIPE(0x2F),
|
0x37
|
||||||
HRM_STATE(0x30),
|
),
|
||||||
IN_CASE_TONE_CONFIG(0x31),
|
|
||||||
SIRI_MULTITONE_CONFIG(0x32),
|
|
||||||
HEARING_ASSIST_CONFIG(0x33),
|
|
||||||
ALLOW_OFF_OPTION(0x34),
|
|
||||||
STEM_CONFIG(0x39),
|
|
||||||
SLEEP_DETECTION_CONFIG(0x35),
|
|
||||||
ALLOW_AUTO_CONNECT(0x36), // not sure what this does, AUTOMATIC_CONNECTION is the only one used, but this is newer... so ¯\_(ツ)_/¯
|
|
||||||
EAR_DETECTION_CONFIG(0x0A),
|
|
||||||
AUTOMATIC_CONNECTION_CONFIG(0x20),
|
|
||||||
OWNS_CONNECTION(0x06),
|
|
||||||
PPE_TOGGLE_CONFIG(0x37),
|
|
||||||
PPE_CAP_LEVEL_CONFIG(0x38);
|
PPE_CAP_LEVEL_CONFIG(0x38);
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -129,59 +118,44 @@ class AACPManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum class ProximityKeyType(val value: Byte) {
|
enum class ProximityKeyType(val value: Byte) {
|
||||||
IRK(0x01),
|
IRK(0x01), ENC_KEY(0x04);
|
||||||
ENC_KEY(0x04);
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromByte(byte: Byte): ProximityKeyType =
|
fun fromByte(byte: Byte): ProximityKeyType = entries.find { it.value == byte }
|
||||||
ProximityKeyType.entries.find { it.value == byte }
|
?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
|
||||||
?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class StemPressType(val value: Byte) {
|
enum class StemPressType(val value: Byte) {
|
||||||
SINGLE_PRESS(0x05),
|
SINGLE_PRESS(0x05), DOUBLE_PRESS(0x06), TRIPLE_PRESS(0x07), LONG_PRESS(0x08);
|
||||||
DOUBLE_PRESS(0x06),
|
|
||||||
TRIPLE_PRESS(0x07),
|
|
||||||
LONG_PRESS(0x08);
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromByte(byte: Byte): StemPressType? =
|
fun fromByte(byte: Byte): StemPressType? = entries.find { it.value == byte }
|
||||||
entries.find { it.value == byte }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class StemPressBudType(val value: Byte) {
|
enum class StemPressBudType(val value: Byte) {
|
||||||
LEFT(0x01),
|
LEFT(0x01), RIGHT(0x02);
|
||||||
RIGHT(0x02);
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromByte(byte: Byte): StemPressBudType? =
|
fun fromByte(byte: Byte): StemPressBudType? = entries.find { it.value == byte }
|
||||||
entries.find { it.value == byte }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class AudioSourceType(val value: Byte) {
|
enum class AudioSourceType(val value: Byte) {
|
||||||
NONE(0x00),
|
NONE(0x00), CALL(0x01), MEDIA(0x02);
|
||||||
CALL(0x01),
|
|
||||||
MEDIA(0x02);
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromByte(byte: Byte): AudioSourceType? =
|
fun fromByte(byte: Byte): AudioSourceType? = entries.find { it.value == byte }
|
||||||
entries.find { it.value == byte }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class AudioSource(
|
data class AudioSource(
|
||||||
val mac: String,
|
val mac: String, val type: AudioSourceType
|
||||||
val type: AudioSourceType
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ConnectedDevice(
|
data class ConnectedDevice(
|
||||||
val mac: String,
|
val mac: String, val info1: Byte, val info2: Byte, var type: String?
|
||||||
val info1: Byte,
|
|
||||||
val info2: Byte,
|
|
||||||
var type: String?
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AirPodsInformation(
|
data class AirPodsInformation(
|
||||||
@@ -216,7 +190,7 @@ class AACPManager {
|
|||||||
var audioSource: AudioSource? = null
|
var audioSource: AudioSource? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var eqData = FloatArray(8) { 0.0f }
|
var eqData = FloatArray(8)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var eqOnPhone: Boolean = false
|
var eqOnPhone: Boolean = false
|
||||||
@@ -230,8 +204,7 @@ class AACPManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setControlCommandStatusValue(
|
private fun setControlCommandStatusValue(
|
||||||
identifier: ControlCommandIdentifiers,
|
identifier: ControlCommandIdentifiers, value: ByteArray
|
||||||
value: ByteArray
|
|
||||||
) {
|
) {
|
||||||
val existingStatus = getControlCommandStatus(identifier)
|
val existingStatus = getControlCommandStatus(identifier)
|
||||||
if (existingStatus == value) {
|
if (existingStatus == value) {
|
||||||
@@ -265,6 +238,7 @@ class AACPManager {
|
|||||||
fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>)
|
fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>)
|
||||||
fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean)
|
fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean)
|
||||||
fun onShowNearbyUI(sender: String)
|
fun onShowNearbyUI(sender: String)
|
||||||
|
fun onEQPacketReceived(eqData: FloatArray)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
|
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
|
||||||
@@ -287,15 +261,13 @@ class AACPManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun registerControlCommandListener(
|
fun registerControlCommandListener(
|
||||||
identifier: ControlCommandIdentifiers,
|
identifier: ControlCommandIdentifiers, callback: ControlCommandListener
|
||||||
callback: ControlCommandListener
|
|
||||||
) {
|
) {
|
||||||
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
|
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unregisterControlCommandListener(
|
fun unregisterControlCommandListener(
|
||||||
identifier: ControlCommandIdentifiers,
|
identifier: ControlCommandIdentifiers, callback: ControlCommandListener
|
||||||
callback: ControlCommandListener
|
|
||||||
) {
|
) {
|
||||||
controlCommandListeners[identifier]?.remove(callback)
|
controlCommandListeners[identifier]?.remove(callback)
|
||||||
}
|
}
|
||||||
@@ -330,8 +302,7 @@ class AACPManager {
|
|||||||
fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean {
|
fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean {
|
||||||
val controlPacket = createControlCommandPacket(identifier, value)
|
val controlPacket = createControlCommandPacket(identifier, value)
|
||||||
setControlCommandStatusValue(
|
setControlCommandStatusValue(
|
||||||
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
ControlCommandIdentifiers.fromByte(identifier) ?: return false, value
|
||||||
value
|
|
||||||
)
|
)
|
||||||
return sendDataPacket(controlPacket)
|
return sendDataPacket(controlPacket)
|
||||||
}
|
}
|
||||||
@@ -340,16 +311,14 @@ class AACPManager {
|
|||||||
fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
|
fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
|
||||||
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
|
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
|
||||||
setControlCommandStatusValue(
|
setControlCommandStatusValue(
|
||||||
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
ControlCommandIdentifiers.fromByte(identifier) ?: return false, byteArrayOf(value)
|
||||||
byteArrayOf(value)
|
|
||||||
)
|
)
|
||||||
return sendDataPacket(controlPacket)
|
return sendDataPacket(controlPacket)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendControlCommand(identifier: Byte, value: Boolean): Boolean {
|
fun sendControlCommand(identifier: Byte, value: Boolean): Boolean {
|
||||||
val controlPacket = createControlCommandPacket(
|
val controlPacket = createControlCommandPacket(
|
||||||
identifier,
|
identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
|
||||||
if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
|
|
||||||
)
|
)
|
||||||
setControlCommandStatusValue(
|
setControlCommandStatusValue(
|
||||||
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
||||||
@@ -369,8 +338,7 @@ class AACPManager {
|
|||||||
|
|
||||||
fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> {
|
fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> {
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG, "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}"
|
||||||
"Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}"
|
|
||||||
)
|
)
|
||||||
if (data.size < 4) {
|
if (data.size < 4) {
|
||||||
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
|
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
|
||||||
@@ -398,11 +366,9 @@ class AACPManager {
|
|||||||
keys[ProximityKeyType.fromByte(keyType)] = key
|
keys[ProximityKeyType.fromByte(keyType)] = key
|
||||||
offset += keyLength
|
offset += keyLength
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${
|
||||||
"Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${
|
key.joinToString(" ") { "%02X".format(it) }
|
||||||
key.joinToString(" ") { "%02X".format(it) }
|
}")
|
||||||
}"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
@@ -422,26 +388,21 @@ class AACPManager {
|
|||||||
fun receivePacket(packet: ByteArray) {
|
fun receivePacket(packet: ByteArray) {
|
||||||
if (!packet.toHexString().startsWith("04000400")) {
|
if (!packet.toHexString().startsWith("04000400")) {
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
TAG, "Received packet does not start with expected header: ${
|
||||||
"Received packet does not start with expected header: ${
|
packet.joinToString(" ") {
|
||||||
packet.joinToString(" ") {
|
"%02X".format(it)
|
||||||
"%02X".format(it)
|
}
|
||||||
}
|
}")
|
||||||
}"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (packet.size < 6) {
|
if (packet.size < 6) {
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
TAG, "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}"
|
||||||
"Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}"
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val opcode = packet[4]
|
when (val opcode = packet[4]) {
|
||||||
|
|
||||||
when (opcode) {
|
|
||||||
Opcodes.BATTERY_INFO -> {
|
Opcodes.BATTERY_INFO -> {
|
||||||
callback?.onBatteryInfoReceived(packet)
|
callback?.onBatteryInfoReceived(packet)
|
||||||
}
|
}
|
||||||
@@ -456,10 +417,9 @@ class AACPManager {
|
|||||||
TAG,
|
TAG,
|
||||||
"Control command received: ${controlCommand.identifier.toHexString()} - ${
|
"Control command received: ${controlCommand.identifier.toHexString()} - ${
|
||||||
controlCommand.value.joinToString(" ") { "%02X".format(it) }
|
controlCommand.value.joinToString(" ") { "%02X".format(it) }
|
||||||
}"
|
}")
|
||||||
)
|
|
||||||
Log.d(
|
val controlCommandListText = try {
|
||||||
TAG, "Control command list is now: ${
|
|
||||||
controlCommandStatusList.joinToString(", ") { it ->
|
controlCommandStatusList.joinToString(", ") { it ->
|
||||||
"${it.identifier.name} (${it.identifier.value.toHexString()}) - ${
|
"${it.identifier.name} (${it.identifier.value.toHexString()}) - ${
|
||||||
it.value.joinToString(
|
it.value.joinToString(
|
||||||
@@ -467,12 +427,19 @@ class AACPManager {
|
|||||||
) { "%02X".format(it) }
|
) { "%02X".format(it) }
|
||||||
}"
|
}"
|
||||||
}
|
}
|
||||||
}")
|
} catch (e: Exception) {
|
||||||
|
e.message
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(
|
||||||
|
TAG, "Control command list is now: $controlCommandListText"
|
||||||
|
)
|
||||||
|
|
||||||
val controlCommandIdentifier =
|
val controlCommandIdentifier =
|
||||||
ControlCommandIdentifiers.fromByte(controlCommand.identifier)
|
ControlCommandIdentifiers.fromByte(controlCommand.identifier)
|
||||||
if (controlCommandIdentifier != null) {
|
if (controlCommandIdentifier != null) {
|
||||||
controlCommandListeners[controlCommandIdentifier]?.forEach { listener ->
|
controlCommandListeners[controlCommandIdentifier]?.forEach { listener ->
|
||||||
|
Log.d(TAG, "calling listener for ${controlCommandIdentifier.name}")
|
||||||
listener.onControlCommandReceived(controlCommand)
|
listener.onControlCommandReceived(controlCommand)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -500,13 +467,11 @@ class AACPManager {
|
|||||||
Opcodes.HEADTRACKING -> {
|
Opcodes.HEADTRACKING -> {
|
||||||
if (packet.size < 70) {
|
if (packet.size < 70) {
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
TAG, "Received HEADTRACKING packet too short: ${
|
||||||
"Received HEADTRACKING packet too short: ${
|
packet.joinToString(" ") {
|
||||||
packet.joinToString(" ") {
|
"%02X".format(it)
|
||||||
"%02X".format(it)
|
}
|
||||||
}
|
}")
|
||||||
}"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
callback?.onHeadTrackingReceived(packet)
|
callback?.onHeadTrackingReceived(packet)
|
||||||
@@ -538,7 +503,8 @@ class AACPManager {
|
|||||||
|
|
||||||
Opcodes.SMART_ROUTING_RESP -> {
|
Opcodes.SMART_ROUTING_RESP -> {
|
||||||
val packetString = packet.decodeToString()
|
val packetString = packet.decodeToString()
|
||||||
val sender = packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) }
|
val sender =
|
||||||
|
packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) }
|
||||||
|
|
||||||
// if (connectedDevices.find { it.mac == sender }?.type == null && packetString.contains("btName")) {
|
// if (connectedDevices.find { it.mac == sender }?.type == null && packetString.contains("btName")) {
|
||||||
// val nameStartIndex = packetString.indexOf("btName") + 8
|
// val nameStartIndex = packetString.indexOf("btName") + 8
|
||||||
@@ -558,9 +524,15 @@ class AACPManager {
|
|||||||
} else if ("Android" in packetString) {
|
} else if ("Android" in packetString) {
|
||||||
connectedDevices.find { it.mac == sender }?.type = "Android"
|
connectedDevices.find { it.mac == sender }?.type = "Android"
|
||||||
}
|
}
|
||||||
Log.d(TAG, "Smart Routing Response from $sender: $packetString, type: ${connectedDevices.find { it.mac == sender }?.type}")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"Smart Routing Response from $sender: $packetString, type: ${connectedDevices.find { it.mac == sender }?.type}"
|
||||||
|
)
|
||||||
if (packetString.contains("SetOwnershipToFalse")) {
|
if (packetString.contains("SetOwnershipToFalse")) {
|
||||||
callback?.onOwnershipToFalseRequest(sender, packetString.contains("ReverseBannerTapped"))
|
callback?.onOwnershipToFalseRequest(
|
||||||
|
sender,
|
||||||
|
packetString.contains("ReverseBannerTapped")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (packetString.contains("ShowNearbyUI")) {
|
if (packetString.contains("ShowNearbyUI")) {
|
||||||
callback?.onShowNearbyUI(sender)
|
callback?.onShowNearbyUI(sender)
|
||||||
@@ -585,25 +557,33 @@ class AACPManager {
|
|||||||
|
|
||||||
eqOnMedia = (packet[10] == 0x01.toByte())
|
eqOnMedia = (packet[10] == 0x01.toByte())
|
||||||
eqOnPhone = (packet[11] == 0x01.toByte())
|
eqOnPhone = (packet[11] == 0x01.toByte())
|
||||||
// there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media enabled. just directly the EQ... weird.
|
// there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media visible. just directly the EQ... weird.
|
||||||
// the EQs are little endian floats
|
// the EQs are little endian floats
|
||||||
val eq1 = ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
val eq1 =
|
||||||
val eq2 = ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
||||||
val eq3 = ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
||||||
val eq4 = ByteBuffer.wrap(packet, 108, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
||||||
|
ByteBuffer.wrap(packet, 108, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
||||||
|
|
||||||
// for now, taking just the first EQ
|
// for now, taking just the first EQ
|
||||||
eqData = FloatArray(8) { i -> eq1.get(i) }
|
eqData = FloatArray(8) { i -> eq1.get(i) }
|
||||||
Log.d(TAG, "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia")
|
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia"
|
||||||
|
)
|
||||||
|
|
||||||
|
callback?.onEQPacketReceived(eqData)
|
||||||
}
|
}
|
||||||
|
|
||||||
Opcodes.INFORMATION -> {
|
Opcodes.INFORMATION -> {
|
||||||
Log.e(TAG, "Parsing Information Packet")
|
Log.d(TAG, "Parsing Information Packet")
|
||||||
val information = parseInformationPacket(packet)
|
val information = parseInformationPacket(packet)
|
||||||
callback?.onDeviceInformationReceived(information)
|
callback?.onDeviceInformationReceived(information)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Log.d(TAG, "Unknown opcode received: ${opcode.toHexString()}")
|
Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}")
|
||||||
callback?.onUnknownPacketReceived(packet)
|
callback?.onUnknownPacketReceived(packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -633,10 +613,22 @@ class AACPManager {
|
|||||||
|
|
||||||
fun createHandshakePacket(): ByteArray {
|
fun createHandshakePacket(): ByteArray {
|
||||||
return byteArrayOf(
|
return byteArrayOf(
|
||||||
0x00, 0x00, 0x04, 0x00,
|
0x00,
|
||||||
0x01, 0x00, 0x02, 0x00,
|
0x00,
|
||||||
0x00, 0x00, 0x00, 0x00,
|
0x04,
|
||||||
0x00, 0x00, 0x00, 0x00
|
0x00,
|
||||||
|
0x01,
|
||||||
|
0x00,
|
||||||
|
0x02,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,17 +774,31 @@ class AACPManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun sendMediaInformationNewDevice(selfMacAddress: String, targetMacAddress: String): Boolean {
|
fun sendMediaInformationNewDevice(selfMacAddress: String, targetMacAddress: String): Boolean {
|
||||||
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
|
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(
|
||||||
|
Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")
|
||||||
|
)
|
||||||
|
) {
|
||||||
// throw IllegalArgumentException("MAC address must be 6 bytes")
|
// throw IllegalArgumentException("MAC address must be 6 bytes")
|
||||||
Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress")
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress"
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
Log.d(TAG, "SELFMAC: ${selfMacAddress}, TARGETMAC: $targetMacAddress")
|
Log.d(TAG, "SELFMAC: ${selfMacAddress}, TARGETMAC: $targetMacAddress")
|
||||||
Log.d(TAG, "Sending Media Information packet to $targetMacAddress")
|
Log.d(TAG, "Sending Media Information packet to $targetMacAddress")
|
||||||
return sendDataPacket(createMediaInformationNewDevicePacket(selfMacAddress, targetMacAddress))
|
return sendDataPacket(
|
||||||
|
createMediaInformationNewDevicePacket(
|
||||||
|
selfMacAddress,
|
||||||
|
targetMacAddress
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createMediaInformationNewDevicePacket(selfMacAddress: String, targetMacAddress: String): ByteArray {
|
fun createMediaInformationNewDevicePacket(
|
||||||
|
selfMacAddress: String,
|
||||||
|
targetMacAddress: String
|
||||||
|
): ByteArray {
|
||||||
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
|
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
|
||||||
val buffer = ByteBuffer.allocate(116)
|
val buffer = ByteBuffer.allocate(116)
|
||||||
buffer.put(
|
buffer.put(
|
||||||
@@ -881,17 +887,13 @@ class AACPManager {
|
|||||||
Log.d(TAG, "Sending Media Information packet to $targetMac")
|
Log.d(TAG, "Sending Media Information packet to $targetMac")
|
||||||
return sendDataPacket(
|
return sendDataPacket(
|
||||||
createMediaInformationPacket(
|
createMediaInformationPacket(
|
||||||
selfMacAddress,
|
selfMacAddress, targetMac, streamingState
|
||||||
targetMac,
|
|
||||||
streamingState
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createMediaInformationPacket(
|
fun createMediaInformationPacket(
|
||||||
selfMacAddress: String,
|
selfMacAddress: String, targetMacAddress: String, streamingState: Boolean = true
|
||||||
targetMacAddress: String,
|
|
||||||
streamingState: Boolean = true
|
|
||||||
): ByteArray {
|
): ByteArray {
|
||||||
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
|
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
|
||||||
val buffer = ByteBuffer.allocate(138)
|
val buffer = ByteBuffer.allocate(138)
|
||||||
@@ -924,7 +926,7 @@ class AACPManager {
|
|||||||
buffer.put("AudioCategory".toByteArray())
|
buffer.put("AudioCategory".toByteArray())
|
||||||
buffer.put(byteArrayOf(0x31, 0x2D, 0x01))
|
buffer.put(byteArrayOf(0x31, 0x2D, 0x01))
|
||||||
|
|
||||||
return opcode+buffer.array()
|
return opcode + buffer.array()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendSmartRoutingShowUI(selfMacAddress: String): Boolean {
|
fun sendSmartRoutingShowUI(selfMacAddress: String): Boolean {
|
||||||
@@ -1006,9 +1008,15 @@ class AACPManager {
|
|||||||
|
|
||||||
|
|
||||||
fun sendAddTiPiDevice(selfMacAddress: String, targetMacAddress: String): Boolean {
|
fun sendAddTiPiDevice(selfMacAddress: String, targetMacAddress: String): Boolean {
|
||||||
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
|
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(
|
||||||
|
Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")
|
||||||
|
)
|
||||||
|
) {
|
||||||
// throw IllegalArgumentException("MAC address must be 6 bytes")
|
// throw IllegalArgumentException("MAC address must be 6 bytes")
|
||||||
Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress")
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress"
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress")
|
Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress")
|
||||||
@@ -1042,8 +1050,7 @@ class AACPManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data class ControlCommand(
|
data class ControlCommand(
|
||||||
val identifier: Byte,
|
val identifier: Byte, val value: ByteArray
|
||||||
val value: ByteArray
|
|
||||||
) {
|
) {
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
@@ -1095,10 +1102,8 @@ class AACPManager {
|
|||||||
triplePressCustomized: Boolean = false,
|
triplePressCustomized: Boolean = false,
|
||||||
longPressCustomized: Boolean = false
|
longPressCustomized: Boolean = false
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val value = ((if (singlePressCustomized) 0x01 else 0) or
|
val value =
|
||||||
(if (doublePressCustomized) 0x02 else 0) or
|
((if (singlePressCustomized) 0x01 else 0) or (if (doublePressCustomized) 0x02 else 0) or (if (triplePressCustomized) 0x04 else 0) or (if (longPressCustomized) 0x08 else 0)).toByte()
|
||||||
(if (triplePressCustomized) 0x04 else 0) or
|
|
||||||
(if (longPressCustomized) 0x08 else 0)).toByte()
|
|
||||||
Log.d(TAG, "Sending Stem Config Packet with value: ${value.toHexString()}")
|
Log.d(TAG, "Sending Stem Config Packet with value: ${value.toHexString()}")
|
||||||
return sendControlCommand(
|
return sendControlCommand(
|
||||||
ControlCommandIdentifiers.STEM_CONFIG.value, value
|
ControlCommandIdentifiers.STEM_CONFIG.value, value
|
||||||
@@ -1113,19 +1118,18 @@ class AACPManager {
|
|||||||
if (packet[4] == Opcodes.CONTROL_COMMAND) {
|
if (packet[4] == Opcodes.CONTROL_COMMAND) {
|
||||||
val controlCommand = ControlCommand.fromByteArray(packet)
|
val controlCommand = ControlCommand.fromByteArray(packet)
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${
|
||||||
"Control command: ${controlCommand.identifier.toHexString()} - ${
|
controlCommand.value.joinToString(" ") { "%02X".format(it) }
|
||||||
controlCommand.value.joinToString(" ") { "%02X".format(it) }
|
}")
|
||||||
}"
|
|
||||||
)
|
|
||||||
setControlCommandStatusValue(
|
setControlCommandStatusValue(
|
||||||
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false,
|
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false,
|
||||||
controlCommand.value
|
controlCommand.value
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val socket = BluetoothConnectionManager.getCurrentSocket()
|
val socket = BluetoothConnectionManager.getCurrentSocket() ?: return false
|
||||||
if (socket?.isConnected == true) {
|
|
||||||
|
if (socket.isConnected) {
|
||||||
socket.outputStream?.write(packet)
|
socket.outputStream?.write(packet)
|
||||||
socket.outputStream?.flush()
|
socket.outputStream?.flush()
|
||||||
return true
|
return true
|
||||||
@@ -1202,7 +1206,10 @@ class AACPManager {
|
|||||||
var offset = 9
|
var offset = 9
|
||||||
for (i in 0 until deviceCount) {
|
for (i in 0 until deviceCount) {
|
||||||
if (offset + 8 > data.size) {
|
if (offset + 8 > data.size) {
|
||||||
Log.w(TAG, "Data array too short to parse all connected devices, returning what we have")
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Data array too short to parse all connected devices, returning what we have"
|
||||||
|
)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
val macBytes = data.sliceArray(offset until offset + 6)
|
val macBytes = data.sliceArray(offset until offset + 6)
|
||||||
@@ -1216,6 +1223,7 @@ class AACPManager {
|
|||||||
|
|
||||||
return devices
|
return devices
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendSomePacketIDontKnowWhatItIs() {
|
fun sendSomePacketIDontKnowWhatItIs() {
|
||||||
// 2900 00ff ffff ffff ffff -- enables setting EQ
|
// 2900 00ff ffff ffff ffff -- enables setting EQ
|
||||||
sendDataPacket(
|
sendDataPacket(
|
||||||
+20
-9
@@ -21,17 +21,18 @@
|
|||||||
* and receiving notifications. It is not a complete implementation of the ATT protocol.
|
* and receiving notifications. It is not a complete implementation of the ATT protocol.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.bluetooth
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothAdapter
|
||||||
import android.bluetooth.BluetoothDevice
|
import android.bluetooth.BluetoothDevice
|
||||||
import android.bluetooth.BluetoothSocket
|
import android.bluetooth.BluetoothSocket
|
||||||
import android.os.ParcelUuid
|
import android.os.ParcelUuid
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.lsposed.hiddenapibypass.HiddenApiBypass
|
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.util.concurrent.LinkedBlockingQueue
|
import java.util.concurrent.LinkedBlockingQueue
|
||||||
@@ -49,7 +50,7 @@ enum class ATTCCCDHandles(val value: Int) {
|
|||||||
HEARING_AID(ATTHandles.HEARING_AID.value + 1),
|
HEARING_AID(ATTHandles.HEARING_AID.value + 1),
|
||||||
}
|
}
|
||||||
|
|
||||||
class ATTManager(private val device: BluetoothDevice) {
|
class ATTManager(private val adapter: BluetoothAdapter, private val device: BluetoothDevice) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "ATTManager"
|
private const val TAG = "ATTManager"
|
||||||
|
|
||||||
@@ -62,18 +63,22 @@ class ATTManager(private val device: BluetoothDevice) {
|
|||||||
private var input: InputStream? = null
|
private var input: InputStream? = null
|
||||||
private var output: OutputStream? = null
|
private var output: OutputStream? = null
|
||||||
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
|
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
|
||||||
private var notificationJob: kotlinx.coroutines.Job? = null
|
private var notificationJob: Job? = null
|
||||||
|
|
||||||
// queue for non-notification PDUs (responses to requests)
|
// queue for non-notification PDUs (responses to requests)
|
||||||
private val responses = LinkedBlockingQueue<ByteArray>()
|
private val responses = LinkedBlockingQueue<ByteArray>()
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun connect() {
|
fun connect() {
|
||||||
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
|
|
||||||
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
|
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
|
||||||
|
|
||||||
socket = createBluetoothSocket(device, uuid)
|
socket = createBluetoothSocket(adapter, device, uuid)
|
||||||
socket!!.connect()
|
try {
|
||||||
|
socket!!.connect()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "ATT socket failed to connect")
|
||||||
|
return
|
||||||
|
}
|
||||||
input = socket!!.inputStream
|
input = socket!!.inputStream
|
||||||
output = socket!!.outputStream
|
output = socket!!.outputStream
|
||||||
Log.d(TAG, "Connected to ATT")
|
Log.d(TAG, "Connected to ATT")
|
||||||
@@ -195,9 +200,10 @@ class ATTManager(private val device: BluetoothDevice) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createBluetoothSocket(device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
|
private fun createBluetoothSocket(adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
|
||||||
val type = 3 // L2CAP
|
val type = 3 // L2CAP
|
||||||
val constructorSpecs = listOf(
|
val constructorSpecs = listOf(
|
||||||
|
arrayOf(adapter, device, type, true, 31, uuid),
|
||||||
arrayOf(device, type, true, true, 31, uuid),
|
arrayOf(device, type, true, true, 31, uuid),
|
||||||
arrayOf(device, type, 1, true, true, 31, uuid),
|
arrayOf(device, type, 1, true, true, 31, uuid),
|
||||||
arrayOf(type, 1, true, true, device, 31, uuid),
|
arrayOf(type, 1, true, true, device, 31, uuid),
|
||||||
@@ -219,7 +225,12 @@ class ATTManager(private val device: BluetoothDevice) {
|
|||||||
try {
|
try {
|
||||||
Log.d("ATTManager", "Trying constructor signature #${index + 1}")
|
Log.d("ATTManager", "Trying constructor signature #${index + 1}")
|
||||||
attemptedConstructors++
|
attemptedConstructors++
|
||||||
return HiddenApiBypass.newInstance(BluetoothSocket::class.java, *params) as BluetoothSocket
|
|
||||||
|
val paramTypes = params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray()
|
||||||
|
val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes)
|
||||||
|
constructor.isAccessible = true
|
||||||
|
return constructor.newInstance(*params) as BluetoothSocket
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("ATTManager", "Constructor signature #${index + 1} failed: ${e.message}")
|
Log.e("ATTManager", "Constructor signature #${index + 1} failed: ${e.message}")
|
||||||
lastException = e
|
lastException = e
|
||||||
+3
-1
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.bluetooth
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
@@ -30,8 +30,10 @@ import android.content.SharedPreferences
|
|||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import me.kavishdevar.librepods.utils.BluetoothCryptography
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.spec.SecretKeySpec
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
import kotlin.collections.iterator
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.bluetooth
|
||||||
|
|
||||||
import android.bluetooth.BluetoothDevice
|
import android.bluetooth.BluetoothDevice
|
||||||
import android.bluetooth.BluetoothSocket
|
import android.bluetooth.BluetoothSocket
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.scale
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun BatteryIndicator(
|
|
||||||
batteryPercentage: Int,
|
|
||||||
charging: Boolean = false,
|
|
||||||
prefix: String = "",
|
|
||||||
previousCharging: Boolean = false,
|
|
||||||
) {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val backgroundColor = if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)
|
|
||||||
val batteryTextColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
val batteryFillColor = if (batteryPercentage > 25)
|
|
||||||
if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759)
|
|
||||||
else if (isDarkTheme) Color(0xFFFC4244) else Color(0xFFfe373C)
|
|
||||||
|
|
||||||
val initialScale = if (previousCharging) 1f else 0f
|
|
||||||
val scaleAnim = remember { Animatable(initialScale) }
|
|
||||||
val targetScale = if (charging) 1f else 0f
|
|
||||||
|
|
||||||
LaunchedEffect(previousCharging, charging) {
|
|
||||||
scaleAnim.animateTo(targetScale, animationSpec = tween(durationMillis = 250))
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(backgroundColor), // just for haze to work
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.padding(bottom = 4.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
progress = { batteryPercentage / 100f },
|
|
||||||
modifier = Modifier.size(40.dp),
|
|
||||||
color = batteryFillColor,
|
|
||||||
gapSize = 0.dp,
|
|
||||||
strokeCap = StrokeCap.Round,
|
|
||||||
strokeWidth = 4.dp,
|
|
||||||
trackColor = if (isDarkTheme) Color(0xFF0E0E0F) else Color(0xFFE3E3E8)
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "\uDBC0\uDEE6",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = batteryFillColor,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
),
|
|
||||||
modifier = Modifier.scale(scaleAnim.value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "$prefix $batteryPercentage%",
|
|
||||||
color = batteryTextColor,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
|
||||||
@Composable
|
|
||||||
fun BatteryIndicatorPreview() {
|
|
||||||
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.background(bg)
|
|
||||||
) {
|
|
||||||
BatteryIndicator(batteryPercentage = 24, charging = true, prefix = "\uDBC6\uDCE5", previousCharging = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import android.os.Build
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.imageResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
|
||||||
import me.kavishdevar.librepods.constants.Battery
|
|
||||||
import me.kavishdevar.librepods.constants.BatteryComponent
|
|
||||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun BatteryView(service: AirPodsService, preview: Boolean = false) {
|
|
||||||
val batteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
|
|
||||||
|
|
||||||
val previousBatteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
|
|
||||||
|
|
||||||
@Suppress("DEPRECATION") val batteryReceiver = remember {
|
|
||||||
object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
if (intent.action == AirPodsNotifications.BATTERY_DATA) {
|
|
||||||
batteryStatus.value =
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
intent.getParcelableArrayListExtra("data", Battery::class.java)
|
|
||||||
} else {
|
|
||||||
intent.getParcelableArrayListExtra("data")
|
|
||||||
}?.toList() ?: listOf()
|
|
||||||
}
|
|
||||||
else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
|
|
||||||
try {
|
|
||||||
context.unregisterReceiver(this)
|
|
||||||
}
|
|
||||||
catch (_: IllegalArgumentException) {
|
|
||||||
Log.wtf("BatteryReceiver", "Receiver already unregistered")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
LaunchedEffect(context) {
|
|
||||||
val batteryIntentFilter = IntentFilter()
|
|
||||||
.apply {
|
|
||||||
addAction(AirPodsNotifications.BATTERY_DATA)
|
|
||||||
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
context.registerReceiver(
|
|
||||||
batteryReceiver,
|
|
||||||
batteryIntentFilter,
|
|
||||||
Context.RECEIVER_EXPORTED
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
previousBatteryStatus.value = batteryStatus.value
|
|
||||||
batteryStatus.value = service.getBattery()
|
|
||||||
|
|
||||||
if (preview) {
|
|
||||||
batteryStatus.value = listOf(
|
|
||||||
Battery(BatteryComponent.LEFT, 100, BatteryStatus.NOT_CHARGING),
|
|
||||||
Battery(BatteryComponent.RIGHT, 94, BatteryStatus.CHARGING),
|
|
||||||
Battery(BatteryComponent.CASE, 40, BatteryStatus.CHARGING)
|
|
||||||
)
|
|
||||||
previousBatteryStatus.value = batteryStatus.value
|
|
||||||
}
|
|
||||||
|
|
||||||
val left = batteryStatus.value.find { it.component == BatteryComponent.LEFT }
|
|
||||||
val right = batteryStatus.value.find { it.component == BatteryComponent.RIGHT }
|
|
||||||
val case = batteryStatus.value.find { it.component == BatteryComponent.CASE }
|
|
||||||
val leftLevel = left?.level ?: 0
|
|
||||||
val rightLevel = right?.level ?: 0
|
|
||||||
val caseLevel = case?.level ?: 0
|
|
||||||
val leftCharging = left?.status == BatteryStatus.CHARGING
|
|
||||||
val rightCharging = right?.status == BatteryStatus.CHARGING
|
|
||||||
val caseCharging = case?.status == BatteryStatus.CHARGING
|
|
||||||
|
|
||||||
val prevLeft = previousBatteryStatus.value.find { it.component == BatteryComponent.LEFT }
|
|
||||||
val prevRight = previousBatteryStatus.value.find { it.component == BatteryComponent.RIGHT }
|
|
||||||
val prevCase = previousBatteryStatus.value.find { it.component == BatteryComponent.CASE }
|
|
||||||
val prevLeftCharging = prevLeft?.status == BatteryStatus.CHARGING
|
|
||||||
val prevRightCharging = prevRight?.status == BatteryStatus.CHARGING
|
|
||||||
val prevCaseCharging = prevCase?.status == BatteryStatus.CHARGING
|
|
||||||
|
|
||||||
val singleDisplayed = remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val airpodsInstance = service.airpodsInstance
|
|
||||||
if (airpodsInstance == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val budsRes = airpodsInstance.model.budsRes
|
|
||||||
val caseRes = airpodsInstance.model.caseRes
|
|
||||||
|
|
||||||
Row {
|
|
||||||
Column (
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(0.5f),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Image (
|
|
||||||
bitmap = ImageBitmap.imageResource(budsRes),
|
|
||||||
contentDescription = stringResource(R.string.buds),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(8.dp)
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
leftCharging == rightCharging &&
|
|
||||||
(leftLevel - rightLevel) in -3..3
|
|
||||||
)
|
|
||||||
{
|
|
||||||
BatteryIndicator(
|
|
||||||
leftLevel.coerceAtMost(rightLevel),
|
|
||||||
leftCharging,
|
|
||||||
previousCharging = (prevLeftCharging && prevRightCharging)
|
|
||||||
)
|
|
||||||
singleDisplayed.value = true
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
singleDisplayed.value = false
|
|
||||||
Row (
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
|
|
||||||
BatteryIndicator(
|
|
||||||
leftLevel,
|
|
||||||
leftCharging,
|
|
||||||
"\uDBC6\uDCE5",
|
|
||||||
previousCharging = prevLeftCharging
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (leftLevel > 0 && rightLevel > 0)
|
|
||||||
{
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
}
|
|
||||||
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED)
|
|
||||||
{
|
|
||||||
BatteryIndicator(
|
|
||||||
rightLevel,
|
|
||||||
rightCharging,
|
|
||||||
"\uDBC6\uDCE8",
|
|
||||||
previousCharging = prevRightCharging
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column (
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
bitmap = ImageBitmap.imageResource(caseRes),
|
|
||||||
contentDescription = stringResource(R.string.case_alt),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(8.dp)
|
|
||||||
)
|
|
||||||
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
|
|
||||||
BatteryIndicator(
|
|
||||||
caseLevel,
|
|
||||||
caseCharging,
|
|
||||||
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else "",
|
|
||||||
previousCharging = prevCaseCharging
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
|
||||||
@Composable
|
|
||||||
fun BatteryViewPreview() {
|
|
||||||
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.background(bg)
|
|
||||||
) {
|
|
||||||
BatteryView(AirPodsService(), preview = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-109
@@ -1,109 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.composables.NavigationButton
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.utils.Capability
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun HearingHealthSettings(navController: NavController) {
|
|
||||||
val service = ServiceManager.getService()
|
|
||||||
if (service == null) return
|
|
||||||
val airpodsInstance = service.airpodsInstance
|
|
||||||
if (airpodsInstance == null) return
|
|
||||||
if (airpodsInstance.model.capabilities.contains(Capability.HEARING_AID)) {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
|
|
||||||
if (airpodsInstance.model.capabilities.contains(Capability.PPE)) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
|
||||||
){
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.hearing_health),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(28.dp))
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
|
||||||
.padding(top = 2.dp)
|
|
||||||
) {
|
|
||||||
NavigationButton(
|
|
||||||
to = "hearing_protection",
|
|
||||||
name = stringResource(R.string.hearing_protection),
|
|
||||||
navController = navController,
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
NavigationButton(
|
|
||||||
to = "hearing_aid",
|
|
||||||
name = stringResource(R.string.hearing_aid),
|
|
||||||
navController = navController,
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
NavigationButton(
|
|
||||||
to = "hearing_aid",
|
|
||||||
name = stringResource(R.string.hearing_aid),
|
|
||||||
navController = navController
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,687 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.MutableState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import me.kavishdevar.librepods.utils.ATTHandles
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun StyledToggle(
|
|
||||||
title: String? = null,
|
|
||||||
label: String,
|
|
||||||
description: String? = null,
|
|
||||||
checkedState: MutableState<Boolean> = remember { mutableStateOf(false) } ,
|
|
||||||
sharedPreferenceKey: String? = null,
|
|
||||||
sharedPreferences: SharedPreferences? = null,
|
|
||||||
independent: Boolean = true,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
onCheckedChange: ((Boolean) -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
var checked by checkedState
|
|
||||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
|
||||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
|
||||||
if (sharedPreferenceKey != null && sharedPreferences != null) {
|
|
||||||
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
|
|
||||||
}
|
|
||||||
fun cb() {
|
|
||||||
if (sharedPreferences != null) {
|
|
||||||
if (sharedPreferenceKey == null) {
|
|
||||||
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
|
|
||||||
}
|
|
||||||
onCheckedChange?.invoke(checked)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (independent) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
|
||||||
if (title != null) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f)
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
|
|
||||||
.padding(4.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
backgroundColor =
|
|
||||||
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
|
||||||
tryAwaitRelease()
|
|
||||||
backgroundColor =
|
|
||||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
},
|
|
||||||
onTap = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = !checked
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(55.dp)
|
|
||||||
.padding(horizontal = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
StyledSwitch(
|
|
||||||
checked = checked,
|
|
||||||
enabled = enabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = it
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (description != null) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Light,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val isPressed = remember { mutableStateOf(false) }
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
shape = RoundedCornerShape(28.dp),
|
|
||||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
|
||||||
)
|
|
||||||
.padding(16.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
isPressed.value = true
|
|
||||||
tryAwaitRelease()
|
|
||||||
isPressed.value = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
) {
|
|
||||||
if (enabled) {
|
|
||||||
checked = !checked
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
if (description != null) {
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
StyledSwitch(
|
|
||||||
checked = checked,
|
|
||||||
enabled = enabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = it
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun StyledToggle(
|
|
||||||
title: String? = null,
|
|
||||||
label: String,
|
|
||||||
description: String? = null,
|
|
||||||
controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers,
|
|
||||||
independent: Boolean = true,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
sharedPreferenceKey: String? = null,
|
|
||||||
sharedPreferences: SharedPreferences? = null,
|
|
||||||
onCheckedChange: ((Boolean) -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val service = ServiceManager.getService() ?: return
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
val checkedValue = service.aacpManager.controlCommandStatusList.find {
|
|
||||||
it.identifier == controlCommandIdentifier
|
|
||||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
|
||||||
var checked by remember { mutableStateOf(checkedValue == 1.toByte()) }
|
|
||||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
|
||||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
|
||||||
if (sharedPreferenceKey != null && sharedPreferences != null) {
|
|
||||||
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
|
|
||||||
}
|
|
||||||
fun cb() {
|
|
||||||
service.aacpManager.sendControlCommand(identifier = controlCommandIdentifier.value, value = checked)
|
|
||||||
if (sharedPreferences != null) {
|
|
||||||
if (sharedPreferenceKey == null) {
|
|
||||||
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
|
|
||||||
}
|
|
||||||
onCheckedChange?.invoke(checked)
|
|
||||||
}
|
|
||||||
|
|
||||||
val listener = remember {
|
|
||||||
object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (controlCommand.identifier == controlCommandIdentifier.value) {
|
|
||||||
Log.d("StyledToggle", "Received control command for $label: ${controlCommand.value}")
|
|
||||||
checked = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
service.aacpManager.registerControlCommandListener(controlCommandIdentifier, listener)
|
|
||||||
}
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
service.aacpManager.unregisterControlCommandListener(controlCommandIdentifier, listener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (independent) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
|
||||||
if (title != null) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f)
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
|
|
||||||
.padding(4.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
backgroundColor =
|
|
||||||
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
|
||||||
tryAwaitRelease()
|
|
||||||
backgroundColor =
|
|
||||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
},
|
|
||||||
onTap = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = !checked
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(55.dp)
|
|
||||||
.padding(horizontal = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
StyledSwitch(
|
|
||||||
checked = checked,
|
|
||||||
enabled = enabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = it
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (description != null) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Light,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val isPressed = remember { mutableStateOf(false) }
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
shape = RoundedCornerShape(28.dp),
|
|
||||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
|
||||||
)
|
|
||||||
.padding(16.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
isPressed.value = true
|
|
||||||
tryAwaitRelease()
|
|
||||||
isPressed.value = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
) {
|
|
||||||
if (enabled) {
|
|
||||||
checked = !checked
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
if (description != null) {
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
StyledSwitch(
|
|
||||||
checked = checked,
|
|
||||||
enabled = enabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = it
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun StyledToggle(
|
|
||||||
title: String? = null,
|
|
||||||
label: String,
|
|
||||||
description: String? = null,
|
|
||||||
attHandle: ATTHandles,
|
|
||||||
independent: Boolean = true,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
sharedPreferenceKey: String? = null,
|
|
||||||
sharedPreferences: SharedPreferences? = null,
|
|
||||||
onCheckedChange: ((Boolean) -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val attManager = ServiceManager.getService()?.attManager ?: return
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
val checkedValue = try {
|
|
||||||
attManager.read(attHandle).getOrNull(0)?.toInt()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w("StyledToggle", "Error reading initial value for $label: ${e.message}")
|
|
||||||
null
|
|
||||||
} ?: 0
|
|
||||||
var checked by remember { mutableStateOf(checkedValue !=0) }
|
|
||||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
|
||||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
|
||||||
|
|
||||||
attManager.enableNotifications(attHandle)
|
|
||||||
|
|
||||||
if (sharedPreferenceKey != null && sharedPreferences != null) {
|
|
||||||
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cb() {
|
|
||||||
if (sharedPreferences != null) {
|
|
||||||
if (sharedPreferenceKey == null) {
|
|
||||||
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
|
|
||||||
}
|
|
||||||
onCheckedChange?.invoke(checked)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(checked) {
|
|
||||||
if (attManager.socket?.isConnected != true) return@LaunchedEffect
|
|
||||||
attManager.write(attHandle, if (checked) byteArrayOf(1) else byteArrayOf(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
val listener = remember {
|
|
||||||
object : (ByteArray) -> Unit {
|
|
||||||
override fun invoke(value: ByteArray) {
|
|
||||||
if (value.isNotEmpty()) {
|
|
||||||
checked = value[0].toInt() != 0
|
|
||||||
Log.d("StyledToggle", "Updated from notification for $label: enabled=$checked")
|
|
||||||
} else {
|
|
||||||
Log.w("StyledToggle", "Empty value in notification for $label")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
attManager.registerListener(attHandle, listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
attManager.unregisterListener(attHandle, listener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (independent) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
|
||||||
if (title != null) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f)
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
|
|
||||||
.padding(4.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
backgroundColor =
|
|
||||||
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
|
||||||
tryAwaitRelease()
|
|
||||||
backgroundColor =
|
|
||||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
},
|
|
||||||
onTap = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = !checked
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(55.dp)
|
|
||||||
.padding(horizontal = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
StyledSwitch(
|
|
||||||
checked = checked,
|
|
||||||
enabled = enabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = it
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (description != null) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Light,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val isPressed = remember { mutableStateOf(false) }
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
shape = RoundedCornerShape(28.dp),
|
|
||||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
|
||||||
)
|
|
||||||
.padding(16.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
isPressed.value = true
|
|
||||||
tryAwaitRelease()
|
|
||||||
isPressed.value = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
) {
|
|
||||||
if (enabled) {
|
|
||||||
checked = !checked
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
if (description != null) {
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
lineHeight = 14.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
StyledSwitch(
|
|
||||||
checked = checked,
|
|
||||||
enabled = enabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = it
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun StyledTogglePreview() {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val sharedPrefs = context.getSharedPreferences("preview", 0)
|
|
||||||
StyledToggle(
|
|
||||||
label = "Example Toggle",
|
|
||||||
description = "This is an example description for the styled toggle.",
|
|
||||||
sharedPreferences = sharedPrefs
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+83
-41
@@ -16,10 +16,8 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.data
|
||||||
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import me.kavishdevar.librepods.utils.ATTManager
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
|
||||||
open class AirPodsBase(
|
open class AirPodsBase(
|
||||||
@@ -52,33 +50,48 @@ enum class Capability {
|
|||||||
class AirPods: AirPodsBase(
|
class AirPods: AirPodsBase(
|
||||||
modelNumber = listOf("A1523", "A1722"),
|
modelNumber = listOf("A1523", "A1722"),
|
||||||
name = "AirPods 1",
|
name = "AirPods 1",
|
||||||
budCaseRes = R.drawable.airpods_1,
|
// budCaseRes = R.drawable.airpods_1
|
||||||
budsRes = R.drawable.airpods_1_buds,
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
leftBudsRes = R.drawable.airpods_1_left,
|
// budsRes = R.drawable.airpods_1_buds
|
||||||
rightBudsRes = R.drawable.airpods_1_right,
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
caseRes = R.drawable.airpods_1_case,
|
// leftBudsRes = R.drawable.airpods_1_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_1_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_1_case
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
capabilities = emptySet()
|
capabilities = emptySet()
|
||||||
)
|
)
|
||||||
|
|
||||||
class AirPods2: AirPodsBase(
|
class AirPods2: AirPodsBase(
|
||||||
modelNumber = listOf("A2032", "A2031"),
|
modelNumber = listOf("A2032", "A2031"),
|
||||||
name = "AirPods 2",
|
name = "AirPods 2",
|
||||||
budCaseRes = R.drawable.airpods_2,
|
// budCaseRes = R.drawable.airpods_2
|
||||||
budsRes = R.drawable.airpods_2_buds,
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
leftBudsRes = R.drawable.airpods_2_left,
|
// budsRes = R.drawable.airpods_2_buds
|
||||||
rightBudsRes = R.drawable.airpods_2_right,
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
caseRes = R.drawable.airpods_2_case,
|
// leftBudsRes = R.drawable.airpods_2_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_2_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_2_case
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
capabilities = emptySet()
|
capabilities = emptySet()
|
||||||
)
|
)
|
||||||
|
|
||||||
class AirPods3: AirPodsBase(
|
class AirPods3: AirPodsBase(
|
||||||
modelNumber = listOf("A2565", "A2564"),
|
modelNumber = listOf("A2565", "A2564"),
|
||||||
name = "AirPods 3",
|
name = "AirPods 3",
|
||||||
budCaseRes = R.drawable.airpods_3,
|
// budCaseRes = R.drawable.airpods_3
|
||||||
budsRes = R.drawable.airpods_3_buds,
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
leftBudsRes = R.drawable.airpods_3_left,
|
// budsRes = R.drawable.airpods_3_buds
|
||||||
rightBudsRes = R.drawable.airpods_3_right,
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
caseRes = R.drawable.airpods_3_case,
|
// leftBudsRes = R.drawable.airpods_3_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_3_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_3_case
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
capabilities = setOf(
|
capabilities = setOf(
|
||||||
Capability.HEAD_GESTURES
|
Capability.HEAD_GESTURES
|
||||||
)
|
)
|
||||||
@@ -87,11 +100,16 @@ class AirPods3: AirPodsBase(
|
|||||||
class AirPods4: AirPodsBase(
|
class AirPods4: AirPodsBase(
|
||||||
modelNumber = listOf("A3053", "A3050", "A3054"),
|
modelNumber = listOf("A3053", "A3050", "A3054"),
|
||||||
name = "AirPods 4",
|
name = "AirPods 4",
|
||||||
budCaseRes = R.drawable.airpods_4,
|
// budCaseRes = R.drawable.airpods_4
|
||||||
budsRes = R.drawable.airpods_4_buds,
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
leftBudsRes = R.drawable.airpods_4_left,
|
// budsRes = R.drawable.airpods_4_buds
|
||||||
rightBudsRes = R.drawable.airpods_4_right,
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
caseRes = R.drawable.airpods_4_case,
|
// leftBudsRes = R.drawable.airpods_4_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_4_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_4_case
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
capabilities = setOf(
|
capabilities = setOf(
|
||||||
Capability.HEAD_GESTURES,
|
Capability.HEAD_GESTURES,
|
||||||
Capability.SLEEP_DETECTION,
|
Capability.SLEEP_DETECTION,
|
||||||
@@ -102,18 +120,24 @@ class AirPods4: AirPodsBase(
|
|||||||
class AirPods4ANC: AirPodsBase(
|
class AirPods4ANC: AirPodsBase(
|
||||||
modelNumber = listOf("A3056", "A3055", "A3057"),
|
modelNumber = listOf("A3056", "A3055", "A3057"),
|
||||||
name = "AirPods 4 (ANC)",
|
name = "AirPods 4 (ANC)",
|
||||||
budCaseRes = R.drawable.airpods_4,
|
// budCaseRes = R.drawable.airpods_4
|
||||||
budsRes = R.drawable.airpods_4_buds,
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
leftBudsRes = R.drawable.airpods_4_left,
|
// budsRes = R.drawable.airpods_4_buds
|
||||||
rightBudsRes = R.drawable.airpods_4_right,
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
caseRes = R.drawable.airpods_4_case,
|
// leftBudsRes = R.drawable.airpods_4_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_4_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_4_case
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
capabilities = setOf(
|
capabilities = setOf(
|
||||||
Capability.LISTENING_MODE,
|
Capability.LISTENING_MODE,
|
||||||
Capability.CONVERSATION_AWARENESS,
|
Capability.CONVERSATION_AWARENESS,
|
||||||
Capability.HEAD_GESTURES,
|
Capability.HEAD_GESTURES,
|
||||||
Capability.ADAPTIVE_AUDIO,
|
Capability.ADAPTIVE_AUDIO,
|
||||||
Capability.SLEEP_DETECTION,
|
Capability.SLEEP_DETECTION,
|
||||||
Capability.ADAPTIVE_VOLUME
|
Capability.ADAPTIVE_VOLUME,
|
||||||
|
Capability.STEM_CONFIG
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -121,11 +145,16 @@ class AirPodsPro1: AirPodsBase(
|
|||||||
modelNumber = listOf("A2084", "A2083"),
|
modelNumber = listOf("A2084", "A2083"),
|
||||||
name = "AirPods Pro 1",
|
name = "AirPods Pro 1",
|
||||||
displayName = "AirPods Pro",
|
displayName = "AirPods Pro",
|
||||||
budCaseRes = R.drawable.airpods_pro_1,
|
// budCaseRes = R.drawable.airpods_pro_1
|
||||||
budsRes = R.drawable.airpods_pro_1_buds,
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
leftBudsRes = R.drawable.airpods_pro_1_left,
|
// budsRes = R.drawable.airpods_pro_1_buds
|
||||||
rightBudsRes = R.drawable.airpods_pro_1_right,
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
caseRes = R.drawable.airpods_pro_1_case,
|
// leftBudsRes = R.drawable.airpods_pro_1_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_pro_1_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_pro_1_case
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
capabilities = setOf(
|
capabilities = setOf(
|
||||||
Capability.LISTENING_MODE
|
Capability.LISTENING_MODE
|
||||||
)
|
)
|
||||||
@@ -135,10 +164,15 @@ class AirPodsPro2Lightning: AirPodsBase(
|
|||||||
modelNumber = listOf("A2931", "A2699", "A2698"),
|
modelNumber = listOf("A2931", "A2699", "A2698"),
|
||||||
name = "AirPods Pro 2 with Magsafe Charging Case (Lightning)",
|
name = "AirPods Pro 2 with Magsafe Charging Case (Lightning)",
|
||||||
displayName = "AirPods Pro",
|
displayName = "AirPods Pro",
|
||||||
|
// budCaseRes = R.drawable.airpods_pro_2
|
||||||
budCaseRes = R.drawable.airpods_pro_2,
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
|
// budsRes = R.drawable.airpods_pro_2_buds
|
||||||
budsRes = R.drawable.airpods_pro_2_buds,
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
|
// leftBudsRes = R.drawable.airpods_pro_2_left
|
||||||
leftBudsRes = R.drawable.airpods_pro_2_left,
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_pro_2_right
|
||||||
rightBudsRes = R.drawable.airpods_pro_2_right,
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_pro_2_case
|
||||||
caseRes = R.drawable.airpods_pro_2_case,
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
capabilities = setOf(
|
capabilities = setOf(
|
||||||
Capability.LISTENING_MODE,
|
Capability.LISTENING_MODE,
|
||||||
@@ -158,10 +192,15 @@ class AirPodsPro2USBC: AirPodsBase(
|
|||||||
modelNumber = listOf("A3047", "A3048", "A3049"),
|
modelNumber = listOf("A3047", "A3048", "A3049"),
|
||||||
name = "AirPods Pro 2 with Magsafe Charging Case (USB-C)",
|
name = "AirPods Pro 2 with Magsafe Charging Case (USB-C)",
|
||||||
displayName = "AirPods Pro",
|
displayName = "AirPods Pro",
|
||||||
|
// budCaseRes = R.drawable.airpods_pro_2
|
||||||
budCaseRes = R.drawable.airpods_pro_2,
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
|
// budsRes = R.drawable.airpods_pro_2_buds
|
||||||
budsRes = R.drawable.airpods_pro_2_buds,
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
|
// leftBudsRes = R.drawable.airpods_pro_2_left
|
||||||
leftBudsRes = R.drawable.airpods_pro_2_left,
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_pro_2_right
|
||||||
rightBudsRes = R.drawable.airpods_pro_2_right,
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_pro_2_case
|
||||||
caseRes = R.drawable.airpods_pro_2_case,
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
capabilities = setOf(
|
capabilities = setOf(
|
||||||
Capability.LISTENING_MODE,
|
Capability.LISTENING_MODE,
|
||||||
@@ -181,11 +220,16 @@ class AirPodsPro3: AirPodsBase(
|
|||||||
modelNumber = listOf("A3063", "A3064", "A3065"),
|
modelNumber = listOf("A3063", "A3064", "A3065"),
|
||||||
name = "AirPods Pro 3",
|
name = "AirPods Pro 3",
|
||||||
displayName = "AirPods Pro",
|
displayName = "AirPods Pro",
|
||||||
budCaseRes = R.drawable.airpods_pro_3,
|
// budCaseRes = R.drawable.airpods_pro_3
|
||||||
budsRes = R.drawable.airpods_pro_3_buds,
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
leftBudsRes = R.drawable.airpods_pro_3_left,
|
// budsRes = R.drawable.airpods_pro_3_buds
|
||||||
rightBudsRes = R.drawable.airpods_pro_3_right,
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
caseRes = R.drawable.airpods_pro_3_case,
|
// leftBudsRes = R.drawable.airpods_pro_3_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_pro_3_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_pro_3_case
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
capabilities = setOf(
|
capabilities = setOf(
|
||||||
Capability.LISTENING_MODE,
|
Capability.LISTENING_MODE,
|
||||||
Capability.CONVERSATION_AWARENESS,
|
Capability.CONVERSATION_AWARENESS,
|
||||||
@@ -212,8 +256,6 @@ data class AirPodsInstance(
|
|||||||
val version1: String?,
|
val version1: String?,
|
||||||
val version2: String?,
|
val version2: String?,
|
||||||
val version3: String?,
|
val version3: String?,
|
||||||
val aacpManager: AACPManager,
|
|
||||||
val attManager: ATTManager?
|
|
||||||
)
|
)
|
||||||
|
|
||||||
object AirPodsModels {
|
object AirPodsModels {
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.data
|
||||||
|
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
|
||||||
|
|
||||||
|
class ControlCommandRepository(
|
||||||
|
private val aacpManager: AACPManager
|
||||||
|
) {
|
||||||
|
fun getValue(
|
||||||
|
identifier: ControlCommandIdentifiers
|
||||||
|
): ByteArray? {
|
||||||
|
return aacpManager.controlCommandStatusList
|
||||||
|
.find { it.identifier == identifier }
|
||||||
|
?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setValue(
|
||||||
|
id: ControlCommandIdentifiers,
|
||||||
|
value: ByteArray
|
||||||
|
) {
|
||||||
|
aacpManager.sendControlCommand(id.value, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun observe(
|
||||||
|
identifier: ControlCommandIdentifiers,
|
||||||
|
onChange: (ByteArray) -> Unit
|
||||||
|
): AACPManager.ControlCommandListener {
|
||||||
|
|
||||||
|
val listener = object : AACPManager.ControlCommandListener {
|
||||||
|
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||||
|
onChange(controlCommand.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aacpManager.registerControlCommandListener(identifier, listener)
|
||||||
|
return listener
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(
|
||||||
|
identifier: ControlCommandIdentifiers,
|
||||||
|
listener: AACPManager.ControlCommandListener
|
||||||
|
) {
|
||||||
|
aacpManager.unregisterControlCommandListener(identifier, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMap(): Map<ControlCommandIdentifiers, ByteArray> {
|
||||||
|
return aacpManager.controlCommandStatusList.associate {
|
||||||
|
it.identifier to it.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.data
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
@@ -25,6 +25,8 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
|
import me.kavishdevar.librepods.bluetooth.ATTManager
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
+5
-1
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.constants
|
package me.kavishdevar.librepods.data
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -40,6 +40,7 @@ object BatteryStatus {
|
|||||||
const val CHARGING = 1
|
const val CHARGING = 1
|
||||||
const val NOT_CHARGING = 2
|
const val NOT_CHARGING = 2
|
||||||
const val DISCONNECTED = 4
|
const val DISCONNECTED = 4
|
||||||
|
const val OPTIMIZED_CHARGING = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
@@ -58,6 +59,7 @@ data class Battery(val component: Int, val level: Int, val status: Int) : Parcel
|
|||||||
BatteryStatus.CHARGING -> "CHARGING"
|
BatteryStatus.CHARGING -> "CHARGING"
|
||||||
BatteryStatus.NOT_CHARGING -> "NOT_CHARGING"
|
BatteryStatus.NOT_CHARGING -> "NOT_CHARGING"
|
||||||
BatteryStatus.DISCONNECTED -> "DISCONNECTED"
|
BatteryStatus.DISCONNECTED -> "DISCONNECTED"
|
||||||
|
BatteryStatus.OPTIMIZED_CHARGING -> "OPTIMIZED_CHARGING"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,6 +80,8 @@ class AirPodsNotifications {
|
|||||||
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
|
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
|
||||||
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
|
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
|
||||||
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
|
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
|
||||||
|
const val EQ_DATA = "me.kavishdevar.librepods.EQ_DATA"
|
||||||
|
const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED"
|
||||||
}
|
}
|
||||||
|
|
||||||
class EarDetection {
|
class EarDetection {
|
||||||
+2
-2
@@ -16,9 +16,9 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.constants
|
package me.kavishdevar.librepods.data
|
||||||
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
|
||||||
enum class StemAction {
|
enum class StemAction {
|
||||||
PLAY_PAUSE,
|
PLAY_PAUSE,
|
||||||
+4
-3
@@ -16,13 +16,14 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.data
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
@@ -139,7 +140,7 @@ fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings {
|
|||||||
|
|
||||||
private var debounceJob: Job? = null
|
private var debounceJob: Job? = null
|
||||||
|
|
||||||
fun sendTransparencySettings(attManager: ATTManager, transparencySettings: TransparencySettings) {
|
fun sendTransparencySettings(writer: (ATTHandles, ByteArray) -> Unit, transparencySettings: TransparencySettings) {
|
||||||
debounceJob?.cancel()
|
debounceJob?.cancel()
|
||||||
debounceJob = CoroutineScope(Dispatchers.IO).launch {
|
debounceJob = CoroutineScope(Dispatchers.IO).launch {
|
||||||
delay(100)
|
delay(100)
|
||||||
@@ -171,7 +172,7 @@ fun sendTransparencySettings(attManager: ATTManager, transparencySettings: Trans
|
|||||||
}
|
}
|
||||||
|
|
||||||
val data = buffer.array()
|
val data = buffer.array()
|
||||||
attManager.write(ATTHandles.TRANSPARENCY, value = data)
|
writer(ATTHandles.TRANSPARENCY, data)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package me.kavishdevar.librepods.data
|
||||||
|
|
||||||
|
interface XposedRemotePref {
|
||||||
|
fun isAvailable(): Boolean
|
||||||
|
|
||||||
|
fun getBoolean(key: String, def: Boolean): Boolean
|
||||||
|
fun putBoolean(key: String, value: Boolean)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package me.kavishdevar.librepods.data
|
||||||
|
|
||||||
|
object XposedRemotePrefProvider {
|
||||||
|
fun create(): XposedRemotePref = XposedRemotePrefImpl()
|
||||||
|
}
|
||||||
+22
-21
@@ -18,12 +18,12 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -34,35 +34,35 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.NavigationButton
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AboutCard(navController: NavController) {
|
fun AboutCard(
|
||||||
|
navController: NavController,
|
||||||
|
modelName: String,
|
||||||
|
actualModel: String,
|
||||||
|
serialNumbers: List<String>,
|
||||||
|
version: String?
|
||||||
|
) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val service = ServiceManager.getService()
|
|
||||||
if (service == null) return
|
|
||||||
val airpodsInstance = service.airpodsInstance
|
|
||||||
if (airpodsInstance == null) return
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
@@ -75,7 +75,8 @@ fun AboutCard(navController: NavController) {
|
|||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = textColor.copy(alpha = 0.6f)
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -108,7 +109,7 @@ fun AboutCard(navController: NavController) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = airpodsInstance.model.displayName,
|
text = modelName,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||||
@@ -137,7 +138,7 @@ fun AboutCard(navController: NavController) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = airpodsInstance.actualModelNumber,
|
text = actualModel,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||||
@@ -152,11 +153,11 @@ fun AboutCard(navController: NavController) {
|
|||||||
.padding(horizontal = 12.dp)
|
.padding(horizontal = 12.dp)
|
||||||
)
|
)
|
||||||
val serialNumbers = listOf(
|
val serialNumbers = listOf(
|
||||||
airpodsInstance.serialNumber?: "",
|
serialNumbers[0],
|
||||||
" ${airpodsInstance.leftSerialNumber}",
|
" ${serialNumbers[1]}",
|
||||||
" ${airpodsInstance.rightSerialNumber}"
|
" ${serialNumbers[2]}"
|
||||||
)
|
)
|
||||||
val serialNumber = remember { mutableStateOf(0) }
|
val serialNumber = remember { mutableIntStateOf(0) }
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -172,7 +173,7 @@ fun AboutCard(navController: NavController) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = serialNumbers[serialNumber.value],
|
text = serialNumbers[serialNumber.intValue],
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||||
@@ -183,7 +184,7 @@ fun AboutCard(navController: NavController) {
|
|||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null
|
indication = null
|
||||||
) {
|
) {
|
||||||
serialNumber.value = (serialNumber.value + 1) % serialNumbers.size
|
serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -197,7 +198,7 @@ fun AboutCard(navController: NavController) {
|
|||||||
to = "version_info",
|
to = "version_info",
|
||||||
navController = navController,
|
navController = navController,
|
||||||
name = stringResource(R.string.version),
|
name = stringResource(R.string.version),
|
||||||
currentState = airpodsInstance.version3,
|
currentState = version,
|
||||||
independent = false,
|
independent = false,
|
||||||
height = rowHeight.value + 32.dp
|
height = rowHeight.value + 32.dp
|
||||||
)
|
)
|
||||||
+57
-27
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -35,6 +35,8 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -42,25 +44,32 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import me.kavishdevar.librepods.utils.ATTHandles
|
|
||||||
import me.kavishdevar.librepods.utils.Capability
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioSettings(navController: NavController) {
|
fun AudioSettings(
|
||||||
|
navController: NavController,
|
||||||
|
adaptiveVolumeCapability: Boolean,
|
||||||
|
conversationalAwarenessCapability: Boolean,
|
||||||
|
loudSoundReductionCapability: Boolean,
|
||||||
|
adaptiveAudioCapability: Boolean,
|
||||||
|
|
||||||
|
adaptiveVolumeChecked: Boolean,
|
||||||
|
onAdaptiveVolumeCheckedChange: (Boolean) -> Unit,
|
||||||
|
|
||||||
|
conversationalAwarenessChecked: Boolean,
|
||||||
|
onConversationalAwarenessCheckedChange: (Boolean) -> Unit,
|
||||||
|
|
||||||
|
loudSoundReductionChecked: Boolean,
|
||||||
|
onLoudSoundReductionCheckedChange: (Boolean) -> Unit,
|
||||||
|
|
||||||
|
vendorIdHook: Boolean,
|
||||||
|
isPremium: Boolean
|
||||||
|
) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val service = ServiceManager.getService()
|
|
||||||
if (service == null) return
|
if (!adaptiveVolumeCapability && !conversationalAwarenessCapability && !loudSoundReductionCapability && !adaptiveAudioCapability) {
|
||||||
val airpodsInstance = service.airpodsInstance
|
|
||||||
if (airpodsInstance == null) return
|
|
||||||
if (!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME) &&
|
|
||||||
!airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS) &&
|
|
||||||
!airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) &&
|
|
||||||
!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
@@ -73,7 +82,8 @@ fun AudioSettings(navController: NavController) {
|
|||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = textColor.copy(alpha = 0.6f)
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -88,12 +98,14 @@ fun AudioSettings(navController: NavController) {
|
|||||||
.padding(top = 2.dp)
|
.padding(top = 2.dp)
|
||||||
) {
|
) {
|
||||||
|
|
||||||
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME)) {
|
if (adaptiveVolumeCapability) {
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.personalized_volume),
|
label = stringResource(R.string.personalized_volume),
|
||||||
description = stringResource(R.string.personalized_volume_description),
|
description = stringResource(R.string.personalized_volume_description),
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
|
independent = false,
|
||||||
independent = false
|
checked = adaptiveVolumeChecked,
|
||||||
|
onCheckedChange = onAdaptiveVolumeCheckedChange,
|
||||||
|
enabled = isPremium
|
||||||
)
|
)
|
||||||
|
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
@@ -104,12 +116,14 @@ fun AudioSettings(navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS)) {
|
if (conversationalAwarenessCapability) {
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.conversational_awareness),
|
label = stringResource(R.string.conversational_awareness),
|
||||||
description = stringResource(R.string.conversational_awareness_description),
|
description = stringResource(R.string.conversational_awareness_description),
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
independent = false,
|
||||||
independent = false
|
checked = conversationalAwarenessChecked,
|
||||||
|
onCheckedChange = onConversationalAwarenessCheckedChange,
|
||||||
|
enabled = isPremium
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
thickness = 1.dp,
|
thickness = 1.dp,
|
||||||
@@ -119,12 +133,14 @@ fun AudioSettings(navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){
|
if (loudSoundReductionCapability && vendorIdHook){
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.loud_sound_reduction),
|
label = stringResource(R.string.loud_sound_reduction),
|
||||||
description = stringResource(R.string.loud_sound_reduction_description),
|
description = stringResource(R.string.loud_sound_reduction_description),
|
||||||
attHandle = ATTHandles.LOUD_SOUND_REDUCTION,
|
independent = false,
|
||||||
independent = false
|
checked = loudSoundReductionChecked,
|
||||||
|
onCheckedChange = onLoudSoundReductionCheckedChange,
|
||||||
|
enabled = isPremium
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
thickness = 1.dp,
|
thickness = 1.dp,
|
||||||
@@ -134,7 +150,7 @@ fun AudioSettings(navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)) {
|
if (adaptiveAudioCapability) {
|
||||||
NavigationButton(
|
NavigationButton(
|
||||||
to = "adaptive_strength",
|
to = "adaptive_strength",
|
||||||
name = stringResource(R.string.adaptive_audio),
|
name = stringResource(R.string.adaptive_audio),
|
||||||
@@ -148,5 +164,19 @@ fun AudioSettings(navController: NavController) {
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioSettingsPreview() {
|
fun AudioSettingsPreview() {
|
||||||
AudioSettings(rememberNavController())
|
AudioSettings(
|
||||||
|
navController = rememberNavController(),
|
||||||
|
adaptiveVolumeCapability = true,
|
||||||
|
conversationalAwarenessCapability = true,
|
||||||
|
loudSoundReductionCapability = true,
|
||||||
|
adaptiveAudioCapability = true,
|
||||||
|
adaptiveVolumeChecked = true,
|
||||||
|
onAdaptiveVolumeCheckedChange = { },
|
||||||
|
conversationalAwarenessChecked = true,
|
||||||
|
onConversationalAwarenessCheckedChange = { },
|
||||||
|
loudSoundReductionChecked = true,
|
||||||
|
onLoudSoundReductionCheckedChange = { },
|
||||||
|
vendorIdHook = true,
|
||||||
|
isPremium = true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.data.BatteryStatus
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BatteryIndicator(
|
||||||
|
batteryPercentage: Int,
|
||||||
|
status: Int,
|
||||||
|
prefix: String = "",
|
||||||
|
previousCharging: Boolean = false,
|
||||||
|
) {
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val backgroundColor = if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)
|
||||||
|
val batteryTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val batteryFillColor =
|
||||||
|
if (batteryPercentage > 25) if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759)
|
||||||
|
else if (isDarkTheme) Color(0xFFFC4244) else Color(0xFFfe373C)
|
||||||
|
|
||||||
|
val initialScale = if (previousCharging) 1f else 0f
|
||||||
|
val scaleAnim = remember { Animatable(initialScale) }
|
||||||
|
val charging = status == BatteryStatus.CHARGING || status == BatteryStatus.OPTIMIZED_CHARGING
|
||||||
|
val targetScale = if (charging) 1f else 0f
|
||||||
|
|
||||||
|
LaunchedEffect(previousCharging, charging) {
|
||||||
|
scaleAnim.animateTo(targetScale, animationSpec = tween(durationMillis = 250))
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.background(backgroundColor).padding(4.dp), // just for haze to work
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.padding(bottom = 4.dp), contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
val strokeWidthPx = with(LocalDensity.current) { 4.dp.toPx() }
|
||||||
|
val gapFromCenterPx = with(LocalDensity.current) { 8.sp.toPx() }
|
||||||
|
|
||||||
|
val trackColor = if (isDarkTheme) Color(0xFF272728) else Color(0xFFE3E3E8)
|
||||||
|
val optimizedLimit = 0.8f
|
||||||
|
val progress = batteryPercentage / 100f
|
||||||
|
|
||||||
|
Canvas(modifier = Modifier.size(34.dp)) {
|
||||||
|
val startAngle = -90f
|
||||||
|
val stroke = Stroke(width = strokeWidthPx, cap = StrokeCap.Round)
|
||||||
|
val inset = strokeWidthPx / 2
|
||||||
|
Rect(
|
||||||
|
left = inset,
|
||||||
|
top = inset,
|
||||||
|
right = size.width - inset,
|
||||||
|
bottom = size.height - inset
|
||||||
|
)
|
||||||
|
val radius = size.minDimension / 2
|
||||||
|
|
||||||
|
if (status == BatteryStatus.OPTIMIZED_CHARGING) {
|
||||||
|
drawArc(
|
||||||
|
color = trackColor,
|
||||||
|
startAngle = startAngle,
|
||||||
|
sweepAngle = 360f * optimizedLimit,
|
||||||
|
useCenter = false,
|
||||||
|
style = stroke
|
||||||
|
)
|
||||||
|
|
||||||
|
val sweep = 360f * min(progress, optimizedLimit)
|
||||||
|
drawArc(
|
||||||
|
color = batteryFillColor,
|
||||||
|
startAngle = startAngle,
|
||||||
|
sweepAngle = sweep,
|
||||||
|
useCenter = false,
|
||||||
|
style = stroke
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- PILL MARKER AT 80% ----
|
||||||
|
val angleDeg = startAngle + 360f * optimizedLimit
|
||||||
|
val angleRad = Math.toRadians(angleDeg.toDouble())
|
||||||
|
|
||||||
|
val arcRadius = radius - strokeWidthPx
|
||||||
|
|
||||||
|
val outerX = center.x + arcRadius * cos(angleRad).toFloat()
|
||||||
|
val outerY = center.y + arcRadius * sin(angleRad).toFloat()
|
||||||
|
|
||||||
|
val dirX = center.x - outerX
|
||||||
|
val dirY = center.y - outerY
|
||||||
|
val length = sqrt(dirX * dirX + dirY * dirY)
|
||||||
|
|
||||||
|
val normX = dirX / length
|
||||||
|
val normY = dirY / length
|
||||||
|
|
||||||
|
val startX = outerX - normX * strokeWidthPx / 2
|
||||||
|
val startY = outerY - normY * strokeWidthPx / 2
|
||||||
|
|
||||||
|
val endX = center.x - normX * gapFromCenterPx
|
||||||
|
val endY = center.y - normY * gapFromCenterPx
|
||||||
|
|
||||||
|
drawLine(
|
||||||
|
color = if (batteryPercentage >= 80) batteryFillColor else trackColor,
|
||||||
|
start = Offset(startX, startY),
|
||||||
|
end = Offset(endX, endY),
|
||||||
|
strokeWidth = strokeWidthPx,
|
||||||
|
cap = StrokeCap.Round
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
drawArc(
|
||||||
|
color = trackColor,
|
||||||
|
startAngle = 0f,
|
||||||
|
sweepAngle = 360f,
|
||||||
|
useCenter = false,
|
||||||
|
style = stroke
|
||||||
|
)
|
||||||
|
|
||||||
|
drawArc(
|
||||||
|
color = batteryFillColor,
|
||||||
|
startAngle = startAngle,
|
||||||
|
sweepAngle = 360f * progress,
|
||||||
|
useCenter = false,
|
||||||
|
style = stroke
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "\uDBC0\uDEE6", style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = batteryFillColor,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
), modifier = Modifier.scale(scaleAnim.value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "$prefix $batteryPercentage%",
|
||||||
|
color = batteryTextColor,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||||
|
@Composable
|
||||||
|
fun BatteryIndicatorPreview() {
|
||||||
|
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.background(bg)
|
||||||
|
) {
|
||||||
|
BatteryIndicator(
|
||||||
|
batteryPercentage = 50,
|
||||||
|
status = BatteryStatus.OPTIMIZED_CHARGING,
|
||||||
|
prefix = "\uDBC6\uDCE5",
|
||||||
|
previousCharging = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.res.imageResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.data.Battery
|
||||||
|
import me.kavishdevar.librepods.data.BatteryComponent
|
||||||
|
import me.kavishdevar.librepods.data.BatteryStatus
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BatteryView(
|
||||||
|
batteryList: List<Battery>,
|
||||||
|
budsRes: Int,
|
||||||
|
caseRes: Int
|
||||||
|
) {
|
||||||
|
val left = batteryList.find { it.component == BatteryComponent.LEFT }
|
||||||
|
val right = batteryList.find { it.component == BatteryComponent.RIGHT }
|
||||||
|
val case = batteryList.find { it.component == BatteryComponent.CASE }
|
||||||
|
|
||||||
|
val leftLevel = left?.level ?: 0
|
||||||
|
val rightLevel = right?.level ?: 0
|
||||||
|
val caseLevel = case?.level ?: 0
|
||||||
|
|
||||||
|
val caseCharging = case?.status == BatteryStatus.CHARGING ||
|
||||||
|
case?.status == BatteryStatus.OPTIMIZED_CHARGING
|
||||||
|
|
||||||
|
val singleDisplayed = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.widthIn(max = 500.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = ImageBitmap.imageResource(budsRes),
|
||||||
|
contentDescription = stringResource(R.string.buds),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
left?.status == right?.status &&
|
||||||
|
(leftLevel - rightLevel) in -3..3
|
||||||
|
) {
|
||||||
|
BatteryIndicator(
|
||||||
|
leftLevel.coerceAtMost(rightLevel),
|
||||||
|
left?.status ?: BatteryStatus.NOT_CHARGING
|
||||||
|
)
|
||||||
|
singleDisplayed.value = true
|
||||||
|
} else {
|
||||||
|
singleDisplayed.value = false
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
|
||||||
|
BatteryIndicator(
|
||||||
|
leftLevel,
|
||||||
|
left?.status ?: BatteryStatus.NOT_CHARGING,
|
||||||
|
"\uDBC6\uDCE5"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leftLevel > 0 && rightLevel > 0) {
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED) {
|
||||||
|
BatteryIndicator(
|
||||||
|
rightLevel,
|
||||||
|
right?.status ?: BatteryStatus.NOT_CHARGING,
|
||||||
|
"\uDBC6\uDCE8"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = ImageBitmap.imageResource(caseRes),
|
||||||
|
contentDescription = stringResource(R.string.case_alt),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
|
||||||
|
BatteryIndicator(
|
||||||
|
caseLevel,
|
||||||
|
case?.status ?: BatteryStatus.NOT_CHARGING,
|
||||||
|
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||||
|
@Composable
|
||||||
|
fun BatteryViewPreview() {
|
||||||
|
val fakeBattery = listOf(
|
||||||
|
Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING),
|
||||||
|
Battery(BatteryComponent.RIGHT, 40, BatteryStatus.OPTIMIZED_CHARGING),
|
||||||
|
Battery(BatteryComponent.CASE, 60, BatteryStatus.NOT_CHARGING)
|
||||||
|
)
|
||||||
|
|
||||||
|
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(bg)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
BatteryView(
|
||||||
|
batteryList = fakeBattery,
|
||||||
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-77
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -36,39 +36,43 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.positionInParent
|
import androidx.compose.ui.layout.positionInParent
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@Composable
|
@Composable
|
||||||
fun CallControlSettings(hazeState: HazeState) {
|
fun CallControlSettings(
|
||||||
|
hazeState: HazeState,
|
||||||
|
flipped: Boolean,
|
||||||
|
onCallControlValueChanged: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
@@ -82,7 +86,8 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = textColor.copy(alpha = 0.6f)
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -93,24 +98,13 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||||
.padding(top = 2.dp)
|
.padding(top = 2.dp)
|
||||||
) {
|
) {
|
||||||
val service = ServiceManager.getService()!!
|
|
||||||
val callControlEnabledValue = service.aacpManager.controlCommandStatusList.find {
|
val scope = rememberCoroutineScope()
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
|
val haptics = LocalHapticFeedback.current
|
||||||
}?.value ?: byteArrayOf(0x00, 0x03)
|
|
||||||
|
|
||||||
val pressOnceText = stringResource(R.string.press_once)
|
val pressOnceText = stringResource(R.string.press_once)
|
||||||
val pressTwiceText = stringResource(R.string.press_twice)
|
val pressTwiceText = stringResource(R.string.press_twice)
|
||||||
|
|
||||||
var flipped by remember {
|
|
||||||
mutableStateOf(
|
|
||||||
callControlEnabledValue.contentEquals(
|
|
||||||
byteArrayOf(
|
|
||||||
0x00,
|
|
||||||
0x02
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) }
|
var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) }
|
||||||
var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) }
|
var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) }
|
||||||
|
|
||||||
@@ -120,6 +114,7 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
var lastDismissTimeSingle by remember { mutableLongStateOf(0L) }
|
var lastDismissTimeSingle by remember { mutableLongStateOf(0L) }
|
||||||
var parentHoveredIndexSingle by remember { mutableStateOf<Int?>(null) }
|
var parentHoveredIndexSingle by remember { mutableStateOf<Int?>(null) }
|
||||||
var parentDragActiveSingle by remember { mutableStateOf(false) }
|
var parentDragActiveSingle by remember { mutableStateOf(false) }
|
||||||
|
var previousIdxSingle by remember { mutableStateOf<Int?>(null) }
|
||||||
|
|
||||||
var showDoublePressDropdown by remember { mutableStateOf(false) }
|
var showDoublePressDropdown by remember { mutableStateOf(false) }
|
||||||
var touchOffsetDouble by remember { mutableStateOf<Offset?>(null) }
|
var touchOffsetDouble by remember { mutableStateOf<Offset?>(null) }
|
||||||
@@ -127,36 +122,8 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
var lastDismissTimeDouble by remember { mutableLongStateOf(0L) }
|
var lastDismissTimeDouble by remember { mutableLongStateOf(0L) }
|
||||||
var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) }
|
var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) }
|
||||||
var parentDragActiveDouble by remember { mutableStateOf(false) }
|
var parentDragActiveDouble by remember { mutableStateOf(false) }
|
||||||
|
var previousIdxDouble by remember { mutableStateOf<Int?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
val listener = object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
|
|
||||||
) {
|
|
||||||
val newFlipped = controlCommand.value.contentEquals(byteArrayOf(0x00, 0x02))
|
|
||||||
flipped = newFlipped
|
|
||||||
singlePressAction = if (newFlipped) pressTwiceText else pressOnceText
|
|
||||||
doublePressAction = if (newFlipped) pressOnceText else pressTwiceText
|
|
||||||
Log.d(
|
|
||||||
"CallControlSettings",
|
|
||||||
"Control command received, flipped: $newFlipped"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
service.aacpManager.registerControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
|
|
||||||
listener
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
service.aacpManager.controlCommandListeners[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(flipped) {
|
LaunchedEffect(flipped) {
|
||||||
Log.d("CallControlSettings", "Call control flipped: $flipped")
|
Log.d("CallControlSettings", "Call control flipped: $flipped")
|
||||||
}
|
}
|
||||||
@@ -231,7 +198,11 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
val touch = touchOffsetSingle ?: current
|
val touch = touchOffsetSingle ?: current
|
||||||
val posInPopupY = current.y - touch.y
|
val posInPopupY = current.y - touch.y
|
||||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||||
|
if (idx != previousIdxSingle) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
|
||||||
|
}
|
||||||
parentHoveredIndexSingle = idx
|
parentHoveredIndexSingle = idx
|
||||||
|
previousIdxSingle = idx
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
parentDragActiveSingle = false
|
parentDragActiveSingle = false
|
||||||
@@ -244,13 +215,13 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||||
showSinglePressDropdown = false
|
showSinglePressDropdown = false
|
||||||
lastDismissTimeSingle = System.currentTimeMillis()
|
lastDismissTimeSingle = System.currentTimeMillis()
|
||||||
val bytes = if (option == pressOnceText) byteArrayOf(
|
onCallControlValueChanged(option != pressOnceText)
|
||||||
0x00,
|
|
||||||
0x03
|
|
||||||
) else byteArrayOf(0x00, 0x02)
|
|
||||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (parentHoveredIndexSingle != null && parentHoveredIndexSingle in 0..1) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
|
||||||
|
}
|
||||||
parentHoveredIndexSingle = null
|
parentHoveredIndexSingle = null
|
||||||
},
|
},
|
||||||
onDragCancel = {
|
onDragCancel = {
|
||||||
@@ -313,11 +284,8 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
doublePressAction =
|
doublePressAction =
|
||||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||||
showSinglePressDropdown = false
|
showSinglePressDropdown = false
|
||||||
val bytes = if (option == pressOnceText) byteArrayOf(
|
val flipped = option != pressOnceText
|
||||||
0x00,
|
onCallControlValueChanged(flipped)
|
||||||
0x03
|
|
||||||
) else byteArrayOf(0x00, 0x02)
|
|
||||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
|
||||||
},
|
},
|
||||||
hazeState = hazeState
|
hazeState = hazeState
|
||||||
)
|
)
|
||||||
@@ -366,7 +334,11 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
val touch = touchOffsetDouble ?: current
|
val touch = touchOffsetDouble ?: current
|
||||||
val posInPopupY = current.y - touch.y
|
val posInPopupY = current.y - touch.y
|
||||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||||
|
if (idx != previousIdxDouble) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
|
||||||
|
}
|
||||||
parentHoveredIndexDouble = idx
|
parentHoveredIndexDouble = idx
|
||||||
|
previousIdxDouble = idx
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
parentDragActiveDouble = false
|
parentDragActiveDouble = false
|
||||||
@@ -379,13 +351,13 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||||
showDoublePressDropdown = false
|
showDoublePressDropdown = false
|
||||||
lastDismissTimeDouble = System.currentTimeMillis()
|
lastDismissTimeDouble = System.currentTimeMillis()
|
||||||
val bytes = if (option == pressOnceText) byteArrayOf(
|
val flipped = option == pressOnceText
|
||||||
0x00,
|
onCallControlValueChanged(flipped)
|
||||||
0x02
|
|
||||||
) else byteArrayOf(0x00, 0x03)
|
|
||||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (parentHoveredIndexDouble != null && parentHoveredIndexDouble in 0..1) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
|
||||||
|
}
|
||||||
parentHoveredIndexDouble = null
|
parentHoveredIndexDouble = null
|
||||||
},
|
},
|
||||||
onDragCancel = {
|
onDragCancel = {
|
||||||
@@ -448,11 +420,8 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
singlePressAction =
|
singlePressAction =
|
||||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||||
showDoublePressDropdown = false
|
showDoublePressDropdown = false
|
||||||
val bytes = if (option == pressOnceText) byteArrayOf(
|
val flipped = option == pressOnceText
|
||||||
0x00,
|
onCallControlValueChanged(flipped)
|
||||||
0x02
|
|
||||||
) else byteArrayOf(0x00, 0x03)
|
|
||||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
|
||||||
},
|
},
|
||||||
hazeState = hazeState
|
hazeState = hazeState
|
||||||
)
|
)
|
||||||
@@ -461,10 +430,3 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun CallControlSettingsPreview() {
|
|
||||||
CallControlSettings(HazeState())
|
|
||||||
}
|
|
||||||
+27
-5
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -24,6 +24,7 @@ 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
|
||||||
@@ -38,13 +39,16 @@ 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
|
||||||
@@ -53,10 +57,12 @@ 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
|
||||||
@@ -74,8 +80,18 @@ 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(onDismissRequest = { showDialog.value = false }) {
|
Dialog(
|
||||||
|
onDismissRequest = { showDialog.value = false },
|
||||||
|
properties = DialogProperties(
|
||||||
|
dismissOnBackPress = false,
|
||||||
|
dismissOnClickOutside = false
|
||||||
|
)
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
// .fillMaxWidth(0.75f)
|
// .fillMaxWidth(0.75f)
|
||||||
@@ -90,7 +106,7 @@ fun ConfirmationDialog(
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
@@ -102,7 +118,7 @@ fun ConfirmationDialog(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp)
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
)
|
)
|
||||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
message,
|
message,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
@@ -113,7 +129,7 @@ fun ConfirmationDialog(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp)
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
)
|
)
|
||||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
thickness = 1.dp,
|
thickness = 1.dp,
|
||||||
color = Color(0x40888888),
|
color = Color(0x40888888),
|
||||||
@@ -148,6 +164,8 @@ 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 {
|
||||||
@@ -158,8 +176,12 @@ 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+13
-20
@@ -18,9 +18,8 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import android.content.Context.MODE_PRIVATE
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -31,16 +30,18 @@ import androidx.compose.material3.HorizontalDivider
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ConnectionSettings() {
|
fun ConnectionSettings(
|
||||||
|
automaticEarDetectionEnabled: Boolean,
|
||||||
|
onAutomaticEarDetectionChanged: (Boolean) -> Unit,
|
||||||
|
automaticConnectionEnabled: Boolean,
|
||||||
|
onAutomaticConnectionChanged: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
@@ -52,10 +53,9 @@ fun ConnectionSettings() {
|
|||||||
) {
|
) {
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.ear_detection),
|
label = stringResource(R.string.ear_detection),
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
|
independent = false,
|
||||||
sharedPreferenceKey = "automatic_ear_detection",
|
checked = automaticEarDetectionEnabled,
|
||||||
sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
|
onCheckedChange = onAutomaticEarDetectionChanged
|
||||||
independent = false
|
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
thickness = 1.dp,
|
thickness = 1.dp,
|
||||||
@@ -67,16 +67,9 @@ fun ConnectionSettings() {
|
|||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.automatically_connect),
|
label = stringResource(R.string.automatically_connect),
|
||||||
description = stringResource(R.string.automatically_connect_description),
|
description = stringResource(R.string.automatically_connect_description),
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
|
independent = false,
|
||||||
sharedPreferenceKey = "automatic_connection_ctrl_cmd",
|
checked = automaticConnectionEnabled,
|
||||||
sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
|
onCheckedChange = onAutomaticConnectionChanged
|
||||||
independent = false
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun ConnectionSettingsPreview() {
|
|
||||||
ConnectionSettings()
|
|
||||||
}
|
|
||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
@file:Suppress("unused")
|
@file:Suppress("unused")
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
+2
-2
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
@@ -56,7 +56,7 @@ import androidx.compose.ui.unit.Dp
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
import me.kavishdevar.librepods.data.NoiseControlMode
|
||||||
|
|
||||||
private val ContainerColor = Color(0x593C3C3E)
|
private val ContainerColor = Color(0x593C3C3E)
|
||||||
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)
|
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HearingHealthSettings(
|
||||||
|
navController: NavController,
|
||||||
|
hasPPECapability: Boolean,
|
||||||
|
hasHearingAidCapability: Boolean,
|
||||||
|
vendorIdHook: Boolean
|
||||||
|
) {
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
val shouldShowHearingAid = hasHearingAidCapability && vendorIdHook
|
||||||
|
|
||||||
|
if (hasPPECapability && shouldShowHearingAid) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
){
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.hearing_health),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(28.dp))
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||||
|
.padding(top = 2.dp)
|
||||||
|
) {
|
||||||
|
NavigationButton(
|
||||||
|
to = "hearing_protection",
|
||||||
|
name = stringResource(R.string.hearing_protection),
|
||||||
|
navController = navController,
|
||||||
|
independent = false
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
NavigationButton(
|
||||||
|
to = "hearing_aid",
|
||||||
|
name = stringResource(R.string.hearing_aid),
|
||||||
|
navController = navController,
|
||||||
|
independent = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (shouldShowHearingAid) {
|
||||||
|
NavigationButton(
|
||||||
|
to = "hearing_aid",
|
||||||
|
name = stringResource(R.string.hearing_aid),
|
||||||
|
navController = navController
|
||||||
|
)
|
||||||
|
} else if (hasPPECapability) {
|
||||||
|
NavigationButton(
|
||||||
|
to = "hearing_protection",
|
||||||
|
name = stringResource(R.string.hearing_protection),
|
||||||
|
title = stringResource(R.string.hearing_health),
|
||||||
|
navController = navController
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-60
@@ -18,9 +18,8 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
@@ -35,38 +34,41 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.positionInParent
|
import androidx.compose.ui.layout.positionInParent
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@Composable
|
@Composable
|
||||||
fun MicrophoneSettings(hazeState: HazeState) {
|
fun MicrophoneSettings(
|
||||||
|
hazeState: HazeState,
|
||||||
|
micModeValue: Byte,
|
||||||
|
onMicModeValueChanged: (Byte) -> Unit
|
||||||
|
) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
@@ -77,11 +79,6 @@ fun MicrophoneSettings(hazeState: HazeState) {
|
|||||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||||
.padding(top = 2.dp)
|
.padding(top = 2.dp)
|
||||||
) {
|
) {
|
||||||
val service = ServiceManager.getService()!!
|
|
||||||
val micModeValue = service.aacpManager.controlCommandStatusList.find {
|
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
|
|
||||||
}?.value?.get(0) ?: 0x00.toByte()
|
|
||||||
|
|
||||||
var selectedMode by remember {
|
var selectedMode by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
when (micModeValue) {
|
when (micModeValue) {
|
||||||
@@ -98,42 +95,13 @@ fun MicrophoneSettings(hazeState: HazeState) {
|
|||||||
var lastDismissTime by remember { mutableLongStateOf(0L) }
|
var lastDismissTime by remember { mutableLongStateOf(0L) }
|
||||||
val reopenThresholdMs = 250L
|
val reopenThresholdMs = 250L
|
||||||
|
|
||||||
val listener = object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
|
|
||||||
) {
|
|
||||||
selectedMode = when (controlCommand.value[0]) {
|
|
||||||
0x00.toByte() -> "Automatic"
|
|
||||||
0x01.toByte() -> "Always Right"
|
|
||||||
0x02.toByte() -> "Always Left"
|
|
||||||
else -> "Automatic"
|
|
||||||
}
|
|
||||||
Log.d("MicrophoneSettings", "Microphone mode received: $selectedMode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
service.aacpManager.registerControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
|
|
||||||
listener
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
service.aacpManager.unregisterControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
|
|
||||||
listener
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val itemHeightPx = with(density) { 48.dp.toPx() }
|
val itemHeightPx = with(density) { 48.dp.toPx() }
|
||||||
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
|
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
var parentDragActive by remember { mutableStateOf(false) }
|
var parentDragActive by remember { mutableStateOf(false) }
|
||||||
|
var previousIdx by remember { mutableStateOf<Int?>(null) }
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
|
val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
|
||||||
val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
|
val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
|
||||||
val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
|
val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
|
||||||
@@ -173,7 +141,11 @@ fun MicrophoneSettings(hazeState: HazeState) {
|
|||||||
val touch = touchOffset ?: current
|
val touch = touchOffset ?: current
|
||||||
val posInPopupY = current.y - touch.y
|
val posInPopupY = current.y - touch.y
|
||||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||||
|
if (idx != previousIdx) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
|
||||||
|
}
|
||||||
parentHoveredIndex = idx
|
parentHoveredIndex = idx
|
||||||
|
previousIdx = idx
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
parentDragActive = false
|
parentDragActive = false
|
||||||
@@ -194,12 +166,16 @@ fun MicrophoneSettings(hazeState: HazeState) {
|
|||||||
options[2] -> 0x02
|
options[2] -> 0x02
|
||||||
else -> 0x00
|
else -> 0x00
|
||||||
}
|
}
|
||||||
service.aacpManager.sendControlCommand(
|
// service.aacpManager.sendControlCommand(
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
|
// AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
|
||||||
byteArrayOf(byteValue.toByte())
|
// byteArrayOf(byteValue.toByte())
|
||||||
)
|
// )
|
||||||
|
onMicModeValueChanged(byteValue.toByte())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (parentHoveredIndex != null && parentHoveredIndex in 0..2) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
|
||||||
|
}
|
||||||
parentHoveredIndex = null
|
parentHoveredIndex = null
|
||||||
},
|
},
|
||||||
onDragCancel = {
|
onDragCancel = {
|
||||||
@@ -277,10 +253,7 @@ fun MicrophoneSettings(hazeState: HazeState) {
|
|||||||
microphoneAlwaysLeftText -> 0x02
|
microphoneAlwaysLeftText -> 0x02
|
||||||
else -> 0x00
|
else -> 0x00
|
||||||
}
|
}
|
||||||
service.aacpManager.sendControlCommand(
|
onMicModeValueChanged(byteValue.toByte())
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
|
|
||||||
byteArrayOf(byteValue.toByte())
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
hazeState = hazeState
|
hazeState = hazeState
|
||||||
)
|
)
|
||||||
@@ -288,10 +261,3 @@ fun MicrophoneSettings(hazeState: HazeState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun MicrophoneSettingsPreview() {
|
|
||||||
MicrophoneSettings(HazeState())
|
|
||||||
}
|
|
||||||
+26
-7
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
@@ -35,21 +35,25 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -62,10 +66,14 @@ fun NavigationButton(
|
|||||||
description: String? = null,
|
description: String? = null,
|
||||||
currentState: String? = null,
|
currentState: String? = null,
|
||||||
height: Dp = 58.dp,
|
height: Dp = 58.dp,
|
||||||
|
enabled: Boolean = true
|
||||||
) {
|
) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
if (title != null) {
|
if (title != null) {
|
||||||
Box(
|
Box(
|
||||||
@@ -79,23 +87,34 @@ fun NavigationButton(
|
|||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(animatedBackgroundColor, RoundedCornerShape(if (independent) 28.dp else 0.dp))
|
.background(
|
||||||
|
animatedBackgroundColor,
|
||||||
|
RoundedCornerShape(if (independent) 28.dp else 0.dp)
|
||||||
|
)
|
||||||
.height(height)
|
.height(height)
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
detectTapGestures(
|
detectTapGestures(
|
||||||
onPress = {
|
onPress = {
|
||||||
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
if (enabled) {
|
||||||
tryAwaitRelease()
|
backgroundColor =
|
||||||
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||||
|
tryAwaitRelease()
|
||||||
|
backgroundColor =
|
||||||
|
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onTap = {
|
onTap = {
|
||||||
if (onClick != null) onClick() else navController.navigate(to)
|
if (enabled) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
|
||||||
|
if (onClick != null) onClick() else navController.navigate(to)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
+35
-82
@@ -18,14 +18,9 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.animation.core.AnimationSpec
|
import androidx.compose.animation.core.AnimationSpec
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.SpringSpec
|
import androidx.compose.animation.core.SpringSpec
|
||||||
@@ -60,64 +55,49 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.imageResource
|
import androidx.compose.ui.res.imageResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
import me.kavishdevar.librepods.data.NoiseControlMode
|
||||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
|
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
|
||||||
@Composable
|
@Composable
|
||||||
fun NoiseControlSettings(
|
fun NoiseControlSettings(
|
||||||
service: AirPodsService,
|
showOffListeningMode: Boolean,
|
||||||
|
noiseControlModeValue: Int,
|
||||||
|
onNoiseControlModeChanged: (Int) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
val offListeningModeConfigValue = service.aacpManager.controlCommandStatusList.find {
|
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
|
||||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
|
|
||||||
val offListeningMode = remember { mutableStateOf(offListeningModeConfigValue) }
|
|
||||||
|
|
||||||
val offListeningModeListener = object: AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
offListeningMode.value = controlCommand.value[0] == 1.toByte()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
service.aacpManager.registerControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
|
|
||||||
offListeningModeListener
|
|
||||||
)
|
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val textColorSelected = if (isDarkTheme) Color.White else Color.Black
|
val textColorSelected = if (isDarkTheme) Color.White else Color.Black
|
||||||
val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF)
|
val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
|
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
|
||||||
|
|
||||||
val d1a = remember { mutableFloatStateOf(0f) }
|
val d1a = remember { mutableFloatStateOf(0f) }
|
||||||
val d2a = remember { mutableFloatStateOf(0f) }
|
val d2a = remember { mutableFloatStateOf(0f) }
|
||||||
val d3a = remember { mutableFloatStateOf(0f) }
|
val d3a = remember { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
|
// this function exists solely for the dividers, should get rid of it
|
||||||
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
|
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
|
||||||
val previousMode = noiseControlMode.value
|
val previousMode = noiseControlMode.value
|
||||||
|
|
||||||
val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) {
|
val targetMode = if (!showOffListeningMode && mode == NoiseControlMode.OFF) {
|
||||||
NoiseControlMode.TRANSPARENCY
|
NoiseControlMode.TRANSPARENCY
|
||||||
} else {
|
} else {
|
||||||
mode
|
mode
|
||||||
@@ -125,9 +105,8 @@ fun NoiseControlSettings(
|
|||||||
|
|
||||||
noiseControlMode.value = targetMode
|
noiseControlMode.value = targetMode
|
||||||
|
|
||||||
if (!received && targetMode != previousMode) {
|
if (!received && targetMode != previousMode) onNoiseControlModeChanged(targetMode.ordinal + 1)
|
||||||
service.aacpManager.sendControlCommand(identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, value = targetMode.ordinal + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
when (noiseControlMode.value) {
|
when (noiseControlMode.value) {
|
||||||
NoiseControlMode.NOISE_CANCELLATION -> {
|
NoiseControlMode.NOISE_CANCELLATION -> {
|
||||||
@@ -153,32 +132,12 @@ fun NoiseControlSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val noiseControlReceiver = remember {
|
|
||||||
object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
if (intent.action == AirPodsNotifications.ANC_DATA) {
|
|
||||||
noiseControlMode.value = NoiseControlMode.entries.toTypedArray()[intent.getIntExtra("data", 3) - 1]
|
|
||||||
onModeSelected(noiseControlMode.value, true)
|
|
||||||
} else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
|
|
||||||
try {
|
|
||||||
context.unregisterReceiver(this)
|
|
||||||
} catch (e: IllegalArgumentException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val noiseControlIntentFilter = IntentFilter().apply {
|
val index = (noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.size - 1)
|
||||||
addAction(AirPodsNotifications.ANC_DATA)
|
noiseControlMode.value = NoiseControlMode.entries[index]
|
||||||
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
|
||||||
}
|
onModeSelected(noiseControlMode.value, received = true)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED)
|
|
||||||
} else {
|
|
||||||
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter)
|
|
||||||
}
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||||
@@ -190,17 +149,18 @@ fun NoiseControlSettings(
|
|||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = textColor.copy(alpha = 0.6f),
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
|
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 8.dp)
|
.padding(vertical = 8.dp)
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val buttonCount = if (offListeningMode.value) 4 else 3
|
val buttonCount = if (showOffListeningMode) 4 else 3
|
||||||
val buttonWidth = maxWidth / buttonCount
|
val buttonWidth = maxWidth / buttonCount
|
||||||
|
|
||||||
val isDragging = remember { mutableStateOf(false) }
|
val isDragging = remember { mutableStateOf(false) }
|
||||||
@@ -208,10 +168,10 @@ fun NoiseControlSettings(
|
|||||||
mutableFloatStateOf(
|
mutableFloatStateOf(
|
||||||
with(density) {
|
with(density) {
|
||||||
when(noiseControlMode.value) {
|
when(noiseControlMode.value) {
|
||||||
NoiseControlMode.OFF -> if (offListeningMode.value) 0f else buttonWidth.toPx()
|
NoiseControlMode.OFF -> if (showOffListeningMode) 0f else buttonWidth.toPx()
|
||||||
NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) buttonWidth.toPx() else 0f
|
NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) buttonWidth.toPx() else 0f
|
||||||
NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) (buttonWidth * 2).toPx() else buttonWidth.toPx()
|
NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) (buttonWidth * 2).toPx() else buttonWidth.toPx()
|
||||||
NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx()
|
NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -224,10 +184,10 @@ fun NoiseControlSettings(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val targetOffset = buttonWidth * when(noiseControlMode.value) {
|
val targetOffset = buttonWidth * when(noiseControlMode.value) {
|
||||||
NoiseControlMode.OFF -> if (offListeningMode.value) 0 else 1
|
NoiseControlMode.OFF -> if (showOffListeningMode) 0 else 1
|
||||||
NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) 1 else 0
|
NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) 1 else 0
|
||||||
NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) 2 else 1
|
NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) 2 else 1
|
||||||
NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) 3 else 2
|
NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) 3 else 2
|
||||||
}
|
}
|
||||||
|
|
||||||
val animatedOffset by animateFloatAsState(
|
val animatedOffset by animateFloatAsState(
|
||||||
@@ -250,7 +210,7 @@ fun NoiseControlSettings(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
if (offListeningMode.value) {
|
if (showOffListeningMode) {
|
||||||
NoiseControlButton(
|
NoiseControlButton(
|
||||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||||
onClick = { onModeSelected(NoiseControlMode.OFF) },
|
onClick = { onModeSelected(NoiseControlMode.OFF) },
|
||||||
@@ -323,13 +283,12 @@ fun NoiseControlSettings(
|
|||||||
val position = dragOffset / with(density) { buttonWidth.toPx() }
|
val position = dragOffset / with(density) { buttonWidth.toPx() }
|
||||||
val newIndex = position.roundToInt()
|
val newIndex = position.roundToInt()
|
||||||
val newMode = when(newIndex) {
|
val newMode = when(newIndex) {
|
||||||
0 -> if (offListeningMode.value) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
|
0 -> if (showOffListeningMode) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
|
||||||
1 -> if (offListeningMode.value) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
|
1 -> if (showOffListeningMode) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
|
||||||
2 -> if (offListeningMode.value) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
|
2 -> if (showOffListeningMode) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
|
||||||
3 -> NoiseControlMode.NOISE_CANCELLATION
|
3 -> NoiseControlMode.NOISE_CANCELLATION
|
||||||
else -> noiseControlMode.value // Keep current if index is invalid
|
else -> noiseControlMode.value // Keep current if index is invalid
|
||||||
}
|
}
|
||||||
// Call onModeSelected which now handles service call but not callback
|
|
||||||
onModeSelected(newMode)
|
onModeSelected(newMode)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -347,7 +306,7 @@ fun NoiseControlSettings(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.zIndex(1f)
|
.zIndex(1f)
|
||||||
) {
|
) {
|
||||||
if (offListeningMode.value) {
|
if (showOffListeningMode) {
|
||||||
NoiseControlButton(
|
NoiseControlButton(
|
||||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||||
onClick = { onModeSelected(NoiseControlMode.OFF) },
|
onClick = { onModeSelected(NoiseControlMode.OFF) },
|
||||||
@@ -406,7 +365,7 @@ fun NoiseControlSettings(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 4.dp)
|
.padding(top = 4.dp)
|
||||||
) {
|
) {
|
||||||
if (offListeningMode.value) {
|
if (showOffListeningMode) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.off),
|
text = stringResource(R.string.off),
|
||||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||||
@@ -436,9 +395,3 @@ fun NoiseControlSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun NoiseControlSettingsPreview() {
|
|
||||||
NoiseControlSettings(AirPodsService())
|
|
||||||
}
|
|
||||||
+9
-23
@@ -16,17 +16,13 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
@@ -35,38 +31,34 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.constants.StemAction
|
import me.kavishdevar.librepods.data.StemAction
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PressAndHoldSettings(navController: NavController) {
|
fun PressAndHoldSettings(
|
||||||
|
navController: NavController,
|
||||||
|
leftAction: StemAction,
|
||||||
|
rightAction: StemAction
|
||||||
|
) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val dividerColor = Color(0x40888888)
|
val dividerColor = Color(0x40888888)
|
||||||
|
|
||||||
val context = LocalContext.current
|
val leftActionText = when (leftAction) {
|
||||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
val leftAction = sharedPreferences.getString("left_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
|
|
||||||
val rightAction = sharedPreferences.getString("right_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
|
|
||||||
|
|
||||||
val leftActionText = when (StemAction.valueOf(leftAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
|
|
||||||
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
||||||
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
||||||
else -> "INVALID!!"
|
else -> "INVALID!!"
|
||||||
}
|
}
|
||||||
|
|
||||||
val rightActionText = when (StemAction.valueOf(rightAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
|
val rightActionText = when (rightAction) {
|
||||||
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
||||||
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
||||||
else -> "INVALID!!"
|
else -> "INVALID!!"
|
||||||
@@ -114,9 +106,3 @@ fun PressAndHoldSettings(navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
|
||||||
@Composable
|
|
||||||
fun PressAndHoldSettingsPreview() {
|
|
||||||
PressAndHoldSettings(navController = NavController(LocalContext.current))
|
|
||||||
}
|
|
||||||
+53
-19
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import android.graphics.RuntimeShader
|
import android.graphics.RuntimeShader
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -46,7 +46,9 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.ShaderBrush
|
import androidx.compose.ui.graphics.ShaderBrush
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.util.fastCoerceAtMost
|
import androidx.compose.ui.util.fastCoerceAtMost
|
||||||
@@ -55,7 +57,7 @@ import androidx.compose.ui.util.lerp
|
|||||||
import com.kyant.backdrop.Backdrop
|
import com.kyant.backdrop.Backdrop
|
||||||
import com.kyant.backdrop.drawBackdrop
|
import com.kyant.backdrop.drawBackdrop
|
||||||
import com.kyant.backdrop.effects.blur
|
import com.kyant.backdrop.effects.blur
|
||||||
import com.kyant.backdrop.effects.refraction
|
import com.kyant.backdrop.effects.lens
|
||||||
import com.kyant.backdrop.effects.vibrancy
|
import com.kyant.backdrop.effects.vibrancy
|
||||||
import com.kyant.backdrop.highlight.Highlight
|
import com.kyant.backdrop.highlight.Highlight
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -77,7 +79,8 @@ fun StyledButton(
|
|||||||
maxScale: Float = 0.1f,
|
maxScale: Float = 0.1f,
|
||||||
content: @Composable RowScope.() -> Unit,
|
content: @Composable RowScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val animationScope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
val progressAnimation = remember { Animatable(0f) }
|
val progressAnimation = remember { Animatable(0f) }
|
||||||
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
|
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
|
||||||
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||||
@@ -146,7 +149,12 @@ half4 main(float2 coord) {
|
|||||||
effects = {
|
effects = {
|
||||||
vibrancy()
|
vibrancy()
|
||||||
blur(2f.dp.toPx())
|
blur(2f.dp.toPx())
|
||||||
refraction(12f.dp.toPx(), 24f.dp.toPx())
|
lens(
|
||||||
|
refractionHeight = 12f.dp.toPx(),
|
||||||
|
refractionAmount = 24f.dp.toPx(),
|
||||||
|
depthEffect = true,
|
||||||
|
chromaticAberration = true
|
||||||
|
)
|
||||||
},
|
},
|
||||||
layerBlock = {
|
layerBlock = {
|
||||||
val width = size.width
|
val width = size.width
|
||||||
@@ -158,19 +166,21 @@ half4 main(float2 coord) {
|
|||||||
val maxOffset = size.minDimension
|
val maxOffset = size.minDimension
|
||||||
val initialDerivative = 0.05f
|
val initialDerivative = 0.05f
|
||||||
val offset = offsetAnimation.value
|
val offset = offsetAnimation.value
|
||||||
translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset)
|
translationX =
|
||||||
translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset)
|
maxOffset * tanh(initialDerivative * offset.x / maxOffset)
|
||||||
|
translationY =
|
||||||
|
maxOffset * tanh(initialDerivative * offset.y / maxOffset)
|
||||||
|
|
||||||
val maxDragScale = 0.1f
|
val maxDragScale = 0.1f
|
||||||
val offsetAngle = atan2(offset.y, offset.x)
|
val offsetAngle = atan2(offset.y, offset.x)
|
||||||
scaleX =
|
scaleX =
|
||||||
scale +
|
scale +
|
||||||
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
|
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
|
||||||
(width / height).fastCoerceAtMost(1f)
|
(width / height).fastCoerceAtMost(1f)
|
||||||
scaleY =
|
scaleY =
|
||||||
scale +
|
scale +
|
||||||
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
|
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
|
||||||
(height / width).fastCoerceAtMost(1f)
|
(height / width).fastCoerceAtMost(1f)
|
||||||
},
|
},
|
||||||
onDrawSurface = {
|
onDrawSurface = {
|
||||||
if (tint.isSpecified) {
|
if (tint.isSpecified) {
|
||||||
@@ -204,7 +214,10 @@ half4 main(float2 coord) {
|
|||||||
interactiveHighlightShader.apply {
|
interactiveHighlightShader.apply {
|
||||||
val offset = pressStartPosition + offsetAnimation.value
|
val offset = pressStartPosition + offsetAnimation.value
|
||||||
setFloatUniform("size", size.width, size.height)
|
setFloatUniform("size", size.width, size.height)
|
||||||
setColorUniform("color", Color.White.copy(0.15f * progress).toArgb())
|
setColorUniform(
|
||||||
|
"color",
|
||||||
|
Color.White.copy(0.15f * progress).toArgb()
|
||||||
|
)
|
||||||
setFloatUniform("radius", size.maxDimension)
|
setFloatUniform("radius", size.maxDimension)
|
||||||
setFloatUniform(
|
setFloatUniform(
|
||||||
"offset",
|
"offset",
|
||||||
@@ -231,31 +244,51 @@ 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(animationScope) {
|
Modifier.pointerInput(scope) {
|
||||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||||
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
||||||
val onDragStop: () -> Unit = {
|
val onDragStop: () -> Unit = {
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
|
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
|
||||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
||||||
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
|
launch {
|
||||||
|
offsetAnimation.animateTo(
|
||||||
|
Offset.Zero,
|
||||||
|
offsetAnimationSpec
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inspectDragGestures(
|
inspectDragGestures(
|
||||||
onDragStart = { down ->
|
onDragStart = { down ->
|
||||||
pressStartPosition = down.position
|
pressStartPosition = down.position
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
|
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
|
||||||
|
launch {
|
||||||
|
progressAnimation.animateTo(
|
||||||
|
1f,
|
||||||
|
progressAnimationSpec
|
||||||
|
)
|
||||||
|
}
|
||||||
launch { offsetAnimation.snapTo(Offset.Zero) }
|
launch { offsetAnimation.snapTo(Offset.Zero) }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDragEnd = { onDragStop() },
|
onDragEnd = {
|
||||||
|
onDragStop()
|
||||||
|
},
|
||||||
onDragCancel = onDragStop
|
onDragCancel = onDragStop
|
||||||
) { _, dragAmount ->
|
) { _, dragAmount ->
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
|
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
|
||||||
|
HapticFeedbackType.SegmentFrequentTick
|
||||||
|
)
|
||||||
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,6 +302,7 @@ half4 main(float2 coord) {
|
|||||||
isPressed = false
|
isPressed = false
|
||||||
},
|
},
|
||||||
onTap = {
|
onTap = {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||||
onClick()
|
onClick()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
+17
-2
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
@@ -49,14 +49,17 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -71,6 +74,7 @@ import dev.chrisbanes.haze.HazeTint
|
|||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@@ -110,6 +114,9 @@ fun StyledDropdown(
|
|||||||
var hoveredIndex by remember { mutableStateOf<Int?>(null) }
|
var hoveredIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
val itemHeight = 48.dp
|
val itemHeight = 48.dp
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
|
||||||
var popupSize by remember { mutableStateOf(IntSize(0, 0)) }
|
var popupSize by remember { mutableStateOf(IntSize(0, 0)) }
|
||||||
var lastDragPosition by remember { mutableStateOf<Offset?>(null) }
|
var lastDragPosition by remember { mutableStateOf<Offset?>(null) }
|
||||||
|
|
||||||
@@ -132,7 +139,12 @@ fun StyledDropdown(
|
|||||||
},
|
},
|
||||||
onDrag = { change, _ ->
|
onDrag = { change, _ ->
|
||||||
val y = change.position.y
|
val y = change.position.y
|
||||||
hoveredIndex = (y / itemHeight.toPx()).toInt()
|
val newHoveredIndex = (y / itemHeight.toPx()).toInt()
|
||||||
|
if (newHoveredIndex != hoveredIndex) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(
|
||||||
|
HapticFeedbackType.SegmentTick) }
|
||||||
|
}
|
||||||
|
hoveredIndex = newHoveredIndex
|
||||||
lastDragPosition = change.position
|
lastDragPosition = change.position
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
@@ -144,6 +156,8 @@ fun StyledDropdown(
|
|||||||
if (withinBounds) {
|
if (withinBounds) {
|
||||||
hoveredIndex?.let { idx ->
|
hoveredIndex?.let { idx ->
|
||||||
if (idx in options.indices) {
|
if (idx in options.indices) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(
|
||||||
|
HapticFeedbackType.GestureEnd) }
|
||||||
onOptionSelected(options[idx])
|
onOptionSelected(options[idx])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,6 +188,7 @@ fun StyledDropdown(
|
|||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null
|
indication = null
|
||||||
) {
|
) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
|
||||||
onOptionSelected(text)
|
onOptionSelected(text)
|
||||||
onDismissRequest()
|
onDismissRequest()
|
||||||
}
|
}
|
||||||
+42
-20
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import android.graphics.RuntimeShader
|
import android.graphics.RuntimeShader
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -50,7 +50,9 @@ 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
|
||||||
@@ -63,8 +65,7 @@ import androidx.compose.ui.util.lerp
|
|||||||
import com.kyant.backdrop.backdrops.LayerBackdrop
|
import com.kyant.backdrop.backdrops.LayerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import com.kyant.backdrop.drawBackdrop
|
import com.kyant.backdrop.drawBackdrop
|
||||||
import com.kyant.backdrop.effects.blur
|
import com.kyant.backdrop.effects.lens
|
||||||
import com.kyant.backdrop.effects.refractionWithDispersion
|
|
||||||
import com.kyant.backdrop.highlight.Highlight
|
import com.kyant.backdrop.highlight.Highlight
|
||||||
import com.kyant.backdrop.shadow.Shadow
|
import com.kyant.backdrop.shadow.Shadow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -78,14 +79,15 @@ import kotlin.math.tanh
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledIconButton(
|
fun StyledIconButton(
|
||||||
onClick: () -> Unit,
|
modifier: Modifier = Modifier,
|
||||||
icon: String,
|
icon: String,
|
||||||
darkMode: Boolean,
|
|
||||||
tint: Color = Color.Unspecified,
|
tint: Color = Color.Unspecified,
|
||||||
backdrop: LayerBackdrop = rememberLayerBackdrop(),
|
backdrop: LayerBackdrop = rememberLayerBackdrop(),
|
||||||
modifier: Modifier = Modifier,
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val animationScope = rememberCoroutineScope()
|
val haptics = LocalHapticFeedback.current
|
||||||
|
val darkMode = isSystemInDarkTheme()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||||
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
||||||
val progressAnimation = remember { Animatable(0f) }
|
val progressAnimation = remember { Animatable(0f) }
|
||||||
@@ -117,7 +119,10 @@ 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)
|
||||||
@@ -148,12 +153,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)
|
||||||
@@ -183,7 +188,12 @@ half4 main(float2 coord) {
|
|||||||
drawLayer(innerShadowLayer)
|
drawLayer(innerShadowLayer)
|
||||||
|
|
||||||
drawRect(
|
drawRect(
|
||||||
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(progress.coerceIn(0.15f, 0.35f))
|
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(
|
||||||
|
progress.coerceIn(
|
||||||
|
0.15f,
|
||||||
|
0.35f
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onDrawFront = {
|
onDrawFront = {
|
||||||
@@ -197,7 +207,10 @@ half4 main(float2 coord) {
|
|||||||
interactiveHighlightShader.apply {
|
interactiveHighlightShader.apply {
|
||||||
val offset = pressStartPosition + offsetAnimation.value
|
val offset = pressStartPosition + offsetAnimation.value
|
||||||
setFloatUniform("size", size.width, size.height)
|
setFloatUniform("size", size.width, size.height)
|
||||||
setColorUniform("color", Color.White.copy(0.15f * progress).toArgb())
|
setColorUniform(
|
||||||
|
"color",
|
||||||
|
Color.White.copy(0.15f * progress).toArgb()
|
||||||
|
)
|
||||||
setFloatUniform("radius", size.maxDimension)
|
setFloatUniform("radius", size.maxDimension)
|
||||||
setFloatUniform(
|
setFloatUniform(
|
||||||
"offset",
|
"offset",
|
||||||
@@ -218,13 +231,18 @@ half4 main(float2 coord) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
effects = {
|
effects = {
|
||||||
refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
|
lens(
|
||||||
// blur(24f, TileMode.Decal)
|
refractionHeight = 6f.dp.toPx(),
|
||||||
|
refractionAmount = size.height / 2f,
|
||||||
|
depthEffect = true,
|
||||||
|
chromaticAberration = true
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.pointerInput(animationScope) {
|
.pointerInput(scope) {
|
||||||
val onDragStop: () -> Unit = {
|
val onDragStop: () -> Unit = {
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
|
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
|
||||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
||||||
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
|
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
|
||||||
}
|
}
|
||||||
@@ -232,7 +250,8 @@ half4 main(float2 coord) {
|
|||||||
inspectDragGestures(
|
inspectDragGestures(
|
||||||
onDragStart = { down ->
|
onDragStart = { down ->
|
||||||
pressStartPosition = down.position
|
pressStartPosition = down.position
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
|
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
|
||||||
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
|
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
|
||||||
launch { offsetAnimation.snapTo(Offset.Zero) }
|
launch { offsetAnimation.snapTo(Offset.Zero) }
|
||||||
}
|
}
|
||||||
@@ -240,7 +259,10 @@ half4 main(float2 coord) {
|
|||||||
onDragEnd = { onDragStop() },
|
onDragEnd = { onDragStop() },
|
||||||
onDragCancel = onDragStop
|
onDragCancel = onDragStop
|
||||||
) { _, dragAmount ->
|
) { _, dragAmount ->
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
|
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
|
||||||
|
HapticFeedbackType.SegmentFrequentTick
|
||||||
|
)
|
||||||
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+6
-11
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -49,7 +49,6 @@ import androidx.compose.ui.unit.Dp
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.kyant.backdrop.backdrops.LayerBackdrop
|
import com.kyant.backdrop.backdrops.LayerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
@@ -57,17 +56,15 @@ import dev.chrisbanes.haze.HazeProgressive
|
|||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.HazeTint
|
import dev.chrisbanes.haze.HazeTint
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import dev.chrisbanes.haze.rememberHazeState
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledScaffold(
|
fun StyledScaffold(
|
||||||
title: String,
|
title: String,
|
||||||
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
||||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||||
content: @Composable (spacerValue: Dp, hazeState: HazeState) -> Unit
|
content: @Composable (spacerValue: Dp, hazeState: HazeState, bottomPadding: Dp) -> Unit
|
||||||
) {
|
) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val hazeState = rememberHazeState(blurEnabled = true)
|
val hazeState = rememberHazeState(blurEnabled = true)
|
||||||
@@ -87,7 +84,7 @@ fun StyledScaffold(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(start = startPadding, end = endPadding, bottom = bottomPadding)
|
.padding(start = startPadding, end = endPadding)
|
||||||
) {
|
) {
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
Box(
|
Box(
|
||||||
@@ -127,13 +124,12 @@ fun StyledScaffold(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
content(topPadding + 64.dp, hazeState)
|
content(topPadding + 64.dp, hazeState, bottomPadding + 12.dp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledScaffold(
|
fun StyledScaffold(
|
||||||
title: String,
|
title: String,
|
||||||
@@ -145,12 +141,11 @@ fun StyledScaffold(
|
|||||||
title = title,
|
title = title,
|
||||||
actionButtons = actionButtons,
|
actionButtons = actionButtons,
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
) { _, _ ->
|
) { _, _, _->
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledScaffold(
|
fun StyledScaffold(
|
||||||
title: String,
|
title: String,
|
||||||
@@ -162,7 +157,7 @@ fun StyledScaffold(
|
|||||||
title = title,
|
title = title,
|
||||||
actionButtons = actionButtons,
|
actionButtons = actionButtons,
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
) { spacerValue, _ ->
|
) { spacerValue, _, _ ->
|
||||||
content(spacerValue)
|
content(spacerValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+24
-19
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
@@ -39,16 +39,18 @@ 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
|
||||||
@@ -59,19 +61,10 @@ data class SelectItem(
|
|||||||
val iconRes: Int? = null,
|
val iconRes: Int? = null,
|
||||||
val selected: Boolean,
|
val selected: Boolean,
|
||||||
val onClick: () -> Unit,
|
val onClick: () -> Unit,
|
||||||
|
val visible: Boolean = true,
|
||||||
val enabled: Boolean = true
|
val enabled: Boolean = true
|
||||||
)
|
)
|
||||||
|
|
||||||
data class SelectItem2(
|
|
||||||
val name: String,
|
|
||||||
val description: String? = null,
|
|
||||||
val iconRes: Int? = null,
|
|
||||||
val selected: () -> Boolean,
|
|
||||||
val onClick: () -> Unit,
|
|
||||||
val enabled: Boolean = true
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledSelectList(
|
fun StyledSelectList(
|
||||||
items: List<SelectItem>,
|
items: List<SelectItem>,
|
||||||
@@ -81,24 +74,28 @@ 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.enabled }
|
val visibleItems = items.filter { it.visible }
|
||||||
visibleItems.forEachIndexed { index, item ->
|
visibleItems.forEachIndexed { index, item ->
|
||||||
val isFirst = index == 0
|
val isFirst = index == 0
|
||||||
val isLast = index == visibleItems.size - 1
|
val isLast = index == visibleItems.size - 1
|
||||||
val hasIcon = item.iconRes != null
|
val hasIcon = item.iconRes != null
|
||||||
|
|
||||||
val shape = when {
|
val shape = when {
|
||||||
|
isFirst && isLast -> RoundedCornerShape(28.dp)
|
||||||
isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
|
isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
|
||||||
isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
|
isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
|
||||||
else -> RoundedCornerShape(0.dp)
|
else -> RoundedCornerShape(0.dp)
|
||||||
}
|
}
|
||||||
var itemBackgroundColor by remember { mutableStateOf(backgroundColor) }
|
var itemBackgroundColor by remember { mutableStateOf(if (item.enabled) backgroundColor else if (isDarkTheme) Color(0x40050505) else Color(0x40D9D9D9)) }
|
||||||
val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500))
|
val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
@@ -108,10 +105,18 @@ fun StyledSelectList(
|
|||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
detectTapGestures(
|
detectTapGestures(
|
||||||
onPress = {
|
onPress = {
|
||||||
itemBackgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
if (item.enabled) {
|
||||||
tryAwaitRelease()
|
itemBackgroundColor =
|
||||||
itemBackgroundColor = backgroundColor
|
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||||
item.onClick()
|
tryAwaitRelease()
|
||||||
|
itemBackgroundColor = backgroundColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTap = {
|
||||||
|
if (item.enabled) {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||||
|
item.onClick()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -121,7 +126,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
|
||||||
+125
-81
@@ -16,8 +16,9 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
@@ -43,7 +44,6 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.MutableFloatState
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
@@ -56,6 +56,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||||
import androidx.compose.ui.input.pointer.util.addPointerInputChange
|
import androidx.compose.ui.input.pointer.util.addPointerInputChange
|
||||||
@@ -64,6 +65,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
|
|||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.layout.positionInParent
|
import androidx.compose.ui.layout.positionInParent
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -81,7 +83,7 @@ import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
|
|||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import com.kyant.backdrop.drawBackdrop
|
import com.kyant.backdrop.drawBackdrop
|
||||||
import com.kyant.backdrop.effects.blur
|
import com.kyant.backdrop.effects.blur
|
||||||
import com.kyant.backdrop.effects.refractionWithDispersion
|
import com.kyant.backdrop.effects.lens
|
||||||
import com.kyant.backdrop.highlight.Highlight
|
import com.kyant.backdrop.highlight.Highlight
|
||||||
import com.kyant.backdrop.shadow.InnerShadow
|
import com.kyant.backdrop.shadow.InnerShadow
|
||||||
import com.kyant.backdrop.shadow.Shadow
|
import com.kyant.backdrop.shadow.Shadow
|
||||||
@@ -203,10 +205,11 @@ class MomentumAnimation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnrememberedMutableState")
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledSlider(
|
fun StyledSlider(
|
||||||
label: String? = null,
|
label: String? = null,
|
||||||
mutableFloatState: MutableFloatState,
|
value: Float,
|
||||||
onValueChange: (Float) -> Unit,
|
onValueChange: (Float) -> Unit,
|
||||||
valueRange: ClosedFloatingPointRange<Float>,
|
valueRange: ClosedFloatingPointRange<Float>,
|
||||||
backdrop: Backdrop = rememberLayerBackdrop(),
|
backdrop: Backdrop = rememberLayerBackdrop(),
|
||||||
@@ -217,23 +220,26 @@ fun StyledSlider(
|
|||||||
startLabel: String? = null,
|
startLabel: String? = null,
|
||||||
endLabel: String? = null,
|
endLabel: String? = null,
|
||||||
independent: Boolean = false,
|
independent: Boolean = false,
|
||||||
description: String? = null
|
description: String? = null,
|
||||||
|
enabled: Boolean = true
|
||||||
) {
|
) {
|
||||||
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
val isLightTheme = !isSystemInDarkTheme()
|
val isLightTheme = !isSystemInDarkTheme()
|
||||||
val accentColor =
|
|
||||||
if (isLightTheme) Color(0xFF0088FF)
|
|
||||||
else Color(0xFF0091FF)
|
|
||||||
val trackColor =
|
val trackColor =
|
||||||
if (isLightTheme) Color(0xFF787878).copy(0.2f)
|
if (isLightTheme) Color(0xFF787878).copy(0.2f)
|
||||||
else Color(0xFF787880).copy(0.36f)
|
else Color(0xFF787880).copy(0.36f)
|
||||||
|
val accentColor =
|
||||||
|
if (enabled) {
|
||||||
|
if (isLightTheme) Color(0xFF0088FF)
|
||||||
|
else Color(0xFF0091FF)
|
||||||
|
} else {
|
||||||
|
trackColor
|
||||||
|
}
|
||||||
val labelTextColor = if (isLightTheme) Color.Black else Color.White
|
val labelTextColor = if (isLightTheme) Color.Black else Color.White
|
||||||
|
|
||||||
val fraction by remember {
|
val fraction by derivedStateOf {
|
||||||
derivedStateOf {
|
((value - valueRange.start) / (valueRange.endInclusive - valueRange.start))
|
||||||
((mutableFloatState.floatValue - valueRange.start) / (valueRange.endInclusive - valueRange.start))
|
.fastCoerceIn(0f, 1f)
|
||||||
.fastCoerceIn(0f, 1f)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val sliderBackdrop = rememberLayerBackdrop()
|
val sliderBackdrop = rememberLayerBackdrop()
|
||||||
@@ -242,6 +248,8 @@ fun StyledSlider(
|
|||||||
val startIconWidthState = remember { mutableFloatStateOf(0f) }
|
val startIconWidthState = remember { mutableFloatStateOf(0f) }
|
||||||
val endIconWidthState = remember { mutableFloatStateOf(0f) }
|
val endIconWidthState = remember { mutableFloatStateOf(0f) }
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
var lastDragValue by remember { mutableFloatStateOf(value) }
|
||||||
|
|
||||||
val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f)
|
val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f)
|
||||||
|
|
||||||
@@ -427,71 +435,93 @@ fun StyledSlider(
|
|||||||
)
|
)
|
||||||
translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() }
|
translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() }
|
||||||
}
|
}
|
||||||
.draggable(
|
.then(
|
||||||
rememberDraggableState { delta ->
|
if (enabled) {
|
||||||
val trackWidth = trackWidthState.floatValue
|
Modifier
|
||||||
if (trackWidth > 0f) {
|
.draggable(
|
||||||
val targetFraction = fraction + delta / trackWidth
|
rememberDraggableState { delta ->
|
||||||
val targetValue =
|
val trackWidth = trackWidthState.floatValue
|
||||||
lerp(valueRange.start, valueRange.endInclusive, targetFraction)
|
if (trackWidth > 0f) {
|
||||||
.fastCoerceIn(valueRange.start, valueRange.endInclusive)
|
val targetFraction = fraction + delta / trackWidth
|
||||||
val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
|
val targetValue =
|
||||||
targetValue,
|
lerp(
|
||||||
snapPoints,
|
valueRange.start,
|
||||||
snapThreshold
|
valueRange.endInclusive,
|
||||||
) else targetValue
|
targetFraction
|
||||||
onValueChange(snappedValue)
|
)
|
||||||
}
|
.fastCoerceIn(
|
||||||
},
|
valueRange.start,
|
||||||
Orientation.Horizontal,
|
valueRange.endInclusive
|
||||||
startDragImmediately = true,
|
)
|
||||||
onDragStarted = {
|
snapPoints.forEach { snap ->
|
||||||
// Remove this block as momentumAnimation handles pressing
|
if ((lastDragValue < snap && targetValue >= snap) ||
|
||||||
},
|
(snap in targetValue..<lastDragValue)) {
|
||||||
onDragStopped = {
|
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||||
// Remove this block as momentumAnimation handles pressing
|
}
|
||||||
onValueChange((mutableFloatState.floatValue * 100).roundToInt() / 100f)
|
}
|
||||||
}
|
lastDragValue = targetValue
|
||||||
)
|
val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
|
||||||
.then(momentumAnimation.modifier)
|
targetValue,
|
||||||
.drawBackdrop(
|
snapPoints,
|
||||||
rememberCombinedBackdrop(backdrop, sliderBackdrop),
|
snapThreshold,
|
||||||
{ RoundedCornerShape(28.dp) },
|
) else targetValue
|
||||||
highlight = {
|
onValueChange(snappedValue)
|
||||||
val progress = momentumAnimation.progress
|
}
|
||||||
Highlight.Ambient.copy(alpha = progress)
|
},
|
||||||
},
|
Orientation.Horizontal,
|
||||||
shadow = {
|
startDragImmediately = true,
|
||||||
Shadow(
|
onDragStarted = {
|
||||||
radius = 4f.dp,
|
lastDragValue = value
|
||||||
color = Color.Black.copy(0.05f)
|
},
|
||||||
)
|
onDragStopped = {
|
||||||
},
|
onValueChange((value * 100).roundToInt() / 100f)
|
||||||
innerShadow = {
|
}
|
||||||
val progress = momentumAnimation.progress
|
)
|
||||||
InnerShadow(
|
.then(momentumAnimation.modifier)
|
||||||
radius = 4f.dp * progress,
|
.drawBackdrop(
|
||||||
alpha = progress
|
rememberCombinedBackdrop(backdrop, sliderBackdrop),
|
||||||
)
|
{ RoundedCornerShape(28.dp) },
|
||||||
},
|
highlight = {
|
||||||
layerBlock = {
|
val progress = momentumAnimation.progress
|
||||||
scaleX = momentumAnimation.scaleX
|
Highlight.Ambient.copy(alpha = progress)
|
||||||
scaleY = momentumAnimation.scaleY
|
},
|
||||||
val velocity = momentumAnimation.velocity / 5000f
|
shadow = {
|
||||||
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.15f, 0.15f)
|
Shadow(
|
||||||
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.15f, 0.15f)
|
radius = 4f.dp,
|
||||||
},
|
color = Color.Black.copy(0.05f)
|
||||||
onDrawSurface = {
|
)
|
||||||
val progress = momentumAnimation.progress
|
},
|
||||||
drawRect(Color.White.copy(alpha = 1f - progress))
|
innerShadow = {
|
||||||
},
|
val progress = momentumAnimation.progress
|
||||||
effects = {
|
InnerShadow(
|
||||||
val progress = momentumAnimation.progress
|
radius = 4f.dp * progress,
|
||||||
blur(8f.dp.toPx() * (1f - progress))
|
alpha = progress
|
||||||
refractionWithDispersion(
|
)
|
||||||
height = 6f.dp.toPx() * progress,
|
},
|
||||||
amount = size.height / 2f * progress
|
layerBlock = {
|
||||||
)
|
scaleX = momentumAnimation.scaleX
|
||||||
|
scaleY = momentumAnimation.scaleY
|
||||||
|
val velocity = momentumAnimation.velocity / 5000f
|
||||||
|
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.15f, 0.15f)
|
||||||
|
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.15f, 0.15f)
|
||||||
|
},
|
||||||
|
onDrawSurface = {
|
||||||
|
val progress = momentumAnimation.progress
|
||||||
|
drawRect(Color.White.copy(alpha = 1f - progress))
|
||||||
|
},
|
||||||
|
effects = {
|
||||||
|
val progress = momentumAnimation.progress
|
||||||
|
blur(8f.dp.toPx() * (1f - progress))
|
||||||
|
lens(
|
||||||
|
refractionHeight = 6f.dp.toPx() * progress,
|
||||||
|
refractionAmount = size.height / 2f * progress,
|
||||||
|
depthEffect = true,
|
||||||
|
chromaticAberration = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Modifier.background(trackColor, RoundedCornerShape(28.dp))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.size(40f.dp, 24f.dp)
|
.size(40f.dp, 24f.dp)
|
||||||
@@ -566,12 +596,13 @@ fun StyledSliderPreview() {
|
|||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
Box (
|
Column (
|
||||||
Modifier.align(Alignment.Center)
|
Modifier.align(Alignment.Center),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
StyledSlider(
|
StyledSlider(
|
||||||
mutableFloatState = a,
|
value = a.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
a.floatValue = it
|
a.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -582,6 +613,19 @@ fun StyledSliderPreview() {
|
|||||||
startIcon = "A",
|
startIcon = "A",
|
||||||
endIcon = "B",
|
endIcon = "B",
|
||||||
)
|
)
|
||||||
|
StyledSlider(
|
||||||
|
value = a.floatValue,
|
||||||
|
onValueChange = {
|
||||||
|
a.floatValue = it
|
||||||
|
},
|
||||||
|
valueRange = 0f..2f,
|
||||||
|
snapPoints = listOf(1f),
|
||||||
|
snapThreshold = 0.1f,
|
||||||
|
independent = true,
|
||||||
|
startIcon = "A",
|
||||||
|
endIcon = "B",
|
||||||
|
enabled = false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+33
-17
@@ -16,10 +16,10 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.animation.Animatable
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
@@ -58,8 +58,10 @@ import androidx.compose.ui.graphics.graphicsLayer
|
|||||||
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
||||||
import androidx.compose.ui.graphics.layer.drawLayer
|
import androidx.compose.ui.graphics.layer.drawLayer
|
||||||
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.util.fastCoerceIn
|
import androidx.compose.ui.util.fastCoerceIn
|
||||||
@@ -68,11 +70,12 @@ import com.kyant.backdrop.backdrops.layerBackdrop
|
|||||||
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
|
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import com.kyant.backdrop.drawBackdrop
|
import com.kyant.backdrop.drawBackdrop
|
||||||
import com.kyant.backdrop.effects.refractionWithDispersion
|
import com.kyant.backdrop.effects.lens
|
||||||
import com.kyant.backdrop.highlight.Highlight
|
import com.kyant.backdrop.highlight.Highlight
|
||||||
import com.kyant.backdrop.shadow.Shadow
|
import com.kyant.backdrop.shadow.Shadow
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledSwitch(
|
fun StyledSwitch(
|
||||||
@@ -81,9 +84,12 @@ fun StyledSwitch(
|
|||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
|
||||||
val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
|
val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
|
||||||
val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
|
val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(
|
||||||
|
0x805B5B5E
|
||||||
|
) else Color(0xFFD1D1D6)
|
||||||
|
|
||||||
val trackWidth = 64.dp
|
val trackWidth = 64.dp
|
||||||
val trackHeight = 28.dp
|
val trackHeight = 28.dp
|
||||||
@@ -98,24 +104,25 @@ fun StyledSwitch(
|
|||||||
val animatedFraction = remember { Animatable(fraction) }
|
val animatedFraction = remember { Animatable(fraction) }
|
||||||
val trackWidthPx = remember { mutableFloatStateOf(0f) }
|
val trackWidthPx = remember { mutableFloatStateOf(0f) }
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val animationScope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||||
val colorAnimationSpec = tween<Color>(200, easing = FastOutSlowInEasing)
|
|
||||||
val progressAnimation = remember { Animatable(0f) }
|
val progressAnimation = remember { Animatable(0f) }
|
||||||
val innerShadowLayer = rememberGraphicsLayer().apply {
|
val innerShadowLayer = rememberGraphicsLayer().apply {
|
||||||
compositingStrategy = CompositingStrategy.Offscreen
|
compositingStrategy = CompositingStrategy.Offscreen
|
||||||
}
|
}
|
||||||
val animatedTrackColor = remember { Animatable(if (checked) onColor else offColor) }
|
val targetColor = if (checked) onColor else offColor
|
||||||
|
val animatedTrackColor by animateColorAsState(targetColor)
|
||||||
val totalDrag = remember { mutableFloatStateOf(0f) }
|
val totalDrag = remember { mutableFloatStateOf(0f) }
|
||||||
val tapThreshold = 10f
|
val tapThreshold = 10f
|
||||||
val isFirstComposition = remember { mutableStateOf(true) }
|
val isFirstComposition = remember { mutableStateOf(true) }
|
||||||
LaunchedEffect(checked) {
|
LaunchedEffect(checked) {
|
||||||
if (!isFirstComposition.value) {
|
if (!isFirstComposition.value) {
|
||||||
|
if (checked) {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.ToggleOn)
|
||||||
|
} else {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.ToggleOff)
|
||||||
|
}
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
launch {
|
|
||||||
val targetColor = if (checked) onColor else offColor
|
|
||||||
animatedTrackColor.animateTo(targetColor, colorAnimationSpec)
|
|
||||||
}
|
|
||||||
launch {
|
launch {
|
||||||
val targetFrac = if (checked) 1f else 0f
|
val targetFrac = if (checked) 1f else 0f
|
||||||
animatedFraction.animateTo(targetFrac, progressAnimationSpec)
|
animatedFraction.animateTo(targetFrac, progressAnimationSpec)
|
||||||
@@ -140,7 +147,7 @@ fun StyledSwitch(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layerBackdrop(switchBackdrop)
|
.layerBackdrop(switchBackdrop)
|
||||||
.clip(RoundedCornerShape(trackHeight / 2))
|
.clip(RoundedCornerShape(trackHeight / 2))
|
||||||
.background(animatedTrackColor.value)
|
.background(animatedTrackColor)
|
||||||
.width(trackWidth)
|
.width(trackWidth)
|
||||||
.height(trackHeight)
|
.height(trackHeight)
|
||||||
.onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() }
|
.onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() }
|
||||||
@@ -154,27 +161,31 @@ fun StyledSwitch(
|
|||||||
.then(if (enabled) Modifier.draggable(
|
.then(if (enabled) Modifier.draggable(
|
||||||
rememberDraggableState { delta ->
|
rememberDraggableState { delta ->
|
||||||
if (trackWidthPx.floatValue > 0f) {
|
if (trackWidthPx.floatValue > 0f) {
|
||||||
|
val oldFraction = animatedFraction.value
|
||||||
val newFraction = (animatedFraction.value + delta / trackWidthPx.floatValue).fastCoerceIn(-0.3f, 1.3f)
|
val newFraction = (animatedFraction.value + delta / trackWidthPx.floatValue).fastCoerceIn(-0.3f, 1.3f)
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
animatedFraction.snapTo(newFraction)
|
animatedFraction.snapTo(newFraction)
|
||||||
}
|
}
|
||||||
totalDrag.floatValue += kotlin.math.abs(delta)
|
totalDrag.floatValue += abs(delta)
|
||||||
val newChecked = newFraction >= 0.5f
|
val newChecked = newFraction >= 0.5f
|
||||||
if (newChecked != checked) {
|
if (newChecked != checked) {
|
||||||
onCheckedChange(newChecked)
|
onCheckedChange(newChecked)
|
||||||
}
|
}
|
||||||
|
if ((oldFraction < 0.5f && newFraction >= 0.5f) || (oldFraction >= 0.5f && newFraction < 0.5f)) {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Orientation.Horizontal,
|
Orientation.Horizontal,
|
||||||
startDragImmediately = true,
|
startDragImmediately = true,
|
||||||
onDragStarted = {
|
onDragStarted = {
|
||||||
totalDrag.floatValue = 0f
|
totalDrag.floatValue = 0f
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
progressAnimation.animateTo(1f, progressAnimationSpec)
|
progressAnimation.animateTo(1f, progressAnimationSpec)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDragStopped = {
|
onDragStopped = {
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
if (totalDrag.floatValue < tapThreshold) {
|
if (totalDrag.floatValue < tapThreshold) {
|
||||||
val newChecked = !checked
|
val newChecked = !checked
|
||||||
onCheckedChange(newChecked)
|
onCheckedChange(newChecked)
|
||||||
@@ -262,7 +273,12 @@ fun StyledSwitch(
|
|||||||
drawRect(Color.White.copy(1f - progress))
|
drawRect(Color.White.copy(1f - progress))
|
||||||
},
|
},
|
||||||
effects = {
|
effects = {
|
||||||
refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
|
lens(
|
||||||
|
refractionHeight = 6f.dp.toPx(),
|
||||||
|
refractionAmount = size.height / 2f,
|
||||||
|
depthEffect = true,
|
||||||
|
chromaticAberration = true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.width(thumbWidth)
|
.width(thumbWidth)
|
||||||
+273
@@ -0,0 +1,273 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StyledToggle(
|
||||||
|
title: String? = null,
|
||||||
|
label: String,
|
||||||
|
description: String? = null,
|
||||||
|
checked: Boolean = false,
|
||||||
|
independent: Boolean = true,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val currentChecked by rememberUpdatedState(checked)
|
||||||
|
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var backgroundColor by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val animatedBackgroundColor by animateColorAsState(
|
||||||
|
targetValue = backgroundColor,
|
||||||
|
animationSpec = tween(durationMillis = 500)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (independent) {
|
||||||
|
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||||
|
if (title != null) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
start = 16.dp,
|
||||||
|
end = 16.dp,
|
||||||
|
top = 8.dp,
|
||||||
|
bottom = 4.dp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
|
||||||
|
.padding(4.dp)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(
|
||||||
|
onPress = {
|
||||||
|
if (enabled) {
|
||||||
|
backgroundColor =
|
||||||
|
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||||
|
tryAwaitRelease()
|
||||||
|
backgroundColor =
|
||||||
|
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTap = {
|
||||||
|
if (enabled) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
|
||||||
|
onCheckedChange(!currentChecked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(55.dp)
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledSwitch(
|
||||||
|
checked = checked,
|
||||||
|
enabled = enabled,
|
||||||
|
onCheckedChange = {
|
||||||
|
if (enabled) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(if (it) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
|
||||||
|
onCheckedChange(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description != null) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.background(
|
||||||
|
if (isDarkTheme) Color(0xFF000000)
|
||||||
|
else Color(0xFFF2F2F7)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = description,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val isPressed = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
||||||
|
)
|
||||||
|
.padding(16.dp)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(
|
||||||
|
onPress = {
|
||||||
|
isPressed.value = true
|
||||||
|
tryAwaitRelease()
|
||||||
|
isPressed.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.clickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
|
) {
|
||||||
|
if (enabled) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
|
||||||
|
onCheckedChange(!currentChecked)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(end = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
if (description != null) {
|
||||||
|
Text(
|
||||||
|
text = description,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledSwitch(
|
||||||
|
checked = checked,
|
||||||
|
enabled = enabled,
|
||||||
|
onCheckedChange = {
|
||||||
|
if (enabled) {
|
||||||
|
onCheckedChange(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun StyledTogglePreview() {
|
||||||
|
val checked = remember { mutableStateOf(false) }
|
||||||
|
StyledToggle(
|
||||||
|
label = "Example Toggle",
|
||||||
|
description = "This is an example description for the styled toggle.",
|
||||||
|
checked = checked.value,
|
||||||
|
onCheckedChange = { checked.value = !checked.value }
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
+11
-6
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.presentation.overlays
|
||||||
|
|
||||||
import android.animation.Animator
|
import android.animation.Animator
|
||||||
import android.animation.AnimatorListenerAdapter
|
import android.animation.AnimatorListenerAdapter
|
||||||
@@ -58,10 +58,10 @@ import androidx.dynamicanimation.animation.DynamicAnimation
|
|||||||
import androidx.dynamicanimation.animation.SpringAnimation
|
import androidx.dynamicanimation.animation.SpringAnimation
|
||||||
import androidx.dynamicanimation.animation.SpringForce
|
import androidx.dynamicanimation.animation.SpringForce
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.constants.Battery
|
import me.kavishdevar.librepods.data.Battery
|
||||||
import me.kavishdevar.librepods.constants.BatteryComponent
|
import me.kavishdevar.librepods.data.BatteryComponent
|
||||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
import me.kavishdevar.librepods.data.BatteryStatus
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
@@ -240,6 +240,7 @@ class IslandWindow(private val context: Context) {
|
|||||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||||
)
|
)
|
||||||
|
|
||||||
containerView.addView(islandView, containerParams)
|
containerView.addView(islandView, containerParams)
|
||||||
|
|
||||||
params = WindowManager.LayoutParams(
|
params = WindowManager.LayoutParams(
|
||||||
@@ -379,7 +380,11 @@ class IslandWindow(private val context: Context) {
|
|||||||
videoView.start()
|
videoView.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
windowManager.addView(containerView, params)
|
try {
|
||||||
|
windowManager.addView(containerView, params)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
|
||||||
islandView.post {
|
islandView.post {
|
||||||
initialHeight = islandView.height
|
initialHeight = islandView.height
|
||||||
+10
-6
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.presentation.overlays
|
||||||
|
|
||||||
import android.animation.Animator
|
import android.animation.Animator
|
||||||
import android.animation.AnimatorListenerAdapter
|
import android.animation.AnimatorListenerAdapter
|
||||||
@@ -45,10 +45,10 @@ import android.widget.LinearLayout
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.VideoView
|
import android.widget.VideoView
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.constants.Battery
|
import me.kavishdevar.librepods.data.Battery
|
||||||
import me.kavishdevar.librepods.constants.BatteryComponent
|
import me.kavishdevar.librepods.data.BatteryComponent
|
||||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
import me.kavishdevar.librepods.data.BatteryStatus
|
||||||
|
|
||||||
@SuppressLint("InflateParams", "ClickableViewAccessibility")
|
@SuppressLint("InflateParams", "ClickableViewAccessibility")
|
||||||
class PopupWindow(
|
class PopupWindow(
|
||||||
@@ -139,7 +139,11 @@ class PopupWindow(
|
|||||||
vid.start()
|
vid.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
mWindowManager.addView(mView, mParams)
|
try {
|
||||||
|
mWindowManager.addView(mView, mParams)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
|
||||||
val displayMetrics = mView.context.resources.displayMetrics
|
val displayMetrics = mView.context.resources.displayMetrics
|
||||||
val screenHeight = displayMetrics.heightPixels
|
val screenHeight = displayMetrics.heightPixels
|
||||||
+239
-283
@@ -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.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
|
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.util.Log
|
|
||||||
import 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,23 +39,25 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.collectAsState
|
||||||
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
|
||||||
@@ -69,80 +71,41 @@ 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.composables.NavigationButton
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
import me.kavishdevar.librepods.composables.StyledDropdown
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.data.Capability
|
||||||
import me.kavishdevar.librepods.composables.StyledSlider
|
import me.kavishdevar.librepods.presentation.components.NavigationButton
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.presentation.components.StyledDropdown
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.utils.ATTHandles
|
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||||
import me.kavishdevar.librepods.utils.Capability
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
private var phoneMediaDebounceJob: Job? = null
|
//private var phoneMediaDebounceJob: Job? = null
|
||||||
private var toneVolumeDebounceJob: Job? = null
|
//private var toneVolumeDebounceJob: Job? = null
|
||||||
private const val TAG = "AccessibilitySettings"
|
//private const val TAG = "AccessibilitySettings"
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
@SuppressLint("DefaultLocale")
|
||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AccessibilitySettingsScreen(navController: NavController) {
|
fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavController) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
|
|
||||||
val isSdpOffsetAvailable =
|
|
||||||
remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
|
|
||||||
|
|
||||||
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
|
val hearingAidEnabled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(1)?.toInt() == 1 && state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0)?.toInt() == 1
|
||||||
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)
|
||||||
) { spacerHeight, hazeState ->
|
) { topPadding, hazeState, bottomPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -152,264 +115,122 @@ fun AccessibilitySettingsScreen(navController: NavController) {
|
|||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
|
|
||||||
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
if (!state.isPremium) {
|
||||||
val phoneEQEnabled = remember { mutableStateOf(false) }
|
StyledButton(
|
||||||
val mediaEQEnabled = remember { mutableStateOf(false) }
|
onClick = {
|
||||||
|
navController.navigate("purchase_screen")
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
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 =
|
|
||||||
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
|
val selectedPressSpeedValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(0)
|
||||||
?.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 =
|
|
||||||
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
|
val selectedPressAndHoldDurationValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(0)
|
||||||
?.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 =
|
val selectedVolumeSwipeSpeedValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull(0)
|
||||||
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 {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
// LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
|
||||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value) {
|
// phoneMediaDebounceJob?.cancel()
|
||||||
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
|
// phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
|
||||||
selectedVolumeSwipeSpeed =
|
// delay(150)
|
||||||
volumeSwipeSpeedOptions[newValue] ?: volumeSwipeSpeedOptions[1]
|
// val manager = ServiceManager.getService()?.aacpManager
|
||||||
}
|
// if (manager == null) {
|
||||||
}
|
// Log.w(TAG, "Cannot write EQ: AACPManager not available")
|
||||||
}
|
// return@launch
|
||||||
LaunchedEffect(Unit) {
|
// }
|
||||||
aacpManager?.registerControlCommandListener(
|
// try {
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
|
// val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
||||||
selectedVolumeSwipeSpeedListener
|
// 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}")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
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
|
||||||
)
|
)
|
||||||
}
|
) {
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
aacpManager?.unregisterControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
|
|
||||||
selectedVolumeSwipeSpeedListener
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
|
|
||||||
phoneMediaDebounceJob?.cancel()
|
|
||||||
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
delay(150)
|
|
||||||
val manager = ServiceManager.getService()?.aacpManager
|
|
||||||
if (manager == null) {
|
|
||||||
Log.w(TAG, "Cannot write EQ: AACPManager not available")
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
|
||||||
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
|
|
||||||
)
|
|
||||||
manager.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Error sending phone/media EQ: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val toneVolumeValue = remember { mutableFloatStateOf(
|
|
||||||
aacpManager?.controlCommandStatusList?.find {
|
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME
|
|
||||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toFloat() ?: 75f
|
|
||||||
) }
|
|
||||||
LaunchedEffect(toneVolumeValue.floatValue) {
|
|
||||||
toneVolumeDebounceJob?.cancel()
|
|
||||||
toneVolumeDebounceJob = CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
delay(150)
|
|
||||||
val manager = ServiceManager.getService()?.aacpManager
|
|
||||||
if (manager == null) {
|
|
||||||
Log.w(TAG, "Cannot write tone volume: AACPManager not available")
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
manager.sendControlCommand(
|
|
||||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value,
|
|
||||||
value = byteArrayOf(toneVolumeValue.floatValue.toInt().toByte(), 0x50.toByte())
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Error sending tone volume: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DropdownMenuComponent(
|
|
||||||
label = stringResource(R.string.press_speed),
|
|
||||||
description = stringResource(R.string.press_speed_description),
|
|
||||||
options = pressSpeedOptions.values.toList(),
|
|
||||||
selectedOption = selectedPressSpeed?: stringResource(R.string.default_option),
|
|
||||||
onOptionSelected = { newValue ->
|
|
||||||
selectedPressSpeed = newValue
|
|
||||||
aacpManager?.sendControlCommand(
|
|
||||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value,
|
|
||||||
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
|
|
||||||
?: 0.toByte()
|
|
||||||
)
|
|
||||||
},
|
|
||||||
textColor = textColor,
|
|
||||||
hazeState = hazeState,
|
|
||||||
independent = true
|
|
||||||
)
|
|
||||||
|
|
||||||
DropdownMenuComponent(
|
|
||||||
label = stringResource(R.string.press_and_hold_duration),
|
|
||||||
description = stringResource(R.string.press_and_hold_duration_description),
|
|
||||||
options = pressAndHoldDurationOptions.values.toList(),
|
|
||||||
selectedOption = selectedPressAndHoldDuration?: stringResource(R.string.default_option),
|
|
||||||
onOptionSelected = { newValue ->
|
|
||||||
selectedPressAndHoldDuration = newValue
|
|
||||||
aacpManager?.sendControlCommand(
|
|
||||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value,
|
|
||||||
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull()
|
|
||||||
?: 0.toByte()
|
|
||||||
)
|
|
||||||
},
|
|
||||||
textColor = textColor,
|
|
||||||
hazeState = hazeState,
|
|
||||||
independent = true
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
title = stringResource(R.string.noise_control),
|
|
||||||
label = stringResource(R.string.noise_cancellation_single_airpod),
|
|
||||||
description = stringResource(R.string.noise_cancellation_single_airpod_description),
|
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE,
|
|
||||||
independent = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)) {
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.loud_sound_reduction),
|
|
||||||
description = stringResource(R.string.loud_sound_reduction_description),
|
|
||||||
attHandle = ATTHandles.LOUD_SOUND_REDUCTION
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
|
|
||||||
NavigationButton(
|
|
||||||
to = "transparency_customization",
|
|
||||||
name = stringResource(R.string.customize_transparency_mode),
|
|
||||||
navController = navController
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledSlider(
|
|
||||||
label = stringResource(R.string.tone_volume),
|
|
||||||
description = stringResource(R.string.tone_volume_description),
|
|
||||||
mutableFloatState = toneVolumeValue,
|
|
||||||
onValueChange = {
|
|
||||||
toneVolumeValue.floatValue = it
|
|
||||||
},
|
|
||||||
valueRange = 0f..100f,
|
|
||||||
snapPoints = listOf(75f),
|
|
||||||
startIcon = "\uDBC0\uDEA1",
|
|
||||||
endIcon = "\uDBC0\uDEA9",
|
|
||||||
independent = true
|
|
||||||
)
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.volume_control),
|
|
||||||
description = stringResource(R.string.volume_control_description),
|
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE,
|
|
||||||
)
|
|
||||||
|
|
||||||
DropdownMenuComponent(
|
DropdownMenuComponent(
|
||||||
label = stringResource(R.string.volume_swipe_speed),
|
label = stringResource(R.string.press_speed),
|
||||||
description = stringResource(R.string.volume_swipe_speed_description),
|
description = stringResource(R.string.press_speed_description),
|
||||||
options = volumeSwipeSpeedOptions.values.toList(),
|
options = pressSpeedOptions.values.toList(),
|
||||||
selectedOption = selectedVolumeSwipeSpeed?: stringResource(R.string.default_option),
|
selectedOption = selectedPressSpeed ?: stringResource(R.string.default_option),
|
||||||
onOptionSelected = { newValue ->
|
onOptionSelected = { newValue ->
|
||||||
selectedVolumeSwipeSpeed = newValue
|
selectedPressSpeed = newValue
|
||||||
aacpManager?.sendControlCommand(
|
viewModel.setControlCommandByte(
|
||||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value,
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
|
||||||
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
|
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
|
||||||
?: 1.toByte()
|
?: 0.toByte()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
@@ -418,7 +239,132 @@ fun AccessibilitySettingsScreen(navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
|
Box (
|
||||||
|
modifier = Modifier.then(
|
||||||
|
if (!state.isPremium) {
|
||||||
|
Modifier
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||||
|
event.changes.forEach { it.consume() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else Modifier
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
DropdownMenuComponent(
|
||||||
|
label = stringResource(R.string.press_and_hold_duration),
|
||||||
|
description = stringResource(R.string.press_and_hold_duration_description),
|
||||||
|
options = pressAndHoldDurationOptions.values.toList(),
|
||||||
|
selectedOption = selectedPressAndHoldDuration
|
||||||
|
?: stringResource(R.string.default_option),
|
||||||
|
onOptionSelected = { newValue ->
|
||||||
|
selectedPressAndHoldDuration = newValue
|
||||||
|
viewModel.setControlCommandByte(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
|
||||||
|
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull()
|
||||||
|
?: 0.toByte()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
textColor = textColor,
|
||||||
|
hazeState = hazeState,
|
||||||
|
independent = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
StyledToggle(
|
||||||
|
title = stringResource(R.string.noise_control),
|
||||||
|
label = stringResource(R.string.noise_cancellation_single_airpod),
|
||||||
|
description = stringResource(R.string.noise_cancellation_single_airpod_description),
|
||||||
|
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) {
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.loud_sound_reduction),
|
||||||
|
description = stringResource(R.string.loud_sound_reduction_description),
|
||||||
|
checked = state.loudSoundReductionEnabled,
|
||||||
|
onCheckedChange = { viewModel.setATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION, if (it) byteArrayOf(0x01) else byteArrayOf(0x00)) },
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hearingAidEnabled && state.vendorIdHook) {
|
||||||
|
NavigationButton(
|
||||||
|
to = "transparency_customization",
|
||||||
|
name = stringResource(R.string.customize_transparency_mode),
|
||||||
|
navController = navController,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val toneVolumeValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f
|
||||||
|
StyledSlider(
|
||||||
|
label = stringResource(R.string.tone_volume),
|
||||||
|
description = stringResource(R.string.tone_volume_description),
|
||||||
|
value = toneVolumeValue,
|
||||||
|
onValueChange = {
|
||||||
|
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME, byteArrayOf(it.toInt().toByte(), 0x50))
|
||||||
|
},
|
||||||
|
valueRange = 0f..100f,
|
||||||
|
snapPoints = listOf(75f),
|
||||||
|
startIcon = "\uDBC0\uDEA1",
|
||||||
|
endIcon = "\uDBC0\uDEA9",
|
||||||
|
independent = true,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
|
if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
|
||||||
|
val volumeSwipeEnabled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull(0)?.toInt() == 0x01
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.volume_control),
|
||||||
|
description = stringResource(R.string.volume_control_description),
|
||||||
|
checked = volumeSwipeEnabled,
|
||||||
|
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it) },
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
|
Box (
|
||||||
|
modifier = Modifier.then(
|
||||||
|
if (!state.isPremium) {
|
||||||
|
Modifier
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||||
|
event.changes.forEach { it.consume() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else Modifier
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
DropdownMenuComponent(
|
||||||
|
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") {
|
||||||
// Text(
|
// Text(
|
||||||
// text = stringResource(R.string.apply_eq_to),
|
// text = stringResource(R.string.apply_eq_to),
|
||||||
// style = TextStyle(
|
// style = TextStyle(
|
||||||
@@ -640,7 +586,7 @@ fun AccessibilitySettingsScreen(navController: NavController) {
|
|||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
}
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -666,6 +612,9 @@ private fun DropdownMenuComponent(
|
|||||||
var lastDismissTime by remember { mutableLongStateOf(0L) }
|
var lastDismissTime by remember { mutableLongStateOf(0L) }
|
||||||
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
|
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
var parentDragActive by remember { mutableStateOf(false) }
|
var parentDragActive by remember { mutableStateOf(false) }
|
||||||
|
var previousIdx by remember { mutableStateOf<Int?>(null) }
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()){
|
Column(modifier = Modifier.fillMaxWidth()){
|
||||||
Column(
|
Column(
|
||||||
@@ -725,7 +674,11 @@ 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
|
||||||
@@ -736,6 +689,9 @@ 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 = {
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navController: NavController) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.customize_adaptive_audio)
|
||||||
|
) { spacerHeight ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
|
if (!state.isPremium) {
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
navController.navigate("purchase_screen")
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+539
@@ -0,0 +1,539 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
|
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context.MODE_PRIVATE
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import com.kyant.backdrop.drawBackdrop
|
||||||
|
import com.kyant.backdrop.highlight.Highlight
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
|
import me.kavishdevar.librepods.data.AirPodsPro3
|
||||||
|
import me.kavishdevar.librepods.data.Capability
|
||||||
|
import me.kavishdevar.librepods.presentation.components.AboutCard
|
||||||
|
import me.kavishdevar.librepods.presentation.components.AudioSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.BatteryView
|
||||||
|
import me.kavishdevar.librepods.presentation.components.CallControlSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.ConnectionSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.HearingHealthSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.MicrophoneSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.NavigationButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.NoiseControlSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.PressAndHoldSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||||
|
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
||||||
|
@Composable
|
||||||
|
fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavController) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
|
||||||
|
var deviceName by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
TextFieldValue(
|
||||||
|
sharedPreferences.getString("name", state.deviceName).toString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val nameChangeListener = remember {
|
||||||
|
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||||
|
if (key == "name") {
|
||||||
|
deviceName =
|
||||||
|
TextFieldValue(sharedPreferences.getString("name", "AirPods Pro").toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
sharedPreferences.registerOnSharedPreferenceChangeListener(nameChangeListener)
|
||||||
|
onDispose {
|
||||||
|
sharedPreferences.unregisterOnSharedPreferenceChangeListener(nameChangeListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.refreshInitialData()
|
||||||
|
}
|
||||||
|
|
||||||
|
isSystemInDarkTheme()
|
||||||
|
val hazeStateS = remember { mutableStateOf(HazeState()) }
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = deviceName.text, actionButtons = listOf(
|
||||||
|
{ scaffoldBackdrop ->
|
||||||
|
StyledIconButton(
|
||||||
|
onClick = { navController.navigate("app_settings") },
|
||||||
|
icon = "",
|
||||||
|
backdrop = scaffoldBackdrop
|
||||||
|
)
|
||||||
|
}), snackbarHostState = snackbarHostState
|
||||||
|
) { topPadding, hazeState, bottomPadding ->
|
||||||
|
hazeStateS.value = hazeState
|
||||||
|
var blockTouches by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.demoActivated.collect {
|
||||||
|
blockTouches = true
|
||||||
|
delay(1000)
|
||||||
|
blockTouches = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isLocallyConnected) {
|
||||||
|
val capabilities = state.capabilities
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.hazeSource(hazeState)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.then(if (blockTouches) Modifier.pointerInput(Unit) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||||
|
event.changes.forEach { it.consume() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else Modifier)) {
|
||||||
|
item(key = "spacer_top") { Spacer(modifier = Modifier.height(topPadding)) }
|
||||||
|
item(key = "battery") {
|
||||||
|
BatteryView(
|
||||||
|
batteryList = state.battery,
|
||||||
|
budsRes = state.instance?.model?.budsRes ?: R.drawable.airpods_pro_2_case,
|
||||||
|
caseRes = state.instance?.model?.caseRes ?: R.drawable.airpods_pro_2_case
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item(key = "spacer_battery") { Spacer(modifier = Modifier.height(32.dp)) }
|
||||||
|
|
||||||
|
item(key = "name") {
|
||||||
|
NavigationButton(
|
||||||
|
to = "rename",
|
||||||
|
name = stringResource(R.string.name),
|
||||||
|
currentState = deviceName.text,
|
||||||
|
navController = navController,
|
||||||
|
independent = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasHearingAidCapability =
|
||||||
|
state.instance?.model?.capabilities?.contains(Capability.HEARING_AID) == true
|
||||||
|
val hasPPECapability =
|
||||||
|
state.instance?.model?.capabilities?.contains(Capability.PPE) == true
|
||||||
|
|
||||||
|
if (hasHearingAidCapability || hasPPECapability) {
|
||||||
|
if (hasPPECapability || (state.vendorIdHook && hasHearingAidCapability)) item(
|
||||||
|
key = "spacer_hearing_health"
|
||||||
|
) { Spacer(modifier = Modifier.height(24.dp)) }
|
||||||
|
item(key = "hearing_health") {
|
||||||
|
HearingHealthSettings(
|
||||||
|
navController = navController,
|
||||||
|
hasPPECapability = hasPPECapability,
|
||||||
|
hasHearingAidCapability = hasHearingAidCapability,
|
||||||
|
vendorIdHook = state.vendorIdHook
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (capabilities.contains(Capability.LISTENING_MODE)) {
|
||||||
|
item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "noise_control") {
|
||||||
|
NoiseControlSettings(
|
||||||
|
showOffListeningMode = state.offListeningMode,
|
||||||
|
noiseControlModeValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE]?.getOrNull(
|
||||||
|
0
|
||||||
|
)?.toInt() ?: 3,
|
||||||
|
onNoiseControlModeChanged = {
|
||||||
|
viewModel.setControlCommandInt(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE,
|
||||||
|
it
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (capabilities.contains(Capability.STEM_CONFIG)) {
|
||||||
|
item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "press_hold") {
|
||||||
|
PressAndHoldSettings(
|
||||||
|
navController = navController,
|
||||||
|
leftAction = state.leftAction,
|
||||||
|
rightAction = state.rightAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "call_control") {
|
||||||
|
val 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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+698
@@ -0,0 +1,698 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
|
import android.accessibilityservice.AccessibilityServiceInfo
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.view.accessibility.AccessibilityManager
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.presentation.components.SelectItem
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledSelectList
|
||||||
|
import me.kavishdevar.librepods.services.AppListenerService
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CameraControlScreen(viewModel: AirPodsViewModel) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val currentCameraAction by viewModel.cameraAction.collectAsState()
|
||||||
|
|
||||||
|
fun isAppListenerServiceEnabled(context: Context): Boolean {
|
||||||
|
val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
|
||||||
|
val enabledServices =
|
||||||
|
am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
|
||||||
|
val serviceComponent = ComponentName(context, AppListenerService::class.java)
|
||||||
|
return enabledServices.any {
|
||||||
|
it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName &&
|
||||||
|
it.resolveInfo.serviceInfo.name == serviceComponent.className
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleSelection(action: StemPressType?) {
|
||||||
|
if (action != null && !isAppListenerServiceEnabled(context)) {
|
||||||
|
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||||
|
} else {
|
||||||
|
viewModel.setCameraAction(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val cameraOptions = remember(currentCameraAction) {
|
||||||
|
listOf(
|
||||||
|
SelectItem(
|
||||||
|
name = "Off",
|
||||||
|
selected = currentCameraAction == null,
|
||||||
|
onClick = { handleSelection(null) }
|
||||||
|
),
|
||||||
|
SelectItem(
|
||||||
|
name = "Press once",
|
||||||
|
selected = currentCameraAction == StemPressType.SINGLE_PRESS,
|
||||||
|
onClick = { handleSelection(StemPressType.SINGLE_PRESS) }
|
||||||
|
),
|
||||||
|
SelectItem(
|
||||||
|
name = "Press and hold AirPods",
|
||||||
|
selected = currentCameraAction == StemPressType.LONG_PRESS,
|
||||||
|
onClick = { handleSelection(StemPressType.LONG_PRESS) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.camera_control)
|
||||||
|
) { spacerHeight ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
|
StyledSelectList(items = cameraOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-8
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
@@ -82,10 +82,10 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
import me.kavishdevar.librepods.data.BatteryStatus
|
||||||
import me.kavishdevar.librepods.constants.isHeadTrackingData
|
import me.kavishdevar.librepods.data.isHeadTrackingData
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@@ -335,12 +335,11 @@ fun DebugScreen(navController: NavController) {
|
|||||||
expandedItems.value = emptySet()
|
expandedItems.value = emptySet()
|
||||||
},
|
},
|
||||||
icon = "",
|
icon = "",
|
||||||
darkMode = isDarkTheme,
|
|
||||||
backdrop = scaffoldBackdrop
|
backdrop = scaffoldBackdrop
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
) { spacerHeight, hazeState ->
|
) { topPadding, hazeState, bottomPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -349,7 +348,7 @@ fun DebugScreen(navController: NavController) {
|
|||||||
.layerBackdrop(backdrop)
|
.layerBackdrop(backdrop)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
+90
-83
@@ -21,12 +21,13 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.content.Context
|
import android.graphics.Paint
|
||||||
import android.os.Build
|
import android.graphics.RadialGradient
|
||||||
|
import android.graphics.Shader
|
||||||
|
import android.graphics.Typeface
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.ExperimentalAnimationApi
|
import androidx.compose.animation.ExperimentalAnimationApi
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
@@ -45,6 +46,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
@@ -74,7 +76,6 @@ import androidx.compose.ui.graphics.asAndroidPath
|
|||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.graphics.nativeCanvas
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.drawText
|
import androidx.compose.ui.text.drawText
|
||||||
@@ -83,9 +84,9 @@ import androidx.compose.ui.text.font.FontFamily
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavController
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
@@ -94,12 +95,13 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledButton
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.HeadTracking
|
import me.kavishdevar.librepods.utils.HeadTracking
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
@@ -107,14 +109,14 @@ import kotlin.math.sin
|
|||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HeadTrackingScreen() {
|
fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
ServiceManager.getService()?.startHeadTracking()
|
viewModel.startHeadTracking()
|
||||||
onDispose {
|
onDispose {
|
||||||
ServiceManager.getService()?.stopHeadTracking()
|
viewModel.stopHeadTracking()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
@@ -127,25 +129,22 @@ fun HeadTrackingScreen() {
|
|||||||
title = stringResource(R.string.head_tracking),
|
title = stringResource(R.string.head_tracking),
|
||||||
actionButtons = listOf(
|
actionButtons = listOf(
|
||||||
{ scaffoldBackdrop ->
|
{ scaffoldBackdrop ->
|
||||||
var isActive by remember { mutableStateOf(ServiceManager.getService()?.isHeadTrackingActive == true) }
|
|
||||||
StyledIconButton(
|
StyledIconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (ServiceManager.getService()?.isHeadTrackingActive == false) {
|
if (!state.headTrackingActive) {
|
||||||
ServiceManager.getService()?.startHeadTracking()
|
viewModel.startHeadTracking()
|
||||||
Log.d("HeadTrackingScreen", "Head tracking started")
|
Log.d("HeadTrackingScreen", "Head tracking started")
|
||||||
} else {
|
} else {
|
||||||
ServiceManager.getService()?.stopHeadTracking()
|
viewModel.stopHeadTracking()
|
||||||
Log.d("HeadTrackingScreen", "Head tracking stopped")
|
Log.d("HeadTrackingScreen", "Head tracking stopped")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon = if (isActive) "" else "",
|
icon = if (state.headTrackingActive) "" else "",
|
||||||
darkMode = isDarkTheme,
|
|
||||||
backdrop = scaffoldBackdrop
|
backdrop = scaffoldBackdrop
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
) { spacerHeight, hazeState ->
|
) { topPadding, hazeState, _ ->
|
||||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
var gestureText by remember { mutableStateOf("") }
|
var gestureText by remember { mutableStateOf("") }
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
@@ -166,35 +165,49 @@ fun HeadTrackingScreen() {
|
|||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
|
if (!state.isPremium) {
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
navController.navigate("purchase_screen")
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
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",
|
||||||
sharedPreferences = sharedPreferences,
|
checked = state.headGesturesEnabled,
|
||||||
sharedPreferenceKey = "head_gestures",
|
onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
|
||||||
)
|
enabled = state.isPremium,
|
||||||
|
description = stringResource(R.string.head_gestures_details)
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.head_gestures_details),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor.copy(0.6f)
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(start = 4.dp)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
"Head Orientation",
|
"Head Orientation",
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 18.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Bold,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
color = textColor.copy(alpha = 0.6f),
|
||||||
color = textColor
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
),
|
),
|
||||||
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp)
|
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
|
||||||
)
|
)
|
||||||
HeadVisualization()
|
HeadVisualization()
|
||||||
|
|
||||||
@@ -202,12 +215,12 @@ fun HeadTrackingScreen() {
|
|||||||
Text(
|
Text(
|
||||||
"Velocity",
|
"Velocity",
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 18.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Bold,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
color = textColor.copy(alpha = 0.6f),
|
||||||
color = textColor
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
),
|
),
|
||||||
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp)
|
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
|
||||||
)
|
)
|
||||||
AccelerationPlot()
|
AccelerationPlot()
|
||||||
|
|
||||||
@@ -383,7 +396,8 @@ private fun HeadVisualization() {
|
|||||||
.aspectRatio(2f),
|
.aspectRatio(2f),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = backgroundColor
|
containerColor = backgroundColor
|
||||||
)
|
),
|
||||||
|
shape = RoundedCornerShape(28.dp)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -458,9 +472,9 @@ private fun HeadVisualization() {
|
|||||||
spherePath.close()
|
spherePath.close()
|
||||||
|
|
||||||
drawContext.canvas.nativeCanvas.apply {
|
drawContext.canvas.nativeCanvas.apply {
|
||||||
val paint = android.graphics.Paint().apply {
|
val paint = Paint().apply {
|
||||||
style = android.graphics.Paint.Style.FILL
|
style = Paint.Style.FILL
|
||||||
shader = android.graphics.RadialGradient(
|
shader = RadialGradient(
|
||||||
center.x + sinY * faceRadius * 0.3f,
|
center.x + sinY * faceRadius * 0.3f,
|
||||||
center.y - sinP * faceRadius * 0.3f,
|
center.y - sinP * faceRadius * 0.3f,
|
||||||
faceRadius * 1.4f,
|
faceRadius * 1.4f,
|
||||||
@@ -472,14 +486,14 @@ private fun HeadVisualization() {
|
|||||||
backgroundColor.copy(alpha = 0.7f).toArgb()
|
backgroundColor.copy(alpha = 0.7f).toArgb()
|
||||||
),
|
),
|
||||||
floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f),
|
floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f),
|
||||||
android.graphics.Shader.TileMode.CLAMP
|
Shader.TileMode.CLAMP
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
drawPath(spherePath.asAndroidPath(), paint)
|
drawPath(spherePath.asAndroidPath(), paint)
|
||||||
|
|
||||||
val highlightPaint = android.graphics.Paint().apply {
|
val highlightPaint = Paint().apply {
|
||||||
style = android.graphics.Paint.Style.FILL
|
style = Paint.Style.FILL
|
||||||
shader = android.graphics.RadialGradient(
|
shader = RadialGradient(
|
||||||
center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f,
|
center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f,
|
||||||
center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f,
|
center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f,
|
||||||
faceRadius * 0.9f,
|
faceRadius * 0.9f,
|
||||||
@@ -489,15 +503,15 @@ private fun HeadVisualization() {
|
|||||||
android.graphics.Color.TRANSPARENT
|
android.graphics.Color.TRANSPARENT
|
||||||
),
|
),
|
||||||
floatArrayOf(0f, 0.3f, 1f),
|
floatArrayOf(0f, 0.3f, 1f),
|
||||||
android.graphics.Shader.TileMode.CLAMP
|
Shader.TileMode.CLAMP
|
||||||
)
|
)
|
||||||
alpha = if (darkTheme) 30 else 60
|
alpha = if (darkTheme) 30 else 60
|
||||||
}
|
}
|
||||||
drawPath(spherePath.asAndroidPath(), highlightPaint)
|
drawPath(spherePath.asAndroidPath(), highlightPaint)
|
||||||
|
|
||||||
val secondaryHighlightPaint = android.graphics.Paint().apply {
|
val secondaryHighlightPaint = Paint().apply {
|
||||||
style = android.graphics.Paint.Style.FILL
|
style = Paint.Style.FILL
|
||||||
shader = android.graphics.RadialGradient(
|
shader = RadialGradient(
|
||||||
center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f,
|
center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f,
|
||||||
center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f,
|
center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f,
|
||||||
faceRadius * 0.7f,
|
faceRadius * 0.7f,
|
||||||
@@ -506,15 +520,15 @@ private fun HeadVisualization() {
|
|||||||
android.graphics.Color.TRANSPARENT
|
android.graphics.Color.TRANSPARENT
|
||||||
),
|
),
|
||||||
floatArrayOf(0f, 1f),
|
floatArrayOf(0f, 1f),
|
||||||
android.graphics.Shader.TileMode.CLAMP
|
Shader.TileMode.CLAMP
|
||||||
)
|
)
|
||||||
alpha = if (darkTheme) 15 else 30
|
alpha = if (darkTheme) 15 else 30
|
||||||
}
|
}
|
||||||
drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint)
|
drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint)
|
||||||
|
|
||||||
val shadowPaint = android.graphics.Paint().apply {
|
val shadowPaint = Paint().apply {
|
||||||
style = android.graphics.Paint.Style.FILL
|
style = Paint.Style.FILL
|
||||||
shader = android.graphics.RadialGradient(
|
shader = RadialGradient(
|
||||||
center.x + sinY * faceRadius * 0.5f,
|
center.x + sinY * faceRadius * 0.5f,
|
||||||
center.y - sinP * faceRadius * 0.5f,
|
center.y - sinP * faceRadius * 0.5f,
|
||||||
faceRadius * 1.1f,
|
faceRadius * 1.1f,
|
||||||
@@ -523,7 +537,7 @@ private fun HeadVisualization() {
|
|||||||
android.graphics.Color.BLACK
|
android.graphics.Color.BLACK
|
||||||
),
|
),
|
||||||
floatArrayOf(0.7f, 1f),
|
floatArrayOf(0.7f, 1f),
|
||||||
android.graphics.Shader.TileMode.CLAMP
|
Shader.TileMode.CLAMP
|
||||||
)
|
)
|
||||||
alpha = if (darkTheme) 40 else 20
|
alpha = if (darkTheme) 40 else 20
|
||||||
}
|
}
|
||||||
@@ -583,13 +597,13 @@ private fun HeadVisualization() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
drawContext.canvas.nativeCanvas.apply {
|
drawContext.canvas.nativeCanvas.apply {
|
||||||
val paint = android.graphics.Paint().apply {
|
val paint = Paint().apply {
|
||||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||||
textSize = 12.sp.toPx()
|
textSize = 12.sp.toPx()
|
||||||
textAlign = android.graphics.Paint.Align.RIGHT
|
textAlign = Paint.Align.RIGHT
|
||||||
typeface = android.graphics.Typeface.create(
|
typeface = Typeface.create(
|
||||||
"SF Pro",
|
"SF Pro",
|
||||||
android.graphics.Typeface.NORMAL
|
Typeface.NORMAL
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,7 +648,8 @@ private fun AccelerationPlot() {
|
|||||||
.height(300.dp),
|
.height(300.dp),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
|
containerColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
|
||||||
)
|
),
|
||||||
|
shape = RoundedCornerShape(28.dp)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -702,10 +717,10 @@ private fun AccelerationPlot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
drawContext.canvas.nativeCanvas.apply {
|
drawContext.canvas.nativeCanvas.apply {
|
||||||
val paint = android.graphics.Paint().apply {
|
val paint = Paint().apply {
|
||||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||||
textSize = 12.sp.toPx()
|
textSize = 12.sp.toPx()
|
||||||
textAlign = android.graphics.Paint.Align.RIGHT
|
textAlign = Paint.Align.RIGHT
|
||||||
}
|
}
|
||||||
|
|
||||||
drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint)
|
drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint)
|
||||||
@@ -718,20 +733,20 @@ private fun AccelerationPlot() {
|
|||||||
|
|
||||||
drawCircle(Color(0xFF007AFF), 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
|
drawCircle(Color(0xFF007AFF), 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
|
||||||
drawContext.canvas.nativeCanvas.apply {
|
drawContext.canvas.nativeCanvas.apply {
|
||||||
val paint = android.graphics.Paint().apply {
|
val paint = Paint().apply {
|
||||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||||
textSize = 12.sp.toPx()
|
textSize = 12.sp.toPx()
|
||||||
textAlign = android.graphics.Paint.Align.LEFT
|
textAlign = Paint.Align.LEFT
|
||||||
}
|
}
|
||||||
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
|
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
|
||||||
}
|
}
|
||||||
|
|
||||||
drawCircle(Color(0xFFFF3B30), 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
|
drawCircle(Color(0xFFFF3B30), 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
|
||||||
drawContext.canvas.nativeCanvas.apply {
|
drawContext.canvas.nativeCanvas.apply {
|
||||||
val paint = android.graphics.Paint().apply {
|
val paint = Paint().apply {
|
||||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||||
textSize = 12.sp.toPx()
|
textSize = 12.sp.toPx()
|
||||||
textAlign = android.graphics.Paint.Align.LEFT
|
textAlign = Paint.Align.LEFT
|
||||||
}
|
}
|
||||||
drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint)
|
drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint)
|
||||||
}
|
}
|
||||||
@@ -739,11 +754,3 @@ private fun AccelerationPlot() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun HeadTrackingScreenPreview() {
|
|
||||||
HeadTrackingScreen()
|
|
||||||
}
|
|
||||||
+23
-51
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -31,9 +31,10 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -41,7 +42,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
@@ -50,15 +50,16 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.composables.StyledSlider
|
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
import me.kavishdevar.librepods.utils.ATTHandles
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
import me.kavishdevar.librepods.utils.HearingAidSettings
|
import me.kavishdevar.librepods.data.HearingAidSettings
|
||||||
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
|
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
||||||
import me.kavishdevar.librepods.utils.sendHearingAidSettings
|
import me.kavishdevar.librepods.data.sendHearingAidSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@@ -69,13 +70,14 @@ private const val TAG = "HearingAidAdjustments"
|
|||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) {
|
fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
|
||||||
isSystemInDarkTheme()
|
isSystemInDarkTheme()
|
||||||
val verticalScrollState = rememberScrollState()
|
val verticalScrollState = rememberScrollState()
|
||||||
val hazeState = remember { HazeState() }
|
val hazeState = remember { HazeState() }
|
||||||
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
|
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
|
||||||
|
|
||||||
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.adjustments)
|
title = stringResource(R.string.adjustments)
|
||||||
@@ -125,25 +127,6 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val hearingAidEnabled = remember {
|
|
||||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
|
||||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
|
||||||
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
|
|
||||||
}
|
|
||||||
|
|
||||||
val hearingAidListener = remember {
|
|
||||||
object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
|
|
||||||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
|
|
||||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
|
||||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
|
||||||
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val hearingAidATTListener = remember {
|
val hearingAidATTListener = remember {
|
||||||
object : (ByteArray) -> Unit {
|
object : (ByteArray) -> Unit {
|
||||||
override fun invoke(value: ByteArray) {
|
override fun invoke(value: ByteArray) {
|
||||||
@@ -165,19 +148,6 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
|
||||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
|
||||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
|
||||||
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) {
|
LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) {
|
||||||
if (!initialLoadComplete.value) {
|
if (!initialLoadComplete.value) {
|
||||||
Log.d(TAG, "Initial device load not complete - skipping send")
|
Log.d(TAG, "Initial device load not complete - skipping send")
|
||||||
@@ -256,7 +226,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.amplification),
|
label = stringResource(R.string.amplification),
|
||||||
valueRange = -1f..1f,
|
valueRange = -1f..1f,
|
||||||
mutableFloatState = amplificationSliderValue,
|
value = amplificationSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
amplificationSliderValue.floatValue = it
|
amplificationSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -268,14 +238,15 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.swipe_to_control_amplification),
|
label = stringResource(R.string.swipe_to_control_amplification),
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE,
|
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
|
||||||
|
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, it) },
|
||||||
description = stringResource(R.string.swipe_amplification_description)
|
description = stringResource(R.string.swipe_amplification_description)
|
||||||
)
|
)
|
||||||
|
|
||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.balance),
|
label = stringResource(R.string.balance),
|
||||||
valueRange = -1f..1f,
|
valueRange = -1f..1f,
|
||||||
mutableFloatState = balanceSliderValue,
|
value = balanceSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
balanceSliderValue.floatValue = it
|
balanceSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -288,7 +259,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.tone),
|
label = stringResource(R.string.tone),
|
||||||
valueRange = -1f..1f,
|
valueRange = -1f..1f,
|
||||||
mutableFloatState = toneSliderValue,
|
value = toneSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
toneSliderValue.floatValue = it
|
toneSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -300,7 +271,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.ambient_noise_reduction),
|
label = stringResource(R.string.ambient_noise_reduction),
|
||||||
valueRange = 0f..1f,
|
valueRange = 0f..1f,
|
||||||
mutableFloatState = ambientNoiseReductionSliderValue,
|
value = ambientNoiseReductionSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
ambientNoiseReductionSliderValue.floatValue = it
|
ambientNoiseReductionSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -311,7 +282,8 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.conversation_boost),
|
label = stringResource(R.string.conversation_boost),
|
||||||
checkedState = conversationBoostEnabled,
|
checked = conversationBoostEnabled.value,
|
||||||
|
onCheckedChange = { conversationBoostEnabled.value = it },
|
||||||
independent = true,
|
independent = true,
|
||||||
description = stringResource(R.string.conversation_boost_description)
|
description = stringResource(R.string.conversation_boost_description)
|
||||||
)
|
)
|
||||||
+40
-58
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -37,8 +37,9 @@ import androidx.compose.material3.HorizontalDivider
|
|||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -61,15 +62,14 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.ConfirmationDialog
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
import me.kavishdevar.librepods.composables.NavigationButton
|
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.data.sendTransparencySettings
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.presentation.components.NavigationButton
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.utils.ATTHandles
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import me.kavishdevar.librepods.utils.sendTransparencySettings
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
private const val TAG = "AccessibilitySettings"
|
private const val TAG = "AccessibilitySettings"
|
||||||
@@ -78,23 +78,22 @@ private const val TAG = "AccessibilitySettings"
|
|||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HearingAidScreen(navController: NavController) {
|
fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val verticalScrollState = rememberScrollState()
|
val verticalScrollState = rememberScrollState()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val attManager = ServiceManager.getService()?.attManager ?: return
|
|
||||||
|
|
||||||
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
|
|
||||||
|
|
||||||
val showDialog = remember { mutableStateOf(false) }
|
val showDialog = remember { mutableStateOf(false) }
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
val initialLoad = remember { mutableStateOf(true) }
|
val initialLoad = remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val hearingAidEnabled = remember {
|
val hearingAidEnabled = remember {
|
||||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
val aidStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]
|
||||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
val assistStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG]
|
||||||
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
|
mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte()))
|
||||||
}
|
}
|
||||||
|
|
||||||
val hazeStateS = remember { mutableStateOf(HazeState()) } // dont question this. i could possibly use something other than initializing it with an empty state and then replacing it with the the one provided by the scaffold
|
val hazeStateS = remember { mutableStateOf(HazeState()) } // dont question this. i could possibly use something other than initializing it with an empty state and then replacing it with the the one provided by the scaffold
|
||||||
@@ -102,7 +101,7 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.hearing_aid),
|
title = stringResource(R.string.hearing_aid),
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
) { spacerHeight, hazeState ->
|
) { topPadding, hazeState, bottomPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layerBackdrop(backdrop)
|
.layerBackdrop(backdrop)
|
||||||
@@ -113,43 +112,18 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
hazeStateS.value = hazeState
|
hazeStateS.value = hazeState
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
val hearingAidListener = remember {
|
|
||||||
object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
|
|
||||||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
|
|
||||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
|
||||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
|
||||||
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// val mediaAssistEnabled = remember { mutableStateOf(false) }
|
// val mediaAssistEnabled = remember { mutableStateOf(false) }
|
||||||
// val adjustMediaEnabled = remember { mutableStateOf(false) }
|
// val adjustMediaEnabled = remember { mutableStateOf(false) }
|
||||||
// val adjustPhoneEnabled = remember { mutableStateOf(false) }
|
// val adjustPhoneEnabled = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
|
||||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
|
||||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(hearingAidEnabled.value) {
|
LaunchedEffect(hearingAidEnabled.value) {
|
||||||
if (hearingAidEnabled.value && !initialLoad.value) {
|
if (hearingAidEnabled.value && !initialLoad.value) {
|
||||||
showDialog.value = true
|
showDialog.value = true
|
||||||
} else if (!hearingAidEnabled.value && !initialLoad.value) {
|
} else if (!hearingAidEnabled.value && !initialLoad.value) {
|
||||||
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x02))
|
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x02))
|
||||||
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x02.toByte())
|
viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x02.toByte())
|
||||||
hearingAidEnabled.value = false
|
hearingAidEnabled.value = false
|
||||||
}
|
}
|
||||||
initialLoad.value = false
|
initialLoad.value = false
|
||||||
@@ -186,7 +160,8 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
) {
|
) {
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.hearing_aid),
|
label = stringResource(R.string.hearing_aid),
|
||||||
checkedState = hearingAidEnabled,
|
checked = hearingAidEnabled.value,
|
||||||
|
onCheckedChange = { hearingAidEnabled.value = it },
|
||||||
independent = false
|
independent = false
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
@@ -198,7 +173,7 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
NavigationButton(
|
NavigationButton(
|
||||||
to = "hearing_aid_adjustments",
|
to = "hearing_aid_adjustments",
|
||||||
name = stringResource(R.string.adjustments),
|
name = stringResource(R.string.adjustments),
|
||||||
navController,
|
navController = navController,
|
||||||
independent = false
|
independent = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -217,7 +192,7 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
NavigationButton(
|
NavigationButton(
|
||||||
to = "update_hearing_test",
|
to = "update_hearing_test",
|
||||||
name = stringResource(R.string.update_hearing_test),
|
name = stringResource(R.string.update_hearing_test),
|
||||||
navController,
|
navController = navController,
|
||||||
independent = true
|
independent = true
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -258,9 +233,9 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
// independent = false
|
// independent = false
|
||||||
// )
|
// )
|
||||||
// }
|
// }
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ConfirmationDialog(
|
ConfirmationDialog(
|
||||||
showDialog = showDialog,
|
showDialog = showDialog,
|
||||||
title = "Enable Hearing Aid",
|
title = "Enable Hearing Aid",
|
||||||
@@ -269,25 +244,32 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
dismissText = "Cancel",
|
dismissText = "Cancel",
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
showDialog.value = false
|
showDialog.value = false
|
||||||
val enrolled = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(0) == 0x01.toByte()
|
val enrolled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0) == 0x01.toByte()
|
||||||
if (!enrolled) {
|
if (!enrolled) {
|
||||||
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01))
|
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
|
||||||
} else {
|
} else {
|
||||||
aacpManager.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01))
|
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
|
||||||
}
|
}
|
||||||
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x01.toByte())
|
viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x01.toByte())
|
||||||
hearingAidEnabled.value = true
|
hearingAidEnabled.value = true
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
val data = attManager.read(ATTHandles.TRANSPARENCY)
|
if (state.hearingAidData.isEmpty()) {
|
||||||
val parsed = parseTransparencySettingsResponse(data)
|
Log.w(TAG, "read failed")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
|
||||||
val disabledSettings = parsed.copy(enabled = false)
|
val disabledSettings = parsed.copy(enabled = false)
|
||||||
sendTransparencySettings(attManager, disabledSettings)
|
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error disabling transparency: ${e.message}")
|
Log.e(TAG, "Error disabling transparency: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onDismiss = {
|
||||||
|
hearingAidEnabled.value = false
|
||||||
|
showDialog.value = false
|
||||||
|
},
|
||||||
hazeState = hazeStateS.value,
|
hazeState = hazeStateS.value,
|
||||||
// backdrop = backdrop
|
// backdrop = backdrop
|
||||||
)
|
)
|
||||||
+36
-36
@@ -16,50 +16,33 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.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 dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
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.presentation.viewmodel.AirPodsViewModel
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
private var debounceJob: Job? = null
|
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HearingProtectionScreen(navController: NavController) {
|
fun HearingProtectionScreen(viewModel: AirPodsViewModel) {
|
||||||
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 ->
|
||||||
@@ -71,19 +54,36 @@ fun HearingProtectionScreen(navController: NavController) {
|
|||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
|
|
||||||
StyledToggle(
|
if (state.vendorIdHook) {
|
||||||
title = stringResource(R.string.environmental_noise),
|
StyledToggle(
|
||||||
label = stringResource(R.string.loud_sound_reduction),
|
title = stringResource(R.string.environmental_noise),
|
||||||
description = stringResource(R.string.loud_sound_reduction_description),
|
label = stringResource(R.string.loud_sound_reduction),
|
||||||
attHandle = ATTHandles.LOUD_SOUND_REDUCTION
|
description = stringResource(R.string.loud_sound_reduction_description),
|
||||||
)
|
checked = state.loudSoundReductionEnabled,
|
||||||
|
onCheckedChange = {
|
||||||
|
viewModel.setATTCharacteristicValue(
|
||||||
|
ATTHandles.LOUD_SOUND_REDUCTION,
|
||||||
|
byteArrayOf(if (it) 1.toByte() else 0.toByte())
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
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),
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG
|
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG]?.getOrNull(
|
||||||
|
0
|
||||||
|
)?.toInt() == 1,
|
||||||
|
onCheckedChange = {
|
||||||
|
viewModel.setControlCommandBoolean(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = state.isPremium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+4
-15
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -28,12 +28,9 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
@@ -42,17 +39,9 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|||||||
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
||||||
import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
|
import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.composables.StyledSlider
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
private var debounceJob: Job? = null
|
private var debounceJob: Job? = null
|
||||||
@@ -76,7 +65,7 @@ fun OpenSourceLicensesScreen(navController: NavController) {
|
|||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = LocalContext.current
|
||||||
val libraries by produceLibraries {
|
val libraries by produceLibraries {
|
||||||
context.resources.openRawResource(R.raw.aboutlibraries)
|
context.resources.openRawResource(R.raw.aboutlibraries)
|
||||||
.bufferedReader()
|
.bufferedReader()
|
||||||
+62
-70
@@ -18,41 +18,29 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.wrapContentWidth
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
@@ -66,54 +54,34 @@ import com.kyant.backdrop.backdrops.layerBackdrop
|
|||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.SelectItem
|
import me.kavishdevar.librepods.presentation.components.SelectItem
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.composables.StyledSelectList
|
import me.kavishdevar.librepods.presentation.components.StyledSelectList
|
||||||
import me.kavishdevar.librepods.constants.StemAction
|
import me.kavishdevar.librepods.data.StemAction
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import kotlin.experimental.and
|
import kotlin.experimental.and
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RightDivider() {
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(start = 72.dp, end = 20.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RightDividerNoIcon() {
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(start = 20.dp, end = 20.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LongPress(navController: NavController, name: String) {
|
fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavController) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
val modesByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
val state by viewModel.uiState.collectAsState()
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
|
||||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
val modesByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0) ?: 0
|
||||||
|
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}")
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
|
||||||
|
|
||||||
if (modesByte != null) {
|
|
||||||
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
|
|
||||||
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
|
|
||||||
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}")
|
|
||||||
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
|
|
||||||
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
|
|
||||||
}
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||||
val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
|
val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
|
||||||
@@ -124,9 +92,8 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = name
|
title = name
|
||||||
) { spacerHeight ->
|
) { spacerHeight ->
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
Column (
|
Column (
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layerBackdrop(backdrop)
|
.layerBackdrop(backdrop)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(top = 8.dp)
|
.padding(top = 8.dp)
|
||||||
@@ -148,11 +115,36 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
longPressAction = StemAction.DIGITAL_ASSISTANT
|
longPressAction = StemAction.DIGITAL_ASSISTANT
|
||||||
sharedPreferences.edit { putString(prefKey, StemAction.DIGITAL_ASSISTANT.name) }
|
sharedPreferences.edit { putString(prefKey, StemAction.DIGITAL_ASSISTANT.name) }
|
||||||
}
|
},
|
||||||
|
enabled = state.isPremium
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
StyledSelectList(items = actionItems)
|
StyledSelectList(items = actionItems)
|
||||||
|
|
||||||
|
if (!state.isPremium) {
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
navController.navigate("purchase_screen")
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
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(
|
||||||
@@ -161,6 +153,7 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = textColor.copy(alpha = 0.6f),
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
),
|
),
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -176,10 +169,11 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
val allowOff = offListeningModeValue == 1.toByte()
|
val allowOff = offListeningModeValue == 1.toByte()
|
||||||
Log.d("PressAndHoldSettingsScreen", "Allow Off option: $allowOff")
|
Log.d("PressAndHoldSettingsScreen", "Allow Off option: $allowOff")
|
||||||
|
|
||||||
val initialByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
val initialByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
?.get(0)?.toInt()
|
||||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toInt() ?: sharedPreferences.getInt("long_press_byte", 0b0101)
|
?: sharedPreferences.getInt("long_press_byte", 0b0101)
|
||||||
var currentByte by remember { mutableStateOf(initialByte) }
|
|
||||||
|
var currentByte by remember { mutableIntStateOf(initialByte) }
|
||||||
|
|
||||||
val listeningModeItems = mutableListOf<SelectItem>()
|
val listeningModeItems = mutableListOf<SelectItem>()
|
||||||
if (allowOff) {
|
if (allowOff) {
|
||||||
@@ -197,8 +191,8 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
} else {
|
} else {
|
||||||
currentByte or bit
|
currentByte or bit
|
||||||
}
|
}
|
||||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
viewModel.setControlCommandByte(
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
|
||||||
newValue.toByte()
|
newValue.toByte()
|
||||||
)
|
)
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
@@ -223,8 +217,8 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
} else {
|
} else {
|
||||||
currentByte or bit
|
currentByte or bit
|
||||||
}
|
}
|
||||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
viewModel.setControlCommandByte(
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
|
||||||
newValue.toByte()
|
newValue.toByte()
|
||||||
)
|
)
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
@@ -246,8 +240,8 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
} else {
|
} else {
|
||||||
currentByte or bit
|
currentByte or bit
|
||||||
}
|
}
|
||||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
viewModel.setControlCommandByte(
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
|
||||||
newValue.toByte()
|
newValue.toByte()
|
||||||
)
|
)
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
@@ -269,8 +263,8 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
} else {
|
} else {
|
||||||
currentByte or bit
|
currentByte or bit
|
||||||
}
|
}
|
||||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
viewModel.setControlCommandByte(
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
|
||||||
newValue.toByte()
|
newValue.toByte()
|
||||||
)
|
)
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
@@ -296,9 +290,7 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Log.d("PressAndHoldSettingsScreen", "Current byte: ${ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
Log.d("PressAndHoldSettingsScreen", "Current byte: ${modesByte.toString(2)}")
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
|
||||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toString(2)}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun countEnabledModes(byteValue: Int): Int {
|
fun countEnabledModes(byteValue: Int): Int {
|
||||||
+496
@@ -0,0 +1,496 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PurchaseScreen(
|
||||||
|
viewModel: PurchaseViewModel = viewModel(),
|
||||||
|
navController: NavController
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.unlock_advanced_features)
|
||||||
|
) { topPadding, hazeState, bottomPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
.hazeSource(state = hazeState)
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)
|
||||||
|
val cardBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
LaunchedEffect(state.isPremium) {
|
||||||
|
if (state.isPremium) {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!state.isPremium) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(backgroundColor)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-15
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -53,26 +53,22 @@ 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.composables.StyledIconButton
|
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
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RenameScreen(navController: NavController) {
|
fun RenameScreen(viewModel: AirPodsViewModel) {
|
||||||
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
|
||||||
@@ -115,7 +111,7 @@ fun RenameScreen(navController: NavController) {
|
|||||||
onValueChange = {
|
onValueChange = {
|
||||||
name.value = it
|
name.value = it
|
||||||
sharedPreferences.edit {putString("name", it.text)}
|
sharedPreferences.edit {putString("name", it.text)}
|
||||||
ServiceManager.getService()?.setName(it.text)
|
viewModel.setName(it.text)
|
||||||
},
|
},
|
||||||
textStyle = TextStyle(
|
textStyle = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
@@ -159,9 +155,3 @@ fun RenameScreen(navController: NavController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun RenameScreenPreview() {
|
|
||||||
RenameScreen(navController = NavController(LocalContext.current))
|
|
||||||
}
|
|
||||||
+40
-72
@@ -16,8 +16,9 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
|
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -41,8 +42,9 @@ import androidx.compose.material3.Slider
|
|||||||
import androidx.compose.material3.SliderDefaults
|
import androidx.compose.material3.SliderDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -58,23 +60,20 @@ import androidx.compose.ui.text.font.FontFamily
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||||
import me.kavishdevar.librepods.composables.StyledSlider
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
import me.kavishdevar.librepods.data.TransparencySettings
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
|
||||||
import me.kavishdevar.librepods.utils.ATTHandles
|
import me.kavishdevar.librepods.data.sendTransparencySettings
|
||||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import me.kavishdevar.librepods.utils.TransparencySettings
|
|
||||||
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
|
|
||||||
import me.kavishdevar.librepods.utils.sendTransparencySettings
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@@ -84,14 +83,10 @@ private const val TAG = "TransparencySettings"
|
|||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun TransparencySettingsScreen(navController: NavController) {
|
fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val verticalScrollState = rememberScrollState()
|
val verticalScrollState = rememberScrollState()
|
||||||
val attManager = ServiceManager.getService()?.attManager ?: return
|
|
||||||
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
|
|
||||||
val isSdpOffsetAvailable =
|
|
||||||
remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
|
|
||||||
|
|
||||||
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
|
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
|
||||||
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||||
@@ -99,9 +94,12 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
|
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.customize_transparency_mode)
|
title = stringResource(R.string.customize_transparency_mode)
|
||||||
){ spacerHeight, hazeState ->
|
){ topPadding, hazeState, bottomPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.hazeSource(hazeState)
|
.hazeSource(hazeState)
|
||||||
@@ -111,7 +109,7 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
val enabled = remember { mutableStateOf(false) }
|
val enabled = remember { mutableStateOf(false) }
|
||||||
@@ -148,23 +146,6 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val transparencyListener = remember {
|
|
||||||
object : (ByteArray) -> Unit {
|
|
||||||
override fun invoke(value: ByteArray) {
|
|
||||||
val parsed = parseTransparencySettingsResponse(value)
|
|
||||||
enabled.value = parsed.enabled
|
|
||||||
amplificationSliderValue.floatValue = parsed.netAmplification
|
|
||||||
balanceSliderValue.floatValue = parsed.balance
|
|
||||||
toneSliderValue.floatValue = parsed.leftTone
|
|
||||||
ambientNoiseReductionSliderValue.floatValue =
|
|
||||||
parsed.leftAmbientNoiseReduction
|
|
||||||
conversationBoostEnabled.value = parsed.leftConversationBoost
|
|
||||||
eq.value = parsed.leftEQ.copyOf()
|
|
||||||
Log.d(TAG, "Updated transparency settings from notification")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
enabled.value,
|
enabled.value,
|
||||||
amplificationSliderValue.floatValue,
|
amplificationSliderValue.floatValue,
|
||||||
@@ -205,35 +186,22 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
balance = balanceSliderValue.floatValue
|
balance = balanceSliderValue.floatValue
|
||||||
)
|
)
|
||||||
Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
|
Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
|
||||||
sendTransparencySettings(attManager, transparencySettings.value)
|
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
attManager.unregisterListener(ATTHandles.TRANSPARENCY, transparencyListener)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
Log.d(TAG, "Connecting to ATT...")
|
Log.d(TAG, "Connecting to ATT...")
|
||||||
try {
|
try {
|
||||||
attManager.enableNotifications(ATTHandles.TRANSPARENCY)
|
|
||||||
attManager.registerListener(ATTHandles.TRANSPARENCY, transparencyListener)
|
|
||||||
|
|
||||||
// If we have an AACP manager, prefer its EQ data to populate EQ controls first
|
// If we have an AACP manager, prefer its EQ data to populate EQ controls first
|
||||||
try {
|
try {
|
||||||
if (aacpManager != null) {
|
Log.d(TAG, "Found AACPManager, reading cached EQ data")
|
||||||
Log.d(TAG, "Found AACPManager, reading cached EQ data")
|
val aacpEQ = state.eqData
|
||||||
val aacpEQ = aacpManager.eqData
|
if (aacpEQ.isNotEmpty()) {
|
||||||
if (aacpEQ.isNotEmpty()) {
|
eq.value = aacpEQ.copyOf()
|
||||||
eq.value = aacpEQ.copyOf()
|
phoneMediaEQ.value = aacpEQ.copyOf()
|
||||||
phoneMediaEQ.value = aacpEQ.copyOf()
|
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
|
||||||
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "AACPManager EQ data empty")
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "No AACPManager available")
|
Log.d(TAG, "AACPManager EQ data empty")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
|
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
|
||||||
@@ -243,7 +211,7 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
for (attempt in 1..3) {
|
for (attempt in 1..3) {
|
||||||
initialReadAttempts.intValue = attempt
|
initialReadAttempts.intValue = attempt
|
||||||
try {
|
try {
|
||||||
val data = attManager.read(ATTHandles.TRANSPARENCY)
|
val data = state.transparencyData
|
||||||
parsedSettings = parseTransparencySettingsResponse(data = data)
|
parsedSettings = parseTransparencySettingsResponse(data = data)
|
||||||
Log.d(TAG, "Parsed settings on attempt $attempt")
|
Log.d(TAG, "Parsed settings on attempt $attempt")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -276,19 +244,19 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only show transparency mode section if SDP offset is available
|
if (state.vendorIdHook) {
|
||||||
if (isSdpOffsetAvailable.value) {
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.transparency_mode),
|
label = stringResource(R.string.transparency_mode),
|
||||||
checkedState = enabled,
|
checked = enabled.value,
|
||||||
independent = true,
|
independent = true,
|
||||||
description = stringResource(R.string.customize_transparency_mode_description)
|
description = stringResource(R.string.customize_transparency_mode_description),
|
||||||
|
onCheckedChange = { enabled.value = it }
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.amplification),
|
label = stringResource(R.string.amplification),
|
||||||
valueRange = -1f..1f,
|
valueRange = -1f..1f,
|
||||||
mutableFloatState = amplificationSliderValue,
|
value = amplificationSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
amplificationSliderValue.floatValue = it
|
amplificationSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -300,7 +268,7 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.balance),
|
label = stringResource(R.string.balance),
|
||||||
valueRange = -1f..1f,
|
valueRange = -1f..1f,
|
||||||
mutableFloatState = balanceSliderValue,
|
value = balanceSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
balanceSliderValue.floatValue = it
|
balanceSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -313,7 +281,7 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.tone),
|
label = stringResource(R.string.tone),
|
||||||
valueRange = -1f..1f,
|
valueRange = -1f..1f,
|
||||||
mutableFloatState = toneSliderValue,
|
value = toneSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
toneSliderValue.floatValue = it
|
toneSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -325,7 +293,7 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.ambient_noise_reduction),
|
label = stringResource(R.string.ambient_noise_reduction),
|
||||||
valueRange = 0f..1f,
|
valueRange = 0f..1f,
|
||||||
mutableFloatState = ambientNoiseReductionSliderValue,
|
value = ambientNoiseReductionSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
ambientNoiseReductionSliderValue.floatValue = it
|
ambientNoiseReductionSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -336,14 +304,12 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.conversation_boost),
|
label = stringResource(R.string.conversation_boost),
|
||||||
checkedState = conversationBoostEnabled,
|
checked = conversationBoostEnabled.value,
|
||||||
independent = true,
|
independent = true,
|
||||||
description = stringResource(R.string.conversation_boost_description)
|
description = stringResource(R.string.conversation_boost_description),
|
||||||
|
onCheckedChange = { conversationBoostEnabled.value = it }
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
// Only show transparency mode EQ section if SDP offset is available
|
|
||||||
if (isSdpOffsetAvailable.value) {
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.equalizer),
|
text = stringResource(R.string.equalizer),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
@@ -443,6 +409,8 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+4
-4
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
@@ -94,7 +94,7 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.utils.LogCollector
|
import me.kavishdevar.librepods.utils.LogCollector
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -216,7 +216,7 @@ fun TroubleshootingScreen(navController: NavController) {
|
|||||||
) {
|
) {
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.troubleshooting)
|
title = stringResource(R.string.troubleshooting)
|
||||||
){ spacerHeight, hazeState ->
|
){ topPadding, hazeState, bottomPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -225,7 +225,7 @@ fun TroubleshootingScreen(navController: NavController) {
|
|||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.saved_logs),
|
text = stringResource(R.string.saved_logs),
|
||||||
+60
-49
@@ -16,9 +16,8 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -33,7 +32,6 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -55,31 +53,25 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.ATTHandles
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
import me.kavishdevar.librepods.utils.HearingAidSettings
|
import me.kavishdevar.librepods.data.HearingAidSettings
|
||||||
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
|
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
||||||
import me.kavishdevar.librepods.utils.sendHearingAidSettings
|
import me.kavishdevar.librepods.data.sendHearingAidSettings
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
|
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
|
||||||
private const val TAG = "HearingAidAdjustments"
|
private const val TAG = "HearingAidAdjustments"
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
fun UpdateHearingTestScreen() {
|
||||||
val verticalScrollState = rememberScrollState()
|
val verticalScrollState = rememberScrollState()
|
||||||
val attManager = ServiceManager.getService()?.attManager
|
val attManager = ServiceManager.getService()?.attManager
|
||||||
if (attManager == null) {
|
if (attManager == null) {
|
||||||
@@ -96,7 +88,7 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.hearing_test)
|
title = stringResource(R.string.hearing_test)
|
||||||
) { spacerHeight, hazeState ->
|
) { topPadding, hazeState, bottomPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.hazeSource(hazeState)
|
.hazeSource(hazeState)
|
||||||
@@ -108,7 +100,7 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
) {
|
) {
|
||||||
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
|
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.hearing_test_value_instruction),
|
text = stringResource(R.string.hearing_test_value_instruction),
|
||||||
@@ -138,17 +130,17 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
HearingAidSettings(
|
HearingAidSettings(
|
||||||
leftEQ = leftEQ.value,
|
leftEQ = leftEQ.value,
|
||||||
rightEQ = rightEQ.value,
|
rightEQ = rightEQ.value,
|
||||||
leftAmplification = leftAmplification.value,
|
leftAmplification = leftAmplification.floatValue,
|
||||||
rightAmplification = rightAmplification.value,
|
rightAmplification = rightAmplification.floatValue,
|
||||||
leftTone = tone.value,
|
leftTone = tone.floatValue,
|
||||||
rightTone = tone.value,
|
rightTone = tone.floatValue,
|
||||||
leftConversationBoost = conversationBoostEnabled.value,
|
leftConversationBoost = conversationBoostEnabled.value,
|
||||||
rightConversationBoost = conversationBoostEnabled.value,
|
rightConversationBoost = conversationBoostEnabled.value,
|
||||||
leftAmbientNoiseReduction = ambientNoiseReduction.value,
|
leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||||
rightAmbientNoiseReduction = ambientNoiseReduction.value,
|
rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||||
netAmplification = leftAmplification.value + rightAmplification.value / 2,
|
netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
|
||||||
balance = 0.5f + (rightAmplification.value - leftAmplification.value) / 2,
|
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
|
||||||
ownVoiceAmplification = ownVoiceAmplification.value
|
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -161,11 +153,11 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
leftEQ.value = parsed.leftEQ.copyOf()
|
leftEQ.value = parsed.leftEQ.copyOf()
|
||||||
rightEQ.value = parsed.rightEQ.copyOf()
|
rightEQ.value = parsed.rightEQ.copyOf()
|
||||||
conversationBoostEnabled.value = parsed.leftConversationBoost
|
conversationBoostEnabled.value = parsed.leftConversationBoost
|
||||||
tone.value = parsed.leftTone
|
tone.floatValue = parsed.leftTone
|
||||||
ambientNoiseReduction.value = parsed.leftAmbientNoiseReduction
|
ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
|
||||||
ownVoiceAmplification.value = parsed.ownVoiceAmplification
|
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
||||||
leftAmplification.value = parsed.leftAmplification
|
leftAmplification.floatValue = parsed.leftAmplification
|
||||||
rightAmplification.value = parsed.rightAmplification
|
rightAmplification.floatValue = parsed.rightAmplification
|
||||||
Log.d(TAG, "Updated hearing aid settings from notification")
|
Log.d(TAG, "Updated hearing aid settings from notification")
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
||||||
@@ -181,31 +173,45 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(leftEQ.value, rightEQ.value, conversationBoostEnabled.value, initialLoadComplete.value, initialReadSucceeded.value, leftAmplification.value, rightAmplification.value, tone.value, ambientNoiseReduction.value, ownVoiceAmplification.value) {
|
LaunchedEffect(
|
||||||
|
leftEQ.value,
|
||||||
|
rightEQ.value,
|
||||||
|
conversationBoostEnabled.value,
|
||||||
|
initialLoadComplete.value,
|
||||||
|
initialReadSucceeded.value,
|
||||||
|
leftAmplification.floatValue,
|
||||||
|
rightAmplification.floatValue,
|
||||||
|
tone.floatValue,
|
||||||
|
ambientNoiseReduction.floatValue,
|
||||||
|
ownVoiceAmplification.floatValue
|
||||||
|
) {
|
||||||
if (!initialLoadComplete.value) {
|
if (!initialLoadComplete.value) {
|
||||||
Log.d(TAG, "Initial device load not complete - skipping send")
|
Log.d(TAG, "Initial device load not complete - skipping send")
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!initialReadSucceeded.value) {
|
if (!initialReadSucceeded.value) {
|
||||||
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"Initial device read not successful yet - skipping send until read succeeds"
|
||||||
|
)
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
hearingAidSettings.value = HearingAidSettings(
|
hearingAidSettings.value = HearingAidSettings(
|
||||||
leftEQ = leftEQ.value,
|
leftEQ = leftEQ.value,
|
||||||
rightEQ = rightEQ.value,
|
rightEQ = rightEQ.value,
|
||||||
leftAmplification = leftAmplification.value,
|
leftAmplification = leftAmplification.floatValue,
|
||||||
rightAmplification = rightAmplification.value,
|
rightAmplification = rightAmplification.floatValue,
|
||||||
leftTone = tone.value,
|
leftTone = tone.floatValue,
|
||||||
rightTone = tone.value,
|
rightTone = tone.floatValue,
|
||||||
leftConversationBoost = conversationBoostEnabled.value,
|
leftConversationBoost = conversationBoostEnabled.value,
|
||||||
rightConversationBoost = conversationBoostEnabled.value,
|
rightConversationBoost = conversationBoostEnabled.value,
|
||||||
leftAmbientNoiseReduction = ambientNoiseReduction.value,
|
leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||||
rightAmbientNoiseReduction = ambientNoiseReduction.value,
|
rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||||
netAmplification = leftAmplification.value + rightAmplification.value / 2,
|
netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
|
||||||
balance = 0.5f + (rightAmplification.value - leftAmplification.value) / 2,
|
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
|
||||||
ownVoiceAmplification = ownVoiceAmplification.value
|
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||||
)
|
)
|
||||||
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||||
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
|
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
|
||||||
@@ -240,14 +246,17 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
leftEQ.value = parsedSettings.leftEQ.copyOf()
|
leftEQ.value = parsedSettings.leftEQ.copyOf()
|
||||||
rightEQ.value = parsedSettings.rightEQ.copyOf()
|
rightEQ.value = parsedSettings.rightEQ.copyOf()
|
||||||
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
||||||
tone.value = parsedSettings.leftTone
|
tone.floatValue = parsedSettings.leftTone
|
||||||
ambientNoiseReduction.value = parsedSettings.leftAmbientNoiseReduction
|
ambientNoiseReduction.floatValue = parsedSettings.leftAmbientNoiseReduction
|
||||||
ownVoiceAmplification.value = parsedSettings.ownVoiceAmplification
|
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
|
||||||
leftAmplification.value = parsedSettings.leftAmplification
|
leftAmplification.floatValue = parsedSettings.leftAmplification
|
||||||
rightAmplification.value = parsedSettings.rightAmplification
|
rightAmplification.floatValue = parsedSettings.rightAmplification
|
||||||
initialReadSucceeded.value = true
|
initialReadSucceeded.value = true
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
@@ -256,7 +265,8 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val frequencies = listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
|
val frequencies =
|
||||||
|
listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -342,6 +352,7 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+15
-27
@@ -16,25 +16,25 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -45,36 +45,23 @@ import androidx.compose.ui.text.font.FontFamily
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
private var debounceJob: Job? = null
|
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VersionScreen(navController: NavController) {
|
fun VersionScreen(viewModel: AirPodsViewModel) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val service = ServiceManager.getService()
|
|
||||||
if (service == null) return
|
|
||||||
val airpodsInstance = service.airpodsInstance
|
|
||||||
if (airpodsInstance == null) return
|
|
||||||
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.customize_adaptive_audio)
|
title = stringResource(R.string.version)
|
||||||
) { spacerHeight ->
|
) { spacerHeight ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -93,7 +80,8 @@ fun VersionScreen(navController: NavController) {
|
|||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = textColor.copy(alpha = 0.6f)
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -120,7 +108,7 @@ fun VersionScreen(navController: NavController) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = airpodsInstance.version1 ?: "N/A",
|
text = state.version1,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = textColor.copy(0.8f),
|
color = textColor.copy(0.8f),
|
||||||
@@ -149,7 +137,7 @@ fun VersionScreen(navController: NavController) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = airpodsInstance.version2 ?: "N/A",
|
text = state.version2,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = textColor.copy(0.8f),
|
color = textColor.copy(0.8f),
|
||||||
@@ -178,7 +166,7 @@ fun VersionScreen(navController: NavController) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = airpodsInstance.version3 ?: "N/A",
|
text = state.version3,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = textColor.copy(0.8f),
|
color = textColor.copy(0.8f),
|
||||||
+1
-1
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.ui.theme
|
package me.kavishdevar.librepods.presentation.theme
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.ui.theme
|
package me.kavishdevar.librepods.presentation.theme
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.ui.theme
|
package me.kavishdevar.librepods.presentation.theme
|
||||||
|
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
+537
@@ -0,0 +1,537 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
package me.kavishdevar.librepods.presentation.viewmodel
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.billing.BillingManager
|
||||||
|
|
||||||
|
data class PurchaseUiState(
|
||||||
|
val isPremium: Boolean = false,
|
||||||
|
val price: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
class PurchaseViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
private val _uiState = MutableStateFlow(PurchaseUiState())
|
||||||
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
observeBilling()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeBilling() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
BillingManager.provider.isPremium.collect { premium ->
|
||||||
|
_uiState.update { it.copy(isPremium = premium) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
BillingManager.provider.price.collect { price ->
|
||||||
|
_uiState.update { it.copy(price = price) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun purchase(context: Context) {
|
||||||
|
BillingManager.provider.purchase(context as Activity)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restorePurchases() {
|
||||||
|
BillingManager.provider.queryPurchases()
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.widgets
|
package me.kavishdevar.librepods.presentation.widgets
|
||||||
|
|
||||||
import android.appwidget.AppWidgetManager
|
import android.appwidget.AppWidgetManager
|
||||||
import android.appwidget.AppWidgetProvider
|
import android.appwidget.AppWidgetProvider
|
||||||
+2
-2
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.widgets
|
package me.kavishdevar.librepods.presentation.widgets
|
||||||
|
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.appwidget.AppWidgetManager
|
import android.appwidget.AppWidgetManager
|
||||||
@@ -29,7 +29,7 @@ import android.util.Log
|
|||||||
import android.widget.RemoteViews
|
import android.widget.RemoteViews
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
class NoiseControlWidget : AppWidgetProvider() {
|
class NoiseControlWidget : AppWidgetProvider() {
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.composables.StyledSlider
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
private var debounceJob: Job? = null
|
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
|
||||||
@Composable
|
|
||||||
fun AdaptiveStrengthScreen(navController: NavController) {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
|
|
||||||
val sliderValue = remember { mutableFloatStateOf(0f) }
|
|
||||||
val service = ServiceManager.getService()!!
|
|
||||||
|
|
||||||
LaunchedEffect(sliderValue) {
|
|
||||||
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
|
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH
|
|
||||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
|
||||||
sliderValueFromAACP?.toFloat()?.let { sliderValue.floatValue = (100 - it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
val listener = remember {
|
|
||||||
object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value) {
|
|
||||||
controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)?.toFloat()?.let {
|
|
||||||
sliderValue.floatValue = (100 - it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
service.aacpManager.registerControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
|
|
||||||
listener
|
|
||||||
)
|
|
||||||
onDispose {
|
|
||||||
service.aacpManager.unregisterControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
|
|
||||||
listener
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
|
||||||
|
|
||||||
StyledScaffold(
|
|
||||||
title = stringResource(R.string.customize_adaptive_audio)
|
|
||||||
) { spacerHeight ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
|
||||||
StyledSlider(
|
|
||||||
label = stringResource(R.string.customize_adaptive_audio),
|
|
||||||
mutableFloatState = sliderValue,
|
|
||||||
onValueChange = {
|
|
||||||
sliderValue.floatValue = it
|
|
||||||
debounceJob?.cancel()
|
|
||||||
debounceJob = CoroutineScope(Dispatchers.Default).launch {
|
|
||||||
delay(300)
|
|
||||||
service.aacpManager.sendControlCommand(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value,
|
|
||||||
(100 - it).toInt()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
valueRange = 0f..100f,
|
|
||||||
snapPoints = listOf(0f, 50f, 100f),
|
|
||||||
startIcon = "",
|
|
||||||
endIcon = "",
|
|
||||||
independent = true,
|
|
||||||
description = stringResource(R.string.adaptive_audio_description)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,453 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.bluetooth.BluetoothDevice
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Context.MODE_PRIVATE
|
|
||||||
import android.content.Context.RECEIVER_EXPORTED
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|
||||||
import com.kyant.backdrop.drawBackdrop
|
|
||||||
import com.kyant.backdrop.highlight.Highlight
|
|
||||||
import dev.chrisbanes.haze.HazeState
|
|
||||||
import dev.chrisbanes.haze.hazeSource
|
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.composables.AboutCard
|
|
||||||
import me.kavishdevar.librepods.composables.AudioSettings
|
|
||||||
import me.kavishdevar.librepods.composables.BatteryView
|
|
||||||
import me.kavishdevar.librepods.composables.CallControlSettings
|
|
||||||
import me.kavishdevar.librepods.composables.ConfirmationDialog
|
|
||||||
import me.kavishdevar.librepods.composables.ConnectionSettings
|
|
||||||
import me.kavishdevar.librepods.composables.HearingHealthSettings
|
|
||||||
import me.kavishdevar.librepods.composables.MicrophoneSettings
|
|
||||||
import me.kavishdevar.librepods.composables.NavigationButton
|
|
||||||
import me.kavishdevar.librepods.composables.NoiseControlSettings
|
|
||||||
import me.kavishdevar.librepods.composables.PressAndHoldSettings
|
|
||||||
import me.kavishdevar.librepods.composables.StyledButton
|
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
|
||||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import me.kavishdevar.librepods.utils.Capability
|
|
||||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
|
||||||
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
|
||||||
@Composable
|
|
||||||
fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
|
||||||
navController: NavController, isConnected: Boolean, isRemotelyConnected: Boolean) {
|
|
||||||
var isLocallyConnected by remember { mutableStateOf(isConnected) }
|
|
||||||
var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
|
|
||||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
|
|
||||||
var device by remember { mutableStateOf(dev) }
|
|
||||||
var deviceName by remember {
|
|
||||||
mutableStateOf(
|
|
||||||
TextFieldValue(
|
|
||||||
sharedPreferences.getString("name", device?.name ?: "AirPods Pro").toString()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(service) {
|
|
||||||
isLocallyConnected = service.isConnectedLocally
|
|
||||||
}
|
|
||||||
|
|
||||||
val nameChangeListener = remember {
|
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
|
||||||
if (key == "name") {
|
|
||||||
deviceName = TextFieldValue(sharedPreferences.getString("name", "AirPods Pro").toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
sharedPreferences.registerOnSharedPreferenceChangeListener(nameChangeListener)
|
|
||||||
onDispose {
|
|
||||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(nameChangeListener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
fun handleRemoteConnection(connected: Boolean) {
|
|
||||||
isRemotelyConnected = connected
|
|
||||||
}
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val connectionReceiver = remember {
|
|
||||||
object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
when (intent?.action) {
|
|
||||||
"me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY" -> {
|
|
||||||
coroutineScope.launch {
|
|
||||||
handleRemoteConnection(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY" -> {
|
|
||||||
coroutineScope.launch {
|
|
||||||
handleRemoteConnection(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AirPodsNotifications.AIRPODS_CONNECTED -> {
|
|
||||||
coroutineScope.launch {
|
|
||||||
isLocallyConnected = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AirPodsNotifications.AIRPODS_DISCONNECTED -> {
|
|
||||||
coroutineScope.launch {
|
|
||||||
isLocallyConnected = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AirPodsNotifications.DISCONNECT_RECEIVERS -> {
|
|
||||||
try {
|
|
||||||
context?.unregisterReceiver(this)
|
|
||||||
} catch (e: IllegalArgumentException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
val filter = IntentFilter().apply {
|
|
||||||
addAction("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY")
|
|
||||||
addAction("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY")
|
|
||||||
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
|
|
||||||
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
|
|
||||||
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
context.registerReceiver(connectionReceiver, filter, RECEIVER_EXPORTED)
|
|
||||||
} else {
|
|
||||||
context.registerReceiver(connectionReceiver, filter)
|
|
||||||
}
|
|
||||||
onDispose {
|
|
||||||
try {
|
|
||||||
context.unregisterReceiver(connectionReceiver)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(service) {
|
|
||||||
service.let {
|
|
||||||
it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
|
|
||||||
putParcelableArrayListExtra("data", ArrayList(it.getBattery()))
|
|
||||||
})
|
|
||||||
it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
|
|
||||||
putExtra("data", it.getANC())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val darkMode = isSystemInDarkTheme()
|
|
||||||
val hazeStateS = remember { mutableStateOf(HazeState()) }
|
|
||||||
|
|
||||||
// val showDialog = remember { mutableStateOf(!sharedPreferences.getBoolean("donationDialogShown", false)) }
|
|
||||||
|
|
||||||
val showDialog = remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
StyledScaffold(
|
|
||||||
title = deviceName.text,
|
|
||||||
actionButtons = listOf(
|
|
||||||
{scaffoldBackdrop ->
|
|
||||||
StyledIconButton(
|
|
||||||
onClick = { navController.navigate("app_settings") },
|
|
||||||
icon = "",
|
|
||||||
darkMode = darkMode,
|
|
||||||
backdrop = scaffoldBackdrop
|
|
||||||
)
|
|
||||||
}
|
|
||||||
),
|
|
||||||
snackbarHostState = snackbarHostState
|
|
||||||
) { spacerHeight, hazeState ->
|
|
||||||
hazeStateS.value = hazeState
|
|
||||||
if (isLocallyConnected || isRemotelyConnected) {
|
|
||||||
val instance = service.airpodsInstance
|
|
||||||
if (instance == null) {
|
|
||||||
Text("Error: AirPods instance is null")
|
|
||||||
return@StyledScaffold
|
|
||||||
}
|
|
||||||
val capabilities = instance.model.capabilities
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.hazeSource(hazeState)
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
item(key = "spacer_top") { Spacer(modifier = Modifier.height(spacerHeight)) }
|
|
||||||
item(key = "battery") {
|
|
||||||
BatteryView(service = service)
|
|
||||||
}
|
|
||||||
item(key = "spacer_battery") { Spacer(modifier = Modifier.height(32.dp)) }
|
|
||||||
|
|
||||||
item(key = "name") {
|
|
||||||
NavigationButton(
|
|
||||||
to = "rename",
|
|
||||||
name = stringResource(R.string.name),
|
|
||||||
currentState = deviceName.text,
|
|
||||||
navController = navController,
|
|
||||||
independent = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val actAsAppleDeviceHookEnabled = RadareOffsetFinder.isSdpOffsetAvailable()
|
|
||||||
if (actAsAppleDeviceHookEnabled) {
|
|
||||||
item(key = "spacer_hearing_health") { Spacer(modifier = Modifier.height(32.dp)) }
|
|
||||||
item(key = "hearing_health") {
|
|
||||||
HearingHealthSettings(navController = navController)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.LISTENING_MODE)) {
|
|
||||||
item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "noise_control") { NoiseControlSettings(service = service) }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.STEM_CONFIG)) {
|
|
||||||
item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "press_hold") { PressAndHoldSettings(navController = navController) }
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "call_control") { CallControlSettings(hazeState = hazeState) }
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.STEM_CONFIG)) {
|
|
||||||
item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "camera_control") { NavigationButton(to = "camera_control", name = stringResource(R.string.camera_remote), description = stringResource(R.string.camera_control_description), title = stringResource(R.string.camera_control), navController = navController) }
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "audio") { AudioSettings(navController = navController) }
|
|
||||||
|
|
||||||
item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "connection") { ConnectionSettings() }
|
|
||||||
|
|
||||||
item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "microphone") { MicrophoneSettings(hazeState) }
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.SLEEP_DETECTION)) {
|
|
||||||
item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "sleep_detection") {
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.sleep_detection),
|
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.HEAD_GESTURES)) {
|
|
||||||
item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "head_tracking") { NavigationButton(to = "head_tracking", name = stringResource(R.string.head_gestures), navController = navController, currentState = if (sharedPreferences.getBoolean("head_gestures", false)) stringResource(R.string.on) else stringResource(R.string.off)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "accessibility") { NavigationButton(to = "accessibility", name = stringResource(R.string.accessibility), navController = navController) }
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){
|
|
||||||
item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "off_listening") {
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.off_listening_mode),
|
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
|
|
||||||
description = stringResource(R.string.off_listening_mode_description)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) }
|
|
||||||
item(key = "about") { AboutCard(navController = navController) }
|
|
||||||
|
|
||||||
item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "debug") { NavigationButton("debug", "Debug", navController) }
|
|
||||||
item(key = "spacer_bottom") { Spacer(Modifier.height(24.dp)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.drawBackdrop(
|
|
||||||
backdrop = rememberLayerBackdrop(),
|
|
||||||
exportedBackdrop = backdrop,
|
|
||||||
shape = { RoundedCornerShape(0.dp) },
|
|
||||||
highlight = {
|
|
||||||
Highlight.Ambient.copy(alpha = 0f)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.hazeSource(hazeState)
|
|
||||||
.padding(horizontal = 8.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.airpods_not_connected),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 24.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.airpods_not_connected_description),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Light,
|
|
||||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
StyledButton(
|
|
||||||
onClick = { navController.navigate("troubleshooting") },
|
|
||||||
backdrop = backdrop,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(0.9f)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.troubleshooting),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
StyledButton(
|
|
||||||
onClick = {
|
|
||||||
service.reconnectFromSavedMac()
|
|
||||||
},
|
|
||||||
backdrop = backdrop,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(0.9f)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.reconnect_to_last_device),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ConfirmationDialog(
|
|
||||||
showDialog = showDialog,
|
|
||||||
title = stringResource(R.string.support_librepods),
|
|
||||||
message = stringResource(R.string.support_dialog_description),
|
|
||||||
confirmText = stringResource(R.string.support_me) + " \uDBC0\uDEB5",
|
|
||||||
dismissText = stringResource(R.string.never_show_again),
|
|
||||||
onConfirm = {
|
|
||||||
val browserIntent = Intent(
|
|
||||||
Intent.ACTION_VIEW,
|
|
||||||
"https://github.com/sponsors/kavishdevar".toUri()
|
|
||||||
)
|
|
||||||
context.startActivity(browserIntent)
|
|
||||||
sharedPreferences.edit { putBoolean("donationDialogShown", true) }
|
|
||||||
},
|
|
||||||
onDismiss = {
|
|
||||||
sharedPreferences.edit { putBoolean("donationDialogShown", true) }
|
|
||||||
},
|
|
||||||
hazeState = hazeStateS.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun AirPodsSettingsScreenPreview() {
|
|
||||||
Column (
|
|
||||||
modifier = Modifier.height(2000.dp)
|
|
||||||
) {
|
|
||||||
LibrePodsTheme (
|
|
||||||
darkTheme = true
|
|
||||||
) {
|
|
||||||
AirPodsSettingsScreen(dev = null, service = AirPodsService(), navController = rememberNavController(), isConnected = true, isRemotelyConnected = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,981 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|
||||||
import dev.chrisbanes.haze.hazeSource
|
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.composables.NavigationButton
|
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.composables.StyledSlider
|
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
|
||||||
import kotlin.io.encoding.Base64
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
|
|
||||||
@Composable
|
|
||||||
fun AppSettingsScreen(navController: NavController) {
|
|
||||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val context = LocalContext.current
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
|
|
||||||
val showResetDialog = remember { mutableStateOf(false) }
|
|
||||||
val showIrkDialog = remember { mutableStateOf(false) }
|
|
||||||
val showEncKeyDialog = remember { mutableStateOf(false) }
|
|
||||||
val showCameraDialog = remember { mutableStateOf(false) }
|
|
||||||
val irkValue = remember { mutableStateOf("") }
|
|
||||||
val encKeyValue = remember { mutableStateOf("") }
|
|
||||||
val cameraPackageValue = remember { mutableStateOf("") }
|
|
||||||
val irkError = remember { mutableStateOf<String?>(null) }
|
|
||||||
val encKeyError = remember { mutableStateOf<String?>(null) }
|
|
||||||
val cameraPackageError = remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
val savedIrk = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null)
|
|
||||||
val savedEncKey = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null)
|
|
||||||
val savedCameraPackage = sharedPreferences.getString("custom_camera_package", null)
|
|
||||||
|
|
||||||
if (savedIrk != null) {
|
|
||||||
try {
|
|
||||||
val decoded = Base64.decode(savedIrk)
|
|
||||||
irkValue.value = decoded.joinToString("") { "%02x".format(it) }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
irkValue.value = ""
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (savedEncKey != null) {
|
|
||||||
try {
|
|
||||||
val decoded = Base64.decode(savedEncKey)
|
|
||||||
encKeyValue.value = decoded.joinToString("") { "%02x".format(it) }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
encKeyValue.value = ""
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (savedCameraPackage != null) {
|
|
||||||
cameraPackageValue.value = savedCameraPackage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val showPhoneBatteryInWidget = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("show_phone_battery_in_widget", true))
|
|
||||||
}
|
|
||||||
val conversationalAwarenessPauseMusicEnabled = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("conversational_awareness_pause_music", false))
|
|
||||||
}
|
|
||||||
val relativeConversationalAwarenessVolumeEnabled = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("relative_conversational_awareness_volume", true))
|
|
||||||
}
|
|
||||||
val openDialogForControlling = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getString("qs_click_behavior", "dialog") == "dialog")
|
|
||||||
}
|
|
||||||
val disconnectWhenNotWearing = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("disconnect_when_not_wearing", false))
|
|
||||||
}
|
|
||||||
|
|
||||||
val takeoverWhenDisconnected = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_disconnected", true))
|
|
||||||
}
|
|
||||||
val takeoverWhenIdle = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_idle", true))
|
|
||||||
}
|
|
||||||
val takeoverWhenMusic = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_music", false))
|
|
||||||
}
|
|
||||||
val takeoverWhenCall = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_call", true))
|
|
||||||
}
|
|
||||||
|
|
||||||
val takeoverWhenRingingCall = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_ringing_call", true))
|
|
||||||
}
|
|
||||||
val takeoverWhenMediaStart = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_media_start", true))
|
|
||||||
}
|
|
||||||
|
|
||||||
val useAlternateHeadTrackingPackets = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("use_alternate_head_tracking_packets", false))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun validateHexInput(input: String): Boolean {
|
|
||||||
val hexPattern = Regex("^[0-9a-fA-F]{32}$")
|
|
||||||
return hexPattern.matches(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
val isProcessingSdp = remember { mutableStateOf(false) }
|
|
||||||
val actAsAppleDevice = remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
BackHandler(enabled = isProcessingSdp.value) {}
|
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
|
||||||
|
|
||||||
StyledScaffold(
|
|
||||||
title = stringResource(R.string.app_settings)
|
|
||||||
) { spacerHeight, hazeState ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
.hazeSource(state = hazeState)
|
|
||||||
.verticalScroll(scrollState)
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
title = stringResource(R.string.widget),
|
|
||||||
label = stringResource(R.string.show_phone_battery_in_widget),
|
|
||||||
description = stringResource(R.string.show_phone_battery_in_widget_description),
|
|
||||||
checkedState = showPhoneBatteryInWidget,
|
|
||||||
sharedPreferenceKey = "show_phone_battery_in_widget",
|
|
||||||
sharedPreferences = sharedPreferences,
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.conversational_awareness),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
|
|
||||||
Column (
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
backgroundColor,
|
|
||||||
RoundedCornerShape(28.dp)
|
|
||||||
)
|
|
||||||
.padding(vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
fun updateConversationalAwarenessPauseMusic(enabled: Boolean) {
|
|
||||||
conversationalAwarenessPauseMusicEnabled.value = enabled
|
|
||||||
sharedPreferences.edit { putBoolean("conversational_awareness_pause_music", enabled)}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateRelativeConversationalAwarenessVolume(enabled: Boolean) {
|
|
||||||
relativeConversationalAwarenessVolumeEnabled.value = enabled
|
|
||||||
sharedPreferences.edit { putBoolean("relative_conversational_awareness_volume", enabled)}
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.conversational_awareness_pause_music),
|
|
||||||
description = stringResource(R.string.conversational_awareness_pause_music_description),
|
|
||||||
checkedState = conversationalAwarenessPauseMusicEnabled,
|
|
||||||
onCheckedChange = { updateConversationalAwarenessPauseMusic(it) },
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.relative_conversational_awareness_volume),
|
|
||||||
description = stringResource(R.string.relative_conversational_awareness_volume_description),
|
|
||||||
checkedState = relativeConversationalAwarenessVolumeEnabled,
|
|
||||||
onCheckedChange = { updateRelativeConversationalAwarenessVolume(it) },
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
val conversationalAwarenessVolume = remember { mutableFloatStateOf(sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat()) }
|
|
||||||
LaunchedEffect(conversationalAwarenessVolume.floatValue) {
|
|
||||||
sharedPreferences.edit { putInt("conversational_awareness_volume", conversationalAwarenessVolume.floatValue.roundToInt()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledSlider(
|
|
||||||
label = stringResource(R.string.conversational_awareness_volume),
|
|
||||||
mutableFloatState = conversationalAwarenessVolume,
|
|
||||||
valueRange = 10f..85f,
|
|
||||||
startLabel = "10%",
|
|
||||||
endLabel = "85%",
|
|
||||||
onValueChange = { newValue -> conversationalAwarenessVolume.floatValue = newValue },
|
|
||||||
independent = true
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
NavigationButton(
|
|
||||||
to = "",
|
|
||||||
title = stringResource(R.string.camera_control),
|
|
||||||
name = stringResource(R.string.set_custom_camera_package),
|
|
||||||
navController = navController,
|
|
||||||
onClick = { showCameraDialog.value = true },
|
|
||||||
independent = true,
|
|
||||||
description = stringResource(R.string.camera_control_app_description)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
title = stringResource(R.string.quick_settings_tile),
|
|
||||||
label = stringResource(R.string.open_dialog_for_controlling),
|
|
||||||
description = stringResource(R.string.open_dialog_for_controlling_description),
|
|
||||||
checkedState = openDialogForControlling,
|
|
||||||
onCheckedChange = {
|
|
||||||
openDialogForControlling.value = it
|
|
||||||
sharedPreferences.edit { putString("qs_click_behavior", if (it) "dialog" else "activity") }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
title = stringResource(R.string.ear_detection),
|
|
||||||
label = stringResource(R.string.disconnect_when_not_wearing),
|
|
||||||
description = stringResource(R.string.disconnect_when_not_wearing_description),
|
|
||||||
checkedState = disconnectWhenNotWearing,
|
|
||||||
sharedPreferenceKey = "disconnect_when_not_wearing",
|
|
||||||
sharedPreferences = sharedPreferences,
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.takeover_airpods_state),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
backgroundColor,
|
|
||||||
RoundedCornerShape(28.dp)
|
|
||||||
)
|
|
||||||
.padding(vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.takeover_disconnected),
|
|
||||||
description = stringResource(R.string.takeover_disconnected_desc),
|
|
||||||
checkedState = takeoverWhenDisconnected,
|
|
||||||
onCheckedChange = {
|
|
||||||
takeoverWhenDisconnected.value = it
|
|
||||||
sharedPreferences.edit { putBoolean("takeover_when_disconnected", it)}
|
|
||||||
},
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.takeover_idle),
|
|
||||||
description = stringResource(R.string.takeover_idle_desc),
|
|
||||||
checkedState = takeoverWhenIdle,
|
|
||||||
onCheckedChange = {
|
|
||||||
takeoverWhenIdle.value = it
|
|
||||||
sharedPreferences.edit { putBoolean("takeover_when_idle", it)}
|
|
||||||
},
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.takeover_music),
|
|
||||||
description = stringResource(R.string.takeover_music_desc),
|
|
||||||
checkedState = takeoverWhenMusic,
|
|
||||||
onCheckedChange = {
|
|
||||||
takeoverWhenMusic.value = it
|
|
||||||
sharedPreferences.edit { putBoolean("takeover_when_music", it)}
|
|
||||||
},
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.takeover_call),
|
|
||||||
description = stringResource(R.string.takeover_call_desc),
|
|
||||||
checkedState = takeoverWhenCall,
|
|
||||||
onCheckedChange = {
|
|
||||||
takeoverWhenCall.value = it
|
|
||||||
sharedPreferences.edit { putBoolean("takeover_when_call", it)}
|
|
||||||
},
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.takeover_phone_state),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
backgroundColor,
|
|
||||||
RoundedCornerShape(28.dp)
|
|
||||||
)
|
|
||||||
.padding(vertical = 4.dp)
|
|
||||||
){
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.takeover_ringing_call),
|
|
||||||
description = stringResource(R.string.takeover_ringing_call_desc),
|
|
||||||
checkedState = takeoverWhenRingingCall,
|
|
||||||
onCheckedChange = {
|
|
||||||
takeoverWhenRingingCall.value = it
|
|
||||||
sharedPreferences.edit { putBoolean("takeover_when_ringing_call", it)}
|
|
||||||
},
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.takeover_media_start),
|
|
||||||
description = stringResource(R.string.takeover_media_start_desc),
|
|
||||||
checkedState = takeoverWhenMediaStart,
|
|
||||||
onCheckedChange = {
|
|
||||||
takeoverWhenMediaStart.value = it
|
|
||||||
sharedPreferences.edit { putBoolean("takeover_when_media_start", it)}
|
|
||||||
},
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.advanced_options),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
backgroundColor,
|
|
||||||
RoundedCornerShape(28.dp)
|
|
||||||
)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable (
|
|
||||||
onClick = { showIrkDialog.value = true },
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(vertical = 8.dp)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.set_identity_resolving_key),
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.set_identity_resolving_key_description),
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
lineHeight = 16.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable (
|
|
||||||
onClick = { showEncKeyDialog.value = true },
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(vertical = 8.dp)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.set_encryption_key),
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.set_encryption_key_description),
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
lineHeight = 16.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.use_alternate_head_tracking_packets),
|
|
||||||
description = stringResource(R.string.use_alternate_head_tracking_packets_description),
|
|
||||||
checkedState = useAlternateHeadTrackingPackets,
|
|
||||||
onCheckedChange = {
|
|
||||||
useAlternateHeadTrackingPackets.value = it
|
|
||||||
sharedPreferences.edit { putBoolean("use_alternate_head_tracking_packets", it)}
|
|
||||||
},
|
|
||||||
independent = true
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
NavigationButton(
|
|
||||||
to = "troubleshooting",
|
|
||||||
name = stringResource(R.string.troubleshooting),
|
|
||||||
navController = navController,
|
|
||||||
independent = true,
|
|
||||||
description = stringResource(R.string.troubleshooting_description)
|
|
||||||
)
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
actAsAppleDevice.value = RadareOffsetFinder.isSdpOffsetAvailable()
|
|
||||||
}
|
|
||||||
val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth)
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.act_as_an_apple_device),
|
|
||||||
description = stringResource(R.string.act_as_an_apple_device_description),
|
|
||||||
checkedState = actAsAppleDevice,
|
|
||||||
onCheckedChange = {
|
|
||||||
actAsAppleDevice.value = it
|
|
||||||
isProcessingSdp.value = true
|
|
||||||
coroutineScope.launch {
|
|
||||||
if (it) {
|
|
||||||
val radareOffsetFinder = RadareOffsetFinder(context)
|
|
||||||
val success = radareOffsetFinder.findSdpOffset()
|
|
||||||
if (success) {
|
|
||||||
Toast.makeText(context, restartBluetoothText, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
RadareOffsetFinder.clearSdpOffset()
|
|
||||||
}
|
|
||||||
isProcessingSdp.value = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
independent = true,
|
|
||||||
enabled = !isProcessingSdp.value
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = { showResetDialog.value = true },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(50.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(28.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Refresh,
|
|
||||||
contentDescription = "Reset",
|
|
||||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.reset_hook_offset),
|
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
NavigationButton(
|
|
||||||
to = "open_source_licenses",
|
|
||||||
name = stringResource(R.string.open_source_licenses),
|
|
||||||
navController = navController,
|
|
||||||
independent = true
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
|
||||||
|
|
||||||
if (showResetDialog.value) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showResetDialog.value = false },
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
"Reset Hook Offset",
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.reset_hook_offset_description),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
val successText = stringResource(R.string.hook_offset_reset_success)
|
|
||||||
val failureText = stringResource(R.string.hook_offset_reset_failure)
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
if (RadareOffsetFinder.clearHookOffsets()) {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
successText,
|
|
||||||
Toast.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
|
|
||||||
navController.navigate("onboarding") {
|
|
||||||
popUpTo("settings") { inclusive = true }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
failureText,
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
showResetDialog.value = false
|
|
||||||
},
|
|
||||||
colors = ButtonDefaults.textButtonColors(
|
|
||||||
contentColor = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.reset),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = { showResetDialog.value = false }
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Cancel",
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showIrkDialog.value) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showIrkDialog.value = false },
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.set_identity_resolving_key),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.enter_irk_hex),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = irkValue.value,
|
|
||||||
onValueChange = {
|
|
||||||
irkValue.value = it.lowercase().filter { char -> char.isDigit() || char in 'a'..'f' }
|
|
||||||
irkError.value = null
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
isError = irkError.value != null,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Ascii,
|
|
||||||
capitalization = KeyboardCapitalization.None
|
|
||||||
),
|
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
|
||||||
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
|
||||||
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
|
|
||||||
),
|
|
||||||
supportingText = {
|
|
||||||
if (irkError.value != null) {
|
|
||||||
Text(stringResource(R.string.must_be_32_hex_chars), color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label = { Text(stringResource(R.string.irk_hex_value)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
val successText = stringResource(R.string.irk_set_success)
|
|
||||||
val errorText = stringResource(R.string.error_converting_hex)
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
if (!validateHexInput(irkValue.value)) {
|
|
||||||
irkError.value = "Must be exactly 32 hex characters"
|
|
||||||
return@TextButton
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val hexBytes = ByteArray(16)
|
|
||||||
for (i in 0 until 16) {
|
|
||||||
val hexByte = irkValue.value.substring(i * 2, i * 2 + 2)
|
|
||||||
hexBytes[i] = hexByte.toInt(16).toByte()
|
|
||||||
}
|
|
||||||
|
|
||||||
val base64Value = Base64.encode(hexBytes)
|
|
||||||
sharedPreferences.edit { putString(AACPManager.Companion.ProximityKeyType.IRK.name, base64Value)}
|
|
||||||
|
|
||||||
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
|
|
||||||
showIrkDialog.value = false
|
|
||||||
} catch (e: Exception) {
|
|
||||||
irkError.value = errorText + " " + (e.message ?: "Unknown error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Save",
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = { showIrkDialog.value = false }
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Cancel",
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showEncKeyDialog.value) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showEncKeyDialog.value = false },
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.set_encryption_key),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.enter_enc_key_hex),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = encKeyValue.value,
|
|
||||||
onValueChange = {
|
|
||||||
encKeyValue.value = it.lowercase().filter { char -> char.isDigit() || char in 'a'..'f' }
|
|
||||||
encKeyError.value = null
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
isError = encKeyError.value != null,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Ascii,
|
|
||||||
capitalization = KeyboardCapitalization.None
|
|
||||||
),
|
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
|
||||||
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
|
||||||
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
|
|
||||||
),
|
|
||||||
supportingText = {
|
|
||||||
if (encKeyError.value != null) {
|
|
||||||
Text(stringResource(R.string.must_be_32_hex_chars), color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label = { Text(stringResource(R.string.enc_key_hex_value)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
val successText = stringResource(R.string.encryption_key_set_success)
|
|
||||||
val errorText = stringResource(R.string.error_converting_hex)
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
if (!validateHexInput(encKeyValue.value)) {
|
|
||||||
encKeyError.value = "Must be exactly 32 hex characters"
|
|
||||||
return@TextButton
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val hexBytes = ByteArray(16)
|
|
||||||
for (i in 0 until 16) {
|
|
||||||
val hexByte = encKeyValue.value.substring(i * 2, i * 2 + 2)
|
|
||||||
hexBytes[i] = hexByte.toInt(16).toByte()
|
|
||||||
}
|
|
||||||
|
|
||||||
val base64Value = Base64.encode(hexBytes)
|
|
||||||
sharedPreferences.edit { putString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, base64Value)}
|
|
||||||
|
|
||||||
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
|
|
||||||
showEncKeyDialog.value = false
|
|
||||||
} catch (e: Exception) {
|
|
||||||
encKeyError.value = errorText + " " + (e.message ?: "Unknown error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Save",
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = { showEncKeyDialog.value = false }
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Cancel",
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showCameraDialog.value) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showCameraDialog.value = false },
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.set_custom_camera_package),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.enter_custom_camera_package),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = cameraPackageValue.value,
|
|
||||||
onValueChange = {
|
|
||||||
cameraPackageValue.value = it
|
|
||||||
cameraPackageError.value = null
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
isError = cameraPackageError.value != null,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Ascii,
|
|
||||||
capitalization = KeyboardCapitalization.None
|
|
||||||
),
|
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
|
||||||
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
|
||||||
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
|
|
||||||
),
|
|
||||||
supportingText = {
|
|
||||||
if (cameraPackageError.value != null) {
|
|
||||||
Text(cameraPackageError.value!!, color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label = { Text(stringResource(R.string.custom_camera_package)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
val successText = stringResource(R.string.custom_camera_package_set_success)
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
if (cameraPackageValue.value.isBlank()) {
|
|
||||||
sharedPreferences.edit { remove("custom_camera_package") }
|
|
||||||
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
|
|
||||||
showCameraDialog.value = false
|
|
||||||
return@TextButton
|
|
||||||
}
|
|
||||||
|
|
||||||
sharedPreferences.edit { putString("custom_camera_package", cameraPackageValue.value) }
|
|
||||||
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
|
|
||||||
showCameraDialog.value = false
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Save",
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = { showCameraDialog.value = false }
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Cancel",
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.provider.Settings
|
|
||||||
import android.view.accessibility.AccessibilityManager
|
|
||||||
import android.accessibilityservice.AccessibilityServiceInfo
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.composables.SelectItem
|
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.composables.StyledSelectList
|
|
||||||
import me.kavishdevar.librepods.composables.StyledSlider
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.services.AppListenerService
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
private var debounceJob: Job? = null
|
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
|
||||||
@Composable
|
|
||||||
fun CameraControlScreen(navController: NavController) {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val context = LocalContext.current
|
|
||||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
val service = ServiceManager.getService()!!
|
|
||||||
var currentCameraAction by remember {
|
|
||||||
mutableStateOf(
|
|
||||||
sharedPreferences.getString("camera_action", null)?.let { StemPressType.valueOf(it) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isAppListenerServiceEnabled(context: Context): Boolean {
|
|
||||||
val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
|
|
||||||
val enabledServices = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
|
|
||||||
val serviceComponent = ComponentName(context, AppListenerService::class.java)
|
|
||||||
return enabledServices.any { it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName && it.resolveInfo.serviceInfo.name == serviceComponent.className }
|
|
||||||
}
|
|
||||||
|
|
||||||
val cameraOptions = listOf(
|
|
||||||
SelectItem(
|
|
||||||
name = stringResource(R.string.off),
|
|
||||||
selected = currentCameraAction == null,
|
|
||||||
onClick = {
|
|
||||||
sharedPreferences.edit { remove("camera_action") }
|
|
||||||
currentCameraAction = null
|
|
||||||
}
|
|
||||||
),
|
|
||||||
SelectItem(
|
|
||||||
name = stringResource(R.string.press_once),
|
|
||||||
selected = currentCameraAction == StemPressType.SINGLE_PRESS,
|
|
||||||
onClick = {
|
|
||||||
if (!isAppListenerServiceEnabled(context)) {
|
|
||||||
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
|
||||||
} else {
|
|
||||||
sharedPreferences.edit { putString("camera_action", StemPressType.SINGLE_PRESS.name) }
|
|
||||||
currentCameraAction = StemPressType.SINGLE_PRESS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
SelectItem(
|
|
||||||
name = stringResource(R.string.press_and_hold_airpods),
|
|
||||||
selected = currentCameraAction == StemPressType.LONG_PRESS,
|
|
||||||
onClick = {
|
|
||||||
if (!isAppListenerServiceEnabled(context)) {
|
|
||||||
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
|
||||||
} else {
|
|
||||||
sharedPreferences.edit { putString("camera_action", StemPressType.LONG_PRESS.name) }
|
|
||||||
currentCameraAction = StemPressType.LONG_PRESS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
|
||||||
|
|
||||||
StyledScaffold(
|
|
||||||
title = stringResource(R.string.camera_control)
|
|
||||||
) { spacerHeight ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
|
||||||
StyledSelectList(items = cameraOptions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,643 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.togetherWith
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material.icons.filled.Clear
|
|
||||||
import androidx.compose.material.icons.filled.Settings
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun Onboarding(navController: NavController, activityContext: Context) {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
|
||||||
|
|
||||||
val radareOffsetFinder = remember { RadareOffsetFinder(activityContext) }
|
|
||||||
val progressState by radareOffsetFinder.progressState.collectAsState()
|
|
||||||
var isComplete by remember { mutableStateOf(false) }
|
|
||||||
var hasStarted by remember { mutableStateOf(false) }
|
|
||||||
var rootCheckPassed by remember { mutableStateOf(false) }
|
|
||||||
var checkingRoot by remember { mutableStateOf(false) }
|
|
||||||
var rootCheckFailed by remember { mutableStateOf(false) }
|
|
||||||
var moduleEnabled by remember { mutableStateOf(false) }
|
|
||||||
var bluetoothToggled by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
var showSkipDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
fun checkRootAccess() {
|
|
||||||
checkingRoot = true
|
|
||||||
rootCheckFailed = false
|
|
||||||
kotlinx.coroutines.MainScope().launch {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val process = Runtime.getRuntime().exec("su -c id")
|
|
||||||
val exitValue = process.waitFor() // no idea why i have this, probably don't need to do this
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
rootCheckPassed = (exitValue == 0)
|
|
||||||
rootCheckFailed = (exitValue != 0)
|
|
||||||
checkingRoot = false
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("Onboarding", "Root check failed", e)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
rootCheckPassed = false
|
|
||||||
rootCheckFailed = true
|
|
||||||
checkingRoot = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(hasStarted) {
|
|
||||||
if (hasStarted && rootCheckPassed) {
|
|
||||||
Log.d("Onboarding", "Checking if hook offset is available...")
|
|
||||||
val isHookReady = radareOffsetFinder.isHookOffsetAvailable()
|
|
||||||
Log.d("Onboarding", "Hook offset ready: $isHookReady")
|
|
||||||
|
|
||||||
if (isHookReady) {
|
|
||||||
Log.d("Onboarding", "Hook is ready")
|
|
||||||
isComplete = true
|
|
||||||
} else {
|
|
||||||
Log.d("Onboarding", "Hook not ready, starting setup process...")
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
radareOffsetFinder.setupAndFindOffset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(progressState) {
|
|
||||||
if (progressState is RadareOffsetFinder.ProgressState.Success) {
|
|
||||||
isComplete = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
|
||||||
StyledScaffold(
|
|
||||||
title = "Setting Up",
|
|
||||||
actionButtons = listOf(
|
|
||||||
{scaffoldBackdrop ->
|
|
||||||
StyledIconButton(
|
|
||||||
onClick = {
|
|
||||||
showSkipDialog = true
|
|
||||||
},
|
|
||||||
icon = "",
|
|
||||||
darkMode = isDarkTheme,
|
|
||||||
backdrop = scaffoldBackdrop
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) { spacerHeight ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = backgroundColor),
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
if (!rootCheckPassed && !hasStarted) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Settings,
|
|
||||||
contentDescription = "Root Access",
|
|
||||||
tint = accentColor,
|
|
||||||
modifier = Modifier.size(50.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.root_access_required),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 22.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.this_app_needs_root_access_to_hook_onto_the_bluetooth_library),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (rootCheckFailed) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.root_access_denied),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = Color(0xFFFF453A)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = { checkRootAccess() },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(50.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = accentColor
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
enabled = !checkingRoot
|
|
||||||
) {
|
|
||||||
if (checkingRoot) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
color = Color.White,
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
"Check Root Access",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
StatusIcon(if (hasStarted) progressState else RadareOffsetFinder.ProgressState.Idle, isDarkTheme)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = if (hasStarted) getStatusTitle(progressState,
|
|
||||||
moduleEnabled, bluetoothToggled) else "Setup Required",
|
|
||||||
transitionSpec = { fadeIn() togetherWith fadeOut() }
|
|
||||||
) { text ->
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 22.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = if (hasStarted)
|
|
||||||
getStatusDescription(progressState, moduleEnabled, bluetoothToggled)
|
|
||||||
else
|
|
||||||
"AirPods functionality requires one-time setup for hooking into Bluetooth library",
|
|
||||||
transitionSpec = { fadeIn() togetherWith fadeOut() }
|
|
||||||
) { text ->
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
if (!hasStarted) {
|
|
||||||
Button(
|
|
||||||
onClick = { hasStarted = true },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(50.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = accentColor
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Start Setup",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
when (progressState) {
|
|
||||||
is RadareOffsetFinder.ProgressState.DownloadProgress -> {
|
|
||||||
val progress = (progressState as RadareOffsetFinder.ProgressState.DownloadProgress).progress
|
|
||||||
val animatedProgress by animateFloatAsState(
|
|
||||||
targetValue = progress,
|
|
||||||
label = "Download Progress"
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { animatedProgress },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(8.dp),
|
|
||||||
strokeCap = StrokeCap.Round,
|
|
||||||
color = accentColor
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "${(progress * 100).toInt()}%",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is RadareOffsetFinder.ProgressState.Success -> {
|
|
||||||
if (!moduleEnabled) {
|
|
||||||
Button(
|
|
||||||
onClick = { moduleEnabled = true },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(50.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = accentColor
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"I've Enabled/Reactivated the Module",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (!bluetoothToggled) {
|
|
||||||
Button(
|
|
||||||
onClick = { bluetoothToggled = true },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(50.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = accentColor
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"I've Toggled Bluetooth",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
navController.navigate("settings") {
|
|
||||||
popUpTo("onboarding") { inclusive = true }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(50.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = accentColor
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Continue to Settings",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is RadareOffsetFinder.ProgressState.Idle,
|
|
||||||
is RadareOffsetFinder.ProgressState.Error -> {
|
|
||||||
// No specific UI for these states
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
LinearProgressIndicator(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(8.dp),
|
|
||||||
strokeCap = StrokeCap.Round,
|
|
||||||
color = accentColor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
|
|
||||||
if (progressState is RadareOffsetFinder.ProgressState.Error && !isComplete && hasStarted) {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
Log.d("Onboarding", "Trying to find offset again...")
|
|
||||||
kotlinx.coroutines.MainScope().launch {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
radareOffsetFinder.setupAndFindOffset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(55.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = accentColor
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Try Again",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showSkipDialog) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showSkipDialog = false },
|
|
||||||
title = { Text("Skip Setup") },
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
"Have you installed the root module that patches the Bluetooth library directly? This option is for users who have manually patched their system instead of using the dynamic hook.",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
val sharedPreferences = activityContext.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
showSkipDialog = false
|
|
||||||
RadareOffsetFinder.clearHookOffsets()
|
|
||||||
sharedPreferences.edit { putBoolean("skip_setup", true) }
|
|
||||||
navController.navigate("settings") {
|
|
||||||
popUpTo("onboarding") { inclusive = true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Yes, Skip Setup",
|
|
||||||
color = accentColor,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = { showSkipDialog = false }
|
|
||||||
) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
containerColor = backgroundColor,
|
|
||||||
textContentColor = textColor,
|
|
||||||
titleContentColor = textColor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun StatusIcon(
|
|
||||||
progressState: RadareOffsetFinder.ProgressState,
|
|
||||||
isDarkTheme: Boolean
|
|
||||||
) {
|
|
||||||
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
|
||||||
val errorColor = if (isDarkTheme) Color(0xFFFF453A) else Color(0xFFFF3B30)
|
|
||||||
val successColor = if (isDarkTheme) Color(0xFF30D158) else Color(0xFF34C759)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.size(80.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
when (progressState) {
|
|
||||||
is RadareOffsetFinder.ProgressState.Error -> {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Clear,
|
|
||||||
contentDescription = "Error",
|
|
||||||
tint = errorColor,
|
|
||||||
modifier = Modifier.size(50.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is RadareOffsetFinder.ProgressState.Success -> {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Check,
|
|
||||||
contentDescription = "Success",
|
|
||||||
tint = successColor,
|
|
||||||
modifier = Modifier.size(50.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is RadareOffsetFinder.ProgressState.Idle -> {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Settings,
|
|
||||||
contentDescription = "Settings",
|
|
||||||
tint = accentColor,
|
|
||||||
modifier = Modifier.size(50.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(50.dp),
|
|
||||||
color = accentColor,
|
|
||||||
strokeWidth = 4.dp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getStatusTitle(
|
|
||||||
state: RadareOffsetFinder.ProgressState,
|
|
||||||
moduleEnabled: Boolean,
|
|
||||||
bluetoothToggled: Boolean
|
|
||||||
): String {
|
|
||||||
return when (state) {
|
|
||||||
is RadareOffsetFinder.ProgressState.Success -> {
|
|
||||||
when {
|
|
||||||
!moduleEnabled -> "Enable Xposed Module"
|
|
||||||
!bluetoothToggled -> "Toggle Bluetooth"
|
|
||||||
else -> "Setup Complete"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is RadareOffsetFinder.ProgressState.Idle -> "Getting Ready"
|
|
||||||
is RadareOffsetFinder.ProgressState.CheckingExisting -> "Checking if radare2 already downloaded"
|
|
||||||
is RadareOffsetFinder.ProgressState.Downloading -> "Downloading radare2"
|
|
||||||
is RadareOffsetFinder.ProgressState.DownloadProgress -> "Downloading radare2"
|
|
||||||
is RadareOffsetFinder.ProgressState.Extracting -> "Extracting radare2"
|
|
||||||
is RadareOffsetFinder.ProgressState.MakingExecutable -> "Setting executable permissions"
|
|
||||||
is RadareOffsetFinder.ProgressState.FindingOffset -> "Finding function offset"
|
|
||||||
is RadareOffsetFinder.ProgressState.SavingOffset -> "Saving offset"
|
|
||||||
is RadareOffsetFinder.ProgressState.Cleaning -> "Cleaning Up"
|
|
||||||
is RadareOffsetFinder.ProgressState.Error -> "Setup Failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getStatusDescription(
|
|
||||||
state: RadareOffsetFinder.ProgressState,
|
|
||||||
moduleEnabled: Boolean,
|
|
||||||
bluetoothToggled: Boolean
|
|
||||||
): String {
|
|
||||||
return when (state) {
|
|
||||||
is RadareOffsetFinder.ProgressState.Success -> {
|
|
||||||
when {
|
|
||||||
!moduleEnabled -> "Please enable the LibrePods Xposed module in your Xposed manager (e.g. LSPosed). If already enabled, disable and re-enable it."
|
|
||||||
!bluetoothToggled -> "Please turn off and then turn on Bluetooth to apply the changes."
|
|
||||||
else -> "All set! You can now use your AirPods with enhanced functionality."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is RadareOffsetFinder.ProgressState.Idle -> "Preparing"
|
|
||||||
is RadareOffsetFinder.ProgressState.CheckingExisting -> "Checking if radare2 are already installed"
|
|
||||||
is RadareOffsetFinder.ProgressState.Downloading -> "Starting radare2 download"
|
|
||||||
is RadareOffsetFinder.ProgressState.DownloadProgress -> "Downloading radare2"
|
|
||||||
is RadareOffsetFinder.ProgressState.Extracting -> "Extracting radare2"
|
|
||||||
is RadareOffsetFinder.ProgressState.MakingExecutable -> "Setting executable permissions on radare2 binaries"
|
|
||||||
is RadareOffsetFinder.ProgressState.FindingOffset -> "Looking for the required Bluetooth function in system libraries"
|
|
||||||
is RadareOffsetFinder.ProgressState.SavingOffset -> "Saving the function offset"
|
|
||||||
is RadareOffsetFinder.ProgressState.Cleaning -> "Removing temporary extracted files"
|
|
||||||
is RadareOffsetFinder.ProgressState.Error -> state.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun OnboardingPreview() {
|
|
||||||
Onboarding(navController = NavController(LocalContext.current), activityContext = LocalContext.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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.constants.AirPodsNotifications
|
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
import me.kavishdevar.librepods.data.NoiseControlMode
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.bluetooth.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?.isConnectedLocally == true
|
isAirPodsConnected = service?.isConnected() == 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 = sharedPreferences.getString("qs_click_behavior", "dialog") ?: "dialog"
|
val clickBehavior = "cycle" // sharedPreferences.getString("qs_click_behavior", "dialog") ?: "dialog"
|
||||||
|
|
||||||
if (clickBehavior == "dialog") {
|
if (clickBehavior == "dialog") {
|
||||||
launchDialogActivity()
|
launchDialogActivity()
|
||||||
@@ -244,8 +244,10 @@ 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 != null && newPackage.isNotBlank()) {
|
if (!newPackage.isNullOrBlank()) {
|
||||||
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 != null && customPackage.isNotBlank()) {
|
if (!customPackage.isNullOrBlank()) {
|
||||||
cameraPackages.add(customPackage)
|
cameraPackages.add(customPackage)
|
||||||
currentCustomPackage = customPackage
|
currentCustomPackage = customPackage
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,289 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.bluetooth.BluetoothAdapter
|
|
||||||
import android.bluetooth.BluetoothManager
|
|
||||||
import android.bluetooth.BluetoothServerSocket
|
|
||||||
import android.bluetooth.BluetoothSocket
|
|
||||||
import android.bluetooth.le.AdvertiseCallback
|
|
||||||
import android.bluetooth.le.AdvertiseData
|
|
||||||
import android.bluetooth.le.AdvertiseSettings
|
|
||||||
import android.bluetooth.le.BluetoothLeAdvertiser
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.os.ParcelUuid
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import java.io.IOException
|
|
||||||
import java.util.UUID
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
enum class CrossDevicePackets(val packet: ByteArray) {
|
|
||||||
AIRPODS_CONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x01)),
|
|
||||||
AIRPODS_DISCONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x00)),
|
|
||||||
REQUEST_DISCONNECT(byteArrayOf(0x00, 0x02, 0x00, 0x00)),
|
|
||||||
REQUEST_BATTERY_BYTES(byteArrayOf(0x00, 0x02, 0x00, 0x01)),
|
|
||||||
REQUEST_ANC_BYTES(byteArrayOf(0x00, 0x02, 0x00, 0x02)),
|
|
||||||
REQUEST_CONNECTION_STATUS(byteArrayOf(0x00, 0x02, 0x00, 0x03)),
|
|
||||||
AIRPODS_DATA_HEADER(byteArrayOf(0x00, 0x04, 0x00, 0x01)),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
object CrossDevice {
|
|
||||||
var initialized = false
|
|
||||||
private val uuid = UUID.fromString("1abbb9a4-10e4-4000-a75c-8953c5471342")
|
|
||||||
private var serverSocket: BluetoothServerSocket? = null
|
|
||||||
private var clientSocket: BluetoothSocket? = null
|
|
||||||
private lateinit var bluetoothAdapter: BluetoothAdapter
|
|
||||||
private lateinit var bluetoothLeAdvertiser: BluetoothLeAdvertiser
|
|
||||||
private const val MANUFACTURER_ID = 0x1234
|
|
||||||
private const val MANUFACTURER_DATA = "ALN_AirPods"
|
|
||||||
var isAvailable: Boolean = false // set to true when airpods are connected to another device
|
|
||||||
var batteryBytes: ByteArray = byteArrayOf()
|
|
||||||
var ancBytes: ByteArray = byteArrayOf()
|
|
||||||
private lateinit var sharedPreferences: SharedPreferences
|
|
||||||
private const val PACKET_LOG_KEY = "packet_log"
|
|
||||||
private var earDetectionStatus = listOf(false, false)
|
|
||||||
var disconnectionRequested = false
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
fun init(context: Context) {
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
Log.d("CrossDevice", "Initializing CrossDevice")
|
|
||||||
sharedPreferences = context.getSharedPreferences("packet_logs", Context.MODE_PRIVATE)
|
|
||||||
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false)}
|
|
||||||
this@CrossDevice.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
|
|
||||||
this@CrossDevice.bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser
|
|
||||||
// startAdvertising()
|
|
||||||
startServer()
|
|
||||||
initialized = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
private fun startServer() {
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
if (!bluetoothAdapter.isEnabled) return@launch
|
|
||||||
// serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
|
|
||||||
Log.d("CrossDevice", "Server started")
|
|
||||||
while (serverSocket != null) {
|
|
||||||
if (!bluetoothAdapter.isEnabled) {
|
|
||||||
serverSocket?.close()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (clientSocket != null) {
|
|
||||||
try {
|
|
||||||
clientSocket!!.close()
|
|
||||||
} catch (e: IOException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val socket = serverSocket!!.accept()
|
|
||||||
handleClientConnection(socket)
|
|
||||||
} catch (e: IOException) { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission", "unused")
|
|
||||||
private fun startAdvertising() {
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
val settings = AdvertiseSettings.Builder()
|
|
||||||
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
|
|
||||||
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
|
|
||||||
.setConnectable(true)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val data = AdvertiseData.Builder()
|
|
||||||
.setIncludeDeviceName(true)
|
|
||||||
.addManufacturerData(MANUFACTURER_ID, MANUFACTURER_DATA.toByteArray())
|
|
||||||
.addServiceUuid(ParcelUuid(uuid))
|
|
||||||
.build()
|
|
||||||
try {
|
|
||||||
bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("CrossDevice", "Failed to start BLE Advertising: ${e.message}")
|
|
||||||
}
|
|
||||||
Log.d("CrossDevice", "BLE Advertising started")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val advertiseCallback = object : AdvertiseCallback() {
|
|
||||||
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
|
|
||||||
Log.d("CrossDevice", "BLE Advertising started successfully")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartFailure(errorCode: Int) {
|
|
||||||
Log.e("CrossDevice", "BLE Advertising failed with error code: $errorCode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setAirPodsConnected(connected: Boolean) {
|
|
||||||
if (connected) {
|
|
||||||
isAvailable = false
|
|
||||||
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false)}
|
|
||||||
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_CONNECTED.packet)
|
|
||||||
} else {
|
|
||||||
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DISCONNECTED.packet)
|
|
||||||
// Reset state variables
|
|
||||||
isAvailable = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendReceivedPacket(packet: ByteArray) {
|
|
||||||
if (clientSocket == null || clientSocket!!.outputStream != null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun logPacket(packet: ByteArray, source: String) {
|
|
||||||
val packetHex = packet.joinToString(" ") { "%02X".format(it) }
|
|
||||||
val logEntry = "$source: $packetHex"
|
|
||||||
val logs = sharedPreferences.getStringSet(PACKET_LOG_KEY, mutableSetOf())?.toMutableSet() ?: mutableSetOf()
|
|
||||||
logs.add(logEntry)
|
|
||||||
sharedPreferences.edit { putStringSet(PACKET_LOG_KEY, logs)}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
private fun handleClientConnection(socket: BluetoothSocket) {
|
|
||||||
Log.d("CrossDevice", "Client connected")
|
|
||||||
notifyAirPodsConnectedRemotely(ServiceManager.getService()?.applicationContext!!)
|
|
||||||
clientSocket = socket
|
|
||||||
val inputStream = socket.inputStream
|
|
||||||
val buffer = ByteArray(1024)
|
|
||||||
var bytes: Int
|
|
||||||
setAirPodsConnected(ServiceManager.getService()?.isConnectedLocally == true)
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
bytes = inputStream.read(buffer)
|
|
||||||
} catch (e: IOException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!)
|
|
||||||
val s = serverSocket?.accept()
|
|
||||||
if (s != null) {
|
|
||||||
handleClientConnection(s)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
var packet = buffer.copyOf(bytes)
|
|
||||||
logPacket(packet, "Relay")
|
|
||||||
Log.d("CrossDevice", "Received packet: ${packet.joinToString("") { "%02x".format(it) }}")
|
|
||||||
if (bytes == -1) {
|
|
||||||
notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!)
|
|
||||||
break
|
|
||||||
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet) || packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet + CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
|
|
||||||
ServiceManager.getService()?.disconnectForCD()
|
|
||||||
disconnectionRequested = true
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
delay(1000)
|
|
||||||
disconnectionRequested = false
|
|
||||||
}
|
|
||||||
} else if (packet.contentEquals(CrossDevicePackets.AIRPODS_CONNECTED.packet)) {
|
|
||||||
isAvailable = true
|
|
||||||
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", true)}
|
|
||||||
} else if (packet.contentEquals(CrossDevicePackets.AIRPODS_DISCONNECTED.packet)) {
|
|
||||||
isAvailable = false
|
|
||||||
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false)}
|
|
||||||
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_BATTERY_BYTES.packet)) {
|
|
||||||
Log.d("CrossDevice", "Received battery request, battery data: ${batteryBytes.joinToString("") { "%02x".format(it) }}")
|
|
||||||
sendRemotePacket(batteryBytes)
|
|
||||||
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_ANC_BYTES.packet)) {
|
|
||||||
Log.d("CrossDevice", "Received ANC request")
|
|
||||||
sendRemotePacket(ancBytes)
|
|
||||||
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_CONNECTION_STATUS.packet)) {
|
|
||||||
Log.d("CrossDevice", "Received connection status request")
|
|
||||||
sendRemotePacket(if (ServiceManager.getService()?.isConnectedLocally == true) CrossDevicePackets.AIRPODS_CONNECTED.packet else CrossDevicePackets.AIRPODS_DISCONNECTED.packet)
|
|
||||||
} else {
|
|
||||||
if (packet.sliceArray(0..3).contentEquals(CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
|
|
||||||
isAvailable = true
|
|
||||||
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", true) }
|
|
||||||
if (packet.size % 2 == 0) {
|
|
||||||
val half = packet.size / 2
|
|
||||||
if (packet.sliceArray(0 until half).contentEquals(packet.sliceArray(half until packet.size))) {
|
|
||||||
Log.d("CrossDevice", "Duplicated packet, trimming")
|
|
||||||
packet = packet.sliceArray(0 until half)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var trimmedPacket = packet.drop(CrossDevicePackets.AIRPODS_DATA_HEADER.packet.size).toByteArray()
|
|
||||||
Log.d("CrossDevice", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
|
|
||||||
if (ServiceManager.getService()?.isConnectedLocally == true) {
|
|
||||||
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
|
|
||||||
// ServiceManager.getService()?.sendPacket(packetInHex)
|
|
||||||
} else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) {
|
|
||||||
batteryBytes = trimmedPacket
|
|
||||||
ServiceManager.getService()?.batteryNotification?.setBattery(trimmedPacket)
|
|
||||||
Log.d("CrossDevice", "Battery data: ${ServiceManager.getService()?.batteryNotification?.getBattery()[0]?.level}")
|
|
||||||
ServiceManager.getService()?.updateBattery()
|
|
||||||
ServiceManager.getService()?.sendBatteryBroadcast()
|
|
||||||
ServiceManager.getService()?.sendBatteryNotification()
|
|
||||||
} else if (ServiceManager.getService()?.ancNotification?.isANCData(trimmedPacket) == true) {
|
|
||||||
ServiceManager.getService()?.ancNotification?.setStatus(trimmedPacket)
|
|
||||||
ServiceManager.getService()?.sendANCBroadcast()
|
|
||||||
ServiceManager.getService()?.updateNoiseControlWidget()
|
|
||||||
ancBytes = trimmedPacket
|
|
||||||
} else if (ServiceManager.getService()?.earDetectionNotification?.isEarDetectionData(trimmedPacket) == true) {
|
|
||||||
Log.d("CrossDevice", "Ear detection data: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
|
|
||||||
ServiceManager.getService()?.earDetectionNotification?.setStatus(trimmedPacket)
|
|
||||||
val newEarDetectionStatus = listOf(
|
|
||||||
ServiceManager.getService()?.earDetectionNotification?.status?.get(0) == 0x00.toByte(),
|
|
||||||
ServiceManager.getService()?.earDetectionNotification?.status?.get(1) == 0x00.toByte()
|
|
||||||
)
|
|
||||||
if (earDetectionStatus == listOf(false, false) && newEarDetectionStatus.contains(true)) {
|
|
||||||
ServiceManager.getService()?.applicationContext?.sendBroadcast(
|
|
||||||
Intent("me.kavishdevar.librepods.cross_device_island")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
earDetectionStatus = newEarDetectionStatus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendRemotePacket(byteArray: ByteArray) {
|
|
||||||
if (clientSocket == null || clientSocket!!.outputStream == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
clientSocket?.outputStream?.write(byteArray)
|
|
||||||
clientSocket?.outputStream?.flush()
|
|
||||||
logPacket(byteArray, "Sent")
|
|
||||||
Log.d("CrossDevice", "Sent packet to remote device")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun notifyAirPodsConnectedRemotely(context: Context) {
|
|
||||||
val intent = Intent("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY")
|
|
||||||
context.sendBroadcast(intent)
|
|
||||||
}
|
|
||||||
fun notifyAirPodsDisconnectedRemotely(context: Context) {
|
|
||||||
val intent = Intent("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY")
|
|
||||||
context.sendBroadcast(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,763 +0,0 @@
|
|||||||
package me.kavishdevar.librepods.utils
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.ApplicationInfo
|
|
||||||
import android.content.res.ColorStateList
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.graphics.drawable.GradientDrawable
|
|
||||||
import android.os.ParcelUuid
|
|
||||||
import android.util.Log
|
|
||||||
import android.view.Gravity
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.view.animation.AccelerateInterpolator
|
|
||||||
import android.view.animation.DecelerateInterpolator
|
|
||||||
import android.widget.FrameLayout
|
|
||||||
import android.widget.ImageButton
|
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import io.github.libxposed.api.XposedInterface
|
|
||||||
import io.github.libxposed.api.XposedInterface.AfterHookCallback
|
|
||||||
import io.github.libxposed.api.XposedModule
|
|
||||||
import io.github.libxposed.api.XposedModuleInterface
|
|
||||||
import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam
|
|
||||||
import io.github.libxposed.api.annotations.AfterInvocation
|
|
||||||
import io.github.libxposed.api.annotations.XposedHooker
|
|
||||||
|
|
||||||
private const val TAG = "AirPodsHook"
|
|
||||||
private lateinit var module: KotlinModule
|
|
||||||
@SuppressLint("DiscouragedApi", "PrivateApi")
|
|
||||||
class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModule(base, param) {
|
|
||||||
init {
|
|
||||||
Log.i(TAG, "AirPodsHook module initialized at :: ${param.processName}")
|
|
||||||
module = this
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPackageLoaded(param: XposedModuleInterface.PackageLoadedParam) {
|
|
||||||
super.onPackageLoaded(param)
|
|
||||||
Log.i(TAG, "onPackageLoaded :: ${param.packageName}")
|
|
||||||
|
|
||||||
if (param.packageName == "com.google.android.bluetooth" || param.packageName == "com.android.bluetooth") {
|
|
||||||
Log.i(TAG, "Bluetooth app detected, hooking l2c_fcr_chk_chan_modes")
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (param.isFirstPackage) {
|
|
||||||
Log.i(TAG, "Loading native library for Bluetooth hook")
|
|
||||||
System.loadLibrary("l2c_fcr_hook")
|
|
||||||
Log.i(TAG, "Native library loaded successfully")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to load native library: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (param.packageName == "com.google.android.settings") {
|
|
||||||
Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling")
|
|
||||||
try {
|
|
||||||
val headerControllerClass = param.classLoader.loadClass(
|
|
||||||
"com.google.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController")
|
|
||||||
|
|
||||||
val updateIconMethod = headerControllerClass.getDeclaredMethod(
|
|
||||||
"updateIcon",
|
|
||||||
ImageView::class.java,
|
|
||||||
String::class.java)
|
|
||||||
|
|
||||||
hook(updateIconMethod, BluetoothIconHooker::class.java)
|
|
||||||
Log.i(TAG, "Successfully hooked updateIcon method in Bluetooth settings")
|
|
||||||
|
|
||||||
try {
|
|
||||||
val displayPreferenceMethod = headerControllerClass.getDeclaredMethod(
|
|
||||||
"displayPreference",
|
|
||||||
param.classLoader.loadClass("androidx.preference.PreferenceScreen"))
|
|
||||||
|
|
||||||
hook(displayPreferenceMethod, BluetoothSettingsAirPodsHooker::class.java)
|
|
||||||
Log.i(TAG, "Successfully hooked displayPreference for AirPods button injection")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to hook displayPreference: ${e.message}", e)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to hook Bluetooth icon handler: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (param.packageName == "com.android.settings") {
|
|
||||||
Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling")
|
|
||||||
try {
|
|
||||||
val headerControllerClass = param.classLoader.loadClass(
|
|
||||||
"com.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController")
|
|
||||||
|
|
||||||
val updateIconMethod = headerControllerClass.getDeclaredMethod(
|
|
||||||
"updateIcon",
|
|
||||||
ImageView::class.java,
|
|
||||||
String::class.java)
|
|
||||||
|
|
||||||
hook(updateIconMethod, BluetoothIconHooker::class.java)
|
|
||||||
Log.i(TAG, "Successfully hooked updateIcon method in Bluetooth settings")
|
|
||||||
|
|
||||||
try {
|
|
||||||
val displayPreferenceMethod = headerControllerClass.getDeclaredMethod(
|
|
||||||
"displayPreference",
|
|
||||||
param.classLoader.loadClass("androidx.preference.PreferenceScreen"))
|
|
||||||
|
|
||||||
hook(displayPreferenceMethod, BluetoothSettingsAirPodsHooker::class.java)
|
|
||||||
Log.i(TAG, "Successfully hooked displayPreference for AirPods button injection")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to hook displayPreference: ${e.message}", e)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to hook Bluetooth icon handler: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@XposedHooker
|
|
||||||
class BluetoothSettingsAirPodsHooker : XposedInterface.Hooker {
|
|
||||||
companion object {
|
|
||||||
private const val AIRPODS_UUID = "74ec2172-0bad-4d01-8f77-997b2be0722a"
|
|
||||||
private const val LIBREPODS_PREFERENCE_KEY = "librepods_open_preference"
|
|
||||||
private const val ACTION_SET_ANC_MODE = "me.kavishdevar.librepods.SET_ANC_MODE"
|
|
||||||
private const val EXTRA_ANC_MODE = "anc_mode"
|
|
||||||
|
|
||||||
private const val ANC_MODE_OFF = 1
|
|
||||||
private const val ANC_MODE_NOISE_CANCELLATION = 2
|
|
||||||
private const val ANC_MODE_TRANSPARENCY = 3
|
|
||||||
private const val ANC_MODE_ADAPTIVE = 4
|
|
||||||
|
|
||||||
private var currentAncMode = ANC_MODE_NOISE_CANCELLATION
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
@AfterInvocation
|
|
||||||
fun afterDisplayPreference(callback: AfterHookCallback) {
|
|
||||||
try {
|
|
||||||
val controller = callback.thisObject!!
|
|
||||||
val preferenceScreen = callback.args[0]!!
|
|
||||||
|
|
||||||
val context = preferenceScreen.javaClass.getMethod("getContext").invoke(preferenceScreen) as Context
|
|
||||||
|
|
||||||
val deviceField = controller.javaClass.getDeclaredField("mCachedDevice")
|
|
||||||
deviceField.isAccessible = true
|
|
||||||
val cachedDevice = deviceField.get(controller) ?: return
|
|
||||||
|
|
||||||
val getDeviceMethod = cachedDevice.javaClass.getMethod("getDevice")
|
|
||||||
val bluetoothDevice = getDeviceMethod.invoke(cachedDevice) ?: return
|
|
||||||
|
|
||||||
val uuidsMethod = bluetoothDevice.javaClass.getMethod("getUuids")
|
|
||||||
val uuids = uuidsMethod.invoke(bluetoothDevice) as? Array<ParcelUuid>
|
|
||||||
|
|
||||||
if (uuids != null) {
|
|
||||||
val isAirPods = uuids.any { it.uuid.toString() == AIRPODS_UUID }
|
|
||||||
|
|
||||||
if (isAirPods) {
|
|
||||||
Log.i(TAG, "AirPods device detected in settings, injecting controls")
|
|
||||||
|
|
||||||
val findPreferenceMethod = preferenceScreen.javaClass.getMethod("findPreference", CharSequence::class.java)
|
|
||||||
val existingPref = findPreferenceMethod.invoke(preferenceScreen, LIBREPODS_PREFERENCE_KEY)
|
|
||||||
|
|
||||||
if (existingPref != null) {
|
|
||||||
Log.i(TAG, "LIBREPODS button already exists, skipping")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val preferenceClass = preferenceScreen.javaClass.classLoader.loadClass("androidx.preference.Preference")
|
|
||||||
val preference = preferenceClass.getConstructor(Context::class.java).newInstance(context)
|
|
||||||
|
|
||||||
val setKeyMethod = preferenceClass.getMethod("setKey", String::class.java)
|
|
||||||
setKeyMethod.invoke(preference, LIBREPODS_PREFERENCE_KEY)
|
|
||||||
|
|
||||||
val setTitleMethod = preferenceClass.getMethod("setTitle", CharSequence::class.java)
|
|
||||||
setTitleMethod.invoke(preference, "Open LibrePods")
|
|
||||||
|
|
||||||
val setSummaryMethod = preferenceClass.getMethod("setSummary", CharSequence::class.java)
|
|
||||||
setSummaryMethod.invoke(preference, "Control AirPods features")
|
|
||||||
|
|
||||||
val setIconMethod = preferenceClass.getMethod("setIcon", Int::class.java)
|
|
||||||
setIconMethod.invoke(preference, android.R.drawable.ic_menu_manage)
|
|
||||||
|
|
||||||
val setOrderMethod = preferenceClass.getMethod("setOrder", Int::class.java)
|
|
||||||
setOrderMethod.invoke(preference, 1000)
|
|
||||||
|
|
||||||
val intent = Intent().apply {
|
|
||||||
setClassName("me.kavishdevar.librepods", "me.kavishdevar.librepods.MainActivity")
|
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
|
||||||
}
|
|
||||||
val setIntentMethod = preferenceClass.getMethod("setIntent", Intent::class.java)
|
|
||||||
setIntentMethod.invoke(preference, intent)
|
|
||||||
|
|
||||||
val addPreferenceMethod = preferenceScreen.javaClass.getMethod("addPreference", preferenceClass)
|
|
||||||
addPreferenceMethod.invoke(preferenceScreen, preference)
|
|
||||||
|
|
||||||
Log.i(TAG, "Successfully added Open LIBREPODS button to AirPods settings")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error in BluetoothSettingsAirPodsHooker: ${e.message}", e)
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@XposedHooker
|
|
||||||
class BluetoothIconHooker : XposedInterface.Hooker {
|
|
||||||
companion object {
|
|
||||||
@JvmStatic
|
|
||||||
@AfterInvocation
|
|
||||||
fun afterUpdateIcon(callback: AfterHookCallback) {
|
|
||||||
Log.i(TAG, "BluetoothIconHooker called with args: ${callback.args.joinToString(", ")}")
|
|
||||||
try {
|
|
||||||
val imageView = callback.args[0] as ImageView
|
|
||||||
val iconUri = callback.args[1] as String
|
|
||||||
|
|
||||||
val uri = iconUri.toUri()
|
|
||||||
if (uri.toString().startsWith("android.resource://me.kavishdevar.librepods")) {
|
|
||||||
Log.i(TAG, "Handling AirPods icon URI: $uri")
|
|
||||||
|
|
||||||
try {
|
|
||||||
val context = imageView.context
|
|
||||||
|
|
||||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
|
||||||
try {
|
|
||||||
val packageName = uri.authority
|
|
||||||
val packageContext = context.createPackageContext(
|
|
||||||
packageName,
|
|
||||||
Context.CONTEXT_IGNORE_SECURITY
|
|
||||||
)
|
|
||||||
|
|
||||||
val resPath = uri.pathSegments
|
|
||||||
if (resPath.size >= 2 && resPath[0] == "drawable") {
|
|
||||||
val resourceName = resPath[1]
|
|
||||||
val resourceId = packageContext.resources.getIdentifier(
|
|
||||||
resourceName, "drawable", packageName
|
|
||||||
)
|
|
||||||
|
|
||||||
if (resourceId != 0) {
|
|
||||||
val drawable = packageContext.resources.getDrawable(
|
|
||||||
resourceId, packageContext.theme
|
|
||||||
)
|
|
||||||
|
|
||||||
imageView.setImageDrawable(drawable)
|
|
||||||
imageView.alpha = 1.0f
|
|
||||||
|
|
||||||
callback.result = null
|
|
||||||
|
|
||||||
Log.i(TAG, "Successfully loaded icon from resource: $resourceName")
|
|
||||||
} else {
|
|
||||||
Log.e(TAG, "Resource not found: $resourceName")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error loading resource from URI $uri: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error accessing context: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error in BluetoothIconHooker: ${e.message}")
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getApplicationInfo(): ApplicationInfo {
|
|
||||||
return super.applicationInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val ANC_MODE_OFF = 1
|
|
||||||
private const val ANC_MODE_NOISE_CANCELLATION = 2
|
|
||||||
private const val ANC_MODE_TRANSPARENCY = 3
|
|
||||||
private const val ANC_MODE_ADAPTIVE = 4
|
|
||||||
|
|
||||||
private var currentANCMode = ANC_MODE_NOISE_CANCELLATION
|
|
||||||
|
|
||||||
private const val ACTION_SET_ANC_MODE = "me.kavishdevar.librepods.SET_ANC_MODE"
|
|
||||||
private const val EXTRA_ANC_MODE = "anc_mode"
|
|
||||||
private const val ANIMATION_DURATION = 250L
|
|
||||||
|
|
||||||
private fun addAirPodsControlsToDialog(volumeDialog: Any) {
|
|
||||||
try {
|
|
||||||
val contextField = volumeDialog.javaClass.getDeclaredField("mContext")
|
|
||||||
contextField.isAccessible = true
|
|
||||||
val context = contextField.get(volumeDialog) as Context
|
|
||||||
|
|
||||||
val dialogViewField = volumeDialog.javaClass.getDeclaredField("mDialogView")
|
|
||||||
dialogViewField.isAccessible = true
|
|
||||||
val dialogView = dialogViewField.get(volumeDialog) as ViewGroup
|
|
||||||
|
|
||||||
val dialogRowsViewField = volumeDialog.javaClass.getDeclaredField("mDialogRowsView")
|
|
||||||
dialogRowsViewField.isAccessible = true
|
|
||||||
val dialogRowsView = dialogRowsViewField.get(volumeDialog) as ViewGroup
|
|
||||||
|
|
||||||
Log.d(TAG, "Found dialogRowsView: ${dialogRowsView.javaClass.name}")
|
|
||||||
|
|
||||||
val existingContainer = dialogView.findViewWithTag<View>("airpods_container")
|
|
||||||
if (existingContainer != null) {
|
|
||||||
Log.d(TAG, "AirPods container already exists, ensuring visibility state")
|
|
||||||
val drawer = existingContainer.findViewWithTag<View>("airpods_drawer_container")
|
|
||||||
drawer?.visibility = View.GONE
|
|
||||||
drawer?.alpha = 0f
|
|
||||||
drawer?.translationY = 0f
|
|
||||||
val button = existingContainer.findViewWithTag<ImageButton>("airpods_button")
|
|
||||||
button?.visibility = View.VISIBLE
|
|
||||||
button?.alpha = 1f
|
|
||||||
if (button != null) {
|
|
||||||
updateMainButtonIcon(context, button, currentANCMode)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val newAirPodsButton = ImageButton(context).apply {
|
|
||||||
tag = "airpods_button"
|
|
||||||
|
|
||||||
try {
|
|
||||||
val airPodsPackage = context.createPackageContext(
|
|
||||||
"me.kavishdevar.librepods",
|
|
||||||
Context.CONTEXT_IGNORE_SECURITY
|
|
||||||
)
|
|
||||||
val airPodsIconRes = airPodsPackage.resources.getIdentifier(
|
|
||||||
"airpods", "drawable", "me.kavishdevar.librepods")
|
|
||||||
|
|
||||||
if (airPodsIconRes != 0) {
|
|
||||||
val airPodsDrawable = airPodsPackage.resources.getDrawable(
|
|
||||||
airPodsIconRes, airPodsPackage.theme)
|
|
||||||
setImageDrawable(airPodsDrawable)
|
|
||||||
} else {
|
|
||||||
setImageResource(android.R.drawable.ic_media_play)
|
|
||||||
Log.d(TAG, "Using fallback icon because airpods icon resource not found")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
setImageResource(android.R.drawable.ic_media_play)
|
|
||||||
Log.e(TAG, "Failed to load AirPods icon: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
val shape = GradientDrawable()
|
|
||||||
shape.shape = GradientDrawable.RECTANGLE
|
|
||||||
shape.setColor(Color.BLACK)
|
|
||||||
background = shape
|
|
||||||
|
|
||||||
imageTintList = ColorStateList.valueOf(Color.WHITE)
|
|
||||||
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
|
||||||
|
|
||||||
setPadding(24, 24, 24, 24)
|
|
||||||
|
|
||||||
val params = LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
90
|
|
||||||
)
|
|
||||||
params.gravity = Gravity.CENTER
|
|
||||||
params.setMargins(0, 0, 0, 0)
|
|
||||||
layoutParams = params
|
|
||||||
|
|
||||||
setOnClickListener {
|
|
||||||
Log.d(TAG, "AirPods button clicked, toggling drawer")
|
|
||||||
val container = findAirPodsContainer(this)
|
|
||||||
val drawerContainer = container?.findViewWithTag<View>("airpods_drawer_container")
|
|
||||||
if (drawerContainer != null && container != null) {
|
|
||||||
if (drawerContainer.visibility == View.VISIBLE) {
|
|
||||||
hideAirPodsDrawer(container, this, drawerContainer)
|
|
||||||
} else {
|
|
||||||
showAirPodsDrawer(container, this, drawerContainer)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.e(TAG, "Could not find container or drawer for toggle")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
contentDescription = "AirPods Settings"
|
|
||||||
}
|
|
||||||
|
|
||||||
val airPodsContainer = FrameLayout(context).apply {
|
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
)
|
|
||||||
tag = "airpods_container"
|
|
||||||
}
|
|
||||||
|
|
||||||
newAirPodsButton.setOnLongClickListener {
|
|
||||||
Log.d(TAG, "AirPods button long-pressed, opening QuickSettingsDialogActivity")
|
|
||||||
val intent = Intent().apply {
|
|
||||||
setClassName("me.kavishdevar.librepods", "me.kavishdevar.librepods.QuickSettingsDialogActivity")
|
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
|
||||||
}
|
|
||||||
context.startActivity(intent)
|
|
||||||
try {
|
|
||||||
val dismissMethod = volumeDialog.javaClass.getMethod("dismissH")
|
|
||||||
dismissMethod.invoke(volumeDialog)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Could not dismiss volume dialog: ${e.message}")
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
val airPodsDrawer = LinearLayout(context).apply {
|
|
||||||
orientation = LinearLayout.VERTICAL
|
|
||||||
layoutParams = FrameLayout.LayoutParams(
|
|
||||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
).apply {
|
|
||||||
gravity = Gravity.TOP
|
|
||||||
}
|
|
||||||
tag = "airpods_drawer_container"
|
|
||||||
visibility = View.GONE
|
|
||||||
alpha = 0f
|
|
||||||
|
|
||||||
val drawerShape = GradientDrawable()
|
|
||||||
drawerShape.shape = GradientDrawable.RECTANGLE
|
|
||||||
drawerShape.setColor(Color.BLACK)
|
|
||||||
background = drawerShape
|
|
||||||
|
|
||||||
setPadding(16, 8, 16, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
val buttonContainer = LinearLayout(context).apply {
|
|
||||||
orientation = LinearLayout.VERTICAL
|
|
||||||
layoutParams = FrameLayout.LayoutParams(
|
|
||||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
).apply {
|
|
||||||
gravity = Gravity.TOP
|
|
||||||
}
|
|
||||||
tag = "airpods_button_container"
|
|
||||||
}
|
|
||||||
|
|
||||||
val modes = listOf(ANC_MODE_OFF, ANC_MODE_TRANSPARENCY, ANC_MODE_ADAPTIVE, ANC_MODE_NOISE_CANCELLATION)
|
|
||||||
for (mode in modes) {
|
|
||||||
val modeOption = createAncModeOption(context, mode, mode == currentANCMode, newAirPodsButton)
|
|
||||||
airPodsDrawer.addView(modeOption)
|
|
||||||
}
|
|
||||||
|
|
||||||
buttonContainer.addView(newAirPodsButton)
|
|
||||||
|
|
||||||
airPodsContainer.addView(airPodsDrawer)
|
|
||||||
airPodsContainer.addView(buttonContainer)
|
|
||||||
|
|
||||||
val settingsViewField = try {
|
|
||||||
val field = volumeDialog.javaClass.getDeclaredField("mSettingsView")
|
|
||||||
field.isAccessible = true
|
|
||||||
field.get(volumeDialog) as? View
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to get settings view field: ${e.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settingsViewField != null && settingsViewField.parent is ViewGroup) {
|
|
||||||
val settingsParent = settingsViewField.parent as ViewGroup
|
|
||||||
val settingsIndex = findViewIndexInParent(settingsParent, settingsViewField)
|
|
||||||
|
|
||||||
if (settingsIndex >= 0) {
|
|
||||||
settingsParent.addView(airPodsContainer, settingsIndex)
|
|
||||||
Log.i(TAG, "Added AirPods controls before settings button")
|
|
||||||
} else {
|
|
||||||
settingsParent.addView(airPodsContainer)
|
|
||||||
Log.i(TAG, "Added AirPods controls to the end of settings parent")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
dialogView.addView(airPodsContainer)
|
|
||||||
Log.i(TAG, "Fallback: Added AirPods controls to dialog view")
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMainButtonIcon(context, newAirPodsButton, currentANCMode)
|
|
||||||
|
|
||||||
Log.i(TAG, "Successfully added AirPods button and drawer to volume dialog")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error adding AirPods button to volume panel: ${e.message}")
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findViewIndexInParent(parent: ViewGroup, view: View): Int {
|
|
||||||
for (i in 0 until parent.childCount) {
|
|
||||||
if (parent.getChildAt(i) == view) {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateMainButtonIcon(context: Context, button: ImageButton, mode: Int) {
|
|
||||||
try {
|
|
||||||
val pkgContext = context.createPackageContext(
|
|
||||||
"me.kavishdevar.librepods",
|
|
||||||
Context.CONTEXT_IGNORE_SECURITY
|
|
||||||
)
|
|
||||||
|
|
||||||
val resName = when (mode) {
|
|
||||||
ANC_MODE_OFF -> "noise_cancellation"
|
|
||||||
ANC_MODE_TRANSPARENCY -> "transparency"
|
|
||||||
ANC_MODE_ADAPTIVE -> "adaptive"
|
|
||||||
ANC_MODE_NOISE_CANCELLATION -> "noise_cancellation"
|
|
||||||
else -> "noise_cancellation"
|
|
||||||
}
|
|
||||||
|
|
||||||
val resId = pkgContext.resources.getIdentifier(
|
|
||||||
resName, "drawable", "me.kavishdevar.librepods"
|
|
||||||
)
|
|
||||||
|
|
||||||
if (resId != 0) {
|
|
||||||
val drawable = pkgContext.resources.getDrawable(resId, pkgContext.theme)
|
|
||||||
button.setImageDrawable(drawable)
|
|
||||||
button.setColorFilter(Color.WHITE)
|
|
||||||
} else {
|
|
||||||
button.setImageResource(getIconResourceForMode(mode))
|
|
||||||
button.setColorFilter(Color.WHITE)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
button.setImageResource(getIconResourceForMode(mode))
|
|
||||||
button.setColorFilter(Color.WHITE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createAncModeOption(context: Context, mode: Int, isSelected: Boolean, mainButton: ImageButton): LinearLayout {
|
|
||||||
return LinearLayout(context).apply {
|
|
||||||
orientation = LinearLayout.HORIZONTAL
|
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
).apply {
|
|
||||||
setMargins(0, 6, 0, 6)
|
|
||||||
}
|
|
||||||
gravity = Gravity.CENTER
|
|
||||||
setPadding(24, 16, 24, 16)
|
|
||||||
tag = "anc_mode_${mode}"
|
|
||||||
|
|
||||||
val icon = ImageView(context).apply {
|
|
||||||
layoutParams = LinearLayout.LayoutParams(60, 60).apply {
|
|
||||||
gravity = Gravity.CENTER
|
|
||||||
}
|
|
||||||
tag = "mode_icon_$mode"
|
|
||||||
|
|
||||||
try {
|
|
||||||
val packageContext = context.createPackageContext(
|
|
||||||
"me.kavishdevar.librepods",
|
|
||||||
Context.CONTEXT_IGNORE_SECURITY
|
|
||||||
)
|
|
||||||
|
|
||||||
val resourceName = when (mode) {
|
|
||||||
ANC_MODE_OFF -> "noise_cancellation"
|
|
||||||
ANC_MODE_TRANSPARENCY -> "transparency"
|
|
||||||
ANC_MODE_ADAPTIVE -> "adaptive"
|
|
||||||
ANC_MODE_NOISE_CANCELLATION -> "noise_cancellation"
|
|
||||||
else -> "noise_cancellation"
|
|
||||||
}
|
|
||||||
|
|
||||||
val resourceId = packageContext.resources.getIdentifier(
|
|
||||||
resourceName, "drawable", "me.kavishdevar.librepods"
|
|
||||||
)
|
|
||||||
|
|
||||||
if (resourceId != 0) {
|
|
||||||
val drawable = packageContext.resources.getDrawable(
|
|
||||||
resourceId, packageContext.theme
|
|
||||||
)
|
|
||||||
setImageDrawable(drawable)
|
|
||||||
} else {
|
|
||||||
setImageResource(getIconResourceForMode(mode))
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
setImageResource(getIconResourceForMode(mode))
|
|
||||||
Log.e(TAG, "Failed to load custom drawable for mode $mode: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSelected) {
|
|
||||||
setColorFilter(Color.BLACK)
|
|
||||||
} else {
|
|
||||||
setColorFilter(Color.WHITE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addView(icon)
|
|
||||||
|
|
||||||
background = if (isSelected) {
|
|
||||||
createSelectedBackground(context)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
setOnClickListener {
|
|
||||||
Log.d(TAG, "ANC mode selected: $mode (was: $currentANCMode)")
|
|
||||||
val container = findAirPodsContainer(this)
|
|
||||||
val drawerContainer = container?.findViewWithTag<View>("airpods_drawer_container")
|
|
||||||
|
|
||||||
if (currentANCMode == mode) {
|
|
||||||
if (drawerContainer != null && container != null) {
|
|
||||||
hideAirPodsDrawer(container, mainButton, drawerContainer)
|
|
||||||
}
|
|
||||||
return@setOnClickListener
|
|
||||||
}
|
|
||||||
|
|
||||||
currentANCMode = mode
|
|
||||||
|
|
||||||
val parentDrawer = parent as? ViewGroup
|
|
||||||
if (parentDrawer != null) {
|
|
||||||
for (i in 0 until parentDrawer.childCount) {
|
|
||||||
val child = parentDrawer.getChildAt(i) as? LinearLayout
|
|
||||||
if (child != null && child.tag.toString().startsWith("anc_mode_")) {
|
|
||||||
val childModeStr = child.tag.toString().substringAfter("anc_mode_")
|
|
||||||
val childMode = childModeStr.toIntOrNull() ?: -1
|
|
||||||
val childIcon = child.findViewWithTag<ImageView>("mode_icon_${childMode}")
|
|
||||||
|
|
||||||
if (childMode == mode) {
|
|
||||||
child.background = createSelectedBackground(context)
|
|
||||||
childIcon?.setColorFilter(Color.BLACK)
|
|
||||||
} else {
|
|
||||||
child.background = null
|
|
||||||
childIcon?.setColorFilter(Color.WHITE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val intent = Intent(ACTION_SET_ANC_MODE).apply {
|
|
||||||
setPackage("me.kavishdevar.librepods")
|
|
||||||
putExtra(EXTRA_ANC_MODE, mode)
|
|
||||||
}
|
|
||||||
context.sendBroadcast(intent)
|
|
||||||
Log.d(TAG, "Sent broadcast to change ANC mode to: ${getLabelForMode(currentANCMode)}")
|
|
||||||
|
|
||||||
|
|
||||||
updateMainButtonIcon(context, mainButton, mode)
|
|
||||||
|
|
||||||
if (drawerContainer != null && container != null) {
|
|
||||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
|
||||||
hideAirPodsDrawer(container, mainButton, drawerContainer)
|
|
||||||
}, 50)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createSelectedBackground(context: Context): GradientDrawable {
|
|
||||||
return GradientDrawable().apply {
|
|
||||||
shape = GradientDrawable.RECTANGLE
|
|
||||||
setColor(Color.WHITE)
|
|
||||||
cornerRadius = 50f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findAirPodsContainer(view: View): ViewGroup? {
|
|
||||||
var current: View? = view
|
|
||||||
while (current != null) {
|
|
||||||
if (current is ViewGroup && current.tag == "airpods_container") {
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
val parent = current.parent
|
|
||||||
if (parent is ViewGroup && parent.tag == "airpods_container") {
|
|
||||||
return parent
|
|
||||||
}
|
|
||||||
current = parent as? View
|
|
||||||
}
|
|
||||||
Log.w(TAG, "Could not find airpods_container ancestor")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showAirPodsDrawer(container: ViewGroup, mainButton: ImageButton, drawerContainer: View) {
|
|
||||||
Log.d(TAG, "Showing AirPods drawer")
|
|
||||||
val selectedModeView = drawerContainer.findViewWithTag<View>("anc_mode_$currentANCMode")
|
|
||||||
val selectedModeIcon = selectedModeView?.findViewWithTag<ImageView>("mode_icon_$currentANCMode")
|
|
||||||
val buttonContainer = container.findViewWithTag<View>("airpods_button_container")
|
|
||||||
|
|
||||||
if (selectedModeView == null || selectedModeIcon == null) {
|
|
||||||
Log.e(TAG, "Cannot find selected mode view or icon for show animation")
|
|
||||||
|
|
||||||
drawerContainer.alpha = 0f
|
|
||||||
drawerContainer.visibility = View.VISIBLE
|
|
||||||
|
|
||||||
drawerContainer.animate()
|
|
||||||
.alpha(1f)
|
|
||||||
.setDuration(ANIMATION_DURATION)
|
|
||||||
.start()
|
|
||||||
|
|
||||||
buttonContainer?.animate()
|
|
||||||
?.alpha(0f)
|
|
||||||
?.setDuration(ANIMATION_DURATION / 2)
|
|
||||||
?.setStartDelay(ANIMATION_DURATION / 2)
|
|
||||||
?.withEndAction {
|
|
||||||
buttonContainer.visibility = View.GONE
|
|
||||||
}
|
|
||||||
?.start()
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
drawerContainer.measure(
|
|
||||||
View.MeasureSpec.makeMeasureSpec(container.width, View.MeasureSpec.EXACTLY),
|
|
||||||
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
|
||||||
)
|
|
||||||
|
|
||||||
val drawerHeight = drawerContainer.measuredHeight
|
|
||||||
|
|
||||||
drawerContainer.alpha = 0f
|
|
||||||
drawerContainer.visibility = View.VISIBLE
|
|
||||||
drawerContainer.translationY = -drawerHeight.toFloat()
|
|
||||||
|
|
||||||
drawerContainer.animate()
|
|
||||||
.translationY(0f)
|
|
||||||
.alpha(1f)
|
|
||||||
.setDuration(ANIMATION_DURATION)
|
|
||||||
.setInterpolator(DecelerateInterpolator())
|
|
||||||
.start()
|
|
||||||
|
|
||||||
buttonContainer?.animate()
|
|
||||||
?.alpha(0f)
|
|
||||||
?.setDuration(ANIMATION_DURATION / 2)
|
|
||||||
?.setStartDelay(ANIMATION_DURATION / 3)
|
|
||||||
?.withEndAction {
|
|
||||||
buttonContainer.visibility = View.GONE
|
|
||||||
}
|
|
||||||
?.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hideAirPodsDrawer(container: ViewGroup, mainButton: ImageButton, drawerContainer: View) {
|
|
||||||
Log.d(TAG, "Hiding AirPods drawer")
|
|
||||||
val buttonContainer = container.findViewWithTag<View>("airpods_button_container")
|
|
||||||
|
|
||||||
if (buttonContainer != null && buttonContainer.visibility != View.VISIBLE) {
|
|
||||||
buttonContainer.alpha = 0f
|
|
||||||
buttonContainer.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
|
|
||||||
buttonContainer?.animate()
|
|
||||||
?.alpha(1f)
|
|
||||||
?.setDuration(ANIMATION_DURATION / 2)
|
|
||||||
?.start()
|
|
||||||
|
|
||||||
drawerContainer.animate()
|
|
||||||
.translationY(-drawerContainer.height.toFloat())
|
|
||||||
.alpha(0f)
|
|
||||||
.setDuration(ANIMATION_DURATION)
|
|
||||||
.setInterpolator(AccelerateInterpolator())
|
|
||||||
.setStartDelay(ANIMATION_DURATION / 4)
|
|
||||||
.withEndAction {
|
|
||||||
drawerContainer.visibility = View.GONE
|
|
||||||
drawerContainer.translationY = 0f
|
|
||||||
}
|
|
||||||
.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getIconResourceForMode(mode: Int): Int {
|
|
||||||
return when (mode) {
|
|
||||||
ANC_MODE_OFF -> android.R.drawable.ic_lock_silent_mode
|
|
||||||
ANC_MODE_TRANSPARENCY -> android.R.drawable.ic_lock_silent_mode_off
|
|
||||||
ANC_MODE_ADAPTIVE -> android.R.drawable.ic_menu_compass
|
|
||||||
ANC_MODE_NOISE_CANCELLATION -> android.R.drawable.ic_lock_idle_charging
|
|
||||||
else -> android.R.drawable.ic_lock_silent_mode_off
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getLabelForMode(mode: Int): String {
|
|
||||||
return when (mode) {
|
|
||||||
ANC_MODE_OFF -> "Off"
|
|
||||||
ANC_MODE_TRANSPARENCY -> "Transparency"
|
|
||||||
ANC_MODE_ADAPTIVE -> "Adaptive"
|
|
||||||
ANC_MODE_NOISE_CANCELLATION -> "Noise Cancellation"
|
|
||||||
else -> "Unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Build
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
|
|
||||||
|
fun isSupported(sharedPreferences: SharedPreferences): Boolean {
|
||||||
|
val isPixel = Build.MANUFACTURER.lowercase() == "google"
|
||||||
|
val isOppoOrOnePlus = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo")
|
||||||
|
|
||||||
|
if (isPixel) {
|
||||||
|
when (Build.VERSION.SDK_INT) {
|
||||||
|
36 -> {
|
||||||
|
return Build.ID == "CP1A.260305.018" || Build.ID == "CP1A.260405.005"
|
||||||
|
}
|
||||||
|
|
||||||
|
37 -> {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (isOppoOrOnePlus) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return if (BuildConfig.FLAVOR == "xposed") true
|
||||||
|
else sharedPreferences.getBoolean("bypass_device_check", false)
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package me.kavishdevar.librepods.utils
|
|||||||
|
|
||||||
import android.bluetooth.BluetoothDevice
|
import android.bluetooth.BluetoothDevice
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import org.lsposed.hiddenapibypass.HiddenApiBypass
|
|
||||||
|
|
||||||
object SystemApisUtils {
|
object SystemApisUtils {
|
||||||
|
|
||||||
@@ -288,18 +287,16 @@ object SystemApisUtils {
|
|||||||
/**
|
/**
|
||||||
* Helper method to set metadata using HiddenApiBypass
|
* Helper method to set metadata using HiddenApiBypass
|
||||||
*/
|
*/
|
||||||
fun setMetadata(device: BluetoothDevice, key: Int, value: ByteArray): Boolean {
|
fun setMetadata(device: BluetoothDevice, key: Int, value: ByteArray): Boolean {
|
||||||
return try {
|
return try {
|
||||||
val result = HiddenApiBypass.invoke(
|
val method = BluetoothDevice::class.java.getMethod(
|
||||||
BluetoothDevice::class.java,
|
|
||||||
device,
|
|
||||||
"setMetadata",
|
"setMetadata",
|
||||||
key,
|
Int::class.java,
|
||||||
value
|
ByteArray::class.java
|
||||||
) as Boolean
|
)
|
||||||
result
|
method.invoke(device, key, value) as Boolean
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("SystemApisUtils", "Failed to set metadata for key $key", e)
|
Log.w("SystemApisUtils", "Failed to set metadata for key $key: ${e.message}")
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
|
import io.github.libxposed.service.XposedService
|
||||||
|
|
||||||
|
object XposedServiceHolder {
|
||||||
|
var service: XposedService? = null
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 605 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user