diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9b20a00b..2fd88956 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,6 +6,9 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) alias(libs.plugins.aboutLibraries) + alias(libs.plugins.ksp) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.room3) // alias(libs.plugins.hilt) id("kotlin-parcelize") } @@ -24,10 +27,17 @@ val releaseSigningAvailable = listOf( "RELEASE_KEY_PASSWORD" ).all { props[it]?.toString()?.isNotBlank() == true } +room3 { + schemaDirectory("$projectDir/schemas") +} + kotlin { compilerOptions { - optIn.add( - "androidx.compose.material3.ExperimentalMaterial3ExpressiveApi" + optIn.addAll( + "androidx.compose.material3.ExperimentalMaterial3ExpressiveApi", + "kotlin.uuid.ExperimentalUuidApi", + "kotlinx.coroutines.FlowPreview", + "kotlinx.serialization.ExperimentalSerializationApi" ) } } @@ -49,7 +59,7 @@ android { defaultConfig { applicationId = "me.kavishdevar.librepods" targetSdk = 37 - versionCode = 63 + versionCode = 65 versionName = appVersionName } buildTypes { @@ -125,7 +135,6 @@ android { dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.accompanist.permissions) - implementation(libs.androidx.compose.ui.text.google.fonts) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.lifecycle.runtime.ktx) @@ -140,12 +149,11 @@ dependencies { implementation(libs.haze) implementation(libs.haze.materials) implementation(libs.androidx.dynamicanimation) - implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.material.icons.core) implementation(libs.billing) debugImplementation(libs.androidx.compose.ui.tooling) implementation(libs.androidx.compose.foundation.layout) - implementation(libs.aboutlibraries) + implementation(libs.aboutlibraries.compose) implementation(libs.aboutlibraries.compose.m3) implementation(libs.backdrop) // implementation(libs.hilt) @@ -158,6 +166,11 @@ dependencies { implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.navigationevent) + implementation(libs.androidx.room3.runtime) + ksp(libs.androidx.room3.compiler) + implementation(libs.kotlinx.serialization.cbor) + +// compileOnly(files("../../../framework-classes.jar")) } aboutLibraries { @@ -178,6 +191,7 @@ fun registerRootModuleZipTask( flavor: String, buildType: String ) = tasks.register(name) { + description = "Zips the root module for the $flavor-$buildType variant." val variantTask = "assemble${cap(flavor)}${cap(buildType)}" dependsOn(variantTask) @@ -213,6 +227,7 @@ val zipDebug = registerRootModuleZipTask( val collect = tasks.register("collectReleaseArtifacts") { + description = "Collects release artifacts (APK, AAB, and root module ZIP) into a single directory for easier distribution." dependsOn( zipRelease, zipDebug, @@ -241,5 +256,6 @@ val collect = tasks.register("collectReleaseArtifacts") { } tasks.register("packageReleaseArtifacts") { + description = "Packages release artifacts (APK, AAB, and root module ZIP) into a single directory for easier distribution." dependsOn(collect) } diff --git a/android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/1.json b/android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/1.json new file mode 100644 index 00000000..674a5c26 --- /dev/null +++ b/android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/1.json @@ -0,0 +1,173 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "ae2e348e0198b8d871e9e3ed6959ab30", + "entities": [ + { + "tableName": "AppleEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`macAddress` TEXT NOT NULL, `settings` BLOB NOT NULL, `metadata` BLOB NOT NULL, `cache` BLOB NOT NULL, PRIMARY KEY(`macAddress`))", + "fields": [ + { + "fieldPath": "macAddress", + "columnName": "macAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "settings", + "columnName": "settings", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "cache", + "columnName": "cache", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "macAddress" + ] + } + }, + { + "tableName": "AppSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, `debugMode` INTEGER NOT NULL, `bleScanMode` INTEGER NOT NULL, `bleReportDelay` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nightMode", + "columnName": "nightMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "designSystem", + "columnName": "designSystem", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "debugMode", + "columnName": "debugMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bleScanMode", + "columnName": "bleScanMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bleReportDelay", + "columnName": "bleReportDelay", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "AppStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `hasCompletedOnboarding` INTEGER NOT NULL, `lastVersionShown` TEXT, `hasConnectedToAACP` INTEGER NOT NULL, `firstSuccessfulConnectionTime` INTEGER, `reviewPrompted` INTEGER NOT NULL, `timeUntilFOSSPremiumExpiry` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasCompletedOnboarding", + "columnName": "hasCompletedOnboarding", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastVersionShown", + "columnName": "lastVersionShown", + "affinity": "TEXT" + }, + { + "fieldPath": "hasConnectedToAACP", + "columnName": "hasConnectedToAACP", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSuccessfulConnectionTime", + "columnName": "firstSuccessfulConnectionTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "reviewPrompted", + "columnName": "reviewPrompted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeUntilFOSSPremiumExpiry", + "columnName": "timeUntilFOSSPremiumExpiry", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "WidgetConfigEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appWidgetId` INTEGER NOT NULL, `macAddress` TEXT NOT NULL, PRIMARY KEY(`appWidgetId`))", + "fields": [ + { + "fieldPath": "appWidgetId", + "columnName": "appWidgetId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "macAddress", + "columnName": "macAddress", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appWidgetId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ae2e348e0198b8d871e9e3ed6959ab30')" + ] + } +} \ No newline at end of file diff --git a/android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/2.json b/android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/2.json new file mode 100644 index 00000000..bf635241 --- /dev/null +++ b/android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/2.json @@ -0,0 +1,155 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "b6034db24a2a1b8f4d424d89fcc4495f", + "entities": [ + { + "tableName": "AppleEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`macAddress` TEXT NOT NULL, `settings` BLOB NOT NULL, `metadata` BLOB NOT NULL, `cache` BLOB NOT NULL, PRIMARY KEY(`macAddress`))", + "fields": [ + { + "fieldPath": "macAddress", + "columnName": "macAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "settings", + "columnName": "settings", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "cache", + "columnName": "cache", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "macAddress" + ] + } + }, + { + "tableName": "AppSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nightMode", + "columnName": "nightMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "designSystem", + "columnName": "designSystem", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "AppStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `hasCompletedOnboarding` INTEGER NOT NULL, `lastVersionShown` TEXT, `hasConnectedToAACP` INTEGER NOT NULL, `firstSuccessfulConnectionTime` INTEGER, `reviewPrompted` INTEGER NOT NULL, `timeUntilFOSSPremiumExpiry` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasCompletedOnboarding", + "columnName": "hasCompletedOnboarding", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastVersionShown", + "columnName": "lastVersionShown", + "affinity": "TEXT" + }, + { + "fieldPath": "hasConnectedToAACP", + "columnName": "hasConnectedToAACP", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSuccessfulConnectionTime", + "columnName": "firstSuccessfulConnectionTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "reviewPrompted", + "columnName": "reviewPrompted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeUntilFOSSPremiumExpiry", + "columnName": "timeUntilFOSSPremiumExpiry", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "WidgetConfigEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appWidgetId` INTEGER NOT NULL, `macAddress` TEXT NOT NULL, PRIMARY KEY(`appWidgetId`))", + "fields": [ + { + "fieldPath": "appWidgetId", + "columnName": "appWidgetId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "macAddress", + "columnName": "macAddress", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appWidgetId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b6034db24a2a1b8f4d424d89fcc4495f')" + ] + } +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0474dfd8..526a6ed6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -26,15 +26,21 @@ + + + + + @@ -80,7 +86,7 @@ @@ -98,16 +104,11 @@ + android:name=".presentation.activities.NoiseControlWidgetConfigurationActivity" + android:exported="false" /> diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt index 02de88f3..29820a16 100644 --- a/android/app/src/main/cpp/CMakeLists.txt +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -3,21 +3,21 @@ cmake_minimum_required(VERSION 3.22.1) project("l2c_fcr_hook") set(CMAKE_CXX_STANDARD 23) -add_library(bluetooth_socket SHARED - bluetooth_socket.cpp +add_library(hiddenapi SHARED + hiddenapi.cpp ) -target_compile_options(bluetooth_socket PRIVATE +target_compile_options(hiddenapi PRIVATE -O2 -fvisibility=hidden ) -target_link_options(bluetooth_socket PRIVATE +target_link_options(hiddenapi PRIVATE -Wl,--strip-all -Wl,--gc-sections ) -target_link_libraries(bluetooth_socket +target_link_libraries(hiddenapi android log ) @@ -25,8 +25,8 @@ target_link_libraries(bluetooth_socket set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp) -add_library(l2c_fcr_hook SHARED - l2c_fcr_hook.cpp +add_library(fluoride_hooks SHARED + fluoride_hooks.cpp xz/xz_crc32.c xz/xz_crc64.c @@ -36,11 +36,11 @@ add_library(l2c_fcr_hook SHARED xz/xz_dec_bcj.c ) -target_include_directories(l2c_fcr_hook PRIVATE +target_include_directories(fluoride_hooks PRIVATE xz ) -target_compile_definitions(l2c_fcr_hook PRIVATE +target_compile_definitions(fluoride_hooks PRIVATE XZ_DEC_X86 XZ_DEC_ARM XZ_DEC_ARMTHUMB @@ -51,7 +51,7 @@ target_compile_definitions(l2c_fcr_hook PRIVATE XZ_DEC_CONCATENATED ) -target_link_libraries(l2c_fcr_hook +target_link_libraries(fluoride_hooks android log ) diff --git a/android/app/src/main/cpp/fluoride_hooks.cpp b/android/app/src/main/cpp/fluoride_hooks.cpp new file mode 100644 index 00000000..3c1a9e86 --- /dev/null +++ b/android/app/src/main/cpp/fluoride_hooks.cpp @@ -0,0 +1,203 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "helpers.h" + +#include "fluoride_hooks.h" + +static std::atomic enableSdpHook(false); + +static HookFunType hook_func = nullptr; + +static uint8_t (*original_l2c_fcr_chk_chan_modes)(void *) = nullptr; + +uint8_t fake_l2c_fcr_chk_chan_modes(void *p_ccb) { + LOGI("fake_l2c_fcr_chk_chan_modes called"); + uint8_t orig = 0; + if (original_l2c_fcr_chk_chan_modes) + orig = original_l2c_fcr_chk_chan_modes(p_ccb); + + LOGI("fake_l2c_fcr_chk_chan_modes: orig = %d, returning 1", orig); + return 1; +} + +static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD *, uint32_t *) = nullptr; + +tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD *p_device_info, uint32_t *p_handle) { + + LOGI("fake_BTA_DmSetLocalDiRecord called"); + + if (original_BTA_DmSetLocalDiRecord && + enableSdpHook.load(std::memory_order_relaxed)) + original_BTA_DmSetLocalDiRecord(p_device_info, p_handle); + + LOGI("fake_BTA_DmSetLocalDiRecord: modifying vendor to 0x004C, vendor_id_source to 0x0001"); + + if (p_device_info) { + p_device_info->vendor = 0x004C; + p_device_info->vendor_id_source = 0x0001; + } + + LOGI("fake_BTA_DmSetLocalDiRecord: returning status %d", + original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, p_handle) + : BTA_FAILURE); + return original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, + p_handle) + : BTA_FAILURE; +} + + +static bool hookLibrary(const char *libname) { + LOGI("hookLibrary called with libname: %s", libname); + + if (!hook_func) { + LOGE("hook_func not initialized"); + return false; + } + + std::string path; + if (!getLibraryPath(libname, path)) { + LOGE("Failed to locate %s", libname); + return false; + } + LOGI("hookLibrary: located path: %s", path.c_str()); + + int fd = open(path.c_str(), O_RDONLY); + if (fd < 0) { + LOGE("hookLibrary: open failed"); + return false; + } + + struct stat st{}; + if (fstat(fd, &st) != 0) { + LOGE("hookLibrary: fstat failed"); + close(fd); + return false; + } + LOGI("hookLibrary: opened file, size: %lld", (long long) st.st_size); + + std::vector file(st.st_size); + read(fd, file.data(), st.st_size); + close(fd); + + auto *eh = reinterpret_cast(file.data()); + auto *shdr = reinterpret_cast( + file.data() + eh->e_shoff); + + const char *shstr = reinterpret_cast( + file.data() + shdr[eh->e_shstrndx].sh_offset); + + uint64_t chk_offset = 0; + uint64_t sdp_offset = 0; + + for (int i = 0; i < eh->e_shnum; ++i) { + if (!strcmp(shstr + shdr[i].sh_name, ".gnu_debugdata")) { + LOGI("hookLibrary: found .gnu_debugdata section"); + + std::vector compressed(file.begin() + shdr[i].sh_offset, + file.begin() + shdr[i].sh_offset + shdr[i].sh_size); + + std::vector decompressed; + + if (decompressXZ(compressed.data(), compressed.size(), decompressed)) { + chk_offset = findSymbolOffset(decompressed, "l2c_fcr_chk_chan_modes"); + sdp_offset = findSymbolOffset(decompressed, "BTA_DmSetLocalDiRecord"); + } else { + LOGE("debugdata decompress failed"); + } + + break; + } + } + + if (!chk_offset) { + LOGI("fallback dynsym chk"); + chk_offset = findSymbolOffsetDynsym(file, "l2c_fcr_chk_chan_modes"); + } + + if (!sdp_offset) { + LOGI("fallback dynsym sdp"); + sdp_offset = findSymbolOffsetDynsym(file, "BTA_DmSetLocalDiRecord"); + } + + uintptr_t base = getModuleBase(libname); + + if (!base) { + LOGE("hookLibrary: getModuleBase failed"); + return false; + } + + if (chk_offset) { + void *target = reinterpret_cast(base + chk_offset); + hook_func(target, (void *) fake_l2c_fcr_chk_chan_modes, + (void **) &original_l2c_fcr_chk_chan_modes); + LOGI("hooked chk"); + } + + if (sdp_offset) { + void *target = reinterpret_cast(base + sdp_offset); + hook_func(target, (void *) fake_BTA_DmSetLocalDiRecord, + (void **) &original_BTA_DmSetLocalDiRecord); + LOGI("hooked sdp"); + } + + return chk_offset || sdp_offset; +} + +static void on_library_loaded(const char *name, void *) { + LOGI("on_library_loaded called with name: %s", name); + + if (strstr(name, "libbluetooth_jni.so")) { + LOGI("Bluetooth JNI loaded"); + hookLibrary("libbluetooth_jni.so"); + } + + if (strstr(name, "libbluetooth_qti.so")) { + LOGI("Bluetooth QTI loaded"); + hookLibrary("libbluetooth_qti.so"); + } +} + +extern "C" [[gnu::visibility("default")]] +[[gnu::used]] +NativeOnModuleLoaded native_init(const NativeAPIEntries *entries) { + LOGI("native_init called with entries: %p", entries); + hook_func = (HookFunType) entries->hook_func; + LOGI("LibrePodsNativeHook initialized, sdp hook enabled: %d", + enableSdpHook.load(std::memory_order_relaxed)); + return on_library_loaded; +} + +extern "C" JNIEXPORT void JNICALL +Java_me_kavishdevar_librepods_utils_NativeBridge_setSdpHook(JNIEnv *, jobject thiz, + jboolean enable) { + LOGI("setSdpHook called with enable: %d", enable); + enableSdpHook.store(enable, std::memory_order_relaxed); + + LOGI("sdp hook enabled: %d", enable); +} diff --git a/android/app/src/main/cpp/l2c_fcr_hook.h b/android/app/src/main/cpp/fluoride_hooks.h similarity index 100% rename from android/app/src/main/cpp/l2c_fcr_hook.h rename to android/app/src/main/cpp/fluoride_hooks.h diff --git a/android/app/src/main/cpp/l2c_fcr_hook.cpp b/android/app/src/main/cpp/helpers.h similarity index 51% rename from android/app/src/main/cpp/l2c_fcr_hook.cpp rename to android/app/src/main/cpp/helpers.h index 529f8627..3daefa6b 100644 --- a/android/app/src/main/cpp/l2c_fcr_hook.cpp +++ b/android/app/src/main/cpp/helpers.h @@ -1,21 +1,3 @@ -/* - 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 . -*/ - #include #include #include @@ -24,60 +6,17 @@ #include #include #include -#include #include -#include "l2c_fcr_hook.h" extern "C" { -#include "xz.h" + #include "xz.h" } #define LOG_TAG "LibrePodsHook" #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) -static HookFunType hook_func = nullptr; - -static uint8_t (*original_l2c_fcr_chk_chan_modes)(void *) = nullptr; - -static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD *, uint32_t *) = nullptr; - -static std::atomic enableSdpHook(false); - -uint8_t fake_l2c_fcr_chk_chan_modes(void *p_ccb) { - LOGI("fake_l2c_fcr_chk_chan_modes called"); - uint8_t orig = 0; - if (original_l2c_fcr_chk_chan_modes) - orig = original_l2c_fcr_chk_chan_modes(p_ccb); - - LOGI("fake_l2c_fcr_chk_chan_modes: orig = %d, returning 1", orig); - return 1; -} - -tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD *p_device_info, uint32_t *p_handle) { - - LOGI("fake_BTA_DmSetLocalDiRecord called"); - - if (original_BTA_DmSetLocalDiRecord && - enableSdpHook.load(std::memory_order_relaxed)) - original_BTA_DmSetLocalDiRecord(p_device_info, p_handle); - - LOGI("fake_BTA_DmSetLocalDiRecord: modifying vendor to 0x004C, vendor_id_source to 0x0001"); - - if (p_device_info) { - p_device_info->vendor = 0x004C; - p_device_info->vendor_id_source = 0x0001; - } - - LOGI("fake_BTA_DmSetLocalDiRecord: returning status %d", - original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, p_handle) - : BTA_FAILURE); - return original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, - p_handle) - : BTA_FAILURE; -} - static bool decompressXZ(const uint8_t *input, size_t input_size, std::vector &output) { LOGI("decompressXZ called with input_size: %zu", input_size); @@ -184,7 +123,7 @@ static uintptr_t getModuleBase(const char *name) { while (fgets(line, sizeof(line), fp)) { if (strstr(line, name)) { base = strtoull(line, nullptr, 16); - LOGI("getModuleBase: found base at 0x%lx", base); + LOGI("getModuleBase: found base at 0x%x", base); break; } } @@ -302,134 +241,3 @@ static uint64_t findSymbolOffset(const std::vector &elf, const char *sy LOGI("findSymbolOffset: no match found for %s", symbol_substring); return 0; } - -static bool hookLibrary(const char *libname) { - LOGI("hookLibrary called with libname: %s", libname); - - if (!hook_func) { - LOGE("hook_func not initialized"); - return false; - } - - std::string path; - if (!getLibraryPath(libname, path)) { - LOGE("Failed to locate %s", libname); - return false; - } - LOGI("hookLibrary: located path: %s", path.c_str()); - - int fd = open(path.c_str(), O_RDONLY); - if (fd < 0) { - LOGE("hookLibrary: open failed"); - return false; - } - - struct stat st{}; - if (fstat(fd, &st) != 0) { - LOGE("hookLibrary: fstat failed"); - close(fd); - return false; - } - LOGI("hookLibrary: opened file, size: %lld", (long long) st.st_size); - - std::vector file(st.st_size); - read(fd, file.data(), st.st_size); - close(fd); - - auto *eh = reinterpret_cast(file.data()); - auto *shdr = reinterpret_cast( - file.data() + eh->e_shoff); - - const char *shstr = reinterpret_cast( - file.data() + shdr[eh->e_shstrndx].sh_offset); - - uint64_t chk_offset = 0; - uint64_t sdp_offset = 0; - - for (int i = 0; i < eh->e_shnum; ++i) { - if (!strcmp(shstr + shdr[i].sh_name, ".gnu_debugdata")) { - LOGI("hookLibrary: found .gnu_debugdata section"); - - std::vector compressed(file.begin() + shdr[i].sh_offset, - file.begin() + shdr[i].sh_offset + shdr[i].sh_size); - - std::vector decompressed; - - if (decompressXZ(compressed.data(), compressed.size(), decompressed)) { - - chk_offset = findSymbolOffset(decompressed, "l2c_fcr_chk_chan_modes"); - - sdp_offset = findSymbolOffset(decompressed, "BTA_DmSetLocalDiRecord"); - } else { - LOGE("debugdata decompress failed"); - } - - break; - } - } - - if (!chk_offset) { - LOGI("fallback dynsym chk"); - chk_offset = findSymbolOffsetDynsym(file, "l2c_fcr_chk_chan_modes"); - } - - if (!sdp_offset) { - LOGI("fallback dynsym sdp"); - sdp_offset = findSymbolOffsetDynsym(file, "BTA_DmSetLocalDiRecord"); - } - - uintptr_t base = getModuleBase(libname); - if (!base) { - LOGE("hookLibrary: getModuleBase failed"); - return false; - } - - if (chk_offset) { - void *target = reinterpret_cast(base + chk_offset); - hook_func(target, (void *) fake_l2c_fcr_chk_chan_modes, - (void **) &original_l2c_fcr_chk_chan_modes); - LOGI("hooked chk"); - } - - if (sdp_offset) { - void *target = reinterpret_cast(base + sdp_offset); - hook_func(target, (void *) fake_BTA_DmSetLocalDiRecord, - (void **) &original_BTA_DmSetLocalDiRecord); - LOGI("hooked sdp"); - } - - return chk_offset || sdp_offset; -} - -static void on_library_loaded(const char *name, void *) { - LOGI("on_library_loaded called with name: %s", name); - - if (strstr(name, "libbluetooth_jni.so")) { - LOGI("Bluetooth JNI loaded"); - hookLibrary("libbluetooth_jni.so"); - } - - if (strstr(name, "libbluetooth_qti.so")) { - LOGI("Bluetooth QTI loaded"); - hookLibrary("libbluetooth_qti.so"); - } -} - -extern "C" [[gnu::visibility("default")]] -[[gnu::used]] -NativeOnModuleLoaded native_init(const NativeAPIEntries *entries) { - LOGI("native_init called with entries: %p", entries); - hook_func = (HookFunType) entries->hook_func; - LOGI("LibrePodsNativeHook initialized, sdp hook enabled: %d", - enableSdpHook.load(std::memory_order_relaxed)); - return on_library_loaded; -} - -extern "C" JNIEXPORT void JNICALL -Java_me_kavishdevar_librepods_utils_NativeBridge_setSdpHook(JNIEnv *, jobject thiz, - jboolean enable) { - LOGI("setSdpHook called with enable: %d", enable); - enableSdpHook.store(enable, std::memory_order_relaxed); - - LOGI("sdp hook enabled: %d", enable); -} diff --git a/android/app/src/main/cpp/bluetooth_socket.cpp b/android/app/src/main/cpp/hiddenapi.cpp similarity index 96% rename from android/app/src/main/cpp/bluetooth_socket.cpp rename to android/app/src/main/cpp/hiddenapi.cpp index 865d4216..269071c5 100644 --- a/android/app/src/main/cpp/bluetooth_socket.cpp +++ b/android/app/src/main/cpp/hiddenapi.cpp @@ -41,7 +41,7 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { 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;"); + constexpr auto c8 = ENC("L"); JNIEnv* env; getVm()->AttachCurrentThread(&env, nullptr); diff --git a/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt deleted file mode 100644 index 2aa9158c..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt +++ /dev/null @@ -1,639 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods - -import android.annotation.SuppressLint -import android.content.BroadcastReceiver -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.ServiceConnection -import android.media.AudioManager -import android.os.Build -import android.os.Bundle -import android.os.IBinder -import android.util.Log -import android.view.Gravity -import android.view.WindowManager -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.animation.Crossfade -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectVerticalDragGestures -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.launch -import me.kavishdevar.librepods.presentation.components.AdaptiveRainbowBrush -import me.kavishdevar.librepods.presentation.components.ControlCenterNoiseControlSegmentedButton -import me.kavishdevar.librepods.presentation.components.IconAreaSize -import me.kavishdevar.librepods.presentation.components.VerticalVolumeSlider -import me.kavishdevar.librepods.data.AirPodsNotifications -import me.kavishdevar.librepods.data.NoiseControlMode -import me.kavishdevar.librepods.services.AirPodsService -import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme -import me.kavishdevar.librepods.bluetooth.AACPManager -import kotlin.io.encoding.ExperimentalEncodingApi -import kotlin.math.abs - -class QuickSettingsDialogActivity : ComponentActivity() { - - private var airPodsService: AirPodsService? = null - private var isBound = false - - private var isNoiseControlExpandedState by mutableStateOf(false) - - private val connection = object : ServiceConnection { - override fun onServiceConnected(className: ComponentName, service: IBinder) { - val binder = service as AirPodsService.LocalBinder - airPodsService = binder.getService() - isBound = true - Log.d("QSActivity", "Service bound") - setContent { - LibrePodsTheme { - DraggableDismissBox( - onDismiss = { finish() }, - onlyCollapseWhenClicked = { - if (isNoiseControlExpandedState) { - isNoiseControlExpandedState = false - true - } else { - false - } - } - ) { - if (isBound && airPodsService != null) { - NewControlCenterDialogContent( - service = airPodsService, - isNoiseControlExpanded = isNoiseControlExpandedState, - onNoiseControlExpandedChange = { isNoiseControlExpandedState = it } - ) - } - } - } - } - } - - override fun onServiceDisconnected(arg0: ComponentName) { - isBound = false - airPodsService = null - Log.d("QSActivity", "Service unbound") - finish() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL) - window.addFlags(WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH) - window.addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND) - window.setGravity(Gravity.BOTTOM) - - Intent(this, AirPodsService::class.java).also { intent -> - bindService(intent, connection, BIND_AUTO_CREATE) - } - - setContent { - LibrePodsTheme { - DraggableDismissBox( - onDismiss = { finish() }, - onlyCollapseWhenClicked = { - if (isNoiseControlExpandedState) { - isNoiseControlExpandedState = false - true - } else { - false - } - } - ) { - if (isBound && airPodsService != null) { - NewControlCenterDialogContent( - service = airPodsService, - isNoiseControlExpanded = isNoiseControlExpandedState, - onNoiseControlExpandedChange = { isNoiseControlExpandedState = it } - ) - } - } - } - } - } - - override fun onDestroy() { - super.onDestroy() - if (isBound) { - unbindService(connection) - isBound = false - } - } -} - -@Composable -fun DraggableDismissBox( - onDismiss: () -> Unit, - onlyCollapseWhenClicked: () -> Boolean, - content: @Composable () -> Unit -) { - val coroutineScope = rememberCoroutineScope() - - var dragOffset by remember { mutableFloatStateOf(0f) } - var isDragging by remember { mutableStateOf(false) } - val dismissThreshold = 400f - - val animatedOffset = remember { Animatable(0f) } - val animatedScale = remember { Animatable(1f) } - val animatedAlpha = remember { Animatable(1f) } - - val backgroundAlpha by animateFloatAsState( - targetValue = if (isDragging) { - val dragProgress = (abs(dragOffset) / 800f).coerceIn(0f, 0.8f) - 1f - dragProgress - } else 1f, - label = "BackgroundFade" - ) - - LaunchedEffect(isDragging) { - if (!isDragging) { - if (abs(dragOffset) < dismissThreshold) { - val springSpec = spring( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessHigh, - visibilityThreshold = 0.1f - ) - launch { animatedOffset.animateTo(0f, springSpec) } - launch { animatedScale.animateTo(1f, springSpec) } - launch { animatedAlpha.animateTo(1f, tween(100)) } - dragOffset = 0f - } - } - } - - LaunchedEffect(dragOffset, isDragging) { - if (isDragging) { - val dragProgress = (abs(dragOffset) / 1000f).coerceIn(0f, 0.5f) - - animatedOffset.snapTo(dragOffset) - animatedScale.snapTo(1f - dragProgress * 0.3f) - animatedAlpha.snapTo(1f - dragProgress * 0.7f) - } - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.5f * backgroundAlpha)) - .pointerInput(Unit) { - detectVerticalDragGestures( - onDragStart = { isDragging = true }, - onDragEnd = { - isDragging = false - if (abs(dragOffset) > dismissThreshold) { - coroutineScope.launch { - val direction = if (dragOffset > 0) 1f else -1f - - launch { - animatedOffset.animateTo( - direction * 1500f, - tween(350, easing = FastOutSlowInEasing) - ) - } - launch { animatedScale.animateTo(0.7f, tween(350)) } - launch { animatedAlpha.animateTo(0f, tween(250)) } - - kotlinx.coroutines.delay(350) - onDismiss() - } - } - }, - onDragCancel = { isDragging = false }, - onVerticalDrag = { change, dragAmount -> - change.consume() - dragOffset += dragAmount - } - ) - } - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { - onlyCollapseWhenClicked() - }, - contentAlignment = Alignment.BottomCenter - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .graphicsLayer( - translationY = animatedOffset.value, - scaleX = animatedScale.value, - scaleY = animatedScale.value, - alpha = animatedAlpha.value - ), - contentAlignment = Alignment.BottomCenter - ) { - content() - } - } -} - -@SuppressLint("UnspecifiedRegisterReceiverFlag") -@Composable -fun NewControlCenterDialogContent( - service: AirPodsService?, - isNoiseControlExpanded: Boolean, - onNoiseControlExpandedChange: (Boolean) -> Unit -) { - val context = LocalContext.current - val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) - val textColor = Color.White - - var currentAncMode by remember { mutableStateOf(NoiseControlMode.TRANSPARENCY) } - var isConvAwarenessEnabled by remember { mutableStateOf(false) } - - val isOffModeEnabled = remember { sharedPreferences.getBoolean("off_listening_mode", true) } - val availableModes = remember(isOffModeEnabled) { - mutableListOf( - NoiseControlMode.TRANSPARENCY, - NoiseControlMode.ADAPTIVE, - NoiseControlMode.NOISE_CANCELLATION - ).apply { - if (isOffModeEnabled) { - add(0, NoiseControlMode.OFF) - } - } - } - - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) } - var currentVolumeInt by remember { mutableIntStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)) } - val animatedVolumeFraction by animateFloatAsState( - targetValue = currentVolumeInt.toFloat() / maxVolume.toFloat(), - animationSpec = spring( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessMediumLow - ), - label = "VolumeAnimation" - ) - var liveDragFraction by remember { mutableFloatStateOf(animatedVolumeFraction) } - var isDraggingVolume by remember { mutableStateOf(false) } - LaunchedEffect(animatedVolumeFraction, isDraggingVolume) { - if (!isDraggingVolume) { - liveDragFraction = animatedVolumeFraction - } - } - - DisposableEffect(service, availableModes) { - val ancReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == AirPodsNotifications.ANC_DATA && service != null) { - val newModeOrdinal = intent.getIntExtra("data", NoiseControlMode.TRANSPARENCY.ordinal + 1) - 1 - val newMode = NoiseControlMode.entries.getOrElse(newModeOrdinal) { NoiseControlMode.TRANSPARENCY } - if (availableModes.contains(newMode)) { - currentAncMode = newMode - } else if (newMode == NoiseControlMode.OFF && !isOffModeEnabled) { - currentAncMode = NoiseControlMode.TRANSPARENCY - } - Log.d("QSActivity", "ANC Receiver updated mode to: $currentAncMode (available: ${availableModes.joinToString()})") - } - } - } - val filter = IntentFilter(AirPodsNotifications.ANC_DATA) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.registerReceiver(ancReceiver, filter, Context.RECEIVER_EXPORTED) - } else { - context.registerReceiver(ancReceiver, filter) - } - - service?.let { - val initialModeOrdinal = it.getANC().minus(1) - var initialMode = NoiseControlMode.entries.getOrElse(initialModeOrdinal) { NoiseControlMode.TRANSPARENCY } - if (!availableModes.contains(initialMode)) { - initialMode = NoiseControlMode.TRANSPARENCY - } - currentAncMode = initialMode - isConvAwarenessEnabled = sharedPreferences.getBoolean("conversational_awareness", true) - Log.d("QSActivity", "Initial ANC: $currentAncMode, ConvAware: $isConvAwarenessEnabled") - } - - onDispose { - context.unregisterReceiver(ancReceiver) - } - } - - DisposableEffect(Unit) { - val volumeReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == "android.media.VOLUME_CHANGED_ACTION") { - val newVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - if (newVolume != currentVolumeInt) { - currentVolumeInt = newVolume - Log.d("QSActivity", "Volume Receiver updated volume to: $currentVolumeInt") - } - } - } - } - val filter = IntentFilter("android.media.VOLUME_CHANGED_ACTION") - context.registerReceiver(volumeReceiver, filter) - onDispose { - context.unregisterReceiver(volumeReceiver) - } - } - - val deviceName = remember { sharedPreferences.getString("name", "AirPods") ?: "AirPods" } - - Column( - modifier = Modifier - .fillMaxSize() - .background(Color.Transparent) - .padding(horizontal = 24.dp) - .pointerInput(Unit) { - awaitPointerEventScope { - while (true) { - awaitPointerEvent() - } - } - }, - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween - ) { - if (service != null) { - Spacer(modifier = Modifier.weight(1f)) - - Column( - modifier = Modifier - .weight(2f) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - painter = painterResource(id = R.drawable.airpods), - contentDescription = "Device Icon", - tint = textColor.copy(alpha = 0.8f), - modifier = Modifier.size(48.dp) - ) - - Spacer(modifier = Modifier.height(4.dp)) - - Text( - text = deviceName, - color = textColor, - fontSize = 16.sp, - fontWeight = FontWeight.Medium - ) - - Spacer(modifier = Modifier.height(32.dp)) - - VerticalVolumeSlider( - displayFraction = animatedVolumeFraction, - maxVolume = maxVolume, - onVolumeChange = { newVolume -> - currentVolumeInt = newVolume - try { - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVolume, 0) - } catch (e: Exception) { Log.e("QSActivity", "Failed to set volume", e) } - }, - initialFraction = animatedVolumeFraction, - onDragStateChange = { dragging -> isDraggingVolume = dragging }, - baseSliderHeight = 400.dp, - baseSliderWidth = 145.dp, - baseCornerRadius = 48.dp, - maxStretchFactor = 1.15f, - minCompressionFactor = 0.875f, - stretchSensitivity = 0.3f, - compressionSensitivity = 0.3f, - cornerRadiusChangeFactor = -0.5f, - directionalStretchRatio = 0.75f, - modifier = Modifier - .width(145.dp) - .padding(vertical = 8.dp) - ) - } - - Spacer(modifier = Modifier.weight(1f)) - - Box( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 72.dp) - .animateContentSize( - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium - ) - ), - contentAlignment = Alignment.Center - ) { - Crossfade( - targetState = isNoiseControlExpanded, - animationSpec = tween(durationMillis = 300), - label = "NoiseControlCrossfade" - ) { expanded -> - if (expanded) { - ControlCenterNoiseControlSegmentedButton( - availableModes = availableModes, - selectedMode = currentAncMode, - onModeSelected = { newMode -> - service.aacpManager.sendControlCommand( - identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, - value = newMode.ordinal + 1 - ) - currentAncMode = newMode - }, - modifier = Modifier.fillMaxWidth(0.8f) - ) - } else { - Row( - modifier = Modifier.fillMaxWidth(0.85f), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top - ) { - val noiseControlButtonBrush = if (currentAncMode == NoiseControlMode.ADAPTIVE) { - AdaptiveRainbowBrush - } else { - null - } - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.weight(1f) - ) { - Box( - modifier = Modifier - .size(IconAreaSize) - .clip(CircleShape) - .background( - brush = noiseControlButtonBrush ?: - Brush.linearGradient(colors = listOf(Color(0xFF0A84FF), Color(0xFF0A84FF))) - ) - .clickable( - onClick = { onNoiseControlExpandedChange(true) }, - indication = null, - interactionSource = remember { MutableInteractionSource() } - ), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(id = getModeIconRes(currentAncMode)), - contentDescription = getModeLabel(currentAncMode), - tint = Color.White, - modifier = Modifier.size(32.dp) - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = getModeLabel(currentAncMode), - color = Color.White, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - textAlign = androidx.compose.ui.text.style.TextAlign.Center - ) - } - - Spacer(modifier = Modifier.width(24.dp)) - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.weight(1f) - ) { - Box( - modifier = Modifier - .size(IconAreaSize) - .clip(CircleShape) - .background( - Brush.linearGradient( - colors = listOf( - if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E), - if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E) - ) - ) - ) - .clickable( - onClick = { - val newState = !isConvAwarenessEnabled - service.aacpManager.sendControlCommand( - identifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value, - value = newState - ) - isConvAwarenessEnabled = newState - }, - indication = null, - interactionSource = remember { MutableInteractionSource() } - ), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(id = R.drawable.airpods), - contentDescription = "Conversational Awareness", - tint = Color.White, - modifier = Modifier.size(32.dp) - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = "Conversational\nAwareness", - color = Color.White, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - textAlign = androidx.compose.ui.text.style.TextAlign.Center, - lineHeight = 14.sp - ) - } - } - } - } - } - - } else { - Spacer(modifier = Modifier.weight(1f)) - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - Text("Loading...", color = textColor) - } - Spacer(modifier = Modifier.weight(1f)) - } - } -} - -private fun getModeIconRes(mode: NoiseControlMode): Int { - return when (mode) { - NoiseControlMode.OFF -> R.drawable.noise_cancellation - NoiseControlMode.TRANSPARENCY -> R.drawable.transparency - NoiseControlMode.ADAPTIVE -> R.drawable.adaptive - NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation - } -} - -private fun getModeLabel(mode: NoiseControlMode): String { - return when (mode) { - NoiseControlMode.OFF -> "Off" - NoiseControlMode.TRANSPARENCY -> "Transparency" - NoiseControlMode.ADAPTIVE -> "Adaptive" - NoiseControlMode.NOISE_CANCELLATION -> "Noise Cancel" - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt deleted file mode 100644 index ac6d356b..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt +++ /dev/null @@ -1,1350 +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 . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.bluetooth - -import android.util.Log -import me.kavishdevar.librepods.data.Capability -import me.kavishdevar.librepods.data.CustomEq -import java.nio.ByteBuffer -import java.nio.ByteOrder -import kotlin.io.encoding.ExperimentalEncodingApi - -/** - * Manager class for Apple Accessory Communication Protocol (AACP) - * This class is responsible for handling the L2CAP socket management, - * constructing and parsing packets for communication with AirPods. - */ -class AACPManager { - private val TAG = "AACPManager[${System.identityHashCode(this)}]" - companion object { - @Suppress("unused") - object Opcodes { - const val SET_FEATURE_FLAGS: Byte = 0x4D - const val REQUEST_NOTIFICATIONS: Byte = 0x0F - const val BATTERY_INFO: Byte = 0x04 - const val CONTROL_COMMAND: Byte = 0x09 - const val EAR_DETECTION: Byte = 0x06 - const val CONVERSATION_AWARENESS: Byte = 0x4B - const val INFORMATION: Byte = 0x1D - const val RENAME: Byte = 0x1A - const val HEADTRACKING: Byte = 0x17 - const val PROXIMITY_KEYS_REQ: Byte = 0x30 - const val PROXIMITY_KEYS_RSP: Byte = 0x31 - const val STEM_PRESS: Byte = 0x19 - const val HEADPHONE_ACCOMMODATION: Byte = 0x53 - const val CONNECTED_DEVICES: Byte = 0x2E // TiPi 1 - const val AUDIO_SOURCE: Byte = 0x0E // TiPi 2 - const val SMART_ROUTING: Byte = 0x10 - const val TIPI_3: Byte = 0x0C // Don't know this one - const val SMART_ROUTING_RESP: Byte = 0x11 - const val SEND_CONNECTED_MAC: Byte = 0x14 - const val AUDIO_SOURCE_2: Byte = 0x0C // seems redundant? - const val CUSTOM_EQ: Byte = 0x63 - } - - private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00) - - data class ControlCommandStatus( - val identifier: ControlCommandIdentifiers, val value: ByteArray - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as ControlCommandStatus - - if (identifier != other.identifier) return false - if (!value.contentEquals(other.value)) return false - - return true - } - - override fun hashCode(): Int { - var result: Int = identifier.hashCode() - result = 31 * result + value.contentHashCode() - return result - } - } - - // @Suppress("unused") - enum class ControlCommandIdentifiers(val value: Byte) { - MIC_MODE(0x01), BUTTON_SEND_MODE(0x05), VOICE_TRIGGER(0x12), SINGLE_CLICK_MODE(0x14), DOUBLE_CLICK_MODE( - 0x15 - ), - CLICK_HOLD_MODE(0x16), DOUBLE_CLICK_INTERVAL(0x17), CLICK_HOLD_INTERVAL(0x18), LISTENING_MODE_CONFIGS( - 0x1A - ), - ONE_BUD_ANC_MODE(0x1B), CROWN_ROTATION_DIRECTION(0x1C), LISTENING_MODE(0x0D), AUTO_ANSWER_MODE( - 0x1E - ), - CHIME_VOLUME(0x1F), VOLUME_SWIPE_INTERVAL(0x23), CALL_MANAGEMENT_CONFIG(0x24), VOLUME_SWIPE_MODE( - 0x25 - ), - ADAPTIVE_VOLUME_CONFIG(0x26), SOFTWARE_MUTE_CONFIG(0x27), CONVERSATION_DETECT_CONFIG( - 0x28 - ), - SSL(0x29), HEARING_AID(0x2C), AUTO_ANC_STRENGTH(0x2E), HPS_GAIN_SWIPE(0x2F), HRM_STATE( - 0x30 - ), - IN_CASE_TONE_CONFIG(0x31), SIRI_MULTITONE_CONFIG(0x32), HEARING_ASSIST_CONFIG(0x33), ALLOW_OFF_OPTION( - 0x34 - ), - STEM_CONFIG(0x39), SLEEP_DETECTION_CONFIG(0x35), ALLOW_AUTO_CONNECT(0x36), // not sure what this does, AUTOMATIC_CONNECTION is the only one used, but this is newer... so ¯\_(ツ)_/¯ - EAR_DETECTION_CONFIG(0x0A), AUTOMATIC_CONNECTION_CONFIG(0x20), OWNS_CONNECTION(0x06), PPE_TOGGLE_CONFIG( - 0x37 - ), - PPE_CAP_LEVEL_CONFIG(0x38), - DYNAMIC_END_OF_CHARGE(0x3B); - - companion object { - fun fromByte(byte: Byte): ControlCommandIdentifiers? = - entries.find { it.value == byte } - } - } - - enum class ProximityKeyType(val value: Byte) { - IRK(0x01), ENC_KEY(0x04); - - companion object { - fun fromByte(byte: Byte): ProximityKeyType = entries.find { it.value == byte } - ?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte") - } - } - - enum class StemPressType(val value: Byte) { - SINGLE_PRESS(0x05), DOUBLE_PRESS(0x06), TRIPLE_PRESS(0x07), LONG_PRESS(0x08); - - companion object { - fun fromByte(byte: Byte): StemPressType? = entries.find { it.value == byte } - } - } - - enum class StemPressBudType(val value: Byte) { - LEFT(0x01), RIGHT(0x02); - - companion object { - fun fromByte(byte: Byte): StemPressBudType? = entries.find { it.value == byte } - } - } - - enum class AudioSourceType(val value: Byte) { - NONE(0x00), CALL(0x01), MEDIA(0x02); - - companion object { - fun fromByte(byte: Byte): AudioSourceType? = entries.find { it.value == byte } - } - } - - data class AudioSource( - val mac: String, val type: AudioSourceType - ) - - data class ConnectedDevice( - val mac: String, val info1: Byte, val info2: Byte, var type: String? - ) - - data class AirPodsInformation( - val name: String, - val modelNumber: String, - val manufacturer: String, - val serialNumber: String, - val version1: String, - val version2: String, - val hardwareRevision: String, - val updaterIdentifier: String, - val leftSerialNumber: String, - val rightSerialNumber: String, - val version3: String - ) - } - - var controlCommandStatusList: MutableList = - mutableListOf() - var controlCommandListeners: MutableMap> = - mutableMapOf() - - var owns: Boolean = false - private set - - var oldConnectedDevices: List = listOf() - private set - - var connectedDevices: List = listOf() - private set - - var audioSource: AudioSource? = null - private set - - var eqData = FloatArray(8) - private set - - var eqOnPhone: Boolean = false - private set - - var eqOnMedia: Boolean = false - private set - - var customEq: CustomEq = CustomEq(state = 1, low = 50, mid = 50, high = 50) - private set - - var customEqCallback: ((CustomEq) -> Unit)? = null - - fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? { - return controlCommandStatusList.find { it.identifier == identifier } - } - - private fun setControlCommandStatusValue( - identifier: ControlCommandIdentifiers, value: ByteArray - ) { - val existingStatus = getControlCommandStatus(identifier) - if (existingStatus?.value.contentEquals(value)) { - controlCommandStatusList.remove(existingStatus) - } - controlCommandListeners[identifier]?.forEach { listener -> - listener.onControlCommandReceived(ControlCommand(identifier.value, value)) - } - controlCommandStatusList.add(ControlCommandStatus(identifier, value)) - - if (identifier == ControlCommandIdentifiers.OWNS_CONNECTION) { - owns = value.isNotEmpty() && value[0] == 0x01.toByte() - } - } - - interface PacketCallback { - fun onBatteryInfoReceived(batteryInfo: ByteArray) - fun onEarDetectionReceived(earDetection: ByteArray) - fun onConversationAwarenessReceived(conversationAwareness: ByteArray) - fun onControlCommandReceived(controlCommand: ByteArray) - fun onDeviceInformationReceived(deviceInformation: AirPodsInformation) - fun onHeadTrackingReceived(headTracking: ByteArray) - fun onUnknownPacketReceived(packet: ByteArray) - fun onProximityKeysReceived(proximityKeys: ByteArray) - fun onStemPressReceived(stemPress: ByteArray) - fun onAudioSourceReceived(audioSource: ByteArray) - fun onOwnershipChangeReceived(owns: Boolean) - fun onConnectedDevicesReceived(connectedDevices: List) - fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean) - fun onShowNearbyUI(sender: String) - fun onHeadphoneAccommodationReceived(eqData: FloatArray) - fun onCustomEqReceived(customEq: CustomEq) - fun onCapabilitiesReceived(capabilities: List) - } - - fun parseStemPressResponse(data: ByteArray): Pair { - Log.d(TAG, "Parsing Stem Press Response: ${data.joinToString(" ") { "%02X".format(it) }}") - if (data.size != 8) { - throw IllegalArgumentException("Data array too short to parse Stem Press Response") - } - if (data[4] != Opcodes.STEM_PRESS) { - throw IllegalArgumentException("Data array does not start with STEM_PRESS opcode") - } - val type = StemPressType.fromByte(data[6]) - ?: throw IllegalArgumentException("Unknown Stem Press Type: ${data[5]}") - val bud = StemPressBudType.fromByte(data[7]) - ?: throw IllegalArgumentException("Unknown Stem Press Bud Type: ${data[6]}") - return Pair(type, bud) - } - - interface ControlCommandListener { - fun onControlCommandReceived(controlCommand: ControlCommand) - } - - fun registerControlCommandListener( - identifier: ControlCommandIdentifiers, callback: ControlCommandListener - ) { - controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback) - } - - fun unregisterControlCommandListener( - identifier: ControlCommandIdentifiers, callback: ControlCommandListener - ) { - controlCommandListeners[identifier]?.remove(callback) - } - - private var callback: PacketCallback? = null - - fun setPacketCallback(callback: PacketCallback) { - this.callback = callback - } - - fun createDataPacket(data: ByteArray): ByteArray { - return HEADER_BYTES + data - } - - fun createControlCommandPacket(identifier: Byte, data: ByteArray): ByteArray { - val opcode = byteArrayOf(Opcodes.CONTROL_COMMAND, 0x00) - val payload = ByteArray(7) - - System.arraycopy(opcode, 0, payload, 0, 2) - payload[2] = identifier - - val dataLength = minOf(data.size, 4) - System.arraycopy(data, 0, payload, 3, dataLength) - - return payload - } - - fun sendDataPacket(data: ByteArray): Boolean { - return sendPacket(createDataPacket(data)) - } - - fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean { - val controlPacket = createControlCommandPacket(identifier, value) - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(identifier) ?: return false, value - ) - return sendDataPacket(controlPacket) - } - - @OptIn(ExperimentalStdlibApi::class) - fun sendControlCommand(identifier: Byte, value: Byte): Boolean { - val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value)) - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(identifier) ?: return false, byteArrayOf(value) - ) - return sendDataPacket(controlPacket) - } - - fun sendControlCommand(identifier: Byte, value: Boolean): Boolean { - val controlPacket = createControlCommandPacket( - identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02) - ) - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(identifier) ?: return false, - if (value) byteArrayOf(0x01) else byteArrayOf(0x02) - ) - return sendDataPacket(controlPacket) - } - - fun sendControlCommand(identifier: Byte, value: Int): Boolean { - val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value.toByte())) - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(identifier) ?: return false, - byteArrayOf(value.toByte()) - ) - return sendDataPacket(controlPacket) - } - - fun parseProximityKeysResponse(data: ByteArray): Map { - Log.d( - TAG, "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}" - ) - if (data.size < 4) { - throw IllegalArgumentException("Data array too short to parse Proximity Keys Response") - } - if (data[4] != Opcodes.PROXIMITY_KEYS_RSP) { - throw IllegalArgumentException("Data array does not start with PROXIMITY_KEYS_RSP opcode") - } - val keyCount = data[6].toInt() - val keys = mutableMapOf() - var offset = 7 - for (i in 0 until keyCount) { - Log.d(TAG, "Parsing Proximity Key $i") - if (offset + 3 >= data.size) { - throw IllegalArgumentException("Data array too short to parse Proximity Keys Response") - } - val keyType = data[offset] - val keyLength = data[offset + 2].toInt() - Log.d(TAG, "Key Type: ${keyType.toString(16)}, Key Length: $keyLength") - offset += 4 - if (offset + keyLength > data.size) { - throw IllegalArgumentException("Data array too short to parse Proximity Keys Response") - } - val key = ByteArray(keyLength) - System.arraycopy(data, offset, key, 0, keyLength) - try { - keys[ProximityKeyType.fromByte(keyType)] = key - } catch (e: Exception) { - Log.e( - TAG, "incorrect key type received: $keyType, ${key.toHexString()}" - ) - } - offset += keyLength - Log.d( - TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${ - key.joinToString(" ") { "%02X".format(it) } - }") - } - return keys - } - - fun sendRequestProximityKeys(type: Byte): Boolean { - Log.d(TAG, "Requesting proximity keys of type: ${type.toString(16)}") - return sendDataPacket(createRequestProximityKeysPacket(type)) - } - - fun createRequestProximityKeysPacket(type: Byte): ByteArray { - val opcode = byteArrayOf(Opcodes.PROXIMITY_KEYS_REQ, 0x00) - val data = byteArrayOf(type, 0x00) - return opcode + data - } - - @OptIn(ExperimentalStdlibApi::class) - fun receivePacket(packet: ByteArray) { - if (!packet.toHexString().startsWith("04000400")) { - Log.w( - TAG, "Received packet does not start with expected header: ${ - packet.joinToString(" ") { - "%02X".format(it) - } - }") - return - } - if (packet.size < 6) { - Log.w( - TAG, "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}" - ) - return - } - - when (val opcode = packet[4]) { - Opcodes.BATTERY_INFO -> { - callback?.onBatteryInfoReceived(packet) - } - - Opcodes.CONTROL_COMMAND -> { - val controlCommand = try { - ControlCommand.fromByteArray(packet) - } catch (e: Exception) { - Log.w(TAG, "Failed to parse control command: ${e.message}") - callback?.onUnknownPacketReceived(packet) - return - } - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return, - controlCommand.value - ) - Log.d( - TAG, - "Control command received: ${controlCommand.identifier.toHexString()} - ${ - controlCommand.value.joinToString(" ") { "%02X".format(it) } - }") - - val controlCommandListText = try { - controlCommandStatusList.joinToString(", ") { it -> - "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${ - it.value.joinToString( - " " - ) { "%02X".format(it) } - }" - } - } catch (e: Exception) { - e.message - } - - Log.d( - TAG, "Control command list is now: $controlCommandListText" - ) - - val controlCommandIdentifier = - ControlCommandIdentifiers.fromByte(controlCommand.identifier) - if (controlCommandIdentifier != null) { - controlCommandListeners[controlCommandIdentifier]?.forEach { listener -> - Log.d(TAG, "calling listener for ${controlCommandIdentifier.name}") - listener.onControlCommandReceived(controlCommand) - } - } else { - Log.w( - TAG, - "Unknown control command identifier: ${controlCommand.identifier.toHexString()}" - ) - } - - if (controlCommandIdentifier == ControlCommandIdentifiers.OWNS_CONNECTION) { - callback?.onOwnershipChangeReceived(owns) - } - - callback?.onControlCommandReceived(packet) - } - - Opcodes.EAR_DETECTION -> { - callback?.onEarDetectionReceived(packet) - } - - Opcodes.CONVERSATION_AWARENESS -> { - callback?.onConversationAwarenessReceived(packet) - } - - Opcodes.HEADTRACKING -> { - if (packet.size < 70) { - Log.w( - TAG, "Received HEADTRACKING packet too short: ${ - packet.joinToString(" ") { - "%02X".format(it) - } - }") - return - } - callback?.onHeadTrackingReceived(packet) - } - - Opcodes.PROXIMITY_KEYS_RSP -> { - callback?.onProximityKeysReceived(packet) - } - - Opcodes.STEM_PRESS -> { - callback?.onStemPressReceived(packet) - } - - Opcodes.AUDIO_SOURCE -> { - try { - val (mac, type) = parseAudioSourceResponse(packet) - audioSource = AudioSource(mac, type) - } catch (e: Exception) { - Log.e(TAG, "Error parsing audio source response: ${e.message}") - } - callback?.onAudioSourceReceived(packet) - } - - Opcodes.CONNECTED_DEVICES -> { - oldConnectedDevices = connectedDevices - connectedDevices = parseConnectedDevicesResponse(packet) - callback?.onConnectedDevicesReceived(connectedDevices) - } - - Opcodes.SMART_ROUTING_RESP -> { - val packetString = packet.decodeToString() - val sender = - packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) } - - // if (connectedDevices.find { it.mac == sender }?.type == null && packetString.contains("btName")) { - // val nameStartIndex = packetString.indexOf("btName") + 8 - // val nameEndIndex = if (packetString.contains("other")) (packetString.indexOf("otherDevice") - 1) else (packetString.indexOf("nearbyAudio") - 1) - // val name = packet.sliceArray(nameStartIndex..nameEndIndex).decodeToString() - // connectedDevices.find { it.mac == sender }?.type = name - // Log.d(TAG, "Device $sender is named $name") - // } // doesn't work, it's different for Mac and iPad. just hardcoding for now - if ("iPad" in packetString) { - connectedDevices.find { it.mac == sender }?.type = "iPad" - } else if ("Mac" in packetString) { - connectedDevices.find { it.mac == sender }?.type = "Mac" - } else if ("iPhone" in packetString) { // not sure if this is it - don't have an iphone - connectedDevices.find { it.mac == sender }?.type = "iPhone" - } else if ("Linux" in packetString) { - connectedDevices.find { it.mac == sender }?.type = "Linux" - } else if ("Android" in packetString) { - connectedDevices.find { it.mac == sender }?.type = "Android" - } - Log.d( - TAG, - "Smart Routing Response from $sender: $packetString, type: ${connectedDevices.find { it.mac == sender }?.type}" - ) - if (packetString.contains("SetOwnershipToFalse")) { - callback?.onOwnershipToFalseRequest( - sender, - packetString.contains("ReverseBannerTapped") - ) - } - if (packetString.contains("ShowNearbyUI")) { - callback?.onShowNearbyUI(sender) - } - } - - Opcodes.HEADPHONE_ACCOMMODATION -> { - if (packet.size != 140) { - Log.w( - TAG, - "Received HEADPHONE_ACCOMMODATION packet of unexpected size: ${packet.size}, expected 140" - ) - return - } - if (packet[6] != 0x84.toByte()) { - Log.w( - TAG, - "Received HEADPHONE_ACCOMMODATION packet with unexpected identifier: ${packet[6].toHexString()}, expected 0x84" - ) - return - } - - eqOnMedia = (packet[10] == 0x01.toByte()) - eqOnPhone = (packet[11] == 0x01.toByte()) - // there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media visible. just directly the EQ... weird. - // the EQs are little endian floats - val eq1 = - ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() - ByteBuffer.wrap(packet, 44, 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 - eqData = FloatArray(8) { i -> eq1.get(i) } - - Log.d( - TAG, - "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia" - ) - - callback?.onHeadphoneAccommodationReceived(eqData) - } - - Opcodes.INFORMATION -> { - Log.d(TAG, "Parsing Information Packet") - val information = parseInformationPacket(packet) - callback?.onDeviceInformationReceived(information) - } - - Opcodes.CUSTOM_EQ -> { - Log.d(TAG, "Parsing CUSTOM_EQ: ${packet.toHexString()}") - customEq = parseCustomEqPacket(packet) - customEqCallback?.invoke(customEq) - callback?.onCustomEqReceived(customEq) - } - - else -> { - Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}") - callback?.onUnknownPacketReceived(packet) - } - } - } - - fun sendNotificationRequest(): Boolean { - return sendDataPacket(createRequestNotificationPacket()) - } - - fun createRequestNotificationPacket(): ByteArray { - val opcode = byteArrayOf(Opcodes.REQUEST_NOTIFICATIONS, 0x00) - val data = byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()) - // note to self #1: third byte is 0xfd when ear detection is disabled - // note to self #2: this can be sent any time, not just at the start of the aacp connection - return opcode + data - } - - fun sendSetFeatureFlagsPacket(): Boolean { - return sendDataPacket(createSetFeatureFlagsPacket()) - } - - fun createSetFeatureFlagsPacket(): ByteArray { - val opcode = byteArrayOf(Opcodes.SET_FEATURE_FLAGS, 0x00) - val data = byteArrayOf(0xD7.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) - return opcode + data - } - - fun createHandshakePacket(): ByteArray { - return byteArrayOf( - 0x00, - 0x00, - 0x04, - 0x00, - 0x01, - 0x00, - 0x02, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00 - ) - } - - fun sendStartHeadTracking(): Boolean { - return sendDataPacket(createStartHeadTrackingPacket()) - } - - fun createStartHeadTrackingPacket(): ByteArray { - val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00) - val data = byteArrayOf( - 0x00, - 0x00, - 0x10, - 0x00, - 0x10, - 0x00, - 0x08, - 0xA1.toByte(), - 0x02, - 0x42, - 0x0B, - 0x08, - 0x0E, - 0x10, - 0x02, - 0x1A, - 0x05, - 0x01, - 0x40, - 0x9C.toByte(), - 0x00, - 0x00, - ) - return opcode + data - } - - fun createAlternateStartHeadTrackingPacket(): ByteArray { - val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00) - val data = byteArrayOf( - 0x00, - 0x00, - 0x10, - 0x00, - 0x0F, - 0x00, - 0x08, - 0x73, - 0x42, - 0x0B, - 0x08, - 0x10, - 0x10, - 0x02, - 0x1A, - 0x05, - 0x01, - 0x40, - 0x9C.toByte(), - 0x00, - 0x00 - ) - return opcode + data - } - - fun sendStopHeadTracking(): Boolean { - return sendDataPacket(createStopHeadTrackingPacket()) - } - - fun createStopHeadTrackingPacket(): ByteArray { - val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00) - val data = byteArrayOf( - 0x00, - 0x00, - 0x10, - 0x00, - 0x11, - 0x00, - 0x08, - 0x7E, - 0x10, - 0x02, - 0x42, - 0x0B, - 0x08, - 0x4E, - 0x10, - 0x02, - 0x1A, - 0x05, - 0x01, - 0x00, - 0x00, - 0x00, - 0x00 - ) - return opcode + data - } - - fun createAlternateStopHeadTrackingPacket(): ByteArray { - val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00) - val data = byteArrayOf( - 0x00, - 0x00, - 0x10, - 0x00, - 0x0F, - 0x00, - 0x08, - 0x75, - 0x42, - 0x0B, - 0x08, - 0x10, - 0x10, - 0x02, - 0x1A, - 0x05, - 0x01, - 0x00, - 0x00, - 0x00, - 0x00 - ) - return opcode + data - } - - fun sendRename(name: String): Boolean { - return sendDataPacket(createRenamePacket(name)) - } - - fun createRenamePacket(name: String): ByteArray { - val nameBytes = name.toByteArray() - val size = nameBytes.size - val packet = ByteArray(5 + size) - packet[0] = Opcodes.RENAME - packet[1] = 0x00 - packet[2] = 0x01 - packet[3] = size.toByte() - packet[4] = 0x00 - System.arraycopy(nameBytes, 0, packet, 5, size) - - return packet - } - - 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}") - ) - ) { - // throw IllegalArgumentException("MAC address must be 6 bytes") - Log.w( - TAG, - "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress" - ) - return false - } - Log.d(TAG, "SELFMAC: ${selfMacAddress}, TARGETMAC: $targetMacAddress") - Log.d(TAG, "Sending Media Information packet to $targetMacAddress") - return sendDataPacket( - createMediaInformationNewDevicePacket( - selfMacAddress, - targetMacAddress - ) - ) - } - - fun createMediaInformationNewDevicePacket( - selfMacAddress: String, - targetMacAddress: String - ): ByteArray { - val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) - val buffer = ByteBuffer.allocate(116) - buffer.put( - targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() - ) - buffer.put(byteArrayOf(0x6C, 0x00)) - buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) - buffer.put("playingApp".toByteArray()) - buffer.put(0x42) - buffer.put("NA".toByteArray()) - buffer.put(0x52) - buffer.put("hostStreamingState".toByteArray()) - buffer.put(0x42) - buffer.put("NO".toByteArray()) - buffer.put(0x49) - buffer.put("btAddress".toByteArray()) - buffer.put(0x51) - buffer.put(selfMacAddress.toByteArray()) - buffer.put(0x46) - buffer.put("btName".toByteArray()) - buffer.put(0x47) - buffer.put("Android".toByteArray()) - buffer.put(0x58) - buffer.put("otherDevice".toByteArray()) - buffer.put("AudioCategory".toByteArray()) - buffer.put(byteArrayOf(0x30, 0x64)) - - return opcode + buffer.array() - } - - fun sendHijackRequest(selfMacAddress: String): Boolean { - if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { - // throw IllegalArgumentException("MAC address must be 6 bytes") - Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress") - return false - } - var success = false - for (connectedDevice in connectedDevices) { - if (connectedDevice.mac != selfMacAddress) { - Log.d(TAG, "Sending Hijack Request packet to ${connectedDevice.mac}") - success = sendDataPacket(createHijackRequestPacket(connectedDevice.mac)) || success - } - } - return success - } - - fun createHijackRequestPacket(targetMacAddress: String): ByteArray { - val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) - val buffer = ByteBuffer.allocate(106) - buffer.put( - targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() - ) - buffer.put(byteArrayOf(0x62, 0x00)) - buffer.put(byteArrayOf(0x01, 0xE5.toByte())) - buffer.put(0x4A) - buffer.put("localscore".toByteArray()) - buffer.put(byteArrayOf(0x30, 0x64)) - buffer.put(0x46) - buffer.put("reason".toByteArray()) - buffer.put(0x48) - buffer.put("Hijackv2".toByteArray()) - buffer.put(0x51) - buffer.put("audioRoutingScore".toByteArray()) - buffer.put(byteArrayOf(0x31, 0x2D, 0x01, 0x5F)) - buffer.put("audioRoutingSetOwnershipToFalse".toByteArray()) - buffer.put(0x01) - buffer.put(0x4B) - buffer.put("remotescore".toByteArray()) - buffer.put(0xA5.toByte()) - - return opcode + buffer.array() - } - - fun sendMediaInformataion(selfMacAddress: String, streamingState: Boolean = false): Boolean { - if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { - // throw IllegalArgumentException("MAC address must be 6 bytes") - Log.d(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress") - return false - } - Log.d(TAG, "SELFMAC: $selfMacAddress") - val targetMac = connectedDevices.find { it.mac != selfMacAddress }?.mac - if (targetMac == null) { - Log.w(TAG, "Cannot send Media Information packet: No connected device found") - return false - } - Log.d(TAG, "Sending Media Information packet to $targetMac") - return sendDataPacket( - createMediaInformationPacket( - selfMacAddress, targetMac, streamingState - ) - ) - } - - fun createMediaInformationPacket( - selfMacAddress: String, targetMacAddress: String, streamingState: Boolean = true - ): ByteArray { - val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) - val buffer = ByteBuffer.allocate(138) - buffer.put( - targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() - ) - buffer.put( - byteArrayOf( - 0x82.toByte(), // related to the length - 0x00 - ) - ) - buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) // unknown, constant - buffer.put("PlayingApp".toByteArray()) - buffer.put(byteArrayOf(0x56)) // 'V', seems like an identifier or a separator - buffer.put("com.google.ios.youtube".toByteArray()) // package name, hardcoding for now, aforementioned reason - buffer.put(byteArrayOf(0x52)) // 'R' - buffer.put("HostStreamingState".toByteArray()) - buffer.put(byteArrayOf(0x42)) // 'B' - buffer.put((if (streamingState) "YES" else "NO").toByteArray()) // streaming state - buffer.put(0x49) // 'I' - buffer.put("btAddress".toByteArray()) // self MAC - buffer.put(0x51) // 'Q' - buffer.put(selfMacAddress.toByteArray()) // self MAC - buffer.put("btName".toByteArray()) // self name - buffer.put(0x47) // 'D' - buffer.put("Android".toByteArray()) // if set to iPad, shows "Moved to iPad", but most likely we're running on a phone. setting to anything else of the same length will show iPhone instead. - buffer.put(0x58) // 'X' - buffer.put("otherDevice".toByteArray()) - buffer.put("AudioCategory".toByteArray()) - buffer.put(byteArrayOf(0x31, 0x2D, 0x01)) - - return opcode + buffer.array() - } - - fun sendSmartRoutingShowUI(selfMacAddress: String): Boolean { - if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { - // throw IllegalArgumentException("MAC address must be 6 bytes") - Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress") - return false - } - - val targetMac = connectedDevices.find { it.mac != selfMacAddress }?.mac - if (targetMac == null) { - Log.w(TAG, "Cannot send Smart Routing Show UI packet: No connected device found") - return false - } - Log.d(TAG, "Sending Smart Routing Show UI packet to $targetMac") - return sendDataPacket(createSmartRoutingShowUIPacket(targetMac)) - } - - fun createSmartRoutingShowUIPacket(targetMacAddress: String): ByteArray { - val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) - val buffer = ByteBuffer.allocate(134) - buffer.put( - targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() - ) - buffer.put(byteArrayOf(0x7E, 0x00)) - buffer.put(byteArrayOf(0x01, 0xE6.toByte(), 0x5B)) - buffer.put("SmartRoutingKeyShowNearbyUI".toByteArray()) - buffer.put(0x01) // separator? - buffer.put(0x4A) - buffer.put("localscore".toByteArray()) - buffer.put(0x31, 0x2D) - buffer.put(0x01) - buffer.put(0x46) - buffer.put("reasonHhijackv2".toByteArray()) - buffer.put(0x51.toByte()) - buffer.put("audioRoutingScore".toByteArray()) - buffer.put(0xA2.toByte()) - buffer.put(0x5F) - buffer.put("audioRoutingSetOwnershipToFalse".toByteArray()) - buffer.put(0x01) - buffer.put(0x4B) - buffer.put("remotescore".toByteArray()) - buffer.put(0xA2.toByte()) - return opcode + buffer.array() - } - - fun sendHijackReversed(selfMacAddress: String): Boolean { - var success = false - for (connectedDevice in connectedDevices) { - if (connectedDevice.mac != selfMacAddress) { - Log.d(TAG, "Sending Hijack Reversed packet to ${connectedDevice.mac}") - success = sendDataPacket(createHijackReversedPacket(connectedDevice.mac)) || success - } - } - return success - } - - fun createHijackReversedPacket(targetMacAddress: String): ByteArray { - val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) - val buffer = ByteBuffer.allocate(97) - buffer.put( - targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() - ) - buffer.put(byteArrayOf(0x59, 0x00)) - buffer.put(byteArrayOf(0x01, 0xE3.toByte())) - buffer.put(0x5F) - buffer.put("audioRoutingSetOwnershipToFalse".toByteArray()) - buffer.put(0x01) - buffer.put(0x59) - buffer.put("audioRoutingShowReverseUI".toByteArray()) - buffer.put(0x01) - buffer.put(0x46) - buffer.put("reason".toByteArray()) - buffer.put(0x53) - buffer.put("ReverseBannerTapped".toByteArray()) - - return opcode + buffer.array() - } - - - 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}") - ) - ) { - // throw IllegalArgumentException("MAC address must be 6 bytes") - Log.w( - TAG, - "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress" - ) - return false - } - Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress") - return sendDataPacket(createAddTiPiDevicePacket(selfMacAddress, targetMacAddress)) - } - - fun createAddTiPiDevicePacket(selfMacAddress: String, targetMacAddress: String): ByteArray { - val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) - val buffer = ByteBuffer.allocate(90) - buffer.put( - targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() - ) - buffer.put(byteArrayOf(0x52, 0x00)) - buffer.put(byteArrayOf(0x01, 0xE5.toByte())) - buffer.put(0x48) // 'H' - buffer.put("idleTime".toByteArray()) - buffer.put(byteArrayOf(0x08, 0x47)) - buffer.put("newTipi".toByteArray()) - buffer.put(byteArrayOf(0x01, 0x49)) - buffer.put("btAddress".toByteArray()) - buffer.put(0x51) - buffer.put(selfMacAddress.toByteArray()) - buffer.put(0x46) - buffer.put("btName".toByteArray()) - buffer.put(0x47) - buffer.put("Android".toByteArray()) - buffer.put(0x50) - buffer.put("nearbyAudioScore".toByteArray()) - buffer.put(byteArrayOf(0x0E)) - return opcode + buffer.array() - } - - data class ControlCommand( - val identifier: Byte, val value: ByteArray - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as ControlCommand - - if (identifier != other.identifier) return false - if (!value.contentEquals(other.value)) return false - - return true - } - - override fun hashCode(): Int { - var result: Int = identifier.toInt() - result = 31 * result + value.contentHashCode() - return result - } - - companion object { - fun fromByteArray(data: ByteArray): ControlCommand { - var offset = 0 - while (data.size - offset >= 4 && - data[offset] == 0x04.toByte() && - data[offset + 1] == 0x00.toByte() && - data[offset + 2] == 0x04.toByte() && - data[offset + 3] == 0x00.toByte() - ) { - offset += 4 - } - if (data.size - offset < 7) { - throw IllegalArgumentException("Too short for ControlCommand") - } - if (data[offset] != Opcodes.CONTROL_COMMAND) { - throw IllegalArgumentException("Invalid opcode") - } - val identifier = data[offset + 2] - val value = data.copyOfRange(offset + 3, offset + 7) - val trimmed = value.dropLastWhile { it == 0x00.toByte() }.toByteArray() - return ControlCommand(identifier, if (trimmed.isEmpty()) byteArrayOf(0x00) else trimmed) - } - } - } - - @OptIn(ExperimentalStdlibApi::class) - fun sendStemConfigPacket( - singlePressCustomized: Boolean = false, - doublePressCustomized: Boolean = false, - triplePressCustomized: Boolean = false, - longPressCustomized: Boolean = false - ): Boolean { - val value = - ((if (singlePressCustomized) 0x01 else 0) or (if (doublePressCustomized) 0x02 else 0) or (if (triplePressCustomized) 0x04 else 0) or (if (longPressCustomized) 0x08 else 0)).toByte() - Log.d(TAG, "Sending Stem Config Packet with value: ${value.toHexString()}") - return sendControlCommand( - ControlCommandIdentifiers.STEM_CONFIG.value, value - ) - } - - @OptIn(ExperimentalStdlibApi::class) - fun sendPacket(packet: ByteArray): Boolean { - try { - Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}") - - if (packet[4] == Opcodes.CONTROL_COMMAND) { - val controlCommand = try { - ControlCommand.fromByteArray(packet) - } catch (e: Exception) { - Log.w(TAG, "Invalid control command: ${e.message}") - callback?.onUnknownPacketReceived(packet) - return false - } - Log.d( - TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${ - controlCommand.value.joinToString(" ") { "%02X".format(it) } - }") - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false, - controlCommand.value - ) - } - - val socket = BluetoothConnectionManager.aacpSocket ?: return false - - if (socket.isConnected) { - socket.outputStream?.write(packet) - socket.outputStream?.flush() - return true - } else { - Log.d(TAG, "Can't send packet: Socket not initialized or connected") - return false - } - } catch (e: Exception) { - Log.e(TAG, "Error sending packet: ${e.message}") - return false - } - } - - fun sendPhoneMediaEQ(eq: FloatArray, phone: Byte = 0x02.toByte(), media: Byte = 0x02.toByte()) { - if (eq.size != 8) throw IllegalArgumentException("EQ must be 8 floats") - val header = byteArrayOf( - 0x04.toByte(), - 0x00.toByte(), - 0x04.toByte(), - 0x00.toByte(), - 0x53.toByte(), - 0x00.toByte(), - 0x84.toByte(), - 0x00.toByte(), - 0x02.toByte(), - 0x02.toByte(), - phone, - media - ) - val buffer = ByteBuffer.allocate(128).order(ByteOrder.LITTLE_ENDIAN) - for (block in 0..3) { - for (i in 0..7) { - buffer.putFloat(eq[i]) - } - } - val payload = buffer.array() - val packet = header + payload - sendPacket(packet) - this.eqData = eq.copyOf() - this.eqOnPhone = phone == 0x01.toByte() - this.eqOnMedia = media == 0x01.toByte() - } - - fun parseAudioSourceResponse(data: ByteArray): Pair { - Log.d(TAG, "Parsing Audio Source Response: ${data.joinToString(" ") { "%02X".format(it) }}") - if (data.size < 9) { - throw IllegalArgumentException("Data array too short to parse Audio Source Response") - } - if (data[4] != Opcodes.AUDIO_SOURCE) { - throw IllegalArgumentException("Data array does not start with AUDIO_SOURCE opcode") - } - val macBytes = data.sliceArray(6..11).reversedArray() - val mac = macBytes.joinToString(":") { "%02X".format(it) } - val typeByte = data[12] - val type = AudioSourceType.fromByte(typeByte) - ?: throw IllegalArgumentException("Unknown Audio Source Type: $typeByte") - return Pair(mac, type) - } - - fun parseConnectedDevicesResponse(data: ByteArray): List { - Log.d( - TAG, - "Parsing Connected Devices Response: ${data.joinToString(" ") { "%02X".format(it) }}" - ) - if (data.size < 8) { - throw IllegalArgumentException("Data array too short to parse Connected Devices Response") - } - if (data[4] != Opcodes.CONNECTED_DEVICES) { - throw IllegalArgumentException("Data array does not start with CONNECTED_DEVICES opcode") - } - val deviceCount = data[8].toInt() - val devices = mutableListOf() - - var offset = 9 - for (i in 0 until deviceCount) { - if (offset + 8 > data.size) { - Log.w( - TAG, - "Data array too short to parse all connected devices, returning what we have" - ) - break - } - val macBytes = data.sliceArray(offset until offset + 6) - val mac = macBytes.joinToString(":") { "%02X".format(it) } - val info1 = data[offset + 6] - val info2 = data[offset + 7] - val existingDevice = devices.find { it.mac == mac } - devices.add(ConnectedDevice(mac, info1, info2, existingDevice?.type)) - offset += 8 - } - - return devices - } - - fun sendSomePacketIDontKnowWhatItIs() { - // 2900 00ff ffff ffff ffff -- enables setting EQ - sendDataPacket( - byteArrayOf( - 0x29, 0x00, - 0x00, 0xFF.toByte(), - 0xFF.toByte(), 0xFF.toByte(), - 0xFF.toByte(), 0xFF.toByte(), - 0xFF.toByte(), 0xFF.toByte(), - ) - ) - } - - fun disconnected() { - Log.d(TAG, "Disconnected, clearing state") - controlCommandStatusList.clear() - controlCommandListeners.clear() - owns = false - oldConnectedDevices = listOf() - connectedDevices = listOf() - audioSource = null - } - - fun parseInformationPacket(packet: ByteArray): AirPodsInformation { - val data = packet.sliceArray(6 until packet.size) - - var index = 0 - while (index < data.size && data[index] != 0x00.toByte()) index++ - - val strings = mutableListOf() - while (index < data.size) { - // skip 0x00 bytes - while (index < data.size && data[index] == 0x00.toByte()) index++ - if (index >= data.size) break - val start = index - // find next 0x00 byte - while (index < data.size && data[index] != 0x00.toByte()) index++ - val str = data.sliceArray(start until index).decodeToString() - strings.add(str) - } - - strings.removeAt(0) // I'm too lazy to adjust, just removing the first empty string - - return AirPodsInformation( - name = strings.getOrNull(0) ?: "", - modelNumber = strings.getOrNull(1) ?: "", - manufacturer = strings.getOrNull(2) ?: "", - serialNumber = strings.getOrNull(3) ?: "", - version1 = strings.getOrNull(4) ?: "", - version2 = strings.getOrNull(5) ?: "", - hardwareRevision = strings.getOrNull(6) ?: "", - updaterIdentifier = strings.getOrNull(7) ?: "", - leftSerialNumber = strings.getOrNull(8) ?: "", - rightSerialNumber = strings.getOrNull(9) ?: "", - version3 = strings.getOrNull(10) ?: "", - ) - } - - fun sendCustomEqPacket(customEq: CustomEq): Boolean { - return sendDataPacket(customEq.toPacket()) - } - - fun parseCustomEqPacket(packet: ByteArray): CustomEq { - val data = packet.sliceArray(6 until packet.size) - - if (data.size < 7) { - Log.e(TAG, "custom EQ packet length less than 7, returning default") - return CustomEq(1, 50, 50, 50) - } - - val lengthLow = data[0].toInt() and 0xFF - val lengthHigh = data[1].toInt() and 0xFF - - val length = (lengthHigh shl 8) or lengthLow - - if (length != 5) { - Log.w(TAG, "parseCustomEqPacket: unexpected length ($length). parsing normally") - } - - val state = data[3].toInt() - val low = data[4].toInt() - val mid = data[5].toInt() - val high = data[6].toInt() - - return CustomEq( - state, - low, - mid, - high - ) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt deleted file mode 100644 index 753c4d3d..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt +++ /dev/null @@ -1,211 +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 . -*/ - -package me.kavishdevar.librepods.bluetooth - -import android.util.Log -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean - -private const val TAG = "ATTManager" - -enum class ATTHandles(val value: Int) { - TRANSPARENCY(0x18), - LOUD_SOUND_REDUCTION(0x1B), - HEARING_AID(0x2A) -} - -enum class ATTCCCDHandles(val value: Int) { - TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1), - // LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1), // doesn't work - HEARING_AID(ATTHandles.HEARING_AID.value + 1) -} - -class ATTManagerv2 { - val characteristicList = mutableMapOf() - - private val responseQueues = ConcurrentHashMap>() - - private val readerRunning = AtomicBoolean(false) - private var readerThread: Thread? = null - - private var onNotificationReceived: ((handle: Byte, value: ByteArray) -> Unit)? = null - - fun startReader() { - if (readerRunning.getAndSet(true)) return - - readerThread = Thread { - try { - runReaderLoop() - } catch (t: Throwable) { - Log.e(TAG, "reader thread crashed: ${t.message}", t) - } finally { - readerRunning.set(false) - Log.d(TAG, "reader thread stopped") - } - }.also { it.name = "ATT-Reader"; it.isDaemon = true; it.start() } - Log.d(TAG, "reader started") - } - - fun stopReader() { - readerRunning.set(false) - readerThread?.interrupt() - readerThread = null - } - - fun setOnNotificationReceived(listener: ((handle: Byte, value: ByteArray) -> Unit)?) { - onNotificationReceived = listener - } - - fun enableNotification(handle: ATTCCCDHandles) { - writeCharacteristic(handle.value.toByte(), byteArrayOf(0x01)) - } - - fun getCharacteristic(handle: ATTHandles): ByteArray? { - val storedValue = characteristicList[handle] - return if (storedValue?.isNotEmpty() != true) { - readCharacteristic(handle) - } else storedValue - } - - fun readCharacteristic(handle: ATTHandles, timeoutMillis: Long = 2000): ByteArray? { - val socket = BluetoothConnectionManager.attSocket ?: return null - try { - val output = socket.outputStream - val pdu = byteArrayOf(0x0A, handle.value.toByte(), 0x00) - synchronized(output) { - output.write(pdu) - output.flush() - } - Log.d(TAG, "sending read request: ${pdu.joinToString(" ") { String.format("%02X", it) }}") - - val resp = waitForResponse(0x0B, timeoutMillis) ?: run { - Log.e(TAG, "Timeout waiting for Read Response (0x0B) for handle ${handle.value}") - return null - } - - Log.d(TAG, "read response: ${resp.joinToString(" ") { String.format("%02X", it) }}") - val value = resp.copyOfRange(1, resp.size) - characteristicList[handle] = value - return value - } catch (e: Exception) { - Log.e(TAG, "error reading characteristic: ${e.message}") - return null - } - } - - fun writeCharacteristic(handle: ATTHandles, data: ByteArray, timeoutMillis: Long = 2000) { - characteristicList[handle] = data - writeCharacteristic(handle.value.toByte(), data, timeoutMillis) - } - - fun writeCharacteristic(handle: Byte, data: ByteArray, timeoutMillis: Long = 2000) { - val socket = BluetoothConnectionManager.attSocket ?: return - try { - val output = socket.outputStream - val pdu = byteArrayOf(0x12, handle, 0x00) + data // 0x00 for LE - synchronized(output) { - output.write(pdu) - output.flush() - } - Log.d(TAG, "sending write request: ${pdu.joinToString(" ") { String.format("%02X", it) }}") - - val resp = waitForResponse(0x13, timeoutMillis) ?: run { - Log.e(TAG, "timeout waiting for response (0x13) for handle ${String.format("%02X", handle)}") - return - } - - Log.d(TAG, "write respose: ${resp.joinToString(" ") { String.format("%02X", it) }}") - } catch (e: Exception) { - Log.e(TAG, "error writing characteristic: ${e.message}") - } - } - - fun disconnected() { - characteristicList.clear() - stopReader() - val socket = BluetoothConnectionManager.attSocket?: return - try { - socket.close() - } catch (e: Exception) { - Log.w(TAG, "error closing socket: ${e.message}") - } - Log.d(TAG, "ATT disconnected") - } - - private fun runReaderLoop() { - val socket = BluetoothConnectionManager.attSocket ?: run { - Log.w(TAG, "ATT socket not available. stopping reader") - readerRunning.set(false) - return - } - - val input = socket.inputStream - val buffer = ByteArray(512) - - while (readerRunning.get()) { - try { - val len = input.read(buffer) - if (len == -1) { - Log.w(TAG, "ATT input stream ended") - break - } - val data = buffer.copyOfRange(0, len) - if (data.isEmpty()) continue - - val opcode = data[0] - Log.d(TAG, "pdu received ${data.joinToString(" ") { String.format("%02X", it) }}") - - val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() } - queue.offer(data) - - if (opcode == 0x1B.toByte()) { - if (data.size >= 3) { - val handle = data[1] - val value = if (data.size > 3) data.copyOfRange(3, data.size) else ByteArray(0) - Log.d(TAG, "notification/indication handle=0x${String.format("%02X", handle)} value=${value.toHexString()}") - try { - onNotificationReceived?.invoke(handle, value) - } catch (t: Throwable) { - Log.e(TAG, "onNotificationReceived threw: ${t.message}", t) - } - } else { - Log.w(TAG, "notification PDU too short: ${data.joinToString(" ") { String.format("%02X", it) }}") - } - } - } catch (e: Exception) { - Log.e(TAG, "error in reader loop: ${e.message}", e) - break - } - } - - readerRunning.set(false) - } - - private fun waitForResponse(opcode: Byte, timeoutMillis: Long): ByteArray? { - val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() } - return try { - queue.poll(timeoutMillis, TimeUnit.MILLISECONDS) - } catch (e: Exception) { - e.printStackTrace() - null - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BluetoothConnectionManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BluetoothConnectionManager.kt deleted file mode 100644 index 95d5d655..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BluetoothConnectionManager.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.bluetooth - -import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothDevice -import android.bluetooth.BluetoothSocket -import android.os.ParcelUuid -import android.util.Log - -object BluetoothConnectionManager { - var aacpSocket: BluetoothSocket? = null - var attSocket: BluetoothSocket? = null -} - -fun createBluetoothSocket( - adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid, psm: Int -): BluetoothSocket { - val type = 3 // L2CAP - val constructorSpecs = listOf( - arrayOf(adapter, device, type, true, true, psm, uuid), // A16QPR3 - arrayOf(device, type, true, true, psm, uuid), - arrayOf(device, type, 1, true, true, psm, uuid), - arrayOf(type, 1, true, true, device, psm, uuid), - arrayOf(type, true, true, device, psm, uuid) - ) - - val constructors = BluetoothSocket::class.java.declaredConstructors - Log.d("createSocket", "BluetoothSocket has ${constructors.size} constructors:") - - constructors.forEachIndexed { index, constructor -> - val params = constructor.parameterTypes.joinToString(", ") { it.simpleName } - Log.d("createSocket", "Constructor $index: ($params)") - } - - var lastException: Exception? = null - var attemptedConstructors = 0 - - for ((index, params) in constructorSpecs.withIndex()) { - try { - Log.d("createSocket", "Trying constructor signature #${index + 1}") - attemptedConstructors++ - - val paramTypes = - params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray() - val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes) - constructor.isAccessible = true - return constructor.newInstance(*params) as BluetoothSocket - - } catch (e: Exception) { - Log.e("createSocket", "Constructor signature #${index + 1} failed: ${e.message}") - lastException = e - } - } - - val errorMessage = - "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures" - Log.e("createSocket", errorMessage) - throw lastException ?: IllegalStateException(errorMessage) -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/AirPods.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/AirPods.kt deleted file mode 100644 index 9d83f05f..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/AirPods.kt +++ /dev/null @@ -1,277 +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 . -*/ - -package me.kavishdevar.librepods.data - -import me.kavishdevar.librepods.R - -open class AirPodsBase( - val modelNumber: List, - val name: String, - val displayName: String = "AirPods", - val manufacturer: String = "Apple Inc.", - val budCaseRes: Int, - val budsRes: Int, - val leftBudsRes: Int, - val rightBudsRes: Int, - val caseRes: Int, - val capabilities: Set -) -enum class Capability { - LISTENING_MODE, - CONVERSATION_AWARENESS, - STEM_CONFIG, - HEAD_GESTURES, - LOUD_SOUND_REDUCTION, - PPE, - SLEEP_DETECTION, - HEARING_AID, - ADAPTIVE_AUDIO, - ADAPTIVE_VOLUME, - SWIPE_FOR_VOLUME, - HRM -} - -class AirPods: AirPodsBase( - modelNumber = listOf("A1523", "A1722"), - name = "AirPods 1", - // budCaseRes = R.drawable.airpods_1 - budCaseRes = R.drawable.airpods_pro_2, - // budsRes = R.drawable.airpods_1_buds - budsRes = R.drawable.airpods_pro_2_buds, - // 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() -) - -class AirPods2: AirPodsBase( - modelNumber = listOf("A2032", "A2031"), - name = "AirPods 2", - // budCaseRes = R.drawable.airpods_2 - budCaseRes = R.drawable.airpods_pro_2, - // budsRes = R.drawable.airpods_2_buds - budsRes = R.drawable.airpods_pro_2_buds, - // 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() -) - -class AirPods3: AirPodsBase( - modelNumber = listOf("A2565", "A2564"), - name = "AirPods 3", - // budCaseRes = R.drawable.airpods_3 - budCaseRes = R.drawable.airpods_pro_2, - // budsRes = R.drawable.airpods_3_buds - budsRes = R.drawable.airpods_pro_2_buds, - // 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( - Capability.HEAD_GESTURES - ) -) - -class AirPods4: AirPodsBase( - modelNumber = listOf("A3053", "A3050", "A3054"), - name = "AirPods 4", - // budCaseRes = R.drawable.airpods_4 - budCaseRes = R.drawable.airpods_pro_2, - // budsRes = R.drawable.airpods_4_buds - budsRes = R.drawable.airpods_pro_2_buds, - // 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( - Capability.HEAD_GESTURES, - Capability.SLEEP_DETECTION, - Capability.ADAPTIVE_VOLUME - ) -) - -class AirPods4ANC: AirPodsBase( - modelNumber = listOf("A3056", "A3055", "A3057"), - name = "AirPods 4 (ANC)", - // budCaseRes = R.drawable.airpods_4 - budCaseRes = R.drawable.airpods_pro_2, - // budsRes = R.drawable.airpods_4_buds - budsRes = R.drawable.airpods_pro_2_buds, - // 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( - Capability.LISTENING_MODE, - Capability.CONVERSATION_AWARENESS, - Capability.HEAD_GESTURES, - Capability.ADAPTIVE_AUDIO, - Capability.SLEEP_DETECTION, - Capability.ADAPTIVE_VOLUME, - Capability.STEM_CONFIG - ) -) - -class AirPodsPro1: AirPodsBase( - modelNumber = listOf("A2084", "A2083"), - name = "AirPods Pro 1", - displayName = "AirPods Pro", - // budCaseRes = R.drawable.airpods_pro_1 - budCaseRes = R.drawable.airpods_pro_2, - // budsRes = R.drawable.airpods_pro_1_buds - budsRes = R.drawable.airpods_pro_2_buds, - // 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( - Capability.LISTENING_MODE - ) -) - -class AirPodsPro2Lightning: AirPodsBase( - modelNumber = listOf("A2931", "A2699", "A2698"), - name = "AirPods Pro 2 with Magsafe Charging Case (Lightning)", - displayName = "AirPods Pro", - // budCaseRes = R.drawable.airpods_pro_2 - budCaseRes = R.drawable.airpods_pro_2, - // 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, - // 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, - capabilities = setOf( - Capability.LISTENING_MODE, - Capability.CONVERSATION_AWARENESS, - Capability.STEM_CONFIG, - Capability.LOUD_SOUND_REDUCTION, - Capability.SLEEP_DETECTION, - Capability.HEARING_AID, - Capability.ADAPTIVE_AUDIO, - Capability.ADAPTIVE_VOLUME, - Capability.SWIPE_FOR_VOLUME, - Capability.HEAD_GESTURES - ) -) - -class AirPodsPro2USBC: AirPodsBase( - modelNumber = listOf("A3047", "A3048", "A3049"), - name = "AirPods Pro 2 with Magsafe Charging Case (USB-C)", - displayName = "AirPods Pro", - // budCaseRes = R.drawable.airpods_pro_2 - budCaseRes = R.drawable.airpods_pro_2, - // 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, - // 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, - capabilities = setOf( - Capability.LISTENING_MODE, - Capability.CONVERSATION_AWARENESS, - Capability.STEM_CONFIG, - Capability.LOUD_SOUND_REDUCTION, - Capability.SLEEP_DETECTION, - Capability.HEARING_AID, - Capability.ADAPTIVE_AUDIO, - Capability.ADAPTIVE_VOLUME, - Capability.SWIPE_FOR_VOLUME, - Capability.HEAD_GESTURES - ) -) - -class AirPodsPro3: AirPodsBase( - modelNumber = listOf("A3063", "A3064", "A3065"), - name = "AirPods Pro 3", - displayName = "AirPods Pro", - // budCaseRes = R.drawable.airpods_pro_3 - budCaseRes = R.drawable.airpods_pro_2, - // budsRes = R.drawable.airpods_pro_3_buds - budsRes = R.drawable.airpods_pro_2_buds, - // 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( - Capability.LISTENING_MODE, - Capability.CONVERSATION_AWARENESS, - Capability.HEAD_GESTURES, - Capability.STEM_CONFIG, - Capability.LOUD_SOUND_REDUCTION, - Capability.PPE, - Capability.SLEEP_DETECTION, - Capability.HEARING_AID, - Capability.ADAPTIVE_AUDIO, - Capability.ADAPTIVE_VOLUME, - Capability.SWIPE_FOR_VOLUME, - Capability.HRM - ) -) - -data class AirPodsInstance( - val name: String, - val model: AirPodsBase, - val actualModelNumber: String, - val serialNumber: String?, - val leftSerialNumber: String?, - val rightSerialNumber: String?, - val version1: String?, - val version2: String?, - val version3: String?, -) - -object AirPodsModels { - val models: List = listOf( - AirPods(), - AirPods2(), - AirPods3(), - AirPods4(), - AirPods4ANC(), - AirPodsPro1(), - AirPodsPro2Lightning(), - AirPodsPro2USBC(), - AirPodsPro3() - ) - - fun getModelByModelNumber(modelNumber: String): AirPodsBase? { - return models.find { modelNumber in it.modelNumber } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/ControlCommandRepository.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/ControlCommandRepository.kt deleted file mode 100644 index 9097a2ee..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/ControlCommandRepository.kt +++ /dev/null @@ -1,70 +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 . -*/ - -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 { - return aacpManager.controlCommandStatusList.associate { - it.identifier to it.value - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt deleted file mode 100644 index fe0232f3..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt +++ /dev/null @@ -1,265 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.data - -import android.os.Parcelable -import android.util.Log -import kotlinx.parcelize.Parcelize - -// TODO: Remove everything but Battery-related stuff - -enum class Enums(val value: ByteArray) { - NOISE_CANCELLATION(byteArrayOf(0x0d)), - PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)), - SETTINGS(byteArrayOf(0x09, 0x00)), - NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value), - CONVERSATION_AWARENESS_RECEIVE_PREFIX(PREFIX.value + byteArrayOf(0x4b, 0x00, 0x02, 0x00)), -} - -object BatteryComponent { - const val LEFT = 4 - const val RIGHT = 2 - const val CASE = 8 -} - -object BatteryStatus { - const val CHARGING = 1 - const val NOT_CHARGING = 2 - const val DISCONNECTED = 4 - const val OPTIMIZED_CHARGING = 5 -} - -@Parcelize -data class Battery(val component: Int, val level: Int, val status: Int) : Parcelable { - fun getComponentName(): String? { - return when (component) { - BatteryComponent.LEFT -> "LEFT" - BatteryComponent.RIGHT -> "RIGHT" - BatteryComponent.CASE -> "CASE" - else -> null - } - } - - fun getStatusName(): String? { - return when (status) { - BatteryStatus.CHARGING -> "CHARGING" - BatteryStatus.NOT_CHARGING -> "NOT_CHARGING" - BatteryStatus.DISCONNECTED -> "DISCONNECTED" - BatteryStatus.OPTIMIZED_CHARGING -> "OPTIMIZED_CHARGING" - else -> null - } - } -} - -enum class NoiseControlMode { - OFF, NOISE_CANCELLATION, TRANSPARENCY, ADAPTIVE -} - -class AirPodsNotifications { - companion object { - const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED" - const val AIRPODS_L2CAP_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED" - const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA" - const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA" - const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA" - const val BATTERY_DATA = "me.kavishdevar.librepods.BATTERY_DATA" - const val CA_DATA = "me.kavishdevar.librepods.CA_DATA" - const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED" - const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED" - const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS" - const val EQ_DATA = "me.kavishdevar.librepods.HEADPHONE_ACCOMMODATION" - const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED" - } - - class EarDetection { - private val notificationBit = 6.toByte() - private val notificationPrefix = Enums.PREFIX.value + notificationBit - - var status: List = listOf(0x01, 0x01) - - fun setStatus(data: ByteArray) { - status = listOf(data[6], data[7]) - } - - fun isEarDetectionData(data: ByteArray): Boolean { - if (data.size != 8) { - return false - } - val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) } - val dataHex = data.joinToString("") { "%02x".format(it) } - return dataHex.startsWith(prefixHex) - } - } - - class ANC { - private val notificationPrefix = Enums.NOISE_CANCELLATION_PREFIX.value - - var status: Int = 1 - private set - - fun isANCData(data: ByteArray): Boolean { - if (data.size != 11) { - return false - } - val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) } - val dataHex = data.joinToString("") { "%02x".format(it) } - return dataHex.startsWith(prefixHex) - } - - fun setStatus(data: ByteArray) { - when (data.size) { - // if the whole packet is given - 11 -> { - status = data[7].toInt() - } - // if only the data is given - 1 -> { - status = data[0].toInt() - } - // if the value of control command is given - 4 -> { - status = data[0].toInt() - } - else -> { - Log.d("ANC", "Invalid ANC data size: ${data.size}") - } - } - } - - val name: String = - when (status) { - 1 -> "OFF" - 2 -> "ON" - 3 -> "TRANSPARENCY" - 4 -> "ADAPTIVE" - else -> "UNKNOWN" - } - - } - - class BatteryNotification { - private var first: Battery = Battery(BatteryComponent.LEFT, 0, BatteryStatus.DISCONNECTED) - private var second: Battery = Battery(BatteryComponent.RIGHT, 0, BatteryStatus.DISCONNECTED) - private var case: Battery = Battery(BatteryComponent.CASE, 0, BatteryStatus.DISCONNECTED) - - fun isBatteryData(data: ByteArray): Boolean { - if (data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")) { - Log.d("BatteryNotification", "Battery data starts with 040004000400. Most likely is a battery packet.") - } else { - return false - } - if (data.size != 22) { - Log.d("BatteryNotification", "Battery data size is not 22, probably being used with Airpods with fewer or more battery count.") - return false - } - Log.d("BatteryNotification", data.joinToString("") { "%02x".format(it) }.startsWith("040004000400").toString()) - return data.joinToString("") { "%02x".format(it) }.startsWith("040004000400") - } - - fun setBatteryDirect( - leftLevel: Int, - leftCharging: Boolean, - rightLevel: Int, - rightCharging: Boolean, - caseLevel: Int, - caseCharging: Boolean - ) { - first = Battery(BatteryComponent.LEFT, leftLevel, if (leftCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING) - second = Battery(BatteryComponent.RIGHT, rightLevel, if (rightCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING) - case = Battery(BatteryComponent.CASE, caseLevel, if (caseCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING) - } - - fun setBattery(data: ByteArray) { - if (data.size != 22) { - return - } -// first = if (data[10].toInt() == BatteryStatus.DISCONNECTED) { -// Battery(first.component, first.level, data[10].toInt()) -// } else { -// Battery(data[7].toInt(), data[9].toInt(), data[10].toInt()) -// } -// second = if (data[15].toInt() == BatteryStatus.DISCONNECTED) { -// Battery(second.component, second.level, data[15].toInt()) -// } else { -// Battery(data[12].toInt(), data[14].toInt(), data[15].toInt()) -// } -// case = if (data[20].toInt() == BatteryStatus.DISCONNECTED && case.status != BatteryStatus.DISCONNECTED) { -// Battery(case.component, case.level, data[20].toInt()) -// } else { -// Battery(data[17].toInt(), data[19].toInt(), data[20].toInt()) -// } -// sometimes it shows battery as -1%, just skip all that and set it normally - first = Battery( - data[7].toInt(), data[9].toInt(), data[10].toInt() - ) - second = Battery( - data[12].toInt(), data[14].toInt(), data[15].toInt() - ) - case = Battery( - data[17].toInt(), data[19].toInt(), data[20].toInt() - ) - } - - fun getBattery(): List { - val left = if (first.component == BatteryComponent.LEFT) first else second - val right = if (first.component == BatteryComponent.LEFT) second else first - return listOf(left, right, case) - } - } - - class ConversationalAwarenessNotification { - @Suppress("PrivatePropertyName") - private val NOTIFICATION_PREFIX = Enums.CONVERSATION_AWARENESS_RECEIVE_PREFIX.value - - var status: Byte = 0 - private set - - fun isConversationalAwarenessData(data: ByteArray): Boolean { - if (data.size != 10) { - return false - } - val prefixHex = NOTIFICATION_PREFIX.joinToString("") { "%02x".format(it) } - val dataHex = data.joinToString("") { "%02x".format(it) } - return dataHex.startsWith(prefixHex) - } - - fun setData(data: ByteArray) { - status = data[9] - } - } -} - -fun isHeadTrackingData(data: ByteArray): Boolean { - if (data.size <= 60) return false - - val prefixPattern = byteArrayOf( - 0x04, 0x00, 0x04, 0x00, 0x17, 0x00, 0x00, 0x00, - 0x10, 0x00 - ) - - for (i in prefixPattern.indices) { - if (data[i] != prefixPattern[i]) return false - } - - if (data[10] != 0x44.toByte() && data[10] != 0x45.toByte()) return false - - if (data[11] != 0x00.toByte()) return false - - return true -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/MaterialIcons.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/MaterialIcons.kt deleted file mode 100644 index f4d6bb4a..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/MaterialIcons.kt +++ /dev/null @@ -1,528 +0,0 @@ -package me.kavishdevar.librepods.presentation - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.PathFillType -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.unit.dp - -object MaterialIcons { - val notifications: ImageVector - get() { - if (_notifications != null) { - return _notifications!! - } - _notifications = - ImageVector.Builder( - name = "notifications", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f, - ) - .apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1f, - stroke = null, - strokeAlpha = 1f, - strokeLineWidth = 1f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Bevel, - strokeLineMiter = 1f, - pathFillType = PathFillType.NonZero, - ) { - moveTo(4f, 19f) - verticalLineTo(17f) - horizontalLineTo(6f) - verticalLineTo(10f) - quadTo(6f, 7.93f, 7.25f, 6.31f) - reflectiveQuadTo(10.5f, 4.2f) - verticalLineTo(3.5f) - quadToRelative(0f, -0.63f, 0.44f, -1.06f) - reflectiveQuadTo(12f, 2f) - reflectiveQuadToRelative(1.06f, 0.44f) - reflectiveQuadTo(13.5f, 3.5f) - verticalLineTo(4.2f) - quadToRelative(2f, 0.5f, 3.25f, 2.11f) - reflectiveQuadTo(18f, 10f) - verticalLineToRelative(7f) - horizontalLineToRelative(2f) - verticalLineToRelative(2f) - horizontalLineTo(4f) - close() - moveToRelative(8f, -7.5f) - close() - moveTo(12f, 22f) - quadToRelative(-0.82f, 0f, -1.41f, -0.59f) - reflectiveQuadTo(10f, 20f) - horizontalLineToRelative(4f) - quadToRelative(0f, 0.82f, -0.59f, 1.41f) - reflectiveQuadTo(12f, 22f) - close() - moveTo(8f, 17f) - horizontalLineToRelative(8f) - verticalLineTo(10f) - quadTo(16f, 8.35f, 14.83f, 7.18f) - reflectiveQuadTo(12f, 6f) - reflectiveQuadTo(9.18f, 7.18f) - reflectiveQuadTo(8f, 10f) - verticalLineToRelative(7f) - close() - } - } - .build() - return _notifications!! - } - - private var _notifications: ImageVector? = null - - val headset_off: ImageVector - get() { - if (_headset_off != null) { - return _headset_off!! - } - _headset_off = - ImageVector.Builder( - name = "headset_off", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f, - ) - .apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1f, - stroke = null, - strokeAlpha = 1f, - strokeLineWidth = 1f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Bevel, - strokeLineMiter = 1f, - pathFillType = PathFillType.NonZero, - ) { - moveTo(21f, 18.15f) - lineToRelative(-2f, -2f) - verticalLineTo(14f) - horizontalLineTo(16.85f) - lineToRelative(-2f, -2f) - horizontalLineTo(19f) - verticalLineTo(11f) - quadTo(19f, 8.05f, 16.95f, 6.02f) - reflectiveQuadTo(12f, 4f) - quadTo(10.9f, 4f, 9.91f, 4.31f) - reflectiveQuadTo(8.1f, 5.2f) - lineTo(6.65f, 3.8f) - quadTo(7.78f, 2.92f, 9.14f, 2.46f) - reflectiveQuadTo(12f, 2f) - quadToRelative(1.85f, 0f, 3.49f, 0.7f) - reflectiveQuadToRelative(2.86f, 1.93f) - reflectiveQuadToRelative(1.94f, 2.86f) - reflectiveQuadTo(21f, 11f) - verticalLineToRelative(7.15f) - close() - moveTo(12f, 23f) - verticalLineTo(21f) - horizontalLineToRelative(6.18f) - lineToRelative(-1f, -1f) - horizontalLineTo(15f) - verticalLineTo(17.83f) - lineTo(5.53f, 8.35f) - quadTo(5.3f, 8.95f, 5.15f, 9.64f) - reflectiveQuadTo(5f, 11f) - verticalLineToRelative(1f) - horizontalLineTo(9f) - verticalLineToRelative(8f) - horizontalLineTo(5f) - quadTo(4.18f, 20f, 3.59f, 19.41f) - reflectiveQuadTo(3f, 18f) - verticalLineTo(11f) - quadTo(3f, 9.88f, 3.26f, 8.82f) - reflectiveQuadToRelative(0.76f, -2f) - lineTo(0.68f, 3.5f) - lineTo(2.1f, 2.1f) - lineTo(21.88f, 21.9f) - verticalLineTo(23f) - horizontalLineTo(12f) - close() - moveTo(5f, 18f) - horizontalLineTo(7f) - verticalLineTo(14f) - horizontalLineTo(5f) - verticalLineToRelative(4f) - close() - moveTo(5f, 14f) - horizontalLineTo(7f) - horizontalLineTo(5f) - close() - moveToRelative(11.85f, 0f) - horizontalLineTo(19f) - horizontalLineTo(16.85f) - close() - } - } - .build() - return _headset_off!! - } - - private var _headset_off: ImageVector? = null - - val pause: ImageVector - get() { - if (_pause != null) { - return _pause!! - } - _pause = - ImageVector.Builder( - name = "pause", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f, - ) - .apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1f, - stroke = null, - strokeAlpha = 1f, - strokeLineWidth = 1f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Bevel, - strokeLineMiter = 1f, - pathFillType = PathFillType.NonZero, - ) { - moveTo(13f, 19f) - verticalLineTo(5f) - horizontalLineToRelative(6f) - verticalLineTo(19f) - horizontalLineTo(13f) - close() - moveTo(5f, 19f) - verticalLineTo(5f) - horizontalLineToRelative(6f) - verticalLineTo(19f) - horizontalLineTo(5f) - close() - moveTo(15f, 17f) - horizontalLineToRelative(2f) - verticalLineTo(7f) - horizontalLineTo(15f) - verticalLineTo(17f) - close() - moveTo(7f, 17f) - horizontalLineTo(9f) - verticalLineTo(7f) - horizontalLineTo(7f) - verticalLineTo(17f) - close() - moveTo(7f, 7f) - verticalLineTo(17f) - verticalLineTo(7f) - close() - moveToRelative(8f, 0f) - verticalLineTo(17f) - verticalLineTo(7f) - close() - } - } - .build() - return _pause!! - } - - private var _pause: ImageVector? = null - - val bluetooth: ImageVector - get() { - if (_bluetooth != null) { - return _bluetooth!! - } - _bluetooth = - ImageVector.Builder( - name = "bluetooth", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f, - ) - .apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1f, - stroke = null, - strokeAlpha = 1f, - strokeLineWidth = 1f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Bevel, - strokeLineMiter = 1f, - pathFillType = PathFillType.NonZero, - ) { - moveTo(11f, 22f) - verticalLineTo(14.4f) - lineTo(6.4f, 19f) - lineTo(5f, 17.6f) - lineTo(10.6f, 12f) - lineTo(5f, 6.4f) - lineTo(6.4f, 5f) - lineTo(11f, 9.6f) - verticalLineTo(2f) - horizontalLineToRelative(1f) - lineToRelative(5.7f, 5.7f) - lineTo(13.4f, 12f) - lineToRelative(4.3f, 4.3f) - lineTo(12f, 22f) - horizontalLineTo(11f) - close() - moveTo(13f, 9.6f) - lineTo(14.9f, 7.7f) - lineTo(13f, 5.85f) - verticalLineTo(9.6f) - close() - moveToRelative(0f, 8.55f) - lineTo(14.9f, 16.3f) - lineTo(13f, 14.4f) - verticalLineToRelative(3.75f) - close() - } - } - .build() - return _bluetooth!! - } - - private var _bluetooth: ImageVector? = null - - val bluetooth_searching: ImageVector - get() { - if (_bluetooth_searching != null) { - return _bluetooth_searching!! - } - _bluetooth_searching = - ImageVector.Builder( - name = "bluetooth_searching", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f, - ) - .apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1f, - stroke = null, - strokeAlpha = 1f, - strokeLineWidth = 1f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Bevel, - strokeLineMiter = 1f, - pathFillType = PathFillType.NonZero, - ) { - moveTo(9f, 22f) - verticalLineTo(14.4f) - lineTo(4.4f, 19f) - lineTo(3f, 17.6f) - lineTo(8.6f, 12f) - lineTo(3f, 6.4f) - lineTo(4.4f, 5f) - lineTo(9f, 9.6f) - verticalLineTo(2f) - horizontalLineToRelative(1f) - lineToRelative(5.7f, 5.7f) - lineTo(11.4f, 12f) - lineToRelative(4.3f, 4.3f) - lineTo(10f, 22f) - horizontalLineTo(9f) - close() - moveTo(11f, 9.6f) - lineTo(12.9f, 7.7f) - lineTo(11f, 5.85f) - verticalLineTo(9.6f) - close() - moveToRelative(0f, 8.55f) - lineTo(12.9f, 16.3f) - lineTo(11f, 14.4f) - verticalLineToRelative(3.75f) - close() - moveToRelative(5.55f, -3.8f) - lineTo(14.25f, 12f) - lineToRelative(2.3f, -2.3f) - quadToRelative(0.23f, 0.55f, 0.36f, 1.13f) - reflectiveQuadTo(17.05f, 12f) - reflectiveQuadToRelative(-0.14f, 1.19f) - quadToRelative(-0.14f, 0.59f, -0.36f, 1.16f) - close() - moveTo(19.5f, 17.2f) - lineTo(18.25f, 16f) - quadToRelative(0.5f, -0.93f, 0.78f, -1.94f) - reflectiveQuadTo(19.3f, 12f) - reflectiveQuadTo(19.03f, 9.94f) - quadTo(18.75f, 8.92f, 18.25f, 8f) - lineTo(19.5f, 6.75f) - quadToRelative(0.73f, 1.2f, 1.11f, 2.52f) - reflectiveQuadTo(21f, 12f) - reflectiveQuadToRelative(-0.39f, 2.71f) - quadTo(20.23f, 16.02f, 19.5f, 17.2f) - close() - } - } - .build() - return _bluetooth_searching!! - } - - private var _bluetooth_searching: ImageVector? = null - - val call: ImageVector - get() { - if (_call != null) { - return _call!! - } - _call = - ImageVector.Builder( - name = "call", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f, - ) - .apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1f, - stroke = null, - strokeAlpha = 1f, - strokeLineWidth = 1f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Bevel, - strokeLineMiter = 1f, - pathFillType = PathFillType.Companion.NonZero, - ) { - moveTo(19.95f, 21f) - quadToRelative(-3.13f, 0f, -6.18f, -1.36f) - reflectiveQuadTo(8.23f, 15.78f) - quadTo(5.73f, 13.27f, 4.36f, 10.23f) - reflectiveQuadTo(3f, 4.05f) - quadTo(3f, 3.6f, 3.3f, 3.3f) - reflectiveQuadTo(4.05f, 3f) - horizontalLineTo(8.1f) - quadTo(8.45f, 3f, 8.73f, 3.24f) - reflectiveQuadTo(9.05f, 3.8f) - lineTo(9.7f, 7.3f) - quadTo(9.75f, 7.7f, 9.68f, 7.97f) - reflectiveQuadTo(9.4f, 8.45f) - lineTo(6.98f, 10.9f) - quadToRelative(0.5f, 0.93f, 1.19f, 1.79f) - reflectiveQuadToRelative(1.51f, 1.66f) - quadToRelative(0.78f, 0.78f, 1.63f, 1.44f) - reflectiveQuadTo(13.1f, 17f) - lineToRelative(2.35f, -2.35f) - quadToRelative(0.22f, -0.23f, 0.59f, -0.34f) - reflectiveQuadToRelative(0.71f, -0.06f) - lineToRelative(3.45f, 0.7f) - quadToRelative(0.35f, 0.1f, 0.57f, 0.36f) - reflectiveQuadTo(21f, 15.9f) - verticalLineToRelative(4.05f) - quadToRelative(0f, 0.45f, -0.3f, 0.75f) - reflectiveQuadTo(19.95f, 21f) - close() - moveTo(6.03f, 9f) - lineTo(7.68f, 7.35f) - lineTo(7.25f, 5f) - horizontalLineTo(5.03f) - quadTo(5.15f, 6.02f, 5.38f, 7.02f) - reflectiveQuadTo(6.03f, 9f) - close() - moveToRelative(8.95f, 8.95f) - quadToRelative(0.97f, 0.43f, 1.99f, 0.68f) - reflectiveQuadTo(19f, 18.95f) - verticalLineToRelative(-2.2f) - lineTo(16.65f, 16.27f) - lineToRelative(-1.68f, 1.68f) - close() - moveTo(6.03f, 9f) - close() - moveToRelative(8.95f, 8.95f) - close() - } - } - .build() - return _call!! - } - - private var _call: ImageVector? = null - - val stack: ImageVector - get() { - if (_stack != null) { - return _stack!! - } - _stack = - ImageVector.Builder( - name = "stack", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f, - ) - .apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1f, - stroke = null, - strokeAlpha = 1f, - strokeLineWidth = 1f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Bevel, - strokeLineMiter = 1f, - pathFillType = PathFillType.Companion.NonZero, - ) { - moveTo(6f, 14f) - verticalLineToRelative(2f) - horizontalLineTo(4f) - quadTo(3.18f, 16f, 2.59f, 15.41f) - reflectiveQuadTo(2f, 14f) - verticalLineTo(4f) - quadTo(2f, 3.17f, 2.59f, 2.59f) - reflectiveQuadTo(4f, 2f) - horizontalLineTo(14f) - quadToRelative(0.83f, 0f, 1.41f, 0.59f) - reflectiveQuadTo(16f, 4f) - verticalLineTo(6f) - horizontalLineTo(14f) - verticalLineTo(4f) - horizontalLineTo(4f) - verticalLineTo(14f) - horizontalLineTo(6f) - close() - moveToRelative(4f, 8f) - quadTo(9.18f, 22f, 8.59f, 21.41f) - reflectiveQuadTo(8f, 20f) - verticalLineTo(10f) - quadTo(8f, 9.17f, 8.59f, 8.59f) - reflectiveQuadTo(10f, 8f) - horizontalLineTo(20f) - quadToRelative(0.83f, 0f, 1.41f, 0.59f) - reflectiveQuadTo(22f, 10f) - verticalLineTo(20f) - quadToRelative(0f, 0.82f, -0.59f, 1.41f) - reflectiveQuadTo(20f, 22f) - horizontalLineTo(10f) - close() - moveToRelative(0f, -2f) - horizontalLineTo(20f) - verticalLineTo(10f) - horizontalLineTo(10f) - verticalLineTo(20f) - close() - moveToRelative(5f, -5f) - close() - } - } - .build() - return _stack!! - } - - private var _stack: ImageVector? = null -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AboutCard.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AboutCard.kt deleted file mode 100644 index 00b18672..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AboutCard.kt +++ /dev/null @@ -1,79 +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 . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.presentation.components - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.res.stringResource -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import kotlin.io.encoding.ExperimentalEncodingApi - -@Composable -fun AboutCard( - modelName: String, - actualModel: String, - serialNumbers: List, - version: String?, - navigateToVersion: () -> Unit -) { - val serialNumbers = when (LocalDesignSystem.current) { - DesignSystem.Apple -> listOf( - serialNumbers[0], - "􀀛 ${serialNumbers[1]}", - "􀀧 ${serialNumbers[2]}" - ) - - DesignSystem.Material -> listOf( - serialNumbers[0], - stringResource(R.string.left) + " " + serialNumbers[1], - stringResource(R.string.right) + " " + serialNumbers[2], - ) - } - - val serialNumber = remember { mutableIntStateOf(0) } - - StyledList (title = stringResource(R.string.about)) { - StyledListItem( - name = stringResource(R.string.model_name), - description = modelName - ) - - StyledListItem( - name = stringResource(R.string.model_number), - description = actualModel - ) - - StyledListItem ( - name = stringResource(R.string.serial_number), - description = serialNumbers[serialNumber.intValue], - onClick = { serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size } - ) - - StyledListItem( - name = stringResource(R.string.version), - description = version, - onClick = navigateToVersion, - ) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryView.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryView.kt deleted file mode 100644 index a3f9dffa..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryView.kt +++ /dev/null @@ -1,174 +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 . -*/ - -@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, - 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 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 - ) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledListItem.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledListItem.kt deleted file mode 100644 index 7a92106e..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledListItem.kt +++ /dev/null @@ -1,409 +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 . -*/ - -package me.kavishdevar.librepods.presentation.components - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SegmentedListItem -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.launch -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.theme.sectionHeader - -@Composable -fun StyledListItem( - modifier: Modifier = Modifier, - title: String? = null, - name: String, - onClick: (() -> Unit)?, - description: String? = null, - height: Dp = 58.dp, - enabled: Boolean = true, - orientation: ListItemOrientation = ListItemOrientation.Horizontal, - leadingContent: (@Composable () -> Unit)? = null, - trailingContent: (@Composable () -> Unit)? = null -) { - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - Column { - title?.let { - Box( - modifier = Modifier - .background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer) - .padding(horizontal = 16.dp) - .padding(top = 4.dp, bottom = if (m3eEnabled) 8.dp else 4.dp) - ) { - Text( - text = it, - color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader, - style = MaterialTheme.typography.labelSmallEmphasized - ) - } - } - Column( - modifier = modifier - .fillMaxWidth() - .heightIn(min = 48.dp) - .background( - if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surface, - RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp) - ) - .clip(RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp)) - ) { - StyledListItemContent( - name = name, - onClick = onClick, - description = description, - height = height, - enabled = enabled, - index = 0, - count = 1, - orientation = orientation, - leadingContent = leadingContent, - trailingContent = trailingContent - ) - } - } -} - -@Composable -fun StyledListScope.StyledListItem( - modifier: Modifier = Modifier, - name: String, - onClick: (() -> Unit)? = null, - description: String? = null, - enabled: Boolean = onClick != null, - orientation: ListItemOrientation = ListItemOrientation.Horizontal, - selected: Boolean? = null, - leadingContent: (@Composable () -> Unit)? = null, - trailingContent: (@Composable () -> Unit)? = null -) { - item { index, count -> - StyledListItemContent( - name = name, - onClick = onClick, - description = description, - enabled = enabled, - index = index, - count = count, - orientation = orientation, - modifier = modifier, - selected = selected, - leadingContent = leadingContent, - trailingContent = trailingContent - ) - } -} - -enum class ListItemOrientation{ - Horizontal, - Vertical -} - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun StyledListItemContent( - modifier: Modifier = Modifier, - name: String, - onClick: (() -> Unit)?, - description: String? = null, - height: Dp = 58.dp, - enabled: Boolean = true, - index: Int, - count: Int, - orientation: ListItemOrientation = ListItemOrientation.Horizontal, - selected: Boolean? = null, - leadingContent: (@Composable () -> Unit)? = null, - trailingContent: (@Composable () -> Unit)? = null -) { - val isDarkTheme = isSystemInDarkTheme() - val surfaceColor = MaterialTheme.colorScheme.surface - val surfaceDimColor = MaterialTheme.colorScheme.surfaceDim - var backgroundColor by remember { mutableStateOf(surfaceColor) } - val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) - val haptics = LocalHapticFeedback.current - val scope = rememberCoroutineScope() - - when (LocalDesignSystem.current) { - DesignSystem.Apple -> { - val trailingContentDefault: @Composable () -> Unit = { - if (trailingContent == null) { - if (onClick != null) { - if (selected != null) { - val floatAnimateState by animateFloatAsState( - targetValue = if (selected) 1f else 0f, - animationSpec = tween(durationMillis = 300) - ) - - Text( - text = "􀆅", - style = TextStyle( - fontSize = 20.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = MaterialTheme.colorScheme.primary.copy(alpha = floatAnimateState), - ), - modifier = Modifier.padding(end = 4.dp) - ) - } else { - Text( - text = "􀯻", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(0.6f), - modifier = Modifier - .padding(start = if (description != null) 6.dp else 0.dp) - ) - } - } - } else { - trailingContent() - } - } - Column ( - modifier = Modifier - .background( - animatedBackgroundColor, - when { - (index == 0 && count == 1) -> { - RoundedCornerShape(28.dp) - } - - (index == 0) -> { - RoundedCornerShape( - topStart = 28.dp, - topEnd = 28.dp, - bottomStart = 0.dp, - bottomEnd = 0.dp - ) - } - - (index + 1 == count) -> { - RoundedCornerShape( - topStart = 0.dp, - topEnd = 0.dp, - bottomStart = 28.dp, - bottomEnd = 28.dp - ) - } - - else -> { - RectangleShape - } - } - ) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - if (enabled) { - backgroundColor = surfaceDimColor - tryAwaitRelease() - backgroundColor = surfaceColor - } - }, - onTap = { - if (enabled) { - scope.launch { - haptics.performHapticFeedback( - HapticFeedbackType.ContextClick - ) - } - onClick?.invoke() - } - } - ) - } - .heightIn(min = height) - .padding(horizontal = 16.dp) - ) { - Row( - modifier = Modifier - .heightIn(min = height) - .padding(vertical = if (orientation == ListItemOrientation.Vertical) 12.dp else 0.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (leadingContent != null) { - leadingContent() - Spacer(modifier = Modifier.width(12.dp)) - } - Column (verticalArrangement = Arrangement.Center) { - Text( - text = name, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - if (description != null && orientation == ListItemOrientation.Vertical) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(if (isDarkTheme) 0.6f else 0.8f), // TODO: move to color scheme - ) - } - } - - Spacer(modifier = Modifier.weight(1f)) - - if (orientation == ListItemOrientation.Horizontal && description != null) { - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(if (isDarkTheme) 0.6f else 0.8f) // TODO: move to color scheme - ) - } - - trailingContentDefault() - } - if (index+1 != count) { - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier - .padding(start = if (leadingContent != null) 12.dp else 0.dp) - ) - } - } - } - - DesignSystem.Material -> { - val defaultShape = when { - count == 1 -> RoundedCornerShape(24.dp) - - index == 0 -> RoundedCornerShape( - topStart = 24.dp, - topEnd = 24.dp, - bottomStart = 8.dp, - bottomEnd = 8.dp - ) - - index == count - 1 -> RoundedCornerShape( - topStart = 8.dp, - topEnd = 8.dp, - bottomStart = 24.dp, - bottomEnd = 24.dp - ) - - else -> RoundedCornerShape(8.dp) - } - Column { - SegmentedListItem( - modifier = modifier.heightIn(min = 64.dp), - shapes = ListItemDefaults.shapes().copy( - shape = defaultShape, - pressedShape = RoundedCornerShape(24.dp), - selectedShape = RoundedCornerShape(24.dp), - hoveredShape = RoundedCornerShape(24.dp), - ), - onClick = onClick ?: {}, - leadingContent = leadingContent, - trailingContent = { - if (trailingContent == null) { - if (onClick != null) { - if (selected == true) { - Icon(Icons.Default.Check, contentDescription = null) - } else if (selected == null) { - Icon( - Icons.AutoMirrored.Default.KeyboardArrowRight, - contentDescription = null - ) - } - } - } else { - trailingContent() - } - }, - supportingContent = { - if (description != null) Text( - description, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(bottom = 4.dp) - ) - }, - content = { - Text( - text = name, - style = MaterialTheme.typography.labelMediumEmphasized, - modifier = Modifier.padding( - top = 4.dp, - bottom = if (description != null) 0.dp else 4.dp - ) - ) - }, - verticalAlignment = Alignment.CenterVertically, - colors = if (onClick == null) { - ListItemDefaults.segmentedColors().run { - copy( - disabledContentColor = contentColor, - disabledSupportingContentColor = supportingContentColor, - disabledTrailingContentColor = trailingContentColor - ) - } - } else ListItemDefaults.segmentedColors(), - enabled = onClick != null && enabled, - selected = selected ?: false - ) - if (index+1 != count) { - Spacer(modifier = Modifier.height(2.dp)) - } - } - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt deleted file mode 100644 index 14479eb5..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt +++ /dev/null @@ -1,324 +0,0 @@ -package me.kavishdevar.librepods.presentation.navigation - -import androidx.activity.BackEventCompat.Companion.EDGE_LEFT -import androidx.compose.animation.SharedTransitionLayout -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith -import androidx.compose.runtime.Composable -import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.ui.NavDisplay -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import me.kavishdevar.librepods.bluetooth.AACPManager -import me.kavishdevar.librepods.data.updates.updates -import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen -import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen -import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsRoute -import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen -import me.kavishdevar.librepods.presentation.screens.CallControlScreen -import me.kavishdevar.librepods.presentation.screens.EqualizerRoute -import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen -import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen -import me.kavishdevar.librepods.presentation.screens.HearingAidScreen -import me.kavishdevar.librepods.presentation.screens.HearingProtectionScreen -import me.kavishdevar.librepods.presentation.screens.LoadingScreen -import me.kavishdevar.librepods.presentation.screens.LongPress -import me.kavishdevar.librepods.presentation.screens.MicrophoneSettingsRoute -import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen -import me.kavishdevar.librepods.presentation.screens.PurchaseScreen -import me.kavishdevar.librepods.presentation.screens.ReleaseNotesScreen -import me.kavishdevar.librepods.presentation.screens.RenameScreen -import me.kavishdevar.librepods.presentation.screens.TransparencySettingsScreen -import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen -import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestRoute -import me.kavishdevar.librepods.presentation.screens.VersionScreen -import me.kavishdevar.librepods.presentation.screens.onboarding.OnboardingScreen -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel -import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel -import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel - -@OptIn(ExperimentalHazeMaterialsApi::class) -@Composable -fun AppNavGraph( - showReleaseNotes: Boolean = false, - updatesShown: () -> Unit = {}, - showOnboarding: Boolean = false, - onboardingComplete: () -> Unit = {}, - backStack: SnapshotStateList, - airPodsViewModel: AirPodsViewModel, -) { - val navigate: (Screen) -> Unit = { screen -> - backStack.add(screen) - } - - fun navigateToPurchase() { - navigate(Screen.Purchase) - } - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - - SharedTransitionLayout { - NavDisplay( - sharedTransitionScope = this, - backStack = backStack, - onBack = { - if (backStack.size > 1) { - backStack.removeAt(backStack.lastIndex) - } - }, - entryProvider = { screen -> - when (screen) { - Screen.Onboarding -> - NavEntry(screen) { - OnboardingScreen { - onboardingComplete() - if (showReleaseNotes) navigate(Screen.ReleaseNotes) else navigate(Screen.AirPodsSettings) - backStack.remove(screen) - } - } - Screen.AirPodsSettings -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - AirPodsSettingsRoute( - viewModel = airPodsViewModel, - navigateToRename = { navigate(Screen.Rename) }, - navigateToHearingProtection = { navigate(Screen.HearingProtection) }, - navigateToHearingAid = { navigate(Screen.HearingAid) }, - navigateToLeftLongPress = { - navigate( - Screen.LongPress("Left") - ) - }, - navigateToRightLongPress = { - navigate( - Screen.LongPress("Right") - ) - }, - navigateToPurchase = { navigate(Screen.Purchase) }, - navigateToAdaptiveStrength = { navigate(Screen.AdaptiveStrength) }, - navigateToEqualizer = { navigate(Screen.Equalizer) }, - navigateToHeadTracking = { navigate(Screen.HeadTracking) }, - navigateToAccessibility = { navigate(Screen.Accessibility) }, - navigateToVersion = { navigate(Screen.VersionInfo) }, - navigateToTroubleshooting = { navigate(Screen.Troubleshooting) }, - navigateToCallControlScreen = { navigate(Screen.CallControl(it)) }, - navigateToMicrophoneSettings = { navigate(Screen.MicrophoneSettings) }, - ) - } - - Screen.Rename -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - RenameScreen(airPodsViewModel) - } - - Screen.AppSettings -> - NavEntry(screen) { - val vm: AppSettingsViewModel = viewModel() - AppSettingsScreen( - viewModel = vm, - navigateToPurchase = ::navigateToPurchase, - navigateToTroubleshooting = { navigate(Screen.Troubleshooting) }, - navigateToOpenSourceLicenses = { navigate(Screen.OpenSourceLicenses) }, - navigateToReleaseNotesScreen = { navigate(Screen.ReleaseNotes) } - ) - } - - Screen.Troubleshooting -> - NavEntry(screen) { - TroubleshootingScreen() - } - - Screen.HeadTracking -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - HeadTrackingScreen(airPodsViewModel, ::navigateToPurchase) - } - - Screen.Accessibility -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - AccessibilitySettingsScreen( - viewModel = airPodsViewModel, - navigateToPurchase = ::navigateToPurchase, - navigateToTransparencyCustomization = { navigate(Screen.TransparencyCustomization) } - ) - } - - Screen.TransparencyCustomization -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - TransparencySettingsScreen(airPodsViewModel) - } - - Screen.HearingAid -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - HearingAidScreen( - viewModel = airPodsViewModel, - onNavigateHearingAidAdjustments = { navigate(Screen.HearingAidAdjustments) }, - onNavigateHearingTest = { navigate(Screen.UpdateHearingTest) }, - ) - } - - Screen.HearingAidAdjustments -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - HearingAidAdjustmentsScreen(airPodsViewModel) - } - - Screen.AdaptiveStrength -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - AdaptiveStrengthScreen(airPodsViewModel, ::navigateToPurchase) - } - -// Screen.CameraControl -> -// NavEntry(screen) { -// CameraControlScreen(airPodsViewModel) -// } - - Screen.OpenSourceLicenses -> - NavEntry(screen) { - OpenSourceLicensesScreen() - } - - Screen.UpdateHearingTest -> - NavEntry(screen) { - UpdateHearingTestRoute(airPodsViewModel) - } - - Screen.VersionInfo -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - VersionScreen(airPodsViewModel) - } - - Screen.HearingProtection -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - HearingProtectionScreen( - viewModel = airPodsViewModel, - navigateToPurchase = ::navigateToPurchase - ) - } - - Screen.Purchase -> - NavEntry(screen) { - val vm: PurchaseViewModel = viewModel() - PurchaseScreen(vm, backStack) - } - - Screen.Equalizer -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - EqualizerRoute(airPodsViewModel) - } - - is Screen.LongPress -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - LongPress( - viewModel = airPodsViewModel, - name = screen.bud, - navigateToPurchase = ::navigateToPurchase - ) - } - - is Screen.CallControl -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - CallControlScreen( - viewModel = airPodsViewModel, - action = screen.action, - onCallControlValueChanged = { flipped -> - airPodsViewModel.setControlCommandValue( - AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG, - if (flipped) byteArrayOf(0x00, 0x02) else byteArrayOf( - 0x00, - 0x03 - ) - ) - } - ) - } - - is Screen.MicrophoneSettings -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - MicrophoneSettingsRoute(viewModel = airPodsViewModel) - } - - is Screen.ReleaseNotes -> - NavEntry(screen) { - ReleaseNotesScreen( - updates = updates, - releaseNotesShown = { - if (showReleaseNotes) { - navigate(Screen.AirPodsSettings) - backStack.remove(screen) - updatesShown() - } else { - backStack.removeAt(backStack.lastIndex) - } - } - ) - } - } - }, - transitionSpec = { - slideInHorizontally { it } togetherWith slideOutHorizontally { -it / 4 } - }, - popTransitionSpec = { - slideInHorizontally { -it / 4 } togetherWith slideOutHorizontally { it } - }, - predictivePopTransitionSpec = { swipeEdge -> - if (m3eEnabled) { - val enterOffset: (Int) -> Int = - if (swipeEdge == EDGE_LEFT) { - { -it / 6 } - } else { - { it / 6 } - } - - val exitOffset: (Int) -> Int = - if (swipeEdge == EDGE_LEFT) { - { it / 8 } - } else { - { -it / 8 } - } - - fadeIn( - animationSpec = tween(250) - ) + - slideInHorizontally( - initialOffsetX = enterOffset, - animationSpec = tween(250) - ) togetherWith - fadeOut( - targetAlpha = 0.75f, - animationSpec = tween(250) - ) + - scaleOut( - targetScale = 0.85f, - animationSpec = tween(250) - ) + - slideOutHorizontally( - targetOffsetX = exitOffset, - animationSpec = tween(250) - ) - } else { - slideInHorizontally { -it / 4 } togetherWith slideOutHorizontally { it } - } - }, - ) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt deleted file mode 100644 index 1a8959f3..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt +++ /dev/null @@ -1,84 +0,0 @@ -package me.kavishdevar.librepods.presentation.navigation - -import androidx.navigation3.runtime.NavKey -import kotlinx.serialization.Serializable - -@Serializable -sealed interface Screen: NavKey { - val showTopBar: Boolean - get() = true - - @Serializable - data object Onboarding: Screen { - override val showTopBar: Boolean = false - } - - @Serializable - data object AirPodsSettings: Screen - - @Serializable - data object Rename: Screen - - @Serializable - data object AppSettings: Screen - - @Serializable - data object Troubleshooting: Screen - - @Serializable - data object HeadTracking: Screen - - @Serializable - data object Accessibility: Screen - - @Serializable - data object TransparencyCustomization: Screen - - @Serializable - data object HearingAid: Screen - - @Serializable - data object HearingAidAdjustments: Screen - - @Serializable - data object AdaptiveStrength: Screen - -// @Serializable -// data object CameraControl: Screen - - @Serializable - data object OpenSourceLicenses: Screen - - @Serializable - data object UpdateHearingTest: Screen - - @Serializable - data object VersionInfo: Screen - - @Serializable - data object HearingProtection: Screen - - @Serializable - data object Purchase: Screen - - @Serializable - data object Equalizer: Screen - - @Serializable - data class LongPress( - val bud: String - ): Screen - - @Serializable - data class CallControl( - val action: String - ): Screen - - @Serializable - data object MicrophoneSettings: Screen - - @Serializable - data object ReleaseNotes: Screen { - override val showTopBar: Boolean = false - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt deleted file mode 100644 index 9583ccea..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt +++ /dev/null @@ -1,1022 +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 . -*/ - -@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.Intent -import android.content.SharedPreferences -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialShapes -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.material3.ripple -import androidx.compose.material3.toPath -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.draw.clip -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.geometry.center -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Matrix -import androidx.compose.ui.graphics.Path -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.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.net.toUri -import androidx.graphics.shapes.Morph -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import com.kyant.backdrop.drawBackdrop -import com.kyant.backdrop.highlight.Highlight -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.MaterialIcons -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.MaterialButtonStyle -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.StyledListItem -import me.kavishdevar.librepods.presentation.components.StyledToggle -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel -import me.kavishdevar.librepods.presentation.viewmodel.demoState -import java.util.concurrent.TimeUnit -import kotlin.io.encoding.ExperimentalEncodingApi -import kotlin.math.min -import kotlin.time.Duration.Companion.seconds - -@Composable -fun AirPodsSettingsRoute( - viewModel: AirPodsViewModel, - navigateToRename: () -> Unit, - navigateToHearingProtection: () -> Unit, - navigateToHearingAid: () -> Unit, - navigateToLeftLongPress: () -> Unit, - navigateToRightLongPress: () -> Unit, - navigateToPurchase: () -> Unit, - navigateToAdaptiveStrength: () -> Unit, - navigateToEqualizer: () -> Unit, - navigateToHeadTracking: () -> Unit, - navigateToAccessibility: () -> Unit, - navigateToVersion: () -> Unit, - navigateToTroubleshooting: () -> Unit, - navigateToCallControlScreen: (action: String) -> Unit, - navigateToMicrophoneSettings: () -> Unit -) { - val state by viewModel.uiState.collectAsState() - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + if (m3eEnabled) 0.dp else 84.dp - val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - Box ( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { - AirPodsSettingsScreen( - state = state, - - topPadding = topPadding, - bottomPadding = bottomPadding, - - setControlCommandInt = viewModel::setControlCommandInt, - setControlCommandBoolean = viewModel::setControlCommandBoolean, -// setControlCommandValue = viewModel::setControlCommandValue, - setControlCommandByte = viewModel::setControlCommandByte, - - setATTCharacteristicValue = viewModel::setATTCharacteristicValue, - - onAutomaticEarDetectionChanged = viewModel::setAutomaticEarDetectionEnabled, - onAutomaticConnectionChanged = viewModel::setAutomaticConnectionEnabled, - setDynamicEndOfCharge = viewModel::setDynamicEndOfCharge, - setOffListeningMode = viewModel::setOffListeningMode, - disconnect = viewModel::disconnect, - - navigateToRename = navigateToRename, - navigateToHearingProtection = navigateToHearingProtection, - navigateToHearingAid = navigateToHearingAid, - navigateToLeftLongPress = navigateToLeftLongPress, - navigateToRightLongPress = navigateToRightLongPress, - navigateToPurchase = navigateToPurchase, - navigateToAdaptiveStrength = navigateToAdaptiveStrength, - navigateToEqualizer = navigateToEqualizer, - navigateToHeadTracking = navigateToHeadTracking, - navigateToAccessibility = navigateToAccessibility, - navigateToVersion = navigateToVersion, - navigateToTroubleshooting = navigateToTroubleshooting, - navigateToCallControlScreen = navigateToCallControlScreen, - navigateToMicrophoneSettings = navigateToMicrophoneSettings, - - activateDemoMode = viewModel::activateDemoMode, - reconnectFromSavedMac = viewModel::reconnectFromSavedMac - ) - } -} - - @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) -@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") -@Composable -fun AirPodsSettingsScreen( - state: AirPodsUiState, - - topPadding: Dp = 16.dp, - bottomPadding: Dp = 16.dp, - - setControlCommandInt: (AACPManager.Companion.ControlCommandIdentifiers, Int) -> Unit, - setControlCommandBoolean: (AACPManager.Companion.ControlCommandIdentifiers, Boolean) -> Unit, -// setControlCommandValue: (AACPManager.Companion.ControlCommandIdentifiers, ByteArray) -> Unit, - setControlCommandByte: (AACPManager.Companion.ControlCommandIdentifiers, Byte) -> Unit, - setATTCharacteristicValue: (ATTHandles, ByteArray) -> Unit, - - onAutomaticEarDetectionChanged: (Boolean) -> Unit, - onAutomaticConnectionChanged: (Boolean) -> Unit, - setDynamicEndOfCharge: (Boolean) -> Unit, - setOffListeningMode: (Boolean) -> Unit, - disconnect: () -> Unit, - - navigateToRename: () -> Unit, - navigateToHearingProtection: () -> Unit, - navigateToHearingAid: () -> Unit, - navigateToLeftLongPress: () -> Unit, - navigateToRightLongPress: () -> Unit, - navigateToPurchase: () -> Unit, - navigateToAdaptiveStrength: () -> Unit, - navigateToEqualizer: () -> Unit, - navigateToHeadTracking: () -> Unit, - navigateToAccessibility: () -> Unit, - navigateToVersion: () -> Unit, - navigateToTroubleshooting: () -> Unit, - navigateToCallControlScreen: (action: String) -> Unit, - navigateToMicrophoneSettings: () -> Unit, - - activateDemoMode: () -> Unit, - reconnectFromSavedMac: () -> Unit, -) { - 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) - } - } - - if (state.isLocallyConnected) { - val capabilities = state.capabilities - - LazyColumn( - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(horizontal = 16.dp) - ) { - item(key = "top_padding") { Spacer(modifier = Modifier.height(topPadding)) } - item(key = "play_update_banner") { - if (state.timeUntilFOSSPremiumExpiry > 0L) { - val context = LocalContext.current - Box( - modifier = Modifier - .background(Color(0xFF32829B), RoundedCornerShape(28.dp)) - .clip(RoundedCornerShape(28.dp)) - .clickable { - val emailIntent = Intent(Intent.ACTION_SENDTO).apply { - data = "mailto:".toUri() - putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz")) - putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error") - putExtra( - Intent.EXTRA_TEXT, - "Please enter your GitHub username to restore your premium access:\n\nGitHub username: " - ) - } - context.startActivity(emailIntent) - }) { - Text( - text = stringResource( - R.string.play_foss_premium_banner, - maxOf( - 1, - TimeUnit.MILLISECONDS.toDays(state.timeUntilFOSSPremiumExpiry) - .toInt() - ) - ), modifier = Modifier.padding(16.dp), style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - color = Color.White, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) - ) - } - } - } - - item(key = "battery") { - BatteryView( - batteryList = state.battery, - budsRes = state.instance?.model?.budsRes ?: R.drawable.airpods_pro_2_buds, - caseRes = state.instance?.model?.caseRes ?: R.drawable.airpods_pro_2_case - ) - } - item(key = "spacer_battery") { - Spacer(modifier = Modifier.height(32.dp)) - } - - item(key = "name") { - StyledListItem( - name = stringResource(R.string.name), - description = deviceName.text, - onClick = navigateToRename, - ) - } - - 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) { - item(key = "spacer_hearing_health") { - Spacer(modifier = Modifier.height(24.dp)) - } - } - item(key = "hearing_health") { - HearingHealthSettings( - hasPPECapability = hasPPECapability, - hasHearingAidCapability = hasHearingAidCapability, - vendorIdHook = state.vendorIdHook, - navigateToHearingProtection = navigateToHearingProtection, - navigateToHearingAid = navigateToHearingAid - ) - } - } - - 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 = { - 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( - leftAction = state.leftAction, - rightAction = state.rightAction, - navigateToLeftLongPress = navigateToLeftLongPress, - navigateToRightLongPress = navigateToRightLongPress - ) - } - } - - item(key = "spacer_call") { - Spacer(modifier = Modifier.height(16.dp)) - } - item(key = "call_control") { - val bytes = - state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take( - 2 - )?.toByteArray() ?: byteArrayOf(0x00, 0x00) - val flipped = try { - bytes[1] == 0x02.toByte() - } catch (_: Exception) { - false - } - CallControlSettings( - flipped = flipped, - navigateToCallControlScreen = navigateToCallControlScreen - ) - } - -// if (capabilities.contains(Capability.STEM_CONFIG) && !BuildConfig.PLAY_BUILD) { -// item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) } -// item(key = "camera_control") { -// StyledListItem( -// to = "camera_control", -// name = stringResource(R.string.camera_remote), -// descriptionRes = stringResource(R.string.camera_control_description), -// titleRes = stringResource(R.string.camera_control), -// navController = navController -// ) -// } -// } - - item(key = "upgrade_button") { - if (!state.isPremium) { - Spacer(modifier = Modifier.height(28.dp)) - StyledButton( - onClick = navigateToPurchase, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - maxScale = 0.05f, - surfaceColor = MaterialTheme.colorScheme.primary - ) { - Text( - stringResource(R.string.unlock_advanced_features), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimary - ) - } - Spacer(modifier = Modifier.height(8.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( - adaptiveVolumeCapability = adaptiveVolumeCapability, - conversationalAwarenessCapability = conversationalAwarenessCapability, - loudSoundReductionCapability = loudSoundReductionCapability, - adaptiveAudioCapability = adaptiveAudioCapability, - customEqCapability = true, - adaptiveVolumeChecked = adaptiveVolumeChecked, - onAdaptiveVolumeCheckedChange = { checked -> - setControlCommandBoolean( - AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG, - checked - ) - }, - conversationalAwarenessChecked = conversationalAwarenessChecked && state.isPremium, - onConversationalAwarenessCheckedChange = { checked -> - setControlCommandBoolean( - AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, - checked - ) - }, - loudSoundReductionChecked = state.loudSoundReductionEnabled, - onLoudSoundReductionCheckedChange = { checked -> - setATTCharacteristicValue( - ATTHandles.LOUD_SOUND_REDUCTION, - byteArrayOf(if (checked) 0x01.toByte() else 0x00.toByte()) - ) - }, - navigateToAdaptiveStrength = navigateToAdaptiveStrength, - navigateToEqualizer = navigateToEqualizer, - vendorIdHook = state.vendorIdHook, - isPremium = state.isPremium - ) - } - - item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) } - item(key = "connection") { - ConnectionSettings( - automaticEarDetectionEnabled = state.automaticEarDetectionEnabled, - onAutomaticEarDetectionChanged = onAutomaticEarDetectionChanged, - automaticConnectionEnabled = state.automaticConnectionEnabled, - onAutomaticConnectionChanged = onAutomaticConnectionChanged - ) - } - - item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) } - item(key = "microphone") { - val id = AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE - - val selectedModeText = when (state.controlStates[id]?.getOrNull(0) ?: 0x00.toByte()) { - 0x00.toByte() -> stringResource(R.string.microphone_automatic) - 0x01.toByte() -> stringResource(R.string.microphone_always_right) - 0x02.toByte() -> stringResource(R.string.microphone_always_left) - else -> stringResource(R.string.microphone_automatic) - } - - StyledListItem( - name = stringResource(R.string.microphone_mode), - description = selectedModeText, - onClick = navigateToMicrophoneSettings - ) - } - - 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 = { 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") { - StyledListItem( - name = stringResource(R.string.head_gestures), - description = if (sharedPreferences.getBoolean( - "head_gestures", false - ) - ) stringResource(R.string.on) else stringResource(R.string.off), - onClick = navigateToHeadTracking - ) - } - } - - item(key = "spacer_dynamic_end_of_charge") { Spacer(modifier = Modifier.height(16.dp)) } - item(key = "dynamic_end_of_charge") { - StyledToggle( - label = stringResource(R.string.optimized_charging), - description = stringResource(R.string.optimized_charging_description), - checked = state.dynamicEndOfCharge, - onCheckedChange = setDynamicEndOfCharge - ) - } - - item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) } - item(key = "accessibility") { - StyledListItem( - name = stringResource(R.string.accessibility), onClick = navigateToAccessibility - ) - } - - 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 = setOffListeningMode - ) - } - } - - item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) } - item(key = "about") { - AboutCard( - modelName = state.modelName, - actualModel = state.actualModel, - serialNumbers = state.serialNumbers, - version = state.version3, - navigateToVersion = navigateToVersion - ) - } - - item(key = "spacer_disconnect") { Spacer(modifier = Modifier.height(28.dp)) } - item(key = "disconnect_button") { - StyledButton( - onClick = disconnect, - backdrop = rememberLayerBackdrop(), - isInteractive = false, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 56.dp) - ) { - Text( - text = stringResource(R.string.disconnect), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer, - textAlign = TextAlign.Start, - modifier = Modifier.fillMaxWidth() - ) - } - } - -// item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) } -// item(key = "debug") { StyledListItem("debug", "Debug", navController) } - - item(key = "bottom_padding") { Spacer(modifier = Modifier.height(bottomPadding)) } - } - } else { - val backdrop = rememberLayerBackdrop() - Box( - modifier = Modifier - .drawBackdrop( - backdrop = rememberLayerBackdrop(), - exportedBackdrop = backdrop, - shape = { RoundedCornerShape(0.dp) }, - highlight = { - Highlight.Ambient.copy(alpha = 0f) - }, - effects = {} - ) - .fillMaxSize() - .padding(start = 8.dp, end = 8.dp, bottom = bottomPadding), - contentAlignment = Alignment.Center - ) { - val tapCount = remember { mutableIntStateOf(0) } - val lastTapTime = remember { mutableLongStateOf(0L) } - - var reconnecting by remember { mutableStateOf(false) } - - LaunchedEffect(reconnecting) { - if (reconnecting) { - delay(5.seconds) - reconnecting = false - } - } - - when (LocalDesignSystem.current) { - DesignSystem.Material -> { - val polygons = remember { - listOf( - MaterialShapes.Cookie9Sided, - MaterialShapes.Clover4Leaf, - MaterialShapes.SoftBurst, - MaterialShapes.Sunny, - MaterialShapes.Pentagon, - MaterialShapes.Cookie4Sided, - MaterialShapes.Oval, - ) - } - - val morphs = remember { - buildList { - for (i in polygons.indices) { - add( - Morph( - polygons[i].normalized(), - polygons[(i + 1) % polygons.size].normalized() - ) - ) - } - } - } - - var currentMorphIndex by remember { mutableIntStateOf(0) } - - val morphProgress = remember { Animatable(0f) } - - LaunchedEffect(reconnecting) { - if (!reconnecting) { - currentMorphIndex = 0 - morphProgress.snapTo(0f) - return@LaunchedEffect - } - - while (reconnecting) { - morphProgress.snapTo(0f) - - morphProgress.animateTo( - targetValue = 1f, - animationSpec = tween( - durationMillis = 650, - easing = FastOutSlowInEasing - ) - ) - - currentMorphIndex = (currentMorphIndex + 1) % morphs.size - } - } - - val path = remember { Path() } - val scaleMatrix = remember { Matrix() } - - Box( - modifier = Modifier - .fillMaxSize() - .pointerInput(Unit) { - detectTapGestures( - onLongPress = { - activateDemoMode() - } - ) - } - ) { - Column( - modifier = Modifier - .align(Alignment.Center) - .padding(horizontal = 32.dp) - .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 - activateDemoMode() - } - } - ) - }, - horizontalAlignment = Alignment.CenterHorizontally - ) { - val primaryContainerColor = MaterialTheme.colorScheme.tertiaryContainer - val secondaryContainerColor = MaterialTheme.colorScheme.secondaryContainer - - val animatedShapeColor by animateColorAsState(if (reconnecting) primaryContainerColor else secondaryContainerColor) - - Box( - modifier = Modifier - .size(240.dp) - .background( - MaterialTheme.colorScheme.surfaceBright, - CircleShape - ) - .clickable( - interactionSource = null, - indication = ripple( - bounded = false, - radius = 120.dp - ), - enabled = !reconnecting, - onClick = {} - ) - .pointerInput(Unit) { - detectTapGestures( - onTap = { - if (!reconnecting) { - currentMorphIndex = 1 - reconnecting = true - reconnectFromSavedMac() - } - }, - onPress = { - if (!reconnecting) { - morphProgress.animateTo( - targetValue = 1f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ) - ) - tryAwaitRelease() - morphProgress.animateTo( - targetValue = 0f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ) - ) - } - } - ) - } - .drawWithContent { - val activeMorph = morphs[currentMorphIndex] - - val shapePath = activeMorph.toPath( - progress = morphProgress.value, - path = path - ) - - val bounds = shapePath.getBounds() - - val scale = min(size.width/bounds.width, size.height/bounds.height) * 0.8f - - scaleMatrix.reset() - - scaleMatrix.scale(x = scale, y = scale) - - shapePath.transform(scaleMatrix) - - shapePath.translate(size.center - shapePath.getBounds().center) - - drawPath( - path = shapePath, - color = animatedShapeColor - ) - - drawContent() - }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = if (reconnecting) MaterialIcons.bluetooth_searching else MaterialIcons.headset_off, - contentDescription = null, - modifier = Modifier.size(84.dp), - tint = if (reconnecting) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer - ) - } - - Spacer(Modifier.height(40.dp)) - - Text( - text = if (reconnecting) stringResource(R.string.reconnecting) else stringResource(R.string.tap_to_reconnect), - style = MaterialTheme.typography.labelSmallEmphasized, - color = MaterialTheme.colorScheme.primary - ) - } - - if (!BuildConfig.PLAY_BUILD) { - OutlinedButton( - onClick = navigateToTroubleshooting, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(24.dp) - ) { - Text( - stringResource( - R.string.troubleshooting - ), - style = MaterialTheme.typography.labelMedium, - ) - } - } - } - } - - DesignSystem.Apple -> { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.Center - ) { - 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 - activateDemoMode() - } - }) - }) { - Text( - text = stringResource(R.string.airpods_not_connected), - style = MaterialTheme.typography.displaySmall, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.height(24.dp)) - Text( - text = stringResource(R.string.airpods_not_connected_description), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - } - - if (state.connectionSuccessful) { - StyledButton( - onClick = { reconnectFromSavedMac(); reconnecting = true }, - backdrop = backdrop, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .widthIn(max = 200.dp), - enabled = !reconnecting - ) { - Text( - text = stringResource(R.string.reconnect_to_last_device), - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - - if (!BuildConfig.PLAY_BUILD) { - StyledButton( - onClick = navigateToTroubleshooting, - backdrop = backdrop, - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .padding(16.dp) - .widthIn(max = 200.dp), - materialButtonStyle = MaterialButtonStyle.Outlined, - ) { - Text( - text = stringResource(R.string.troubleshooting), - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - } - } - } -} - -@Preview(name = "Apple") -@Composable -fun AirPodsSettingsScreenPreviewApple() { - LibrePodsTheme( - m3eEnabled = false - ) { - Box( - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { - AirPodsSettingsScreen( - state = demoState, - - setControlCommandInt = { _, _ -> }, - setControlCommandBoolean = { _, _ -> }, - setControlCommandByte = { _, _ -> }, - setATTCharacteristicValue = { _, _ -> }, - - onAutomaticEarDetectionChanged = {}, - onAutomaticConnectionChanged = {}, - setDynamicEndOfCharge = {}, - setOffListeningMode = {}, - disconnect = {}, - - navigateToRename = {}, - navigateToHearingProtection = {}, - navigateToHearingAid = {}, - navigateToLeftLongPress = {}, - navigateToRightLongPress = {}, - navigateToPurchase = {}, - navigateToAdaptiveStrength = {}, - navigateToEqualizer = {}, - navigateToHeadTracking = {}, - navigateToAccessibility = {}, - navigateToVersion = {}, - navigateToTroubleshooting = {}, - navigateToCallControlScreen = {}, - navigateToMicrophoneSettings = {}, - - activateDemoMode = {}, - reconnectFromSavedMac = {} - ) - } - } -} - - -@Preview(name = "Material") -@Composable -fun AirPodsSettingsScreenPreviewMaterial() { - LibrePodsTheme( - m3eEnabled = true - ) { - Box ( - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { - AirPodsSettingsScreen( - state = demoState, - - setControlCommandInt = { _, _ -> }, - setControlCommandBoolean = { _, _ -> }, - setControlCommandByte = { _, _ -> }, - setATTCharacteristicValue = { _, _ -> }, - - onAutomaticEarDetectionChanged = {}, - onAutomaticConnectionChanged = {}, - setDynamicEndOfCharge = {}, - setOffListeningMode = {}, - disconnect = {}, - - navigateToRename = {}, - navigateToHearingProtection = {}, - navigateToHearingAid = {}, - navigateToLeftLongPress = {}, - navigateToRightLongPress = {}, - navigateToPurchase = {}, - navigateToAdaptiveStrength = {}, - navigateToEqualizer = {}, - navigateToHeadTracking = {}, - navigateToAccessibility = {}, - navigateToVersion = {}, - navigateToTroubleshooting = {}, - navigateToCallControlScreen = {}, - navigateToMicrophoneSettings = {}, - - activateDemoMode = {}, - reconnectFromSavedMac = {} - ) - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt deleted file mode 100644 index 06436561..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt +++ /dev/null @@ -1,613 +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 . -*/ - -package me.kavishdevar.librepods.presentation.screens - -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Build -import android.widget.Toast -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.clearText -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.lerp -import androidx.compose.ui.unit.sp -import androidx.core.net.toUri -import androidx.lifecycle.viewmodel.compose.viewModel -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import me.kavishdevar.librepods.BuildConfig -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.components.AppInfoCard -import me.kavishdevar.librepods.presentation.components.DeviceInfoCard -import me.kavishdevar.librepods.presentation.components.StyledBottomSheet -import me.kavishdevar.librepods.presentation.components.StyledButton -import me.kavishdevar.librepods.presentation.components.StyledIconButton -import me.kavishdevar.librepods.presentation.components.StyledInputField -import me.kavishdevar.librepods.presentation.components.StyledList -import me.kavishdevar.librepods.presentation.components.StyledListItem -import me.kavishdevar.librepods.presentation.components.StyledSlider -import me.kavishdevar.librepods.presentation.components.StyledToggle -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.theme.MaterialTypography -import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel -import me.kavishdevar.librepods.utils.XposedState -import java.util.concurrent.TimeUnit - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AppSettingsScreen( - viewModel: AppSettingsViewModel = viewModel(), - navigateToPurchase: () -> Unit, - navigateToTroubleshooting: () -> Unit, - navigateToOpenSourceLicenses: () -> Unit, - navigateToReleaseNotesScreen: () -> Unit -) { - val context = LocalContext.current - val scrollState = rememberScrollState() - val state by viewModel.uiState.collectAsState() - - val backdrop = rememberLayerBackdrop() - - val contactBottomSheet = remember { mutableStateOf(false) } - val subjectState = remember { TextFieldState() } - val descriptionState = remember { TextFieldState() } - val subjectFocusRequester = remember { FocusRequester() } - val descriptionFocusRequester = remember { FocusRequester() } - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = if (m3eEnabled) 16.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .layerBackdrop(backdrop) - .verticalScroll(scrollState) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - - val isDarkTheme = isSystemInDarkTheme() - - if (!state.isPremium && state.connectionSuccessful) { - StyledButton( - onClick = navigateToPurchase, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - maxScale = 0.05f, - surfaceColor = MaterialTheme.colorScheme.primary - ) { - Text( - stringResource(R.string.unlock_advanced_features), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimary - ) - } - Spacer(modifier = Modifier.height(16.dp)) - } - if (state.timeUntilFOSSPremiumExpiry > 0L) { - Box( - modifier = Modifier - .background(Color(0xFF32829B), RoundedCornerShape(28.dp)) - .clip(RoundedCornerShape(28.dp)) - .clickable { - val emailIntent = Intent(Intent.ACTION_SENDTO).apply { - data = "mailto:".toUri() - putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz")) - putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error") - putExtra( - Intent.EXTRA_TEXT, - "Please enter your GitHub username to restore your premium access:\n\nGitHub username: " - ) - } - context.startActivity(emailIntent) - } - ) { - Text( - text = stringResource( - R.string.play_foss_premium_banner, maxOf(1, TimeUnit.MILLISECONDS.toDays(state.timeUntilFOSSPremiumExpiry).toInt()) - ), - modifier = Modifier - .padding(16.dp), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - color = Color.White, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) - ) - } - } - - StyledToggle( - title = stringResource(R.string.appearance), - label = stringResource(R.string.use_material3e), - checked = state.m3eEnabled, - onCheckedChange = viewModel::setm3eEnabled, - enabled = state.isPremium - ) - - if (state.connectionSuccessful) { - 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 - ) - - StyledList(title = stringResource(R.string.popup_animations)) { - StyledToggle( - label = stringResource(R.string.show_bottom_sheet_popup), - description = stringResource(R.string.show_bottom_sheet_popup_description), - checked = state.showBottomSheetPopup, - onCheckedChange = viewModel::setShowBottomSheetPopup, - ) - - StyledToggle( - label = stringResource(R.string.show_island_popup), - description = stringResource(R.string.show_island_popup_description), - checked = state.showIslandPopup, - onCheckedChange = viewModel::setShowIslandPopup, - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - StyledList (title = stringResource(R.string.conversational_awareness)) { - StyledToggle( - label = stringResource(R.string.conversational_awareness_pause_music), - description = stringResource(R.string.conversational_awareness_pause_music_description), - checked = state.conversationalAwarenessPauseMusicEnabled, - onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled, - enabled = state.isPremium - ) - - StyledToggle( - label = stringResource(R.string.relative_conversational_awareness_volume), - description = stringResource(R.string.relative_conversational_awareness_volume_description), - checked = state.relativeConversationalAwarenessVolumeEnabled, - onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled, - 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)) -// -// StyledListItem( -// to = "", -// titleRes = stringResource(R.string.camera_control), -// name = stringResource(R.string.set_custom_camera_package), -// navController = navController, -// onClick = { -// if (state.isPremium) viewModel.setShowCameraDialog(true) -// }, -// independent = true, -// descriptionRes = stringResource(R.string.camera_control_app_description) -// ) -// } - - Spacer(modifier = Modifier.height(16.dp)) - if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) { - StyledToggle( - title = stringResource(R.string.ear_detection), - label = stringResource(R.string.disconnect_when_not_wearing), - description = stringResource(R.string.disconnect_when_not_wearing_description), - checked = state.disconnectWhenNotWearing, - onCheckedChange = viewModel::setDisconnectWhenNotWearing, - enabled = state.isPremium - ) - } - - StyledList(title = stringResource(R.string.takeover_airpods_state)) { - StyledToggle( - label = stringResource(R.string.takeover_disconnected), - description = stringResource(R.string.takeover_disconnected_desc), - checked = state.takeoverWhenDisconnected, - onCheckedChange = viewModel::setTakeoverWhenDisconnected, - enabled = state.isPremium - ) - StyledToggle( - label = stringResource(R.string.takeover_idle), - description = stringResource(R.string.takeover_idle_desc), - checked = state.takeoverWhenIdle, - onCheckedChange = viewModel::setTakeoverWhenIdle, - enabled = state.isPremium - ) - StyledToggle( - label = stringResource(R.string.takeover_music), - description = stringResource(R.string.takeover_music_desc), - checked = state.takeoverWhenMusic, - onCheckedChange = viewModel::setTakeoverWhenMusic, - enabled = state.isPremium - ) - - StyledToggle( - label = stringResource(R.string.takeover_call), - description = stringResource(R.string.takeover_call_desc), - checked = state.takeoverWhenCall, - onCheckedChange = viewModel::setTakeoverWhenCall, - enabled = state.isPremium - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - StyledList(title = stringResource(R.string.takeover_phone_state)) { - StyledToggle( - label = stringResource(R.string.takeover_ringing_call), - description = stringResource(R.string.takeover_ringing_call_desc), - checked = state.takeoverWhenRingingCall, - onCheckedChange = viewModel::setTakeoverWhenRingingCall, - enabled = state.isPremium - ) - StyledToggle( - label = stringResource(R.string.takeover_media_start), - description = stringResource(R.string.takeover_media_start_desc), - checked = state.takeoverWhenMediaStart, - onCheckedChange = viewModel::setTakeoverWhenMediaStart, - enabled = state.isPremium - ) - } - - StyledToggle( - title = stringResource(R.string.advanced_options), // shouldn't be here, but okay - 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, - enabled = state.isPremium - ) - Spacer(modifier = Modifier.height(16.dp)) - } else { - Box( - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(horizontal = 16.dp) - .padding(top = 16.dp, bottom = 2.dp) - ) { - Text( - text = stringResource(R.string.customizations_unavailable), - style = MaterialTypography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier - ) - } - } - - if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) { - val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth) - StyledToggle( - label = stringResource(R.string.act_as_an_apple_device) + " (${ - stringResource( - R.string.requires_xposed - ) - })", - description = stringResource(R.string.act_as_an_apple_device_description), - checked = state.vendorIdHook, - onCheckedChange = { enabled -> - Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show() - viewModel.setVendorIdHook(enabled) - } - ) - } - - if (!BuildConfig.PLAY_BUILD) { - Spacer(modifier = Modifier.height(16.dp)) - StyledList { - StyledListItem( - name = stringResource(R.string.troubleshooting), - onClick = navigateToTroubleshooting, - ) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - StyledList(title = stringResource(R.string.contact)) { - StyledListItem( - name = stringResource(R.string.email), - onClick = { contactBottomSheet.value = true }, - ) - - StyledListItem( - name = stringResource(R.string.discord), - onClick = { - val intent = - Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri()) - context.startActivity(intent) - }, - ) - - StyledListItem( - name = stringResource(R.string.github_issues), - onClick = { - val appVersion = - Uri.encode("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") - val device = Uri.encode("${Build.MANUFACTURER} ${Build.MODEL}") - val androidVersion = Uri.encode("${Build.ID} (${Build.DISPLAY})") - val appSource = Uri.encode( - when { - BuildConfig.PLAY_BUILD -> "Play" - else -> "GitHub" - } - ) - val url = "https://github.com/kavishdevar/librepods/issues/new" + - "?template=01-bug-report-android.yml" + - "&app-source=$appSource" + - "&app-version=$appVersion" + - "&device=$device" + - "&android-version=$androidVersion" - - val intent = Intent(Intent.ACTION_VIEW, url.toUri()) - context.startActivity(intent) - }, - ) - } - - Spacer(modifier = Modifier.height(20.dp)) - DeviceInfoCard() - Spacer(modifier = Modifier.height(16.dp)) - AppInfoCard(navigateToReleaseNotesScreen) - - Spacer(modifier = Modifier.height(16.dp)) - - StyledListItem( - name = stringResource(R.string.open_source_licenses), - onClick = navigateToOpenSourceLicenses, - ) - - Spacer(modifier = Modifier.height(bottomPadding)) - - if (state.showCameraDialog) { - AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = { - Text( - stringResource(R.string.set_custom_camera_package), - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Medium - ) - }, text = { - Column { - Text( - stringResource(R.string.enter_custom_camera_package), - fontFamily = FontFamily(Font(R.font.sf_pro)), - modifier = Modifier.padding(bottom = 8.dp) - ) - - OutlinedTextField( - value = state.cameraPackageValue, - onValueChange = { - viewModel.setCameraPackageValue(it) - viewModel.setCameraPackageError(null) - }, - modifier = Modifier.fillMaxWidth(), - isError = state.cameraPackageError != null, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Ascii, - capitalization = KeyboardCapitalization.None - ), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color( - 0xFF3C6DF5 - ), - unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray - ), - supportingText = { - if (state.cameraPackageError != null) { - Text( - state.cameraPackageError ?: "", - color = MaterialTheme.colorScheme.error - ) - } - }, - label = { Text(stringResource(R.string.custom_camera_package)) }) - } - }, confirmButton = { - val successText = stringResource(R.string.custom_camera_package_set_success) - TextButton( - onClick = { - viewModel.saveCameraPackage() - Toast.makeText(context, successText, Toast.LENGTH_SHORT).show() - }) { - Text( - "Save", - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Medium - ) - } - }, dismissButton = { - TextButton( - onClick = { viewModel.setShowCameraDialog(false) }) { - Text( - "Cancel", - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Medium - ) - } - }) - } - } - - StyledBottomSheet( - visible = contactBottomSheet.value, - onDismiss = { contactBottomSheet.value = false }, - backdrop = backdrop - ) { innerBackdrop, progress -> - val animatedPadding = lerp(16.dp, 2.dp, progress) - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = animatedPadding) - .padding(bottom = 16.dp), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - StyledIconButton( - icon = "\uDBC0\uDD84", - backdrop = innerBackdrop, - onClick = { contactBottomSheet.value = false } - ) - Text ( - text = stringResource(R.string.describe_your_issue), - style = TextStyle( - fontSize = 18.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - color = if (isSystemInDarkTheme()) Color.White else Color.Black - ) - ) - StyledIconButton( - icon = "\uDBC0\uDE1F", - backdrop = innerBackdrop, - surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF) else Color(0xFF0088FF), - iconTint = if (subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty()) Color.White else Color.Gray, - enabled = subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty(), - onClick = { - contactBottomSheet.value = false - val intent = Intent(Intent.ACTION_SENDTO).apply { - data = "mailto:".toUri() - putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz")) - putExtra(Intent.EXTRA_SUBJECT, "LibrePods: ${subjectState.text}") - putExtra( - Intent.EXTRA_TEXT, - "${descriptionState.text}" + - "\n\n----------" + - "\nPhone details:" + - "\nMANUFACTURER: ${Build.MANUFACTURER}" + - "\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" + - "\nDISPLAY_VERSION: ${Build.DISPLAY}" + - "\nID: ${Build.ID} (SDK ${Build.VERSION.SDK_INT_FULL})" + - "\nXposed enabled/active: ${XposedState.isAvailable}/${XposedState.bluetoothScopeEnabled}" + - "\n\nApp details:" + - "\nVERSION: ${BuildConfig.VERSION_NAME}" + - "\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" + - "\nFLAVOR: ${BuildConfig.FLAVOR}" + - "\nBUILD_TYPE: ${BuildConfig.BUILD_TYPE}" - ) - } - context.startActivity(intent) - subjectState.clearText() - descriptionState.clearText() - } - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - StyledInputField( - inputState = subjectState, - focusRequester = subjectFocusRequester, - placeholder = stringResource(R.string.subject), - forceApple = true - ) - - Spacer(modifier = Modifier.height(12.dp)) - - StyledInputField( - inputState = descriptionState, - focusRequester = descriptionFocusRequester, - placeholder = stringResource(R.string.describe_your_issue), - singleLine = false, - forceApple = true - ) - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/EqualizerScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/EqualizerScreen.kt deleted file mode 100644 index 408e6f57..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/EqualizerScreen.kt +++ /dev/null @@ -1,762 +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 . -*/ - -package me.kavishdevar.librepods.presentation.screens - -import androidx.compose.animation.Crossfade -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.visible -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.util.lerp -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import com.kyant.backdrop.drawBackdrop -import com.kyant.backdrop.effects.lens -import com.kyant.backdrop.highlight.Highlight -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.debounce -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.components.StyledButton -import me.kavishdevar.librepods.presentation.components.StyledList -import me.kavishdevar.librepods.presentation.components.StyledListItem -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel -import me.kavishdevar.librepods.presentation.viewmodel.demoState -import kotlin.math.abs -import kotlin.math.roundToInt -import kotlin.time.Duration.Companion.milliseconds - -@Composable -fun EqualizerRoute(viewModel: AirPodsViewModel) { - val state by viewModel.uiState.collectAsState() - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - Box ( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { - EqualizerScreen( - state = state, - topPadding = topPadding, - bottomPadding = bottomPadding, - setCustomEqEnabled = viewModel::setCustomEqEnabled, - setCustomEq = viewModel::setCustomEq - ) - } -} - -@OptIn(FlowPreview::class) -@Composable -fun EqualizerScreen( - state: AirPodsUiState, - topPadding: Dp = 16.dp, - bottomPadding: Dp = 16.dp, - setCustomEqEnabled: (Boolean) -> Unit, - setCustomEq: (Int, Int, Int) -> Unit -) { - val customEq = state.customEq - - val scrollState = rememberScrollState() - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .verticalScroll(scrollState), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - val height = 200.dp - val maxOffset = with(LocalDensity.current) { height.toPx() } / 2 - - val offsets = remember(state.customEq) { - listOf( - mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.low.toFloat() / 100)), - mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.mid.toFloat() / 100)), - mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.high.toFloat() / 100)) - ) - } - - LaunchedEffect(offsets) { - snapshotFlow { - Triple( - offsets[0].floatValue, - offsets[1].floatValue, - offsets[2].floatValue - ) - } - .debounce(100.milliseconds) // cool, should've been using this since the very beginning - .collect { (lowF, midF, highF) -> - val low = - 100 - ((lowF / (2 * maxOffset) + 0.5f) * 100).roundToInt() - val mid = - 100 - ((midF / (2 * maxOffset) + 0.5f) * 100).roundToInt() - val high = - 100 - ((highF / (2 * maxOffset) + 0.5f) * 100).roundToInt() - - setCustomEq(low, mid, high) - } - } - - Spacer(modifier = Modifier.height(topPadding)) - - val enabled = customEq.isEnabled() - - StyledList { - StyledListItem( - name = stringResource(R.string.recommended), - selected = !enabled, - onClick = { setCustomEqEnabled(false) } - ) - - StyledListItem( - name = stringResource(R.string.custom), - selected = enabled, - onClick = { setCustomEqEnabled(true) } - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - - Crossfade ( - customEq.isEnabled() - ) { visible -> - Column( - modifier = Modifier - .fillMaxWidth() - .visible(visible), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - - EqualizerCard( - lowOffset = offsets[0], - midOffset = offsets[1], - highOffset = offsets[2] - ) - - val resetButtonEnabled = remember { derivedStateOf { !offsets.all { it.floatValue == 0f } } } - - StyledButton( - onClick = { - offsets[0].floatValue = 0f - offsets[1].floatValue = 0f - offsets[2].floatValue = 0f - }, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - isInteractive = false, - enabled = resetButtonEnabled.value - ) { - Text( - text = stringResource(R.string.reset), - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - - Spacer(modifier = Modifier.height(bottomPadding)) - } -} - - -@Composable -fun EqualizerCard( - lowOffset: MutableState, - midOffset: MutableState, - highOffset: MutableState -) { - val height = 200.dp - val maxOffset = with(LocalDensity.current) { height.toPx() } / 2 - - Column( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(28.dp)) - ) { - val dashColor = if (isSystemInDarkTheme()) Color(0x80AAAAAA) else Color(0x809D9D9D) - - val backdrop = rememberLayerBackdrop() - Column( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(28.dp)) - ) { - Spacer(modifier = Modifier.height(42.dp)) - // Row( - // modifier = Modifier - // .fillMaxWidth() - // .padding(18.dp), - // verticalAlignment = Alignment.CenterVertically, - // horizontalArrangement = Arrangement.spacedBy(12.dp) - // ) { - // Box( - // modifier = Modifier - // .size(64.dp) - // .background(if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray, RoundedCornerShape(12.dp)) - // ) - // Column( - // modifier = Modifier - // .weight(1f), - // verticalArrangement = Arrangement.Center - // ) { - // Text( - // text = "Written into Changes", - // style = TextStyle( - // fontSize = 16.sp, - // fontFamily = FontFamily(Font(R.font.sf_pro)), - // fontWeight = FontWeight.Bold, - // color = if (isSystemInDarkTheme()) Color.White else Color.Black - // ) - // ) - // Spacer(modifier = Modifier.height(4.dp)) - // Text( - // text = "Avalon Emerson", - // style = TextStyle( - // fontSize = 14.sp, - // fontFamily = FontFamily(Font(R.font.sf_pro)), - // fontWeight = FontWeight.Normal, - // color = if (isSystemInDarkTheme()) Color.White else Color.Black - // ) - // ) - // } - // val paused = remember { mutableStateOf(false) } - // Box( - // modifier = Modifier - // .size(48.dp) - // .background(Color(0x600091FF), CircleShape) - // .clickable( - // interactionSource = remember { MutableInteractionSource() }, - // indication = null, - // ) { - // paused.value = !paused.value - // }, - // contentAlignment = Alignment.Center - // ) { - // Crossfade( - // targetState = paused.value, - // label = "media_icon" - // ) { p -> - // Text( - // text = if (p) "􀊄" else "􀊆", - // style = TextStyle( - // fontSize = 24.sp, - // fontFamily = FontFamily(Font(R.font.sf_pro)), - // fontWeight = FontWeight.Normal, - // color = Color(0xFF0091FF), - // textAlign = TextAlign.Center - // ) - // ) - // } - // } - // } - // - // HorizontalDivider( - // thickness = 1.dp, - // color = Color(0x40888888), - // modifier = Modifier - // .padding(horizontal = 20.dp) - // .padding(bottom = 16.dp) - // ) - - Box( - modifier = Modifier.fillMaxWidth() - ) { - fun colorFromY(y: Float): Color { - val f = ((y + maxOffset) / (2f * maxOffset)).coerceIn(0f, 1f) - val stops = listOf( - 0.0f to Color(0xFFFFA300), - 0.25f to Color(0xFFFCE600), - 0.5f to Color(0xFF00FAAF), - 0.75f to Color(0xFF00FAFF), - 1.0f to Color(0xFF00B5FF) - ) - val (start, end) = stops.zipWithNext() - .first { f <= it.second.first } - val c = (f - start.first) / (end.first - start.first) - return lerp(start.second, end.second, c) - } - - fun pathBrush( - startY: Float, - endY: Float, - ): Brush { - val stops = (0..20).map { i -> - val t = i / 20f - val y = lerp(startY, endY, t) - t to colorFromY(y) - } - - return Brush.linearGradient( - colorStops = stops.toTypedArray() - ) - } - - Column( - modifier = Modifier - .fillMaxWidth() - .layerBackdrop(backdrop) - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(height) - .padding(horizontal = 20.dp) - ) { - Row( - modifier = Modifier - .fillMaxSize() - ) { - val dashCount = (height / 10.dp).toInt() - repeat(3) { - Box( - modifier = Modifier - .fillMaxSize() - .weight(1f), - contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier - .fillMaxHeight(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - for (i in 1..(dashCount)) { - val t = i.toFloat() / dashCount - val centerDistance = abs(0.5f - t) - val alpha = 1f - (centerDistance * 2f) - Box( - modifier = Modifier - .height(9.dp) - .width(0.75.dp) - .background( - dashColor.copy(alpha), - RoundedCornerShape(28.dp) - ) - ) - } - } - } - } - } - - val backgroundColor = MaterialTheme.colorScheme.surface - - Canvas( - modifier = Modifier - .fillMaxSize() - ) { - val canvasWidth = size.width - - drawLine( - color = backgroundColor, - start = Offset( - x = 0f, - y = lowOffset.value + maxOffset - ), - end = Offset( - x = 1 / 6f * canvasWidth, - y = lowOffset.value + maxOffset - ), - strokeWidth = 10f - ) - drawLine( - color = colorFromY(lowOffset.value), - start = Offset( - x = 0f, - y = lowOffset.value + maxOffset - ), - end = Offset( - x = 1 / 6f * canvasWidth, - y = lowOffset.value + maxOffset - ), - strokeWidth = 8f - ) - - val lowToMidPath = Path() - lowToMidPath.moveTo( - x = 1 / 6f * canvasWidth, - y = lowOffset.value + maxOffset - ) - lowToMidPath.cubicTo( - x1 = canvasWidth * 1 / 6f + 108.dp.value, - y1 = lowOffset.value + maxOffset, - x2 = canvasWidth * 0.5f - 108.dp.value, - y2 = midOffset.value + maxOffset, - x3 = canvasWidth * 0.5f, - y3 = midOffset.value + maxOffset - ) - drawPath( - color = backgroundColor, - path = lowToMidPath, - style = Stroke(width = 10f) - ) - drawPath( - brush = pathBrush( - lowOffset.value, - midOffset.value - ), - path = lowToMidPath, - style = Stroke(width = 8f) - ) - - val midToHighPath = Path() - midToHighPath.moveTo( - x = 0.5f * canvasWidth, - y = midOffset.value + maxOffset - ) - midToHighPath.cubicTo( - x1 = canvasWidth * 0.5f + 108.dp.value, - y1 = midOffset.value + maxOffset, - x2 = canvasWidth * 5 / 6f - 108.dp.value, - y2 = highOffset.value + maxOffset, - x3 = canvasWidth * 5 / 6f, - y3 = highOffset.value + maxOffset - ) - drawPath( - color = backgroundColor, - path = midToHighPath, - style = Stroke(width = 10f) - ) - drawPath( - brush = pathBrush( - midOffset.value, - highOffset.value - ), - path = midToHighPath, - style = Stroke(width = 8f) - ) - drawLine( - color = backgroundColor, - start = Offset( - x = 5 / 6f * canvasWidth, - y = highOffset.value + maxOffset - ), - end = Offset( - x = 1f * canvasWidth, - y = highOffset.value + maxOffset - ), - strokeWidth = 10f - ) - drawLine( - color = colorFromY(highOffset.value), - start = Offset( - x = 5 / 6f * canvasWidth, - y = highOffset.value + maxOffset - ), - end = Offset( - x = 1f * canvasWidth, - y = highOffset.value + maxOffset - ), - strokeWidth = 8f - ) - } - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp, horizontal = 20.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier.weight(1f) - ) { - Text( - text = "Low".uppercase(), - style = TextStyle( - fontSize = 14.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Bold, - color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy( - 0.2f - ), - textAlign = TextAlign.Center - ), - modifier = Modifier.fillMaxWidth() - ) - } - Box( - modifier = Modifier.weight(1f) - ) { - Text( - text = "Mid".uppercase(), - style = TextStyle( - fontSize = 14.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Bold, - color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy( - 0.2f - ), - textAlign = TextAlign.Center - ), - modifier = Modifier.fillMaxWidth() - ) - } - Box( - modifier = Modifier.weight(1f) - ) { - Text( - text = "High".uppercase(), - style = TextStyle( - fontSize = 14.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Bold, - color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy( - 0.2f - ), - textAlign = TextAlign.Center - ), - modifier = Modifier.fillMaxWidth() - ) - } - } - Spacer(modifier = Modifier.height(24.dp)) - } - Row( - modifier = Modifier - .fillMaxWidth() - .height(height) - .padding(horizontal = 20.dp), - - verticalAlignment = Alignment.CenterVertically - ) { - for (i in 0..2) { - Row( - modifier = Modifier - .weight(1f), - horizontalArrangement = Arrangement.Center - ) { - val pressed = remember { mutableStateOf(false) } - Box( - modifier = Modifier - .offset { - IntOffset( - x = 0, - y = when (i) { 0 -> lowOffset.value; 1 -> midOffset.value; 2-> highOffset.value else -> 0f}.roundToInt() - ) - }, - contentAlignment = Alignment.Center - ) { - Crossfade( - pressed.value - ) { - Box( - modifier = Modifier - .size(96.dp) - .then( - if (it) { - Modifier.drawBackdrop( - backdrop = backdrop, - shape = { CircleShape }, - highlight = { - Highlight.Ambient - }, - onDrawSurface = { - drawCircle( - color = Color.White.copy( - 0.2f - ), - radius = size.height - ) - drawCircle( - color = colorFromY( - when (i) { - 0 -> lowOffset.value; 1 -> midOffset.value; 2 -> highOffset.value - else -> 0f - } - ), - style = Stroke(2.dp.value), - radius = size.height / 2 - ) - }, - effects = { - lens( - refractionHeight = 32f.dp.value, - refractionAmount = size.height - ) - } - ) - } else Modifier - ) - ) - } - Box( - modifier = Modifier - .size(18.dp) - .background( - colorFromY( - when (i) { - 0 -> lowOffset.value; 1 -> midOffset.value; 2 -> highOffset.value - else -> 0f - } - ), - CircleShape - ) - .border( - 2.5.dp, - MaterialTheme.colorScheme.surfaceContainer, - CircleShape - ) - .draggable( - orientation = Orientation.Vertical, - state = rememberDraggableState { delta -> - when (i) { - 0 -> { - lowOffset.value = - (lowOffset.value + delta).coerceIn( - -maxOffset, - maxOffset - ) - } - - 1 -> { - midOffset.value = - (midOffset.value + delta).coerceIn( - -maxOffset, - maxOffset - ) - } - - 2 -> { - highOffset.value = - (highOffset.value + delta).coerceIn( - -maxOffset, - maxOffset - ) - } - } - }, - onDragStarted = { - pressed.value = true - }, - onDragStopped = { - pressed.value = false - } - ) - ) - } - } - } - } - } - } - } -} - -@Preview(name = "Apple") -@Composable -fun EqualizerScreenPreviewApple() { - LibrePodsTheme( - m3eEnabled = false - ) { - Box ( - modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer) - ) { - EqualizerScreen( - state = demoState, - setCustomEqEnabled = { }, - setCustomEq = {_, _, _ -> } - ) - } - } -} - -@Preview(name = "Material") -@Composable -fun EqualizerScreenPreviewMaterial() { - LibrePodsTheme( - m3eEnabled = true - ) { - val state = remember { mutableStateOf(demoState) } - Box ( - modifier = Modifier - .wrapContentHeight() - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { - EqualizerScreen( - state = state.value, - setCustomEqEnabled = { state.value = state.value.copy(customEq = state.value.customEq.copy(state = if (it) 2 else 1)) }, - setCustomEq = {low, mid, high -> state.value = state.value.copy(customEq = state.value.customEq.copy(low = low, mid = mid, high = high))} - ) - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/OpenSourceLicensesScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/OpenSourceLicensesScreen.kt deleted file mode 100644 index d8663111..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/OpenSourceLicensesScreen.kt +++ /dev/null @@ -1,87 +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 . -*/ - -package me.kavishdevar.librepods.presentation.screens - -import android.annotation.SuppressLint -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer -import com.mikepenz.aboutlibraries.ui.compose.produceLibraries -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import kotlinx.coroutines.Job -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import kotlin.io.encoding.ExperimentalEncodingApi - -private var debounceJob: Job? = null - -@SuppressLint("DefaultLocale") -@ExperimentalHazeMaterialsApi -@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) -@Composable -fun OpenSourceLicensesScreen() { - val backdrop = rememberLayerBackdrop() - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - Column( - modifier = Modifier - .fillMaxSize() - .layerBackdrop(backdrop) - .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - val context = LocalContext.current - val libraries by produceLibraries { - context.resources.openRawResource(R.raw.aboutlibraries) - .bufferedReader() - .use { it.readText() } - } - LibrariesContainer( - libraries = libraries, - modifier = Modifier - .padding(0.dp) - .fillMaxSize() - ) - Spacer(modifier = Modifier.height(bottomPadding)) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt deleted file mode 100644 index 8c99178d..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt +++ /dev/null @@ -1,786 +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 . -*/ - -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.content.pm.PackageManager -import android.widget.Toast -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.core.content.edit -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers -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.ATTCCCDHandles -import me.kavishdevar.librepods.bluetooth.ATTHandles -import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager -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.CustomEq -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 = "AirPods", - - val isLocallyConnected: Boolean = false, - - val instance: AirPodsInstance? = null, - val capabilities: Set = emptySet(), - - val controlStates: Map = emptyMap(), - val offListeningMode: Boolean = true, - - val battery: List = emptyList(), - val ancMode: Int = 3, - - val modelName: String = "", - val actualModel: String = "", - val serialNumbers: List = 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, - - val dynamicEndOfCharge: Boolean = false, - - val connectionSuccessful: Boolean = false, - val timeUntilFOSSPremiumExpiry: Long = 0L, - - val customEq: CustomEq = CustomEq(1, 50, 50, 50) // disabled -) - -val demoInstance = AirPodsInstance( - name = "AirPods Pro", - model = AirPodsModels.getModelByModelNumber("A3064")!!, - actualModelNumber = "A3064", - serialNumber = "JXF9Q94A40", - leftSerialNumber = "L-DEMO", - rightSerialNumber = "R-DEMO", - version1 = "90.3388000000000000.1786", - version2 = "90.3388000000000000.1786", - version3 = "9441861", -) - -val demoState = AirPodsUiState( - deviceName = demoInstance.name, - - isLocallyConnected = true, - - capabilities = demoInstance.model.capabilities, - - battery = listOf( - Battery(BatteryComponent.LEFT, 80, BatteryStatus.OPTIMIZED_CHARGING), - Battery(BatteryComponent.RIGHT, 18, BatteryStatus.CHARGING), - Battery(BatteryComponent.CASE, 76, BatteryStatus.NOT_CHARGING) - ), - - ancMode = 3, - offListeningMode = false, - - modelName = demoInstance.model.displayName, - actualModel = demoInstance.actualModelNumber, - serialNumbers = listOf( - demoInstance.serialNumber?: "", - demoInstance.leftSerialNumber?: "", - demoInstance.rightSerialNumber?: "" - ), - - version1 = demoInstance.version1?: "", - version2 = demoInstance.version2?: "", - version3 = demoInstance.version3?: "", - - headTrackingActive = true, - headGesturesEnabled = true, - - automaticEarDetectionEnabled = true, - automaticConnectionEnabled = true, - - leftAction = StemAction.CYCLE_NOISE_CONTROL_MODES, - rightAction = StemAction.DIGITAL_ASSISTANT, - - loudSoundReductionEnabled = true, - - isPremium = true, - vendorIdHook = true, - - dynamicEndOfCharge = true, - - connectionSuccessful = true, - - customEq = CustomEq(state = 2, low = 65, mid = 50, high = 70), - - controlStates = mapOf( - ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG to byteArrayOf(0x01), - ControlCommandIdentifiers.STEM_CONFIG to byteArrayOf(0x00), - ControlCommandIdentifiers.CLICK_HOLD_INTERVAL to byteArrayOf(0x00), - ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL to byteArrayOf(0x00), - ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL to byteArrayOf(0x00), - ControlCommandIdentifiers.VOLUME_SWIPE_MODE to byteArrayOf(0x01), - ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG to byteArrayOf(0x00, 0x03), - ControlCommandIdentifiers.CHIME_VOLUME to byteArrayOf(0x46, 0x50), - ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG to byteArrayOf(0x01), - ControlCommandIdentifiers.HEARING_AID to byteArrayOf(0x01, 0x02), - ControlCommandIdentifiers.HPS_GAIN_SWIPE to byteArrayOf(0x01), - ControlCommandIdentifiers.HEARING_ASSIST_CONFIG to byteArrayOf(0x02), - ControlCommandIdentifiers.HRM_STATE to byteArrayOf(0x01), - ControlCommandIdentifiers.AUTO_ANC_STRENGTH to byteArrayOf(0x45), - ControlCommandIdentifiers.ONE_BUD_ANC_MODE to byteArrayOf(0x01), - ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG to byteArrayOf(0x01), - ControlCommandIdentifiers.PPE_TOGGLE_CONFIG to byteArrayOf(0x01), - ControlCommandIdentifiers.PPE_CAP_LEVEL_CONFIG to byteArrayOf(0x52), - ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE to byteArrayOf(0x01), - ControlCommandIdentifiers.LISTENING_MODE to byteArrayOf(0x04) - ) -) - -class AirPodsViewModel( - -) : ViewModel() { - private lateinit var sharedPreferences: SharedPreferences - private lateinit var appContext: Context - private lateinit var service: AirPodsService - private lateinit var controlRepo: ControlCommandRepository - - var isReady by mutableStateOf(false) - private set - - fun init(service: AirPodsService, controlRepo: ControlCommandRepository, sharedPreferences: SharedPreferences, appContext: Context) { - this.service = service - this.controlRepo = controlRepo - this.sharedPreferences = sharedPreferences - this.appContext = appContext - - observeBroadcasts() - loadName() - loadInstance() - loadSharedPreferences() - observeAACP() - loadCurrentStatus() - loadEq() - loadATT() - observeATT() - observeSharedPreferences() - observeBilling() - if (isDemoMode) activateDemoMode() - isReady = true - } - - private val _uiState = MutableStateFlow(AirPodsUiState()) - - val uiState: StateFlow = _uiState - - private var isDemoMode = false - - private val listeners = - mutableMapOf() - - 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 = _cameraAction -// -// fun setCameraAction(action: AACPManager.Companion.StemPressType?) { -// sharedPreferences.edit { -// if (action == null) remove("camera_action") -// else putString("camera_action", action.name) -// } -// _cameraAction.value = action -// } - - fun setCustomEq(low: Int, mid: Int, high: Int) { - require(low in 0..100) - require(mid in 0..100) - require(high in 0..100) - val updatedEq = _uiState.value.customEq.copy(low = low, mid = mid, high = high) - service.aacpManager.sendCustomEqPacket(updatedEq) - _uiState.update { - it.copy( - customEq = updatedEq - ) - } - } - - fun setCustomEqEnabled(enabled: Boolean) { - service.aacpManager.sendCustomEqPacket(_uiState.value.customEq.copy(state = if (enabled) 2 else 1)) - _uiState.update { - it.copy( - customEq = it.customEq.copy(state = if (enabled) 2 else 1) - ) - } - } - - override fun onCleared() { - listeners.forEach { (id, listener) -> - controlRepo.remove(id, listener) - } - service.aacpManager.customEqCallback = null - appContext.unregisterReceiver(broadcastReceiver) - } - - private fun loadName() { - val name = sharedPreferences.getString("name", "AirPods Pro")!! - _uiState.update { it.copy(deviceName = name) } - } - - private fun observeBilling() { - if (isDemoMode) return - viewModelScope.launch { - BillingManager.provider.isPremium.collect { premium -> - if (premium) { - sharedPreferences.edit { - remove("premium_expiry_time") - if (BuildConfig.PLAY_BUILD) remove("foss_upgraded") - } - _uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) } - } else { - if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) { - setControlCommandBoolean( - ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, - false - ) - setHeadGesturesEnabled(false) - _uiState.update { it.copy(isPremium = false) } - } - } - } - } - } - - private fun observeSharedPreferences() { - val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - when (key) { - "name" -> loadName() - "off_listening_mode", "automatic_ear_detection", "automatic_connection_ctrl_cmd", - "head_gestures", "left_long_press_action", "right_long_press_action", - "dynamic_end_of_charge", "foss_upgraded", "premium_expiry_time" -> loadSharedPreferences() - } - } - sharedPreferences.registerOnSharedPreferenceChangeListener(listener) - } - - private fun observeBroadcasts() { - broadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - val action = intent?.action ?: return - if (!isDemoMode) when (action) { - AirPodsNotifications.AIRPODS_L2CAP_CONNECTED -> { - _uiState.update { - it.copy(isLocallyConnected = true) - } - } - - AirPodsNotifications.AIRPODS_DISCONNECTED -> { - _uiState.update { - it.copy(isLocallyConnected = false) - } - } - - AirPodsNotifications.BATTERY_DATA -> { - _uiState.update { - it.copy(battery = service.getBattery()) - } - } - - AirPodsNotifications.EQ_DATA -> { - val data = intent.getFloatArrayExtra("eqData") ?: floatArrayOf() - - _uiState.update { - it.copy(eqData = data) - } - } - - AirPodsNotifications.AIRPODS_INFORMATION_UPDATED -> { - loadInstance() - } - } - } - } - - val filter = IntentFilter().apply { - addAction(AirPodsNotifications.AIRPODS_CONNECTED) - addAction(AirPodsNotifications.AIRPODS_DISCONNECTED) - addAction(AirPodsNotifications.BATTERY_DATA) - addAction(AirPodsNotifications.EQ_DATA) - addAction(AirPodsNotifications.AIRPODS_INFORMATION_UPDATED) - } - - appContext.registerReceiver( - broadcastReceiver, filter, Context.RECEIVER_NOT_EXPORTED - ) - } - - fun setControlCommandValue( - identifier: ControlCommandIdentifiers, value: ByteArray - ) { - if (!isDemoMode) controlRepo.setValue(identifier, value) - _uiState.update { - it.copy( - controlStates = it.controlStates + (identifier to value) - ) - } - } - - fun setControlCommandBoolean( - identifier: ControlCommandIdentifiers, enabled: Boolean - ) { - setControlCommandValue( - identifier, if (enabled) byteArrayOf(0x01) else byteArrayOf(0x02) - ) - } - - fun setControlCommandInt( - identifier: ControlCommandIdentifiers, value: Int - ) { - setControlCommandValue(identifier, byteArrayOf(value.toByte())) - } - - fun setControlCommandByte( - identifier: ControlCommandIdentifiers, value: Byte - ) { - setControlCommandValue(identifier, byteArrayOf(value)) - } - - fun observeControl(identifier: ControlCommandIdentifiers) { - val listener = controlRepo.observe(identifier) { value -> - _uiState.update { state -> - val current = state.controlStates[identifier] - if (current?.contentEquals(value) == true) return@update state - - if (identifier == ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE) { - state.copy( - dynamicEndOfCharge = value[0] == 0x01.toByte(), - controlStates = state.controlStates + (identifier to value) - ) - } else { - state.copy( - controlStates = state.controlStates + (identifier to value) - ) - } - } - } - - listeners[identifier] = listener - } - - // I'm lazy, sorry. - fun observeAACP() { - 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, - ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE - ) - for (identifier in identifiersList) { - observeControl(identifier) - } - service.aacpManager.customEqCallback = { customEq -> - _uiState.update { it.copy(customEq = customEq) } - } - } - - fun loadCurrentStatus() { - if (isDemoMode) return - service.let { service -> - _uiState.update { - it.copy( - isLocallyConnected = BluetoothConnectionManager.aacpSocket?.isConnected == true, - battery = service.getBattery(), - ancMode = controlRepo.getValue(ControlCommandIdentifiers.LISTENING_MODE)?.get(0)?.toInt() ?: 1, - controlStates = controlRepo.getMap() - ) - } - } - } - - 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) - val dynamicEndOfCharge = sharedPreferences.getBoolean("dynamic_end_of_charge", false) - - val connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false) - - _uiState.update { - it.copy( - offListeningMode = offListeningModeEnabled, - automaticEarDetectionEnabled = automaticEarDetectionEnabled, - automaticConnectionEnabled = automaticConnectionEnabled, - headGesturesEnabled = headGesturesEnabled, - leftAction = leftAction, - rightAction = rightAction, - vendorIdHook = vendorIdHook, - dynamicEndOfCharge = dynamicEndOfCharge, - connectionSuccessful = connectionSuccessful, - ) - } - - // faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase - if (BuildConfig.PLAY_BUILD) { - val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false) - val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L) - val now = System.currentTimeMillis() - - when { - // existing temporary premium - expiryTime > 0L -> { - if (expiryTime <= now) { - sharedPreferences.edit { - remove("premium_expiry_time") - remove("foss_upgraded") - } - - _uiState.update { - it.copy( - timeUntilFOSSPremiumExpiry = 0L, - isPremium = false - ) - } - } else { - _uiState.update { - it.copy( - timeUntilFOSSPremiumExpiry = expiryTime - now, - isPremium = true - ) - } - } - } - - // First migration from accidental FOSS Play build - fossUpgraded && !_uiState.value.isPremium -> { - val newExpiry = now + 28L * 24 * 60 * 60 * 1000 - - sharedPreferences.edit { - putLong("premium_expiry_time", newExpiry) - } - - _uiState.update { - it.copy( - timeUntilFOSSPremiumExpiry = newExpiry - now, - isPremium = true - ) - } - } - } - } - } - - fun setOffListeningMode(enabled: Boolean) { - sharedPreferences.edit { putBoolean("off_listening_mode", enabled) } - setControlCommandBoolean(ControlCommandIdentifiers.ALLOW_OFF_OPTION, enabled) - _uiState.update { - it.copy(offListeningMode = enabled) - } - } - - fun setHeadGesturesEnabled(enabled: Boolean) { - sharedPreferences.edit { putBoolean("head_gestures", enabled) } - _uiState.update { - it.copy(headGesturesEnabled = enabled) - } - } - - fun setDynamicEndOfCharge(enabled: Boolean) { - service.aacpManager.sendControlCommand(ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE.value, enabled) - sharedPreferences.edit { putBoolean("dynamic_end_of_charge", enabled) } - _uiState.update { - it.copy(dynamicEndOfCharge = enabled) - } - } - - private fun loadEq() { - _uiState.update { - it.copy( - customEq = service.aacpManager.customEq - ) - } - } - - 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) { - when (handle) { - // ideally should be using a different viewmodel for ATT based things because there are a lot of values, and I am not going to add all to this state, but there's loudsoundreduction. - ATTHandles.LOUD_SOUND_REDUCTION -> { - _uiState.value = _uiState.value.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) - } - ATTHandles.HEARING_AID -> { - _uiState.value = _uiState.value.copy(hearingAidData = value) - } - ATTHandles.TRANSPARENCY -> { - _uiState.value = _uiState.value.copy(transparencyData = value) - } - } - viewModelScope.launch(Dispatchers.IO) { - try { - service.attManager.writeCharacteristic(handle, value) - } catch (e: Exception) { - e.printStackTrace() - } - } - } - - fun loadATT() { - val loudSoundReduction = service.attManager.getCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) ?: byteArrayOf() - val loudSoundReductionEnabled = if (loudSoundReduction.isNotEmpty()) { - loudSoundReduction[0].toInt() == 1 - } else false - val hearingAidData = service.attManager.getCharacteristic(ATTHandles.HEARING_AID) ?: byteArrayOf() - val transparencyData = service.attManager.getCharacteristic(ATTHandles.TRANSPARENCY) ?: byteArrayOf() - _uiState.update { - it.copy( - loudSoundReductionEnabled = loudSoundReductionEnabled, - transparencyData = transparencyData, - hearingAidData = hearingAidData - ) - } - } - - fun observeATT() { - viewModelScope.launch(Dispatchers.IO) { - service.attManager.enableNotification(ATTCCCDHandles.HEARING_AID) - service.attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY) - } - service.attManager.setOnNotificationReceived { handle, value -> - when (handle) { - ATTHandles.LOUD_SOUND_REDUCTION.value.toByte() -> { - val loudSoundReductionEnabled = if (value.isNotEmpty()) { - value[0].toInt() == 1 - } else false - _uiState.update { - it.copy(loudSoundReductionEnabled = loudSoundReductionEnabled) - } - } - ATTHandles.HEARING_AID.value.toByte() -> { - _uiState.update { - it.copy(hearingAidData = value) - } - } - ATTHandles.TRANSPARENCY.value.toByte() -> { - _uiState.update { - it.copy(transparencyData = value) - } - } - } - } - } - - fun setAutomaticEarDetectionEnabled(enabled: Boolean) { - sharedPreferences.edit { putBoolean("automatic_ear_detection", enabled) } - setControlCommandBoolean(ControlCommandIdentifiers.EAR_DETECTION_CONFIG, enabled) - _uiState.update { - it.copy( - automaticEarDetectionEnabled = enabled - ) - } - } - - fun setAutomaticConnectionEnabled(enabled: Boolean) { - sharedPreferences.edit { putBoolean("automatic_connection_ctrl_cmd", enabled) } - setControlCommandBoolean(ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG, enabled) - _uiState.update { - it.copy( - automaticConnectionEnabled = enabled - ) - } - } - - fun activateDemoMode() { - isDemoMode = true - _uiState.update {demoState} - } - - fun sendPhoneMediaEQ(eq: FloatArray, phoneByte: Byte, mediaByte: Byte) { - service.aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte) - } - - fun setLongPressAction(side: String, action: StemAction) { - val prefKey = if (side.lowercase() == "left") "left_long_press_action" else "right_long_press_action" - sharedPreferences.edit { putString(prefKey, action.name) } - _uiState.update { - if (side.lowercase() == "left") it.copy(leftAction = action) else it.copy(rightAction = action) - } - } - - private fun countEnabledModes(byteValue: Int): Int { - var count = 0 - if ((byteValue and 0x01) != 0) count++ - if ((byteValue and 0x02) != 0) count++ - if ((byteValue and 0x04) != 0) count++ - if ((byteValue and 0x08) != 0) count++ - return count - } - - fun toggleListeningMode(modeBit: Int) { - val currentByte = uiState.value.controlStates[ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0 - val newValue = if ((currentByte and modeBit) != 0) { - val temp = currentByte and modeBit.inv() - if (countEnabledModes(temp) >= 2) temp else currentByte - } else { - currentByte or modeBit - } - setControlCommandByte(ControlCommandIdentifiers.LISTENING_MODE_CONFIGS, newValue.toByte()) - sharedPreferences.edit { putInt("long_press_byte", newValue) } - } - - fun disconnect() { - if (isDemoMode) { - isDemoMode = false - _uiState.update { - it.copy(isLocallyConnected = false) - } - } else { - service.disconnectAirPods() - if (appContext.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) { - Toast.makeText( - appContext, "App has disconnected, disconnect from Android Settings.", - Toast.LENGTH_LONG - ).show() - } - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt deleted file mode 100644 index 2c1716c3..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt +++ /dev/null @@ -1,260 +0,0 @@ -package me.kavishdevar.librepods.presentation.viewmodel - -import android.app.Application -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import me.kavishdevar.librepods.BuildConfig -import me.kavishdevar.librepods.billing.BillingManager -import me.kavishdevar.librepods.data.XposedRemotePrefProvider -import kotlin.math.roundToInt - -data class AppSettingsUiState( - val showPhoneBatteryInWidget: Boolean = false, - val conversationalAwarenessPauseMusicEnabled: Boolean = false, - val relativeConversationalAwarenessVolumeEnabled: Boolean = true, - val disconnectWhenNotWearing: Boolean = false, - val takeoverWhenDisconnected: Boolean = false, - val takeoverWhenIdle: Boolean = false, - val takeoverWhenMusic: Boolean = false, - val takeoverWhenCall: Boolean = false, - val takeoverWhenRingingCall: Boolean = false, - val takeoverWhenMediaStart: Boolean = false, - val useAlternateHeadTrackingPackets: Boolean = true, - val conversationalAwarenessVolume: Float = 43f, - val showCameraDialog: Boolean = false, - val cameraPackageValue: String = "", - val cameraPackageError: String? = null, - val vendorIdHook: Boolean = false, - val isPremium: Boolean = false, - val connectionSuccessful: Boolean = false, - val showBottomSheetPopup: Boolean = true, - val showIslandPopup: Boolean = true, - val timeUntilFOSSPremiumExpiry: Long = 0L, - val m3eEnabled: Boolean = false -) - -class AppSettingsViewModel(application: Application) : AndroidViewModel(application) { - private val sharedPreferences = application.getSharedPreferences("settings", Context.MODE_PRIVATE) - - private val _uiState = MutableStateFlow(AppSettingsUiState()) - val uiState = _uiState.asStateFlow() - - private val xposedRemotePref = XposedRemotePrefProvider.create() - - val sharedPrefListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPref, key -> - if (key == "connection_successful") { - _uiState.update { it.copy(connectionSuccessful = sharedPref.getBoolean(key, false)) } - } - } - - - init { - loadSettings() - observeBilling() - sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPrefListener) - } - - override fun onCleared() { - sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPrefListener) - } - - private fun observeBilling() { - viewModelScope.launch { - BillingManager.provider.isPremium.collect { premium -> - if (premium) { - sharedPreferences.edit { - remove("premium_expiry_time") - if (BuildConfig.PLAY_BUILD) remove("foss_upgraded") - } - _uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) } - } else { - // No billing premium, only update if no temporary premium is active - if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) { - _uiState.update { it.copy(isPremium = false) } - } - } - } - } - } - - private fun loadSettings() { - // faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase - - val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false) - val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L) - val now = System.currentTimeMillis() - - when { - // existing temporary premium - expiryTime > 0L -> { - if (expiryTime <= now) { - sharedPreferences.edit { - remove("premium_expiry_time") - remove("foss_upgraded") - } - - _uiState.update { - it.copy( - timeUntilFOSSPremiumExpiry = 0L, - isPremium = false - ) - } - } else { - _uiState.update { - it.copy( - timeUntilFOSSPremiumExpiry = expiryTime - now, - isPremium = true - ) - } - } - } - - // First migration from accidental FOSS Play build - fossUpgraded && !_uiState.value.isPremium && BuildConfig.PLAY_BUILD -> { - val newExpiry = now + 28L * 24 * 60 * 60 * 1000 - - sharedPreferences.edit { - putLong("premium_expiry_time", newExpiry) - } - - _uiState.update { - it.copy( - timeUntilFOSSPremiumExpiry = newExpiry - now, - isPremium = true - ) - } - } - } - - _uiState.update { currentState -> - currentState.copy( - showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false), - conversationalAwarenessPauseMusicEnabled = sharedPreferences.getBoolean("conversational_awareness_pause_music", false), - relativeConversationalAwarenessVolumeEnabled = sharedPreferences.getBoolean("relative_conversational_awareness_volume", true), - disconnectWhenNotWearing = sharedPreferences.getBoolean("disconnect_when_not_wearing", false), - takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", false), - takeoverWhenIdle = sharedPreferences.getBoolean("takeover_when_idle", false), - takeoverWhenMusic = sharedPreferences.getBoolean("takeover_when_music", false), - takeoverWhenCall = sharedPreferences.getBoolean("takeover_when_call", false), - takeoverWhenRingingCall = sharedPreferences.getBoolean("takeover_when_ringing_call", false), - takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", false), - useAlternateHeadTrackingPackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true), - conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(), - cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: "", - vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false), - connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false), - showBottomSheetPopup = sharedPreferences.getBoolean("show_bottom_sheet_popup", true), - showIslandPopup = sharedPreferences.getBoolean("show_island_popup", true), - m3eEnabled = sharedPreferences.getBoolean("m3e_enabled", true) - ) - } - } - - 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) { - xposedRemotePref.putBoolean("vendor_id_hook", enabled) - _uiState.update { it.copy(vendorIdHook = enabled) } - } - - fun setShowBottomSheetPopup(enabled: Boolean) { - sharedPreferences.edit { putBoolean("show_bottom_sheet_popup", enabled) } - _uiState.update { it.copy(showBottomSheetPopup = enabled) } - } - - fun setShowIslandPopup(enabled: Boolean) { - sharedPreferences.edit { putBoolean("show_island_popup", enabled) } - _uiState.update { it.copy(showIslandPopup = enabled) } - } - - fun setm3eEnabled(enabled: Boolean) { - sharedPreferences.edit { putBoolean("m3e_enabled", enabled) } - _uiState.update { it.copy(m3eEnabled = enabled) } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/widgets/NoiseControlWidget.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/widgets/NoiseControlWidget.kt deleted file mode 100644 index 6253ccdf..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/widgets/NoiseControlWidget.kt +++ /dev/null @@ -1,99 +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 . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.presentation.widgets - -import android.app.PendingIntent -import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider -import android.content.Context -import android.content.Intent -import android.util.Log -import android.widget.RemoteViews -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager -import me.kavishdevar.librepods.services.ServiceManager -import kotlin.io.encoding.ExperimentalEncodingApi - -class NoiseControlWidget : AppWidgetProvider() { - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray - ) { - val views = RemoteViews(context.packageName, R.layout.noise_control_widget) - - val offIntent = Intent(context, NoiseControlWidget::class.java).apply { - action = "ACTION_SET_ANC_MODE" - putExtra("ANC_MODE", 1) - } - val transparencyIntent = Intent(context, NoiseControlWidget::class.java).apply { - action = "ACTION_SET_ANC_MODE" - putExtra("ANC_MODE", 3) - } - val adaptiveIntent = Intent(context, NoiseControlWidget::class.java).apply { - action = "ACTION_SET_ANC_MODE" - putExtra("ANC_MODE", 4) - } - val ancIntent = Intent(context, NoiseControlWidget::class.java).apply { - action = "ACTION_SET_ANC_MODE" - putExtra("ANC_MODE", 2) - } - - views.setOnClickPendingIntent( - R.id.widget_off_button, - PendingIntent.getBroadcast(context, 0, offIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - ) - views.setOnClickPendingIntent( - R.id.widget_transparency_button, - PendingIntent.getBroadcast(context, 1, transparencyIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - ) - views.setOnClickPendingIntent( - R.id.widget_adaptive_button, - PendingIntent.getBroadcast(context, 2, adaptiveIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - ) - views.setOnClickPendingIntent( - R.id.widget_anc_button, - PendingIntent.getBroadcast(context, 3, ancIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - ) - ServiceManager.getService()?.updateNoiseControlWidget() - appWidgetManager.updateAppWidget(appWidgetIds, views) - } - - override fun onReceive(context: Context, intent: Intent) { - super.onReceive(context, intent) - if (intent.action == "ACTION_SET_ANC_MODE") { - val mode = intent.getIntExtra("ANC_MODE", 1) - Log.d("NoiseControlWidget", "Setting ANC mode to $mode") - val service = ServiceManager.getService() - - if (service == null) { - Log.w("NoiseControlWidget", "Service unavailable") - return - } - - service.aacpManager - .sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, - mode.toByte() - ) - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt deleted file mode 100644 index 0cf08c11..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ /dev/null @@ -1,3218 +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 . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.services - -//import me.kavishdevar.librepods.utils.CrossDevice -//import me.kavishdevar.librepods.utils.CrossDevicePackets -import android.Manifest -import android.annotation.SuppressLint -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.appwidget.AppWidgetManager -import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothDevice -import android.bluetooth.BluetoothHeadset -import android.bluetooth.BluetoothManager -import android.bluetooth.BluetoothProfile -import android.bluetooth.BluetoothSocket -import android.content.BroadcastReceiver -import android.content.ComponentName -import android.content.ContentResolver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.SharedPreferences -import android.content.pm.PackageManager -import android.content.res.Resources -import android.graphics.Color -import android.media.AudioManager -import android.net.Uri -import android.os.BatteryManager -import android.os.Binder -import android.os.Build -import android.os.Handler -import android.os.IBinder -import android.os.Looper -import android.os.ParcelUuid -import android.os.UserHandle -import android.provider.Settings -import android.telecom.TelecomManager -import android.telephony.TelephonyCallback -import android.telephony.TelephonyManager -import android.util.Log -import android.util.TypedValue -import android.view.View -import android.widget.RemoteViews -import android.widget.Toast -import androidx.annotation.RequiresApi -import androidx.annotation.RequiresPermission -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.core.app.NotificationCompat -import androidx.core.content.edit -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withTimeout -import me.kavishdevar.librepods.BuildConfig -import me.kavishdevar.librepods.MainActivity -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager -import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType -import me.kavishdevar.librepods.bluetooth.ATTHandles -import me.kavishdevar.librepods.bluetooth.ATTManagerv2 -import me.kavishdevar.librepods.bluetooth.BLEManager -import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager -import me.kavishdevar.librepods.bluetooth.createBluetoothSocket -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.CustomEq -import me.kavishdevar.librepods.data.StemAction -import me.kavishdevar.librepods.data.XposedRemotePrefProvider -import me.kavishdevar.librepods.data.isHeadTrackingData -import me.kavishdevar.librepods.presentation.overlays.IslandType -import me.kavishdevar.librepods.presentation.overlays.IslandWindow -import me.kavishdevar.librepods.presentation.overlays.PopupWindow -import me.kavishdevar.librepods.presentation.widgets.BatteryWidget -import me.kavishdevar.librepods.presentation.widgets.NoiseControlWidget -import me.kavishdevar.librepods.utils.GestureDetector -import me.kavishdevar.librepods.utils.HeadTracking -import me.kavishdevar.librepods.utils.MediaController -import me.kavishdevar.librepods.utils.SystemApisUtils -import me.kavishdevar.librepods.utils.SystemApisUtils.DEVICE_TYPE_UNTETHERED_HEADSET -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_COMPANION_APP -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_DEVICE_TYPE -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_MAIN_ICON -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_MANUFACTURER_NAME -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_MODEL_NAME -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_CASE_BATTERY -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_CASE_CHARGING -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_CASE_ICON -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_CASE_LOW_BATTERY_THRESHOLD -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_LEFT_BATTERY -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_LEFT_CHARGING -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_LEFT_ICON -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_LEFT_LOW_BATTERY_THRESHOLD -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_BATTERY -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_CHARGING -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_ICON -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD -import java.nio.ByteBuffer -import java.nio.ByteOrder -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi -import kotlin.time.Duration.Companion.milliseconds - -private const val TAG = "AirPodsService" - -object ServiceManager { - private var service: AirPodsService? = null - - @Synchronized - fun getService(): AirPodsService? { - return service - } - - @Synchronized - fun setService(service: AirPodsService?) { - this.service = service - } -} - -// @Suppress("unused") -class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeListener { - var macAddress = "" - var localMac = "" - lateinit var aacpManager: AACPManager - lateinit var attManager: ATTManagerv2 - var airpodsInstance: AirPodsInstance? = null - var cameraActive = false - private var disconnectedBecauseReversed = false - private var otherDeviceTookOver = false - - data class ServiceConfig( - var deviceName: String = "AirPods", - var earDetectionEnabled: Boolean = true, - var conversationalAwarenessPauseMusic: Boolean = false, - var showPhoneBatteryInWidget: Boolean = true, - var relativeConversationalAwarenessVolume: Boolean = true, - var headGestures: Boolean = true, - var disconnectWhenNotWearing: Boolean = false, - var conversationalAwarenessVolume: Int = 43, - var qsClickBehavior: String = "cycle", - var bleOnlyMode: Boolean = false, - - // AirPods state-based takeover - var takeoverWhenDisconnected: Boolean = true, - var takeoverWhenIdle: Boolean = true, - var takeoverWhenMusic: Boolean = false, - var takeoverWhenCall: Boolean = true, - - // Phone state-based takeover - var takeoverWhenRingingCall: Boolean = true, - var takeoverWhenMediaStart: Boolean = true, - - var leftSinglePressAction: StemAction = StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!, - var rightSinglePressAction: StemAction = StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!, - - var leftDoublePressAction: StemAction = StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!, - var rightDoublePressAction: StemAction = StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!, - - var leftTriplePressAction: StemAction = StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!, - var rightTriplePressAction: StemAction = StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!, - - var leftLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!, - var rightLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!, - - var cameraAction: StemPressType? = null, - - // AirPods device information - var airpodsName: String = "", - var airpodsModelNumber: String = "", - var airpodsManufacturer: String = "", - var airpodsSerialNumber: String = "", - var airpodsLeftSerialNumber: String = "", - var airpodsRightSerialNumber: String = "", - var airpodsVersion1: String = "", - var airpodsVersion2: String = "", - var airpodsVersion3: String = "", - var airpodsHardwareRevision: String = "", - var airpodsUpdaterIdentifier: String = "", - - // phone's mac, needed for tipi - var selfMacAddress: String = "" - ) - - private lateinit var config: ServiceConfig - - inner class LocalBinder : Binder() { - fun getService(): AirPodsService = this@AirPodsService - } - - private lateinit var sharedPreferencesLogs: SharedPreferences - private lateinit var sharedPreferences: SharedPreferences - private val packetLogKey = "packet_log" - private val _packetLogsFlow = MutableStateFlow>(emptySet()) - val packetLogsFlow: StateFlow> get() = _packetLogsFlow - - private lateinit var telephonyManager: TelephonyManager - private lateinit var phoneStateListener: TelephonyCallback - private val maxLogEntries = 1000 - private val inMemoryLogs = mutableSetOf() - - private var handleIncomingCallOnceConnected = false - - lateinit var bleManager: BLEManager - - companion object { - init { - System.loadLibrary("bluetooth_socket") - } - } - - private val bleStatusListener = object : BLEManager.AirPodsStatusListener { - @SuppressLint("NewApi") - override fun onDeviceStatusChanged( - device: BLEManager.AirPodsStatus, previousStatus: BLEManager.AirPodsStatus? - ) { - if (device.connectionState == "Disconnected" && BluetoothConnectionManager.aacpSocket?.isConnected != true) { // should never happen unless android messes up and sends us a stale broadcast - Log.d(TAG, "Seems no device has taken over, we will.") - val bluetoothManager = getSystemService(BluetoothManager::class.java) - val bluetoothAdapter = bluetoothManager.adapter - val bluetoothDevice = bluetoothAdapter.getRemoteDevice( - sharedPreferences.getString( - "mac_address", "" - ) ?: "" - ) - connectToSocket(bluetoothAdapter, bluetoothDevice) - } - Log.d(TAG, "Device status changed") - if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return - val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0 - val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0 - val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0 - val leftCharging = bleManager.getMostRecentStatus()?.isLeftCharging - val rightCharging = bleManager.getMostRecentStatus()?.isRightCharging - val caseCharging = bleManager.getMostRecentStatus()?.isCaseCharging - - batteryNotification.setBatteryDirect( - leftLevel = leftLevel, - leftCharging = leftCharging == true, - rightLevel = rightLevel, - rightCharging = rightCharging == true, - caseLevel = caseLevel, - caseCharging = caseCharging == true - ) - updateBattery() - } - - override fun onBroadcastFromNewAddress(device: BLEManager.AirPodsStatus) { - Log.d(TAG, "New address detected") - } - - override fun onLidStateChanged( - lidOpen: Boolean, - ) { - if (lidOpen) { - Log.d(TAG, "Lid opened") - showPopup( - this@AirPodsService, - getSharedPreferences("settings", MODE_PRIVATE).getString("name", "AirPods Pro") - ?: "AirPods" - ) - if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return - val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0 - val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0 - val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0 - val leftCharging = bleManager.getMostRecentStatus()?.isLeftCharging - val rightCharging = bleManager.getMostRecentStatus()?.isRightCharging - val caseCharging = bleManager.getMostRecentStatus()?.isCaseCharging - - batteryNotification.setBatteryDirect( - leftLevel = leftLevel, - leftCharging = leftCharging == true, - rightLevel = rightLevel, - rightCharging = rightCharging == true, - caseLevel = caseLevel, - caseCharging = caseCharging == true - ) - sendBatteryBroadcast() - } else { - Log.d(TAG, "Lid closed") - } - } - - override fun onEarStateChanged( - device: BLEManager.AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean - ) { - Log.d(TAG, "Ear state changed - Left: $leftInEar, Right: $rightInEar") - - // In BLE-only mode, ear detection is purely based on BLE data - if (config.bleOnlyMode) { - Log.d(TAG, "BLE-only mode: ear detection from BLE data") - } - } - - override fun onBatteryChanged(device: BLEManager.AirPodsStatus) { - if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return - val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0 - val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0 - val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0 - val leftCharging = bleManager.getMostRecentStatus()?.isLeftCharging - val rightCharging = bleManager.getMostRecentStatus()?.isRightCharging - val caseCharging = bleManager.getMostRecentStatus()?.isCaseCharging - - batteryNotification.setBatteryDirect( - leftLevel = leftLevel, - leftCharging = leftCharging == true, - rightLevel = rightLevel, - rightCharging = rightCharging == true, - caseLevel = caseLevel, - caseCharging = caseCharging == true - ) - updateBattery() - Log.d(TAG, "Battery changed") - } - - override fun onDeviceDisappeared() { - Log.d(TAG, "All disappeared") - updateNotificationContent( - false - ) - } - } - - fun isBluetoothSocketExempted(): Boolean { - return try { - BluetoothSocket::class.java.declaredConstructors // will throw if still blocked - true - } catch (e: Exception) { - e.printStackTrace() - false - } - } - - - @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag", "HardwareIds") - override fun onCreate() { - super.onCreate() - Log.i(TAG, "lib exempt worked: ${isBluetoothSocketExempted()}") - - sharedPreferencesLogs = getSharedPreferences("packet_logs", MODE_PRIVATE) - - inMemoryLogs.addAll( - sharedPreferencesLogs.getStringSet(packetLogKey, emptySet()) ?: emptySet() - ) - _packetLogsFlow.value = inMemoryLogs.toSet() - - sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) - initializeConfig() - - aacpManager = AACPManager() - initializeAACPManagerCallback() - - attManager = ATTManagerv2() - - sharedPreferences.registerOnSharedPreferenceChangeListener(this) - - localMac = config.selfMacAddress - if (localMac.isEmpty()) { - if (checkSelfPermission("android.permission.LOCAL_MAC_ADDRESS") == PackageManager.PERMISSION_GRANTED) { - val bluetoothManager = getSystemService(BluetoothManager::class.java) - val bluetoothAdapter = bluetoothManager.adapter - localMac = bluetoothAdapter.address - } else { - localMac = try { - val process = Runtime.getRuntime().exec( - arrayOf("su", "-c", "settings get secure bluetooth_address") - ) - - val exitCode = process.waitFor() - - if (exitCode == 0) { - process.inputStream.bufferedReader().use { it.readLine()?.trim().orEmpty() } - } else { - "" - } - } catch (e: Exception) { - Log.e( - TAG, - "Error retrieving local MAC address: ${e.message}. We probably aren't rooted." - ) - "" - } - } - config.selfMacAddress = localMac - sharedPreferences.edit { - putString("self_mac_address", localMac) - } - } - - ServiceManager.setService(this) - startForegroundNotification() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - initGestureDetector() - } else { - gestureDetector = null - config.headGestures = false - sharedPreferences.edit { putBoolean("head_gestures", false) } - Log.d(TAG, "Head gestures disabled as device is running Android 9 or below") - } - - bleManager = BLEManager(this) - bleManager.setAirPodsStatusListener(bleStatusListener) - - sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) - - with(sharedPreferences) { - edit { - if (!contains("conversational_awareness_pause_music")) putBoolean( - "conversational_awareness_pause_music", false - ) - if (!contains("personalized_volume")) putBoolean("personalized_volume", false) - if (!contains("automatic_ear_detection")) putBoolean( - "automatic_ear_detection", true - ) - if (!contains("long_press_nc")) putBoolean("long_press_nc", true) - if (!contains("show_phone_battery_in_widget")) putBoolean( - "show_phone_battery_in_widget", true - ) - if (!contains("single_anc")) putBoolean("single_anc", true) - if (!contains("long_press_transparency")) putBoolean( - "long_press_transparency", true - ) - if (!contains("conversational_awareness")) putBoolean( - "conversational_awareness", true - ) - if (!contains("relative_conversational_awareness_volume")) putBoolean( - "relative_conversational_awareness_volume", true - ) - if (!contains("long_press_adaptive")) putBoolean("long_press_adaptive", true) - if (!contains("loud_sound_reduction")) putBoolean("loud_sound_reduction", true) - if (!contains("long_press_off")) putBoolean("long_press_off", false) - if (!contains("volume_control")) putBoolean("volume_control", true) - if (!contains("head_gestures")) putBoolean("head_gestures", true) - if (!contains("disconnect_when_not_wearing")) putBoolean( - "disconnect_when_not_wearing", false - ) - - // AirPods state-based takeover - if (!contains("takeover_when_disconnected")) putBoolean( - "takeover_when_disconnected", false - ) - if (!contains("takeover_when_idle")) putBoolean("takeover_when_idle", false) - if (!contains("takeover_when_music")) putBoolean("takeover_when_music", false) - if (!contains("takeover_when_call")) putBoolean("takeover_when_call", false) - - // Phone state-based takeover - if (!contains("takeover_when_ringing_call")) putBoolean( - "takeover_when_ringing_call", false - ) - if (!contains("takeover_when_media_start")) putBoolean( - "takeover_when_media_start", false - ) - - if (!contains("adaptive_strength")) putInt("adaptive_strength", 51) - if (!contains("tone_volume")) putInt("tone_volume", 75) - if (!contains("conversational_awareness_volume")) putInt( - "conversational_awareness_volume", 43 - ) - - if (!contains("qs_click_behavior")) putString("qs_click_behavior", "cycle") - if (!contains("name")) putString("name", "AirPods") - - if (!contains("left_single_press_action")) putString( - "left_single_press_action", - StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!.name - ) - if (!contains("right_single_press_action")) putString( - "right_single_press_action", - StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!.name - ) - if (!contains("left_double_press_action")) putString( - "left_double_press_action", - StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!.name - ) - if (!contains("right_double_press_action")) putString( - "right_double_press_action", - StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!.name - ) - if (!contains("left_triple_press_action")) putString( - "left_triple_press_action", - StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!.name - ) - if (!contains("right_triple_press_action")) putString( - "right_triple_press_action", - StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!.name - ) - if (!contains("left_long_press_action")) putString( - "left_long_press_action", - StemAction.defaultActions[StemPressType.LONG_PRESS]!!.name - ) - if (!contains("right_long_press_action")) putString( - "right_long_press_action", - StemAction.defaultActions[StemPressType.LONG_PRESS]!!.name - ) - if (!contains("camera_action")) putString("camera_action", "SINGLE_PRESS") - - } - } - - initializeConfig() - - externalBroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == "me.kavishdevar.librepods.SET_ANC_MODE") { - if (intent.hasExtra("mode")) { - val mode = intent.getIntExtra("mode", -1) - if (mode in 1..4) { - aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, - mode - ) - } - } else { - val currentMode = ancNotification.status - val configByte = sharedPreferences.getInt("long_press_byte", 0b0111) - val allowOffModeValue = - aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION } - val allowOffMode = - allowOffModeValue?.value?.takeIf { it.isNotEmpty() }?.get(0) == 0x01.toByte() || sharedPreferences.getBoolean("off_listening_mode", true) - val nextMode = getNextMode(currentMode = currentMode, configByte = configByte, allowOffMode) - - aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, - nextMode - ) - Log.d( - TAG, - "Cycling ANC mode from $currentMode to $nextMode" - ) - } - } else if (intent?.action == "me.kavishdevar.librepods.CONVO_DETECT") { - if (intent.hasExtra("enabled")) { - val enabled = intent.getBooleanExtra("enabled", false) - aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value, - enabled - ) - } - } - } - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(externalBroadcastReceiver, externalBroadcastFilter, RECEIVER_EXPORTED) - } else { - @Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver( - externalBroadcastReceiver, externalBroadcastFilter - ) - } - val audioManager = this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager - MediaController.initialize( - audioManager, this@AirPodsService.getSharedPreferences( - "settings", MODE_PRIVATE - ) - ) -// Log.d(TAG, "Initializing CrossDevice") -// CoroutineScope(Dispatchers.IO).launch { -// CrossDevice.init(this@AirPodsService) -// Log.d(TAG, "CrossDevice initialized") -// } - - sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) - macAddress = sharedPreferences.getString("mac_address", "") ?: "" - - telephonyManager = getSystemService(TELEPHONY_SERVICE) as TelephonyManager - phoneStateListener = object: TelephonyCallback(), TelephonyCallback.CallStateListener { - override fun onCallStateChanged(state: Int) { - when (state) { - TelephonyManager.CALL_STATE_RINGING -> { - val leAvailableForAudio = - bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true -// if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope(Dispatchers.IO).launch { - if (leAvailableForAudio) runBlocking { - takeOver("call") - } - if (config.headGestures) { - handleIncomingCall() - } - } - - TelephonyManager.CALL_STATE_OFFHOOK -> { - val leAvailableForAudio = - bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true -// if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope( - if (leAvailableForAudio) CoroutineScope( - Dispatchers.IO - ).launch { - takeOver("call") - } - isInCall = true - } - - TelephonyManager.CALL_STATE_IDLE -> { - isInCall = false - gestureDetector?.stopDetection() - } - } - } - } - if (checkSelfPermission("android.permission.READ_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { - telephonyManager.registerTelephonyCallback(mainExecutor, phoneStateListener) - } - - if (config.showPhoneBatteryInWidget) { - widgetMobileBatteryEnabled = true - val batteryChangedIntentFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) - batteryChangedIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver( - BatteryChangedIntentReceiver, batteryChangedIntentFilter, RECEIVER_EXPORTED - ) - } else { - @Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver( - BatteryChangedIntentReceiver, batteryChangedIntentFilter - ) - } - } - val serviceIntentFilter = IntentFilter().apply { - addAction("android.bluetooth.device.action.ACL_CONNECTED") - addAction("android.bluetooth.device.action.ACL_DISCONNECTED") - addAction("android.bluetooth.device.action.BOND_STATE_CHANGED") - addAction("android.bluetooth.device.action.NAME_CHANGED") - addAction("android.bluetooth.adapter.action.CONNECTION_STATE_CHANGED") - addAction("android.bluetooth.adapter.action.STATE_CHANGED") - addAction("android.bluetooth.headset.profile.action.CONNECTION_STATE_CHANGED") - addAction("android.bluetooth.headset.action.VENDOR_SPECIFIC_HEADSET_EVENT") - addAction("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") - addAction("android.bluetooth.a2dp.profile.action.PLAYING_STATE_CHANGED") - addAction("android.bluetooth.device.action.UUID") - } - - connectionReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) { - device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra("device", BluetoothDevice::class.java)!! - } else { - intent.getParcelableExtra("device") as BluetoothDevice? - } - - if (config.deviceName == "AirPods" && device?.name != null) { - config.deviceName = device?.name ?: "AirPods" - sharedPreferences.edit { putString("name", config.deviceName) } - } - -// Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString()) -// if (!CrossDevice.isAvailable) { - Log.d(TAG, "${config.deviceName} connected") - CoroutineScope(Dispatchers.IO).launch { - val bluetoothManager = getSystemService(BluetoothManager::class.java) - connectToSocket(bluetoothManager.adapter, device!!) - } - Log.d(TAG, "Setting metadata") - setMetadatas(device!!) -// isConnectedLocally = true - macAddress = device!!.address - sharedPreferences.edit { - putString("mac_address", macAddress) - } -// } - - } else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) { - device = null -// isConnectedLocally = false - popupShown = false - updateNotificationContent(false) - aacpManager.disconnected() - BluetoothConnectionManager.aacpSocket = null - BluetoothConnectionManager.attSocket = null - } - } - } - val showIslandReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == "me.kavishdevar.librepods.cross_device_island") { - showIsland( - this@AirPodsService, - batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost( - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level!! - ) - ) - } else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) { - try { - context?.unregisterReceiver(this) - } catch (e: Exception) { - e.printStackTrace() - } - } - } - } - - val showIslandIntentFilter = IntentFilter().apply { - addAction("me.kavishdevar.librepods.cross_device_island") - addAction(AirPodsNotifications.DISCONNECT_RECEIVERS) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(showIslandReceiver, showIslandIntentFilter, RECEIVER_EXPORTED) - } else { - @Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver( - showIslandReceiver, showIslandIntentFilter - ) - } - - val deviceIntentFilter = IntentFilter().apply { - addAction(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) - addAction(AirPodsNotifications.AIRPODS_DISCONNECTED) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(connectionReceiver, deviceIntentFilter, RECEIVER_EXPORTED) - registerReceiver(bluetoothReceiver, serviceIntentFilter, RECEIVER_EXPORTED) - } else { - @Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver( - connectionReceiver, deviceIntentFilter - ) - registerReceiver(bluetoothReceiver, serviceIntentFilter) - } - - val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter - - bluetoothAdapter.bondedDevices.forEach { device -> - device.fetchUuidsWithSdp() - if (device.uuids != null) { - if (device.uuids.contains(ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a"))) { - bluetoothAdapter.getProfileProxy( - this, object : BluetoothProfile.ServiceListener { - @SuppressLint("NewApi") - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.A2DP) { - val connectedDevices = proxy.connectedDevices - if (connectedDevices.isNotEmpty()) { -// if (!CrossDevice.isAvailable) { - CoroutineScope(Dispatchers.IO).launch { - connectToSocket(bluetoothAdapter, device) - } - setMetadatas(device) - macAddress = device.address - sharedPreferences.edit { - putString("mac_address", macAddress) - } -// } - sendBroadcast( - Intent(AirPodsNotifications.AIRPODS_CONNECTED).apply { - setPackage(packageName) - }) - } - } - bluetoothAdapter.closeProfileProxy(profile, proxy) - } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.A2DP - ) - } - } - } - -// if (!isConnectedLocally && !CrossDevice.isAvailable) { -// clearPacketLogs() -// } - - CoroutineScope(Dispatchers.IO).launch { - bleManager.startScanning() - } - } - - @Suppress("unused") - fun cameraOpened() { - Log.d(TAG, "Camera opened, gonna handle stem presses and take action if visible") - cameraActive = true - setupStemActions() - } - - @Suppress("unused") - fun cameraClosed() { - cameraActive = false - setupStemActions() - } - - fun isCustomAction( - action: StemAction?, default: StemAction? - ): Boolean { - return action != default - } - - fun setupStemActions() { - val singlePressDefault = StemAction.defaultActions[StemPressType.SINGLE_PRESS] - val doublePressDefault = StemAction.defaultActions[StemPressType.DOUBLE_PRESS] - val triplePressDefault = StemAction.defaultActions[StemPressType.TRIPLE_PRESS] - val longPressDefault = StemAction.defaultActions[StemPressType.LONG_PRESS] - - val singlePressCustomized = - isCustomAction(config.leftSinglePressAction, singlePressDefault) || isCustomAction( - config.rightSinglePressAction, singlePressDefault - ) || (cameraActive && config.cameraAction == StemPressType.SINGLE_PRESS) - val doublePressCustomized = - isCustomAction(config.leftDoublePressAction, doublePressDefault) || isCustomAction( - config.rightDoublePressAction, doublePressDefault - ) - val triplePressCustomized = - isCustomAction(config.leftTriplePressAction, triplePressDefault) || isCustomAction( - config.rightTriplePressAction, triplePressDefault - ) - val longPressCustomized = isCustomAction( - config.leftLongPressAction, longPressDefault - ) || isCustomAction( - config.rightLongPressAction, longPressDefault - ) || (cameraActive && config.cameraAction == StemPressType.LONG_PRESS) - Log.d( - TAG, - "Setting up stem actions: Single Press Customized: $singlePressCustomized, Double Press Customized: $doublePressCustomized, Triple Press Customized: $triplePressCustomized, Long Press Customized: $longPressCustomized" - ) - aacpManager.sendStemConfigPacket( - singlePressCustomized, - doublePressCustomized, - triplePressCustomized, - longPressCustomized, - ) - } - - @ExperimentalEncodingApi - private fun initializeAACPManagerCallback() { - aacpManager.setPacketCallback(object : AACPManager.PacketCallback { - @SuppressLint("MissingPermission") - override fun onBatteryInfoReceived(batteryInfo: ByteArray) { - batteryNotification.setBattery(batteryInfo) - sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply { - putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery())) - setPackage(packageName) - }) - updateBattery() - updateNotificationContent( - true, - this@AirPodsService.getSharedPreferences("settings", MODE_PRIVATE) - .getString("name", device?.name), - batteryNotification.getBattery() - ) -// CrossDevice.sendRemotePacket(batteryInfo) -// CrossDevice.batteryBytes = batteryInfo - - for (battery in batteryNotification.getBattery()) { - Log.d( - "AirPodsParser", - "${battery.getComponentName()}: ${battery.getStatusName()} at ${battery.level}% " - ) - } - - if (batteryNotification.getBattery()[0].status == BatteryStatus.CHARGING && batteryNotification.getBattery()[1].status == BatteryStatus.CHARGING) { - disconnectAudio(this@AirPodsService, device) - } else { - connectAudio(this@AirPodsService, device) - } - } - - override fun onEarDetectionReceived(earDetection: ByteArray) { - sendBroadcast(Intent(AirPodsNotifications.EAR_DETECTION_DATA).apply { - val list = earDetectionNotification.status - val bytes = ByteArray(2) - bytes[0] = list[0] - bytes[1] = list[1] - putExtra("data", bytes) - }.apply { - setPackage(packageName) - }) - Log.d( - "AirPodsParser", - "Ear Detection: ${earDetectionNotification.status[0]} ${earDetectionNotification.status[1]}" - ) - processEarDetectionChange(earDetection) - } - - override fun onConversationAwarenessReceived(conversationAwareness: ByteArray) { - conversationAwarenessNotification.setData(conversationAwareness) - sendBroadcast(Intent(AirPodsNotifications.CA_DATA).apply { - putExtra("data", conversationAwarenessNotification.status) - }.apply { - setPackage(packageName) - }) - - if (conversationAwarenessNotification.status == 1.toByte() || conversationAwarenessNotification.status == 2.toByte()) { - MediaController.startSpeaking() - } else if (conversationAwarenessNotification.status == 6.toByte() ||conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) { - MediaController.stopSpeaking() - } - - Log.d( - "AirPodsParser", - "Conversation Awareness: ${conversationAwarenessNotification.status}" - ) - } - - override fun onControlCommandReceived(controlCommand: ByteArray) { - val command = AACPManager.ControlCommand.fromByteArray(controlCommand) - if (command.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value) { - ancNotification.setStatus(byteArrayOf(command.value.takeIf { it.isNotEmpty() } - ?.get(0) ?: 0x00.toByte())) - sendANCBroadcast() - updateNoiseControlWidget() - } - } - - override fun onOwnershipChangeReceived(owns: Boolean) { - if (!owns) { - MediaController.recentlyLostOwnership = true - Handler(Looper.getMainLooper()).postDelayed({ - MediaController.recentlyLostOwnership = false - }, 3000) - Log.d(TAG, "ownership lost") - MediaController.sendPause() - MediaController.pausedForOtherDevice = true - otherDeviceTookOver = true - disconnectAudio( - this@AirPodsService, device - ) - } - } - - override fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean) { - // TODO: Show a reverse button, but that's a lot of effort -- i'd have to change the UI too, which i hate doing, and handle other device's reverses too, and disconnect audio etc... so for now, just pause the audio and show the island without asking to reverse. - // handling reverse is a problem because we'd have to disconnect the audio, but there's no option connect audio again natively, so notification would have to be changed. I wish there was a way to just "change the audio output device". - // (20 minutes later) i've done it nonetheless :] - val senderName = - aacpManager.connectedDevices.find { it.mac == sender }?.type ?: "Other device" - Log.d( - TAG, - "other device has hijacked the connection, reasonReverseTapped: $reasonReverseTapped" - ) - aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value, - byteArrayOf(0x00) - ) - otherDeviceTookOver = true - disconnectAudio( - this@AirPodsService, device - ) - if (reasonReverseTapped) { - Log.d(TAG, "reverse tapped, disconnecting audio") - disconnectedBecauseReversed = true - disconnectAudio(this@AirPodsService, device) - showIsland( - this@AirPodsService, - (batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level - ?: 0).coerceAtMost( - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level ?: 0 - ), - IslandType.MOVED_TO_OTHER_DEVICE, - reversed = true, - otherDeviceName = senderName - ) - } - if (!aacpManager.owns) { - showIsland( - this@AirPodsService, - (batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level - ?: 0).coerceAtMost( - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level ?: 0 - ), - IslandType.MOVED_TO_OTHER_DEVICE, - reversed = reasonReverseTapped, - otherDeviceName = senderName - ) - } - MediaController.sendPause() - } - - override fun onShowNearbyUI(sender: String) { - val senderName = - aacpManager.connectedDevices.find { it.mac == sender }?.type ?: "Other device" - showIsland( - this@AirPodsService, - (batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level ?: 0).coerceAtMost( - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level ?: 0 - ), - IslandType.MOVED_TO_OTHER_DEVICE, - reversed = false, - otherDeviceName = senderName - ) - } - - override fun onDeviceInformationReceived(deviceInformation: AACPManager.Companion.AirPodsInformation) { - Log.d( - "AirPodsParser", - "Device Information: name: ${deviceInformation.name}, modelNumber: ${deviceInformation.modelNumber}, manufacturer: ${deviceInformation.manufacturer}, serialNumber: ${deviceInformation.serialNumber}, version1: ${deviceInformation.version1}, version2: ${deviceInformation.version2}, hardwareRevision: ${deviceInformation.hardwareRevision}, updaterIdentifier: ${deviceInformation.updaterIdentifier}, leftSerialNumber: ${deviceInformation.leftSerialNumber}, rightSerialNumber: ${deviceInformation.rightSerialNumber}, version3: ${deviceInformation.version3}" - ) - // Store in SharedPreferences - sharedPreferences.edit { - putString("name", deviceInformation.name) - putString("airpods_model_number", deviceInformation.modelNumber) - putString("airpods_manufacturer", deviceInformation.manufacturer) - putString("airpods_serial_number", deviceInformation.serialNumber) - putString("airpods_left_serial_number", deviceInformation.leftSerialNumber) - putString("airpods_right_serial_number", deviceInformation.rightSerialNumber) - putString("airpods_version1", deviceInformation.version1) - putString("airpods_version2", deviceInformation.version2) - putString("airpods_version3", deviceInformation.version3) - putString("airpods_hardware_revision", deviceInformation.hardwareRevision) - putString("airpods_updater_identifier", deviceInformation.updaterIdentifier) - } - // Update config - config.airpodsName = deviceInformation.name - config.airpodsModelNumber = deviceInformation.modelNumber - config.airpodsManufacturer = deviceInformation.manufacturer - config.airpodsSerialNumber = deviceInformation.serialNumber - config.airpodsLeftSerialNumber = deviceInformation.leftSerialNumber - config.airpodsRightSerialNumber = deviceInformation.rightSerialNumber - config.airpodsVersion1 = deviceInformation.version1 - config.airpodsVersion2 = deviceInformation.version2 - config.airpodsVersion3 = deviceInformation.version3 - config.airpodsHardwareRevision = deviceInformation.hardwareRevision - config.airpodsUpdaterIdentifier = deviceInformation.updaterIdentifier - - val model = AirPodsModels.getModelByModelNumber(config.airpodsModelNumber) - if (model != null) { - airpodsInstance = AirPodsInstance( - name = config.airpodsName, - model = model, - actualModelNumber = config.airpodsModelNumber, - serialNumber = config.airpodsSerialNumber, - leftSerialNumber = config.airpodsLeftSerialNumber, - rightSerialNumber = config.airpodsRightSerialNumber, - version1 = config.airpodsVersion1, - version2 = config.airpodsVersion2, - version3 = config.airpodsVersion3, - ) - if (device != null) setMetadatas(device!!) - } - sendBroadcast( - Intent(AirPodsNotifications.AIRPODS_INFORMATION_UPDATED).setPackage( - packageName - ) - ) - } - - @SuppressLint("NewApi") - override fun onHeadTrackingReceived(headTracking: ByteArray) { - if (isHeadTrackingActive) { - HeadTracking.processPacket(headTracking) - processHeadTrackingData(headTracking) - } - } - - override fun onProximityKeysReceived(proximityKeys: ByteArray) { - val keys = aacpManager.parseProximityKeysResponse(proximityKeys) - Log.d("AirPodsParser", "Proximity keys: $keys") - sharedPreferences.edit { - for (key in keys) { - Log.d("AirPodsParser", "Proximity key: ${key.key.name} = ${key.value}") - putString(key.key.name, Base64.encode(key.value)) - } - } - } - - override fun onStemPressReceived(stemPress: ByteArray) { - - val (stemPressType, bud) = aacpManager.parseStemPressResponse(stemPress) - - Log.d( - "AirPodsParser", - "Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}" - ) - if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) { - Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27")) - } else { - val action = getActionFor(bud, stemPressType) - Log.d("AirPodsParser", "$bud $stemPressType action: $action") - action?.let { executeStemAction(it) } - } - } - - override fun onAudioSourceReceived(audioSource: ByteArray) { - Log.d( - "AirPodsParser", - "Audio source changed mac: ${aacpManager.audioSource?.mac}, type: ${aacpManager.audioSource?.type?.name}" - ) - if (localMac!="" && (aacpManager.audioSource?.type != AACPManager.Companion.AudioSourceType.NONE && aacpManager.audioSource?.mac != localMac)) { - Log.d( - "AirPodsParser", - "Audio source is another device, better to give up aacp control" - ) - aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value, - byteArrayOf(0x00) - ) - // this also means that the other device has start playing the audio, and if that's true, we can again start listening for audio config changes -// Log.d(TAG, "Another device started playing audio, listening for audio config changes again") -// MediaController.pausedForOtherDevice = false -// future me: what the heck is this? this just means it will not be taking over again if audio source doesn't change??? - } - } - - override fun onConnectedDevicesReceived(connectedDevices: List) { - for (device in connectedDevices) { - Log.d( - "AirPodsParser", - "Connected device: ${device.mac}, info1: ${device.info1}, info2: ${device.info2})" - ) - } - val newDevices = connectedDevices.filter { newDevice -> - val notInOld = - aacpManager.oldConnectedDevices.none { oldDevice -> oldDevice.mac == newDevice.mac } - val notLocal = newDevice.mac != localMac - notInOld && notLocal - } - - for (device in newDevices) { - Log.d( - "AirPodsParser", - "New connected device: ${device.mac}, info1: ${device.info1}, info2: ${device.info2})" - ) - Log.d( - TAG, - "Sending new Tipi packet for device ${device.mac}, and sending media info to the device" - ) - aacpManager.sendMediaInformationNewDevice( - selfMacAddress = localMac, targetMacAddress = device.mac - ) - aacpManager.sendAddTiPiDevice( - selfMacAddress = localMac, targetMacAddress = device.mac - ) - } - } - - override fun onHeadphoneAccommodationReceived(eqData: FloatArray) { - sendBroadcast( - Intent(AirPodsNotifications.EQ_DATA).putExtra("eqData", eqData).apply { - setPackage(packageName) - }) - } - - override fun onCustomEqReceived(customEq: CustomEq) { - // TODO - } - - override fun onCapabilitiesReceived(capabilities: List) { - // TODO - } - - override fun onUnknownPacketReceived(packet: ByteArray) { - Log.d( - "AACPManager", - "Unknown packet received: ${packet.joinToString(" ") { "%02X".format(it) }}" - ) - } - }) - } - - private fun getActionFor( - bud: AACPManager.Companion.StemPressBudType, type: StemPressType - ): StemAction? { - return when (type) { - StemPressType.SINGLE_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftSinglePressAction else config.rightSinglePressAction - StemPressType.DOUBLE_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftDoublePressAction else config.rightDoublePressAction - StemPressType.TRIPLE_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftTriplePressAction else config.rightTriplePressAction - StemPressType.LONG_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftLongPressAction else config.rightLongPressAction - } - } - - private fun executeStemAction(action: StemAction) { - when (action) { - StemAction.defaultActions[StemPressType.SINGLE_PRESS] -> { - Log.d( - "AirPodsParser", "Default single press action: Play/Pause, not taking action." - ) - } - - StemAction.PLAY_PAUSE -> MediaController.sendPlayPause() - StemAction.PREVIOUS_TRACK -> MediaController.sendPreviousTrack() - StemAction.NEXT_TRACK -> MediaController.sendNextTrack() - StemAction.DIGITAL_ASSISTANT -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val intent = Intent(Intent.ACTION_VOICE_COMMAND).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - startActivity(intent) - } else { - Log.w( - "AirPodsParser", - "Digital Assistant action is not supported on this Android version." - ) - } - } - - StemAction.CYCLE_NOISE_CONTROL_MODES -> { - Log.d("AirPodsParser", "Cycling noise control modes") - sendBroadcast(Intent("me.kavishdevar.librepods.SET_ANC_MODE").apply { - setPackage(packageName) - }) - } - } - } - - private fun processEarDetectionChange(earDetection: ByteArray) { - var inEar: Boolean - val inEarData = listOf( - earDetectionNotification.status[0] == 0x00.toByte(), - earDetectionNotification.status[1] == 0x00.toByte() - ) - var justEnabledA2dp = false - earDetectionNotification.setStatus(earDetection) - if (config.earDetectionEnabled) { - val data = earDetection.copyOfRange(earDetection.size - 2, earDetection.size) - inEar = data[0] == 0x00.toByte() && data[1] == 0x00.toByte() - - val newInEarData = listOf( - data[0] == 0x00.toByte(), data[1] == 0x00.toByte() - ) - - if (inEarData.sorted() == listOf(false, false) && newInEarData.sorted() != listOf( - false, false - ) && islandWindow?.isVisible != true - ) { - showIsland( - this@AirPodsService, - (batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level ?: 0).coerceAtMost( - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level ?: 0 - ) - ) - } - - if (newInEarData == listOf(false, false) && islandWindow?.isVisible == true) { - islandWindow?.close() - } - - if (newInEarData.contains(true) && inEarData == listOf(false, false)) { - connectAudio(this@AirPodsService, device) - justEnabledA2dp = true - registerA2dpConnectionReceiver() - if (MediaController.getMusicActive()) { - MediaController.userPlayedTheMedia = true - } - } else if (newInEarData == listOf(false, false)) { - MediaController.sendPause(force = true) - if (config.disconnectWhenNotWearing) { - disconnectAudio(this@AirPodsService, device) - } - } - val wasNone = inEarData == listOf(false, false) - val nowSingle = newInEarData.count { it } == 1 - - if (wasNone && nowSingle) { - MediaController.sendPlay() - MediaController.iPausedTheMedia = false - return - } - - if (inEarData.contains(false) && newInEarData == listOf(true, true)) { - Log.d("AirPodsParser", "User put in both AirPods from just one.") - MediaController.userPlayedTheMedia = false - } - - if (newInEarData.contains(false) && inEarData == listOf(true, true)) { - Log.d("AirPodsParser", "User took one of two out.") - MediaController.userPlayedTheMedia = false - } - - Log.d( - "AirPodsParser", - "inEarData: ${inEarData.sorted()}, newInEarData: ${newInEarData.sorted()}" - ) - - if (newInEarData.sorted() != inEarData.sorted()) { - if (inEar) { - if (!justEnabledA2dp) { - MediaController.sendPlay() - MediaController.iPausedTheMedia = false - } - } else { - MediaController.sendPause() - } - } - } - } - - private fun registerA2dpConnectionReceiver() { - val a2dpConnectionStateReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == "android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") { - val state = intent.getIntExtra( - BluetoothProfile.EXTRA_STATE, BluetoothProfile.STATE_DISCONNECTED - ) - val previousState = intent.getIntExtra( - BluetoothProfile.EXTRA_PREVIOUS_STATE, BluetoothProfile.STATE_DISCONNECTED - ) - val device = - intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) - - Log.d( - "MediaController", - "A2DP state changed: $previousState -> $state for device: ${device?.address}" - ) - - if (state == BluetoothProfile.STATE_CONNECTED && previousState != BluetoothProfile.STATE_CONNECTED && device?.address == this@AirPodsService.device?.address) { - - Log.d("MediaController", "A2DP connected, sending play command") - MediaController.sendPlay() - MediaController.iPausedTheMedia = false - - context.unregisterReceiver(this) - } - } - } - } - - val a2dpIntentFilter = - IntentFilter("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(a2dpConnectionStateReceiver, a2dpIntentFilter, RECEIVER_EXPORTED) - } else { - registerReceiver(a2dpConnectionStateReceiver, a2dpIntentFilter) - } - } - - private fun initializeConfig() { - config = ServiceConfig( - deviceName = sharedPreferences.getString("name", "AirPods") ?: "AirPods", - earDetectionEnabled = sharedPreferences.getBoolean("automatic_ear_detection", true), - conversationalAwarenessPauseMusic = sharedPreferences.getBoolean( - "conversational_awareness_pause_music", false - ), - showPhoneBatteryInWidget = sharedPreferences.getBoolean( - "show_phone_battery_in_widget", true - ), - relativeConversationalAwarenessVolume = sharedPreferences.getBoolean( - "relative_conversational_awareness_volume", true - ), - headGestures = sharedPreferences.getBoolean("head_gestures", true), - disconnectWhenNotWearing = sharedPreferences.getBoolean( - "disconnect_when_not_wearing", false - ), - conversationalAwarenessVolume = sharedPreferences.getInt( - "conversational_awareness_volume", 43 - ), - qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle", - - // AirPods state-based takeover - 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), - - // Phone state-based takeover - takeoverWhenRingingCall = sharedPreferences.getBoolean( - "takeover_when_ringing_call", false - ), - takeoverWhenMediaStart = sharedPreferences.getBoolean( - "takeover_when_media_start", false - ), - - // Stem actions - leftSinglePressAction = StemAction.fromString( - sharedPreferences.getString( - "left_single_press_action", "PLAY_PAUSE" - ) ?: "PLAY_PAUSE" - )!!, - rightSinglePressAction = StemAction.fromString( - sharedPreferences.getString( - "right_single_press_action", "PLAY_PAUSE" - ) ?: "PLAY_PAUSE" - )!!, - - leftDoublePressAction = StemAction.fromString( - sharedPreferences.getString( - "left_double_press_action", "PREVIOUS_TRACK" - ) ?: "NEXT_TRACK" - )!!, - rightDoublePressAction = StemAction.fromString( - sharedPreferences.getString( - "right_double_press_action", "NEXT_TRACK" - ) ?: "NEXT_TRACK" - )!!, - - leftTriplePressAction = StemAction.fromString( - sharedPreferences.getString( - "left_triple_press_action", "PREVIOUS_TRACK" - ) ?: "PREVIOUS_TRACK" - )!!, - rightTriplePressAction = StemAction.fromString( - sharedPreferences.getString( - "right_triple_press_action", "PREVIOUS_TRACK" - ) ?: "PREVIOUS_TRACK" - )!!, - - leftLongPressAction = StemAction.fromString( - sharedPreferences.getString( - "left_long_press_action", "CYCLE_NOISE_CONTROL_MODES" - ) ?: "CYCLE_NOISE_CONTROL_MODES" - )!!, - rightLongPressAction = StemAction.fromString( - sharedPreferences.getString( - "right_long_press_action", "DIGITAL_ASSISTANT" - ) ?: "DIGITAL_ASSISTANT" - )!!, - - cameraAction = sharedPreferences.getString("camera_action", null) - ?.let { StemPressType.valueOf(it) }, - - // AirPods device information - airpodsName = sharedPreferences.getString("airpods_name", "") ?: "", - airpodsModelNumber = sharedPreferences.getString("airpods_model_number", "") ?: "", - airpodsManufacturer = sharedPreferences.getString("airpods_manufacturer", "") ?: "", - airpodsSerialNumber = sharedPreferences.getString("airpods_serial_number", "") ?: "", - airpodsLeftSerialNumber = sharedPreferences.getString("airpods_left_serial_number", "") - ?: "", - airpodsRightSerialNumber = sharedPreferences.getString( - "airpods_right_serial_number", "" - ) ?: "", - airpodsVersion1 = sharedPreferences.getString("airpods_version1", "") ?: "", - airpodsVersion2 = sharedPreferences.getString("airpods_version2", "") ?: "", - airpodsVersion3 = sharedPreferences.getString("airpods_version3", "") ?: "", - airpodsHardwareRevision = sharedPreferences.getString("airpods_hardware_revision", "") - ?: "", - airpodsUpdaterIdentifier = sharedPreferences.getString("airpods_updater_identifier", "") - ?: "", - - selfMacAddress = sharedPreferences.getString("self_mac_address", "") ?: "" - ) - } - - override fun onSharedPreferenceChanged(preferences: SharedPreferences?, key: String?) { - if (preferences == null || key == null) return - - when (key) { - "name" -> config.deviceName = preferences.getString(key, "AirPods") ?: "AirPods" - "mac_address" -> macAddress = preferences.getString(key, "") ?: "" - "automatic_ear_detection" -> config.earDetectionEnabled = - preferences.getBoolean(key, true) - - "conversational_awareness_pause_music" -> config.conversationalAwarenessPauseMusic = - preferences.getBoolean(key, false) - - "show_phone_battery_in_widget" -> { - config.showPhoneBatteryInWidget = preferences.getBoolean(key, true) - widgetMobileBatteryEnabled = config.showPhoneBatteryInWidget - updateBattery() - } - - "relative_conversational_awareness_volume" -> config.relativeConversationalAwarenessVolume = - preferences.getBoolean(key, true) - - "head_gestures" -> config.headGestures = preferences.getBoolean(key, true) - "disconnect_when_not_wearing" -> config.disconnectWhenNotWearing = - preferences.getBoolean(key, false) - - "conversational_awareness_volume" -> config.conversationalAwarenessVolume = - preferences.getInt(key, 43) - - "qs_click_behavior" -> config.qsClickBehavior = - preferences.getString(key, "cycle") ?: "cycle" - - // AirPods state-based takeover - "takeover_when_disconnected" -> config.takeoverWhenDisconnected = - preferences.getBoolean(key, true) - - "takeover_when_idle" -> config.takeoverWhenIdle = preferences.getBoolean(key, true) - "takeover_when_music" -> config.takeoverWhenMusic = preferences.getBoolean(key, false) - "takeover_when_call" -> config.takeoverWhenCall = preferences.getBoolean(key, true) - - // Phone state-based takeover - "takeover_when_ringing_call" -> config.takeoverWhenRingingCall = - preferences.getBoolean(key, true) - - "takeover_when_media_start" -> config.takeoverWhenMediaStart = - preferences.getBoolean(key, true) - - "left_single_press_action" -> { - config.leftSinglePressAction = StemAction.fromString( - preferences.getString(key, "PLAY_PAUSE") ?: "PLAY_PAUSE" - )!! - setupStemActions() - } - - "right_single_press_action" -> { - config.rightSinglePressAction = StemAction.fromString( - preferences.getString(key, "PLAY_PAUSE") ?: "PLAY_PAUSE" - )!! - setupStemActions() - } - - "left_double_press_action" -> { - config.leftDoublePressAction = StemAction.fromString( - preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK" - )!! - setupStemActions() - } - - "right_double_press_action" -> { - config.rightDoublePressAction = StemAction.fromString( - preferences.getString(key, "NEXT_TRACK") ?: "NEXT_TRACK" - )!! - setupStemActions() - } - - "left_triple_press_action" -> { - config.leftTriplePressAction = StemAction.fromString( - preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK" - )!! - setupStemActions() - } - - "right_triple_press_action" -> { - config.rightTriplePressAction = StemAction.fromString( - preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK" - )!! - setupStemActions() - } - - "left_long_press_action" -> { - config.leftLongPressAction = StemAction.fromString( - preferences.getString(key, "CYCLE_NOISE_CONTROL_MODES") - ?: "CYCLE_NOISE_CONTROL_MODES" - )!! - setupStemActions() - } - - "right_long_press_action" -> { - config.rightLongPressAction = StemAction.fromString( - preferences.getString(key, "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT" - )!! - setupStemActions() - } - - "camera_action" -> config.cameraAction = - preferences.getString(key, null)?.let { StemPressType.valueOf(it) } - - // AirPods device information - "airpods_name" -> config.airpodsName = preferences.getString(key, "") ?: "" - "airpods_model_number" -> config.airpodsModelNumber = - preferences.getString(key, "") ?: "" - - "airpods_manufacturer" -> config.airpodsManufacturer = - preferences.getString(key, "") ?: "" - - "airpods_serial_number" -> config.airpodsSerialNumber = - preferences.getString(key, "") ?: "" - - "airpods_left_serial_number" -> config.airpodsLeftSerialNumber = - preferences.getString(key, "") ?: "" - - "airpods_right_serial_number" -> config.airpodsRightSerialNumber = - preferences.getString(key, "") ?: "" - - "airpods_version1" -> config.airpodsVersion1 = preferences.getString(key, "") ?: "" - "airpods_version2" -> config.airpodsVersion2 = preferences.getString(key, "") ?: "" - "airpods_version3" -> config.airpodsVersion3 = preferences.getString(key, "") ?: "" - "airpods_hardware_revision" -> config.airpodsHardwareRevision = - preferences.getString(key, "") ?: "" - - "airpods_updater_identifier" -> config.airpodsUpdaterIdentifier = - preferences.getString(key, "") ?: "" - - "self_mac_address" -> config.selfMacAddress = preferences.getString(key, "") ?: "" - } - } - - private fun logPacket(packet: ByteArray, @Suppress("SameParameterValue") source: String) { - val packetHex = packet.joinToString(" ") { "%02X".format(it) } - val logEntry = "$source: $packetHex" - - synchronized(inMemoryLogs) { - inMemoryLogs.add(logEntry) - if (inMemoryLogs.size > maxLogEntries) { - inMemoryLogs.iterator().next().let { - inMemoryLogs.remove(it) - } - } - - _packetLogsFlow.value = inMemoryLogs.toSet() - } - - CoroutineScope(Dispatchers.IO).launch { - val logs = - sharedPreferencesLogs.getStringSet(packetLogKey, mutableSetOf())?.toMutableSet() - ?: mutableSetOf() - logs.add(logEntry) - - if (logs.size > maxLogEntries) { - val toKeep = logs.toList().takeLast(maxLogEntries).toSet() - sharedPreferencesLogs.edit { putStringSet(packetLogKey, toKeep) } - } else { - sharedPreferencesLogs.edit { putStringSet(packetLogKey, logs) } - } - } - } - - private fun clearPacketLogs() { - synchronized(inMemoryLogs) { - inMemoryLogs.clear() - _packetLogsFlow.value = emptySet() - } - sharedPreferencesLogs.edit { remove(packetLogKey) } - } - - fun clearLogs() { - clearPacketLogs() - _packetLogsFlow.value = emptySet() - } - - override fun onBind(intent: Intent?): IBinder { - return LocalBinder() - } - - private var gestureDetector: GestureDetector? = null - private var isInCall = false - private var callNumber: String? = null - - private fun initGestureDetector() { - if (gestureDetector == null) { - gestureDetector = GestureDetector(this) - } - } - - - var popupShown = false - fun showPopup(service: Service, name: String) { - if (!sharedPreferences.getBoolean("show_bottom_sheet_popup", true)) { - return - } - if (!Settings.canDrawOverlays(service)) { - Log.d(TAG, "No permission for SYSTEM_ALERT_WINDOW") - return - } - if (popupShown) { - return - } - val popupWindow = PopupWindow(service.applicationContext) - popupWindow.open(name, batteryNotification) - popupShown = true - } - - var islandOpen = false - var islandWindow: IslandWindow? = null - - @SuppressLint("MissingPermission") - fun showIsland( - service: Service, - batteryPercentage: Int, - type: IslandType = IslandType.CONNECTED, - reversed: Boolean = false, - otherDeviceName: String? = null - ) { - Log.d(TAG, "Showing island window") - if (!sharedPreferences.getBoolean("show_island_popup", true)) { - return - } - if (!Settings.canDrawOverlays(service)) { - Log.d(TAG, "No permission for SYSTEM_ALERT_WINDOW") - return - } - CoroutineScope(Dispatchers.Main).launch { - islandWindow = IslandWindow(service.applicationContext) - islandWindow!!.show( - sharedPreferences.getString("name", "AirPods Pro").toString(), - batteryPercentage, - this@AirPodsService, - type, - reversed, - otherDeviceName - ) - } - } - - @OptIn(ExperimentalMaterial3Api::class) - fun startMainActivity() { - val intent = Intent(this, MainActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(intent) - } - - // var isConnectedLocally = false - var device: BluetoothDevice? = null - - private lateinit var earReceiver: BroadcastReceiver - var widgetMobileBatteryEnabled = false - - object BatteryChangedIntentReceiver : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent) { - if (intent.action == Intent.ACTION_BATTERY_CHANGED) { - ServiceManager.getService()?.updateBattery() - } else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) { - try { - context?.unregisterReceiver(this) - } catch (e: Exception) { - e.printStackTrace() - } - } - } - } - - @OptIn(ExperimentalMaterial3Api::class) - fun startForegroundNotification() { - val disconnectedNotificationChannel = NotificationChannel( - "background_service_status", - "Background Service Status", - NotificationManager.IMPORTANCE_NONE - ) - - val connectedNotificationChannel = NotificationChannel( - "airpods_connection_status", - "AirPods Connection Status", - NotificationManager.IMPORTANCE_LOW, - ) - - val socketFailureChannel = NotificationChannel( - "socket_connection_failure", - "AirPods BluetoothConnectionManager.aacpSocket? Connection Issues", - NotificationManager.IMPORTANCE_HIGH - ).apply { - description = "Notifications about problems connecting to AirPods protocol" - enableLights(true) - lightColor = Color.RED - enableVibration(true) - } - - val notificationManager = getSystemService(NotificationManager::class.java) - notificationManager.createNotificationChannel(disconnectedNotificationChannel) - notificationManager.createNotificationChannel(connectedNotificationChannel) - notificationManager.createNotificationChannel(socketFailureChannel) - - val notificationSettingsIntent = - Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, packageName) - putExtra(Settings.EXTRA_CHANNEL_ID, "background_service_status") - } - val pendingIntentNotifDisable = PendingIntent.getActivity( - this, - 0, - notificationSettingsIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - val notification = NotificationCompat.Builder(this, "background_service_status") - .setSmallIcon(R.drawable.airpods).setContentTitle("Background Service Running") - .setContentText("Useless notification, disable it by clicking on it.") - .setContentIntent(pendingIntentNotifDisable).setCategory(Notification.CATEGORY_SERVICE) - .setPriority(NotificationCompat.PRIORITY_LOW).setOngoing(true).build() - - try { - startForeground(1, notification) - } catch (e: Exception) { - e.printStackTrace() - } - } - - @Suppress("KotlinUnreachableCode") - @OptIn(ExperimentalMaterial3Api::class) - private fun showSocketConnectionFailureNotification(errorMessage: String) { - return // something causes too many notifications. turning off for now - if (BuildConfig.FLAVOR != "xposed") { - Log.w( - TAG, - "Not showing BluetoothConnectionManager.aacpSocket? error notification to user, the service shouldn't be running if it isn't supported." - ) - return - } - val notificationManager = getSystemService(NotificationManager::class.java) - - val notificationIntent = Intent(this, MainActivity::class.java) - val pendingIntent = PendingIntent.getActivity( - this, - 0, - notificationIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - val notification = NotificationCompat.Builder(this, "socket_connection_failure") - .setSmallIcon(R.drawable.airpods).setContentTitle("AirPods Connection Issue") - .setContentText("Unable to connect to AirPods over L2CAP").setStyle( - NotificationCompat.BigTextStyle().bigText( - "Your AirPods are connected via Bluetooth, but LibrePods couldn't connect to AirPods using L2CAP. Error: $errorMessage" - ) - ).setContentIntent(pendingIntent).setCategory(Notification.CATEGORY_ERROR) - .setPriority(NotificationCompat.PRIORITY_HIGH).setAutoCancel(true).build() - - notificationManager.notify(3, notification) - } - - fun sendANCBroadcast() { - sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply { - putExtra("data", ancNotification.status) - setPackage(packageName) - }) - } - - fun sendBatteryBroadcast() { - broadcastBatteryInformation() - sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply { - putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery())) - setPackage(packageName) - }) - } - - @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) - fun sendBatteryNotification() { - updateNotificationContent( - true, - getSharedPreferences("settings", MODE_PRIVATE).getString("name", device?.name), - batteryNotification.getBattery() - ) - } - - fun setBatteryMetadata() { - if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) { - device?.let { it -> - SystemApisUtils.setMetadata( - it, - it.METADATA_UNTETHERED_CASE_BATTERY, - batteryNotification.getBattery() - .find { it.component == BatteryComponent.CASE }?.level.toString() - .toByteArray() - ) - SystemApisUtils.setMetadata( - it, - it.METADATA_UNTETHERED_CASE_CHARGING, - (if (batteryNotification.getBattery() - .find { it.component == BatteryComponent.CASE }?.status == BatteryStatus.CHARGING - ) "1".toByteArray() else "0".toByteArray()) - ) - SystemApisUtils.setMetadata( - it, - it.METADATA_UNTETHERED_LEFT_BATTERY, - batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level.toString() - .toByteArray() - ) - SystemApisUtils.setMetadata( - it, - it.METADATA_UNTETHERED_LEFT_CHARGING, - (if (batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.status == BatteryStatus.CHARGING - ) "1".toByteArray() else "0".toByteArray()) - ) - SystemApisUtils.setMetadata( - it, - it.METADATA_UNTETHERED_RIGHT_BATTERY, - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level.toString() - .toByteArray() - ) - SystemApisUtils.setMetadata( - it, - it.METADATA_UNTETHERED_RIGHT_CHARGING, - (if (batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.status == BatteryStatus.CHARGING - ) "1".toByteArray() else "0".toByteArray()) - ) - } - } - } - - @OptIn(ExperimentalMaterial3Api::class) - fun updateBatteryWidget() { - val appWidgetManager = AppWidgetManager.getInstance(this) - val componentName = ComponentName(this, BatteryWidget::class.java) - val widgetIds = appWidgetManager.getAppWidgetIds(componentName) - - val remoteViews = RemoteViews(packageName, R.layout.battery_widget).also { it -> - val openActivityIntent = PendingIntent.getActivity( - this, - 0, - Intent(this, MainActivity::class.java), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - it.setOnClickPendingIntent(R.id.battery_widget, openActivityIntent) - - val leftBattery = - batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT } - val rightBattery = - batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT } - val caseBattery = - batteryNotification.getBattery().find { it.component == BatteryComponent.CASE } - - it.setTextViewText(R.id.left_battery_widget, leftBattery?.let { - "${it.level}%" - } ?: "") - it.setProgressBar( - R.id.left_battery_progress, 100, leftBattery?.level ?: 0, false - ) - it.setViewVisibility( - R.id.left_charging_icon, - if (leftBattery?.status == BatteryStatus.CHARGING || leftBattery?.status == BatteryStatus.OPTIMIZED_CHARGING) View.VISIBLE else View.GONE - ) - - it.setTextViewText(R.id.right_battery_widget, rightBattery?.let { - "${it.level}%" - } ?: "") - it.setProgressBar( - R.id.right_battery_progress, 100, rightBattery?.level ?: 0, false - ) - it.setViewVisibility( - R.id.right_charging_icon, - if (rightBattery?.status == BatteryStatus.CHARGING || rightBattery?.status == BatteryStatus.OPTIMIZED_CHARGING ) View.VISIBLE else View.GONE - ) - - it.setTextViewText(R.id.case_battery_widget, caseBattery?.let { - "${it.level}%" - } ?: "") - it.setProgressBar( - R.id.case_battery_progress, 100, caseBattery?.level ?: 0, false - ) - it.setViewVisibility( - R.id.case_charging_icon, - if (caseBattery?.status == BatteryStatus.CHARGING || caseBattery?.status == BatteryStatus.OPTIMIZED_CHARGING ) View.VISIBLE else View.GONE - ) - - it.setViewVisibility( - R.id.phone_battery_widget_container, - if (widgetMobileBatteryEnabled) View.VISIBLE else View.GONE - ) - if (widgetMobileBatteryEnabled) { - val batteryManager = getSystemService(BatteryManager::class.java) - val batteryLevel = - batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) - val charging = - batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_STATUS) == BatteryManager.BATTERY_STATUS_CHARGING - it.setTextViewText( - R.id.phone_battery_widget, "$batteryLevel%" - ) - it.setViewVisibility( - R.id.phone_charging_icon, if (charging) View.VISIBLE else View.GONE - ) - it.setProgressBar( - R.id.phone_battery_progress, 100, batteryLevel, false - ) - } - } - appWidgetManager.updateAppWidget(widgetIds, remoteViews) - } - - @SuppressLint("MissingPermission") - @OptIn(ExperimentalMaterial3Api::class) - fun updateBattery() { - setBatteryMetadata() - updateBatteryWidget() - sendBatteryBroadcast() - sendBatteryNotification() - } - - fun updateNoiseControlWidget() { - val appWidgetManager = AppWidgetManager.getInstance(this) - val componentName = ComponentName(this, NoiseControlWidget::class.java) - val widgetIds = appWidgetManager.getAppWidgetIds(componentName) - val remoteViews = RemoteViews(packageName, R.layout.noise_control_widget).also { it -> - val ancStatus = ancNotification.status - val allowOffModeValue = - aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION } - val allowOffMode = - allowOffModeValue?.value?.takeIf { it.isNotEmpty() }?.get(0) == 0x01.toByte() || sharedPreferences.getBoolean("off_listening_mode", true) - it.setInt( - R.id.widget_off_button, - "setBackgroundResource", - if (ancStatus == 1) R.drawable.widget_button_checked_shape_start else R.drawable.widget_button_shape_start - ) - it.setInt( - R.id.widget_transparency_button, - "setBackgroundResource", - if (ancStatus == 3) (if (allowOffMode) R.drawable.widget_button_checked_shape_middle else R.drawable.widget_button_checked_shape_start) else (if (allowOffMode) R.drawable.widget_button_shape_middle else R.drawable.widget_button_shape_start) - ) - it.setInt( - R.id.widget_adaptive_button, - "setBackgroundResource", - if (ancStatus == 4) R.drawable.widget_button_checked_shape_middle else R.drawable.widget_button_shape_middle - ) - it.setInt( - R.id.widget_anc_button, - "setBackgroundResource", - if (ancStatus == 2) R.drawable.widget_button_checked_shape_end else R.drawable.widget_button_shape_end - ) - it.setViewVisibility( - R.id.widget_off_button, if (allowOffMode) View.VISIBLE else View.GONE - ) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - it.setViewLayoutMargin( - R.id.widget_transparency_button, - RemoteViews.MARGIN_START, - if (allowOffMode) 2f else 12f, - TypedValue.COMPLEX_UNIT_DIP - ) - } else { - it.setViewPadding( - R.id.widget_transparency_button, - if (allowOffMode) 2.dpToPx() else 12.dpToPx(), - 12.dpToPx(), - 2.dpToPx(), - 12.dpToPx() - ) - } - } - - appWidgetManager.updateAppWidget(widgetIds, remoteViews) - } - - @OptIn(ExperimentalMaterial3Api::class) - fun updateNotificationContent( - connected: Boolean, airpodsName: String? = null, batteryList: List? = null - ) { - val notificationManager = getSystemService(NotificationManager::class.java) - - val notificationIntent = Intent(this, MainActivity::class.java) - val pendingIntent = PendingIntent.getActivity( - this, - 0, - notificationIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - if (BluetoothConnectionManager.aacpSocket == null) { - return - } - if (BluetoothConnectionManager.aacpSocket?.isConnected == true) { - val updatedNotificationBuilder = - NotificationCompat.Builder(this, "airpods_connection_status") - .setSmallIcon(R.drawable.airpods) - .setContentTitle(airpodsName ?: config.deviceName).setContentText( - """${ - batteryList?.find { it.component == BatteryComponent.LEFT }?.let { - if (it.status != BatteryStatus.DISCONNECTED) { - "L: ${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" - } else { - "" - } - } ?: "" - } ${ - batteryList?.find { it.component == BatteryComponent.RIGHT }?.let { - if (it.status != BatteryStatus.DISCONNECTED) { - "R: ${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" - } else { - "" - } - } ?: "" - } ${ - batteryList?.find { it.component == BatteryComponent.CASE }?.let { - if (it.status != BatteryStatus.DISCONNECTED) { - "Case: ${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" - } else { - "" - } - } ?: "" - }""").setContentIntent(pendingIntent).setCategory(Notification.CATEGORY_STATUS) - .setPriority(NotificationCompat.PRIORITY_LOW).setOngoing(true) - - if (disconnectedBecauseReversed) { - updatedNotificationBuilder.addAction( - R.drawable.ic_bluetooth, "Reconnect", PendingIntent.getService( - this, 0, Intent(this, AirPodsService::class.java).apply { - action = "me.kavishdevar.librepods.RECONNECT_AFTER_REVERSE" - }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - ) - } - - val updatedNotification = updatedNotificationBuilder.build() - - notificationManager.notify(2, updatedNotification) - notificationManager.cancel(1) - } else if (!connected) { - notificationManager.cancel(2) - } else if (!config.bleOnlyMode && BluetoothConnectionManager.aacpSocket?.isConnected != true) { - showSocketConnectionFailureNotification("BluetoothConnectionManager.aacpSocket? created, but not connected. Check logs") - } - } - - fun handleIncomingCall() { - if (isInCall) return - if (config.headGestures) { - initGestureDetector() - startHeadTracking() - gestureDetector?.startDetection { accepted -> - if (accepted) { - answerCall() - handleIncomingCallOnceConnected = false - } else { - rejectCall() - handleIncomingCallOnceConnected = false - } - } - - } - } - - @OptIn(ExperimentalCoroutinesApi::class) - suspend fun testHeadGestures(): Boolean { - return suspendCancellableCoroutine { continuation -> - gestureDetector?.startDetection(doNotStop = true) { accepted -> - if (continuation.isActive) { - continuation.resume(accepted) { _, _, _ -> - gestureDetector?.stopDetection() - } - } - } - } - } - - private fun answerCall() { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager - if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) == PackageManager.PERMISSION_GRANTED) { - telecomManager.acceptRingingCall() // TODO: Switch to InCallService (needs CDM association) - } - } else { - val telephonyService = getSystemService(TELEPHONY_SERVICE) as TelephonyManager - val telephonyClass = Class.forName(telephonyService.javaClass.name) - val method = telephonyClass.getDeclaredMethod("getITelephony") - method.isAccessible = true - val telephonyInterface = method.invoke(telephonyService) - val answerCallMethod = - telephonyInterface.javaClass.getDeclaredMethod("answerRingingCall") - answerCallMethod.invoke(telephonyInterface) - } - - sendToast("Call answered via head gesture") - } catch (e: Exception) { - e.printStackTrace() - sendToast("Failed to answer call: ${e.message}") - } finally { - islandWindow?.close() - } - } - - private fun rejectCall() { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager - if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) == PackageManager.PERMISSION_GRANTED) { - telecomManager.endCall() // TODO: Switch to InCallService (needs CDM association) - } - } else { - val telephonyService = getSystemService(TELEPHONY_SERVICE) as TelephonyManager - val telephonyClass = Class.forName(telephonyService.javaClass.name) - val method = telephonyClass.getDeclaredMethod("getITelephony") - method.isAccessible = true - val telephonyInterface = method.invoke(telephonyService) - val endCallMethod = telephonyInterface.javaClass.getDeclaredMethod("endCall") - endCallMethod.invoke(telephonyInterface) - } - - sendToast("Call rejected via head gesture") - } catch (e: Exception) { - e.printStackTrace() - sendToast("Failed to reject call: ${e.message}") - } finally { - islandWindow?.close() - } - } - - fun sendToast(message: String) { - Handler(Looper.getMainLooper()).post { - Toast.makeText(applicationContext, message, Toast.LENGTH_SHORT).show() - } - } - - @RequiresApi(Build.VERSION_CODES.R) - fun processHeadTrackingData(data: ByteArray) { - val horizontal = ByteBuffer.wrap(data, 51, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt() - val vertical = ByteBuffer.wrap(data, 53, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt() - try { - gestureDetector?.processHeadOrientation(horizontal, vertical) - } catch (e: Exception) { - Log.w(TAG, "gesture detector on ${data.toHexString()}: ${e.message}") - } - } - - private lateinit var connectionReceiver: BroadcastReceiver - - private fun resToUri(resId: Int): Uri? { - return try { - Uri.Builder().scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) - .authority("me.kavishdevar.librepods") - .appendPath(applicationContext.resources.getResourceTypeName(resId)) - .appendPath(applicationContext.resources.getResourceEntryName(resId)).build() - } catch (_: Resources.NotFoundException) { - null - } - } - - @Suppress("PrivatePropertyName") - private val VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV = "+IPHONEACCEV" - - @Suppress("PrivatePropertyName") - private val VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV_BATTERY_LEVEL = 1 - - @Suppress("PrivatePropertyName") - private val APPLE = 0x004C - - @Suppress("PrivatePropertyName") - private val ACTION_BATTERY_LEVEL_CHANGED = - "android.bluetooth.device.action.BATTERY_LEVEL_CHANGED" - - @Suppress("PrivatePropertyName") - private val EXTRA_BATTERY_LEVEL = "android.bluetooth.device.extra.BATTERY_LEVEL" - - @Suppress("PrivatePropertyName") - private val PACKAGE_ASI = "com.google.android.settings.intelligence" - - @Suppress("PrivatePropertyName") - private val ACTION_ASI_UPDATE_BLUETOOTH_DATA = "batterywidget.impl.action.update_bluetooth_data" - - @SuppressLint("MissingPermission") - fun broadcastBatteryInformation() { - if (device == null || checkSelfPermission("android.permission.INTERACT_ACROSS_USERS") != PackageManager.PERMISSION_GRANTED) return - - val batteryList = batteryNotification.getBattery() - val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT } - val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT } - - // Calculate unified battery level (minimum of left and right) - val batteryUnified = minOf( - leftBattery?.level ?: 100, rightBattery?.level ?: 100 - ) - - // Check charging status - val isLeftCharging = leftBattery?.status == BatteryStatus.CHARGING - val isRightCharging = rightBattery?.status == BatteryStatus.CHARGING - isLeftCharging && isRightCharging - - // Create arguments for vendor-specific event - val arguments = arrayOf( - 1, // Number of key/value pairs - VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV_BATTERY_LEVEL, // IndicatorType: Battery Level - batteryUnified // Battery Level - ) - - // Broadcast vendor-specific event - val intent = Intent(BluetoothHeadset.ACTION_VENDOR_SPECIFIC_HEADSET_EVENT).apply { - putExtra( - BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD, - VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV - ) - putExtra( - BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD_TYPE, - BluetoothHeadset.AT_CMD_TYPE_SET - ) - putExtra(BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_ARGS, arguments) - putExtra(BluetoothDevice.EXTRA_DEVICE, device) - putExtra(BluetoothDevice.EXTRA_NAME, device?.name) - addCategory("${BluetoothHeadset.VENDOR_SPECIFIC_HEADSET_EVENT_COMPANY_ID_CATEGORY}.$APPLE") - } - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - sendBroadcastAsUser( - intent, - UserHandle.getUserHandleForUid(-1), - Manifest.permission.BLUETOOTH_CONNECT - ) - } else { - sendBroadcastAsUser(intent, UserHandle.getUserHandleForUid(-1)) - } - } catch (e: Exception) { - Log.e(TAG, "Failed to send vendor-specific event: ${e.message}") - } - - // Broadcast battery level changes - val batteryIntent = Intent(ACTION_BATTERY_LEVEL_CHANGED).apply { - putExtra(BluetoothDevice.EXTRA_DEVICE, device) - putExtra(EXTRA_BATTERY_LEVEL, batteryUnified) - } - - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - sendBroadcast(batteryIntent, Manifest.permission.BLUETOOTH_CONNECT) - } else { - sendBroadcastAsUser(batteryIntent, UserHandle.getUserHandleForUid(-1)) - } - } catch (e: Exception) { - Log.e(TAG, "Failed to send battery level broadcast: ${e.message}") - } - - // Update Android Settings Intelligence's battery widget - val statusIntent = Intent(ACTION_ASI_UPDATE_BLUETOOTH_DATA).apply { - setPackage(PACKAGE_ASI) - putExtra(ACTION_BATTERY_LEVEL_CHANGED, intent) - } - - try { - sendBroadcastAsUser(statusIntent, UserHandle.getUserHandleForUid(-1)) - } catch (e: Exception) { - Log.e(TAG, "Failed to send ASI battery level broadcast: ${e.message}") - } - - Log.d(TAG, "Broadcast battery level $batteryUnified% to system") - } - - private fun setMetadatas(d: BluetoothDevice) { - if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) { - Log.d(TAG, "no permission BLUETOOTH_PRIVILEGED, returning") - return - } - Log.d(TAG, "has permission BLUETOOTH_PRIVILEGED, proceeding") - d.let { device -> - val instance = airpodsInstance - if (instance != null) { - val metadataSet = SystemApisUtils.setMetadata( - device, - device.METADATA_MAIN_ICON, - resToUri(instance.model.budCaseRes).toString().toByteArray() - ) && SystemApisUtils.setMetadata( - device, device.METADATA_MODEL_NAME, instance.model.name.toByteArray() - ) && SystemApisUtils.setMetadata( - device, - device.METADATA_DEVICE_TYPE, - device.DEVICE_TYPE_UNTETHERED_HEADSET.toByteArray() - ) && SystemApisUtils.setMetadata( - device, - device.METADATA_UNTETHERED_CASE_ICON, - resToUri(instance.model.caseRes).toString().toByteArray() - ) && SystemApisUtils.setMetadata( - device, - device.METADATA_UNTETHERED_RIGHT_ICON, - resToUri(instance.model.rightBudsRes).toString().toByteArray() - ) && SystemApisUtils.setMetadata( - device, - device.METADATA_UNTETHERED_LEFT_ICON, - resToUri(instance.model.leftBudsRes).toString().toByteArray() - ) && SystemApisUtils.setMetadata( - device, - device.METADATA_MANUFACTURER_NAME, - instance.model.manufacturer.toByteArray() - ) && SystemApisUtils.setMetadata( - device, device.METADATA_COMPANION_APP, "me.kavishdevar.librepods".toByteArray() - ) && SystemApisUtils.setMetadata( - device, - device.METADATA_UNTETHERED_CASE_LOW_BATTERY_THRESHOLD, - "20".toByteArray() - ) && SystemApisUtils.setMetadata( - device, - device.METADATA_UNTETHERED_LEFT_LOW_BATTERY_THRESHOLD, - "20".toByteArray() - ) && SystemApisUtils.setMetadata( - device, - device.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD, - "20".toByteArray() - ) - Log.d(TAG, "Metadata set: $metadataSet") - } else { - Log.w( - TAG, - "AirPods demoInstance is not of type AirPodsInstance, skipping metadata setting" - ) - } - } - } - - @Suppress("ClassName") - private object bluetoothReceiver : BroadcastReceiver() { - @SuppressLint("MissingPermission") - override fun onReceive(context: Context?, intent: Intent) { - val bluetoothDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra( - "android.bluetooth.device.extra.DEVICE", BluetoothDevice::class.java - ) - } else { - intent.getParcelableExtra("android.bluetooth.device.extra.DEVICE") as BluetoothDevice? - } - val action = intent.action - val context = context?.applicationContext - val name = context?.getSharedPreferences("settings", MODE_PRIVATE) - ?.getString("name", bluetoothDevice?.name) - if (bluetoothDevice != null && !action.isNullOrEmpty()) { - Log.d(TAG, "Received bluetooth connection broadcast: action=$action") - val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") - - if (BluetoothDevice.ACTION_ACL_CONNECTED == action) { - if (bluetoothDevice.uuids?.contains(uuid) == true) { - val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) - intent.putExtra("name", name) - intent.putExtra("device", bluetoothDevice) - context?.sendBroadcast(intent) - } else { - bluetoothDevice.fetchUuidsWithSdp() - } - } else if ("android.bluetooth.device.action.UUID" == action) { - val savedMac = context?.getSharedPreferences("settings", MODE_PRIVATE) - ?.getString("mac_address", "") ?: "" - val matchedByMac = savedMac.isNotEmpty() && bluetoothDevice.address == savedMac - val matchedByUuid = bluetoothDevice.uuids?.contains(uuid) == true - if (matchedByUuid || matchedByMac) { - val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) - intent.putExtra("name", name) - intent.putExtra("device", bluetoothDevice) - context?.sendBroadcast(intent) - } - } - } - } - } - - val externalBroadcastFilter = IntentFilter().apply { - addAction("me.kavishdevar.librepods.SET_ANC_MODE") - addAction("me.kavishdevar.librepods.CONVO_DETECT") - } - var externalBroadcastReceiver: BroadcastReceiver? = null - - @SuppressLint("InlinedApi", "MissingPermission", "UnspecifiedRegisterReceiverFlag") - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Log.d(TAG, "Service started with intent action: ${intent?.action}") - - if (intent?.action == "me.kavishdevar.librepods.RECONNECT_AFTER_REVERSE") { - Log.d(TAG, "reconnect after reversed received, taking over") - disconnectedBecauseReversed = false - otherDeviceTookOver = false - takeOver("music", manualTakeOverAfterReversed = true) - } - - return START_STICKY - } - - @RequiresApi(Build.VERSION_CODES.R) - @SuppressLint("MissingPermission", "HardwareIds") - fun takeOver( - takingOverFor: String, - manualTakeOverAfterReversed: Boolean = false, - startHeadTrackingAgain: Boolean = false - ) { - if (takingOverFor == "reverse") { - aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value, 1 - ) - aacpManager.sendMediaInformataion( - localMac - ) - aacpManager.sendHijackReversed( - localMac - ) - connectAudio( - this@AirPodsService, device - ) - otherDeviceTookOver = false - } - val ownsConnection = aacpManager.getControlCommandStatus(AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION)?.value?.get(0)?.toInt() - Log.d( - TAG, "owns connection: $ownsConnection" - ) - if (BluetoothConnectionManager.aacpSocket?.isConnected == true) { - if (!XposedRemotePrefProvider.create().getBoolean("vendor_id_hook", false) || ownsConnection == 0) { - Log.d(TAG, "not taking over, vendorid is probably not set to apple") - return - } - if (aacpManager.getControlCommandStatus(AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION)?.value[0]?.toInt() != 1 || (aacpManager.audioSource?.mac != localMac && aacpManager.audioSource?.type != AACPManager.Companion.AudioSourceType.NONE)) { - if (disconnectedBecauseReversed) { - if (manualTakeOverAfterReversed) { - Log.d(TAG, "forcefully taking over despite reverse as user requested") - disconnectedBecauseReversed = false - } else { - Log.d( - TAG, - "connected locally, but can not hijack as other device had reversed" - ) - return - } - } - - Log.d(TAG, "already connected locally, hijacking connection by asking AirPods") - aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value, 1 - ) - aacpManager.sendMediaInformataion( - localMac - ) - aacpManager.sendSmartRoutingShowUI( - localMac - ) - aacpManager.sendHijackRequest( - localMac - ) - otherDeviceTookOver = false - connectAudio(this, device) - showIsland( - this, - batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost( - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level!! - ), - IslandType.CONNECTED - ) - - CoroutineScope(Dispatchers.IO).launch { - delay(500) // a2dp takes time, and so does taking control + AirPods pause it for no reason after connecting - if (takingOverFor == "music") { - Log.d(TAG, "Resuming music after taking control") - MediaController.sendPlay(replayWhenPaused = true) - } else if (startHeadTrackingAgain) { - Log.d(TAG, "Starting head tracking again after taking control") - Handler(Looper.getMainLooper()).postDelayed({ - startHeadTracking() - }, 500) - } - delay(1000) // should ideally have a callback when it's taken over because for some reason android doesn't dispatch when it's paused - if (takingOverFor == "music") { - Log.d(TAG, "resuming again just in case") - MediaController.sendPlay(force = true) - } - } - } else { - Log.d( - TAG, "Already connected locally and already own connection, skipping takeover" - ) - } - return - } - -// if (CrossDevice.isAvailable) { -// Log.d(TAG, "CrossDevice is available, continuing") -// } -// else if (bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true) { -// Log.d(TAG, "At least one AirPod is in ear, continuing") -// } -// else { -// Log.d(TAG, "CrossDevice not available and AirPods not in ear, skipping") -// return -// } - - if (bleManager.getMostRecentStatus()?.isLeftInEar == false && bleManager.getMostRecentStatus()?.isRightInEar == false) { - Log.d(TAG, "Both AirPods are out of ear, not taking over audio") - return - } - - val shouldTakeOverPState = when (takingOverFor) { - "music" -> config.takeoverWhenMediaStart - "call" -> config.takeoverWhenRingingCall - else -> false - } - - if (!shouldTakeOverPState) { - Log.d(TAG, "Not taking over audio, phone state takeover disabled") - return - } - - val shouldTakeOver = when (bleManager.getMostRecentStatus()?.connectionState) { - "Disconnected" -> config.takeoverWhenDisconnected - "Idle" -> config.takeoverWhenIdle - "Music" -> config.takeoverWhenMusic - "Call" -> config.takeoverWhenCall - "Ringing" -> config.takeoverWhenCall - "Hanging Up" -> config.takeoverWhenCall - else -> false - } - - if (!shouldTakeOver) { - Log.d(TAG, "Not taking over audio, airpods state takeover disabled") - return - } - - if (takingOverFor == "music") { - Log.d(TAG, "Pausing music so that it doesn't play through speakers") - MediaController.pausedWhileTakingOver = true - MediaController.sendPause(true) - } else { - handleIncomingCallOnceConnected = true - } - - Log.d(TAG, "Taking over audio") -// CrossDevice.sendRemotePacket(CrossDevicePackets.REQUEST_DISCONNECT.packet) - Log.d(TAG, macAddress) - -// sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false) } - val bluetoothManager = getSystemService(BluetoothManager::class.java) - val bluetoothAdapter = bluetoothManager.adapter - device = bluetoothAdapter.bondedDevices.find { - it.address == macAddress - } - - if (device != null) { - if (config.bleOnlyMode) { - // In BLE-only mode, just show connecting status without actual L2CAP connection - Log.d(TAG, "BLE-only mode: showing connecting status without L2CAP connection") - updateNotificationContent( - true, config.deviceName, batteryNotification.getBattery() - ) - // Set a temporary connecting state -// isConnectedLocally = false // Keep as false since we're not actually connecting to L2CAP - } else { - connectToSocket(bluetoothAdapter, device!!) - connectAudio(this, device) -// isConnectedLocally = true - } - } - showIsland( - this, - batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost( - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level!! - ), - IslandType.TAKING_OVER - ) - -// CrossDevice.isAvailable = false - } - - @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") - fun connectToSocket( - adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean = false - ) { - if (BluetoothConnectionManager.aacpSocket != null && BluetoothConnectionManager.aacpSocket?.isConnected == true) return - Log.d(TAG, " Connecting to socket") - val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") -// if (!isConnectedLocally) { - val socket = try { - createBluetoothSocket(adapter, device, uuid, 4097) - } catch (e: Exception) { - Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}") - showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}") - return - } - - try { - runBlocking { - withTimeout(5000.milliseconds) { - try { - socket.connect() - this@AirPodsService.device = device - val xposedRemotePref = XposedRemotePrefProvider.create() - val attSocket = if (xposedRemotePref.getBoolean("vendor_id_hook", false)) { - createBluetoothSocket( - adapter, - device, - ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"), - 31 - ) - } else null - attSocket?.connect() - - if (attSocket != null) { - attManager.startReader() - attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) - attManager.readCharacteristic(ATTHandles.TRANSPARENCY) - attManager.readCharacteristic(ATTHandles.HEARING_AID) - } - - BluetoothConnectionManager.aacpSocket = socket - BluetoothConnectionManager.attSocket = attSocket - - // Create AirPodsInstance from stored config if available - if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) { - val model = - AirPodsModels.getModelByModelNumber(config.airpodsModelNumber) - if (model != null) { - airpodsInstance = AirPodsInstance( - name = config.airpodsName, - model = model, - actualModelNumber = config.airpodsModelNumber, - serialNumber = config.airpodsSerialNumber, - leftSerialNumber = config.airpodsLeftSerialNumber, - rightSerialNumber = config.airpodsRightSerialNumber, - version1 = config.airpodsVersion1, - version2 = config.airpodsVersion2, - version3 = config.airpodsVersion3, - ) - setMetadatas(device) - } - } - - updateNotificationContent( - true, config.deviceName, batteryNotification.getBattery() - ) - Log.d(TAG, " Socket connected") - sharedPreferences.edit { putBoolean("connection_successful", true) } - if (!sharedPreferences.contains("first_connection_successful_time")) { - sharedPreferences.edit { - putLong( - "first_connection_successful_time", - System.currentTimeMillis() - ) - } - } - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED)) - } catch (e: Exception) { -// sharedPreferences.edit { putBoolean("connection_successful", false) } - Log.d( - TAG, " Socket not connected, ${e.message}" - ) - if (manual) { - sendToast( - "Couldn't connect to socket: ${e.localizedMessage}" - ) - } else { - showSocketConnectionFailureNotification("Couldn't connect to socket: ${e.localizedMessage}") - } - return@withTimeout -// throw e // lol how did i not catch this before... gonna comment this line instead of removing to preserve history - } - } - } - if (!socket.isConnected) { - Log.d(TAG, " socket not connected") - if (manual) { - sendToast( - "Couldn't connect to socket: timeout." - ) - } else { - showSocketConnectionFailureNotification("Couldn't connect to socket: Timeout") - } - return - } - this@AirPodsService.device = device - BluetoothConnectionManager.aacpSocket?.let { - aacpManager.sendPacket(aacpManager.createHandshakePacket()) - aacpManager.sendSetFeatureFlagsPacket() - aacpManager.sendNotificationRequest() - Log.d(TAG, "Requesting proximity keys") - aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte()) - CoroutineScope(Dispatchers.IO).launch { - delay(200) - aacpManager.sendPacket(aacpManager.createHandshakePacket()) - delay(200) - aacpManager.sendSetFeatureFlagsPacket() - delay(200) - aacpManager.sendNotificationRequest() - delay(200) - aacpManager.sendSomePacketIDontKnowWhatItIs() - delay(200) - aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte()) - if (!handleIncomingCallOnceConnected) startHeadTracking() else handleIncomingCall() - Handler(Looper.getMainLooper()).postDelayed({ - aacpManager.sendPacket(aacpManager.createHandshakePacket()) - aacpManager.sendSetFeatureFlagsPacket() - aacpManager.sendNotificationRequest() - aacpManager.sendRequestProximityKeys(AACPManager.Companion.ProximityKeyType.IRK.value) - if (!handleIncomingCallOnceConnected) stopHeadTracking() - }, 5000) - - sendBroadcast( - Intent(AirPodsNotifications.AIRPODS_CONNECTED).putExtra("device", device) - .apply { - setPackage(packageName) - }) - - setupStemActions() - - while (socket.isConnected) { - try { - val buffer = ByteArray(1024) - val bytesRead = it.inputStream.read(buffer) - var data: ByteArray - if (bytesRead > 0) { - data = buffer.copyOfRange(0, bytesRead) - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply { - putExtra("data", buffer.copyOfRange(0, bytesRead)) - setPackage(packageName) - }) - val bytes = buffer.copyOfRange(0, bytesRead) - val formattedHex = bytes.joinToString(" ") { "%02X".format(it) } -// CrossDevice.sendReceivedPacket(bytes) - updateNotificationContent( - true, - sharedPreferences.getString("name", device.name), - batteryNotification.getBattery() - ) - - aacpManager.receivePacket(data) - - if (!isHeadTrackingData(data)) { - Log.d("AirPodsData", "Data received: $formattedHex") - logPacket(data, "AirPods") - } - - } else if (bytesRead == -1) { - Log.d("AirPodsService", "socket closed (bytesRead = -1)") - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { - setPackage(packageName) - }) - aacpManager.disconnected() - return@launch - } - } catch (e: Exception) { - Log.w(TAG, "Error reading data, we have probably disconnected.") - e.printStackTrace() - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { - setPackage(packageName) - }) - aacpManager.disconnected() - return@launch - } - - } - Log.d("AirPods Service", "socket closed") -// isConnectedLocally = false - aacpManager.disconnected() - updateNotificationContent(false) - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { - setPackage(packageName) - }) - } - } - } catch (e: Exception) { - e.printStackTrace() - Log.d(TAG, "Failed to connect to BluetoothConnectionManager.aacpSocket?: ${e.message}") - showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}") -// isConnectedLocally = false - this@AirPodsService.device = device - updateNotificationContent(false) - } -// } else { -// Log.d(TAG, "Already connected locally, skipping BluetoothConnectionManager.aacpSocket? connection (isConnectedLocally = $isConnectedLocally, BluetoothConnectionManager.aacpSocket?.isConnected = ${this::BluetoothConnectionManager.aacpSocket?.isInitialized && BluetoothConnectionManager.aacpSocket?.isConnected})") -// } - } - - fun disconnectForCD() { - BluetoothConnectionManager.aacpSocket?.close() - MediaController.pausedWhileTakingOver = false - Log.d(TAG, "Disconnected from AirPods, showing island.") - showIsland( - this, - batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost( - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level!! - ), - IslandType.MOVED_TO_REMOTE - ) - val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter - bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.A2DP) { - val connectedDevices = proxy.connectedDevices - if (connectedDevices.isNotEmpty()) { - MediaController.sendPause() - } - } - bluetoothAdapter.closeProfileProxy(profile, proxy) - } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.A2DP) -// isConnectedLocally = false -// CrossDevice.isAvailable = true - } - - fun disconnectAirPods() { - if (BluetoothConnectionManager.aacpSocket == null) return - try { - BluetoothConnectionManager.aacpSocket?.close() - } catch(e: Exception) { - Log.e(TAG, "error closing aacp socket ${e.message}") - } -// isConnectedLocally = false - aacpManager.disconnected() - - BluetoothConnectionManager.aacpSocket = null - BluetoothConnectionManager.attSocket = null - - updateNotificationContent(false) - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { - setPackage(packageName) - }) - - val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter - if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED){ - bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.A2DP) { - val connectedDevices = proxy.connectedDevices - if (connectedDevices.isNotEmpty()) { - MediaController.sendPause() - } - } - bluetoothAdapter.closeProfileProxy(profile, proxy) - } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.A2DP) - try { - device?.disconnect() - } catch (e: Exception) { - Log.w(TAG, "device.disconnect() failed, $e") - } - } - if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED){ - bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.HEADSET) { - val connectedDevices = proxy.connectedDevices - if (connectedDevices.isNotEmpty()) { - MediaController.sendPause() - } - } - bluetoothAdapter.closeProfileProxy(profile, proxy) - } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.HEADSET) - } - Log.d(TAG, "Disconnected AirPods upon user request") - } - - val earDetectionNotification = AirPodsNotifications.EarDetection() - val ancNotification = AirPodsNotifications.ANC() - val batteryNotification = AirPodsNotifications.BatteryNotification() - val conversationAwarenessNotification = - AirPodsNotifications.ConversationalAwarenessNotification() - - @Suppress("unused") - fun setEarDetection(enabled: Boolean) { - if (config.earDetectionEnabled != enabled) { - config.earDetectionEnabled = enabled - sharedPreferences.edit { putBoolean("automatic_ear_detection", enabled) } - } - } - - fun getBattery(): List { -// if (!isConnectedLocally && CrossDevice.isAvailable) { -// batteryNotification.setBattery(CrossDevice.batteryBytes) -// } - return batteryNotification.getBattery() - } - - fun getANC(): Int { -// if (!isConnectedLocally && CrossDevice.isAvailable) { -// ancNotification.setStatus(CrossDevice.ancBytes) -// } - return ancNotification.status - } - - fun disconnectAudio(context: Context, device: BluetoothDevice?) { - val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter - if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) { - bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.A2DP) { - try { - if (proxy.getConnectionState(device) == BluetoothProfile.STATE_DISCONNECTED) { - Log.d(TAG, "Already disconnected from A2DP") - return - } - val method = proxy.javaClass.getMethod( - "setConnectionPolicy", BluetoothDevice::class.java, Int::class.java - ) - Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 0") - method.invoke(proxy, device, 0) - } catch (e: Exception) { - e.printStackTrace() - } finally { - bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) - } - } - } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.A2DP) - } else { - Log.d(TAG, "not disconnecting A2DP, no BLUETOOTH_PRIVILEGED permission") - } - if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { - bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.HEADSET) { - try { - val method = - proxy.javaClass.getMethod( - "setConnectionPolicy", - BluetoothDevice::class.java, - Int::class.java - ) - Log.d(TAG, "calling HEADSET.setConnectionPolicy for ${device?.address} to 0") - method.invoke(proxy, device, 0) - } catch (e: Exception) { - e.printStackTrace() - } finally { - bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) - } - } - } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.HEADSET) - } else { - Log.d(TAG, "not disconnecting HEADSET, no MODIFIY_PHONE_STATE permission") - } - } - - fun connectAudio(context: Context, device: BluetoothDevice?) { - val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter - - bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.A2DP) { - if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) { - try { - val policyMethod = proxy.javaClass.getMethod( - "setConnectionPolicy", - BluetoothDevice::class.java, - Int::class.java - ) - Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 100") - policyMethod.invoke(proxy, device, 100) - - val connectMethod = - proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) - connectMethod.invoke( - proxy, device - ) - } catch (e: Exception) { - e.printStackTrace() - } finally { - bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) - if (MediaController.pausedWhileTakingOver) { - MediaController.sendPlay() - } - } - } - else { - val connectMethod = - proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) - connectMethod.invoke( - proxy, device - ) - Log.d(TAG, "not setting connection policy for A2DP, no BLUETOOTH_PRIVILEGED permission. just called connect") - } - } - } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.A2DP) - - bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.HEADSET) { - if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { - try { - val policyMethod = proxy.javaClass.getMethod( - "setConnectionPolicy", - BluetoothDevice::class.java, - Int::class.java - ) - Log.d( - TAG, - "calling HEADSET.setConnectionPolicy for ${device?.address} to 100" - ) - policyMethod.invoke(proxy, device, 100) - val connectMethod = - proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) - connectMethod.invoke(proxy, device) - } catch (e: Exception) { - e.printStackTrace() - } finally { - bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) - } - } else { - Log.d(TAG, "not setting connection policy for HEADSET, no MODIFIY_PHONE_STATE permission") - } - } - } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.HEADSET) - } - - fun setName(name: String) { - aacpManager.sendRename(name) - - if (config.deviceName != name) { - config.deviceName = name - device?.alias = name - sharedPreferences.edit { putString("name", name) } - } - - updateNotificationContent(true, name, batteryNotification.getBattery()) - Log.d(TAG, "setName: $name") - } - - @SuppressLint("MissingPermission") - override fun onDestroy() { - clearPacketLogs() - Log.d(TAG, "Service stopped is being destroyed for some reason!") - - sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) - - try { - unregisterReceiver(bluetoothReceiver) - } catch (e: Exception) { - e.printStackTrace() - } - try { - unregisterReceiver(externalBroadcastReceiver) - } catch (e: Exception) { - e.printStackTrace() - } - try { - unregisterReceiver(connectionReceiver) - } catch (e: Exception) { - e.printStackTrace() - } - try { - unregisterReceiver(earReceiver) - } catch (e: Exception) { - e.printStackTrace() - } - try { - bleManager.stopScanning() - } catch (e: Exception) { - e.printStackTrace() - } - if (checkSelfPermission("android.permission.READ_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { - telephonyManager.unregisterTelephonyCallback(phoneStateListener) - } -// isConnectedLocally = false -// CrossDevice.isAvailable = true - super.onDestroy() - } - - var isHeadTrackingActive = false - - fun startHeadTracking() { - isHeadTrackingActive = true - val useAlternatePackets = - sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && aacpManager.getControlCommandStatus( - AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION - )?.value?.get(0)?.toInt() != 1 - ) { - takeOver("call", startHeadTrackingAgain = true) - Log.d(TAG, "Taking over for head tracking") - } else { - Log.w(TAG, "Will not be taking over for head tracking, might not work.") - } - if (useAlternatePackets) { - aacpManager.sendDataPacket(aacpManager.createAlternateStartHeadTrackingPacket()) - } else { - aacpManager.sendStartHeadTracking() - } - HeadTracking.reset() - } - - fun stopHeadTracking() { - val useAlternatePackets = - sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true) - if (useAlternatePackets) { - aacpManager.sendDataPacket(aacpManager.createAlternateStopHeadTrackingPacket()) - } else { - aacpManager.sendStopHeadTracking() - } - isHeadTrackingActive = false - gestureDetector?.stopDetection() - } - - @SuppressLint("MissingPermission") - fun reconnectFromSavedMac() { - val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter - device = bluetoothAdapter.bondedDevices.find { - it.address == macAddress - } - if (device != null) { - CoroutineScope(Dispatchers.IO).launch { - Log.d(TAG, "connecting to $macAddress") - connectToSocket(bluetoothAdapter, device!!, manual = true) - connectAudio(this@AirPodsService, device!!) - } - } - } -} - -private fun Int.dpToPx(): Int { - val density = Resources.getSystem().displayMetrics.density - return (this * density).toInt() -} - -fun getNextMode(currentMode: Int, configByte: Int, offmodeEnabled: Boolean): Int { - val enabledModes = buildList { - if ((configByte and 0x01) != 0 && offmodeEnabled) add(1) - if ((configByte and 0x04) != 0) add(3) - if ((configByte and 0x08) != 0) add(4) - if ((configByte and 0x02) != 0) add(2) - } - Log.d(TAG, "currentMode: $currentMode, config: ${configByte.toString(2)}") - - if (enabledModes.isEmpty()) return currentMode - - val currentIndex = enabledModes.indexOf(currentMode) - val nextIndex = if (currentIndex == -1) 0 else (currentIndex + 1) % enabledModes.size - - return enabledModes[nextIndex] -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothCryptography.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothCryptography.kt deleted file mode 100644 index 80d3a456..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothCryptography.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.utils - -import android.annotation.SuppressLint -import javax.crypto.Cipher -import javax.crypto.spec.SecretKeySpec - -/** - * Utilities for Bluetooth cryptography operations, particularly for - * verifying Resolvable Private Addresses (RPA) used by AirPods. - */ -object BluetoothCryptography { - - /** - * Verifies if the provided Bluetooth address is an RPA that matches the given Identity Resolving Key (IRK) - * - * @param addr The Bluetooth address to verify - * @param irk The Identity Resolving Key to use for verification - * @return true if the address is verified as an RPA matching the IRK - */ - fun verifyRPA(addr: String, irk: ByteArray): Boolean { - val rpa = addr.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray() - val prand = rpa.copyOfRange(3, 6) - val hash = rpa.copyOfRange(0, 3) - val computedHash = ah(irk, prand) - return hash.contentEquals(computedHash) - } - - /** - * Performs E function (AES-128) as specified in Bluetooth Core Specification - * - * @param key The key for encryption - * @param data The data to encrypt - * @return The encrypted data - */ - @SuppressLint("GetInstance") - fun e(key: ByteArray, data: ByteArray): ByteArray { - val swappedKey = key.reversedArray() - val swappedData = data.reversedArray() - val cipher = Cipher.getInstance("AES/ECB/NoPadding") - val secretKey = SecretKeySpec(swappedKey, "AES") - cipher.init(Cipher.ENCRYPT_MODE, secretKey) - return cipher.doFinal(swappedData).reversedArray() - } - - /** - * Performs the ah function as specified in Bluetooth Core Specification - * - * @param k The IRK key - * @param r The random part of the address - * @return The hash part of the address - */ - fun ah(k: ByteArray, r: ByteArray): ByteArray { - val rPadded = ByteArray(16) - r.copyInto(rPadded, 0, 0, 3) - val encrypted = e(k, rPadded) - return encrypted.copyOfRange(0, 3) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt deleted file mode 100644 index e5a1e7bd..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt +++ /dev/null @@ -1,761 +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 . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.utils - -import android.content.Context -import android.util.Log -import androidx.compose.runtime.NoLiveLiterals -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.withContext -import me.kavishdevar.librepods.services.ServiceManager -import java.io.BufferedReader -import java.io.File -import java.io.FileOutputStream -import java.io.InputStreamReader -import java.net.HttpURLConnection -import java.net.URL -import kotlin.io.encoding.ExperimentalEncodingApi - -@NoLiveLiterals -class RadareOffsetFinder(context: Context) { - companion object { - private const val TAG = "RadareOffsetFinder" - private const val RADARE2_URL = "https://github.com/devnoname120/radare2/releases/download/5.9.8-android-aln/radare2-5.9.9-android-aarch64-aln.tar.gz" - private const val HOOK_OFFSET_PROP = "persist.librepods.hook_offset" - private const val CFG_REQ_OFFSET_PROP = "persist.librepods.cfg_req_offset" - private const val CSM_CONFIG_OFFSET_PROP = "persist.librepods.csm_config_offset" - private const val PEER_INFO_REQ_OFFSET_PROP = "persist.librepods.peer_info_req_offset" - private const val SDP_OFFSET_PROP = "persist.librepods.sdp_offset" - private const val EXTRACT_DIR = "/" - - private const val RADARE2_BIN_PATH = "$EXTRACT_DIR/data/local/tmp/aln_unzip/org.radare.radare2installer/radare2/bin" - private const val RADARE2_LIB_PATH = "$EXTRACT_DIR/data/local/tmp/aln_unzip/org.radare.radare2installer/radare2/lib" - private const val BUSYBOX_PATH = "$EXTRACT_DIR/data/local/tmp/aln_unzip/busybox" - - private val LIBRARY_PATHS = listOf( - "/apex/com.android.bt/lib64/libbluetooth_jni.so", - "/apex/com.android.btservices/lib64/libbluetooth_jni.so", - "/system/lib64/libbluetooth_jni.so", - "/system/lib64/libbluetooth_qti.so", - "/system_ext/lib64/libbluetooth_qti.so" - ) - - fun findBluetoothLibraryPath(): String? { - for (path in LIBRARY_PATHS) { - if (File(path).exists()) { - Log.d(TAG, "Found Bluetooth library at $path") - return path - } - } - Log.e(TAG, "Could not find Bluetooth library") - return null - } - - fun clearHookOffsets(): Boolean { - try { - val process = Runtime.getRuntime().exec(arrayOf( - "su", "-c", - "/system/bin/setprop $HOOK_OFFSET_PROP '' && " + - "/system/bin/setprop $CFG_REQ_OFFSET_PROP '' && " + - "/system/bin/setprop $CSM_CONFIG_OFFSET_PROP '' && " + - "/system/bin/setprop $PEER_INFO_REQ_OFFSET_PROP '' &&" + - "/system/bin/setprop $SDP_OFFSET_PROP ''" - )) - val exitCode = process.waitFor() - - if (exitCode == 0) { - Log.d(TAG, "Successfully cleared hook offset properties") - return true - } else { - Log.e(TAG, "Failed to clear hook offset properties, exit code: $exitCode") - } - } catch (e: Exception) { - Log.e(TAG, "Error clearing hook offset properties", e) - } - return false - } - - fun clearSdpOffset(): Boolean { - try { - val process = Runtime.getRuntime().exec(arrayOf( - "su", "-c", "/system/bin/setprop $SDP_OFFSET_PROP ''" - )) - val exitCode = process.waitFor() - - if (exitCode == 0) { - Log.d(TAG, "Successfully cleared SDP offset property") - return true - } else { - Log.e(TAG, "Failed to clear SDP offset property, exit code: $exitCode") - } - } catch (e: Exception) { - Log.e(TAG, "Error clearing SDP offset property", e) - } - return false - } - - fun isSdpOffsetAvailable(): Boolean { - val sharedPreferences = ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE) // ik not good practice- too lazy - if (sharedPreferences?.getBoolean("skip_setup", false) == true) { - Log.d(TAG, "Setup skipped, returning true for SDP offset.") - return true - } - try { - val process = Runtime.getRuntime().exec(arrayOf("/system/bin/getprop", SDP_OFFSET_PROP)) - val reader = BufferedReader(InputStreamReader(process.inputStream)) - val propValue = reader.readLine() - process.waitFor() - - if (propValue != null && propValue.isNotEmpty()) { - Log.d(TAG, "SDP offset property exists: $propValue") - return true - } - } catch (e: Exception) { - Log.e(TAG, "Error checking if SDP offset property exists", e) - } - - Log.d(TAG, "No SDP offset available") - return false - } - } - - private val radare2TarballFile = File(context.cacheDir, "radare2.tar.gz") - - private val _progressState = MutableStateFlow(ProgressState.Idle) - val progressState: StateFlow = _progressState - - sealed class ProgressState { - object Idle : ProgressState() - object CheckingExisting : ProgressState() - object Downloading : ProgressState() - data class DownloadProgress(val progress: Float) : ProgressState() - object Extracting : ProgressState() - object MakingExecutable : ProgressState() - object FindingOffset : ProgressState() - object SavingOffset : ProgressState() - object Cleaning : ProgressState() - data class Error(val message: String) : ProgressState() - data class Success(val offset: Long) : ProgressState() - } - - - fun isHookOffsetAvailable(): Boolean { - Log.d(TAG, "Setup Skipped? " + ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE)?.getBoolean("skip_setup", false).toString()) - if (ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE)?.getBoolean("skip_setup", false) == true) { - Log.d(TAG, "Setup skipped, returning true.") - return true - } - _progressState.value = ProgressState.CheckingExisting - try { - val process = Runtime.getRuntime().exec(arrayOf("/system/bin/getprop", HOOK_OFFSET_PROP)) - val reader = BufferedReader(InputStreamReader(process.inputStream)) - val propValue = reader.readLine() - process.waitFor() - - if (propValue != null && propValue.isNotEmpty()) { - Log.d(TAG, "Hook offset property exists: $propValue") - _progressState.value = ProgressState.Idle - return true - } - } catch (e: Exception) { - Log.e(TAG, "Error checking if offset property exists", e) - _progressState.value = ProgressState.Error("Failed to check if offset property exists: ${e.message}") - } - - Log.d(TAG, "No hook offset available") - _progressState.value = ProgressState.Idle - return false - } - - suspend fun setupAndFindOffset(): Boolean { - val offset = findOffset() - return offset > 0 - } - - suspend fun findOffset(): Long = withContext(Dispatchers.IO) { - try { - _progressState.value = ProgressState.Downloading - if (!downloadRadare2TarballIfNeeded()) { - _progressState.value = ProgressState.Error("Failed to download radare2 tarball") - Log.e(TAG, "Failed to download radare2 tarball") - return@withContext 0L - } - - _progressState.value = ProgressState.Extracting - if (!extractRadare2Tarball()) { - _progressState.value = ProgressState.Error("Failed to extract radare2 tarball") - Log.e(TAG, "Failed to extract radare2 tarball") - return@withContext 0L - } - - _progressState.value = ProgressState.MakingExecutable - if (!makeExecutable()) { - _progressState.value = ProgressState.Error("Failed to make binaries executable") - Log.e(TAG, "Failed to make binaries executable") - return@withContext 0L - } - - _progressState.value = ProgressState.FindingOffset - val offset = findFunctionOffset() - if (offset == 0L) { - _progressState.value = ProgressState.Error("Failed to find function offset") - Log.e(TAG, "Failed to find function offset") - return@withContext 0L - } - - _progressState.value = ProgressState.SavingOffset - if (!saveOffset(offset)) { - _progressState.value = ProgressState.Error("Failed to save offset") - Log.e(TAG, "Failed to save offset") - return@withContext 0L - } - - _progressState.value = ProgressState.Cleaning - cleanupExtractedFiles() - - _progressState.value = ProgressState.Success(offset) - return@withContext offset - - } catch (e: Exception) { - _progressState.value = ProgressState.Error("Error: ${e.message}") - Log.e(TAG, "Error in findOffset", e) - return@withContext 0L - } - } - - private suspend fun downloadRadare2TarballIfNeeded(): Boolean = withContext(Dispatchers.IO) { - if (radare2TarballFile.exists() && radare2TarballFile.length() > 0) { - Log.d(TAG, "Radare2 tarball already downloaded to ${radare2TarballFile.absolutePath}") - return@withContext true - } - - try { - val url = URL(RADARE2_URL) - val connection = url.openConnection() as HttpURLConnection - connection.connectTimeout = 60000 - connection.readTimeout = 60000 - - val contentLength = connection.contentLength.toFloat() - val inputStream = connection.inputStream - val outputStream = FileOutputStream(radare2TarballFile) - - val buffer = ByteArray(4096) - var bytesRead: Int - var totalBytesRead = 0L - - while (inputStream.read(buffer).also { bytesRead = it } != -1) { - outputStream.write(buffer, 0, bytesRead) - totalBytesRead += bytesRead - if (contentLength > 0) { - val progress = totalBytesRead.toFloat() / contentLength - _progressState.value = ProgressState.DownloadProgress(progress) - } - } - - outputStream.close() - inputStream.close() - - Log.d(TAG, "Download successful to ${radare2TarballFile.absolutePath}") - return@withContext true - } catch (e: Exception) { - Log.e(TAG, "Failed to download radare2 tarball", e) - return@withContext false - } - } - - private suspend fun extractRadare2Tarball(): Boolean = withContext(Dispatchers.IO) { - try { - val isAlreadyExtracted = checkIfAlreadyExtracted() - - if (isAlreadyExtracted) { - Log.d(TAG, "Radare2 files already extracted correctly, skipping extraction") - return@withContext true - } - - Log.d(TAG, "Removing existing extract directory") - Runtime.getRuntime().exec(arrayOf("su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor() - - Runtime.getRuntime().exec(arrayOf("su", "-c", "mkdir -p $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor() - - Log.d(TAG, "Extracting ${radare2TarballFile.absolutePath} to $EXTRACT_DIR") - - val process = Runtime.getRuntime().exec( - arrayOf("su", "-c", "tar xvf ${radare2TarballFile.absolutePath} -C $EXTRACT_DIR") - ) - - val reader = BufferedReader(InputStreamReader(process.inputStream)) - val errorReader = BufferedReader(InputStreamReader(process.errorStream)) - - var line: String? - while (reader.readLine().also { line = it } != null) { - Log.d(TAG, "Extract output: $line") - } - - while (errorReader.readLine().also { line = it } != null) { - Log.e(TAG, "Extract error: $line") - } - - val exitCode = process.waitFor() - if (exitCode == 0) { - Log.d(TAG, "Extraction completed successfully") - return@withContext true - } else { - Log.e(TAG, "Extraction failed with exit code $exitCode") - return@withContext false - } - } catch (e: Exception) { - Log.e(TAG, "Failed to extract radare2", e) - return@withContext false - } - } - - private suspend fun checkIfAlreadyExtracted(): Boolean = withContext(Dispatchers.IO) { - try { - val checkDirProcess = Runtime.getRuntime().exec( - arrayOf("su", "-c", "[ -d $EXTRACT_DIR/data/local/tmp/aln_unzip ] && echo 'exists'") - ) - val dirExists = BufferedReader(InputStreamReader(checkDirProcess.inputStream)).readLine() == "exists" - checkDirProcess.waitFor() - - if (!dirExists) { - Log.d(TAG, "Extract directory doesn't exist, need to extract") - return@withContext false - } - - val tarProcess = Runtime.getRuntime().exec( - arrayOf("su", "-c", "tar tf ${radare2TarballFile.absolutePath}") - ) - val tarFiles = BufferedReader(InputStreamReader(tarProcess.inputStream)).readLines() - .filter { it.isNotEmpty() } - .map { it.trim() } - .toSet() - tarProcess.waitFor() - - if (tarFiles.isEmpty()) { - Log.e(TAG, "Failed to get file list from tarball") - return@withContext false - } - - val findProcess = Runtime.getRuntime().exec( - arrayOf("su", "-c", "find $EXTRACT_DIR/data/local/tmp/aln_unzip -type f | sort") - ) - val extractedFiles = BufferedReader(InputStreamReader(findProcess.inputStream)).readLines() - .filter { it.isNotEmpty() } - .map { it.trim() } - .toSet() - findProcess.waitFor() - - if (extractedFiles.isEmpty()) { - Log.d(TAG, "No files found in extract directory, need to extract") - return@withContext false - } - - for (tarFile in tarFiles) { - if (tarFile.endsWith("/")) continue - - val filePathInExtractDir = "$EXTRACT_DIR/$tarFile" - val fileCheckProcess = Runtime.getRuntime().exec( - arrayOf("su", "-c", "[ -f $filePathInExtractDir ] && echo 'exists'") - ) - val fileExists = BufferedReader(InputStreamReader(fileCheckProcess.inputStream)).readLine() == "exists" - fileCheckProcess.waitFor() - - if (!fileExists) { - Log.d(TAG, "File $filePathInExtractDir from tarball missing in extract directory") - Runtime.getRuntime().exec(arrayOf("su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor() - return@withContext false - } - } - - Log.d(TAG, "All ${tarFiles.size} files from tarball exist in extract directory") - return@withContext true - } catch (e: Exception) { - Log.e(TAG, "Error checking extraction status", e) - return@withContext false - } - } - - private suspend fun makeExecutable(): Boolean = withContext(Dispatchers.IO) { - try { - Log.d(TAG, "Making binaries executable in $RADARE2_BIN_PATH") - val chmod1Result = Runtime.getRuntime().exec( - arrayOf("su", "-c", "chmod -R 755 $RADARE2_BIN_PATH") - ).waitFor() - - Log.d(TAG, "Making binaries executable in $BUSYBOX_PATH") - - val chmod2Result = Runtime.getRuntime().exec( - arrayOf("su", "-c", "chmod -R 755 $BUSYBOX_PATH") - ).waitFor() - - if (chmod1Result == 0 && chmod2Result == 0) { - Log.d(TAG, "Successfully made binaries executable") - return@withContext true - } else { - Log.e(TAG, "Failed to make binaries executable, exit codes: $chmod1Result, $chmod2Result") - return@withContext false - } - } catch (e: Exception) { - Log.e(TAG, "Error making binaries executable", e) - return@withContext false - } - } - - private suspend fun findFunctionOffset(): Long = withContext(Dispatchers.IO) { - val libraryPath = findBluetoothLibraryPath() ?: return@withContext 0L - var offset = 0L - - try { - @Suppress("LocalVariableName") val currentLD_LIBRARY_PATH = ProcessBuilder().command("su", "-c", "printenv LD_LIBRARY_PATH").start().inputStream.bufferedReader().readText().trim() - val currentPATH = ProcessBuilder().command("su", "-c", "printenv PATH").start().inputStream.bufferedReader().readText().trim() - val envSetup = """ - export LD_LIBRARY_PATH="$RADARE2_LIB_PATH:$currentLD_LIBRARY_PATH" - export PATH="$BUSYBOX_PATH:$RADARE2_BIN_PATH:$currentPATH" - """.trimIndent() - - val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep fcr_chk_chan" - Log.d(TAG, "Running command: $command") - - val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) - - val reader = BufferedReader(InputStreamReader(process.inputStream)) - val errorReader = BufferedReader(InputStreamReader(process.errorStream)) - - var line: String? - - while (reader.readLine().also { line = it } != null) { - Log.d(TAG, "rabin2 output: $line") - if (line?.contains("fcr_chk_chan") == true) { - val parts = line.split(" ") - if (parts.isNotEmpty() && parts[0].startsWith("0x")) { - offset = parts[0].substring(2).toLong(16) - Log.d(TAG, "Found offset at ${parts[0]}") - break - } - } - } - - while (errorReader.readLine().also { line = it } != null) { - Log.d(TAG, "rabin2 error: $line") - } - - val exitCode = process.waitFor() - if (exitCode != 0) { - Log.e(TAG, "rabin2 command failed with exit code $exitCode") - } - -// findAndSaveL2cuProcessCfgReqOffset(libraryPath, envSetup) -// findAndSaveL2cCsmConfigOffset(libraryPath, envSetup) -// findAndSaveL2cuSendPeerInfoReqOffset(libraryPath, envSetup) - - // findAndSaveSdpOffset(libraryPath, envSetup) Should not be run by default, only when user asks for it. - - } catch (e: Exception) { - Log.e(TAG, "Failed to find function offset", e) - return@withContext 0L - } - - if (offset == 0L) { - Log.e(TAG, "Failed to extract function offset from output, aborting") - return@withContext 0L - } - - Log.d(TAG, "Successfully found offset: 0x${offset.toString(16)}") - return@withContext offset - } - - private suspend fun findAndSaveL2cuProcessCfgReqOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) { - try { - val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2cu_process_our_cfg_req" - Log.d(TAG, "Running command: $command") - - val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) - val reader = BufferedReader(InputStreamReader(process.inputStream)) - val errorReader = BufferedReader(InputStreamReader(process.errorStream)) - - var line: String? - var offset = 0L - - while (reader.readLine().also { line = it } != null) { - Log.d(TAG, "rabin2 output: $line") - if (line?.contains("l2cu_process_our_cfg_req") == true) { - val parts = line.split(" ") - if (parts.isNotEmpty() && parts[0].startsWith("0x")) { - offset = parts[0].substring(2).toLong(16) - Log.d(TAG, "Found l2cu_process_our_cfg_req offset at ${parts[0]}") - break - } - } - } - - while (errorReader.readLine().also { line = it } != null) { - Log.d(TAG, "rabin2 error: $line") - } - - val exitCode = process.waitFor() - if (exitCode != 0) { - Log.e(TAG, "rabin2 command failed with exit code $exitCode") - } - - if (offset > 0L) { - val hexString = "0x${offset.toString(16)}" - Runtime.getRuntime().exec(arrayOf( - "su", "-c", "/system/bin/setprop $CFG_REQ_OFFSET_PROP $hexString" - )).waitFor() - Log.d(TAG, "Saved l2cu_process_our_cfg_req offset: $hexString") - } - } catch (e: Exception) { - Log.e(TAG, "Failed to find or save l2cu_process_our_cfg_req offset", e) - } - } - - private suspend fun findAndSaveL2cCsmConfigOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) { - try { - val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2c_csm_config" - Log.d(TAG, "Running command: $command") - - val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) - val reader = BufferedReader(InputStreamReader(process.inputStream)) - val errorReader = BufferedReader(InputStreamReader(process.errorStream)) - - var line: String? - var offset = 0L - - while (reader.readLine().also { line = it } != null) { - Log.d(TAG, "rabin2 output: $line") - if (line?.contains("l2c_csm_config") == true) { - val parts = line.split(" ") - if (parts.isNotEmpty() && parts[0].startsWith("0x")) { - offset = parts[0].substring(2).toLong(16) - Log.d(TAG, "Found l2c_csm_config offset at ${parts[0]}") - break - } - } - } - - while (errorReader.readLine().also { line = it } != null) { - Log.d(TAG, "rabin2 error: $line") - } - - val exitCode = process.waitFor() - if (exitCode != 0) { - Log.e(TAG, "rabin2 command failed with exit code $exitCode") - } - - if (offset > 0L) { - val hexString = "0x${offset.toString(16)}" - Runtime.getRuntime().exec(arrayOf( - "su", "-c", "/system/bin/setprop $CSM_CONFIG_OFFSET_PROP $hexString" - )).waitFor() - Log.d(TAG, "Saved l2c_csm_config offset: $hexString") - } - } catch (e: Exception) { - Log.e(TAG, "Failed to find or save l2c_csm_config offset", e) - } - } - - private suspend fun findAndSaveL2cuSendPeerInfoReqOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) { - try { - val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2cu_send_peer_info_req" - Log.d(TAG, "Running command: $command") - - val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) - val reader = BufferedReader(InputStreamReader(process.inputStream)) - val errorReader = BufferedReader(InputStreamReader(process.errorStream)) - - var line: String? - var offset = 0L - - while (reader.readLine().also { line = it } != null) { - Log.d(TAG, "rabin2 output: $line") - if (line?.contains("l2cu_send_peer_info_req") == true) { - val parts = line.split(" ") - if (parts.isNotEmpty() && parts[0].startsWith("0x")) { - offset = parts[0].substring(2).toLong(16) - Log.d(TAG, "Found l2cu_send_peer_info_req offset at ${parts[0]}") - break - } - } - } - - while (errorReader.readLine().also { line = it } != null) { - Log.d(TAG, "rabin2 error: $line") - } - - val exitCode = process.waitFor() - if (exitCode != 0) { - Log.e(TAG, "rabin2 command failed with exit code $exitCode") - } - - if (offset > 0L) { - val hexString = "0x${offset.toString(16)}" - Runtime.getRuntime().exec(arrayOf( - "su", "-c", "/system/bin/setprop $PEER_INFO_REQ_OFFSET_PROP $hexString" - )).waitFor() - Log.d(TAG, "Saved l2cu_send_peer_info_req offset: $hexString") - } - } catch (e: Exception) { - Log.e(TAG, "Failed to find or save l2cu_send_peer_info_req offset", e) - } - } - - private suspend fun findAndSaveSdpOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) { - try { - val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep DmSetLocalDiRecord" - Log.d(TAG, "Running command: $command") - - val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) - val reader = BufferedReader(InputStreamReader(process.inputStream)) - val errorReader = BufferedReader(InputStreamReader(process.errorStream)) - - var line: String? - var offset = 0L - - while (reader.readLine().also { line = it } != null) { - Log.d(TAG, "rabin2 output: $line") - if (line?.contains("DmSetLocalDiRecord") == true) { - val parts = line.split(" ") - if (parts.isNotEmpty() && parts[0].startsWith("0x")) { - offset = parts[0].substring(2).toLong(16) - Log.d(TAG, "Found DmSetLocalDiRecord offset at ${parts[0]}") - break - } - } - } - - while (errorReader.readLine().also { line = it } != null) { - Log.d(TAG, "rabin2 error: $line") - } - - val exitCode = process.waitFor() - if (exitCode != 0) { - Log.e(TAG, "rabin2 command failed with exit code $exitCode") - } - - if (offset > 0L) { - val hexString = "0x${offset.toString(16)}" - Runtime.getRuntime().exec(arrayOf( - "su", "-c", "/system/bin/setprop $SDP_OFFSET_PROP $hexString" - )).waitFor() - Log.d(TAG, "Saved DmSetLocalDiRecord offset: $hexString") - } - } catch (e: Exception) { - Log.e(TAG, "Failed to find or save DmSetLocalDiRecord offset", e) - } - } - - private suspend fun saveOffset(offset: Long): Boolean = withContext(Dispatchers.IO) { - try { - val hexString = "0x${offset.toString(16)}" - Log.d(TAG, "Saving offset to system property: $hexString") - - val process = Runtime.getRuntime().exec(arrayOf( - "su", "-c", "/system/bin/setprop $HOOK_OFFSET_PROP $hexString" - )) - - val exitCode = process.waitFor() - if (exitCode == 0) { - val verifyProcess = Runtime.getRuntime().exec(arrayOf( - "/system/bin/getprop", HOOK_OFFSET_PROP - )) - val propValue = BufferedReader(InputStreamReader(verifyProcess.inputStream)).readLine() - verifyProcess.waitFor() - - if (propValue != null && propValue.isNotEmpty()) { - Log.d(TAG, "Successfully saved offset to system property: $propValue") - return@withContext true - } else { - Log.e(TAG, "Property was set but couldn't be verified") - } - } else { - Log.e(TAG, "Failed to set property, exit code: $exitCode") - } - return@withContext false - } catch (e: Exception) { - Log.e(TAG, "Failed to save offset", e) - return@withContext false - } - } - - private fun cleanupExtractedFiles() { - try { - Runtime.getRuntime().exec(arrayOf("su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor() - Log.d(TAG, "Cleaned up extracted files at $EXTRACT_DIR/data/local/tmp/aln_unzip") - } catch (e: Exception) { - Log.e(TAG, "Failed to cleanup extracted files", e) - } - } - - suspend fun findSdpOffset(): Boolean = withContext(Dispatchers.IO) { - try { - _progressState.value = ProgressState.Downloading - if (!downloadRadare2TarballIfNeeded()) { - _progressState.value = ProgressState.Error("Failed to download radare2 tarball") - Log.e(TAG, "Failed to download radare2 tarball") - return@withContext false - } - - _progressState.value = ProgressState.Extracting - if (!extractRadare2Tarball()) { - _progressState.value = ProgressState.Error("Failed to extract radare2 tarball") - Log.e(TAG, "Failed to extract radare2 tarball") - return@withContext false - } - - _progressState.value = ProgressState.MakingExecutable - if (!makeExecutable()) { - _progressState.value = ProgressState.Error("Failed to make binaries executable") - Log.e(TAG, "Failed to make binaries executable") - return@withContext false - } - - _progressState.value = ProgressState.FindingOffset - val libraryPath = findBluetoothLibraryPath() - if (libraryPath == null) { - _progressState.value = ProgressState.Error("Failed to find Bluetooth library") - Log.e(TAG, "Failed to find Bluetooth library") - return@withContext false - } - - @Suppress("LocalVariableName") val currentLD_LIBRARY_PATH = ProcessBuilder().command("su", "-c", "printenv LD_LIBRARY_PATH").start().inputStream.bufferedReader().readText().trim() - val currentPATH = ProcessBuilder().command("su", "-c", "printenv PATH").start().inputStream.bufferedReader().readText().trim() - val envSetup = """ - export LD_LIBRARY_PATH="$RADARE2_LIB_PATH:$currentLD_LIBRARY_PATH" - export PATH="$BUSYBOX_PATH:$RADARE2_BIN_PATH:$currentPATH" - """.trimIndent() - - findAndSaveSdpOffset(libraryPath, envSetup) - - _progressState.value = ProgressState.Cleaning - cleanupExtractedFiles() - - _progressState.value = ProgressState.Success(0L) - return@withContext true - - } catch (e: Exception) { - _progressState.value = ProgressState.Error("Error: ${e.message}") - Log.e(TAG, "Error in findSdpOffset", e) - return@withContext false - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/LibrePodsApplication.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/LibrePodsApplication.kt similarity index 68% rename from android/app/src/main/java/me/kavishdevar/librepods/LibrePodsApplication.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/LibrePodsApplication.kt index d6968038..3317d188 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/LibrePodsApplication.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/LibrePodsApplication.kt @@ -4,16 +4,35 @@ import android.app.Application import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner +import androidx.room3.Room import io.github.libxposed.service.XposedService import io.github.libxposed.service.XposedServiceHelper import me.kavishdevar.librepods.billing.BillingManager import me.kavishdevar.librepods.billing.BillingProviderFactory +import me.kavishdevar.librepods.database.LibrePodsDatabase +import me.kavishdevar.librepods.repository.AppDataRepository +import me.kavishdevar.librepods.repository.AppleRepository +import me.kavishdevar.librepods.repository.WidgetConfigRepository import me.kavishdevar.librepods.utils.XposedServiceHolder import me.kavishdevar.librepods.utils.XposedState class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener, DefaultLifecycleObserver { + lateinit var database: LibrePodsDatabase + private set + + val appleRepository by lazy { AppleRepository(database.appleDao()) } + val appDataRepository by lazy { AppDataRepository(database.appSettingsDao(), database.appStateDao()) } + val widgetConfigRepository by lazy { WidgetConfigRepository(database.widgetConfigDao()) } override fun onCreate() { + System.loadLibrary("hiddenapi") + + database = Room.databaseBuilder( + applicationContext, + LibrePodsDatabase::class.java, + "librepods.db" + ).build() + XposedServiceHelper.registerListener(this) BillingManager.provider = BillingProviderFactory.create(this) ProcessLifecycleOwner.get().lifecycle.addObserver(this) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/audio/EldDecoder.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/audio/EldDecoder.kt new file mode 100644 index 00000000..c2ce9b28 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/audio/EldDecoder.kt @@ -0,0 +1,105 @@ +package me.kavishdevar.librepods.audio + +import android.media.MediaCodec +import android.media.MediaCodec.BufferInfo +import android.media.MediaCodecInfo +import android.media.MediaFormat +import java.nio.ByteBuffer + +class EldDecoder { + + companion object { + private val ASC = byteArrayOf( + 0xF8.toByte(), + 0xE6.toByte(), + 0x30, + 0x00 + ) + } + + private val codec = MediaCodec.createDecoderByType( + MediaFormat.MIMETYPE_AUDIO_AAC + ) + + private val info = BufferInfo() + + init { + val format = MediaFormat.createAudioFormat( + MediaFormat.MIMETYPE_AUDIO_AAC, + 64_000, + 1 + ) + + format.setInteger( + MediaFormat.KEY_AAC_PROFILE, + MediaCodecInfo.CodecProfileLevel.AACObjectELD + ) + + format.setByteBuffer( + "csd-0", + ByteBuffer.wrap(ASC) + ) + + codec.configure(format, null, null, 0) + codec.start() + } + + fun decode( + accessUnit: ByteArray, + onPcm: (ByteArray) -> Unit + ) { + val input = codec.dequeueInputBuffer(10_000) + + if (input >= 0) { + codec.getInputBuffer(input)?.apply { + clear() + put(accessUnit) + } + + codec.queueInputBuffer( + input, + 0, + accessUnit.size, + 0, + 0 + ) + } + + while (true) { + val output = codec.dequeueOutputBuffer(info, 0) + + when { + output >= 0 -> { + val buffer = codec.getOutputBuffer(output)!! + + val pcm = ByteArray(info.size) + + buffer.position(info.offset) + buffer.limit(info.offset + info.size) + buffer.get(pcm) + + codec.releaseOutputBuffer(output, false) + + onPcm(pcm) + } + + output == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { + // ignore + } + + output == MediaCodec.INFO_TRY_AGAIN_LATER -> { + break + } + + else -> { + break + } + } + } + } + + fun close() { + codec.stop() + codec.release() + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/audio/WavWriter.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/audio/WavWriter.kt new file mode 100644 index 00000000..1804846e --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/audio/WavWriter.kt @@ -0,0 +1,72 @@ +package me.kavishdevar.librepods.audio + +import java.io.File +import java.io.RandomAccessFile + +class WavWriter( + file: File, + private val sampleRate: Int = 64_000, + private val channels: Int = 1, + private val bitsPerSample: Int = 16 +) : AutoCloseable { + + private val raf = RandomAccessFile(file, "rw") + private var dataSize = 0L + + init { + writeHeader() + } + + fun write(pcm: ByteArray) { + raf.write(pcm) + dataSize += pcm.size + } + + override fun close() { + raf.seek(4) + raf.writeIntLE((36 + dataSize).toInt()) + + raf.seek(40) + raf.writeIntLE(dataSize.toInt()) + + raf.close() + } + + private fun writeHeader() { + raf.writeBytes("RIFF") + raf.writeIntLE(0) + + raf.writeBytes("WAVE") + + raf.writeBytes("fmt ") + raf.writeIntLE(16) + raf.writeShortLE(1) + + raf.writeShortLE(channels.toShort()) + + raf.writeIntLE(sampleRate) + + val byteRate = sampleRate * channels * bitsPerSample / 8 + raf.writeIntLE(byteRate) + + val blockAlign = channels * bitsPerSample / 8 + raf.writeShortLE(blockAlign.toShort()) + + raf.writeShortLE(bitsPerSample.toShort()) + + raf.writeBytes("data") + raf.writeIntLE(0) + } +} + +private fun RandomAccessFile.writeIntLE(value: Int) { + write(value and 0xff) + write((value ushr 8) and 0xff) + write((value ushr 16) and 0xff) + write((value ushr 24) and 0xff) +} + +private fun RandomAccessFile.writeShortLE(value: Short) { + write(value.toInt() and 0xff) + write((value.toInt() ushr 8) and 0xff) +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingManager.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/billing/BillingManager.kt similarity index 100% rename from android/app/src/main/java/me/kavishdevar/librepods/billing/BillingManager.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/billing/BillingManager.kt diff --git a/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProvider.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/billing/BillingProvider.kt similarity index 100% rename from android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProvider.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/billing/BillingProvider.kt diff --git a/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProviderFactory.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/billing/BillingProviderFactory.kt similarity index 100% rename from android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProviderFactory.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/billing/BillingProviderFactory.kt diff --git a/android/app/src/main/java/me/kavishdevar/librepods/billing/FOSSBillingProvider.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/billing/FOSSBillingProvider.kt similarity index 100% rename from android/app/src/main/java/me/kavishdevar/librepods/billing/FOSSBillingProvider.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/billing/FOSSBillingProvider.kt diff --git a/android/app/src/main/java/me/kavishdevar/librepods/billing/PlayBillingProvider.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/billing/PlayBillingProvider.kt similarity index 100% rename from android/app/src/main/java/me/kavishdevar/librepods/billing/PlayBillingProvider.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/billing/PlayBillingProvider.kt diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/Utils.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/Utils.kt new file mode 100644 index 00000000..3aede712 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/Utils.kt @@ -0,0 +1,149 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package me.kavishdevar.librepods.bluetooth + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothSocket +import android.os.ParcelUuid +import android.util.Log +import kotlinx.serialization.Serializable +import me.kavishdevar.librepods.utils.redactMac +import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec + +fun createBluetoothSocket( + adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid, psm: Int +): BluetoothSocket { + val type = 3 // L2CAP + val constructorSpecs = listOf( + arrayOf(adapter, device, type, true, true, psm, uuid), // A16QPR3 + arrayOf(device, type, true, true, psm, uuid), + arrayOf(device, type, 1, true, true, psm, uuid), + arrayOf(type, 1, true, true, device, psm, uuid), + arrayOf(type, true, true, device, psm, uuid) + ) + + val constructors = BluetoothSocket::class.java.declaredConstructors + Log.d("createSocket<$psm>", "BluetoothSocket has ${constructors.size} constructors:") + + constructors.forEachIndexed { index, constructor -> + val params = constructor.parameterTypes.joinToString(", ") { it.simpleName } + Log.d("createSocket<$psm>", "Constructor $index: ($params)") + } + + var lastException: Exception? = null + var attemptedConstructors = 0 + + for ((index, params) in constructorSpecs.withIndex()) { + try { + Log.d("createSocket<$psm>", "Trying constructor signature #${index + 1}") + attemptedConstructors++ + + val paramTypes = + params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray() + val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes) + constructor.isAccessible = true + return constructor.newInstance(*params) as BluetoothSocket + + } catch (e: Exception) { + Log.e("createSocket<$psm>", "Constructor signature #${index + 1} failed: ${e.message}") + lastException = e + } + } + + val errorMessage = + "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures" + Log.e("createSocket<$psm>", errorMessage) + throw lastException ?: IllegalStateException(errorMessage) +} + +/** + * Verifies if the provided Bluetooth address is an RPA that matches the given Identity Resolving Key (IRK) + * + * @param addr The Bluetooth address to verify + * @param irk The Identity Resolving Key to use for verification + * @return true if the address is verified as an RPA matching the IRK + */ +fun verifyRPA(addr: String, irk: ByteArray): Boolean { + val rpa = addr.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray() + val prand = rpa.copyOfRange(3, 6) + val hash = rpa.copyOfRange(0, 3) + val computedHash = ah(irk, prand) + return hash.contentEquals(computedHash) +} + +/** + * Performs E function (AES-128) as specified in Bluetooth Core Specification + * + * @param key The key for encryption + * @param data The data to encrypt + * @return The encrypted data + */ +@SuppressLint("GetInstance") +fun e(key: ByteArray, data: ByteArray): ByteArray { + val swappedKey = key.reversedArray() + val swappedData = data.reversedArray() + val cipher = Cipher.getInstance("AES/ECB/NoPadding") + val secretKey = SecretKeySpec(swappedKey, "AES") + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + return cipher.doFinal(swappedData).reversedArray() +} + +/** + * Performs the ah function as specified in Bluetooth Core Specification + * + * @param k The IRK key + * @param r The random part of the address + * @return The hash part of the address + */ +fun ah(k: ByteArray, r: ByteArray): ByteArray { + val rPadded = ByteArray(16) + r.copyInto(rPadded, 0, 0, 3) + val encrypted = e(k, rPadded) + return encrypted.copyOfRange(0, 3) +} + +@JvmInline +@Serializable +value class MacAddress(val value: String) { + init { + require(value.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { + "Invalid MAC address format: $value" + } + } + + override fun toString(): String { + return value + } + + fun toRedactedString(): String { + return value.redactMac() + } + + fun toNotificationId(): Int { + return value + .replace(":", "") + .takeLast(8) + .toUInt(16) + .toInt() + + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt new file mode 100644 index 00000000..62e36606 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt @@ -0,0 +1,1177 @@ +/* + 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 . +*/ + +@file:OptIn(ExperimentalEncodingApi::class) + +package me.kavishdevar.librepods.bluetooth.aacp + +import android.bluetooth.BluetoothSocket +import android.os.ParcelUuid +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.bluetooth.aacp.packet.AACPPacket +import me.kavishdevar.librepods.bluetooth.aacp.packet.AACPPacketType +import me.kavishdevar.librepods.bluetooth.aacp.packet.AudioSourcePacket +import me.kavishdevar.librepods.bluetooth.aacp.packet.BatteryInfoPacket +import me.kavishdevar.librepods.bluetooth.aacp.packet.ConnectedDevicesPacket +import me.kavishdevar.librepods.bluetooth.aacp.packet.ControlCommandPacket +import me.kavishdevar.librepods.bluetooth.aacp.packet.CustomEqPacket +import me.kavishdevar.librepods.bluetooth.aacp.packet.EarDetectionResponsePacket +import me.kavishdevar.librepods.bluetooth.aacp.packet.InformationPacket +import me.kavishdevar.librepods.bluetooth.aacp.packet.MagicKeyResponsePacket +import me.kavishdevar.librepods.bluetooth.aacp.packet.RenamePacket +import me.kavishdevar.librepods.bluetooth.aacp.packet.StemPressPacket +import me.kavishdevar.librepods.bluetooth.aacp.types.AppleEvent +import me.kavishdevar.librepods.bluetooth.aacp.types.Capability +import me.kavishdevar.librepods.bluetooth.aacp.types.CapabilityEntry +import me.kavishdevar.librepods.bluetooth.aacp.types.ConnectOpcode +import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommand +import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier +import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq +import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType +import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode +import me.kavishdevar.librepods.data.audio.MicrophoneFrame +import me.kavishdevar.librepods.devices.AppleDevice +import me.kavishdevar.librepods.devices.BatteryStatus +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.time.Duration.Companion.milliseconds + +class AACPManager(private val device: AppleDevice) { + private val macParts = device.macAddress.value.split(":") + private val TAG = "AACPManager[${macParts[0]}:${macParts[1]}:${macParts[2]}]" + + private var socket: BluetoothSocket? = null + + fun connect(): Boolean { + if (socket != null && socket!!.isConnected) { + Log.i(TAG, "Already connected") + return true + } + try { + socket = device.createSocket(ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a"), 4097) + } catch (e: Exception) { + Log.e(TAG, "failed to create socket", e) + return false + } + + socket?.let { socket -> + try { + Log.i(TAG, "connecting...") + socket.connect() + } catch (e: Exception) { + Log.e(TAG, "failed to connect", e) + return false + } + + if (socket.isConnected) { + Log.i(TAG, "connected!") + } + + CoroutineScope(Dispatchers.IO).launch { + while(!socket.isConnected) { + Log.i(TAG, "waiting for connection...") + delay(500.milliseconds) + } + Log.i(TAG, "initializing connection") + + connectService4() + delay(200.milliseconds) + sendSourceFeatureCapabilities() + delay(200.milliseconds) + sendNotificationRequest() + delay(200.milliseconds) + sendRequestMagicKeys((MagicKeyType.IRK.value + MagicKeyType.ENC_KEY.value).toByte()) + } + + CoroutineScope(Dispatchers.IO).launch { + Log.i(TAG, "starting to read data...") + + while (socket.isConnected) { + try { + val buffer = ByteArray(1024) + val bytesRead = socket.inputStream.read(buffer) + var data: ByteArray + if (bytesRead > 0) { + data = buffer.copyOfRange(0, bytesRead) + try { + processPacket(data) + } catch (e: Exception) { + Log.e(TAG, "Error processing received packet: ${e.message}") + e.printStackTrace() + } + + } else if (bytesRead == -1) { + Log.i("AirPodsService", "socket closed (bytesRead = -1)") + } + } catch (e: Exception) { + Log.i(TAG, "Error reading data, we have probably disconnected.") + e.printStackTrace() + } + } + } + } ?: run { + Log.e(TAG, "socket is null after creation") + } + return true + } + + fun sendRawPacket(data: ByteArray): Boolean { + val packet = AACPPacket.createUnknownPacket( + opcode = MessageOpcode.fromByte(data[4]), + payload = data.copyOfRange(6, data.size), + type = AACPPacketType.fromByte(data[0]), + service = data[2] + ) + return sendPacket(packet) + } + + fun sendPacket(packet: AACPPacket): Boolean { + try { + Log.d(TAG, "Sending packet: ${packet.rawPacket.joinToString(" ") { "%02X".format(it) }}") + + val socket = this.socket ?: run { + Log.e(TAG, "Can't send packet: Socket is null") + return false + } + + if (socket.isConnected) { + socket.outputStream?.write(packet.rawPacket) + socket.outputStream?.flush() + + device.updateState { + it.copy( + aacpPackets = it.aacpPackets + packet + ) + } + + return true + } else { + Log.d(TAG, "Can't send packet: Socket not initialized or connected") + return false + } + } catch (e: Exception) { + Log.e(TAG, "Error sending packet: ${e.message}") + return false + } + } + + @OptIn(ExperimentalStdlibApi::class) + fun processPacket(packet: ByteArray) { + if (!packet.toHexString().startsWith("04000400")) { + Log.w( + TAG, "Received packet does not start with expected header: ${ + packet.joinToString(" ") { + "%02X".format(it) + } + }") + return + } + if (packet.size < 6) { + Log.w( + TAG, "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}" + ) + return + } + Log.d(TAG, "received packet: ${packet.toHexString()}") + val opcode = packet[4] + when (MessageOpcode.fromByte(opcode)) { + MessageOpcode.BUD_ROLE -> { + val payload = packet.copyOfRange(6, packet.size) + val packet = AACPPacket.createUnknownPacket( + opcode = MessageOpcode.BUD_ROLE, + payload = payload + ) + + device.updateState { + it.copy( + leftIsPrimary = payload[0] == 0x01.toByte(), + aacpPackets = it.aacpPackets + packet + ) + } + } + MessageOpcode.CAPABILITIES -> { + val payload = packet.copyOfRange(6, packet.size) + val packet = AACPPacket.createUnknownPacket( + opcode = MessageOpcode.CAPABILITIES, + payload = payload + ) + + device.updateState { + it.copy( + capabilities = parseCapabilitiesResponse(packet.rawPacket), + aacpPackets = it.aacpPackets + packet + ) + } + } + MessageOpcode.BATTERY_INFO -> { + val batteryPacket = BatteryInfoPacket.parse(packet) + + val cacheDisconnectedComponentBattery = device.settings.value.cacheDisconnectedComponentBattery + + if (!cacheDisconnectedComponentBattery) { + device.updateState { + it.copy( + battery = batteryPacket.batteries, + aacpPackets = it.aacpPackets + batteryPacket + ) + } + } else { + device.updateState { state -> + val previous = state.battery.associateBy { it.component } + + val updated = batteryPacket.batteries.map { battery -> + val existing = previous[battery.component] + + if (battery.status == BatteryStatus.DISCONNECTED && existing != null) { + battery.copy(level = existing.level) + } else { + battery + } + }.toSet() + + state.copy( + battery = updated, + aacpPackets = state.aacpPackets + batteryPacket + ) + } + } + } + + MessageOpcode.CONTROL_COMMAND -> { + val controlCommandPacket = ControlCommandPacket.parse(packet) + val controlCommand = controlCommandPacket.controlCommand + + Log.i(TAG, "Received control command: ${controlCommand.identifier}, value: ${controlCommand.value.toHexString()}") + + device.updateState { + it.copy( + controlStates = it.controlStates.toMutableMap().apply { + put(controlCommand.identifier, controlCommand.value) + } + ) + } + + if (controlCommand.identifier == ControlCommandIdentifier.OWNS_CONNECTION) { + val owns = controlCommand.value[0] == 0x01.toByte() + device.updateState { + it.copy( + owns = owns + ) + } + Log.i(TAG, "Owns connection: $owns") + } + + device.updateState { + it.copy( + aacpPackets = it.aacpPackets + controlCommandPacket + ) + } + } + + MessageOpcode.EAR_DETECTION -> { + val earDetectionResponsePacket = EarDetectionResponsePacket.parse(packet, device.state.value.leftIsPrimary) + + device.updateState { + it.copy( + componentState = earDetectionResponsePacket.componentStates, + aacpPackets = it.aacpPackets + earDetectionResponsePacket + ) + } + } + + MessageOpcode.CONVERSATION_AWARENESS -> { + val payload = packet.copyOfRange(6, packet.size) + val packet = AACPPacket.createUnknownPacket( + opcode = MessageOpcode.CONVERSATION_AWARENESS, + payload = packet.copyOfRange(6, packet.size) + ) + device.updateState { + it.copy( + conversationalAwarenessState = payload.getOrElse(3, {0}).toInt(), + aacpPackets = it.aacpPackets + packet + ) + } + } + + MessageOpcode.BUDDY_COMMAND -> { + Log.w(TAG, "BUDDY not implemented") + } + + MessageOpcode.MAGIC_KEYS_RESPONSE -> { + val packet = MagicKeyResponsePacket.parse(packet) + + device.updateState { + it.copy(magicKeys = packet.magicKeys) + } + + device.updateState { + it.copy( + aacpPackets = it.aacpPackets + packet + ) + } + } + + MessageOpcode.STEM_PRESS -> { + val packet = StemPressPacket.parse(packet) + + CoroutineScope(Dispatchers.IO).launch { + device.emitEvent( + AppleEvent.StemPress( + pressType = packet.stemPressType, + bud = packet.stemPressBud + ) + ) + } + + device.updateState { + it.copy( + aacpPackets = it.aacpPackets + packet + ) + } + } + + MessageOpcode.AUDIO_SOURCE -> { + try { + val packet = AudioSourcePacket.parse(packet) + + device.updateState { + it.copy( + audioSource = packet.audioSource, + aacpPackets = it.aacpPackets + packet + ) + } + } catch (e: Exception) { + Log.e(TAG, "Error parsing audio source response: ${e.message}") + } + } + + MessageOpcode.CONNECTED_DEVICES -> { + try { + val packet = ConnectedDevicesPacket.parse(packet) + + device.updateState { + it.copy ( + connectedDevices = packet.connectedDevices, + aacpPackets = it.aacpPackets + packet + ) + } + } catch (e: Exception) { + Log.e(TAG, "Error parsing connected devices response: ${e.message}") + } + } + + MessageOpcode.SMART_ROUTING_RESPONSE -> { + val packetString = packet.decodeToString() + val sender = + packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) } + + // if (connectedDevices.find { it.mac == sender }?.type == null && packetString.contains("btName")) { + // val nameStartIndex = packetString.indexOf("btName") + 8 + // val nameEndIndex = if (packetString.contains("other")) (packetString.indexOf("otherDevice") - 1) else (packetString.indexOf("nearbyAudio") - 1) + // val name = packet.sliceArray(nameStartIndex..nameEndIndex).decodeToString() + // connectedDevices.find { it.mac == sender }?.type = name + // Log.d(TAG, "Device $sender is named $name") + // } // doesn't work, it's different for Mac and iPad. just hardcoding for now + if ("iPad" in packetString) { + device.state.value.connectedDevices.find { it.macAddress.value == sender }?.type = "iPad" + } else if ("Mac" in packetString) { + device.state.value.connectedDevices.find { it.macAddress.value == sender }?.type = "Mac" + } else if ("iPhone" in packetString) { // not sure if this is it - don't have an iphone + device.state.value.connectedDevices.find { it.macAddress.value == sender }?.type = "iPhone" + } else if ("Linux" in packetString) { + device.state.value.connectedDevices.find { it.macAddress.value == sender }?.type = "Linux" + } else if ("Android" in packetString) { + device.state.value.connectedDevices.find { it.macAddress.value == sender }?.type = "Android" + } + Log.i(TAG, "Smart Routing Response from $sender: $packetString, type: ${device.state.value.connectedDevices.find { it.macAddress.value == sender }?.type}") + if (packetString.contains("SetOwnershipToFalse")) { + CoroutineScope(Dispatchers.IO).launch { + device.emitEvent( + AppleEvent.OwnershipToFalseRequest( + sender = sender, + reverseTapped = packetString.contains("ReverseBannerTapped") + ) + ) + } + } + if (packetString.contains("ShowNearbyUI")) { + CoroutineScope(Dispatchers.IO).launch { + device.emitEvent( + AppleEvent.ShowNearbyUi( + sender = sender + ) + ) + } + } + + val packet = AACPPacket.createUnknownPacket( + opcode = MessageOpcode.SMART_ROUTING_RESPONSE, + payload = packet.copyOfRange(6, packet.size) + ) + + device.updateState { + it.copy( + aacpPackets = it.aacpPackets + packet + ) + } + } + + MessageOpcode.HEADPHONE_ACCOMMODATION -> { + if (packet.size != 140) { + Log.w( + TAG, + "Received HEADPHONE_ACCOMMODATION packet of unexpected size: ${packet.size}, expected 140" + ) + return + } + if (packet[6] != 0x84.toByte()) { + Log.w( + TAG, + "Received HEADPHONE_ACCOMMODATION packet with unexpected identifier: ${packet[6].toHexString()}, expected 0x84" + ) + return + } + + val eqOnMedia = (packet[10] == 0x01.toByte()) + val eqOnPhone = (packet[11] == 0x01.toByte()) + // there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media visible. just directly the EQ... weird. + // the EQs are little endian floats + val eq1 = ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() + ByteBuffer.wrap(packet, 44, 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 + val eqData = FloatArray(8) { i -> eq1.get(i) } + + Log.d( + TAG, + "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia" + ) + + val packet = AACPPacket.createUnknownPacket( + opcode = MessageOpcode.HEADPHONE_ACCOMMODATION, + payload = packet.copyOfRange(6, packet.size) + ) + + device.updateState { + it.copy( + headphoneAccomodation = eqData, + headphoneAccomodationEnabledForMedia = eqOnMedia, + headphoneAccomodationEnabledForPhone = eqOnPhone, + aacpPackets = it.aacpPackets + packet + ) + } + } + + MessageOpcode.INFORMATION -> { + val informationPacket = InformationPacket.parse(packet) + + device.updateState { + it.copy( + aacpPackets = it.aacpPackets + informationPacket + ) + } + device.updateMetadata { informationPacket.metadata } + } + + MessageOpcode.CUSTOM_EQ -> { + try { + val customEqPacket = CustomEqPacket.parse(packet) + + val customEq = customEqPacket.customEq + + device.updateState { + it.copy( + customEq = customEq, + aacpPackets = it.aacpPackets + customEqPacket + ) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to parse custom EQ packet", e) + } + } + + MessageOpcode.MICROPHONE_STREAM -> { + try { + MicrophoneFrame.parsePacket(packet).forEach{ frame -> + device.updateState { + it.copy( + microphoneFrames = it.microphoneFrames + frame + ) + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to parse microphoneState packet", e) + } + } + + MessageOpcode.MAC_ADDRESS -> { + try { + val macAddress = packet.copyOfRange(6, 12).reversedArray().joinToString(":") { "%02X".format(it) } + val extra1 = packet[12] + val extra2 = packet[13] + Log.i(TAG, "Received MAC address packet: $macAddress, extra1: ${extra1.toHexString()}, extra2: ${extra2.toHexString()}") + device.updateState { + it.copy( + aacpPackets = it.aacpPackets + AACPPacket.createUnknownPacket( + opcode = MessageOpcode.MAC_ADDRESS, + payload = packet.copyOfRange(6, packet.size) + ) + ) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to parse MAC address packet", e) + } + } + + else -> { + val packet = AACPPacket.createUnknownPacket( + opcode = MessageOpcode.fromByte(opcode), + payload = packet.copyOfRange(6, packet.size) + ) + device.updateState { + it.copy( + aacpPackets = it.aacpPackets + packet + ) + } + Log.w(TAG, "Unhandled messageOpcode received: ${opcode.toHexString()}") + } + } + } + + fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean { + val controlCommand = ControlCommand(ControlCommandIdentifier.fromByte(identifier) ?: return false, value) + + val packet = ControlCommandPacket.create(controlCommand) + + device.updateState { + it.copy( + controlStates = it.controlStates.toMutableMap().apply { + put(ControlCommandIdentifier.fromByte(identifier) ?: return false, value) + } + ) + } + + return sendPacket(packet) + } + + fun sendControlCommand(identifier: Byte, value: Byte): Boolean = sendControlCommand(identifier, byteArrayOf(value)) + fun sendControlCommand(identifier: Byte, value: Boolean): Boolean = sendControlCommand(identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02)) + fun sendControlCommand(identifier: Byte, value: Int): Boolean = sendControlCommand(identifier, byteArrayOf(value.toByte())) + + fun sendRequestMagicKeys(type: Byte): Boolean { + Log.d(TAG, "Requesting proximity keys of type: ${type.toString(16)}") + + val payload = byteArrayOf(type, 0x00) + + val packet = AACPPacket.createUnknownPacket( + MessageOpcode.MAGIC_KEYS_REQUEST, + payload + ) + + return sendPacket(packet) + } + + fun sendNotificationRequest(): Boolean { + // note to self #1: third byte is 0xfd when ear detection is disabled + // note to self #2: this can be sent any time, not just at the start of the aacp connection + val payload = byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()) + val packet = AACPPacket.createUnknownPacket( + MessageOpcode.REQUEST_NOTIFICATIONS, + payload + ) + + return sendPacket(packet) + } + + fun sendSourceFeatureCapabilities(): Boolean { + val payload = byteArrayOf(0xFF.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + val packet = AACPPacket.createUnknownPacket( + opcode = MessageOpcode.SOURCE_FEATURE_CAPABILITIES, + payload + ) + return sendPacket(packet) + } + + fun connectService4(): Boolean { + val payload = byteArrayOf(0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + val packet = AACPPacket.createUnknownPacket( + type = AACPPacketType.CONNECT, + opcode = ConnectOpcode.SOMETHING, + payload = payload, + ) + + return sendPacket(packet) + } + + fun sendStartHeadTracking(): Boolean { + val payload = byteArrayOf( + 0x00, 0x00, 0x10, 0x00, + 0x10, 0x00, 0x08, 0xA1.toByte(), 0x02, 0x42, 0x0B, 0x08, 0x0E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00 + ) + val packet = AACPPacket.createUnknownPacket( + MessageOpcode.BUDDY_COMMAND, + payload + ) + return sendPacket(packet) + } + + fun sendStartAlternateHeadTracking(): Boolean { + val payload = byteArrayOf( + 0x00, 0x00, 0x10, 0x00, + 0x0F, 0x00, 0x08, 0x73, 0x42, 0x0B, 0x08, 0x10, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00 + ) + val packet = AACPPacket.createUnknownPacket( + MessageOpcode.BUDDY_COMMAND, + payload + ) + return sendPacket(packet) + } + + fun sendStopHeadTracking(): Boolean { + val payload = byteArrayOf( + 0x00, 0x00, 0x10, 0x00, + 0x11, 0x00, 0x08, 0x7E, 0x10, 0x02, 0x42, 0x0B, 0x08, 0x4E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00 + ) + val packet = AACPPacket.createUnknownPacket( + MessageOpcode.BUDDY_COMMAND, + payload + ) + return sendPacket(packet) + } + + fun sendStopAlternateHeadTracking(): Boolean { + val payload = byteArrayOf( + 0x00, 0x00, 0x10, 0x00, + 0x0F, 0x00, 0x08, 0x75, 0x42, 0x0B, 0x08, 0x10, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00 + ) + + val packet = AACPPacket.createUnknownPacket( + opcode = MessageOpcode.BUDDY_COMMAND, + payload = payload, + ) + + return sendPacket(packet) + } + + fun sendRename(name: String): Boolean { + val packet = RenamePacket.create(name) + + device.updateMetadata { + it.copy( + name = name + ) + } + + return sendPacket(packet) + } + + 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}") + ) + ) { + Log.w( + TAG, + "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress" + ) + return false + } + + Log.d(TAG, "SELFMAC: ${selfMacAddress}, TARGETMAC: $targetMacAddress") + Log.d(TAG, "Sending Media Information packet to $targetMacAddress") + + val buffer = ByteBuffer.allocate(116) + + buffer.put( + targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() + ) + buffer.put(byteArrayOf(0x6C, 0x00)) + buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) + buffer.put("playingApp".toByteArray()) + buffer.put(0x42) + buffer.put("NA".toByteArray()) + buffer.put(0x52) + buffer.put("hostStreamingState".toByteArray()) + buffer.put(0x42) + buffer.put("NO".toByteArray()) + buffer.put(0x49) + buffer.put("btAddress".toByteArray()) + buffer.put(0x51) + buffer.put(selfMacAddress.toByteArray()) + buffer.put(0x46) + buffer.put("btName".toByteArray()) + buffer.put(0x47) + buffer.put("Android".toByteArray()) + buffer.put(0x58) + buffer.put("otherDevice".toByteArray()) + buffer.put("AudioCategory".toByteArray()) + buffer.put(byteArrayOf(0x30, 0x64)) + + val packet = AACPPacket.createUnknownPacket( + opcode = MessageOpcode.SMART_ROUTING, + payload = buffer.array() + ) + + return sendPacket(packet) + } + + fun sendHijackRequest(selfMacAddress: String): Boolean { + if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { + Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress") + return false + } + var success = false + for (connectedDevice in device.state.value.connectedDevices) { + if (connectedDevice.macAddress.value != selfMacAddress) { + Log.d(TAG, "Sending Hijack Request packet to ${connectedDevice.macAddress}") + success = sendPacket(createHijackRequestPacket(connectedDevice.macAddress.value)) || success + } + } + return success + } + + fun createHijackRequestPacket(targetMacAddress: String): AACPPacket { + val buffer = ByteBuffer.allocate(106) + buffer.put( + targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() + ) + buffer.put(byteArrayOf(0x62, 0x00)) + buffer.put(byteArrayOf(0x01, 0xE5.toByte())) + buffer.put(0x4A) + buffer.put("localscore".toByteArray()) + buffer.put(byteArrayOf(0x30, 0x64)) + buffer.put(0x46) + buffer.put("reason".toByteArray()) + buffer.put(0x48) + buffer.put("Hijackv2".toByteArray()) + buffer.put(0x51) + buffer.put("audioRoutingScore".toByteArray()) + buffer.put(byteArrayOf(0x31, 0x2D, 0x01, 0x5F)) + buffer.put("audioRoutingSetOwnershipToFalse".toByteArray()) + buffer.put(0x01) + buffer.put(0x4B) + buffer.put("remotescore".toByteArray()) + buffer.put(0xA5.toByte()) + + val packet = AACPPacket.createUnknownPacket( + opcode = MessageOpcode.SMART_ROUTING, + payload = buffer.array() + ) + + return packet + } + + fun sendMediaInformataion(selfMacAddress: String, streamingState: Boolean = false): Boolean { + if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { + // throw IllegalArgumentException("MAC address must be 6 bytes") + Log.d(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress") + return false + } + Log.d(TAG, "SELFMAC: $selfMacAddress") + val targetMac = device.state.value.connectedDevices.find { it.macAddress.value != selfMacAddress }?.macAddress + if (targetMac == null) { + Log.w(TAG, "Cannot send Media Information packet: No connected device found") + return false + } + Log.d(TAG, "Sending Media Information packet to $targetMac") + + val buffer = ByteBuffer.allocate(138) + buffer.put( + targetMac.value.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() + ) + buffer.put( + byteArrayOf( + 0x82.toByte(), // related to the length + 0x00 + ) + ) + buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) // unknown, constant + buffer.put("PlayingApp".toByteArray()) + buffer.put(byteArrayOf(0x56)) // 'V', seems like an identifier or a separator + buffer.put("com.google.ios.youtube".toByteArray()) // package name, hardcoding for now, aforementioned reason + buffer.put(byteArrayOf(0x52)) // 'R' + buffer.put("HostStreamingState".toByteArray()) + buffer.put(byteArrayOf(0x42)) // 'B' + buffer.put((if (streamingState) "YES" else "NO").toByteArray()) // streaming state + buffer.put(0x49) // 'I' + buffer.put("btAddress".toByteArray()) // self MAC + buffer.put(0x51) // 'Q' + buffer.put(selfMacAddress.toByteArray()) // self MAC + buffer.put("btName".toByteArray()) // self name + buffer.put(0x47) // 'D' + buffer.put("Android".toByteArray()) // if set to iPad, shows "Moved to iPad", but most likely we're running on a phone. setting to anything else of the same length will show iPhone instead. + buffer.put(0x58) // 'X' + buffer.put("otherDevice".toByteArray()) + buffer.put("AudioCategory".toByteArray()) + buffer.put(byteArrayOf(0x31, 0x2D, 0x01)) + + val packet = AACPPacket.createUnknownPacket( + opcode = MessageOpcode.SMART_ROUTING, + payload = buffer.array() + ) + + return sendPacket(packet) + } + + fun sendSmartRoutingShowUI(selfMacAddress: String): Boolean { + if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { + // throw IllegalArgumentException("MAC address must be 6 bytes") + Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress") + return false + } + + val targetMac = device.state.value.connectedDevices.find { it.macAddress.value != selfMacAddress }?.macAddress + if (targetMac == null) { + Log.w(TAG, "Cannot send Smart Routing Show UI packet: No connected device found") + return false + } + Log.d(TAG, "Sending Smart Routing Show UI packet to $targetMac") + + val buffer = ByteBuffer.allocate(134) + buffer.put( + targetMac.value.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() + ) + buffer.put(byteArrayOf(0x7E, 0x00)) + buffer.put(byteArrayOf(0x01, 0xE6.toByte(), 0x5B)) + buffer.put("SmartRoutingKeyShowNearbyUI".toByteArray()) + buffer.put(0x01) // separator? + buffer.put(0x4A) + buffer.put("localscore".toByteArray()) + buffer.put(0x31, 0x2D) + buffer.put(0x01) + buffer.put(0x46) + buffer.put("reasonHhijackv2".toByteArray()) + buffer.put(0x51.toByte()) + buffer.put("audioRoutingScore".toByteArray()) + buffer.put(0xA2.toByte()) + buffer.put(0x5F) + buffer.put("audioRoutingSetOwnershipToFalse".toByteArray()) + buffer.put(0x01) + buffer.put(0x4B) + buffer.put("remotescore".toByteArray()) + buffer.put(0xA2.toByte()) + + val packet = AACPPacket.createUnknownPacket( + opcode = MessageOpcode.SMART_ROUTING, + payload = buffer.array() + ) + + return sendPacket(packet) + } + + fun sendHijackReversed(selfMacAddress: String): Boolean { + var success = false + for (connectedDevice in device.state.value.connectedDevices) { + if (connectedDevice.macAddress.value != selfMacAddress) { + Log.d(TAG, "Sending Hijack Reversed packet to ${connectedDevice.macAddress}") + success = sendPacket(createHijackReversedPacket(connectedDevice.macAddress.value)) || success + } + } + return success + } + + fun createHijackReversedPacket(targetMacAddress: String): AACPPacket { + val buffer = ByteBuffer.allocate(97) + buffer.put( + targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() + ) + buffer.put(byteArrayOf(0x59, 0x00)) + buffer.put(byteArrayOf(0x01, 0xE3.toByte())) + buffer.put(0x5F) + buffer.put("audioRoutingSetOwnershipToFalse".toByteArray()) + buffer.put(0x01) + buffer.put(0x59) + buffer.put("audioRoutingShowReverseUI".toByteArray()) + buffer.put(0x01) + buffer.put(0x46) + buffer.put("reason".toByteArray()) + buffer.put(0x53) + buffer.put("ReverseBannerTapped".toByteArray()) + + val packet = AACPPacket.createUnknownPacket( + opcode = MessageOpcode.SMART_ROUTING, + payload = buffer.array() + ) + + return packet + } + + 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}") + ) + ) { + // throw IllegalArgumentException("MAC address must be 6 bytes") + Log.w( + TAG, + "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress" + ) + return false + } + Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress") + + val buffer = ByteBuffer.allocate(90) + buffer.put( + targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() + ) + buffer.put(byteArrayOf(0x52, 0x00)) + buffer.put(byteArrayOf(0x01, 0xE5.toByte())) + buffer.put(0x48) // 'H' + buffer.put("idleTime".toByteArray()) + buffer.put(byteArrayOf(0x08, 0x47)) + buffer.put("newTipi".toByteArray()) + buffer.put(byteArrayOf(0x01, 0x49)) + buffer.put("btAddress".toByteArray()) + buffer.put(0x51) + buffer.put(selfMacAddress.toByteArray()) + buffer.put(0x46) + buffer.put("btName".toByteArray()) + buffer.put(0x47) + buffer.put("Android".toByteArray()) + buffer.put(0x50) + buffer.put("nearbyAudioScore".toByteArray()) + buffer.put(byteArrayOf(0x0E)) + + val packet = AACPPacket.createUnknownPacket( + opcode = MessageOpcode.SMART_ROUTING, + payload = buffer.array() + ) + + return sendPacket(packet) + } + + fun sendRawGesturesConfig( + singlePressCustomized: Boolean = false, + doublePressCustomized: Boolean = false, + triplePressCustomized: Boolean = false, + longPressCustomized: Boolean = false + ): Boolean { + val value = ( + (if (singlePressCustomized) 0x01 else 0) or + (if (doublePressCustomized) 0x02 else 0) or + (if (triplePressCustomized) 0x04 else 0) or + (if (longPressCustomized) 0x08 else 0) + ).toByte() + + return sendControlCommand( + ControlCommandIdentifier.RAW_GESTURES_CONFIG.value, value + ) + } + + fun sendPhoneMediaEQ(eq: FloatArray, phone: Byte = 0x02.toByte(), media: Byte = 0x02.toByte()) { + if (eq.size != 8) throw IllegalArgumentException("EQ must be 8 floats") + val header = byteArrayOf( + 0x84.toByte(), + 0x00.toByte(), + 0x02.toByte(), + 0x02.toByte(), + phone, + media + ) + val buffer = ByteBuffer.allocate(128).order(ByteOrder.LITTLE_ENDIAN) + for (block in 0..3) { + for (i in 0..7) { + buffer.putFloat(eq[i]) + } + } + + val payload = header + buffer.array() + + val packet = AACPPacket.createUnknownPacket( + opcode = MessageOpcode.HEADPHONE_ACCOMMODATION, + payload = payload + ) + + sendPacket(packet) + + device.updateState { + it.copy( + headphoneAccomodation = eq.copyOf(), + headphoneAccomodationEnabledForMedia = media == 0x01.toByte(), + headphoneAccomodationEnabledForPhone = phone == 0x01.toByte() + ) + } + } + + fun sendCountryCode() { + val payload = byteArrayOf( + 0x00, 0xFF.toByte(), + 0xFF.toByte(), 0xFF.toByte(), + 0xFF.toByte(), 0xFF.toByte(), + 0xFF.toByte(), 0xFF.toByte(), + ) + + val packet = AACPPacket.createUnknownPacket( + opcode = MessageOpcode.SET_COUNTRY_CODE, + payload = payload + ) + + sendPacket(packet) + } + + fun disconnect() { + try { + socket?.close() + } catch (e: Exception) { + Log.e(TAG, "Error closing socket", e) + } + Log.i(TAG, "disconnected") + socket = null + device.updateState { + it.copy( + battery = emptySet(), + connectedDevices = emptyList(), + microphoneFrames = emptyList(), + controlStates = emptyMap(), + ) + } + } + + fun setCustomEq(customEq: CustomEq): Boolean { + device.updateState { + it.copy( + customEq = customEq + ) + } + + val payload = customEq.toAACPPayload() + + val packet = AACPPacket.createUnknownPacket( + opcode = MessageOpcode.CUSTOM_EQ, + payload = payload + ) + + return sendPacket(packet) + } + + fun parseCustomEqPacket(packet: ByteArray): CustomEq { + val data = packet.sliceArray(6 until packet.size) + + if (data.size < 7) { + Log.e(TAG, "custom EQ packet length less than 7, returning default") + return CustomEq(1, 50, 50, 50) + } + + val lengthLow = data[0].toInt() and 0xFF + val lengthHigh = data[1].toInt() and 0xFF + + val length = (lengthHigh shl 8) or lengthLow + + if (length != 5) { + Log.w(TAG, "parseCustomEqPacket: unexpected length ($length). parsing normally") + } + + val state = data[3].toInt() + val low = data[4].toInt() + val mid = data[5].toInt() + val high = data[6].toInt() + + return CustomEq( + state, + low, + mid, + high + ) + } + + fun requestMicrophoneStream(): Boolean { + val payload = byteArrayOf( + 0x00, 0x00, + 0x09, 0x00, + 0x00, 0x01, + 0x82.toByte(), 0x00, + 0x00, 0x00, + 0x04, 0x96.toByte(), + 0x00 + ) + + val packet = AACPPacket.createUnknownPacket( + opcode = MessageOpcode.MICROPHONE_STREAM, + payload = payload + ) + + return sendPacket(packet) + } + + fun endMicrophoneStream(): Boolean { + val payload = byteArrayOf( + MessageOpcode.MICROPHONE_STREAM.value, 0x00, + 0x00, 0x00, + 0x02, 0x00, + 0x03, 0x01 + ) + + val packet = AACPPacket.createUnknownPacket( + opcode = MessageOpcode.MICROPHONE_STREAM, + payload = payload + ) + + return sendPacket(packet) + } + + fun parseCapabilitiesResponse(packet: ByteArray): Set { + require(packet.size >= 7) { "Packet too short" } + require(packet[4] == MessageOpcode.CAPABILITIES.value) { + "Not a capabilities packet" + } + + var offset = 6 + val capabilityCount = packet[offset++].toInt() and 0xFF + + val capabilities = mutableSetOf() + + repeat(capabilityCount) { + if (offset >= packet.size) { + throw IllegalArgumentException("Unexpected end of packet") + } + + val capabilityId = packet[offset++] + val capability = Capability.fromByte(capabilityId) + ?: throw IllegalArgumentException( + "Unknown capability 0x%02X".format(capabilityId.toInt() and 0xFF) + ) + + if (offset + capability.valueSize > packet.size) { + throw IllegalArgumentException("Truncated packet") + } + + capabilities += CapabilityEntry( + capability, + packet.copyOfRange(offset, offset + capability.valueSize) + ) + + offset += capability.valueSize + } + + for (capability in capabilities) { + Log.d( + TAG, + "Capability: ${capability.capability.name} - ${capability.value.joinToString(" ")}" + ) + } + + return capabilities + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/AACPPacket.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/AACPPacket.kt new file mode 100644 index 00000000..c3936589 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/AACPPacket.kt @@ -0,0 +1,69 @@ +package me.kavishdevar.librepods.bluetooth.aacp.packet + +import me.kavishdevar.librepods.bluetooth.aacp.types.Opcode +import me.kavishdevar.librepods.devices.PacketDestination + +data class AACPPacketType(val value: Byte) { + companion object { + val CONNECT = AACPPacketType(0x00) + val CONNECT_RESPONSE = AACPPacketType(0x01) + val DISCONNECT = AACPPacketType(0x02) + val DISCONNECT_RESPONSE = AACPPacketType(0x03) + val MESSAGE = AACPPacketType(0x04) + + fun fromByte(value: Byte): AACPPacketType { + return when (value) { + 0x00.toByte() -> CONNECT + 0x01.toByte() -> CONNECT_RESPONSE + 0x02.toByte() -> DISCONNECT + 0x03.toByte() -> DISCONNECT_RESPONSE + 0x04.toByte() -> MESSAGE + else -> AACPPacketType(value) + } + } + } +} + +sealed interface AACPPacket { + /** + * The entire packet; contains the opcode and header + */ + val rawPacket: ByteArray + get() = byteArrayOf(type.value, 0x00, service, 0x00, opcode.value, 0x00) + payload + + val destination: PacketDestination + + val type: AACPPacketType + + val service: Byte + + val opcode: Opcode + + val payload: ByteArray + + companion object { + fun createUnknownPacket( + opcode: Opcode, + payload: ByteArray, + destination: PacketDestination = PacketDestination.DEVICE, + type: AACPPacketType = AACPPacketType.MESSAGE, + service: Byte = 0x04, + ): AACPPacket { + return UnknownAACPPacket( + service = service, + type = type, + opcode = opcode, + payload = payload, + destination = destination + ) + } + } +} + +data class UnknownAACPPacket ( + override val service: Byte = 0x04, + override val type: AACPPacketType = AACPPacketType.MESSAGE, + override val opcode: Opcode, + override val payload: ByteArray, + override val destination: PacketDestination +): AACPPacket diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/AudioSourcePacket.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/AudioSourcePacket.kt new file mode 100644 index 00000000..d37b38e8 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/AudioSourcePacket.kt @@ -0,0 +1,39 @@ +package me.kavishdevar.librepods.bluetooth.aacp.packet + +import me.kavishdevar.librepods.bluetooth.aacp.types.AudioSource +import me.kavishdevar.librepods.bluetooth.aacp.types.AudioSourceType +import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode +import me.kavishdevar.librepods.bluetooth.MacAddress +import me.kavishdevar.librepods.devices.PacketDestination + +data class AudioSourcePacket( + val audioSource: AudioSource, + override val payload: ByteArray, +): AACPPacket { + override val destination: PacketDestination = PacketDestination.HOST + + override val type: AACPPacketType = AACPPacketType.MESSAGE + override val service: Byte = 0x04 + + override val opcode = MessageOpcode.STEM_PRESS + + companion object { + fun parse( + packet: ByteArray, + ): AudioSourcePacket { + if (packet.size < 9) { + throw IllegalArgumentException("Data array too short to parse Audio Source Response") + } + + val payload = packet.copyOfRange(6, packet.size) + val macAddress = MacAddress(payload.sliceArray(0..5).toHexString(HexFormat{ upperCase = true }).chunked(2).joinToString(":")) + + val typeByte = payload[6] + val type = AudioSourceType.fromByte(typeByte) + + val audioSource = AudioSource(macAddress, type) + + return AudioSourcePacket(audioSource, payload) + } + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/BatteryInfoPacket.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/BatteryInfoPacket.kt new file mode 100644 index 00000000..0038a592 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/BatteryInfoPacket.kt @@ -0,0 +1,52 @@ +package me.kavishdevar.librepods.bluetooth.aacp.packet + +import android.util.Log +import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode +import me.kavishdevar.librepods.bluetooth.aacp.types.Opcode +import me.kavishdevar.librepods.devices.Battery +import me.kavishdevar.librepods.devices.BatteryComponent +import me.kavishdevar.librepods.devices.BatteryStatus +import me.kavishdevar.librepods.devices.PacketDestination + +private const val TAG = "BatteryInfoPacket" + +data class BatteryInfoPacket( + val batteries: Set, + override val payload: ByteArray, +): AACPPacket { + override val destination: PacketDestination = PacketDestination.HOST + + override val type: AACPPacketType = AACPPacketType.MESSAGE + override val service: Byte = 0x04 + + override val opcode: Opcode = MessageOpcode.BATTERY_INFO + + companion object { + fun parse( + packet: ByteArray, + ): BatteryInfoPacket { + val payload = packet.sliceArray(6 until packet.size) + + var offset = 0 + val batteryCount = payload[offset].toInt() + val batteries = mutableSetOf() + offset += 1 + for (i in 0 until batteryCount) { + val componentByte = payload[offset] + val component = BatteryComponent.fromAirPodsByte(componentByte) + val levelByte = payload[offset + 2] + val level = levelByte.toInt() + val statusByte = payload[offset + 3] + val status = BatteryStatus.fromAirPodsByte(statusByte) + + Log.i(TAG, "parsed battery#$i: component=${component.name}, level=$level, status=${status.name}") + + batteries.add(Battery(component, level, status)) + offset += 5 + } + Log.i(TAG, "parsed Battery Info: $batteries") + + return BatteryInfoPacket(batteries, payload) + } + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/ConnectedDevicesPacket.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/ConnectedDevicesPacket.kt new file mode 100644 index 00000000..b86ea8a2 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/ConnectedDevicesPacket.kt @@ -0,0 +1,59 @@ +package me.kavishdevar.librepods.bluetooth.aacp.packet + +import android.util.Log +import me.kavishdevar.librepods.bluetooth.aacp.types.ConnectedDevice +import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode +import me.kavishdevar.librepods.bluetooth.MacAddress +import me.kavishdevar.librepods.devices.PacketDestination + +private const val TAG = "ConnectedDevicesPacket" + +data class ConnectedDevicesPacket( + val connectedDevices: List, + override val payload: ByteArray, +): AACPPacket { + override val destination: PacketDestination = PacketDestination.HOST + + override val type: AACPPacketType = AACPPacketType.MESSAGE + override val service: Byte = 0x04 + + override val opcode = MessageOpcode.CONNECTED_DEVICES + + companion object { + fun parse( + packet: ByteArray, + ): ConnectedDevicesPacket { + if (packet.size < 8) { + throw IllegalArgumentException("Data array too short to parse Connected Devices Response") + } + + val payload = packet.copyOfRange(6, packet.size) + + val deviceCount = payload[2].toInt() + val devices = mutableListOf() + + var offset = 3 + + for (i in 0 until deviceCount) { + if (offset + 8 > payload.size) { + Log.w( + TAG, + "Data array too short to parse all connected devices, returning what we have" + ) + break + } + + val macAddress = MacAddress(payload.sliceArray(offset until offset + 6).toHexString(HexFormat{ upperCase = true }).chunked(2).joinToString(":")) + + val info1 = payload[offset + 6] + val info2 = payload[offset + 7] + + val existingDevice = devices.find { it.macAddress == macAddress } + devices.add(ConnectedDevice(macAddress, info1, info2, existingDevice?.type)) + offset += 8 + } + + return ConnectedDevicesPacket(devices, payload) + } + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/ControlCommandPacket.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/ControlCommandPacket.kt new file mode 100644 index 00000000..6cfeeb36 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/ControlCommandPacket.kt @@ -0,0 +1,62 @@ +package me.kavishdevar.librepods.bluetooth.aacp.packet + +import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommand +import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode +import me.kavishdevar.librepods.devices.PacketDestination + +data class ControlCommandPacket( + val controlCommand: ControlCommand, + override val destination: PacketDestination, +): AACPPacket { + override val type: AACPPacketType = AACPPacketType.MESSAGE + override val service: Byte = 0x04 + + override val opcode = MessageOpcode.CONTROL_COMMAND + + override val payload: ByteArray = controlCommand.toAACPPayload() + + companion object { + fun parse( + data: ByteArray, + destination: PacketDestination = PacketDestination.HOST + ): ControlCommandPacket { + val payload = if (data.toHexString().startsWith("040004000900")) { + data.copyOfRange(6, data.size) + } else data + + val controlCommand = ControlCommand.fromAACPPayload(payload) + + return ControlCommandPacket(controlCommand, destination) + } + + fun create( + controlCommand: ControlCommand, + destination: PacketDestination = PacketDestination.DEVICE + ): ControlCommandPacket { + return ControlCommandPacket( + controlCommand = controlCommand, + destination = destination, + ) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ControlCommandPacket + + if (controlCommand != other.controlCommand) return false + if (destination != other.destination) return false + if (!rawPacket.contentEquals(other.rawPacket)) return false + + return true + } + + override fun hashCode(): Int { + var result = controlCommand.hashCode() + result = 31 * result + destination.hashCode() + result = 31 * result + rawPacket.contentHashCode() + return result + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/CustomEqPacket.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/CustomEqPacket.kt new file mode 100644 index 00000000..471a4145 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/CustomEqPacket.kt @@ -0,0 +1,51 @@ +package me.kavishdevar.librepods.bluetooth.aacp.packet + +import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq +import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode +import me.kavishdevar.librepods.devices.PacketDestination + + +data class CustomEqPacket( + val customEq: CustomEq, + override val destination: PacketDestination +): AACPPacket { + override val type: AACPPacketType = AACPPacketType.MESSAGE + override val service: Byte = 0x04 + + override val opcode = MessageOpcode.CONTROL_COMMAND + + override val payload: ByteArray = customEq.toAACPPayload() + + companion object { + fun parse( + packet: ByteArray, + destination: PacketDestination = PacketDestination.HOST + ): CustomEqPacket { + val payload = packet.copyOfRange(6, packet.size) + + val length = payload[0].toInt() + require(length == 5) { "Invalid length for CustomEqPacket: $length" } + + val state = payload[2].toInt() + val low = payload[3].toInt() + val mid = payload[4].toInt() + val high = payload[5].toInt() + + val customEq = CustomEq(state, low, mid, high) + + return CustomEqPacket(customEq, destination) + } + + // TODO: not all customeq messages are sending custom eq. there is one to request the current custom eq settings. + // this AACPPacket class only supports sending/receiving the custom eq settings, not other messages. + fun create( + customEq: CustomEq, + destination: PacketDestination = PacketDestination.DEVICE + ): CustomEqPacket { + return CustomEqPacket( + customEq = customEq, + destination = destination, + ) + } + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/EarDetectionResponsePacket.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/EarDetectionResponsePacket.kt new file mode 100644 index 00000000..65c693b5 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/EarDetectionResponsePacket.kt @@ -0,0 +1,79 @@ +package me.kavishdevar.librepods.bluetooth.aacp.packet + +import android.util.Log +import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode +import me.kavishdevar.librepods.devices.ComponentStatus +import me.kavishdevar.librepods.devices.DeviceComponent +import me.kavishdevar.librepods.devices.DeviceComponentState +import me.kavishdevar.librepods.devices.PacketDestination + +private const val TAG = "EarDetectionResponsePacket" + +data class EarDetectionResponsePacket( + val componentStates: Set, + val isLeftPrimary: Boolean +): AACPPacket { + override val destination: PacketDestination = PacketDestination.HOST + + override val type: AACPPacketType = AACPPacketType.MESSAGE + override val service: Byte = 0x04 + + override val opcode = MessageOpcode.EAR_DETECTION + + override val payload: ByteArray = byteArrayOf( + componentStates.first { it.component == DeviceComponent.LEFT }.status.toAirPodsByte(), + componentStates.first { it.component == DeviceComponent.RIGHT }.status.toAirPodsByte() + ).let { + if (isLeftPrimary) it else it.reversedArray() + } + + companion object { + fun parse( + packet: ByteArray, + isLeftPrimary: Boolean + ): EarDetectionResponsePacket { + val payload = packet.copyOfRange(6, packet.size) + Log.d( + TAG, + "parsing Ear Detection Response: ${packet.joinToString(" ") { "%02X".format(it) }}" + ) + + val primaryStatus = payload[0] + val secondaryStatus = payload[1] + + val componentStates = mutableSetOf() + + if (isLeftPrimary) { + componentStates.addAll( + listOf( + DeviceComponentState( + DeviceComponent.LEFT, + ComponentStatus.fromAirPodsByte(primaryStatus) + ), + DeviceComponentState( + DeviceComponent.RIGHT, + ComponentStatus.fromAirPodsByte(secondaryStatus) + ) + ) + ) + } else { + componentStates.addAll( + listOf( + DeviceComponentState( + DeviceComponent.LEFT, + ComponentStatus.fromAirPodsByte(secondaryStatus) + ), + DeviceComponentState( + DeviceComponent.RIGHT, + ComponentStatus.fromAirPodsByte(primaryStatus) + ) + ) + ) + } + + Log.i(TAG, "parsed Ear Detection Response: $componentStates") + + return EarDetectionResponsePacket(componentStates, isLeftPrimary) + } + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/InformationPacket.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/InformationPacket.kt new file mode 100644 index 00000000..9661001d --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/InformationPacket.kt @@ -0,0 +1,71 @@ +package me.kavishdevar.librepods.bluetooth.aacp.packet + +import android.util.Log +import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode +import me.kavishdevar.librepods.devices.AirPodsModel +import me.kavishdevar.librepods.devices.AirPodsSpecs +import me.kavishdevar.librepods.devices.AppleMetadata +import me.kavishdevar.librepods.devices.PacketDestination + +private const val TAG = "InformationPacket" + +data class InformationPacket( + val metadata: AppleMetadata, + override val payload: ByteArray +): AACPPacket { + override val destination: PacketDestination = PacketDestination.HOST + + override val type: AACPPacketType = AACPPacketType.MESSAGE + override val service: Byte = 0x04 + + override val opcode = MessageOpcode.INFORMATION + + companion object { + fun parse( + packet: ByteArray, + ): InformationPacket { + val payload = packet.copyOfRange(6, packet.size) + + var index = 0 + while (index < payload.size && payload[index] != 0x00.toByte()) index++ + + val strings = mutableListOf() + while (index < payload.size) { + // skip 0x00 bytes + while (index < payload.size && payload[index] == 0x00.toByte()) index++ + if (index >= payload.size) break + val start = index + // find next 0x00 byte + while (index < payload.size && payload[index] != 0x00.toByte()) index++ + val str = payload.sliceArray(start until index).decodeToString() + strings.add(str) + } + + Log.i(TAG, "parse: strings: $strings") + + strings.removeAt(0) + + val model = AirPodsModel.fromModelNumber(strings.getOrNull(1)?: "A3063") + + return InformationPacket( + metadata = AppleMetadata( + name = strings.getOrNull(0) ?: "", + model = model, + modelName = AirPodsSpecs.getSpec(model).displayName, + modelNumber = strings.getOrNull(1) ?: "", + manufacturer = strings.getOrNull(2) ?: "", + serialNumber = strings.getOrNull(3) ?: "", + leftSerialNumber = strings.getOrNull(8) ?: "", + rightSerialNumber = strings.getOrNull(9) ?: "", + version1 = strings.getOrNull(4) ?: "", + version2 = strings.getOrNull(5) ?: "", + version3 = strings.getOrNull(10) ?: "", + hardwareRevision = strings.getOrNull(6) ?: "", + updaterIdentifier = strings.getOrNull(7) ?: "" + ), + payload = payload + ) + } + } +} + diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/MagicKeysResponsePacket.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/MagicKeysResponsePacket.kt new file mode 100644 index 00000000..69f1d99a --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/MagicKeysResponsePacket.kt @@ -0,0 +1,55 @@ +package me.kavishdevar.librepods.bluetooth.aacp.packet + +import android.util.Log +import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType +import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode +import me.kavishdevar.librepods.devices.PacketDestination + +private const val TAG = "MagicKeyResponsePacket" + +data class MagicKeyResponsePacket( + val magicKeys: Map, + override val payload: ByteArray, +): AACPPacket { + override val destination: PacketDestination = PacketDestination.HOST + + override val type: AACPPacketType = AACPPacketType.MESSAGE + override val service: Byte = 0x04 + + override val opcode = MessageOpcode.MAGIC_KEYS_RESPONSE + + companion object { + fun parse( + packet: ByteArray, + ): MagicKeyResponsePacket { + val payload = packet.copyOfRange(6, packet.size) + val keyCount = payload[0].toInt() + val keys = mutableMapOf() + var offset = 1 + for (i in 0 until keyCount) { + Log.d(TAG, "Parsing Proximity Key $i") + if (offset + 3 >= payload.size) { + throw IllegalArgumentException("Data array too short to parse Proximity Keys Response") + } + val keyType = payload[offset] + val keyLength = payload[offset + 2].toInt() + Log.d(TAG, "Key Type: ${keyType.toString(16)}, Key Length: $keyLength") + offset += 4 + if (offset + keyLength > payload.size) { + throw IllegalArgumentException("Data array too short to parse Proximity Keys Response") + } + val key = ByteArray(keyLength) + System.arraycopy(payload, offset, key, 0, keyLength) + try { + keys[MagicKeyType.fromByte(keyType)] = key + } catch (e: Exception) { + Log.e(TAG, "incorrect key type received: $keyType, ${key.toHexString()}", e) + } + offset += keyLength + Log.d(TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${key.toHexString()}") + } + + return MagicKeyResponsePacket(keys, payload) + } + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/RenamePacket.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/RenamePacket.kt new file mode 100644 index 00000000..5d576d60 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/RenamePacket.kt @@ -0,0 +1,28 @@ +package me.kavishdevar.librepods.bluetooth.aacp.packet + +import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode +import me.kavishdevar.librepods.devices.PacketDestination + +data class RenamePacket( + val name: String, + override val destination: PacketDestination = PacketDestination.DEVICE, +): AACPPacket { + override val type: AACPPacketType = AACPPacketType.MESSAGE + override val service: Byte = 0x04 + + override val opcode = MessageOpcode.RENAME + + override val payload: ByteArray = byteArrayOf(0x01, name.length.toByte()) + name.toByteArray() + byteArrayOf(0x00) + + companion object { + fun create( + name: String, + destination: PacketDestination = PacketDestination.DEVICE + ): RenamePacket { + return RenamePacket( + name = name, + destination = destination, + ) + } + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/StemPressPacket.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/StemPressPacket.kt new file mode 100644 index 00000000..6c77db2c --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/StemPressPacket.kt @@ -0,0 +1,36 @@ +package me.kavishdevar.librepods.bluetooth.aacp.packet + +import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode +import me.kavishdevar.librepods.bluetooth.aacp.types.StemPressBud +import me.kavishdevar.librepods.bluetooth.aacp.types.StemPressType +import me.kavishdevar.librepods.devices.PacketDestination + +data class StemPressPacket( + val stemPressBud: StemPressBud, + val stemPressType: StemPressType, + override val payload: ByteArray, +): AACPPacket { + override val destination: PacketDestination = PacketDestination.HOST + + override val type: AACPPacketType = AACPPacketType.MESSAGE + override val service: Byte = 0x04 + + override val opcode = MessageOpcode.STEM_PRESS + + companion object { + fun parse( + packet: ByteArray, + ): StemPressPacket { + if (packet.size != 8) { + throw IllegalArgumentException("Data array too short to parse Stem Press Response") + } + + val payload = packet.copyOfRange(6, packet.size) + + val bud = StemPressBud.fromByte(payload[1])?: throw IllegalArgumentException("Invalid bud value: ${payload[1]}") + val type = StemPressType.fromByte(payload[0])?: throw IllegalArgumentException("Invalid type value: ${payload[0]}") + + return StemPressPacket(bud, type, payload) + } + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/AudioSource.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/AudioSource.kt new file mode 100644 index 00000000..fb70540d --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/AudioSource.kt @@ -0,0 +1,20 @@ +package me.kavishdevar.librepods.bluetooth.aacp.types + +import me.kavishdevar.librepods.bluetooth.MacAddress + +enum class AudioSourceType(val value: Byte) { + NONE(0x00), + CALL(0x01), + MEDIA(0x02), + UNKNOWN_1(0x04), + UNKNOWN_2(0x06), + UNKNOWN(-1); + + companion object { + fun fromByte(byte: Byte): AudioSourceType = entries.find { it.value == byte }?: UNKNOWN + } +} + +data class AudioSource( + val mac: MacAddress, val type: AudioSourceType +) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/Capability.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/Capability.kt new file mode 100644 index 00000000..ae7147c8 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/Capability.kt @@ -0,0 +1,70 @@ +package me.kavishdevar.librepods.bluetooth.aacp.types + +import kotlinx.serialization.Serializable + +// TODO: use a map instead, this is inefficient + +@Serializable +data class CapabilityEntry( + val capability: Capability, + val value: ByteArray +) + +@Serializable +enum class Capability( + val value: Byte, + val valueSize: Int = 1 +) { + UNKNOWN_01(0x01), + UNKNOWN_02(0x02), + UNKNOWN_03(0x03, 4), + SELECTIVE_SPEECH_LISTENING(0x04, 4), + ENHANCED_TRANSPARENCY_VERSION(0x06, 4), + UNKNOWN_07(0x07, 4), + UNKNOWN_09(0x09), + UNKNOWN_0A(0x0A), + UNKNOWN_0B(0x0B), + UNKNOWN_0F(0x0F), + UNKNOWN_10(0x10), + PERSONAL_MEDICAL_EQUIPMENT(0x11), + CASE_SOUND(0x12), + HIDE_OFF_LISTENING_MODE(0x13), + UNKNOWN_14(0x14), + SIRI_MULTITONE(0x15), + HIDE_EAR_DETECTION(0x16), + EAR_TIP_FIT_TEST(0x17), + AUTO_ANC(0x18), + UNKNOWN_PAUSE_MEDIA_ON_SLEEP(0x19), + WIRED_LOSSLESS_AUDIO(0x20), + SLEEP_DETECTION(0x21), + HEARING_AID(0x22), + CAMERA_CONTROL(0x23), + OVAD_STREAMING(0x24), + FAR_FIELD_UPLINK(0x25), + HEART_RATE_MONITOR(0x26), + HEARING_PROTECTION_PPE(0x28), + DYNAMIC_END_OF_CHARGE(0x29), + HEARING_PROTECTION(0x30, 4), + HEARING_AID_V2(0x31), + UNKNOWN_WIRED_LOSSLESS_AUDIO_2(0x34), + SMART_ROUTING_VERSION(0x35), + FAR_FIELD_UPLINK_MODERN(0x36), + PREFERENCE_EQ(0x37), + EXTENDED_CLICK_HOLD(0x38), + UNKNOWN_SPATIAL_AUDIO_SUPPORT(0x40), + UNKNOWN_CALL_MANAGEMENT(0x50), + UNKNOWN_60(0x60), + UNKNOWN_ADAPTIVE_VOLUME(0x90.toByte()), + UNKNOWN_A0(0xA0.toByte()), + UNKNOWN_AUTO_ANC_2(0xB0.toByte()), + UNKNOWN_HEARING_AID_2(0xC0.toByte()), + HEARING_TEST(0xD0.toByte()), + UNKNOWN_SENSOR_DATA_2(0xE0.toByte()), + BOBBLE(0xF0.toByte()); + + companion object { + private val map = entries.associateBy(Capability::value) + + fun fromByte(value: Byte): Capability? = map[value] + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/ConnectedDevice.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/ConnectedDevice.kt new file mode 100644 index 00000000..b5b37242 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/ConnectedDevice.kt @@ -0,0 +1,7 @@ +package me.kavishdevar.librepods.bluetooth.aacp.types + +import me.kavishdevar.librepods.bluetooth.MacAddress + +data class ConnectedDevice( + val macAddress: MacAddress, val info1: Byte, val info2: Byte, var type: String? +) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/ControlCommand.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/ControlCommand.kt new file mode 100644 index 00000000..51db37d6 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/ControlCommand.kt @@ -0,0 +1,115 @@ +package me.kavishdevar.librepods.bluetooth.aacp.types + +data class ControlCommand( + val identifier: ControlCommandIdentifier, val value: ByteArray +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ControlCommand + + if (identifier != other.identifier) return false + if (!value.contentEquals(other.value)) return false + + return true + } + + override fun hashCode(): Int { + var result: Int = identifier.value.toInt() + result = 31 * result + value.contentHashCode() + return result + } + + companion object { + fun fromAACPPayload(data: ByteArray): ControlCommand { + val identifier = ControlCommandIdentifier.fromByte(data[0]) + ?: throw IllegalArgumentException("Unknown ControlCommandIdentifier: ${data[0].toHexString()}") + + val value = data.copyOfRange(1, data.size) + val trimmed = value.dropLastWhile { it == 0x00.toByte() }.toByteArray() + return ControlCommand(identifier, if (trimmed.isEmpty()) byteArrayOf(0x00) else trimmed) + } + } + + fun toAACPPayload(): ByteArray { + val payload = ByteArray(5) + payload[0] = this.identifier.value + System.arraycopy(this.value, 0, payload, 1, this.value.size.coerceAtMost(4)) + return payload + } +} + + +enum class ControlCommandIdentifier(val value: Byte) { + MIC_MODE(0x01), + SCAN(0x02), + RESET(0x03), + BASIC_DOUBLE_TAP_MODE(0x04), + BUTTON_SEND_MODE(0x05), + OWNS_CONNECTION(0x06), + TAP_INTERVAL(0x07), + BUD_ROLE(0x08), + DEBUG_GET_DATA(0x09), + EAR_DETECTION_CONFIG(0x0A), + JITTER_BUFFER(0x0B), + DOUBLE_TAP_MODE(0x0C), + LISTENING_MODE(0x0D), + HEART_RATE_MONITOR_1(0x0E), + HEART_RATE_MONITOR_2(0x0F), + UNKNOWN10(0x10), + SWITCH_CONTROL(0x11), + VOICE_TRIGGER(0x12), + DOAP_MODE(0x13), + SINGLE_CLICK_MODE(0x14), + DOUBLE_CLICK_MODE(0x15), + CLICK_HOLD_MODE(0x16), + DOUBLE_CLICK_INTERVAL(0x17), + CLICK_HOLD_INTERVAL(0x18), + UNKNOWN19(0x19), + LISTENING_MODE_CONFIGS(0x1A), + ONE_BUD_ANC_MODE(0x1B), + CROWN_ROTATION_DIRECTION(0x1C), + UNKNOWN1D(0x1D), + AUTO_ANSWER_MODE(0x1E), + CHIME_VOLUME(0x1F), + SMART_ROUTING_MODE(0x20), + UNKNOWN21(0x21), + HFP_UPLINK_MODE(0x22), + VOLUME_SWIPE_INTERVAL(0x23), + CALL_MANAGEMENT_CONFIG(0x24), + VOLUME_SWIPE_MODE(0x25), + ADAPTIVE_VOLUME_CONFIG(0x26), + SOFTWARE_MUTE_CONFIG(0x27), + CONVERSATION_DETECT_CONFIG(0x28), + SSL(0x29), + UNKNOWN2A(0x2A), + UNKNOWN2B(0x2B), + HEARING_AID(0x2C), + UNKNOWN2D(0x2D), + AUTO_ANC_STRENGTH(0x2E), + HPS_GAIN_SWIPE(0x2F), + HRM_STATE(0x30), + IN_CASE_TONE_CONFIG(0x31), + SIRI_MULTITONE_CONFIG(0x32), + HEARING_ASSIST_CONFIG(0x33), + ALLOW_OFF_OPTION(0x34), + SLEEP_DETECTION_CONFIG(0x35), + ALLOW_AUTO_CONNECT(0x36), + PPE_TOGGLE_CONFIG(0x37), + PPE_CAP_LEVEL_CONFIG(0x38), + RAW_GESTURES_CONFIG(0x39), + ALLOW_TEMPORARY_MANAGED_PAIRING(0x3A), + DYNAMIC_END_OF_CHARGE(0x3B), + SYSTEM_SIRI_MODE(0x3C), + HEARING_AID_GENERIC(0x3D), + UPLINK_EQ_BUD(0x3E), + UPLINK_EQ_SOURCE(0x3F), + IN_CASE_TONE_VOLUME(0x40), + DISABLE_BUTTON_INPUT(0x41), + EXTENDED_HOLD_AND_RELEASE(0x42); + + companion object { + fun fromByte(byte: Byte): ControlCommandIdentifier? = entries.find { it.value == byte } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/CustomEq.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/CustomEq.kt similarity index 61% rename from android/app/src/main/java/me/kavishdevar/librepods/data/CustomEq.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/CustomEq.kt index 38fe3b79..5a5e3068 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/CustomEq.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/CustomEq.kt @@ -1,18 +1,21 @@ -package me.kavishdevar.librepods.data +package me.kavishdevar.librepods.bluetooth.aacp.types -import me.kavishdevar.librepods.bluetooth.AACPManager +import kotlinx.serialization.Serializable -enum class CustomEqBand { LOW, MID, HIGH } - -data class CustomEq(val state: Int, val low: Int, val mid: Int, val high: Int) { +@Serializable +data class CustomEq( + val state: Int, + val low: Int, + val mid: Int, + val high: Int +) { fun isEnabled(): Boolean { return state == 2 } - fun toPacket(): ByteArray { + fun toAACPPayload(): ByteArray { return byteArrayOf( - AACPManager.Companion.Opcodes.CUSTOM_EQ, 0x00, 0x05, 0x00, // length (LE) 0x01, state.toByte(), low.toByte(), mid.toByte(), high.toByte() diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/Events.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/Events.kt new file mode 100644 index 00000000..1a30213a --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/Events.kt @@ -0,0 +1,21 @@ +package me.kavishdevar.librepods.bluetooth.aacp.types + +sealed interface AppleEvent { + data class StemPress( + val pressType: StemPressType, + val bud: StemPressBud + ): AppleEvent + + data class ShowNearbyUi( + val sender: String + ): AppleEvent + + data class OwnershipToFalseRequest( + val sender: String, + val reverseTapped: Boolean + ): AppleEvent + + data class HeadGesturesResult( + val yes: Boolean, + ) +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/MagicKey.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/MagicKey.kt new file mode 100644 index 00000000..da31d971 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/MagicKey.kt @@ -0,0 +1,10 @@ +package me.kavishdevar.librepods.bluetooth.aacp.types + +enum class MagicKeyType(val value: Byte) { + IRK(0x01), ENC_KEY(0x04); + + companion object { + fun fromByte(byte: Byte): MagicKeyType = entries.find { it.value == byte } + ?: throw IllegalArgumentException("Unknown MagicKeyType: $byte") + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/Opcodes.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/Opcodes.kt new file mode 100644 index 00000000..dd13dda0 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/Opcodes.kt @@ -0,0 +1,91 @@ +package me.kavishdevar.librepods.bluetooth.aacp.types + +sealed interface Opcode { val value: Byte } + +data class UnknownOpcode(override val value: Byte): Opcode + +enum class ConnectOpcode(override val value: Byte): Opcode { + SOMETHING (0x01); + + companion object { + fun fromByte(value: Byte): Opcode = + entries.firstOrNull { it.value == value }?: UnknownOpcode(value) + } +} + +enum class MessageOpcode(override val value: Byte): Opcode { + CAPABILITIES_REQUEST(0x01), + CAPABILITIES(0x02), + BATTERY_INFO_REQUEST(0x03), + BATTERY_INFO(0x04), + EAR_DETECTION_REQUEST(0x05), + EAR_DETECTION(0x06), + BUD_ROLE_REQUEST(0x07), + BUD_ROLE(0x08), + CONTROL_COMMAND(0x09), + DEVICE_LIST(0x0B), + MAC_ADDRESS(0x0C), + AUDIO_SOURCE_REQUEST(0x0D), + AUDIO_SOURCE(0x0E), + REQUEST_NOTIFICATIONS(0x0F), + SMART_ROUTING(0x10), + SMART_ROUTING_RESPONSE(0x11), + EASY_PAIR_REQUEST(0x12), + EASY_PAIR(0x13), + CONNECT_PRIORITY_LIST(0x14), + TRIANGLE_LINK_STATUS_REQUEST(0x15), + TRIANGLE_LINK_STATUS(0x16), + BUDDY_COMMAND(0x17), + STEM_PRESS(0x19), + RENAME(0x1A), + TIMESTAMP(0x1B), + INFORMATION(0x1D), + EXTERNAL_ACCESSORY_SESSION(0x1E), + SESSION_STATE(0x1F), + REMOTE_FIRMWARE_AUTH(0x20), + UNKNOWN_21(0x21), + CASE_INFO_REQUEST(0x22), + CASE_INFO(0x23), + DEVICE_INFO(0x24), + CERTIFICATES_REQUEST(0x26), + CERTIFICATES(0x27), + GYRO_INFO(0x28), + SET_COUNTRY_CODE(0x29), + STREAM_STATE_INFO(0x2B), + GAPA_CHALLENGE(0x2C), + CONNECTED_DEVICES_REQUEST(0x2D), + CONNECTED_DEVICES(0x2E), + MAGIC_KEYS_REQUEST(0x30), + MAGIC_KEYS_RESPONSE(0x31), + MAGIC_KEYS_2(0x32), + UNKNOWN_40(0x40), + SMART_ROUTING_V2_INFO(0x44), + FAST_CONNECT_COMPLETE(0x45), + BUD_SWAP_PROCEDURE(0x47), + SWAP_IMMINENT_CONFIRM(0x48), + BUD_SWAP_COMPLETE(0x49), + SWAP_COMPLETE_CONFIRM(0x4A), + CONVERSATION_AWARENESS(0x4B), + ADAPTIVE_VOLUME(0x4C), + SOURCE_FEATURE_CAPABILITIES(0x4D), + FEATURE_PROXCARD_STATUS(0x4E), + UARP_DATA(0x4F), + PERF_STATS(0x50), + SOURCE_CONTEXT(0x52), + HEADPHONE_ACCOMMODATION(0x53), + SET_BAND_EDGES(0x54), + UNKNOWN_55(0x55), + USB_SPATIAL_SENSOR_DATA_REQUEST(0x56), + SLEEP_DETECTION_UPDATE(0x57), + MICROPHONE_STREAM(0x58), + DYNAMIC_END_OF_CHARGE(0x59), + PERSONAL_TRANSLATION(0x60), + SET_FEATURE_FLAGS(0x62), + CUSTOM_EQ(0x63), + APPLECARE(0x64); + + companion object { + fun fromByte(value: Byte): Opcode = + entries.firstOrNull { it.value == value }?: UnknownOpcode(value) + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/StemPress.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/StemPress.kt new file mode 100644 index 00000000..4b6e4e31 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/StemPress.kt @@ -0,0 +1,18 @@ +package me.kavishdevar.librepods.bluetooth.aacp.types + +enum class StemPressType(val value: Byte) { + SINGLE_PRESS(0x05), DOUBLE_PRESS(0x06), TRIPLE_PRESS(0x07), LONG_PRESS(0x08); + + companion object { + fun fromByte(byte: Byte): StemPressType? = entries.find { it.value == byte } + } +} + +// TODO: make DeviceComponent, BatteryComponent, and StemPressBud the same with helpers to parse from byte for specific messages +enum class StemPressBud(val value: Byte) { + LEFT(0x01), RIGHT(0x02); + + companion object { + fun fromByte(byte: Byte): StemPressBud? = entries.find { it.value == byte } + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/att/ATTManager.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/att/ATTManager.kt new file mode 100644 index 00000000..746b15fe --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/att/ATTManager.kt @@ -0,0 +1,159 @@ +/* + 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 . +*/ + +package me.kavishdevar.librepods.bluetooth.att + +import android.bluetooth.BluetoothSocket +import android.os.ParcelUuid +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import me.kavishdevar.librepods.devices.Device + +enum class ATTHandle(val value: Int) { + TRANSPARENCY(0x18), + LOUD_SOUND_REDUCTION(0x1B), + HEARING_AID(0x2A) +} + +class ATTManager ( + private val device: Device<*, *, *> +) { + val macParts = device.macAddress.value.split(":") + private val TAG = "ATTManager[${macParts[0]}:${macParts[1]}:${macParts[2]}]" + private var socket: BluetoothSocket? = null + suspend fun readCharacteristic(handle: ATTHandle): ByteArray? { + // TODO + return null + if (socket != null) { + Log.d(TAG, "Closing existing socket before reading characteristic") + try { + socket!!.close() + Log.d(TAG, "Existing socket closed successfully") + } catch (e: Exception) { + e.printStackTrace() + } + socket = null + } + + Log.d(TAG, "Creating new socket for reading characteristic") + socket = try { + device.createSocket(ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"), 31) + } catch (e: Exception) { + Log.e(TAG, "Error creating socket: ${e.message}") + return null + } + + try { + socket?.connect() + Log.d(TAG, "Socket connected successfully") + val output = socket?.outputStream ?: return null + Log.d(TAG, "Sending read request for handle: ${handle.value}") + val pdu = byteArrayOf(0x0A, handle.value.toByte(), 0x00) + + withContext(Dispatchers.IO) { + output.write(pdu) + Log.d(TAG, "Read request sent: ${pdu.joinToString(" ") { String.format("%02X", it) }}") + output.flush() + Log.d(TAG, "Output stream flushed after sending read request") + } + + val buffer = ByteArray(1024) + val bytesRead = withContext(Dispatchers.IO) { + socket?.inputStream?.read(buffer)?: -1 + } + Log.d(TAG, "Read response received, bytes read: ${buffer.copyOfRange(0, bytesRead).toHexString()}") + return if (bytesRead > 0) { + buffer.copyOfRange(1, bytesRead) + } else { + null + } + } catch (e: Exception) { + e.printStackTrace() + Log.e(TAG, "Error during read operation: ${e.message}") + } finally { + try { + Log.d(TAG, "Closing socket after read operation") + socket?.close() + socket = null + Log.d(TAG, "Socket closed successfully after read operation") + } catch (e: Exception) { + e.printStackTrace() + } + } + return null + } + + suspend fun writeCharacteristic(handle: ATTHandle, data: ByteArray): Boolean { + return false + // todo + if (socket != null) { + Log.d(TAG, "Closing existing socket before writing characteristic") + try { + socket!!.close() + Log.d(TAG, "Existing socket closed successfully") + } catch (e: Exception) { + e.printStackTrace() + } + socket = null + } + + Log.d(TAG, "Creating new socket for writing characteristic") + socket = try { + device.createSocket(ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"), 31) + } catch (e: Exception) { + e.printStackTrace() + return false + } + Log.d(TAG, "Socket created successfully, attempting to connect") + + try { + socket?.connect() + Log.d(TAG, "Socket connected successfully") + val output = socket?.outputStream ?: return false + Log.d(TAG, "Sending write request for handle: ${handle.value}") + val pdu = byteArrayOf(0x12, handle.value.toByte(), 0x00) + data + + withContext(Dispatchers.IO) { + output.write(pdu) + Log.d(TAG, "Write request sent: ${pdu.joinToString(" ") { String.format("%02X", it) }}") + output.flush() + Log.d(TAG, "Output stream flushed after sending write request") + } + + val buffer = ByteArray(1024) + val bytesRead = withContext(Dispatchers.IO) { + socket?.inputStream?.read(buffer)?: -1 + } + Log.d(TAG, "Write response received, bytes read: ${buffer.copyOfRange(0, bytesRead).toHexString()}") + return bytesRead > 0 + } catch (e: Exception) { + e.printStackTrace() + } finally { + try { + Log.d(TAG, "Closing socket after write operation") + socket?.close() + socket = null + Log.d(TAG, "Socket closed successfully after write operation") + } catch (e: Exception) { + e.printStackTrace() + } + } + return false + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/HearingAid.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/att/types/HearingAid.kt similarity index 97% rename from android/app/src/main/java/me/kavishdevar/librepods/data/HearingAid.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/att/types/HearingAid.kt index 7b989d6d..bfe0dd8a 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/HearingAid.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/att/types/HearingAid.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.data +package me.kavishdevar.librepods.bluetooth.att.types import android.util.Log import androidx.compose.runtime.MutableState @@ -25,7 +25,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import me.kavishdevar.librepods.bluetooth.ATTHandles +import me.kavishdevar.librepods.bluetooth.att.ATTHandle import java.io.IOException import java.nio.ByteBuffer import java.nio.ByteOrder @@ -141,7 +141,7 @@ fun sendHearingAidSettings( currentData: ByteArray, hearingAidSettings: HearingAidSettings, debounceJob: MutableState, - sender: (ATTHandles, ByteArray) -> Unit + sender: (ATTHandle, ByteArray) -> Unit ) { debounceJob.value?.cancel() debounceJob.value = CoroutineScope(Dispatchers.IO).launch { @@ -184,7 +184,7 @@ fun sendHearingAidSettings( Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}") - sender(ATTHandles.HEARING_AID, currentData) + sender(ATTHandle.HEARING_AID, currentData) } catch (e: IOException) { e.printStackTrace() } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/Transparency.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/att/types/Transparency.kt similarity index 94% rename from android/app/src/main/java/me/kavishdevar/librepods/data/Transparency.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/att/types/Transparency.kt index c43e1cff..62f8c70a 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/Transparency.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/att/types/Transparency.kt @@ -16,17 +16,18 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.data +package me.kavishdevar.librepods.bluetooth.att.types import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import me.kavishdevar.librepods.bluetooth.ATTHandles +import me.kavishdevar.librepods.bluetooth.att.ATTHandle import java.io.IOException import java.nio.ByteBuffer import java.nio.ByteOrder +import kotlin.time.Duration.Companion.milliseconds data class TransparencySettings( val enabled: Boolean, @@ -141,10 +142,10 @@ fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings? { private var debounceJob: Job? = null -fun sendTransparencySettings(writer: (ATTHandles, ByteArray) -> Unit, transparencySettings: TransparencySettings) { +fun sendTransparencySettings(writer: (ATTHandle, ByteArray) -> Unit, transparencySettings: TransparencySettings) { debounceJob?.cancel() debounceJob = CoroutineScope(Dispatchers.IO).launch { - delay(100) + delay(100.milliseconds) try { val buffer = ByteBuffer.allocate( if (transparencySettings.ownVoiceAmplification != null) 104 else 100 @@ -173,7 +174,7 @@ fun sendTransparencySettings(writer: (ATTHandles, ByteArray) -> Unit, transparen } val data = buffer.array() - writer(ATTHandles.TRANSPARENCY, data) + writer(ATTHandle.TRANSPARENCY, data) } catch (e: IOException) { e.printStackTrace() } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BLEManager.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/ble/BLEManagerold.kt similarity index 74% rename from android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BLEManager.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/ble/BLEManagerold.kt index 52fa0551..355c8f0c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BLEManager.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/ble/BLEManagerold.kt @@ -1,22 +1,4 @@ -/* - 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 . -*/ - -package me.kavishdevar.librepods.bluetooth +package me.kavishdevar.librepods.bluetooth.ble import android.annotation.SuppressLint import android.bluetooth.BluetoothManager @@ -30,10 +12,9 @@ import android.content.SharedPreferences import android.os.Handler import android.os.Looper import android.util.Log -import me.kavishdevar.librepods.utils.BluetoothCryptography +import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType import javax.crypto.Cipher import javax.crypto.spec.SecretKeySpec -import kotlin.collections.iterator import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -41,7 +22,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi * Manager for Bluetooth Low Energy scanning operations specifically for AirPods */ @OptIn(ExperimentalEncodingApi::class) -class BLEManager(private val context: Context) { +class BLEManagerold(private val context: Context) { data class AirPodsStatus( val address: String, @@ -212,7 +193,7 @@ class BLEManager(private val context: Context) { @OptIn(ExperimentalEncodingApi::class) private fun getEncryptionKeyFromPreferences(): ByteArray? { - val keyBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null) + val keyBase64 = sharedPreferences.getString(MagicKeyType.ENC_KEY.name, null) return if (keyBase64 != null) { try { Base64.decode(keyBase64) @@ -250,86 +231,86 @@ class BLEManager(private val context: Context) { } private fun processScanResult(result: ScanResult) { - try { - val scanRecord = result.scanRecord ?: return - val address = result.device.address - - if (processedAddresses.contains(address)) { - return - } - - val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return - if (manufacturerData.size <= 20) return - - if (!verifiedAddresses.contains(address)) { - val irk = getIrkFromPreferences() - if (irk == null || !BluetoothCryptography.verifyRPA(address, irk)) { - return - } - verifiedAddresses.add(address) - Log.d(TAG, "RPA verified and added to trusted list: $address") - } - - processedAddresses.add(address) - lastBroadcastTime = System.currentTimeMillis() - - val encryptionKey = getEncryptionKeyFromPreferences() - val decryptedData = if (encryptionKey != null) decryptLastBytes(manufacturerData, encryptionKey) else null - val parsedStatus = if (decryptedData != null && decryptedData.size == 16) { - parseProximityMessageWithDecryption(address, manufacturerData, decryptedData) - } else { - parseProximityMessage(address, manufacturerData) - } - - val previousStatus = deviceStatusMap[address] - deviceStatusMap[address] = parsedStatus - - airPodsStatusListener?.let { listener -> - if (previousStatus == null) { - listener.onBroadcastFromNewAddress(parsedStatus) - Log.d(TAG, "New AirPods device detected: $address") - - if (currentGlobalLidState == null || currentGlobalLidState != parsedStatus.lidOpen) { - currentGlobalLidState = parsedStatus.lidOpen - listener.onLidStateChanged(parsedStatus.lidOpen) - Log.d(TAG, "Lid state ${if (parsedStatus.lidOpen) "opened" else "closed"} (detected from new device)") - } - } else { - if (parsedStatus != previousStatus) { - listener.onDeviceStatusChanged(parsedStatus, previousStatus) - } - - if (parsedStatus.lidOpen != previousStatus.lidOpen) { - val previousGlobalState = currentGlobalLidState - currentGlobalLidState = parsedStatus.lidOpen - - if (previousGlobalState != parsedStatus.lidOpen) { - listener.onLidStateChanged(parsedStatus.lidOpen) - Log.d(TAG, "Lid state changed from $previousGlobalState to ${parsedStatus.lidOpen}") - } - } - - if (parsedStatus.isLeftInEar != previousStatus.isLeftInEar || - parsedStatus.isRightInEar != previousStatus.isRightInEar) { - listener.onEarStateChanged( - parsedStatus, - parsedStatus.isLeftInEar, - parsedStatus.isRightInEar - ) - Log.d(TAG, "Ear state changed - Left: ${parsedStatus.isLeftInEar}, Right: ${parsedStatus.isRightInEar}") - } - - if (parsedStatus.leftBattery != previousStatus.leftBattery || - parsedStatus.rightBattery != previousStatus.rightBattery || - parsedStatus.caseBattery != previousStatus.caseBattery) { - listener.onBatteryChanged(parsedStatus) - Log.d(TAG, "Battery changed - Left: ${parsedStatus.leftBattery}, Right: ${parsedStatus.rightBattery}, Case: ${parsedStatus.caseBattery}") - } - } - } - } catch (t: Throwable) { - Log.e(TAG, "Error processing scan result", t) - } +// try { +// val scanRecord = result.scanRecord ?: return +// val address = result.device.address +// +// if (processedAddresses.contains(address)) { +// return +// } +// +// val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return +// if (manufacturerData.size <= 20) return +// +// if (!verifiedAddresses.contains(address)) { +// val irk = getIrkFromPreferences() +// if (irk == null || !BluetoothCryptography.verifyRPA(address, irk)) { +// return +// } +// verifiedAddresses.add(address) +// Log.d(TAG, "RPA verified and added to trusted list: $address") +// } +// +// processedAddresses.add(address) +// lastBroadcastTime = System.currentTimeMillis() +// +// val encryptionKey = getEncryptionKeyFromPreferences() +// val decryptedData = if (encryptionKey != null) decryptLastBytes(manufacturerData, encryptionKey) else null +// val parsedStatus = if (decryptedData != null && decryptedData.size == 16) { +// parseProximityMessageWithDecryption(address, manufacturerData, decryptedData) +// } else { +// parseProximityMessage(address, manufacturerData) +// } +// +// val previousStatus = deviceStatusMap[address] +// deviceStatusMap[address] = parsedStatus +// +// airPodsStatusListener?.let { listener -> +// if (previousStatus == null) { +// listener.onBroadcastFromNewAddress(parsedStatus) +// Log.d(TAG, "New AirPods device detected: $address") +// +// if (currentGlobalLidState == null || currentGlobalLidState != parsedStatus.lidOpen) { +// currentGlobalLidState = parsedStatus.lidOpen +// listener.onLidStateChanged(parsedStatus.lidOpen) +// Log.d(TAG, "Lid state ${if (parsedStatus.lidOpen) "opened" else "closed"} (detected from new device)") +// } +// } else { +// if (parsedStatus != previousStatus) { +// listener.onDeviceStatusChanged(parsedStatus, previousStatus) +// } +// +// if (parsedStatus.lidOpen != previousStatus.lidOpen) { +// val previousGlobalState = currentGlobalLidState +// currentGlobalLidState = parsedStatus.lidOpen +// +// if (previousGlobalState != parsedStatus.lidOpen) { +// listener.onLidStateChanged(parsedStatus.lidOpen) +// Log.d(TAG, "Lid state changed from $previousGlobalState to ${parsedStatus.lidOpen}") +// } +// } +// +// if (parsedStatus.isLeftInEar != previousStatus.isLeftInEar || +// parsedStatus.isRightInEar != previousStatus.isRightInEar) { +// listener.onEarStateChanged( +// parsedStatus, +// parsedStatus.isLeftInEar, +// parsedStatus.isRightInEar +// ) +// Log.d(TAG, "Ear state changed - Left: ${parsedStatus.isLeftInEar}, Right: ${parsedStatus.isRightInEar}") +// } +// +// if (parsedStatus.leftBattery != previousStatus.leftBattery || +// parsedStatus.rightBattery != previousStatus.rightBattery || +// parsedStatus.caseBattery != previousStatus.caseBattery) { +// listener.onBatteryChanged(parsedStatus) +// Log.d(TAG, "Battery changed - Left: ${parsedStatus.leftBattery}, Right: ${parsedStatus.rightBattery}, Case: ${parsedStatus.caseBattery}") +// } +// } +// } +// } catch (t: Throwable) { +// Log.e(TAG, "Error processing scan result", t) +// } } private fun parseProximityMessageWithDecryption(address: String, data: ByteArray, decrypted: ByteArray): AirPodsStatus { @@ -417,7 +398,7 @@ class BLEManager(private val context: Context) { @OptIn(ExperimentalEncodingApi::class) private fun getIrkFromPreferences(): ByteArray? { - val irkBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null) + val irkBase64 = sharedPreferences.getString(MagicKeyType.IRK.name, null) return if (irkBase64 != null) { try { Base64.decode(irkBase64) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/AirPodsNotifications.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/AirPodsNotifications.kt new file mode 100644 index 00000000..22a337fb --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/AirPodsNotifications.kt @@ -0,0 +1,7 @@ +package me.kavishdevar.librepods.data + +enum class AirPodsNotifications(val action: String) { + AIRPODS_CONNECTED("me.kavishdevar.librepods.AIRPODS_CONNECTED"), + ANC_DATA("me.kavishdevar.librepods.ANC_DATA"), + AIRPODS_DISCONNECTED("me.kavishdevar.librepods.AIRPODS_DISCONNECTED"), +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/StemAction.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/StemAction.kt similarity index 69% rename from android/app/src/main/java/me/kavishdevar/librepods/data/StemAction.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/data/StemAction.kt index 5bd9e6c8..02c4e4ea 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/StemAction.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/StemAction.kt @@ -18,8 +18,10 @@ package me.kavishdevar.librepods.data -import me.kavishdevar.librepods.bluetooth.AACPManager +import kotlinx.serialization.Serializable +import me.kavishdevar.librepods.bluetooth.aacp.types.StemPressType +@Serializable enum class StemAction { PLAY_PAUSE, PREVIOUS_TRACK, @@ -30,11 +32,11 @@ enum class StemAction { fun fromString(action: String): StemAction? { return entries.find { it.name == action } } - val defaultActions: Map = mapOf( - AACPManager.Companion.StemPressType.SINGLE_PRESS to PLAY_PAUSE, - AACPManager.Companion.StemPressType.DOUBLE_PRESS to NEXT_TRACK, - AACPManager.Companion.StemPressType.TRIPLE_PRESS to PREVIOUS_TRACK, - AACPManager.Companion.StemPressType.LONG_PRESS to CYCLE_NOISE_CONTROL_MODES, + val defaultActions: Map = mapOf( + StemPressType.SINGLE_PRESS to PLAY_PAUSE, + StemPressType.DOUBLE_PRESS to NEXT_TRACK, + StemPressType.TRIPLE_PRESS to PREVIOUS_TRACK, + StemPressType.LONG_PRESS to CYCLE_NOISE_CONTROL_MODES, ) } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/apple/AppleCache.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/apple/AppleCache.kt new file mode 100644 index 00000000..50925833 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/apple/AppleCache.kt @@ -0,0 +1,15 @@ +package me.kavishdevar.librepods.data.apple + +import kotlinx.serialization.Serializable +import me.kavishdevar.librepods.bluetooth.aacp.types.CapabilityEntry +import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier +import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq +import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType + +@Serializable +data class AppleCache( + val capabilities: Set = emptySet(), + val magicKeys: Map = emptyMap(), + val controlStates: Map = emptyMap(), + val customEq: CustomEq = CustomEq(1, 50, 50, 50), +) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/audio/MicrophoneFrame.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/audio/MicrophoneFrame.kt new file mode 100644 index 00000000..a168c42f --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/audio/MicrophoneFrame.kt @@ -0,0 +1,57 @@ +package me.kavishdevar.librepods.data.audio + +import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode +import java.nio.ByteBuffer +import java.nio.ByteOrder + +data class MicrophoneFrame( + val timestamp: UInt, + val accessUnit: ByteArray +) { + companion object { + fun parsePacket(packet: ByteArray): List { + if (packet.size < 22) { + throw IllegalArgumentException("Microphone packet too short") + } + + if (packet[4] != MessageOpcode.MICROPHONE_STREAM.value) { + throw IllegalArgumentException("Not a microphoneState packet") + } + + + if (packet[6] != 0x01.toByte() || packet[7] != 0x00.toByte()) { + return emptyList() + } + + val frames = mutableListOf() + + var offset = 22 + + while (offset + 5 <= packet.size) { + val timestamp = ByteBuffer + .wrap(packet, offset, 4) + .order(ByteOrder.LITTLE_ENDIAN) + .int + .toUInt() + + val length = packet[offset + 4].toUByte().toInt() + + val start = offset + 5 + val end = start + length + + if (end > packet.size) { + break + } + + frames += MicrophoneFrame( + timestamp = timestamp, + accessUnit = packet.copyOfRange(start, end) + ) + + offset = end + } + + return frames + } + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/audio/MicrophoneState.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/audio/MicrophoneState.kt new file mode 100644 index 00000000..02e9ac3a --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/audio/MicrophoneState.kt @@ -0,0 +1,12 @@ +package me.kavishdevar.librepods.data.audio + +data class MicrophoneState( + val isActive: Boolean = false, + + val packetsReceived: Long = 0, + val decodeErrors: Long = 0, + + val durationMs: Long = 0, + + val level: Float = 0f, +) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/recording/Recording.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/recording/Recording.kt new file mode 100644 index 00000000..000c7d36 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/recording/Recording.kt @@ -0,0 +1,11 @@ +package me.kavishdevar.librepods.data.recording + +import java.io.File +import kotlin.time.Instant +import kotlin.uuid.Uuid + +data class Recording( + val uuid: Uuid, + val file: File, + val createdAt: Instant +) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/recording/RecordingState.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/recording/RecordingState.kt new file mode 100644 index 00000000..7812cea8 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/recording/RecordingState.kt @@ -0,0 +1,6 @@ +package me.kavishdevar.librepods.data.recording + +class RecordingState { + val isRecording: Boolean = false + val recording: Recording? = null +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/updates/UpdateItem.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/updates/UpdateItem.kt similarity index 100% rename from android/app/src/main/java/me/kavishdevar/librepods/data/updates/UpdateItem.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/data/updates/UpdateItem.kt diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/updates/Updates.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/updates/Updates.kt similarity index 79% rename from android/app/src/main/java/me/kavishdevar/librepods/data/updates/Updates.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/data/updates/Updates.kt index f7d82ebf..bcdab1fe 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/updates/Updates.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/updates/Updates.kt @@ -2,9 +2,9 @@ package me.kavishdevar.librepods.data.updates import androidx.compose.runtime.Composable import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreenPreviewMaterial -import me.kavishdevar.librepods.presentation.screens.EqualizerScreenPreviewApple -import me.kavishdevar.librepods.presentation.screens.EqualizerScreenPreviewMaterial +import me.kavishdevar.librepods.presentation.screens.apple.AirPodsSettingsScreenPreviewMaterial +import me.kavishdevar.librepods.presentation.screens.apple.EqualizerScreenPreviewApple +import me.kavishdevar.librepods.presentation.screens.apple.EqualizerScreenPreviewMaterial import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePref.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/xposed/XposedRemotePref.kt similarity index 78% rename from android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePref.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/data/xposed/XposedRemotePref.kt index 1977b043..e5e55450 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePref.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/xposed/XposedRemotePref.kt @@ -1,4 +1,4 @@ -package me.kavishdevar.librepods.data +package me.kavishdevar.librepods.data.xposed interface XposedRemotePref { fun isAvailable(): Boolean diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePrefImpl.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/xposed/XposedRemotePrefImpl.kt similarity index 93% rename from android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePrefImpl.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/data/xposed/XposedRemotePrefImpl.kt index 112e7527..5db752e7 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePrefImpl.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/xposed/XposedRemotePrefImpl.kt @@ -1,4 +1,4 @@ -package me.kavishdevar.librepods.data +package me.kavishdevar.librepods.data.xposed import androidx.core.content.edit import me.kavishdevar.librepods.utils.XposedServiceHolder diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePrefProvider.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/xposed/XposedRemotePrefProvider.kt similarity index 68% rename from android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePrefProvider.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/data/xposed/XposedRemotePrefProvider.kt index 9f18e8ca..52e930f1 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePrefProvider.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/xposed/XposedRemotePrefProvider.kt @@ -1,4 +1,4 @@ -package me.kavishdevar.librepods.data +package me.kavishdevar.librepods.data.xposed object XposedRemotePrefProvider { fun create(): XposedRemotePref = XposedRemotePrefImpl() diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/Converters.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/Converters.kt new file mode 100644 index 00000000..af8e1905 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/Converters.kt @@ -0,0 +1,42 @@ +package me.kavishdevar.librepods.database + +import androidx.room3.ColumnTypeConverter +import kotlinx.serialization.cbor.Cbor +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import me.kavishdevar.librepods.bluetooth.MacAddress +import me.kavishdevar.librepods.data.apple.AppleCache +import me.kavishdevar.librepods.devices.AppleMetadata +import me.kavishdevar.librepods.devices.AppleSettings + +object Converters { + @ColumnTypeConverter + fun macAddressToString(mac: MacAddress): String = mac.value + + @ColumnTypeConverter + fun stringToMacAddress(value: String): MacAddress = MacAddress(value) + + @ColumnTypeConverter + fun appleSettingsToBytes(settings: AppleSettings): ByteArray = + Cbor.encodeToByteArray(settings) + + @ColumnTypeConverter + fun bytesToAppleSettings(bytes: ByteArray): AppleSettings = + Cbor.decodeFromByteArray(bytes) + + @ColumnTypeConverter + fun appleMetadataToBytes(metadata: AppleMetadata): ByteArray = + Cbor.encodeToByteArray(metadata) + + @ColumnTypeConverter + fun bytesToAppleMetadata(bytes: ByteArray): AppleMetadata = + Cbor.decodeFromByteArray(bytes) + + @ColumnTypeConverter + fun appleCacheToBytes(cache: AppleCache): ByteArray = + Cbor.encodeToByteArray(cache) + + @ColumnTypeConverter + fun bytesToAppleCache(bytes: ByteArray): AppleCache = + Cbor.decodeFromByteArray(bytes) +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/LibrePodsDatabase.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/LibrePodsDatabase.kt new file mode 100644 index 00000000..ef16a0c3 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/LibrePodsDatabase.kt @@ -0,0 +1,32 @@ +package me.kavishdevar.librepods.database + +import androidx.room3.ColumnTypeConverters +import androidx.room3.Database +import androidx.room3.RoomDatabase +import me.kavishdevar.librepods.database.app.AppSettingsDao +import me.kavishdevar.librepods.database.app.AppSettingsEntity +import me.kavishdevar.librepods.database.app.AppStateDao +import me.kavishdevar.librepods.database.app.AppStateEntity +import me.kavishdevar.librepods.database.apple.AppleDao +import me.kavishdevar.librepods.database.apple.AppleEntity +import me.kavishdevar.librepods.database.widget.WidgetConfigDao +import me.kavishdevar.librepods.database.widget.WidgetConfigEntity + +@ColumnTypeConverters(Converters::class) +@Database( + entities = [ + AppleEntity::class, + AppSettingsEntity::class, + AppStateEntity::class, + WidgetConfigEntity::class, + ], + version = 1, +) +abstract class LibrePodsDatabase: RoomDatabase() { + abstract fun appleDao(): AppleDao + + abstract fun appSettingsDao(): AppSettingsDao + abstract fun appStateDao(): AppStateDao + + abstract fun widgetConfigDao(): WidgetConfigDao +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/app/AppSettingsDao.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/app/AppSettingsDao.kt new file mode 100644 index 00000000..51537538 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/app/AppSettingsDao.kt @@ -0,0 +1,15 @@ +package me.kavishdevar.librepods.database.app + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Upsert + +@Dao +interface AppSettingsDao { + + @Query("SELECT * FROM AppSettingsEntity WHERE id = 0") + suspend fun get(): AppSettingsEntity? + + @Upsert + suspend fun upsert(settings: AppSettingsEntity) +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/app/AppSettingsEntity.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/app/AppSettingsEntity.kt new file mode 100644 index 00000000..5457ff20 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/app/AppSettingsEntity.kt @@ -0,0 +1,24 @@ +package me.kavishdevar.librepods.database.app + +import android.bluetooth.le.ScanSettings +import androidx.room3.Entity +import androidx.room3.PrimaryKey +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.NightTheme + +@Entity +data class AppSettingsEntity( + @PrimaryKey + val id: Int = 0, + + val nightMode: NightTheme = NightTheme.System, + val designSystem: DesignSystem = DesignSystem.Material, + + /** + * Currently only shows the button for Debug screen. + */ + val debugMode: Boolean = false, + + val bleScanMode: Int = ScanSettings.SCAN_MODE_BALANCED, + val bleReportDelay: Long = 0, +) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/app/AppStateDao.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/app/AppStateDao.kt new file mode 100644 index 00000000..12a8571f --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/app/AppStateDao.kt @@ -0,0 +1,15 @@ +package me.kavishdevar.librepods.database.app + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Upsert + +@Dao +interface AppStateDao { + + @Query("SELECT * FROM AppStateEntity WHERE id = 0") + suspend fun get(): AppStateEntity? + + @Upsert + suspend fun upsert(state: AppStateEntity) +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/app/AppStateEntiy.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/app/AppStateEntiy.kt new file mode 100644 index 00000000..96de2635 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/app/AppStateEntiy.kt @@ -0,0 +1,20 @@ +package me.kavishdevar.librepods.database.app + +import androidx.room3.Entity +import androidx.room3.PrimaryKey + +@Entity +data class AppStateEntity( + @PrimaryKey + val id: Int = 0, + + val hasCompletedOnboarding: Boolean = false, + val lastVersionShown: String? = null, + + val hasConnectedToAACP: Boolean = false, + val firstSuccessfulConnectionTime: Long? = null, + + val reviewPrompted: Boolean = false, + + val timeUntilFOSSPremiumExpiry: Long = 0L, +) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/apple/AppleDao.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/apple/AppleDao.kt new file mode 100644 index 00000000..62884dd4 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/apple/AppleDao.kt @@ -0,0 +1,43 @@ +package me.kavishdevar.librepods.database.apple + +import android.util.Log +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Upsert +import me.kavishdevar.librepods.data.apple.AppleCache +import me.kavishdevar.librepods.devices.AppleMetadata +import me.kavishdevar.librepods.devices.AppleSettings +import me.kavishdevar.librepods.bluetooth.MacAddress + +private const val TAG = "AppleDao" + +@Dao +interface AppleDao { + + @Query("SELECT * FROM AppleEntity WHERE macAddress = :macAddress") + suspend fun get(macAddress: MacAddress): AppleEntity? + + @Query("SELECT * FROM AppleEntity") + suspend fun getAll(): List + + @Upsert + suspend fun upsert(device: AppleEntity) + + suspend fun updateSettings(macAddress: MacAddress, settings: AppleSettings) { + Log.d(TAG, "Updating settings for $macAddress: $settings") + val device = get(macAddress)?: AppleEntity(macAddress = macAddress, settings = settings, metadata = AppleMetadata(), cache = AppleCache()) + upsert(device.copy(settings = settings)) + } + + suspend fun updateMetadata(macAddress: MacAddress, metadata: AppleMetadata) { + Log.d(TAG, "Updating metadata for $macAddress: $metadata") + val device = get(macAddress)?: AppleEntity(macAddress = macAddress, settings = AppleSettings(), metadata = metadata, cache = AppleCache()) + upsert(device.copy(metadata = metadata)) + } + + suspend fun saveCache(macAddress: MacAddress, cache: AppleCache) { + Log.d(TAG, "Saving cache for $macAddress: $cache") + val device = get(macAddress)?: AppleEntity(macAddress = macAddress, settings = AppleSettings(), metadata = AppleMetadata(), cache = cache) + upsert(device.copy(cache = cache)) + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/apple/AppleEntity.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/apple/AppleEntity.kt new file mode 100644 index 00000000..b7391dde --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/apple/AppleEntity.kt @@ -0,0 +1,18 @@ +package me.kavishdevar.librepods.database.apple + +import androidx.room3.Entity +import androidx.room3.PrimaryKey +import me.kavishdevar.librepods.data.apple.AppleCache +import me.kavishdevar.librepods.devices.AppleMetadata +import me.kavishdevar.librepods.devices.AppleSettings +import me.kavishdevar.librepods.bluetooth.MacAddress + +@Entity +data class AppleEntity( + @PrimaryKey + val macAddress: MacAddress, + + val settings: AppleSettings, + val metadata: AppleMetadata, + val cache: AppleCache, +) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/widget/WidgetConfigDao.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/widget/WidgetConfigDao.kt new file mode 100644 index 00000000..dc9b6e93 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/widget/WidgetConfigDao.kt @@ -0,0 +1,18 @@ +package me.kavishdevar.librepods.database.widget + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Upsert + +@Dao +interface WidgetConfigDao { + + @Query("SELECT * FROM WidgetConfigEntity") + suspend fun getAll(): List + + @Query("SELECT * FROM WidgetConfigEntity WHERE appWidgetId = :appWidgetId") + suspend fun getWidgetById(appWidgetId: Int): WidgetConfigEntity? + + @Upsert + suspend fun upsert(widgetConfig: WidgetConfigEntity) +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/widget/WidgetConfigEntity.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/widget/WidgetConfigEntity.kt new file mode 100644 index 00000000..0bd3e725 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/widget/WidgetConfigEntity.kt @@ -0,0 +1,14 @@ +package me.kavishdevar.librepods.database.widget + +import androidx.room3.Entity +import androidx.room3.PrimaryKey +import me.kavishdevar.librepods.bluetooth.MacAddress + +@Entity +data class WidgetConfigEntity( + @PrimaryKey + val appWidgetId: Int, + + val macAddress: MacAddress +) + diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AirPods.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AirPods.kt new file mode 100644 index 00000000..0720be3e --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AirPods.kt @@ -0,0 +1,414 @@ +/* + 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 . +*/ + +package me.kavishdevar.librepods.devices + +import androidx.annotation.DrawableRes +import kotlinx.serialization.Serializable +import me.kavishdevar.librepods.R + +data class ComponentSpec( + val type: DeviceComponent, + val iconName: String, + val label: String, +) + +@Serializable +enum class AirPodsModel { + AIRPODS_1, + AIRPODS_2, + AIRPODS_3, + AIRPODS_4, + AIRPODS_4_ANC, + AIRPODS_PRO_1, + AIRPODS_PRO_2_LIGHTNING, + AIRPODS_PRO_2_USBC, + AIRPODS_PRO_3, + AIRPODS_MAX_LIGHTNING, + AIRPODS_MAX_USBC, + AIRPODS_MAX_2, + UNKNOWN; + + companion object { + fun fromModelNumber(modelNumber: String): AirPodsModel { + return AirPodsSpecs.specs.entries.firstOrNull { (_, spec) -> + spec.modelNumbers.contains(modelNumber) + }?.key ?: UNKNOWN + } + } +} + +data class AirPodsSpec( + val modelNumbers: Set, + val name: String, + val displayName: String, + val components: Set = emptySet(), + val genericIconName: String = "AirPodsPro3", + @DrawableRes val primaryImageRes: Int = R.drawable.img_airpods_pro_2_buds, + @DrawableRes val caseImageRes: Int? = R.drawable.img_airpods_pro_2_case, + val baseCapabilities: Set, +) + +object AirPodsSpecs { + internal val specs = mapOf( + AirPodsModel.AIRPODS_1 to AirPodsSpec( + modelNumbers = setOf("A1523", "A1722"), + name = "AirPods 1", + displayName = "AirPods1", + components = setOf( + ComponentSpec( + type = DeviceComponent.LEFT, + iconName = "LeftCircleFill", + label = "Left", + ), + ComponentSpec( + type = DeviceComponent.RIGHT, + iconName = "RightCircleFill", + label = "Right" + ), + ComponentSpec( + type = DeviceComponent.CASE, + iconName = "AirPods1Case", + label = "Charging Case" + ) + ), + genericIconName = "AirPods", + baseCapabilities = emptySet() + ), + AirPodsModel.AIRPODS_2 to AirPodsSpec( + modelNumbers = setOf("A2032", "A2031"), + name = "AirPods 2", + displayName = "AirPods", + components = setOf( + ComponentSpec( + type = DeviceComponent.LEFT, + iconName = "LeftCircleFill", + label = "Left" + ), + ComponentSpec( + type = DeviceComponent.RIGHT, + iconName = "RightCircleFill", + label = "Right" + ), + ComponentSpec( + type = DeviceComponent.CASE, + iconName = "AirPods2Case", + label = "Charging Case" + ) + ), + genericIconName = "AirPods", + baseCapabilities = emptySet() + ), + AirPodsModel.AIRPODS_3 to AirPodsSpec( + modelNumbers = setOf("A2565", "A2564"), + name = "AirPods 3", + displayName = "AirPods", + components = setOf( + ComponentSpec( + type = DeviceComponent.LEFT, + iconName = "LeftCircleFill", + label = "Left" + ), + ComponentSpec( + type = DeviceComponent.RIGHT, + iconName = "RightCircleFill", + label = "Right" + ), + ComponentSpec( + type = DeviceComponent.CASE, + iconName = "AirPods3Case", + label = "Charging Case" + ) + ), + genericIconName = "AirPods3", + baseCapabilities = setOf( + BaseCapability.HEAD_GESTURES + ) + ), + AirPodsModel.AIRPODS_4 to AirPodsSpec( + modelNumbers = setOf("A3053", "A3050", "A3054"), + name = "AirPods 4", + displayName = "AirPods", + components = setOf( + ComponentSpec( + type = DeviceComponent.LEFT, + iconName = "AirPods4Left", + label = "Left" + ), + ComponentSpec( + type = DeviceComponent.RIGHT, + iconName = "AirPods4Right", + label = "Right" + ), + ComponentSpec( + type = DeviceComponent.CASE, + iconName = "AirPods4Case", + label = "Charging Case" + ) + ), + genericIconName = "AirPods4", + baseCapabilities = setOf( + BaseCapability.HEAD_GESTURES, + BaseCapability.SLEEP_DETECTION, + BaseCapability.ADAPTIVE_VOLUME + ) + ), + AirPodsModel.AIRPODS_4_ANC to AirPodsSpec( + modelNumbers = setOf("A3056", "A3055", "A3057"), + name = "AirPods 4 (ANC)", + displayName = "AirPods", + components = setOf( + ComponentSpec( + type = DeviceComponent.LEFT, + iconName = "AirPods4Left", + label = "Left" + ), + ComponentSpec( + type = DeviceComponent.RIGHT, + iconName = "AirPods4Right", + label = "Right" + ), + ComponentSpec( + type = DeviceComponent.CASE, + iconName = "AirPods4Case", + label = "Charging Case" + ) + ), + genericIconName = "AirPods4", + baseCapabilities = setOf( + BaseCapability.LISTENING_MODE, + BaseCapability.CONVERSATION_AWARENESS, + BaseCapability.HEAD_GESTURES, + BaseCapability.ADAPTIVE_AUDIO, + BaseCapability.SLEEP_DETECTION, + BaseCapability.ADAPTIVE_VOLUME, + BaseCapability.STEM_CONFIG + ) + ), + AirPodsModel.AIRPODS_PRO_1 to AirPodsSpec( + modelNumbers = setOf("A2084", "A2083"), + name = "AirPods Pro 1", + displayName = "AirPods Pro", + components = setOf( + ComponentSpec( + type = DeviceComponent.LEFT, + iconName = "AirPodsPro1Left", + label = "Left" + ), + ComponentSpec( + type = DeviceComponent.RIGHT, + iconName = "AirPodsPro1Right", + label = "Right" + ), + ComponentSpec( + type = DeviceComponent.CASE, + iconName = "AirPodsPro1Case", + label = "Charging Case" + ) + ), + genericIconName = "AirPodsPro2", + baseCapabilities = setOf( + BaseCapability.LISTENING_MODE + ) + ), + AirPodsModel.AIRPODS_PRO_2_LIGHTNING to AirPodsSpec( + modelNumbers = setOf("A2931", "A2699", "A2698"), + name = "AirPods Pro 2 with Magsafe Charging Case (Lightning)", + displayName = "AirPods Pro", + components = setOf( + ComponentSpec( + type = DeviceComponent.LEFT, + iconName = "AirPodsPro2Left", + label = "Left" + ), + ComponentSpec( + type = DeviceComponent.RIGHT, + iconName = "AirPodsPro2Right", + label = "Right" + ), + ComponentSpec( + type = DeviceComponent.CASE, + iconName = "AirPodsPro2Case", + label = "Charging Case" + ) + ), + genericIconName = "AirPodsPro2", + baseCapabilities = setOf( + BaseCapability.LISTENING_MODE, + BaseCapability.CONVERSATION_AWARENESS, + BaseCapability.STEM_CONFIG, + BaseCapability.LOUD_SOUND_REDUCTION, + BaseCapability.SLEEP_DETECTION, + BaseCapability.HEARING_AID, + BaseCapability.ADAPTIVE_AUDIO, + BaseCapability.ADAPTIVE_VOLUME, + BaseCapability.SWIPE_FOR_VOLUME, + BaseCapability.HEAD_GESTURES + ) + ), + AirPodsModel.AIRPODS_PRO_2_USBC to AirPodsSpec( + modelNumbers = setOf("A3047", "A3048", "A3049"), + name = "AirPods Pro 2 with Magsafe Charging Case (USB-C)", + displayName = "AirPods Pro", + components = setOf( + ComponentSpec( + type = DeviceComponent.LEFT, + iconName = "AirPodsPro2Left", + label = "Left" + ), + ComponentSpec( + type = DeviceComponent.RIGHT, + iconName = "AirPodsPro2Right", + label = "Right" + ), + ComponentSpec( + type = DeviceComponent.CASE, + iconName = "AirPodsPro2Case", + label = "Charging Case" + ) + ), + genericIconName = "AirPodsPro2", + baseCapabilities = setOf( + BaseCapability.LISTENING_MODE, + BaseCapability.CONVERSATION_AWARENESS, + BaseCapability.STEM_CONFIG, + BaseCapability.LOUD_SOUND_REDUCTION, + BaseCapability.SLEEP_DETECTION, + BaseCapability.HEARING_AID, + BaseCapability.ADAPTIVE_AUDIO, + BaseCapability.ADAPTIVE_VOLUME, + BaseCapability.SWIPE_FOR_VOLUME, + BaseCapability.HEAD_GESTURES + ) + ), + AirPodsModel.AIRPODS_PRO_3 to AirPodsSpec( + modelNumbers = setOf("A3063", "A3064", "A3065"), + name = "AirPods Pro 3", + displayName = "AirPods Pro", + components = setOf( + ComponentSpec( + type = DeviceComponent.LEFT, + iconName = "AirPodsPro3Left", + label = "Left" + ), + ComponentSpec( + type = DeviceComponent.RIGHT, + iconName = "AirPodsPro3Right", + label = "Right" + ), + ComponentSpec( + type = DeviceComponent.CASE, + iconName = "AirPodsPro3Case", + label = "Charging Case" + ) + ), + genericIconName = "AirPodsPro3", + baseCapabilities = setOf( + BaseCapability.LISTENING_MODE, + BaseCapability.CONVERSATION_AWARENESS, + BaseCapability.HEAD_GESTURES, + BaseCapability.STEM_CONFIG, + BaseCapability.LOUD_SOUND_REDUCTION, + BaseCapability.PPE, + BaseCapability.SLEEP_DETECTION, + BaseCapability.HEARING_AID, + BaseCapability.ADAPTIVE_AUDIO, + BaseCapability.ADAPTIVE_VOLUME, + BaseCapability.SWIPE_FOR_VOLUME, + BaseCapability.HRM + ) + ), + AirPodsModel.AIRPODS_MAX_LIGHTNING to AirPodsSpec( + modelNumbers = setOf("A2096"), + name = "AirPods Max (Lightning)", + displayName = "AirPods Max", + components = setOf( + ComponentSpec( + type = DeviceComponent.HEADSET, + iconName = "AirPodsMax", + label = "Headset" + ) + ), + genericIconName = "AirPodsMax", + baseCapabilities = setOf( + BaseCapability.LISTENING_MODE, + ) + ), + AirPodsModel.AIRPODS_MAX_USBC to AirPodsSpec( + modelNumbers = setOf("A3184"), + name = "AirPods Max (USB-C)", + displayName = "AirPods Max", + components = setOf( + ComponentSpec( + type = DeviceComponent.HEADSET, + iconName = "AirPodsMax", + label = "Headset" + ), + ), + genericIconName = "AirPodsMax", + baseCapabilities = setOf( + BaseCapability.LISTENING_MODE, + ) + ), + AirPodsModel.AIRPODS_MAX_2 to AirPodsSpec( + modelNumbers = setOf("A3454"), + name = "AirPods Max 2", + displayName = "AirPods Max 2", + components = setOf( + ComponentSpec( + type = DeviceComponent.HEADSET, + iconName = "AirPodsMax", + label = "Headset" + ), + ), + genericIconName = "AirPodsMax2", + baseCapabilities = setOf( + BaseCapability.LISTENING_MODE, + BaseCapability.CONVERSATION_AWARENESS, + BaseCapability.LOUD_SOUND_REDUCTION, + BaseCapability.ADAPTIVE_AUDIO, + BaseCapability.ADAPTIVE_VOLUME, + ) + ), + AirPodsModel.UNKNOWN to AirPodsSpec( + modelNumbers = emptySet(), + name = "Unknown AirPods", + displayName = "Unknown AirPods", + components = emptySet(), + genericIconName = "AirPods1", + baseCapabilities = emptySet() + ) + ) + fun getSpec(model: AirPodsModel): AirPodsSpec = specs[model] ?: specs[AirPodsModel.UNKNOWN]!! +} + +@Serializable +enum class BaseCapability { + LISTENING_MODE, + CONVERSATION_AWARENESS, + STEM_CONFIG, + HEAD_GESTURES, + LOUD_SOUND_REDUCTION, + PPE, + SLEEP_DETECTION, + HEARING_AID, + ADAPTIVE_AUDIO, + ADAPTIVE_VOLUME, + SWIPE_FOR_VOLUME, + HRM +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt new file mode 100644 index 00000000..725c680c --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt @@ -0,0 +1,284 @@ +package me.kavishdevar.librepods.devices + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.os.Build +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.bluetooth.MacAddress +import me.kavishdevar.librepods.bluetooth.aacp.AACPManager +import me.kavishdevar.librepods.bluetooth.aacp.types.AppleEvent +import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier +import me.kavishdevar.librepods.bluetooth.att.ATTHandle +import me.kavishdevar.librepods.bluetooth.att.ATTManager +import me.kavishdevar.librepods.data.StemAction +import kotlin.time.Duration.Companion.milliseconds + +private const val TAG = "AppleDevice" + +@SuppressLint("MissingPermission") +class AppleDevice( + override val bluetoothAdapter: BluetoothAdapter, + override val bluetoothDevice: BluetoothDevice, + currentState: ConnectionState +) : Device { + override val macAddress = MacAddress(bluetoothDevice.address) + private val _state = MutableStateFlow(AppleState()) + override val state = _state.asStateFlow() + + private val _settings = MutableStateFlow(AppleSettings()) + override val settings = _settings.asStateFlow() + + private val _metadata = MutableStateFlow(AppleMetadata()) + override val metadata = _metadata.asStateFlow() + + private val _connectionState = MutableStateFlow(currentState) + override val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _events = MutableSharedFlow() + val events = _events.asSharedFlow() + + private val _connectionNumber = MutableStateFlow(0) + override val connectionNumber = _connectionNumber.asStateFlow() + + fun loadInitialState(state: AppleState, settings: AppleSettings, metadata: AppleMetadata) { + _state.value = state + _settings.value = settings + _metadata.value = metadata + } + + internal inline fun updateState( + transform: (AppleState) -> AppleState + ) { + _state.update(transform) + } + + internal inline fun updateMetadata( + transform: (AppleMetadata) -> AppleMetadata + ) { + _metadata.update(transform) + } + + internal suspend fun emitEvent(event: AppleEvent) { + _events.emit(event) + } + + val aacp = AACPManager(this) + val att = ATTManager(this) + + init { + updateMetadata { + it.copy( + name = bluetoothDevice.alias ?: bluetoothDevice.name ?: "Unknown" + ) + } + + if (currentState == ConnectionState.AVAILABLE) { + connect() + } + } + + override fun connect(): Boolean { + _connectionState.update { + ConnectionState.CONNECTING + } + + val success = aacp.connect() // && att.connect() + + CoroutineScope(Dispatchers.IO).launch { + _state.update { + it.copy( + loudSoundReductionEnabled = readATTCharacteristic(ATTHandle.LOUD_SOUND_REDUCTION)?.getOrNull(0)?.toInt() == 1, + transparencyData = readATTCharacteristic(ATTHandle.TRANSPARENCY)?: byteArrayOf(), + hearingAidData = readATTCharacteristic(ATTHandle.HEARING_AID)?: byteArrayOf() + ) + } + } + + _connectionState.update { + if (success) ConnectionState.CONNECTED else ConnectionState.DISCONNECTED + } + _connectionNumber.update { + it + 1 + } + + return success + } + + override fun disconnect() { + _connectionState.update { + ConnectionState.DISCONNECTING + } + + aacp.disconnect() +// att.disconnect() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) { + try { + bluetoothDevice.disconnect() + } catch (e: Exception) { + Log.e(TAG, "couldn't disconnect bluetooth device", e) + } + } + + _connectionState.update { + ConnectionState.DISCONNECTED + } + } + + fun setControlCommand(identifier: ControlCommandIdentifier, value: ByteArray): Boolean = aacp.sendControlCommand(identifier.value, value) + fun setControlCommand(identifier: ControlCommandIdentifier, value: Byte): Boolean = aacp.sendControlCommand(identifier.value, value) + fun setControlCommand(identifier: ControlCommandIdentifier, value: Int): Boolean = aacp.sendControlCommand(identifier.value, value) + fun setControlCommand(identifier: ControlCommandIdentifier, value: Boolean): Boolean = aacp.sendControlCommand(identifier.value, value) + + suspend fun writeATTCharacteristic(handle: ATTHandle, value: ByteArray): Boolean { + val success = att.writeCharacteristic(handle, value) + if (success) { + when (handle) { + ATTHandle.LOUD_SOUND_REDUCTION -> _state.update { it.copy(loudSoundReductionEnabled = value.getOrNull(0)?.toInt() == 1) } + ATTHandle.TRANSPARENCY -> _state.update { it.copy(transparencyData = value) } + ATTHandle.HEARING_AID -> _state.update { it.copy(hearingAidData = value) } + } + } + return success + } + + // can't use att notifications; keeping ATT connected causes airpods to disconnect every few seconds for some reason + // we will poll the characteristic every 1 seconds instead. + fun observeATTCharacteristic(handle: ATTHandle): Job = CoroutineScope(Dispatchers.IO).launch { + while (true) { + val value = readATTCharacteristic(handle) + if (value != null) { + when (handle) { + ATTHandle.LOUD_SOUND_REDUCTION -> _state.update { it.copy(loudSoundReductionEnabled = value.getOrNull(0)?.toInt() == 1) } + ATTHandle.TRANSPARENCY -> _state.update { it.copy(transparencyData = value) } + ATTHandle.HEARING_AID -> _state.update { it.copy(hearingAidData = value) } + } + } + delay(1000.milliseconds) + } + } + + + suspend fun readATTCharacteristic(handle: ATTHandle): ByteArray? = att.readCharacteristic(handle) + + // TODO: handle recording session + fun startRecording() = aacp.requestMicrophoneStream() + fun stopRecording() = aacp.endMicrophoneStream() + + fun toggleListeningMode(modeBit: Int) { + val currentByte = state.value.controlStates[ControlCommandIdentifier.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0 + val newValue = if ((currentByte and modeBit) != 0) { + val temp = currentByte and modeBit.inv() + if (countEnabledModes(temp) >= 2) temp else currentByte + } else { + currentByte or modeBit + } + setControlCommand(ControlCommandIdentifier.LISTENING_MODE_CONFIGS, newValue) +// sharedPreferences.edit { putInt("long_press_byte", newValue) } + } + + fun setLongPressAction(side: String, action: StemAction) { +// val prefKey = if (side.lowercase() == "left") "left_long_press_action" else "right_long_press_action" +// sharedPreferences.edit { putString(prefKey, action.name) } + _settings.update { + if (side.lowercase() == "left") it.copy(leftLongPressAction = action) else it.copy(rightLongPressAction = action) + } + } + + fun renameDevice(newName: String) { + aacp.sendRename(newName) + _metadata.update { + it.copy(name = newName) + } + } + + fun startHeadTracking() { + if (settings.value.alternateHeadTrackingPackets) { + aacp.sendStartAlternateHeadTracking() + } else { + aacp.sendStartHeadTracking() + } + _state.update { + it.copy(headTrackingActive = true) + } + } + + fun stopHeadTracking() { + if (settings.value.alternateHeadTrackingPackets) { + aacp.sendStopAlternateHeadTracking() + } else { + aacp.sendStopHeadTracking() + } + _state.update { + it.copy(headTrackingActive = false) + } + } + + fun setHeadGesturesEnabled(enabled: Boolean) { + _settings.update { + it.copy(headGesturesEnabled = enabled) + } + } + + fun setCustomEqEnabled(enabled: Boolean) { + aacp.setCustomEq(_state.value.customEq.copy(state = if(enabled) 2 else 1)) + } + + fun setCustomEq(low: Int, mid: Int, high: Int) { + require(low in 0..100) + require(mid in 0..100) + require(high in 0..100) + aacp.setCustomEq(_state.value.customEq.copy(low = low, mid = mid, high = high)) + } + + fun testHeadGestures() { + if (settings.value.alternateHeadTrackingPackets) { + aacp.sendStartAlternateHeadTracking() + } else { + aacp.sendStartHeadTracking() + } + _state.update { + it.copy( + detectHeadGestures = true + ) + } + } + + fun sendRawPacket(data: ByteArray): Boolean = aacp.sendRawPacket(data) + +//// private val _cameraAction = MutableStateFlow( +//// sharedPreferences.getString("camera_action", null) +//// ?.let { value -> StemPressType.entries.find { it.name == value } }) +//// +//// val cameraAction: StateFlow = _cameraAction +//// +//// fun setCameraAction(action: StemPressType?) { +//// sharedPreferences.edit { +//// if (action == null) remove("camera_action") +//// else putString("camera_action", action.name) +//// } +//// _cameraAction.value = action +//// } + +} + +private fun countEnabledModes(byteValue: Int): Int { + var count = 0 + if ((byteValue and 0x01) != 0) count++ + if ((byteValue and 0x02) != 0) count++ + if ((byteValue and 0x04) != 0) count++ + if ((byteValue and 0x08) != 0) count++ + return count +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleMetadata.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleMetadata.kt new file mode 100644 index 00000000..1bc2a034 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleMetadata.kt @@ -0,0 +1,27 @@ +package me.kavishdevar.librepods.devices + +import kotlinx.serialization.Serializable + +@Serializable +data class AppleMetadata( + override val name: String = "", + + val model: AirPodsModel = AirPodsModel.UNKNOWN, + val modelName: String = "", + val modelNumber: String = "", + val manufacturer: String = "", + + val serialNumber: String = "", + val leftSerialNumber: String = "", + val rightSerialNumber: String = "", + + val version1: String = "", + val version2: String = "", + val version3: String = "", + + val hardwareRevision: String = "", + val updaterIdentifier: String = "", +): DeviceMetadata { + override val iconName: String + get() = AirPodsSpecs.getSpec(model).genericIconName +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleSettings.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleSettings.kt new file mode 100644 index 00000000..6465f2e4 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleSettings.kt @@ -0,0 +1,35 @@ +package me.kavishdevar.librepods.devices + +import kotlinx.serialization.Serializable +import me.kavishdevar.librepods.data.StemAction + +@Serializable +data class AppleSettings( + val disconnectWhenNotWearing: Boolean = true, // disconnect_when_not_wearing + + val cacheDisconnectedComponentBattery: Boolean = true, + + val headGesturesEnabled: Boolean = true, // head_gestures_enabled + + val leftLongPressAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES, // left_long_press_action + val rightLongPressAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES, // right_long_press_action + + val showIslandPopup: Boolean = true, // show_island_popup + val showBottomSheetPopup: Boolean = true, // show_bottom_sheet_popup + + val alternateHeadTrackingPackets: Boolean = true, // use_alternate_head_tracking_packets + + val takeoverWhenDisconnected: Boolean = true, // takeover_when_disconnected + val takeoverWhenIdle: Boolean = true, // takeover_when_idle + val takeoverWhenMusic: Boolean = true, // takeover_when_music + val takeoverWhenCall: Boolean = true, // takeover_when_call + + val takeoverWhenRingingCall: Boolean = true, // takeover_when_ringing_call + val takeoverWhenMediaStart: Boolean = true, // takeover_when_media_start + + val conversationalAwarenessPauseMusicEnabled: Boolean = false, // conversational_awareness_pause_music + val relativeConversationalAwarenessVolumeEnabled: Boolean = true, // relative_conversational_awareness_volume + + val conversationalAwarenessVolume: Float = 43f, // conversational_awareness_volume + +): DeviceSettings diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleState.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleState.kt new file mode 100644 index 00000000..f06aafcf --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleState.kt @@ -0,0 +1,55 @@ +package me.kavishdevar.librepods.devices + +import me.kavishdevar.librepods.bluetooth.MacAddress +import me.kavishdevar.librepods.bluetooth.aacp.packet.AACPPacket +import me.kavishdevar.librepods.bluetooth.aacp.types.AudioSource +import me.kavishdevar.librepods.bluetooth.aacp.types.AudioSourceType +import me.kavishdevar.librepods.bluetooth.aacp.types.CapabilityEntry +import me.kavishdevar.librepods.bluetooth.aacp.types.ConnectedDevice +import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier +import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq +import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType +import me.kavishdevar.librepods.data.audio.MicrophoneFrame +import me.kavishdevar.librepods.data.audio.MicrophoneState +import me.kavishdevar.librepods.data.recording.RecordingState + +data class AppleState( + val isLocallyConnected: Boolean = false, + + val owns: Boolean = true, + + val componentState: Set = emptySet(), + + val conversationalAwarenessState: Int = 0, + + val capabilities: Set = emptySet(), + + val battery: Set = emptySet(), + val controlStates: Map = emptyMap(), + + val magicKeys: Map = emptyMap(), + + val headTrackingActive: Boolean = false, + val detectHeadGestures: Boolean = false, + + val loudSoundReductionEnabled: Boolean = false, + val transparencyData: ByteArray = byteArrayOf(), + val hearingAidData: ByteArray = byteArrayOf(), + + val customEq: CustomEq = CustomEq(1, 50, 50, 50), + + val microphoneFrames: List = emptyList(), + val microphoneState: MicrophoneState = MicrophoneState(), + val recordingState: RecordingState = RecordingState(), + + val audioSource: AudioSource = AudioSource(MacAddress("00:00:00:00:00:00"), AudioSourceType.NONE), + var connectedDevices: List = emptyList(), + + val leftIsPrimary: Boolean = true, + + val headphoneAccomodation: FloatArray = FloatArray(8), + val headphoneAccomodationEnabledForMedia: Boolean = false, + val headphoneAccomodationEnabledForPhone: Boolean = false, + + val aacpPackets: List = emptyList(), +): DeviceState diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/Device.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/Device.kt new file mode 100644 index 00000000..3338d79b --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/Device.kt @@ -0,0 +1,309 @@ +package me.kavishdevar.librepods.devices + +import android.bluetooth.BluetoothA2dp +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Context.RECEIVER_EXPORTED +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.os.ParcelUuid +import android.util.Log +import kotlinx.coroutines.flow.StateFlow +import me.kavishdevar.librepods.bluetooth.MacAddress +import me.kavishdevar.librepods.bluetooth.createBluetoothSocket + +sealed interface Device { + private val TAG: String + get() = "LibrePodsDevice<${macAddress.toRedactedString()}>" + + val macAddress: MacAddress + + val bluetoothAdapter: BluetoothAdapter + val bluetoothDevice: BluetoothDevice + + val connectionState: StateFlow + + val state: StateFlow + val settings: StateFlow + val metadata: StateFlow + + val connectionNumber: StateFlow + + fun connect(): Boolean + + fun disconnect() + + fun createSocket(uuid: ParcelUuid, psm: Int) = createBluetoothSocket( + adapter = bluetoothAdapter, + device = bluetoothDevice, + uuid = uuid, + psm = psm + ) + + fun disableAudio(context: Context) { + disableA2dp(context) + disableHeadset(context) + } + + fun disableA2dp(context: Context) { + val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter + + if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) { + bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (profile == BluetoothProfile.A2DP) { + try { + if (proxy.getConnectionState(bluetoothDevice) == BluetoothProfile.STATE_DISCONNECTED) { + Log.d(TAG, "Already disconnected from A2DP") + return + } + val method = proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java) + Log.d(TAG, "calling A2DP.setConnectionPolicy(0)") + method.invoke(proxy, bluetoothDevice, 0) + } catch (e: Exception) { + e.printStackTrace() + } finally { + bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) + } + } + } + + override fun onServiceDisconnected(profile: Int) {} + }, BluetoothProfile.A2DP) + } else { + Log.d(TAG, "not disconnecting A2DP, no BLUETOOTH_PRIVILEGED permission") + } + } + + fun disableHeadset(context: Context) { + val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter + + if (context.checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { + bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (profile == BluetoothProfile.HEADSET) { + try { + val method = proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java) + Log.d(TAG, "calling HEADSET.setConnectionPolicy(0)") + method.invoke(proxy, bluetoothDevice, 0) + } catch (e: Exception) { + e.printStackTrace() + } finally { + bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) + } + } + } + override fun onServiceDisconnected(profile: Int) {} + }, BluetoothProfile.HEADSET) + } else { + Log.d(TAG, "not disconnecting HEADSET, no MODIFIY_PHONE_STATE permission") + } + } + + fun enableAudio(context: Context) { + enableA2dp(context) + enableHeadset(context) + } + + fun enableA2dp(context: Context) { + val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter + + bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (profile == BluetoothProfile.A2DP) { + if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) { + try { + val policyMethod = proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java) + Log.d(TAG, "calling A2DP.setConnectionPolicy(100)") + policyMethod.invoke(proxy, bluetoothDevice, 100) + } catch (e: Exception) { + e.printStackTrace() + } finally { + bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) + } + } + else { + Log.i(TAG, "not setting connection policy for A2DP, no BLUETOOTH_PRIVILEGED permission. just called connect") + } + } + } + + override fun onServiceDisconnected(profile: Int) {} + }, BluetoothProfile.A2DP) + } + + fun enableHeadset(context: Context) { + val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter + + bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (profile == BluetoothProfile.HEADSET) { + if (context.checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { + try { + val policyMethod = proxy.javaClass.getMethod( + "setConnectionPolicy", + BluetoothDevice::class.java, + Int::class.java + ) + Log.d(TAG, "calling HEADSET.setConnectionPolicy(100)") + policyMethod.invoke(proxy, bluetoothDevice, 100) + } catch (e: Exception) { + e.printStackTrace() + } finally { + bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) + } + } else { + Log.d(TAG, "not setting connection policy for HEADSET, no MODIFIY_PHONE_STATE permission") + } + } + } + + override fun onServiceDisconnected(profile: Int) {} + }, BluetoothProfile.HEADSET) + } + + fun connectAudio(context: Context) { + connectA2dp(context) + connectHeadset(context) + } + + fun connectA2dp(context: Context) { + val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter + + bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (profile == BluetoothProfile.A2DP) { + try { + val connectMethod = proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) + connectMethod.invoke(proxy, bluetoothDevice) + } catch (e: Exception) { + e.printStackTrace() + } finally { + bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) + } + } + } + + override fun onServiceDisconnected(profile: Int) {} + }, BluetoothProfile.A2DP) + } + + fun connectHeadset(context: Context) { + val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter + + bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (profile == BluetoothProfile.HEADSET) { + try { + val connectMethod = proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) + connectMethod.invoke(proxy, bluetoothDevice) + } catch (e: Exception) { + e.printStackTrace() + } finally { + bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) + } + } + } + + override fun onServiceDisconnected(profile: Int) {} + }, BluetoothProfile.HEADSET) + } + + fun disconnectAudio(context: Context) { + disconnectA2dp(context) + disconnectHeadset(context) + } + + fun disconnectA2dp(context: Context) { + val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter + + bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (profile == BluetoothProfile.A2DP) { + try { + val disconnectMethod = proxy.javaClass.getMethod("disconnect", BluetoothDevice::class.java) + disconnectMethod.invoke(proxy, bluetoothDevice) + } catch (e: Exception) { + e.printStackTrace() + } finally { + bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) + } + } + } + + override fun onServiceDisconnected(profile: Int) {} + }, BluetoothProfile.A2DP) + } + + fun disconnectHeadset(context: Context) { + val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter + + bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (profile == BluetoothProfile.HEADSET) { + try { + val disconnectMethod = proxy.javaClass.getMethod("disconnect", BluetoothDevice::class.java) + disconnectMethod.invoke(proxy, bluetoothDevice) + } catch (e: Exception) { + e.printStackTrace() + } finally { + bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) + } + } + } + + override fun onServiceDisconnected(profile: Int) {} + }, BluetoothProfile.HEADSET) + } + + fun waitForA2dpConnection( + context: Context, + onConnected: () -> Unit + ): BroadcastReceiver { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val state = intent.getIntExtra( + BluetoothProfile.EXTRA_STATE, + BluetoothProfile.STATE_DISCONNECTED + ) + val previousState = intent.getIntExtra( + BluetoothProfile.EXTRA_PREVIOUS_STATE, + BluetoothProfile.STATE_DISCONNECTED + ) + val device = intent.getParcelableExtra( + BluetoothDevice.EXTRA_DEVICE, + BluetoothDevice::class.java + ) + + if ( + state == BluetoothProfile.STATE_CONNECTED && + previousState != BluetoothProfile.STATE_CONNECTED && + device?.address == macAddress.value + ) { + context.unregisterReceiver(this) + onConnected() + } + } + } + + context.registerReceiver( + receiver, + IntentFilter(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED), + RECEIVER_EXPORTED + ) + + return receiver + } +} + +interface DeviceState +interface DeviceSettings +interface DeviceMetadata { + val name: String + val iconName: String +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/Types.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/Types.kt new file mode 100644 index 00000000..9a353185 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/Types.kt @@ -0,0 +1,112 @@ +package me.kavishdevar.librepods.devices + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import me.kavishdevar.librepods.utils.redactMac + +// TODO: differentiate between bluetooth connected and oem-specific stuff connected. availiable is reserved for BLE based things, hence not used bluetooth connected +enum class ConnectionState { + DISCONNECTED, + DISCONNECTING, + AVAILABLE, + CONNECTING, + CONNECTED, +} + +enum class BatteryComponent { + LEFT, + RIGHT, + CASE, + HEADSET; + + companion object { + fun fromAirPodsByte(value: Byte): BatteryComponent { + return when (value.toInt()) { + 1 -> HEADSET + 4 -> LEFT + 2 -> RIGHT + 8 -> CASE + else -> throw IllegalArgumentException("Unknown battery component value: $value") + } + } + } +} + +enum class BatteryStatus { + UNKNOWN, // got this from pro 3 when one of the airpods had crashed and the other was still connected + CHARGING, + NOT_CHARGING, + DISCONNECTED, + OPTIMIZED_CHARGING; + + companion object { + fun fromAirPodsByte(value: Byte): BatteryStatus { + return when (value.toInt()) { + 0 -> UNKNOWN + 1 -> CHARGING + 2 -> NOT_CHARGING + 4 -> DISCONNECTED + 5 -> OPTIMIZED_CHARGING + else -> throw IllegalArgumentException("Unknown battery status value: $value") + } + } + } +} + +@Parcelize +data class Battery( + val component: BatteryComponent, + val level: Int, + val status: BatteryStatus +) : Parcelable + +// TODO: use this for battery too. BatteryComponent is very redundant +enum class DeviceComponent { + LEFT, + RIGHT, + CASE, + HEADSET +} + +enum class ComponentStatus { + IN_EAR, + OUT_OF_EAR, + IN_CASE, + DISCONNECTED; + + companion object { + fun fromAirPodsByte(value: Byte): ComponentStatus { + return when (value.toInt()) { + 0 -> IN_EAR + 1 -> OUT_OF_EAR + 2 -> IN_CASE + 3 -> DISCONNECTED + else -> throw IllegalArgumentException("Unknown device status value: $value") + } + } + } + + fun toAirPodsByte(): Byte { + return when (this) { + IN_EAR -> 0 + OUT_OF_EAR -> 1 + IN_CASE -> 2 + DISCONNECTED -> 3 + }.toByte() + } +} + +data class DeviceComponentState( + val component: DeviceComponent, + val status: ComponentStatus +) + +enum class PacketDestination { + DEVICE, + HOST; +} + +enum class NoiseControlMode { + OFF, NOISE_CANCELLATION, TRANSPARENCY, ADAPTIVE +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/MainActivity.kt similarity index 68% rename from android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/MainActivity.kt index 2e7b49a9..b95dfcd6 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/MainActivity.kt @@ -18,12 +18,9 @@ @file:OptIn(ExperimentalEncodingApi::class) -package me.kavishdevar.librepods +package me.kavishdevar.librepods.presentation.activities -// import me.kavishdevar.librepods.screens.Onboarding -// import me.kavishdevar.librepods.utils.RadareOffsetFinder //import dagger.hilt.android.AndroidEntryPoint -import android.annotation.SuppressLint import android.app.Activity import android.content.BroadcastReceiver import android.content.ComponentName @@ -31,72 +28,76 @@ import android.content.Context import android.content.Context.MODE_PRIVATE import android.content.Intent import android.content.ServiceConnection -import android.content.SharedPreferences import android.os.Bundle import android.os.IBinder import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.foundation.isSystemInDarkTheme 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.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView import androidx.core.content.edit -import androidx.lifecycle.viewmodel.compose.viewModel -import com.google.accompanist.permissions.ExperimentalPermissionsApi +import androidx.core.view.WindowCompat import com.google.android.play.core.review.ReviewManagerFactory import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import me.kavishdevar.librepods.data.AirPodsNotifications -import me.kavishdevar.librepods.data.ControlCommandRepository +import kotlinx.coroutines.flow.MutableStateFlow +import me.kavishdevar.librepods.BuildConfig +import me.kavishdevar.librepods.LibrePodsApplication import me.kavishdevar.librepods.presentation.navigation.NavigationRoot import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel -import me.kavishdevar.librepods.services.AirPodsService +import me.kavishdevar.librepods.presentation.theme.NightTheme +import me.kavishdevar.librepods.services.LibrePodsService import me.kavishdevar.librepods.utils.XposedState import kotlin.io.encoding.ExperimentalEncodingApi lateinit var serviceConnection: ServiceConnection lateinit var connectionStatusReceiver: BroadcastReceiver -lateinit var testReviewReceiver: BroadcastReceiver +//lateinit var testReviewReceiver: BroadcastReceiver -//@AndroidEntryPoint -@ExperimentalMaterial3Api class MainActivity : ComponentActivity() { companion object { init { if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) { - System.loadLibrary("l2c_fcr_hook") + System.loadLibrary("fluoride_hooks") } } } + val appDataRepository by lazy { (application as LibrePodsApplication).appDataRepository } @ExperimentalHazeMaterialsApi override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { - val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE) - val m3eEnabled = remember { mutableStateOf(sharedPreferences.getBoolean("m3e_enabled", true)) } + val settings by appDataRepository.settings.collectAsState() - val sharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> - when (key) { - "m3e_enabled" -> m3eEnabled.value = sharedPreferences.getBoolean(key, true) - } + val systemDarkTheme = isSystemInDarkTheme() + + val darkTheme = when (settings.nightMode) { + NightTheme.Dark -> true + NightTheme.Light -> false + NightTheme.System -> systemDarkTheme } - DisposableEffect(Unit) { - sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener) - onDispose { - sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener) - } + val view = LocalView.current + val window = (view.context as Activity).window + + LaunchedEffect(darkTheme) { + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme } + LibrePodsTheme( - m3eEnabled = m3eEnabled.value + designSystem = settings.designSystem, + darkTheme = darkTheme ) { // For demo screenshots // val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) @@ -121,7 +122,6 @@ class MainActivity : ComponentActivity() { } catch (e: Exception) { Log.e("MainActivity", "Error while unregistering receiver: $e") } - sendBroadcast(Intent(AirPodsNotifications.DISCONNECT_RECEIVERS)) super.onDestroy() } @@ -142,17 +142,12 @@ class MainActivity : ComponentActivity() { } } -@ExperimentalHazeMaterialsApi -@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag") -@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) @Composable fun Main() { val context = LocalContext.current val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE) - val airPodsService = remember { mutableStateOf(null) } - - val airPodsViewModel: AirPodsViewModel = viewModel() + val librepodsService = remember { mutableStateOf(null) } LaunchedEffect(Unit) { if (BuildConfig.PLAY_BUILD) { @@ -184,19 +179,50 @@ fun Main() { val releaseNotesShownPrefKey = "release_notes_shown_${BuildConfig.VERSION_NAME.removeSuffix("-debug").removeSuffix("-play")}" val releaseNotesShown = sharedPreferences.getBoolean(releaseNotesShownPrefKey, false) + val devicesState = remember(librepodsService.value) { + librepodsService.value?.devices ?: MutableStateFlow(emptyMap()) + }.collectAsState() + + DisposableEffect(onboardingComplete) { + if (!onboardingComplete) { + onDispose { } + } else { + val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + val binder = service as LibrePodsService.LocalBinder + librepodsService.value = binder.getService() + } + + override fun onServiceDisconnected(name: ComponentName?) { + librepodsService.value = null + } + } + + context.startForegroundService(Intent(context, LibrePodsService::class.java)) + + context.bindService( + Intent(context, LibrePodsService::class.java), + connection, + Context.BIND_AUTO_CREATE + ) + + onDispose { + try { + context.unbindService(connection) + } catch(e: Exception) { + Log.w("Main", "Error while unbinding service: $e") + } + } + } + } + fun bindService() { - context.startForegroundService(Intent(context, AirPodsService::class.java)) + context.startForegroundService(Intent(context, LibrePodsService::class.java)) serviceConnection = object: ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - val binder = service as AirPodsService.LocalBinder + val binder = service as LibrePodsService.LocalBinder val service = binder.getService() - airPodsService.value = service - airPodsViewModel.init( - service = service, - controlRepo = ControlCommandRepository(service.aacpManager), - sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE), - appContext = context.applicationContext - ) + librepodsService.value = service if (!sharedPreferences.contains("first_connection_successful_time")) { sharedPreferences.edit { @@ -206,21 +232,17 @@ fun Main() { } override fun onServiceDisconnected(name: ComponentName?) { - airPodsService.value = null + librepodsService.value = null } } context.bindService( - Intent(context, AirPodsService::class.java), + Intent(context, LibrePodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE ) } - if (onboardingComplete) { - bindService() - } - NavigationRoot( showReleaseNotes = !releaseNotesShown, updatesShown = { sharedPreferences.edit { putBoolean(releaseNotesShownPrefKey, true) } }, @@ -229,7 +251,7 @@ fun Main() { sharedPreferences.edit { putBoolean("onboarding_complete", true) } bindService() }, - airPodsViewModel = airPodsViewModel + devicesState = devicesState ) } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/NoiseControlWidgetConfigurationActivity.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/NoiseControlWidgetConfigurationActivity.kt new file mode 100644 index 00000000..d9d7888b --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/NoiseControlWidgetConfigurationActivity.kt @@ -0,0 +1,236 @@ +package me.kavishdevar.librepods.presentation.activities + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Bundle +import android.os.IBinder +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.LibrePodsApplication +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.database.widget.WidgetConfigEntity +import me.kavishdevar.librepods.devices.Device +import me.kavishdevar.librepods.bluetooth.MacAddress +import me.kavishdevar.librepods.presentation.components.StyledButton +import me.kavishdevar.librepods.presentation.components.StyledList +import me.kavishdevar.librepods.presentation.components.StyledListItem +import me.kavishdevar.librepods.presentation.components.StyledScaffold +import me.kavishdevar.librepods.presentation.icons.LocalIcons +import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme +import me.kavishdevar.librepods.presentation.theme.NightTheme +import me.kavishdevar.librepods.services.LibrePodsService + +class NoiseControlWidgetConfigurationActivity: ComponentActivity() { + private var service by mutableStateOf(null) + private var bound = false + + private val serviceConnection = object: ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + service = (binder as LibrePodsService.LocalBinder).getService() + bound = true + } + + override fun onServiceDisconnected(name: ComponentName?) { + service = null + bound = false + } + } + + private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + + val appDataRepository by lazy { (application as LibrePodsApplication).appDataRepository } + val widgetConfigRepository by lazy { (application as LibrePodsApplication).widgetConfigRepository } + + override fun onStart() { + Intent(this, LibrePodsService::class.java).also { intent -> + bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) + } + super.onStart() + } + + override fun onStop() { + if (bound) { + unbindService(serviceConnection) + bound = false + } + super.onStop() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setResult(RESULT_CANCELED) + + appWidgetId = intent.getIntExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) + + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + + setContent { + val settings by appDataRepository.settings.collectAsState() + + val darkTheme = when (settings.nightMode) { + NightTheme.Dark -> true + NightTheme.Light -> false + NightTheme.System -> isSystemInDarkTheme() + } + + LibrePodsTheme( + designSystem = settings.designSystem, + darkTheme = darkTheme + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + service?.let { + val devices by it.devices.collectAsState() + + val widgetConfigs by widgetConfigRepository.widgetConfigs.collectAsState() + + val widgetConfig = widgetConfigs.find { config -> config.appWidgetId == appWidgetId } + + WidgetDevicePickerContent( + devices = devices, + pickedDevice = widgetConfig?.macAddress, + onDevicePicked = { macAddress -> + val config = widgetConfig?.copy(macAddress = macAddress) ?: WidgetConfigEntity( + appWidgetId = appWidgetId, + macAddress = macAddress + ) + widgetConfigRepository.setWidgetConfig(config) + }, + onDoneClicked = { + val resultValue = Intent().apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + } + setResult(RESULT_OK, resultValue) + finish() + }, + onCancelClicked = { + finish() + } + ) + } ?: run { + Text( + text = "Service not available. Please ensure LibrePods is running and try again.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + } +} + +@Composable +private fun WidgetDevicePickerContent( + devices: Map>, + pickedDevice: MacAddress?, + onDevicePicked: (MacAddress) -> Unit, + onDoneClicked: () -> Unit, + onCancelClicked: () -> Unit +) { + StyledScaffold( + title = stringResource(R.string.configure_widget), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + if (devices.isEmpty()) { + Text( + text = "No devices found. Please ensure a compatible device is paired with your phone and try again.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + StyledButton( + modifier = Modifier.fillMaxWidth(), + onClick = onCancelClicked + ) { + Text( + text = "Cancel", + style = MaterialTheme.typography.bodyMedium, + ) + } + } else { + StyledList(title = stringResource(R.string.devices)) { + devices.forEach { device -> + val metadata by device.value.metadata.collectAsState() + + StyledListItem( + contentText = metadata.name, + supportingText = device.key.value, + leadingContent = { + Box( + modifier = Modifier + .size(56.dp) + .background( + if (pickedDevice == device.key) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.secondaryContainer, + MaterialShapes.Circle.normalized().toShape() + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = LocalIcons.current.fromName(metadata.iconName) ?: LocalIcons.current.Headphones, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = if (pickedDevice == device.key) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSecondaryContainer + ) + } + }, + onClick = { + onDevicePicked(device.key) + }, + selected = pickedDevice == device.key + ) + } + } + + StyledButton( + modifier = Modifier.fillMaxWidth(), + onClick = onDoneClicked, + enabled = pickedDevice != null + ) { + Text( + text = "Done", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/AboutCard.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/AboutCard.kt new file mode 100644 index 00000000..4fc9de3e --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/AboutCard.kt @@ -0,0 +1,106 @@ +/* + 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 . +*/ + +@file:OptIn(ExperimentalEncodingApi::class) + +package me.kavishdevar.librepods.presentation.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.icons.richText +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import kotlin.io.encoding.ExperimentalEncodingApi + +@Composable +fun AboutCard( + modelName: String, + actualModel: String, + serialNumbers: List, + version: String?, + navigateToVersion: () -> Unit +) { + val serialNumbers = listOf( + richText(serialNumbers[0]), + richText("\\icon{LeftCircleFill} " + serialNumbers[1]), + richText("\\icon{RightCircleFill} " + serialNumbers[2]), + ) + + val serialNumber = remember { mutableIntStateOf(0) } + + StyledList (title = stringResource(R.string.about)) { + StyledListItem( + contentText = stringResource(R.string.model_name), + supportingText = modelName + ) + + StyledListItem( + contentText = stringResource(R.string.model_number), + supportingText = actualModel + ) + + StyledListItem ( + contentText = stringResource(R.string.serial_number), + supportingContent = { + Text( + text = serialNumbers[serialNumber.intValue].text, + inlineContent = serialNumbers[serialNumber.intValue].inlineContent, + style = if (LocalDesignSystem.current == DesignSystem.Apple) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(0.7f), + ) + }, + onClick = { serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size } + ) + + if (version != null) { + StyledListItem( + contentText = stringResource(R.string.version), + supportingText = version, + onClick = navigateToVersion, + ) + } else { + StyledListItem( + contentText = stringResource(R.string.version), + onClick = navigateToVersion, + ) + } + } +} + +@Preview +@Composable +fun AboutCardPreview() { + LibrePodsTheme( + designSystem = DesignSystem.Apple + ) { + AboutCard( + modelName = "AirPods Pro", + actualModel = "A2084", + serialNumbers = listOf("123456789", "987654321", "567890123"), + version = "9141234", + navigateToVersion = {} + ) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AppInfoCard.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/AppInfoCard.kt similarity index 73% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AppInfoCard.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/AppInfoCard.kt index 992cf85d..23ee690d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AppInfoCard.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/AppInfoCard.kt @@ -29,24 +29,24 @@ fun AppInfoCard( ) { StyledList(title = stringResource(R.string.about)) { StyledListItem( - name = stringResource(R.string.version), - description = BuildConfig.VERSION_NAME, + contentText = stringResource(R.string.version), + supportingText = BuildConfig.VERSION_NAME, onClick = navigateToReleaseNotesScreen ) StyledListItem( - name = stringResource(R.string.version_code), - description = BuildConfig.VERSION_CODE.toString(), + contentText = stringResource(R.string.version_code), + supportingText = BuildConfig.VERSION_CODE.toString(), ) StyledListItem( - name = stringResource(R.string.flavor), - description = BuildConfig.FLAVOR, + contentText = stringResource(R.string.flavor), + supportingText = BuildConfig.FLAVOR, ) StyledListItem( - name = stringResource(R.string.build_type), - description = BuildConfig.BUILD_TYPE, + contentText = stringResource(R.string.build_type), + supportingText = BuildConfig.BUILD_TYPE, ) } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AudioSettings.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/AudioSettings.kt similarity index 96% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AudioSettings.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/AudioSettings.kt index 396c72cf..e70708e0 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AudioSettings.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/AudioSettings.kt @@ -82,14 +82,14 @@ fun AudioSettings( if (adaptiveAudioCapability) { StyledListItem( - name = stringResource(R.string.adaptive_audio), + contentText = stringResource(R.string.adaptive_audio), onClick = navigateToAdaptiveStrength, ) } if (customEqCapability) { StyledListItem( - name = stringResource(R.string.equalizer), + contentText = stringResource(R.string.equalizer), onClick = navigateToEqualizer, ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryIndicator.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/BatteryIndicator.kt similarity index 82% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryIndicator.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/BatteryIndicator.kt index 4c0d4351..2e7efc32 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryIndicator.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/BatteryIndicator.kt @@ -24,13 +24,14 @@ 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.Row 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.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -44,16 +45,15 @@ 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.graphics.luminance 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 me.kavishdevar.librepods.devices.BatteryStatus +import me.kavishdevar.librepods.presentation.icons.LocalIcons +import me.kavishdevar.librepods.presentation.icons.richText +import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import kotlin.math.cos import kotlin.math.min @@ -63,12 +63,11 @@ import kotlin.math.sqrt @Composable fun BatteryIndicator( batteryPercentage: Int, - status: Int, - prefix: String = "", + status: BatteryStatus, + imageVectorName: String? = null, previousCharging: Boolean = false, ) { - val isDarkTheme = isSystemInDarkTheme() - val batteryTextColor = if (isDarkTheme) Color.White else Color.Black + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f val batteryFillColor = if (batteryPercentage > 25) if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759) else if (isDarkTheme) Color(0xFFFC4244) else Color(0xFFfe373C) @@ -92,7 +91,7 @@ fun BatteryIndicator( 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 trackColor = MaterialTheme.colorScheme.surfaceContainerHigh val optimizedLimit = 0.8f val progress = batteryPercentage / 100f @@ -174,39 +173,45 @@ fun BatteryIndicator( } } - 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) + Icon( + imageVector = LocalIcons.current.Bolt, + contentDescription = null, + tint = batteryFillColor, + modifier = Modifier + .size(14.dp) + .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 - ), - ) + Row { + val richText = if (imageVectorName != null) { + richText("\\icon{$imageVectorName,onSurface} $batteryPercentage%") + } else { + richText("$batteryPercentage%") + } + + Text( + text = richText.text, + inlineContent = richText.inlineContent, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } } } @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun BatteryIndicatorPreview() { - LibrePodsTheme(m3eEnabled = false) { + LibrePodsTheme(designSystem = DesignSystem.Material) { BatteryIndicator( batteryPercentage = 50, status = BatteryStatus.OPTIMIZED_CHARGING, - prefix = "\uDBC6\uDCE5", - previousCharging = false + previousCharging = false, + imageVectorName = "LeftCircleFill" ) } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/BatteryView.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/BatteryView.kt new file mode 100644 index 00000000..5d6d62ad --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/BatteryView.kt @@ -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 . +*/ + +@file:OptIn(ExperimentalEncodingApi::class) + +package me.kavishdevar.librepods.presentation.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +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.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.devices.Battery +import me.kavishdevar.librepods.devices.BatteryComponent +import me.kavishdevar.librepods.devices.BatteryStatus +import kotlin.io.encoding.ExperimentalEncodingApi + +@Composable +fun BatteryView( + batteryList: Set, + primaryImageRes: Int, + caseImageRes: Int +) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Row( + modifier = Modifier.widthIn(max = 500.dp), + horizontalArrangement = Arrangement.Center + ) { + val headsetBattery = batteryList.find { it.component == BatteryComponent.HEADSET } + if (headsetBattery != null) { + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + bitmap = ImageBitmap.imageResource(primaryImageRes), + contentDescription = "Headset", + modifier = Modifier + .widthIn(max = 200.dp) + .fillMaxWidth() + .padding(8.dp) + ) + + if (headsetBattery.level > 0 || headsetBattery.status != BatteryStatus.DISCONNECTED) { + BatteryIndicator( + headsetBattery.level, + headsetBattery.status, + ) + } + } + } else { + + 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 singleDisplayed = remember { mutableStateOf(false) } + + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + bitmap = ImageBitmap.imageResource(primaryImageRes), + 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, + "LeftCircleFill" + ) + } + + 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, + "RightCircleFill" + ) + } + } + } + } + + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + bitmap = ImageBitmap.imageResource(caseImageRes), + 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, + if (!singleDisplayed.value) "AirPodsPro3CaseFill" else null + ) + } + } + } + } + } +} + + +@Preview +@Composable +fun BatteryViewPreview() { + val fakeBattery = setOf( + Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING), + Battery(BatteryComponent.RIGHT, 40, BatteryStatus.OPTIMIZED_CHARGING), + Battery(BatteryComponent.CASE, 60, BatteryStatus.NOT_CHARGING) + ) + + Column { + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(16.dp) + ) { + BatteryView( + batteryList = fakeBattery, + primaryImageRes = R.drawable.img_airpods_pro_2_buds, + caseImageRes = R.drawable.img_airpods_pro_2_case + ) + } + + val fakeBatteryHeadset = setOf( + Battery(BatteryComponent.HEADSET, 50, BatteryStatus.CHARGING), + ) + + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(16.dp) + ) { + BatteryView( + batteryList = fakeBatteryHeadset, + primaryImageRes = R.drawable.img_airpods_max, + caseImageRes = R.drawable.img_airpods_pro_2_case + ) + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/CallControlSettings.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/CallControlSettings.kt similarity index 87% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/CallControlSettings.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/CallControlSettings.kt index e21b7efb..40278d24 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/CallControlSettings.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/CallControlSettings.kt @@ -47,25 +47,25 @@ fun CallControlSettings( StyledList(title = stringResource(R.string.call_controls)) { StyledListItem( - name = stringResource(R.string.answer_call), - description = stringResource(R.string.press_once), + contentText = stringResource(R.string.answer_call), + supportingText = stringResource(R.string.press_once), enabled = false ) StyledListItem( - name = muteUnmuteText, - description = singlePressAction, + contentText = muteUnmuteText, + supportingText = singlePressAction, onClick = { navigateToCallControlScreen(muteUnmuteText) } , ) StyledListItem( - name = hangUpText, - description = doublePressAction, + contentText = hangUpText, + supportingText = doublePressAction, onClick = { navigateToCallControlScreen(hangUpText) } ) // StyledListItem( -// name = pressOnceText, +// contentText = pressOnceText, // selected = doublePressAction == pressOnceText, // onClick = { // doublePressAction = pressOnceText @@ -76,7 +76,7 @@ fun CallControlSettings( // ) // // StyledListItem( -// name = pressTwiceText, +// contentText = pressTwiceText, // selected = doublePressAction == pressTwiceText, // onClick = { // doublePressAction = pressTwiceText diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConfirmationDialog.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/ConfirmationDialog.kt similarity index 95% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConfirmationDialog.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/ConfirmationDialog.kt index 3d7fc287..5def1f5d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConfirmationDialog.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/ConfirmationDialog.kt @@ -25,7 +25,6 @@ import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -48,13 +47,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogProperties import com.kyant.backdrop.backdrops.LayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop @@ -62,13 +57,10 @@ import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.effects.blur import com.kyant.backdrop.effects.lens import com.kyant.backdrop.effects.vibrancy -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import me.kavishdevar.librepods.R import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem @OptIn(ExperimentalMaterial3Api::class) -@ExperimentalHazeMaterialsApi @Composable fun ConfirmationDialog( showDialog: MutableState, @@ -92,7 +84,9 @@ fun ConfirmationDialog( properties = DialogProperties( dismissOnBackPress = true, dismissOnClickOutside = false - ) + ), + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceContainerHighest, RoundedCornerShape(28.dp)) ) { Column( modifier = Modifier.padding(16.dp), @@ -140,7 +134,7 @@ fun ConfirmationDialog( .clickable(enabled = false, onClick = {}), contentAlignment = Alignment.Center ) { - val isDarkTheme = isSystemInDarkTheme() + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f Box( modifier = Modifier .requiredWidthIn(min = 200.dp, max = 360.dp) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt similarity index 100% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterButton.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/ControlCenterButton.kt similarity index 100% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterButton.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/ControlCenterButton.kt diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterNoiseControlSegmentedButton.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/ControlCenterNoiseControlSegmentedButton.kt similarity index 96% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterNoiseControlSegmentedButton.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/ControlCenterNoiseControlSegmentedButton.kt index ca0d1f4c..e1042fdb 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterNoiseControlSegmentedButton.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/ControlCenterNoiseControlSegmentedButton.kt @@ -56,7 +56,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.data.NoiseControlMode +import me.kavishdevar.librepods.devices.NoiseControlMode private val ContainerColor = Color(0x593C3C3E) private val SelectedIndicatorColorGray = Color(0xFF6C6C6E) @@ -224,10 +224,10 @@ private fun NoiseControlIconItem( private fun getModeIconRes(mode: NoiseControlMode): Int { return when (mode) { - NoiseControlMode.OFF -> R.drawable.noise_cancellation - NoiseControlMode.TRANSPARENCY -> R.drawable.transparency - NoiseControlMode.ADAPTIVE -> R.drawable.adaptive - NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation + NoiseControlMode.OFF -> R.drawable.ic_noise_cancellation + NoiseControlMode.TRANSPARENCY -> R.drawable.ic_transparency + NoiseControlMode.ADAPTIVE -> R.drawable.ic_adaptive + NoiseControlMode.NOISE_CANCELLATION -> R.drawable.ic_noise_cancellation } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt similarity index 56% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt index b5a99688..70cd33b2 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt @@ -10,32 +10,32 @@ import me.kavishdevar.librepods.utils.XposedState fun DeviceInfoCard() { StyledList(title = stringResource(R.string.device_info)) { StyledListItem( - name = stringResource(R.string.manufacturer), - description = Build.MANUFACTURER, + contentText = stringResource(R.string.manufacturer), + supportingText = Build.MANUFACTURER, enabled = false ) StyledListItem( - name = stringResource(R.string.model_number), - description = Build.MODEL, + contentText = stringResource(R.string.model_number), + supportingText = Build.MODEL, enabled = false ) StyledListItem( - name = stringResource(R.string.build_id), - description = Build.DISPLAY, + contentText = stringResource(R.string.build_id), + supportingText = Build.DISPLAY, enabled = false ) StyledListItem( - name = stringResource(R.string.version), - description = "${Build.ID} (${Build.VERSION.SDK_INT_FULL})", + contentText = stringResource(R.string.version), + supportingText = "${Build.ID} (${Build.VERSION.SDK_INT_FULL})", enabled = false ) StyledListItem( - name = stringResource(R.string.xposed_available), - description = if (XposedState.isAvailable) { + contentText = stringResource(R.string.xposed_available), + supportingText = if (XposedState.isAvailable) { stringResource(R.string.yes) } else { stringResource(R.string.no) @@ -44,8 +44,8 @@ fun DeviceInfoCard() { ) StyledListItem( - name = stringResource(R.string.app_enabled_in_xposed), - description = if (XposedState.bluetoothScopeEnabled) { + contentText = stringResource(R.string.app_enabled_in_xposed), + supportingText = if (XposedState.bluetoothScopeEnabled) { stringResource(R.string.yes) } else { stringResource(R.string.no) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HearingHealthSettings.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/HearingHealthSettings.kt similarity index 87% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HearingHealthSettings.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/HearingHealthSettings.kt index ae554995..4288f6a6 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HearingHealthSettings.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/HearingHealthSettings.kt @@ -38,24 +38,24 @@ fun HearingHealthSettings( if (hasPPECapability && shouldShowHearingAid) { StyledList(title = stringResource(R.string.hearing_health)) { StyledListItem( - name = stringResource(R.string.hearing_protection), + contentText = stringResource(R.string.hearing_protection), onClick = navigateToHearingProtection ) StyledListItem( - name = stringResource(R.string.hearing_aid), + contentText = stringResource(R.string.hearing_aid), onClick = navigateToHearingAid ) } } else if (shouldShowHearingAid) { StyledListItem( - name = stringResource(R.string.hearing_aid), + contentText = stringResource(R.string.hearing_aid), onClick = navigateToHearingAid ) } else if (hasPPECapability) { StyledListItem( title = stringResource(R.string.hearing_health), - name = stringResource(R.string.hearing_protection), + contentText = stringResource(R.string.hearing_protection), onClick = navigateToHearingProtection ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlButton.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/NoiseControlButton.kt similarity index 97% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlButton.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/NoiseControlButton.kt index a5b880f5..97b4724f 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlButton.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/NoiseControlButton.kt @@ -70,7 +70,7 @@ fun NoiseControlButton( @Composable fun NoiseControlButtonPreview() { NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), + icon = ImageBitmap.imageResource(R.drawable.ic_noise_cancellation), onClick = {}, textColor = Color.White, ) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlSettings.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/NoiseControlSettings.kt similarity index 88% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlSettings.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/NoiseControlSettings.kt index 4fbcf332..bc965791 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlSettings.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/NoiseControlSettings.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -63,6 +62,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.imageResource import androidx.compose.ui.res.stringResource @@ -74,7 +74,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.data.NoiseControlMode +import me.kavishdevar.librepods.devices.NoiseControlMode import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem @@ -88,7 +88,8 @@ import kotlin.math.roundToInt fun NoiseControlSettings( showOffListeningMode: Boolean, noiseControlModeValue: Int, - onNoiseControlModeChanged: (Int) -> Unit + onNoiseControlModeChanged: (Int) -> Unit, + showLabels: Boolean = true ) { when (LocalDesignSystem.current) { DesignSystem.Material -> { @@ -98,7 +99,7 @@ fun NoiseControlSettings( Triple( NoiseControlMode.OFF, R.string.off, - R.drawable.noise_cancellation + R.drawable.ic_noise_cancellation ) ) } @@ -107,21 +108,21 @@ fun NoiseControlSettings( Triple( NoiseControlMode.TRANSPARENCY, R.string.transparency, - R.drawable.transparency + R.drawable.ic_transparency ) ) add( Triple( NoiseControlMode.ADAPTIVE, R.string.adaptive, - R.drawable.adaptive + R.drawable.ic_adaptive ) ) add( Triple( NoiseControlMode.NOISE_CANCELLATION, R.string.noise_cancellation, - R.drawable.noise_cancellation + R.drawable.ic_noise_cancellation ) ) } @@ -129,15 +130,14 @@ fun NoiseControlSettings( val selectedMode = NoiseControlMode.entries[(noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.lastIndex)] Column { - Box( - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 4.dp, bottom = 12.dp) - ) { + if (showLabels) { Text( text = stringResource(R.string.noise_control), color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.labelSmallEmphasized + style = MaterialTheme.typography.labelSmallEmphasized, + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 4.dp, bottom = 12.dp) ) } Row( @@ -163,7 +163,7 @@ fun NoiseControlSettings( else -> ButtonGroupDefaults.connectedMiddleButtonShapes() }, colors = ToggleButtonDefaults.toggleButtonColors() - .copy(containerColor = MaterialTheme.colorScheme.surface), + .copy(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), modifier = Modifier.fillMaxWidth() ) { Icon( @@ -173,22 +173,25 @@ fun NoiseControlSettings( ) } - Text( - text = stringResource(labelRes), - style = MaterialTheme.typography.labelSmall, - textAlign = TextAlign.Center, - maxLines = 2, - modifier = Modifier.fillMaxWidth() - ) + if (showLabels) { + Text( + text = stringResource(labelRes), + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + maxLines = 2, + modifier = Modifier.fillMaxWidth() + ) + } } } } } } + // TODO: make it draggable (?) DesignSystem.Apple -> { - val isDarkTheme = isSystemInDarkTheme() - val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8) + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f + val backgroundColor = if (isDarkTheme) (if (showLabels) Color(0xFF1C1C1E) else MaterialTheme.colorScheme.surfaceContainerLow) else Color(0xFFE3E3E8) val textColor = if (isDarkTheme) Color.White else Color.Black val textColorSelected = if (isDarkTheme) Color.White else Color.Black val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF) @@ -244,16 +247,14 @@ fun NoiseControlSettings( onModeSelected(noiseControlMode.value, received = true) - Box( - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(horizontal = 16.dp) - .padding(top = 4.dp, bottom = 4.dp) - ) { + if (showLabels) { Text( text = stringResource(R.string.noise_control), color = MaterialTheme.colorScheme.sectionHeader, - style = MaterialTheme.typography.labelSmallEmphasized + style = MaterialTheme.typography.labelSmallEmphasized, + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 4.dp, bottom = 12.dp) ) } BoxWithConstraints( @@ -314,7 +315,7 @@ fun NoiseControlSettings( ) { if (showOffListeningMode) { NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), + icon = ImageBitmap.imageResource(R.drawable.ic_noise_cancellation), onClick = { onModeSelected(NoiseControlMode.OFF) }, textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor, modifier = Modifier.weight(1f), @@ -329,7 +330,7 @@ fun NoiseControlSettings( ) } NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.transparency), + icon = ImageBitmap.imageResource(R.drawable.ic_transparency), onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) }, textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor, modifier = Modifier.weight(1f), @@ -343,7 +344,7 @@ fun NoiseControlSettings( color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) ) NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.adaptive), + icon = ImageBitmap.imageResource(R.drawable.ic_adaptive), onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) }, textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor, modifier = Modifier.weight(1f), @@ -357,7 +358,7 @@ fun NoiseControlSettings( color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) ) NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), + icon = ImageBitmap.imageResource(R.drawable.ic_noise_cancellation), onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) }, textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor, modifier = Modifier.weight(1f), @@ -411,7 +412,7 @@ fun NoiseControlSettings( ) { if (showOffListeningMode) { NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), + icon = ImageBitmap.imageResource(R.drawable.ic_noise_cancellation), onClick = { onModeSelected(NoiseControlMode.OFF) }, textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor, modifier = Modifier.weight(1f), @@ -426,7 +427,7 @@ fun NoiseControlSettings( ) } NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.transparency), + icon = ImageBitmap.imageResource(R.drawable.ic_transparency), onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) }, textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor, modifier = Modifier.weight(1f), @@ -440,7 +441,7 @@ fun NoiseControlSettings( color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) ) NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.adaptive), + icon = ImageBitmap.imageResource(R.drawable.ic_adaptive), onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) }, textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor, modifier = Modifier.weight(1f), @@ -454,7 +455,7 @@ fun NoiseControlSettings( color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) ) NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), + icon = ImageBitmap.imageResource(R.drawable.ic_noise_cancellation), onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) }, textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor, modifier = Modifier.weight(1f), @@ -463,37 +464,39 @@ fun NoiseControlSettings( } } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp) - ) { - if (showOffListeningMode) { + if (showLabels) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) { + if (showOffListeningMode) { + Text( + text = stringResource(R.string.off), + style = TextStyle(fontSize = 12.sp, color = textColor), + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f) + ) + } Text( - text = stringResource(R.string.off), + text = stringResource(R.string.transparency), + style = TextStyle(fontSize = 12.sp, color = textColor), + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(R.string.adaptive), + style = TextStyle(fontSize = 12.sp, color = textColor), + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(R.string.noise_cancellation), style = TextStyle(fontSize = 12.sp, color = textColor), textAlign = TextAlign.Center, modifier = Modifier.weight(1f) ) } - Text( - text = stringResource(R.string.transparency), - style = TextStyle(fontSize = 12.sp, color = textColor), - textAlign = TextAlign.Center, - modifier = Modifier.weight(1f) - ) - Text( - text = stringResource(R.string.adaptive), - style = TextStyle(fontSize = 12.sp, color = textColor), - textAlign = TextAlign.Center, - modifier = Modifier.weight(1f) - ) - Text( - text = stringResource(R.string.noise_cancellation), - style = TextStyle(fontSize = 12.sp, color = textColor), - textAlign = TextAlign.Center, - modifier = Modifier.weight(1f) - ) } } } @@ -506,7 +509,7 @@ fun NoiseControlSettings( @Composable fun NoiseControlSettingsPreview() { LibrePodsTheme( - m3eEnabled = true + designSystem = DesignSystem.Material ) { Box( modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/PressAndHoldSettings.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/PressAndHoldSettings.kt similarity index 90% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/PressAndHoldSettings.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/PressAndHoldSettings.kt index d0df910e..28f60df9 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/PressAndHoldSettings.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/PressAndHoldSettings.kt @@ -46,13 +46,13 @@ fun PressAndHoldSettings( title = stringResource(R.string.press_and_hold_airpods) ) { StyledListItem( - name = stringResource(R.string.left), - description = leftActionText, + contentText = stringResource(R.string.left), + supportingText = leftActionText, onClick = navigateToLeftLongPress ) StyledListItem( - name = stringResource(R.string.right), - description = rightActionText, + contentText = stringResource(R.string.right), + supportingText = rightActionText, onClick = navigateToRightLongPress, ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledBottomSheet.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledBottomSheet.kt similarity index 92% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledBottomSheet.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledBottomSheet.kt index b9813981..8e46fc20 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledBottomSheet.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledBottomSheet.kt @@ -1,12 +1,12 @@ package me.kavishdevar.librepods.presentation.components import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberModalBottomSheetState @@ -15,6 +15,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import com.kyant.backdrop.backdrops.LayerBackdrop @@ -35,7 +36,7 @@ fun StyledBottomSheet( ) { if (!visible) return - val isDarkTheme = isSystemInDarkTheme() + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f val sheetState = rememberModalBottomSheetState(false) // move this to parent composable val isExpanded = sheetState.targetValue == SheetValue.Expanded @@ -72,9 +73,7 @@ fun StyledBottomSheet( }, onDrawSurface = { drawRect( - if (isDarkTheme) Color.DarkGray.copy(alpha = 0.3f) else Color( - 0xFFE0E0E0 - ).copy(alpha = 0.45f) + if (isDarkTheme) Color.DarkGray.copy(alpha = 0.3f) else Color(0xFFE0E0E0).copy(alpha = 0.45f) ) } ) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledButton.kt similarity index 96% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledButton.kt index aeafa332..8842316c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledButton.kt @@ -35,6 +35,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -60,6 +61,7 @@ import androidx.compose.ui.util.fastCoerceAtMost import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.lerp import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.effects.blur import com.kyant.backdrop.effects.lens @@ -84,9 +86,9 @@ enum class MaterialButtonStyle { @Composable fun StyledButton( - onClick: () -> Unit, - backdrop: Backdrop, modifier: Modifier = Modifier, + onClick: () -> Unit, + backdrop: Backdrop = rememberLayerBackdrop(), isInteractive: Boolean = true, tint: Color = Color.Unspecified, surfaceColor: Color = Color.Unspecified, @@ -102,7 +104,8 @@ fun StyledButton( Button( modifier = modifier.height(48.dp), onClick = onClick, - content = content + content = content, + enabled = enabled, ) } MaterialButtonStyle.Tonal -> { @@ -110,6 +113,7 @@ fun StyledButton( modifier = modifier.height(48.dp), onClick = onClick, content = content, + enabled = enabled, colors = ButtonDefaults.filledTonalButtonColors(containerColor = surfaceColor) ) } @@ -118,7 +122,8 @@ fun StyledButton( OutlinedButton( modifier = modifier.height(48.dp), onClick = onClick, - content = content + content = content, + enabled = enabled, ) } @@ -126,12 +131,14 @@ fun StyledButton( TextButton( modifier = modifier.height(48.dp), onClick = onClick, - content = content + content = content, + enabled = enabled, ) } } } DesignSystem.Apple -> { + val defaultButtonColor = MaterialTheme.colorScheme.surfaceContainerHigh val isInteractive = enabled && isInteractive val scope = rememberCoroutineScope() val haptics = LocalHapticFeedback.current @@ -195,6 +202,9 @@ half4 main(float2 coord) { if (isPressed && enabled) { drawRect(Color.Black.copy(alpha = 0.4f)) drawRect(Color.White.copy(alpha = 0.2f)) + } else { + + drawRect(defaultButtonColor) } } }, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledIconButton.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledIconButton.kt similarity index 79% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledIconButton.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledIconButton.kt index ecd3765c..e3a80b8c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledIconButton.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledIconButton.kt @@ -21,13 +21,11 @@ package me.kavishdevar.librepods.presentation.components import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.graphics.RuntimeShader -import android.os.Build import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.spring import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -36,9 +34,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedIconButton -import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -60,16 +59,13 @@ import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.layer.CompositingStrategy import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.rememberGraphicsLayer import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.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.DpOffset import androidx.compose.ui.unit.dp @@ -84,7 +80,7 @@ import com.kyant.backdrop.effects.lens import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.shadow.InnerShadow import kotlinx.coroutines.launch -import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.icons.LocalIcons import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.utils.inspectDragGestures @@ -97,13 +93,12 @@ import kotlin.math.tanh @Composable fun StyledIconButton( modifier: Modifier = Modifier, - icon: String, - iconTint: Color = Color.Unspecified, surfaceColor: Color = Color.Unspecified, backdrop: LayerBackdrop = rememberLayerBackdrop(), onClick: () -> Unit, enabled: Boolean = true, - materialButtonStyle: MaterialButtonStyle = MaterialButtonStyle.Normal + materialButtonStyle: MaterialButtonStyle = MaterialButtonStyle.Normal, + content: @Composable () -> Unit, ) { when (LocalDesignSystem.current) { DesignSystem.Material -> { @@ -114,13 +109,7 @@ fun StyledIconButton( enabled = enabled, modifier = Modifier.size(52.dp) ) { - Text( - text = icon, - style = TextStyle( - fontSize = 20.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) - ) + content() } } MaterialButtonStyle.Filled -> { @@ -129,13 +118,7 @@ fun StyledIconButton( enabled = enabled, modifier = Modifier.size(52.dp) ) { - Text( - text = icon, - style = TextStyle( - fontSize = 20.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) - ) + content() } } MaterialButtonStyle.Outlined -> { @@ -144,13 +127,7 @@ fun StyledIconButton( enabled = enabled, modifier = Modifier.size(52.dp) ) { - Text( - text = icon, - style = TextStyle( - fontSize = 20.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) - ) + content() } } MaterialButtonStyle.Normal -> { @@ -159,20 +136,13 @@ fun StyledIconButton( enabled = enabled, modifier = Modifier.size(52.dp) ) { - Text( - text = icon, - style = TextStyle( - fontSize = 20.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) - ) + content() } } } } DesignSystem.Apple -> { val haptics = LocalHapticFeedback.current - val darkMode = isSystemInDarkTheme() val scope = rememberCoroutineScope() val progressAnimationSpec = spring(0.5f, 300f, 0.001f) val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold) @@ -185,8 +155,7 @@ fun StyledIconButton( val density = LocalDensity.current val interactiveHighlightShader = remember { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - RuntimeShader( + RuntimeShader( """ uniform float2 size; layout(color) uniform half4 color; @@ -200,11 +169,8 @@ half4 main(float2 coord) { return color * intensity; }""" ) - } else { - null - } } - val isDarkTheme = isSystemInDarkTheme() + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f TextButton( onClick = { if (enabled) { @@ -313,35 +279,28 @@ half4 main(float2 coord) { if (!enabled) return@drawBackdrop val progress = progressAnimation.value.fastCoerceIn(0f, 1f) if (progress > 0f) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) { - drawRect( - Color.White.copy(0.1f * progress), - blendMode = BlendMode.Plus + drawRect( + Color.White.copy(0.1f * progress), + blendMode = BlendMode.Plus + ) + interactiveHighlightShader.apply { + val offset = pressStartPosition + offsetAnimation.value + setFloatUniform("size", size.width, size.height) + setColorUniform( + "color", + Color.White.copy(0.15f * progress).toArgb() ) - interactiveHighlightShader.apply { - val offset = pressStartPosition + offsetAnimation.value - setFloatUniform("size", size.width, size.height) - setColorUniform( - "color", - Color.White.copy(0.15f * progress).toArgb() - ) - setFloatUniform("radius", size.maxDimension) - setFloatUniform( - "offset", - offset.x.fastCoerceIn(0f, size.width), - offset.y.fastCoerceIn(0f, size.height) - ) - } - drawRect( - ShaderBrush(interactiveHighlightShader), - blendMode = BlendMode.Plus - ) - } else { - drawRect( - Color.White.copy(0.25f * progress), - blendMode = BlendMode.Plus + setFloatUniform("radius", size.maxDimension) + setFloatUniform( + "offset", + offset.x.fastCoerceIn(0f, size.width), + offset.y.fastCoerceIn(0f, size.height) ) } + drawRect( + ShaderBrush(interactiveHighlightShader), + blendMode = BlendMode.Plus + ) } }, effects = { @@ -404,15 +363,7 @@ half4 main(float2 coord) { } .size(with(density) { 48.sp.toDp() }), ) { - Text( - text = icon, - style = TextStyle( - fontSize = 20.sp, - fontWeight = FontWeight.Normal, - color = if (iconTint.isSpecified) iconTint else if (darkMode) Color.White else Color.Black, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) - ) + content() } } } @@ -426,12 +377,16 @@ fun StyledIconButtonPreview() { .height(120.dp) .width(200.dp) .background( - if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7), + MaterialTheme.colorScheme.surfaceContainer, RoundedCornerShape(28.dp) ), contentAlignment = Alignment.Center) { StyledIconButton( - icon = "􀍟", onClick = { } - ) + ) { + Icon( + imageVector = LocalIcons.current.Settings, + contentDescription = "Settings", + ) + } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledInputField.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledInputField.kt similarity index 76% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledInputField.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledInputField.kt index fa99e6cd..e6d297ed 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledInputField.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledInputField.kt @@ -5,7 +5,6 @@ import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -14,12 +13,16 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText +import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField @@ -29,17 +32,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.icons.LocalIcons import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem @@ -50,7 +48,12 @@ fun StyledInputField( focusRequester: FocusRequester, placeholder: String = "", singleLine: Boolean = true, - forceApple: Boolean = false + forceApple: Boolean = false, + colors: ListItemColors = ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) ) { val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material && !forceApple @@ -71,9 +74,6 @@ fun StyledInputField( ) } else { - val isDarkTheme = isSystemInDarkTheme() - val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - val textColor = if (isDarkTheme) Color.White else Color.Black val minHeight = if (singleLine) 58.dp else 120.dp val verticalAlignment = if (singleLine) Alignment.CenterVertically else Alignment.Top val hasText = inputState.text.isNotEmpty() @@ -99,7 +99,7 @@ fun StyledInputField( .fillMaxWidth() .heightIn(min = minHeight) .background( - backgroundColor, + colors.containerColor, RoundedCornerShape(28.dp) ) .padding(horizontal = 16.dp, vertical = 8.dp) @@ -112,12 +112,8 @@ fun StyledInputField( BasicTextField( state = inputState, lineLimits = if (singleLine) TextFieldLineLimits.SingleLine else TextFieldLineLimits.Default, - textStyle = TextStyle( - fontSize = 16.sp, - color = textColor, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - cursorBrush = SolidColor(textColor), + textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onBackground), + cursorBrush = SolidColor(MaterialTheme.colorScheme.onBackground), decorator = { innerTextField -> Row( modifier = Modifier.padding(top = if (singleLine) 0.dp else 16.dp), @@ -134,12 +130,8 @@ fun StyledInputField( ) { Text( text = placeholder, - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Light, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor.copy(alpha = 0.8f) - ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f), modifier = Modifier .offset(y = yOffset) ) @@ -153,15 +145,11 @@ fun StyledInputField( inputState.clearText() } ) { - Text( - text = "􀁡", - style = TextStyle( - fontSize = 16.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy( - alpha = 0.6f - ) - ), + Icon( + imageVector = LocalIcons.current.CloseCircle, + contentDescription = "Clear text", + tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + modifier = Modifier.size(16.dp) ) } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledList.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledList.kt similarity index 79% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledList.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledList.kt index 1edcd893..0e8498ee 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledList.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledList.kt @@ -7,10 +7,15 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -28,12 +33,23 @@ import me.kavishdevar.librepods.presentation.theme.sectionHeader @Composable fun StyledList( modifier: Modifier = Modifier, + scrollEnabled: Boolean = false, title: String? = null, description: String? = null, + colors: ListItemColors = ListItemDefaults.segmentedColors().run { + copy( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + }, + key: Any? = null, content: @Composable StyledListScope.() -> Unit ) { val scope = StyledListScope() - scope.content() + key(key) { + scope.content() + } val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material @@ -52,11 +68,13 @@ fun StyledList( ) } } + val scrollState = rememberScrollState() Column( modifier = Modifier .fillMaxWidth() - .background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surface, RoundedCornerShape(if (m3eEnabled) 24.dp else 28.dp)) + .background(if (m3eEnabled) Color.Transparent else colors.containerColor, RoundedCornerShape(if (m3eEnabled) 24.dp else 28.dp)) .clip(RoundedCornerShape(if (m3eEnabled) 24.dp else 28.dp)) + .then(if (scrollEnabled) Modifier.verticalScroll(scrollState) else Modifier) ) { if (m3eEnabled && description != null) { Text( @@ -74,9 +92,10 @@ fun StyledList( } } if (!m3eEnabled && description != null) { + Spacer(modifier = Modifier.height(4.dp)) Text( text = description, - style = MaterialTheme.typography.bodySmallEmphasized, + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onBackground.copy(0.6f), modifier = Modifier.padding(horizontal = 16.dp) ) @@ -99,11 +118,11 @@ class StyledListScope { @Composable fun StyledListDemo() { LibrePodsTheme( - m3eEnabled = true + designSystem = DesignSystem.Apple, + darkTheme = false ) { - val backgroundC = MaterialTheme.colorScheme.background StyledScaffold( - title = "${backgroundC.red}, ${backgroundC.green}, ${backgroundC.blue}" + title = "StyledListTest" ) { Column ( modifier = Modifier.padding(horizontal = 12.dp) @@ -114,7 +133,7 @@ fun StyledListDemo() { ) { for (i in 0..2) { StyledListItem( - name = i.toString(), + contentText = i.toString(), onClick = {} ) } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledListItem.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledListItem.kt new file mode 100644 index 00000000..b1c39517 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledListItem.kt @@ -0,0 +1,962 @@ +/* + 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 . +*/ + +package me.kavishdevar.librepods.presentation.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.presentation.icons.LocalIcons +import me.kavishdevar.librepods.presentation.icons.richText +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.theme.sectionHeader + +@Composable +fun StyledListItem( + modifier: Modifier = Modifier, + title: String? = null, + onClick: (() -> Unit)?, + content: @Composable () -> Unit, + supportingContent: (@Composable () -> Unit)? = null, + height: Dp = 58.dp, + enabled: Boolean = true, + orientation: StyledListItemOrientation = StyledListItemOrientation.Horizontal, + leadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + index: Int = 0, + count: Int = 1, + colors: ListItemColors = if (onClick == null) { + ListItemDefaults.segmentedColors().run { + copy( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContentColor = contentColor, + disabledSupportingContentColor = supportingContentColor, + disabledTrailingContentColor = trailingContentColor + ) + } + } else ListItemDefaults.segmentedColors().run { + copy( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +) { + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + Column { + title?.let { + Box( + modifier = Modifier + .background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 16.dp) + .padding(top = 4.dp, bottom = if (m3eEnabled) 8.dp else 4.dp) + ) { + Text( + text = it, + color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader, + style = MaterialTheme.typography.labelSmallEmphasized + ) + } + } + Column( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .background( + if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer, + RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp) + ) + .clip(RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp)) + ) { + StyledListItemContent( + onClick = onClick, + content = content, + supportingContent = supportingContent, + height = height, + enabled = enabled, + index = index, + count = count, + orientation = orientation, + leadingContent = leadingContent, + trailingContent = trailingContent, + colors = colors + ) + } + } +} + +@Composable +fun StyledListItem( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + title: String? = null, + contentText: String, + height: Dp = 58.dp, + enabled: Boolean = true, + orientation: StyledListItemOrientation = StyledListItemOrientation.Horizontal, + leadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + index: Int = 0, + count: Int = 1, + colors: ListItemColors = if (onClick == null) { + ListItemDefaults.segmentedColors().run { + copy( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContentColor = contentColor, + disabledSupportingContentColor = supportingContentColor, + disabledTrailingContentColor = trailingContentColor + ) + } + } else ListItemDefaults.segmentedColors().run { + copy( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +) { + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + Column { + title?.let { + Box( + modifier = Modifier + .background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 16.dp) + .padding(top = 4.dp, bottom = if (m3eEnabled) 8.dp else 4.dp) + ) { + Text( + text = it, + color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader, + style = MaterialTheme.typography.labelSmallEmphasized + ) + } + } + Column( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .background( + if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer, + RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp) + ) + .clip(RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp)) + ) { + StyledListItemContent( + onClick = onClick, + content = { + when (LocalDesignSystem.current) { + DesignSystem.Apple -> { + Text( + text = contentText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + + DesignSystem.Material -> { + Text( + text = contentText, + style = MaterialTheme.typography.labelMediumEmphasized, + ) + } + } + }, + supportingContent = null, + height = height, + enabled = enabled, + index = index, + count = count, + orientation = orientation, + leadingContent = leadingContent, + trailingContent = trailingContent, + colors = colors + ) + } + } +} + +@Composable +fun StyledListItem( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + title: String? = null, + contentText: String, + supportingContent: @Composable () -> Unit, + height: Dp = 58.dp, + enabled: Boolean = true, + orientation: StyledListItemOrientation = StyledListItemOrientation.Horizontal, + leadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + index: Int = 0, + count: Int = 1, + colors: ListItemColors = if (onClick == null) { + ListItemDefaults.segmentedColors().run { + copy( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContentColor = contentColor, + disabledSupportingContentColor = supportingContentColor, + disabledTrailingContentColor = trailingContentColor + ) + } + } else ListItemDefaults.segmentedColors().run { + copy( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +) { + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + Column { + title?.let { + Box( + modifier = Modifier + .background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 16.dp) + .padding(top = 4.dp, bottom = if (m3eEnabled) 8.dp else 4.dp) + ) { + Text( + text = it, + color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader, + style = MaterialTheme.typography.labelSmallEmphasized + ) + } + } + Column( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .background( + if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer, + RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp) + ) + .clip(RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp)) + ) { + StyledListItemContent( + onClick = onClick, + content = { + when (LocalDesignSystem.current) { + DesignSystem.Apple -> { + Text( + text = contentText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + + DesignSystem.Material -> { + Text( + text = contentText, + style = MaterialTheme.typography.labelMediumEmphasized, + ) + } + } + }, + supportingContent = supportingContent, + height = height, + enabled = enabled, + index = index, + count = count, + orientation = orientation, + leadingContent = leadingContent, + trailingContent = trailingContent, + colors = colors + ) + } + } +} + +@Composable +fun StyledListItem( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + title: String? = null, + contentText: String, + supportingText: String? = null, + height: Dp = 58.dp, + enabled: Boolean = true, + orientation: StyledListItemOrientation = StyledListItemOrientation.Horizontal, + leadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + index: Int = 0, + count: Int = 1, + colors: ListItemColors = if (onClick == null) { + ListItemDefaults.segmentedColors().run { + copy( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContentColor = contentColor, + disabledSupportingContentColor = supportingContentColor, + disabledTrailingContentColor = trailingContentColor + ) + } + } else ListItemDefaults.segmentedColors().run { + copy( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +) { + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + Column { + title?.let { + Box( + modifier = Modifier + .background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 16.dp) + .padding(top = 4.dp, bottom = if (m3eEnabled) 8.dp else 4.dp) + ) { + Text( + text = it, + color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader, + style = MaterialTheme.typography.labelSmallEmphasized + ) + } + } + Column( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .background( + if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer, + RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp) + ) + .clip(RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp)) + ) { + StyledListItemContent( + onClick = onClick, + content = { + when (LocalDesignSystem.current) { + DesignSystem.Apple -> { + Text( + text = contentText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + + DesignSystem.Material -> { + Text( + text = contentText, + style = MaterialTheme.typography.labelMediumEmphasized, + ) + } + } + }, + supportingContent = if (supportingText != null && (LocalDesignSystem.current == DesignSystem.Material || orientation == StyledListItemOrientation.Horizontal)) { + @Composable { + Text( + text = supportingText, + style = if (LocalDesignSystem.current == DesignSystem.Apple && orientation == StyledListItemOrientation.Horizontal) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(0.7f), + ) + } + } else null, + height = height, + enabled = enabled, + index = index, + count = count, + orientation = orientation, + leadingContent = leadingContent, + trailingContent = trailingContent, + colors = colors + ) + } + if (supportingText != null && LocalDesignSystem.current == DesignSystem.Apple && orientation == StyledListItemOrientation.Vertical) { + Box( + modifier = Modifier + .background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 16.dp) + .padding(top = 4.dp, bottom = if (m3eEnabled) 8.dp else 4.dp) + ) { + Text( + text = supportingText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(0.7f), + ) + } + } + } +} + +@Composable +fun StyledListScope.StyledListItem( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + contentText: String, + enabled: Boolean = onClick != null, + orientation: StyledListItemOrientation = StyledListItemOrientation.Horizontal, + selected: Boolean? = null, + leadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + colors: ListItemColors = if (onClick == null) { + ListItemDefaults.segmentedColors().run { + copy( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContentColor = contentColor, + disabledSupportingContentColor = supportingContentColor, + disabledTrailingContentColor = trailingContentColor + ) + } + } else ListItemDefaults.segmentedColors().run { + copy( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +) { + item { index, count -> + StyledListItemContent( + onClick = onClick, + content = { + when (LocalDesignSystem.current) { + DesignSystem.Apple -> { + Text( + text = contentText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + + DesignSystem.Material -> { + Text( + text = contentText, + style = MaterialTheme.typography.labelMediumEmphasized, + ) + } + } + }, + enabled = enabled, + index = index, + count = count, + orientation = orientation, + modifier = modifier, + selected = selected, + leadingContent = leadingContent, + trailingContent = trailingContent, + colors = colors + ) + } +} + +@Composable +fun StyledListScope.StyledListItem( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + content: @Composable () -> Unit, + supportingContent: (@Composable () -> Unit)? = null, + enabled: Boolean = onClick != null, + orientation: StyledListItemOrientation = StyledListItemOrientation.Horizontal, + selected: Boolean? = null, + leadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + colors: ListItemColors = if (onClick == null) { + ListItemDefaults.segmentedColors().run { + copy( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContentColor = contentColor, + disabledSupportingContentColor = supportingContentColor, + disabledTrailingContentColor = trailingContentColor + ) + } + } else ListItemDefaults.segmentedColors().run { + copy( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +) { + item { index, count -> + StyledListItemContent( + onClick = onClick, + content = content, + supportingContent = supportingContent, + enabled = enabled, + index = index, + count = count, + orientation = orientation, + modifier = modifier, + selected = selected, + leadingContent = leadingContent, + trailingContent = trailingContent, + colors = colors + ) + } +} + +@Composable +fun StyledListScope.StyledListItem( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + contentText: String, + supportingContent: (@Composable () -> Unit)? = null, + enabled: Boolean = onClick != null, + orientation: StyledListItemOrientation = StyledListItemOrientation.Horizontal, + selected: Boolean? = null, + leadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + colors: ListItemColors = if (onClick == null) { + ListItemDefaults.segmentedColors().run { + copy( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContentColor = contentColor, + disabledSupportingContentColor = supportingContentColor, + disabledTrailingContentColor = trailingContentColor + ) + } + } else ListItemDefaults.segmentedColors().run { + copy( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +) { + item { index, count -> + StyledListItemContent( + onClick = onClick, + content = { + when (LocalDesignSystem.current) { + DesignSystem.Apple -> { + Text( + text = contentText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + + DesignSystem.Material -> { + Text( + text = contentText, + style = MaterialTheme.typography.labelMediumEmphasized, + ) + } + } + }, + supportingContent = supportingContent, + enabled = enabled, + index = index, + count = count, + orientation = orientation, + modifier = modifier, + selected = selected, + leadingContent = leadingContent, + trailingContent = trailingContent, + colors = colors + ) + } +} + +@Composable +fun StyledListScope.StyledListItem( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + contentText: String, + supportingText: String? = null, + enabled: Boolean = onClick != null, + orientation: StyledListItemOrientation = StyledListItemOrientation.Horizontal, + selected: Boolean? = null, + leadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + colors: ListItemColors = if (onClick == null) { + ListItemDefaults.segmentedColors().run { + copy( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContentColor = contentColor, + disabledSupportingContentColor = supportingContentColor, + disabledTrailingContentColor = trailingContentColor + ) + } + } else ListItemDefaults.segmentedColors().run { + copy( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +) { + item { index, count -> + StyledListItemContent( + onClick = onClick, + content = { + when (LocalDesignSystem.current) { + DesignSystem.Apple -> { + Text( + text = contentText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + + DesignSystem.Material -> { + Text( + text = contentText, + style = MaterialTheme.typography.labelMediumEmphasized, + ) + } + } + }, + supportingContent = if (supportingText != null) { + @Composable { + Text( + text = supportingText, + style = if (LocalDesignSystem.current == DesignSystem.Apple && orientation == StyledListItemOrientation.Horizontal) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.bodySmall, + color = if (selected == true) MaterialTheme.colorScheme.onPrimaryContainer.copy(0.7f) else MaterialTheme.colorScheme.onSurface.copy(0.7f), // TODO: move to color scheme + ) + } + } else null, + enabled = enabled, + index = index, + count = count, + orientation = orientation, + modifier = modifier, + selected = selected, + leadingContent = leadingContent, + trailingContent = trailingContent, + colors = colors + ) + } +} + +enum class StyledListItemOrientation{ + Horizontal, + Vertical +} + +@Composable +private fun StyledListItemContent( + modifier: Modifier = Modifier, + onClick: (() -> Unit)?, + content: @Composable () -> Unit, + supportingContent: (@Composable () -> Unit)? = null, + height: Dp = 58.dp, + enabled: Boolean = true, + index: Int, + count: Int, + orientation: StyledListItemOrientation = StyledListItemOrientation.Horizontal, + selected: Boolean? = null, + leadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + colors: ListItemColors = if (onClick == null) { + ListItemDefaults.segmentedColors().run { + copy( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContentColor = contentColor, + disabledSupportingContentColor = supportingContentColor, + disabledTrailingContentColor = trailingContentColor + ) + } + } else ListItemDefaults.segmentedColors().run { + copy( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +) { + val haptics = LocalHapticFeedback.current + val scope = rememberCoroutineScope() + + when (LocalDesignSystem.current) { + DesignSystem.Apple -> { + val surfaceColor = colors.containerColor + val pressedColor = MaterialTheme.colorScheme.surfaceContainerLow + + var backgroundColor by remember(surfaceColor) { mutableStateOf(surfaceColor) } + val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) + + val trailingContentDefault: @Composable () -> Unit = { + if (trailingContent == null) { + if (onClick != null) { + if (selected != null) { + val floatAnimateState by animateFloatAsState( + targetValue = if (selected) 1f else 0f, + animationSpec = tween(durationMillis = 300) + ) + + val color = MaterialTheme.colorScheme.primary.copy(alpha = floatAnimateState) + + val richText = richText( + source = "\\icon{Check,${String.format("#%08X", color.toArgb())}}", + ) + Text( + text = richText.text, + inlineContent = richText.inlineContent, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = if (supportingContent != null) 6.dp else 0.dp) + ) + } else { + val color = MaterialTheme.colorScheme.onSurface.copy(0.7f) + + val richText = richText( + source = "\\icon{ChevronRight,#${String.format("%08X", color.toArgb())}}", + ) + Text( + text = richText.text, + inlineContent = richText.inlineContent, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = if (supportingContent != null) 6.dp else 0.dp) + ) + } + } + } else { + trailingContent() + } + } + + Column ( + modifier = Modifier + .background( + animatedBackgroundColor, + when { + (index == 0 && count == 1) -> { + RoundedCornerShape(28.dp) + } + + (index == 0) -> { + RoundedCornerShape( + topStart = 28.dp, + topEnd = 28.dp, + bottomStart = 0.dp, + bottomEnd = 0.dp + ) + } + + (index + 1 == count) -> { + RoundedCornerShape( + topStart = 0.dp, + topEnd = 0.dp, + bottomStart = 28.dp, + bottomEnd = 28.dp + ) + } + + else -> { + RectangleShape + } + } + ) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + if (enabled) { + backgroundColor = pressedColor + tryAwaitRelease() + backgroundColor = surfaceColor + } + }, + onTap = { + if (enabled) { + scope.launch { + haptics.performHapticFeedback( + HapticFeedbackType.ContextClick + ) + } + onClick?.invoke() + } + } + ) + } + .heightIn(min = height) + .padding(horizontal = 16.dp) + ) { + val density = LocalDensity.current + + val leadingContentWidth = remember { mutableStateOf(0.dp) } + val trailingContentWidth = remember { mutableStateOf(0.dp) } + + Row( + modifier = Modifier + .heightIn(min = height) + .padding(vertical = if (orientation == StyledListItemOrientation.Vertical) 12.dp else 0.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (leadingContent != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.onGloballyPositioned { coordinates -> + with(density) { + leadingContentWidth.value = coordinates.size.width.toDp() + } + } + ) { + leadingContent() + Spacer(modifier = Modifier.width(12.dp)) + } + } + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier.weight(1f) + ) { + content() + supportingContent?.let { + if (orientation == StyledListItemOrientation.Vertical) { + Spacer(modifier = Modifier.height(8.dp)) + it() + } + } + } + + supportingContent?.let { + if (orientation == StyledListItemOrientation.Horizontal) { + it() + } + } + + Box( + modifier = Modifier.onGloballyPositioned { coordinates -> + with (density) { + if (trailingContent != null) trailingContentWidth.value = coordinates.size.width.toDp() + } + } + ) { + trailingContentDefault() + } + } + if (index+1 != count) { + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier.padding(start = leadingContentWidth.value, end = trailingContentWidth.value) + ) + } + } + } + + DesignSystem.Material -> { + val defaultShape = when { + count == 1 -> RoundedCornerShape(24.dp) + + index == 0 -> RoundedCornerShape( + topStart = 24.dp, + topEnd = 24.dp, + bottomStart = 8.dp, + bottomEnd = 8.dp + ) + + index == count - 1 -> RoundedCornerShape( + topStart = 8.dp, + topEnd = 8.dp, + bottomStart = 24.dp, + bottomEnd = 24.dp + ) + + else -> RoundedCornerShape(8.dp) + } + Column { + SegmentedListItem( + modifier = modifier.heightIn(min = 64.dp), + shapes = ListItemDefaults.shapes().copy( + shape = defaultShape, + pressedShape = RoundedCornerShape(24.dp), + selectedShape = RoundedCornerShape(24.dp), + hoveredShape = RoundedCornerShape(24.dp), + ), + onClick = onClick ?: {}, + leadingContent = leadingContent, + trailingContent = { + if (trailingContent == null) { + if (onClick != null) { + if (selected == true) { + Icon( + imageVector = LocalIcons.current.Check, + contentDescription = null, + modifier = Modifier + .size(24.dp) + ) + } else if (selected == null) { + Icon( + imageVector = LocalIcons.current.ChevronRight, + contentDescription = null, + modifier = Modifier + .size(24.dp) + .padding(start = if (supportingContent != null && orientation == StyledListItemOrientation.Horizontal) 6.dp else 0.dp) + ) + } + } + } else { + trailingContent() + } + }, + supportingContent = supportingContent, + content = content, + verticalAlignment = Alignment.CenterVertically, + colors = colors, + enabled = onClick != null && enabled, + selected = selected ?: false, + ) + if (index+1 != count) { + Spacer(modifier = Modifier.height(2.dp)) + } + } + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt similarity index 89% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt index 656dd1e0..3849b84c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt @@ -27,11 +27,12 @@ import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.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.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -60,15 +61,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import com.kyant.backdrop.backdrops.LayerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop @@ -77,7 +74,7 @@ import dev.chrisbanes.haze.HazeTint import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState -import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.icons.LocalIcons import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem @@ -93,7 +90,6 @@ fun StyledScaffold( snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, content: @Composable () -> Unit ) { - val isDarkTheme = isSystemInDarkTheme() val hazeState = rememberHazeState(blurEnabled = true) when (LocalDesignSystem.current) { @@ -150,13 +146,15 @@ fun StyledScaffold( } }, ) { paddingValues -> - Box( + Column( modifier = modifier - .then(if (visible) Modifier.padding(paddingValues) else Modifier) + .then(if (visible) Modifier.padding(start = paddingValues.calculateStartPadding(LocalLayoutDirection.current), end = paddingValues.calculateEndPadding(LocalLayoutDirection.current)) else Modifier) .fillMaxSize() .hazeSource(hazeState) ) { + Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding())) content() + Spacer(modifier = Modifier.height(paddingValues.calculateBottomPadding())) } } } @@ -166,14 +164,13 @@ fun StyledScaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, modifier = Modifier .then( - if (!isDarkTheme) Modifier.shadow( + if (MaterialTheme.colorScheme.surface.luminance() > 0.5) Modifier.shadow( elevation = 36.dp, shape = RoundedCornerShape(52.dp), ambientColor = Color.Black, spotColor = Color.Black ) else Modifier ) - .clip(RoundedCornerShape(52.dp)) ) { paddingValues -> val topPadding = paddingValues.calculateTopPadding() val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current) @@ -182,6 +179,7 @@ fun StyledScaffold( Box( modifier = Modifier .fillMaxSize() + .clip(RoundedCornerShape(52.dp)) .padding(start = startPadding, end = endPadding) ) { val backdrop = rememberLayerBackdrop() @@ -203,9 +201,15 @@ fun StyledScaffold( ) { StyledIconButton( onClick = onNavigateBack, - icon = "􀯶", backdrop = backdrop - ) + ) { + Icon( + imageVector = LocalIcons.current.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onBackground + ) + } } AnimatedVisibility( @@ -224,17 +228,15 @@ fun StyledScaffold( .fillMaxWidth() .layerBackdrop(backdrop) ){ + val scrimColor = MaterialTheme.colorScheme.scrim + Box( modifier = Modifier.hazeEffect( state = hazeState, ) { backgroundColor = bgColor tints = listOf( - HazeTint( - if (isDarkTheme) Color.Black.copy(0.55f) else Color( - 0xFFF2F2F7 - ).copy(alpha = 0.85f) - ) + HazeTint(scrimColor) ) blurRadius = 6.dp } @@ -245,12 +247,7 @@ fun StyledScaffold( Crossfade(targetState = title) { Text( text = it, - style = TextStyle( - fontSize = 20.sp, - fontWeight = FontWeight.SemiBold, - color = if (isDarkTheme) Color.White else Color.Black, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), + style = MaterialTheme.typography.titleLarge, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center ) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSlider.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledSlider.kt similarity index 87% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSlider.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledSlider.kt index 5ce29a1c..37813b2b 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSlider.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledSlider.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -42,6 +41,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemColors import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SegmentedListItem @@ -60,6 +61,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.util.VelocityTracker @@ -70,15 +73,11 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalDensity 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.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.fastRoundToInt import androidx.compose.ui.util.lerp @@ -95,6 +94,7 @@ import com.kyant.backdrop.shadow.Shadow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.icons.LocalIcons import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem @@ -223,15 +223,20 @@ fun StyledSlider( backdrop: Backdrop = rememberLayerBackdrop(), snapPoints: List = emptyList(), snapThreshold: Float = 0.05f, - startIcon: String? = null, - endIcon: String? = null, + startImageVector: ImageVector? = null, + endImageVector: ImageVector? = null, startLabel: String? = null, endLabel: String? = null, independent: Boolean = false, description: String? = null, enabled: Boolean = true, index: Int = 0, - count: Int = 1 + count: Int = 1, + colors: ListItemColors = ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) ) { when (LocalDesignSystem.current) { DesignSystem.Material -> { @@ -317,8 +322,13 @@ fun StyledSlider( verticalAlignment = Alignment.CenterVertically ) { - startIcon?.let { - Text(it, fontFamily = FontFamily(Font(R.font.sf_pro))) + startImageVector?.let { + Icon( + imageVector = it, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) Spacer(Modifier.width(12.dp)) } @@ -343,13 +353,19 @@ fun StyledSlider( enabled = enabled ) - endIcon?.let { + endImageVector?.let { Spacer(Modifier.width(12.dp)) - Text(it, fontFamily = FontFamily(Font(R.font.sf_pro))) + Icon( + imageVector = it, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) } } } - } + }, + colors = colors ) if (index + 1 != count) { @@ -361,20 +377,16 @@ fun StyledSlider( } DesignSystem.Apple -> { - val backgroundColor = - if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - val isDarkTheme = isSystemInDarkTheme() + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f val trackColor = if (isDarkTheme) Color(0xFF787880).copy(0.36f) else Color(0xFF787878).copy(0.2f) val accentColor = if (enabled) { - if (isDarkTheme) Color(0xFF0091FF) - else Color(0xFF0088FF) + MaterialTheme.colorScheme.primary } else { trackColor } - val labelTextColor = if (isDarkTheme) Color.White else Color.Black val fraction by derivedStateOf { ((value - valueRange.start) / (valueRange.endInclusive - valueRange.start)) @@ -395,7 +407,7 @@ fun StyledSlider( val content = @Composable { Box( Modifier - .fillMaxWidth(if (startIcon == null && endIcon == null) 0.95f else 1f) + .fillMaxWidth(if (startImageVector == null && endImageVector == null) 0.95f else 1f) ) { Box( Modifier @@ -418,21 +430,13 @@ fun StyledSlider( ) { Text( text = startLabel ?: "", - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Normal, - color = labelTextColor, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface ) Text( text = endLabel ?: "", - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Normal, - color = labelTextColor, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface ) } Spacer(modifier = Modifier.height(12.dp)) @@ -442,7 +446,7 @@ fun StyledSlider( .fillMaxWidth() .padding(vertical = 4.dp) .then( - if (startIcon == null && endIcon == null) Modifier.padding( + if (startImageVector == null && endImageVector == null) Modifier.padding( horizontal = 8.dp ) else Modifier ), @@ -451,20 +455,17 @@ fun StyledSlider( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(0.dp) ) { - if (startIcon != null) { - Text( - text = startIcon, - style = TextStyle( - fontSize = 18.sp, - fontWeight = FontWeight.Normal, - color = accentColor, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), + startImageVector?.let{ + Icon( + imageVector = it, + contentDescription = null, + tint = accentColor, modifier = Modifier .padding(horizontal = 12.dp) - .onGloballyPositioned { + .size(24.dp) + .onGloballyPositioned { coordinates -> startIconWidthState.floatValue = - it.size.width.toFloat() + coordinates.size.width.toFloat() } ) } @@ -503,17 +504,14 @@ fun StyledSlider( } ) } - if (endIcon != null) { - Text( - text = endIcon, - style = TextStyle( - fontSize = 18.sp, - fontWeight = FontWeight.Normal, - color = accentColor, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), + if (endImageVector != null) { + Icon( + imageVector = endImageVector, + contentDescription = null, + tint = accentColor, modifier = Modifier .padding(horizontal = 12.dp) + .size(24.dp) .onGloballyPositioned { endIconWidthState.floatValue = it.size.width.toFloat() @@ -531,13 +529,13 @@ fun StyledSlider( ) { if (snapPoints.isNotEmpty()) { val trackWidth = - if (startIcon != null && endIcon != null) trackWidthState.floatValue - with( + if (startImageVector != null && endImageVector != null) trackWidthState.floatValue - with( density ) { 6.dp.toPx() } * 2 else trackWidthState.floatValue - with( density ) { 22.dp.toPx() } val startOffset = - if (startIcon != null) startIconWidthState.floatValue + with( + if (startImageVector != null) startIconWidthState.floatValue + with( density ) { 34.dp.toPx() } else with(density) { 14.dp.toPx() } Box( @@ -572,7 +570,7 @@ fun StyledSlider( Modifier .graphicsLayer { val startOffset = - if (startIcon != null) + if (startImageVector != null) startIconWidthState.floatValue + with(density) { 24.dp.toPx() } else with(density) { 8.dp.toPx() } @@ -704,12 +702,8 @@ fun StyledSlider( if (label != null) { Text( text = label, - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = labelTextColor.copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), + style = MaterialTheme.typography.labelSmallEmphasized, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), modifier = Modifier.padding(horizontal = 18.dp, vertical = 4.dp) ) } @@ -717,7 +711,7 @@ fun StyledSlider( Box( modifier = Modifier .fillMaxWidth() - .background(backgroundColor, RoundedCornerShape(28.dp)) + .background(colors.containerColor, RoundedCornerShape(28.dp)) .padding(horizontal = 8.dp, vertical = 0.dp) .heightIn(min = 58.dp), contentAlignment = Alignment.Center @@ -728,16 +722,10 @@ fun StyledSlider( if (description != null) { Text( text = description, - style = TextStyle( - fontSize = 12.sp, - fontWeight = FontWeight.Light, - color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy( - alpha = 0.6f - ), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), + style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Light), + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), modifier = Modifier - .padding(horizontal = 18.dp, vertical = 4.dp) + .padding(horizontal = 18.dp, vertical = 8.dp) ) } } @@ -761,12 +749,13 @@ private fun snapIfClose(value: Float, points: List, threshold: Float = 0. return if (abs(nearest - value) <= threshold) nearest else value } -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, wallpaper = GREEN_DOMINATED_EXAMPLE) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Composable fun StyledSliderPreview() { val a = remember { mutableFloatStateOf(0.5f) } LibrePodsTheme( - m3eEnabled = true + designSystem = DesignSystem.Apple ) { StyledScaffold( title = "test", @@ -785,8 +774,8 @@ fun StyledSliderPreview() { snapPoints = listOf(1f), snapThreshold = 0.1f, independent = true, - startIcon = "A", - endIcon = "B", + startImageVector = LocalIcons.current.LeftCircleFill, + endImageVector = LocalIcons.current.RightCircleFill, ) StyledSlider( label = "Small label", @@ -799,8 +788,20 @@ fun StyledSliderPreview() { snapPoints = listOf(1f), snapThreshold = 0.1f, independent = true, - startIcon = "A", - endIcon = "B", + startImageVector = LocalIcons.current.SpeakerMin, + endImageVector = LocalIcons.current.SpeakerMax, + ) + val sliderValue = remember { mutableFloatStateOf(50f) } + StyledSlider( + label = stringResource(R.string.customize_adaptive_audio), + value = sliderValue.floatValue, + onValueChange = { sliderValue.floatValue = it }, + valueRange = 0f..100f, + snapPoints = listOf(0f, 50f, 100f), + startImageVector = LocalIcons.current.SpeakerMin, + endImageVector = LocalIcons.current.SpeakerMax, + independent = true, + description = stringResource(R.string.adaptive_audio_description), ) } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSwitch.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledSwitch.kt similarity index 96% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSwitch.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledSwitch.kt index d479da0d..2e116962 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSwitch.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledSwitch.kt @@ -28,12 +28,12 @@ import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -57,6 +57,7 @@ import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.layer.CompositingStrategy import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.rememberGraphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.onSizeChanged @@ -82,8 +83,9 @@ fun StyledSwitch( checked: Boolean, onCheckedChange: (Boolean) -> Unit, enabled: Boolean = true, + backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainer, ) { - val isDarkTheme = isSystemInDarkTheme() + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f val haptics = LocalHapticFeedback.current val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) @@ -235,7 +237,7 @@ fun StyledSwitch( right = size.width, bottom = size.height, paint = Paint().apply { - color = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7) + color = backgroundColor } ) scale(0.7f) { @@ -290,11 +292,9 @@ fun StyledSwitch( @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Composable fun StyledSwitchPreview() { - val isDarkTheme = isSystemInDarkTheme() - val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7) Box( modifier = Modifier - .background(backgroundColor) + .background(MaterialTheme.colorScheme.surfaceContainer) .width(100.dp) .height(150.dp), contentAlignment = Alignment.Center @@ -308,9 +308,9 @@ fun StyledSwitchPreview() { enabled = true, ) // LaunchedEffect(Unit) { -// delay(1000) +// delay(1.seconds) // checked.value = false -// delay(1000) +// delay(1.seconds) // checked.value = true // } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledToggle.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledToggle.kt similarity index 82% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledToggle.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledToggle.kt index 0d115d8f..b3e2cd92 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledToggle.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledToggle.kt @@ -24,8 +24,6 @@ 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 @@ -36,6 +34,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItemColors import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SegmentedListItem @@ -54,14 +53,10 @@ 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.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 me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem @@ -76,29 +71,33 @@ fun StyledToggle( checked: Boolean = false, enabled: Boolean = true, onCheckedChange: (Boolean) -> Unit, - header: Boolean = false + header: Boolean = false, + colors: ListItemColors = if (header) ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) else ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) ) { val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material Column(modifier = Modifier.padding(vertical = 12.dp)) { title?.let { - Box( + Text( + text = it, + color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader, + style = MaterialTheme.typography.labelSmallEmphasized, modifier = Modifier - .background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer) .padding(horizontal = 16.dp) .padding(top = 4.dp, bottom = if (m3eEnabled) 12.dp else 4.dp) - ) { - Text( - text = it, - color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader, - style = MaterialTheme.typography.labelSmallEmphasized - ) - } + ) } Column( modifier = Modifier .fillMaxWidth() .background( - if (m3eEnabled) if (header) MaterialTheme.colorScheme.primaryContainer else Color.Transparent else MaterialTheme.colorScheme.surface, + if (m3eEnabled) if (header) MaterialTheme.colorScheme.primaryContainer else Color.Transparent else MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(if (m3eEnabled) (if (header) 64.dp else 16.dp) else 28.dp) ) .clip(RoundedCornerShape(if (m3eEnabled) (if (header) 64.dp else 16.dp) else 28.dp)) @@ -112,7 +111,8 @@ fun StyledToggle( onCheckedChange = onCheckedChange, index = 0, count = 1, - header = header + header = header, + colors = colors ) } else { StyledToggleContent( @@ -121,18 +121,18 @@ fun StyledToggle( enabled = enabled, onCheckedChange = onCheckedChange, index = 0, - count = 1 + count = 1, + colors = colors ) } } if (description != null && !m3eEnabled) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = description, style = TextStyle( - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onBackground.copy(0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)), - ), modifier = Modifier.padding(horizontal = 16.dp) + text = description, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp), + color = MaterialTheme.colorScheme.onBackground.copy(0.6f), + modifier = Modifier.padding(horizontal = 16.dp) ) } } @@ -145,6 +145,11 @@ fun StyledListScope.StyledToggle( checked: Boolean = false, enabled: Boolean = true, onCheckedChange: (Boolean) -> Unit, + colors: ListItemColors = ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) ) { item { index, count -> StyledToggleContent( @@ -154,7 +159,8 @@ fun StyledListScope.StyledToggle( enabled = enabled, onCheckedChange = onCheckedChange, index = index, - count = count + count = count, + colors = colors ) } } @@ -169,13 +175,18 @@ private fun StyledToggleContent( onCheckedChange: (Boolean) -> Unit, index: Int, count: Int, - header: Boolean = false + header: Boolean = false, + colors: ListItemColors = if (header) ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) else ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) ) { val currentChecked by rememberUpdatedState(checked) - val isDarkTheme = isSystemInDarkTheme() - val textColor = if (isDarkTheme) Color.White else Color.Black - val haptics = LocalHapticFeedback.current val scope = rememberCoroutineScope() @@ -246,7 +257,7 @@ private fun StyledToggleContent( enabled = enabled, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.heightIn(min = 64.dp), - colors = if (header) ListItemDefaults.segmentedColors(containerColor = MaterialTheme.colorScheme.primaryContainer) else ListItemDefaults.segmentedColors() + colors = colors ) if (index+1 != count) { Spacer(modifier = Modifier.height(2.dp)) @@ -286,12 +297,12 @@ private fun StyledToggleContent( Column( modifier = Modifier .weight(1f) - .padding(end = 4.dp) + .padding(end = 12.dp) ) { Text( text = label, style = MaterialTheme.typography.labelMedium, - color = textColor, + color = MaterialTheme.colorScheme.onBackground, ) if (description != null) { @@ -299,7 +310,7 @@ private fun StyledToggleContent( Text( text = description, style = MaterialTheme.typography.bodySmall, - color = textColor.copy(0.8f) + color = MaterialTheme.colorScheme.onBackground.copy(0.8f) ) } } @@ -311,7 +322,8 @@ private fun StyledToggleContent( if (enabled) { onCheckedChange(it) } - } + }, + backgroundColor = colors.containerColor ) } if (index+1 != count) { @@ -330,7 +342,7 @@ private fun StyledToggleContent( @Composable fun StyledToggleAppleListPreview() { val checked = remember { mutableStateOf(false) } - LibrePodsTheme(m3eEnabled = false) { + LibrePodsTheme(designSystem = DesignSystem.Apple) { StyledList { StyledToggle( label = "Apple Styled List", @@ -346,7 +358,7 @@ fun StyledToggleAppleListPreview() { @Composable fun StyledToggleApplePreview() { val checked = remember { mutableStateOf(false) } - LibrePodsTheme(m3eEnabled = false) { + LibrePodsTheme(designSystem = DesignSystem.Apple) { StyledToggle( label = "Apple", description = "This is an example description for the styled toggle.", @@ -360,7 +372,7 @@ fun StyledToggleApplePreview() { @Composable fun StyledToggleM3EListPreview() { val checked = remember { mutableStateOf(false) } - LibrePodsTheme(m3eEnabled = true) { + LibrePodsTheme(designSystem = DesignSystem.Material) { StyledList { StyledToggle( label = "Apple Styled List", @@ -376,7 +388,7 @@ fun StyledToggleM3EListPreview() { @Composable fun StyledToggleM3EPreview() { val checked = remember { mutableStateOf(false) } - LibrePodsTheme(m3eEnabled = true) { + LibrePodsTheme(designSystem = DesignSystem.Material) { StyledToggle( label = "Material", description = "This is an example description for the styled toggle.", diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/VerticalVolumeSlider.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/VerticalVolumeSlider.kt similarity index 100% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/components/VerticalVolumeSlider.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/VerticalVolumeSlider.kt diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/AppleIcons.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/AppleIcons.kt new file mode 100644 index 00000000..6822e910 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/AppleIcons.kt @@ -0,0 +1,1251 @@ +@file:Suppress("PrivatePropertyName") + +package me.kavishdevar.librepods.presentation.icons + + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.common.Bluetooth +import me.kavishdevar.librepods.presentation.icons.common.CircleDotted +import me.kavishdevar.librepods.presentation.icons.common.LeftCircleFill +import me.kavishdevar.librepods.presentation.icons.common.RightCircleFill +object AppleIcons: IconSet { + // Material Icons don't scale like Apple's, we scale those up + fun isMaterialIcon(name: String): Boolean { + return when (name) { + "Bluetooth" -> true + else -> false + } + } + + override val Notifications: ImageVector + get() = Bell + + override val Headphones: ImageVector + get() { + val current = _headphones + if (current != null) return current + + return ImageVector.Builder( + name = "Headphones", + defaultWidth = 63.8129997253418.dp, + defaultHeight = 66.28099822998047.dp, + viewportWidth = 63.813f, + viewportHeight = 66.281f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 0.0f, y = 34.22f) + curveTo(x1 = 0.0f, y1 = 43.62f, x2 = 2.72f, y2 = 54.0f, x3 = 7.31f, y3 = 62.06f) + curveToRelative(dx1 = 0.78f, dy1 = 1.35f, dx2 = 2.25f, dy2 = 1.72f, dx3 = 3.66f, dy3 = 0.94f) + curveToRelative(dx1 = 1.31f, dy1 = -0.72f, dx2 = 1.69f, dy2 = -2.19f, dx3 = 0.87f, dy3 = -3.66f) + arcToRelative(a = 53.7f, b = 53.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -6.53f, dy1 = -25.12f) + curveToRelative(dx1 = 0.0f, dy1 = -17.34f, dx2 = 10.6f, dy2 = -28.9f, dx3 = 26.5f, dy3 = -28.9f) + curveToRelative(dx1 = 15.88f, dy1 = 0.0f, dx2 = 26.5f, dy2 = 11.56f, dx3 = 26.5f, dy3 = 28.9f) + curveToRelative(dx1 = 0.0f, dy1 = 8.4f, dx2 = -2.37f, dy2 = 17.5f, dx3 = -6.56f, dy3 = 25.12f) + curveToRelative(dx1 = -0.81f, dy1 = 1.47f, dx2 = -0.44f, dy2 = 2.94f, dx3 = 0.88f, dy3 = 3.66f) + curveToRelative(dx1 = 1.4f, dy1 = 0.78f, dx2 = 2.9f, dy2 = 0.4f, dx3 = 3.65f, dy3 = -0.94f) + arcToRelative(a = 58.7f, b = 58.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 7.34f, dy1 = -27.84f) + curveTo(x1 = 63.63f, y1 = 13.66f, x2 = 50.95f, y2 = 0.0f, x3 = 31.83f, y3 = 0.0f) + curveTo(x1 = 12.65f, y1 = 0.0f, x2 = 0.0f, y2 = 13.66f, x3 = 0.0f, y3 = 34.22f) + moveToRelative(dx = 9.78f, dy = 27.06f) + curveToRelative(dx1 = 1.03f, dy1 = 3.6f, dx2 = 4.1f, dy2 = 5.25f, dx3 = 7.72f, dy3 = 4.22f) + curveToRelative(dx1 = 3.6f, dy1 = -1.03f, dx2 = 5.28f, dy2 = -4.16f, dx3 = 4.22f, dy3 = -7.75f) + lineTo(x = 17.25f, y = 42.5f) + curveToRelative(dx1 = -1.03f, dy1 = -3.56f, dx2 = -4.1f, dy2 = -5.25f, dx3 = -7.72f, dy3 = -4.22f) + curveToRelative(dx1 = -3.6f, dy1 = 1.06f, dx2 = -5.28f, dy2 = 4.16f, dx3 = -4.22f, dy3 = 7.78f) + close() + moveToRelative(dx = 44.03f, dy = 0.0f) + lineToRelative(dx = 4.47f, dy = -15.22f) + curveToRelative(dx1 = 1.06f, dy1 = -3.65f, dx2 = -0.6f, dy2 = -6.72f, dx3 = -4.22f, dy3 = -7.78f) + curveToRelative(dx1 = -3.62f, dy1 = -1.03f, dx2 = -6.65f, dy2 = 0.66f, dx3 = -7.72f, dy3 = 4.22f) + lineToRelative(dx = -4.47f, dy = 15.25f) + curveToRelative(dx1 = -1.06f, dy1 = 3.63f, dx2 = 0.63f, dy2 = 6.72f, dx3 = 4.22f, dy3 = 7.75f) + curveToRelative(dx1 = 3.66f, dy1 = 1.03f, dx2 = 6.7f, dy2 = -0.62f, dx3 = 7.72f, dy3 = -4.22f) + } + }.build().also { _headphones = it } + } + + @Suppress("ObjectPropertyName") + private var _headphones: ImageVector? = null + + override val Play: ImageVector + get() = PlayFill + + override val Pause: ImageVector + get() = PauseFill + + override val Bluetooth: ImageVector + get() = CommonIcons.Bluetooth + + override val Call: ImageVector + get() = Phone + + override val Overlay: ImageVector + get() = RectangleOnRectangleDashed + + override val ArrowBack: ImageVector + get() = ChevronLeft + + override val LeftCircleFill: ImageVector + get() = CommonIcons.LeftCircleFill + + override val RightCircleFill: ImageVector + get() = CommonIcons.RightCircleFill + + override val Settings: ImageVector + get() = Gear + + override val Send: ImageVector + get() = PaperplaneFill + + override val Close: ImageVector + get() = XMark + + override val CloseCircle: ImageVector + get() = XMarkCircleFill + + override val SpeakerMin: ImageVector + get() = SpeakerFill + + override val SpeakerMax: ImageVector + get() = SpeakerWave3Fill + + override val Bolt: ImageVector + get() = BoltFill + + override val Check: ImageVector + get() = Checkmark + + override val Save: ImageVector + get() = SquareAndArrowDown + + override val Incoming: ImageVector + get() = SquareAndArrowDown + + override val Outgoing: ImageVector + get() = SquareAndArrowUp + + // SF names + + override val ChevronLeft: ImageVector + get() { + val current = _chevron_left + if (current != null) return current + + return ImageVector.Builder( + name = "chevron_left", + defaultWidth = 38.6879997253418.dp, + defaultHeight = 54.28099822998047.dp, + viewportWidth = 38.688f, + viewportHeight = 54.281f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 0.0f, y = 27.13f) + arcToRelative(a = 2.8f, b = 2.8f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.88f, dy1 = 2.06f) + lineTo(x = 25.66f, y = 53.4f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.03f, dy1 = 0.84f) + arcToRelative(a = 2.8f, b = 2.8f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.87f, dy1 = -2.84f) + curveToRelative(dx1 = 0.0f, dy1 = -0.82f, dx2 = -0.34f, dy2 = -1.5f, dx3 = -0.84f, dy3 = -2.03f) + lineTo(x = 6.97f, y = 27.12f) + lineTo(x = 29.72f, y = 4.89f) + arcToRelative(a = 3.0f, b = 3.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.84f, dy1 = -2.04f) + arcTo(horizontalEllipseRadius = 2.8f, verticalEllipseRadius = 2.8f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 27.7f, y1 = 0.0f) + arcToRelative(a = 2.8f, b = 2.8f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.03f, dy1 = 0.81f) + lineTo(x = 0.88f, y = 25.06f) + arcTo(horizontalEllipseRadius = 2.8f, verticalEllipseRadius = 2.8f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 0.0f, y1 = 27.13f) + } + }.build().also { _chevron_left = it } + } + + private var _chevron_left: ImageVector? = null + + override val ChevronRight: ImageVector + get() { + val current = _chevronRight + if (current != null) return current + + return ImageVector.Builder( + name = "ChevronRight", + defaultWidth = 38.375.dp, + defaultHeight = 54.28099822998047.dp, + viewportWidth = 38.375f, + viewportHeight = 54.281f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 38.38f, y = 27.13f) + arcToRelative(a = 2.8f, b = 2.8f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -0.91f, dy1 = -2.07f) + lineTo(x = 12.72f, y = 0.81f) + arcTo(horizontalEllipseRadius = 3.0f, verticalEllipseRadius = 3.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 10.66f, y1 = 0.0f) + arcTo(horizontalEllipseRadius = 2.8f, verticalEllipseRadius = 2.8f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 7.8f, y1 = 2.84f) + curveToRelative(dx1 = 0.0f, dy1 = 0.79f, dx2 = 0.32f, dy2 = 1.5f, dx3 = 0.82f, dy3 = 2.04f) + lineToRelative(dx = 22.74f, dy = 22.25f) + lineTo(x = 8.64f, y = 49.38f) + arcTo(horizontalEllipseRadius = 3.0f, verticalEllipseRadius = 3.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 7.8f, y1 = 51.4f) + arcToRelative(a = 2.8f, b = 2.8f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.85f, dy1 = 2.84f) + arcToRelative(a = 2.8f, b = 2.8f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.06f, dy1 = -0.84f) + lineToRelative(dx = 24.75f, dy = -24.22f) + arcToRelative(a = 2.9f, b = 2.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.9f, dy1 = -2.07f) + } + }.build().also { _chevronRight = it } + } + + private var _chevronRight: ImageVector? = null + + val Bell: ImageVector + get() { + val current = _bell + if (current != null) return current + + return ImageVector.Builder( + name = "Bell", + defaultWidth = 59.15599822998047.dp, + defaultHeight = 64.59400177001953.dp, + viewportWidth = 59.156f, + viewportHeight = 64.594f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 0.0f, y = 49.47f) + curveToRelative(dx1 = 0.0f, dy1 = 2.28f, dx2 = 1.75f, dy2 = 3.78f, dx3 = 4.72f, dy3 = 3.78f) + horizontalLineTo(x = 17.9f) + curveToRelative(dx1 = 0.25f, dy1 = 6.03f, dx2 = 4.97f, dy2 = 11.31f, dx3 = 11.56f, dy3 = 11.31f) + curveToRelative(dx1 = 6.62f, dy1 = 0.0f, dx2 = 11.34f, dy2 = -5.25f, dx3 = 11.6f, dy3 = -11.31f) + horizontalLineToRelative(dx = 13.18f) + curveToRelative(dx1 = 2.94f, dy1 = 0.0f, dx2 = 4.72f, dy2 = -1.5f, dx3 = 4.72f, dy3 = -3.78f) + curveToRelative(dx1 = 0.0f, dy1 = -3.13f, dx2 = -3.19f, dy2 = -5.94f, dx3 = -5.88f, dy3 = -8.72f) + curveToRelative(dx1 = -2.06f, dy1 = -2.16f, dx2 = -2.62f, dy2 = -6.6f, dx3 = -2.87f, dy3 = -10.19f) + curveTo(x1 = 50.0f, y1 = 18.25f, x2 = 46.82f, y2 = 10.31f, x3 = 38.5f, y3 = 7.31f) + curveTo(x1 = 37.44f, y1 = 3.21f, x2 = 34.1f, y2 = 0.0f, x3 = 29.47f, y3 = 0.0f) + curveToRelative(dx1 = -4.6f, dy1 = 0.0f, dx2 = -7.97f, dy2 = 3.22f, dx3 = -9.0f, dy3 = 7.31f) + curveToRelative(dx1 = -8.31f, dy1 = 3.0f, dx2 = -11.5f, dy2 = 10.94f, dx3 = -11.72f, dy3 = 23.25f) + curveToRelative(dx1 = -0.25f, dy1 = 3.6f, dx2 = -0.81f, dy2 = 8.03f, dx3 = -2.87f, dy3 = 10.19f) + curveTo(x1 = 3.16f, y1 = 43.53f, x2 = 0.0f, y2 = 46.35f, x3 = 0.0f, y3 = 49.47f) + moveToRelative(dx = 6.06f, dy = -0.94f) + verticalLineToRelative(dy = -0.37f) + curveToRelative(dx1 = 0.57f, dy1 = -0.91f, dx2 = 2.44f, dy2 = -2.75f, dx3 = 4.07f, dy3 = -4.57f) + curveToRelative(dx1 = 2.24f, dy1 = -2.5f, dx2 = 3.3f, dy2 = -6.53f, dx3 = 3.59f, dy3 = -12.62f) + curveToRelative(dx1 = 0.25f, dy1 = -13.66f, dx2 = 4.31f, dy2 = -18.0f, dx3 = 9.65f, dy3 = -19.47f) + curveToRelative(dx1 = 0.79f, dy1 = -0.19f, dx2 = 1.22f, dy2 = -0.56f, dx3 = 1.25f, dy3 = -1.37f) + curveToRelative(dx1 = 0.1f, dy1 = -3.26f, dx2 = 1.97f, dy2 = -5.54f, dx3 = 4.85f, dy3 = -5.54f) + curveToRelative(dx1 = 2.9f, dy1 = 0.0f, dx2 = 4.75f, dy2 = 2.29f, dx3 = 4.87f, dy3 = 5.54f) + curveToRelative(dx1 = 0.03f, dy1 = 0.8f, dx2 = 0.44f, dy2 = 1.18f, dx3 = 1.22f, dy3 = 1.37f) + curveToRelative(dx1 = 5.38f, dy1 = 1.47f, dx2 = 9.44f, dy2 = 5.81f, dx3 = 9.69f, dy3 = 19.47f) + curveToRelative(dx1 = 0.28f, dy1 = 6.1f, dx2 = 1.34f, dy2 = 10.12f, dx3 = 3.56f, dy3 = 12.62f) + curveToRelative(dx1 = 1.66f, dy1 = 1.82f, dx2 = 3.5f, dy2 = 3.66f, dx3 = 4.06f, dy3 = 4.57f) + verticalLineToRelative(dy = 0.37f) + close() + moveToRelative(dx = 16.72f, dy = 4.72f) + horizontalLineToRelative(dx = 13.4f) + curveToRelative(dx1 = -0.24f, dy1 = 4.25f, dx2 = -2.93f, dy2 = 6.9f, dx3 = -6.71f, dy3 = 6.9f) + curveToRelative(dx1 = -3.75f, dy1 = 0.0f, dx2 = -6.47f, dy2 = -2.65f, dx3 = -6.69f, dy3 = -6.9f) + } + }.build().also { _bell = it } + } + + private var _bell: ImageVector? = null + + val HeadphonesSlash: ImageVector + get() { + val current = _headphonesSlash + if (current != null) return current + + return ImageVector.Builder( + name = "HeadphonesSlash", + defaultWidth = 72.60900115966797.dp, + defaultHeight = 73.97699737548828.dp, + viewportWidth = 72.609f, + viewportHeight = 73.977f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 12.63f, y = 23.69f) + arcTo(horizontalEllipseRadius = 34.0f, verticalEllipseRadius = 34.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 9.7f, y1 = 38.07f) + quadToRelative(dx1 = 0.0f, dy1 = 3.35f, dx2 = 0.49f, dy2 = 6.78f) + arcToRelative(a = 6.3f, b = 6.3f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 3.72f, dy1 = -2.72f) + curveToRelative(dx1 = 3.66f, dy1 = -1.03f, dx2 = 6.7f, dy2 = 0.66f, dx3 = 7.75f, dy3 = 4.22f) + lineToRelative(dx = 4.47f, dy = 15.25f) + curveToRelative(dx1 = 1.03f, dy1 = 3.6f, dx2 = -0.62f, dy2 = 6.72f, dx3 = -4.25f, dy3 = 7.75f) + curveToRelative(dx1 = -2.91f, dy1 = 0.83f, dx2 = -5.44f, dy2 = -0.08f, dx3 = -6.87f, dy3 = -2.34f) + arcToRelative(a = 2.5f, b = 2.5f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -3.31f, dy1 = -1.1f) + arcToRelative(a = 59.0f, b = 59.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -7.32f, dy1 = -27.84f) + curveToRelative(dx1 = 0.0f, dy1 = -7.04f, dx2 = 1.49f, dy2 = -13.27f, dx3 = 4.24f, dy3 = -18.4f) + close() + moveTo(x = 56.78f, y = 67.8f) + curveToRelative(dx1 = -1.48f, dy1 = 1.66f, dx2 = -3.74f, dy2 = 2.26f, dx3 = -6.3f, dy3 = 1.54f) + curveToRelative(dx1 = -3.6f, dy1 = -1.03f, dx2 = -5.28f, dy2 = -4.13f, dx3 = -4.22f, dy3 = -7.75f) + lineToRelative(dx = 0.97f, dy = -3.33f) + close() + moveTo(x = 68.0f, y = 38.07f) + curveToRelative(dx1 = 0.0f, dy1 = 6.6f, dx2 = -1.35f, dy2 = 13.67f, dx3 = -3.77f, dy3 = 20.07f) + lineTo(x = 51.2f, y = 45.11f) + curveToRelative(dx1 = 1.34f, dy1 = -2.7f, dx2 = 4.06f, dy2 = -3.88f, dx3 = 7.24f, dy3 = -2.98f) + arcToRelative(a = 6.3f, b = 6.3f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 3.75f, dy1 = 2.7f) + arcToRelative(a = 47.0f, b = 47.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.5f, dy1 = -6.76f) + curveToRelative(dx1 = 0.0f, dy1 = -17.35f, dx2 = -10.63f, dy2 = -28.91f, dx3 = -26.5f, dy3 = -28.91f) + curveToRelative(dx1 = -6.24f, dy1 = 0.0f, dx2 = -11.66f, dy2 = 1.78f, dx3 = -15.93f, dy3 = 5.0f) + lineToRelative(dx = -3.89f, dy = -3.88f) + curveTo(x1 = 21.6f, y1 = 6.14f, x2 = 28.35f, y2 = 3.85f, x3 = 36.2f, y3 = 3.85f) + curveTo(x1 = 55.32f, y1 = 3.85f, x2 = 68.0f, y2 = 17.5f, x3 = 68.0f, y3 = 38.07f) + moveToRelative(dx = -4.28f, dy = 31.56f) + arcToRelative(a = 2.41f, b = 2.41f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 3.4f, dy1 = -3.4f) + lineTo(x = 7.8f, y = 6.81f) + arcToRelative(a = 2.4f, b = 2.4f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -3.44f, dy1 = 0.0f) + arcToRelative(a = 2.44f, b = 2.44f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.0f, dy1 = 3.4f) + close() + } + }.build().also { _headphonesSlash = it } + } + + private var _headphonesSlash: ImageVector? = null + + val PlayFill: ImageVector + get() { + val current = _playFill + if (current != null) return current + + return ImageVector.Builder( + name = "PlayFill", + defaultWidth = 53.09400177001953.dp, + defaultHeight = 52.53099822998047.dp, + viewportWidth = 53.094f, + viewportHeight = 52.531f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 6.44f, y = 47.94f) + curveToRelative(dx1 = 0.0f, dy1 = 3.1f, dx2 = 1.78f, dy2 = 4.56f, dx3 = 3.9f, dy3 = 4.56f) + curveToRelative(dx1 = 0.94f, dy1 = 0.0f, dx2 = 1.91f, dy2 = -0.31f, dx3 = 2.88f, dy3 = -0.81f) + lineToRelative(dx = 36.4f, dy = -21.28f) + curveToRelative(dx1 = 2.6f, dy1 = -1.5f, dx2 = 3.47f, dy2 = -2.53f, dx3 = 3.47f, dy3 = -4.16f) + curveToRelative(dx1 = 0.0f, dy1 = -1.66f, dx2 = -0.87f, dy2 = -2.66f, dx3 = -3.47f, dy3 = -4.16f) + lineTo(x = 13.23f, y = 0.81f) + arcTo(horizontalEllipseRadius = 6.0f, verticalEllipseRadius = 6.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 10.34f, y1 = 0.0f) + curveToRelative(dx1 = -2.12f, dy1 = 0.0f, dx2 = -3.9f, dy2 = 1.47f, dx3 = -3.9f, dy3 = 4.56f) + close() + } + }.build().also { _playFill = it } + } + + private var _playFill: ImageVector? = null + + val PauseFill: ImageVector + get() { + val current = _pauseFill + if (current != null) return current + + return ImageVector.Builder( + name = "PauseFill", + defaultWidth = 38.3129997253418.dp, + defaultHeight = 51.71900177001953.dp, + viewportWidth = 38.313f, + viewportHeight = 51.719f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 4.16f, y = 51.69f) + horizontalLineToRelative(dx = 7.12f) + curveToRelative(dx1 = 2.72f, dy1 = 0.0f, dx2 = 4.16f, dy2 = -1.44f, dx3 = 4.16f, dy3 = -4.19f) + verticalLineTo(y = 4.16f) + curveTo(x1 = 15.44f, y1 = 1.28f, x2 = 14.0f, y2 = 0.0f, x3 = 11.28f, y3 = 0.0f) + horizontalLineTo(x = 4.16f) + curveTo(x1 = 1.44f, y1 = 0.0f, x2 = 0.0f, y2 = 1.4f, x3 = 0.0f, y3 = 4.16f) + verticalLineTo(y = 47.5f) + curveToRelative(dx1 = 0.0f, dy1 = 2.75f, dx2 = 1.44f, dy2 = 4.19f, dx3 = 4.16f, dy3 = 4.19f) + moveToRelative(dx = 22.72f, dy = 0.0f) + horizontalLineToRelative(dx = 7.09f) + curveToRelative(dx1 = 2.75f, dy1 = 0.0f, dx2 = 4.16f, dy2 = -1.44f, dx3 = 4.16f, dy3 = -4.19f) + verticalLineTo(y = 4.16f) + curveToRelative(dx1 = 0.0f, dy1 = -2.88f, dx2 = -1.41f, dy2 = -4.16f, dx3 = -4.16f, dy3 = -4.16f) + horizontalLineToRelative(dx = -7.1f) + curveToRelative(dx1 = -2.75f, dy1 = 0.0f, dx2 = -4.18f, dy2 = 1.4f, dx3 = -4.18f, dy3 = 4.16f) + verticalLineTo(y = 47.5f) + curveToRelative(dx1 = 0.0f, dy1 = 2.75f, dx2 = 1.43f, dy2 = 4.19f, dx3 = 4.18f, dy3 = 4.19f) + } + }.build().also { _pauseFill = it } + } + + private var _pauseFill: ImageVector? = null + + val Phone: ImageVector + get() { + val current = _phone + if (current != null) return current + + return ImageVector.Builder( + name = "Phone", + defaultWidth = 61.21900177001953.dp, + defaultHeight = 61.03300094604492.dp, + viewportWidth = 61.219f, + viewportHeight = 61.033f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 45.38f, y = 61.03f) + curveToRelative(dx1 = 5.43f, dy1 = 0.0f, dx2 = 9.03f, dy2 = -1.47f, dx3 = 12.18f, dy3 = -5.0f) + lineToRelative(dx = 0.72f, dy = -0.81f) + curveToRelative(dx1 = 1.88f, dy1 = -2.06f, dx2 = 2.75f, dy2 = -4.1f, dx3 = 2.75f, dy3 = -6.03f) + curveToRelative(dx1 = 0.0f, dy1 = -2.25f, dx2 = -1.28f, dy2 = -4.44f, dx3 = -4.06f, dy3 = -6.35f) + lineToRelative(dx = -7.84f, dy = -5.37f) + curveToRelative(dx1 = -2.41f, dy1 = -1.63f, dx2 = -4.44f, dy2 = -1.72f, dx3 = -7.44f, dy3 = -0.28f) + lineToRelative(dx = -4.85f, dy = 2.37f) + arcToRelative(a = 2.5f, b = 2.5f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -2.65f, dy1 = -0.12f) + arcTo(horizontalEllipseRadius = 53.0f, verticalEllipseRadius = 53.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, x1 = 27.0f, y1 = 33.38f) + arcToRelative(a = 41.0f, b = 41.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -5.72f, dy1 = -7.0f) + curveToRelative(dx1 = -0.4f, dy1 = -0.72f, dx2 = -0.34f, dy2 = -1.32f, dx3 = 0.19f, dy3 = -2.16f) + lineToRelative(dx = 2.81f, dy = -4.4f) + curveToRelative(dx1 = 1.22f, dy1 = -1.94f, dx2 = 1.6f, dy2 = -4.6f, dx3 = 0.03f, dy3 = -6.85f) + lineToRelative(dx = -6.18f, dy = -8.88f) + curveTo(x1 = 16.18f, y1 = 1.31f, x2 = 14.09f, y2 = 0.03f, x3 = 11.84f, y3 = 0.0f) + quadToRelative(dx1 = -2.9f, dy1 = -0.05f, dx2 = -6.06f, dy2 = 2.75f) + lineTo(x = 5.0f, y = 3.47f) + curveToRelative(dx1 = -3.53f, dy1 = 3.12f, dx2 = -5.0f, dy2 = 6.72f, dx3 = -5.0f, dy3 = 12.12f) + curveToRelative(dx1 = 0.0f, dy1 = 8.94f, dx2 = 5.53f, dy2 = 19.88f, dx3 = 15.56f, dy3 = 29.88f) + curveToRelative(dx1 = 9.97f, dy1 = 9.97f, dx2 = 20.88f, dy2 = 15.56f, dx3 = 29.82f, dy3 = 15.56f) + moveToRelative(dx = 0.03f, dy = -4.75f) + curveToRelative(dx1 = -7.97f, dy1 = 0.16f, dx2 = -18.16f, dy2 = -5.97f, dx3 = -26.25f, dy3 = -14.03f) + curveTo(x1 = 11.0f, y1 = 34.13f, x2 = 4.59f, y2 = 23.56f, x3 = 4.75f, y3 = 15.56f) + curveTo(x1 = 4.81f, y1 = 12.13f, x2 = 6.0f, y2 = 9.2f, x3 = 8.47f, y3 = 7.03f) + lineToRelative(dx = 0.56f, dy = -0.47f) + arcToRelative(a = 4.6f, b = 4.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 2.85f, dy1 = -1.25f) + arcToRelative(a = 2.6f, b = 2.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 2.34f, dy1 = 1.28f) + lineToRelative(dx = 5.62f, dy = 8.44f) + curveToRelative(dx1 = 0.54f, dy1 = 0.78f, dx2 = 0.5f, dy2 = 1.47f, dx3 = -0.12f, dy3 = 2.56f) + lineToRelative(dx = -3.13f, dy = 5.0f) + curveToRelative(dx1 = -1.37f, dy1 = 2.22f, dx2 = -1.12f, dy2 = 3.91f, dx3 = 0.25f, dy3 = 5.79f) + arcToRelative(a = 82.0f, b = 82.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 6.94f, dy1 = 8.25f) + arcToRelative(a = 79.0f, b = 79.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 8.5f, dy1 = 7.25f) + curveToRelative(dx1 = 1.88f, dy1 = 1.37f, dx2 = 3.56f, dy2 = 1.68f, dx3 = 6.47f, dy3 = 0.28f) + lineToRelative(dx = 5.25f, dy = -2.5f) + arcToRelative(a = 3.0f, b = 3.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 3.13f, dy1 = 0.25f) + lineToRelative(dx = 7.3f, dy = 4.9f) + arcToRelative(a = 2.6f, b = 2.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 1.29f, dy1 = 2.35f) + curveToRelative(dx1 = 0.0f, dy1 = 0.87f, dx2 = -0.44f, dy2 = 1.9f, dx3 = -1.25f, dy3 = 2.84f) + lineTo(x = 54.0f, y = 52.56f) + curveToRelative(dx1 = -2.16f, dy1 = 2.47f, dx2 = -5.12f, dy2 = 3.66f, dx3 = -8.6f, dy3 = 3.72f) + } + }.build().also { _phone = it } + } + + private var _phone: ImageVector? = null + + val RectangleOnRectangleDashed: ImageVector + get() { + val current = _rectangleOnRectangleDashed + if (current != null) return current + + return ImageVector.Builder( + name = "RectangleOnRectangleDashed", + defaultWidth = 77.93800354003906.dp, + defaultHeight = 63.34400177001953.dp, + viewportWidth = 77.938f, + viewportHeight = 63.344f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 5.03f, y = 38.9f) + curveToRelative(dx1 = 0.0f, dy1 = 3.26f, dx2 = 1.72f, dy2 = 4.91f, dx3 = 4.85f, dy3 = 4.91f) + horizontalLineToRelative(dx = 5.56f) + verticalLineToRelative(dy = 5.03f) + horizontalLineTo(x = 9.8f) + quadTo(x1 = 0.0f, y1 = 48.84f, x2 = 0.0f, y2 = 39.16f) + verticalLineToRelative(dy = -4.2f) + horizontalLineToRelative(dx = 5.03f) + close() + moveToRelative(dx = 0.0f, dy = -8.06f) + horizontalLineTo(x = 0.0f) + verticalLineTo(y = 18.0f) + horizontalLineToRelative(dx = 5.03f) + close() + moveTo(x = 62.28f, y = 9.7f) + verticalLineToRelative(dy = 4.78f) + horizontalLineToRelative(dx = -5.03f) + verticalLineToRelative(dy = -4.5f) + curveToRelative(dx1 = 0.0f, dy1 = -3.25f, dx2 = -1.75f, dy2 = -4.94f, dx3 = -4.87f, dy3 = -4.94f) + horizontalLineTo(x = 48.5f) + verticalLineTo(y = 0.0f) + horizontalLineToRelative(dx = 3.97f) + quadToRelative(dx1 = 9.8f, dy1 = 0.02f, dx2 = 9.81f, dy2 = 9.69f) + moveTo(x = 13.75f, y = 5.03f) + horizontalLineTo(x = 9.88f) + curveToRelative(dx1 = -3.13f, dy1 = 0.0f, dx2 = -4.85f, dy2 = 1.69f, dx3 = -4.85f, dy3 = 4.94f) + verticalLineToRelative(dy = 3.9f) + horizontalLineTo(x = 0.0f) + verticalLineTo(y = 9.7f) + quadTo(x1 = -0.02f, y1 = 0.0f, x2 = 9.81f, y2 = 0.0f) + horizontalLineToRelative(dx = 3.94f) + close() + moveToRelative(dx = 30.6f, dy = 0.0f) + horizontalLineTo(x = 33.18f) + verticalLineTo(y = 0.0f) + horizontalLineToRelative(dx = 11.15f) + close() + moveToRelative(dx = -15.29f, dy = 0.0f) + horizontalLineTo(x = 17.88f) + verticalLineTo(y = 0.0f) + horizontalLineToRelative(dx = 11.18f) + close() + moveToRelative(dx = -3.81f, dy = 58.28f) + horizontalLineToRelative(dx = 42.69f) + curveToRelative(dx1 = 6.5f, dy1 = 0.0f, dx2 = 9.81f, dy2 = -3.25f, dx3 = 9.81f, dy3 = -9.69f) + verticalLineTo(y = 24.17f) + curveToRelative(dx1 = 0.0f, dy1 = -6.44f, dx2 = -3.31f, dy2 = -9.7f, dx3 = -9.81f, dy3 = -9.7f) + horizontalLineTo(x = 25.25f) + curveToRelative(dx1 = -6.56f, dy1 = 0.0f, dx2 = -9.81f, dy2 = 3.26f, dx3 = -9.81f, dy3 = 9.7f) + verticalLineToRelative(dy = 29.47f) + quadToRelative(dx1 = -0.02f, dy1 = 9.68f, dx2 = 9.81f, dy2 = 9.68f) + moveToRelative(dx = 0.06f, dy = -5.03f) + curveToRelative(dx1 = -3.12f, dy1 = 0.0f, dx2 = -4.84f, dy2 = -1.66f, dx3 = -4.84f, dy3 = -4.9f) + verticalLineTo(y = 24.44f) + curveToRelative(dx1 = 0.0f, dy1 = -3.25f, dx2 = 1.72f, dy2 = -4.94f, dx3 = 4.84f, dy3 = -4.94f) + horizontalLineToRelative(dx = 42.53f) + curveToRelative(dx1 = 3.1f, dy1 = 0.0f, dx2 = 4.88f, dy2 = 1.69f, dx3 = 4.88f, dy3 = 4.94f) + verticalLineToRelative(dy = 28.94f) + curveToRelative(dx1 = 0.0f, dy1 = 3.24f, dx2 = -1.78f, dy2 = 4.9f, dx3 = -4.88f, dy3 = 4.9f) + close() + } + }.build().also { _rectangleOnRectangleDashed = it } + } + + private var _rectangleOnRectangleDashed: ImageVector? = null + + val Gear: ImageVector + get() { + val current = _gear + if (current != null) return current + + return ImageVector.Builder( + name = "Gear", + defaultWidth = 72.06300354003906.dp, + defaultHeight = 71.84400177001953.dp, + viewportWidth = 72.063f, + viewportHeight = 71.844f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 35.94f, y = 71.84f) + curveToRelative(dx1 = 0.81f, dy1 = 0.0f, dx2 = 1.47f, dy2 = -0.43f, dx3 = 1.69f, dy3 = -1.93f) + lineToRelative(dx = 0.3f, dy = -2.57f) + curveToRelative(dx1 = 0.2f, dy1 = -1.06f, dx2 = 0.82f, dy2 = -1.62f, dx3 = 1.95f, dy3 = -1.75f) + curveToRelative(dx1 = 1.09f, dy1 = -0.18f, dx2 = 1.8f, dy2 = 0.28f, dx3 = 2.21f, dy3 = 1.22f) + lineToRelative(dx = 0.97f, dy = 2.38f) + curveToRelative(dx1 = 0.63f, dy1 = 1.4f, dx2 = 1.38f, dy2 = 1.65f, dx3 = 2.16f, dy3 = 1.44f) + curveToRelative(dx1 = 0.78f, dy1 = -0.25f, dx2 = 1.31f, dy2 = -0.82f, dx3 = 1.1f, dy3 = -2.35f) + lineToRelative(dx = -0.35f, dy = -2.5f) + curveToRelative(dx1 = -0.16f, dy1 = -1.1f, dx2 = 0.37f, dy2 = -1.75f, dx3 = 1.47f, dy3 = -2.25f) + curveToRelative(dx1 = 0.94f, dy1 = -0.4f, dx2 = 1.81f, dy2 = -0.28f, dx3 = 2.47f, dy3 = 0.6f) + lineToRelative(dx = 1.56f, dy = 2.03f) + curveToRelative(dx1 = 0.97f, dy1 = 1.28f, dx2 = 1.69f, dy2 = 1.3f, dx3 = 2.44f, dy3 = 0.84f) + curveToRelative(dx1 = 0.75f, dy1 = -0.4f, dx2 = 1.06f, dy2 = -1.06f, dx3 = 0.47f, dy3 = -2.5f) + lineToRelative(dx = -0.97f, dy = -2.34f) + curveToRelative(dx1 = -0.44f, dy1 = -1.07f, dx2 = -0.16f, dy2 = -1.88f, dx3 = 0.78f, dy3 = -2.53f) + curveToRelative(dx1 = 0.87f, dy1 = -0.63f, dx2 = 1.69f, dy2 = -0.76f, dx3 = 2.56f, dy3 = -0.07f) + lineToRelative(dx = 2.03f, dy = 1.56f) + curveToRelative(dx1 = 1.22f, dy1 = 0.94f, dx2 = 2.0f, dy2 = 0.82f, dx3 = 2.6f, dy3 = 0.22f) + curveToRelative(dx1 = 0.56f, dy1 = -0.62f, dx2 = 0.71f, dy2 = -1.37f, dx3 = -0.26f, dy3 = -2.56f) + lineToRelative(dx = -1.56f, dy = -2.03f) + quadToRelative(dx1 = -0.92f, dy1 = -1.28f, dx2 = 0.13f, dy2 = -2.66f) + curveToRelative(dx1 = 0.62f, dy1 = -0.8f, dx2 = 1.44f, dy2 = -1.12f, dx3 = 2.47f, dy3 = -0.68f) + lineToRelative(dx = 2.37f, dy = 0.97f) + curveToRelative(dx1 = 1.44f, dy1 = 0.59f, dx2 = 2.1f, dy2 = 0.28f, dx3 = 2.5f, dy3 = -0.5f) + curveToRelative(dx1 = 0.44f, dy1 = -0.72f, dx2 = 0.4f, dy2 = -1.44f, dx3 = -0.84f, dy3 = -2.41f) + lineToRelative(dx = -2.0f, dy = -1.56f) + curveToRelative(dx1 = -0.85f, dy1 = -0.7f, dx2 = -1.0f, dy2 = -1.57f, dx3 = -0.6f, dy3 = -2.53f) + curveToRelative(dx1 = 0.38f, dy1 = -1.0f, dx2 = 1.07f, dy2 = -1.47f, dx3 = 2.2f, dy3 = -1.38f) + lineToRelative(dx = 2.55f, dy = 0.34f) + curveToRelative(dx1 = 1.5f, dy1 = 0.2f, dx2 = 2.13f, dy2 = -0.3f, dx3 = 2.32f, dy3 = -1.12f) + curveToRelative(dx1 = 0.18f, dy1 = -0.78f, dx2 = -0.03f, dy2 = -1.53f, dx3 = -1.47f, dy3 = -2.13f) + lineToRelative(dx = -2.35f, dy = -0.97f) + curveToRelative(dx1 = -0.97f, dy1 = -0.37f, dx2 = -1.37f, dy2 = -1.12f, dx3 = -1.25f, dy3 = -2.34f) + curveToRelative(dx1 = 0.16f, dy1 = -1.03f, dx2 = 0.7f, dy2 = -1.69f, dx3 = 1.82f, dy3 = -1.84f) + lineToRelative(dx = 2.5f, dy = -0.31f) + curveToRelative(dx1 = 1.5f, dy1 = -0.22f, dx2 = 1.97f, dy2 = -0.88f, dx3 = 1.97f, dy3 = -1.7f) + curveToRelative(dx1 = 0.0f, dy1 = -0.84f, dx2 = -0.47f, dy2 = -1.46f, dx3 = -1.97f, dy3 = -1.68f) + lineToRelative(dx = -2.5f, dy = -0.31f) + curveToRelative(dx1 = -1.16f, dy1 = -0.19f, dx2 = -1.63f, dy2 = -0.85f, dx3 = -1.82f, dy3 = -1.94f) + curveToRelative(dx1 = -0.12f, dy1 = -1.06f, dx2 = 0.25f, dy2 = -1.81f, dx3 = 1.25f, dy3 = -2.25f) + lineToRelative(dx = 2.38f, dy = -0.94f) + curveToRelative(dx1 = 1.4f, dy1 = -0.62f, dx2 = 1.66f, dy2 = -1.37f, dx3 = 1.44f, dy3 = -2.15f) + curveToRelative(dx1 = -0.22f, dy1 = -0.79f, dx2 = -0.82f, dy2 = -1.32f, dx3 = -2.35f, dy3 = -1.13f) + lineToRelative(dx = -2.5f, dy = 0.34f) + arcToRelative(a = 1.97f, b = 1.97f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -2.25f, dy1 = -1.37f) + curveToRelative(dx1 = -0.4f, dy1 = -1.1f, dx2 = -0.28f, dy2 = -1.84f, dx3 = 0.63f, dy3 = -2.53f) + lineToRelative(dx = 2.0f, dy = -1.56f) + curveToRelative(dx1 = 1.25f, dy1 = -0.97f, dx2 = 1.28f, dy2 = -1.7f, dx3 = 0.87f, dy3 = -2.41f) + curveToRelative(dx1 = -0.47f, dy1 = -0.81f, dx2 = -1.15f, dy2 = -1.06f, dx3 = -2.53f, dy3 = -0.53f) + lineToRelative(dx = -2.37f, dy = 1.03f) + curveToRelative(dx1 = -1.03f, dy1 = 0.44f, dx2 = -1.88f, dy2 = 0.06f, dx3 = -2.5f, dy3 = -0.78f) + reflectiveCurveToRelative(dx1 = -0.72f, dy1 = -1.69f, dx2 = -0.07f, dy2 = -2.6f) + lineToRelative(dx = 1.57f, dy = -2.0f) + curveToRelative(dx1 = 0.93f, dy1 = -1.21f, dx2 = 0.8f, dy2 = -2.0f, dx3 = 0.22f, dy3 = -2.59f) + curveToRelative(dx1 = -0.6f, dy1 = -0.56f, dx2 = -1.38f, dy2 = -0.72f, dx3 = -2.57f, dy3 = 0.22f) + lineToRelative(dx = -2.03f, dy = 1.6f) + curveToRelative(dx1 = -0.84f, dy1 = 0.68f, dx2 = -1.78f, dy2 = 0.53f, dx3 = -2.62f, dy3 = -0.13f) + curveToRelative(dx1 = -0.91f, dy1 = -0.75f, dx2 = -1.2f, dy2 = -1.44f, dx3 = -0.75f, dy3 = -2.5f) + lineToRelative(dx = 0.97f, dy = -2.38f) + curveToRelative(dx1 = 0.59f, dy1 = -1.43f, dx2 = 0.28f, dy2 = -2.06f, dx3 = -0.47f, dy3 = -2.5f) + curveToRelative(dx1 = -0.82f, dy1 = -0.46f, dx2 = -1.57f, dy2 = -0.28f, dx3 = -2.44f, dy3 = 0.85f) + lineTo(x = 49.9f, y = 7.72f) + curveToRelative(dx1 = -0.7f, dy1 = 0.84f, dx2 = -1.53f, dy2 = 1.0f, dx3 = -2.57f, dy3 = 0.56f) + curveToRelative(dx1 = -0.97f, dy1 = -0.37f, dx2 = -1.5f, dy2 = -1.06f, dx3 = -1.34f, dy3 = -2.19f) + lineToRelative(dx = 0.38f, dy = -2.53f) + curveToRelative(dx1 = 0.15f, dy1 = -1.5f, dx2 = -0.26f, dy2 = -2.1f, dx3 = -1.16f, dy3 = -2.31f) + curveToRelative(dx1 = -0.88f, dy1 = -0.22f, dx2 = -1.6f, dy2 = 0.16f, dx3 = -2.13f, dy3 = 1.44f) + lineToRelative(dx = -0.93f, dy = 2.37f) + curveToRelative(dx1 = -0.41f, dy1 = 1.0f, dx2 = -1.22f, dy2 = 1.44f, dx3 = -2.35f, dy3 = 1.25f) + curveTo(x1 = 38.7f, y1 = 6.13f, x2 = 38.1f, y2 = 5.6f, x3 = 37.97f, y3 = 4.5f) + lineToRelative(dx = -0.34f, dy = -2.53f) + curveToRelative(dx1 = -0.22f, dy1 = -1.5f, dx2 = -0.88f, dy2 = -1.94f, dx3 = -1.66f, dy3 = -1.94f) + curveToRelative(dx1 = -0.88f, dy1 = 0.0f, dx2 = -1.53f, dy2 = 0.44f, dx3 = -1.72f, dy3 = 1.9f) + lineToRelative(dx = -0.31f, dy = 2.6f) + curveTo(x1 = 33.75f, y1 = 5.56f, x2 = 33.16f, y2 = 6.2f, x3 = 32.0f, y3 = 6.28f) + quadToRelative(dx1 = -1.65f, dy1 = 0.2f, dx2 = -2.22f, dy2 = -1.22f) + lineTo(x = 28.81f, y = 2.7f) + curveTo(x1 = 28.25f, y1 = 1.44f, x2 = 27.56f, y2 = 1.0f, x3 = 26.66f, y3 = 1.25f) + curveToRelative(dx1 = -0.97f, dy1 = 0.25f, dx2 = -1.32f, dy2 = 0.97f, dx3 = -1.13f, dy3 = 2.34f) + lineToRelative(dx = 0.34f, dy = 2.5f) + curveToRelative(dx1 = 0.16f, dy1 = 1.13f, dx2 = -0.37f, dy2 = 1.85f, dx3 = -1.4f, dy3 = 2.22f) + curveToRelative(dx1 = -1.06f, dy1 = 0.4f, dx2 = -1.81f, dy2 = 0.25f, dx3 = -2.5f, dy3 = -0.6f) + lineTo(x = 20.4f, y = 5.7f) + curveTo(x1 = 19.5f, y1 = 4.53f, x2 = 18.78f, y2 = 4.4f, x3 = 18.0f, y3 = 4.84f) + curveToRelative(dx1 = -0.78f, dy1 = 0.47f, dx2 = -1.1f, dy2 = 1.07f, dx3 = -0.5f, dy3 = 2.5f) + lineToRelative(dx = 0.97f, dy = 2.38f) + quadToRelative(dx1 = 0.6f, dy1 = 1.5f, dx2 = -0.72f, dy2 = 2.5f) + curveToRelative(dx1 = -0.87f, dy1 = 0.65f, dx2 = -1.75f, dy2 = 0.75f, dx3 = -2.66f, dy3 = 0.1f) + lineToRelative(dx = -2.0f, dy = -1.6f) + curveToRelative(dx1 = -1.12f, dy1 = -0.85f, dx2 = -1.93f, dy2 = -0.85f, dx3 = -2.59f, dy3 = -0.19f) + curveToRelative(dx1 = -0.66f, dy1 = 0.69f, dx2 = -0.62f, dy2 = 1.47f, dx3 = 0.22f, dy3 = 2.56f) + lineToRelative(dx = 1.6f, dy = 2.0f) + curveToRelative(dx1 = 0.68f, dy1 = 0.88f, dx2 = 0.52f, dy2 = 1.85f, dx3 = -0.13f, dy3 = 2.66f) + curveToRelative(dx1 = -0.75f, dy1 = 0.84f, dx2 = -1.4f, dy2 = 1.16f, dx3 = -2.5f, dy3 = 0.72f) + lineTo(x = 7.34f, y = 17.5f) + curveToRelative(dx1 = -1.3f, dy1 = -0.53f, dx2 = -2.06f, dy2 = -0.34f, dx3 = -2.5f, dy3 = 0.47f) + curveToRelative(dx1 = -0.53f, dy1 = 0.84f, dx2 = -0.28f, dy2 = 1.6f, dx3 = 0.85f, dy3 = 2.44f) + lineToRelative(dx = 2.0f, dy = 1.56f) + curveToRelative(dx1 = 0.84f, dy1 = 0.69f, dx2 = 1.0f, dy2 = 1.6f, dx3 = 0.6f, dy3 = 2.53f) + curveToRelative(dx1 = -0.48f, dy1 = 1.0f, dx2 = -1.13f, dy2 = 1.47f, dx3 = -2.23f, dy3 = 1.34f) + lineTo(x = 3.53f, y = 25.5f) + curveToRelative(dx1 = -1.5f, dy1 = -0.16f, dx2 = -2.12f, dy2 = 0.34f, dx3 = -2.31f, dy3 = 1.16f) + curveToRelative(dx1 = -0.19f, dy1 = 0.78f, dx2 = 0.03f, dy2 = 1.53f, dx3 = 1.47f, dy3 = 2.12f) + lineToRelative(dx = 2.34f, dy = 0.94f) + curveToRelative(dx1 = 1.0f, dy1 = 0.47f, dx2 = 1.44f, dy2 = 1.22f, dx3 = 1.22f, dy3 = 2.31f) + curveToRelative(dx1 = -0.19f, dy1 = 1.1f, dx2 = -0.62f, dy2 = 1.72f, dx3 = -1.78f, dy3 = 1.9f) + lineToRelative(dx = -2.5f, dy = 0.32f) + curveTo(x1 = 0.44f, y1 = 34.47f, x2 = 0.0f, y2 = 35.09f, x3 = 0.0f, y3 = 35.94f) + curveToRelative(dx1 = 0.0f, dy1 = 0.81f, dx2 = 0.44f, dy2 = 1.47f, dx3 = 1.97f, dy3 = 1.69f) + lineToRelative(dx = 2.5f, dy = 0.3f) + curveToRelative(dx1 = 1.16f, dy1 = 0.2f, dx2 = 1.62f, dy2 = 0.82f, dx3 = 1.78f, dy3 = 1.88f) + reflectiveCurveTo(x1 = 6.03f, y1 = 41.7f, x2 = 5.03f, y2 = 42.1f) + lineToRelative(dx = -2.37f, dy = 0.97f) + curveTo(x1 = 1.25f, y1 = 43.7f, x2 = 1.0f, y2 = 44.44f, x3 = 1.22f, y3 = 45.22f) + reflectiveCurveToRelative(dx1 = 0.81f, dy1 = 1.31f, dx2 = 2.34f, dy2 = 1.1f) + lineToRelative(dx = 2.47f, dy = -0.35f) + curveToRelative(dx1 = 1.1f, dy1 = -0.13f, dx2 = 1.78f, dy2 = 0.37f, dx3 = 2.28f, dy3 = 1.44f) + curveToRelative(dx1 = 0.38f, dy1 = 0.93f, dx2 = 0.22f, dy2 = 1.84f, dx3 = -0.62f, dy3 = 2.5f) + lineToRelative(dx = -2.0f, dy = 1.56f) + curveToRelative(dx1 = -1.28f, dy1 = 0.97f, dx2 = -1.32f, dy2 = 1.69f, dx3 = -0.85f, dy3 = 2.44f) + curveToRelative(dx1 = 0.41f, dy1 = 0.75f, dx2 = 1.07f, dy2 = 1.06f, dx3 = 2.5f, dy3 = 0.5f) + lineToRelative(dx = 2.35f, dy = -1.03f) + curveToRelative(dx1 = 1.03f, dy1 = -0.44f, dx2 = 1.87f, dy2 = -0.04f, dx3 = 2.53f, dy3 = 0.78f) + curveToRelative(dx1 = 0.62f, dy1 = 0.78f, dx2 = 0.72f, dy2 = 1.68f, dx3 = 0.06f, dy3 = 2.56f) + lineToRelative(dx = -1.6f, dy = 2.03f) + curveToRelative(dx1 = -0.9f, dy1 = 1.22f, dx2 = -0.77f, dy2 = 1.97f, dx3 = -0.18f, dy3 = 2.6f) + curveToRelative(dx1 = 0.6f, dy1 = 0.56f, dx2 = 1.38f, dy2 = 0.71f, dx3 = 2.56f, dy3 = -0.26f) + lineToRelative(dx = 2.0f, dy = -1.56f) + curveToRelative(dx1 = 0.88f, dy1 = -0.66f, dx2 = 1.72f, dy2 = -0.53f, dx3 = 2.69f, dy3 = 0.13f) + curveToRelative(dx1 = 0.88f, dy1 = 0.65f, dx2 = 1.16f, dy2 = 1.47f, dx3 = 0.72f, dy3 = 2.5f) + lineTo(x = 17.5f, y = 64.5f) + curveToRelative(dx1 = -0.6f, dy1 = 1.44f, dx2 = -0.28f, dy2 = 2.1f, dx3 = 0.5f, dy3 = 2.5f) + curveToRelative(dx1 = 0.72f, dy1 = 0.47f, dx2 = 1.44f, dy2 = 0.44f, dx3 = 2.4f, dy3 = -0.84f) + lineToRelative(dx = 1.57f, dy = -2.0f) + curveToRelative(dx1 = 0.72f, dy1 = -0.91f, dx2 = 1.53f, dy2 = -1.03f, dx3 = 2.47f, dy3 = -0.63f) + curveToRelative(dx1 = 1.0f, dy1 = 0.4f, dx2 = 1.56f, dy2 = 1.13f, dx3 = 1.4f, dy3 = 2.25f) + lineToRelative(dx = -0.37f, dy = 2.53f) + curveToRelative(dx1 = -0.13f, dy1 = 1.5f, dx2 = 0.34f, dy2 = 2.1f, dx3 = 1.19f, dy3 = 2.31f) + curveToRelative(dx1 = 0.78f, dy1 = 0.2f, dx2 = 1.53f, dy2 = -0.03f, dx3 = 2.12f, dy3 = -1.43f) + lineToRelative(dx = 0.94f, dy = -2.38f) + curveToRelative(dx1 = 0.37f, dy1 = -0.94f, dx2 = 1.12f, dy2 = -1.37f, dx3 = 2.34f, dy3 = -1.25f) + curveToRelative(dx1 = 1.13f, dy1 = 0.13f, dx2 = 1.72f, dy2 = 0.72f, dx3 = 1.85f, dy3 = 1.81f) + lineToRelative(dx = 0.34f, dy = 2.5f) + curveToRelative(dx1 = 0.19f, dy1 = 1.54f, dx2 = 0.84f, dy2 = 1.97f, dx3 = 1.69f, dy3 = 1.97f) + moveTo(x = 17.8f, y = 54.2f) + arcToRelative(a = 25.3f, b = 25.3f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -7.72f, dy1 = -18.25f) + curveToRelative(dx1 = 0.0f, dy1 = -7.19f, dx2 = 2.94f, dy2 = -13.63f, dx3 = 7.72f, dy3 = -18.28f) + curveToRelative(dx1 = 1.9f, dy1 = -1.94f, dx2 = 3.97f, dy2 = -1.5f, dx3 = 5.35f, dy3 = 0.87f) + lineToRelative(dx = 8.47f, dy = 14.6f) + arcToRelative(a = 5.2f, b = 5.2f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -0.04f, dy1 = 5.68f) + lineToRelative(dx = -8.4f, dy = 14.5f) + curveToRelative(dx1 = -1.38f, dy1 = 2.4f, dx2 = -3.44f, dy2 = 2.81f, dx3 = -5.38f, dy3 = 0.88f) + moveToRelative(dx = 17.97f, dy = 7.4f) + curveToRelative(dx1 = -2.34f, dy1 = 0.0f, dx2 = -4.66f, dy2 = -0.34f, dx3 = -6.84f, dy3 = -0.97f) + curveToRelative(dx1 = -2.66f, dy1 = -0.71f, dx2 = -3.32f, dy2 = -2.71f, dx3 = -1.9f, dy3 = -5.12f) + lineTo(x = 35.4f, y = 41.0f) + arcToRelative(a = 5.1f, b = 5.1f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 4.97f, dy1 = -2.84f) + horizontalLineToRelative(dx = 16.71f) + curveToRelative(dx1 = 2.78f, dy1 = 0.0f, dx2 = 4.16f, dy2 = 1.56f, dx3 = 3.41f, dy3 = 4.22f) + arcToRelative(a = 25.5f, b = 25.5f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -24.72f, dy1 = 19.21f) + moveToRelative(dx = -0.1f, dy = -23.8f) + curveToRelative(dx1 = -1.0f, dy1 = 0.0f, dx2 = -1.77f, dy2 = -0.82f, dx3 = -1.77f, dy3 = -1.79f) + curveToRelative(dx1 = 0.0f, dy1 = -1.0f, dx2 = 0.78f, dy2 = -1.81f, dx3 = 1.78f, dy3 = -1.81f) + reflectiveCurveTo(x1 = 37.47f, y1 = 35.0f, x2 = 37.47f, y2 = 36.0f) + arcToRelative(a = 1.8f, b = 1.8f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -1.78f, dy1 = 1.78f) + moveToRelative(dx = 4.7f, dy = -4.07f) + curveToRelative(dx1 = -2.35f, dy1 = 0.0f, dx2 = -3.82f, dy2 = -0.88f, dx3 = -4.91f, dy3 = -2.81f) + lineTo(x = 27.0f, y = 16.3f) + curveToRelative(dx1 = -1.34f, dy1 = -2.37f, dx2 = -0.72f, dy2 = -4.37f, dx3 = 1.94f, dy3 = -5.1f) + arcTo(horizontalEllipseRadius = 25.42f, verticalEllipseRadius = 25.42f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, x1 = 60.5f, y1 = 29.5f) + curveToRelative(dx1 = 0.75f, dy1 = 2.67f, dx2 = -0.62f, dy2 = 4.23f, dx3 = -3.37f, dy3 = 4.23f) + close() + } + }.build().also { _gear = it } + } + + private var _gear: ImageVector? = null + + val PaperplaneFill: ImageVector + get() { + val current = _paperplaneFill + if (current != null) return current + + return ImageVector.Builder( + name = "PaperplaneFill", + defaultWidth = 68.875.dp, + defaultHeight = 68.46900177001953.dp, + viewportWidth = 68.875f, + viewportHeight = 68.469f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 39.13f, y = 68.47f) + curveToRelative(dx1 = 2.24f, dy1 = 0.0f, dx2 = 3.84f, dy2 = -1.94f, dx3 = 5.0f, dy3 = -4.94f) + lineToRelative(dx = 20.46f, dy = -53.47f) + arcToRelative(a = 11.0f, b = 11.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.88f, dy1 = -3.78f) + curveToRelative(dx1 = 0.0f, dy1 = -2.03f, dx2 = -1.25f, dy2 = -3.28f, dx3 = -3.28f, dy3 = -3.28f) + curveToRelative(dx1 = -1.06f, dy1 = 0.0f, dx2 = -2.35f, dy2 = 0.31f, dx3 = -3.78f, dy3 = 0.88f) + lineTo(x = 4.66f, y = 24.47f) + curveToRelative(dx1 = -2.63f, dy1 = 1.0f, dx2 = -4.66f, dy2 = 2.6f, dx3 = -4.66f, dy3 = 4.87f) + curveToRelative(dx1 = 0.0f, dy1 = 2.88f, dx2 = 2.19f, dy2 = 3.85f, dx3 = 5.19f, dy3 = 4.75f) + lineToRelative(dx = 16.87f, dy = 5.13f) + curveToRelative(dx1 = 2.0f, dy1 = 0.62f, dx2 = 3.13f, dy2 = 0.56f, dx3 = 4.47f, dy3 = -0.69f) + lineTo(x = 60.81f, y = 6.5f) + curveToRelative(dx1 = 0.4f, dy1 = -0.37f, dx2 = 0.88f, dy2 = -0.31f, dx3 = 1.19f, dy3 = -0.03f) + curveToRelative(dx1 = 0.31f, dy1 = 0.31f, dx2 = 0.34f, dy2 = 0.78f, dx3 = -0.03f, dy3 = 1.19f) + lineToRelative(dx = -31.9f, dy = 34.4f) + curveToRelative(dx1 = -1.23f, dy1 = 1.28f, dx2 = -1.32f, dy2 = 2.35f, dx3 = -0.73f, dy3 = 4.44f) + lineTo(x = 34.31f, y = 63.0f) + curveToRelative(dx1 = 0.94f, dy1 = 3.16f, dx2 = 1.9f, dy2 = 5.47f, dx3 = 4.81f, dy3 = 5.47f) + } + }.build().also { _paperplaneFill = it } + } + + private var _paperplaneFill: ImageVector? = null + + val XMark: ImageVector + get() { + val current = _xMark + if (current != null) return current + + return ImageVector.Builder( + name = "XMark", + defaultWidth = 49.742000579833984.dp, + defaultHeight = 49.58599853515625.dp, + viewportWidth = 49.742f, + viewportHeight = 49.586f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 44.78f, y = 0.87f) + lineTo(x = 0.8f, y = 44.84f) + arcToRelative(a = 2.8f, b = 2.8f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.0f, dy1 = 3.94f) + arcToRelative(a = 2.87f, b = 2.87f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 3.97f, dy1 = 0.0f) + lineTo(x = 48.75f, y = 4.8f) + arcToRelative(a = 2.8f, b = 2.8f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -3.97f, dy1 = -3.94f) + moveToRelative(dx = 3.97f, dy = 43.97f) + lineTo(x = 4.78f, y = 0.87f) + arcToRelative(a = 2.8f, b = 2.8f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -3.97f, dy1 = 0.0f) + arcToRelative(a = 2.83f, b = 2.83f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.0f, dy1 = 3.94f) + lineToRelative(dx = 43.97f, dy = 43.97f) + arcToRelative(a = 2.84f, b = 2.84f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 3.97f, dy1 = 0.0f) + arcToRelative(a = 2.83f, b = 2.83f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.0f, dy1 = -3.94f) + } + }.build().also { _xMark = it } + } + + private var _xMark: ImageVector? = null + + val SpeakerFill: ImageVector + get() { + val current = _speakerFill + if (current != null) return current + + return ImageVector.Builder( + name = "SpeakerFill", + defaultWidth = 44.65599822998047.dp, + defaultHeight = 55.15599822998047.dp, + viewportWidth = 44.656f, + viewportHeight = 55.156f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 33.69f, y = 55.16f) + arcToRelative(a = 3.4f, b = 3.4f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 3.53f, dy1 = -3.5f) + verticalLineTo(y = 3.72f) + arcToRelative(a = 3.56f, b = 3.56f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -3.6f, dy1 = -3.69f) + curveToRelative(dx1 = -1.43f, dy1 = 0.0f, dx2 = -2.43f, dy2 = 0.63f, dx3 = -4.03f, dy3 = 2.16f) + lineToRelative(dx = -13.3f, dy = 12.5f) + arcToRelative(a = 1.1f, b = 1.1f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -0.79f, dy1 = 0.28f) + horizontalLineTo(x = 6.53f) + curveTo(x1 = 2.31f, y1 = 14.97f, x2 = 0.0f, y2 = 17.3f, x3 = 0.0f, y3 = 21.8f) + verticalLineToRelative(dy = 11.63f) + curveToRelative(dx1 = 0.0f, dy1 = 4.53f, dx2 = 2.31f, dy2 = 6.84f, dx3 = 6.53f, dy3 = 6.84f) + horizontalLineToRelative(dx = 8.97f) + quadToRelative(dx1 = 0.46f, dy1 = 0.0f, dx2 = 0.78f, dy2 = 0.28f) + lineTo(x = 29.6f, y = 53.2f) + curveToRelative(dx1 = 1.44f, dy1 = 1.37f, dx2 = 2.66f, dy2 = 1.97f, dx3 = 4.1f, dy3 = 1.97f) + } + }.build().also { _speakerFill = it } + } + + private var _speakerFill: ImageVector? = null + + val SpeakerWave3Fill: ImageVector + get() { + val current = _speakerWave3Fill + if (current != null) return current + + return ImageVector.Builder( + name = "SpeakerWave3Fill", + defaultWidth = 84.06300354003906.dp, + defaultHeight = 60.39899826049805.dp, + viewportWidth = 84.063f, + viewportHeight = 60.399f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 71.69f, y = 59.93f) + curveToRelative(dx1 = 1.06f, dy1 = 0.79f, dx2 = 2.6f, dy2 = 0.47f, dx3 = 3.44f, dy3 = -0.78f) + curveToRelative(dx1 = 5.46f, dy1 = -7.9f, dx2 = 8.75f, dy2 = -17.93f, dx3 = 8.75f, dy3 = -28.97f) + arcToRelative(a = 51.5f, b = 51.5f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -8.75f, dy1 = -28.96f) + curveToRelative(dx1 = -0.85f, dy1 = -1.29f, dx2 = -2.38f, dy2 = -1.57f, dx3 = -3.44f, dy3 = -0.79f) + curveToRelative(dx1 = -1.19f, dy1 = 0.82f, dx2 = -1.35f, dy2 = 2.29f, dx3 = -0.5f, dy3 = 3.57f) + arcToRelative(a = 46.6f, b = 46.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 7.9f, dy1 = 26.18f) + curveToRelative(dx1 = 0.0f, dy1 = 10.0f, dx2 = -3.06f, dy2 = 19.07f, dx3 = -7.9f, dy3 = 26.2f) + curveToRelative(dx1 = -0.85f, dy1 = 1.27f, dx2 = -0.69f, dy2 = 2.74f, dx3 = 0.5f, dy3 = 3.55f) + moveToRelative(dx = -11.72f, dy = -8.28f) + curveToRelative(dx1 = 1.16f, dy1 = 0.78f, dx2 = 2.6f, dy2 = 0.5f, dx3 = 3.44f, dy3 = -0.68f) + curveToRelative(dx1 = 4.0f, dy1 = -5.5f, dx2 = 6.34f, dy2 = -13.07f, dx3 = 6.34f, dy3 = -20.79f) + reflectiveCurveTo(x1 = 67.44f, y1 = 14.84f, x2 = 63.41f, y2 = 9.4f) + curveToRelative(dx1 = -0.85f, dy1 = -1.18f, dx2 = -2.28f, dy2 = -1.47f, dx3 = -3.44f, dy3 = -0.68f) + reflectiveCurveToRelative(dx1 = -1.34f, dy1 = 2.25f, dx2 = -0.44f, dy2 = 3.53f) + curveToRelative(dx1 = 3.4f, dy1 = 4.8f, dx2 = 5.38f, dy2 = 11.28f, dx3 = 5.38f, dy3 = 17.93f) + reflectiveCurveToRelative(dx1 = -2.03f, dy1 = 13.07f, dx2 = -5.38f, dy2 = 17.94f) + curveToRelative(dx1 = -0.87f, dy1 = 1.28f, dx2 = -0.72f, dy2 = 2.75f, dx3 = 0.44f, dy3 = 3.53f) + moveToRelative(dx = -11.6f, dy = -8.15f) + curveToRelative(dx1 = 1.04f, dy1 = 0.72f, dx2 = 2.5f, dy2 = 0.5f, dx3 = 3.35f, dy3 = -0.72f) + curveToRelative(dx1 = 2.4f, dy1 = -3.16f, dx2 = 3.84f, dy2 = -7.81f, dx3 = 3.84f, dy3 = -12.6f) + reflectiveCurveToRelative(dx1 = -1.44f, dy1 = -9.4f, dx2 = -3.84f, dy2 = -12.59f) + curveToRelative(dx1 = -0.84f, dy1 = -1.22f, dx2 = -2.31f, dy2 = -1.47f, dx3 = -3.34f, dy3 = -0.72f) + curveToRelative(dx1 = -1.29f, dy1 = 0.88f, dx2 = -1.47f, dy2 = 2.44f, dx3 = -0.5f, dy3 = 3.72f) + curveToRelative(dx1 = 1.8f, dy1 = 2.5f, dx2 = 2.84f, dy2 = 5.97f, dx3 = 2.84f, dy3 = 9.6f) + reflectiveCurveToRelative(dx1 = -1.06f, dy1 = 7.06f, dx2 = -2.84f, dy2 = 9.59f) + curveToRelative(dx1 = -0.94f, dy1 = 1.31f, dx2 = -0.79f, dy2 = 2.81f, dx3 = 0.5f, dy3 = 3.72f) + moveTo(x = 33.7f, y = 57.78f) + arcToRelative(a = 3.4f, b = 3.4f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 3.53f, dy1 = -3.5f) + verticalLineTo(y = 6.34f) + arcToRelative(a = 3.56f, b = 3.56f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -3.6f, dy1 = -3.69f) + curveToRelative(dx1 = -1.46f, dy1 = 0.0f, dx2 = -2.46f, dy2 = 0.63f, dx3 = -4.03f, dy3 = 2.16f) + lineTo(x = 16.25f, y = 17.3f) + arcToRelative(a = 1.0f, b = 1.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -0.78f, dy1 = 0.28f) + horizontalLineTo(x = 6.53f) + curveTo(x1 = 2.28f, y1 = 17.59f, x2 = 0.0f, y2 = 19.93f, x3 = 0.0f, y3 = 24.43f) + verticalLineToRelative(dy = 11.63f) + curveToRelative(dx1 = 0.0f, dy1 = 4.53f, dx2 = 2.28f, dy2 = 6.84f, dx3 = 6.53f, dy3 = 6.84f) + horizontalLineToRelative(dx = 8.94f) + curveToRelative(dx1 = 0.31f, dy1 = 0.0f, dx2 = 0.6f, dy2 = 0.1f, dx3 = 0.78f, dy3 = 0.28f) + lineToRelative(dx = 13.34f, dy = 12.63f) + curveToRelative(dx1 = 1.41f, dy1 = 1.37f, dx2 = 2.63f, dy2 = 1.97f, dx3 = 4.1f, dy3 = 1.97f) + } + }.build().also { _speakerWave3Fill = it } + } + + private var _speakerWave3Fill: ImageVector? = null + + val BoltFill: ImageVector + get() { + val current = _boltFill + if (current != null) return current + + return ImageVector.Builder( + name = "BoltFill", + defaultWidth = 44.21900177001953.dp, + defaultHeight = 70.3290023803711.dp, + viewportWidth = 44.219f, + viewportHeight = 70.329f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 0.0f, y = 38.77f) + curveTo(x1 = 0.0f, y1 = 40.0f, x2 = 0.94f, y2 = 40.9f, x3 = 2.25f, y3 = 40.9f) + horizontalLineToRelative(dx = 17.66f) + lineTo(x = 10.59f, y = 66.2f) + curveToRelative(dx1 = -1.21f, dy1 = 3.22f, dx2 = 2.13f, dy2 = 4.94f, dx3 = 4.22f, dy3 = 2.31f) + lineToRelative(dx = 28.4f, dy = -35.5f) + arcToRelative(a = 3.0f, b = 3.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.82f, dy1 = -2.0f) + curveToRelative(dx1 = 0.0f, dy1 = -1.18f, dx2 = -0.94f, dy2 = -2.12f, dx3 = -2.25f, dy3 = -2.12f) + horizontalLineTo(x = 24.13f) + lineToRelative(dx = 9.3f, dy = -25.31f) + curveToRelative(dx1 = 1.23f, dy1 = -3.22f, dx2 = -2.12f, dy2 = -4.94f, dx3 = -4.21f, dy3 = -2.28f) + lineTo(x = 0.82f, y = 36.77f) + arcToRelative(a = 3.2f, b = 3.2f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -0.82f, dy1 = 2.0f) + } + }.build().also { _boltFill = it } + } + + private var _boltFill: ImageVector? = null + + val XMarkCircleFill: ImageVector + get() { + val current = _xMarkCircleFill + if (current != null) return current + + return ImageVector.Builder( + name = "XMarkCircleFill", + defaultWidth = 63.9379997253418.dp, + defaultHeight = 63.78099822998047.dp, + viewportWidth = 63.938f, + viewportHeight = 63.781f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 63.75f, y = 31.88f) + arcToRelative(a = 31.9f, b = 31.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -31.87f, dy1 = 31.87f) + arcTo(horizontalEllipseRadius = 31.93f, verticalEllipseRadius = 31.93f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, x1 = 0.0f, y1 = 31.88f) + arcTo(horizontalEllipseRadius = 31.9f, verticalEllipseRadius = 31.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, x1 = 31.88f, y1 = 0.0f) + arcToRelative(a = 31.9f, b = 31.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 31.87f, dy1 = 31.88f) + moveTo(x = 41.13f, y = 19.13f) + lineToRelative(dx = -9.22f, dy = 9.15f) + lineToRelative(dx = -9.2f, dy = -9.16f) + arcToRelative(a = 2.5f, b = 2.5f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -1.8f, dy1 = -0.75f) + arcToRelative(a = 2.55f, b = 2.55f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -1.85f, dy1 = 4.38f) + lineToRelative(dx = 9.18f, dy = 9.17f) + lineToRelative(dx = -9.18f, dy = 9.11f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -0.75f, dy1 = 1.84f) + arcToRelative(a = 2.6f, b = 2.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.6f, dy1 = 2.63f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 1.87f, dy1 = -0.78f) + lineToRelative(dx = 9.12f, dy = -9.13f) + lineToRelative(dx = 9.13f, dy = 9.13f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 1.88f, dy1 = 0.78f) + arcToRelative(a = 2.6f, b = 2.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.56f, dy1 = -2.62f) + arcToRelative(a = 2.6f, b = 2.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -0.75f, dy1 = -1.85f) + lineToRelative(dx = -9.15f, dy = -9.11f) + lineToRelative(dx = 9.15f, dy = -9.17f) + arcToRelative(a = 2.5f, b = 2.5f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.75f, dy1 = -1.84f) + curveToRelative(dx1 = 0.0f, dy1 = -1.41f, dx2 = -1.16f, dy2 = -2.53f, dx3 = -2.56f, dy3 = -2.53f) + arcToRelative(a = 2.3f, b = 2.3f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -1.78f, dy1 = 0.75f) + } + }.build().also { _xMarkCircleFill = it } + } + + private var _xMarkCircleFill: ImageVector? = null + + val Checkmark: ImageVector + get() { + val current = _checkmark + if (current != null) return current + + return ImageVector.Builder( + name = "Checkmark", + defaultWidth = 54.03099822998047.dp, + defaultHeight = 55.15599822998047.dp, + viewportWidth = 54.031f, + viewportHeight = 55.156f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 20.38f, y = 55.16f) + quadToRelative(dx1 = 2.02f, dy1 = -0.01f, dx2 = 3.15f, dy2 = -1.75f) + lineTo(x = 53.06f, y = 6.9f) + arcToRelative(a = 4.2f, b = 4.2f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.78f, dy1 = -2.32f) + curveToRelative(dx1 = 0.0f, dy1 = -1.71f, dx2 = -1.12f, dy2 = -2.84f, dx3 = -2.84f, dy3 = -2.84f) + curveToRelative(dx1 = -1.25f, dy1 = 0.0f, dx2 = -1.94f, dy2 = 0.4f, dx3 = -2.69f, dy3 = 1.6f) + lineTo(x = 20.25f, y = 48.05f) + lineTo(x = 5.69f, y = 29.0f) + curveTo(x1 = 4.9f, y1 = 27.9f, x2 = 4.12f, y2 = 27.47f, x3 = 3.0f, y3 = 27.47f) + curveToRelative(dx1 = -1.78f, dy1 = 0.0f, dx2 = -3.0f, dy2 = 1.22f, dx3 = -3.0f, dy3 = 2.94f) + arcToRelative(a = 3.8f, b = 3.8f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.9f, dy1 = 2.28f) + lineToRelative(dx = 16.23f, dy = 20.65f) + curveToRelative(dx1 = 0.93f, dy1 = 1.22f, dx2 = 1.9f, dy2 = 1.82f, dx3 = 3.25f, dy3 = 1.82f) + } + }.build().also { _checkmark = it } + } + + private var _checkmark: ImageVector? = null + + val SquareAndArrowDown: ImageVector + get() { + val current = _squareAndArrowDown + if (current != null) return current + + return ImageVector.Builder( + name = "SquareAndArrowDown", + defaultWidth = 55.65599822998047.dp, + defaultHeight = 81.09400177001953.dp, + viewportWidth = 55.656f, + viewportHeight = 81.094f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 55.47f, y = 35.56f) + verticalLineToRelative(dy = 24.07f) + curveToRelative(dx1 = 0.0f, dy1 = 8.4f, dx2 = -4.69f, dy2 = 13.06f, dx3 = -13.1f, dy3 = 13.06f) + horizontalLineToRelative(dx = -29.3f) + curveTo(x1 = 4.65f, y1 = 72.69f, x2 = 0.0f, y2 = 68.03f, x3 = 0.0f, y3 = 59.63f) + verticalLineTo(y = 35.56f) + curveTo(x1 = 0.0f, y1 = 27.2f, x2 = 4.66f, y2 = 22.5f, x3 = 13.06f, y3 = 22.5f) + horizontalLineToRelative(dx = 6.32f) + verticalLineToRelative(dy = 5.03f) + horizontalLineToRelative(dx = -6.32f) + curveToRelative(dx1 = -5.12f, dy1 = 0.0f, dx2 = -8.03f, dy2 = 2.9f, dx3 = -8.03f, dy3 = 8.03f) + verticalLineToRelative(dy = 24.07f) + curveToRelative(dx1 = 0.0f, dy1 = 5.15f, dx2 = 2.9f, dy2 = 8.03f, dx3 = 8.03f, dy3 = 8.03f) + horizontalLineToRelative(dx = 29.32f) + curveToRelative(dx1 = 5.15f, dy1 = 0.0f, dx2 = 8.06f, dy2 = -2.88f, dx3 = 8.06f, dy3 = -8.03f) + verticalLineTo(y = 35.56f) + curveToRelative(dx1 = 0.0f, dy1 = -5.12f, dx2 = -2.9f, dy2 = -8.03f, dx3 = -8.06f, dy3 = -8.03f) + horizontalLineToRelative(dx = -6.29f) + verticalLineTo(y = 22.5f) + horizontalLineToRelative(dx = 6.28f) + curveToRelative(dx1 = 8.41f, dy1 = 0.0f, dx2 = 13.1f, dy2 = 4.69f, dx3 = 13.1f, dy3 = 13.06f) + } + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 27.75f, y = 5.88f) + curveToRelative(dx1 = -1.34f, dy1 = 0.0f, dx2 = -2.5f, dy2 = 1.09f, dx3 = -2.5f, dy3 = 2.4f) + verticalLineTo(y = 40.1f) + lineToRelative(dx = 0.38f, dy = 8.41f) + arcToRelative(a = 2.16f, b = 2.16f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.12f, dy1 = 2.1f) + arcToRelative(a = 2.16f, b = 2.16f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.1f, dy1 = -2.1f) + lineToRelative(dx = 0.37f, dy = -8.4f) + verticalLineTo(y = 8.27f) + curveToRelative(dx1 = 0.0f, dy1 = -1.31f, dx2 = -1.13f, dy2 = -2.4f, dx3 = -2.47f, dy3 = -2.4f) + moveTo(x = 17.13f, y = 37.0f) + arcToRelative(a = 2.15f, b = 2.15f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.25f, dy1 = 2.19f) + arcToRelative(a = 2.2f, b = 2.2f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.74f, dy1 = 1.65f) + lineToRelative(dx = 10.32f, dy = 9.94f) + curveToRelative(dx1 = 0.62f, dy1 = 0.63f, dx2 = 1.15f, dy2 = 0.84f, dx3 = 1.81f, dy3 = 0.84f) + curveToRelative(dx1 = 0.63f, dy1 = 0.0f, dx2 = 1.16f, dy2 = -0.21f, dx3 = 1.78f, dy3 = -0.84f) + lineToRelative(dx = 10.31f, dy = -9.94f) + arcToRelative(a = 2.2f, b = 2.2f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.75f, dy1 = -1.65f) + curveToRelative(dx1 = 0.0f, dy1 = -1.25f, dx2 = -1.0f, dy2 = -2.19f, dx3 = -2.28f, dy3 = -2.19f) + arcToRelative(a = 2.2f, b = 2.2f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -1.69f, dy1 = 0.75f) + lineToRelative(dx = -4.84f, dy = 5.16f) + lineToRelative(dx = -4.03f, dy = 4.3f) + lineToRelative(dx = -4.06f, dy = -4.3f) + lineToRelative(dx = -4.85f, dy = -5.16f) + arcTo(horizontalEllipseRadius = 2.3f, verticalEllipseRadius = 2.3f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 17.13f, y1 = 37.0f) + } + }.build().also { _squareAndArrowDown = it } + } + + private var _squareAndArrowDown: ImageVector? = null + + val SquareAndArrowUp: ImageVector + get() { + val current = _squareAndArrowUp + if (current != null) return current + + return ImageVector.Builder( + name = "SquareAndArrowUp", + defaultWidth = 55.65599822998047.dp, + defaultHeight = 85.84400177001953.dp, + viewportWidth = 55.656f, + viewportHeight = 85.844f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 55.47f, y = 37.94f) + verticalLineTo(y = 62.0f) + curveToRelative(dx1 = 0.0f, dy1 = 8.4f, dx2 = -4.69f, dy2 = 13.06f, dx3 = -13.1f, dy3 = 13.06f) + horizontalLineToRelative(dx = -29.3f) + curveTo(x1 = 4.65f, y1 = 75.06f, x2 = 0.0f, y2 = 70.41f, x3 = 0.0f, y3 = 62.0f) + verticalLineTo(y = 37.94f) + curveToRelative(dx1 = 0.0f, dy1 = -8.38f, dx2 = 4.66f, dy2 = -13.06f, dx3 = 13.06f, dy3 = -13.06f) + horizontalLineToRelative(dx = 6.32f) + verticalLineToRelative(dy = 5.03f) + horizontalLineToRelative(dx = -6.32f) + curveToRelative(dx1 = -5.12f, dy1 = 0.0f, dx2 = -8.03f, dy2 = 2.9f, dx3 = -8.03f, dy3 = 8.03f) + verticalLineTo(y = 62.0f) + curveToRelative(dx1 = 0.0f, dy1 = 5.16f, dx2 = 2.9f, dy2 = 8.03f, dx3 = 8.03f, dy3 = 8.03f) + horizontalLineToRelative(dx = 29.32f) + curveToRelative(dx1 = 5.15f, dy1 = 0.0f, dx2 = 8.06f, dy2 = -2.87f, dx3 = 8.06f, dy3 = -8.03f) + verticalLineTo(y = 37.94f) + curveToRelative(dx1 = 0.0f, dy1 = -5.13f, dx2 = -2.9f, dy2 = -8.03f, dx3 = -8.06f, dy3 = -8.03f) + horizontalLineToRelative(dx = -6.32f) + verticalLineToRelative(dy = -5.03f) + horizontalLineToRelative(dx = 6.31f) + curveToRelative(dx1 = 8.41f, dy1 = 0.0f, dx2 = 13.1f, dy2 = 4.68f, dx3 = 13.1f, dy3 = 13.06f) + } + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 17.13f, y = 19.47f) + curveToRelative(dx1 = 0.59f, dy1 = 0.0f, dx2 = 1.28f, dy2 = -0.25f, dx3 = 1.71f, dy3 = -0.75f) + lineToRelative(dx = 4.85f, dy = -5.16f) + lineToRelative(dx = 4.03f, dy = -4.28f) + lineToRelative(dx = 4.06f, dy = 4.28f) + lineToRelative(dx = 4.81f, dy = 5.16f) + arcToRelative(a = 2.3f, b = 2.3f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 1.7f, dy1 = 0.75f) + curveToRelative(dx1 = 1.3f, dy1 = 0.0f, dx2 = 2.27f, dy2 = -0.9f, dx3 = 2.27f, dy3 = -2.19f) + arcToRelative(a = 2.2f, b = 2.2f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -0.72f, dy1 = -1.66f) + lineTo(x = 29.54f, y = 5.7f) + curveToRelative(dx1 = -0.63f, dy1 = -0.63f, dx2 = -1.16f, dy2 = -0.82f, dx3 = -1.82f, dy3 = -0.82f) + curveToRelative(dx1 = -0.63f, dy1 = 0.0f, dx2 = -1.16f, dy2 = 0.2f, dx3 = -1.81f, dy3 = 0.82f) + lineToRelative(dx = -10.28f, dy = 9.93f) + arcToRelative(a = 2.2f, b = 2.2f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -0.76f, dy1 = 1.66f) + curveToRelative(dx1 = 0.0f, dy1 = 1.28f, dx2 = 0.94f, dy2 = 2.19f, dx3 = 2.26f, dy3 = 2.19f) + moveToRelative(dx = 10.59f, dy = 31.12f) + curveToRelative(dx1 = 1.34f, dy1 = 0.0f, dx2 = 2.5f, dy2 = -1.09f, dx3 = 2.5f, dy3 = -2.4f) + verticalLineTo(y = 16.4f) + lineToRelative(dx = -0.38f, dy = -8.44f) + arcToRelative(a = 2.2f, b = 2.2f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.12f, dy1 = -2.1f) + curveToRelative(dx1 = -1.13f, dy1 = 0.0f, dx2 = -2.03f, dy2 = 0.97f, dx3 = -2.1f, dy3 = 2.1f) + lineToRelative(dx = -0.37f, dy = 8.44f) + verticalLineToRelative(dy = 31.78f) + curveToRelative(dx1 = 0.0f, dy1 = 1.31f, dx2 = 1.13f, dy2 = 2.4f, dx3 = 2.47f, dy3 = 2.4f) + } + }.build().also { _squareAndArrowUp = it } + } + + private var _squareAndArrowUp: ImageVector? = null + + override val BoltCircle: ImageVector + get() { + val current = _boltCircle + if (current != null) return current + + return ImageVector.Builder( + name = "BoltCircle", + defaultWidth = 63.9379997253418.dp, + defaultHeight = 63.78099822998047.dp, + viewportWidth = 63.938f, + viewportHeight = 63.781f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 31.88f, y = 63.75f) + arcToRelative(a = 31.9f, b = 31.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 31.87f, dy1 = -31.87f) + arcTo(horizontalEllipseRadius = 31.9f, verticalEllipseRadius = 31.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 31.88f, y1 = 0.0f) + arcTo(horizontalEllipseRadius = 31.9f, verticalEllipseRadius = 31.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 0.0f, y1 = 31.88f) + arcToRelative(a = 31.9f, b = 31.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 31.88f, dy1 = 31.87f) + moveToRelative(dx = 0.0f, dy = -5.31f) + arcTo(horizontalEllipseRadius = 26.54f, verticalEllipseRadius = 26.54f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, x1 = 5.3f, y1 = 31.88f) + arcTo(horizontalEllipseRadius = 26.54f, verticalEllipseRadius = 26.54f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, x1 = 31.88f, y1 = 5.3f) + arcToRelative(a = 26.54f, b = 26.54f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 26.56f, dy1 = 26.57f) + arcToRelative(a = 26.54f, b = 26.54f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -26.56f, dy1 = 26.56f) + } + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 18.16f, y = 34.03f) + curveToRelative(dx1 = 0.0f, dy1 = 0.78f, dx2 = 0.62f, dy2 = 1.34f, dx3 = 1.43f, dy3 = 1.34f) + horizontalLineToRelative(dx = 10.6f) + lineToRelative(dx = -5.66f, dy = 15.2f) + curveToRelative(dx1 = -0.75f, dy1 = 2.0f, dx2 = 1.38f, dy2 = 3.09f, dx3 = 2.69f, dy3 = 1.46f) + lineToRelative(dx = 17.12f, dy = -21.5f) + arcToRelative(a = 2.0f, b = 2.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.53f, dy1 = -1.22f) + curveToRelative(dx1 = 0.0f, dy1 = -0.78f, dx2 = -0.62f, dy2 = -1.34f, dx3 = -1.43f, dy3 = -1.34f) + horizontalLineToRelative(dx = -10.6f) + lineToRelative(dx = 5.66f, dy = -15.19f) + curveToRelative(dx1 = 0.75f, dy1 = -2.0f, dx2 = -1.37f, dy2 = -3.1f, dx3 = -2.69f, dy3 = -1.5f) + lineTo(x = 18.7f, y = 32.78f) + arcToRelative(a = 2.0f, b = 2.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -0.53f, dy1 = 1.25f) + } + }.build().also { _boltCircle = it } + } + + private var _boltCircle: ImageVector? = null + + override val Circle: ImageVector + get() { + val current = _circle + if (current != null) return current + + return ImageVector.Builder( + name = "Circle", + defaultWidth = 63.9379997253418.dp, + defaultHeight = 63.78099822998047.dp, + viewportWidth = 63.938f, + viewportHeight = 63.781f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 31.88f, y = 63.75f) + arcToRelative(a = 31.9f, b = 31.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 31.87f, dy1 = -31.87f) + arcTo(horizontalEllipseRadius = 31.9f, verticalEllipseRadius = 31.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 31.88f, y1 = 0.0f) + arcTo(horizontalEllipseRadius = 31.9f, verticalEllipseRadius = 31.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 0.0f, y1 = 31.88f) + arcToRelative(a = 31.9f, b = 31.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 31.88f, dy1 = 31.87f) + moveToRelative(dx = 0.0f, dy = -5.31f) + arcTo(horizontalEllipseRadius = 26.54f, verticalEllipseRadius = 26.54f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, x1 = 5.3f, y1 = 31.88f) + arcTo(horizontalEllipseRadius = 26.54f, verticalEllipseRadius = 26.54f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, x1 = 31.88f, y1 = 5.3f) + arcToRelative(a = 26.54f, b = 26.54f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 26.56f, dy1 = 26.57f) + arcToRelative(a = 26.54f, b = 26.54f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -26.56f, dy1 = 26.56f) + } + }.build().also { _circle = it } + } + + private var _circle: ImageVector? = null + + override val CircleDotted: ImageVector + get() = CommonIcons.CircleDotted +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/CommonIcons.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/CommonIcons.kt new file mode 100644 index 00000000..b680b885 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/CommonIcons.kt @@ -0,0 +1,3 @@ +package me.kavishdevar.librepods.presentation.icons + +object CommonIcons diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/IconSet.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/IconSet.kt new file mode 100644 index 00000000..0515c235 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/IconSet.kt @@ -0,0 +1,194 @@ +@file:Suppress("PropertyName") + +package me.kavishdevar.librepods.presentation.icons + +import androidx.compose.ui.graphics.vector.ImageVector +import me.kavishdevar.librepods.presentation.icons.common.airpods.AirPods +import me.kavishdevar.librepods.presentation.icons.common.airpods.AirPods3 +import me.kavishdevar.librepods.presentation.icons.common.airpods.AirPods4 +import me.kavishdevar.librepods.presentation.icons.common.airpods.AirPods4Case +import me.kavishdevar.librepods.presentation.icons.common.airpods.AirPods4CaseFill +import me.kavishdevar.librepods.presentation.icons.common.airpods.AirPods4Left +import me.kavishdevar.librepods.presentation.icons.common.airpods.AirPods4Right +import me.kavishdevar.librepods.presentation.icons.common.airpods.AirPodsCase +import me.kavishdevar.librepods.presentation.icons.common.airpods.AirPodsCaseFill +import me.kavishdevar.librepods.presentation.icons.common.airpods.AirPodsMax +import me.kavishdevar.librepods.presentation.icons.common.airpods.AirPodsPro1 +import me.kavishdevar.librepods.presentation.icons.common.airpods.AirPodsPro1Case +import me.kavishdevar.librepods.presentation.icons.common.airpods.AirPodsPro1CaseFill +import me.kavishdevar.librepods.presentation.icons.common.airpods.AirPodsPro1Left +import me.kavishdevar.librepods.presentation.icons.common.airpods.AirPodsPro1Right +import me.kavishdevar.librepods.presentation.icons.common.airpods.AirPodsPro3 +import me.kavishdevar.librepods.presentation.icons.common.airpods.AirPodsPro3Case +import me.kavishdevar.librepods.presentation.icons.common.airpods.AirPodsPro3CaseFill +import me.kavishdevar.librepods.presentation.icons.common.airpods.AirPodsPro3Left +import me.kavishdevar.librepods.presentation.icons.common.airpods.AirPodsPro3Right +import me.kavishdevar.librepods.presentation.icons.common.airpods.AirPodsWirelessCase + +interface IconSet { + val Notifications: ImageVector + val Headphones: ImageVector + val Play: ImageVector + val Pause: ImageVector + val Bluetooth: ImageVector + val Call: ImageVector + val Overlay: ImageVector + val ArrowBack: ImageVector + val LeftCircleFill: ImageVector + val RightCircleFill: ImageVector + val Settings: ImageVector + val Send: ImageVector + val Close: ImageVector + val CloseCircle: ImageVector + val SpeakerMin: ImageVector + val SpeakerMax: ImageVector + val Bolt: ImageVector + val Check: ImageVector + val ChevronLeft: ImageVector + val ChevronRight: ImageVector + val Save: ImageVector + val Incoming: ImageVector + val Outgoing: ImageVector + + val BoltCircle: ImageVector + val Circle: ImageVector + + val CircleDotted: ImageVector + + /* + * AirPods Icons + */ + + + val AirPods1: ImageVector + get() = CommonIcons.AirPods + val AirPods1Case: ImageVector + get() = CommonIcons.AirPodsCase + val AirPods1CaseFill: ImageVector + get() = CommonIcons.AirPodsCaseFill + + val AirPods2: ImageVector + get() = CommonIcons.AirPods + val AirPods2Case: ImageVector + get() = CommonIcons.AirPodsWirelessCase + val AirPods2CaseFill: ImageVector + get() = CommonIcons.AirPodsCaseFill + + val AirPods3: ImageVector + get() = CommonIcons.AirPods3 + val AirPods3Case: ImageVector + get() = CommonIcons.AirPodsPro3Case + val AirPods3CaseFill: ImageVector + get() = CommonIcons.AirPodsPro3CaseFill + + val AirPods4: ImageVector + get() = CommonIcons.AirPods4 + val AirPods4Left: ImageVector + get() = CommonIcons.AirPods4Left + val AirPods4Right: ImageVector + get() = CommonIcons.AirPods4Right + val AirPods4Case: ImageVector + get() = CommonIcons.AirPods4Case + val AirPods4CaseFill: ImageVector + get() = CommonIcons.AirPods4CaseFill + + val AirPodsPro1: ImageVector + get() = CommonIcons.AirPodsPro1 + val AirPodsPro1Left: ImageVector + get() = CommonIcons.AirPodsPro1Left + val AirPodsPro1Right: ImageVector + get() = CommonIcons.AirPodsPro1Right + val AirPodsPro1Case: ImageVector + get() = CommonIcons.AirPodsPro1Case + val AirPodsPro1CaseFill: ImageVector + get() = CommonIcons.AirPodsPro1CaseFill + + val AirPodsPro2: ImageVector + get() = CommonIcons.AirPodsPro1 + val AirPodsPro2Left: ImageVector + get() = CommonIcons.AirPodsPro1Left + val AirPodsPro2Right: ImageVector + get() = CommonIcons.AirPodsPro1Right + val AirPodsPro2Case: ImageVector + get() = CommonIcons.AirPodsPro1Case + val AirPodsPro2CaseFill: ImageVector + get() = CommonIcons.AirPodsPro1CaseFill + + val AirPodsPro3: ImageVector + get() = CommonIcons.AirPodsPro3 + val AirPodsPro3Left: ImageVector + get() = CommonIcons.AirPodsPro3Left + val AirPodsPro3Right: ImageVector + get() = CommonIcons.AirPodsPro3Right + val AirPodsPro3Case: ImageVector + get() = CommonIcons.AirPodsPro3Case + val AirPodsPro3CaseFill: ImageVector + get() = CommonIcons.AirPodsPro3CaseFill + + val AirPodsMax: ImageVector + get() = CommonIcons.AirPodsMax + + val IconMap: Map + get() = mapOf( + "Notifications" to Notifications, + "Headphones" to Headphones, + "Play" to Play, + "Pause" to Pause, + "Bluetooth" to Bluetooth, + "Overlay" to Overlay, + "ArrowBack" to ArrowBack, + "LeftCircleFill" to LeftCircleFill, + "RightCircleFill" to RightCircleFill, + "Settings" to Settings, + "Send" to Send, + "Close" to Close, + "CloseCircle" to CloseCircle, + "SpeakerMin" to SpeakerMin, + "SpeakerMax" to SpeakerMax, + "Bolt" to Bolt, + "Check" to Check, + "ChevronLeft" to ChevronLeft, + "ChevronRight" to ChevronRight, + "Save" to Save, + "Incoming" to Incoming, + "Outgoing" to Outgoing, + "BoltCircle" to BoltCircle, + "Circle" to Circle, + "CircleDotted" to CircleDotted, + + "AirPods1" to AirPods1, + "AirPods1Case" to AirPods1Case, + "AirPods1CaseFill" to AirPods1CaseFill, + "AirPods2" to AirPods2, + "AirPods2Case" to AirPods2Case, + "AirPods2CaseFill" to AirPods2CaseFill, + "AirPods3" to AirPods3, + "AirPods3Case" to AirPods3Case, + "AirPods3CaseFill" to AirPods3CaseFill, + "AirPods4" to AirPods4, + "AirPods4Left" to AirPods4Left, + "AirPods4Right" to AirPods4Right, + "AirPods4Case" to AirPods4Case, + "AirPods4CaseFill" to AirPods4CaseFill, + "AirPodsPro1" to AirPodsPro1, + "AirPodsPro1Left" to AirPodsPro1Left, + "AirPodsPro1Right" to AirPodsPro1Right, + "AirPodsPro1Case" to AirPodsPro1Case, + "AirPodsPro1CaseFill" to AirPodsPro1CaseFill, + "AirPodsPro2" to AirPodsPro2, + "AirPodsPro2Left" to AirPodsPro2Left, + "AirPodsPro2Right" to AirPodsPro2Right, + "AirPodsPro2Case" to AirPodsPro2Case, + "AirPodsPro2CaseFill" to AirPodsPro2CaseFill, + "AirPodsPro3" to AirPodsPro3, + "AirPodsPro3Left" to AirPodsPro3Left, + "AirPodsPro3Right" to AirPodsPro3Right, + "AirPodsPro3Case" to AirPodsPro3Case, + "AirPodsPro3CaseFill" to AirPodsPro3CaseFill, + "AirPodsMax" to AirPodsMax, + ) + + fun fromName(name: String): ImageVector? { + return IconMap[name] + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/LocalIcons.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/LocalIcons.kt new file mode 100644 index 00000000..4e110d11 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/LocalIcons.kt @@ -0,0 +1,7 @@ +package me.kavishdevar.librepods.presentation.icons + +import androidx.compose.runtime.compositionLocalOf + +val LocalIcons = compositionLocalOf { + MaterialIcons +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/MaterialIcons.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/MaterialIcons.kt new file mode 100644 index 00000000..c8877b3d --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/MaterialIcons.kt @@ -0,0 +1,1661 @@ +package me.kavishdevar.librepods.presentation.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.common.CircleDotted +import me.kavishdevar.librepods.presentation.icons.common.LeftCircleFill +import me.kavishdevar.librepods.presentation.icons.common.RightCircleFill + +object MaterialIcons: IconSet { + // Material Icons don't scale like Apple's. so we need to scale up all but Apple's + fun isAppleIcon(name: String): Boolean { + return when (name) { + "CircleDotted", "LeftCircleFill", "RightCircleFill" -> true + else -> name.startsWith("AirPods") + } + } + + override val Notifications: ImageVector + get() { + if (_notifications != null) { + return _notifications!! + } + _notifications = + ImageVector.Builder( + name = "notifications", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(4f, 19f) + verticalLineTo(17f) + horizontalLineTo(6f) + verticalLineTo(10f) + quadTo(6f, 7.93f, 7.25f, 6.31f) + reflectiveQuadTo(10.5f, 4.2f) + verticalLineTo(3.5f) + quadToRelative(0f, -0.63f, 0.44f, -1.06f) + reflectiveQuadTo(12f, 2f) + reflectiveQuadToRelative(1.06f, 0.44f) + reflectiveQuadTo(13.5f, 3.5f) + verticalLineTo(4.2f) + quadToRelative(2f, 0.5f, 3.25f, 2.11f) + reflectiveQuadTo(18f, 10f) + verticalLineToRelative(7f) + horizontalLineToRelative(2f) + verticalLineToRelative(2f) + horizontalLineTo(4f) + close() + moveToRelative(8f, -7.5f) + close() + moveTo(12f, 22f) + quadToRelative(-0.82f, 0f, -1.41f, -0.59f) + reflectiveQuadTo(10f, 20f) + horizontalLineToRelative(4f) + quadToRelative(0f, 0.82f, -0.59f, 1.41f) + reflectiveQuadTo(12f, 22f) + close() + moveTo(8f, 17f) + horizontalLineToRelative(8f) + verticalLineTo(10f) + quadTo(16f, 8.35f, 14.83f, 7.18f) + reflectiveQuadTo(12f, 6f) + reflectiveQuadTo(9.18f, 7.18f) + reflectiveQuadTo(8f, 10f) + verticalLineToRelative(7f) + close() + } + } + .build() + return _notifications!! + } + + private var _notifications: ImageVector? = null + + + override val Headphones: ImageVector + get() { + if (_headphones != null) { + return _headphones!! + } + _headphones = + ImageVector.Builder( + name = "headphones", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.Companion.NonZero, + ) { + moveTo(9f, 21f) + horizontalLineTo(5f) + quadTo(4.18f, 21f, 3.59f, 20.41f) + reflectiveQuadTo(3f, 19f) + verticalLineTo(12f) + quadTo(3f, 10.13f, 3.71f, 8.49f) + reflectiveQuadTo(5.64f, 5.64f) + quadTo(6.85f, 4.42f, 8.49f, 3.71f) + reflectiveQuadTo(12f, 3f) + reflectiveQuadToRelative(3.51f, 0.71f) + reflectiveQuadToRelative(2.85f, 1.93f) + reflectiveQuadToRelative(1.93f, 2.85f) + reflectiveQuadTo(21f, 12f) + verticalLineToRelative(7f) + quadToRelative(0f, 0.82f, -0.59f, 1.41f) + reflectiveQuadTo(19f, 21f) + horizontalLineTo(15f) + verticalLineTo(13f) + horizontalLineToRelative(4f) + verticalLineTo(12f) + quadTo(19f, 9.07f, 16.96f, 7.04f) + reflectiveQuadTo(12f, 5f) + quadTo(9.08f, 5f, 7.04f, 7.04f) + reflectiveQuadTo(5f, 12f) + verticalLineToRelative(1f) + horizontalLineTo(9f) + verticalLineToRelative(8f) + close() + moveTo(7f, 15f) + horizontalLineTo(5f) + verticalLineToRelative(4f) + horizontalLineTo(7f) + verticalLineTo(15f) + close() + moveToRelative(10f, 0f) + verticalLineToRelative(4f) + horizontalLineToRelative(2f) + verticalLineTo(15f) + horizontalLineTo(17f) + close() + moveTo(7f, 15f) + horizontalLineTo(5f) + horizontalLineTo(7f) + close() + moveToRelative(10f, 0f) + horizontalLineToRelative(2f) + horizontalLineTo(17f) + close() + } + } + .build() + return _headphones!! + } + + private var _headphones: ImageVector? = null + + override val Play: ImageVector + get() = PlayArrow + + override val Pause: ImageVector + get() { + if (_pause != null) { + return _pause!! + } + _pause = + ImageVector.Builder( + name = "pause", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(13f, 19f) + verticalLineTo(5f) + horizontalLineToRelative(6f) + verticalLineTo(19f) + horizontalLineTo(13f) + close() + moveTo(5f, 19f) + verticalLineTo(5f) + horizontalLineToRelative(6f) + verticalLineTo(19f) + horizontalLineTo(5f) + close() + moveTo(15f, 17f) + horizontalLineToRelative(2f) + verticalLineTo(7f) + horizontalLineTo(15f) + verticalLineTo(17f) + close() + moveTo(7f, 17f) + horizontalLineTo(9f) + verticalLineTo(7f) + horizontalLineTo(7f) + verticalLineTo(17f) + close() + moveTo(7f, 7f) + verticalLineTo(17f) + verticalLineTo(7f) + close() + moveToRelative(8f, 0f) + verticalLineTo(17f) + verticalLineTo(7f) + close() + } + } + .build() + return _pause!! + } + + private var _pause: ImageVector? = null + + override val Bluetooth: ImageVector + get() { + if (_bluetooth != null) { + return _bluetooth!! + } + _bluetooth = + ImageVector.Builder( + name = "bluetooth", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(11f, 22f) + verticalLineTo(14.4f) + lineTo(6.4f, 19f) + lineTo(5f, 17.6f) + lineTo(10.6f, 12f) + lineTo(5f, 6.4f) + lineTo(6.4f, 5f) + lineTo(11f, 9.6f) + verticalLineTo(2f) + horizontalLineToRelative(1f) + lineToRelative(5.7f, 5.7f) + lineTo(13.4f, 12f) + lineToRelative(4.3f, 4.3f) + lineTo(12f, 22f) + horizontalLineTo(11f) + close() + moveTo(13f, 9.6f) + lineTo(14.9f, 7.7f) + lineTo(13f, 5.85f) + verticalLineTo(9.6f) + close() + moveToRelative(0f, 8.55f) + lineTo(14.9f, 16.3f) + lineTo(13f, 14.4f) + verticalLineToRelative(3.75f) + close() + } + } + .build() + return _bluetooth!! + } + + private var _bluetooth: ImageVector? = null + override val Call: ImageVector + get() { + if (_call != null) { + return _call!! + } + _call = + ImageVector.Builder( + name = "call", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(19.95f, 21f) + quadToRelative(-3.13f, 0f, -6.18f, -1.36f) + reflectiveQuadTo(8.23f, 15.78f) + quadTo(5.73f, 13.27f, 4.36f, 10.23f) + reflectiveQuadTo(3f, 4.05f) + quadTo(3f, 3.6f, 3.3f, 3.3f) + reflectiveQuadTo(4.05f, 3f) + horizontalLineTo(8.1f) + quadTo(8.45f, 3f, 8.73f, 3.24f) + reflectiveQuadTo(9.05f, 3.8f) + lineTo(9.7f, 7.3f) + quadTo(9.75f, 7.7f, 9.68f, 7.97f) + reflectiveQuadTo(9.4f, 8.45f) + lineTo(6.98f, 10.9f) + quadToRelative(0.5f, 0.93f, 1.19f, 1.79f) + reflectiveQuadToRelative(1.51f, 1.66f) + quadToRelative(0.78f, 0.78f, 1.63f, 1.44f) + reflectiveQuadTo(13.1f, 17f) + lineToRelative(2.35f, -2.35f) + quadToRelative(0.22f, -0.23f, 0.59f, -0.34f) + reflectiveQuadToRelative(0.71f, -0.06f) + lineToRelative(3.45f, 0.7f) + quadToRelative(0.35f, 0.1f, 0.57f, 0.36f) + reflectiveQuadTo(21f, 15.9f) + verticalLineToRelative(4.05f) + quadToRelative(0f, 0.45f, -0.3f, 0.75f) + reflectiveQuadTo(19.95f, 21f) + close() + moveTo(6.03f, 9f) + lineTo(7.68f, 7.35f) + lineTo(7.25f, 5f) + horizontalLineTo(5.03f) + quadTo(5.15f, 6.02f, 5.38f, 7.02f) + reflectiveQuadTo(6.03f, 9f) + close() + moveToRelative(8.95f, 8.95f) + quadToRelative(0.97f, 0.43f, 1.99f, 0.68f) + reflectiveQuadTo(19f, 18.95f) + verticalLineToRelative(-2.2f) + lineTo(16.65f, 16.27f) + lineToRelative(-1.68f, 1.68f) + close() + moveTo(6.03f, 9f) + close() + moveToRelative(8.95f, 8.95f) + close() + } + } + .build() + return _call!! + } + + private var _call: ImageVector? = null + + override val Overlay: ImageVector + get() = Stack + + val Stack: ImageVector + get() { + if (_stack != null) { + return _stack!! + } + _stack = + ImageVector.Builder( + name = "stack", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(6f, 14f) + verticalLineToRelative(2f) + horizontalLineTo(4f) + quadTo(3.18f, 16f, 2.59f, 15.41f) + reflectiveQuadTo(2f, 14f) + verticalLineTo(4f) + quadTo(2f, 3.17f, 2.59f, 2.59f) + reflectiveQuadTo(4f, 2f) + horizontalLineTo(14f) + quadToRelative(0.83f, 0f, 1.41f, 0.59f) + reflectiveQuadTo(16f, 4f) + verticalLineTo(6f) + horizontalLineTo(14f) + verticalLineTo(4f) + horizontalLineTo(4f) + verticalLineTo(14f) + horizontalLineTo(6f) + close() + moveToRelative(4f, 8f) + quadTo(9.18f, 22f, 8.59f, 21.41f) + reflectiveQuadTo(8f, 20f) + verticalLineTo(10f) + quadTo(8f, 9.17f, 8.59f, 8.59f) + reflectiveQuadTo(10f, 8f) + horizontalLineTo(20f) + quadToRelative(0.83f, 0f, 1.41f, 0.59f) + reflectiveQuadTo(22f, 10f) + verticalLineTo(20f) + quadToRelative(0f, 0.82f, -0.59f, 1.41f) + reflectiveQuadTo(20f, 22f) + horizontalLineTo(10f) + close() + moveToRelative(0f, -2f) + horizontalLineTo(20f) + verticalLineTo(10f) + horizontalLineTo(10f) + verticalLineTo(20f) + close() + moveToRelative(5f, -5f) + close() + } + } + .build() + return _stack!! + } + + private var _stack: ImageVector? = null + + override val ArrowBack: ImageVector + get() { + if (_arrow_back != null) { + return _arrow_back!! + } + _arrow_back = + ImageVector.Builder( + name = "arrow_back", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(7.83f, 13f) + lineToRelative(4.9f, 4.9f) + quadToRelative(0.3f, 0.3f, 0.29f, 0.7f) + reflectiveQuadTo(12.7f, 19.3f) + quadTo(12.4f, 19.58f, 12f, 19.59f) + reflectiveQuadTo(11.3f, 19.3f) + lineTo(4.7f, 12.7f) + quadTo(4.55f, 12.55f, 4.49f, 12.38f) + reflectiveQuadTo(4.43f, 12f) + reflectiveQuadTo(4.49f, 11.63f) + reflectiveQuadTo(4.7f, 11.3f) + lineTo(11.3f, 4.7f) + quadTo(11.58f, 4.42f, 11.99f, 4.42f) + reflectiveQuadTo(12.7f, 4.7f) + quadTo(13f, 5f, 13f, 5.41f) + reflectiveQuadTo(12.7f, 6.13f) + lineTo(7.83f, 11f) + horizontalLineTo(19f) + quadToRelative(0.43f, 0f, 0.71f, 0.29f) + reflectiveQuadTo(20f, 12f) + reflectiveQuadToRelative(-0.29f, 0.71f) + reflectiveQuadTo(19f, 13f) + horizontalLineTo(7.83f) + close() + } + } + .build() + return _arrow_back!! + } + + private var _arrow_back: ImageVector? = null + + override val LeftCircleFill: ImageVector + get() = CommonIcons.LeftCircleFill + + override val RightCircleFill: ImageVector + get() = CommonIcons.RightCircleFill + + override val Settings: ImageVector + get() { + if (_settings != null) { + return _settings!! + } + _settings = + ImageVector.Builder( + name = "settings", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(10.83f, 22f) + quadTo(10.15f, 22f, 9.66f, 21.55f) + reflectiveQuadTo(9.08f, 20.45f) + lineTo(8.85f, 18.8f) + quadTo(8.53f, 18.68f, 8.24f, 18.5f) + reflectiveQuadTo(7.68f, 18.13f) + lineTo(6.13f, 18.77f) + quadTo(5.5f, 19.05f, 4.88f, 18.83f) + reflectiveQuadTo(3.9f, 18.02f) + lineTo(2.73f, 15.98f) + quadTo(2.38f, 15.4f, 2.53f, 14.75f) + reflectiveQuadTo(3.2f, 13.68f) + lineToRelative(1.33f, -1f) + quadTo(4.5f, 12.5f, 4.5f, 12.34f) + quadToRelative(0f, -0.16f, 0f, -0.34f) + reflectiveQuadToRelative(0f, -0.34f) + reflectiveQuadTo(4.53f, 11.33f) + lineToRelative(-1.33f, -1f) + quadTo(2.68f, 9.9f, 2.53f, 9.25f) + reflectiveQuadTo(2.73f, 8.02f) + lineTo(3.9f, 5.97f) + quadTo(4.25f, 5.4f, 4.88f, 5.18f) + reflectiveQuadTo(6.13f, 5.22f) + lineTo(7.68f, 5.88f) + quadTo(7.95f, 5.68f, 8.25f, 5.5f) + reflectiveQuadTo(8.85f, 5.2f) + lineTo(9.08f, 3.55f) + quadTo(9.18f, 2.9f, 9.66f, 2.45f) + reflectiveQuadTo(10.83f, 2f) + horizontalLineToRelative(2.35f) + quadToRelative(0.68f, 0f, 1.16f, 0.45f) + reflectiveQuadToRelative(0.59f, 1.1f) + lineTo(15.15f, 5.2f) + quadToRelative(0.33f, 0.13f, 0.61f, 0.3f) + reflectiveQuadToRelative(0.56f, 0.38f) + lineTo(17.88f, 5.22f) + quadTo(18.5f, 4.95f, 19.13f, 5.18f) + reflectiveQuadToRelative(0.98f, 0.8f) + lineToRelative(1.18f, 2.05f) + quadToRelative(0.35f, 0.58f, 0.2f, 1.23f) + reflectiveQuadTo(20.8f, 10.33f) + lineToRelative(-1.32f, 1f) + quadToRelative(0.02f, 0.18f, 0.02f, 0.34f) + reflectiveQuadToRelative(0f, 0.34f) + reflectiveQuadToRelative(0f, 0.34f) + reflectiveQuadToRelative(-0.05f, 0.34f) + lineToRelative(1.32f, 1f) + quadToRelative(0.52f, 0.43f, 0.68f, 1.08f) + reflectiveQuadToRelative(-0.2f, 1.22f) + lineToRelative(-1.2f, 2.05f) + quadToRelative(-0.35f, 0.58f, -0.98f, 0.8f) + reflectiveQuadTo(17.83f, 18.77f) + lineToRelative(-1.5f, -0.65f) + quadToRelative(-0.27f, 0.2f, -0.57f, 0.38f) + reflectiveQuadToRelative(-0.6f, 0.3f) + lineToRelative(-0.22f, 1.65f) + quadToRelative(-0.1f, 0.65f, -0.59f, 1.1f) + reflectiveQuadTo(13.18f, 22f) + horizontalLineTo(10.83f) + close() + moveTo(11f, 20f) + horizontalLineToRelative(1.98f) + lineToRelative(0.35f, -2.65f) + quadToRelative(0.78f, -0.2f, 1.44f, -0.59f) + reflectiveQuadToRelative(1.21f, -0.94f) + lineToRelative(2.47f, 1.03f) + lineToRelative(0.98f, -1.7f) + lineTo(17.28f, 13.52f) + quadToRelative(0.13f, -0.35f, 0.17f, -0.74f) + reflectiveQuadTo(17.5f, 12f) + reflectiveQuadTo(17.45f, 11.21f) + quadTo(17.4f, 10.83f, 17.28f, 10.48f) + lineTo(19.43f, 8.85f) + lineTo(18.45f, 7.15f) + lineTo(15.98f, 8.2f) + quadTo(15.43f, 7.63f, 14.76f, 7.24f) + reflectiveQuadTo(13.33f, 6.65f) + lineTo(13f, 4f) + horizontalLineTo(11.03f) + lineTo(10.68f, 6.65f) + quadTo(9.9f, 6.85f, 9.24f, 7.24f) + reflectiveQuadTo(8.03f, 8.17f) + lineTo(5.55f, 7.15f) + lineTo(4.58f, 8.85f) + lineToRelative(2.15f, 1.6f) + quadTo(6.6f, 10.83f, 6.55f, 11.2f) + reflectiveQuadTo(6.5f, 12f) + quadToRelative(0f, 0.4f, 0.05f, 0.77f) + reflectiveQuadToRelative(0.17f, 0.75f) + lineTo(4.58f, 15.15f) + lineToRelative(0.98f, 1.7f) + lineTo(8.03f, 15.8f) + quadToRelative(0.55f, 0.58f, 1.21f, 0.96f) + reflectiveQuadToRelative(1.44f, 0.59f) + lineTo(11f, 20f) + close() + moveToRelative(1.05f, -4.5f) + quadToRelative(1.45f, 0f, 2.47f, -1.03f) + reflectiveQuadTo(15.55f, 12f) + reflectiveQuadTo(14.53f, 9.52f) + reflectiveQuadTo(12.05f, 8.5f) + quadToRelative(-1.47f, 0f, -2.49f, 1.02f) + reflectiveQuadTo(8.55f, 12f) + reflectiveQuadToRelative(1.01f, 2.47f) + reflectiveQuadToRelative(2.49f, 1.03f) + close() + moveTo(12f, 12f) + close() + } + } + .build() + return _settings!! + } + + private var _settings: ImageVector? = null + + val PlayArrow: ImageVector + get() { + if (_play_arrow != null) { + return _play_arrow!! + } + _play_arrow = + ImageVector.Builder( + name = "play_arrow", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(8f, 17.18f) + verticalLineTo(6.82f) + quadTo(8f, 6.4f, 8.3f, 6.11f) + quadTo(8.6f, 5.82f, 9f, 5.82f) + quadToRelative(0.13f, 0f, 0.26f, 0.04f) + reflectiveQuadTo(9.53f, 5.97f) + lineToRelative(8.15f, 5.18f) + quadToRelative(0.23f, 0.15f, 0.34f, 0.38f) + quadToRelative(0.11f, 0.23f, 0.11f, 0.48f) + reflectiveQuadToRelative(-0.11f, 0.47f) + reflectiveQuadToRelative(-0.34f, 0.38f) + lineTo(9.53f, 18.02f) + quadTo(9.4f, 18.1f, 9.26f, 18.14f) + quadTo(9.13f, 18.18f, 9f, 18.18f) + quadToRelative(-0.4f, 0f, -0.7f, -0.29f) + reflectiveQuadTo(8f, 17.18f) + close() + moveTo(10f, 12f) + close() + moveToRelative(0f, 3.35f) + lineTo(15.25f, 12f) + lineTo(10f, 8.65f) + verticalLineToRelative(6.7f) + close() + } + } + .build() + return _play_arrow!! + } + + private var _play_arrow: ImageVector? = null + + override val Send: ImageVector + get() { + if (_send != null) { + return _send!! + } + _send = + ImageVector.Builder( + name = "send", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(19.8f, 12.93f) + lineTo(4.4f, 19.43f) + quadTo(3.9f, 19.63f, 3.45f, 19.34f) + reflectiveQuadTo(3f, 18.5f) + verticalLineTo(5.5f) + quadTo(3f, 4.95f, 3.45f, 4.66f) + quadTo(3.9f, 4.38f, 4.4f, 4.57f) + lineToRelative(15.4f, 6.5f) + quadToRelative(0.63f, 0.28f, 0.63f, 0.93f) + reflectiveQuadTo(19.8f, 12.93f) + close() + moveTo(5f, 17f) + lineTo(16.85f, 12f) + lineTo(5f, 7f) + verticalLineToRelative(3.5f) + lineTo(11f, 12f) + lineTo(5f, 13.5f) + verticalLineTo(17f) + close() + moveToRelative(0f, 0f) + verticalLineTo(12f) + verticalLineTo(7f) + verticalLineToRelative(3.5f) + verticalLineToRelative(3f) + verticalLineTo(17f) + close() + } + } + .build() + return _send!! + } + + private var _send: ImageVector? = null + + override val Close: ImageVector + get() { + if (_close != null) { + return _close!! + } + _close = + ImageVector.Builder( + name = "close", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(12f, 13.4f) + lineTo(7.1f, 18.3f) + quadTo(6.83f, 18.58f, 6.4f, 18.58f) + reflectiveQuadTo(5.7f, 18.3f) + quadTo(5.43f, 18.02f, 5.43f, 17.6f) + reflectiveQuadTo(5.7f, 16.9f) + lineTo(10.6f, 12f) + lineTo(5.7f, 7.1f) + quadTo(5.43f, 6.82f, 5.43f, 6.4f) + reflectiveQuadTo(5.7f, 5.7f) + reflectiveQuadTo(6.4f, 5.43f) + reflectiveQuadTo(7.1f, 5.7f) + lineTo(12f, 10.6f) + lineTo(16.9f, 5.7f) + quadTo(17.18f, 5.43f, 17.6f, 5.43f) + reflectiveQuadTo(18.3f, 5.7f) + reflectiveQuadToRelative(0.27f, 0.7f) + reflectiveQuadTo(18.3f, 7.1f) + lineTo(13.4f, 12f) + lineToRelative(4.9f, 4.9f) + quadToRelative(0.27f, 0.28f, 0.27f, 0.7f) + quadToRelative(0f, 0.42f, -0.27f, 0.7f) + reflectiveQuadToRelative(-0.7f, 0.27f) + reflectiveQuadTo(16.9f, 18.3f) + lineTo(12f, 13.4f) + close() + } + } + .build() + return _close!! + } + + private var _close: ImageVector? = null + + override val CloseCircle: ImageVector + get() = Cancel + + override val SpeakerMin: ImageVector + get() = VolumeMute + + override val SpeakerMax: ImageVector + get() = VolumeUp + + val VolumeMute: ImageVector + get() { + if (_volumeMute != null) { + return _volumeMute!! + } + _volumeMute = + ImageVector.Builder( + name = "volume_mute", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(11f, 15f) + horizontalLineTo(8f) + quadTo(7.58f, 15f, 7.29f, 14.71f) + reflectiveQuadTo(7f, 14f) + verticalLineTo(10f) + quadTo(7f, 9.57f, 7.29f, 9.29f) + reflectiveQuadTo(8f, 9f) + horizontalLineToRelative(3f) + lineTo(14.3f, 5.7f) + quadTo(14.78f, 5.22f, 15.39f, 5.49f) + reflectiveQuadTo(16f, 6.43f) + verticalLineTo(17.58f) + quadToRelative(0f, 0.68f, -0.61f, 0.94f) + reflectiveQuadTo(14.3f, 18.3f) + lineTo(11f, 15f) + close() + moveTo(9f, 13f) + horizontalLineToRelative(2.85f) + lineTo(14f, 15.15f) + verticalLineTo(8.85f) + lineTo(11.85f, 11f) + horizontalLineTo(9f) + verticalLineToRelative(2f) + close() + moveToRelative(2.5f, -1f) + close() + } + } + .build() + return _volumeMute!! + } + + private var _volumeMute: ImageVector? = null + + val VolumeUp: ImageVector + get() { + if (_volumeUp != null) { + return _volumeUp!! + } + _volumeUp = + ImageVector.Builder( + name = "volume_up", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(19f, 11.98f) + quadTo(19f, 9.9f, 17.9f, 8.19f) + quadTo(16.8f, 6.47f, 14.95f, 5.63f) + quadTo(14.58f, 5.45f, 14.4f, 5.09f) + reflectiveQuadTo(14.35f, 4.35f) + quadTo(14.5f, 3.95f, 14.89f, 3.77f) + reflectiveQuadToRelative(0.79f, 0f) + quadToRelative(2.43f, 1.07f, 3.88f, 3.29f) + quadTo(21f, 9.27f, 21f, 11.98f) + reflectiveQuadToRelative(-1.45f, 4.91f) + reflectiveQuadToRelative(-3.88f, 3.29f) + quadToRelative(-0.4f, 0.18f, -0.79f, 0f) + reflectiveQuadTo(14.35f, 19.6f) + quadTo(14.23f, 19.23f, 14.4f, 18.86f) + reflectiveQuadToRelative(0.55f, -0.54f) + quadTo(16.8f, 17.48f, 17.9f, 15.76f) + reflectiveQuadTo(19f, 11.98f) + close() + moveTo(7f, 15f) + horizontalLineTo(4f) + quadTo(3.58f, 15f, 3.29f, 14.71f) + reflectiveQuadTo(3f, 14f) + verticalLineTo(10f) + quadTo(3f, 9.57f, 3.29f, 9.29f) + reflectiveQuadTo(4f, 9f) + horizontalLineTo(7f) + lineTo(10.3f, 5.7f) + quadTo(10.78f, 5.22f, 11.39f, 5.49f) + reflectiveQuadTo(12f, 6.43f) + verticalLineTo(17.58f) + quadToRelative(0f, 0.68f, -0.61f, 0.94f) + reflectiveQuadTo(10.3f, 18.3f) + lineTo(7f, 15f) + close() + moveToRelative(9.5f, -3f) + quadToRelative(0f, 1.05f, -0.47f, 1.99f) + reflectiveQuadToRelative(-1.25f, 1.54f) + quadToRelative(-0.25f, 0.15f, -0.51f, 0.01f) + reflectiveQuadTo(14f, 15.1f) + verticalLineTo(8.85f) + quadToRelative(0f, -0.3f, 0.26f, -0.44f) + quadToRelative(0.26f, -0.14f, 0.51f, 0.01f) + quadTo(15.55f, 9.05f, 16.03f, 10f) + reflectiveQuadToRelative(0.47f, 2f) + close() + moveTo(10f, 8.85f) + lineTo(7.85f, 11f) + horizontalLineTo(5f) + verticalLineToRelative(2f) + horizontalLineTo(7.85f) + lineTo(10f, 15.15f) + verticalLineTo(8.85f) + close() + moveTo(7.5f, 12f) + close() + } + } + .build() + return _volumeUp!! + } + + private var _volumeUp: ImageVector? = null + + override val Bolt: ImageVector + get() { + if (_bolt != null) { + return _bolt!! + } + _bolt = + ImageVector.Builder( + name = "bolt", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(10.55f, 18.2f) + lineTo(15.73f, 12f) + horizontalLineToRelative(-4f) + lineTo(12.45f, 6.32f) + lineTo(7.83f, 13f) + horizontalLineTo(11.3f) + lineToRelative(-0.75f, 5.2f) + close() + moveTo(9f, 15f) + horizontalLineTo(5.9f) + quadTo(5.3f, 15f, 5.01f, 14.46f) + quadTo(4.73f, 13.93f, 5.08f, 13.43f) + lineTo(12.55f, 2.67f) + quadTo(12.8f, 2.32f, 13.2f, 2.19f) + reflectiveQuadTo(14.03f, 2.2f) + reflectiveQuadToRelative(0.63f, 0.52f) + reflectiveQuadToRelative(0.15f, 0.8f) + lineTo(14f, 10f) + horizontalLineToRelative(3.88f) + quadToRelative(0.65f, 0f, 0.91f, 0.57f) + reflectiveQuadToRelative(-0.16f, 1.07f) + lineTo(10.4f, 21.5f) + quadToRelative(-0.28f, 0.32f, -0.67f, 0.43f) + reflectiveQuadTo(8.95f, 21.85f) + reflectiveQuadTo(8.36f, 21.31f) + reflectiveQuadTo(8.2f, 20.53f) + lineTo(9f, 15f) + close() + moveToRelative(2.78f, -2.75f) + close() + } + } + .build() + return _bolt!! + } + + private var _bolt: ImageVector? = null + + val Cancel: ImageVector + get() { + if (_cancel != null) { + return _cancel!! + } + _cancel = + ImageVector.Builder( + name = "cancel", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(12f, 13.4f) + lineToRelative(2.9f, 2.9f) + quadToRelative(0.28f, 0.27f, 0.7f, 0.27f) + reflectiveQuadTo(16.3f, 16.3f) + quadToRelative(0.27f, -0.28f, 0.27f, -0.7f) + reflectiveQuadTo(16.3f, 14.9f) + lineTo(13.4f, 12f) + lineTo(16.3f, 9.1f) + quadTo(16.58f, 8.82f, 16.58f, 8.4f) + reflectiveQuadTo(16.3f, 7.7f) + reflectiveQuadTo(15.6f, 7.43f) + reflectiveQuadTo(14.9f, 7.7f) + lineTo(12f, 10.6f) + lineTo(9.1f, 7.7f) + quadTo(8.83f, 7.43f, 8.4f, 7.43f) + reflectiveQuadTo(7.7f, 7.7f) + reflectiveQuadTo(7.43f, 8.4f) + reflectiveQuadTo(7.7f, 9.1f) + lineTo(10.6f, 12f) + lineTo(7.7f, 14.9f) + quadTo(7.43f, 15.18f, 7.43f, 15.6f) + reflectiveQuadTo(7.7f, 16.3f) + reflectiveQuadToRelative(0.7f, 0.27f) + quadToRelative(0.43f, 0f, 0.7f, -0.27f) + lineTo(12f, 13.4f) + close() + moveTo(12f, 22f) + quadTo(9.93f, 22f, 8.1f, 21.21f) + quadTo(6.28f, 20.43f, 4.93f, 19.08f) + quadTo(3.58f, 17.73f, 2.79f, 15.9f) + reflectiveQuadTo(2f, 12f) + quadTo(2f, 9.92f, 2.79f, 8.1f) + quadTo(3.58f, 6.27f, 4.93f, 4.93f) + quadTo(6.28f, 3.57f, 8.1f, 2.79f) + quadTo(9.93f, 2f, 12f, 2f) + reflectiveQuadToRelative(3.9f, 0.79f) + reflectiveQuadToRelative(3.17f, 2.14f) + quadToRelative(1.35f, 1.35f, 2.14f, 3.17f) + quadTo(22f, 9.92f, 22f, 12f) + reflectiveQuadToRelative(-0.79f, 3.9f) + reflectiveQuadToRelative(-2.14f, 3.17f) + quadToRelative(-1.35f, 1.35f, -3.17f, 2.14f) + reflectiveQuadTo(12f, 22f) + close() + moveToRelative(0f, -2f) + quadToRelative(3.35f, 0f, 5.68f, -2.32f) + reflectiveQuadTo(20f, 12f) + reflectiveQuadTo(17.68f, 6.32f) + reflectiveQuadTo(12f, 4f) + reflectiveQuadTo(6.33f, 6.32f) + reflectiveQuadTo(4f, 12f) + reflectiveQuadToRelative(2.33f, 5.68f) + reflectiveQuadTo(12f, 20f) + close() + moveToRelative(0f, -8f) + close() + } + } + .build() + return _cancel!! + } + + private var _cancel: ImageVector? = null + + override val Check: ImageVector + get() { + if (_check != null) { + return _check!! + } + _check = + ImageVector.Builder( + name = "check", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.Companion.NonZero, + ) { + moveTo(9.55f, 15.15f) + lineTo(18.03f, 6.68f) + quadToRelative(0.3f, -0.3f, 0.7f, -0.3f) + reflectiveQuadToRelative(0.7f, 0.3f) + quadToRelative(0.3f, 0.3f, 0.3f, 0.71f) + reflectiveQuadTo(19.43f, 8.1f) + lineToRelative(-9.18f, 9.2f) + quadToRelative(-0.3f, 0.3f, -0.7f, 0.3f) + reflectiveQuadTo(8.85f, 17.3f) + lineTo(4.55f, 13f) + quadTo(4.25f, 12.7f, 4.26f, 12.29f) + reflectiveQuadTo(4.58f, 11.58f) + reflectiveQuadToRelative(0.71f, -0.3f) + reflectiveQuadTo(6f, 11.58f) + lineToRelative(3.55f, 3.58f) + close() + } + } + .build() + return _check!! + } + + private var _check: ImageVector? = null + + override val ChevronRight: ImageVector + get() { + if (_chevron_right != null) { + return _chevron_right!! + } + _chevron_right = + ImageVector.Builder( + name = "chevron_right", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.Companion.NonZero, + ) { + moveTo(12.6f, 12f) + lineTo(8.7f, 8.1f) + quadTo(8.43f, 7.82f, 8.43f, 7.4f) + reflectiveQuadTo(8.7f, 6.7f) + reflectiveQuadTo(9.4f, 6.43f) + reflectiveQuadTo(10.1f, 6.7f) + lineToRelative(4.6f, 4.6f) + quadToRelative(0.15f, 0.15f, 0.21f, 0.33f) + reflectiveQuadTo(14.98f, 12f) + reflectiveQuadToRelative(-0.06f, 0.38f) + reflectiveQuadTo(14.7f, 12.7f) + lineToRelative(-4.6f, 4.6f) + quadTo(9.83f, 17.58f, 9.4f, 17.58f) + reflectiveQuadTo(8.7f, 17.3f) + quadTo(8.43f, 17.02f, 8.43f, 16.6f) + reflectiveQuadTo(8.7f, 15.9f) + lineTo(12.6f, 12f) + close() + } + } + .build() + return _chevron_right!! + } + + private var _chevron_right: ImageVector? = null + + override val ChevronLeft: ImageVector + get() { + if (_chevron_left != null) { + return _chevron_left!! + } + _chevron_left = + ImageVector.Builder( + name = "chevron_left", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.Companion.NonZero, + ) { + moveTo(10.8f, 12f) + lineToRelative(3.9f, 3.9f) + quadToRelative(0.28f, 0.28f, 0.28f, 0.7f) + quadToRelative(0f, 0.42f, -0.28f, 0.7f) + reflectiveQuadTo(14f, 17.58f) + reflectiveQuadTo(13.3f, 17.3f) + lineTo(8.7f, 12.7f) + quadTo(8.55f, 12.55f, 8.49f, 12.38f) + reflectiveQuadTo(8.43f, 12f) + reflectiveQuadTo(8.49f, 11.63f) + reflectiveQuadTo(8.7f, 11.3f) + lineTo(13.3f, 6.7f) + quadTo(13.58f, 6.43f, 14f, 6.43f) + reflectiveQuadTo(14.7f, 6.7f) + reflectiveQuadToRelative(0.28f, 0.7f) + reflectiveQuadTo(14.7f, 8.1f) + lineTo(10.8f, 12f) + close() + } + } + .build() + return _chevron_left!! + } + + private var _chevron_left: ImageVector? = null + + override val Save: ImageVector + get() { + if (_save != null) { + return _save!! + } + _save = + ImageVector.Builder( + name = "save", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.Companion.NonZero, + ) { + moveTo(5f, 21f) + quadTo(4.18f, 21f, 3.59f, 20.41f) + reflectiveQuadTo(3f, 19f) + verticalLineTo(5f) + quadTo(3f, 4.17f, 3.59f, 3.59f) + reflectiveQuadTo(5f, 3f) + horizontalLineTo(16.18f) + quadToRelative(0.4f, 0f, 0.76f, 0.15f) + reflectiveQuadToRelative(0.64f, 0.43f) + lineToRelative(2.85f, 2.85f) + quadTo(20.7f, 6.7f, 20.85f, 7.06f) + reflectiveQuadTo(21f, 7.82f) + verticalLineTo(19f) + quadToRelative(0f, 0.82f, -0.59f, 1.41f) + reflectiveQuadTo(19f, 21f) + horizontalLineTo(5f) + close() + moveTo(19f, 7.85f) + lineTo(16.15f, 5f) + horizontalLineTo(5f) + verticalLineTo(19f) + horizontalLineTo(19f) + verticalLineTo(7.85f) + close() + moveToRelative(-4.88f, 9.28f) + quadTo(15f, 16.25f, 15f, 15f) + reflectiveQuadTo(14.13f, 12.88f) + reflectiveQuadTo(12f, 12f) + reflectiveQuadTo(9.88f, 12.88f) + reflectiveQuadTo(9f, 15f) + reflectiveQuadToRelative(0.88f, 2.13f) + reflectiveQuadTo(12f, 18f) + reflectiveQuadToRelative(2.13f, -0.88f) + close() + moveTo(7f, 10f) + horizontalLineToRelative(7f) + quadToRelative(0.43f, 0f, 0.71f, -0.29f) + reflectiveQuadTo(15f, 9f) + verticalLineTo(7f) + quadTo(15f, 6.57f, 14.71f, 6.29f) + reflectiveQuadTo(14f, 6f) + horizontalLineTo(7f) + quadTo(6.58f, 6f, 6.29f, 6.29f) + reflectiveQuadTo(6f, 7f) + verticalLineTo(9f) + quadTo(6f, 9.42f, 6.29f, 9.71f) + reflectiveQuadTo(7f, 10f) + close() + moveTo(5f, 7.85f) + verticalLineTo(19f) + verticalLineTo(5f) + verticalLineTo(7.85f) + close() + } + } + .build() + return _save!! + } + + private var _save: ImageVector? = null + + override val Incoming: ImageVector + get() = InputCircle + + val InputCircle: ImageVector + get() { + if (_input_circle != null) { + return _input_circle!! + } + _input_circle = + ImageVector.Builder( + name = "input_circle", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.Companion.NonZero, + ) { + moveTo(12f, 8f) + lineTo(7f, 13f) + lineToRelative(1.4f, 1.4f) + lineTo(11f, 11.83f) + verticalLineTo(22f) + horizontalLineToRelative(2f) + verticalLineTo(11.83f) + lineToRelative(2.6f, 2.57f) + lineTo(17f, 13f) + lineTo(12f, 8f) + close() + moveTo(3.65f, 17.5f) + quadTo(2.85f, 16.27f, 2.43f, 14.88f) + reflectiveQuadTo(2f, 12f) + quadTo(2f, 9.92f, 2.79f, 8.1f) + quadTo(3.58f, 6.27f, 4.93f, 4.93f) + quadTo(6.28f, 3.57f, 8.1f, 2.79f) + quadTo(9.93f, 2f, 12f, 2f) + reflectiveQuadToRelative(3.9f, 0.79f) + reflectiveQuadToRelative(3.17f, 2.14f) + quadToRelative(1.35f, 1.35f, 2.14f, 3.17f) + quadTo(22f, 9.92f, 22f, 12f) + quadToRelative(0f, 1.47f, -0.42f, 2.88f) + reflectiveQuadTo(20.35f, 17.5f) + lineTo(18.9f, 16.05f) + quadToRelative(0.55f, -0.93f, 0.82f, -1.95f) + reflectiveQuadTo(20f, 12f) + quadTo(20f, 8.65f, 17.68f, 6.32f) + reflectiveQuadTo(12f, 4f) + reflectiveQuadTo(6.33f, 6.32f) + reflectiveQuadTo(4f, 12f) + quadToRelative(0f, 1.07f, 0.28f, 2.1f) + reflectiveQuadTo(5.1f, 16.05f) + lineTo(3.65f, 17.5f) + close() + } + } + .build() + return _input_circle!! + } + + private var _input_circle: ImageVector? = null + + override val Outgoing: ImageVector + get() = OutputCircle + val OutputCircle: ImageVector + get() { + if (_output_circle != null) { + return _output_circle!! + } + _output_circle = + ImageVector.Builder( + name = "output_circle", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.Companion.NonZero, + ) { + moveTo(12f, 22f) + lineTo(7f, 17f) + lineTo(8.4f, 15.6f) + lineTo(11f, 18.18f) + verticalLineTo(8f) + horizontalLineToRelative(2f) + verticalLineTo(18.18f) + lineTo(15.6f, 15.6f) + lineTo(17f, 17f) + lineToRelative(-5f, 5f) + close() + moveTo(3.65f, 17.5f) + quadTo(2.85f, 16.27f, 2.43f, 14.88f) + reflectiveQuadTo(2f, 12f) + quadTo(2f, 9.92f, 2.79f, 8.1f) + quadTo(3.58f, 6.27f, 4.93f, 4.93f) + quadTo(6.28f, 3.57f, 8.1f, 2.79f) + quadTo(9.93f, 2f, 12f, 2f) + reflectiveQuadToRelative(3.9f, 0.79f) + reflectiveQuadToRelative(3.17f, 2.14f) + quadToRelative(1.35f, 1.35f, 2.14f, 3.17f) + quadTo(22f, 9.92f, 22f, 12f) + quadToRelative(0f, 1.47f, -0.42f, 2.88f) + reflectiveQuadTo(20.35f, 17.5f) + lineTo(18.9f, 16.05f) + quadToRelative(0.55f, -0.93f, 0.82f, -1.95f) + reflectiveQuadTo(20f, 12f) + quadTo(20f, 8.65f, 17.68f, 6.32f) + reflectiveQuadTo(12f, 4f) + reflectiveQuadTo(6.33f, 6.32f) + reflectiveQuadTo(4f, 12f) + quadToRelative(0f, 1.07f, 0.28f, 2.1f) + reflectiveQuadTo(5.1f, 16.05f) + lineTo(3.65f, 17.5f) + close() + } + } + .build() + return _output_circle!! + } + + private var _output_circle: ImageVector? = null + + override val BoltCircle: ImageVector + get() = Charger + + val Charger: ImageVector + get() { + if (_charger != null) { + return _charger!! + } + _charger = + ImageVector.Builder( + name = "charger", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.Companion.NonZero, + ) { + moveTo(11.3f, 20f) + lineToRelative(5f, -9.75f) + horizontalLineTo(12.8f) + verticalLineTo(4f) + lineToRelative(-5f, 9.75f) + horizontalLineToRelative(3.5f) + verticalLineTo(20f) + close() + moveTo(12f, 22f) + quadTo(9.93f, 22f, 8.1f, 21.21f) + quadTo(6.28f, 20.43f, 4.93f, 19.08f) + quadTo(3.58f, 17.73f, 2.79f, 15.9f) + reflectiveQuadTo(2f, 12f) + quadTo(2f, 9.92f, 2.79f, 8.1f) + quadTo(3.58f, 6.27f, 4.93f, 4.93f) + quadTo(6.28f, 3.57f, 8.1f, 2.79f) + quadTo(9.93f, 2f, 12f, 2f) + reflectiveQuadToRelative(3.9f, 0.79f) + reflectiveQuadToRelative(3.17f, 2.14f) + quadToRelative(1.35f, 1.35f, 2.14f, 3.17f) + quadTo(22f, 9.92f, 22f, 12f) + reflectiveQuadToRelative(-0.79f, 3.9f) + reflectiveQuadToRelative(-2.14f, 3.17f) + quadToRelative(-1.35f, 1.35f, -3.17f, 2.14f) + reflectiveQuadTo(12f, 22f) + close() + moveTo(12f, 12f) + close() + moveToRelative(5.66f, 5.66f) + quadTo(20f, 15.33f, 20f, 12f) + quadTo(20f, 8.67f, 17.66f, 6.34f) + reflectiveQuadTo(12f, 4f) + quadTo(8.68f, 4f, 6.34f, 6.34f) + reflectiveQuadTo(4f, 12f) + reflectiveQuadToRelative(2.34f, 5.66f) + reflectiveQuadTo(12f, 20f) + reflectiveQuadToRelative(5.66f, -2.34f) + close() + } + } + .build() + return _charger!! + } + + private var _charger: ImageVector? = null + + override val Circle: ImageVector + get() { + if (_circle != null) { + return _circle!! + } + _circle = + ImageVector.Builder( + name = "circle", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.Companion.NonZero, + ) { + moveTo(12f, 22f) + quadTo(9.93f, 22f, 8.1f, 21.21f) + quadTo(6.28f, 20.43f, 4.93f, 19.08f) + quadTo(3.58f, 17.73f, 2.79f, 15.9f) + reflectiveQuadTo(2f, 12f) + quadTo(2f, 9.92f, 2.79f, 8.1f) + quadTo(3.58f, 6.27f, 4.93f, 4.93f) + quadTo(6.28f, 3.57f, 8.1f, 2.79f) + quadTo(9.93f, 2f, 12f, 2f) + reflectiveQuadToRelative(3.9f, 0.79f) + reflectiveQuadToRelative(3.17f, 2.14f) + quadToRelative(1.35f, 1.35f, 2.14f, 3.17f) + quadTo(22f, 9.92f, 22f, 12f) + reflectiveQuadToRelative(-0.79f, 3.9f) + reflectiveQuadToRelative(-2.14f, 3.17f) + quadToRelative(-1.35f, 1.35f, -3.17f, 2.14f) + reflectiveQuadTo(12f, 22f) + close() + moveToRelative(0f, -2f) + quadToRelative(3.35f, 0f, 5.68f, -2.32f) + reflectiveQuadTo(20f, 12f) + reflectiveQuadTo(17.68f, 6.32f) + reflectiveQuadTo(12f, 4f) + reflectiveQuadTo(6.33f, 6.32f) + reflectiveQuadTo(4f, 12f) + reflectiveQuadToRelative(2.33f, 5.68f) + reflectiveQuadTo(12f, 20f) + close() + moveToRelative(0f, -8f) + close() + } + } + .build() + return _circle!! + } + + private var _circle: ImageVector? = null + + override val CircleDotted: ImageVector + get() = CommonIcons.CircleDotted +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/RichText.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/RichText.kt new file mode 100644 index 00000000..72235f8f --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/RichText.kt @@ -0,0 +1,318 @@ +package me.kavishdevar.librepods.presentation.icons + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.buildAnnotatedString +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.em +import androidx.compose.ui.unit.sp +import androidx.core.graphics.toColorInt +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme + +data class RichText( + val text: AnnotatedString, + val inlineContent: Map +) + +@Composable +fun richText( + source: String, +): RichText { + val icons = LocalIcons.current + val inlineContent = mutableMapOf() + + val text = buildAnnotatedString { + var i = 0 + var id = 0 + + while (i < source.length) { + if (!source.startsWith("\\icon{", i)) { + append(source[i]) + i++ + continue + } + + val end = source.indexOf('}', i) + if (end == -1) { + append(source.substring(i)) + break + } + + val parts = source.substring(i + 6, end).split(',', limit = 2) + + val name = parts[0].trim() + val tint = parts.getOrNull(1)?.trim() + + val vector = icons.fromName(name) + + val resolvedTint = tint?.parseColor(MaterialTheme.colorScheme, LocalContentColor.current) ?: LocalContentColor.current + + if (vector != null) { + val key = "icon$id" + + appendInlineContent(key) + + inlineContent[key] = InlineTextContent( + Placeholder( + width = 1.125.em, + height = 1.125.em, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter, + ) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .scale(if ((icons is MaterialIcons && !icons.isAppleIcon(name) || icons is AppleIcons && icons.isMaterialIcon(name))) 1.25f else 1f) + .background(Color.Transparent) + ) { + Icon( + imageVector = vector, + contentDescription = null, + tint = resolvedTint, + modifier = Modifier + .align(Alignment.Center) + .fillMaxHeight() +// .border(Dp.Hairline, Color.Red), + ) + } + } + + id++ + } else { + append(source.substring(i, end + 1)) + } + + i = end + 1 + } + } + + return RichText( + text = text, + inlineContent = inlineContent + ) +} + +fun String.parseColor(colorScheme: ColorScheme, defaultColor: Color): Color { + if (startsWith("#")) { + return runCatching { + Color(this.toColorInt()) + }.getOrElse { + defaultColor + } + } + + return when(this) { + "primary" -> colorScheme.primary + "onPrimary" -> colorScheme.onPrimary + "primaryContainer" -> colorScheme.primaryContainer + "onPrimaryContainer" -> colorScheme.onPrimaryContainer + "inversePrimary" -> colorScheme.inversePrimary + "secondary" -> colorScheme.secondary + "onSecondary" -> colorScheme.onSecondary + "secondaryContainer" -> colorScheme.secondaryContainer + "onSecondaryContainer" -> colorScheme.onSecondaryContainer + "tertiary" -> colorScheme.tertiary + "onTertiary" -> colorScheme.onTertiary + "tertiaryContainer" -> colorScheme.tertiaryContainer + "onTertiaryContainer" -> colorScheme.onTertiaryContainer + "background" -> colorScheme.surface + "onBackground" -> colorScheme.onBackground + "surface" -> colorScheme.surface + "onSurface" -> colorScheme.onSurface + "surfaceVariant" -> colorScheme.surfaceVariant + "onSurfaceVariant" -> colorScheme.onSurfaceVariant + "surfaceTint" -> colorScheme.surfaceTint + "inverseSurface" -> colorScheme.inverseSurface + "inverseOnSurface" -> colorScheme.inverseOnSurface + "error" -> colorScheme.error + "onError" -> colorScheme.onError + "errorContainer" -> colorScheme.errorContainer + "onErrorContainer" -> colorScheme.onErrorContainer + "outline" -> colorScheme.outline + "outlineVariant" -> colorScheme.outlineVariant + "scrim" -> colorScheme.scrim + "surfaceBright" -> colorScheme.surfaceBright + "surfaceDim" -> colorScheme.surfaceDim + "surfaceContainer" -> colorScheme.surfaceContainer + "surfaceContainerHigh" -> colorScheme.surfaceContainerHigh + "surfaceContainerHighest" -> colorScheme.surfaceContainerHighest + "surfaceContainerLow" -> colorScheme.surfaceContainerLow + "surfaceContainerLowest" -> colorScheme.surfaceContainerLowest + "primaryFixed" -> colorScheme.primaryFixed + "primaryFixedDim" -> colorScheme.primaryFixedDim + "onPrimaryFixed" -> colorScheme.onPrimaryFixed + "onPrimaryFixedVariant" -> colorScheme.onPrimaryFixedVariant + "secondaryFixed" -> colorScheme.secondaryFixed + "secondaryFixedDim" -> colorScheme.secondaryFixedDim + "onSecondaryFixed" -> colorScheme.onSecondaryFixed + "onSecondaryFixedVariant" -> colorScheme.onSecondaryFixedVariant + "tertiaryFixed" -> colorScheme.tertiaryFixed + "tertiaryFixedDim" -> colorScheme.tertiaryFixedDim + "onTertiaryFixed" -> colorScheme.onTertiaryFixed + "onTertiaryFixedVariant" -> colorScheme.onTertiaryFixedVariant + else -> defaultColor + } +} + +// TODO: create a composable for previewing +@Preview +@Composable +fun RichTextPreview() { + val designSystem = remember { mutableStateOf(DesignSystem.Material) } + val darkTheme = remember { mutableStateOf(true) } + + LibrePodsTheme( + designSystem = designSystem.value, + darkTheme = darkTheme.value + ) { + val iconMap = LocalIcons.current.IconMap + + val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .background(MaterialTheme.colorScheme.surfaceContainer), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Spacer(modifier = Modifier.height(topPadding)) + + val materialAppleIconTestText = richText( + source = "Text \\icon{Bluetooth,onBackground} \\icon{LeftCircleFill,onBackground} \\icon{BoltCircle,onBackground} \\icon{BoltCircle,onBackground} \\icon{RightCircleFill,onBackground} \\icon{AirPodsPro3CaseFill,onBackground} \\icon{BoltCircle,onBackground}" + ) + + Text( + text = materialAppleIconTestText.text, + inlineContent = materialAppleIconTestText.inlineContent, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 24.sp, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Button( + onClick = { + darkTheme.value = !darkTheme.value + }, + modifier = Modifier.weight(1f) + ) { + Text(if (darkTheme.value) "Light" else "Dark") + } + + Button( + onClick = { + designSystem.value = + if (designSystem.value == DesignSystem.Apple) DesignSystem.Material else DesignSystem.Apple + }, + modifier = Modifier.weight(1f) + ) { + Text(if (designSystem.value == DesignSystem.Apple) "Material" else "Apple") + } + } + + iconMap.keys.forEach { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.weight(1f) + ) + + LibrePodsTheme( + designSystem = DesignSystem.Material, + darkTheme = darkTheme.value + ) { + val richText = richText( + source = "Text \\icon{$it,primary}" + ) + + Text( + text = richText.text, + inlineContent = richText.inlineContent, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 24.sp, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center + ) + } + + + LibrePodsTheme( + designSystem = DesignSystem.Apple, + darkTheme = darkTheme.value + ) { + val richText = richText( + source = "Text \\icon{$it,primary}" + ) + + Text( + text = richText.text, + inlineContent = richText.inlineContent, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 24.sp, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center + ) + } + } + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.onBackground) + ) + } + + Spacer(modifier = Modifier.height(bottomPadding)) + } + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/Bluetooth.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/Bluetooth.kt new file mode 100644 index 00000000..a5142c46 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/Bluetooth.kt @@ -0,0 +1,90 @@ +package me.kavishdevar.librepods.presentation.icons.common + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.Bluetooth: ImageVector + get() { + if (_bluetooth != null) { + return _bluetooth!! + } + _bluetooth = + ImageVector.Builder( + name = "bluetooth", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(11f, 20.58f) + verticalLineTo(14.4f) + lineTo(7.1f, 18.3f) + quadTo(6.83f, 18.58f, 6.4f, 18.58f) + reflectiveQuadTo(5.7f, 18.3f) + quadTo(5.43f, 18.02f, 5.43f, 17.6f) + reflectiveQuadTo(5.7f, 16.9f) + lineTo(10.6f, 12f) + lineTo(5.7f, 7.1f) + quadTo(5.43f, 6.82f, 5.43f, 6.4f) + reflectiveQuadTo(5.7f, 5.7f) + reflectiveQuadTo(6.4f, 5.43f) + reflectiveQuadTo(7.1f, 5.7f) + lineTo(11f, 9.6f) + verticalLineTo(3.42f) + quadTo(11f, 2.97f, 11.3f, 2.69f) + reflectiveQuadTo(12f, 2.4f) + quadToRelative(0.2f, 0f, 0.38f, 0.07f) + reflectiveQuadTo(12.7f, 2.7f) + lineTo(17f, 7f) + quadToRelative(0.15f, 0.15f, 0.21f, 0.32f) + reflectiveQuadTo(17.28f, 7.7f) + reflectiveQuadTo(17.21f, 8.07f) + reflectiveQuadTo(17f, 8.4f) + lineTo(13.4f, 12f) + lineTo(17f, 15.6f) + quadToRelative(0.15f, 0.15f, 0.21f, 0.32f) + reflectiveQuadToRelative(0.06f, 0.38f) + reflectiveQuadToRelative(-0.06f, 0.38f) + reflectiveQuadTo(17f, 17f) + lineToRelative(-4.3f, 4.3f) + quadToRelative(-0.15f, 0.15f, -0.33f, 0.22f) + reflectiveQuadTo(12f, 21.6f) + quadToRelative(-0.4f, 0f, -0.7f, -0.29f) + reflectiveQuadTo(11f, 20.58f) + close() + moveTo(13f, 9.6f) + lineTo(14.9f, 7.7f) + lineTo(13f, 5.85f) + verticalLineTo(9.6f) + close() + moveToRelative(0f, 8.55f) + lineTo(14.9f, 16.3f) + lineTo(13f, 14.4f) + verticalLineToRelative(3.75f) + close() + } + } + .build() + return _bluetooth!! + } + +private var _bluetooth: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/CircleDotted.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/CircleDotted.kt new file mode 100644 index 00000000..259af59b --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/CircleDotted.kt @@ -0,0 +1,123 @@ +package me.kavishdevar.librepods.presentation.icons.common + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.CircleDotted: ImageVector + get() { + val current = _circleDotted + if (current != null) return current + + return ImageVector.Builder( + name = "CircleDotted", + defaultWidth = 63.78099822998047.dp, + defaultHeight = 63.34400177001953.dp, + viewportWidth = 63.781f, + viewportHeight = 63.344f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 31.81f, y = 5.38f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.69f, dy1 = -2.7f) + arcTo(horizontalEllipseRadius = 2.7f, verticalEllipseRadius = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 31.81f, y1 = 0.0f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.68f, dy1 = 2.69f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.68f, dy1 = 2.69f) + moveToRelative(dx = 8.94f, dy = 1.46f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.69f, dy1 = -2.68f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.69f, dy1 = -2.7f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.69f, dy1 = 2.7f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.69f, dy1 = 2.68f) + moveToRelative(dx = 8.13f, dy = 4.1f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.68f, dy1 = -2.69f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.69f, dy1 = -2.69f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.68f, dy1 = 2.69f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.69f, dy1 = 2.69f) + moveToRelative(dx = 6.4f, dy = 6.44f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.69f, dy1 = -2.7f) + arcTo(horizontalEllipseRadius = 2.7f, verticalEllipseRadius = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 55.28f, y1 = 12.0f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.69f, dy1 = 2.69f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.7f, dy1 = 2.69f) + moveToRelative(dx = 4.1f, dy = 8.0f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.68f, dy1 = -2.7f) + arcTo(horizontalEllipseRadius = 2.7f, verticalEllipseRadius = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 59.37f, y1 = 20.0f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.68f, dy1 = 2.69f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.69f, dy1 = 2.68f) + moveToRelative(dx = 1.53f, dy = 8.96f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.68f, dy1 = -2.68f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.68f, dy1 = -2.7f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.7f, dy1 = 2.7f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.7f, dy1 = 2.68f) + moveToRelative(dx = -1.53f, dy = 8.97f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.68f, dy1 = -2.69f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.69f, dy1 = -2.68f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.68f, dy1 = 2.69f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.69f, dy1 = 2.68f) + moveToRelative(dx = -4.1f, dy = 8.03f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.69f, dy1 = -2.68f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.69f, dy1 = -2.7f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.69f, dy1 = 2.7f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.7f, dy1 = 2.68f) + moveToRelative(dx = -6.4f, dy = 6.41f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.68f, dy1 = -2.69f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.69f, dy1 = -2.69f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.68f, dy1 = 2.7f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.69f, dy1 = 2.68f) + moveToRelative(dx = -8.13f, dy = 4.1f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.69f, dy1 = -2.7f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.69f, dy1 = -2.68f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.69f, dy1 = 2.69f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.69f, dy1 = 2.68f) + moveToRelative(dx = -8.94f, dy = 1.46f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.69f, dy1 = -2.69f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.69f, dy1 = -2.68f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.68f, dy1 = 2.69f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.68f, dy1 = 2.68f) + moveToRelative(dx = -8.93f, dy = -1.47f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.68f, dy1 = -2.68f) + arcToRelative(a = 2.69f, b = 2.69f, theta = 0.0f, isMoreThanHalf = true, isPositiveArc = false, dx1 = -2.69f, dy1 = 2.69f) + moveToRelative(dx = -8.13f, dy = -4.09f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.69f, dy1 = -2.69f) + arcToRelative(a = 2.69f, b = 2.69f, theta = 0.0f, isMoreThanHalf = true, isPositiveArc = false, dx1 = -2.69f, dy1 = 2.69f) + moveToRelative(dx = -6.44f, dy = -6.4f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.69f, dy1 = -2.7f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.69f, dy1 = -2.68f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.69f, dy1 = 2.69f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.7f, dy1 = 2.68f) + moveToRelative(dx = -4.1f, dy = -8.04f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.7f, dy1 = -2.69f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.7f, dy1 = -2.68f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.68f, dy1 = 2.69f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.69f, dy1 = 2.68f) + moveTo(x = 2.7f, y = 34.34f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.69f, dy1 = -2.68f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.7f, dy1 = -2.7f) + arcTo(horizontalEllipseRadius = 2.7f, verticalEllipseRadius = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 0.0f, y1 = 31.67f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.69f, dy1 = 2.68f) + moveToRelative(dx = 1.53f, dy = -8.97f) + arcTo(horizontalEllipseRadius = 2.7f, verticalEllipseRadius = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 6.9f, y1 = 22.7f) + arcTo(horizontalEllipseRadius = 2.7f, verticalEllipseRadius = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 4.2f, y1 = 20.0f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.68f, dy1 = 2.69f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.69f, dy1 = 2.68f) + moveToRelative(dx = 4.1f, dy = -8.0f) + arcTo(horizontalEllipseRadius = 2.7f, verticalEllipseRadius = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 11.0f, y1 = 14.7f) + arcTo(horizontalEllipseRadius = 2.7f, verticalEllipseRadius = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 8.31f, y1 = 12.0f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.69f, dy1 = 2.69f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.7f, dy1 = 2.69f) + moveToRelative(dx = 6.43f, dy = -6.43f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.69f, dy1 = -2.69f) + arcToRelative(a = 2.69f, b = 2.69f, theta = 0.0f, isMoreThanHalf = true, isPositiveArc = false, dx1 = -2.69f, dy1 = 2.69f) + moveToRelative(dx = 8.13f, dy = -4.1f) + arcToRelative(a = 2.7f, b = 2.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.68f, dy1 = -2.68f) + arcToRelative(a = 2.69f, b = 2.69f, theta = 0.0f, isMoreThanHalf = true, isPositiveArc = false, dx1 = -2.69f, dy1 = 2.69f) + } + }.build().also { _circleDotted = it } + } + +@Suppress("ObjectPropertyName") +private var _circleDotted: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/LeftCircleFill.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/LeftCircleFill.kt new file mode 100644 index 00000000..8c3dffff --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/LeftCircleFill.kt @@ -0,0 +1,45 @@ +package me.kavishdevar.librepods.presentation.icons.common + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.LeftCircleFill: ImageVector + get() { + val current = _leftCircleFill + if (current != null) return current + + return ImageVector.Builder( + name = "LeftCircleFill", + defaultWidth = 63.9379997253418.dp, + defaultHeight = 63.78099822998047.dp, + viewportWidth = 63.938f, + viewportHeight = 63.781f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 63.75f, y = 31.88f) + arcToRelative(a = 31.9f, b = 31.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -31.87f, dy1 = 31.87f) + arcTo(horizontalEllipseRadius = 31.93f, verticalEllipseRadius = 31.93f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, x1 = 0.0f, y1 = 31.88f) + arcTo(horizontalEllipseRadius = 31.9f, verticalEllipseRadius = 31.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, x1 = 31.88f, y1 = 0.0f) + arcToRelative(a = 31.9f, b = 31.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 31.87f, dy1 = 31.88f) + moveTo(x = 23.31f, y = 19.4f) + verticalLineToRelative(dy = 23.93f) + curveToRelative(dx1 = 0.0f, dy1 = 1.72f, dx2 = 0.9f, dy2 = 2.91f, dx3 = 2.57f, dy3 = 2.91f) + horizontalLineToRelative(dx = 14.84f) + curveToRelative(dx1 = 1.25f, dy1 = 0.0f, dx2 = 2.16f, dy2 = -0.81f, dx3 = 2.16f, dy3 = -2.12f) + curveToRelative(dx1 = 0.0f, dy1 = -1.32f, dx2 = -0.91f, dy2 = -2.13f, dx3 = -2.16f, dy3 = -2.13f) + horizontalLineTo(x = 28.5f) + verticalLineTo(y = 19.4f) + curveToRelative(dx1 = 0.0f, dy1 = -1.77f, dx2 = -0.9f, dy2 = -2.93f, dx3 = -2.62f, dy3 = -2.93f) + curveToRelative(dx1 = -1.66f, dy1 = 0.0f, dx2 = -2.57f, dy2 = 1.22f, dx3 = -2.57f, dy3 = 2.94f) + } + }.build().also { _leftCircleFill = it } + } + +private var _leftCircleFill: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/RightCircleFill.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/RightCircleFill.kt new file mode 100644 index 00000000..d9a6ded8 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/RightCircleFill.kt @@ -0,0 +1,57 @@ +package me.kavishdevar.librepods.presentation.icons.common + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.RightCircleFill: ImageVector + get() { + val current = _rightCircleFill + if (current != null) return current + + return ImageVector.Builder( + name = "RightCircleFill", + defaultWidth = 63.9379997253418.dp, + defaultHeight = 63.78099822998047.dp, + viewportWidth = 63.938f, + viewportHeight = 63.781f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 63.75f, y = 31.88f) + arcToRelative(a = 31.9f, b = 31.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -31.87f, dy1 = 31.87f) + arcTo(horizontalEllipseRadius = 31.93f, verticalEllipseRadius = 31.93f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, x1 = 0.0f, y1 = 31.88f) + arcTo(horizontalEllipseRadius = 31.9f, verticalEllipseRadius = 31.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, x1 = 31.88f, y1 = 0.0f) + arcToRelative(a = 31.9f, b = 31.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 31.87f, dy1 = 31.88f) + moveToRelative(dx = -40.19f, dy = -15.0f) + curveToRelative(dx1 = -1.62f, dy1 = 0.0f, dx2 = -2.5f, dy2 = 1.18f, dx3 = -2.5f, dy3 = 2.93f) + verticalLineToRelative(dy = 23.94f) + curveToRelative(dx1 = 0.0f, dy1 = 1.72f, dx2 = 0.9f, dy2 = 2.9f, dx3 = 2.53f, dy3 = 2.9f) + curveToRelative(dx1 = 1.7f, dy1 = 0.0f, dx2 = 2.63f, dy2 = -1.12f, dx3 = 2.63f, dy3 = -2.9f) + verticalLineToRelative(dy = -8.0f) + horizontalLineToRelative(dx = 6.0f) + lineToRelative(dx = 5.56f, dy = 9.19f) + curveToRelative(dx1 = 0.78f, dy1 = 1.25f, dx2 = 1.44f, dy2 = 1.72f, dx3 = 2.6f, dy3 = 1.72f) + curveToRelative(dx1 = 1.37f, dy1 = 0.0f, dx2 = 2.34f, dy2 = -0.91f, dx3 = 2.34f, dy3 = -2.22f) + arcToRelative(a = 3.3f, b = 3.3f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -0.6f, dy1 = -1.88f) + lineTo(x = 37.33f, y = 35.0f) + arcToRelative(a = 8.9f, b = 8.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 6.21f, dy1 = -8.62f) + curveToRelative(dx1 = 0.0f, dy1 = -5.82f, dx2 = -4.16f, dy2 = -9.5f, dx3 = -10.69f, dy3 = -9.5f) + close() + moveToRelative(dx = 14.88f, dy = 9.62f) + curveToRelative(dx1 = 0.0f, dy1 = 3.5f, dx2 = -2.47f, dy2 = 5.47f, dx3 = -6.35f, dy3 = 5.47f) + horizontalLineToRelative(dx = -5.87f) + verticalLineTo(y = 20.84f) + horizontalLineToRelative(dx = 6.0f) + curveToRelative(dx1 = 3.69f, dy1 = 0.0f, dx2 = 6.22f, dy2 = 2.07f, dx3 = 6.22f, dy3 = 5.66f) + } + }.build().also { _rightCircleFill = it } + } + +@Suppress("ObjectPropertyName") +private var _rightCircleFill: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods.kt new file mode 100644 index 00000000..e50156c3 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods.kt @@ -0,0 +1,85 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPods: ImageVector + get() { + val current = _airPods + if (current != null) return current + + return ImageVector.Builder( + name = "AirPods", + defaultWidth = 62.3129997253418.dp, + defaultHeight = 60.96900177001953.dp, + viewportWidth = 62.313f, + viewportHeight = 60.969f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 13.75f, y = 25.34f) + curveToRelative(dx1 = 7.31f, dy1 = 0.07f, dx2 = 13.6f, dy2 = -5.68f, dx3 = 13.5f, dy3 = -12.71f) + curveTo(x1 = 27.15f, y1 = 5.69f, x2 = 21.06f, y2 = 0.0f, x3 = 13.75f, y3 = 0.0f) + curveTo(x1 = 7.03f, y1 = 0.0f, x2 = 2.91f, y2 = 4.16f, x3 = 0.94f, y3 = 7.06f) + arcTo(horizontalEllipseRadius = 6.0f, verticalEllipseRadius = 6.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 0.0f, y1 = 10.38f) + verticalLineToRelative(dy = 4.59f) + arcToRelative(a = 6.0f, b = 6.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.94f, dy1 = 3.31f) + curveToRelative(dx1 = 1.9f, dy1 = 2.9f, dx2 = 6.1f, dy2 = 6.97f, dx3 = 12.81f, dy3 = 7.06f) + moveToRelative(dx = -8.06f, dy = -7.71f) + arcToRelative(a = 1.47f, b = 1.47f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -1.47f, dy1 = -1.5f) + verticalLineTo(y = 9.18f) + curveToRelative(dx1 = 0.0f, dy1 = -0.85f, dx2 = 0.66f, dy2 = -1.5f, dx3 = 1.47f, dy3 = -1.5f) + curveToRelative(dx1 = 0.87f, dy1 = 0.0f, dx2 = 1.5f, dy2 = 0.65f, dx3 = 1.5f, dy3 = 1.5f) + verticalLineToRelative(dy = 6.94f) + curveToRelative(dx1 = 0.0f, dy1 = 0.84f, dx2 = -0.63f, dy2 = 1.5f, dx3 = -1.5f, dy3 = 1.5f) + moveToRelative(dx = 9.65f, dy = 32.93f) + horizontalLineToRelative(dx = 8.82f) + verticalLineTo(y = 24.88f) + arcToRelative(a = 16.5f, b = 16.5f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -8.82f, dy1 = 3.4f) + close() + moveToRelative(dx = 3.07f, dy = 10.31f) + horizontalLineToRelative(dx = 2.65f) + curveToRelative(dx1 = 1.9f, dy1 = 0.0f, dx2 = 3.1f, dy2 = -0.93f, dx3 = 3.1f, dy3 = -2.78f) + verticalLineToRelative(dy = -4.4f) + horizontalLineToRelative(dx = -8.82f) + verticalLineToRelative(dy = 4.4f) + curveToRelative(dx1 = 0.0f, dy1 = 1.85f, dx2 = 1.22f, dy2 = 2.78f, dx3 = 3.07f, dy3 = 2.78f) + moveToRelative(dx = 29.97f, dy = -35.53f) + arcToRelative(a = 15.5f, b = 15.5f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 12.8f, dy1 = -7.06f) + arcToRelative(a = 6.0f, b = 6.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.95f, dy1 = -3.31f) + verticalLineToRelative(dy = -4.6f) + arcToRelative(a = 6.0f, b = 6.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -0.94f, dy1 = -3.3f) + curveTo(x1 = 59.25f, y1 = 4.15f, x2 = 55.09f, y2 = 0.0f, x3 = 48.38f, y3 = 0.0f) + curveToRelative(dx1 = -7.32f, dy1 = 0.0f, dx2 = -13.41f, dy2 = 5.69f, dx3 = -13.5f, dy3 = 12.63f) + curveToRelative(dx1 = -0.1f, dy1 = 7.03f, dx2 = 6.18f, dy2 = 12.78f, dx3 = 13.5f, dy3 = 12.71f) + moveToRelative(dx = 8.06f, dy = -7.71f) + arcToRelative(a = 1.46f, b = 1.46f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -1.5f, dy1 = -1.5f) + verticalLineTo(y = 9.18f) + curveToRelative(dx1 = 0.0f, dy1 = -0.85f, dx2 = 0.62f, dy2 = -1.5f, dx3 = 1.5f, dy3 = -1.5f) + arcToRelative(a = 1.47f, b = 1.47f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 1.47f, dy1 = 1.5f) + verticalLineToRelative(dy = 6.94f) + curveToRelative(dx1 = 0.0f, dy1 = 0.84f, dx2 = -0.66f, dy2 = 1.5f, dx3 = -1.47f, dy3 = 1.5f) + moveToRelative(dx = -9.66f, dy = 32.93f) + verticalLineTo(y = 28.28f) + arcToRelative(a = 16.5f, b = 16.5f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -8.81f, dy1 = -3.4f) + verticalLineToRelative(dy = 25.68f) + close() + moveToRelative(dx = -3.06f, dy = 10.31f) + curveToRelative(dx1 = 1.84f, dy1 = 0.0f, dx2 = 3.06f, dy2 = -0.93f, dx3 = 3.06f, dy3 = -2.78f) + verticalLineToRelative(dy = -4.4f) + horizontalLineToRelative(dx = -8.81f) + verticalLineToRelative(dy = 4.4f) + curveToRelative(dx1 = 0.0f, dy1 = 1.85f, dx2 = 1.19f, dy2 = 2.78f, dx3 = 3.1f, dy3 = 2.78f) + close() + } + }.build().also { _airPods = it } + } + +@Suppress("ObjectPropertyName") +private var _airPods: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods3.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods3.kt new file mode 100644 index 00000000..55bd1e86 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods3.kt @@ -0,0 +1,79 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPods3: ImageVector + get() { + val current = _airPods3 + if (current != null) return current + + return ImageVector.Builder( + name = "AirPods3", + defaultWidth = 76.46900177001953.dp, + defaultHeight = 54.96900177001953.dp, + viewportWidth = 76.469f, + viewportHeight = 54.969f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 21.03f, y = 0.22f) + curveTo(x1 = 11.47f, y1 = 0.16f, x2 = 0.0f, y2 = 9.75f, x3 = 0.0f, y3 = 19.03f) + curveToRelative(dx1 = 0.0f, dy1 = 7.69f, dx2 = 7.69f, dy2 = 13.75f, dx3 = 13.9f, dy3 = 13.72f) + curveToRelative(dx1 = 8.66f, dy1 = -0.03f, dx2 = 20.6f, dy2 = -10.06f, dx3 = 20.6f, dy3 = -18.44f) + curveToRelative(dx1 = 0.0f, dy1 = -8.1f, dx2 = -6.25f, dy2 = -14.03f, dx3 = -13.47f, dy3 = -14.1f) + moveToRelative(dx = -10.1f, dy = 28.22f) + curveToRelative(dx1 = -1.74f, dy1 = 0.0f, dx2 = -4.43f, dy2 = -2.19f, dx3 = -6.12f, dy3 = -4.9f) + curveToRelative(dx1 = -1.65f, dy1 = -2.79f, dx2 = -1.6f, dy2 = -4.95f, dx3 = 0.16f, dy3 = -4.95f) + curveToRelative(dx1 = 1.72f, dy1 = 0.0f, dx2 = 4.44f, dy2 = 2.16f, dx3 = 6.1f, dy3 = 4.94f) + curveToRelative(dx1 = 1.65f, dy1 = 2.72f, dx2 = 1.59f, dy2 = 4.9f, dx3 = -0.13f, dy3 = 4.9f) + moveToRelative(dx = 8.73f, dy = -13.94f) + arcToRelative(a = 3.0f, b = 3.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 0.71f, dy1 = -4.25f) + lineToRelative(dx = 4.35f, dy = -3.16f) + arcToRelative(a = 2.93f, b = 2.93f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 4.19f, dy1 = 0.75f) + arcToRelative(a = 2.9f, b = 2.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -0.63f, dy1 = 4.22f) + lineToRelative(dx = -4.34f, dy = 3.1f) + arcToRelative(a = 3.0f, b = 3.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -4.28f, dy1 = -0.66f) + moveToRelative(dx = 2.9f, dy = 40.47f) + horizontalLineToRelative(dx = 3.9f) + curveToRelative(dx1 = 1.95f, dy1 = 0.0f, dx2 = 3.23f, dy2 = -1.0f, dx3 = 3.23f, dy3 = -2.9f) + verticalLineTo(y = 28.71f) + arcToRelative(a = 29.0f, b = 29.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -10.35f, dy1 = 6.1f) + verticalLineToRelative(dy = 17.24f) + curveToRelative(dx1 = 0.0f, dy1 = 1.9f, dx2 = 1.25f, dy2 = 2.9f, dx3 = 3.22f, dy3 = 2.9f) + moveTo(x = 55.25f, y = 0.22f) + curveToRelative(dx1 = -7.22f, dy1 = 0.06f, dx2 = -13.47f, dy2 = 6.0f, dx3 = -13.47f, dy3 = 14.1f) + curveToRelative(dx1 = 0.0f, dy1 = 8.37f, dx2 = 11.94f, dy2 = 18.4f, dx3 = 20.6f, dy3 = 18.43f) + curveToRelative(dx1 = 6.21f, dy1 = 0.03f, dx2 = 13.9f, dy2 = -6.03f, dx3 = 13.9f, dy3 = -13.72f) + curveToRelative(dx1 = 0.0f, dy1 = -9.28f, dx2 = -11.47f, dy2 = -18.87f, dx3 = -21.03f, dy3 = -18.81f) + moveToRelative(dx = 10.1f, dy = 28.22f) + curveToRelative(dx1 = -1.72f, dy1 = 0.0f, dx2 = -1.79f, dy2 = -2.19f, dx3 = -0.13f, dy3 = -4.9f) + curveToRelative(dx1 = 1.66f, dy1 = -2.79f, dx2 = 4.37f, dy2 = -4.95f, dx3 = 6.1f, dy3 = -4.95f) + curveToRelative(dx1 = 1.74f, dy1 = 0.0f, dx2 = 1.8f, dy2 = 2.16f, dx3 = 0.15f, dy3 = 4.94f) + curveToRelative(dx1 = -1.66f, dy1 = 2.72f, dx2 = -4.38f, dy2 = 4.9f, dx3 = -6.13f, dy3 = 4.9f) + moveTo(x = 56.62f, y = 14.5f) + arcToRelative(a = 3.0f, b = 3.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -4.26f, dy1 = 0.66f) + lineToRelative(dx = -4.34f, dy = -3.1f) + arcToRelative(a = 2.9f, b = 2.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -0.66f, dy1 = -4.22f) + arcToRelative(a = 2.93f, b = 2.93f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 4.2f, dy1 = -0.75f) + lineToRelative(dx = 4.37f, dy = 3.16f) + arcToRelative(a = 3.0f, b = 3.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 0.69f, dy1 = 4.25f) + moveToRelative(dx = -2.91f, dy = 40.47f) + curveToRelative(dx1 = 1.97f, dy1 = 0.0f, dx2 = 3.22f, dy2 = -1.0f, dx3 = 3.22f, dy3 = -2.9f) + verticalLineTo(y = 34.8f) + arcToRelative(a = 29.0f, b = 29.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -10.35f, dy1 = -6.1f) + verticalLineToRelative(dy = 23.35f) + curveToRelative(dx1 = 0.0f, dy1 = 1.9f, dx2 = 1.28f, dy2 = 2.9f, dx3 = 3.22f, dy3 = 2.9f) + close() + } + }.build().also { _airPods3 = it } + } + +@Suppress("ObjectPropertyName") +private var _airPods3: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods3Case.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods3Case.kt new file mode 100644 index 00000000..a7e0b65d --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods3Case.kt @@ -0,0 +1,66 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPods3Case: ImageVector + get() { + val current = _airPods3Case + if (current != null) return current + + return ImageVector.Builder( + name = "AirPods3Case", + defaultWidth = 66.15599822998047.dp, + defaultHeight = 54.53099822998047.dp, + viewportWidth = 66.156f, + viewportHeight = 54.531f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 18.94f, y = 54.5f) + horizontalLineToRelative(dx = 28.1f) + curveToRelative(dx1 = 12.74f, dy1 = 0.0f, dx2 = 18.93f, dy2 = -6.12f, dx3 = 18.93f, dy3 = -18.84f) + verticalLineTo(y = 18.84f) + curveTo(x1 = 65.97f, y1 = 6.13f, x2 = 59.78f, y2 = 0.0f, x3 = 47.03f, y3 = 0.0f) + horizontalLineToRelative(dx = -28.1f) + curveTo(x1 = 6.2f, y1 = 0.0f, x2 = 0.0f, y2 = 6.13f, x3 = 0.0f, y3 = 18.84f) + verticalLineToRelative(dy = 16.82f) + curveTo(x1 = 0.0f, y1 = 48.38f, x2 = 6.19f, y2 = 54.5f, x3 = 18.94f, y3 = 54.5f) + moveToRelative(dx = 0.0f, dy = -5.03f) + curveToRelative(dx1 = -9.6f, dy1 = 0.0f, dx2 = -13.9f, dy2 = -4.28f, dx3 = -13.9f, dy3 = -13.81f) + verticalLineTo(y = 18.84f) + curveToRelative(dx1 = 0.0f, dy1 = -9.53f, dx2 = 4.3f, dy2 = -13.8f, dx3 = 13.9f, dy3 = -13.8f) + horizontalLineToRelative(dx = 28.1f) + curveToRelative(dx1 = 9.59f, dy1 = 0.0f, dx2 = 13.9f, dy2 = 4.27f, dx3 = 13.9f, dy3 = 13.8f) + verticalLineToRelative(dy = 16.82f) + curveToRelative(dx1 = 0.0f, dy1 = 9.53f, dx2 = -4.31f, dy2 = 13.8f, dx3 = -13.9f, dy3 = 13.8f) + close() + moveToRelative(dx = -16.4f, dy = -28.5f) + horizontalLineToRelative(dx = 60.9f) + verticalLineTo(y = 17.8f) + horizontalLineTo(x = 2.54f) + close() + moveToRelative(dx = 17.93f, dy = 2.16f) + horizontalLineTo(x = 45.5f) + arcToRelative(a = 3.6f, b = 3.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 3.75f, dy1 = -3.75f) + arcToRelative(a = 3.6f, b = 3.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -3.75f, dy1 = -3.72f) + horizontalLineTo(x = 20.47f) + arcToRelative(a = 3.6f, b = 3.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -3.75f, dy1 = 3.72f) + arcToRelative(a = 3.63f, b = 3.63f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 3.75f, dy1 = 3.75f) + moveTo(x = 33.0f, y = 33.78f) + curveToRelative(dx1 = 1.66f, dy1 = -0.03f, dx2 = 3.0f, dy2 = -1.4f, dx3 = 3.0f, dy3 = -2.97f) + arcToRelative(a = 3.03f, b = 3.03f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -3.0f, dy1 = -3.03f) + arcToRelative(a = 3.03f, b = 3.03f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -3.03f, dy1 = 3.03f) + curveToRelative(dx1 = 0.0f, dy1 = 1.63f, dx2 = 1.34f, dy2 = 3.0f, dx3 = 3.03f, dy3 = 2.97f) + } + }.build().also { _airPods3Case = it } + } + +@Suppress("ObjectPropertyName") +private var _airPods3Case: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods3CaseFill.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods3CaseFill.kt new file mode 100644 index 00000000..bf819f2d --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods3CaseFill.kt @@ -0,0 +1,58 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPods3CaseFill: ImageVector + get() { + val current = _airPods3CaseFill + if (current != null) return current + + return ImageVector.Builder( + name = "AirPods3CaseFill", + defaultWidth = 64.56300354003906.dp, + defaultHeight = 55.15599822998047.dp, + viewportWidth = 64.563f, + viewportHeight = 55.156f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 18.94f, y = 55.13f) + horizontalLineToRelative(dx = 26.5f) + curveToRelative(dx1 = 12.75f, dy1 = 0.0f, dx2 = 18.94f, dy2 = -6.16f, dx3 = 18.94f, dy3 = -18.88f) + verticalLineTo(y = 20.81f) + horizontalLineTo(x = 48.22f) + arcToRelative(a = 3.6f, b = 3.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -3.5f, dy1 = 2.32f) + horizontalLineTo(x = 19.69f) + curveToRelative(dx1 = -1.63f, dy1 = 0.0f, dx2 = -3.0f, dy2 = -0.91f, dx3 = -3.5f, dy3 = -2.32f) + horizontalLineTo(x = 0.0f) + verticalLineToRelative(dy = 15.44f) + curveToRelative(dx1 = 0.0f, dy1 = 12.72f, dx2 = 6.19f, dy2 = 18.88f, dx3 = 18.94f, dy3 = 18.88f) + moveToRelative(dx = 13.25f, dy = -20.1f) + arcToRelative(a = 2.83f, b = 2.83f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -2.88f, dy1 = -2.81f) + arcToRelative(a = 2.9f, b = 2.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 2.88f, dy1 = -2.88f) + arcToRelative(a = 2.9f, b = 2.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 2.87f, dy1 = 2.88f) + arcToRelative(a = 2.94f, b = 2.94f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -2.87f, dy1 = 2.81f) + moveTo(x = 0.0f, y = 17.97f) + horizontalLineToRelative(dx = 16.13f) + arcToRelative(a = 3.6f, b = 3.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 3.5f, dy1 = -2.31f) + horizontalLineToRelative(dx = 25.06f) + curveToRelative(dx1 = 1.62f, dy1 = 0.0f, dx2 = 2.97f, dy2 = 0.9f, dx3 = 3.47f, dy3 = 2.3f) + horizontalLineToRelative(dx = 16.12f) + verticalLineToRelative(dy = -1.0f) + curveTo(x1 = 64.28f, y1 = 5.57f, x2 = 57.5f, y2 = 0.0f, x3 = 45.34f, y3 = 0.0f) + horizontalLineToRelative(dx = -26.4f) + curveTo(x1 = 6.78f, y1 = 0.0f, x2 = 0.0f, y2 = 5.56f, x3 = 0.0f, y3 = 16.97f) + close() + } + }.build().also { _airPods3CaseFill = it } + } + +@Suppress("ObjectPropertyName") +private var _airPods3CaseFill: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods4.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods4.kt new file mode 100644 index 00000000..74869f50 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods4.kt @@ -0,0 +1,77 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPods4: ImageVector + get() { + val current = _airPods4 + if (current != null) return current + + return ImageVector.Builder( + name = "AirPods4", + defaultWidth = 73.28099822998047.dp, + defaultHeight = 55.21900177001953.dp, + viewportWidth = 73.281f, + viewportHeight = 55.219f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 12.69f, y = 31.47f) + curveToRelative(dx1 = 9.28f, dy1 = 0.0f, dx2 = 20.22f, dy2 = -8.66f, dx3 = 20.22f, dy3 = -17.03f) + curveToRelative(dx1 = 0.0f, dy1 = -8.22f, dx2 = -6.75f, dy2 = -14.16f, dx3 = -13.16f, dy3 = -14.16f) + curveTo(x1 = 11.47f, y1 = 0.28f, x2 = 0.0f, y2 = 9.0f, x3 = 0.0f, y3 = 16.75f) + curveToRelative(dx1 = 0.0f, dy1 = 7.28f, dx2 = 4.38f, dy2 = 14.72f, dx3 = 12.69f, dy3 = 14.72f) + moveToRelative(dx = -5.28f, dy = -5.4f) + curveToRelative(dx1 = -2.07f, dy1 = -1.1f, dx2 = -3.91f, dy2 = -6.04f, dx3 = -3.88f, dy3 = -9.32f) + curveToRelative(dx1 = 0.0f, dy1 = -1.16f, dx2 = 0.44f, dy2 = -1.9f, dx3 = 1.16f, dy3 = -1.9f) + curveToRelative(dx1 = 2.28f, dy1 = 0.0f, dx2 = 4.53f, dy2 = 6.5f, dx3 = 4.53f, dy3 = 9.62f) + curveToRelative(dx1 = 0.0f, dy1 = 1.19f, dx2 = -0.47f, dy2 = 2.28f, dx3 = -1.81f, dy3 = 1.6f) + moveToRelative(dx = 7.43f, dy = -8.7f) + arcToRelative(a = 2.96f, b = 2.96f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 0.72f, dy1 = -4.18f) + lineToRelative(dx = 1.75f, dy = -1.28f) + curveToRelative(dx1 = 1.4f, dy1 = -1.0f, dx2 = 3.22f, dy2 = -0.63f, dx3 = 4.16f, dy3 = 0.75f) + arcToRelative(a = 2.9f, b = 2.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -0.63f, dy1 = 4.18f) + lineToRelative(dx = -1.75f, dy = 1.22f) + arcToRelative(a = 3.0f, b = 3.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -4.25f, dy1 = -0.68f) + moveToRelative(dx = 0.25f, dy = 16.94f) + verticalLineToRelative(dy = 16.1f) + curveToRelative(dx1 = 0.0f, dy1 = 4.06f, dx2 = 1.75f, dy2 = 4.8f, dx3 = 5.82f, dy3 = 4.8f) + curveToRelative(dx1 = 4.0f, dy1 = 0.0f, dx2 = 5.72f, dy2 = -0.74f, dx3 = 5.72f, dy3 = -4.8f) + verticalLineTo(y = 29.63f) + arcToRelative(a = 26.0f, b = 26.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -11.54f, dy1 = 4.68f) + moveToRelative(dx = 45.35f, dy = -2.84f) + curveToRelative(dx1 = 8.31f, dy1 = 0.0f, dx2 = 12.65f, dy2 = -7.44f, dx3 = 12.65f, dy3 = -14.72f) + curveTo(x1 = 73.1f, y1 = 9.0f, x2 = 61.62f, y2 = 0.28f, x3 = 53.37f, y3 = 0.28f) + curveToRelative(dx1 = -6.43f, dy1 = 0.0f, dx2 = -13.15f, dy2 = 5.94f, dx3 = -13.15f, dy3 = 14.16f) + curveToRelative(dx1 = 0.0f, dy1 = 8.37f, dx2 = 10.9f, dy2 = 17.03f, dx3 = 20.22f, dy3 = 17.03f) + moveToRelative(dx = 5.28f, dy = -5.4f) + curveToRelative(dx1 = -1.38f, dy1 = 0.68f, dx2 = -1.84f, dy2 = -0.41f, dx3 = -1.84f, dy3 = -1.6f) + curveToRelative(dx1 = 0.0f, dy1 = -3.13f, dx2 = 2.28f, dy2 = -9.63f, dx3 = 4.56f, dy3 = -9.63f) + curveToRelative(dx1 = 0.69f, dy1 = 0.0f, dx2 = 1.12f, dy2 = 0.75f, dx3 = 1.12f, dy3 = 1.91f) + curveToRelative(dx1 = 0.03f, dy1 = 3.28f, dx2 = -1.78f, dy2 = 8.22f, dx3 = -3.84f, dy3 = 9.31f) + moveToRelative(dx = -7.47f, dy = -8.7f) + arcToRelative(a = 2.97f, b = 2.97f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -4.25f, dy1 = 0.7f) + lineToRelative(dx = -1.72f, dy = -1.23f) + arcToRelative(a = 2.84f, b = 2.84f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -0.62f, dy1 = -4.18f) + arcToRelative(a = 2.9f, b = 2.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 4.15f, dy1 = -0.75f) + lineToRelative(dx = 1.75f, dy = 1.28f) + curveToRelative(dx1 = 1.4f, dy1 = 1.0f, dx2 = 1.66f, dy2 = 2.84f, dx3 = 0.69f, dy3 = 4.19f) + moveTo(x = 58.0f, y = 34.32f) + arcToRelative(a = 26.0f, b = 26.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -11.53f, dy1 = -4.69f) + verticalLineToRelative(dy = 20.79f) + curveToRelative(dx1 = 0.0f, dy1 = 4.06f, dx2 = 1.72f, dy2 = 4.8f, dx3 = 5.72f, dy3 = 4.8f) + curveToRelative(dx1 = 4.06f, dy1 = 0.0f, dx2 = 5.81f, dy2 = -0.74f, dx3 = 5.81f, dy3 = -4.8f) + close() + } + }.build().also { _airPods4 = it } + } + +@Suppress("ObjectPropertyName") +private var _airPods4: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods4Case.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods4Case.kt new file mode 100644 index 00000000..7928084a --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods4Case.kt @@ -0,0 +1,65 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPods4Case: ImageVector + get() { + val current = _airPods4Case + if (current != null) return current + + return ImageVector.Builder( + name = "AirPods4Case", + defaultWidth = 59.375.dp, + defaultHeight = 54.53099822998047.dp, + viewportWidth = 59.375f, + viewportHeight = 54.531f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 19.13f, y = 54.5f) + horizontalLineToRelative(dx = 20.93f) + curveToRelative(dx1 = 12.78f, dy1 = 0.0f, dx2 = 19.13f, dy2 = -6.31f, dx3 = 19.13f, dy3 = -19.03f) + verticalLineTo(y = 19.03f) + curveTo(x1 = 59.19f, y1 = 6.31f, x2 = 52.84f, y2 = 0.0f, x3 = 40.06f, y3 = 0.0f) + horizontalLineTo(x = 19.13f) + curveTo(x1 = 6.34f, y1 = 0.0f, x2 = 0.0f, y2 = 6.31f, x3 = 0.0f, y3 = 19.03f) + verticalLineToRelative(dy = 16.44f) + curveTo(x1 = 0.0f, y1 = 48.19f, x2 = 6.34f, y2 = 54.5f, x3 = 19.13f, y3 = 54.5f) + moveToRelative(dx = 0.0f, dy = -5.03f) + curveToRelative(dx1 = -9.63f, dy1 = 0.0f, dx2 = -14.1f, dy2 = -4.44f, dx3 = -14.1f, dy3 = -14.0f) + verticalLineTo(y = 19.03f) + curveToRelative(dx1 = 0.0f, dy1 = -9.56f, dx2 = 4.47f, dy2 = -14.0f, dx3 = 14.1f, dy3 = -14.0f) + horizontalLineToRelative(dx = 20.93f) + curveToRelative(dx1 = 9.63f, dy1 = 0.0f, dx2 = 14.1f, dy2 = 4.44f, dx3 = 14.1f, dy3 = 14.0f) + verticalLineToRelative(dy = 16.44f) + curveToRelative(dx1 = 0.0f, dy1 = 9.56f, dx2 = -4.47f, dy2 = 14.0f, dx3 = -14.1f, dy3 = 14.0f) + close() + moveTo(x = 1.93f, y = 18.25f) + horizontalLineToRelative(dx = 54.41f) + verticalLineToRelative(dy = -3.16f) + horizontalLineTo(x = 1.94f) + close() + moveToRelative(dx = 18.04f, dy = 2.16f) + horizontalLineToRelative(dx = 19.1f) + arcToRelative(a = 3.6f, b = 3.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 3.74f, dy1 = -3.72f) + arcToRelative(a = 3.6f, b = 3.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -3.75f, dy1 = -3.72f) + horizontalLineToRelative(dx = -19.1f) + arcToRelative(a = 3.6f, b = 3.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -3.74f, dy1 = 3.72f) + arcToRelative(a = 3.6f, b = 3.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 3.75f, dy1 = 3.72f) + moveToRelative(dx = 9.62f, dy = 10.68f) + curveToRelative(dx1 = 1.66f, dy1 = -0.06f, dx2 = 3.04f, dy2 = -1.43f, dx3 = 3.04f, dy3 = -3.0f) + arcToRelative(a = 3.03f, b = 3.03f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -6.07f, dy1 = 0.0f) + arcToRelative(a = 3.03f, b = 3.03f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 3.03f, dy1 = 3.0f) + } + }.build().also { _airPods4Case = it } + } + +@Suppress("ObjectPropertyName") +private var _airPods4Case: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods4CaseFill.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods4CaseFill.kt new file mode 100644 index 00000000..40b5a2d9 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods4CaseFill.kt @@ -0,0 +1,55 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPods4CaseFill: ImageVector + get() { + val current = _airPods4CaseFill + if (current != null) return current + + return ImageVector.Builder( + name = "AirPods4CaseFill", + defaultWidth = 59.375.dp, + defaultHeight = 54.53099822998047.dp, + viewportWidth = 59.375f, + viewportHeight = 54.531f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 0.22f, y = 15.22f) + horizontalLineToRelative(dx = 15.87f) + curveToRelative(dx1 = 0.5f, dy1 = -1.4f, dx2 = 1.85f, dy2 = -2.28f, dx3 = 3.47f, dy3 = -2.28f) + horizontalLineToRelative(dx = 19.97f) + curveToRelative(dx1 = 1.66f, dy1 = 0.0f, dx2 = 3.0f, dy2 = 0.87f, dx3 = 3.5f, dy3 = 2.28f) + horizontalLineTo(x = 59.0f) + curveTo(x1 = 57.81f, y1 = 5.03f, x2 = 51.5f, y2 = 0.0f, x3 = 40.1f, y3 = 0.0f) + horizontalLineTo(x = 19.12f) + curveTo(x1 = 7.69f, y1 = 0.0f, x2 = 1.4f, y2 = 5.03f, x3 = 0.21f, y3 = 15.22f) + moveToRelative(dx = 39.34f, dy = 5.15f) + horizontalLineTo(x = 19.62f) + arcToRelative(a = 3.6f, b = 3.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -3.5f, dy1 = -2.3f) + horizontalLineTo(x = 0.0f) + verticalLineToRelative(dy = 17.4f) + curveTo(x1 = 0.0f, y1 = 48.19f, x2 = 6.34f, y2 = 54.5f, x3 = 19.13f, y3 = 54.5f) + horizontalLineToRelative(dx = 20.93f) + curveToRelative(dx1 = 12.78f, dy1 = 0.0f, dx2 = 19.13f, dy2 = -6.31f, dx3 = 19.13f, dy3 = -19.03f) + verticalLineToRelative(dy = -17.4f) + horizontalLineToRelative(dx = -16.1f) + arcToRelative(a = 3.6f, b = 3.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -3.53f, dy1 = 2.3f) + moveToRelative(dx = -9.97f, dy = 11.7f) + arcToRelative(a = 2.9f, b = 2.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -2.87f, dy1 = -2.85f) + arcToRelative(a = 2.88f, b = 2.88f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 5.75f, dy1 = 0.0f) + curveToRelative(dx1 = 0.0f, dy1 = 1.47f, dx2 = -1.31f, dy2 = 2.78f, dx3 = -2.88f, dy3 = 2.84f) + } + }.build().also { _airPods4CaseFill = it } + } + +@Suppress("ObjectPropertyName") +private var _airPods4CaseFill: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods4Left.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods4Left.kt new file mode 100644 index 00000000..306bef46 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods4Left.kt @@ -0,0 +1,55 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPods4Left: ImageVector + get() { + val current = _airPods4Left + if (current != null) return current + + return ImageVector.Builder( + name = ".MyIcon", + defaultWidth = 33.09400177001953.dp, + defaultHeight = 55.28099822998047.dp, + viewportWidth = 33.094f, + viewportHeight = 55.281f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 20.22f, y = 31.53f) + curveToRelative(dx1 = 8.31f, dy1 = 0.0f, dx2 = 12.69f, dy2 = -7.44f, dx3 = 12.69f, dy3 = -14.75f) + curveToRelative(dx1 = 0.0f, dy1 = -7.75f, dx2 = -11.47f, dy2 = -16.47f, dx3 = -19.75f, dy3 = -16.47f) + curveTo(x1 = 6.75f, y1 = 0.31f, x2 = 0.0f, y2 = 6.28f, x3 = 0.0f, y3 = 14.47f) + curveToRelative(dx1 = 0.0f, dy1 = 8.4f, dx2 = 10.9f, dy2 = 17.06f, dx3 = 20.22f, dy3 = 17.06f) + moveToRelative(dx = 5.28f, dy = -5.44f) + curveToRelative(dx1 = -1.34f, dy1 = 0.72f, dx2 = -1.81f, dy2 = -0.37f, dx3 = -1.81f, dy3 = -1.59f) + curveToRelative(dx1 = 0.0f, dy1 = -3.1f, dx2 = 2.25f, dy2 = -9.6f, dx3 = 4.53f, dy3 = -9.6f) + curveToRelative(dx1 = 0.72f, dy1 = 0.0f, dx2 = 1.12f, dy2 = 0.72f, dx3 = 1.12f, dy3 = 1.91f) + curveToRelative(dx1 = 0.07f, dy1 = 3.25f, dx2 = -1.78f, dy2 = 8.19f, dx3 = -3.84f, dy3 = 9.28f) + moveToRelative(dx = -7.44f, dy = -8.65f) + arcToRelative(a = 3.03f, b = 3.03f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -4.25f, dy1 = 0.68f) + lineToRelative(dx = -1.75f, dy = -1.25f) + arcToRelative(a = 2.87f, b = 2.87f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -0.62f, dy1 = -4.18f) + arcToRelative(a = 2.93f, b = 2.93f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 4.15f, dy1 = -0.72f) + lineToRelative(dx = 1.75f, dy = 1.25f) + arcToRelative(a = 3.0f, b = 3.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 0.72f, dy1 = 4.22f) + moveToRelative(dx = -0.25f, dy = 16.9f) + curveToRelative(dx1 = -4.0f, dy1 = -0.5f, dx2 = -8.06f, dy2 = -2.18f, dx3 = -11.53f, dy3 = -4.65f) + verticalLineToRelative(dy = 20.75f) + curveToRelative(dx1 = 0.0f, dy1 = 4.06f, dx2 = 1.72f, dy2 = 4.84f, dx3 = 5.72f, dy3 = 4.84f) + curveToRelative(dx1 = 4.06f, dy1 = 0.0f, dx2 = 5.81f, dy2 = -0.78f, dx3 = 5.81f, dy3 = -4.84f) + close() + } + }.build().also { _airPods4Left = it } + } + +@Suppress("ObjectPropertyName") +private var _airPods4Left: ImageVector? = null + diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods4Right.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods4Right.kt new file mode 100644 index 00000000..6235d9c3 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPods4Right.kt @@ -0,0 +1,54 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPods4Right: ImageVector + get() { + val current = _airPods4Right + if (current != null) return current + + return ImageVector.Builder( + name = "AirPods4Right", + defaultWidth = 33.09400177001953.dp, + defaultHeight = 55.28099822998047.dp, + viewportWidth = 33.094f, + viewportHeight = 55.281f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 12.69f, y = 31.53f) + curveToRelative(dx1 = 9.28f, dy1 = 0.0f, dx2 = 20.22f, dy2 = -8.66f, dx3 = 20.22f, dy3 = -17.06f) + curveToRelative(dx1 = 0.0f, dy1 = -8.19f, dx2 = -6.75f, dy2 = -14.16f, dx3 = -13.16f, dy3 = -14.16f) + curveTo(x1 = 11.47f, y1 = 0.31f, x2 = 0.0f, y2 = 9.03f, x3 = 0.0f, y3 = 16.78f) + curveToRelative(dx1 = 0.0f, dy1 = 7.31f, dx2 = 4.38f, dy2 = 14.75f, dx3 = 12.69f, dy3 = 14.75f) + moveTo(x = 7.4f, y = 26.1f) + curveTo(x1 = 5.34f, y1 = 25.0f, x2 = 3.5f, y2 = 20.06f, x3 = 3.53f, y3 = 16.81f) + curveToRelative(dx1 = 0.0f, dy1 = -1.18f, dx2 = 0.44f, dy2 = -1.9f, dx3 = 1.16f, dy3 = -1.9f) + curveToRelative(dx1 = 2.28f, dy1 = 0.0f, dx2 = 4.53f, dy2 = 6.5f, dx3 = 4.53f, dy3 = 9.59f) + curveToRelative(dx1 = 0.0f, dy1 = 1.22f, dx2 = -0.47f, dy2 = 2.31f, dx3 = -1.81f, dy3 = 1.6f) + moveToRelative(dx = 7.43f, dy = -8.65f) + arcToRelative(a = 3.0f, b = 3.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 0.72f, dy1 = -4.22f) + lineToRelative(dx = 1.75f, dy = -1.25f) + curveToRelative(dx1 = 1.4f, dy1 = -1.0f, dx2 = 3.22f, dy2 = -0.63f, dx3 = 4.16f, dy3 = 0.72f) + arcToRelative(a = 2.9f, b = 2.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -0.63f, dy1 = 4.19f) + lineToRelative(dx = -1.75f, dy = 1.25f) + arcToRelative(a = 3.03f, b = 3.03f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -4.25f, dy1 = -0.7f) + moveToRelative(dx = 0.25f, dy = 16.9f) + verticalLineToRelative(dy = 16.1f) + curveToRelative(dx1 = 0.0f, dy1 = 4.06f, dx2 = 1.75f, dy2 = 4.84f, dx3 = 5.82f, dy3 = 4.84f) + curveToRelative(dx1 = 4.0f, dy1 = 0.0f, dx2 = 5.72f, dy2 = -0.78f, dx3 = 5.72f, dy3 = -4.84f) + verticalLineTo(y = 29.69f) + arcToRelative(a = 26.0f, b = 26.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -11.54f, dy1 = 4.65f) + } + }.build().also { _airPods4Right = it } + } + +@Suppress("ObjectPropertyName") +private var _airPods4Right: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsCase.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsCase.kt new file mode 100644 index 00000000..f34e609a --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsCase.kt @@ -0,0 +1,61 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPodsCase: ImageVector + get() { + val current = _airPodsCase + if (current != null) return current + + return ImageVector.Builder( + name = "AirPodsCase", + defaultWidth = 51.0.dp, + defaultHeight = 62.09400177001953.dp, + viewportWidth = 51.0f, + viewportHeight = 62.094f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 15.75f, y = 62.06f) + horizontalLineToRelative(dx = 19.31f) + curveToRelative(dx1 = 10.63f, dy1 = 0.0f, dx2 = 15.75f, dy2 = -5.12f, dx3 = 15.75f, dy3 = -15.75f) + verticalLineTo(y = 15.75f) + curveTo(x1 = 50.81f, y1 = 5.13f, x2 = 45.7f, y2 = 0.0f, x3 = 35.06f, y3 = 0.0f) + horizontalLineTo(x = 15.75f) + curveTo(x1 = 5.13f, y1 = 0.0f, x2 = 0.0f, y2 = 5.13f, x3 = 0.0f, y3 = 15.75f) + verticalLineToRelative(dy = 30.56f) + curveToRelative(dx1 = 0.0f, dy1 = 10.63f, dx2 = 5.13f, dy2 = 15.75f, dx3 = 15.75f, dy3 = 15.75f) + moveToRelative(dx = 0.0f, dy = -5.03f) + curveToRelative(dx1 = -7.47f, dy1 = 0.0f, dx2 = -10.72f, dy2 = -3.25f, dx3 = -10.72f, dy3 = -10.72f) + verticalLineTo(y = 15.75f) + curveToRelative(dx1 = 0.0f, dy1 = -7.47f, dx2 = 3.25f, dy2 = -10.72f, dx3 = 10.72f, dy3 = -10.72f) + horizontalLineToRelative(dx = 19.31f) + curveToRelative(dx1 = 7.47f, dy1 = 0.0f, dx2 = 10.72f, dy2 = 3.25f, dx3 = 10.72f, dy3 = 10.72f) + verticalLineToRelative(dy = 30.56f) + curveToRelative(dx1 = 0.0f, dy1 = 7.47f, dx2 = -3.25f, dy2 = 10.72f, dx3 = -10.72f, dy3 = 10.72f) + close() + moveTo(x = 2.53f, y = 20.81f) + horizontalLineToRelative(dx = 45.75f) + verticalLineToRelative(dy = -3.15f) + horizontalLineTo(x = 2.53f) + close() + moveToRelative(dx = 14.22f, dy = 2.13f) + horizontalLineToRelative(dx = 17.31f) + arcToRelative(a = 3.6f, b = 3.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 3.75f, dy1 = -3.72f) + arcToRelative(a = 3.6f, b = 3.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -3.75f, dy1 = -3.72f) + horizontalLineTo(x = 16.75f) + arcTo(horizontalEllipseRadius = 3.66f, verticalEllipseRadius = 3.66f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 13.0f, y1 = 19.22f) + arcToRelative(a = 3.66f, b = 3.66f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 3.75f, dy1 = 3.72f) + } + }.build().also { _airPodsCase = it } + } + +@Suppress("ObjectPropertyName") +private var _airPodsCase: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsCaseFill.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsCaseFill.kt new file mode 100644 index 00000000..9c362f45 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsCaseFill.kt @@ -0,0 +1,53 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPodsCaseFill: ImageVector + get() { + val current = _airPodsCaseFill + if (current != null) return current + + return ImageVector.Builder( + name = "AirPodsCaseFill", + defaultWidth = 51.0.dp, + defaultHeight = 62.09400177001953.dp, + viewportWidth = 51.0f, + viewportHeight = 62.094f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 15.75f, y = 62.06f) + horizontalLineToRelative(dx = 19.31f) + curveToRelative(dx1 = 10.63f, dy1 = 0.0f, dx2 = 15.75f, dy2 = -5.12f, dx3 = 15.75f, dy3 = -15.75f) + verticalLineToRelative(dy = -25.5f) + horizontalLineTo(x = 37.6f) + arcToRelative(a = 3.8f, b = 3.8f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -3.53f, dy1 = 2.13f) + horizontalLineTo(x = 16.75f) + arcToRelative(a = 3.8f, b = 3.8f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -3.5f, dy1 = -2.13f) + horizontalLineTo(x = 0.0f) + verticalLineToRelative(dy = 25.5f) + curveToRelative(dx1 = 0.0f, dy1 = 10.63f, dx2 = 5.13f, dy2 = 15.75f, dx3 = 15.75f, dy3 = 15.75f) + moveTo(x = 0.0f, y = 17.66f) + horizontalLineToRelative(dx = 13.25f) + arcToRelative(a = 3.8f, b = 3.8f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 3.5f, dy1 = -2.16f) + horizontalLineToRelative(dx = 17.31f) + arcToRelative(a = 3.7f, b = 3.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 3.53f, dy1 = 2.16f) + horizontalLineToRelative(dx = 13.22f) + verticalLineToRelative(dy = -1.91f) + curveTo(x1 = 50.81f, y1 = 5.13f, x2 = 45.7f, y2 = 0.0f, x3 = 35.06f, y3 = 0.0f) + horizontalLineTo(x = 15.75f) + curveTo(x1 = 5.13f, y1 = 0.0f, x2 = 0.0f, y2 = 5.13f, x3 = 0.0f, y3 = 15.75f) + close() + } + }.build().also { _airPodsCaseFill = it } + } + +@Suppress("ObjectPropertyName") +private var _airPodsCaseFill: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsMax.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsMax.kt new file mode 100644 index 00000000..bf997230 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsMax.kt @@ -0,0 +1,88 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPodsMax: ImageVector + get() { + val current = _airpodsMax + if (current != null) return current + + return ImageVector.Builder( + name = "AirPodsMax", + defaultWidth = 64.5199966430664.dp, + defaultHeight = 69.53099822998047.dp, + viewportWidth = 64.52f, + viewportHeight = 69.531f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 2.0f, y = 40.1f) + horizontalLineToRelative(dx = 2.84f) + verticalLineTo(y = 33.8f) + horizontalLineTo(x = 1.99f) + close() + moveToRelative(dx = 14.78f, dy = 29.43f) + curveToRelative(dx1 = 3.9f, dy1 = 0.0f, dx2 = 5.71f, dy2 = -3.6f, dx3 = 3.59f, dy3 = -7.4f) + lineTo(x = 8.68f, y = 41.47f) + curveToRelative(dx1 = -1.0f, dy1 = -1.72f, dx2 = -2.5f, dy2 = -2.5f, dx3 = -4.37f, dy3 = -2.5f) + curveToRelative(dx1 = -2.94f, dy1 = 0.0f, dx2 = -4.53f, dy2 = 2.19f, dx3 = -4.28f, dy3 = 5.1f) + curveToRelative(dx1 = 0.62f, dy1 = 3.71f, dx2 = 3.12f, dy2 = 9.65f, dx3 = 6.15f, dy3 = 14.52f) + curveToRelative(dx1 = 4.78f, dy1 = 8.1f, dx2 = 7.56f, dy2 = 10.94f, dx3 = 10.6f, dy3 = 10.94f) + moveTo(x = 62.34f, y = 40.1f) + verticalLineToRelative(dy = -6.28f) + horizontalLineToRelative(dx = -2.85f) + verticalLineToRelative(dy = 6.28f) + close() + moveTo(x = 47.56f, y = 69.53f) + curveToRelative(dx1 = 3.03f, dy1 = 0.0f, dx2 = 5.8f, dy2 = -2.84f, dx3 = 10.6f, dy3 = -10.94f) + curveToRelative(dx1 = 3.0f, dy1 = -4.87f, dx2 = 5.52f, dy2 = -10.8f, dx3 = 6.15f, dy3 = -14.53f) + curveToRelative(dx1 = 0.25f, dy1 = -2.9f, dx2 = -1.35f, dy2 = -5.1f, dx3 = -4.28f, dy3 = -5.1f) + arcToRelative(a = 4.8f, b = 4.8f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -4.38f, dy1 = 2.5f) + lineTo(x = 43.96f, y = 62.14f) + curveToRelative(dx1 = -2.15f, dy1 = 3.8f, dx2 = -0.3f, dy2 = 7.4f, dx3 = 3.6f, dy3 = 7.4f) + moveTo(x = 0.93f, y = 33.28f) + curveToRelative(dx1 = 0.0f, dy1 = 1.47f, dx2 = 0.94f, dy2 = 2.38f, dx3 = 2.4f, dy3 = 2.38f) + curveToRelative(dx1 = 1.48f, dy1 = 0.0f, dx2 = 2.35f, dy2 = -0.91f, dx3 = 2.35f, dy3 = -2.38f) + curveTo(x1 = 5.84f, y1 = 16.44f, x2 = 15.87f, y2 = 5.75f, x3 = 32.15f, y3 = 5.75f) + curveToRelative(dx1 = 16.31f, dy1 = 0.0f, dx2 = 26.31f, dy2 = 10.69f, dx3 = 26.47f, dy3 = 27.53f) + curveToRelative(dx1 = 0.0f, dy1 = 1.47f, dx2 = 0.9f, dy2 = 2.38f, dx3 = 2.37f, dy3 = 2.38f) + reflectiveCurveToRelative(dx1 = 2.38f, dy1 = -0.91f, dx2 = 2.38f, dy2 = -2.38f) + curveToRelative(dx1 = 0.0f, dy1 = -19.15f, dx2 = -11.78f, dy2 = -32.31f, dx3 = -31.22f, dy3 = -32.31f) + reflectiveCurveTo(x1 = 0.93f, y1 = 14.13f, x2 = 0.93f, y2 = 33.28f) + } + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.425f, + ) { + moveTo(x = 26.12f, y = 63.34f) + curveToRelative(dx1 = 2.0f, dy1 = -1.15f, dx2 = 2.53f, dy2 = -3.75f, dx3 = 1.25f, dy3 = -5.97f) + lineTo(x = 16.84f, y = 38.78f) + curveToRelative(dx1 = -1.28f, dy1 = -2.22f, dx2 = -3.78f, dy2 = -3.03f, dx3 = -5.75f, dy3 = -1.87f) + curveToRelative(dx1 = -1.22f, dy1 = 0.65f, dx2 = -1.5f, dy2 = 1.8f, dx3 = -0.88f, dy3 = 2.9f) + lineToRelative(dx = 12.94f, dy = 22.75f) + curveToRelative(dx1 = 0.66f, dy1 = 1.13f, dx2 = 1.78f, dy2 = 1.44f, dx3 = 2.97f, dy3 = 0.78f) + moveToRelative(dx = 12.1f, dy = 0.0f) + curveToRelative(dx1 = 1.18f, dy1 = 0.66f, dx2 = 2.3f, dy2 = 0.35f, dx3 = 2.93f, dy3 = -0.78f) + lineTo(x = 54.1f, y = 39.81f) + curveToRelative(dx1 = 0.65f, dy1 = -1.1f, dx2 = 0.37f, dy2 = -2.25f, dx3 = -0.85f, dy3 = -2.9f) + curveToRelative(dx1 = -2.0f, dy1 = -1.16f, dx2 = -4.46f, dy2 = -0.35f, dx3 = -5.78f, dy3 = 1.87f) + lineToRelative(dx = -10.53f, dy = 18.6f) + curveToRelative(dx1 = -1.28f, dy1 = 2.21f, dx2 = -0.72f, dy2 = 4.8f, dx3 = 1.28f, dy3 = 5.96f) + moveTo(x = 13.9f, y = 16.63f) + curveToRelative(dx1 = 5.13f, dy1 = -2.79f, dx2 = 11.13f, dy2 = -4.16f, dx3 = 18.25f, dy3 = -4.16f) + curveToRelative(dx1 = 7.16f, dy1 = 0.0f, dx2 = 13.16f, dy2 = 1.37f, dx3 = 18.25f, dy3 = 4.15f) + curveToRelative(dx1 = -4.1f, dy1 = -5.25f, dx2 = -9.94f, dy2 = -7.87f, dx3 = -18.25f, dy3 = -7.87f) + curveToRelative(dx1 = -8.28f, dy1 = 0.0f, dx2 = -14.12f, dy2 = 2.63f, dx3 = -18.25f, dy3 = 7.88f) + } + }.build().also { _airpodsMax = it } + } + +@Suppress("ObjectPropertyName") +private var _airpodsMax: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro1.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro1.kt new file mode 100644 index 00000000..a9d8d745 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro1.kt @@ -0,0 +1,100 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPodsPro1: ImageVector + get() { + val current = _airPodsProGen1 + if (current != null) return current + + return ImageVector.Builder( + name = "AirPodsPro1", + defaultWidth = 96.84400177001953.dp, + defaultHeight = 61.65700149536133.dp, + viewportWidth = 96.844f, + viewportHeight = 61.657f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 32.4f, y = 55.53f) + curveToRelative(dx1 = 0.0f, dy1 = 1.94f, dx2 = -1.27f, dy2 = 2.94f, dx3 = -3.21f, dy3 = 2.94f) + horizontalLineToRelative(dx = -2.44f) + curveToRelative(dx1 = -1.97f, dy1 = 0.0f, dx2 = -3.22f, dy2 = -1.0f, dx3 = -3.22f, dy3 = -2.94f) + verticalLineTo(y = 36.64f) + arcToRelative(a = 31.0f, b = 31.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 8.88f, dy1 = -1.73f) + close() + moveToRelative(dx = 40.73f, dy = -18.89f) + verticalLineToRelative(dy = 18.9f) + curveToRelative(dx1 = 0.0f, dy1 = 1.93f, dx2 = -1.25f, dy2 = 2.93f, dx3 = -3.22f, dy3 = 2.93f) + horizontalLineToRelative(dx = -2.44f) + curveToRelative(dx1 = -1.94f, dy1 = 0.0f, dx2 = -3.22f, dy2 = -1.0f, dx3 = -3.22f, dy3 = -2.94f) + verticalLineTo(y = 34.91f) + arcToRelative(a = 31.0f, b = 31.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 8.88f, dy1 = 1.73f) + moveTo(x = 43.78f, y = 15.75f) + curveToRelative(dx1 = 0.0f, dy1 = 6.9f, dx2 = -4.1f, dy2 = 11.72f, dx3 = -9.22f, dy3 = 14.69f) + arcToRelative(a = 22.0f, b = 22.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -9.0f, dy1 = 2.8f) + quadToRelative(dx1 = 0.98f, dy1 = -2.24f, dx2 = 1.0f, dy2 = -5.18f) + curveToRelative(dx1 = 0.0f, dy1 = -6.33f, dx2 = -5.22f, dy2 = -12.77f, dx3 = -12.4f, dy3 = -13.92f) + arcToRelative(a = 18.0f, b = 18.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 1.78f, dy1 = -2.83f) + curveToRelative(dx1 = 4.0f, dy1 = -5.56f, dx2 = 9.97f, dy2 = -8.18f, dx3 = 15.31f, dy3 = -8.12f) + curveToRelative(dx1 = 6.9f, dy1 = 0.06f, dx2 = 12.53f, dy2 = 4.6f, dx3 = 12.53f, dy3 = 12.56f) + moveToRelative(dx = 36.94f, dy = -4.44f) + arcToRelative(a = 18.0f, b = 18.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 1.77f, dy1 = 2.83f) + curveToRelative(dx1 = -7.18f, dy1 = 1.15f, dx2 = -12.4f, dy2 = 7.59f, dx3 = -12.4f, dy3 = 13.92f) + quadToRelative(dx1 = 0.02f, dy1 = 2.95f, dx2 = 1.0f, dy2 = 5.18f) + arcToRelative(a = 22.0f, b = 22.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -9.0f, dy1 = -2.8f) + curveToRelative(dx1 = -5.15f, dy1 = -2.97f, dx2 = -9.22f, dy2 = -7.78f, dx3 = -9.22f, dy3 = -14.69f) + curveToRelative(dx1 = 0.0f, dy1 = -7.97f, dx2 = 5.63f, dy2 = -12.5f, dx3 = 12.54f, dy3 = -12.56f) + curveToRelative(dx1 = 5.34f, dy1 = -0.06f, dx2 = 11.3f, dy2 = 2.56f, dx3 = 15.3f, dy3 = 8.12f) + moveToRelative(dx = -45.6f, dy = -0.9f) + lineToRelative(dx = -4.59f, dy = 3.87f) + arcToRelative(a = 1.5f, b = 1.5f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -0.19f, dy1 = 2.13f) + arcToRelative(a = 1.46f, b = 1.46f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.1f, dy1 = 0.18f) + lineToRelative(dx = 4.65f, dy = -3.87f) + arcToRelative(a = 1.43f, b = 1.43f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.13f, dy1 = -2.1f) + arcToRelative(a = 1.46f, b = 1.46f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.1f, dy1 = -0.21f) + moveToRelative(dx = 24.32f, dy = 0.22f) + arcToRelative(a = 1.43f, b = 1.43f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.12f, dy1 = 2.09f) + lineToRelative(dx = 4.66f, dy = 3.87f) + arcToRelative(a = 1.46f, b = 1.46f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.1f, dy1 = -0.18f) + arcToRelative(a = 1.47f, b = 1.47f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -0.23f, dy1 = -2.13f) + lineToRelative(dx = -4.56f, dy = -3.87f) + arcToRelative(a = 1.46f, b = 1.46f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.1f, dy1 = 0.22f) + } + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.425f, + ) { + moveTo(x = 14.38f, y = 37.13f) + curveToRelative(dx1 = 4.87f, dy1 = 0.0f, dx2 = 9.15f, dy2 = -3.0f, dx3 = 9.15f, dy3 = -9.07f) + curveToRelative(dx1 = 0.0f, dy1 = -5.4f, dx2 = -5.1f, dy2 = -11.12f, dx3 = -11.69f, dy3 = -11.12f) + curveToRelative(dx1 = -5.62f, dy1 = 0.0f, dx2 = -8.8f, dy2 = 4.44f, dx3 = -8.8f, dy3 = 8.97f) + curveToRelative(dx1 = 0.0f, dy1 = 6.78f, dx2 = 5.74f, dy2 = 11.22f, dx3 = 11.34f, dy3 = 11.22f) + moveToRelative(dx = -1.54f, dy = -4.75f) + curveToRelative(dx1 = -0.9f, dy1 = 0.78f, dx2 = -2.37f, dy2 = 0.0f, dx3 = -4.25f, dy3 = -2.22f) + curveToRelative(dx1 = -1.78f, dy1 = -2.22f, dx2 = -2.25f, dy2 = -3.75f, dx3 = -1.4f, dy3 = -4.5f) + quadToRelative(dx1 = 1.41f, dy1 = -1.21f, dx2 = 4.25f, dy2 = 2.18f) + curveToRelative(dx1 = 1.78f, dy1 = 2.29f, dx2 = 2.28f, dy2 = 3.79f, dx3 = 1.4f, dy3 = 4.54f) + moveToRelative(dx = 69.44f, dy = 4.75f) + curveToRelative(dx1 = 5.6f, dy1 = 0.0f, dx2 = 11.35f, dy2 = -4.44f, dx3 = 11.35f, dy3 = -11.22f) + curveToRelative(dx1 = 0.0f, dy1 = -4.53f, dx2 = -3.2f, dy2 = -8.97f, dx3 = -8.82f, dy3 = -8.97f) + curveToRelative(dx1 = -6.6f, dy1 = 0.0f, dx2 = -11.69f, dy2 = 5.72f, dx3 = -11.69f, dy3 = 11.12f) + curveToRelative(dx1 = 0.0f, dy1 = 6.07f, dx2 = 4.29f, dy2 = 9.07f, dx3 = 9.16f, dy3 = 9.07f) + moveToRelative(dx = 1.53f, dy = -4.75f) + curveToRelative(dx1 = -0.87f, dy1 = -0.75f, dx2 = -0.37f, dy2 = -2.25f, dx3 = 1.4f, dy3 = -4.54f) + quadToRelative(dx1 = 2.83f, dy1 = -3.4f, dx2 = 4.26f, dy2 = -2.18f) + curveToRelative(dx1 = 0.84f, dy1 = 0.75f, dx2 = 0.37f, dy2 = 2.28f, dx3 = -1.4f, dy3 = 4.5f) + curveToRelative(dx1 = -1.88f, dy1 = 2.22f, dx2 = -3.35f, dy2 = 3.0f, dx3 = -4.26f, dy3 = 2.22f) + } + }.build().also { _airPodsProGen1 = it } + } + +@Suppress("ObjectPropertyName") +private var _airPodsProGen1: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro1Case.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro1Case.kt new file mode 100644 index 00000000..b94cf2f7 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro1Case.kt @@ -0,0 +1,66 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPodsPro1Case: ImageVector + get() { + val current = _airPodsPro1Case + if (current != null) return current + + return ImageVector.Builder( + name = "AirPodsPro1Case", + defaultWidth = 70.93800354003906.dp, + defaultHeight = 54.53099822998047.dp, + viewportWidth = 70.938f, + viewportHeight = 54.531f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 18.94f, y = 54.5f) + horizontalLineTo(x = 51.8f) + curveToRelative(dx1 = 12.78f, dy1 = 0.0f, dx2 = 18.94f, dy2 = -6.12f, dx3 = 18.94f, dy3 = -18.84f) + verticalLineTo(y = 18.84f) + curveTo(x1 = 70.75f, y1 = 6.13f, x2 = 64.59f, y2 = 0.0f, x3 = 51.81f, y3 = 0.0f) + horizontalLineTo(x = 18.94f) + curveTo(x1 = 6.19f, y1 = 0.0f, x2 = 0.0f, y2 = 6.13f, x3 = 0.0f, y3 = 18.84f) + verticalLineToRelative(dy = 16.82f) + curveTo(x1 = 0.0f, y1 = 48.38f, x2 = 6.19f, y2 = 54.5f, x3 = 18.94f, y3 = 54.5f) + moveToRelative(dx = 0.0f, dy = -5.03f) + curveToRelative(dx1 = -9.6f, dy1 = 0.0f, dx2 = -13.9f, dy2 = -4.28f, dx3 = -13.9f, dy3 = -13.81f) + verticalLineTo(y = 18.84f) + curveToRelative(dx1 = 0.0f, dy1 = -9.53f, dx2 = 4.3f, dy2 = -13.8f, dx3 = 13.9f, dy3 = -13.8f) + horizontalLineTo(x = 51.8f) + curveToRelative(dx1 = 9.63f, dy1 = 0.0f, dx2 = 13.9f, dy2 = 4.27f, dx3 = 13.9f, dy3 = 13.8f) + verticalLineToRelative(dy = 16.82f) + curveToRelative(dx1 = 0.0f, dy1 = 9.53f, dx2 = -4.27f, dy2 = 13.8f, dx3 = -13.9f, dy3 = 13.8f) + close() + moveToRelative(dx = -16.4f, dy = -28.5f) + horizontalLineToRelative(dx = 65.68f) + verticalLineTo(y = 17.8f) + horizontalLineTo(x = 2.53f) + close() + moveToRelative(dx = 20.34f, dy = 2.16f) + horizontalLineTo(x = 47.9f) + arcToRelative(a = 3.6f, b = 3.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 3.75f, dy1 = -3.75f) + arcToRelative(a = 3.6f, b = 3.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -3.75f, dy1 = -3.72f) + horizontalLineTo(x = 22.88f) + arcToRelative(a = 3.65f, b = 3.65f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -3.79f, dy1 = 3.72f) + arcToRelative(a = 3.65f, b = 3.65f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 3.79f, dy1 = 3.75f) + moveToRelative(dx = 12.5f, dy = 10.65f) + arcToRelative(a = 3.1f, b = 3.1f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 3.03f, dy1 = -2.97f) + arcToRelative(a = 3.05f, b = 3.05f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -3.03f, dy1 = -3.03f) + curveToRelative(dx1 = -1.66f, dy1 = 0.0f, dx2 = -3.0f, dy2 = 1.38f, dx3 = -3.0f, dy3 = 3.03f) + curveToRelative(dx1 = 0.0f, dy1 = 1.63f, dx2 = 1.34f, dy2 = 3.0f, dx3 = 3.0f, dy3 = 2.97f) + } + }.build().also { _airPodsPro1Case = it } + } + +@Suppress("ObjectPropertyName") +private var _airPodsPro1Case: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro1CaseFill.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro1CaseFill.kt new file mode 100644 index 00000000..8ce3db02 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro1CaseFill.kt @@ -0,0 +1,58 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPodsPro1CaseFill: ImageVector + get() { + val current = _airPodsPro1CaseFill + if (current != null) return current + + return ImageVector.Builder( + name = "AirPodsPro1CaseFill", + defaultWidth = 70.93800354003906.dp, + defaultHeight = 54.53099822998047.dp, + viewportWidth = 70.938f, + viewportHeight = 54.531f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 18.94f, y = 54.5f) + horizontalLineTo(x = 51.8f) + curveToRelative(dx1 = 12.78f, dy1 = 0.0f, dx2 = 18.94f, dy2 = -6.12f, dx3 = 18.94f, dy3 = -18.84f) + verticalLineTo(y = 20.8f) + horizontalLineTo(x = 51.41f) + arcToRelative(a = 3.6f, b = 3.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -3.5f, dy1 = 2.32f) + horizontalLineTo(x = 22.88f) + curveToRelative(dx1 = -1.63f, dy1 = 0.0f, dx2 = -3.0f, dy2 = -0.91f, dx3 = -3.5f, dy3 = -2.32f) + horizontalLineTo(x = 0.0f) + verticalLineToRelative(dy = 14.85f) + curveTo(x1 = 0.0f, y1 = 48.38f, x2 = 6.19f, y2 = 54.5f, x3 = 18.94f, y3 = 54.5f) + moveToRelative(dx = 16.43f, dy = -19.47f) + arcToRelative(a = 2.83f, b = 2.83f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -2.84f, dy1 = -2.81f) + arcToRelative(a = 2.9f, b = 2.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 2.84f, dy1 = -2.88f) + arcToRelative(a = 2.9f, b = 2.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 2.88f, dy1 = 2.88f) + arcToRelative(a = 2.94f, b = 2.94f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -2.87f, dy1 = 2.81f) + moveTo(x = 0.0f, y = 17.97f) + horizontalLineToRelative(dx = 19.31f) + curveToRelative(dx1 = 0.5f, dy1 = -1.44f, dx2 = 1.88f, dy2 = -2.31f, dx3 = 3.5f, dy3 = -2.31f) + horizontalLineToRelative(dx = 25.07f) + curveToRelative(dx1 = 1.62f, dy1 = 0.0f, dx2 = 2.96f, dy2 = 0.87f, dx3 = 3.46f, dy3 = 2.3f) + horizontalLineToRelative(dx = 19.32f) + verticalLineToRelative(dy = -1.0f) + curveTo(x1 = 70.66f, y1 = 5.57f, x2 = 63.9f, y2 = 0.0f, x3 = 51.72f, y3 = 0.0f) + horizontalLineTo(x = 18.94f) + curveTo(x1 = 6.78f, y1 = 0.0f, x2 = 0.0f, y2 = 5.56f, x3 = 0.0f, y3 = 16.97f) + close() + } + }.build().also { _airPodsPro1CaseFill = it } + } + +@Suppress("ObjectPropertyName") +private var _airPodsPro1CaseFill: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro1Left.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro1Left.kt new file mode 100644 index 00000000..214a00ad --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro1Left.kt @@ -0,0 +1,68 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPodsPro1Left: ImageVector + get() { + val current = _airPodsPro1Left + if (current != null) return current + + return ImageVector.Builder( + name = "AirPodsPro1Left", + defaultWidth = 47.28099822998047.dp, + defaultHeight = 61.65700149536133.dp, + viewportWidth = 47.281f, + viewportHeight = 61.657f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 23.4f, y = 36.64f) + verticalLineToRelative(dy = 18.9f) + curveToRelative(dx1 = 0.0f, dy1 = 1.93f, dx2 = -1.27f, dy2 = 2.93f, dx3 = -3.21f, dy3 = 2.93f) + horizontalLineToRelative(dx = -2.44f) + curveToRelative(dx1 = -1.94f, dy1 = 0.0f, dx2 = -3.22f, dy2 = -1.0f, dx3 = -3.22f, dy3 = -2.94f) + verticalLineTo(y = 34.91f) + arcToRelative(a = 31.0f, b = 31.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 8.88f, dy1 = 1.73f) + moveTo(x = 31.0f, y = 11.31f) + arcToRelative(a = 18.0f, b = 18.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 1.78f, dy1 = 2.83f) + curveToRelative(dx1 = -7.19f, dy1 = 1.15f, dx2 = -12.4f, dy2 = 7.59f, dx3 = -12.4f, dy3 = 13.92f) + quadToRelative(dx1 = 0.02f, dy1 = 2.95f, dx2 = 1.0f, dy2 = 5.18f) + arcToRelative(a = 22.0f, b = 22.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -9.0f, dy1 = -2.8f) + curveToRelative(dx1 = -5.16f, dy1 = -2.97f, dx2 = -9.22f, dy2 = -7.78f, dx3 = -9.22f, dy3 = -14.69f) + curveToRelative(dx1 = 0.0f, dy1 = -7.97f, dx2 = 5.62f, dy2 = -12.5f, dx3 = 12.53f, dy3 = -12.56f) + curveTo(x1 = 21.03f, y1 = 3.13f, x2 = 27.0f, y2 = 5.75f, x3 = 31.0f, y3 = 11.3f) + moveTo(x = 9.72f, y = 10.63f) + arcToRelative(a = 1.43f, b = 1.43f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.12f, dy1 = 2.09f) + lineToRelative(dx = 4.66f, dy = 3.87f) + arcToRelative(a = 1.46f, b = 1.46f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.1f, dy1 = -0.18f) + arcToRelative(a = 1.47f, b = 1.47f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -0.23f, dy1 = -2.13f) + lineToRelative(dx = -4.56f, dy = -3.87f) + arcToRelative(a = 1.46f, b = 1.46f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.1f, dy1 = 0.22f) + } + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.425f, + ) { + moveTo(x = 32.56f, y = 37.13f) + curveToRelative(dx1 = 5.6f, dy1 = 0.0f, dx2 = 11.35f, dy2 = -4.44f, dx3 = 11.35f, dy3 = -11.22f) + curveToRelative(dx1 = 0.0f, dy1 = -4.53f, dx2 = -3.2f, dy2 = -8.97f, dx3 = -8.82f, dy3 = -8.97f) + curveToRelative(dx1 = -6.59f, dy1 = 0.0f, dx2 = -11.68f, dy2 = 5.72f, dx3 = -11.68f, dy3 = 11.12f) + curveToRelative(dx1 = 0.0f, dy1 = 6.07f, dx2 = 4.28f, dy2 = 9.07f, dx3 = 9.15f, dy3 = 9.07f) + moveToRelative(dx = 1.53f, dy = -4.75f) + curveToRelative(dx1 = -0.87f, dy1 = -0.75f, dx2 = -0.37f, dy2 = -2.25f, dx3 = 1.41f, dy3 = -4.54f) + curveToRelative(dx1 = 1.84f, dy1 = -2.25f, dx2 = 3.31f, dy2 = -3.0f, dx3 = 4.25f, dy3 = -2.18f) + curveToRelative(dx1 = 0.84f, dy1 = 0.75f, dx2 = 0.38f, dy2 = 2.28f, dx3 = -1.4f, dy3 = 4.5f) + curveToRelative(dx1 = -1.88f, dy1 = 2.22f, dx2 = -3.35f, dy2 = 3.0f, dx3 = -4.26f, dy3 = 2.22f) + } + }.build().also { _airPodsPro1Left = it } + } + +@Suppress("ObjectPropertyName") +private var _airPodsPro1Left: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro1Right.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro1Right.kt new file mode 100644 index 00000000..e79dd4a8 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro1Right.kt @@ -0,0 +1,68 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPodsPro1Right: ImageVector + get() { + val current = _airPodsPro1Right + if (current != null) return current + + return ImageVector.Builder( + name = "AirPodsPro1Right", + defaultWidth = 46.96900177001953.dp, + defaultHeight = 61.65700149536133.dp, + viewportWidth = 46.969f, + viewportHeight = 61.657f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 32.38f, y = 55.53f) + curveToRelative(dx1 = 0.0f, dy1 = 1.94f, dx2 = -1.26f, dy2 = 2.94f, dx3 = -3.2f, dy3 = 2.94f) + horizontalLineToRelative(dx = -2.43f) + curveToRelative(dx1 = -1.97f, dy1 = 0.0f, dx2 = -3.25f, dy2 = -1.0f, dx3 = -3.25f, dy3 = -2.94f) + verticalLineTo(y = 36.65f) + arcToRelative(a = 31.0f, b = 31.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 8.88f, dy1 = -1.73f) + close() + moveToRelative(dx = 11.37f, dy = -39.78f) + curveToRelative(dx1 = 0.0f, dy1 = 6.9f, dx2 = -4.06f, dy2 = 11.72f, dx3 = -9.19f, dy3 = 14.69f) + arcToRelative(a = 22.0f, b = 22.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -9.03f, dy1 = 2.8f) + quadToRelative(dx1 = 0.98f, dy1 = -2.22f, dx2 = 1.0f, dy2 = -5.18f) + curveToRelative(dx1 = 0.0f, dy1 = -6.33f, dx2 = -5.19f, dy2 = -12.76f, dx3 = -12.38f, dy3 = -13.91f) + arcToRelative(a = 19.0f, b = 19.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 1.79f, dy1 = -2.84f) + curveToRelative(dx1 = 4.0f, dy1 = -5.56f, dx2 = 9.94f, dy2 = -8.18f, dx3 = 15.31f, dy3 = -8.12f) + curveToRelative(dx1 = 6.88f, dy1 = 0.06f, dx2 = 12.5f, dy2 = 4.6f, dx3 = 12.5f, dy3 = 12.56f) + moveToRelative(dx = -8.62f, dy = -5.34f) + lineToRelative(dx = -4.6f, dy = 3.87f) + arcToRelative(a = 1.5f, b = 1.5f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -0.19f, dy1 = 2.13f) + arcToRelative(a = 1.46f, b = 1.46f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.1f, dy1 = 0.18f) + lineToRelative(dx = 4.62f, dy = -3.87f) + arcToRelative(a = 1.46f, b = 1.46f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.16f, dy1 = -2.1f) + arcToRelative(a = 1.46f, b = 1.46f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -2.1f, dy1 = -0.21f) + } + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.425f, + ) { + moveTo(x = 14.38f, y = 37.13f) + curveToRelative(dx1 = 4.87f, dy1 = 0.0f, dx2 = 9.15f, dy2 = -3.0f, dx3 = 9.15f, dy3 = -9.07f) + curveToRelative(dx1 = 0.0f, dy1 = -5.4f, dx2 = -5.1f, dy2 = -11.12f, dx3 = -11.72f, dy3 = -11.12f) + curveToRelative(dx1 = -5.6f, dy1 = 0.0f, dx2 = -8.81f, dy2 = 4.44f, dx3 = -8.81f, dy3 = 8.97f) + curveToRelative(dx1 = 0.0f, dy1 = 6.78f, dx2 = 5.78f, dy2 = 11.22f, dx3 = 11.38f, dy3 = 11.22f) + moveToRelative(dx = -1.54f, dy = -4.75f) + curveToRelative(dx1 = -0.9f, dy1 = 0.78f, dx2 = -2.37f, dy2 = 0.0f, dx3 = -4.25f, dy3 = -2.22f) + curveToRelative(dx1 = -1.8f, dy1 = -2.22f, dx2 = -2.28f, dy2 = -3.75f, dx3 = -1.4f, dy3 = -4.5f) + quadToRelative(dx1 = 1.41f, dy1 = -1.21f, dx2 = 4.25f, dy2 = 2.18f) + curveToRelative(dx1 = 1.78f, dy1 = 2.29f, dx2 = 2.28f, dy2 = 3.79f, dx3 = 1.4f, dy3 = 4.54f) + } + }.build().also { _airPodsPro1Right = it } + } + +@Suppress("ObjectPropertyName") +private var _airPodsPro1Right: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro3.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro3.kt new file mode 100644 index 00000000..88dcd3b4 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro3.kt @@ -0,0 +1,98 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPodsPro3: ImageVector + get() { + val current = _airPodsProGen3 + if (current != null) return current + + return ImageVector.Builder( + name = "AirPodsProGen3", + defaultWidth = 82.28099822998047.dp, + defaultHeight = 61.78300094604492.dp, + viewportWidth = 82.281f, + viewportHeight = 61.783f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 28.47f, y = 55.03f) + curveToRelative(dx1 = 0.0f, dy1 = 2.5f, dx2 = -1.63f, dy2 = 3.78f, dx3 = -4.06f, dy3 = 3.78f) + horizontalLineToRelative(dx = -2.78f) + curveToRelative(dx1 = -2.44f, dy1 = 0.0f, dx2 = -4.1f, dy2 = -1.09f, dx3 = -4.1f, dy3 = -3.78f) + verticalLineTo(y = 37.51f) + arcToRelative(a = 32.0f, b = 32.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 10.94f, dy1 = -3.23f) + close() + moveToRelative(dx = 36.1f, dy = -17.52f) + verticalLineToRelative(dy = 17.52f) + curveToRelative(dx1 = 0.0f, dy1 = 2.69f, dx2 = -1.66f, dy2 = 3.78f, dx3 = -4.1f, dy3 = 3.78f) + horizontalLineToRelative(dx = -2.78f) + curveToRelative(dx1 = -2.44f, dy1 = 0.0f, dx2 = -4.06f, dy2 = -1.28f, dx3 = -4.06f, dy3 = -3.78f) + verticalLineTo(y = 34.28f) + arcToRelative(a = 32.0f, b = 32.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 10.93f, dy1 = 3.23f) + moveTo(x = 38.9f, y = 15.66f) + curveToRelative(dx1 = 0.0f, dy1 = 6.52f, dx2 = -4.93f, dy2 = 12.97f, dx3 = -13.8f, dy3 = 16.25f) + arcToRelative(a = 15.0f, b = 15.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 1.67f, dy1 = -7.28f) + curveToRelative(dx1 = 0.0f, dy1 = -7.0f, dx2 = -6.5f, dy2 = -14.85f, dx3 = -15.06f, dy3 = -14.85f) + quadToRelative(dx1 = -0.15f, dy1 = 0.0f, dx2 = -0.3f, dy2 = 0.02f) + curveTo(x1 = 16.05f, y1 = 4.86f, x2 = 21.59f, y2 = 2.97f, x3 = 26.09f, y3 = 3.0f) + curveToRelative(dx1 = 7.2f, dy1 = 0.06f, dx2 = 12.82f, dy2 = 5.38f, dx3 = 12.82f, dy3 = 12.66f) + moveTo(x = 70.68f, y = 9.8f) + lineToRelative(dx = -0.3f, dy = -0.02f) + curveToRelative(dx1 = -8.57f, dy1 = 0.0f, dx2 = -15.07f, dy2 = 7.85f, dx3 = -15.07f, dy3 = 14.85f) + curveToRelative(dx1 = 0.0f, dy1 = 2.9f, dx2 = 0.6f, dy2 = 5.34f, dx3 = 1.67f, dy3 = 7.28f) + curveToRelative(dx1 = -8.86f, dy1 = -3.28f, dx2 = -13.8f, dy2 = -9.73f, dx3 = -13.8f, dy3 = -16.25f) + curveTo(x1 = 43.19f, y1 = 8.38f, x2 = 48.82f, y2 = 3.06f, x3 = 56.0f, y3 = 3.0f) + curveToRelative(dx1 = 4.5f, dy1 = -0.03f, dx2 = 10.04f, dy2 = 1.86f, dx3 = 14.68f, dy3 = 6.8f) + moveTo(x = 30.22f, y = 9.03f) + lineTo(x = 29.0f, y = 9.88f) + arcToRelative(a = 2.25f, b = 2.25f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -0.47f, dy1 = 3.25f) + curveToRelative(dx1 = 0.66f, dy1 = 1.03f, dx2 = 2.1f, dy2 = 1.21f, dx3 = 3.19f, dy3 = 0.46f) + lineToRelative(dx = 1.19f, dy = -0.84f) + arcToRelative(a = 2.17f, b = 2.17f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.53f, dy1 = -3.12f) + curveToRelative(dx1 = -0.78f, dy1 = -1.07f, dx2 = -2.13f, dy2 = -1.38f, dx3 = -3.22f, dy3 = -0.6f) + moveToRelative(dx = 18.44f, dy = 0.6f) + arcToRelative(a = 2.17f, b = 2.17f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.53f, dy1 = 3.12f) + lineToRelative(dx = 1.19f, dy = 0.84f) + curveToRelative(dx1 = 1.09f, dy1 = 0.75f, dx2 = 2.53f, dy2 = 0.57f, dx3 = 3.18f, dy3 = -0.46f) + arcToRelative(a = 2.25f, b = 2.25f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -0.47f, dy1 = -3.25f) + lineToRelative(dx = -1.22f, dy = -0.85f) + curveToRelative(dx1 = -1.09f, dy1 = -0.78f, dx2 = -2.43f, dy2 = -0.47f, dx3 = -3.21f, dy3 = 0.6f) + } + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.425f, + ) { + moveTo(x = 14.84f, y = 34.69f) + curveToRelative(dx1 = 5.13f, dy1 = 0.0f, dx2 = 8.94f, dy2 = -3.25f, dx3 = 8.94f, dy3 = -10.06f) + curveToRelative(dx1 = 0.0f, dy1 = -5.47f, dx2 = -5.28f, dy2 = -11.82f, dx3 = -12.06f, dy3 = -11.82f) + curveToRelative(dx1 = -5.38f, dy1 = 0.0f, dx2 = -8.69f, dy2 = 4.85f, dx3 = -8.69f, dy3 = 9.5f) + curveToRelative(dx1 = 0.0f, dy1 = 6.28f, dx2 = 5.5f, dy2 = 12.38f, dx3 = 11.81f, dy3 = 12.38f) + moveToRelative(dx = -2.15f, dy = -6.4f) + curveToRelative(dx1 = -2.19f, dy1 = 0.0f, dx2 = -4.56f, dy2 = -3.2f, dx3 = -4.56f, dy3 = -5.26f) + curveToRelative(dx1 = 0.0f, dy1 = -1.4f, dx2 = 0.62f, dy2 = -1.84f, dx3 = 1.4f, dy3 = -1.84f) + curveToRelative(dx1 = 1.97f, dy1 = 0.0f, dx2 = 4.28f, dy2 = 3.19f, dx3 = 4.31f, dy3 = 5.37f) + curveToRelative(dx1 = 0.04f, dy1 = 1.28f, dx2 = -0.34f, dy2 = 1.72f, dx3 = -1.15f, dy3 = 1.72f) + moveToRelative(dx = 54.56f, dy = 6.4f) + curveToRelative(dx1 = 6.31f, dy1 = 0.0f, dx2 = 11.81f, dy2 = -6.1f, dx3 = 11.81f, dy3 = -12.38f) + curveToRelative(dx1 = 0.0f, dy1 = -4.65f, dx2 = -3.31f, dy2 = -9.5f, dx3 = -8.69f, dy3 = -9.5f) + curveToRelative(dx1 = -6.78f, dy1 = 0.0f, dx2 = -12.06f, dy2 = 6.35f, dx3 = -12.06f, dy3 = 11.82f) + curveToRelative(dx1 = 0.0f, dy1 = 6.8f, dx2 = 3.81f, dy2 = 10.06f, dx3 = 8.94f, dy3 = 10.06f) + moveToRelative(dx = 2.16f, dy = -6.4f) + curveToRelative(dx1 = -0.82f, dy1 = 0.0f, dx2 = -1.2f, dy2 = -0.45f, dx3 = -1.16f, dy3 = -1.73f) + curveToRelative(dx1 = 0.03f, dy1 = -2.18f, dx2 = 2.34f, dy2 = -5.37f, dx3 = 4.31f, dy3 = -5.37f) + curveToRelative(dx1 = 0.78f, dy1 = 0.0f, dx2 = 1.4f, dy2 = 0.44f, dx3 = 1.4f, dy3 = 1.84f) + curveToRelative(dx1 = 0.0f, dy1 = 2.06f, dx2 = -2.37f, dy2 = 5.25f, dx3 = -4.55f, dy3 = 5.25f) + } + }.build().also { _airPodsProGen3 = it } + } + +@Suppress("ObjectPropertyName") +private var _airPodsProGen3: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro3Case.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro3Case.kt new file mode 100644 index 00000000..f658c1ad --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro3Case.kt @@ -0,0 +1,61 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPodsPro3Case: ImageVector + get() { + val current = _airPodsPro3Case + if (current != null) return current + + return ImageVector.Builder( + name = "AirPodsPro3Case", + defaultWidth = 70.93800354003906.dp, + defaultHeight = 54.53099822998047.dp, + viewportWidth = 70.938f, + viewportHeight = 54.531f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 18.94f, y = 54.5f) + horizontalLineTo(x = 51.8f) + curveToRelative(dx1 = 12.78f, dy1 = 0.0f, dx2 = 18.94f, dy2 = -6.12f, dx3 = 18.94f, dy3 = -18.84f) + verticalLineTo(y = 18.84f) + curveTo(x1 = 70.75f, y1 = 6.13f, x2 = 64.59f, y2 = 0.0f, x3 = 51.81f, y3 = 0.0f) + horizontalLineTo(x = 18.94f) + curveTo(x1 = 6.19f, y1 = 0.0f, x2 = 0.0f, y2 = 6.13f, x3 = 0.0f, y3 = 18.84f) + verticalLineToRelative(dy = 16.82f) + curveTo(x1 = 0.0f, y1 = 48.38f, x2 = 6.19f, y2 = 54.5f, x3 = 18.94f, y3 = 54.5f) + moveToRelative(dx = 0.0f, dy = -5.03f) + curveToRelative(dx1 = -9.6f, dy1 = 0.0f, dx2 = -13.9f, dy2 = -4.28f, dx3 = -13.9f, dy3 = -13.81f) + verticalLineTo(y = 18.84f) + curveToRelative(dx1 = 0.0f, dy1 = -9.53f, dx2 = 4.3f, dy2 = -13.8f, dx3 = 13.9f, dy3 = -13.8f) + horizontalLineTo(x = 51.8f) + curveToRelative(dx1 = 9.63f, dy1 = 0.0f, dx2 = 13.9f, dy2 = 4.27f, dx3 = 13.9f, dy3 = 13.8f) + verticalLineToRelative(dy = 16.82f) + curveToRelative(dx1 = 0.0f, dy1 = 9.53f, dx2 = -4.27f, dy2 = 13.8f, dx3 = -13.9f, dy3 = 13.8f) + close() + moveToRelative(dx = -16.4f, dy = -28.5f) + horizontalLineToRelative(dx = 65.68f) + verticalLineTo(y = 17.8f) + horizontalLineTo(x = 2.53f) + close() + moveToRelative(dx = 20.34f, dy = 2.16f) + horizontalLineTo(x = 47.9f) + arcToRelative(a = 3.6f, b = 3.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 3.75f, dy1 = -3.75f) + arcToRelative(a = 3.6f, b = 3.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -3.75f, dy1 = -3.72f) + horizontalLineTo(x = 22.88f) + arcToRelative(a = 3.65f, b = 3.65f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -3.79f, dy1 = 3.72f) + arcToRelative(a = 3.65f, b = 3.65f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 3.79f, dy1 = 3.75f) + } + }.build().also { _airPodsPro3Case = it } + } + +@Suppress("ObjectPropertyName") +private var _airPodsPro3Case: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro3CaseFill.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro3CaseFill.kt new file mode 100644 index 00000000..f8603cf7 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro3CaseFill.kt @@ -0,0 +1,53 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPodsPro3CaseFill: ImageVector + get() { + val current = _airPodsPro3CaseFill + if (current != null) return current + + return ImageVector.Builder( + name = "AirPodsPro3CaseFill", + defaultWidth = 70.93800354003906.dp, + defaultHeight = 54.53099822998047.dp, + viewportWidth = 70.938f, + viewportHeight = 54.531f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 18.94f, y = 54.5f) + horizontalLineTo(x = 51.8f) + curveToRelative(dx1 = 12.78f, dy1 = 0.0f, dx2 = 18.94f, dy2 = -6.12f, dx3 = 18.94f, dy3 = -18.84f) + verticalLineTo(y = 20.8f) + horizontalLineTo(x = 51.41f) + arcToRelative(a = 3.6f, b = 3.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -3.5f, dy1 = 2.32f) + horizontalLineTo(x = 22.88f) + curveToRelative(dx1 = -1.63f, dy1 = 0.0f, dx2 = -3.0f, dy2 = -0.91f, dx3 = -3.5f, dy3 = -2.32f) + horizontalLineTo(x = 0.0f) + verticalLineToRelative(dy = 14.85f) + curveTo(x1 = 0.0f, y1 = 48.38f, x2 = 6.19f, y2 = 54.5f, x3 = 18.94f, y3 = 54.5f) + moveTo(x = 0.0f, y = 17.97f) + horizontalLineToRelative(dx = 19.31f) + curveToRelative(dx1 = 0.5f, dy1 = -1.44f, dx2 = 1.88f, dy2 = -2.31f, dx3 = 3.5f, dy3 = -2.31f) + horizontalLineToRelative(dx = 25.07f) + curveToRelative(dx1 = 1.62f, dy1 = 0.0f, dx2 = 2.96f, dy2 = 0.87f, dx3 = 3.46f, dy3 = 2.3f) + horizontalLineToRelative(dx = 19.32f) + verticalLineToRelative(dy = -1.0f) + curveTo(x1 = 70.66f, y1 = 5.57f, x2 = 63.9f, y2 = 0.0f, x3 = 51.72f, y3 = 0.0f) + horizontalLineTo(x = 18.94f) + curveTo(x1 = 6.78f, y1 = 0.0f, x2 = 0.0f, y2 = 5.56f, x3 = 0.0f, y3 = 16.97f) + close() + } + }.build().also { _airPodsPro3CaseFill = it } + } + +@Suppress("ObjectPropertyName") +private var _airPodsPro3CaseFill: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro3Left.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro3Left.kt new file mode 100644 index 00000000..7085d8e9 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro3Left.kt @@ -0,0 +1,67 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPodsPro3Left: ImageVector + get() { + val current = _airPodsPro3Left + if (current != null) return current + + return ImageVector.Builder( + name = "AirPodsPro3Left", + defaultWidth = 42.09400177001953.dp, + defaultHeight = 61.78300094604492.dp, + viewportWidth = 42.094f, + viewportHeight = 61.783f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 24.4f, y = 37.51f) + verticalLineToRelative(dy = 17.52f) + curveToRelative(dx1 = 0.0f, dy1 = 2.69f, dx2 = -1.65f, dy2 = 3.78f, dx3 = -4.12f, dy3 = 3.78f) + horizontalLineTo(x = 17.5f) + curveToRelative(dx1 = -2.4f, dy1 = 0.0f, dx2 = -4.06f, dy2 = -1.28f, dx3 = -4.06f, dy3 = -3.78f) + verticalLineTo(y = 34.28f) + arcTo(horizontalEllipseRadius = 32.0f, verticalEllipseRadius = 32.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 24.4f, y1 = 37.5f) + moveToRelative(dx = 6.12f, dy = -27.7f) + lineTo(x = 30.2f, y = 9.77f) + curveToRelative(dx1 = -8.53f, dy1 = 0.0f, dx2 = -15.07f, dy2 = 7.85f, dx3 = -15.07f, dy3 = 14.85f) + curveToRelative(dx1 = 0.0f, dy1 = 2.9f, dx2 = 0.6f, dy2 = 5.34f, dx3 = 1.67f, dy3 = 7.28f) + curveToRelative(dx1 = -8.85f, dy1 = -3.28f, dx2 = -13.76f, dy2 = -9.73f, dx3 = -13.76f, dy3 = -16.25f) + curveTo(x1 = 3.03f, y1 = 8.38f, x2 = 8.66f, y2 = 3.06f, x3 = 15.81f, y3 = 3.0f) + curveToRelative(dx1 = 4.53f, dy1 = -0.03f, dx2 = 10.08f, dy2 = 1.86f, dx3 = 14.71f, dy3 = 6.8f) + moveTo(x = 8.47f, y = 9.62f) + arcTo(horizontalEllipseRadius = 2.17f, verticalEllipseRadius = 2.17f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 9.0f, y1 = 12.75f) + lineToRelative(dx = 1.19f, dy = 0.84f) + curveToRelative(dx1 = 1.1f, dy1 = 0.75f, dx2 = 2.53f, dy2 = 0.57f, dx3 = 3.22f, dy3 = -0.46f) + arcToRelative(a = 2.3f, b = 2.3f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -0.5f, dy1 = -3.25f) + lineToRelative(dx = -1.2f, dy = -0.85f) + curveToRelative(dx1 = -1.12f, dy1 = -0.78f, dx2 = -2.46f, dy2 = -0.47f, dx3 = -3.24f, dy3 = 0.6f) + } + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.425f, + ) { + moveTo(x = 27.06f, y = 34.69f) + curveToRelative(dx1 = 6.32f, dy1 = 0.0f, dx2 = 11.85f, dy2 = -6.1f, dx3 = 11.85f, dy3 = -12.38f) + curveToRelative(dx1 = 0.0f, dy1 = -4.65f, dx2 = -3.32f, dy2 = -9.5f, dx3 = -8.72f, dy3 = -9.5f) + curveToRelative(dx1 = -6.75f, dy1 = 0.0f, dx2 = -12.07f, dy2 = 6.35f, dx3 = -12.07f, dy3 = 11.82f) + curveToRelative(dx1 = 0.0f, dy1 = 6.8f, dx2 = 3.82f, dy2 = 10.06f, dx3 = 8.94f, dy3 = 10.06f) + moveToRelative(dx = 2.19f, dy = -6.4f) + curveToRelative(dx1 = -0.84f, dy1 = 0.0f, dx2 = -1.22f, dy2 = -0.45f, dx3 = -1.19f, dy3 = -1.73f) + curveToRelative(dx1 = 0.03f, dy1 = -2.18f, dx2 = 2.35f, dy2 = -5.37f, dx3 = 4.32f, dy3 = -5.37f) + curveToRelative(dx1 = 0.78f, dy1 = 0.0f, dx2 = 1.4f, dy2 = 0.44f, dx3 = 1.4f, dy3 = 1.84f) + curveToRelative(dx1 = 0.0f, dy1 = 2.06f, dx2 = -2.37f, dy2 = 5.25f, dx3 = -4.53f, dy3 = 5.25f) + } + }.build().also { _airPodsPro3Left = it } + } + +@Suppress("ObjectPropertyName") +private var _airPodsPro3Left: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro3Right.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro3Right.kt new file mode 100644 index 00000000..05c27c11 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsPro3Right.kt @@ -0,0 +1,67 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPodsPro3Right: ImageVector + get() { + val current = _myIcon + if (current != null) return current + + return ImageVector.Builder( + name = "AirPodsPro3Right", + defaultWidth = 42.09400177001953.dp, + defaultHeight = 61.78300094604492.dp, + viewportWidth = 42.094f, + viewportHeight = 61.783f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 28.47f, y = 55.03f) + curveToRelative(dx1 = 0.0f, dy1 = 2.5f, dx2 = -1.63f, dy2 = 3.78f, dx3 = -4.06f, dy3 = 3.78f) + horizontalLineToRelative(dx = -2.78f) + curveToRelative(dx1 = -2.44f, dy1 = 0.0f, dx2 = -4.1f, dy2 = -1.09f, dx3 = -4.1f, dy3 = -3.78f) + verticalLineTo(y = 37.51f) + arcToRelative(a = 32.0f, b = 32.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 10.94f, dy1 = -3.23f) + close() + moveTo(x = 38.9f, y = 15.66f) + curveToRelative(dx1 = 0.0f, dy1 = 6.52f, dx2 = -4.93f, dy2 = 12.97f, dx3 = -13.8f, dy3 = 16.25f) + arcToRelative(a = 15.0f, b = 15.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 1.67f, dy1 = -7.28f) + curveToRelative(dx1 = 0.0f, dy1 = -7.0f, dx2 = -6.5f, dy2 = -14.85f, dx3 = -15.06f, dy3 = -14.85f) + quadToRelative(dx1 = -0.15f, dy1 = 0.0f, dx2 = -0.3f, dy2 = 0.02f) + curveTo(x1 = 16.05f, y1 = 4.86f, x2 = 21.59f, y2 = 2.97f, x3 = 26.09f, y3 = 3.0f) + curveToRelative(dx1 = 7.2f, dy1 = 0.06f, dx2 = 12.82f, dy2 = 5.38f, dx3 = 12.82f, dy3 = 12.66f) + moveToRelative(dx = -8.7f, dy = -6.63f) + lineTo(x = 29.0f, y = 9.88f) + arcToRelative(a = 2.25f, b = 2.25f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -0.47f, dy1 = 3.25f) + curveToRelative(dx1 = 0.66f, dy1 = 1.03f, dx2 = 2.1f, dy2 = 1.21f, dx3 = 3.19f, dy3 = 0.46f) + lineToRelative(dx = 1.19f, dy = -0.84f) + arcToRelative(a = 2.17f, b = 2.17f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 0.53f, dy1 = -3.12f) + curveToRelative(dx1 = -0.78f, dy1 = -1.07f, dx2 = -2.13f, dy2 = -1.38f, dx3 = -3.22f, dy3 = -0.6f) + } + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.425f, + ) { + moveTo(x = 14.84f, y = 34.69f) + curveToRelative(dx1 = 5.13f, dy1 = 0.0f, dx2 = 8.94f, dy2 = -3.25f, dx3 = 8.94f, dy3 = -10.06f) + curveToRelative(dx1 = 0.0f, dy1 = -5.47f, dx2 = -5.28f, dy2 = -11.82f, dx3 = -12.06f, dy3 = -11.82f) + curveToRelative(dx1 = -5.38f, dy1 = 0.0f, dx2 = -8.69f, dy2 = 4.85f, dx3 = -8.69f, dy3 = 9.5f) + curveToRelative(dx1 = 0.0f, dy1 = 6.28f, dx2 = 5.5f, dy2 = 12.38f, dx3 = 11.81f, dy3 = 12.38f) + moveToRelative(dx = -2.15f, dy = -6.4f) + curveToRelative(dx1 = -2.19f, dy1 = 0.0f, dx2 = -4.56f, dy2 = -3.2f, dx3 = -4.56f, dy3 = -5.26f) + curveToRelative(dx1 = 0.0f, dy1 = -1.4f, dx2 = 0.62f, dy2 = -1.84f, dx3 = 1.4f, dy3 = -1.84f) + curveToRelative(dx1 = 1.97f, dy1 = 0.0f, dx2 = 4.28f, dy2 = 3.19f, dx3 = 4.31f, dy3 = 5.37f) + curveToRelative(dx1 = 0.04f, dy1 = 1.28f, dx2 = -0.34f, dy2 = 1.72f, dx3 = -1.15f, dy3 = 1.72f) + } + }.build().also { _myIcon = it } + } + +@Suppress("ObjectPropertyName") +private var _myIcon: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsWirelessCase.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsWirelessCase.kt new file mode 100644 index 00000000..753b2268 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsWirelessCase.kt @@ -0,0 +1,65 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPodsWirelessCase: ImageVector + get() { + val current = _airPodsWirelessCase + if (current != null) return current + + return ImageVector.Builder( + name = "AirPodsCaseWireless", + defaultWidth = 51.0.dp, + defaultHeight = 62.09400177001953.dp, + viewportWidth = 51.0f, + viewportHeight = 62.094f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 25.4f, y = 33.63f) + arcToRelative(a = 3.1f, b = 3.1f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 3.04f, dy1 = -2.97f) + arcToRelative(a = 3.03f, b = 3.03f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -6.07f, dy1 = 0.0f) + arcToRelative(a = 3.0f, b = 3.0f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 3.04f, dy1 = 2.97f) + moveToRelative(dx = -9.65f, dy = 28.43f) + horizontalLineToRelative(dx = 19.31f) + curveToRelative(dx1 = 10.63f, dy1 = 0.0f, dx2 = 15.75f, dy2 = -5.12f, dx3 = 15.75f, dy3 = -15.75f) + verticalLineTo(y = 15.75f) + curveTo(x1 = 50.81f, y1 = 5.13f, x2 = 45.7f, y2 = 0.0f, x3 = 35.06f, y3 = 0.0f) + horizontalLineTo(x = 15.75f) + curveTo(x1 = 5.13f, y1 = 0.0f, x2 = 0.0f, y2 = 5.13f, x3 = 0.0f, y3 = 15.75f) + verticalLineToRelative(dy = 30.56f) + curveToRelative(dx1 = 0.0f, dy1 = 10.63f, dx2 = 5.13f, dy2 = 15.75f, dx3 = 15.75f, dy3 = 15.75f) + moveToRelative(dx = 0.0f, dy = -5.03f) + curveToRelative(dx1 = -7.47f, dy1 = 0.0f, dx2 = -10.72f, dy2 = -3.25f, dx3 = -10.72f, dy3 = -10.72f) + verticalLineTo(y = 15.75f) + curveToRelative(dx1 = 0.0f, dy1 = -7.47f, dx2 = 3.25f, dy2 = -10.72f, dx3 = 10.72f, dy3 = -10.72f) + horizontalLineToRelative(dx = 19.31f) + curveToRelative(dx1 = 7.47f, dy1 = 0.0f, dx2 = 10.72f, dy2 = 3.25f, dx3 = 10.72f, dy3 = 10.72f) + verticalLineToRelative(dy = 30.56f) + curveToRelative(dx1 = 0.0f, dy1 = 7.47f, dx2 = -3.25f, dy2 = 10.72f, dx3 = -10.72f, dy3 = 10.72f) + close() + moveTo(x = 2.53f, y = 20.81f) + horizontalLineToRelative(dx = 45.75f) + verticalLineToRelative(dy = -3.15f) + horizontalLineTo(x = 2.53f) + close() + moveToRelative(dx = 14.22f, dy = 2.13f) + horizontalLineToRelative(dx = 17.31f) + arcToRelative(a = 3.6f, b = 3.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 3.75f, dy1 = -3.72f) + arcToRelative(a = 3.6f, b = 3.6f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = -3.75f, dy1 = -3.72f) + horizontalLineTo(x = 16.75f) + arcTo(horizontalEllipseRadius = 3.66f, verticalEllipseRadius = 3.66f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 13.0f, y1 = 19.22f) + arcToRelative(a = 3.66f, b = 3.66f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 3.75f, dy1 = 3.72f) + } + }.build().also { _airPodsWirelessCase = it } + } + +@Suppress("ObjectPropertyName") +private var _airPodsWirelessCase: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsWirelessCaseFill.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsWirelessCaseFill.kt new file mode 100644 index 00000000..555ba141 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/common/airpods/AirPodsWirelessCaseFill.kt @@ -0,0 +1,58 @@ +package me.kavishdevar.librepods.presentation.icons.common.airpods + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.icons.CommonIcons + +val CommonIcons.AirPodsWirelessCaseFill: ImageVector + get() { + val current = _airPodsWirelessCaseFill + if (current != null) return current + + return ImageVector.Builder( + name = ".MyIcon", + defaultWidth = 51.0.dp, + defaultHeight = 62.09400177001953.dp, + viewportWidth = 51.0f, + viewportHeight = 62.094f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + fillAlpha = 0.85f, + ) { + moveTo(x = 25.4f, y = 35.69f) + arcToRelative(a = 2.9f, b = 2.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -2.87f, dy1 = -2.85f) + arcToRelative(a = 2.9f, b = 2.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 2.88f, dy1 = -2.87f) + arcToRelative(a = 2.9f, b = 2.9f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 2.87f, dy1 = 2.87f) + arcToRelative(a = 2.97f, b = 2.97f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -2.87f, dy1 = 2.85f) + moveToRelative(dx = -9.65f, dy = 26.37f) + horizontalLineToRelative(dx = 19.31f) + curveToRelative(dx1 = 10.63f, dy1 = 0.0f, dx2 = 15.75f, dy2 = -5.12f, dx3 = 15.75f, dy3 = -15.75f) + verticalLineToRelative(dy = -25.5f) + horizontalLineTo(x = 37.6f) + arcToRelative(a = 3.8f, b = 3.8f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -3.53f, dy1 = 2.13f) + horizontalLineTo(x = 16.75f) + arcToRelative(a = 3.8f, b = 3.8f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = -3.5f, dy1 = -2.13f) + horizontalLineTo(x = 0.0f) + verticalLineToRelative(dy = 25.5f) + curveToRelative(dx1 = 0.0f, dy1 = 10.63f, dx2 = 5.13f, dy2 = 15.75f, dx3 = 15.75f, dy3 = 15.75f) + moveTo(x = 0.0f, y = 17.66f) + horizontalLineToRelative(dx = 13.25f) + arcToRelative(a = 3.8f, b = 3.8f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 3.5f, dy1 = -2.16f) + horizontalLineToRelative(dx = 17.31f) + arcToRelative(a = 3.7f, b = 3.7f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = true, dx1 = 3.53f, dy1 = 2.16f) + horizontalLineToRelative(dx = 13.22f) + verticalLineToRelative(dy = -1.91f) + curveTo(x1 = 50.81f, y1 = 5.13f, x2 = 45.7f, y2 = 0.0f, x3 = 35.06f, y3 = 0.0f) + horizontalLineTo(x = 15.75f) + curveTo(x1 = 5.13f, y1 = 0.0f, x2 = 0.0f, y2 = 5.13f, x3 = 0.0f, y3 = 15.75f) + close() + } + }.build().also { _airPodsWirelessCaseFill = it } + } + +@Suppress("ObjectPropertyName") +private var _airPodsWirelessCaseFill: ImageVector? = null diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt new file mode 100644 index 00000000..34338860 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt @@ -0,0 +1,656 @@ +package me.kavishdevar.librepods.presentation.navigation + +import android.annotation.SuppressLint +import androidx.activity.BackEventCompat.Companion.EDGE_LEFT +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.ui.NavDisplay +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import me.kavishdevar.librepods.LibrePodsApplication +import me.kavishdevar.librepods.bluetooth.MacAddress +import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier +import me.kavishdevar.librepods.data.updates.updates +import me.kavishdevar.librepods.devices.AppleDevice +import me.kavishdevar.librepods.devices.ConnectionState +import me.kavishdevar.librepods.devices.Device +import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen +import me.kavishdevar.librepods.presentation.screens.BLESettingsScreenRoute +import me.kavishdevar.librepods.presentation.screens.DeviceListRoute +import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen +import me.kavishdevar.librepods.presentation.screens.PurchaseScreen +import me.kavishdevar.librepods.presentation.screens.ReleaseNotesScreen +import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen +import me.kavishdevar.librepods.presentation.screens.apple.AccessibilitySettingsScreen +import me.kavishdevar.librepods.presentation.screens.apple.AdaptiveStrengthScreen +import me.kavishdevar.librepods.presentation.screens.apple.AirPodsSettingsRoute +import me.kavishdevar.librepods.presentation.screens.apple.CallControlScreen +import me.kavishdevar.librepods.presentation.screens.apple.DebugRoute +import me.kavishdevar.librepods.presentation.screens.apple.EqualizerRoute +import me.kavishdevar.librepods.presentation.screens.apple.HeadTrackingScreen +import me.kavishdevar.librepods.presentation.screens.apple.HearingAidAdjustmentsScreen +import me.kavishdevar.librepods.presentation.screens.apple.HearingAidScreen +import me.kavishdevar.librepods.presentation.screens.apple.HearingProtectionScreen +import me.kavishdevar.librepods.presentation.screens.apple.LongPress +import me.kavishdevar.librepods.presentation.screens.apple.MicrophoneSettingsRoute +import me.kavishdevar.librepods.presentation.screens.apple.RecordingScreenRoute +import me.kavishdevar.librepods.presentation.screens.apple.RenameScreen +import me.kavishdevar.librepods.presentation.screens.apple.TransparencySettingsScreen +import me.kavishdevar.librepods.presentation.screens.apple.UpdateHearingTestRoute +import me.kavishdevar.librepods.presentation.screens.apple.VersionScreen +import me.kavishdevar.librepods.presentation.screens.onboarding.OnboardingScreen +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel +import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel +import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel +import me.kavishdevar.librepods.repository.RecordingRepository + +@OptIn(ExperimentalHazeMaterialsApi::class) +@Composable +fun AppNavGraph( + showReleaseNotes: Boolean = false, + updatesShown: () -> Unit = {}, + onboardingComplete: () -> Unit = {}, + backStack: SnapshotStateList, + devicesState: State>> +) { + val devices by devicesState + + val navigate: (Screen) -> Unit = { screen -> + backStack.add(screen) + } + + fun navigateToPurchase() { + navigate(Screen.Purchase) + } + + val context = LocalContext.current + + val appDataRepository by lazy { (context.applicationContext as LibrePodsApplication).appDataRepository } + val recordingRepository = RecordingRepository(context) + + val currentDevice: Device<*, *, *>? = + devices[backStack.lastOrNull()?.let { (it as? DeviceScreen)?.macAddress }] + + @SuppressLint("UnrememberedMutableState") + val currentConnectionState by (currentDevice as? AppleDevice)?.connectionState?.collectAsState() + ?: mutableStateOf(ConnectionState.DISCONNECTED) + + if (currentConnectionState == ConnectionState.DISCONNECTED) { + // not sure how we will be able to navigate to another device from one device, but just in case we had two different devices in the backstack, we will remove DeviceScreens only of the disconnected device from the backstack + while (backStack.isNotEmpty() && backStack.last() is DeviceScreen && (backStack.last() as DeviceScreen).macAddress == currentDevice?.macAddress) { + backStack.removeAt(backStack.lastIndex) + } + } + + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + + SharedTransitionLayout { + NavDisplay( + sharedTransitionScope = this, + backStack = backStack, + onBack = { + if (backStack.size > 1) { + backStack.removeAt(backStack.lastIndex) + } + }, + entryProvider = { screen -> + when (screen) { + Screen.Onboarding -> + NavEntry(screen) { + OnboardingScreen { + onboardingComplete() + if (showReleaseNotes) navigate(Screen.ReleaseNotes) else navigate( + Screen.DeviceList + ) + backStack.remove(screen) + } + } + + Screen.DeviceList -> + NavEntry(screen) { + DeviceListRoute( + devices = devices, + navigateToDevice = { macAddress -> + when (devices[macAddress]) { + is AppleDevice -> navigate(Screen.AppleScreen(macAddress)) + else -> {} + } + } + ) + } + + is Screen.AppleScreen -> + NavEntry(screen) { + val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry + + val factory = viewModelFactory { + initializer { + AppleViewModel( + device = device, + recordingRepository = recordingRepository, + ) + } + } + + val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory) + + AirPodsSettingsRoute( + viewModel = appleViewModel, + navigateToRename = { navigate(Screen.Rename(screen.macAddress)) }, + navigateToHearingProtection = { + navigate( + Screen.HearingProtection( + screen.macAddress + ) + ) + }, + navigateToHearingAid = { navigate(Screen.HearingAid(screen.macAddress)) }, + navigateToLeftLongPress = { + navigate( + Screen.LongPress(screen.macAddress, "Left") + ) + }, + navigateToRightLongPress = { + navigate( + Screen.LongPress(screen.macAddress, "Right") + ) + }, + navigateToPurchase = { navigate(Screen.Purchase) }, + navigateToAdaptiveStrength = { + navigate( + Screen.AdaptiveStrength( + screen.macAddress + ) + ) + }, + navigateToEqualizer = { navigate(Screen.Equalizer(screen.macAddress)) }, + navigateToHeadTracking = { navigate(Screen.HeadTracking(screen.macAddress)) }, + navigateToAccessibility = { navigate(Screen.Accessibility(screen.macAddress)) }, + navigateToVersion = { navigate(Screen.VersionInfo(screen.macAddress)) }, + navigateToCallControlScreen = { + navigate( + Screen.CallControl( + screen.macAddress, + it + ) + ) + }, + navigateToMicrophoneSettings = { + navigate( + Screen.MicrophoneSettings( + screen.macAddress + ) + ) + }, + navigateToRecordingScreen = { navigate(Screen.Recording(screen.macAddress)) }, + navigateToDebugScreen = { navigate(Screen.Debug(screen.macAddress)) } + ) + } + + is Screen.Rename -> + NavEntry(screen) { + val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry + + val factory = viewModelFactory { + initializer { + AppleViewModel( + device = device, + recordingRepository = recordingRepository, + ) + } + } + + val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory) + + RenameScreen(appleViewModel) + } + + Screen.AppSettings -> + NavEntry(screen) { + val factory = viewModelFactory { + initializer { + AppSettingsViewModel( + appDataRepository = appDataRepository, + ) + } + } + val appSettingsViewModel: AppSettingsViewModel = viewModel(factory = factory) + + AppSettingsScreen( + viewModel = appSettingsViewModel, + navigateToPurchase = ::navigateToPurchase, + navigateToTroubleshooting = { navigate(Screen.Troubleshooting) }, + navigateToOpenSourceLicenses = { navigate(Screen.OpenSourceLicenses) }, + navigateToReleaseNotesScreen = { navigate(Screen.ReleaseNotes) }, + navigateToBleSettingsScreen = { navigate(Screen.BLESettings) } + ) + } + + Screen.Troubleshooting -> + NavEntry(screen) { + TroubleshootingScreen() + } + + is Screen.HeadTracking -> + NavEntry(screen) { + val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry + + val factory = viewModelFactory { + initializer { + AppleViewModel( + device = device, + recordingRepository = recordingRepository, + ) + } + } + + val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory) + + HeadTrackingScreen(appleViewModel, ::navigateToPurchase) + } + + is Screen.Accessibility -> + NavEntry(screen) { + val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry + val factory = viewModelFactory { + initializer { + AppleViewModel( + device = device, + recordingRepository = recordingRepository, + ) + } + } + + val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory) + + AccessibilitySettingsScreen( + viewModel = appleViewModel, + navigateToPurchase = ::navigateToPurchase, + navigateToTransparencyCustomization = { + navigate( + Screen.TransparencyCustomization( + screen.macAddress + ) + ) + } + ) + } + + is Screen.TransparencyCustomization -> + NavEntry(screen) { + val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry + + val factory = viewModelFactory { + initializer { + AppleViewModel( + device = device, + recordingRepository = recordingRepository, + ) + } + } + + val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory) + + TransparencySettingsScreen(appleViewModel) + } + + is Screen.HearingAid -> + NavEntry(screen) { + val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry + + val factory = viewModelFactory { + initializer { + AppleViewModel( + device = device, + recordingRepository = recordingRepository, + ) + } + } + + val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory) + + HearingAidScreen( + viewModel = appleViewModel, + onNavigateHearingAidAdjustments = { + navigate( + Screen.HearingAidAdjustments( + screen.macAddress + ) + ) + }, + onNavigateHearingTest = { navigate(Screen.UpdateHearingTest(screen.macAddress)) }, + ) + } + + is Screen.HearingAidAdjustments -> + NavEntry(screen) { + val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry + + val factory = viewModelFactory { + initializer { + AppleViewModel( + device = device, + recordingRepository = recordingRepository, + ) + } + } + + val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory) + + HearingAidAdjustmentsScreen(appleViewModel) + } + + is Screen.AdaptiveStrength -> + NavEntry(screen) { + val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry + + val factory = viewModelFactory { + initializer { + AppleViewModel( + device = device, + recordingRepository = recordingRepository, + ) + } + } + + val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory) + + AdaptiveStrengthScreen(appleViewModel, ::navigateToPurchase) + } + +// Screen.CameraControl -> +// NavEntry(screen) { +// CameraControlScreen(AppleViewModel) +// } + + Screen.OpenSourceLicenses -> + NavEntry(screen) { + OpenSourceLicensesScreen() + } + + is Screen.UpdateHearingTest -> + NavEntry(screen) { + val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry + + val factory = viewModelFactory { + initializer { + AppleViewModel( + device = device, + recordingRepository = recordingRepository, + ) + } + } + + val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory) + + UpdateHearingTestRoute(appleViewModel) + } + + is Screen.VersionInfo -> + NavEntry(screen) { + val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry + + val factory = viewModelFactory { + initializer { + AppleViewModel( + device = device, + recordingRepository = recordingRepository, + ) + } + } + + val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory) + + VersionScreen(appleViewModel) + } + + is Screen.HearingProtection -> + NavEntry(screen) { + val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry + + val factory = viewModelFactory { + initializer { + AppleViewModel( + device = device, + recordingRepository = recordingRepository, + ) + } + } + + val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory) + + HearingProtectionScreen( + viewModel = appleViewModel, + navigateToPurchase = ::navigateToPurchase + ) + } + + is Screen.Purchase -> + NavEntry(screen) { + val vm: PurchaseViewModel = viewModel() + PurchaseScreen(vm, backStack) + } + + is Screen.Equalizer -> + NavEntry(screen) { + val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry + + val factory = viewModelFactory { + initializer { + AppleViewModel( + device = device, + recordingRepository = recordingRepository, + ) + } + } + + val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory) + + EqualizerRoute(appleViewModel) + } + + is Screen.LongPress -> + NavEntry(screen) { + val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry + + val factory = viewModelFactory { + initializer { + AppleViewModel( + device = device, + recordingRepository = recordingRepository, + ) + } + } + + val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory) + + LongPress( + viewModel = appleViewModel, + name = screen.bud, + navigateToPurchase = ::navigateToPurchase + ) + } + + is Screen.CallControl -> + NavEntry(screen) { + val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry + + val factory = viewModelFactory { + initializer { + AppleViewModel( + device = device, + recordingRepository = recordingRepository, + ) + } + } + + val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory) + + CallControlScreen( + viewModel = appleViewModel, + action = screen.action, + onCallControlValueChanged = { flipped -> + device.setControlCommand( + ControlCommandIdentifier.CALL_MANAGEMENT_CONFIG, + if (flipped) byteArrayOf(0x00, 0x02) else byteArrayOf( + 0x00, + 0x03 + ) + ) + } + ) + } + + is Screen.MicrophoneSettings -> + NavEntry(screen) { + val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry + + val factory = viewModelFactory { + initializer { + AppleViewModel( + device = device, + recordingRepository = recordingRepository, + ) + } + } + + val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory) + + MicrophoneSettingsRoute(viewModel = appleViewModel) + } + + is Screen.ReleaseNotes -> + NavEntry(screen) { + ReleaseNotesScreen( + updates = updates, + releaseNotesShown = { + if (showReleaseNotes) { + navigate(Screen.DeviceList) + backStack.remove(screen) + updatesShown() + } else { + backStack.removeAt(backStack.lastIndex) + } + } + ) + } + + is Screen.Recording -> + NavEntry(screen) { + val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry + + val factory = viewModelFactory { + initializer { + AppleViewModel( + device = device, + recordingRepository = recordingRepository, + ) + } + } + + val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory) + + RecordingScreenRoute(appleViewModel) + } + + is Screen.Debug -> + NavEntry(screen) { + val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry + + val factory = viewModelFactory { + initializer { + AppleViewModel( + device = device, + recordingRepository = recordingRepository, + ) + } + } + + val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory) + + DebugRoute(appleViewModel) + } + + is Screen.BLESettings -> + NavEntry(screen) { + val factory = viewModelFactory { + initializer { + AppSettingsViewModel( + appDataRepository = appDataRepository, + ) + } + } + val appSettingsViewModel: AppSettingsViewModel = viewModel(factory = factory) + + BLESettingsScreenRoute( + viewModel = appSettingsViewModel + ) + } + } + }, + transitionSpec = { + slideInHorizontally { it } togetherWith slideOutHorizontally { -it / 4 } + }, + popTransitionSpec = { + slideInHorizontally { -it / 4 } togetherWith slideOutHorizontally { it } + }, + predictivePopTransitionSpec = { swipeEdge -> + if (m3eEnabled) { + val enterOffset: (Int) -> Int = + if (swipeEdge == EDGE_LEFT) { + { -it / 6 } + } else { + { it / 6 } + } + + val exitOffset: (Int) -> Int = + if (swipeEdge == EDGE_LEFT) { + { it / 8 } + } else { + { -it / 8 } + } + + fadeIn( + animationSpec = tween(250) + ) + + slideInHorizontally( + initialOffsetX = enterOffset, + animationSpec = tween(250) + ) togetherWith + fadeOut( + targetAlpha = 0.75f, + animationSpec = tween(250) + ) + + scaleOut( + targetScale = 0.85f, + animationSpec = tween(250) + ) + + slideOutHorizontally( + targetOffsetX = exitOffset, + animationSpec = tween(250) + ) + } else { + slideInHorizontally { -it / 4 } togetherWith slideOutHorizontally { it } + } + }, + ) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt similarity index 60% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt index 2bca355a..c30b6ee9 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt @@ -1,6 +1,5 @@ package me.kavishdevar.librepods.presentation.navigation -import android.util.Log import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow @@ -9,22 +8,28 @@ import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilledTonalIconToggleButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import com.kyant.backdrop.backdrops.LayerBackdrop import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.MaterialIcons +import me.kavishdevar.librepods.bluetooth.MacAddress +import me.kavishdevar.librepods.devices.AppleDevice +import me.kavishdevar.librepods.devices.Device import me.kavishdevar.librepods.presentation.components.StyledIconButton import me.kavishdevar.librepods.presentation.components.StyledScaffold +import me.kavishdevar.librepods.presentation.icons.LocalIcons +import me.kavishdevar.librepods.presentation.icons.MaterialIcons import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel @Composable fun NavigationRoot( @@ -32,52 +37,56 @@ fun NavigationRoot( updatesShown: () -> Unit = {}, showOnboarding: Boolean = false, onboardingComplete: () -> Unit = {}, - airPodsViewModel: AirPodsViewModel + devicesState: State>> ) { + val devices by devicesState + val backStack = remember { mutableStateListOf( when { showOnboarding -> Screen.Onboarding showReleaseNotes -> Screen.ReleaseNotes - else -> Screen.AirPodsSettings + else -> Screen.DeviceList } ) } val currentScreen = backStack.last() - val state by airPodsViewModel.uiState.collectAsState() - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material val title = when (currentScreen) { Screen.Onboarding -> "" - Screen.AirPodsSettings -> if (state.isLocallyConnected) state.deviceName else stringResource(R.string.app_name) - Screen.Accessibility -> stringResource(R.string.accessibility) - Screen.AdaptiveStrength -> stringResource(R.string.customize_adaptive_audio) + Screen.DeviceList -> stringResource(R.string.app_name) + is Screen.AppleScreen -> devices[currentScreen.macAddress]?.metadata?.collectAsState()?.value?.name ?: currentScreen.macAddress.value + is Screen.Accessibility -> stringResource(R.string.accessibility) + is Screen.AdaptiveStrength -> stringResource(R.string.customize_adaptive_audio) Screen.AppSettings -> stringResource(R.string.settings) // Screen.CameraControl -> stringResource(R.string.camera_control) - Screen.Equalizer -> stringResource(R.string.equalizer) - Screen.HeadTracking -> stringResource(R.string.head_tracking) - Screen.HearingAid -> stringResource(R.string.hearing_aid) - Screen.HearingAidAdjustments -> stringResource(R.string.adjustments) - Screen.HearingProtection -> stringResource(R.string.hearing_protection) + is Screen.Equalizer -> stringResource(R.string.equalizer) + is Screen.HeadTracking -> stringResource(R.string.head_tracking) + is Screen.HearingAid -> stringResource(R.string.hearing_aid) + is Screen.HearingAidAdjustments -> stringResource(R.string.adjustments) + is Screen.HearingProtection -> stringResource(R.string.hearing_protection) is Screen.LongPress -> currentScreen.bud Screen.OpenSourceLicenses -> stringResource(R.string.open_source_licenses) Screen.Purchase -> stringResource(R.string.unlock_advanced_features) - Screen.Rename -> stringResource(R.string.name) - Screen.TransparencyCustomization -> stringResource(R.string.customize_transparency_mode) + is Screen.Rename -> stringResource(R.string.name) + is Screen.TransparencyCustomization -> stringResource(R.string.customize_transparency_mode) Screen.Troubleshooting -> stringResource(R.string.troubleshooting) - Screen.UpdateHearingTest -> stringResource(R.string.update_hearing_test) - Screen.VersionInfo -> stringResource(R.string.version) + is Screen.UpdateHearingTest -> stringResource(R.string.update_hearing_test) + is Screen.VersionInfo -> stringResource(R.string.version) is Screen.CallControl -> currentScreen.action - Screen.MicrophoneSettings -> stringResource(R.string.microphone_mode) + is Screen.MicrophoneSettings -> stringResource(R.string.microphone_mode) Screen.ReleaseNotes -> "" + is Screen.Recording -> stringResource(R.string.recorder) + is Screen.Debug -> "debug" + is Screen.BLESettings -> stringResource(R.string.ble_settings) } // is this a bad idea? probably. I can't think of a better way without having to pass around a shouldShowBackButton to each screen to pass to each scaffold val actionButtons = when (currentScreen) { - Screen.AirPodsSettings -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>( + is Screen.AppleScreen, is Screen.DeviceList -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>( { scaffoldBackdrop -> if (m3eEnabled) { FilledTonalIconButton( @@ -90,49 +99,56 @@ fun NavigationRoot( Icon( imageVector = Icons.Outlined.Settings, contentDescription = "settings", - modifier = Modifier.size(IconButtonDefaults.mediumIconSize) + modifier = Modifier.size(IconButtonDefaults.mediumIconSize), ) } } else { StyledIconButton( onClick = { backStack.add(Screen.AppSettings) }, - icon = "􀍟", backdrop = scaffoldBackdrop - ) + ) { + Icon( + imageVector = LocalIcons.current.Settings, + contentDescription = "Settings", + tint = MaterialTheme.colorScheme.onBackground + ) + } } } ) - Screen.HeadTracking -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>( + is Screen.HeadTracking -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>( { scaffoldBackdrop -> + val device = devices[currentScreen.macAddress] as? AppleDevice? ?: return@listOf + + val state by device.state.collectAsState() + if (m3eEnabled) { FilledTonalIconToggleButton( checked = state.headTrackingActive, - onCheckedChange = { if (it) airPodsViewModel.startHeadTracking() else airPodsViewModel.stopHeadTracking() }, + onCheckedChange = { if (it) device.startHeadTracking() else device.stopHeadTracking() }, modifier = Modifier .minimumInteractiveComponentSize() .size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)), shape = IconButtonDefaults.mediumRoundShape ) { Icon( - imageVector = if (state.headTrackingActive) MaterialIcons.pause else Icons.Default.PlayArrow, + imageVector = if (state.headTrackingActive) MaterialIcons.Pause else Icons.Default.PlayArrow, contentDescription = "Play/Pause", - modifier = Modifier.size(IconButtonDefaults.mediumIconSize) + modifier = Modifier.size(IconButtonDefaults.mediumIconSize), ) } } else { StyledIconButton( - onClick = { - if (!state.headTrackingActive) { - airPodsViewModel.startHeadTracking() - Log.d("HeadTrackingScreen", "Head tracking started") - } else { - airPodsViewModel.stopHeadTracking() - Log.d("HeadTrackingScreen", "Head tracking stopped") - } - }, - icon = if (state.headTrackingActive) "􀊅" else "􀊃", + onClick = if (!state.headTrackingActive) device::startHeadTracking else device::stopHeadTracking, backdrop = scaffoldBackdrop - ) + ) { + Icon( + imageVector = if (state.headTrackingActive) LocalIcons.current.Pause else LocalIcons.current.Play, + contentDescription = "Play/Pause", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onBackground + ) + } } } ) @@ -149,10 +165,9 @@ fun NavigationRoot( AppNavGraph( showReleaseNotes = showReleaseNotes, updatesShown = updatesShown, - showOnboarding = showOnboarding, onboardingComplete = onboardingComplete, backStack = backStack, - airPodsViewModel = airPodsViewModel, + devicesState = devicesState, ) } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/Screen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/Screen.kt new file mode 100644 index 00000000..81181177 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/Screen.kt @@ -0,0 +1,134 @@ +package me.kavishdevar.librepods.presentation.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable +import me.kavishdevar.librepods.bluetooth.MacAddress + +@Serializable +sealed interface Screen: NavKey { + val showTopBar: Boolean + get() = true + + @Serializable + data object Onboarding: Screen { + override val showTopBar: Boolean = false + } + + @Serializable + data object DeviceList: Screen + + @Serializable + data class AppleScreen( + override val macAddress: MacAddress + ): DeviceScreen + + @Serializable + data class Rename( + override val macAddress: MacAddress + ): DeviceScreen + + @Serializable + data object AppSettings: Screen + + @Serializable + data object Troubleshooting: Screen + + @Serializable + data class HeadTracking( + override val macAddress: MacAddress + ): DeviceScreen + + @Serializable + data class Accessibility( + override val macAddress: MacAddress + ): DeviceScreen + + @Serializable + data class TransparencyCustomization( + override val macAddress: MacAddress + ): DeviceScreen + + @Serializable + data class HearingAid( + override val macAddress: MacAddress + ): DeviceScreen + + @Serializable + data class HearingAidAdjustments( + override val macAddress: MacAddress + ): DeviceScreen + + @Serializable + data class AdaptiveStrength( + override val macAddress: MacAddress + ): DeviceScreen + +// @Serializable +// data object CameraControl: Screen + + @Serializable + data object OpenSourceLicenses: Screen + + @Serializable + data class UpdateHearingTest( + override val macAddress: MacAddress + ): DeviceScreen + + @Serializable + data class VersionInfo( + override val macAddress: MacAddress + ): DeviceScreen + + @Serializable + data class HearingProtection( + override val macAddress: MacAddress + ): DeviceScreen + + @Serializable + data object Purchase: Screen + + @Serializable + data class Equalizer( + override val macAddress: MacAddress + ): DeviceScreen + + @Serializable + data class LongPress( + override val macAddress: MacAddress, + val bud: String + ): DeviceScreen + + @Serializable + data class CallControl( + override val macAddress: MacAddress, + val action: String + ): DeviceScreen + + @Serializable + data class MicrophoneSettings( + override val macAddress: MacAddress + ): DeviceScreen + + @Serializable + data object ReleaseNotes: Screen { + override val showTopBar: Boolean = false + } + + @Serializable + data class Recording( + override val macAddress: MacAddress + ): DeviceScreen + + @Serializable + data class Debug( + override val macAddress: MacAddress + ): DeviceScreen + + @Serializable + data object BLESettings: Screen +} + +@Serializable +sealed interface DeviceScreen : Screen { + val macAddress: MacAddress +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt similarity index 61% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt index bf5eff89..f446a380 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt @@ -26,15 +26,11 @@ import android.animation.ObjectAnimator import android.animation.PropertyValuesHolder import android.animation.ValueAnimator import android.annotation.SuppressLint -import android.content.BroadcastReceiver import android.content.Context -import android.content.Intent -import android.content.IntentFilter import android.content.res.Resources import android.graphics.PixelFormat import android.graphics.drawable.GradientDrawable import android.media.AudioManager -import android.os.Build import android.os.Handler import android.os.Looper import android.util.Log.e @@ -46,8 +42,6 @@ import android.view.View import android.view.WindowManager import android.view.animation.AccelerateInterpolator import android.view.animation.AnticipateOvershootInterpolator -import android.view.animation.DecelerateInterpolator -import android.view.animation.OvershootInterpolator import android.widget.FrameLayout import android.widget.ImageButton import android.widget.LinearLayout @@ -59,11 +53,9 @@ import androidx.dynamicanimation.animation.DynamicAnimation import androidx.dynamicanimation.animation.SpringAnimation import androidx.dynamicanimation.animation.SpringForce import me.kavishdevar.librepods.R -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.services.ServiceManager +import me.kavishdevar.librepods.devices.Battery +import me.kavishdevar.librepods.devices.BatteryComponent +import me.kavishdevar.librepods.devices.BatteryStatus import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.abs @@ -106,32 +98,12 @@ class IslandWindow(private val context: Context) { private lateinit var springAnimation: SpringAnimation private val flingAnimator = ValueAnimator() - private val batteryReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == AirPodsNotifications.BATTERY_DATA) { - val batteryList = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableArrayListExtra("data", Battery::class.java) - } else { - @Suppress("DEPRECATION") - intent.getParcelableArrayListExtra("data") - } - updateBatteryDisplay(batteryList) - } else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) { - try { - context?.unregisterReceiver(this) - } catch (e: Exception) { - e.printStackTrace() - } - } - } - } - val isVisible: Boolean get() = containerView.parent != null && containerView.visibility == View.VISIBLE @SuppressLint("SetTextI18n") - private fun updateBatteryDisplay(batteryList: ArrayList?) { - if (batteryList == null || batteryList.isEmpty()) return + fun updateBattery(batteryList: Set) { + if (batteryList.isEmpty()) return val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT } val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT } @@ -165,39 +137,26 @@ class IslandWindow(private val context: Context) { @SuppressLint("SetTextI18s", "ClickableViewAccessibility", "UnspecifiedRegisterReceiverFlag", "SetTextI18n" ) - fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED, reversed: Boolean = false, otherDeviceName: String? = null) { - if (ServiceManager.getService()?.islandOpen == true) return - else ServiceManager.getService()?.islandOpen = true + fun show( + name: String, + batteryPercentage: Int, + context: Context, + type: IslandType = IslandType.CONNECTED, + reversed: Boolean = false, + otherDeviceName: String? = null, + onReverseAction: () -> Unit = {} + ) { val displayMetrics = Resources.getSystem().displayMetrics val width = (displayMetrics.widthPixels * 0.95).toInt() screenHeight = displayMetrics.heightPixels - val batteryList = ServiceManager.getService()?.getBattery() val batteryText = islandView.findViewById(R.id.island_battery_text) val batteryProgressBar = islandView.findViewById(R.id.island_battery_progress) - val displayBatteryLevel = if (batteryList != null) { - val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT } - val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT } - - when { - (leftBattery?.level ?: 0) > 0 && (rightBattery?.level ?: 0) > 0 -> - minOf(leftBattery!!.level, rightBattery!!.level) - (leftBattery?.level ?: 0) > 0 -> leftBattery!!.level - (rightBattery?.level ?: 0) > 0 -> rightBattery!!.level - batteryPercentage > 0 -> batteryPercentage - else -> null - } - } else if (batteryPercentage > 0) { - batteryPercentage - } else { - null - } - - if (displayBatteryLevel != null) { - batteryText.text = "$displayBatteryLevel%" - batteryProgressBar.progress = displayBatteryLevel + if (batteryPercentage != 0) { + batteryText.text = "$batteryPercentage%" + batteryProgressBar.progress = batteryPercentage } else { batteryText.text = "?" batteryProgressBar.progress = 0 @@ -211,9 +170,7 @@ class IslandWindow(private val context: Context) { if (type == IslandType.MOVED_TO_OTHER_DEVICE && !reversed) { actionButton.visibility = View.VISIBLE actionButton.setOnClickListener { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - ServiceManager.getService()?.takeOver("reverse") - } + onReverseAction() close() } batteryText.visibility = View.GONE @@ -226,16 +183,6 @@ class IslandWindow(private val context: Context) { batteryBg.visibility = View.VISIBLE } - val batteryIntentFilter = IntentFilter(AirPodsNotifications.BATTERY_DATA) - batteryIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.registerReceiver(batteryReceiver, batteryIntentFilter, Context.RECEIVER_EXPORTED) - } else { - context.registerReceiver(batteryReceiver, batteryIntentFilter) - } - - ServiceManager.getService()?.sendBatteryBroadcast() - containerView.removeAllViews() val containerParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, @@ -324,20 +271,30 @@ class IslandWindow(private val context: Context) { if (isBeingDragged) { val currentTranslationY = containerView.translationY - abs(yVelocity) > 800 - val significantDrag = abs(dragDistance) > 80 - when { - yVelocity < -1200 || (currentTranslationY < -80 && !isDraggingDown) -> { - animateDismissWithInertia(yVelocity) - } - yVelocity > 1200 || (isDraggingDown && significantDrag) -> { - animateExpandWithStretch(yVelocity) - } - else -> { - springBackWithInertia(yVelocity) + if (isDraggingDown && (currentTranslationY > 200 || yVelocity > 1000)) { + flingAnimator.cancel() + flingAnimator.setFloatValues(currentTranslationY, screenHeight.toFloat()) + flingAnimator.duration = 300 + flingAnimator.interpolator = AccelerateInterpolator() + flingAnimator.addUpdateListener { animation -> + val value = animation.animatedValue as Float + containerView.translationY = value } + flingAnimator.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + close() + } + }) + flingAnimator.start() + } else { + springAnimation.cancel() + springAnimation.setStartValue(currentTranslationY) + springAnimation.start() + + applyCustomStretchEffect(0f) } + } else if (dragDistance < 10) { resetAutoCloseTimer() } @@ -402,10 +359,23 @@ class IslandWindow(private val context: Context) { val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0.5f, 1f) val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.5f, 1f) val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -200f, 0f) - ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply { - duration = 700 - interpolator = AnticipateOvershootInterpolator() - start() + + Looper.getMainLooper().let { mainLooper -> + if (Looper.myLooper() == mainLooper) { + ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply { + duration = 700 + interpolator = AnticipateOvershootInterpolator() + start() + } + } else { + Handler(mainLooper).post { + ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply { + duration = 700 + interpolator = AnticipateOvershootInterpolator() + start() + } + } + } } resetAutoCloseTimer() @@ -476,178 +446,6 @@ class IslandWindow(private val context: Context) { autoCloseHandler?.postDelayed(autoCloseRunnable!!, 4500) } - private fun springBackWithInertia(velocity: Float) { - springAnimation.cancel() - flingAnimator.cancel() - - springAnimation.setStartVelocity(velocity) - - val baseStiffness = SpringForce.STIFFNESS_MEDIUM - val dynamicStiffness = baseStiffness * (1f + (abs(velocity) / 3000f)) - springAnimation.spring = SpringForce(0f) - .setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY) - .setStiffness(dynamicStiffness) - - resetStretchEffects() - - if (params != null) { - params!!.height = WindowManager.LayoutParams.WRAP_CONTENT - try { - windowManager.updateViewLayout(containerView, params) - } catch (e: Exception) { - e.printStackTrace() - } - } - - springAnimation.start() - } - - private fun resetStretchEffects() { - try { - val mainLayout = islandView.findViewById(R.id.island_window_layout) - val deviceText = islandView.findViewById(R.id.island_device_name) - - val heightAnimator = ValueAnimator.ofInt(mainLayout.minimumHeight, initialHeight) - heightAnimator.duration = 300 - heightAnimator.interpolator = OvershootInterpolator(1.5f) - heightAnimator.addUpdateListener { animation -> - mainLayout.minimumHeight = animation.animatedValue as Int - } - - val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams - val textMarginAnimator = ValueAnimator.ofInt(deviceTextParams.topMargin, 0) - textMarginAnimator.duration = 300 - textMarginAnimator.interpolator = OvershootInterpolator(1.5f) - textMarginAnimator.addUpdateListener { animation -> - deviceTextParams.topMargin = animation.animatedValue as Int - deviceText.layoutParams = deviceTextParams - } - - heightAnimator.start() - textMarginAnimator.start() - } catch (e: Exception) { - e.printStackTrace() - } - } - - private fun animateDismissWithInertia(velocity: Float) { - springAnimation.cancel() - flingAnimator.cancel() - - val baseDistance = -screenHeight - val velocityFactor = (abs(velocity) / 2000f).coerceIn(0.5f, 2.0f) - val targetDistance = baseDistance * velocityFactor - - val baseDuration = 400L - val velocityDurationFactor = (1500f / (abs(velocity) + 1500f)) - val duration = (baseDuration * velocityDurationFactor).toLong().coerceIn(200L, 500L) - - flingAnimator.setFloatValues(containerView.translationY, targetDistance) - flingAnimator.duration = duration - flingAnimator.addUpdateListener { animation -> - containerView.translationY = animation.animatedValue as Float - - val progress = animation.animatedFraction - containerView.scaleX = 1f - (progress * 0.5f) - containerView.scaleY = 1f - (progress * 0.5f) - - containerView.alpha = 1f - progress - } - flingAnimator.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - forceClose() - } - }) - - flingAnimator.interpolator = DecelerateInterpolator(1.2f) - flingAnimator.start() - } - - private fun animateExpandWithStretch(velocity: Float) { - springAnimation.cancel() - flingAnimator.cancel() - - val baseDuration = 600L - val velocityFactor = (1800f / (abs(velocity) + 1800f)).coerceIn(0.5f, 1.5f) - val expandDuration = (baseDuration * velocityFactor).toLong().coerceIn(300L, 700L) - - if (params != null) { - params!!.height = screenHeight - try { - windowManager.updateViewLayout(containerView, params) - } catch (e: Exception) { - e.printStackTrace() - } - } - - val containerAnimator = ValueAnimator.ofFloat(containerView.translationY, screenHeight * 0.6f) - containerAnimator.duration = expandDuration - containerAnimator.interpolator = DecelerateInterpolator(0.8f) - containerAnimator.addUpdateListener { animation -> - containerView.translationY = animation.animatedValue as Float - } - - val stretchAnimator = ValueAnimator.ofFloat(0f, 1f) - stretchAnimator.duration = expandDuration - stretchAnimator.interpolator = OvershootInterpolator(0.5f) - stretchAnimator.addUpdateListener { animation -> - val progress = animation.animatedValue as Float - animateCustomStretch(progress) - } - - val normalizeAnimator = ValueAnimator.ofFloat(1.0f, 0.0f) - normalizeAnimator.duration = 300 - normalizeAnimator.startDelay = expandDuration - 150 - normalizeAnimator.interpolator = AccelerateInterpolator(1.2f) - normalizeAnimator.addUpdateListener { animation -> - val progress = animation.animatedValue as Float - containerView.alpha = progress - - if (progress < 0.7f) { - islandView.findViewById(R.id.island_video_view).visibility = View.GONE - } - } - normalizeAnimator.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - ServiceManager.getService()?.startMainActivity() - forceClose() - } - }) - - containerAnimator.start() - stretchAnimator.start() - normalizeAnimator.start() - } - - private fun animateCustomStretch(progress: Float) { - try { - val mainLayout = islandView.findViewById(R.id.island_window_layout) - val connectedText = islandView.findViewById(R.id.island_connected_text) - val deviceText = islandView.findViewById(R.id.island_device_name) - - val targetHeight = (screenHeight * 0.7f).toInt() - val currentHeight = initialHeight + ((targetHeight - initialHeight) * progress) - mainLayout.minimumHeight = currentHeight.toInt() - - val mainLayoutParams = mainLayout.layoutParams - mainLayoutParams.height = LinearLayout.LayoutParams.MATCH_PARENT - mainLayout.layoutParams = mainLayoutParams - - val targetMargin = (400 * progress).toInt() - val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams - deviceTextParams.topMargin = targetMargin - deviceText.layoutParams = deviceTextParams - - val baseTextSize = 24f - deviceText.textSize = baseTextSize + (progress * 8f) - - val baseSubTextSize = 16f - connectedText.textSize = baseSubTextSize + (progress * 4f) - } catch (e: Exception) { - e.printStackTrace() - } - } - fun close() { if (Looper.myLooper() != Looper.getMainLooper()) { Handler(Looper.getMainLooper()).post { close() } @@ -657,17 +455,8 @@ class IslandWindow(private val context: Context) { if (isClosing) return isClosing = true - try { - context.unregisterReceiver(batteryReceiver) - } catch (e: Exception) { -// e.printStackTrace() - } - - ServiceManager.getService()?.islandOpen = false autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return) - resetStretchEffects() - val videoView = islandView.findViewById(R.id.island_video_view) try { videoView.stopPlayback() @@ -713,7 +502,7 @@ class IslandWindow(private val context: Context) { e("IslandWindow", "Error removing view: $e") } isClosing = false - // Make sure all animations are canceled + try { springAnimation.cancel() } catch (e: Exception) { @@ -735,20 +524,11 @@ class IslandWindow(private val context: Context) { if (isClosing) return isClosing = true - try { - context.unregisterReceiver(batteryReceiver) - } catch (e: Exception) { - e.printStackTrace() - } - - ServiceManager.getService()?.islandOpen = false autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return) - // Cancel all ongoing animations springAnimation.cancel() flingAnimator.cancel() - // Immediately remove the view without animations cleanupAndRemoveView() } catch (e: Exception) { e.printStackTrace() diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt similarity index 76% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt index 4247ea47..02116809 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt @@ -24,13 +24,9 @@ import android.animation.AnimatorListenerAdapter import android.animation.ObjectAnimator import android.animation.PropertyValuesHolder import android.annotation.SuppressLint -import android.content.BroadcastReceiver import android.content.Context -import android.content.Intent -import android.content.IntentFilter import android.graphics.PixelFormat import android.media.AudioManager -import android.os.Build import android.os.Handler import android.os.Looper import android.util.Log @@ -46,10 +42,9 @@ import android.widget.LinearLayout import android.widget.TextView import android.widget.VideoView import me.kavishdevar.librepods.R -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.devices.Battery +import me.kavishdevar.librepods.devices.BatteryComponent +import me.kavishdevar.librepods.devices.BatteryStatus @SuppressLint("InflateParams", "ClickableViewAccessibility") class PopupWindow( @@ -60,7 +55,6 @@ class PopupWindow( private var isClosing = false private var autoCloseHandler = Handler(Looper.getMainLooper()) private var autoCloseRunnable: Runnable? = null - private var batteryUpdateReceiver: BroadcastReceiver? = null @Suppress("DEPRECATION") private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply { @@ -130,12 +124,12 @@ class PopupWindow( } @SuppressLint("InlinedApi", "SetTextI18s") - fun open(name: String = "AirPods Pro", batteryNotification: AirPodsNotifications.BatteryNotification) { + fun open(name: String = "AirPods Pro", batteries: Set) { try { if (mView.windowToken == null && mView.parent == null && !isClosing) { mView.findViewById(R.id.name).text = name - updateBatteryStatus(batteryNotification) + updateBatteryStatus(batteries) val vid = mView.findViewById(R.id.video) vid.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE) @@ -166,8 +160,6 @@ class PopupWindow( start() } - registerBatteryUpdateReceiver() - autoCloseRunnable = Runnable { close() } autoCloseHandler.postDelayed(autoCloseRunnable!!, 12000) } @@ -177,43 +169,6 @@ class PopupWindow( } } - @SuppressLint("UnspecifiedRegisterReceiverFlag") - private fun registerBatteryUpdateReceiver() { - batteryUpdateReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == AirPodsNotifications.BATTERY_DATA) { - val batteryList = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableArrayListExtra("data", Battery::class.java) - } else { - @Suppress("DEPRECATION") - intent.getParcelableArrayListExtra("data") - } - if (batteryList != null) { - updateBatteryStatusFromList(batteryList) - } - } - } - } - - val filter = IntentFilter(AirPodsNotifications.BATTERY_DATA) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.registerReceiver(batteryUpdateReceiver, filter, Context.RECEIVER_EXPORTED) - } else { - context.registerReceiver(batteryUpdateReceiver, filter) - } - } - - private fun unregisterBatteryUpdateReceiver() { - batteryUpdateReceiver?.let { - try { - context.unregisterReceiver(it) - batteryUpdateReceiver = null - } catch (e: Exception) { - Log.e("PopupWindow", "Error unregistering battery receiver: ${e.message}") - } - } - } - private fun updateBatteryStatusFromList(batteryList: List) { val batteryLeftText = mView.findViewById(R.id.left_battery) val batteryRightText = mView.findViewById(R.id.right_battery) @@ -221,7 +176,7 @@ class PopupWindow( batteryLeftText.text = batteryList.find { it.component == BatteryComponent.LEFT }?.let { if (it.status != BatteryStatus.DISCONNECTED) { - "\uDBC3\uDC8E ${it.level}%" + "L ${it.level}%" } else { "" } @@ -229,7 +184,7 @@ class PopupWindow( batteryRightText.text = batteryList.find { it.component == BatteryComponent.RIGHT }?.let { if (it.status != BatteryStatus.DISCONNECTED) { - "\uDBC3\uDC8D ${it.level}%" + "R ${it.level}%" } else { "" } @@ -237,7 +192,7 @@ class PopupWindow( batteryCaseText.text = batteryList.find { it.component == BatteryComponent.CASE }?.let { if (it.status != BatteryStatus.DISCONNECTED) { - "\uDBC3\uDE6C ${it.level}%" + "C ${it.level}%" } else { "" } @@ -245,9 +200,8 @@ class PopupWindow( } @SuppressLint("SetTextI18s") - fun updateBatteryStatus(batteryNotification: AirPodsNotifications.BatteryNotification) { - val batteryStatus = batteryNotification.getBattery() - updateBatteryStatusFromList(batteryStatus) + fun updateBatteryStatus(batteries: Set) { + updateBatteryStatusFromList(batteries.toList()) } fun close() { @@ -256,7 +210,6 @@ class PopupWindow( isClosing = true autoCloseRunnable?.let { autoCloseHandler.removeCallbacks(it) } - unregisterBatteryUpdateReceiver() val vid = mView.findViewById(R.id.video) vid.stopPlayback() diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt new file mode 100644 index 00000000..9f243334 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt @@ -0,0 +1,504 @@ +/* + 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 . +*/ + +package me.kavishdevar.librepods.presentation.screens + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp +import androidx.core.net.toUri +import androidx.lifecycle.viewmodel.compose.viewModel +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import me.kavishdevar.librepods.BuildConfig +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.components.AppInfoCard +import me.kavishdevar.librepods.presentation.components.DeviceInfoCard +import me.kavishdevar.librepods.presentation.components.StyledBottomSheet +import me.kavishdevar.librepods.presentation.components.StyledButton +import me.kavishdevar.librepods.presentation.components.StyledIconButton +import me.kavishdevar.librepods.presentation.components.StyledInputField +import me.kavishdevar.librepods.presentation.components.StyledList +import me.kavishdevar.librepods.presentation.components.StyledListItem +import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation +import me.kavishdevar.librepods.presentation.components.StyledToggle +import me.kavishdevar.librepods.presentation.icons.LocalIcons +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.theme.NightTheme +import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel +import me.kavishdevar.librepods.utils.XposedState +import java.util.concurrent.TimeUnit + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppSettingsScreen( + viewModel: AppSettingsViewModel = viewModel(), + navigateToPurchase: () -> Unit, + navigateToTroubleshooting: () -> Unit, + navigateToOpenSourceLicenses: () -> Unit, + navigateToReleaseNotesScreen: () -> Unit, + navigateToBleSettingsScreen: () -> Unit +) { + val context = LocalContext.current + val scrollState = rememberScrollState() + val state by viewModel.uiState.collectAsState() + + val backdrop = rememberLayerBackdrop() + + val contactBottomSheet = remember { mutableStateOf(false) } + val subjectState = remember { TextFieldState() } + val descriptionState = remember { TextFieldState() } + val subjectFocusRequester = remember { FocusRequester() } + val descriptionFocusRequester = remember { FocusRequester() } + + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 16.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .layerBackdrop(backdrop) + .verticalScroll(scrollState) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) + + if (!state.isPremium && state.state.hasConnectedToAACP) { + StyledButton( + onClick = navigateToPurchase, + backdrop = rememberLayerBackdrop(), + modifier = Modifier.fillMaxWidth(), + maxScale = 0.05f, + surfaceColor = MaterialTheme.colorScheme.primary + ) { + Text( + stringResource(R.string.unlock_advanced_features), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimary + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + if (state.state.timeUntilFOSSPremiumExpiry > 0L) { + Box( + modifier = Modifier + .background(Color(0xFF32829B), RoundedCornerShape(28.dp)) + .clip(RoundedCornerShape(28.dp)) + .clickable { + val emailIntent = Intent(Intent.ACTION_SENDTO).apply { + data = "mailto:".toUri() + putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz")) + putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error") + putExtra( + Intent.EXTRA_TEXT, + "Please enter your GitHub username to restore your premium access:\n\nGitHub username: " + ) + } + context.startActivity(emailIntent) + } + ) { + Text( + text = stringResource( + R.string.play_foss_premium_banner, maxOf(1, TimeUnit.MILLISECONDS.toDays(state.state.timeUntilFOSSPremiumExpiry).toInt()) + ), + modifier = Modifier + .padding(16.dp), + style = MaterialTheme.typography.bodyMediumEmphasized, + color = Color.White + ) + } + } + + if (state.state.hasConnectedToAACP) { + StyledList(title = stringResource(R.string.appearance)) { + StyledListItem( + contentText = stringResource(R.string.light), + selected = state.settings.nightMode == NightTheme.Light, + onClick = { viewModel.updateSettings { it.copy(nightMode = NightTheme.Light) } }, + enabled = state.isPremium + ) + + StyledListItem( + contentText = stringResource(R.string.system), + selected = state.settings.nightMode == NightTheme.System, + onClick = { viewModel.updateSettings { it.copy(nightMode = NightTheme.System) } }, + enabled = state.isPremium + ) + + StyledListItem( + contentText = stringResource(R.string.dark), + selected = state.settings.nightMode == NightTheme.Dark, + onClick = { viewModel.updateSettings { it.copy(nightMode = NightTheme.Dark) } }, + enabled = state.isPremium + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + StyledList(title = stringResource(R.string.design_system)) { + StyledListItem( + contentText = stringResource(R.string.apple), + selected = state.settings.designSystem == DesignSystem.Apple, + onClick = { viewModel.updateSettings { it.copy(designSystem = DesignSystem.Apple) } }, + enabled = state.isPremium + ) + + StyledListItem( + contentText = stringResource(R.string.material3e), + selected = state.settings.designSystem == DesignSystem.Material, + onClick = { viewModel.updateSettings { it.copy(designSystem = DesignSystem.Material) } }, + enabled = state.isPremium + ) + } + } + + if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) { + val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth) + StyledToggle( + label = stringResource(R.string.act_as_an_apple_device) + " (${ + stringResource( + R.string.requires_xposed + ) + })", + description = stringResource(R.string.act_as_an_apple_device_description), + checked = state.vendorIdHook, + onCheckedChange = { checked -> + Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show() + viewModel.setVendorIdHook(checked) + } + ) + } + + StyledListItem( + contentText = stringResource(R.string.ble_settings), + supportingText = stringResource(R.string.do_not_change), + orientation = StyledListItemOrientation.Vertical, + onClick = navigateToBleSettingsScreen + ) + + StyledToggle( + label = stringResource(R.string.enable_debug_mode), + checked = state.settings.debugMode, + onCheckedChange = { checked -> + viewModel.updateSettings { + it.copy(debugMode = checked) + } + } + ) + + if (!BuildConfig.PLAY_BUILD) { + Spacer(modifier = Modifier.height(16.dp)) + StyledList { + StyledListItem( + contentText = stringResource(R.string.troubleshooting), + onClick = navigateToTroubleshooting, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + StyledList(title = stringResource(R.string.contact)) { + StyledListItem( + contentText = stringResource(R.string.email), + supportingText = stringResource(R.string.contact_email_supporting_text), + orientation = StyledListItemOrientation.Vertical, + onClick = { contactBottomSheet.value = true }, + ) + + val errorOpeningDiscordInviteText = stringResource(R.string.error_opening_discord_invite) + + StyledListItem( + contentText = stringResource(R.string.discord), + supportingText = stringResource(R.string.contact_discord_supporting_text), + orientation = StyledListItemOrientation.Vertical, + onClick = { + try { + val intent = + Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri()) + context.startActivity(intent) + } catch (e: Exception) { + e.printStackTrace() + Toast.makeText( + context, + errorOpeningDiscordInviteText, + Toast.LENGTH_SHORT + ).show() + } + }, + ) + + val errorOpeningGitHubLink = stringResource(R.string.error_opening_github_link) + + StyledListItem( + contentText = stringResource(R.string.github_issues), + supportingText = stringResource(R.string.contact_github_supporting_text), + orientation = StyledListItemOrientation.Vertical, + onClick = { + try { + val appVersion = + Uri.encode("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") + val device = Uri.encode("${Build.MANUFACTURER} ${Build.MODEL}") + val androidVersion = Uri.encode("${Build.ID} (${Build.DISPLAY})") + val appSource = Uri.encode( + when { + BuildConfig.PLAY_BUILD -> "Play" + else -> "GitHub" + } + ) + val url = "https://github.com/kavishdevar/librepods/issues/new" + + "?template=01-bug-report-android.yml" + + "&app-source=$appSource" + + "&app-version=$appVersion" + + "&device=$device" + + "&android-version=$androidVersion" + + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + context.startActivity(intent) + } catch (e: Exception) { + e.printStackTrace() + Toast.makeText( + context, + errorOpeningGitHubLink, + Toast.LENGTH_SHORT + ).show() + } + }, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + DeviceInfoCard() + Spacer(modifier = Modifier.height(16.dp)) + AppInfoCard(navigateToReleaseNotesScreen) + + Spacer(modifier = Modifier.height(16.dp)) + + StyledListItem( + contentText = stringResource(R.string.open_source_licenses), + onClick = navigateToOpenSourceLicenses, + ) + + Spacer(modifier = Modifier.height(bottomPadding)) + +// if (state.showCameraDialog) { +// AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = { +// Text( +// stringResource(R.string.set_custom_camera_package), +// style = MaterialTheme.typography.titleSmall, +// ) +// }, text = { +// Column { +// Text( +// stringResource(R.string.enter_custom_camera_package), +// style = MaterialTheme.typography.bodyMedium, +// 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", +// style = MaterialTheme.typography.labelMedium +// ) +// } +// }, dismissButton = { +// TextButton( +// onClick = { viewModel.setShowCameraDialog(false) }) { +// Text( +// "Cancel", +// style = MaterialTheme.typography.labelMedium +// ) +// } +// }) +// } + } + + StyledBottomSheet( + visible = contactBottomSheet.value, + onDismiss = { contactBottomSheet.value = false }, + backdrop = backdrop + ) { innerBackdrop, progress -> + val animatedPadding = lerp(16.dp, 2.dp, progress) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = animatedPadding) + .padding(bottom = 16.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + StyledIconButton( + backdrop = innerBackdrop, + onClick = { contactBottomSheet.value = false } + ) { + Icon( + imageVector = LocalIcons.current.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onBackground + ) + } + Text ( + text = stringResource(R.string.describe_your_issue), + style = MaterialTheme.typography.labelLargeEmphasized, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onBackground + ) + StyledIconButton( + backdrop = innerBackdrop, + surfaceColor = MaterialTheme.colorScheme.primary, + enabled = subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty(), + onClick = { + contactBottomSheet.value = false + val intent = Intent(Intent.ACTION_SENDTO).apply { + data = "mailto:".toUri() + putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz")) + putExtra(Intent.EXTRA_SUBJECT, "LibrePods: ${subjectState.text}") + putExtra( + Intent.EXTRA_TEXT, + "${descriptionState.text}" + + "\n\n----------" + + "\nPhone details:" + + "\nMANUFACTURER: ${Build.MANUFACTURER}" + + "\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" + + "\nDISPLAY_VERSION: ${Build.DISPLAY}" + + "\nID: ${Build.ID} (SDK ${Build.VERSION.SDK_INT_FULL})" + + "\nXposed enabled/active: ${XposedState.isAvailable}/${XposedState.bluetoothScopeEnabled}" + + "\n\nApp details:" + + "\nVERSION: ${BuildConfig.VERSION_NAME}" + + "\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" + + "\nFLAVOR: ${BuildConfig.FLAVOR}" + + "\nBUILD_TYPE: ${BuildConfig.BUILD_TYPE}" + ) + } + context.startActivity(intent) + subjectState.clearText() + descriptionState.clearText() + } + ) { + Icon( + imageVector = LocalIcons.current.Send, + contentDescription = "Send", + tint = if (subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty()) Color.White else Color.Gray + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + StyledInputField( + inputState = subjectState, + focusRequester = subjectFocusRequester, + placeholder = stringResource(R.string.subject), + forceApple = true + ) + + Spacer(modifier = Modifier.height(12.dp)) + + StyledInputField( + inputState = descriptionState, + focusRequester = descriptionFocusRequester, + placeholder = stringResource(R.string.describe_your_issue), + singleLine = false, + forceApple = true + ) + } + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/BLESettingsScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/BLESettingsScreen.kt new file mode 100644 index 00000000..7d84d989 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/BLESettingsScreen.kt @@ -0,0 +1,144 @@ +package me.kavishdevar.librepods.presentation.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.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.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.debounce +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.components.StyledList +import me.kavishdevar.librepods.presentation.components.StyledListItem +import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation +import me.kavishdevar.librepods.presentation.components.StyledSlider +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel +import kotlin.time.Duration.Companion.seconds + +@Composable +fun BLESettingsScreenRoute( + viewModel: AppSettingsViewModel +) { + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + + val uiState by viewModel.uiState.collectAsState() + val settings = uiState.settings + + Box ( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + BLESettingsScreen( + topPadding = topPadding, + bottomPadding = bottomPadding, + scanMode = settings.bleScanMode, + onScanModeChanged = { scanMode -> + viewModel.updateSettings { + it.copy(bleScanMode = scanMode) + } + }, + reportDelay = settings.bleReportDelay, + onReportDelayChanged = { reportDelay -> + viewModel.updateSettings { + it.copy(bleReportDelay = reportDelay) + } + }, + ) + } +} + +@Composable +fun BLESettingsScreen( + topPadding: Dp = 16.dp, + bottomPadding: Dp = 16.dp, + scanMode: Int, + onScanModeChanged: (Int) -> Unit, + reportDelay: Long, + onReportDelayChanged: (Long) -> Unit, +) { + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.padding(top = topPadding)) + + Text( + text = stringResource(R.string.do_not_change), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + + StyledList(title = stringResource(R.string.scanMode)) { + StyledListItem( + onClick = { onScanModeChanged(0) }, + contentText = stringResource(R.string.low_power), + supportingText = stringResource(R.string.ble_scan_mode_low_power_description), + orientation = StyledListItemOrientation.Vertical, + selected = scanMode == 0 + ) + StyledListItem( + onClick = { onScanModeChanged(1) }, + contentText = stringResource(R.string.balanced), + supportingText = stringResource(R.string.ble_scan_mode_balanced_description), + orientation = StyledListItemOrientation.Vertical, + selected = scanMode == 1 + ) + StyledListItem( + onClick = { onScanModeChanged(2) }, + contentText = stringResource(R.string.low_latency), + supportingText = stringResource(R.string.ble_scan_mode_low_latency_description), + orientation = StyledListItemOrientation.Vertical, + selected = scanMode == 2 + ) + } + + val sliderValue = remember { mutableFloatStateOf(reportDelay.toFloat()) } + + LaunchedEffect(sliderValue) { + snapshotFlow { sliderValue.floatValue } + .debounce(1.seconds) + .collect { newValue -> + onReportDelayChanged(newValue.toLong()) + } + } + + StyledList(title = stringResource(R.string.ble_report_delay)) { + StyledSlider( + value = sliderValue.floatValue, + onValueChange = { sliderValue.floatValue = it }, + valueRange = 0f..1000f, + description = sliderValue.floatValue.toString() + "ms", + ) + } + Spacer(modifier = Modifier.padding(bottom = bottomPadding)) + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/DeviceListScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/DeviceListScreen.kt new file mode 100644 index 00000000..5fb197bc --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/DeviceListScreen.kt @@ -0,0 +1,466 @@ +package me.kavishdevar.librepods.presentation.screens + +import android.util.Log +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.material3.toPath +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.graphics.shapes.Morph +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.bluetooth.MacAddress +import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier +import me.kavishdevar.librepods.devices.AirPodsSpecs +import me.kavishdevar.librepods.devices.AppleDevice +import me.kavishdevar.librepods.devices.AppleMetadata +import me.kavishdevar.librepods.devices.AppleState +import me.kavishdevar.librepods.devices.BaseCapability +import me.kavishdevar.librepods.devices.ConnectionState +import me.kavishdevar.librepods.devices.Device +import me.kavishdevar.librepods.presentation.components.NoiseControlSettings +import me.kavishdevar.librepods.presentation.components.StyledList +import me.kavishdevar.librepods.presentation.components.StyledListItem +import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation +import me.kavishdevar.librepods.presentation.icons.LocalIcons +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.utils.createAirPodsBatteryRichText +import kotlin.math.min +import kotlin.time.Duration.Companion.milliseconds + +@Composable +fun DeviceListRoute( + devices: Map>, + navigateToDevice: (MacAddress) -> Unit, +) { + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + + Box ( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + DeviceListScreen( + devices = devices, + navigateToDevice = navigateToDevice, + topPadding = topPadding, + bottomPadding = bottomPadding + ) + } +} + +@Composable +fun DeviceListScreen( + devices: Map>, + navigateToDevice: (MacAddress) -> Unit, + topPadding: Dp = 16.dp, + bottomPadding: Dp = 16.dp +) { + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.padding(top = topPadding)) + + Log.d("DeviceListScreen", "Rendering device list with ${devices.size} devices") + + StyledList(title = stringResource(R.string.devices), key = devices) { + devices.forEach { (macAddress, device) -> + val connectionState by device.connectionState.collectAsState() + val deviceState by device.state.collectAsState() + val deviceMetadata by device.metadata.collectAsState() + + fun ConnectionState.shape() = when (this) { + ConnectionState.DISCONNECTED -> MaterialShapes.Circle.normalized() + ConnectionState.CONNECTING -> MaterialShapes.SoftBurst.normalized() + ConnectionState.CONNECTED -> MaterialShapes.SoftBurst.normalized() + ConnectionState.DISCONNECTING -> MaterialShapes.Cookie4Sided.normalized() + ConnectionState.AVAILABLE -> MaterialShapes.Circle.normalized() + } + + val connectingShapes = remember { + listOf( + MaterialShapes.Cookie4Sided.normalized(), + MaterialShapes.SoftBurst.normalized(), + MaterialShapes.Cookie9Sided.normalized(), + MaterialShapes.Pentagon.normalized(), + MaterialShapes.Pill.normalized(), + MaterialShapes.Sunny.normalized(), + MaterialShapes.Cookie4Sided.normalized(), + MaterialShapes.Oval.normalized(), + ) + } + + val connectingMorphs = remember { + buildList { + connectingShapes.zipWithNext { a, b -> + add(Morph(a, b)) + } + add(Morph(connectingShapes.last(), connectingShapes.first())) + } + } + + var previousState by remember { mutableStateOf(connectionState) } + + var pressed by remember { mutableStateOf(false) } + + val touchMorph = remember { + Morph( + if (connectionState == ConnectionState.CONNECTED) MaterialShapes.SoftBurst.normalized() else MaterialShapes.Circle.normalized(), + MaterialShapes.Cookie4Sided.normalized() + ) + } + + val touchProgress = remember { Animatable(0f) } + + LaunchedEffect(pressed) { + touchProgress.animateTo( + targetValue = if (pressed) 1f else 0f, + animationSpec = spring( + dampingRatio = 0.6f, + stiffness = 200f, + visibilityThreshold = 0.1f + ) + ) + } + + var currentMorphIndex by remember { mutableIntStateOf(0) } + var morphRotationTarget by remember { mutableFloatStateOf(90f) } + + val morphProgress = remember { Animatable(0f) } + val globalRotation = remember { Animatable(0f) } + + LaunchedEffect(connectionState) { + if (connectionState == ConnectionState.CONNECTING) { + pressed = false + currentMorphIndex = 0 + morphRotationTarget = 90f + + morphProgress.stop() + morphProgress.snapTo(0f) + + globalRotation.stop() + globalRotation.snapTo(0f) + + coroutineScope { + launch { + while (isActive) { + val deferred = async { + morphProgress.animateTo( + 1f, + spring( + dampingRatio = 0.6f, + stiffness = 200f, + visibilityThreshold = 0.1f + ) + ) + + currentMorphIndex = + (currentMorphIndex + 1) % connectingMorphs.size + + morphProgress.snapTo(0f) + + morphRotationTarget = + (morphRotationTarget + 90f) % 360f + } + + delay(650.milliseconds) + deferred.await() + } + } + + launch { + globalRotation.animateTo( + targetValue = 360f, + animationSpec = infiniteRepeatable( + tween(4666, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ) + ) + } + } + } else { + globalRotation.stop() + morphProgress.stop() + + morphProgress.snapTo(0f) + + morphProgress.animateTo( + 1f, + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ) + ) + + previousState = connectionState + } + } + + val morph = remember( + connectionState, + previousState, + currentMorphIndex + ) { + if (connectionState == ConnectionState.CONNECTING) { + connectingMorphs[currentMorphIndex] + } else { + Morph(previousState.shape(), connectionState.shape()) + } + } + + val iconBackgroundColor by animateColorAsState( + targetValue = when (connectionState) { + ConnectionState.CONNECTING -> MaterialTheme.colorScheme.secondaryContainer + ConnectionState.CONNECTED -> MaterialTheme.colorScheme.primaryContainer + ConnectionState.DISCONNECTING -> MaterialTheme.colorScheme.surfaceContainer + ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.surfaceDim + ConnectionState.AVAILABLE -> MaterialTheme.colorScheme.surfaceBright + }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ), + label = "iconBackgroundColor" + ) + + val iconColor by animateColorAsState( + targetValue = when (connectionState) { + ConnectionState.CONNECTING -> MaterialTheme.colorScheme.onSecondaryContainer + ConnectionState.CONNECTED -> MaterialTheme.colorScheme.onPrimaryContainer + ConnectionState.DISCONNECTING -> MaterialTheme.colorScheme.onSurface + ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.contentColorFor(MaterialTheme.colorScheme.surfaceDim) + ConnectionState.AVAILABLE -> MaterialTheme.colorScheme.contentColorFor(MaterialTheme.colorScheme.surfaceBright) + }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ), + label = "iconColor" + ) + + val path = remember { Path() } + val matrix = remember { Matrix() } + + StyledListItem( + onClick = if (device.connectionState.collectAsState().value == ConnectionState.CONNECTED) { { navigateToDevice(macAddress) } } else null, + contentText = deviceMetadata.name, + leadingContent = { + Box( + modifier = Modifier + .size(56.dp) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + pressed = true + tryAwaitRelease() + pressed = false + }, + onTap = { + CoroutineScope(Dispatchers.IO).launch { + when (connectionState) { + ConnectionState.CONNECTED -> device.disconnect() + ConnectionState.DISCONNECTED -> device.connect() + else -> {} + } + } + } + ) + } + .drawBehind { + val activeMorph: Morph + val activeProgress: Float + + if (connectionState != ConnectionState.CONNECTING && touchProgress.value > 0f) { + activeMorph = touchMorph + activeProgress = touchProgress.value + } else { + activeMorph = morph + activeProgress = morphProgress.value + } + + val shapePath = activeMorph.toPath( + progress = activeProgress, + path = path + ) + + val bounds = shapePath.getBounds() + + val scale = min( + size.width / bounds.width, + size.height / bounds.height + ) * 0.9f + + matrix.reset() + matrix.scale(scale, scale) + + shapePath.transform(matrix) + + shapePath.translate( + size.center - shapePath.getBounds().center + ) + + val rotation = + if (connectionState == ConnectionState.CONNECTING) { + morphProgress.value * 90f + + morphRotationTarget + + globalRotation.value + } else { + 0f + } + + rotate(rotation) { + drawPath( + path = shapePath, + color = iconBackgroundColor + ) + } + }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = LocalIcons.current.fromName(deviceMetadata.iconName)?: LocalIcons.current.Headphones, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = iconColor + ) + } + }, + supportingContent = { + when (connectionState) { + ConnectionState.AVAILABLE -> { + when (deviceState) { + is AppleState -> { +// battery from BLE + } + else -> Text( + text = "????", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + ConnectionState.DISCONNECTING, ConnectionState.DISCONNECTED -> { + Text( + text = macAddress.value, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + ConnectionState.CONNECTING -> { + Text( + text = stringResource(R.string.connecting), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + ConnectionState.CONNECTED -> { + when (deviceState) { + is AppleState -> { + Column ( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + val deviceState = deviceState as AppleState + val deviceMetadata = deviceMetadata as AppleMetadata + + val batteryRichText = createAirPodsBatteryRichText( + battery = deviceState.battery, + airPodsSpec = AirPodsSpecs.getSpec(deviceMetadata.model) + ) + + Text( + text = batteryRichText.text, + inlineContent = batteryRichText.inlineContent, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (AirPodsSpecs.getSpec(deviceMetadata.model).baseCapabilities.contains(BaseCapability.LISTENING_MODE)) { + NoiseControlSettings( + showOffListeningMode = deviceState.controlStates[ControlCommandIdentifier.LISTENING_MODE]?.get(0) == 1.toByte(), + noiseControlModeValue = deviceState.controlStates[ControlCommandIdentifier.LISTENING_MODE]?.get(0)?.toInt() ?: 2, + onNoiseControlModeChanged = { newMode -> + CoroutineScope(Dispatchers.IO).launch { + (device as AppleDevice).setControlCommand( + ControlCommandIdentifier.LISTENING_MODE, + newMode.toByte() + ) + } + }, + showLabels = false + ) + } + } + } + } + } + } + }, + orientation = StyledListItemOrientation.Vertical + ) + } + } + + Spacer(modifier = Modifier.padding(top = bottomPadding)) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/LoadingScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/LoadingScreen.kt similarity index 100% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/LoadingScreen.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/LoadingScreen.kt diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/OpenSourceLicensesScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/OpenSourceLicensesScreen.kt new file mode 100644 index 00000000..c6230100 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/OpenSourceLicensesScreen.kt @@ -0,0 +1,187 @@ +/* + 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 . +*/ + +package me.kavishdevar.librepods.presentation.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.mikepenz.aboutlibraries.ui.compose.LibrariesContainer +import com.mikepenz.aboutlibraries.ui.compose.LibraryDefaults +import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries +import com.mikepenz.aboutlibraries.ui.compose.libraryColors +import com.mikepenz.aboutlibraries.ui.compose.m3.style.m3VariantColors +import com.mikepenz.aboutlibraries.ui.compose.style.DefaultLibraryActionBadges +import com.mikepenz.aboutlibraries.ui.compose.variant.LibrariesVariant +import com.mikepenz.aboutlibraries.ui.compose.variant.LibraryActionMode +import com.mikepenz.aboutlibraries.ui.compose.variant.LibraryBadges +import com.mikepenz.aboutlibraries.ui.compose.variant.LibraryDetailMode +import com.mikepenz.aboutlibraries.ui.compose.variant.LibraryInlineDetail +import com.mikepenz.aboutlibraries.ui.compose.variant.LibraryRow +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem + +@Composable +fun OpenSourceLicensesScreen() { + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(Modifier.height(topPadding)) + + val libraries by produceLibraries(R.raw.aboutlibraries) + + val count = libraries?.libraries?.size ?: 0 + + LibrariesContainer( + libraries = libraries, + modifier = Modifier.fillMaxSize(), + + contentPadding = PaddingValues(top = 16.dp, bottom = bottomPadding), + + badges = LibraryBadges(version = true), + + variant = LibrariesVariant.Refined, + detailMode = LibraryDetailMode.Inline, + + colors = LibraryDefaults.libraryColors( + libraryBackgroundColor = MaterialTheme.colorScheme.surface, + libraryContentColor = MaterialTheme.colorScheme.onBackground, + ), + + variantColors = LibraryDefaults.m3VariantColors( + rowBackground = MaterialTheme.colorScheme.surfaceContainer, + rowOnBackground = MaterialTheme.colorScheme.onSurface, + rowExpandedBackground = MaterialTheme.colorScheme.surfaceContainer + ), + + divider = { + Spacer(modifier = Modifier.height(2.dp)) + }, + + libraryRow = { index, library, expanded, toggle, style -> + val transition = updateTransition( + targetState = expanded, + label = "library" + ) + + val bottomCorner by transition.animateDp( + transitionSpec = { + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ) + }, + label = "bottomCorner" + ) { expanded -> + if (expanded) 0.dp else if (index == count - 1) 24.dp else 8.dp + } + + val topCorner = when { + count == 1 -> 24.dp + index == 0 -> 24.dp + index == count - 1 -> 8.dp + else -> 8.dp + } + + val shape = RoundedCornerShape( + topStart = topCorner, + topEnd = topCorner, + bottomStart = bottomCorner, + bottomEnd = bottomCorner, + ) + + LibraryRow( + library = library, + expanded = expanded, + onToggle = toggle, + style = style, + variant = LibrariesVariant.Refined, + badges = LibraryBadges(version = true), + modifier = Modifier.clip(shape) + ) + + transition.AnimatedVisibility( + visible = { it }, + enter = expandVertically( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ) + ), + exit = shrinkVertically( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ) + ), + ) { + LibraryInlineDetail( + library = library, + actionMode = LibraryActionMode.Chips, + style = style, + actionLabels = DefaultLibraryActionBadges, + onActionClick = { _, _ -> false }, + onDialogRequest = { }, + modifier = Modifier + .clip( + RoundedCornerShape( + bottomStart = if (index == count - 1) 24.dp else 8.dp, + bottomEnd = if (index == count - 1) 24.dp else 8.dp + ) + ) + .background(MaterialTheme.colorScheme.surfaceContainer) + ) + } + } + ) + + Spacer(Modifier.height(bottomPadding)) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt similarity index 68% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt index c5d1642c..0d1607fb 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt @@ -45,7 +45,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.components.ListItemOrientation +import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation import me.kavishdevar.librepods.presentation.components.MaterialButtonStyle import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledList @@ -70,7 +70,7 @@ fun PurchaseScreen( val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp Column( modifier = Modifier @@ -91,87 +91,87 @@ fun PurchaseScreen( if (!state.isPremium) { StyledList(title = stringResource(R.string.free_features)) { StyledListItem( - name = stringResource(R.string.ear_detection), - description = stringResource(R.string.ear_detection_description), + contentText = stringResource(R.string.ear_detection), + supportingText = stringResource(R.string.ear_detection_description), enabled = false, - orientation = ListItemOrientation.Vertical + orientation = StyledListItemOrientation.Vertical ) StyledListItem( - name = stringResource(R.string.battery), - description = stringResource(R.string.battery_description), + contentText = stringResource(R.string.battery), + supportingText = stringResource(R.string.battery_description), enabled = false, - orientation = ListItemOrientation.Vertical + orientation = StyledListItemOrientation.Vertical ) StyledListItem( - name = stringResource(R.string.noise_control), - description = stringResource(R.string.noise_control_description), + contentText = stringResource(R.string.noise_control), + supportingText = stringResource(R.string.noise_control_description), enabled = false, - orientation = ListItemOrientation.Vertical + orientation = StyledListItemOrientation.Vertical ) if (XposedState.isAvailable) { StyledListItem( - name = "${stringResource(R.string.hearing_aid)} (${stringResource(R.string.requires_xposed)})", - description = stringResource(R.string.hearing_aid_description) + contentText = "${stringResource(R.string.hearing_aid)} (${stringResource(R.string.requires_xposed)})", + supportingText = stringResource(R.string.hearing_aid_description) .substringBefore("\n\n"), enabled = false, - orientation = ListItemOrientation.Vertical + orientation = StyledListItemOrientation.Vertical ) } } Spacer(modifier = Modifier.height(24.dp)) - StyledList(title = stringResource(R.string.advanced_features), description = stringResource(R.string.feature_availability_disclaimer)) { + StyledList(title = stringResource(R.string.advanced_features), description = stringResource(R.string.feature_availability_disclaimer)) { StyledListItem( - name = stringResource(R.string.conversational_awareness), - description = stringResource(R.string.conversational_awareness_description), + contentText = stringResource(R.string.conversational_awareness), + supportingText = stringResource(R.string.conversational_awareness_description), enabled = false, - orientation = ListItemOrientation.Vertical + orientation = StyledListItemOrientation.Vertical ) StyledListItem( - name = stringResource(R.string.digital_assistant_on_long_press), - description = stringResource(R.string.digital_assistant_on_long_press_description), + contentText = stringResource(R.string.digital_assistant_on_long_press), + supportingText = stringResource(R.string.digital_assistant_on_long_press_description), enabled = false, - orientation = ListItemOrientation.Vertical + orientation = StyledListItemOrientation.Vertical ) StyledListItem( - name = stringResource(R.string.head_gestures), - description = stringResource(R.string.head_gestures_details), + contentText = stringResource(R.string.head_gestures), + supportingText = stringResource(R.string.head_gestures_details), enabled = false, - orientation = ListItemOrientation.Vertical + orientation = StyledListItemOrientation.Vertical ) StyledListItem( - name = stringResource(R.string.advanced_device_settings), - description = stringResource(R.string.advanced_device_settings_description), + contentText = stringResource(R.string.advanced_device_settings), + supportingText = stringResource(R.string.advanced_device_settings_description), enabled = false, - orientation = ListItemOrientation.Vertical + orientation = StyledListItemOrientation.Vertical ) StyledListItem( - name = stringResource(R.string.automatic_connection), - description = stringResource(R.string.automatic_connection_description), + contentText = stringResource(R.string.automatic_connection), + supportingText = stringResource(R.string.automatic_connection_description), enabled = false, - orientation = ListItemOrientation.Vertical + orientation = StyledListItemOrientation.Vertical ) StyledListItem( - name = stringResource(R.string.customizations), - description = stringResource(R.string.customizations_description), + contentText = stringResource(R.string.customizations), + supportingText = stringResource(R.string.customizations_description), enabled = false, - orientation = ListItemOrientation.Vertical + orientation = StyledListItemOrientation.Vertical ) StyledListItem( - name = stringResource(R.string.support_the_development), - description = stringResource(R.string.support_development_description), + contentText = stringResource(R.string.support_the_development), + supportingText = stringResource(R.string.support_development_description), enabled = false, - orientation = ListItemOrientation.Vertical + orientation = StyledListItemOrientation.Vertical ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/ReleaseNotesScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/ReleaseNotesScreen.kt similarity index 99% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/ReleaseNotesScreen.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/ReleaseNotesScreen.kt index c9ca9953..ec69b9b0 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/ReleaseNotesScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/ReleaseNotesScreen.kt @@ -100,11 +100,11 @@ fun ReleaseNotesScreen( val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - LibrePodsTheme(m3eEnabled = true) { + LibrePodsTheme(designSystem = DesignSystem.Material) { Column( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background, RoundedCornerShape(52.dp)), + .background(MaterialTheme.colorScheme.surfaceContainer, RoundedCornerShape(52.dp)), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { @@ -372,7 +372,7 @@ fun ReleaseNotesScreen( @Composable fun ReleaseNotesScreenPreview() { LibrePodsTheme( - m3eEnabled = false + designSystem = DesignSystem.Apple ) { ReleaseNotesScreen( updates = updates, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt similarity index 99% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt index 38d299e3..db9239fb 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt @@ -77,6 +77,7 @@ 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.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -96,6 +97,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.icons.LocalIcons import me.kavishdevar.librepods.utils.LogCollector import java.io.File import java.text.SimpleDateFormat @@ -234,7 +236,7 @@ fun TroubleshootingScreen() { fontSize = 14.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) + fontFamily = FontFamily(Font(R.font.inter)) ), modifier = Modifier.padding(16.dp, bottom = 4.dp, top = 8.dp) ) @@ -375,7 +377,7 @@ fun TroubleshootingScreen() { fontSize = 14.sp, fontWeight = FontWeight.Light, color = textColor.copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) + fontFamily = FontFamily(Font(R.font.inter)) ), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 8.dp) ) @@ -628,7 +630,7 @@ fun TroubleshootingScreen() { modifier = Modifier.width(150.dp) ) { Icon( - painter = painterResource(id = R.drawable.ic_save), + imageVector = LocalIcons.current.Save, contentDescription = "Save" ) Spacer(modifier = Modifier.width(8.dp)) @@ -790,7 +792,7 @@ fun TroubleshootingScreen() { style = TextStyle( fontWeight = FontWeight.Bold, fontSize = 20.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)) + fontFamily = FontFamily(Font(R.font.inter)) ), color = textColor ) @@ -799,7 +801,7 @@ fun TroubleshootingScreen() { .format(Date(selectedLogFile?.lastModified() ?: 0)), fontSize = 14.sp, color = textColor.copy(alpha = 0.7f), - fontFamily = FontFamily(Font(R.font.sf_pro)) + fontFamily = FontFamily(Font(R.font.inter)) ) } @@ -900,7 +902,7 @@ fun TroubleshootingScreen() { modifier = Modifier.weight(1f) ) { Icon( - painter = painterResource(id = R.drawable.ic_save), + imageVector = LocalIcons.current.Save, contentDescription = "Save" ) Spacer(modifier = Modifier.width(8.dp)) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AccessibilitySettingsScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AccessibilitySettingsScreen.kt similarity index 80% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AccessibilitySettingsScreen.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AccessibilitySettingsScreen.kt index 52e7c1f4..ea43afc5 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AccessibilitySettingsScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AccessibilitySettingsScreen.kt @@ -16,11 +16,8 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.presentation.screens +package me.kavishdevar.librepods.presentation.screens.apple -// import me.kavishdevar.librepods.utils.RadareOffsetFinder -import android.annotation.SuppressLint -import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -35,7 +32,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -51,50 +47,44 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.launch import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager -import me.kavishdevar.librepods.bluetooth.ATTHandles -import me.kavishdevar.librepods.data.Capability +import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier +import me.kavishdevar.librepods.bluetooth.att.ATTHandle +import me.kavishdevar.librepods.devices.AirPodsSpecs +import me.kavishdevar.librepods.devices.BaseCapability import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledToggle +import me.kavishdevar.librepods.presentation.icons.LocalIcons import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel -import kotlin.io.encoding.ExperimentalEncodingApi +import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import kotlin.time.Duration.Companion.milliseconds -private var phoneMediaDebounceJob: Job? = null +//private var phoneMediaDebounceJob: Job? = null -@SuppressLint("DefaultLocale") -@ExperimentalHazeMaterialsApi -@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class, FlowPreview::class) @Composable -fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit, navigateToTransparencyCustomization: () -> Unit) { - val state by viewModel.uiState.collectAsState() +fun AccessibilitySettingsScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit, navigateToTransparencyCustomization: () -> Unit) { + val uiState by viewModel.uiState.collectAsState() + + val state = uiState.state + val metadata = uiState.metadata val hearingAidEnabled = - state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull( + state.controlStates[ControlCommandIdentifier.HEARING_AID]?.getOrNull( 1 ) - ?.toInt() == 1 && state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull( + ?.toInt() == 1 && state.controlStates[ControlCommandIdentifier.HEARING_AID]?.getOrNull( 0 )?.toInt() == 1 val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp Column( modifier = Modifier @@ -106,7 +96,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: ) { Spacer(modifier = Modifier.height(topPadding)) - if (!state.isPremium) { + if (!uiState.isPremium) { StyledButton( onClick = navigateToPurchase, backdrop = rememberLayerBackdrop(), @@ -134,7 +124,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: ) val selectedPressSpeedValue = - state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull( + state.controlStates[ControlCommandIdentifier.DOUBLE_CLICK_INTERVAL]?.getOrNull( 0 ) var selectedPressSpeed by remember { @@ -150,7 +140,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: ) val selectedPressAndHoldDurationValue = - state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull( + state.controlStates[ControlCommandIdentifier.CLICK_HOLD_INTERVAL]?.getOrNull( 0 ) var selectedPressAndHoldDuration by remember { @@ -166,7 +156,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: 3.toByte() to stringResource(R.string.longest) ) val selectedVolumeSwipeSpeedValue = - state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull( + state.controlStates[ControlCommandIdentifier.VOLUME_SWIPE_INTERVAL]?.getOrNull( 0 ) var selectedVolumeSwipeSpeed by remember { @@ -176,30 +166,30 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: ) } - val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) } - val phoneEQEnabled = remember { mutableStateOf(false) } - val mediaEQEnabled = remember { mutableStateOf(false) } - - LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) { - phoneMediaDebounceJob?.cancel() - phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch { - delay(150.milliseconds) - try { - val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte() - val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte() - Log.d( - "AccessibilitySettingsScreen", - "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})" - ) - viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte) - } catch (e: Exception) { - Log.w( - "AccessibilitySettingsScreen", - "Error sending phone/media EQ: ${e.message}" - ) - } - } - } +// val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) } +// val phoneEQEnabled = remember { mutableStateOf(false) } +// val mediaEQEnabled = remember { mutableStateOf(false) } +// +// LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) { +// phoneMediaDebounceJob?.cancel() +// phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch { +// delay(150.milliseconds) +// try { +// val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte() +// val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte() +// Log.d( +// "AccessibilitySettingsScreen", +// "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})" +// ) +// viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte) +// } catch (e: Exception) { +// Log.w( +// "AccessibilitySettingsScreen", +// "Error sending phone/media EQ: ${e.message}" +// ) +// } +// } +// } StyledList( title = stringResource(R.string.press_speed), @@ -207,13 +197,13 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: ) { pressSpeedOptions.forEach { (value, label) -> StyledListItem( - name = label, + contentText = label, selected = selectedPressSpeed == label, onClick = { selectedPressSpeed = label - viewModel.setControlCommandByte( - identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL, + viewModel.setControlCommand( + identifier = ControlCommandIdentifier.DOUBLE_CLICK_INTERVAL, value = value ) } @@ -227,13 +217,13 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: ) { pressAndHoldDurationOptions.forEach { (value, label) -> StyledListItem( - name = label, + contentText = label, selected = selectedPressAndHoldDuration == label, onClick = { selectedPressAndHoldDuration = label - viewModel.setControlCommandByte( - identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL, + viewModel.setControlCommand( + identifier = ControlCommandIdentifier.CLICK_HOLD_INTERVAL, value = value ) } @@ -245,41 +235,41 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: title = stringResource(R.string.noise_control), label = stringResource(R.string.noise_cancellation_single_airpod), description = stringResource(R.string.noise_cancellation_single_airpod_description), - checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull( + checked = state.controlStates[ControlCommandIdentifier.ONE_BUD_ANC_MODE]?.getOrNull( 0 ) == 0x01.toByte(), onCheckedChange = { - viewModel.setControlCommandBoolean( - AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it + viewModel.setControlCommand( + ControlCommandIdentifier.ONE_BUD_ANC_MODE, it ) }, - enabled = state.isPremium + enabled = uiState.isPremium ) - if (state.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) && state.vendorIdHook) { + if (AirPodsSpecs.getSpec(metadata.model).baseCapabilities.contains(BaseCapability.LOUD_SOUND_REDUCTION) && uiState.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, + viewModel.writeATTCharacteristic( + ATTHandle.LOUD_SOUND_REDUCTION, if (it) byteArrayOf(0x01) else byteArrayOf(0x00) ) }, - enabled = state.isPremium + enabled = uiState.isPremium ) } - if (!hearingAidEnabled && state.vendorIdHook) { + if (!hearingAidEnabled && uiState.vendorIdHook) { StyledListItem( - name = stringResource(R.string.customize_transparency_mode), + contentText = stringResource(R.string.customize_transparency_mode), onClick = navigateToTransparencyCustomization, - enabled = state.isPremium + enabled = uiState.isPremium ) } - val toneVolumeValue = remember { mutableFloatStateOf(state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f) } + val toneVolumeValue = remember { mutableFloatStateOf(state.controlStates[ControlCommandIdentifier.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f) } LaunchedEffect(toneVolumeValue) { snapshotFlow { @@ -287,8 +277,8 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: } .debounce(100.milliseconds) .collect { - viewModel.setControlCommandValue( - AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME, + viewModel.setControlCommand( + ControlCommandIdentifier.CHIME_VOLUME, byteArrayOf(it.toInt().toByte(), 0x50) ) } @@ -303,15 +293,15 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: }, valueRange = 0f..100f, snapPoints = listOf(75f), - startIcon = "\uDBC0\uDEA1", - endIcon = "\uDBC0\uDEA9", + startImageVector = LocalIcons.current.SpeakerMin, + endImageVector = LocalIcons.current.SpeakerMax, independent = true, - enabled = state.isPremium + enabled = uiState.isPremium ) - if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) { + if (AirPodsSpecs.getSpec(metadata.model).baseCapabilities.contains(BaseCapability.SWIPE_FOR_VOLUME)) { val volumeSwipeEnabled = - state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull( + state.controlStates[ControlCommandIdentifier.VOLUME_SWIPE_MODE]?.getOrNull( 0 )?.toInt() == 0x01 StyledToggle( @@ -319,11 +309,11 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: description = stringResource(R.string.volume_control_description), checked = volumeSwipeEnabled, onCheckedChange = { - viewModel.setControlCommandBoolean( - AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it + viewModel.setControlCommand( + ControlCommandIdentifier.VOLUME_SWIPE_MODE, it ) }, - enabled = state.isPremium + enabled = uiState.isPremium ) StyledList( @@ -332,13 +322,13 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: ) { volumeSwipeSpeedOptions.forEach { (value, label) -> StyledListItem( - name = label, + contentText = label, selected = selectedVolumeSwipeSpeed == label, onClick = { selectedVolumeSwipeSpeed = label - viewModel.setControlCommandByte( - identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL, + viewModel.setControlCommand( + identifier = ControlCommandIdentifier.VOLUME_SWIPE_INTERVAL, value = value ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AdaptiveStrengthScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AdaptiveStrengthScreen.kt similarity index 72% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AdaptiveStrengthScreen.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AdaptiveStrengthScreen.kt index 37a097e4..96014fb1 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AdaptiveStrengthScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AdaptiveStrengthScreen.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.presentation.screens +package me.kavishdevar.librepods.presentation.screens.apple import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -33,37 +33,38 @@ import androidx.compose.foundation.layout.statusBars import androidx.compose.material3.MaterialTheme 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.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.runtime.snapshotFlow import androidx.compose.ui.Modifier 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 kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.debounce import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager +import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledSlider +import me.kavishdevar.librepods.presentation.icons.LocalIcons import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel +import kotlin.time.Duration.Companion.milliseconds @Composable -fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) { - val state by viewModel.uiState.collectAsState() +fun AdaptiveStrengthScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit) { + val uiState by viewModel.uiState.collectAsState() + val state = uiState.state + val backdrop = rememberLayerBackdrop() val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp Column( modifier = Modifier @@ -74,7 +75,7 @@ fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navigateToPurchase: () - verticalArrangement = Arrangement.spacedBy(16.dp) ) { Spacer(modifier = Modifier.height(topPadding)) - if (!state.isPremium) { + if (!uiState.isPremium) { StyledButton( onClick = navigateToPurchase, backdrop = rememberLayerBackdrop(), @@ -91,31 +92,31 @@ fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navigateToPurchase: () - Spacer(modifier = Modifier.height(16.dp)) } val sliderValue = remember { - mutableFloatStateOf(100f - (state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(0)?.toFloat() ?: 50f)) + mutableFloatStateOf(100f - (state.controlStates[ControlCommandIdentifier.AUTO_ANC_STRENGTH]?.getOrNull(0)?.toFloat() ?: 50f)) } - var job by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() + + LaunchedEffect(sliderValue) { + snapshotFlow { sliderValue.floatValue } + .debounce(100.milliseconds) + .collect { value -> + viewModel.setControlCommand( + ControlCommandIdentifier.AUTO_ANC_STRENGTH, + byteArrayOf((100 - value).toInt().toByte()) + ) + } + } + 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()) - ) - } - }, + onValueChange = { sliderValue.floatValue = it }, valueRange = 0f..100f, snapPoints = listOf(0f, 50f, 100f), - startIcon = "􀊥", - endIcon = "􀊩", + startImageVector = LocalIcons.current.SpeakerMin, + endImageVector = LocalIcons.current.SpeakerMax, independent = true, description = stringResource(R.string.adaptive_audio_description), - enabled = state.isPremium + enabled = uiState.isPremium ) Spacer(modifier = Modifier.height(bottomPadding)) } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AppleSettingsScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AppleSettingsScreen.kt new file mode 100644 index 00000000..a867456c --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AppleSettingsScreen.kt @@ -0,0 +1,585 @@ +/* + 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 . +*/ + +@file:OptIn(ExperimentalEncodingApi::class) + +package me.kavishdevar.librepods.presentation.screens.apple + +import android.annotation.SuppressLint +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +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 com.kyant.backdrop.backdrops.rememberLayerBackdrop +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier +import me.kavishdevar.librepods.bluetooth.att.ATTHandle +import me.kavishdevar.librepods.devices.AirPodsSpecs +import me.kavishdevar.librepods.devices.BaseCapability +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.StyledListItemOrientation +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.StyledListItem +import me.kavishdevar.librepods.presentation.components.StyledToggle +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState +import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel +import kotlin.io.encoding.ExperimentalEncodingApi + +@Composable +fun AirPodsSettingsRoute( + viewModel: AppleViewModel, + navigateToRename: () -> Unit, + navigateToHearingProtection: () -> Unit, + navigateToHearingAid: () -> Unit, + navigateToLeftLongPress: () -> Unit, + navigateToRightLongPress: () -> Unit, + navigateToPurchase: () -> Unit, + navigateToAdaptiveStrength: () -> Unit, + navigateToEqualizer: () -> Unit, + navigateToHeadTracking: () -> Unit, + navigateToAccessibility: () -> Unit, + navigateToVersion: () -> Unit, + navigateToCallControlScreen: (action: String) -> Unit, + navigateToMicrophoneSettings: () -> Unit, + navigateToRecordingScreen: () -> Unit, + navigateToDebugScreen: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = WindowInsets.statusBars.asPaddingValues() + .calculateTopPadding() + if (m3eEnabled) 0.dp else 84.dp + val bottomPadding = + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + AirPodsSettingsScreen( + uiState = uiState, + + topPadding = topPadding, + bottomPadding = bottomPadding, + + setControlCommandInt = { id, value -> viewModel.setControlCommand(id, value) }, + setControlCommandBoolean = { id, value -> viewModel.setControlCommand(id, value) }, +// setControlCommandByte = { id, value -> viewModel.setControlCommand(id, value) }, +// setControlCommandValue = { id, value -> viewModel.setControlCommand(id, value) }, + + writeATTCharacteristic = viewModel::writeATTCharacteristic, + +// onAutomaticEarDetectionChanged = viewModel::setAutomaticEarDetectionEnabled, +// onAutomaticConnectionChanged = viewModel::setAutomaticConnectionEnabled, + disconnect = viewModel::disconnect, + + navigateToRename = navigateToRename, + navigateToHearingProtection = navigateToHearingProtection, + navigateToHearingAid = navigateToHearingAid, + navigateToLeftLongPress = navigateToLeftLongPress, + navigateToRightLongPress = navigateToRightLongPress, + navigateToPurchase = navigateToPurchase, + navigateToAdaptiveStrength = navigateToAdaptiveStrength, + navigateToEqualizer = navigateToEqualizer, + navigateToHeadTracking = navigateToHeadTracking, + navigateToAccessibility = navigateToAccessibility, + navigateToVersion = navigateToVersion, + navigateToCallControlScreen = navigateToCallControlScreen, + navigateToMicrophoneSettings = navigateToMicrophoneSettings, + navigateToRecordingScreen = navigateToRecordingScreen, + navigateToDebugScreen = navigateToDebugScreen + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) +@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") +@Composable +fun AirPodsSettingsScreen( + uiState: AppleUiState, + + topPadding: Dp = 16.dp, + bottomPadding: Dp = 16.dp, + + setControlCommandInt: (ControlCommandIdentifier, Int) -> Unit, + setControlCommandBoolean: (ControlCommandIdentifier, Boolean) -> Unit, +// setControlCommandByte: (ControlCommandIdentifier, Byte) -> Unit, +// setControlCommandValue: (ControlCommandIdentifier, ByteArray) -> Unit, + + writeATTCharacteristic: (ATTHandle, ByteArray) -> Unit, + +// onAutomaticEarDetectionChanged: (Boolean) -> Unit, +// onAutomaticConnectionChanged: (Boolean) -> Unit, + + disconnect: () -> Unit, + + navigateToRename: () -> Unit, + navigateToHearingProtection: () -> Unit, + navigateToHearingAid: () -> Unit, + navigateToLeftLongPress: () -> Unit, + navigateToRightLongPress: () -> Unit, + navigateToPurchase: () -> Unit, + navigateToAdaptiveStrength: () -> Unit, + navigateToEqualizer: () -> Unit, + navigateToHeadTracking: () -> Unit, + navigateToAccessibility: () -> Unit, + navigateToVersion: () -> Unit, + navigateToCallControlScreen: (action: String) -> Unit, + navigateToMicrophoneSettings: () -> Unit, + navigateToRecordingScreen: () -> Unit, + navigateToDebugScreen: () -> Unit +) { + val state = uiState.state + val settings = uiState.settings + val metadata = uiState.metadata + + val spec = AirPodsSpecs.getSpec(metadata.model) + + val baseCapabilities = spec.baseCapabilities + + LazyColumn( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 16.dp), + ) { + item(key = "top_padding") { Spacer(modifier = Modifier.height(topPadding)) } + + item(key = "battery") { + BatteryView( + batteryList = state.battery, + primaryImageRes = spec.primaryImageRes, + caseImageRes = spec.caseImageRes ?: R.drawable.img_airpods_pro_2_case // TODO + ) + } + item(key = "spacer_battery") { + Spacer(modifier = Modifier.height(32.dp)) + } + + item(key = "name") { + StyledListItem( + contentText = stringResource(R.string.name), + supportingText = metadata.name, + onClick = navigateToRename, + ) + } + + val hasHearingAidCapability = baseCapabilities.contains(BaseCapability.HEARING_AID) + val hasPPECapability = baseCapabilities.contains(BaseCapability.PPE) + + if (hasHearingAidCapability || hasPPECapability) { + if (hasPPECapability || uiState.vendorIdHook) { + item(key = "spacer_hearing_health") { + Spacer(modifier = Modifier.height(24.dp)) + } + } + item(key = "hearing_health") { + HearingHealthSettings( + hasPPECapability = hasPPECapability, + hasHearingAidCapability = hasHearingAidCapability, + vendorIdHook = uiState.vendorIdHook, + navigateToHearingProtection = navigateToHearingProtection, + navigateToHearingAid = navigateToHearingAid + ) + } + } + + if (metadata.version3.startsWith("8") || metadata.version3.startsWith("9")) { + item(key = "spacer_recording") { + Spacer(modifier = Modifier.height(16.dp)) + } + item(key = "recording") { + StyledListItem( + contentText = stringResource(R.string.recorder), + supportingText = stringResource(R.string.recorder_description), + onClick = navigateToRecordingScreen, + orientation = StyledListItemOrientation.Vertical + ) + } + } + + if (baseCapabilities.contains(BaseCapability.LISTENING_MODE)) { + item(key = "spacer_noise") { + Spacer(modifier = Modifier.height(16.dp)) + } + item(key = "noise_control") { + NoiseControlSettings( + showOffListeningMode = state.controlStates[ControlCommandIdentifier.ALLOW_OFF_OPTION]?.getOrNull(0)?.toInt() == 1, + noiseControlModeValue = state.controlStates[ControlCommandIdentifier.LISTENING_MODE]?.getOrNull(0)?.toInt() ?: 3, + onNoiseControlModeChanged = { + setControlCommandInt( + ControlCommandIdentifier.LISTENING_MODE, it + ) + }, + ) + } + } + + if (baseCapabilities.contains(BaseCapability.STEM_CONFIG)) { + item(key = "spacer_press_hold") { + Spacer(modifier = Modifier.height(16.dp)) + } + item(key = "press_hold") { + PressAndHoldSettings( + leftAction = settings.leftLongPressAction, + rightAction = settings.rightLongPressAction, + navigateToLeftLongPress = navigateToLeftLongPress, + navigateToRightLongPress = navigateToRightLongPress + ) + } + } + + item(key = "spacer_call") { + Spacer(modifier = Modifier.height(16.dp)) + } + item(key = "call_control") { + val bytes = + state.controlStates[ControlCommandIdentifier.CALL_MANAGEMENT_CONFIG]?.take(2)?.toByteArray() ?: byteArrayOf(0x00, 0x00) + val flipped = try { + bytes[1] == 0x02.toByte() + } catch (_: Exception) { + false + } + CallControlSettings( + flipped = flipped, + navigateToCallControlScreen = navigateToCallControlScreen + ) + } + +// if (baseCapabilities.contains(BaseCapability.RAW_GESTURES_CONFIG) && !BuildConfig.PLAY_BUILD) { +// item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) } +// item(key = "camera_control") { +// StyledListItem( +// to = "camera_control", +// contentText = stringResource(R.string.camera_remote), +// descriptionRes = stringResource(R.string.camera_control_description), +// titleRes = stringResource(R.string.camera_control), +// navController = navController +// ) +// } +// } + + item(key = "upgrade_button") { + if (!uiState.isPremium) { + Spacer(modifier = Modifier.height(28.dp)) + StyledButton( + onClick = navigateToPurchase, + backdrop = rememberLayerBackdrop(), + modifier = Modifier.fillMaxWidth(), + maxScale = 0.05f, + surfaceColor = MaterialTheme.colorScheme.primary + ) { + Text( + stringResource(R.string.unlock_advanced_features), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimary + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } + } + + item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "audio") { + val adaptiveVolumeCapability = + baseCapabilities.contains(BaseCapability.ADAPTIVE_VOLUME) + val conversationalAwarenessCapability = + baseCapabilities.contains(BaseCapability.CONVERSATION_AWARENESS) + val loudSoundReductionCapability = + baseCapabilities.contains(BaseCapability.LOUD_SOUND_REDUCTION) + val adaptiveAudioCapability = + baseCapabilities.contains(BaseCapability.ADAPTIVE_VOLUME) + + val adaptiveVolumeChecked = + state.controlStates[ControlCommandIdentifier.ADAPTIVE_VOLUME_CONFIG]?.getOrNull( + 0 + ) == 0x01.toByte() + val conversationalAwarenessChecked = + state.controlStates[ControlCommandIdentifier.CONVERSATION_DETECT_CONFIG]?.getOrNull( + 0 + ) == 0x01.toByte() + + AudioSettings( + adaptiveVolumeCapability = adaptiveVolumeCapability, + conversationalAwarenessCapability = conversationalAwarenessCapability, + loudSoundReductionCapability = loudSoundReductionCapability, + adaptiveAudioCapability = adaptiveAudioCapability, + customEqCapability = metadata.version3.startsWith("9"), + adaptiveVolumeChecked = adaptiveVolumeChecked, + onAdaptiveVolumeCheckedChange = { checked -> + setControlCommandBoolean( + ControlCommandIdentifier.ADAPTIVE_VOLUME_CONFIG, + checked + ) + }, + conversationalAwarenessChecked = conversationalAwarenessChecked && uiState.isPremium, + onConversationalAwarenessCheckedChange = { checked -> + setControlCommandBoolean( + ControlCommandIdentifier.CONVERSATION_DETECT_CONFIG, + checked + ) + }, + loudSoundReductionChecked = state.loudSoundReductionEnabled, + onLoudSoundReductionCheckedChange = { checked -> + writeATTCharacteristic( + ATTHandle.LOUD_SOUND_REDUCTION, + byteArrayOf(if (checked) 0x01.toByte() else 0x00.toByte()) + ) + }, + navigateToAdaptiveStrength = navigateToAdaptiveStrength, + navigateToEqualizer = navigateToEqualizer, + vendorIdHook = uiState.vendorIdHook, + isPremium = uiState.isPremium + ) + } + + item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "connection") { + ConnectionSettings( + automaticEarDetectionEnabled = state.controlStates[ControlCommandIdentifier.EAR_DETECTION_CONFIG]?.getOrNull(0) == 0x01.toByte(), + onAutomaticEarDetectionChanged = { setControlCommandBoolean(ControlCommandIdentifier.EAR_DETECTION_CONFIG, it) }, + automaticConnectionEnabled = state.controlStates[ControlCommandIdentifier.SMART_ROUTING_MODE]?.getOrNull(0) == 0x01.toByte(), + onAutomaticConnectionChanged = { setControlCommandBoolean(ControlCommandIdentifier.SMART_ROUTING_MODE, it) } + ) + } + + item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "microphoneState") { + val id = ControlCommandIdentifier.MIC_MODE + + val selectedModeText = + when (state.controlStates[id]?.getOrNull(0) ?: 0x00.toByte()) { + 0x00.toByte() -> stringResource(R.string.microphone_automatic) + 0x01.toByte() -> stringResource(R.string.microphone_always_right) + 0x02.toByte() -> stringResource(R.string.microphone_always_left) + else -> stringResource(R.string.microphone_automatic) + } + + StyledListItem( + contentText = stringResource(R.string.microphone_mode), + supportingText = selectedModeText, + onClick = navigateToMicrophoneSettings + ) + } + + if (baseCapabilities.contains(BaseCapability.SLEEP_DETECTION)) { + item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "sleep_detection") { + val id = ControlCommandIdentifier.SLEEP_DETECTION_CONFIG + StyledToggle( + label = stringResource(R.string.sleep_detection), + checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(), + onCheckedChange = { setControlCommandBoolean(id, it) }, + enabled = uiState.isPremium + ) + } + } + + if (baseCapabilities.contains(BaseCapability.HEAD_GESTURES)) { + item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "head_tracking") { + StyledListItem( + contentText = stringResource(R.string.head_gestures), + supportingText = if (settings.headGesturesEnabled) stringResource(R.string.on) else stringResource(R.string.off), + onClick = navigateToHeadTracking + ) + } + } + + item(key = "spacer_dynamic_end_of_charge") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "dynamic_end_of_charge") { + StyledToggle( + label = stringResource(R.string.optimized_charging), + description = stringResource(R.string.optimized_charging_description), + checked = state.controlStates[ControlCommandIdentifier.DYNAMIC_END_OF_CHARGE]?.getOrNull(0) == 0x01.toByte(), + onCheckedChange = { setControlCommandBoolean(ControlCommandIdentifier.DYNAMIC_END_OF_CHARGE, it) } + ) + } + + item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "accessibility") { + StyledListItem( + contentText = stringResource(R.string.accessibility), onClick = navigateToAccessibility + ) + } + + if (baseCapabilities.contains(BaseCapability.LOUD_SOUND_REDUCTION) && (metadata.version3.startsWith("8") || metadata.version3.startsWith("9"))) { + item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "off_listening") { + val id = ControlCommandIdentifier.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 = { setControlCommandBoolean(id, it) } + ) + } + } + + item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) } + item(key = "about") { + AboutCard( + modelName = metadata.modelName, + actualModel = metadata.modelNumber, + serialNumbers = listOf(metadata.serialNumber, metadata.leftSerialNumber, metadata.rightSerialNumber), + version = metadata.version3, + navigateToVersion = navigateToVersion + ) + } + + item(key = "spacer_disconnect") { Spacer(modifier = Modifier.height(28.dp)) } + item(key = "disconnect_button") { + StyledButton( + onClick = disconnect, + backdrop = rememberLayerBackdrop(), + isInteractive = false, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + ) { + Text( + text = stringResource(R.string.disconnect), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + } + } + + item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "debug") { + StyledListItem( + contentText = "debug", + onClick = navigateToDebugScreen + ) + } + + item(key = "bottom_padding") { Spacer(modifier = Modifier.height(bottomPadding)) } + } +} + +@Preview(name = "Apple") +@Composable +fun AirPodsSettingsScreenPreviewApple() { + LibrePodsTheme( + designSystem = DesignSystem.Apple + ) { + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + AirPodsSettingsScreen( + uiState = AppleUiState(), + + setControlCommandInt = { _, _ -> }, + setControlCommandBoolean = { _, _ -> }, + writeATTCharacteristic = { _, _ -> }, + + disconnect = {}, + + navigateToRename = {}, + navigateToHearingProtection = {}, + navigateToHearingAid = {}, + navigateToLeftLongPress = {}, + navigateToRightLongPress = {}, + navigateToPurchase = {}, + navigateToAdaptiveStrength = {}, + navigateToEqualizer = {}, + navigateToHeadTracking = {}, + navigateToAccessibility = {}, + navigateToVersion = {}, + navigateToCallControlScreen = {}, + navigateToMicrophoneSettings = {}, + navigateToRecordingScreen = {}, + navigateToDebugScreen = {} + ) + } + } +} + + +@Preview(name = "Material") +@Composable +fun AirPodsSettingsScreenPreviewMaterial() { + LibrePodsTheme( + designSystem = DesignSystem.Material + ) { + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + AirPodsSettingsScreen( + uiState = AppleUiState(), + + setControlCommandInt = { _, _ -> }, + setControlCommandBoolean = { _, _ -> }, + writeATTCharacteristic = { _, _ -> }, + + disconnect = {}, + + navigateToRename = {}, + navigateToHearingProtection = {}, + navigateToHearingAid = {}, + navigateToLeftLongPress = {}, + navigateToRightLongPress = {}, + navigateToPurchase = {}, + navigateToAdaptiveStrength = {}, + navigateToEqualizer = {}, + navigateToHeadTracking = {}, + navigateToAccessibility = {}, + navigateToVersion = {}, + navigateToCallControlScreen = {}, + navigateToMicrophoneSettings = {}, + navigateToRecordingScreen = {}, + navigateToDebugScreen = {} + ) + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CallControlScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/CallControlScreen.kt similarity index 82% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CallControlScreen.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/CallControlScreen.kt index 101e4bba..a94771f6 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CallControlScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/CallControlScreen.kt @@ -1,4 +1,4 @@ -package me.kavishdevar.librepods.presentation.screens +package me.kavishdevar.librepods.presentation.screens.apple import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column @@ -25,26 +25,28 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager +import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CallControlScreen(viewModel: AirPodsViewModel, action: String, onCallControlValueChanged: (Boolean) -> Unit) { - val state by viewModel.uiState.collectAsState() +fun CallControlScreen(viewModel: AppleViewModel, action: String, onCallControlValueChanged: (Boolean) -> Unit) { + val uiState by viewModel.uiState.collectAsState() + + val state = uiState.state val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp val scrollState = rememberScrollState() val bytes = - state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take( + state.controlStates[ControlCommandIdentifier.CALL_MANAGEMENT_CONFIG]?.take( 2 )?.toByteArray() ?: byteArrayOf(0x00, 0x00) val flipped = try { @@ -74,7 +76,7 @@ fun CallControlScreen(viewModel: AirPodsViewModel, action: String, onCallControl StyledList { StyledListItem( - name = pressOnceText, + contentText = pressOnceText, selected = pressOnceIsAction, onClick = { singlePressAction = pressOnceText @@ -83,7 +85,7 @@ fun CallControlScreen(viewModel: AirPodsViewModel, action: String, onCallControl ) StyledListItem( - name = pressTwiceText, + contentText = pressTwiceText, selected = !pressOnceIsAction, onClick = { singlePressAction = pressTwiceText diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CameraControlScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/CameraControlScreen.kt similarity index 96% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CameraControlScreen.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/CameraControlScreen.kt index 7582fc3a..9ca5e207 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CameraControlScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/CameraControlScreen.kt @@ -16,10 +16,10 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.presentation.screens +package me.kavishdevar.librepods.presentation.screens.apple //@Composable -//fun CameraControlScreen(viewModel: AirPodsViewModel) { +//fun CameraControlScreen(viewModel: AppleViewModel) { // val context = LocalContext.current // val currentCameraAction by viewModel.cameraAction.collectAsState() // diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/DebugScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/DebugScreen.kt new file mode 100644 index 00000000..122c9058 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/DebugScreen.kt @@ -0,0 +1,272 @@ +package me.kavishdevar.librepods.presentation.screens.apple + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.delete +import androidx.compose.material3.MaterialTheme +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.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.bluetooth.aacp.packet.AACPPacketType +import me.kavishdevar.librepods.bluetooth.aacp.packet.BatteryInfoPacket +import me.kavishdevar.librepods.bluetooth.aacp.packet.ControlCommandPacket +import me.kavishdevar.librepods.bluetooth.aacp.packet.EarDetectionResponsePacket +import me.kavishdevar.librepods.bluetooth.aacp.packet.MagicKeyResponsePacket +import me.kavishdevar.librepods.bluetooth.aacp.packet.RenamePacket +import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode +import me.kavishdevar.librepods.devices.DeviceComponent +import me.kavishdevar.librepods.devices.PacketDestination +import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation +import me.kavishdevar.librepods.presentation.components.StyledButton +import me.kavishdevar.librepods.presentation.components.StyledInputField +import me.kavishdevar.librepods.presentation.components.StyledList +import me.kavishdevar.librepods.presentation.components.StyledListItem +import me.kavishdevar.librepods.presentation.icons.richText +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState +import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel +import me.kavishdevar.librepods.utils.nonScaledSp + +@Composable +fun DebugRoute(viewModel: AppleViewModel) { + val uiState by viewModel.uiState.collectAsState() + + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + Box ( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + DebugScreen( + uiState = uiState, + topPadding = topPadding, + bottomPadding = bottomPadding, + sendPacket = viewModel::sendRawPacket + ) + } +} + +@Composable +fun DebugScreen( + uiState: AppleUiState, + topPadding: Dp = 16.dp, + bottomPadding: Dp = 16.dp, + sendPacket: (ByteArray) -> Boolean = { false } +) { + val state = uiState.state + + Log.d("DebugScreen", "Screen ${state.aacpPackets.size}") + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.padding(top = topPadding)) + + val inputState = remember { TextFieldState() } + val focusRequester = remember { FocusRequester() } + + val success = remember { mutableStateOf(false) } + val firstPacketSent = remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + LaunchedEffect(inputState.text) { + val inputText = inputState.text + if (inputText.isNotEmpty()) { + val hexRegex = Regex("^[0-9A-Fa-f ]+$") + if (!hexRegex.matches(inputText)) { + inputState.edit { + delete(inputText.length - 1, inputText.length) + } + } + } + } + + StyledInputField( + inputState = inputState, + focusRequester = focusRequester, + placeholder = "data (in hex)", + ) + + StyledButton( + modifier = Modifier + .fillMaxWidth(), + onClick = { + val inputText = inputState.text + if (inputText.isNotEmpty()) { + firstPacketSent.value = true + + val hexString = inputText.toString().replace(" ", "") + if (hexString.length % 2 != 0) { + Log.d("DebugScreen", "Invalid hex string length: ${hexString.length}") + success.value = false + return@StyledButton + } + + val byteArray = hexString.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + Log.d("DebugScreen", "Sending packet: ${byteArray.toHexString()}") + + success.value = sendPacket(byteArray) + } + }, + enabled = inputState.text.isNotEmpty() && inputState.text.toString().replace(" ", "").length % 2 == 0 && inputState.text.matches(Regex("^[0-9A-Fa-f ]+$")) + ) { + val text = richText("\\icon{Send,onPrimary} Send Packet") + Text( + text = text.text, + inlineContent = text.inlineContent, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onPrimary, + textAlign = TextAlign.Center + ) + } + + if (firstPacketSent.value) { + Text( + text = if (success.value) "Sent" else "Failed", + style = MaterialTheme.typography.labelMedium, + color = if (success.value) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + + StyledList( + modifier = Modifier + .weight(1f), + scrollEnabled = true, + title = "Packets ${state.aacpPackets.size}", + ) { + state.aacpPackets.reversed().forEach { packet -> + StyledListItem( + content = { + val text = richText( + if (packet.type == AACPPacketType.MESSAGE) { + when (packet) { + is EarDetectionResponsePacket -> { + val left = packet.componentStates.find { it.component == DeviceComponent.LEFT } + val right = packet.componentStates.find { it.component == DeviceComponent.RIGHT } + val HEADSET = packet.componentStates.find { it.component == DeviceComponent.HEADSET } + + if (left != null && right != null) { + "\\icon{LeftCircleFill} ${left.status.name} " + + "\\icon{RightCircleFill} ${right.status.name}" + } else { + HEADSET?.status?.name ?: "Unknown" + } + } + + is ControlCommandPacket -> { + val controlCommand = packet.controlCommand + "${controlCommand.identifier.name} - ${controlCommand.value.toHexString()}" + } + + is BatteryInfoPacket -> { + "Battery Info" + } + + is RenamePacket -> { + "Rename to ${packet.name}" + } + + is MagicKeyResponsePacket -> { + "Magic Keys (${packet.magicKeys.size})" + } + + else -> packet.opcode.toString() + } + } else { + packet.type.toString() + } + ) + + Text( + text = text.text, + inlineContent = text.inlineContent, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + }, + supportingContent = { + val text = richText( + when (packet) { + is BatteryInfoPacket -> buildString { + for (battery in packet.batteries) { + append("${battery.component} ${battery.status} ${battery.level}\n") + } + } + is MagicKeyResponsePacket -> buildString { + packet.magicKeys.entries.forEach { (key, value) -> + append("${key.name}: ${value.toHexString()}\n") + } + } + else -> packet.payload.toHexString() + } + ) + Text( + text = text.text, + inlineContent = text.inlineContent, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onBackground.copy(0.8f) + ) + }, + leadingContent = { + val text = richText( + when (packet.destination) { + PacketDestination.HOST -> "\\icon{Incoming,primary}" + PacketDestination.DEVICE -> "\\icon{Outgoing,tertiary}" + } + ) + Text( + text = text.text, + inlineContent = text.inlineContent, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 28.nonScaledSp()), + color = MaterialTheme.colorScheme.onBackground + ) + }, + orientation = StyledListItemOrientation.Vertical, + onClick = if (packet.destination == PacketDestination.DEVICE || packet.opcode == MessageOpcode.CONTROL_COMMAND) { + { + inputState.edit { + delete(0, inputState.text.length) + append(packet.rawPacket.toHexString()) + } + } + } else null + ) + } + } + + Spacer(modifier = Modifier.padding(top = bottomPadding)) + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/EqualizerScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/EqualizerScreen.kt new file mode 100644 index 00000000..a08dd327 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/EqualizerScreen.kt @@ -0,0 +1,732 @@ +/* + 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 . +*/ + +package me.kavishdevar.librepods.presentation.screens.apple + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.visible +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.highlight.Highlight +import kotlinx.coroutines.flow.debounce +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.components.StyledButton +import me.kavishdevar.librepods.presentation.components.StyledList +import me.kavishdevar.librepods.presentation.components.StyledListItem +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState +import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.milliseconds + +@Composable +fun EqualizerRoute(viewModel: AppleViewModel) { + val uiState by viewModel.uiState.collectAsState() + + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + + Box ( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + EqualizerScreen( + uiState = uiState, + topPadding = topPadding, + bottomPadding = bottomPadding, + setCustomEqEnabled = viewModel::setCustomEqEnabled, + setCustomEq = viewModel::setCustomEq + ) + } +} +@Composable +fun EqualizerScreen( + uiState: AppleUiState, + topPadding: Dp = 16.dp, + bottomPadding: Dp = 16.dp, + setCustomEqEnabled: (Boolean) -> Unit, + setCustomEq: (Int, Int, Int) -> Unit +) { + val state = uiState.state + + val customEq = state.customEq + + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + val height = 200.dp + val maxOffset = with(LocalDensity.current) { height.toPx() } / 2 + + val offsets = remember(state.customEq) { + listOf( + mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.low.toFloat() / 100)), + mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.mid.toFloat() / 100)), + mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.high.toFloat() / 100)) + ) + } + + LaunchedEffect(offsets) { + snapshotFlow { + Triple( + offsets[0].floatValue, + offsets[1].floatValue, + offsets[2].floatValue + ) + } + .debounce(100.milliseconds) // nice, should've been using this since the very beginning + .collect { (lowF, midF, highF) -> + val low = + 100 - ((lowF / (2 * maxOffset) + 0.5f) * 100).roundToInt() + val mid = + 100 - ((midF / (2 * maxOffset) + 0.5f) * 100).roundToInt() + val high = + 100 - ((highF / (2 * maxOffset) + 0.5f) * 100).roundToInt() + + setCustomEq(low, mid, high) + } + } + + Spacer(modifier = Modifier.height(topPadding)) + + val enabled = customEq.isEnabled() + + StyledList { + StyledListItem( + contentText = stringResource(R.string.recommended), + selected = !enabled, + onClick = { setCustomEqEnabled(false) } + ) + + StyledListItem( + contentText = stringResource(R.string.custom), + selected = enabled, + onClick = { setCustomEqEnabled(true) } + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Crossfade ( + customEq.isEnabled() + ) { visible -> + Column( + modifier = Modifier + .fillMaxWidth() + .visible(visible), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + + EqualizerCard( + lowOffset = offsets[0], + midOffset = offsets[1], + highOffset = offsets[2] + ) + + val resetButtonEnabled = remember { derivedStateOf { !offsets.all { it.floatValue == 0f } } } + + StyledButton( + onClick = { + offsets[0].floatValue = 0f + offsets[1].floatValue = 0f + offsets[2].floatValue = 0f + }, + backdrop = rememberLayerBackdrop(), + modifier = Modifier.fillMaxWidth(), + isInteractive = false, + enabled = resetButtonEnabled.value + ) { + Text( + text = stringResource(R.string.reset), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + + Spacer(modifier = Modifier.height(bottomPadding)) + } +} + + +@Composable +fun EqualizerCard( + lowOffset: MutableState, + midOffset: MutableState, + highOffset: MutableState +) { + val height = 200.dp + val maxOffset = with(LocalDensity.current) { height.toPx() } / 2 + + val dashColor = MaterialTheme.colorScheme.onSurface.copy(0.2f) + + val backdrop = rememberLayerBackdrop() + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(28.dp)) + ) { + Spacer(modifier = Modifier.height(42.dp)) + // Row( + // modifier = Modifier + // .fillMaxWidth() + // .padding(18.dp), + // verticalAlignment = Alignment.CenterVertically, + // horizontalArrangement = Arrangement.spacedBy(12.dp) + // ) { + // Box( + // modifier = Modifier + // .size(64.dp) + // .background(if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray, RoundedCornerShape(12.dp)) + // ) + // Column( + // modifier = Modifier + // .weight(1f), + // verticalArrangement = Arrangement.Center + // ) { + // Text( + // text = "Written into Changes", + // style = TextStyle( + // fontSize = 16.sp, + // fontFamily = FontFamily(Font(R.font.sf_pro)), + // fontWeight = FontWeight.Bold, + // color = if (isSystemInDarkTheme()) Color.White else Color.Black + // ) + // ) + // Spacer(modifier = Modifier.height(4.dp)) + // Text( + // text = "Avalon Emerson", + // style = TextStyle( + // fontSize = 14.sp, + // fontFamily = FontFamily(Font(R.font.sf_pro)), + // fontWeight = FontWeight.Normal, + // color = if (isSystemInDarkTheme()) Color.White else Color.Black + // ) + // ) + // } + // val paused = remember { mutableStateOf(false) } + // Box( + // modifier = Modifier + // .size(48.dp) + // .background(Color(0x600091FF), CircleShape) + // .clickable( + // interactionSource = remember { MutableInteractionSource() }, + // indication = null, + // ) { + // paused.value = !paused.value + // }, + // contentAlignment = Alignment.Center + // ) { + // Crossfade( + // targetState = paused.value, + // label = "media_icon" + // ) { p -> + // Text( + // text = if (p) "􀊄" else "􀊆", + // style = TextStyle( + // fontSize = 24.sp, + // fontFamily = FontFamily(Font(R.font.sf_pro)), + // fontWeight = FontWeight.Normal, + // color = Color(0xFF0091FF), + // textAlign = TextAlign.Center + // ) + // ) + // } + // } + // } + // + // HorizontalDivider( + // thickness = 1.dp, + // color = Color(0x40888888), + // modifier = Modifier + // .padding(horizontal = 20.dp) + // .padding(bottom = 16.dp) + // ) + + Box( + modifier = Modifier.fillMaxWidth() + ) { + fun colorFromY(y: Float): Color { + val f = ((y + maxOffset) / (2f * maxOffset)).coerceIn(0f, 1f) + val stops = listOf( + 0.0f to Color(0xFFFFA300), + 0.25f to Color(0xFFFCE600), + 0.5f to Color(0xFF00FAAF), + 0.75f to Color(0xFF00FAFF), + 1.0f to Color(0xFF00B5FF) + ) + val (start, end) = stops.zipWithNext() + .first { f <= it.second.first } + val c = (f - start.first) / (end.first - start.first) + return lerp(start.second, end.second, c) + } + + fun pathBrush( + startY: Float, + endY: Float, + ): Brush { + val stops = (0..20).map { i -> + val t = i / 20f + val y = lerp(startY, endY, t) + t to colorFromY(y) + } + + return Brush.linearGradient( + colorStops = stops.toTypedArray() + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .layerBackdrop(backdrop) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(height) + .padding(horizontal = 20.dp) + ) { + Row( + modifier = Modifier + .fillMaxSize() + ) { + val dashCount = (height / 10.dp).toInt() + repeat(3) { + Box( + modifier = Modifier + .fillMaxSize() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + for (i in 1..(dashCount)) { + val t = i.toFloat() / dashCount + val centerDistance = abs(0.5f - t) + val alpha = 1f - (centerDistance * 2f) + Box( + modifier = Modifier + .height(9.dp) + .width(0.75.dp) + .background( + dashColor.copy(alpha), + RoundedCornerShape(28.dp) + ) + ) + } + } + } + } + } + + val backgroundColor = MaterialTheme.colorScheme.surfaceContainer + + Canvas( + modifier = Modifier + .fillMaxSize() + ) { + val canvasWidth = size.width + + drawLine( + color = backgroundColor, + start = Offset( + x = 0f, + y = lowOffset.value + maxOffset + ), + end = Offset( + x = 1 / 6f * canvasWidth, + y = lowOffset.value + maxOffset + ), + strokeWidth = 10f + ) + drawLine( + color = colorFromY(lowOffset.value), + start = Offset( + x = 0f, + y = lowOffset.value + maxOffset + ), + end = Offset( + x = 1 / 6f * canvasWidth, + y = lowOffset.value + maxOffset + ), + strokeWidth = 8f + ) + + val lowToMidPath = Path() + lowToMidPath.moveTo( + x = 1 / 6f * canvasWidth, + y = lowOffset.value + maxOffset + ) + lowToMidPath.cubicTo( + x1 = canvasWidth * 1 / 6f + 108.dp.value, + y1 = lowOffset.value + maxOffset, + x2 = canvasWidth * 0.5f - 108.dp.value, + y2 = midOffset.value + maxOffset, + x3 = canvasWidth * 0.5f, + y3 = midOffset.value + maxOffset + ) + drawPath( + color = backgroundColor, + path = lowToMidPath, + style = Stroke(width = 10f) + ) + drawPath( + brush = pathBrush( + lowOffset.value, + midOffset.value + ), + path = lowToMidPath, + style = Stroke(width = 8f) + ) + + val midToHighPath = Path() + midToHighPath.moveTo( + x = 0.5f * canvasWidth, + y = midOffset.value + maxOffset + ) + midToHighPath.cubicTo( + x1 = canvasWidth * 0.5f + 108.dp.value, + y1 = midOffset.value + maxOffset, + x2 = canvasWidth * 5 / 6f - 108.dp.value, + y2 = highOffset.value + maxOffset, + x3 = canvasWidth * 5 / 6f, + y3 = highOffset.value + maxOffset + ) + drawPath( + color = backgroundColor, + path = midToHighPath, + style = Stroke(width = 10f) + ) + drawPath( + brush = pathBrush( + midOffset.value, + highOffset.value + ), + path = midToHighPath, + style = Stroke(width = 8f) + ) + drawLine( + color = backgroundColor, + start = Offset( + x = 5 / 6f * canvasWidth, + y = highOffset.value + maxOffset + ), + end = Offset( + x = 1f * canvasWidth, + y = highOffset.value + maxOffset + ), + strokeWidth = 10f + ) + drawLine( + color = colorFromY(highOffset.value), + start = Offset( + x = 5 / 6f * canvasWidth, + y = highOffset.value + maxOffset + ), + end = Offset( + x = 1f * canvasWidth, + y = highOffset.value + maxOffset + ), + strokeWidth = 8f + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp, horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.weight(1f) + ) { + Text( + text = "Low".uppercase(), + style = MaterialTheme.typography.labelSmallEmphasized, + color = MaterialTheme.colorScheme.onSurface.copy(0.2f), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + Box( + modifier = Modifier.weight(1f) + ) { + Text( + text = "Mid".uppercase(), + style = MaterialTheme.typography.labelSmallEmphasized, + color = MaterialTheme.colorScheme.onSurface.copy(0.2f), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + Box( + modifier = Modifier.weight(1f) + ) { + Text( + text = "High".uppercase(), + style = MaterialTheme.typography.labelSmallEmphasized, + color = MaterialTheme.colorScheme.onSurface.copy(0.2f), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } + Spacer(modifier = Modifier.height(24.dp)) + } + Row( + modifier = Modifier + .fillMaxWidth() + .height(height) + .padding(horizontal = 20.dp), + + verticalAlignment = Alignment.CenterVertically + ) { + for (i in 0..2) { + Row( + modifier = Modifier + .weight(1f), + horizontalArrangement = Arrangement.Center + ) { + val pressed = remember { mutableStateOf(false) } + Box( + modifier = Modifier + .offset { + IntOffset( + x = 0, + y = when (i) { 0 -> lowOffset.value; 1 -> midOffset.value; 2-> highOffset.value else -> 0f}.roundToInt() + ) + }, + contentAlignment = Alignment.Center + ) { + Crossfade( + pressed.value + ) { + Box( + modifier = Modifier + .size(96.dp) + .then( + if (it) { + Modifier.drawBackdrop( + backdrop = backdrop, + shape = { CircleShape }, + highlight = { + Highlight.Ambient + }, + onDrawSurface = { + drawCircle( + color = Color.White.copy( + 0.2f + ), + radius = size.height + ) + drawCircle( + color = colorFromY( + when (i) { + 0 -> lowOffset.value; 1 -> midOffset.value; 2 -> highOffset.value + else -> 0f + } + ), + style = Stroke(2.dp.value), + radius = size.height / 2 + ) + }, + effects = { + lens( + refractionHeight = 32f.dp.value, + refractionAmount = size.height + ) + } + ) + } else Modifier + ) + ) + } + Box( + modifier = Modifier + .size(18.dp) + .background( + colorFromY( + when (i) { + 0 -> lowOffset.value; 1 -> midOffset.value; 2 -> highOffset.value + else -> 0f + } + ), + CircleShape + ) + .border( + 2.5.dp, + MaterialTheme.colorScheme.surfaceContainer, + CircleShape + ) + .draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { delta -> + when (i) { + 0 -> { + lowOffset.value = + (lowOffset.value + delta).coerceIn( + -maxOffset, + maxOffset + ) + } + + 1 -> { + midOffset.value = + (midOffset.value + delta).coerceIn( + -maxOffset, + maxOffset + ) + } + + 2 -> { + highOffset.value = + (highOffset.value + delta).coerceIn( + -maxOffset, + maxOffset + ) + } + } + }, + onDragStarted = { + pressed.value = true + }, + onDragStopped = { + pressed.value = false + } + ) + ) + } + } + } + } + } + } +} + +@Preview(name = "Apple") +@Composable +fun EqualizerScreenPreviewApple() { + LibrePodsTheme( + designSystem = DesignSystem.Apple + ) { + val state = remember { mutableStateOf(AppleUiState()) } + + Box ( + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer) + ) { + EqualizerScreen( + uiState = state.value, + setCustomEqEnabled = { state.value = state.value.copy(state = state.value.state.copy(customEq = state.value.state.customEq.copy(state = if (it) 2 else 1))) }, + setCustomEq = {low, mid, high -> state.value = state.value.copy(state = state.value.state.copy(customEq = state.value.state.customEq.copy(low = low, mid = mid, high = high)))} + ) + } + } +} + +@Preview(name = "Material") +@Composable +fun EqualizerScreenPreviewMaterial() { + LibrePodsTheme( + designSystem = DesignSystem.Material + ) { + val state = remember { mutableStateOf(AppleUiState()) } + Box ( + modifier = Modifier + .wrapContentHeight() + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + EqualizerScreen( + uiState = state.value, + setCustomEqEnabled = { state.value = state.value.copy(state = state.value.state.copy(customEq = state.value.state.customEq.copy(state = if (it) 2 else 1))) }, + setCustomEq = {low, mid, high -> state.value = state.value.copy(state = state.value.state.copy(customEq = state.value.state.customEq.copy(low = low, mid = mid, high = high)))} + ) + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeadTrackingScreen.kt similarity index 80% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeadTrackingScreen.kt index 79a14af1..eaa53d43 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeadTrackingScreen.kt @@ -21,7 +21,7 @@ @file:OptIn(ExperimentalEncodingApi::class) -package me.kavishdevar.librepods.presentation.screens +package me.kavishdevar.librepods.presentation.screens.apple import android.graphics.Paint import androidx.compose.animation.AnimatedContent @@ -33,7 +33,6 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.togetherWith 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 @@ -68,13 +67,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb 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 @@ -83,38 +78,43 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.bluetooth.aacp.types.AppleEvent +import me.kavishdevar.librepods.presentation.components.MaterialButtonStyle import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel -import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import me.kavishdevar.librepods.utils.HeadTracking import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.abs +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds @ExperimentalHazeMaterialsApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) @Composable -fun HeadTrackingScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) { - val state by viewModel.uiState.collectAsState() +fun HeadTrackingScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit) { + val uiState by viewModel.uiState.collectAsState() + + val settings = uiState.settings + DisposableEffect(Unit) { viewModel.startHeadTracking() onDispose { viewModel.stopHeadTracking() } } - val isDarkTheme = isSystemInDarkTheme() - if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - val textColor = if (isDarkTheme) Color.White else Color.Black val backdrop = rememberLayerBackdrop() val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp var gestureText by remember { mutableStateOf("") } val coroutineScope = rememberCoroutineScope() @@ -141,7 +141,7 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Un .padding(horizontal = 16.dp) ) { - if (!state.isPremium) { + if (!uiState.isPremium) { StyledButton( onClick = navigateToPurchase, backdrop = rememberLayerBackdrop(), @@ -160,9 +160,9 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Un StyledToggle( label = "Head Gestures", - checked = state.headGesturesEnabled, + checked = settings.headGesturesEnabled, onCheckedChange = { viewModel.setHeadGesturesEnabled(it) }, - enabled = state.isPremium || state.headGesturesEnabled, + enabled = uiState.isPremium || settings.headGesturesEnabled, description = stringResource(R.string.head_gestures_details), header = true ) @@ -172,12 +172,7 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Un Spacer(modifier = Modifier.height(16.dp)) Text( "Velocity", - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = textColor.copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), + style = MaterialTheme.typography.labelSmallEmphasized, modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp) ) Plot() @@ -187,7 +182,7 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Un LaunchedEffect(gestureText) { if (gestureText.isNotEmpty()) { lastClickTime = System.currentTimeMillis() - delay(3000) + delay(3.seconds) if (System.currentTimeMillis() - lastClickTime >= 3000) { shouldExplode = true } @@ -199,7 +194,11 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Un onClick = { gestureText = gestureTextValue coroutineScope.launch { - val accepted = ServiceManager.getService()?.testHeadGestures() ?: false + viewModel.testHeadGestures() + val accepted = viewModel.events + .filterIsInstance() + .first() + .yes gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected." } }, @@ -207,16 +206,13 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Un modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), - maxScale = 0.05f + maxScale = 0.05f, + materialButtonStyle = MaterialButtonStyle.Outlined ) { Text( "Test Head Gestures", - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = MaterialTheme.colorScheme.onSecondaryContainer - ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary ) } Box( @@ -237,32 +233,23 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Un if (shouldExplode) { LaunchedEffect(Unit) { CoroutineScope(coroutineScope.coroutineContext).launch { - delay(750) + delay(750.milliseconds) gestureText = "" } } Text( text = text, - style = TextStyle( - fontSize = 20.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily(Font(R.font.sf_pro)), - textAlign = TextAlign.Center - ), - color = MaterialTheme.colorScheme.onBackground + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center ) } else { Text( text = text, - style = TextStyle( - fontSize = 20.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor, - textAlign = TextAlign.Center - ), - modifier = Modifier - .fillMaxWidth() + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() ) } } @@ -276,7 +263,6 @@ private fun Plot() { val acceleration by HeadTracking.acceleration.collectAsState() val maxPoints = 100 val points = remember { mutableStateListOf>() } - val darkTheme = isSystemInDarkTheme() var maxAbs by remember { mutableFloatStateOf(1000f) } @@ -294,7 +280,7 @@ private fun Plot() { modifier = Modifier .fillMaxWidth() .height(300.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), shape = RoundedCornerShape(28.dp) ) { val horizontalColor = MaterialTheme.colorScheme.primary @@ -305,6 +291,7 @@ private fun Plot() { .fillMaxSize() .padding(16.dp) ) { + val onBackground = MaterialTheme.colorScheme.onBackground Canvas( modifier = Modifier.fillMaxSize() ) { @@ -314,7 +301,7 @@ private fun Plot() { val yScale = (height - 40.dp.toPx()) / (maxAbs * 2) val zeroY = height / 2 - val gridColor = if (darkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.1f) + val gridColor = onBackground.copy(alpha = 0.1f) for (i in 0..maxPoints step 10) { val x = i * xScale @@ -338,7 +325,7 @@ private fun Plot() { } drawLine( - color = if (darkTheme) Color.White.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.3f), + color = onBackground.copy(alpha = 0.3f), start = Offset(0f, zeroY), end = Offset(width, zeroY), strokeWidth = 1.5f.dp.toPx() @@ -367,7 +354,7 @@ private fun Plot() { drawContext.canvas.nativeCanvas.apply { val paint = Paint().apply { - color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK + color = onBackground.toArgb() textSize = 12.sp.toPx() textAlign = Paint.Align.RIGHT } @@ -383,7 +370,7 @@ private fun Plot() { drawCircle(horizontalColor, 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY)) drawContext.canvas.nativeCanvas.apply { val paint = Paint().apply { - color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK + color = onBackground.toArgb() textSize = 12.sp.toPx() textAlign = Paint.Align.LEFT } @@ -393,7 +380,7 @@ private fun Plot() { drawCircle(verticalColor, 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY)) drawContext.canvas.nativeCanvas.apply { val paint = Paint().apply { - color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK + color = onBackground.toArgb() textSize = 12.sp.toPx() textAlign = Paint.Align.LEFT } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidAdjustmentsScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HearingAidAdjustmentsScreen.kt similarity index 85% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidAdjustmentsScreen.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HearingAidAdjustmentsScreen.kt index e4b78c4b..7064b256 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidAdjustmentsScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HearingAidAdjustmentsScreen.kt @@ -16,9 +16,8 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.presentation.screens +package me.kavishdevar.librepods.presentation.screens.apple -import android.annotation.SuppressLint import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -33,9 +32,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -46,29 +45,28 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import kotlinx.coroutines.Job import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager -import me.kavishdevar.librepods.data.HearingAidSettings -import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse -import me.kavishdevar.librepods.data.sendHearingAidSettings +import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier +import me.kavishdevar.librepods.bluetooth.att.ATTHandle +import me.kavishdevar.librepods.bluetooth.att.types.HearingAidSettings +import me.kavishdevar.librepods.bluetooth.att.types.parseHearingAidSettingsResponse +import me.kavishdevar.librepods.bluetooth.att.types.sendHearingAidSettings import me.kavishdevar.librepods.presentation.components.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledToggle +import me.kavishdevar.librepods.presentation.icons.LocalIcons import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel -import kotlin.io.encoding.ExperimentalEncodingApi +import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel private const val TAG = "HearingAidAdjustments" -@SuppressLint("DefaultLocale") -@ExperimentalHazeMaterialsApi -@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @Composable -fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) { +fun HearingAidAdjustmentsScreen(viewModel: AppleViewModel) { val verticalScrollState = rememberScrollState() - val state by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsState() + + val state = uiState.state val debounceJob = remember { mutableStateOf(null) } @@ -83,6 +81,14 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) { val initialized = rememberSaveable { mutableStateOf(false) } + DisposableEffect(Unit) { + viewModel.observeATTCharacteristic(ATTHandle.HEARING_AID) + + onDispose { + viewModel.stopObservingATTCharacteristic() + } + } + val hearingAidSettings = remember { mutableStateOf( HearingAidSettings( leftEQ = leftEQ.value, @@ -140,12 +146,12 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) { ownVoiceAmplification = ownVoiceAmplification.floatValue ) Log.d(TAG, "Updated settings: ${hearingAidSettings.value}") - sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue) + sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::writeATTCharacteristic) } val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp Column( modifier = Modifier @@ -164,15 +170,15 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) { onValueChange = { amplificationSliderValue.floatValue = it }, - startIcon = "􀊥", - endIcon = "􀊩", + startImageVector = LocalIcons.current.SpeakerMin, + endImageVector = LocalIcons.current.SpeakerMax, independent = true, ) StyledToggle( label = stringResource(R.string.swipe_to_control_amplification), - checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(), - onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, it) }, + checked = state.controlStates[ControlCommandIdentifier.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(), + onCheckedChange = { viewModel.setControlCommand(ControlCommandIdentifier.HPS_GAIN_SWIPE, it) }, description = stringResource(R.string.swipe_amplification_description) ) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HearingAidScreen.kt similarity index 76% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidScreen.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HearingAidScreen.kt index 4a040a16..8e758f88 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HearingAidScreen.kt @@ -16,12 +16,11 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.presentation.screens +package me.kavishdevar.librepods.presentation.screens.apple import android.annotation.SuppressLint import android.util.Log import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -44,14 +43,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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 com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi @@ -59,43 +53,42 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager -import me.kavishdevar.librepods.data.parseTransparencySettingsResponse -import me.kavishdevar.librepods.data.sendTransparencySettings +import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier +import me.kavishdevar.librepods.bluetooth.att.types.parseTransparencySettingsResponse +import me.kavishdevar.librepods.bluetooth.att.types.sendTransparencySettings import me.kavishdevar.librepods.presentation.components.ConfirmationDialog import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import kotlin.io.encoding.ExperimentalEncodingApi private const val TAG = "AccessibilitySettings" @SuppressLint("DefaultLocale") -@ExperimentalHazeMaterialsApi -@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @Composable -fun HearingAidScreen(viewModel: AirPodsViewModel, onNavigateHearingAidAdjustments: () -> Unit, onNavigateHearingTest: () -> Unit) { +fun HearingAidScreen(viewModel: AppleViewModel, onNavigateHearingAidAdjustments: () -> Unit, onNavigateHearingTest: () -> Unit) { val verticalScrollState = rememberScrollState() val backdrop = rememberLayerBackdrop() val showDialog = remember { mutableStateOf(false) } val initialLoad = remember { mutableStateOf(true) } - val state by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsState() + + val state = uiState.state val hearingAidEnabled = remember { - val aidStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID] - val assistStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG] + val aidStatus = state.controlStates[ControlCommandIdentifier.HEARING_AID] + val assistStatus = state.controlStates[ControlCommandIdentifier.HEARING_ASSIST_CONFIG] mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte())) } - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp Column( modifier = Modifier @@ -116,8 +109,8 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, onNavigateHearingAidAdjustment if (hearingAidEnabled.value && !initialLoad.value) { showDialog.value = true } else if (!hearingAidEnabled.value && !initialLoad.value) { - viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x02)) - viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x02.toByte()) + viewModel.setControlCommand(ControlCommandIdentifier.HEARING_AID, byteArrayOf(0x01, 0x02)) + viewModel.setControlCommand(ControlCommandIdentifier.HEARING_ASSIST_CONFIG, 0x02.toByte()) hearingAidEnabled.value = false } initialLoad.value = false @@ -138,25 +131,21 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, onNavigateHearingAidAdjustment onCheckedChange = { hearingAidEnabled.value = it }, ) StyledListItem( - name = stringResource(R.string.adjustments), + contentText = stringResource(R.string.adjustments), onClick = onNavigateHearingAidAdjustments, ) } Text( text = stringResource(R.string.hearing_aid_description), - style = TextStyle( - fontSize = 12.sp, - fontWeight = FontWeight.Light, - color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), + style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Light), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), modifier = Modifier.padding(horizontal = 16.dp) ) Spacer(modifier = Modifier.height(16.dp)) StyledListItem( - name = stringResource(R.string.update_hearing_test), + contentText = stringResource(R.string.update_hearing_test), onClick = onNavigateHearingTest, ) @@ -208,13 +197,13 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, onNavigateHearingAidAdjustment dismissText = "Cancel", onConfirm = { showDialog.value = false - val enrolled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0) == 0x01.toByte() + val enrolled = state.controlStates[ControlCommandIdentifier.HEARING_AID]?.getOrNull(0) == 0x01.toByte() if (!enrolled) { - viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01)) + viewModel.setControlCommand(ControlCommandIdentifier.HEARING_AID, byteArrayOf(0x01, 0x01)) } else { - viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01)) + viewModel.setControlCommand(ControlCommandIdentifier.HEARING_AID, byteArrayOf(0x01, 0x01)) } - viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x01.toByte()) + viewModel.setControlCommand(ControlCommandIdentifier.HEARING_ASSIST_CONFIG, 0x01.toByte()) hearingAidEnabled.value = true CoroutineScope(Dispatchers.IO).launch { try { @@ -228,7 +217,7 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, onNavigateHearingAidAdjustment return@launch } val disabledSettings = parsed.copy(enabled = false) - sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings) + sendTransparencySettings(viewModel::writeATTCharacteristic, disabledSettings) } catch (e: Exception) { Log.e(TAG, "Error disabling transparency: ${e.message}") } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingProtectionScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HearingProtectionScreen.kt similarity index 80% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingProtectionScreen.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HearingProtectionScreen.kt index 9c83f507..72707fbe 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingProtectionScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HearingProtectionScreen.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.presentation.screens +package me.kavishdevar.librepods.presentation.screens.apple import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column @@ -40,22 +40,24 @@ 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.bluetooth.AACPManager -import me.kavishdevar.librepods.bluetooth.ATTHandles +import me.kavishdevar.librepods.bluetooth.att.ATTHandle +import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel @Composable -fun HearingProtectionScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) { +fun HearingProtectionScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit) { val backdrop = rememberLayerBackdrop() - val state by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsState() + + val state = uiState.state val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp Column( modifier = Modifier @@ -65,7 +67,7 @@ fun HearingProtectionScreen(viewModel: AirPodsViewModel, navigateToPurchase: () .padding(horizontal = 16.dp) ) { Spacer(modifier = Modifier.height(topPadding)) - if (!state.isPremium) { + if (!uiState.isPremium) { StyledButton( onClick = navigateToPurchase, backdrop = rememberLayerBackdrop(), @@ -82,19 +84,19 @@ fun HearingProtectionScreen(viewModel: AirPodsViewModel, navigateToPurchase: () Spacer(modifier = Modifier.height(16.dp)) } - if (state.vendorIdHook) { + if (uiState.vendorIdHook) { StyledToggle( title = stringResource(R.string.environmental_noise), label = stringResource(R.string.loud_sound_reduction), description = stringResource(R.string.loud_sound_reduction_description), checked = state.loudSoundReductionEnabled, onCheckedChange = { - viewModel.setATTCharacteristicValue( - ATTHandles.LOUD_SOUND_REDUCTION, + viewModel.writeATTCharacteristic( + ATTHandle.LOUD_SOUND_REDUCTION, byteArrayOf(if (it) 1.toByte() else 0.toByte()) ) }, - enabled = state.isPremium + enabled = uiState.isPremium ) Spacer(modifier = Modifier.height(12.dp)) @@ -103,15 +105,15 @@ fun HearingProtectionScreen(viewModel: AirPodsViewModel, navigateToPurchase: () title = stringResource(R.string.workspace_use), label = stringResource(R.string.ppe), description = stringResource(R.string.workspace_use_description), - checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG]?.getOrNull( + checked = state.controlStates[ControlCommandIdentifier.PPE_TOGGLE_CONFIG]?.getOrNull( 0 )?.toInt() == 1, onCheckedChange = { - viewModel.setControlCommandBoolean( - AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it + viewModel.setControlCommand( + ControlCommandIdentifier.PPE_TOGGLE_CONFIG, it ) }, - enabled = state.isPremium + enabled = uiState.isPremium ) Spacer(modifier = Modifier.height(bottomPadding)) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/MicrophoneSettingsScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/MicrophoneSettingsScreen.kt similarity index 80% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/MicrophoneSettingsScreen.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/MicrophoneSettingsScreen.kt index 91bf9750..e47c277d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/MicrophoneSettingsScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/MicrophoneSettingsScreen.kt @@ -1,4 +1,4 @@ -package me.kavishdevar.librepods.presentation.screens +package me.kavishdevar.librepods.presentation.screens.apple import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -22,24 +22,26 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager +import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel @Composable fun MicrophoneSettingsRoute( - viewModel: AirPodsViewModel + viewModel: AppleViewModel ) { - val state by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsState() + + val state = uiState.state val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - val id = AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE + val id = ControlCommandIdentifier.MIC_MODE Box ( modifier = Modifier @@ -51,7 +53,7 @@ fun MicrophoneSettingsRoute( topPadding = topPadding, bottomPadding = bottomPadding, onMicrophoneSettingsChanged = { - viewModel.setControlCommandInt(id, it) + viewModel.setControlCommand(id, it) } ) } @@ -78,19 +80,19 @@ fun MicrophoneSettingsScreen( StyledList { StyledListItem( - name = stringResource(R.string.microphone_automatic), + contentText = stringResource(R.string.microphone_automatic), selected = selectedMode == 0, onClick = { onMicrophoneSettingsChanged(0) } ) StyledListItem( - name = stringResource(R.string.microphone_always_right), + contentText = stringResource(R.string.microphone_always_right), selected = selectedMode == 1, onClick = { onMicrophoneSettingsChanged(1) } ) StyledListItem( - name = stringResource(R.string.microphone_always_left), + contentText = stringResource(R.string.microphone_always_left), selected = selectedMode == 2, onClick = { onMicrophoneSettingsChanged(2) } ) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PressAndHoldSettingsScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/PressAndHoldSettingsScreen.kt similarity index 78% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PressAndHoldSettingsScreen.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/PressAndHoldSettingsScreen.kt index 247eacb5..5b1bdd7c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PressAndHoldSettingsScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/PressAndHoldSettingsScreen.kt @@ -18,7 +18,7 @@ @file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class) -package me.kavishdevar.librepods.presentation.screens +package me.kavishdevar.librepods.presentation.screens.apple import android.util.Log import androidx.compose.foundation.background @@ -49,25 +49,28 @@ import androidx.compose.ui.unit.dp import com.kyant.backdrop.backdrops.rememberLayerBackdrop import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager +import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier import me.kavishdevar.librepods.data.StemAction -import me.kavishdevar.librepods.presentation.components.ListItemOrientation +import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import kotlin.experimental.and import kotlin.io.encoding.ExperimentalEncodingApi @ExperimentalHazeMaterialsApi @OptIn(ExperimentalMaterial3Api::class) @Composable -fun LongPress(viewModel: AirPodsViewModel, name: String, navigateToPurchase: () -> Unit) { - val state by viewModel.uiState.collectAsState() +fun LongPress(viewModel: AppleViewModel, name: String, navigateToPurchase: () -> Unit) { + val uiState by viewModel.uiState.collectAsState() - val modesByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0) ?: 0 + val state = uiState.state + val settings = uiState.settings + + val modesByte = state.controlStates[ControlCommandIdentifier.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()}") @@ -75,11 +78,11 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navigateToPurchase: () Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}") Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}") - val longPressAction = if (name.lowercase() == "left") state.leftAction else state.rightAction + val longPressAction = if (name.lowercase() == "left") settings.leftLongPressAction else settings.rightLongPressAction val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp val scrollState = rememberScrollState() @@ -95,7 +98,7 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navigateToPurchase: () StyledList { StyledListItem( - name = stringResource(R.string.noise_control), + contentText = stringResource(R.string.noise_control), selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES, onClick = { viewModel.setLongPressAction( @@ -106,7 +109,7 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navigateToPurchase: () ) StyledListItem( - name = stringResource(R.string.digital_assistant), + contentText = stringResource(R.string.digital_assistant), selected = longPressAction == StemAction.DIGITAL_ASSISTANT, onClick = { viewModel.setLongPressAction( @@ -114,11 +117,11 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navigateToPurchase: () StemAction.DIGITAL_ASSISTANT ) }, - enabled = state.isPremium + enabled = uiState.isPremium ) } - if (!state.isPremium) { + if (!uiState.isPremium) { Spacer(modifier = Modifier.height(24.dp)) StyledButton( onClick = navigateToPurchase, @@ -139,24 +142,24 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navigateToPurchase: () if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) { Spacer(modifier = Modifier.height(32.dp)) - val currentByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0 + val currentByte = state.controlStates[ControlCommandIdentifier.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0 StyledList( title = stringResource(R.string.noise_control), description = stringResource(R.string.press_and_hold_noise_control_description) ) { - if (state.offListeningMode) { + if (state.controlStates[ControlCommandIdentifier.ALLOW_OFF_OPTION]?.get(0) == 1.toByte()) { StyledListItem( - name = stringResource(R.string.off), - description = stringResource(R.string.listening_mode_off_description), + contentText = stringResource(R.string.off), + supportingText = stringResource(R.string.listening_mode_off_description), selected = (currentByte and 0x01) != 0, onClick = { viewModel.toggleListeningMode(0x01) }, - orientation = ListItemOrientation.Vertical, + orientation = StyledListItemOrientation.Vertical, leadingContent = { Icon( - painter = painterResource(R.drawable.noise_cancellation), + painter = painterResource(R.drawable.ic_noise_cancellation), contentDescription = "Icon", tint = MaterialTheme.colorScheme.primary, modifier = Modifier @@ -168,16 +171,16 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navigateToPurchase: () } StyledListItem( - name = stringResource(R.string.transparency), - description = stringResource(R.string.listening_mode_transparency_description), + contentText = stringResource(R.string.transparency), + supportingText = stringResource(R.string.listening_mode_transparency_description), selected = (currentByte and 0x04) != 0, onClick = { viewModel.toggleListeningMode(0x04) }, - orientation = ListItemOrientation.Vertical, + orientation = StyledListItemOrientation.Vertical, leadingContent = { Icon( - painter = painterResource(R.drawable.transparency), + painter = painterResource(R.drawable.ic_transparency), contentDescription = "Icon", tint = MaterialTheme.colorScheme.primary, modifier = Modifier @@ -188,16 +191,16 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navigateToPurchase: () ) StyledListItem( - name = stringResource(R.string.adaptive), - description = stringResource(R.string.listening_mode_adaptive_description), + contentText = stringResource(R.string.adaptive), + supportingText = stringResource(R.string.listening_mode_adaptive_description), selected = (currentByte and 0x08) != 0, onClick = { viewModel.toggleListeningMode(0x08) }, - orientation = ListItemOrientation.Vertical, + orientation = StyledListItemOrientation.Vertical, leadingContent = { Icon( - painter = painterResource(R.drawable.adaptive), + painter = painterResource(R.drawable.ic_adaptive), contentDescription = "Icon", tint = MaterialTheme.colorScheme.primary, modifier = Modifier @@ -208,16 +211,16 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navigateToPurchase: () ) StyledListItem( - name = stringResource(R.string.noise_cancellation), - description = stringResource(R.string.listening_mode_noise_cancellation_description), + contentText = stringResource(R.string.noise_cancellation), + supportingText = stringResource(R.string.listening_mode_noise_cancellation_description), selected = (currentByte and 0x02) != 0, onClick = { viewModel.toggleListeningMode(0x02) }, - orientation = ListItemOrientation.Vertical, + orientation = StyledListItemOrientation.Vertical, leadingContent = { Icon( - painter = painterResource(R.drawable.noise_cancellation), + painter = painterResource(R.drawable.ic_noise_cancellation), contentDescription = "Icon", tint = MaterialTheme.colorScheme.primary, modifier = Modifier diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/RecordingScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/RecordingScreen.kt new file mode 100644 index 00000000..6f256c03 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/RecordingScreen.kt @@ -0,0 +1,288 @@ +package me.kavishdevar.librepods.presentation.screens.apple + +import android.content.Intent +import android.text.format.DateFormat +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLocale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import me.kavishdevar.librepods.BuildConfig +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.data.recording.Recording +import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation +import me.kavishdevar.librepods.presentation.components.StyledButton +import me.kavishdevar.librepods.presentation.components.StyledList +import me.kavishdevar.librepods.presentation.components.StyledListItem +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState +import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Composable +fun RecordingScreenRoute( + viewModel: AppleViewModel, +) { + val uiState by viewModel.uiState.collectAsState() + + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + + Box ( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + RecordingScreen( + uiState = uiState, + recordings = viewModel.recordings(), + startRecording = viewModel::startRecording, + stopRecording = viewModel::stopRecording, + topPadding = topPadding, + bottomPadding = bottomPadding + ) + } + + DisposableEffect(Unit) { + onDispose { + viewModel.stopRecording() + } + } +} + +@Composable +fun RecordingScreen( + uiState: AppleUiState, + recordings: List, + startRecording: () -> Unit, + stopRecording: () -> Unit, + topPadding: Dp = 16.dp, + bottomPadding: Dp = 16.dp +) { + val state = uiState.state + + val locale = LocalLocale.current.platformLocale + val datePattern = DateFormat.getBestDateTimePattern( + locale, + "yMMMd" + ) + + val timePattern = DateFormat.getBestDateTimePattern( + locale, + "jms" + ) + + val formatter = DateTimeFormatter.ofPattern( + "$datePattern - $timePattern" + ) + + val context = LocalContext.current + + val history = rememberSaveable( + saver = listSaver( + save = { it.toList() }, + restore = { it.toMutableStateList() } + ) + ) { + mutableStateListOf() + } + + LaunchedEffect(state.microphoneState.level) { + if (!state.recordingState.isRecording) { + history.clear() + return@LaunchedEffect + } + + history += state.microphoneState.level + + while (history.size > 240) { + history.removeAt(0) + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.padding(top = topPadding)) + + AnimatedContent( + targetState = state.recordingState.isRecording, + label = "recording", + modifier = Modifier.padding(vertical = 24.dp).weight(1f) + ) { recording -> + if (recording) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surface, + RoundedCornerShape(28.dp) + ) + .padding(24.dp) + ) { + // AI-generated + Canvas( + modifier = Modifier + .fillMaxSize() + ) { + if (history.isEmpty()) return@Canvas + + val spacing = 2.dp.toPx() + val width = 3.dp.toPx() + + val visible = ((size.width + spacing) / (width + spacing)).toInt() + + val start = (history.size - visible).coerceAtLeast(0) + + val centerY = size.height / 2f + + var x = size.width - width + + for (i in history.lastIndex downTo start) { + val h = history[i] + .coerceIn(0f, 1f) + .let { 6.dp.toPx() + it * (size.height * 0.45f) } + + drawRoundRect( + color = Color(0xFFFF4D4D), + topLeft = Offset( + x, + centerY - h + ), + size = Size( + width, + h * 2 + ), + cornerRadius = CornerRadius( + width / 2, + width / 2 + ) + ) + + x -= width + spacing + + if (x < 0f) + break + } + } + + Spacer(Modifier.height(24.dp)) + + Text( + text = buildString { + val total = state.microphoneState.durationMs + + append((total / 60000).toString().padStart(2, '0')) + append(':') + + append(((total / 1000) % 60).toString().padStart(2, '0')) + append('.') + + append(((total % 1000) / 10).toString().padStart(2, '0')) + }, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.displaySmall + ) + + Spacer(Modifier.height(12.dp)) + } + } else { + val scrollState = rememberScrollState() + Box( + modifier = Modifier.verticalScroll(scrollState) + ) { + if (recordings.isNotEmpty()) { + StyledList(title = stringResource(R.string.recordings)) { + recordings.forEach { + val text = formatter.format( + Instant.ofEpochMilli(it.createdAt.toEpochMilliseconds()) + .atZone(ZoneId.systemDefault()) + ) + StyledListItem( + contentText = text, + supportingText = it.uuid.toString(), + orientation = StyledListItemOrientation.Vertical, + onClick = { + val uri = FileProvider.getUriForFile( + context, + "${BuildConfig.APPLICATION_ID}.provider", + it.file + ) + + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "audio/wav") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + context.startActivity( + Intent.createChooser(intent, null) + ) + } + ) + } + } + } + } + } + } + + StyledButton( + modifier = Modifier.fillMaxWidth(), + onClick = if (state.recordingState.isRecording) { + stopRecording + } else { + startRecording + } + ) { + Text( + text = if (state.recordingState.isRecording) { + "Stop Recording" + } else "Start Recording", + style = MaterialTheme.typography.labelMedium + ) + } + Spacer(modifier = Modifier.padding(bottom = bottomPadding)) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/RenameScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/RenameScreen.kt similarity index 86% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/RenameScreen.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/RenameScreen.kt index 44e97e6a..32fbd425 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/RenameScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/RenameScreen.kt @@ -18,7 +18,7 @@ @file:OptIn(ExperimentalEncodingApi::class) -package me.kavishdevar.librepods.presentation.screens +package me.kavishdevar.librepods.presentation.screens.apple import android.content.Context import androidx.compose.foundation.background @@ -36,6 +36,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -47,13 +49,12 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import me.kavishdevar.librepods.presentation.components.StyledInputField import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import kotlin.io.encoding.ExperimentalEncodingApi - @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @Composable -fun RenameScreen(viewModel: AirPodsViewModel) { +fun RenameScreen(viewModel: AppleViewModel) { val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current @@ -65,7 +66,10 @@ fun RenameScreen(viewModel: AirPodsViewModel) { val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + + val uiState by viewModel.uiState.collectAsState() + val metadata = uiState.metadata Column( modifier = Modifier @@ -75,12 +79,10 @@ fun RenameScreen(viewModel: AirPodsViewModel) { ) { Spacer(modifier = Modifier.height(topPadding)) - val name = sharedPreferences.getString("name", "")?: "" - val textFieldState = rememberTextFieldState(initialText = name) + val textFieldState = rememberTextFieldState(initialText = metadata.name) LaunchedEffect(textFieldState.text) { - sharedPreferences.edit {putString("name", textFieldState.text as String?)} - viewModel.setName(textFieldState.text.toString()) + viewModel.renameDevice(textFieldState.text.toString()) } StyledInputField( diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TransparencySettingsScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/TransparencySettingsScreen.kt similarity index 90% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TransparencySettingsScreen.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/TransparencySettingsScreen.kt index c6700e57..f92c148b 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TransparencySettingsScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/TransparencySettingsScreen.kt @@ -16,13 +16,11 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.presentation.screens +package me.kavishdevar.librepods.presentation.screens.apple -// import me.kavishdevar.librepods.utils.RadareOffsetFinder import android.annotation.SuppressLint import android.util.Log import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -59,23 +57,22 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.data.TransparencySettings -import me.kavishdevar.librepods.data.parseTransparencySettingsResponse -import me.kavishdevar.librepods.data.sendTransparencySettings +import me.kavishdevar.librepods.bluetooth.att.types.TransparencySettings +import me.kavishdevar.librepods.bluetooth.att.types.parseTransparencySettingsResponse +import me.kavishdevar.librepods.bluetooth.att.types.sendTransparencySettings import me.kavishdevar.librepods.presentation.components.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledToggle +import me.kavishdevar.librepods.presentation.icons.LocalIcons import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.presentation.theme.sectionHeader +import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import kotlin.io.encoding.ExperimentalEncodingApi private const val TAG = "TransparencySettings" @@ -84,8 +81,8 @@ private const val TAG = "TransparencySettings" @ExperimentalHazeMaterialsApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @Composable -fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { - val isDarkTheme = isSystemInDarkTheme() +fun TransparencySettingsScreen(viewModel: AppleViewModel) { + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f val textColor = if (isDarkTheme) Color.White else Color.Black val verticalScrollState = rememberScrollState() @@ -93,11 +90,13 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) - val state by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsState() + + val state = uiState.state val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp Column( modifier = Modifier @@ -108,7 +107,7 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { verticalArrangement = Arrangement.spacedBy(16.dp) ) { Spacer(modifier = Modifier.height(topPadding)) - val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + val backgroundColor = MaterialTheme.colorScheme.surfaceContainer val enabled = rememberSaveable { mutableStateOf(false) } val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) } @@ -122,12 +121,13 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { restore = { mutableStateOf(it.toFloatArray()) } ) ) { mutableStateOf(FloatArray(8)) } - val phoneMediaEQ = rememberSaveable( - saver = Saver( - save = { it.value.toList() }, - restore = { mutableStateOf(it.toFloatArray()) } - ) - ) { mutableStateOf(FloatArray(8) { 0.5f }) } + +// val phoneMediaEQ = rememberSaveable( +// saver = Saver( +// save = { it.value.toList() }, +// restore = { mutableStateOf(it.toFloatArray()) } +// ) +// ) { mutableStateOf(FloatArray(8) { 0.5f }) } val initialized = rememberSaveable { mutableStateOf(false) } @@ -177,7 +177,7 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { balance = balanceSliderValue.floatValue ) Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}") - sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value) + sendTransparencySettings(viewModel::writeATTCharacteristic, transparencySettings.value) } LaunchedEffect(state.transparencyData) { @@ -196,7 +196,7 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { initialized.value = true } - if (state.vendorIdHook) { + if (uiState.vendorIdHook) { StyledToggle( label = stringResource(R.string.transparency_mode), checked = enabled.value, @@ -211,8 +211,8 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { onValueChange = { amplificationSliderValue.floatValue = it }, - startIcon = "􀊥", - endIcon = "􀊩", + startImageVector = LocalIcons.current.SpeakerMin, + endImageVector = LocalIcons.current.SpeakerMax, independent = true ) @@ -262,12 +262,8 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { Text( text = stringResource(R.string.equalizer), - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = textColor.copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), + style = MaterialTheme.typography.labelSmallEmphasized, + color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader, modifier = Modifier.padding(16.dp, bottom = 4.dp) ) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/UpdateHearingTestScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/UpdateHearingTestScreen.kt similarity index 87% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/UpdateHearingTestScreen.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/UpdateHearingTestScreen.kt index 514f3943..00833665 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/UpdateHearingTestScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/UpdateHearingTestScreen.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.presentation.screens +package me.kavishdevar.librepods.presentation.screens.apple import android.util.Log import androidx.compose.foundation.background @@ -53,36 +53,32 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import kotlinx.coroutines.Job import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.ATTHandles -import me.kavishdevar.librepods.data.HearingAidSettings -import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse -import me.kavishdevar.librepods.data.sendHearingAidSettings +import me.kavishdevar.librepods.bluetooth.att.ATTHandle +import me.kavishdevar.librepods.bluetooth.att.types.HearingAidSettings +import me.kavishdevar.librepods.bluetooth.att.types.parseHearingAidSettingsResponse +import me.kavishdevar.librepods.bluetooth.att.types.sendHearingAidSettings import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel -import me.kavishdevar.librepods.presentation.viewmodel.demoState +import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState +import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel private const val TAG = "UpdateHearingTestScreen" @Composable -fun UpdateHearingTestRoute(viewModel: AirPodsViewModel) { - val state by viewModel.uiState.collectAsState() +fun UpdateHearingTestRoute(viewModel: AppleViewModel) { + val uiState by viewModel.uiState.collectAsState() + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp Box( modifier = Modifier @@ -90,21 +86,23 @@ fun UpdateHearingTestRoute(viewModel: AirPodsViewModel) { .background(MaterialTheme.colorScheme.surfaceContainer) ) { UpdateHearingTestScreen( - state = state, + uiState = uiState, topPadding = topPadding, bottomPadding = bottomPadding, - setATTCharacteristicValue = viewModel::setATTCharacteristicValue + setATTCharacteristicValue = viewModel::writeATTCharacteristic ) } } @Composable fun UpdateHearingTestScreen( - state: AirPodsUiState, + uiState: AppleUiState, topPadding: Dp = 16.dp, bottomPadding: Dp = 16.dp, - setATTCharacteristicValue: (ATTHandles, ByteArray) -> Unit + setATTCharacteristicValue: (ATTHandle, ByteArray) -> Unit ) { + val state = uiState.state + val verticalScrollState = rememberScrollState() Column( @@ -261,12 +259,8 @@ fun UpdateHearingTestScreen( Log.d(TAG, "Left EQ updated at index $index to $parsed") } }, -// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - textStyle = TextStyle( - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontSize = 14.sp - ), + textStyle = MaterialTheme.typography.labelSmall, modifier = Modifier.weight(1f) ) OutlinedTextField( @@ -280,12 +274,8 @@ fun UpdateHearingTestScreen( Log.d(TAG, "Right EQ updated at index $index to $parsed") } }, -// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - textStyle = TextStyle( - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontSize = 14.sp - ), + textStyle = MaterialTheme.typography.labelSmall, modifier = Modifier.weight(1f) ) } @@ -298,13 +288,13 @@ fun UpdateHearingTestScreen( @Composable fun UpdateHearingTestScreenPreviewApple() { LibrePodsTheme( - m3eEnabled = false + designSystem = DesignSystem.Apple ) { Box ( modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer) ) { UpdateHearingTestScreen( - state = demoState, + uiState = AppleUiState(), setATTCharacteristicValue = { _, _ -> } ) } @@ -315,7 +305,7 @@ fun UpdateHearingTestScreenPreviewApple() { @Composable fun UpdateHearingTestScreenPreviewMaterial() { LibrePodsTheme( - m3eEnabled = true + designSystem = DesignSystem.Material ) { Box ( modifier = Modifier @@ -323,7 +313,7 @@ fun UpdateHearingTestScreenPreviewMaterial() { .background(MaterialTheme.colorScheme.surfaceContainer) ) { UpdateHearingTestScreen( - state = demoState, + uiState = AppleUiState(), setATTCharacteristicValue = { _, _ -> } ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/VersionInfoScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/VersionInfoScreen.kt similarity index 78% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/VersionInfoScreen.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/VersionInfoScreen.kt index 0cfcda92..ab2d3364 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/VersionInfoScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/VersionInfoScreen.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.presentation.screens +package me.kavishdevar.librepods.presentation.screens.apple import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column @@ -40,15 +40,17 @@ import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel @Composable -fun VersionScreen(viewModel: AirPodsViewModel) { - val state by viewModel.uiState.collectAsState() +fun VersionScreen(viewModel: AppleViewModel) { + val uiState by viewModel.uiState.collectAsState() + + val metadata = uiState.metadata val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp Column( modifier = Modifier @@ -59,20 +61,20 @@ fun VersionScreen(viewModel: AirPodsViewModel) { Spacer(modifier = Modifier.height(topPadding)) StyledList(title = stringResource(R.string.version)) { StyledListItem( - name = stringResource(R.string.version) + " 1", - description = state.version1, + contentText = stringResource(R.string.version) + " 1", + supportingText = metadata.version1, enabled = false ) StyledListItem( - name = stringResource(R.string.version) + " 2", - description = state.version2, + contentText = stringResource(R.string.version) + " 2", + supportingText = metadata.version2, enabled = false ) StyledListItem( - name = stringResource(R.string.version) + " 3", - description = state.version3, + contentText = stringResource(R.string.version) + " 3", + supportingText = metadata.version3, enabled = false ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/NotSupportedPage.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/NotSupportedPage.kt similarity index 78% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/NotSupportedPage.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/NotSupportedPage.kt index 66125ea6..2ea14862 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/NotSupportedPage.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/NotSupportedPage.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -12,6 +14,7 @@ import androidx.compose.material3.MaterialTheme 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.res.stringResource import androidx.compose.ui.unit.dp import me.kavishdevar.librepods.R @@ -27,9 +30,10 @@ fun NotSupportedPage( Box( modifier = Modifier.background( - color = MaterialTheme.colorScheme.surfaceContainer, - shape = RoundedCornerShape(42.dp) - ) + color = MaterialTheme.colorScheme.surfaceContainerLow, + shape = RoundedCornerShape(42.dp) + ) + .clip(RoundedCornerShape(42.dp)) ) { Column( modifier = Modifier @@ -37,6 +41,7 @@ fun NotSupportedPage( .verticalScroll(scrollState), verticalArrangement = Arrangement.spacedBy(16.dp) ) { + Spacer(modifier = Modifier.height(16.dp)) Text( text = stringResource(R.string.check_the_repository_for_more_info), style = MaterialTheme.typography.bodyMedium, @@ -47,11 +52,11 @@ fun NotSupportedPage( ) DeviceInfoCard() AppInfoCard() - StyledListItem( - name = stringResource(R.string.bypass_compatibility_check), + contentText = stringResource(R.string.bypass_compatibility_check), onClick = bypassCompatibilityCheck ) + Spacer(modifier = Modifier.height(16.dp)) } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/OnboardingScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/OnboardingScreen.kt similarity index 95% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/OnboardingScreen.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/OnboardingScreen.kt index ca6816c5..16bfff55 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/OnboardingScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/OnboardingScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.unit.dp import com.google.accompanist.permissions.ExperimentalPermissionsApi import kotlinx.coroutines.launch import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.utils.XposedState import me.kavishdevar.librepods.utils.bypassDeviceCheck @@ -83,16 +84,15 @@ fun OnboardingScreen( } LibrePodsTheme( - m3eEnabled = true + designSystem = DesignSystem.Material ) { Column( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background), + .background(MaterialTheme.colorScheme.surfaceContainer), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Spacer(modifier = Modifier.height(topPadding)) HorizontalUncontainedCarousel( modifier = Modifier .fillMaxWidth() @@ -100,14 +100,15 @@ fun OnboardingScreen( .padding(12.dp), state = state, itemWidth = LocalWindowInfo.current.containerDpSize.width - 24.dp, - userScrollEnabled = false + userScrollEnabled = false, ) { index -> val shape = rememberMaskShape(RoundedCornerShape(52.dp)) Surface( shape = shape, modifier = Modifier .fillMaxSize() - .clip(RoundedCornerShape(52.dp)) + .clip(RoundedCornerShape(52.dp)), + color = MaterialTheme.colorScheme.surfaceContainer ) { Column( modifier = Modifier, @@ -135,7 +136,7 @@ fun OnboardingScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "Welcome to", + text = stringResource(R.string.welcome_to_appname), style = MaterialTheme.typography.displayLarge, color = MaterialTheme.colorScheme.primary, textAlign = TextAlign.Center, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PermissionsPage.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/PermissionsPage.kt similarity index 92% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PermissionsPage.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/PermissionsPage.kt index 8f428d0b..78ded78b 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PermissionsPage.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/PermissionsPage.kt @@ -46,10 +46,10 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.accompanist.permissions.rememberPermissionState -import me.kavishdevar.librepods.presentation.MaterialIcons -import me.kavishdevar.librepods.presentation.components.ListItemOrientation +import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledListItem +import me.kavishdevar.librepods.presentation.icons.MaterialIcons @OptIn(ExperimentalPermissionsApi::class) @Composable @@ -88,7 +88,6 @@ fun PermissionsPage( } } - val bluetoothPermissionsState = rememberMultiplePermissionsState( listOf( "android.permission.BLUETOOTH_CONNECT", @@ -125,7 +124,7 @@ fun PermissionsPage( Box( modifier = Modifier.background( - color = MaterialTheme.colorScheme.surfaceContainer, + color = MaterialTheme.colorScheme.surfaceContainerLow, shape = RoundedCornerShape(42.dp) ) ) { @@ -143,15 +142,15 @@ fun PermissionsPage( ) StyledListItem( - name = "Bluetooth", + contentText = "Bluetooth", onClick = if (!bluetoothPermissionsState.allPermissionsGranted) { { grantingAll = false bluetoothPermissionsState.launchMultiplePermissionRequest() } } else null, - description = "Required to communicate with AirPods", - orientation = ListItemOrientation.Vertical, + supportingText = "Required to communicate with AirPods", + orientation = StyledListItemOrientation.Vertical, leadingContent = { Box( modifier = Modifier @@ -164,7 +163,7 @@ fun PermissionsPage( contentAlignment = Alignment.Center ) { Icon( - imageVector = MaterialIcons.bluetooth, + imageVector = MaterialIcons.Bluetooth, contentDescription = "bluetooth", modifier = Modifier.size(24.dp), tint = animatedBluetoothIconColor @@ -186,15 +185,15 @@ fun PermissionsPage( ) StyledListItem( - name = "Notifications", + contentText = "Notifications", onClick = if (!notificationPermissionState.status.isGranted) { { grantingAll = false notificationPermissionState.launchPermissionRequest() } } else null, - description = "Show battery status", - orientation = ListItemOrientation.Vertical, + supportingText = "Show battery status", + orientation = StyledListItemOrientation.Vertical, leadingContent = { Box( modifier = Modifier @@ -207,7 +206,7 @@ fun PermissionsPage( contentAlignment = Alignment.Center ) { Icon( - imageVector = MaterialIcons.notifications, + imageVector = MaterialIcons.Notifications, contentDescription = "notifications", modifier = Modifier.size(24.dp), tint = animatedNotificationsIconColor @@ -216,15 +215,15 @@ fun PermissionsPage( }, ) StyledListItem( - name = "Phone", + contentText = "Phone", onClick = if (!phonePermissionState.allPermissionsGranted) { { grantingAll = false phonePermissionState.launchMultiplePermissionRequest() } } else null, - description = "Respond to phone calls with head gestures", - orientation = ListItemOrientation.Vertical, + supportingText = "Respond to phone calls with head gestures", + orientation = StyledListItemOrientation.Vertical, leadingContent = { Box( modifier = Modifier @@ -237,7 +236,7 @@ fun PermissionsPage( contentAlignment = Alignment.Center ) { Icon( - imageVector = MaterialIcons.call, + imageVector = MaterialIcons.Call, contentDescription = "bluetooth", modifier = Modifier.size(24.dp), tint = animatedPhoneIconColor @@ -251,7 +250,7 @@ fun PermissionsPage( val animatedOverlayContainerColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest) StyledListItem( - name = "Display over other apps", + contentText = "Display over other apps", onClick = if (!canDrawOverlays.value) { { grantingAll = false @@ -262,8 +261,8 @@ fun PermissionsPage( context.startActivity(intent) } } else null, - description = "Show popups when AirPods are nearby or audio switches to them.", - orientation = ListItemOrientation.Vertical, + supportingText = "Show popups when AirPods are nearby or audio switches to them.", + orientation = StyledListItemOrientation.Vertical, leadingContent = { Box( modifier = Modifier @@ -276,7 +275,7 @@ fun PermissionsPage( contentAlignment = Alignment.Center ) { Icon( - imageVector = MaterialIcons.stack, + imageVector = MaterialIcons.Overlay, contentDescription = "bluetooth", modifier = Modifier.size(24.dp), tint = animatedOverlayIconColor diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PrivacyPolicyPage.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/PrivacyPolicyPage.kt similarity index 99% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PrivacyPolicyPage.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/PrivacyPolicyPage.kt index 23eaa837..8a36789d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PrivacyPolicyPage.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/PrivacyPolicyPage.kt @@ -29,7 +29,7 @@ fun PrivacyPolicyPage( Box( modifier = Modifier.background( - color = MaterialTheme.colorScheme.surfaceContainer, + color = MaterialTheme.colorScheme.surfaceContainerLow, shape = RoundedCornerShape(42.dp) ) ) { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Color.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Color.kt similarity index 100% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Color.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Color.kt diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/DesignSystem.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/DesignSystem.kt new file mode 100644 index 00000000..74a48778 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/DesignSystem.kt @@ -0,0 +1,9 @@ +package me.kavishdevar.librepods.presentation.theme + +import kotlinx.serialization.Serializable + +@Serializable +enum class DesignSystem { + Apple, + Material +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/LocalDesignSystem.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/LocalDesignSystem.kt similarity index 66% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/LocalDesignSystem.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/LocalDesignSystem.kt index 1580e6ba..5751dbcf 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/LocalDesignSystem.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/LocalDesignSystem.kt @@ -1,12 +1,8 @@ package me.kavishdevar.librepods.presentation.theme import androidx.compose.runtime.compositionLocalOf - -enum class DesignSystem { - Apple, - Material -} +import kotlinx.serialization.Serializable val LocalDesignSystem = compositionLocalOf { - DesignSystem.Apple + DesignSystem.Material } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/NightTheme.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/NightTheme.kt new file mode 100644 index 00000000..f177b466 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/NightTheme.kt @@ -0,0 +1,7 @@ +package me.kavishdevar.librepods.presentation.theme + +enum class NightTheme { + System, + Light, + Dark +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Theme.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Theme.kt similarity index 61% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Theme.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Theme.kt index 5d76d69e..62933279 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Theme.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Theme.kt @@ -30,58 +30,79 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import me.kavishdevar.librepods.presentation.icons.AppleIcons +import me.kavishdevar.librepods.presentation.icons.LocalIcons +import me.kavishdevar.librepods.presentation.icons.MaterialIcons val ColorScheme.sectionHeader: Color get() = onBackground.copy(alpha = 0.6f) private val AppleDarkColorScheme = darkColorScheme( - surfaceContainer = Color(0xFF000000), // for some reason background is not used as the background in gmail and settings app, but surfacecontainer, so using that + background = Color(0xFF000000), onBackground = Color(0xFFFFFFFF), - surface = Color(0xFF1C1C1E), + surfaceContainer = Color(0xFF000000), onSurface = Color(0xFFFFFFFF), surfaceDim = Color(0x40888888), primary = Color(0xFF0091FF), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFF003258), + onPrimaryContainer = Color(0xFFB3D9FF), secondaryContainer = Color(0xFF366AA8), - onSecondaryContainer = Color(0xFF0091FF), - onPrimary = Color(0xFFFFFFFF) + onSecondaryContainer = Color(0xFFB3D9FF), + tertiary = Color(0xFFEA7B00), + scrim = Color(0x8C000000), + surfaceContainerHigh = Color(0xFF1C1C1E), + surfaceContainerLow = Color(0xFF2C2C2E) ) private val AppleLightColorScheme = lightColorScheme( - surfaceContainer = Color(0xFFF2F2F7), + background = Color(0xFFF2F2F7), onBackground = Color(0xFF000000), - surface = Color(0xFFFFFFFF), + surfaceContainer = Color(0xFFF2F2F7), onSurface = Color(0xFF000000), surfaceDim = Color(0x40D9D9D9), - secondaryContainer = Color(0xFF6BC0FF), - onSecondaryContainer = Color(0xFF0088FF), primary = Color(0xFF0088FF), - onPrimary = Color(0xFFFFFFFF) + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFB3D9FF), + onPrimaryContainer = Color(0xFF003258), + secondaryContainer = Color(0xFF6BC0FF), + onSecondaryContainer = Color(0xFF003258), + tertiary = Color(0xFFEA7B00), + scrim = Color(0xD9F2F2F7), + surfaceContainerHigh = Color(0xFFFFFFFF), + surfaceContainerLow = Color(0xFFE7E7E7) ) @Composable fun LibrePodsTheme( darkTheme: Boolean = isSystemInDarkTheme(), - m3eEnabled: Boolean = false, + designSystem: DesignSystem = DesignSystem.Material, content: @Composable () -> Unit ) { - val colorScheme = when { - m3eEnabled -> { + val colorScheme = when(designSystem) { + DesignSystem.Material -> { val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } - darkTheme -> AppleDarkColorScheme - else -> AppleLightColorScheme + DesignSystem.Apple -> if (darkTheme) AppleDarkColorScheme else AppleLightColorScheme + } + + val typography = when(designSystem) { + DesignSystem.Material -> MaterialTypography + DesignSystem.Apple -> AppleTypography } CompositionLocalProvider( - LocalDesignSystem provides - if (m3eEnabled) DesignSystem.Material - else DesignSystem.Apple + LocalDesignSystem provides designSystem, + LocalIcons provides when (designSystem) { + DesignSystem.Material -> MaterialIcons + DesignSystem.Apple -> AppleIcons + } ) { MaterialExpressiveTheme( colorScheme = colorScheme, motionScheme = MotionScheme.expressive(), - typography = if (m3eEnabled) MaterialTypography else AppleTypography, + typography = typography, content = content ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Type.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Type.kt similarity index 85% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Type.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Type.kt index a62b9fdd..88360310 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Type.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Type.kt @@ -35,65 +35,82 @@ import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontVariation import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.googlefonts.GoogleFont import androidx.compose.ui.tooling.preview.Devices.PIXEL_9_PRO_XL import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import me.kavishdevar.librepods.R -val sfProFamily = FontFamily(Font(R.font.sf_pro)) +val interFamily = FontFamily(Font(R.font.inter)) val AppleTypography = Typography().run { copy( - displayLarge = displayLarge.copy(fontFamily = sfProFamily), - displayMedium = displayMedium.copy(fontFamily = sfProFamily), - displaySmall = displaySmall.copy(fontFamily = sfProFamily), + displayLarge = displayLarge.copy(fontFamily = interFamily), + displayMedium = displayMedium.copy(fontFamily = interFamily), + displaySmall = displaySmall.copy(fontFamily = interFamily), - headlineLarge = headlineLarge.copy(fontFamily = sfProFamily), - headlineMedium = headlineMedium.copy(fontFamily = sfProFamily), - headlineSmall = headlineSmall.copy(fontFamily = sfProFamily), + headlineLarge = headlineLarge.copy(fontFamily = interFamily), + headlineMedium = headlineMedium.copy(fontFamily = interFamily), + headlineSmall = headlineSmall.copy(fontFamily = interFamily), - titleLarge = titleLarge.copy(fontFamily = sfProFamily), - titleMedium = titleMedium.copy(fontFamily = sfProFamily), - titleSmall = titleSmall.copy(fontFamily = sfProFamily), + titleLarge = titleLarge.copy(fontFamily = interFamily), + titleMedium = titleMedium.copy(fontFamily = interFamily), + titleSmall = titleSmall.copy(fontFamily = interFamily), - bodyLarge = bodyLarge.copy(fontFamily = sfProFamily), - bodyMedium = bodyMedium.copy( - fontFamily = sfProFamily, - fontSize = 16.sp - ), bodySmall = bodySmall.copy( - fontFamily = sfProFamily, + fontFamily = interFamily, fontSize = 14.sp, lineHeight = 18.sp ), + bodySmallEmphasized = bodySmallEmphasized.copy( + fontFamily = interFamily, + fontSize = 14.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.Bold + ), + bodyMedium = bodyMedium.copy( + fontFamily = interFamily, + fontSize = 16.sp, + lineHeight = 22.sp + ), + bodyMediumEmphasized = bodyMediumEmphasized.copy( + fontFamily = interFamily, + fontSize = 16.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.Bold + ), + bodyLarge = bodyLarge.copy( + fontFamily = interFamily, + fontSize = 18.sp, + lineHeight = 24.sp + ), + bodyLargeEmphasized = bodyLargeEmphasized.copy( + fontFamily = interFamily, + fontSize = 18.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Bold + ), - labelLarge = labelLarge.copy(fontFamily = sfProFamily), + + labelLarge = labelLarge.copy(fontFamily = interFamily), labelMedium = labelMedium.copy( - fontFamily = sfProFamily, + fontFamily = interFamily, fontSize = 16.sp, ), labelMediumEmphasized = labelMediumEmphasized.copy( - fontFamily = sfProFamily, + fontFamily = interFamily, fontSize = 16.sp, fontWeight = FontWeight.Bold ), labelSmallEmphasized = labelSmallEmphasized.copy( - fontFamily = sfProFamily, + fontFamily = interFamily, fontSize = 14.sp, fontWeight = FontWeight.Bold ) ) } -val provider = GoogleFont.Provider( - providerAuthority = "com.google.android.gms.fonts", - providerPackage = "com.google.android.gms", - certificates = R.array.com_google_android_gms_fonts_certs -) - private fun robotoFlex( wght: Float = 400f, slnt: Float = 0f, @@ -103,11 +120,8 @@ private fun robotoFlex( xopq: Float = 96f, yopq: Float = 79f, ) = FontFamily( - androidx.compose.ui.text.googlefonts.Font( -// Font( -// resId = R.font.roboto_flex, - googleFont = GoogleFont("Roboto Flex"), - fontProvider = provider, + Font( + resId = R.font.roboto_flex, variationSettings = FontVariation.Settings( FontVariation.Setting("wght", wght), FontVariation.Setting("wdth", wdth), @@ -236,13 +250,13 @@ val MaterialTypography = Typography().run { bodyMedium = bodyMedium.copy( fontFamily = body, fontSize = 16.sp, - lineHeight = 24.sp, + lineHeight = 22.sp, ), bodyLarge = bodyLarge.copy( fontFamily = body, fontSize = 18.sp, - lineHeight = 28.sp, + lineHeight = 24.sp, ), bodySmallEmphasized = bodySmallEmphasized.copy( @@ -254,13 +268,13 @@ val MaterialTypography = Typography().run { bodyMediumEmphasized = bodyMediumEmphasized.copy( fontFamily = bodyEmphasized, fontSize = 16.sp, - lineHeight = 24.sp, + lineHeight = 22.sp, ), bodyLargeEmphasized = bodyLargeEmphasized.copy( fontFamily = bodyEmphasized, fontSize = 18.sp, - lineHeight = 28.sp, + lineHeight = 24.sp, ), labelSmall = labelSmall.copy( @@ -308,7 +322,7 @@ val MaterialTypography = Typography().run { ) @Composable private fun TypographyPreview() { - LibrePodsTheme (m3eEnabled = true) { + LibrePodsTheme (designSystem = DesignSystem.Material) { Box( modifier = Modifier .fillMaxSize() diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/utils/BatteryTextGenerator.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/utils/BatteryTextGenerator.kt new file mode 100644 index 00000000..d4ce126d --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/utils/BatteryTextGenerator.kt @@ -0,0 +1,100 @@ +package me.kavishdevar.librepods.presentation.utils + +import androidx.compose.runtime.Composable +import me.kavishdevar.librepods.devices.AirPodsSpec +import me.kavishdevar.librepods.devices.Battery +import me.kavishdevar.librepods.devices.BatteryComponent +import me.kavishdevar.librepods.devices.BatteryStatus +import me.kavishdevar.librepods.devices.DeviceComponent +import me.kavishdevar.librepods.presentation.icons.RichText +import me.kavishdevar.librepods.presentation.icons.richText +import kotlin.math.absoluteValue + +@Composable +fun createAirPodsBatteryRichText( + battery: Set, + airPodsSpec: AirPodsSpec +): RichText { + val airPodsIconName = airPodsSpec.genericIconName + val leftIconName = airPodsSpec.components.find { it.type == DeviceComponent.LEFT }?.iconName ?: "AirPodsPro3Left" + val rightIconName = airPodsSpec.components.find { it.type == DeviceComponent.RIGHT }?.iconName ?: "AirPodsPro3Right" + val airPodsCaseiconName = (airPodsSpec.components.find { it.type == DeviceComponent.CASE }?.iconName?: "AirPodsPro3Case") + "Fill" + val headsetIconName = airPodsSpec.components.find { it.type == DeviceComponent.HEADSET }?.iconName ?: "AirPodsMax" + + val left = battery.find { it.component == BatteryComponent.LEFT } + val leftLevel = left?.level ?: 0 + + val right = battery.find { it.component == BatteryComponent.RIGHT } + val rightLevel = right?.level ?: 0 + + val case = battery.find { it.component == BatteryComponent.CASE } + val caseLevel = case?.level ?: 0 + + val headset = battery.find { it.component == BatteryComponent.HEADSET } + val headsetLevel = headset?.level ?: 0 + + val individualIcons = + (leftLevel - rightLevel).absoluteValue >= 5 || left?.status != right?.status + + val budsBatteryText = if (individualIcons) { + val leftBatteryText = + if (left != null && leftLevel > 0) { + val statusIcon = when (left.status) { + BatteryStatus.CHARGING, BatteryStatus.OPTIMIZED_CHARGING -> "\\icon{BoltCircle}" + BatteryStatus.NOT_CHARGING -> "\\icon{Circle}" + BatteryStatus.UNKNOWN, BatteryStatus.DISCONNECTED -> "\\icon{CircleDotted}" + } + "\\icon{$leftIconName} " + statusIcon + " ${leftLevel}%" + } else "" + + val rightBatteryText = + if (right != null && rightLevel > 0) { + val statusIcon = when (right.status) { + BatteryStatus.CHARGING, BatteryStatus.OPTIMIZED_CHARGING -> "\\icon{BoltCircle}" + BatteryStatus.NOT_CHARGING -> "\\icon{Circle}" + BatteryStatus.UNKNOWN, BatteryStatus.DISCONNECTED -> "\\icon{CircleDotted}" + } + "\\icon{$rightIconName} " + statusIcon + " ${rightLevel}%" + + } else "" + + "$leftBatteryText $rightBatteryText" + } else { + val statusIcon = when { + left?.status == BatteryStatus.CHARGING || left?.status == BatteryStatus.OPTIMIZED_CHARGING -> "\\icon{BoltCircle}" + left != null && (left.status == BatteryStatus.DISCONNECTED) -> "\\icon{CircleDotted}" + else -> "\\icon{Circle}" + } + "\\icon{${airPodsIconName}} " + statusIcon + " ${ + leftLevel.coerceAtMost( + rightLevel + ) + }%" + } + + val caseBatteryText = if (case != null && caseLevel > 0) { + val statusIcon = when (case.status) { + BatteryStatus.CHARGING, BatteryStatus.OPTIMIZED_CHARGING -> "\\icon{BoltCircle}" + BatteryStatus.NOT_CHARGING -> "\\icon{Circle}" + BatteryStatus.UNKNOWN, BatteryStatus.DISCONNECTED -> "\\icon{CircleDotted}" + } + "\\icon{$airPodsCaseiconName} " + statusIcon + " ${caseLevel}%" + } else "" + + return richText( + if (left != null && right != null && case != null) { + "$budsBatteryText $caseBatteryText" + } else { + if (headset != null) { + val statusIcon = when (headset.status) { + BatteryStatus.CHARGING, BatteryStatus.OPTIMIZED_CHARGING -> "\\icon{BoltCircle}" + BatteryStatus.NOT_CHARGING -> "\\icon{Circle}" + BatteryStatus.UNKNOWN, BatteryStatus.DISCONNECTED -> "\\icon{CircleDotted}" + } + headsetIconName + statusIcon + " ${headsetLevel}%" + } else { + "No battery info available" + } + } + ) +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt new file mode 100644 index 00000000..7f25b260 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt @@ -0,0 +1,68 @@ +package me.kavishdevar.librepods.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import me.kavishdevar.librepods.billing.BillingManager +import me.kavishdevar.librepods.data.xposed.XposedRemotePrefProvider +import me.kavishdevar.librepods.database.app.AppSettingsEntity +import me.kavishdevar.librepods.database.app.AppStateEntity +import me.kavishdevar.librepods.repository.AppDataRepository + +data class AppUiState( + val settings: AppSettingsEntity = AppSettingsEntity(), + val state: AppStateEntity = AppStateEntity(), + + val vendorIdHook: Boolean = false, + val isPremium: Boolean = false, +) + +class AppSettingsViewModel( + private val appDataRepository: AppDataRepository, +) : ViewModel() { + + private val xposedRemotePref = XposedRemotePrefProvider.create() + + private val vendorIdHook = MutableStateFlow( + xposedRemotePref.getBoolean("vendor_id_hook", false) + ) + + val uiState = combine( + appDataRepository.settings, + appDataRepository.state, + BillingManager.provider.isPremium, + vendorIdHook, + ) { settings, state, isPremium, vendorIdHook -> + AppUiState( + settings = settings, + state = state, + isPremium = isPremium, + vendorIdHook = vendorIdHook, + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + AppUiState( + settings = appDataRepository.settings.value, + state = appDataRepository.state.value, + isPremium = BillingManager.provider.isPremium.value, + vendorIdHook = vendorIdHook.value, + ) + ) + + fun updateSettings( + transform: (AppSettingsEntity) -> AppSettingsEntity + ) = appDataRepository.updateSettings(transform) + + fun updateState( + transform: (AppStateEntity) -> AppStateEntity + ) = appDataRepository.updateState(transform) + + fun setVendorIdHook(enabled: Boolean) { + xposedRemotePref.putBoolean("vendor_id_hook", enabled) + vendorIdHook.value = enabled + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/viewmodel/AppleViewModel.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/viewmodel/AppleViewModel.kt new file mode 100644 index 00000000..fa79f8da --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/viewmodel/AppleViewModel.kt @@ -0,0 +1,139 @@ +/* + 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 . +*/ + +package me.kavishdevar.librepods.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.billing.BillingManager +import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier +import me.kavishdevar.librepods.bluetooth.att.ATTHandle +import me.kavishdevar.librepods.data.StemAction +import me.kavishdevar.librepods.data.recording.Recording +import me.kavishdevar.librepods.data.xposed.XposedRemotePrefProvider +import me.kavishdevar.librepods.devices.AppleDevice +import me.kavishdevar.librepods.devices.AppleMetadata +import me.kavishdevar.librepods.devices.AppleSettings +import me.kavishdevar.librepods.devices.AppleState +import me.kavishdevar.librepods.repository.RecordingRepository + +data class AppleUiState( + val state: AppleState = AppleState(), + val settings: AppleSettings = AppleSettings(), + val metadata: AppleMetadata = AppleMetadata(), + + val isPremium: Boolean = false, + val vendorIdHook: Boolean = false, + val recordings: List = emptyList(), +) + +class AppleViewModel( + private val device: AppleDevice, + private val recordingRepository: RecordingRepository, +) : ViewModel(), DeviceViewModel { + val billingManager = BillingManager + + val events = device.events + + private var attObserveJob: Job? = null + + val uiState = combine( + device.state, + device.settings, + device.metadata, + billingManager.provider.isPremium, + ) { state, settings, metadata, isPremium -> + AppleUiState( + state = state, + settings = settings, + metadata = metadata, + isPremium = isPremium, + vendorIdHook = XposedRemotePrefProvider.create().getBoolean( + "vendor_id_hook", + false + ) // TODO: make this a Flow, even if it means polling every few seconds + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + AppleUiState( + state = device.state.value, + settings = device.settings.value, + metadata = device.metadata.value, + isPremium = billingManager.provider.isPremium.value, + vendorIdHook = XposedRemotePrefProvider.create() + .getBoolean("vendor_id_hook", false) + ) + ) + + fun disconnect() = device.disconnect() + fun recordings(): List = recordingRepository.recordings() + + fun setControlCommand(identifier: ControlCommandIdentifier, value: ByteArray): Boolean = + device.setControlCommand(identifier, value) + + fun setControlCommand(identifier: ControlCommandIdentifier, value: Byte): Boolean = + device.setControlCommand(identifier, value) + + fun setControlCommand(identifier: ControlCommandIdentifier, value: Int): Boolean = + device.setControlCommand(identifier, value) + + fun setControlCommand(identifier: ControlCommandIdentifier, value: Boolean): Boolean = + device.setControlCommand(identifier, value) + + fun writeATTCharacteristic(handle: ATTHandle, value: ByteArray) { + viewModelScope.launch { + device.writeATTCharacteristic(handle, value) + } + } + + fun observeATTCharacteristic(handle: ATTHandle) { + attObserveJob = device.observeATTCharacteristic(handle) + } + fun stopObservingATTCharacteristic() { + attObserveJob?.cancel() + attObserveJob = null + } + + fun startRecording() = device.startRecording() + fun stopRecording() = device.stopRecording() + + fun toggleListeningMode(modeBit: Int) = device.toggleListeningMode(modeBit) + + fun setLongPressAction(side: String, action: StemAction) = device.setLongPressAction(side, action) + + fun renameDevice(newName: String) = device.renameDevice(newName) + + + fun startHeadTracking() = device.startHeadTracking() + fun stopHeadTracking() = device.stopHeadTracking() + + fun setHeadGesturesEnabled(enabled: Boolean) = device.setHeadGesturesEnabled(enabled) + + fun setCustomEqEnabled(enabled: Boolean) = device.setCustomEqEnabled(enabled) + fun setCustomEq(low: Int, mid: Int, high: Int) = device.setCustomEq(low, mid, high) + + fun testHeadGestures() = device.testHeadGestures() + + fun sendRawPacket(data: ByteArray): Boolean = device.sendRawPacket(data) +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/viewmodel/DeviceViewModel.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/viewmodel/DeviceViewModel.kt new file mode 100644 index 00000000..e6cb2835 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/viewmodel/DeviceViewModel.kt @@ -0,0 +1,5 @@ +package me.kavishdevar.librepods.presentation.viewmodel + +import androidx.lifecycle.ViewModel + +sealed interface DeviceViewModel diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/PurchaseViewModel.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/viewmodel/PurchaseViewModel.kt similarity index 100% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/PurchaseViewModel.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/viewmodel/PurchaseViewModel.kt diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/widgets/BatteryWidget.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/widgets/BatteryWidget.kt similarity index 71% rename from android/app/src/main/java/me/kavishdevar/librepods/presentation/widgets/BatteryWidget.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/widgets/BatteryWidget.kt index 20a12d54..2b7b41f8 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/widgets/BatteryWidget.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/widgets/BatteryWidget.kt @@ -20,18 +20,7 @@ package me.kavishdevar.librepods.presentation.widgets -import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider -import android.content.Context -import me.kavishdevar.librepods.services.ServiceManager import kotlin.io.encoding.ExperimentalEncodingApi -class BatteryWidget : AppWidgetProvider() { - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray - ) { - ServiceManager.getService()?.updateBattery() - } -} +class BatteryWidget : AppWidgetProvider() diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/widgets/NoiseControlWidget.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/widgets/NoiseControlWidget.kt new file mode 100644 index 00000000..068daf3b --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/widgets/NoiseControlWidget.kt @@ -0,0 +1,91 @@ +/* + 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 . +*/ + +@file:OptIn(ExperimentalEncodingApi::class) + +package me.kavishdevar.librepods.presentation.widgets + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.content.Intent +import android.widget.RemoteViews +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.services.LibrePodsService +import kotlin.io.encoding.ExperimentalEncodingApi + +class NoiseControlWidget : AppWidgetProvider() { + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + for (appWidgetId in appWidgetIds) { + val views = RemoteViews(context.packageName, R.layout.noise_control_widget) + + val intent = Intent(context, LibrePodsService::class.java).apply { + action = "ACTION_SET_ANC_MODE" + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + } + + val offIntent = Intent(intent).putExtra("ANC_MODE", 1) + val transparencyIntent = Intent(intent).putExtra("ANC_MODE", 3) + val adaptiveIntent = Intent(intent).putExtra("ANC_MODE", 4) + val ancIntent = Intent(intent).putExtra("ANC_MODE", 2) + + views.setOnClickPendingIntent( + R.id.widget_off_button, + PendingIntent.getForegroundService( + context, + 0, + offIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + ) + views.setOnClickPendingIntent( + R.id.widget_transparency_button, + PendingIntent.getForegroundService( + context, + 1, + transparencyIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + ) + views.setOnClickPendingIntent( + R.id.widget_adaptive_button, + PendingIntent.getForegroundService( + context, + 2, + adaptiveIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + ) + views.setOnClickPendingIntent( + R.id.widget_anc_button, + PendingIntent.getForegroundService( + context, + 3, + ancIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + ) + appWidgetManager.updateAppWidget(appWidgetId, views) + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/receivers/BootReceiver.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/receivers/BootReceiver.kt similarity index 90% rename from android/app/src/main/java/me/kavishdevar/librepods/receivers/BootReceiver.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/receivers/BootReceiver.kt index 180a7e95..acb5c34b 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/receivers/BootReceiver.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/receivers/BootReceiver.kt @@ -23,8 +23,8 @@ package me.kavishdevar.librepods.receivers import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import me.kavishdevar.librepods.services.LibrePodsService import kotlin.io.encoding.ExperimentalEncodingApi -import me.kavishdevar.librepods.services.AirPodsService class BootReceiver: BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { @@ -32,15 +32,15 @@ class BootReceiver: BroadcastReceiver() { Intent.ACTION_MY_PACKAGE_REPLACED -> try { context?.startForegroundService( Intent( context, - AirPodsService::class.java + LibrePodsService::class.java ) ) } catch (e: Exception) { e.printStackTrace() } Intent.ACTION_BOOT_COMPLETED -> try { context?.startForegroundService( Intent( context, - AirPodsService::class.java + LibrePodsService::class.java ) ) } catch (e: Exception) { e.printStackTrace() } } } -} \ No newline at end of file +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/AppDataRepository.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/AppDataRepository.kt new file mode 100644 index 00000000..9bfdb49c --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/AppDataRepository.kt @@ -0,0 +1,57 @@ +package me.kavishdevar.librepods.repository + +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.flow.asStateFlow +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.database.app.AppSettingsDao +import me.kavishdevar.librepods.database.app.AppSettingsEntity +import me.kavishdevar.librepods.database.app.AppStateDao +import me.kavishdevar.librepods.database.app.AppStateEntity + +class AppDataRepository( + private val settingsDao: AppSettingsDao, + private val stateDao: AppStateDao, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val _settings = MutableStateFlow(AppSettingsEntity()) + val settings: StateFlow = _settings.asStateFlow() + + private val _state = MutableStateFlow(AppStateEntity()) + val state: StateFlow = _state.asStateFlow() + + init { + scope.launch { + _settings.value = settingsDao.get() ?: AppSettingsEntity() + _state.value = stateDao.get() ?: AppStateEntity() + } + } + + fun updateSettings( + transform: (AppSettingsEntity) -> AppSettingsEntity + ) { + val newSettings = transform(_settings.value) + + _settings.value = newSettings + + scope.launch { + settingsDao.upsert(newSettings) + } + } + + fun updateState( + transform: (AppStateEntity) -> AppStateEntity + ) { + val newState = transform(_state.value) + + _state.value = newState + + scope.launch { + stateDao.upsert(newState) + } + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/AppleRepository.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/AppleRepository.kt new file mode 100644 index 00000000..ed363b3c --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/AppleRepository.kt @@ -0,0 +1,58 @@ +package me.kavishdevar.librepods.repository + +import android.util.Log +import me.kavishdevar.librepods.bluetooth.MacAddress +import me.kavishdevar.librepods.data.apple.AppleCache +import me.kavishdevar.librepods.database.apple.AppleDao +import me.kavishdevar.librepods.database.apple.AppleEntity +import me.kavishdevar.librepods.devices.AppleMetadata +import me.kavishdevar.librepods.devices.AppleSettings +import me.kavishdevar.librepods.devices.AppleState + +private const val TAG = "AppleRepository" +class AppleRepository( + private val dao: AppleDao, +) { + suspend fun load(macAddress: MacAddress): AppleEntity? { + Log.d(TAG, "Loading AppleEntity for $macAddress") + val entity = dao.get(macAddress) + Log.i(TAG, "Loaded AppleEntity for $macAddress: $entity") + return entity + } + + suspend fun saveSettings(macAddress: MacAddress, settings: AppleSettings) { + Log.d(TAG, "Saving AppleSettings for $macAddress: $settings") + dao.updateSettings(macAddress, settings) + Log.i(TAG, "Saved AppleSettings for $macAddress: $settings") + } + + suspend fun saveMetadata(macAddress: MacAddress, metadata: AppleMetadata) { + Log.d(TAG, "Saving AppleMetadata for $macAddress: $metadata") + dao.updateMetadata(macAddress, metadata) + Log.i(TAG, "Saved AppleMetadata for $macAddress: $metadata") + } + + suspend fun saveCache(macAddress: MacAddress, cache: AppleCache) { + Log.d(TAG, "Saving AppleCache for $macAddress: $cache") + dao.saveCache(macAddress, cache) + Log.i(TAG, "Saved AppleCache for $macAddress: $cache") + } + + suspend fun saveCacheFromState(macAddress: MacAddress, state: AppleState) { + Log.d(TAG, "Saving AppleCache from AppleState for ${macAddress.toRedactedString()}: $state") + val cache = try { + AppleCache( + capabilities = state.capabilities, + magicKeys = state.magicKeys, + controlStates = state.controlStates, + customEq = state.customEq + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to create AppleCache from AppleState for ${macAddress.toRedactedString()}: $state", e) + return + } + + saveCache(macAddress, cache) + Log.i(TAG, "Saved AppleCache from AppleState for ${macAddress.toRedactedString()}: $cache") + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/RecordingRepository.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/RecordingRepository.kt new file mode 100644 index 00000000..6e22c0a0 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/RecordingRepository.kt @@ -0,0 +1,48 @@ +package me.kavishdevar.librepods.repository + +import android.content.Context +import android.os.Environment +import me.kavishdevar.librepods.data.recording.Recording +import java.io.File +import kotlin.time.Clock +import kotlin.time.Instant +import kotlin.uuid.Uuid + +class RecordingRepository( + context: Context +) { + private val recordingsDir = context.getExternalFilesDir(Environment.DIRECTORY_RECORDINGS)?: File( + context.getExternalFilesDir(null), + "Recordings" + ) + + fun createRecording(): Recording { + val now = Clock.System.now() + + val uuid = Uuid.random() + + val file = File( + recordingsDir, + "${uuid}_${now.toEpochMilliseconds()}.wav" + ) + + return Recording( + uuid = uuid, + file = file, + createdAt = now + ) + } + + fun recordings(): List = + recordingsDir + .listFiles { f -> f.extension == "wav" } + ?.sortedByDescending(File::lastModified) + ?.map { + Recording( + uuid = Uuid.parse(it.name.split("_")[0]), + file = it, + createdAt = Instant.fromEpochMilliseconds(it.lastModified()) + ) + } + ?: emptyList() +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/WidgetConfigRepository.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/WidgetConfigRepository.kt new file mode 100644 index 00000000..303aa9b5 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/WidgetConfigRepository.kt @@ -0,0 +1,42 @@ +package me.kavishdevar.librepods.repository + +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.flow.asStateFlow +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.database.widget.WidgetConfigDao +import me.kavishdevar.librepods.database.widget.WidgetConfigEntity + +class WidgetConfigRepository( + private val widgetConfigDao: WidgetConfigDao +) { + private val _widgetConfigs = MutableStateFlow>(emptyList()) + val widgetConfigs: StateFlow> = _widgetConfigs.asStateFlow() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + init { + scope.launch { + _widgetConfigs.value = widgetConfigDao.getAll() + } + } + + fun setWidgetConfig(widgetConfig: WidgetConfigEntity) { + val existingConfig = _widgetConfigs.value.find { it.appWidgetId == widgetConfig.appWidgetId } + if (existingConfig != null) { + val updatedConfigs = _widgetConfigs.value.map { + if (it.appWidgetId == widgetConfig.appWidgetId) widgetConfig else it + } + _widgetConfigs.value = updatedConfigs + } else { + _widgetConfigs.value += widgetConfig + } + + scope.launch { + widgetConfigDao.upsert(widgetConfig) + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/AirPodsQSService.kt similarity index 67% rename from android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/services/AirPodsQSService.kt index 8a708016..6d64516f 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/AirPodsQSService.kt @@ -21,27 +21,20 @@ package me.kavishdevar.librepods.services import android.annotation.SuppressLint -import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.SharedPreferences import android.graphics.drawable.Icon -import android.os.Build import android.service.quicksettings.Tile import android.service.quicksettings.TileService import android.util.Log -import androidx.annotation.RequiresApi -import me.kavishdevar.librepods.QuickSettingsDialogActivity import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager -import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager import me.kavishdevar.librepods.data.AirPodsNotifications -import me.kavishdevar.librepods.data.NoiseControlMode +import me.kavishdevar.librepods.devices.NoiseControlMode import kotlin.io.encoding.ExperimentalEncodingApi -@RequiresApi(Build.VERSION_CODES.Q) class AirPodsQSService : TileService() { private lateinit var sharedPreferences: SharedPreferences @@ -50,7 +43,7 @@ class AirPodsQSService : TileService() { private val ancStatusReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - if (intent.action == AirPodsNotifications.ANC_DATA) { + if (intent.action == AirPodsNotifications.ANC_DATA.action) { val newMode = intent.getIntExtra("data", NoiseControlMode.OFF.ordinal + 1) Log.d("AirPodsQSService", "Received ANC update: $newMode") currentAncMode = newMode @@ -62,14 +55,14 @@ class AirPodsQSService : TileService() { private val availabilityReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { - AirPodsNotifications.AIRPODS_CONNECTED -> { + AirPodsNotifications.AIRPODS_CONNECTED.action -> { Log.d("AirPodsQSService", "Received AIRPODS_CONNECTED") isAirPodsConnected = true - currentAncMode = - ServiceManager.getService()?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1) + currentAncMode = 3 +// currentAncMode = ServiceManager.getService()?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1) updateTile() } - AirPodsNotifications.AIRPODS_DISCONNECTED -> { + AirPodsNotifications.AIRPODS_DISCONNECTED.action -> { Log.d("AirPodsQSService", "Received AIRPODS_DISCONNECTED") isAirPodsConnected = false updateTile() @@ -98,28 +91,23 @@ class AirPodsQSService : TileService() { super.onStartListening() Log.d("AirPodsQSService", "onStartListening") - val service = ServiceManager.getService() - isAirPodsConnected = BluetoothConnectionManager.aacpSocket?.isConnected == true - currentAncMode = service?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1) +// val service = ServiceManager.getService() +// isAirPodsConnected = +// currentAncMode = service?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1) if (currentAncMode == NoiseControlMode.OFF.ordinal + 1 && !isOffModeEnabled()) { currentAncMode = NoiseControlMode.TRANSPARENCY.ordinal + 1 } - val ancIntentFilter = IntentFilter(AirPodsNotifications.ANC_DATA) + val ancIntentFilter = IntentFilter(AirPodsNotifications.ANC_DATA.action) val availabilityIntentFilter = IntentFilter().apply { - addAction(AirPodsNotifications.AIRPODS_CONNECTED) - addAction(AirPodsNotifications.AIRPODS_DISCONNECTED) + addAction(AirPodsNotifications.AIRPODS_CONNECTED.action) + addAction(AirPodsNotifications.AIRPODS_DISCONNECTED.action) } try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(ancStatusReceiver, ancIntentFilter, RECEIVER_EXPORTED) - registerReceiver(availabilityReceiver, availabilityIntentFilter, RECEIVER_EXPORTED) - } else { - registerReceiver(ancStatusReceiver, ancIntentFilter) - registerReceiver(availabilityReceiver, availabilityIntentFilter) - } + registerReceiver(ancStatusReceiver, ancIntentFilter, RECEIVER_EXPORTED) + registerReceiver(availabilityReceiver, availabilityIntentFilter, RECEIVER_EXPORTED) sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener) Log.d("AirPodsQSService", "Receivers registered") } catch (e: Exception) { @@ -152,53 +140,21 @@ class AirPodsQSService : TileService() { return } - val clickBehavior = "cycle" // sharedPreferences.getString("qs_click_behavior", "dialog") ?: "dialog" - - if (clickBehavior == "dialog") { - launchDialogActivity() - } else { - cycleAncMode() - } - } - - private fun launchDialogActivity() { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - val pendingIntent = PendingIntent.getActivity( - this, - 0, - Intent(this, QuickSettingsDialogActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - }, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - startActivityAndCollapse(pendingIntent) - } else { - val intent = Intent(this, QuickSettingsDialogActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - } - @Suppress("DEPRECATION") - @SuppressLint("StartActivityAndCollapseDeprecated") - startActivityAndCollapse(intent) - } - Log.d("AirPodsQSService", "Called startActivityAndCollapse for QuickSettingsDialogActivity") - } catch (e: Exception) { - Log.e("AirPodsQSService", "Error launching QuickSettingsDialogActivity: $e") - } + cycleAncMode() } private fun cycleAncMode() { - val service = ServiceManager.getService() - if (service == null) { - Log.d("AirPodsQSService", "Tile clicked (cycle mode) but service is null.") - return - } - val nextMode = getNextAncMode() - Log.d("AirPodsQSService", "Cycling ANC mode to: $nextMode") - service.aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, - nextMode - ) +// val service = ServiceManager.getService() +// if (service == null) { +// Log.d("AirPodsQSService", "Tile clicked (cycle mode) but service is null.") +// return +// } +// val nextMode = getNextAncMode() +// Log.d("AirPodsQSService", "Cycling ANC mode to: $nextMode") +// service.aacpManager.sendControlCommand( +// ControlCommandIdentifier.LISTENING_MODE.value, +// nextMode +// ) } private fun updateTile() { @@ -216,7 +172,7 @@ class AirPodsQSService : TileService() { tile.state = Tile.STATE_UNAVAILABLE tile.label = "AirPods" tile.subtitle = "Disconnected" - tile.icon = Icon.createWithResource(this, R.drawable.airpods) + tile.icon = Icon.createWithResource(this, R.drawable.ic_airpods) } try { @@ -264,11 +220,11 @@ class AirPodsQSService : TileService() { private fun getModeIcon(mode: Int): Int { return when (mode) { - NoiseControlMode.OFF.ordinal + 1 -> R.drawable.noise_cancellation - NoiseControlMode.TRANSPARENCY.ordinal + 1 -> R.drawable.transparency - NoiseControlMode.ADAPTIVE.ordinal + 1 -> R.drawable.adaptive - NoiseControlMode.NOISE_CANCELLATION.ordinal + 1 -> R.drawable.noise_cancellation - else -> R.drawable.airpods + NoiseControlMode.OFF.ordinal + 1 -> R.drawable.ic_noise_cancellation + NoiseControlMode.TRANSPARENCY.ordinal + 1 -> R.drawable.ic_transparency + NoiseControlMode.ADAPTIVE.ordinal + 1 -> R.drawable.ic_adaptive + NoiseControlMode.NOISE_CANCELLATION.ordinal + 1 -> R.drawable.ic_noise_cancellation + else -> R.drawable.ic_airpods } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/AirPodsService.kt new file mode 100644 index 00000000..bcaf77c9 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/AirPodsService.kt @@ -0,0 +1,3387 @@ +///* +// 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 . +//*/ +// +//@file:OptIn(ExperimentalEncodingApi::class) +// +package me.kavishdevar.librepods.services +// +////import me.kavishdevar.librepods.utils.CrossDevice +////import me.kavishdevar.librepods.utils.CrossDevicePackets +//import android.Manifest +//import android.annotation.SuppressLint +//import android.app.Notification +//import android.app.NotificationChannel +//import android.app.NotificationManager +//import android.app.PendingIntent +//import android.app.Service +//import android.appwidget.AppWidgetManager +//import android.bluetooth.BluetoothAdapter +//import android.bluetooth.BluetoothDevice +//import android.bluetooth.BluetoothHeadset +//import android.bluetooth.BluetoothManager +//import android.bluetooth.BluetoothProfile +//import android.bluetooth.BluetoothSocket +//import android.content.BroadcastReceiver +//import android.content.ComponentName +//import android.content.ContentResolver +//import android.content.Context +//import android.content.Intent +//import android.content.IntentFilter +//import android.content.SharedPreferences +//import android.content.pm.PackageManager +//import android.content.res.Resources +//import android.graphics.Color +//import android.media.AudioManager +//import android.net.Uri +//import android.os.BatteryManager +//import android.os.Binder +//import android.os.Build +//import android.os.Handler +//import android.os.IBinder +//import android.os.Looper +//import android.os.ParcelUuid +//import android.os.UserHandle +//import android.provider.Settings +//import android.telecom.TelecomManager +//import android.telephony.TelephonyCallback +//import android.telephony.TelephonyManager +//import android.util.Log +//import android.util.TypedValue +//import android.view.View +//import android.widget.RemoteViews +//import android.widget.Toast +//import androidx.annotation.RequiresApi +//import androidx.annotation.RequiresPermission +//import androidx.compose.material3.ExperimentalMaterial3Api +//import androidx.core.app.NotificationCompat +//import androidx.core.content.edit +//import kotlinx.coroutines.CoroutineScope +//import kotlinx.coroutines.Dispatchers +//import kotlinx.coroutines.ExperimentalCoroutinesApi +//import kotlinx.coroutines.delay +//import kotlinx.coroutines.flow.MutableStateFlow +//import kotlinx.coroutines.flow.StateFlow +//import kotlinx.coroutines.flow.asStateFlow +//import kotlinx.coroutines.flow.update +//import kotlinx.coroutines.launch +//import kotlinx.coroutines.runBlocking +//import kotlinx.coroutines.suspendCancellableCoroutine +//import kotlinx.coroutines.withTimeout +//import me.kavishdevar.librepods.BuildConfig +//import me.kavishdevar.librepods.presentation.activities.MainActivity +//import me.kavishdevar.librepods.R +//import me.kavishdevar.librepods.audio.EldDecoder +//import me.kavishdevar.librepods.audio.WavWriter +//import me.kavishdevar.librepods.bluetooth.att.ATTHandle +//import me.kavishdevar.librepods.bluetooth.att.ATTManager +//import me.kavishdevar.librepods.bluetooth.ble.BLEManager +//import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager +//import me.kavishdevar.librepods.bluetooth.aacp.AACPManager +//import me.kavishdevar.librepods.bluetooth.aacp.types.AudioSourceType +//import me.kavishdevar.librepods.bluetooth.aacp.types.CapabilityEntry +//import me.kavishdevar.librepods.bluetooth.aacp.types.ConnectedDevice +//import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier +//import me.kavishdevar.librepods.bluetooth.aacp.Information +//import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType +//import me.kavishdevar.librepods.bluetooth.aacp.types.StemPressBud +//import me.kavishdevar.librepods.bluetooth.aacp.types.StemPressType +//import me.kavishdevar.librepods.bluetooth.createBluetoothSocket +//import me.kavishdevar.librepods.data.AirPodsInstance +//import me.kavishdevar.librepods.devices.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.CustomEq +//import me.kavishdevar.librepods.data.StemAction +//import me.kavishdevar.librepods.data.xposed.XposedRemotePrefProvider +//import me.kavishdevar.librepods.data.audio.MicrophoneFrame +//import me.kavishdevar.librepods.data.audio.MicrophoneState +//import me.kavishdevar.librepods.data.isHeadTrackingData +//import me.kavishdevar.librepods.data.recording.Recording +//import me.kavishdevar.librepods.repository.RecordingRepository +//import me.kavishdevar.librepods.presentation.overlays.IslandType +//import me.kavishdevar.librepods.presentation.overlays.IslandWindow +//import me.kavishdevar.librepods.presentation.overlays.PopupWindow +//import me.kavishdevar.librepods.presentation.widgets.BatteryWidget +//import me.kavishdevar.librepods.presentation.widgets.NoiseControlWidget +//import me.kavishdevar.librepods.utils.GestureDetector +//import me.kavishdevar.librepods.utils.HeadTracking +//import me.kavishdevar.librepods.utils.MediaController +//import me.kavishdevar.librepods.utils.SystemApisUtils +//import me.kavishdevar.librepods.utils.SystemApisUtils.DEVICE_TYPE_UNTETHERED_HEADSET +//import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_COMPANION_APP +//import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_DEVICE_TYPE +//import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_MAIN_ICON +//import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_MANUFACTURER_NAME +//import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_MODEL_NAME +//import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_CASE_BATTERY +//import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_CASE_CHARGING +//import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_CASE_ICON +//import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_CASE_LOW_BATTERY_THRESHOLD +//import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_LEFT_BATTERY +//import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_LEFT_CHARGING +//import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_LEFT_ICON +//import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_LEFT_LOW_BATTERY_THRESHOLD +//import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_BATTERY +//import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_CHARGING +//import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_ICON +//import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD +//import me.kavishdevar.librepods.utils.calculateLevel +//import java.nio.ByteBuffer +//import java.nio.ByteOrder +//import kotlin.io.encoding.Base64 +//import kotlin.io.encoding.ExperimentalEncodingApi +//import kotlin.time.Duration.Companion.milliseconds +// +//private const val TAG = "AirPodsService" +// +//object ServiceManager { +// private var service: AirPodsService? = null +// +// @Synchronized +// fun getService(): AirPodsService? { +// return service +// } +// +// @Synchronized +// fun setService(service: AirPodsService?) { +// this.service = service +// } +//} +// +//// @Suppress("unused") +//class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeListener { +// var macAddress = "" +// var localMac = "" +// lateinit var aacpManager: AACPManager +// lateinit var attManager: ATTManager +// var airpodsInstance: AirPodsInstance? = null +// var cameraActive = false +// private var disconnectedBecauseReversed = false +// private var otherDeviceTookOver = false +// +// private lateinit var recordingRepository: RecordingRepository +// private var decoder: EldDecoder? = null +// private var wavWriter: WavWriter? = null +// private var currentRecording: Recording? = null +// +// private val _microphoneState = MutableStateFlow(MicrophoneState()) +// val microphoneState: StateFlow = _microphoneState.asStateFlow() +// +// data class ServiceConfig( +// var deviceName: String = "AirPods", +// var earDetectionEnabled: Boolean = true, +// var conversationalAwarenessPauseMusic: Boolean = false, +// var showPhoneBatteryInWidget: Boolean = true, +// var relativeConversationalAwarenessVolume: Boolean = true, +// var headGestures: Boolean = true, +// var disconnectWhenNotWearing: Boolean = false, +// var conversationalAwarenessVolume: Int = 43, +// var qsClickBehavior: String = "cycle", +// var bleOnlyMode: Boolean = false, +// +// // AirPods state-based takeover +// var takeoverWhenDisconnected: Boolean = true, +// var takeoverWhenIdle: Boolean = true, +// var takeoverWhenMusic: Boolean = false, +// var takeoverWhenCall: Boolean = true, +// +// // Phone state-based takeover +// var takeoverWhenRingingCall: Boolean = true, +// var takeoverWhenMediaStart: Boolean = true, +// +// var leftSinglePressAction: StemAction = StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!, +// var rightSinglePressAction: StemAction = StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!, +// +// var leftDoublePressAction: StemAction = StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!, +// var rightDoublePressAction: StemAction = StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!, +// +// var leftTriplePressAction: StemAction = StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!, +// var rightTriplePressAction: StemAction = StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!, +// +// var leftLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!, +// var rightLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!, +// +// var cameraAction: StemPressType? = null, +// +// // AirPods device information +// var airpodsName: String = "", +// var airpodsModelNumber: String = "", +// var airpodsManufacturer: String = "", +// var airpodsSerialNumber: String = "", +// var airpodsLeftSerialNumber: String = "", +// var airpodsRightSerialNumber: String = "", +// var airpodsVersion1: String = "", +// var airpodsVersion2: String = "", +// var airpodsVersion3: String = "", +// var airpodsHardwareRevision: String = "", +// var airpodsUpdaterIdentifier: String = "", +// +// // phone's mac, needed for tipi +// var selfMacAddress: String = "" +// ) +// +// private lateinit var config: ServiceConfig +// +// inner class LocalBinder : Binder() { +// fun getService(): AirPodsService = this@AirPodsService +// } +// +// private lateinit var sharedPreferencesLogs: SharedPreferences +// private lateinit var sharedPreferences: SharedPreferences +// private val packetLogKey = "packet_log" +// private val _packetLogsFlow = MutableStateFlow>(emptySet()) +// val packetLogsFlow: StateFlow> get() = _packetLogsFlow +// +// private lateinit var telephonyManager: TelephonyManager +// private lateinit var phoneStateListener: TelephonyCallback +// private val maxLogEntries = 1000 +// private val inMemoryLogs = mutableSetOf() +// +// private var handleIncomingCallOnceConnected = false +// +// lateinit var bleManager: BLEManager +// +// companion object { +// init { +// System.loadLibrary("hiddenapi") +// } +// } +// +// private val bleStatusListener = object : BLEManager.AirPodsStatusListener { +// @SuppressLint("NewApi") +// override fun onDeviceStatusChanged( +// device: BLEManager.AirPodsStatus, previousStatus: BLEManager.AirPodsStatus? +// ) { +// if (device.connectionState == "Disconnected" && BluetoothConnectionManager.aacpSocket?.isConnected != true) { // should never happen unless android messes up and sends us a stale broadcast +// Log.d(TAG, "Seems no device has taken over, we will.") +// val bluetoothManager = getSystemService(BluetoothManager::class.java) +// val bluetoothAdapter = bluetoothManager.adapter +// val bluetoothDevice = bluetoothAdapter.getRemoteDevice( +// sharedPreferences.getString( +// "mac_address", "" +// ) ?: "" +// ) +// connectToSocket(bluetoothAdapter, bluetoothDevice) +// } +// Log.d(TAG, "Device status changed") +// if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return +// val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0 +// val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0 +// val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0 +// val leftCharging = bleManager.getMostRecentStatus()?.isLeftCharging +// val rightCharging = bleManager.getMostRecentStatus()?.isRightCharging +// val caseCharging = bleManager.getMostRecentStatus()?.isCaseCharging +// +// batteryNotification.setBatteryDirect( +// leftLevel = leftLevel, +// leftCharging = leftCharging == true, +// rightLevel = rightLevel, +// rightCharging = rightCharging == true, +// caseLevel = caseLevel, +// caseCharging = caseCharging == true +// ) +// updateBattery() +// } +// +// override fun onBroadcastFromNewAddress(device: BLEManager.AirPodsStatus) { +// Log.d(TAG, "New address detected") +// } +// +// override fun onLidStateChanged( +// lidOpen: Boolean, +// ) { +// if (lidOpen) { +// Log.d(TAG, "Lid opened") +// showPopup( +// this@AirPodsService, +// getSharedPreferences("settings", MODE_PRIVATE).getString("name", "AirPods Pro") +// ?: "AirPods" +// ) +// if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return +// val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0 +// val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0 +// val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0 +// val leftCharging = bleManager.getMostRecentStatus()?.isLeftCharging +// val rightCharging = bleManager.getMostRecentStatus()?.isRightCharging +// val caseCharging = bleManager.getMostRecentStatus()?.isCaseCharging +// +// batteryNotification.setBatteryDirect( +// leftLevel = leftLevel, +// leftCharging = leftCharging == true, +// rightLevel = rightLevel, +// rightCharging = rightCharging == true, +// caseLevel = caseLevel, +// caseCharging = caseCharging == true +// ) +// sendBatteryBroadcast() +// } else { +// Log.d(TAG, "Lid closed") +// } +// } +// +// override fun onEarStateChanged( +// device: BLEManager.AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean +// ) { +// Log.d(TAG, "Ear state changed - Left: $leftInEar, Right: $rightInEar") +// +// // In BLE-only mode, ear detection is purely based on BLE data +// if (config.bleOnlyMode) { +// Log.d(TAG, "BLE-only mode: ear detection from BLE data") +// } +// } +// +// override fun onBatteryChanged(device: BLEManager.AirPodsStatus) { +// if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return +// val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0 +// val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0 +// val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0 +// val leftCharging = bleManager.getMostRecentStatus()?.isLeftCharging +// val rightCharging = bleManager.getMostRecentStatus()?.isRightCharging +// val caseCharging = bleManager.getMostRecentStatus()?.isCaseCharging +// +// batteryNotification.setBatteryDirect( +// leftLevel = leftLevel, +// leftCharging = leftCharging == true, +// rightLevel = rightLevel, +// rightCharging = rightCharging == true, +// caseLevel = caseLevel, +// caseCharging = caseCharging == true +// ) +// updateBattery() +// Log.d(TAG, "Battery changed") +// } +// +// override fun onDeviceDisappeared() { +// Log.d(TAG, "All disappeared") +// updateNotificationContent( +// false +// ) +// } +// } +// +// fun isBluetoothSocketExempted(): Boolean { +// return try { +// BluetoothSocket::class.java.declaredConstructors // will throw if still blocked +// true +// } catch (e: Exception) { +// e.printStackTrace() +// false +// } +// } +// +// +// @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag", "HardwareIds") +// override fun onCreate() { +// super.onCreate() +// Log.i(TAG, "lib exempt worked: ${isBluetoothSocketExempted()}") +// +// sharedPreferencesLogs = getSharedPreferences("packet_logs", MODE_PRIVATE) +// +// inMemoryLogs.addAll( +// sharedPreferencesLogs.getStringSet(packetLogKey, emptySet()) ?: emptySet() +// ) +// _packetLogsFlow.value = inMemoryLogs.toSet() +// +// sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) +// initializeConfig() +// +// aacpManager = AACPManager() +// initializeAACPManagerCallback() +// +// attManager = ATTManager() +// +// recordingRepository = RecordingRepository(this) +// +// sharedPreferences.registerOnSharedPreferenceChangeListener(this) +// +// localMac = config.selfMacAddress +// if (localMac.isEmpty()) { +// if (checkSelfPermission("android.permission.LOCAL_MAC_ADDRESS") == PackageManager.PERMISSION_GRANTED) { +// val bluetoothManager = getSystemService(BluetoothManager::class.java) +// val bluetoothAdapter = bluetoothManager.adapter +// localMac = bluetoothAdapter.address +// } else { +// localMac = try { +// val process = Runtime.getRuntime().exec( +// arrayOf("su", "-c", "settings get secure bluetooth_address") +// ) +// +// val exitCode = process.waitFor() +// +// if (exitCode == 0) { +// process.inputStream.bufferedReader().use { it.readLine()?.trim().orEmpty() } +// } else { +// "" +// } +// } catch (e: Exception) { +// Log.e( +// TAG, +// "Error retrieving local MAC address: ${e.message}. We probably aren't rooted." +// ) +// "" +// } +// } +// config.selfMacAddress = localMac +// sharedPreferences.edit { +// putString("self_mac_address", localMac) +// } +// } +// +// ServiceManager.setService(this) +// startForegroundNotification() +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { +// initGestureDetector() +// } else { +// gestureDetector = null +// config.headGestures = false +// sharedPreferences.edit { putBoolean("head_gestures", false) } +// Log.d(TAG, "Head gestures disabled as device is running Android 9 or below") +// } +// +// bleManager = BLEManager(this) +// bleManager.setAirPodsStatusListener(bleStatusListener) +// +// sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) +// +// with(sharedPreferences) { +// edit { +// if (!contains("conversational_awareness_pause_music")) putBoolean( +// "conversational_awareness_pause_music", false +// ) +// if (!contains("personalized_volume")) putBoolean("personalized_volume", false) +// if (!contains("automatic_ear_detection")) putBoolean( +// "automatic_ear_detection", true +// ) +// if (!contains("long_press_nc")) putBoolean("long_press_nc", true) +// if (!contains("show_phone_battery_in_widget")) putBoolean( +// "show_phone_battery_in_widget", true +// ) +// if (!contains("single_anc")) putBoolean("single_anc", true) +// if (!contains("long_press_transparency")) putBoolean( +// "long_press_transparency", true +// ) +// if (!contains("conversational_awareness")) putBoolean( +// "conversational_awareness", true +// ) +// if (!contains("relative_conversational_awareness_volume")) putBoolean( +// "relative_conversational_awareness_volume", true +// ) +// if (!contains("long_press_adaptive")) putBoolean("long_press_adaptive", true) +// if (!contains("loud_sound_reduction")) putBoolean("loud_sound_reduction", true) +// if (!contains("long_press_off")) putBoolean("long_press_off", false) +// if (!contains("volume_control")) putBoolean("volume_control", true) +// if (!contains("head_gestures")) putBoolean("head_gestures", true) +// if (!contains("disconnect_when_not_wearing")) putBoolean( +// "disconnect_when_not_wearing", false +// ) +// +// // AirPods state-based takeover +// if (!contains("takeover_when_disconnected")) putBoolean( +// "takeover_when_disconnected", false +// ) +// if (!contains("takeover_when_idle")) putBoolean("takeover_when_idle", false) +// if (!contains("takeover_when_music")) putBoolean("takeover_when_music", false) +// if (!contains("takeover_when_call")) putBoolean("takeover_when_call", false) +// +// // Phone state-based takeover +// if (!contains("takeover_when_ringing_call")) putBoolean( +// "takeover_when_ringing_call", false +// ) +// if (!contains("takeover_when_media_start")) putBoolean( +// "takeover_when_media_start", false +// ) +// +// if (!contains("adaptive_strength")) putInt("adaptive_strength", 51) +// if (!contains("tone_volume")) putInt("tone_volume", 75) +// if (!contains("conversational_awareness_volume")) putInt( +// "conversational_awareness_volume", 43 +// ) +// +// if (!contains("qs_click_behavior")) putString("qs_click_behavior", "cycle") +// if (!contains("name")) putString("name", "AirPods") +// +// if (!contains("left_single_press_action")) putString( +// "left_single_press_action", +// StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!.name +// ) +// if (!contains("right_single_press_action")) putString( +// "right_single_press_action", +// StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!.name +// ) +// if (!contains("left_double_press_action")) putString( +// "left_double_press_action", +// StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!.name +// ) +// if (!contains("right_double_press_action")) putString( +// "right_double_press_action", +// StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!.name +// ) +// if (!contains("left_triple_press_action")) putString( +// "left_triple_press_action", +// StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!.name +// ) +// if (!contains("right_triple_press_action")) putString( +// "right_triple_press_action", +// StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!.name +// ) +// if (!contains("left_long_press_action")) putString( +// "left_long_press_action", +// StemAction.defaultActions[StemPressType.LONG_PRESS]!!.name +// ) +// if (!contains("right_long_press_action")) putString( +// "right_long_press_action", +// StemAction.defaultActions[StemPressType.LONG_PRESS]!!.name +// ) +// if (!contains("camera_action")) putString("camera_action", "SINGLE_PRESS") +// +// } +// } +// +// initializeConfig() +// +// externalBroadcastReceiver = object : BroadcastReceiver() { +// override fun onReceive(context: Context?, intent: Intent?) { +// if (intent?.action == "me.kavishdevar.librepods.SET_ANC_MODE") { +// if (intent.hasExtra("mode")) { +// val mode = intent.getIntExtra("mode", -1) +// if (mode in 1..4) { +// aacpManager.sendControlCommand( +// ControlCommandIdentifier.LISTENING_MODE.value, +// mode +// ) +// } +// } else { +// val currentMode = ancNotification.status +// val configByte = sharedPreferences.getInt("long_press_byte", 0b0111) +// val allowOffModeValue = +// aacpManager.controlCommandStatusList.find { it.identifier == ControlCommandIdentifier.ALLOW_OFF_OPTION } +// val allowOffMode = +// allowOffModeValue?.value?.takeIf { it.isNotEmpty() }?.get(0) == 0x01.toByte() || sharedPreferences.getBoolean("off_listening_mode", true) +// val nextMode = getNextMode(currentMode = currentMode, configByte = configByte, allowOffMode) +// +// aacpManager.sendControlCommand( +// ControlCommandIdentifier.LISTENING_MODE.value, +// nextMode +// ) +// Log.d( +// TAG, +// "Cycling ANC mode from $currentMode to $nextMode" +// ) +// } +// } else if (intent?.action == "me.kavishdevar.librepods.CONVO_DETECT") { +// if (intent.hasExtra("enabled")) { +// val enabled = intent.getBooleanExtra("enabled", false) +// aacpManager.sendControlCommand( +// ControlCommandIdentifier.CONVERSATION_DETECT_CONFIG.value, +// enabled +// ) +// } +// } +// } +// } +// +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { +// registerReceiver(externalBroadcastReceiver, externalBroadcastFilter, RECEIVER_EXPORTED) +// } else { +// @Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver( +// externalBroadcastReceiver, externalBroadcastFilter +// ) +// } +// val audioManager = this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager +// MediaController.initialize( +// audioManager, this@AirPodsService.getSharedPreferences( +// "settings", MODE_PRIVATE +// ) +// ) +//// Log.d(TAG, "Initializing CrossDevice") +//// CoroutineScope(Dispatchers.IO).launch { +//// CrossDevice.init(this@AirPodsService) +//// Log.d(TAG, "CrossDevice initialized") +//// } +// +// sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) +// macAddress = sharedPreferences.getString("mac_address", "") ?: "" +// +// telephonyManager = getSystemService(TELEPHONY_SERVICE) as TelephonyManager +// phoneStateListener = object: TelephonyCallback(), TelephonyCallback.CallStateListener { +// override fun onCallStateChanged(state: Int) { +// when (state) { +// TelephonyManager.CALL_STATE_RINGING -> { +// val leAvailableForAudio = +// bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true +//// if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope(Dispatchers.IO).launch { +// if (leAvailableForAudio) runBlocking { +// takeOver("call") +// } +// if (config.headGestures) { +// handleIncomingCall() +// } +// } +// +// TelephonyManager.CALL_STATE_OFFHOOK -> { +// val leAvailableForAudio = +// bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true +//// if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope( +// if (leAvailableForAudio) CoroutineScope( +// Dispatchers.IO +// ).launch { +// takeOver("call") +// } +// isInCall = true +// } +// +// TelephonyManager.CALL_STATE_IDLE -> { +// isInCall = false +// gestureDetector?.stopDetection() +// } +// } +// } +// } +// if (checkSelfPermission("android.permission.READ_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { +// telephonyManager.registerTelephonyCallback(mainExecutor, phoneStateListener) +// } +// +// if (config.showPhoneBatteryInWidget) { +// widgetMobileBatteryEnabled = true +// val batteryChangedIntentFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) +// batteryChangedIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS) +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { +// registerReceiver( +// BatteryChangedIntentReceiver, batteryChangedIntentFilter, RECEIVER_EXPORTED +// ) +// } else { +// @Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver( +// BatteryChangedIntentReceiver, batteryChangedIntentFilter +// ) +// } +// } +// val serviceIntentFilter = IntentFilter().apply { +// addAction("android.bluetooth.device.action.ACL_CONNECTED") +// addAction("android.bluetooth.device.action.ACL_DISCONNECTED") +// addAction("android.bluetooth.device.action.BOND_STATE_CHANGED") +// addAction("android.bluetooth.device.action.NAME_CHANGED") +// addAction("android.bluetooth.adapter.action.CONNECTION_STATE_CHANGED") +// addAction("android.bluetooth.adapter.action.STATE_CHANGED") +// addAction("android.bluetooth.headset.profile.action.CONNECTION_STATE_CHANGED") +// addAction("android.bluetooth.headset.action.VENDOR_SPECIFIC_HEADSET_EVENT") +// addAction("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") +// addAction("android.bluetooth.a2dp.profile.action.PLAYING_STATE_CHANGED") +// addAction("android.bluetooth.device.action.UUID") +// } +// +// connectionReceiver = object : BroadcastReceiver() { +// override fun onReceive(context: Context?, intent: Intent?) { +// if (intent?.action == AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) { +// device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { +// intent.getParcelableExtra("device", BluetoothDevice::class.java)!! +// } else { +// intent.getParcelableExtra("device") as BluetoothDevice? +// } +// +// if (config.deviceName == "AirPods" && device?.name != null) { +// config.deviceName = device?.name ?: "AirPods" +// sharedPreferences.edit { putString("name", config.deviceName) } +// } +// +//// Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString()) +//// if (!CrossDevice.isAvailable) { +// Log.d(TAG, "${config.deviceName} connected") +// CoroutineScope(Dispatchers.IO).launch { +// val bluetoothManager = getSystemService(BluetoothManager::class.java) +// connectToSocket(bluetoothManager.adapter, device!!) +// } +// Log.d(TAG, "Setting metadata") +// setMetadatas(device!!) +//// isConnectedLocally = true +// macAddress = device!!.address +// sharedPreferences.edit { +// putString("mac_address", macAddress) +// } +//// } +// +// } else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) { +// device = null +//// isConnectedLocally = false +// popupShown = false +// updateNotificationContent(false) +// aacpManager.disconnected() +// BluetoothConnectionManager.aacpSocket = null +// BluetoothConnectionManager.attSocket = null +// } +// } +// } +// val showIslandReceiver = object : BroadcastReceiver() { +// override fun onReceive(context: Context?, intent: Intent?) { +// if (intent?.action == "me.kavishdevar.librepods.cross_device_island") { +// showIsland( +// this@AirPodsService, +// batteryNotification.getBattery() +// .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost( +// batteryNotification.getBattery() +// .find { it.component == BatteryComponent.RIGHT }?.level!! +// ) +// ) +// } else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) { +// try { +// context?.unregisterReceiver(this) +// } catch (e: Exception) { +// e.printStackTrace() +// } +// } +// } +// } +// +// val showIslandIntentFilter = IntentFilter().apply { +// addAction("me.kavishdevar.librepods.cross_device_island") +// addAction(AirPodsNotifications.DISCONNECT_RECEIVERS) +// } +// +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { +// registerReceiver(showIslandReceiver, showIslandIntentFilter, RECEIVER_EXPORTED) +// } else { +// @Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver( +// showIslandReceiver, showIslandIntentFilter +// ) +// } +// +// val deviceIntentFilter = IntentFilter().apply { +// addAction(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) +// addAction(AirPodsNotifications.AIRPODS_DISCONNECTED) +// } +// +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { +// registerReceiver(connectionReceiver, deviceIntentFilter, RECEIVER_EXPORTED) +// registerReceiver(bluetoothReceiver, serviceIntentFilter, RECEIVER_EXPORTED) +// } else { +// @Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver( +// connectionReceiver, deviceIntentFilter +// ) +// registerReceiver(bluetoothReceiver, serviceIntentFilter) +// } +// +// val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter +// +// bluetoothAdapter.bondedDevices.forEach { device -> +// device.fetchUuidsWithSdp() +// if (device.uuids != null) { +// if (device.uuids.contains(ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a"))) { +// bluetoothAdapter.getProfileProxy( +// this, object : BluetoothProfile.ServiceListener { +// @SuppressLint("NewApi") +// override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { +// if (profile == BluetoothProfile.A2DP) { +// val connectedDevices = proxy.connectedDevices +// if (connectedDevices.isNotEmpty()) { +//// if (!CrossDevice.isAvailable) { +// CoroutineScope(Dispatchers.IO).launch { +// connectToSocket(bluetoothAdapter, device) +// } +// setMetadatas(device) +// macAddress = device.address +// sharedPreferences.edit { +// putString("mac_address", macAddress) +// } +//// } +// sendBroadcast( +// Intent(AirPodsNotifications.AIRPODS_CONNECTED).apply { +// setPackage(packageName) +// }) +// } +// } +// bluetoothAdapter.closeProfileProxy(profile, proxy) +// } +// +// override fun onServiceDisconnected(profile: Int) {} +// }, BluetoothProfile.A2DP +// ) +// } +// } +// } +// +//// if (!isConnectedLocally && !CrossDevice.isAvailable) { +//// clearPacketLogs() +//// } +// +// CoroutineScope(Dispatchers.IO).launch { +// bleManager.startScanning() +// } +// } +// +// @Suppress("unused") +// fun cameraOpened() { +// Log.d(TAG, "Camera opened, gonna handle stem presses and take action if visible") +// cameraActive = true +// setupStemActions() +// } +// +// @Suppress("unused") +// fun cameraClosed() { +// cameraActive = false +// setupStemActions() +// } +// +// fun isCustomAction( +// action: StemAction?, default: StemAction? +// ): Boolean { +// return action != default +// } +// +// fun setupStemActions() { +// val singlePressDefault = StemAction.defaultActions[StemPressType.SINGLE_PRESS] +// val doublePressDefault = StemAction.defaultActions[StemPressType.DOUBLE_PRESS] +// val triplePressDefault = StemAction.defaultActions[StemPressType.TRIPLE_PRESS] +// val longPressDefault = StemAction.defaultActions[StemPressType.LONG_PRESS] +// +// val singlePressCustomized = +// isCustomAction(config.leftSinglePressAction, singlePressDefault) || isCustomAction( +// config.rightSinglePressAction, singlePressDefault +// ) || (cameraActive && config.cameraAction == StemPressType.SINGLE_PRESS) +// val doublePressCustomized = +// isCustomAction(config.leftDoublePressAction, doublePressDefault) || isCustomAction( +// config.rightDoublePressAction, doublePressDefault +// ) +// val triplePressCustomized = +// isCustomAction(config.leftTriplePressAction, triplePressDefault) || isCustomAction( +// config.rightTriplePressAction, triplePressDefault +// ) +// val longPressCustomized = isCustomAction( +// config.leftLongPressAction, longPressDefault +// ) || isCustomAction( +// config.rightLongPressAction, longPressDefault +// ) || (cameraActive && config.cameraAction == StemPressType.LONG_PRESS) +// Log.d( +// TAG, +// "Setting up stem actions: Single Press Customized: $singlePressCustomized, Double Press Customized: $doublePressCustomized, Triple Press Customized: $triplePressCustomized, Long Press Customized: $longPressCustomized" +// ) +// aacpManager.sendStemConfigPacket( +// singlePressCustomized, +// doublePressCustomized, +// triplePressCustomized, +// longPressCustomized, +// ) +// } +// +// @ExperimentalEncodingApi +// private fun initializeAACPManagerCallback() { +// aacpManager.setPacketCallback(object : AACPManager.PacketCallback { +// @SuppressLint("MissingPermission") +// override fun onBatteryInfoReceived(batteryInfo: ByteArray) { +// batteryNotification.setBattery(batteryInfo) +// sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply { +// putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery())) +// setPackage(packageName) +// }) +// updateBattery() +// updateNotificationContent( +// true, +// this@AirPodsService.getSharedPreferences("settings", MODE_PRIVATE) +// .getString("name", device?.name), +// batteryNotification.getBattery() +// ) +//// CrossDevice.sendRemotePacket(batteryInfo) +//// CrossDevice.batteryBytes = batteryInfo +// +// for (battery in batteryNotification.getBattery()) { +// Log.d( +// "AirPodsParser", +// "${battery.getComponentName()}: ${battery.getStatusName()} at ${battery.level}% " +// ) +// } +// +// if (batteryNotification.getBattery()[0].status == BatteryStatus.CHARGING && batteryNotification.getBattery()[1].status == BatteryStatus.CHARGING) { +// disconnectAudio() +// } else { +// connectAudio() +// } +// } +// +// override fun onEarDetectionReceived(earDetection: ByteArray) { +// sendBroadcast(Intent(AirPodsNotifications.EAR_DETECTION_DATA).apply { +// val list = earDetectionNotification.status +// val bytes = ByteArray(2) +// bytes[0] = list[0] +// bytes[1] = list[1] +// putExtra("data", bytes) +// }.apply { +// setPackage(packageName) +// }) +// Log.d( +// "AirPodsParser", +// "Ear Detection: ${earDetectionNotification.status[0]} ${earDetectionNotification.status[1]}" +// ) +// processEarDetectionChange(earDetection) +// } +// +// override fun onConversationAwarenessReceived(conversationAwareness: ByteArray) { +// conversationAwarenessNotification.setData(conversationAwareness) +// sendBroadcast(Intent(AirPodsNotifications.CA_DATA).apply { +// putExtra("data", conversationAwarenessNotification.status) +// }.apply { +// setPackage(packageName) +// }) +// +// if (conversationAwarenessNotification.status == 1.toByte() || conversationAwarenessNotification.status == 2.toByte()) { +// MediaController.startSpeaking() +// } else if (conversationAwarenessNotification.status == 6.toByte() ||conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) { +// MediaController.stopSpeaking() +// } +// +// Log.d( +// "AirPodsParser", +// "Conversation Awareness: ${conversationAwarenessNotification.status}" +// ) +// } +// +// override fun onControlCommandReceived(controlCommand: ByteArray) { +// val command = AACPManager.ControlCommand.fromByteArray(controlCommand) +// if (command.identifier == ControlCommandIdentifier.LISTENING_MODE.value) { +// ancNotification.setStatus(byteArrayOf(command.value.takeIf { it.isNotEmpty() } +// ?.get(0) ?: 0x00.toByte())) +// sendANCBroadcast() +// updateNoiseControlWidget() +// } +// } +// +// override fun onOwnershipChangeReceived(owns: Boolean) { +// if (!owns) { +// MediaController.recentlyLostOwnership = true +// Handler(Looper.getMainLooper()).postDelayed({ +// MediaController.recentlyLostOwnership = false +// }, 3000) +// Log.d(TAG, "ownership lost") +// MediaController.sendPause() +// MediaController.pausedForOtherDevice = true +// otherDeviceTookOver = true +// disconnectAudio() +// } +// } +// +// override fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean) { +// // TODO: Show a reverse button, but that's a lot of effort -- i'd have to change the UI too, which i hate doing, and handle other device's reverses too, and disconnect audio etc... so for now, just pause the audio and show the island without asking to reverse. +// // handling reverse is a problem because we'd have to disconnect the audio, but there's no option connect audio again natively, so notification would have to be changed. I wish there was a way to just "change the audio output device". +// // (20 minutes later) i've done it nonetheless :] +// val senderName = +// aacpManager.connectedDevices.find { it.mac == sender }?.type ?: "Other device" +// Log.d( +// TAG, +// "other device has hijacked the connection, reasonReverseTapped: $reasonReverseTapped" +// ) +// aacpManager.sendControlCommand( +// ControlCommandIdentifier.OWNS_CONNECTION.value, +// byteArrayOf(0x00) +// ) +// otherDeviceTookOver = true +// disconnectAudio() +// if (reasonReverseTapped) { +// Log.d(TAG, "reverse tapped, disconnecting audio") +// disconnectedBecauseReversed = true +// disconnectAudio() +// showIsland( +// this@AirPodsService, +// (batteryNotification.getBattery() +// .find { it.component == BatteryComponent.LEFT }?.level +// ?: 0).coerceAtMost( +// batteryNotification.getBattery() +// .find { it.component == BatteryComponent.RIGHT }?.level ?: 0 +// ), +// IslandType.MOVED_TO_OTHER_DEVICE, +// reversed = true, +// otherDeviceName = senderName +// ) +// } +// if (!aacpManager.owns) { +// showIsland( +// this@AirPodsService, +// (batteryNotification.getBattery() +// .find { it.component == BatteryComponent.LEFT }?.level +// ?: 0).coerceAtMost( +// batteryNotification.getBattery() +// .find { it.component == BatteryComponent.RIGHT }?.level ?: 0 +// ), +// IslandType.MOVED_TO_OTHER_DEVICE, +// reversed = reasonReverseTapped, +// otherDeviceName = senderName +// ) +// } +// MediaController.sendPause() +// } +// +// override fun onShowNearbyUI(sender: String) { +// val senderName = +// aacpManager.connectedDevices.find { it.mac == sender }?.type ?: "Other device" +// showIsland( +// this@AirPodsService, +// (batteryNotification.getBattery() +// .find { it.component == BatteryComponent.LEFT }?.level ?: 0).coerceAtMost( +// batteryNotification.getBattery() +// .find { it.component == BatteryComponent.RIGHT }?.level ?: 0 +// ), +// IslandType.MOVED_TO_OTHER_DEVICE, +// reversed = false, +// otherDeviceName = senderName +// ) +// } +// +// override fun onDeviceInformationReceived(deviceInformation: Information) { +// Log.d( +// "AirPodsParser", +// "Device Information: name: ${deviceInformation.name}, modelNumber: ${deviceInformation.modelNumber}, manufacturer: ${deviceInformation.manufacturer}, serialNumber: ${deviceInformation.serialNumber}, version1: ${deviceInformation.version1}, version2: ${deviceInformation.version2}, hardwareRevision: ${deviceInformation.hardwareRevision}, updaterIdentifier: ${deviceInformation.updaterIdentifier}, leftSerialNumber: ${deviceInformation.leftSerialNumber}, rightSerialNumber: ${deviceInformation.rightSerialNumber}, version3: ${deviceInformation.version3}" +// ) +// // Store in SharedPreferences +// sharedPreferences.edit { +// putString("name", deviceInformation.name) +// putString("airpods_model_number", deviceInformation.modelNumber) +// putString("airpods_manufacturer", deviceInformation.manufacturer) +// putString("airpods_serial_number", deviceInformation.serialNumber) +// putString("airpods_left_serial_number", deviceInformation.leftSerialNumber) +// putString("airpods_right_serial_number", deviceInformation.rightSerialNumber) +// putString("airpods_version1", deviceInformation.version1) +// putString("airpods_version2", deviceInformation.version2) +// putString("airpods_version3", deviceInformation.version3) +// putString("airpods_hardware_revision", deviceInformation.hardwareRevision) +// putString("airpods_updater_identifier", deviceInformation.updaterIdentifier) +// } +// // Update config +// config.airpodsName = deviceInformation.name +// config.airpodsModelNumber = deviceInformation.modelNumber +// config.airpodsManufacturer = deviceInformation.manufacturer +// config.airpodsSerialNumber = deviceInformation.serialNumber +// config.airpodsLeftSerialNumber = deviceInformation.leftSerialNumber +// config.airpodsRightSerialNumber = deviceInformation.rightSerialNumber +// config.airpodsVersion1 = deviceInformation.version1 +// config.airpodsVersion2 = deviceInformation.version2 +// config.airpodsVersion3 = deviceInformation.version3 +// config.airpodsHardwareRevision = deviceInformation.hardwareRevision +// config.airpodsUpdaterIdentifier = deviceInformation.updaterIdentifier +// +// val model = AirPodsModels.getModelByModelNumber(config.airpodsModelNumber) +// if (model != null) { +// airpodsInstance = AirPodsInstance( +// name = config.airpodsName, +// model = model, +// actualModelNumber = config.airpodsModelNumber, +// serialNumber = config.airpodsSerialNumber, +// leftSerialNumber = config.airpodsLeftSerialNumber, +// rightSerialNumber = config.airpodsRightSerialNumber, +// version1 = config.airpodsVersion1, +// version2 = config.airpodsVersion2, +// version3 = config.airpodsVersion3, +// ) +// if (device != null) setMetadatas(device!!) +// } +// sendBroadcast( +// Intent(AirPodsNotifications.AIRPODS_INFORMATION_UPDATED).setPackage( +// packageName +// ) +// ) +// } +// +// @SuppressLint("NewApi") +// override fun onHeadTrackingReceived(headTracking: ByteArray) { +// if (isHeadTrackingActive) { +// HeadTracking.processPacket(headTracking) +// processHeadTrackingData(headTracking) +// } +// } +// +// override fun onMagicKeysReceived(proximityKeys: ByteArray) { +// val keys = aacpManager.parseMagicKeysResponse(proximityKeys) +// Log.d("AirPodsParser", "Proximity keys: $keys") +// sharedPreferences.edit { +// for (key in keys) { +// Log.d("AirPodsParser", "Proximity key: ${key.key.name} = ${key.value}") +// putString(key.key.name, Base64.encode(key.value)) +// } +// } +// } +// +// override fun onStemPressReceived(stemPress: ByteArray) { +// +// val (stemPressType, bud) = aacpManager.parseStemPressResponse(stemPress) +// +// Log.d( +// "AirPodsParser", +// "Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}" +// ) +// if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) { +// Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27")) +// } else { +// val action = getActionFor(bud, stemPressType) +// Log.d("AirPodsParser", "$bud $stemPressType action: $action") +// action?.let { executeStemAction(it) } +// } +// } +// +// override fun onAudioSourceReceived(audioSource: ByteArray) { +// Log.d( +// "AirPodsParser", +// "Audio source changed mac: ${aacpManager.audioSource?.mac}, type: ${aacpManager.audioSource?.type?.name}" +// ) +// if (localMac!="" && (aacpManager.audioSource?.type != AudioSourceType.NONE && aacpManager.audioSource?.mac != localMac)) { +// Log.d( +// "AirPodsParser", +// "Audio source is another device, better to give up aacp control" +// ) +// aacpManager.sendControlCommand( +// ControlCommandIdentifier.OWNS_CONNECTION.value, +// byteArrayOf(0x00) +// ) +// // this also means that the other device has start playing the audio, and if that's true, we can again start listening for audio config changes +//// Log.d(TAG, "Another device started playing audio, listening for audio config changes again") +//// MediaController.pausedForOtherDevice = false +//// future me: what the heck is this? this just means it will not be taking over again if audio source doesn't change??? +// } +// } +// +// override fun onConnectedDevicesReceived(connectedDevices: List) { +// for (device in connectedDevices) { +// Log.d( +// "AirPodsParser", +// "Connected device: ${device.mac}, info1: ${device.info1}, info2: ${device.info2})" +// ) +// } +// val newDevices = connectedDevices.filter { newDevice -> +// val notInOld = +// aacpManager.oldConnectedDevices.none { oldDevice -> oldDevice.mac == newDevice.mac } +// val notLocal = newDevice.mac != localMac +// notInOld && notLocal +// } +// +// for (device in newDevices) { +// Log.d( +// "AirPodsParser", +// "New connected device: ${device.mac}, info1: ${device.info1}, info2: ${device.info2})" +// ) +// Log.d( +// TAG, +// "Sending new Tipi packet for device ${device.mac}, and sending media info to the device" +// ) +// aacpManager.sendMediaInformationNewDevice( +// selfMacAddress = localMac, targetMacAddress = device.mac +// ) +// aacpManager.sendAddTiPiDevice( +// selfMacAddress = localMac, targetMacAddress = device.mac +// ) +// } +// } +// +// override fun onHeadphoneAccommodationReceived(eqData: FloatArray) { +// sendBroadcast( +// Intent(AirPodsNotifications.EQ_DATA).putExtra("eqData", eqData).apply { +// setPackage(packageName) +// }) +// } +// +// override fun onCustomEqReceived(customEq: CustomEq) { +// // TODO +// } +// +// override fun onCapabilitiesReceived(capabilities: Set) { +// // TODO +// } +// +// override fun onMicrophoneFrame(microphoneFrame: MicrophoneFrame) { +// decoder?.decode(microphoneFrame.accessUnit) { pcm -> +// wavWriter?.write(pcm) +// +// _microphoneState.update { +// it.copy( +// level = calculateLevel(pcm), +// packetsReceived = it.packetsReceived + 1, +// durationMs = it.durationMs + 30 +// ) +// } +// } +// } +// +// override fun onUnknownPacketReceived(packet: ByteArray) { +// Log.d( +// "AACPManager", +// "Unknown packet received: ${packet.joinToString(" ") { "%02X".format(it) }}" +// ) +// } +// }) +// } +// +// private fun getActionFor( +// bud: StemPressBud, type: StemPressType +// ): StemAction? { +// return when (type) { +// StemPressType.SINGLE_PRESS -> if (bud == StemPressBud.LEFT) config.leftSinglePressAction else config.rightSinglePressAction +// StemPressType.DOUBLE_PRESS -> if (bud == StemPressBud.LEFT) config.leftDoublePressAction else config.rightDoublePressAction +// StemPressType.TRIPLE_PRESS -> if (bud == StemPressBud.LEFT) config.leftTriplePressAction else config.rightTriplePressAction +// StemPressType.LONG_PRESS -> if (bud == StemPressBud.LEFT) config.leftLongPressAction else config.rightLongPressAction +// } +// } +// +// private fun executeStemAction(action: StemAction) { +// when (action) { +// StemAction.defaultActions[StemPressType.SINGLE_PRESS] -> { +// Log.d( +// "AirPodsParser", "Default single press action: Play/Pause, not taking action." +// ) +// } +// +// StemAction.PLAY_PAUSE -> MediaController.sendPlayPause() +// StemAction.PREVIOUS_TRACK -> MediaController.sendPreviousTrack() +// StemAction.NEXT_TRACK -> MediaController.sendNextTrack() +// StemAction.DIGITAL_ASSISTANT -> { +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { +// val intent = Intent(Intent.ACTION_VOICE_COMMAND).apply { +// addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) +// } +// startActivity(intent) +// } else { +// Log.w( +// "AirPodsParser", +// "Digital Assistant action is not supported on this Android version." +// ) +// } +// } +// +// StemAction.CYCLE_NOISE_CONTROL_MODES -> { +// Log.d("AirPodsParser", "Cycling noise control modes") +// sendBroadcast(Intent("me.kavishdevar.librepods.SET_ANC_MODE").apply { +// setPackage(packageName) +// }) +// } +// } +// } +// +// private fun processEarDetectionChange(earDetection: ByteArray) { +// var inEar: Boolean +// val inEarData = listOf( +// earDetectionNotification.status[0] == 0x00.toByte(), +// earDetectionNotification.status[1] == 0x00.toByte() +// ) +// var justEnabledA2dp = false +// earDetectionNotification.setStatus(earDetection) +// if (config.earDetectionEnabled) { +// val data = earDetection.copyOfRange(earDetection.size - 2, earDetection.size) +// inEar = data[0] == 0x00.toByte() && data[1] == 0x00.toByte() +// +// val newInEarData = listOf( +// data[0] == 0x00.toByte(), data[1] == 0x00.toByte() +// ) +// +// if (inEarData.sorted() == listOf(false, false) && newInEarData.sorted() != listOf( +// false, false +// ) && islandWindow?.isVisible != true +// ) { +// showIsland( +// this@AirPodsService, +// (batteryNotification.getBattery() +// .find { it.component == BatteryComponent.LEFT }?.level ?: 0).coerceAtMost( +// batteryNotification.getBattery() +// .find { it.component == BatteryComponent.RIGHT }?.level ?: 0 +// ) +// ) +// } +// +// if (newInEarData == listOf(false, false) && islandWindow?.isVisible == true) { +// islandWindow?.close() +// } +// +// if (newInEarData.contains(true) && inEarData == listOf(false, false)) { +// connectAudio() +// justEnabledA2dp = true +// registerA2dpConnectionReceiver() +// if (MediaController.getMusicActive()) { +// MediaController.userPlayedTheMedia = true +// } +// } else if (newInEarData == listOf(false, false)) { +// MediaController.sendPause(force = true) +// if (config.disconnectWhenNotWearing) { +// disconnectAudio() +// } +// } +// val wasNone = inEarData == listOf(false, false) +// val nowSingle = newInEarData.count { it } == 1 +// +// if (wasNone && nowSingle) { +// MediaController.sendPlay() +// MediaController.iPausedTheMedia = false +// return +// } +// +// if (inEarData.contains(false) && newInEarData == listOf(true, true)) { +// Log.d("AirPodsParser", "User put in both AirPods from just one.") +// MediaController.userPlayedTheMedia = false +// } +// +// if (newInEarData.contains(false) && inEarData == listOf(true, true)) { +// Log.d("AirPodsParser", "User took one of two out.") +// MediaController.userPlayedTheMedia = false +// } +// +// Log.d( +// "AirPodsParser", +// "inEarData: ${inEarData.sorted()}, newInEarData: ${newInEarData.sorted()}" +// ) +// +// if (newInEarData.sorted() != inEarData.sorted()) { +// if (inEar) { +// if (!justEnabledA2dp) { +// MediaController.sendPlay() +// MediaController.iPausedTheMedia = false +// } +// } else { +// MediaController.sendPause() +// } +// } +// } +// } +// +// private fun registerA2dpConnectionReceiver() { +// val a2dpConnectionStateReceiver = object : BroadcastReceiver() { +// override fun onReceive(context: Context, intent: Intent) { +// if (intent.action == "android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") { +// val state = intent.getIntExtra( +// BluetoothProfile.EXTRA_STATE, BluetoothProfile.STATE_DISCONNECTED +// ) +// val previousState = intent.getIntExtra( +// BluetoothProfile.EXTRA_PREVIOUS_STATE, BluetoothProfile.STATE_DISCONNECTED +// ) +// val device = +// intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) +// +// Log.d( +// "MediaController", +// "A2DP state changed: $previousState -> $state for device: ${device?.address}" +// ) +// +// if (state == BluetoothProfile.STATE_CONNECTED && previousState != BluetoothProfile.STATE_CONNECTED && device?.address == this@AirPodsService.device?.address) { +// +// Log.d("MediaController", "A2DP connected, sending play command") +// MediaController.sendPlay() +// MediaController.iPausedTheMedia = false +// +// context.unregisterReceiver(this) +// } +// } +// } +// } +// +// val a2dpIntentFilter = +// IntentFilter("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { +// registerReceiver(a2dpConnectionStateReceiver, a2dpIntentFilter, RECEIVER_EXPORTED) +// } else { +// registerReceiver(a2dpConnectionStateReceiver, a2dpIntentFilter) +// } +// } +// +// private fun initializeConfig() { +// config = ServiceConfig( +// deviceName = sharedPreferences.getString("name", "AirPods") ?: "AirPods", +// earDetectionEnabled = sharedPreferences.getBoolean("automatic_ear_detection", true), +// conversationalAwarenessPauseMusic = sharedPreferences.getBoolean( +// "conversational_awareness_pause_music", false +// ), +// showPhoneBatteryInWidget = sharedPreferences.getBoolean( +// "show_phone_battery_in_widget", true +// ), +// relativeConversationalAwarenessVolume = sharedPreferences.getBoolean( +// "relative_conversational_awareness_volume", true +// ), +// headGestures = sharedPreferences.getBoolean("head_gestures", true), +// disconnectWhenNotWearing = sharedPreferences.getBoolean( +// "disconnect_when_not_wearing", false +// ), +// conversationalAwarenessVolume = sharedPreferences.getInt( +// "conversational_awareness_volume", 43 +// ), +// qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle", +// +// // AirPods state-based takeover +// 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), +// +// // Phone state-based takeover +// takeoverWhenRingingCall = sharedPreferences.getBoolean( +// "takeover_when_ringing_call", false +// ), +// takeoverWhenMediaStart = sharedPreferences.getBoolean( +// "takeover_when_media_start", false +// ), +// +// // Stem actions +// leftSinglePressAction = StemAction.fromString( +// sharedPreferences.getString( +// "left_single_press_action", "PLAY_PAUSE" +// ) ?: "PLAY_PAUSE" +// )!!, +// rightSinglePressAction = StemAction.fromString( +// sharedPreferences.getString( +// "right_single_press_action", "PLAY_PAUSE" +// ) ?: "PLAY_PAUSE" +// )!!, +// +// leftDoublePressAction = StemAction.fromString( +// sharedPreferences.getString( +// "left_double_press_action", "PREVIOUS_TRACK" +// ) ?: "NEXT_TRACK" +// )!!, +// rightDoublePressAction = StemAction.fromString( +// sharedPreferences.getString( +// "right_double_press_action", "NEXT_TRACK" +// ) ?: "NEXT_TRACK" +// )!!, +// +// leftTriplePressAction = StemAction.fromString( +// sharedPreferences.getString( +// "left_triple_press_action", "PREVIOUS_TRACK" +// ) ?: "PREVIOUS_TRACK" +// )!!, +// rightTriplePressAction = StemAction.fromString( +// sharedPreferences.getString( +// "right_triple_press_action", "PREVIOUS_TRACK" +// ) ?: "PREVIOUS_TRACK" +// )!!, +// +// leftLongPressAction = StemAction.fromString( +// sharedPreferences.getString( +// "left_long_press_action", "CYCLE_NOISE_CONTROL_MODES" +// ) ?: "CYCLE_NOISE_CONTROL_MODES" +// )!!, +// rightLongPressAction = StemAction.fromString( +// sharedPreferences.getString( +// "right_long_press_action", "DIGITAL_ASSISTANT" +// ) ?: "DIGITAL_ASSISTANT" +// )!!, +// +// cameraAction = sharedPreferences.getString("camera_action", null) +// ?.let { StemPressType.valueOf(it) }, +// +// // AirPods device information +// airpodsName = sharedPreferences.getString("airpods_name", "") ?: "", +// airpodsModelNumber = sharedPreferences.getString("airpods_model_number", "") ?: "", +// airpodsManufacturer = sharedPreferences.getString("airpods_manufacturer", "") ?: "", +// airpodsSerialNumber = sharedPreferences.getString("airpods_serial_number", "") ?: "", +// airpodsLeftSerialNumber = sharedPreferences.getString("airpods_left_serial_number", "") +// ?: "", +// airpodsRightSerialNumber = sharedPreferences.getString( +// "airpods_right_serial_number", "" +// ) ?: "", +// airpodsVersion1 = sharedPreferences.getString("airpods_version1", "") ?: "", +// airpodsVersion2 = sharedPreferences.getString("airpods_version2", "") ?: "", +// airpodsVersion3 = sharedPreferences.getString("airpods_version3", "") ?: "", +// airpodsHardwareRevision = sharedPreferences.getString("airpods_hardware_revision", "") +// ?: "", +// airpodsUpdaterIdentifier = sharedPreferences.getString("airpods_updater_identifier", "") +// ?: "", +// +// selfMacAddress = sharedPreferences.getString("self_mac_address", "") ?: "" +// ) +// } +// +// override fun onSharedPreferenceChanged(preferences: SharedPreferences?, key: String?) { +// if (preferences == null || key == null) return +// +// when (key) { +// "name" -> config.deviceName = preferences.getString(key, "AirPods") ?: "AirPods" +// "mac_address" -> macAddress = preferences.getString(key, "") ?: "" +// "automatic_ear_detection" -> config.earDetectionEnabled = +// preferences.getBoolean(key, true) +// +// "conversational_awareness_pause_music" -> config.conversationalAwarenessPauseMusic = +// preferences.getBoolean(key, false) +// +// "show_phone_battery_in_widget" -> { +// config.showPhoneBatteryInWidget = preferences.getBoolean(key, true) +// widgetMobileBatteryEnabled = config.showPhoneBatteryInWidget +// updateBattery() +// } +// +// "relative_conversational_awareness_volume" -> config.relativeConversationalAwarenessVolume = +// preferences.getBoolean(key, true) +// +// "head_gestures" -> config.headGestures = preferences.getBoolean(key, true) +// "disconnect_when_not_wearing" -> config.disconnectWhenNotWearing = +// preferences.getBoolean(key, false) +// +// "conversational_awareness_volume" -> config.conversationalAwarenessVolume = +// preferences.getInt(key, 43) +// +// "qs_click_behavior" -> config.qsClickBehavior = +// preferences.getString(key, "cycle") ?: "cycle" +// +// // AirPods state-based takeover +// "takeover_when_disconnected" -> config.takeoverWhenDisconnected = +// preferences.getBoolean(key, true) +// +// "takeover_when_idle" -> config.takeoverWhenIdle = preferences.getBoolean(key, true) +// "takeover_when_music" -> config.takeoverWhenMusic = preferences.getBoolean(key, false) +// "takeover_when_call" -> config.takeoverWhenCall = preferences.getBoolean(key, true) +// +// // Phone state-based takeover +// "takeover_when_ringing_call" -> config.takeoverWhenRingingCall = +// preferences.getBoolean(key, true) +// +// "takeover_when_media_start" -> config.takeoverWhenMediaStart = +// preferences.getBoolean(key, true) +// +// "left_single_press_action" -> { +// config.leftSinglePressAction = StemAction.fromString( +// preferences.getString(key, "PLAY_PAUSE") ?: "PLAY_PAUSE" +// )!! +// setupStemActions() +// } +// +// "right_single_press_action" -> { +// config.rightSinglePressAction = StemAction.fromString( +// preferences.getString(key, "PLAY_PAUSE") ?: "PLAY_PAUSE" +// )!! +// setupStemActions() +// } +// +// "left_double_press_action" -> { +// config.leftDoublePressAction = StemAction.fromString( +// preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK" +// )!! +// setupStemActions() +// } +// +// "right_double_press_action" -> { +// config.rightDoublePressAction = StemAction.fromString( +// preferences.getString(key, "NEXT_TRACK") ?: "NEXT_TRACK" +// )!! +// setupStemActions() +// } +// +// "left_triple_press_action" -> { +// config.leftTriplePressAction = StemAction.fromString( +// preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK" +// )!! +// setupStemActions() +// } +// +// "right_triple_press_action" -> { +// config.rightTriplePressAction = StemAction.fromString( +// preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK" +// )!! +// setupStemActions() +// } +// +// "left_long_press_action" -> { +// config.leftLongPressAction = StemAction.fromString( +// preferences.getString(key, "CYCLE_NOISE_CONTROL_MODES") +// ?: "CYCLE_NOISE_CONTROL_MODES" +// )!! +// setupStemActions() +// } +// +// "right_long_press_action" -> { +// config.rightLongPressAction = StemAction.fromString( +// preferences.getString(key, "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT" +// )!! +// setupStemActions() +// } +// +// "camera_action" -> config.cameraAction = +// preferences.getString(key, null)?.let { StemPressType.valueOf(it) } +// +// // AirPods device information +// "airpods_name" -> config.airpodsName = preferences.getString(key, "") ?: "" +// "airpods_model_number" -> config.airpodsModelNumber = +// preferences.getString(key, "") ?: "" +// +// "airpods_manufacturer" -> config.airpodsManufacturer = +// preferences.getString(key, "") ?: "" +// +// "airpods_serial_number" -> config.airpodsSerialNumber = +// preferences.getString(key, "") ?: "" +// +// "airpods_left_serial_number" -> config.airpodsLeftSerialNumber = +// preferences.getString(key, "") ?: "" +// +// "airpods_right_serial_number" -> config.airpodsRightSerialNumber = +// preferences.getString(key, "") ?: "" +// +// "airpods_version1" -> config.airpodsVersion1 = preferences.getString(key, "") ?: "" +// "airpods_version2" -> config.airpodsVersion2 = preferences.getString(key, "") ?: "" +// "airpods_version3" -> config.airpodsVersion3 = preferences.getString(key, "") ?: "" +// "airpods_hardware_revision" -> config.airpodsHardwareRevision = +// preferences.getString(key, "") ?: "" +// +// "airpods_updater_identifier" -> config.airpodsUpdaterIdentifier = +// preferences.getString(key, "") ?: "" +// +// "self_mac_address" -> config.selfMacAddress = preferences.getString(key, "") ?: "" +// } +// } +// +// private fun logPacket(packet: ByteArray, @Suppress("SameParameterValue") source: String) { +// val packetHex = packet.joinToString(" ") { "%02X".format(it) } +// val logEntry = "$source: $packetHex" +// +// synchronized(inMemoryLogs) { +// inMemoryLogs.add(logEntry) +// if (inMemoryLogs.size > maxLogEntries) { +// inMemoryLogs.iterator().next().let { +// inMemoryLogs.remove(it) +// } +// } +// +// _packetLogsFlow.value = inMemoryLogs.toSet() +// } +// +// CoroutineScope(Dispatchers.IO).launch { +// val logs = +// sharedPreferencesLogs.getStringSet(packetLogKey, mutableSetOf())?.toMutableSet() +// ?: mutableSetOf() +// logs.add(logEntry) +// +// if (logs.size > maxLogEntries) { +// val toKeep = logs.toList().takeLast(maxLogEntries).toSet() +// sharedPreferencesLogs.edit { putStringSet(packetLogKey, toKeep) } +// } else { +// sharedPreferencesLogs.edit { putStringSet(packetLogKey, logs) } +// } +// } +// } +// +// private fun clearPacketLogs() { +// synchronized(inMemoryLogs) { +// inMemoryLogs.clear() +// _packetLogsFlow.value = emptySet() +// } +// sharedPreferencesLogs.edit { remove(packetLogKey) } +// } +// +// fun clearLogs() { +// clearPacketLogs() +// _packetLogsFlow.value = emptySet() +// } +// +// override fun onBind(intent: Intent?): IBinder { +// return LocalBinder() +// } +// +// private var gestureDetector: GestureDetector? = null +// private var isInCall = false +// private var callNumber: String? = null +// +// private fun initGestureDetector() { +// if (gestureDetector == null) { +// gestureDetector = GestureDetector(this) +// } +// } +// +// +// var popupShown = false +// fun showPopup(service: Service, name: String) { +// if (!sharedPreferences.getBoolean("show_bottom_sheet_popup", true)) { +// return +// } +// if (!Settings.canDrawOverlays(service)) { +// Log.d(TAG, "No permission for SYSTEM_ALERT_WINDOW") +// return +// } +// if (popupShown) { +// return +// } +// val popupWindow = PopupWindow(service.applicationContext) +// popupWindow.open(name, batteryNotification) +// popupShown = true +// } +// +// var islandOpen = false +// var islandWindow: IslandWindow? = null +// +// @SuppressLint("MissingPermission") +// fun showIsland( +// service: Service, +// batteryPercentage: Int, +// type: IslandType = IslandType.CONNECTED, +// reversed: Boolean = false, +// otherDeviceName: String? = null +// ) { +// Log.d(TAG, "Showing island window") +// if (!sharedPreferences.getBoolean("show_island_popup", true)) { +// return +// } +// if (!Settings.canDrawOverlays(service)) { +// Log.d(TAG, "No permission for SYSTEM_ALERT_WINDOW") +// return +// } +// CoroutineScope(Dispatchers.Main).launch { +// islandWindow = IslandWindow(service.applicationContext) +// islandWindow!!.show( +// sharedPreferences.getString("name", "AirPods Pro").toString(), +// batteryPercentage, +// this@AirPodsService, +// type, +// reversed, +// otherDeviceName +// ) +// } +// } +// +// @OptIn(ExperimentalMaterial3Api::class) +// fun startMainActivity() { +// val intent = Intent(this, MainActivity::class.java) +// intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) +// startActivity(intent) +// } +// +// // var isConnectedLocally = false +// var device: BluetoothDevice? = null +// +// private lateinit var earReceiver: BroadcastReceiver +// var widgetMobileBatteryEnabled = false +// +// object BatteryChangedIntentReceiver : BroadcastReceiver() { +// override fun onReceive(context: Context?, intent: Intent) { +// if (intent.action == Intent.ACTION_BATTERY_CHANGED) { +// ServiceManager.getService()?.updateBattery() +// } else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) { +// try { +// context?.unregisterReceiver(this) +// } catch (e: Exception) { +// e.printStackTrace() +// } +// } +// } +// } +// +// @OptIn(ExperimentalMaterial3Api::class) +// fun startForegroundNotification() { +// val disconnectedNotificationChannel = NotificationChannel( +// "background_service_status", +// "Background Service Status", +// NotificationManager.IMPORTANCE_NONE +// ) +// +// val connectedNotificationChannel = NotificationChannel( +// "airpods_connection_status", +// "AirPods Connection Status", +// NotificationManager.IMPORTANCE_LOW, +// ) +// +// val socketFailureChannel = NotificationChannel( +// "socket_connection_failure", +// "AirPods Socket Connection Issues", +// NotificationManager.IMPORTANCE_HIGH +// ).apply { +// description = "Notifications about problems connecting to AirPods protocol" +// enableLights(true) +// lightColor = Color.RED +// enableVibration(true) +// } +// +// val notificationManager = getSystemService(NotificationManager::class.java) +// notificationManager.createNotificationChannel(disconnectedNotificationChannel) +// notificationManager.createNotificationChannel(connectedNotificationChannel) +// notificationManager.createNotificationChannel(socketFailureChannel) +// +// val notificationSettingsIntent = +// Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { +// putExtra(Settings.EXTRA_APP_PACKAGE, packageName) +// putExtra(Settings.EXTRA_CHANNEL_ID, "background_service_status") +// } +// val pendingIntentNotifDisable = PendingIntent.getActivity( +// this, +// 0, +// notificationSettingsIntent, +// PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE +// ) +// +// val notification = NotificationCompat.Builder(this, "background_service_status") +// .setSmallIcon(R.drawable.airpods).setContentTitle("Background Service Running") +// .setContentText("Useless notification, disable it by clicking on it.") +// .setContentIntent(pendingIntentNotifDisable).setCategory(Notification.CATEGORY_SERVICE) +// .setPriority(NotificationCompat.PRIORITY_LOW).setOngoing(true).build() +// +// try { +// startForeground(1, notification) +// } catch (e: Exception) { +// e.printStackTrace() +// } +// } +// +// @Suppress("KotlinUnreachableCode") +// @OptIn(ExperimentalMaterial3Api::class) +// private fun showSocketConnectionFailureNotification(errorMessage: String) { +// return // something causes too many notifications. turning off for now +// if (BuildConfig.FLAVOR != "xposed") { +// Log.w( +// TAG, +// "Not showing BluetoothConnectionManager.aacpSocket? error notification to user, the service shouldn't be running if it isn't supported." +// ) +// return +// } +// val notificationManager = getSystemService(NotificationManager::class.java) +// +// val notificationIntent = Intent(this, MainActivity::class.java) +// val pendingIntent = PendingIntent.getActivity( +// this, +// 0, +// notificationIntent, +// PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE +// ) +// +// val notification = NotificationCompat.Builder(this, "socket_connection_failure") +// .setSmallIcon(R.drawable.airpods).setContentTitle("AirPods Connection Issue") +// .setContentText("Unable to connect to AirPods over L2CAP").setStyle( +// NotificationCompat.BigTextStyle().bigText( +// "Your AirPods are connected via Bluetooth, but LibrePods couldn't connect to AirPods using L2CAP. Error: $errorMessage" +// ) +// ).setContentIntent(pendingIntent).setCategory(Notification.CATEGORY_ERROR) +// .setPriority(NotificationCompat.PRIORITY_HIGH).setAutoCancel(true).build() +// +// notificationManager.notify(3, notification) +// } +// +// fun sendANCBroadcast() { +// sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply { +// putExtra("data", ancNotification.status) +// setPackage(packageName) +// }) +// } +// +// fun sendBatteryBroadcast() { +// broadcastBatteryInformation() +// sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply { +// putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery())) +// setPackage(packageName) +// }) +// } +// +// @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) +// fun sendBatteryNotification() { +// updateNotificationContent( +// true, +// getSharedPreferences("settings", MODE_PRIVATE).getString("name", device?.name), +// batteryNotification.getBattery() +// ) +// } +// +// fun setBatteryMetadata() { +// if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) { +// device?.let { it -> +// SystemApisUtils.setMetadata( +// it, +// it.METADATA_UNTETHERED_CASE_BATTERY, +// batteryNotification.getBattery() +// .find { it.component == BatteryComponent.CASE }?.level.toString() +// .toByteArray() +// ) +// SystemApisUtils.setMetadata( +// it, +// it.METADATA_UNTETHERED_CASE_CHARGING, +// (if (batteryNotification.getBattery() +// .find { it.component == BatteryComponent.CASE }?.status == BatteryStatus.CHARGING +// ) "1".toByteArray() else "0".toByteArray()) +// ) +// SystemApisUtils.setMetadata( +// it, +// it.METADATA_UNTETHERED_LEFT_BATTERY, +// batteryNotification.getBattery() +// .find { it.component == BatteryComponent.LEFT }?.level.toString() +// .toByteArray() +// ) +// SystemApisUtils.setMetadata( +// it, +// it.METADATA_UNTETHERED_LEFT_CHARGING, +// (if (batteryNotification.getBattery() +// .find { it.component == BatteryComponent.LEFT }?.status == BatteryStatus.CHARGING +// ) "1".toByteArray() else "0".toByteArray()) +// ) +// SystemApisUtils.setMetadata( +// it, +// it.METADATA_UNTETHERED_RIGHT_BATTERY, +// batteryNotification.getBattery() +// .find { it.component == BatteryComponent.RIGHT }?.level.toString() +// .toByteArray() +// ) +// SystemApisUtils.setMetadata( +// it, +// it.METADATA_UNTETHERED_RIGHT_CHARGING, +// (if (batteryNotification.getBattery() +// .find { it.component == BatteryComponent.RIGHT }?.status == BatteryStatus.CHARGING +// ) "1".toByteArray() else "0".toByteArray()) +// ) +// } +// } +// } +// +// @OptIn(ExperimentalMaterial3Api::class) +// fun updateBatteryWidget() { +// val appWidgetManager = AppWidgetManager.getInstance(this) +// val componentName = ComponentName(this, BatteryWidget::class.java) +// val widgetIds = appWidgetManager.getAppWidgetIds(componentName) +// +// val remoteViews = RemoteViews(packageName, R.layout.battery_widget).also { it -> +// val openActivityIntent = PendingIntent.getActivity( +// this, +// 0, +// Intent(this, MainActivity::class.java), +// PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE +// ) +// it.setOnClickPendingIntent(R.id.battery_widget, openActivityIntent) +// +// val leftBattery = +// batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT } +// val rightBattery = +// batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT } +// val caseBattery = +// batteryNotification.getBattery().find { it.component == BatteryComponent.CASE } +// +// it.setTextViewText(R.id.left_battery_widget, leftBattery?.let { +// "${it.level}%" +// } ?: "") +// it.setProgressBar( +// R.id.left_battery_progress, 100, leftBattery?.level ?: 0, false +// ) +// it.setViewVisibility( +// R.id.left_charging_icon, +// if (leftBattery?.status == BatteryStatus.CHARGING || leftBattery?.status == BatteryStatus.OPTIMIZED_CHARGING) View.VISIBLE else View.GONE +// ) +// +// it.setTextViewText(R.id.right_battery_widget, rightBattery?.let { +// "${it.level}%" +// } ?: "") +// it.setProgressBar( +// R.id.right_battery_progress, 100, rightBattery?.level ?: 0, false +// ) +// it.setViewVisibility( +// R.id.right_charging_icon, +// if (rightBattery?.status == BatteryStatus.CHARGING || rightBattery?.status == BatteryStatus.OPTIMIZED_CHARGING ) View.VISIBLE else View.GONE +// ) +// +// it.setTextViewText(R.id.case_battery_widget, caseBattery?.let { +// "${it.level}%" +// } ?: "") +// it.setProgressBar( +// R.id.case_battery_progress, 100, caseBattery?.level ?: 0, false +// ) +// it.setViewVisibility( +// R.id.case_charging_icon, +// if (caseBattery?.status == BatteryStatus.CHARGING || caseBattery?.status == BatteryStatus.OPTIMIZED_CHARGING ) View.VISIBLE else View.GONE +// ) +// +// it.setViewVisibility( +// R.id.phone_battery_widget_container, +// if (widgetMobileBatteryEnabled) View.VISIBLE else View.GONE +// ) +// if (widgetMobileBatteryEnabled) { +// val batteryManager = getSystemService(BatteryManager::class.java) +// val batteryLevel = +// batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) +// val charging = +// batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_STATUS) == BatteryManager.BATTERY_STATUS_CHARGING +// it.setTextViewText( +// R.id.phone_battery_widget, "$batteryLevel%" +// ) +// it.setViewVisibility( +// R.id.phone_charging_icon, if (charging) View.VISIBLE else View.GONE +// ) +// it.setProgressBar( +// R.id.phone_battery_progress, 100, batteryLevel, false +// ) +// } +// } +// appWidgetManager.updateAppWidget(widgetIds, remoteViews) +// } +// +// @SuppressLint("MissingPermission") +// @OptIn(ExperimentalMaterial3Api::class) +// fun updateBattery() { +// setBatteryMetadata() +// updateBatteryWidget() +// sendBatteryBroadcast() +// sendBatteryNotification() +// } +// +// fun updateNoiseControlWidget() { +// val appWidgetManager = AppWidgetManager.getInstance(this) +// val componentName = ComponentName(this, NoiseControlWidget::class.java) +// val widgetIds = appWidgetManager.getAppWidgetIds(componentName) +// val remoteViews = RemoteViews(packageName, R.layout.noise_control_widget).also { it -> +// val ancStatus = ancNotification.status +// val allowOffModeValue = +// aacpManager.controlCommandStatusList.find { it.identifier == ControlCommandIdentifier.ALLOW_OFF_OPTION } +// val allowOffMode = +// allowOffModeValue?.value?.takeIf { it.isNotEmpty() }?.get(0) == 0x01.toByte() || sharedPreferences.getBoolean("off_listening_mode", true) +// it.setInt( +// R.id.widget_off_button, +// "setBackgroundResource", +// if (ancStatus == 1) R.drawable.widget_button_checked_shape_start else R.drawable.widget_button_shape_start +// ) +// it.setInt( +// R.id.widget_transparency_button, +// "setBackgroundResource", +// if (ancStatus == 3) (if (allowOffMode) R.drawable.widget_button_checked_shape_middle else R.drawable.widget_button_checked_shape_start) else (if (allowOffMode) R.drawable.widget_button_shape_middle else R.drawable.widget_button_shape_start) +// ) +// it.setInt( +// R.id.widget_adaptive_button, +// "setBackgroundResource", +// if (ancStatus == 4) R.drawable.widget_button_checked_shape_middle else R.drawable.widget_button_shape_middle +// ) +// it.setInt( +// R.id.widget_anc_button, +// "setBackgroundResource", +// if (ancStatus == 2) R.drawable.widget_button_checked_shape_end else R.drawable.widget_button_shape_end +// ) +// it.setViewVisibility( +// R.id.widget_off_button, if (allowOffMode) View.VISIBLE else View.GONE +// ) +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { +// it.setViewLayoutMargin( +// R.id.widget_transparency_button, +// RemoteViews.MARGIN_START, +// if (allowOffMode) 2f else 12f, +// TypedValue.COMPLEX_UNIT_DIP +// ) +// } else { +// it.setViewPadding( +// R.id.widget_transparency_button, +// if (allowOffMode) 2.dpToPx() else 12.dpToPx(), +// 12.dpToPx(), +// 2.dpToPx(), +// 12.dpToPx() +// ) +// } +// } +// +// appWidgetManager.updateAppWidget(widgetIds, remoteViews) +// } +// +// @OptIn(ExperimentalMaterial3Api::class) +// fun updateNotificationContent( +// connected: Boolean, airpodsName: String? = null, batteryList: List? = null +// ) { +// val notificationManager = getSystemService(NotificationManager::class.java) +// +// val notificationIntent = Intent(this, MainActivity::class.java) +// val pendingIntent = PendingIntent.getActivity( +// this, +// 0, +// notificationIntent, +// PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE +// ) +// +// if (BluetoothConnectionManager.aacpSocket == null) { +// return +// } +// if (BluetoothConnectionManager.aacpSocket?.isConnected == true) { +// val updatedNotificationBuilder = +// NotificationCompat.Builder(this, "airpods_connection_status") +// .setSmallIcon(R.drawable.airpods) +// .setContentTitle(airpodsName ?: config.deviceName).setContentText( +// """${ +// batteryList?.find { it.component == BatteryComponent.LEFT }?.let { +// if (it.status != BatteryStatus.DISCONNECTED) { +// "L: ${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" +// } else { +// "" +// } +// } ?: "" +// } ${ +// batteryList?.find { it.component == BatteryComponent.RIGHT }?.let { +// if (it.status != BatteryStatus.DISCONNECTED) { +// "R: ${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" +// } else { +// "" +// } +// } ?: "" +// } ${ +// batteryList?.find { it.component == BatteryComponent.CASE }?.let { +// if (it.status != BatteryStatus.DISCONNECTED) { +// "Case: ${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" +// } else { +// "" +// } +// } ?: "" +// }""").setContentIntent(pendingIntent).setCategory(Notification.CATEGORY_STATUS) +// .setPriority(NotificationCompat.PRIORITY_LOW).setOngoing(true) +// +// if (disconnectedBecauseReversed) { +// updatedNotificationBuilder.addAction( +// R.drawable.ic_bluetooth, "Reconnect", PendingIntent.getService( +// this, 0, Intent(this, AirPodsService::class.java).apply { +// action = "me.kavishdevar.librepods.RECONNECT_AFTER_REVERSE" +// }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE +// ) +// ) +// } +// +// val updatedNotification = updatedNotificationBuilder.build() +// +// notificationManager.notify(2, updatedNotification) +// notificationManager.cancel(1) +// } else if (!connected) { +// notificationManager.cancel(2) +// } else if (!config.bleOnlyMode && BluetoothConnectionManager.aacpSocket?.isConnected != true) { +// showSocketConnectionFailureNotification("BluetoothConnectionManager.aacpSocket? created, but not connected. Check logs") +// } +// } +// +// fun handleIncomingCall() { +// if (isInCall) return +// if (config.headGestures) { +// initGestureDetector() +// startHeadTracking() +// gestureDetector?.startDetection { accepted -> +// if (accepted) { +// answerCall() +// handleIncomingCallOnceConnected = false +// } else { +// rejectCall() +// handleIncomingCallOnceConnected = false +// } +// } +// +// } +// } +// +// @OptIn(ExperimentalCoroutinesApi::class) +// suspend fun testHeadGestures(): Boolean { +// return suspendCancellableCoroutine { continuation -> +// gestureDetector?.startDetection(doNotStop = true) { accepted -> +// if (continuation.isActive) { +// continuation.resume(accepted) { _, _, _ -> +// gestureDetector?.stopDetection() +// } +// } +// } +// } +// } +// +// private fun answerCall() { +// try { +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { +// val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager +// if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) == PackageManager.PERMISSION_GRANTED) { +// telecomManager.acceptRingingCall() // TODO: Switch to InCallService (needs CDM association) +// } +// } else { +// val telephonyService = getSystemService(TELEPHONY_SERVICE) as TelephonyManager +// val telephonyClass = Class.forName(telephonyService.javaClass.name) +// val method = telephonyClass.getDeclaredMethod("getITelephony") +// method.isAccessible = true +// val telephonyInterface = method.invoke(telephonyService) +// val answerCallMethod = +// telephonyInterface.javaClass.getDeclaredMethod("answerRingingCall") +// answerCallMethod.invoke(telephonyInterface) +// } +// +// sendToast("Call answered via head gesture") +// } catch (e: Exception) { +// e.printStackTrace() +// sendToast("Failed to answer call: ${e.message}") +// } finally { +// islandWindow?.close() +// } +// } +// +// private fun rejectCall() { +// try { +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { +// val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager +// if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) == PackageManager.PERMISSION_GRANTED) { +// telecomManager.endCall() // TODO: Switch to InCallService (needs CDM association) +// } +// } else { +// val telephonyService = getSystemService(TELEPHONY_SERVICE) as TelephonyManager +// val telephonyClass = Class.forName(telephonyService.javaClass.name) +// val method = telephonyClass.getDeclaredMethod("getITelephony") +// method.isAccessible = true +// val telephonyInterface = method.invoke(telephonyService) +// val endCallMethod = telephonyInterface.javaClass.getDeclaredMethod("endCall") +// endCallMethod.invoke(telephonyInterface) +// } +// +// sendToast("Call rejected via head gesture") +// } catch (e: Exception) { +// e.printStackTrace() +// sendToast("Failed to reject call: ${e.message}") +// } finally { +// islandWindow?.close() +// } +// } +// +// fun sendToast(message: String) { +// Handler(Looper.getMainLooper()).post { +// Toast.makeText(applicationContext, message, Toast.LENGTH_SHORT).show() +// } +// } +// +// @RequiresApi(Build.VERSION_CODES.R) +// fun processHeadTrackingData(data: ByteArray) { +// val horizontal = ByteBuffer.wrap(data, 51, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt() +// val vertical = ByteBuffer.wrap(data, 53, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt() +// try { +// gestureDetector?.processHeadOrientation(horizontal, vertical) +// } catch (e: Exception) { +// Log.w(TAG, "gesture detector on ${data.toHexString()}: ${e.message}") +// } +// } +// +// private lateinit var connectionReceiver: BroadcastReceiver +// +// private fun resToUri(resId: Int): Uri? { +// return try { +// Uri.Builder().scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) +// .authority("me.kavishdevar.librepods") +// .appendPath(applicationContext.resources.getResourceTypeName(resId)) +// .appendPath(applicationContext.resources.getResourceEntryName(resId)).build() +// } catch (_: Resources.NotFoundException) { +// null +// } +// } +// +// @Suppress("PrivatePropertyName") +// private val VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV = "+IPHONEACCEV" +// +// @Suppress("PrivatePropertyName") +// private val VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV_BATTERY_LEVEL = 1 +// +// @Suppress("PrivatePropertyName") +// private val APPLE = 0x004C +// +// @Suppress("PrivatePropertyName") +// private val ACTION_BATTERY_LEVEL_CHANGED = +// "android.bluetooth.device.action.BATTERY_LEVEL_CHANGED" +// +// @Suppress("PrivatePropertyName") +// private val EXTRA_BATTERY_LEVEL = "android.bluetooth.device.extra.BATTERY_LEVEL" +// +// @Suppress("PrivatePropertyName") +// private val PACKAGE_ASI = "com.google.android.settings.intelligence" +// +// @Suppress("PrivatePropertyName") +// private val ACTION_ASI_UPDATE_BLUETOOTH_DATA = "batterywidget.impl.action.update_bluetooth_data" +// +// @SuppressLint("MissingPermission") +// fun broadcastBatteryInformation() { +// if (device == null || checkSelfPermission("android.permission.INTERACT_ACROSS_USERS") != PackageManager.PERMISSION_GRANTED) return +// +// val batteryList = batteryNotification.getBattery() +// val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT } +// val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT } +// +// // Calculate unified battery level (minimum of left and right) +// val batteryUnified = minOf( +// leftBattery?.level ?: 100, rightBattery?.level ?: 100 +// ) +// +// // Check charging status +// val isLeftCharging = leftBattery?.status == BatteryStatus.CHARGING +// val isRightCharging = rightBattery?.status == BatteryStatus.CHARGING +// isLeftCharging && isRightCharging +// +// // Create arguments for vendor-specific event +// val arguments = arrayOf( +// 1, // Number of key/value pairs +// VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV_BATTERY_LEVEL, // IndicatorType: Battery Level +// batteryUnified // Battery Level +// ) +// +// // Broadcast vendor-specific event +// val intent = Intent(BluetoothHeadset.ACTION_VENDOR_SPECIFIC_HEADSET_EVENT).apply { +// putExtra( +// BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD, +// VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV +// ) +// putExtra( +// BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD_TYPE, +// BluetoothHeadset.AT_CMD_TYPE_SET +// ) +// putExtra(BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_ARGS, arguments) +// putExtra(BluetoothDevice.EXTRA_DEVICE, device) +// putExtra(BluetoothDevice.EXTRA_NAME, device?.name) +// addCategory("${BluetoothHeadset.VENDOR_SPECIFIC_HEADSET_EVENT_COMPANY_ID_CATEGORY}.$APPLE") +// } +// try { +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { +// sendBroadcastAsUser( +// intent, +// UserHandle.getUserHandleForUid(-1), +// Manifest.permission.BLUETOOTH_CONNECT +// ) +// } else { +// sendBroadcastAsUser(intent, UserHandle.getUserHandleForUid(-1)) +// } +// } catch (e: Exception) { +// Log.e(TAG, "Failed to send vendor-specific event: ${e.message}") +// } +// +// // Broadcast battery level changes +// val batteryIntent = Intent(ACTION_BATTERY_LEVEL_CHANGED).apply { +// putExtra(BluetoothDevice.EXTRA_DEVICE, device) +// putExtra(EXTRA_BATTERY_LEVEL, batteryUnified) +// } +// +// try { +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { +// sendBroadcast(batteryIntent, Manifest.permission.BLUETOOTH_CONNECT) +// } else { +// sendBroadcastAsUser(batteryIntent, UserHandle.getUserHandleForUid(-1)) +// } +// } catch (e: Exception) { +// Log.e(TAG, "Failed to send battery level broadcast: ${e.message}") +// } +// +// // Update Android Settings Intelligence's battery widget +// val statusIntent = Intent(ACTION_ASI_UPDATE_BLUETOOTH_DATA).apply { +// setPackage(PACKAGE_ASI) +// putExtra(ACTION_BATTERY_LEVEL_CHANGED, intent) +// } +// +// try { +// sendBroadcastAsUser(statusIntent, UserHandle.getUserHandleForUid(-1)) +// } catch (e: Exception) { +// Log.e(TAG, "Failed to send ASI battery level broadcast: ${e.message}") +// } +// +// Log.d(TAG, "Broadcast battery level $batteryUnified% to system") +// } +// +// private fun setMetadatas(d: BluetoothDevice) { +// if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) { +// Log.d(TAG, "no permission BLUETOOTH_PRIVILEGED, returning") +// return +// } +// Log.d(TAG, "has permission BLUETOOTH_PRIVILEGED, proceeding") +// d.let { device -> +// val instance = airpodsInstance +// if (instance != null) { +// val metadataSet = SystemApisUtils.setMetadata( +// device, +// device.METADATA_MAIN_ICON, +// resToUri(instance.model.budCaseRes).toString().toByteArray() +// ) && SystemApisUtils.setMetadata( +// device, device.METADATA_MODEL_NAME, instance.model.name.toByteArray() +// ) && SystemApisUtils.setMetadata( +// device, +// device.METADATA_DEVICE_TYPE, +// device.DEVICE_TYPE_UNTETHERED_HEADSET.toByteArray() +// ) && SystemApisUtils.setMetadata( +// device, +// device.METADATA_UNTETHERED_CASE_ICON, +// resToUri(instance.model.caseRes).toString().toByteArray() +// ) && SystemApisUtils.setMetadata( +// device, +// device.METADATA_UNTETHERED_RIGHT_ICON, +// resToUri(instance.model.rightBudsRes).toString().toByteArray() +// ) && SystemApisUtils.setMetadata( +// device, +// device.METADATA_UNTETHERED_LEFT_ICON, +// resToUri(instance.model.leftBudsRes).toString().toByteArray() +// ) && SystemApisUtils.setMetadata( +// device, +// device.METADATA_MANUFACTURER_NAME, +// instance.model.manufacturer.toByteArray() +// ) && SystemApisUtils.setMetadata( +// device, device.METADATA_COMPANION_APP, "me.kavishdevar.librepods".toByteArray() +// ) && SystemApisUtils.setMetadata( +// device, +// device.METADATA_UNTETHERED_CASE_LOW_BATTERY_THRESHOLD, +// "20".toByteArray() +// ) && SystemApisUtils.setMetadata( +// device, +// device.METADATA_UNTETHERED_LEFT_LOW_BATTERY_THRESHOLD, +// "20".toByteArray() +// ) && SystemApisUtils.setMetadata( +// device, +// device.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD, +// "20".toByteArray() +// ) +// Log.d(TAG, "Metadata set: $metadataSet") +// } else { +// Log.w( +// TAG, +// "AirPods demoInstance is not of type AirPodsInstance, skipping metadata setting" +// ) +// } +// } +// } +// +// @Suppress("ClassName") +// private object bluetoothReceiver : BroadcastReceiver() { +// @SuppressLint("MissingPermission") +// override fun onReceive(context: Context?, intent: Intent) { +// val bluetoothDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { +// intent.getParcelableExtra( +// "android.bluetooth.device.extra.DEVICE", BluetoothDevice::class.java +// ) +// } else { +// intent.getParcelableExtra("android.bluetooth.device.extra.DEVICE") as BluetoothDevice? +// } +// val action = intent.action +// val context = context?.applicationContext +// val name = context?.getSharedPreferences("settings", MODE_PRIVATE) +// ?.getString("name", bluetoothDevice?.name) +// if (bluetoothDevice != null && !action.isNullOrEmpty()) { +// Log.d(TAG, "Received bluetooth connection broadcast: action=$action") +// val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") +// +// if (BluetoothDevice.ACTION_ACL_CONNECTED == action) { +// if (bluetoothDevice.uuids?.contains(uuid) == true) { +// val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) +// intent.putExtra("name", name) +// intent.putExtra("device", bluetoothDevice) +// context?.sendBroadcast(intent) +// } else { +// bluetoothDevice.fetchUuidsWithSdp() +// } +// } else if ("android.bluetooth.device.action.UUID" == action) { +// val savedMac = context?.getSharedPreferences("settings", MODE_PRIVATE) +// ?.getString("mac_address", "") ?: "" +// val matchedByMac = savedMac.isNotEmpty() && bluetoothDevice.address == savedMac +// val matchedByUuid = bluetoothDevice.uuids?.contains(uuid) == true +// if (matchedByUuid || matchedByMac) { +// val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) +// intent.putExtra("name", name) +// intent.putExtra("device", bluetoothDevice) +// context?.sendBroadcast(intent) +// } +// } +// } +// } +// } +// +// val externalBroadcastFilter = IntentFilter().apply { +// addAction("me.kavishdevar.librepods.SET_ANC_MODE") +// addAction("me.kavishdevar.librepods.CONVO_DETECT") +// } +// var externalBroadcastReceiver: BroadcastReceiver? = null +// +// @SuppressLint("InlinedApi", "MissingPermission", "UnspecifiedRegisterReceiverFlag") +// override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { +// Log.d(TAG, "Service started with intent action: ${intent?.action}") +// +// if (intent?.action == "me.kavishdevar.librepods.RECONNECT_AFTER_REVERSE") { +// Log.d(TAG, "reconnect after reversed received, taking over") +// disconnectedBecauseReversed = false +// otherDeviceTookOver = false +// takeOver("music", manualTakeOverAfterReversed = true) +// } +// +// return START_STICKY +// } +// +// @RequiresApi(Build.VERSION_CODES.R) +// @SuppressLint("MissingPermission", "HardwareIds") +// fun takeOver( +// takingOverFor: String, +// manualTakeOverAfterReversed: Boolean = false, +// startHeadTrackingAgain: Boolean = false +// ) { +// if (takingOverFor == "reverse") { +// aacpManager.sendControlCommand( +// ControlCommandIdentifier.OWNS_CONNECTION.value, 1 +// ) +// aacpManager.sendMediaInformataion( +// localMac +// ) +// aacpManager.sendHijackReversed( +// localMac +// ) +// connectAudio() +// otherDeviceTookOver = false +// } +// val ownsConnection = aacpManager.getControlCommandStatus(ControlCommandIdentifier.OWNS_CONNECTION)?.value?.get(0)?.toInt() +// Log.d( +// TAG, "owns connection: $ownsConnection" +// ) +// if (BluetoothConnectionManager.aacpSocket?.isConnected == true) { +// if (!XposedRemotePrefProvider.create().getBoolean("vendor_id_hook", false) || ownsConnection == 0) { +// Log.d(TAG, "not taking over, vendorid is probably not set to apple") +// return +// } +// if (aacpManager.getControlCommandStatus(ControlCommandIdentifier.OWNS_CONNECTION)?.value[0]?.toInt() != 1 || (aacpManager.audioSource?.mac != localMac && aacpManager.audioSource?.type != AudioSourceType.NONE)) { +// if (disconnectedBecauseReversed) { +// if (manualTakeOverAfterReversed) { +// Log.d(TAG, "forcefully taking over despite reverse as user requested") +// disconnectedBecauseReversed = false +// } else { +// Log.d( +// TAG, +// "connected locally, but can not hijack as other device had reversed" +// ) +// return +// } +// } +// +// Log.d(TAG, "already connected locally, hijacking connection by asking AirPods") +// aacpManager.sendControlCommand( +// ControlCommandIdentifier.OWNS_CONNECTION.value, 1 +// ) +// aacpManager.sendMediaInformataion( +// localMac +// ) +// aacpManager.sendSmartRoutingShowUI( +// localMac +// ) +// aacpManager.sendHijackRequest( +// localMac +// ) +// otherDeviceTookOver = false +// connectAudio() +// showIsland( +// this, +// batteryNotification.getBattery() +// .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost( +// batteryNotification.getBattery() +// .find { it.component == BatteryComponent.RIGHT }?.level!! +// ), +// IslandType.CONNECTED +// ) +// +// CoroutineScope(Dispatchers.IO).launch { +// delay(500) // a2dp takes time, and so does taking control + AirPods pause it for no reason after connecting +// if (takingOverFor == "music") { +// Log.d(TAG, "Resuming music after taking control") +// MediaController.sendPlay(replayWhenPaused = true) +// } else if (startHeadTrackingAgain) { +// Log.d(TAG, "Starting head tracking again after taking control") +// Handler(Looper.getMainLooper()).postDelayed({ +// startHeadTracking() +// }, 500) +// } +// delay(1000) // should ideally have a callback when it's taken over because for some reason android doesn't dispatch when it's paused +// if (takingOverFor == "music") { +// Log.d(TAG, "resuming again just in case") +// MediaController.sendPlay(force = true) +// } +// } +// } else { +// Log.d( +// TAG, "Already connected locally and already own connection, skipping takeover" +// ) +// } +// return +// } +// +//// if (CrossDevice.isAvailable) { +//// Log.d(TAG, "CrossDevice is available, continuing") +//// } +//// else if (bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true) { +//// Log.d(TAG, "At least one AirPod is in ear, continuing") +//// } +//// else { +//// Log.d(TAG, "CrossDevice not available and AirPods not in ear, skipping") +//// return +//// } +// +// if (bleManager.getMostRecentStatus()?.isLeftInEar == false && bleManager.getMostRecentStatus()?.isRightInEar == false) { +// Log.d(TAG, "Both AirPods are out of ear, not taking over audio") +// return +// } +// +// val shouldTakeOverPState = when (takingOverFor) { +// "music" -> config.takeoverWhenMediaStart +// "call" -> config.takeoverWhenRingingCall +// else -> false +// } +// +// if (!shouldTakeOverPState) { +// Log.d(TAG, "Not taking over audio, phone state takeover disabled") +// return +// } +// +// val shouldTakeOver = when (bleManager.getMostRecentStatus()?.connectionState) { +// "Disconnected" -> config.takeoverWhenDisconnected +// "Idle" -> config.takeoverWhenIdle +// "Music" -> config.takeoverWhenMusic +// "Call" -> config.takeoverWhenCall +// "Ringing" -> config.takeoverWhenCall +// "Hanging Up" -> config.takeoverWhenCall +// else -> false +// } +// +// if (!shouldTakeOver) { +// Log.d(TAG, "Not taking over audio, airpods state takeover disabled") +// return +// } +// +// if (takingOverFor == "music") { +// Log.d(TAG, "Pausing music so that it doesn't play through speakers") +// MediaController.pausedWhileTakingOver = true +// MediaController.sendPause(true) +// } else { +// handleIncomingCallOnceConnected = true +// } +// +// Log.d(TAG, "Taking over audio") +//// CrossDevice.sendRemotePacket(CrossDevicePackets.REQUEST_DISCONNECT.packet) +// Log.d(TAG, macAddress) +// +//// sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false) } +// val bluetoothManager = getSystemService(BluetoothManager::class.java) +// val bluetoothAdapter = bluetoothManager.adapter +// device = bluetoothAdapter.bondedDevices.find { +// it.address == macAddress +// } +// +// if (device != null) { +// if (config.bleOnlyMode) { +// // In BLE-only mode, just show connecting status without actual L2CAP connection +// Log.d(TAG, "BLE-only mode: showing connecting status without L2CAP connection") +// updateNotificationContent( +// true, config.deviceName, batteryNotification.getBattery() +// ) +// // Set a temporary connecting state +//// isConnectedLocally = false // Keep as false since we're not actually connecting to L2CAP +// } else { +// connectToSocket(bluetoothAdapter, device!!) +// connectAudio() +//// isConnectedLocally = true +// } +// } +// showIsland( +// this, +// batteryNotification.getBattery() +// .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost( +// batteryNotification.getBattery() +// .find { it.component == BatteryComponent.RIGHT }?.level!! +// ), +// IslandType.TAKING_OVER +// ) +// +//// CrossDevice.isAvailable = false +// } +// +// @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") +// fun connectToSocket( +// adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean = false +// ) { +// if (BluetoothConnectionManager.aacpSocket != null && BluetoothConnectionManager.aacpSocket?.isConnected == true) return +// Log.d(TAG, " Connecting to socket") +// val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") +//// if (!isConnectedLocally) { +// val socket = try { +// createBluetoothSocket(adapter, device, uuid, 4097) +// } catch (e: Exception) { +// Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}") +// showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}") +// return +// } +// +// try { +// runBlocking { +// withTimeout(5000.milliseconds) { +// try { +// socket.connect() +// this@AirPodsService.device = device +// val xposedRemotePref = XposedRemotePrefProvider.create() +// val attSocket = if (xposedRemotePref.getBoolean("vendor_id_hook", false)) { +// createBluetoothSocket( +// adapter, +// device, +// ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"), +// 31 +// ) +// } else null +// attSocket?.connect() +// +// if (attSocket != null) { +// attManager.startReader() +// attManager.readCharacteristic(ATTHandle.LOUD_SOUND_REDUCTION) +// attManager.readCharacteristic(ATTHandle.TRANSPARENCY) +// attManager.readCharacteristic(ATTHandle.HEARING_AID) +// } +// +// BluetoothConnectionManager.aacpSocket = socket +// BluetoothConnectionManager.attSocket = attSocket +// +// // Create AirPodsInstance from stored config if available +// if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) { +// val model = +// AirPodsModels.getModelByModelNumber(config.airpodsModelNumber) +// if (model != null) { +// airpodsInstance = AirPodsInstance( +// name = config.airpodsName, +// model = model, +// actualModelNumber = config.airpodsModelNumber, +// serialNumber = config.airpodsSerialNumber, +// leftSerialNumber = config.airpodsLeftSerialNumber, +// rightSerialNumber = config.airpodsRightSerialNumber, +// version1 = config.airpodsVersion1, +// version2 = config.airpodsVersion2, +// version3 = config.airpodsVersion3, +// ) +// setMetadatas(device) +// } +// } +// +// updateNotificationContent( +// true, config.deviceName, batteryNotification.getBattery() +// ) +// Log.d(TAG, " Socket connected") +// sharedPreferences.edit { putBoolean("connection_successful", true) } +// if (!sharedPreferences.contains("first_connection_successful_time")) { +// sharedPreferences.edit { +// putLong( +// "first_connection_successful_time", +// System.currentTimeMillis() +// ) +// } +// } +// sendBroadcast(Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED)) +// } catch (e: Exception) { +//// sharedPreferences.edit { putBoolean("connection_successful", false) } +// Log.d( +// TAG, " Socket not connected, ${e.message}" +// ) +// if (manual) { +// sendToast( +// "Couldn't connect to socket: ${e.localizedMessage}" +// ) +// } else { +// showSocketConnectionFailureNotification("Couldn't connect to socket: ${e.localizedMessage}") +// } +// return@withTimeout +//// throw e // lol how did i not catch this before... gonna comment this line instead of removing to preserve history +// } +// } +// } +// if (!socket.isConnected) { +// Log.d(TAG, " socket not connected") +// if (manual) { +// sendToast( +// "Couldn't connect to socket: timeout." +// ) +// } else { +// showSocketConnectionFailureNotification("Couldn't connect to socket: Timeout") +// } +// return +// } +// this@AirPodsService.device = device +// BluetoothConnectionManager.aacpSocket?.let { +// aacpManager.sendPacket(aacpManager.createMessageServicePacket()) +// aacpManager.requestMessageServiceCapabilities() +// +// aacpManager.sendSourceFeatureCapabilities() +// aacpManager.sendNotificationRequest() +// +// Log.d(TAG, "Requesting proximity keys") +// aacpManager.sendRequestMagicKeys((MagicKeyType.IRK.value + MagicKeyType.ENC_KEY.value).toByte()) +// +// CoroutineScope(Dispatchers.IO).launch { +// delay(200.milliseconds) +// +// aacpManager.sendPacket(aacpManager.createMessageServicePacket()) +// delay(200.milliseconds) +// aacpManager.requestMessageServiceCapabilities() +// delay(200.milliseconds) +// +// aacpManager.sendSourceFeatureCapabilities() +// delay(200.milliseconds) +// aacpManager.sendNotificationRequest() +// delay(200.milliseconds) +// aacpManager.sendSomePacketIDontKnowWhatItIs() +// delay(200.milliseconds) +// +// aacpManager.sendRequestMagicKeys((MagicKeyType.IRK.value + MagicKeyType.ENC_KEY.value).toByte()) +// +// if (!handleIncomingCallOnceConnected) startHeadTracking() else handleIncomingCall() +// +// Handler(Looper.getMainLooper()).postDelayed({ +// aacpManager.sendPacket(aacpManager.createMessageServicePacket()) +// aacpManager.sendSourceFeatureCapabilities() +// +// aacpManager.sendNotificationRequest() +// aacpManager.requestMessageServiceCapabilities() +// aacpManager.sendRequestMagicKeys(MagicKeyType.IRK.value) +// if (!handleIncomingCallOnceConnected) stopHeadTracking() +// }, 5000) +// +// sendBroadcast( +// Intent(AirPodsNotifications.AIRPODS_CONNECTED).putExtra("device", device) +// .apply { +// setPackage(packageName) +// }) +// +// setupStemActions() +// +// while (socket.isConnected) { +// try { +// val buffer = ByteArray(1024) +// val bytesRead = it.inputStream.read(buffer) +// var data: ByteArray +// if (bytesRead > 0) { +// data = buffer.copyOfRange(0, bytesRead) +// sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply { +// putExtra("data", buffer.copyOfRange(0, bytesRead)) +// setPackage(packageName) +// }) +// val bytes = buffer.copyOfRange(0, bytesRead) +// val formattedHex = bytes.joinToString(" ") { "%02X".format(it) } +//// CrossDevice.sendReceivedPacket(bytes) +// updateNotificationContent( +// true, +// sharedPreferences.getString("name", device.name), +// batteryNotification.getBattery() +// ) +// +// aacpManager.receivePacket(data) +// +// if (!isHeadTrackingData(data)) { +// Log.d("AirPodsData", "Data received: $formattedHex") +// logPacket(data, "AirPods") +// } +// +// } else if (bytesRead == -1) { +// Log.d("AirPodsService", "socket closed (bytesRead = -1)") +// sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { +// setPackage(packageName) +// }) +// aacpManager.disconnected() +// return@launch +// } +// } catch (e: Exception) { +// Log.w(TAG, "Error reading data, we have probably disconnected.") +// e.printStackTrace() +// sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { +// setPackage(packageName) +// }) +// aacpManager.disconnected() +// return@launch +// } +// +// } +// Log.d("AirPods Service", "socket closed") +//// isConnectedLocally = false +// aacpManager.disconnected() +// updateNotificationContent(false) +// sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { +// setPackage(packageName) +// }) +// } +// } +// } catch (e: Exception) { +// e.printStackTrace() +// Log.d(TAG, "Failed to connect to BluetoothConnectionManager.aacpSocket?: ${e.message}") +// showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}") +//// isConnectedLocally = false +// this@AirPodsService.device = device +// updateNotificationContent(false) +// } +//// } else { +//// Log.d(TAG, "Already connected locally, skipping BluetoothConnectionManager.aacpSocket? connection (isConnectedLocally = $isConnectedLocally, BluetoothConnectionManager.aacpSocket?.isConnected = ${this::BluetoothConnectionManager.aacpSocket?.isInitialized && BluetoothConnectionManager.aacpSocket?.isConnected})") +//// } +// } +// +// fun disconnectForCD() { +// BluetoothConnectionManager.aacpSocket?.close() +// MediaController.pausedWhileTakingOver = false +// Log.d(TAG, "Disconnected from AirPods, showing island.") +// showIsland( +// this, +// batteryNotification.getBattery() +// .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost( +// batteryNotification.getBattery() +// .find { it.component == BatteryComponent.RIGHT }?.level!! +// ), +// IslandType.MOVED_TO_REMOTE +// ) +// val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter +// bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener { +// override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { +// if (profile == BluetoothProfile.A2DP) { +// val connectedDevices = proxy.connectedDevices +// if (connectedDevices.isNotEmpty()) { +// MediaController.sendPause() +// } +// } +// bluetoothAdapter.closeProfileProxy(profile, proxy) +// } +// +// override fun onServiceDisconnected(profile: Int) {} +// }, BluetoothProfile.A2DP) +//// isConnectedLocally = false +//// CrossDevice.isAvailable = true +// } +// +// fun disconnectAirPods() { +// if (BluetoothConnectionManager.aacpSocket == null) return +// try { +// BluetoothConnectionManager.aacpSocket?.close() +// } catch(e: Exception) { +// Log.e(TAG, "error closing aacp socket ${e.message}") +// } +//// isConnectedLocally = false +// aacpManager.disconnected() +// +// BluetoothConnectionManager.aacpSocket = null +// BluetoothConnectionManager.attSocket = null +// +// updateNotificationContent(false) +// sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { +// setPackage(packageName) +// }) +// +// disconnectA2dp() +// +// val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter +// if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED){ +// bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener { +// override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { +// if (profile == BluetoothProfile.A2DP) { +// val connectedDevices = proxy.connectedDevices +// if (connectedDevices.isNotEmpty()) { +// MediaController.sendPause() +// } +// } +// bluetoothAdapter.closeProfileProxy(profile, proxy) +// } +// +// override fun onServiceDisconnected(profile: Int) {} +// }, BluetoothProfile.A2DP) +// +// try { +// device?.disconnect() +// } catch (e: Exception) { +// Log.w(TAG, "device.disconnect() failed, $e") +// } +// } +// if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED){ +// bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener { +// override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { +// if (profile == BluetoothProfile.HEADSET) { +// val connectedDevices = proxy.connectedDevices +// if (connectedDevices.isNotEmpty()) { +// MediaController.sendPause() +// } +// } +// bluetoothAdapter.closeProfileProxy(profile, proxy) +// } +// +// override fun onServiceDisconnected(profile: Int) {} +// }, BluetoothProfile.HEADSET) +// } +// Log.d(TAG, "Disconnected AirPods upon user request") +// } +// +// val earDetectionNotification = AirPodsNotifications.EarDetection() +// val ancNotification = AirPodsNotifications.ANC() +// val batteryNotification = AirPodsNotifications.BatteryNotification() +// val conversationAwarenessNotification = +// AirPodsNotifications.ConversationalAwarenessNotification() +// +// @Suppress("unused") +// fun setEarDetection(enabled: Boolean) { +// if (config.earDetectionEnabled != enabled) { +// config.earDetectionEnabled = enabled +// sharedPreferences.edit { putBoolean("automatic_ear_detection", enabled) } +// } +// } +// +// fun getBattery(): List { +//// if (!isConnectedLocally && CrossDevice.isAvailable) { +//// batteryNotification.setBattery(CrossDevice.batteryBytes) +//// } +// return batteryNotification.getBattery() +// } +// +// fun getANC(): Int { +//// if (!isConnectedLocally && CrossDevice.isAvailable) { +//// ancNotification.setStatus(CrossDevice.ancBytes) +//// } +// return ancNotification.status +// } +// +// fun disconnectAudio() { +// val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter +// if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) { +// bluetoothAdapter?.getProfileProxy(this, object : BluetoothProfile.ServiceListener { +// override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { +// if (profile == BluetoothProfile.A2DP) { +// try { +// if (proxy.getConnectionState(device) == BluetoothProfile.STATE_DISCONNECTED) { +// Log.d(TAG, "Already disconnected from A2DP") +// return +// } +// val method = proxy.javaClass.getMethod( +// "setConnectionPolicy", BluetoothDevice::class.java, Int::class.java +// ) +// Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 0") +// method.invoke(proxy, device, 0) +// } catch (e: Exception) { +// e.printStackTrace() +// } finally { +// bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) +// } +// } +// } +// +// override fun onServiceDisconnected(profile: Int) {} +// }, BluetoothProfile.A2DP) +// } else { +// Log.d(TAG, "not disconnecting A2DP, no BLUETOOTH_PRIVILEGED permission") +// } +// if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { +// bluetoothAdapter?.getProfileProxy(this, object : BluetoothProfile.ServiceListener { +// override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { +// if (profile == BluetoothProfile.HEADSET) { +// try { +// val method = +// proxy.javaClass.getMethod( +// "setConnectionPolicy", +// BluetoothDevice::class.java, +// Int::class.java +// ) +// Log.d(TAG, "calling HEADSET.setConnectionPolicy for ${device?.address} to 0") +// method.invoke(proxy, device, 0) +// } catch (e: Exception) { +// e.printStackTrace() +// } finally { +// bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) +// } +// } +// } +// +// override fun onServiceDisconnected(profile: Int) {} +// }, BluetoothProfile.HEADSET) +// } else { +// Log.d(TAG, "not disconnecting HEADSET, no MODIFIY_PHONE_STATE permission") +// } +// } +// +// fun connectAudio() { +// val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter +// +// bluetoothAdapter?.getProfileProxy(this, object : BluetoothProfile.ServiceListener { +// override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { +// if (profile == BluetoothProfile.A2DP) { +// if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) { +// try { +// val policyMethod = proxy.javaClass.getMethod( +// "setConnectionPolicy", +// BluetoothDevice::class.java, +// Int::class.java +// ) +// Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 100") +// policyMethod.invoke(proxy, device, 100) +// +// val connectMethod = +// proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) +// connectMethod.invoke( +// proxy, device +// ) +// } catch (e: Exception) { +// e.printStackTrace() +// } finally { +// bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) +// if (MediaController.pausedWhileTakingOver) { +// MediaController.sendPlay() +// } +// } +// } +// else { +// val connectMethod = +// proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) +// connectMethod.invoke( +// proxy, device +// ) +// Log.d(TAG, "not setting connection policy for A2DP, no BLUETOOTH_PRIVILEGED permission. just called connect") +// } +// } +// } +// +// override fun onServiceDisconnected(profile: Int) {} +// }, BluetoothProfile.A2DP) +// +// bluetoothAdapter?.getProfileProxy(this, object : BluetoothProfile.ServiceListener { +// override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { +// if (profile == BluetoothProfile.HEADSET) { +// if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { +// try { +// val policyMethod = proxy.javaClass.getMethod( +// "setConnectionPolicy", +// BluetoothDevice::class.java, +// Int::class.java +// ) +// Log.d( +// TAG, +// "calling HEADSET.setConnectionPolicy for ${device?.address} to 100" +// ) +// policyMethod.invoke(proxy, device, 100) +// val connectMethod = +// proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) +// connectMethod.invoke(proxy, device) +// } catch (e: Exception) { +// e.printStackTrace() +// } finally { +// bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) +// } +// } else { +// Log.d(TAG, "not setting connection policy for HEADSET, no MODIFIY_PHONE_STATE permission") +// } +// } +// } +// +// override fun onServiceDisconnected(profile: Int) {} +// }, BluetoothProfile.HEADSET) +// } +// +// fun setName(name: String) { +// aacpManager.sendRename(name) +// +// if (config.deviceName != name) { +// config.deviceName = name +// device?.alias = name +// sharedPreferences.edit { putString("name", name) } +// } +// +// updateNotificationContent(true, name, batteryNotification.getBattery()) +// Log.d(TAG, "setName: $name") +// } +// +// @SuppressLint("MissingPermission") +// override fun onDestroy() { +// clearPacketLogs() +// Log.d(TAG, "Service stopped is being destroyed for some reason!") +// +// sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) +// +// try { +// unregisterReceiver(bluetoothReceiver) +// } catch (e: Exception) { +// e.printStackTrace() +// } +// try { +// unregisterReceiver(externalBroadcastReceiver) +// } catch (e: Exception) { +// e.printStackTrace() +// } +// try { +// unregisterReceiver(connectionReceiver) +// } catch (e: Exception) { +// e.printStackTrace() +// } +// try { +// unregisterReceiver(earReceiver) +// } catch (e: Exception) { +// e.printStackTrace() +// } +// try { +// bleManager.stopScanning() +// } catch (e: Exception) { +// e.printStackTrace() +// } +// if (checkSelfPermission("android.permission.READ_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { +// telephonyManager.unregisterTelephonyCallback(phoneStateListener) +// } +//// isConnectedLocally = false +//// CrossDevice.isAvailable = true +// super.onDestroy() +// } +// +// var isHeadTrackingActive = false +// +// fun startHeadTracking() { +// isHeadTrackingActive = true +// val useAlternatePackets = +// sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true) +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && aacpManager.getControlCommandStatus( +// ControlCommandIdentifier.OWNS_CONNECTION +// )?.value?.get(0)?.toInt() != 1 +// ) { +// takeOver("call", startHeadTrackingAgain = true) +// Log.d(TAG, "Taking over for head tracking") +// } else { +// Log.w(TAG, "Will not be taking over for head tracking, might not work.") +// } +// if (useAlternatePackets) { +// aacpManager.sendDataPacket(aacpManager.createAlternateStartHeadTrackingPacket()) +// } else { +// aacpManager.sendStartHeadTracking() +// } +// HeadTracking.reset() +// } +// +// fun stopHeadTracking() { +// val useAlternatePackets = +// sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true) +// if (useAlternatePackets) { +// aacpManager.sendDataPacket(aacpManager.createAlternateStopHeadTrackingPacket()) +// } else { +// aacpManager.sendStopHeadTracking() +// } +// isHeadTrackingActive = false +// gestureDetector?.stopDetection() +// } +// +// @SuppressLint("MissingPermission") +// fun reconnectFromSavedMac() { +// val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter +// device = bluetoothAdapter.bondedDevices.find { +// it.address == macAddress +// } +// if (device != null) { +// CoroutineScope(Dispatchers.IO).launch { +// Log.d(TAG, "connecting to $macAddress") +// connectToSocket(bluetoothAdapter, device!!, manual = true) +// connectAudio() +// } +// } +// } +// +// fun startRecording() { +// if (decoder != null) +// return +// +// currentRecording = recordingRepository.createRecording() +// +// decoder = EldDecoder() +// +// wavWriter = WavWriter( +// currentRecording!!.file +// ) +// +// try { +// aacpManager.requestMicrophoneStream() +// } catch (e: Exception) { +// +// wavWriter?.close() +// wavWriter = null +// +// decoder?.close() +// decoder = null +// +// currentRecording = null +// +// e.printStackTrace() +// +// return +// } +// +// _microphoneState.update { +// it.copy( +// isActive = true, +// isRecording = true, +// currentRecording = currentRecording, +// packetsReceived = 0, +// decodeErrors = 0, +// durationMs = 0 +// ) +// } +// } +// +// fun stopRecording() { +// aacpManager.endMicrophoneStream() +// +// wavWriter?.close() +// wavWriter = null +// +// decoder?.close() +// decoder = null +// +// currentRecording = null +// +// _microphoneState.update { +// it.copy( +// isActive = false, +// isRecording = false, +// currentRecording = null, +// packetsReceived = 0, +// decodeErrors = 0, +// durationMs = 0 +// ) +// } +// +// // hack: one of the buds disconnect after stopping, disconnect() and connect() after finding our device and A2dp profile +// val shouldResume = MediaController.getMusicActive() +// disconnectA2dp() +// connectA2dp(shouldResume) +// } +// +// fun disconnectA2dp() { +// val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter +// +// bluetoothAdapter?.getProfileProxy(this, object : BluetoothProfile.ServiceListener { +// override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { +// if (profile == BluetoothProfile.A2DP) { +// try { +// if (proxy.getConnectionState(device) == BluetoothProfile.STATE_DISCONNECTED) { +// Log.d(TAG, "Already disconnected from A2DP") +// return +// } +// val method = +// proxy.javaClass.getMethod("disconnect", BluetoothDevice::class.java) +// method.invoke(proxy, device) +// } catch (e: Exception) { +// e.printStackTrace() +// } finally { +// bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) +// } +// } +// } +// +// override fun onServiceDisconnected(profile: Int) {} +// }, BluetoothProfile.A2DP) +// } +// +// fun connectA2dp(shouldResume: Boolean = false) { +// val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter +// +// bluetoothAdapter?.getProfileProxy(this, object : BluetoothProfile.ServiceListener { +// override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { +// if (profile == BluetoothProfile.A2DP) { +// try { +// val method = +// proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) +// method.invoke(proxy, device) +// } catch (e: Exception) { +// e.printStackTrace() +// } finally { +// bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) +// if (shouldResume) { +// MediaController.sendPlay() +// } +// } +// } +// } +// +// override fun onServiceDisconnected(profile: Int) {} +// }, BluetoothProfile.A2DP) +// } +//} +// +//private fun Int.dpToPx(): Int { +// val density = Resources.getSystem().displayMetrics.density +// return (this * density).toInt() +//} +// +//fun getNextMode(currentMode: Int, configByte: Int, offmodeEnabled: Boolean): Int { +// val enabledModes = buildList { +// if ((configByte and 0x01) != 0 && offmodeEnabled) add(1) +// if ((configByte and 0x04) != 0) add(3) +// if ((configByte and 0x08) != 0) add(4) +// if ((configByte and 0x02) != 0) add(2) +// } +// Log.d(TAG, "currentMode: $currentMode, config: ${configByte.toString(2)}") +// +// if (enabledModes.isEmpty()) return currentMode +// +// val currentIndex = enabledModes.indexOf(currentMode) +// val nextIndex = if (currentIndex == -1) 0 else (currentIndex + 1) % enabledModes.size +// +// return enabledModes[nextIndex] +//} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/AppListenerService.kt similarity index 96% rename from android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/services/AppListenerService.kt index 7eeea7c5..e5fd1d16 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/AppListenerService.kt @@ -78,11 +78,11 @@ class AppListenerService: AccessibilityService() { if (pkg in cameraPackages) { Log.d(TAG, "Camera app opened: $pkg") if (!cameraOpen) cameraOpen = true - ServiceManager.getService()?.cameraOpened() +// ServiceManager.getService()?.cameraOpened() } else { if (cameraOpen) { cameraOpen = false - ServiceManager.getService()?.cameraClosed() +// ServiceManager.getService()?.cameraClosed() } else { Log.d(TAG, "ignoring") } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt new file mode 100644 index 00000000..f7f7754b --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt @@ -0,0 +1,1169 @@ +package me.kavishdevar.librepods.services + +import android.annotation.SuppressLint +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.appwidget.AppWidgetManager +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothManager +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioManager +import android.os.BatteryManager +import android.os.Binder +import android.os.IBinder +import android.os.ParcelUuid +import android.provider.Settings +import android.util.Log +import android.view.View +import android.widget.RemoteViews +import androidx.core.app.NotificationCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.LibrePodsApplication +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.bluetooth.MacAddress +import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier +import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType +import me.kavishdevar.librepods.bluetooth.verifyRPA +import me.kavishdevar.librepods.database.app.AppSettingsEntity +import me.kavishdevar.librepods.devices.AppleDevice +import me.kavishdevar.librepods.devices.AppleSettings +import me.kavishdevar.librepods.devices.AppleState +import me.kavishdevar.librepods.devices.BatteryComponent +import me.kavishdevar.librepods.devices.BatteryStatus +import me.kavishdevar.librepods.devices.ComponentStatus +import me.kavishdevar.librepods.devices.ConnectionState +import me.kavishdevar.librepods.devices.Device +import me.kavishdevar.librepods.devices.DeviceComponentState +import me.kavishdevar.librepods.presentation.activities.MainActivity +import me.kavishdevar.librepods.presentation.overlays.IslandType +import me.kavishdevar.librepods.presentation.overlays.IslandWindow +import me.kavishdevar.librepods.presentation.widgets.BatteryWidget +import me.kavishdevar.librepods.utils.MediaController +import me.kavishdevar.librepods.utils.redactMac +import kotlin.time.Duration.Companion.seconds + +private const val TAG = "LibrePodsService" + +@SuppressLint("MissingPermission") +class LibrePodsService : Service() { + inner class LocalBinder : Binder() { + fun getService(): LibrePodsService = this@LibrePodsService + } + + private val binder = LocalBinder() + + private val _devices = MutableStateFlow>>(emptyMap()) + val devices = _devices.asStateFlow() + + private val deviceJobs = mutableMapOf>() + + val irkMap = mutableMapOf() + val rpasByPublicMac = mutableMapOf>() + + val rejectedRandomMac = mutableSetOf() + + private var islandWindow: IslandWindow? = null + + private val appleRepository by lazy { + (application as LibrePodsApplication).appleRepository + } + + private val appDataRepository by lazy { + (application as LibrePodsApplication).appDataRepository + } + + private val widgetConfigRepository by lazy { + (application as LibrePodsApplication).widgetConfigRepository + } + + private val hasConnectedToAACP by lazy { + appDataRepository.state.value.hasConnectedToAACP + } + + override fun onCreate() { + super.onCreate() + + observeAppSettings() + + registerBluetoothReceivers() + + loadDevices() + + startBleScanner() + + MediaController.initialize( + audioManager = getSystemService(AudioManager::class.java), + sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE), + localMac = null // TODO: smart routing. MAC_ADDRESS message gives host mac? + ) + + startForegroundNotification() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + // TODO: make this a sealed class or something, instead of stringly typed. also, make this different for widget so other apps can use it too + "ACTION_SET_ANC_MODE" -> { + val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1) + val macAddress = intent.getStringExtra("MAC_ADDRESS")?.let { MacAddress(it) } + + val ancMode = intent.getIntExtra("ANC_MODE", -1) + val device = + widgetConfigRepository.widgetConfigs.value.find { it.appWidgetId == appWidgetId } + ?.let { config -> + devices.value[config.macAddress] + } ?: devices.value[macAddress] + ?: devices.value.values.firstOrNull { it.connectionState.value == ConnectionState.CONNECTED } + + if (device != null && ancMode != -1) { + Log.i( + TAG, + "Setting ANC mode to $ancMode for device ${device.macAddress.toRedactedString()}" + ) + when (device) { + is AppleDevice -> device.setControlCommand( + ControlCommandIdentifier.LISTENING_MODE, + ancMode + ) + } + } else { + Log.w(TAG, "No connected Apple device found or invalid ANC mode: $ancMode") + } + } + } + + return super.onStartCommand(intent, flags, startId) + } + + override fun onDestroy() { + unregisterBluetoothReceivers() + stopBleScanner() + + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder = binder + + private fun onDeviceConnected(bluetoothDevice: BluetoothDevice) { + if (devices.value[MacAddress(bluetoothDevice.address)]?.connectionState?.value == ConnectionState.CONNECTED) { + Log.d(TAG, "Device already connected: ${bluetoothDevice.address}") + return + } + + val device = + devices.value[MacAddress(bluetoothDevice.address)] ?: createDevice(bluetoothDevice) + + if (device == null) { + Log.d(TAG, "Unsupported device connected: ${bluetoothDevice.address}") + return + } + + when (device) { + is AppleDevice -> CoroutineScope(Dispatchers.IO).launch { + Log.i(TAG, "Loading device ${device.macAddress.toRedactedString()} from db") + + appleRepository.load(device.macAddress)?.let { entity -> + val cache = entity.cache + Log.i( + TAG, + "Loaded cached state for device ${device.macAddress.toRedactedString()}: $cache" + ) + val settings = entity.settings + Log.i( + TAG, + "Loaded settings for device ${device.macAddress.toRedactedString()}: $settings" + ) + val metadata = entity.metadata + Log.i( + TAG, + "Loaded metadata for device ${device.macAddress.toRedactedString()}: $metadata" + ) + + + device.loadInitialState( + state = AppleState().copy( + capabilities = cache.capabilities, + magicKeys = cache.magicKeys, + controlStates = cache.controlStates, + customEq = cache.customEq, + ), + settings = settings, + metadata = metadata + ) + } + + deviceJobs[MacAddress(bluetoothDevice.address)] = mutableListOf() + + deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleState(device)) + deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleSettings(device)) + deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleMetadata(device)) + } + } + + device.connect() + + Log.i( + TAG, + "Device connected: ${device.macAddress.toRedactedString()} (${device.javaClass.simpleName})" + ) + + _devices.update { it + (device.macAddress to device) } + } + + private fun onDeviceDisconnected(mac: MacAddress) { + Log.i(TAG, "Device disconnected: $mac") + deviceJobs[mac]?.forEach { it.cancel() } + devices.value[mac]?.disconnect() + updateDeviceNotification(device = devices.value[mac] ?: return) + } + + private fun loadDevices() { + val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter + val bondedDevices = bluetoothAdapter.bondedDevices + + bondedDevices.forEach { bluetoothDevice -> + val device = createDevice(bluetoothDevice) + if (device != null) { + val notificationManager = getSystemService(NotificationManager::class.java) + val channel = NotificationChannel( + "device_${device.macAddress}", + "Device ${device.metadata.value.name}", + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(channel) + + _devices.update { it + (device.macAddress to device) } + } + } + } + + private val bluetoothReceiver = object : BroadcastReceiver() { + @SuppressLint("MissingPermission") + override fun onReceive(context: Context?, intent: Intent) { + val bluetoothDevice = intent.getParcelableExtra( + "android.bluetooth.device.extra.DEVICE", + BluetoothDevice::class.java + ) + val action = intent.action + + if (bluetoothDevice != null) { + when (action) { + BluetoothDevice.ACTION_ACL_CONNECTED -> { + if (bluetoothDevice.uuids == null) { + bluetoothDevice.fetchUuidsWithSdp() + } else { + onDeviceConnected(bluetoothDevice) + } + } + + BluetoothDevice.ACTION_ACL_DISCONNECTED -> onDeviceDisconnected( + MacAddress( + bluetoothDevice.address + ) + ) + + BluetoothDevice.ACTION_UUID -> onDeviceConnected(bluetoothDevice) + } + } + } + } + + fun registerBluetoothReceivers() { + val intentFilter = IntentFilter().apply { + addAction("android.bluetooth.device.action.ACL_CONNECTED") + addAction("android.bluetooth.device.action.ACL_DISCONNECTED") + addAction("android.bluetooth.device.action.UUID") + } + + registerReceiver(bluetoothReceiver, intentFilter, RECEIVER_EXPORTED) + } + + fun unregisterBluetoothReceivers() { + unregisterReceiver(bluetoothReceiver) + } + + private val bleScanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + Log.d(TAG, + "${result.device.address} " + + "sid=${result.advertisingSid} " + + "legacy=${result.isLegacy} " + + "phy=${result.primaryPhy} " + + "rssi=${result.rssi}" + ) + handleScanResult(result) + } + + override fun onBatchScanResults(results: List?) { + if (results == null) return + for (result in results) { + if (result != null) { + handleScanResult(result) + } + } + } + } + + private fun handleScanResult(result: ScanResult) { + val macAddress = MacAddress(result.device.address) + + if (rejectedRandomMac.contains(macAddress)) return + + val device = getDeviceFromBleMac(macAddress)?: return + + Log.d(TAG, "Scan result for device ${device.macAddress.toRedactedString()}") + + when (device) { + is AppleDevice -> { + val manufacturerData = result.scanRecord?.getManufacturerSpecificData(0x004C) ?: return + + Log.i(TAG, "Apple device scan result: ${manufacturerData.toHexString()}") + // hell. TODO: do stuff + } + } + } + + private fun getDeviceFromBleMac(bleMac: MacAddress): Device<*, *, *>? { + var deviceMac: MacAddress? = devices.value[bleMac]?.macAddress + + if (deviceMac == null) { + Log.d(TAG, "BLE random address found: $bleMac") + rpasByPublicMac.forEach { (address, addresses) -> + if (addresses.contains(bleMac)) { + deviceMac = address + } + } + } + + if (deviceMac == null) { + irkMap.forEach { (macAddress, irk) -> + Log.d(TAG, "Verfiying $bleMac against ${irk.toHexString()}") + if (verifyRPA(bleMac.value, irk)) { + Log.i(TAG, "New RPA for device ${macAddress.toRedactedString()}") + deviceMac = macAddress + val newSet = rpasByPublicMac[macAddress] ?: mutableSetOf() + newSet.add(bleMac) + rpasByPublicMac[macAddress] = newSet + } + } + } + + if (deviceMac == null) { + rejectedRandomMac.add(bleMac) + } + + return devices.value.values.firstOrNull { it.macAddress == deviceMac } + } + + private fun startBleScanner() { + val bluetoothManager = getSystemService(BluetoothManager::class.java) + val bluetoothAdapter = bluetoothManager.adapter + + val bleScanner = bluetoothAdapter.bluetoothLeScanner + + if (bleScanner == null) { + Log.w(TAG, "startBleScanner: ble scanner not available") + return + } + + val appSettings = appDataRepository.settings.value + + val scanSettings = ScanSettings.Builder() + .setScanMode(appSettings.bleScanMode) + .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) + .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) + .setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT) + .setReportDelay(appSettings.bleReportDelay) + .build() + + val manufacturerData = byteArrayOf(0x07, 0x19) + val manufacturerMask = byteArrayOf( + 0xFF.toByte(), + 0xFF.toByte() + ) + + val filter = ScanFilter.Builder() + .setManufacturerData( + 0x004C, + manufacturerData, + manufacturerMask + ) + .build() + + try { + bleScanner.startScan(listOf(filter), scanSettings, bleScanCallback) + Log.i(TAG, "Started BLE scan with user-set params: scanMode: ${appSettings.bleScanMode}, reportDelay: ${appSettings.bleReportDelay}") + } catch (e: Exception) { + if (e.message?.contains("too frequently") == true) { + CoroutineScope(Dispatchers.IO).launch { + delay(5.seconds) + startBleScanner() + } + } else { + Log.e(TAG, "Error starting BLE scan", e) + } + } + } + + private fun stopBleScanner() { + val bluetoothManager = getSystemService(BluetoothManager::class.java) + val bluetoothAdapter = bluetoothManager.adapter + + val bleScanner = bluetoothAdapter.bluetoothLeScanner + + if (bleScanner == null) { + Log.w(TAG, "stopBleScanner: ble scanner not available") + return + } + + try { + bleScanner.stopScan(bleScanCallback) + Log.i(TAG, "Stopped BLE scan") + } catch (e: Exception) { + Log.e(TAG, "Error stopping BLE scan", e) + } + } + + private fun createDevice( + bluetoothDevice: BluetoothDevice + ): Device<*, *, *>? { + + val aacpUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") + + val bluetoothManager = getSystemService(BluetoothManager::class.java) + val bluetoothAdapter = bluetoothManager.adapter + + val isConnectedMethod = BluetoothDevice::class.java.getMethod("isConnected") + isConnectedMethod.isAccessible = true + + val device = when { + bluetoothDevice.uuids?.contains(aacpUuid) == true -> { + Log.i(TAG, "Apple device detected: ${bluetoothDevice.address.redactMac()}") + AppleDevice( + bluetoothAdapter = bluetoothAdapter, + bluetoothDevice = bluetoothDevice, + currentState = if (isConnectedMethod.invoke(bluetoothDevice) as Boolean) ConnectionState.AVAILABLE else ConnectionState.DISCONNECTED + ) + } + + else -> null + } + + when (device) { + is AppleDevice -> CoroutineScope(Dispatchers.IO).launch { + Log.i(TAG, "Loading device ${device.macAddress.toRedactedString()} from db") + + appleRepository.load(device.macAddress)?.let { entity -> + val cache = entity.cache + + // load irk at the earliest so we can start parsing + val irk = entity.cache.magicKeys[MagicKeyType.IRK] + if (irk != null) { + irkMap[device.macAddress] = irk + rejectedRandomMac.clear() + Log.d(TAG, "Loaded IRK for device ${device.macAddress.toRedactedString()}") + } + + Log.i( + TAG, + "Loaded cached state for device ${device.macAddress.toRedactedString()}: $cache" + ) + val settings = entity.settings + Log.i( + TAG, + "Loaded settings for device ${device.macAddress.toRedactedString()}: $settings" + ) + val metadata = entity.metadata + Log.i( + TAG, + "Loaded metadata for device ${device.macAddress.toRedactedString()}: $metadata" + ) + + device.loadInitialState( + state = AppleState().copy( + capabilities = cache.capabilities, + magicKeys = cache.magicKeys, + controlStates = cache.controlStates, + customEq = cache.customEq, + ), + settings = settings, + metadata = metadata + ) + } + + // assuming that the device is not already in the map + deviceJobs[MacAddress(bluetoothDevice.address)] = mutableListOf() + + deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleState(device)) + deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleSettings(device)) + deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleMetadata(device)) + } + } + + return device + } + + + fun observeAppSettings(): Job { + var oldAppSettings: AppSettingsEntity = appDataRepository.settings.value + + return CoroutineScope(Dispatchers.IO).launch { + appDataRepository.settings.collect { settings -> + if (oldAppSettings.bleReportDelay != settings.bleReportDelay || oldAppSettings.bleScanMode != settings.bleScanMode) { + Log.d(TAG, "BLE settings changed: $settings, restarting scanner") + stopBleScanner() + startBleScanner() + } + oldAppSettings = settings + } + } + } + + fun observeAppleState(device: AppleDevice): Job = CoroutineScope(Dispatchers.IO).launch { + var previousState = device.state.value + + device.state.collect { state -> + val deviceSettings = device.settings.value + + + if (state.aacpPackets != previousState.aacpPackets) { + if (!hasConnectedToAACP) { + appDataRepository.updateState { it.copy(hasConnectedToAACP = true) } + } + } + + when { + state.capabilities != previousState.capabilities -> { + Log.i( + TAG, + "capabilities changed for device ${device.macAddress.toRedactedString()}" + ) + Log.d(TAG, "capabilities: ${state.capabilities}") + + if (state.capabilities.isNotEmpty()) { + appleRepository.saveCacheFromState(device.macAddress, state) + } + } + + state.battery != previousState.battery -> { + Log.i( + TAG, + "battery state changed for device ${device.macAddress.toRedactedString()}" + ) + Log.d(TAG, "battery state: ${state.battery}") + + Log.d(TAG, "updating widgets") + updateWidgets() + + Log.d(TAG, "updating island window") + if (islandWindow?.isVisible == true) { + islandWindow?.updateBattery(state.battery) + } + + Log.d(TAG, "updating notification") + updateDeviceNotification(device = device) + + // TODO:Shizuku + /* Log.d(TAG, "updating bluetooth metadata") + shizuku.setMetadata( + device.macAddress.value, + BluetoothMetadata.METADATA_UNTETHERED_CASE_BATTERY, + state.battery.find { it.component == BatteryComponent.CASE }?.level.toString() + .toByteArray() + ) + shizuku.setMetadata( + device.macAddress.value, + BluetoothMetadata.METADATA_UNTETHERED_CASE_CHARGING, + (if (state.battery.find { it.component == BatteryComponent.CASE }?.status == BatteryStatus.CHARGING + || state.battery.find { it.component == BatteryComponent.CASE }?.status == BatteryStatus.OPTIMIZED_CHARGING + ) "1" else "0").toByteArray() + ) + shizuku.setMetadata( + device.macAddress.value, + BluetoothMetadata.METADATA_UNTETHERED_LEFT_BATTERY, + state.battery.find { it.component == BatteryComponent.LEFT }?.level.toString() + .toByteArray() + ) + shizuku.setMetadata( + device.macAddress.value, + BluetoothMetadata.METADATA_UNTETHERED_LEFT_CHARGING, + (if (state.battery.find { it.component == BatteryComponent.LEFT }?.status == BatteryStatus.CHARGING + || state.battery.find { it.component == BatteryComponent.LEFT }?.status == BatteryStatus.OPTIMIZED_CHARGING + ) "1" else "0").toByteArray() + ) + shizuku.setMetadata( + device.macAddress.value, + BluetoothMetadata.METADATA_UNTETHERED_RIGHT_BATTERY, + state.battery.find { it.component == BatteryComponent.RIGHT }?.level.toString() + .toByteArray() + ) + shizuku.setMetadata( + device.macAddress.value, + BluetoothMetadata.METADATA_UNTETHERED_RIGHT_CHARGING, + (if (state.battery.find { it.component == BatteryComponent.RIGHT }?.status == BatteryStatus.CHARGING + || state.battery.find { it.component == BatteryComponent.RIGHT }?.status == BatteryStatus.OPTIMIZED_CHARGING + ) "1" else "0").toByteArray() + ) */ + } + + state.componentState != previousState.componentState -> { + Log.i( + TAG, + "component state changed for device ${device.macAddress.toRedactedString()}" + ) + Log.d(TAG, "component state: ${state.componentState}") + + val earDetectionCtrlCmdValue = + state.controlStates[ControlCommandIdentifier.EAR_DETECTION_CONFIG] + ?: byteArrayOf(0x01.toByte()) + Log.d( + TAG, + "ear detection control command value: ${earDetectionCtrlCmdValue.toHexString()}" + ) + val earDetectionEnabled = earDetectionCtrlCmdValue[0] == 0x01.toByte() + Log.d(TAG, "ear detection enabled: $earDetectionEnabled") + if (earDetectionEnabled) { + processComponentStateChange( + device = device, + previousComponentState = previousState.componentState, + newComponentState = state.componentState, + disconnectWhenNotWearing = deviceSettings.disconnectWhenNotWearing + ) + } + + Log.d(TAG, "updating notification") + updateDeviceNotification(device = device) + } + + state.controlStates != previousState.controlStates -> { + Log.i( + TAG, + "control states changed for device ${device.macAddress.toRedactedString()}" + ) + Log.d(TAG, "control states: ${state.controlStates}") + + if (state.controlStates.isNotEmpty()) { + appleRepository.saveCacheFromState(device.macAddress, state) + } + + Log.d(TAG, "updating notification") + updateDeviceNotification(device = device) + } + + state.conversationalAwarenessState != previousState.conversationalAwarenessState -> { + Log.i( + TAG, + "conversational awareness state changed for device ${device.macAddress.toRedactedString()}" + ) + Log.d( + TAG, + "conversational awareness state: ${state.conversationalAwarenessState}" + ) + + // TODO: multi-step volume change. implementation exists in linux rewrite; copy from there + when (state.conversationalAwarenessState) { + 1, 2 -> MediaController.startSpeaking() + 6, 8, 9 -> MediaController.stopSpeaking() + } + } + } + previousState = state + } + } + + fun observeAppleSettings(device: AppleDevice): Job = CoroutineScope(Dispatchers.IO).launch { + var previousSettings = device.settings.value + + device.settings.collect { settings -> + if (settings != previousSettings) { + Log.i(TAG, "settings changed for device ${device.macAddress.toRedactedString()}") + Log.d(TAG, "settings: $settings") + + appleRepository.saveSettings(device.macAddress, settings) + } + previousSettings = settings + } + } + + fun observeAppleMetadata(device: AppleDevice): Job = CoroutineScope(Dispatchers.IO).launch { + var previousMetadata = device.metadata.value + + device.metadata.collect { metadata -> + if (metadata != previousMetadata) { + Log.i(TAG, "metadata changed for device ${device.macAddress.toRedactedString()}") + Log.d(TAG, "metadata: $metadata") + + appleRepository.saveMetadata(device.macAddress, metadata) + + setAppleBluetoothMetadata(device) + + val notificationManager = getSystemService(NotificationManager::class.java) + val channel = + notificationManager.getNotificationChannel("device_${device.macAddress}") + channel?.name = "Device ${metadata.name}" + notificationManager.createNotificationChannel(channel) + } + previousMetadata = metadata + } + } + + fun showIsland( + device: Device<*, *, *>, + type: IslandType = IslandType.CONNECTED, + reversed: Boolean = false, + otherDeviceName: String? = null + ) { + Log.d(TAG, "Showing island window") + + val state = device.state.value + val settings = device.settings.value + + when (state) { + is AppleState -> { + val state = device.state.value as AppleState + val settings = settings as AppleSettings + val metadata = device.metadata.value + + if (state.componentState.isEmpty()) { + Log.w(TAG, "No component state available, can't show island") + return + } + + if (settings.showIslandPopup) { + if (!Settings.canDrawOverlays(this)) { + Log.w(TAG, "No permission for SYSTEM_ALERT_WINDOW") + return + } + + Log.i(TAG, "Showing island for device ${device.macAddress.toRedactedString()}") + + val leftBattery = + state.battery.find { it.component == BatteryComponent.LEFT }?.level ?: 0 + val rightBattery = + state.battery.find { it.component == BatteryComponent.RIGHT }?.level ?: 0 + val batteryPercentage = leftBattery.coerceAtMost(rightBattery) + + if (islandWindow != null && islandWindow?.isVisible == true) { + Log.i( + TAG, + "Island window already visible, updating instead of creating new one" + ) + islandWindow?.forceClose() + return + } + + islandWindow = IslandWindow(this) + + islandWindow?.show( + name = metadata.name, + batteryPercentage = batteryPercentage, + context = this, + type = type, + reversed = reversed, + otherDeviceName = otherDeviceName + ) + } + + } + + else -> { + Log.d( + TAG, + "Unsupported device state: ${device.state.value.javaClass.simpleName}, not showing island" + ) + return + } + } + } + + fun updateDeviceNotification( + device: Device<*, *, *> + ) { + val notificationManager = getSystemService(NotificationManager::class.java) + + val notificationIntent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, + 0, + notificationIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notificationId = device.macAddress.toNotificationId() + + if (device.connectionState.value == ConnectionState.CONNECTED || device.connectionState.value == ConnectionState.AVAILABLE) { + when (device) { + is AppleDevice -> { + Log.d( + TAG, + "Updating notification for Apple device ${device.macAddress.toRedactedString()}" + ) + + val updatedNotificationBuilder = + NotificationCompat.Builder(this, "device_${device.macAddress}") + .setSmallIcon(R.drawable.ic_airpods) + .setContentTitle(device.metadata.value.name) + .setContentText(device.state.value.battery.joinToString(" ") { "${it.component.name[0]}: ${it.level}%" }) + .setContentIntent(pendingIntent) + .setCategory(Notification.CATEGORY_STATUS) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) + .addAction( + R.drawable.ic_transparency, // icon never shows up?? (tested on AOSP ROMs) + "Transparency", + PendingIntent.getService( + this, + notificationId + 3, + Intent(this, LibrePodsService::class.java).apply { + action = "ACTION_SET_ANC_MODE" + putExtra("MAC_ADDRESS", device.macAddress.toString()) + putExtra("ANC_MODE", 3) + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ), + ) + .addAction( + R.drawable.ic_adaptive, + "Adaptive", + PendingIntent.getService( + this, + notificationId + 4, + Intent(this, LibrePodsService::class.java).apply { + action = "ACTION_SET_ANC_MODE" + putExtra("MAC_ADDRESS", device.macAddress.toString()) + putExtra("ANC_MODE", 4) + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ), + ) + .addAction( + R.drawable.ic_noise_cancellation, + "Noise Cancellation", + PendingIntent.getService( + this, + notificationId + 2, + Intent(this, LibrePodsService::class.java).apply { + action = "ACTION_SET_ANC_MODE" + putExtra("MAC_ADDRESS", device.macAddress.toString()) + putExtra("ANC_MODE", 2) + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ), + ) + + val updatedNotification = updatedNotificationBuilder.build() + + notificationManager.notify(notificationId, updatedNotification) + } + } + + } else { + notificationManager.cancel(notificationId) + } + } + + fun startForegroundNotification() { + val disconnectedNotificationChannel = NotificationChannel( + "background_service_status", + "Background Service Status", + NotificationManager.IMPORTANCE_NONE + ) + + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(disconnectedNotificationChannel) + + val notificationSettingsIntent = + Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + putExtra(Settings.EXTRA_CHANNEL_ID, "background_service_status") + } + + val pendingIntentNotifDisable = PendingIntent.getActivity( + this, + 0, + notificationSettingsIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(this, "background_service_status") + .setSmallIcon(R.drawable.ic_airpods).setContentTitle("Background Service Running") + .setContentText("Useless notification, disable it by clicking on it.") + .setContentIntent(pendingIntentNotifDisable).setCategory(Notification.CATEGORY_SERVICE) + .setPriority(NotificationCompat.PRIORITY_LOW).setOngoing(true).build() + + try { + startForeground(1, notification) + } catch (e: Exception) { + e.printStackTrace() + } + } + + fun updateWidgets() { + // TODO: Room + val widgetMobileBatteryEnabled = getSharedPreferences( + "settings", + MODE_PRIVATE + ).getBoolean("show_phone_battery_in_widget", false) + + val appWidgetManager = AppWidgetManager.getInstance(this) + val componentName = ComponentName(this, BatteryWidget::class.java) + val widgetIds = appWidgetManager.getAppWidgetIds(componentName) + + val remoteViews = RemoteViews(packageName, R.layout.battery_widget).also { it -> + val openActivityIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + it.setOnClickPendingIntent(R.id.battery_widget, openActivityIntent) + + // TODO: device-specific widgets + val device = + devices.value.values.firstOrNull { it is AppleDevice && it.connectionState.value == ConnectionState.CONNECTED } as? AppleDevice + + val leftBattery = + device?.state?.value?.battery?.find { it.component == BatteryComponent.LEFT } + val rightBattery = + device?.state?.value?.battery?.find { it.component == BatteryComponent.RIGHT } + val caseBattery = + device?.state?.value?.battery?.find { it.component == BatteryComponent.CASE } + + it.setTextViewText(R.id.left_battery_widget, leftBattery?.let { + "${it.level}%" + } ?: "") + it.setProgressBar( + R.id.left_battery_progress, 100, leftBattery?.level ?: 0, false + ) + it.setViewVisibility( + R.id.left_charging_icon, + if (leftBattery?.status == BatteryStatus.CHARGING || leftBattery?.status == BatteryStatus.OPTIMIZED_CHARGING) View.VISIBLE else View.GONE + ) + + it.setTextViewText(R.id.right_battery_widget, rightBattery?.let { + "${it.level}%" + } ?: "") + it.setProgressBar( + R.id.right_battery_progress, 100, rightBattery?.level ?: 0, false + ) + it.setViewVisibility( + R.id.right_charging_icon, + if (rightBattery?.status == BatteryStatus.CHARGING || rightBattery?.status == BatteryStatus.OPTIMIZED_CHARGING) View.VISIBLE else View.GONE + ) + + it.setTextViewText(R.id.case_battery_widget, caseBattery?.let { + "${it.level}%" + } ?: "") + it.setProgressBar( + R.id.case_battery_progress, 100, caseBattery?.level ?: 0, false + ) + it.setViewVisibility( + R.id.case_charging_icon, + if (caseBattery?.status == BatteryStatus.CHARGING || caseBattery?.status == BatteryStatus.OPTIMIZED_CHARGING) View.VISIBLE else View.GONE + ) + + it.setViewVisibility( + R.id.phone_battery_widget_container, + if (widgetMobileBatteryEnabled) View.VISIBLE else View.GONE + ) + if (widgetMobileBatteryEnabled) { + val batteryManager = getSystemService(BatteryManager::class.java) + val batteryLevel = + batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) + val charging = + batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_STATUS) == BatteryManager.BATTERY_STATUS_CHARGING + it.setTextViewText( + R.id.phone_battery_widget, "$batteryLevel%" + ) + it.setViewVisibility( + R.id.phone_charging_icon, if (charging) View.VISIBLE else View.GONE + ) + it.setProgressBar( + R.id.phone_battery_progress, 100, batteryLevel, false + ) + } + } + appWidgetManager.updateAppWidget(widgetIds, remoteViews) + } + + private fun processComponentStateChange( + device: Device<*, *, *>, + previousComponentState: Set, + newComponentState: Set, + disconnectWhenNotWearing: Boolean + ) { + val old = earPresenceOf(previousComponentState) + val new = earPresenceOf(newComponentState) + if (old == new) return + + Log.d(TAG, "earPresence: $old -> $new") + + + // new != NONE because old!=new + if (old == EarPresence.NONE && islandWindow?.isVisible != true) { + showIsland( + device = device, + type = IslandType.CONNECTED + ) + Log.i(TAG, "User put in at least one component, showing island.") + } + + if (new == EarPresence.NONE && islandWindow?.isVisible == true) { + islandWindow?.close() + } + + var justEnabledA2dp = false + + when { + old == EarPresence.NONE -> { + Log.d( + TAG, + "User put in at least one component, enabling audio for device ${device.macAddress.toRedactedString()}" + ) + device.enableAudio(this) + device.connectA2dp(this) + justEnabledA2dp = true + + device.waitForA2dpConnection(this) { + MediaController.sendPlay() + MediaController.iPausedTheMedia = false + } + + if (MediaController.getMusicActive()) { + MediaController.userPlayedTheMedia = true + } + if (new == EarPresence.PARTIAL) { + MediaController.sendPlay() + MediaController.iPausedTheMedia = false + } + } + + new == EarPresence.NONE -> { + MediaController.sendPause(force = true) + if (disconnectWhenNotWearing) { + Log.d( + TAG, + "Disconnecting audio for device ${device.macAddress.toRedactedString()} because user took out all components and disconnectWhenNotWearing is true" + ) + device.disableAudio(this) + device.disconnectAudio(this) + } + } + } + + when { + new == EarPresence.FULL -> { + Log.d("AirPodsParser", "User put in all components.") + MediaController.userPlayedTheMedia = false + if (!justEnabledA2dp) { + MediaController.sendPlay() + MediaController.iPausedTheMedia = false + } + } + + old == EarPresence.FULL -> { + Log.d("AirPodsParser", "User took one out.") + MediaController.userPlayedTheMedia = false + if (new == EarPresence.PARTIAL) { + MediaController.sendPause() + } + } + } + } + + // TODO: Shizuku + private fun setAppleBluetoothMetadata( + @Suppress("unused") device: AppleDevice + ) { + /* val macAddress = device.macAddress.value + val metadata = device.metadata.value + val spec = AirPodsSpecs.getSpec(metadata.model) + + shizuku.setMetadata( + macAddress, + BluetoothMetadata.METADATA_MAIN_ICON, + resToUri(spec.primaryImageRes).toString().toByteArray() + ) + shizuku.setMetadata( + macAddress, BluetoothMetadata.METADATA_MODEL_NAME, metadata.modelNumber.toByteArray() + ) + shizuku.setMetadata( + macAddress, + BluetoothMetadata.METADATA_DEVICE_TYPE, + BluetoothMetadata.DEVICE_TYPE_UNTETHERED_HEADSET.toByteArray() + ) + spec.caseImageRes?.let { + shizuku.setMetadata( + macAddress, + BluetoothMetadata.METADATA_UNTETHERED_CASE_ICON, + resToUri(it).toString().toByteArray() + ) + } +// shizuku.setMetadata( +// macAddress, +// BluetoothMetadata.METADATA_UNTETHERED_RIGHT_ICON, +// resToUri(spec.components.find { it.type == DeviceComponent.RIGHT }.imageRes).toString().toByteArray() +// ) +// shizuku.setMetadata( +// macAddress, +// BluetoothMetadata.METADATA_UNTETHERED_LEFT_ICON, +// resToUri(spec.components.find { it.type == DeviceComponent.LEFT }.imageRes).toString().toByteArray() +// ) + shizuku.setMetadata( + macAddress, + BluetoothMetadata.METADATA_MANUFACTURER_NAME, + metadata.manufacturer.toByteArray() + ) + shizuku.setMetadata( + macAddress, BluetoothMetadata.METADATA_COMPANION_APP, "me.kavishdevar.librepods".toByteArray() + ) + shizuku.setMetadata( + macAddress, + BluetoothMetadata.METADATA_UNTETHERED_CASE_LOW_BATTERY_THRESHOLD, + "20".toByteArray() + ) + shizuku.setMetadata( + macAddress, + BluetoothMetadata.METADATA_UNTETHERED_LEFT_LOW_BATTERY_THRESHOLD, + "20".toByteArray() + ) + shizuku.setMetadata( + macAddress, + BluetoothMetadata.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD, + "20".toByteArray() + ) + Log.d(TAG, "Metadata set for apple device ${device.macAddress.toRedactedString()}") + */ + } +} + +// TODO: move out of this file +private enum class EarPresence { NONE, PARTIAL, FULL } + +private fun earPresenceOf(components: Set): EarPresence { + if (components.isEmpty()) return EarPresence.NONE + val inEarCount = components.count { it.status == ComponentStatus.IN_EAR } + return when (inEarCount) { + 0 -> EarPresence.NONE + components.size -> EarPresence.FULL + else -> EarPresence.PARTIAL + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/AudioUtils.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/AudioUtils.kt new file mode 100644 index 00000000..03820997 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/AudioUtils.kt @@ -0,0 +1,17 @@ +package me.kavishdevar.librepods.utils + +fun calculateLevel(pcm: ByteArray): Float { + if (pcm.isEmpty()) return 0f + + var sum = 0.0 + + for (i in pcm.indices step 2) { + val sample = ((pcm[i + 1].toInt() shl 8) or (pcm[i].toInt() and 0xff)).toShort() + val normalized = sample / 32768.0 + sum += normalized * normalized + } + + val rms = kotlin.math.sqrt(sum / (pcm.size / 2)) + + return rms.toFloat().coerceIn(0f, 1f) +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/DragUtils.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/DragUtils.kt similarity index 100% rename from android/app/src/main/java/me/kavishdevar/librepods/utils/DragUtils.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/utils/DragUtils.kt diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/GestureDetector.kt similarity index 95% rename from android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/utils/GestureDetector.kt index 9892096b..30e8271c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/GestureDetector.kt @@ -29,8 +29,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import me.kavishdevar.librepods.services.AirPodsService -import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.services.LibrePodsService import java.util.Collections import java.util.concurrent.CopyOnWriteArrayList import kotlin.io.encoding.ExperimentalEncodingApi @@ -40,7 +39,7 @@ import kotlin.math.min import kotlin.math.pow class GestureDetector( - private val airPodsService: AirPodsService + private val librepodsService: LibrePodsService ) { companion object { private const val TAG = "GestureDetector" @@ -55,7 +54,7 @@ class GestureDetector( private const val MAX_VALID_ORIENTATION_VALUE = 6000 } - val audio = GestureFeedback(ServiceManager.getService()?.baseContext!!) +// val audio = GestureFeedback(ServiceManager.getService()?.baseContext!!) private val horizontalBuffer = Collections.synchronizedList(ArrayList()) private val verticalBuffer = Collections.synchronizedList(ArrayList()) @@ -104,7 +103,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U isRunning = true gestureDetectedCallback = onGestureDetected - Log.d(TAG, "started: ${airPodsService.startHeadTracking()}") +// Log.d(TAG, "started: ${airPodsService.startHeadTracking()}") clearData() @@ -118,7 +117,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U val gesture = detectGestures() if (gesture != null) { withContext(Dispatchers.Main) { - audio.playConfirmation(gesture) +// audio.playConfirmation(gesture) gestureDetectedCallback?.invoke(gesture) stopDetection(doNotStop) @@ -134,14 +133,13 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U Log.d(TAG, "Stopping gesture detection") isRunning = false - if (!doNotStop) airPodsService.stopHeadTracking() +// if (!doNotStop) airPodsService.stopHeadTracking() detectionJob?.cancel() detectionJob = null gestureDetectedCallback = null } - @RequiresApi(Build.VERSION_CODES.R) fun processHeadOrientation(horizontal: Int, vertical: Int) { if (!isRunning) return @@ -158,7 +156,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U if (significantHorizontal && (!significantVertical || abs(horizontalDelta) > abs(verticalDelta))) { CoroutineScope(Dispatchers.Main).launch { - audio.playDirectional(isVertical = false, value = horizontalDelta) +// audio.playDirectional(isVertical = false, value = horizontalDelta) } significantMotion = true lastSignificantMotionTime = System.currentTimeMillis() @@ -166,7 +164,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U } else if (significantVertical) { CoroutineScope(Dispatchers.Main).launch { - audio.playDirectional(isVertical = true, value = verticalDelta) +// audio.playDirectional(isVertical = true, value = verticalDelta) } significantMotion = true lastSignificantMotionTime = System.currentTimeMillis() diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/GestureFeedback.kt similarity index 100% rename from android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/utils/GestureFeedback.kt diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/HeadOrientation.kt similarity index 100% rename from android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/utils/HeadOrientation.kt diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/KotlinModule.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/KotlinModule.kt similarity index 99% rename from android/app/src/main/java/me/kavishdevar/librepods/utils/KotlinModule.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/utils/KotlinModule.kt index b3289268..53a8f4de 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/KotlinModule.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/KotlinModule.kt @@ -29,7 +29,7 @@ class KotlinModule: XposedModule() { try { if (param.isFirstPackage) { val abi = android.os.Build.SUPPORTED_ABIS.first() - val soName = "libl2c_fcr_hook.so" + val soName = "libfluoride_hooks.so" val candidates = buildList { add("${moduleApplicationInfo.sourceDir}!/lib/$abi/$soName") diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/LogCollector.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/LogCollector.kt similarity index 100% rename from android/app/src/main/java/me/kavishdevar/librepods/utils/LogCollector.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/utils/LogCollector.kt diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/MediaController.kt similarity index 96% rename from android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/utils/MediaController.kt index 400e0ff7..efece1e6 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/MediaController.kt @@ -23,14 +23,11 @@ package me.kavishdevar.librepods.utils import android.content.SharedPreferences import android.media.AudioManager import android.media.AudioPlaybackConfiguration -import android.os.Build import android.os.Handler import android.os.Looper import android.os.SystemClock import android.util.Log import android.view.KeyEvent -import androidx.annotation.RequiresApi -import me.kavishdevar.librepods.services.ServiceManager import kotlin.io.encoding.ExperimentalEncodingApi object MediaController { @@ -66,7 +63,10 @@ object MediaController { private var lastPlayWithReplay: Boolean = false private var lastPlayTime: Long = 0L - fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) { + private var localMac: String? = null + + fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences, localMac: String?) { + this.localMac = localMac if (this::audioManager.isInitialized) { return } @@ -97,7 +97,6 @@ object MediaController { } val cb = object : AudioManager.AudioPlaybackCallback() { - @RequiresApi(Build.VERSION_CODES.R) override fun onPlaybackConfigChanged(configs: MutableList?) { super.onPlaybackConfigChanged(configs) val now = SystemClock.uptimeMillis() @@ -157,7 +156,8 @@ object MediaController { pausedForOtherDevice = false userPlayedTheMedia = true if (!pausedWhileTakingOver) { - ServiceManager.getService()?.takeOver("music") +// TODO +// ServiceManager.getService()?.takeOver("music") } } else { Log.d("MediaController", "Skipping take-over due to recent ownership loss or no new music/movie") @@ -171,12 +171,11 @@ object MediaController { } if (configs != null && !iPausedTheMedia) { - val localMac = ServiceManager.getService()?.localMac ?: return - if (localMac == "") return - ServiceManager.getService()?.aacpManager?.sendMediaInformataion( - localMac, - isActive - ) + if (localMac.isNullOrBlank()) return +// ServiceManager.getService()?.aacpManager?.sendMediaInformataion( +// localMac, +// isActive +// ) Log.d("MediaController", "User changed media state themselves; will wait for ear detection pause before auto-play") handler.postDelayed({ userPlayedTheMedia = audioManager.isMusicActive @@ -191,7 +190,8 @@ object MediaController { if (lastKnownIsMusicActive != true) { if (!recentlyLostOwnership) { Log.d("MediaController", "Music/movie is active and not pausedWhileTakingOver; requesting takeOver") - ServiceManager.getService()?.takeOver("music") +// TODO +// ServiceManager.getService()?.takeOver("music") } else { Log.d("MediaController", "Skipping take-over due to recent ownership loss") } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/RootlessSupport.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/RootlessSupport.kt similarity index 100% rename from android/app/src/main/java/me/kavishdevar/librepods/utils/RootlessSupport.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/utils/RootlessSupport.kt diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/Stuff.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/Stuff.kt new file mode 100644 index 00000000..3544edbe --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/Stuff.kt @@ -0,0 +1,14 @@ +package me.kavishdevar.librepods.utils + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.sp + +@Composable +fun Int.nonScaledSp() = (this / LocalDensity.current.fontScale).sp + +fun String.redactMac(): String { + val parts = this.split(":") + if (parts.size != 6) return this + return "${parts[0]}:${parts[1]}:XX:XX:${parts[4]}:${parts[5]}" +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/SystemAPIUtils.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/SystemAPIUtils.kt similarity index 55% rename from android/app/src/main/java/me/kavishdevar/librepods/utils/SystemAPIUtils.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/utils/SystemAPIUtils.kt index cd91e24c..d566044d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/SystemAPIUtils.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/SystemAPIUtils.kt @@ -1,105 +1,89 @@ package me.kavishdevar.librepods.utils -import android.bluetooth.BluetoothDevice -import android.util.Log - -object SystemApisUtils { - +object BluetoothMetadata { /** * Device type which is used in METADATA_DEVICE_TYPE * Indicates this Bluetooth device is an untethered headset. * @hide */ - val BluetoothDevice.DEVICE_TYPE_UNTETHERED_HEADSET: String - get() = "Untethered Headset" + const val DEVICE_TYPE_UNTETHERED_HEADSET: String = "Untethered Headset" /** * Maximum length of a metadata entry, this is to avoid exploding Bluetooth * disk usage * @hide */ - val BluetoothDevice.METADATA_MAX_LENGTH: Int - get() = 2048 + const val METADATA_MAX_LENGTH: Int = 2048 /** * Manufacturer name of this Bluetooth device * Data type should be {@String} as [Byte] array. * @hide */ - val BluetoothDevice.METADATA_MANUFACTURER_NAME: Int - get() = 0 + const val METADATA_MANUFACTURER_NAME: Int = 0 /** * Model name of this Bluetooth device * Data type should be {@String} as [Byte] array. * @hide */ - val BluetoothDevice.METADATA_MODEL_NAME: Int - get() = 1 + const val METADATA_MODEL_NAME: Int = 1 /** * Software version of this Bluetooth device * Data type should be {@String} as [Byte] array. * @hide */ - val BluetoothDevice.METADATA_SOFTWARE_VERSION: Int - get() = 2 + const val METADATA_SOFTWARE_VERSION: Int = 2 /** * Hardware version of this Bluetooth device * Data type should be {@String} as [Byte] array. * @hide */ - val BluetoothDevice.METADATA_HARDWARE_VERSION: Int - get() = 3 + const val METADATA_HARDWARE_VERSION: Int = 3 /** * Package name of the companion app, if any * Data type should be {@String} as [Byte] array. * @hide */ - val BluetoothDevice.METADATA_COMPANION_APP: Int - get() = 4 + const val METADATA_COMPANION_APP: Int = 4 /** * URI to the main icon shown on the settings UI * Data type should be [Byte] array. * @hide */ - val BluetoothDevice.METADATA_MAIN_ICON: Int - get() = 5 + const val METADATA_MAIN_ICON: Int = 5 /** * Whether this device is an untethered headset with left, right and case * Data type should be {@String} as [Byte] array. * @hide */ - val BluetoothDevice.METADATA_IS_UNTETHERED_HEADSET: Int - get() = 6 + const val METADATA_IS_UNTETHERED_HEADSET: Int = 6 /** * URI to icon of the left headset * Data type should be [Byte] array. * @hide */ - val BluetoothDevice.METADATA_UNTETHERED_LEFT_ICON: Int - get() = 7 + const val METADATA_UNTETHERED_LEFT_ICON: Int = 7 /** * URI to icon of the right headset * Data type should be [Byte] array. * @hide */ - val BluetoothDevice.METADATA_UNTETHERED_RIGHT_ICON: Int - get() = 8 + const val METADATA_UNTETHERED_RIGHT_ICON: Int = 8 /** * URI to icon of the headset charging case * Data type should be [Byte] array. * @hide */ - val BluetoothDevice.METADATA_UNTETHERED_CASE_ICON: Int - get() = 9 + const val METADATA_UNTETHERED_CASE_ICON: Int = 9 /** * Battery level of left headset @@ -107,8 +91,7 @@ object SystemApisUtils { * as invalid. * @hide */ - val BluetoothDevice.METADATA_UNTETHERED_LEFT_BATTERY: Int - get() = 10 + const val METADATA_UNTETHERED_LEFT_BATTERY: Int = 10 /** * Battery level of rigth headset @@ -116,8 +99,7 @@ object SystemApisUtils { * as invalid. * @hide */ - val BluetoothDevice.METADATA_UNTETHERED_RIGHT_BATTERY: Int - get() = 11 + const val METADATA_UNTETHERED_RIGHT_BATTERY: Int = 11 /** * Battery level of the headset charging case @@ -125,32 +107,28 @@ object SystemApisUtils { * as invalid. * @hide */ - val BluetoothDevice.METADATA_UNTETHERED_CASE_BATTERY: Int - get() = 12 + const val METADATA_UNTETHERED_CASE_BATTERY: Int = 12 /** * Whether the left headset is charging * Data type should be {@String} as [Byte] array. * @hide */ - val BluetoothDevice.METADATA_UNTETHERED_LEFT_CHARGING: Int - get() = 13 + const val METADATA_UNTETHERED_LEFT_CHARGING: Int = 13 /** * Whether the right headset is charging * Data type should be {@String} as [Byte] array. * @hide */ - val BluetoothDevice.METADATA_UNTETHERED_RIGHT_CHARGING: Int - get() = 14 + const val METADATA_UNTETHERED_RIGHT_CHARGING: Int = 14 /** * Whether the headset charging case is charging * Data type should be {@String} as [Byte] array. * @hide */ - val BluetoothDevice.METADATA_UNTETHERED_CASE_CHARGING: Int - get() = 15 + const val METADATA_UNTETHERED_CASE_CHARGING: Int = 15 /** * URI to the enhanced settings UI slice @@ -158,35 +136,30 @@ object SystemApisUtils { * the UI does not exist. * @hide */ - val BluetoothDevice.METADATA_ENHANCED_SETTINGS_UI_URI: Int - get() = 16 + const val METADATA_ENHANCED_SETTINGS_UI_URI: Int = 16 /** * @hide */ - val BluetoothDevice.COMPANION_TYPE_PRIMARY: String - get() = "COMPANION_PRIMARY" + const val COMPANION_TYPE_PRIMARY: String = "COMPANION_PRIMARY" /** * @hide */ - val BluetoothDevice.COMPANION_TYPE_SECONDARY: String - get() = "COMPANION_SECONDARY" + const val COMPANION_TYPE_SECONDARY: String = "COMPANION_SECONDARY" /** * @hide */ - val BluetoothDevice.COMPANION_TYPE_NONE: String - get() = "COMPANION_NONE" + const val COMPANION_TYPE_NONE: String = "COMPANION_NONE" /** * Type of the Bluetooth device, must be within the list of - * BluetoothDevice.DEVICE_TYPE_* + * DEVICE_TYPE_* * Data type should be {@String} as [Byte] array. * @hide */ - val BluetoothDevice.METADATA_DEVICE_TYPE: Int - get() = 17 + const val METADATA_DEVICE_TYPE: Int = 17 /** * Battery level of the Bluetooth device, use when the Bluetooth device @@ -194,48 +167,42 @@ object SystemApisUtils { * Data type should be {@String} as [Byte] array. * @hide */ - val BluetoothDevice.METADATA_MAIN_BATTERY: Int - get() = 18 + const val METADATA_MAIN_BATTERY: Int = 18 /** * Whether the device is charging. * Data type should be {@String} as [Byte] array. * @hide */ - val BluetoothDevice.METADATA_MAIN_CHARGING: Int - get() = 19 + const val METADATA_MAIN_CHARGING: Int = 19 /** * The battery threshold of the Bluetooth device to show low battery icon. * Data type should be {@String} as [Byte] array. * @hide */ - val BluetoothDevice.METADATA_MAIN_LOW_BATTERY_THRESHOLD: Int - get() = 20 + const val METADATA_MAIN_LOW_BATTERY_THRESHOLD: Int = 20 /** * The battery threshold of the left headset to show low battery icon. * Data type should be {@String} as [Byte] array. * @hide */ - val BluetoothDevice.METADATA_UNTETHERED_LEFT_LOW_BATTERY_THRESHOLD: Int - get() = 21 + const val METADATA_UNTETHERED_LEFT_LOW_BATTERY_THRESHOLD: Int = 21 /** * The battery threshold of the right headset to show low battery icon. * Data type should be {@String} as [Byte] array. * @hide */ - val BluetoothDevice.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD: Int - get() = 22 + const val METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD: Int = 22 /** * The battery threshold of the case to show low battery icon. * Data type should be {@String} as [Byte] array. * @hide */ - val BluetoothDevice.METADATA_UNTETHERED_CASE_LOW_BATTERY_THRESHOLD: Int - get() = 23 + const val METADATA_UNTETHERED_CASE_LOW_BATTERY_THRESHOLD: Int = 23 /** @@ -243,61 +210,43 @@ object SystemApisUtils { * Data type should be [Byte] array. * @hide */ - val BluetoothDevice.METADATA_SPATIAL_AUDIO: Int - get() = 24 + const val METADATA_SPATIAL_AUDIO: Int = 24 /** * The metadata of the Fast Pair for any custmized feature. * Data type should be [Byte] array. * @hide */ - val BluetoothDevice.METADATA_FAST_PAIR_CUSTOMIZED_FIELDS: Int - get() = 25 + const val METADATA_FAST_PAIR_CUSTOMIZED_FIELDS: Int = 25 /** * The metadata of the Fast Pair for LE Audio capable devices. * Data type should be [Byte] array. * @hide */ - val BluetoothDevice.METADATA_LE_AUDIO: Int - get() = 26 + const val METADATA_LE_AUDIO: Int = 26 /** * The UUIDs (16-bit) of registered to CCC characteristics from Media Control services. * Data type should be [Byte] array. * @hide */ - val BluetoothDevice.METADATA_GMCS_CCCD: Int - get() = 27 + const val METADATA_GMCS_CCCD: Int = 27 /** * The UUIDs (16-bit) of registered to CCC characteristics from Telephony Bearer service. * Data type should be [Byte] array. * @hide */ - val BluetoothDevice.METADATA_GTBS_CCCD: Int - get() = 28 + const val METADATA_GTBS_CCCD: Int = 28 const val BATTERY_LEVEL_UNKNOWN: Int = -1 - const val ACTION_BLUETOOTH_HANDSFREE_BATTERY_CHANGED = "android.intent.action.BLUETOOTH_HANDSFREE_BATTERY_CHANGED" - const val EXTRA_SHOW_BT_HANDSFREE_BATTERY = "android.intent.extra.show_bluetooth_handsfree_battery" - const val EXTRA_BT_HANDSFREE_BATTERY_LEVEL = "android.intent.extra.bluetooth_handsfree_battery_level" + const val ACTION_BLUETOOTH_HANDSFREE_BATTERY_CHANGED = + "android.intent.action.BLUETOOTH_HANDSFREE_BATTERY_CHANGED" + const val EXTRA_SHOW_BT_HANDSFREE_BATTERY = + "android.intent.extra.show_bluetooth_handsfree_battery" + const val EXTRA_BT_HANDSFREE_BATTERY_LEVEL = + "android.intent.extra.bluetooth_handsfree_battery_level" - /** - * Helper method to set metadata using HiddenApiBypass - */ - fun setMetadata(device: BluetoothDevice, key: Int, value: ByteArray): Boolean { - return try { - val method = BluetoothDevice::class.java.getMethod( - "setMetadata", - Int::class.java, - ByteArray::class.java - ) - method.invoke(device, key, value) as Boolean - } catch (e: Exception) { - Log.w("SystemApisUtils", "Failed to set metadata for key $key: ${e.message}") - false - } - } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/XposedServiceHolder.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/XposedServiceHolder.kt similarity index 100% rename from android/app/src/main/java/me/kavishdevar/librepods/utils/XposedServiceHolder.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/utils/XposedServiceHolder.kt diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/XposedState.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/XposedState.kt similarity index 100% rename from android/app/src/main/java/me/kavishdevar/librepods/utils/XposedState.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/utils/XposedState.kt diff --git a/android/app/src/main/res-apple/drawable/airpods_pro_2.png b/android/app/src/main/res-apple/drawable/airpods_pro_2.png deleted file mode 100644 index 681ee750..00000000 Binary files a/android/app/src/main/res-apple/drawable/airpods_pro_2.png and /dev/null differ diff --git a/android/app/src/main/res-apple/drawable/img_airpods_max.png b/android/app/src/main/res-apple/drawable/img_airpods_max.png new file mode 100644 index 00000000..dee14c67 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/img_airpods_max.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_pro_2_buds.png b/android/app/src/main/res-apple/drawable/img_airpods_pro_2_buds.png similarity index 100% rename from android/app/src/main/res-apple/drawable/airpods_pro_2_buds.png rename to android/app/src/main/res-apple/drawable/img_airpods_pro_2_buds.png diff --git a/android/app/src/main/res-apple/drawable/airpods_pro_2_case.png b/android/app/src/main/res-apple/drawable/img_airpods_pro_2_case.png similarity index 100% rename from android/app/src/main/res-apple/drawable/airpods_pro_2_case.png rename to android/app/src/main/res-apple/drawable/img_airpods_pro_2_case.png diff --git a/android/app/src/main/res-apple/font/sf_pro.otf b/android/app/src/main/res-apple/font/sf_pro.otf deleted file mode 100644 index dd28280f..00000000 Binary files a/android/app/src/main/res-apple/font/sf_pro.otf and /dev/null differ diff --git a/android/app/src/main/res/drawable-v24/ic_launcher_background.xml b/android/app/src/main/res/drawable-v26/ic_launcher_background.xml similarity index 100% rename from android/app/src/main/res/drawable-v24/ic_launcher_background.xml rename to android/app/src/main/res/drawable-v26/ic_launcher_background.xml diff --git a/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android/app/src/main/res/drawable-v26/ic_launcher_foreground.xml similarity index 100% rename from android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml rename to android/app/src/main/res/drawable-v26/ic_launcher_foreground.xml diff --git a/android/app/src/main/res/drawable-v24/ic_launcher_monochrome.xml b/android/app/src/main/res/drawable-v26/ic_launcher_monochrome.xml similarity index 100% rename from android/app/src/main/res/drawable-v24/ic_launcher_monochrome.xml rename to android/app/src/main/res/drawable-v26/ic_launcher_monochrome.xml diff --git a/android/app/src/main/res/drawable/app_widget_background.xml b/android/app/src/main/res/drawable/app_widget_background.xml deleted file mode 100644 index 785445c6..00000000 --- a/android/app/src/main/res/drawable/app_widget_background.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/android/app/src/main/res/drawable/app_widget_inner_view_background.xml b/android/app/src/main/res/drawable/app_widget_inner_view_background.xml deleted file mode 100644 index 11a09f9b..00000000 --- a/android/app/src/main/res/drawable/app_widget_inner_view_background.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - \ No newline at end of file diff --git a/android/app/src/main/res/drawable-v21/app_widget_background.xml b/android/app/src/main/res/drawable/bg_app_widget.xml similarity index 100% rename from android/app/src/main/res/drawable-v21/app_widget_background.xml rename to android/app/src/main/res/drawable/bg_app_widget.xml diff --git a/android/app/src/main/res/drawable-v21/app_widget_inner_view_background.xml b/android/app/src/main/res/drawable/bg_app_widget_inner_view.xml similarity index 100% rename from android/app/src/main/res/drawable-v21/app_widget_inner_view_background.xml rename to android/app/src/main/res/drawable/bg_app_widget_inner_view.xml diff --git a/android/app/src/main/res/drawable/island_background.xml b/android/app/src/main/res/drawable/bg_island.xml similarity index 100% rename from android/app/src/main/res/drawable/island_background.xml rename to android/app/src/main/res/drawable/bg_island.xml diff --git a/android/app/src/main/res/drawable/island_battery_background.xml b/android/app/src/main/res/drawable/bg_island_battery.xml similarity index 100% rename from android/app/src/main/res/drawable/island_battery_background.xml rename to android/app/src/main/res/drawable/bg_island_battery.xml diff --git a/android/app/src/main/res/drawable/widget_background.xml b/android/app/src/main/res/drawable/bg_widget.xml similarity index 100% rename from android/app/src/main/res/drawable/widget_background.xml rename to android/app/src/main/res/drawable/bg_widget.xml diff --git a/android/app/src/main/res/drawable/conversational_awareness.xml b/android/app/src/main/res/drawable/conversational_awareness.xml deleted file mode 100644 index 86dcec6d..00000000 --- a/android/app/src/main/res/drawable/conversational_awareness.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - diff --git a/android/app/src/main/res/drawable/adaptive.png b/android/app/src/main/res/drawable/ic_adaptive.png similarity index 100% rename from android/app/src/main/res/drawable/adaptive.png rename to android/app/src/main/res/drawable/ic_adaptive.png diff --git a/android/app/src/main/res/drawable/airpods.xml b/android/app/src/main/res/drawable/ic_airpods.xml similarity index 100% rename from android/app/src/main/res/drawable/airpods.xml rename to android/app/src/main/res/drawable/ic_airpods.xml diff --git a/android/app/src/main/res/drawable/ic_bluetooth.xml b/android/app/src/main/res/drawable/ic_bluetooth.xml deleted file mode 100644 index 2f6fee94..00000000 --- a/android/app/src/main/res/drawable/ic_bluetooth.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/android/app/src/main/res/drawable/close.xml b/android/app/src/main/res/drawable/ic_close.xml similarity index 100% rename from android/app/src/main/res/drawable/close.xml rename to android/app/src/main/res/drawable/ic_close.xml diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..ff1817cd --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..7148c088 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_monochrome.xml b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 00000000..f1af758e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_layers.xml b/android/app/src/main/res/drawable/ic_layers.xml deleted file mode 100644 index 4fcff642..00000000 --- a/android/app/src/main/res/drawable/ic_layers.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/android/app/src/main/res/drawable/noise_cancellation.png b/android/app/src/main/res/drawable/ic_noise_cancellation.png similarity index 100% rename from android/app/src/main/res/drawable/noise_cancellation.png rename to android/app/src/main/res/drawable/ic_noise_cancellation.png diff --git a/android/app/src/main/res/drawable/ic_save.xml b/android/app/src/main/res/drawable/ic_save.xml deleted file mode 100644 index c41d2281..00000000 --- a/android/app/src/main/res/drawable/ic_save.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/android/app/src/main/res/drawable/smartphone.xml b/android/app/src/main/res/drawable/ic_smartphone.xml similarity index 100% rename from android/app/src/main/res/drawable/smartphone.xml rename to android/app/src/main/res/drawable/ic_smartphone.xml diff --git a/android/app/src/main/res/drawable/transparency.png b/android/app/src/main/res/drawable/ic_transparency.png similarity index 100% rename from android/app/src/main/res/drawable/transparency.png rename to android/app/src/main/res/drawable/ic_transparency.png diff --git a/android/app/src/main/res/drawable/settings_voice.xml b/android/app/src/main/res/drawable/settings_voice.xml deleted file mode 100644 index 58315708..00000000 --- a/android/app/src/main/res/drawable/settings_voice.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/android/app/src/main/res/drawable/popup_shape.xml b/android/app/src/main/res/drawable/shape_popup.xml similarity index 100% rename from android/app/src/main/res/drawable/popup_shape.xml rename to android/app/src/main/res/drawable/shape_popup.xml diff --git a/android/app/src/main/res/drawable/popup_button_shape.xml b/android/app/src/main/res/drawable/shape_popup_button.xml similarity index 100% rename from android/app/src/main/res/drawable/popup_button_shape.xml rename to android/app/src/main/res/drawable/shape_popup_button.xml diff --git a/android/app/src/main/res/drawable/widget_button_shape_end.xml b/android/app/src/main/res/drawable/shape_widget_button_end.xml similarity index 100% rename from android/app/src/main/res/drawable/widget_button_shape_end.xml rename to android/app/src/main/res/drawable/shape_widget_button_end.xml diff --git a/android/app/src/main/res/drawable/widget_button_checked_shape_end.xml b/android/app/src/main/res/drawable/shape_widget_button_end_checked.xml similarity index 100% rename from android/app/src/main/res/drawable/widget_button_checked_shape_end.xml rename to android/app/src/main/res/drawable/shape_widget_button_end_checked.xml diff --git a/android/app/src/main/res/drawable/widget_button_shape_middle.xml b/android/app/src/main/res/drawable/shape_widget_button_middle.xml similarity index 100% rename from android/app/src/main/res/drawable/widget_button_shape_middle.xml rename to android/app/src/main/res/drawable/shape_widget_button_middle.xml diff --git a/android/app/src/main/res/drawable/widget_button_checked_shape_middle.xml b/android/app/src/main/res/drawable/shape_widget_button_middle_checked.xml similarity index 100% rename from android/app/src/main/res/drawable/widget_button_checked_shape_middle.xml rename to android/app/src/main/res/drawable/shape_widget_button_middle_checked.xml diff --git a/android/app/src/main/res/drawable/widget_button_shape_start.xml b/android/app/src/main/res/drawable/shape_widget_button_start.xml similarity index 100% rename from android/app/src/main/res/drawable/widget_button_shape_start.xml rename to android/app/src/main/res/drawable/shape_widget_button_start.xml diff --git a/android/app/src/main/res/drawable/widget_button_checked_shape_start.xml b/android/app/src/main/res/drawable/shape_widget_button_start_checked.xml similarity index 100% rename from android/app/src/main/res/drawable/widget_button_checked_shape_start.xml rename to android/app/src/main/res/drawable/shape_widget_button_start_checked.xml diff --git a/android/app/src/main/res/font/hack.otf b/android/app/src/main/res/font/hack.otf deleted file mode 100644 index 4c6f9f69..00000000 Binary files a/android/app/src/main/res/font/hack.otf and /dev/null differ diff --git a/android/app/src/main/res/font/inter.ttf b/android/app/src/main/res/font/inter.ttf new file mode 100644 index 00000000..e31b51e3 Binary files /dev/null and b/android/app/src/main/res/font/inter.ttf differ diff --git a/android/app/src/main/res/font/roboto_flex.ttf b/android/app/src/main/res/font/roboto_flex.ttf new file mode 100644 index 00000000..2e5c2a26 Binary files /dev/null and b/android/app/src/main/res/font/roboto_flex.ttf differ diff --git a/android/app/src/main/res/layout/battery_widget.xml b/android/app/src/main/res/layout/battery_widget.xml index df54f5d4..556d1a38 100644 --- a/android/app/src/main/res/layout/battery_widget.xml +++ b/android/app/src/main/res/layout/battery_widget.xml @@ -7,7 +7,7 @@ android:padding="0dp" android:id="@+id/battery_widget" android:theme="@style/Theme.LibrePods.AppWidgetContainer" - android:background="@drawable/widget_background"> + android:background="@drawable/bg_widget"> @@ -259,10 +259,10 @@ android:textSize="24sp" android:textColor="@color/white" android:gravity="center" - android:fontFamily="@font/sf_pro" + android:fontFamily="@font/inter" android:textFontWeight="300" android:text="Case" tools:ignore="HardcodedText" /> - \ No newline at end of file + diff --git a/android/app/src/main/res/layout/island_window.xml b/android/app/src/main/res/layout/island_window.xml index c804737b..b874ee4e 100644 --- a/android/app/src/main/res/layout/island_window.xml +++ b/android/app/src/main/res/layout/island_window.xml @@ -5,7 +5,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="16dp" - android:background="@drawable/island_background" + android:background="@drawable/bg_island" android:elevation="4dp" android:gravity="center" android:minHeight="115dp" @@ -37,7 +37,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="0dp" - android:fontFamily="@font/sf_pro" + android:fontFamily="@font/inter" android:gravity="bottom" android:includeFontPadding="false" android:lineSpacingExtra="0dp" @@ -52,7 +52,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="0dp" - android:fontFamily="@font/sf_pro" + android:fontFamily="@font/inter" android:gravity="bottom" android:includeFontPadding="false" android:lineSpacingExtra="0dp" @@ -80,7 +80,7 @@ android:indeterminate="false" android:max="100" android:progress="100" - android:progressDrawable="@drawable/island_battery_background" /> + android:progressDrawable="@drawable/bg_island_battery" /> @@ -30,7 +30,7 @@ @@ -60,7 +60,7 @@ @@ -92,7 +92,7 @@ @@ -117,7 +117,7 @@ android:layout_marginStart="2dp" android:layout_marginEnd="12dp" android:layout_weight="1" - android:background="@drawable/widget_button_shape_end" + android:background="@drawable/shape_widget_button_end" android:clickable="true" android:gravity="center" android:orientation="vertical"> @@ -125,7 +125,7 @@ + android:textColor="?android:attr/textColorPrimary" /> diff --git a/android/app/src/main/res/layout/popup_window.xml b/android/app/src/main/res/layout/popup_window.xml index f00068db..621c7b11 100644 --- a/android/app/src/main/res/layout/popup_window.xml +++ b/android/app/src/main/res/layout/popup_window.xml @@ -7,7 +7,7 @@ android:layout_margin="16.dp" android:id="@+id/linear_layout" android:orientation="vertical" - android:background="@drawable/popup_shape"> + android:background="@drawable/shape_popup"> + tools:ignore="HardcodedText" + tools:layout_editor_absoluteX="0dp" /> @@ -70,7 +71,7 @@ android:layout_weight="1" android:textAlignment="center" android:layout_marginTop="16dp" - android:fontFamily="@font/sf_pro" + android:fontFamily="@font/inter" android:text="" android:textColor="@color/popup_text" android:textSize="20sp" @@ -84,7 +85,7 @@ android:layout_weight="1" android:textAlignment="center" android:layout_marginTop="16dp" - android:fontFamily="@font/sf_pro" + android:fontFamily="@font/inter" android:gravity="center" android:text="" android:id="@+id/right_battery" @@ -100,7 +101,7 @@ android:textAlignment="center" android:id="@+id/case_battery" android:layout_marginTop="16dp" - android:fontFamily="@font/sf_pro" + android:fontFamily="@font/inter" android:gravity="center" android:text="" android:textColor="@color/popup_text" diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 42c3394b..8fde4563 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,6 @@ - + diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml index e707e2d2..47e6504e 100644 --- a/android/app/src/main/res/values-v31/styles.xml +++ b/android/app/src/main/res/values-v31/styles.xml @@ -3,13 +3,13 @@ diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 3356edc4..c83abcca 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -280,7 +280,6 @@ Custom Recommended Appearance - Use Material 3 Expressive Material 3 Expressive LibrePods now supports a whole new look based on Material 3 Expressive including updated typography and adaptive color schemes.\nYou can switch back to the Apple look from the app\'s settings. Available only on the latest AirPods Beta firmware. An Apple device running OS version 27 is required to install the beta firmware. @@ -290,4 +289,35 @@ Permissions Privacy Policy I agree + Recorder + Recordings + Use AirPods high-quality microphone to record audio. + Heart Rate + System + Light + Dark + Design System + Apple + Apple + Connecting + Devices + Configure Widget + Welcome to + Get help directly from the developer. Might take time to reply, I\'m a single person working on this :) + Get help from the community. + Create an issue on the GitHub repository. Use this only when you are requesting features, or you are sure that you\'ve found a bug. Try asking the community before opening an issue. + Error opening Discord invite + Error opening GitHub link + Scan mode + BLE Settings + Do not change these settings unless you know what you are doing! + Low Power + Consumes the least power + Balanced + Provides a good trade-off between scan frequency and power consumption + Low Latency + Fastest scan frequency + Enable debug mode + Any value greater than 0 enables batching. + Report delay (in ms) diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index 0f3f84e9..d9e13107 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -11,7 +11,7 @@ @android:color/transparent - +