diff --git a/AAP Definitions.md b/AAP Definitions.md index ef83ff292..42dc6025c 100644 --- a/AAP Definitions.md +++ b/AAP Definitions.md @@ -279,50 +279,27 @@ duplicated thrice for some reason ## Customize Transparency mode ``` -52 18 00 -For left bud -[Enabled] +12 18 00 [enabled] + [EQ1][EQ2][EQ3][EQ4][EQ5][EQ6][EQ7][EQ8] [Amplification] [Tone] [Conversation Boost] [Ambient Noise Reduction] -00 0080 3F - + ``` - - - - - -All values are formatted as Little Endian from float values. -| Data | Type | Value range | -|---------------------|---------------|-------------| -| Enabled | Little Endian | 0 or 1 | -| EQ | Little Endian | 0 to 100 | -| Amplification | Little Endian | -1 to 1 | -| Tone | Little Endian | -1 to 1 | -| Conversation Boost | Little Endian | 0 or 1 | + +All values are formatted as IEEE 754 floats in little endian order. +| Data | Type | Range | +|-------------------------|---------------|-------| +| Enabled | IEEE754 Float | 0/1 | +| EQ | IEEE754 Float | 0-100 | +| Amplification | IEEE754 Float | 0-2 | +| Tone | IEEE754 Float | 0-2 | +| Conversation Boost | IEEE754 Float | 0/1 | +| Ambient Noise Reduction | IEEE754 Float | 0-1 | +| Ambient Noise Reduction | IEEE754 Float | 0-1 | > [!IMPORTANT] > Also send the [Headphone Accomodation](#headphone-accomodation) after this. diff --git a/README.md b/README.md index 0079f6e0a..4a72dcc01 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,17 @@ ## What is LibrePods? -LibrePods unlocks Apple's exclusive AirPods features on non-Apple devices. Get access to noise control modes, adaptive transparency, ear detection, battery status, and more - all the premium features you paid for but Apple locked to their ecosystem. +LibrePods unlocks Apple's exclusive AirPods features on non-Apple devices. Get access to noise control modes, adaptive transparency, ear detection, hearing aid, customized transparency mode, battery status, and more - all the premium features you paid for but Apple locked to their ecosystem. ## Device Compatibility -| Status | Device | Features | -|--------|--------|----------| -| ✅ | AirPods Pro (2nd Gen) | Fully supported and tested | -| ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work | +| Status | Device | Features | +| ------ | --------------------- | ---------------------------------------------------------- | +| ✅ | AirPods Pro (2nd Gen) | Fully supported and tested | +| ✅ | AirPods Pro (3rd Gen) | Fully supported (except heartrate monitoring) | +| ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work | -Most features should work with any AirPods. Currently, testing is only performed with AirPods Pro 2. +Most features should work with any AirPods. Currently, I've only got AirPods Pro 2 to test with. ## Key Features @@ -29,6 +30,9 @@ Most features should work with any AirPods. Currently, testing is only performed - **Battery Status**: Accurate battery levels - **Head Gestures**: Answer calls just by nodding your head - **Conversational Awareness**: Volume automatically lowers when you speak +- **Hearing Aid\*** +- **Customize Transparency Mode\*** +- **Multi-device connectivity\*** (upto 2 devices) - **Other customizations**: - Rename your AirPods - Customize long-press actions @@ -58,12 +62,18 @@ For installation and detailed info, see the [Linux README](/linux/README.md). #### Screenshots -| | | | -|-------------------|-------------------|-------------------| -| ![Settings 1](/android/imgs/settings-1.png) | ![Settings 2](/android/imgs/settings-2.png) | ![Debug Screen](/android/imgs/debug.png) | -| ![Battery Notification and QS Tile for NC Mode](/android/imgs/notification-and-qs.png) | ![Popup](/android/imgs/popup.png) | ![Head Tracking and Gestures](/android/imgs/head-tracking-and-gestures.png) | -| ![Long Press Configuration](/android/imgs/long-press.png) | ![Widget](/android/imgs/widget.png) | ![Customizations 1](/android/imgs/customizations-1.png) | -| ![Customizations 2](/android/imgs/customizations-2.png) | ![audio-popup](/android/imgs/audio-connected-island.png) | | +| | | | +| -------------------------------------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------- | +| ![Settings 1](/android/imgs/settings-1.png) | ![Settings 2](/android/imgs/settings-2.png) | ![Debug Screen](/android/imgs/debug.png) | +| ![Battery Notification and QS Tile for NC Mode](/android/imgs/notification-and-qs.png) | ![Popup](/android/imgs/popup.png) | ![Head Tracking and Gestures](/android/imgs/head-tracking-and-gestures.png) | +| ![Long Press Configuration](/android/imgs/long-press.png) | ![Widget](/android/imgs/widget.png) | ![Customizations 1](/android/imgs/customizations-1.png) | +| ![Customizations 2](/android/imgs/customizations-2.png) | ![accessibility](/android/imgs/accessibility.png) | ![transparency](/android/imgs/transparency.png) | +| ![hearing-aid](/android/imgs/hearing-aid.png) | ![hearing-test](/android/imgs/hearing-test.png) | ![hearing-aid-adjustments](/android/imgs/hearing-aid-adjustments.png) | + + +here's a very unprofessional demo video + +https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533 #### Root Requirement @@ -72,6 +82,24 @@ For installation and detailed info, see the [Linux README](/linux/README.md). > > There are **no exceptions** to the root requirement until Google merges the fix. +## Bluetooth DID (Device Identification) Hook + +Turns out, if you change the manufacturerid to that of Apple, you get access to several special features! + +### Multi-device Connectivity + +Upto two devices can be simultaneously connected to AirPods, for audio and control both. Seamless connection switching. The same notification shows up on Apple device when Android takes over the AirPods as if it were an Apple device ("Move to iPhone"). Android also shows a popup when the other device takes over. + +### Accessibility Settings and Hearing Aid + +Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured. + +The hearing aid feature can now also be used. Currently it can only be used to adjust the settings, not actually take a hearing test because it requires much more precision. It is much better to use an already available audiogram result. + +>[!NOTE] +> To enable these features, enable App Settings -> `act as Apple Device`. +> This only works if you use the Xposed method or patch the library yourself. The root module method does not support this feature currently. + #### Installation Methods ##### Method 1: Xposed Module (Recommended) @@ -79,17 +107,19 @@ This method is less intrusive and should be tried first: 1. Install LSPosed, or another Xposed provider on your rooted device 2. Download the LibrePods app from the releases section, and install it. -3. Enable the Xposed module for the bluetooth app in your Xposed manager -4. Follow the instructions in the app to set up the module. -5. Open the app and connect your AirPods +3. Enable the Xposed module for the bluetooth app in your Xposed manager. +4. Disable unmount modules for the Bluetooth app if enabled. +5. Follow the instructions in the app to set up the module. +6. Open the app and connect your AirPods ##### Method 2: Root Module (Backup Option) If the Xposed method doesn't work for you: 1. Download the `btl2capfix.zip` module from the releases section 2. Install it using your preferred root manager (KernelSU, Apatch, or Magisk). -3. Reboot your device -4. Connect your AirPods +3. Disable Unmount modules for the Bluetooth aop if enabled. +4. Reboot your device +5. Connect your AirPods ##### Method 3: Patching it yourself If you prefer to patch the Bluetooth stack yourself, follow these steps: @@ -111,25 +141,6 @@ If you're unfamiliar with these steps, search for tutorials online or ask in And - When renaming your AirPods through the app, you'll need to re-pair them with your phone for the name change to take effect. This is a limitation of how Bluetooth device naming works on Android. -## Development Resources - -For developers interested in the protocol details, check out the [AAP Definitions](/AAP%20Definitions.md) documentation. - -## CrossDevice Stuff - -> [!IMPORTANT] -> This feature is still in early development and might not work as expected. No support is provided for this feature yet. - -### Features in Development - -- **Battery Status Sync**: Get battery status on any device when you connect your AirPods to one of them -- **Cross-device Controls**: Control your AirPods from either device when connected to one -- **Automatic Device Switching**: Seamlessly switch between Linux and Android devices based on active audio sources - -Check out the demo below: - -https://github.com/user-attachments/assets/d08f8a51-cd52-458b-8e55-9b44f4d5f3ab - ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=kavishdevar/librepods&type=Date)](https://star-history.com/#kavishdevar/librepods&Date) diff --git a/android/.gitignore b/android/.gitignore index 28a82e329..62ca9dc6d 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -1,3 +1,4 @@ +crowdin.yml *.iml .gradle /local.properties diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c8c0f9d65..b61702c9f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -2,19 +2,20 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.aboutLibraries) id("kotlin-parcelize") } android { namespace = "me.kavishdevar.librepods" - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "me.kavishdevar.librepods" minSdk = 28 - targetSdk = 35 - versionCode = 7 - versionName = "0.1.0-rc.4" + targetSdk = 36 + versionCode = 8 + versionName = "0.2.0-beta.1" } buildTypes { @@ -43,6 +44,11 @@ android { version = "3.22.1" } } + sourceSets { + getByName("main") { + res.srcDirs("src/main/res", "src/main/res-apple") + } + } } dependencies { @@ -62,5 +68,22 @@ dependencies { implementation(libs.haze) implementation(libs.haze.materials) implementation(libs.androidx.dynamicanimation) - compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar")))) + implementation(libs.androidx.compose.ui) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.foundation.layout) + implementation(libs.aboutlibraries) + implementation(libs.aboutlibraries.compose.m3) + // compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar")))) + // implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.aar")))) + compileOnly(files("libs/libxposed-api-100.aar")) + debugImplementation(files("libs/backdrop-debug.aar")) + releaseImplementation(files("libs/backdrop-release.aar")) +} + +aboutLibraries { + export{ + prettyPrint = true + excludeFields = listOf("generated") + outputFile = file("src/main/res/raw/aboutlibraries.json") + } } diff --git a/android/app/libs/backdrop-debug.aar b/android/app/libs/backdrop-debug.aar new file mode 100644 index 000000000..9ed9a7164 Binary files /dev/null and b/android/app/libs/backdrop-debug.aar differ diff --git a/android/app/libs/backdrop-release.aar b/android/app/libs/backdrop-release.aar new file mode 100644 index 000000000..bfddcab76 Binary files /dev/null and b/android/app/libs/backdrop-release.aar differ diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index af4adc5ec..8474e9107 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,7 +7,8 @@ android:required="false" /> - + @@ -30,11 +31,10 @@ - - - + + @@ -72,15 +73,6 @@ android:resource="@xml/battery_widget_info" /> - - - - - - + - + + + + + + #include #include "l2c_fcr_hook.h" +#include +#include #define LOG_TAG "AirPodsHook" #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) @@ -126,6 +128,9 @@ static void (*original_l2cu_process_our_cfg_req)(tL2C_CCB* p_ccb, tL2CAP_CFG_INF static void (*original_l2c_csm_config)(tL2C_CCB* p_ccb, uint8_t event, void* p_data) = nullptr; static void (*original_l2cu_send_peer_info_req)(tL2C_LCB* p_lcb, uint16_t info_type) = nullptr; +// Add original pointer for BTA_DmSetLocalDiRecord +static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD* p_device_info, uint32_t* p_handle) = nullptr; + uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) { LOGI("l2c_fcr_chk_chan_modes hooked, returning true."); return 1; @@ -156,6 +161,53 @@ void fake_l2cu_send_peer_info_req(tL2C_LCB* p_lcb, uint16_t info_type) { return; } +// New loader for SDP hook offset (persist.librepods.sdp_offset) +uintptr_t loadSdpOffset() { + const char* property_name = "persist.librepods.sdp_offset"; + char value[PROP_VALUE_MAX] = {0}; + + int len = __system_property_get(property_name, value); + if (len > 0) { + LOGI("Read sdp offset from property: %s", value); + uintptr_t offset; + char* endptr = nullptr; + + const char* parse_start = value; + if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) { + parse_start = value + 2; + } + + errno = 0; + offset = strtoul(parse_start, &endptr, 16); + + if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) { + LOGI("Parsed sdp offset: 0x%x", offset); + return offset; + } + + LOGE("Failed to parse sdp offset from property value: %s", value); + } + + LOGI("No sdp offset property present - skipping SDP hook"); + return 0; +} + +// Fake BTA_DmSetLocalDiRecord: set vendor/vendor_id_source then call original +tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD* p_device_info, uint32_t* p_handle) { + LOGI("BTA_DmSetLocalDiRecord hooked - forcing vendor fields"); + if (p_device_info) { + p_device_info->vendor = 0x004C; + p_device_info->vendor_id_source = 0x0001; + } + LOGI("Set vendor=0x%04x, vendor_id_source=0x%04x", p_device_info->vendor, p_device_info->vendor_id_source); + if (original_BTA_DmSetLocalDiRecord) { + return original_BTA_DmSetLocalDiRecord(p_device_info, p_handle); + } + + LOGE("Original BTA_DmSetLocalDiRecord not available"); + return BTA_FAILURE; +} + uintptr_t loadHookOffset([[maybe_unused]] const char* package_name) { const char* property_name = "persist.librepods.hook_offset"; char value[PROP_VALUE_MAX] = {0}; @@ -320,6 +372,7 @@ bool findAndHookFunction(const char *library_name) { uintptr_t l2cu_process_our_cfg_req_offset = loadL2cuProcessCfgReqOffset(); uintptr_t l2c_csm_config_offset = loadL2cCsmConfigOffset(); uintptr_t l2cu_send_peer_info_req_offset = loadL2cuSendPeerInfoReqOffset(); + uintptr_t sdp_offset = loadSdpOffset(); bool success = false; @@ -392,6 +445,21 @@ bool findAndHookFunction(const char *library_name) { LOGI("Skipping l2cu_send_peer_info_req hook as offset is not available"); } + if (sdp_offset > 0) { + void* target = reinterpret_cast(base_addr + sdp_offset); + LOGI("Hooking BTA_DmSetLocalDiRecord at offset: 0x%x, base: %p, target: %p", + sdp_offset, (void*)base_addr, target); + + int result = hook_func(target, (void*)fake_BTA_DmSetLocalDiRecord, (void**)&original_BTA_DmSetLocalDiRecord); + if (result != 0) { + LOGE("Failed to hook BTA_DmSetLocalDiRecord, error: %d", result); + } else { + LOGI("Successfully hooked BTA_DmSetLocalDiRecord (SDP)"); + } + } else { + LOGI("Skipping BTA_DmSetLocalDiRecord hook as sdp offset is not available"); + } + return success; } diff --git a/android/app/src/main/cpp/l2c_fcr_hook.h b/android/app/src/main/cpp/l2c_fcr_hook.h index cff43d473..2ab325632 100644 --- a/android/app/src/main/cpp/l2c_fcr_hook.h +++ b/android/app/src/main/cpp/l2c_fcr_hook.h @@ -26,3 +26,25 @@ uintptr_t loadL2cuProcessCfgReqOffset(); uintptr_t loadL2cCsmConfigOffset(); uintptr_t loadL2cuSendPeerInfoReqOffset(); bool findAndHookFunction(const char *library_path); + +#define SDP_MAX_ATTR_LEN 400 + +typedef struct t_sdp_di_record { + uint16_t vendor; + uint16_t vendor_id_source; + uint16_t product; + uint16_t version; + bool primary_record; + char client_executable_url[SDP_MAX_ATTR_LEN]; + char service_description[SDP_MAX_ATTR_LEN]; + char documentation_url[SDP_MAX_ATTR_LEN]; +} tSDP_DI_RECORD; + +typedef enum : uint8_t { + BTA_SUCCESS = 0, /* Successful operation. */ + BTA_FAILURE = 1, /* Generic failure. */ + BTA_PENDING = 2, /* API cannot be completed right now */ + BTA_BUSY = 3, + BTA_NO_RESOURCES = 4, + BTA_WRONG_MODE = 5, +} tBTA_STATUS; \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt deleted file mode 100644 index 98398aa57..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt +++ /dev/null @@ -1,188 +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 Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package me.kavishdevar.librepods - -import android.Manifest -import android.annotation.SuppressLint -import android.bluetooth.BluetoothDevice -import android.bluetooth.BluetoothDevice.TRANSPORT_LE -import android.bluetooth.BluetoothGatt -import android.bluetooth.BluetoothGattCallback -import android.bluetooth.BluetoothGattCharacteristic -import android.bluetooth.BluetoothManager -import android.os.Build -import android.os.Bundle -import android.util.Log -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.annotation.RequiresPermission -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import me.kavishdevar.librepods.ui.theme.LibrePodsTheme -import org.lsposed.hiddenapibypass.HiddenApiBypass -import java.util.UUID - -class CustomDevice : ComponentActivity() { - @SuppressLint("MissingPermission", "CoroutineCreationDuringComposition") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { - LibrePodsTheme { - val connect = remember { mutableStateOf(false) } - Scaffold( - modifier = Modifier.fillMaxSize(), - topBar = { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("Custom Device", style = MaterialTheme.typography.titleLarge) - } - } - ) { innerPadding -> - HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;") - val manager = getSystemService(BLUETOOTH_SERVICE) as BluetoothManager -// val device: BluetoothDevice = manager.adapter.getRemoteDevice("EC:D6:F4:3D:89:B8") - val device: BluetoothDevice = manager.adapter.getRemoteDevice("E7:48:92:3B:7D:A5") -// val socket = device.createInsecureL2capChannel(31) - -// val batteryLevel = remember { mutableStateOf("") } -// socket.outputStream.write(byteArrayOf(0x12,0x3B,0x00,0x02, 0x00)) -// socket.outputStream.write(byteArrayOf(0x12, 0x3A, 0x00, 0x01, 0x00, 0x08,0x01)) - - val gatt = device.connectGatt(this, true, object: BluetoothGattCallback() { - override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { - if (status == BluetoothGatt.GATT_SUCCESS) { - // Step 2: Iterate through the services and characteristics - gatt.services.forEach { service -> - Log.d("GATT", "Service UUID: ${service.uuid}") - service.characteristics.forEach { characteristic -> - characteristic.descriptors.forEach { descriptor -> - Log.d("GATT", " Descriptor UUID: ${descriptor.uuid}: ${gatt.readDescriptor(descriptor)}") - } - } - } - - } - } - - override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { - if (newState == BluetoothGatt.STATE_CONNECTED) { - Log.d("GATT", "Connected to GATT server") - gatt.discoverServices() // Discover services after connection - } - } - - override fun onCharacteristicWrite( - gatt: BluetoothGatt, - characteristic: BluetoothGattCharacteristic, - status: Int - ) { - if (status == BluetoothGatt.GATT_SUCCESS) { - Log.d("BLE", "Write successful for UUID: ${characteristic.uuid}") - } else { - Log.e("BLE", "Write failed for UUID: ${characteristic.uuid}, status: $status") - } - } - }, TRANSPORT_LE, 1) - - if (connect.value) { - try { - gatt.connect() - } - catch (e: Exception) { - e.printStackTrace() - } - connect.value = false - } - - Column ( - modifier = Modifier.padding(innerPadding), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) - { - Button( - onClick = { connect.value = true } - ) - { - Text("Connect") - } - - Button(onClick = { - val characteristicUuid = "94110001-6D9B-4225-A4F1-6A4A7F01B0DE" - val value = byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x00 ,0x00 ,0x01) - sendWriteRequest(gatt, characteristicUuid, value) - - }) { - Text("batteryLevel.value") - } - } - } - } - } - } -} - -@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) -fun sendWriteRequest( - gatt: BluetoothGatt, - characteristicUuid: String, - value: ByteArray -) { - // Retrieve the service containing the characteristic - val service = gatt.services.find { service -> - service.characteristics.any { it.uuid.toString() == characteristicUuid } - } - - if (service == null) { - Log.e("GATT", "Service containing characteristic UUID $characteristicUuid not found.") - return - } - - // Retrieve the characteristic - val characteristic = service.getCharacteristic(UUID.fromString(characteristicUuid)) - if (characteristic == null) { - Log.e("GATT", "Characteristic with UUID $characteristicUuid not found.") - return - } - - - // Send the write request - val success = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - gatt.writeCharacteristic(characteristic, value, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) - } else { - gatt.writeCharacteristic(characteristic) - } - Log.d("GATT", "Write request sent $success to UUID: $characteristicUuid") -} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt index 92be4eae5..3e589b6a6 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt @@ -27,7 +27,6 @@ import android.content.Context import android.content.Context.MODE_PRIVATE import android.content.Intent import android.content.ServiceConnection -import android.content.SharedPreferences import android.net.Uri import android.os.Build import android.os.Bundle @@ -38,6 +37,7 @@ import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable @@ -45,6 +45,8 @@ import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.Canvas @@ -89,6 +91,8 @@ import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font @@ -97,6 +101,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.edit +import androidx.core.net.toUri import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -104,18 +110,31 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.MultiplePermissionsState import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.constants.AirPodsNotifications +import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen +import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen import me.kavishdevar.librepods.screens.AirPodsSettingsScreen import me.kavishdevar.librepods.screens.AppSettingsScreen +import me.kavishdevar.librepods.screens.CameraControlScreen import me.kavishdevar.librepods.screens.DebugScreen import me.kavishdevar.librepods.screens.HeadTrackingScreen +import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen +import me.kavishdevar.librepods.screens.HearingAidScreen +import me.kavishdevar.librepods.screens.HearingProtectionScreen import me.kavishdevar.librepods.screens.LongPress import me.kavishdevar.librepods.screens.Onboarding +import me.kavishdevar.librepods.screens.OpenSourceLicensesScreen import me.kavishdevar.librepods.screens.RenameScreen +import me.kavishdevar.librepods.screens.TransparencySettingsScreen import me.kavishdevar.librepods.screens.TroubleshootingScreen +import me.kavishdevar.librepods.screens.UpdateHearingTestScreen +import me.kavishdevar.librepods.screens.VersionScreen import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.ui.theme.LibrePodsTheme -import me.kavishdevar.librepods.utils.CrossDevice import me.kavishdevar.librepods.utils.RadareOffsetFinder import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -137,8 +156,10 @@ class MainActivity : ComponentActivity() { setContent { LibrePodsTheme { - getSharedPreferences("settings", MODE_PRIVATE).edit().putLong("textColor", - MaterialTheme.colorScheme.onSurface.toArgb().toLong()).apply() + getSharedPreferences("settings", MODE_PRIVATE).edit { + putLong( + "textColor", + MaterialTheme.colorScheme.onSurface.toArgb().toLong())} Main() } } @@ -191,15 +212,12 @@ class MainActivity : ComponentActivity() { if (data != null && data.scheme == "librepods") { when (data.host) { "add-magic-keys" -> { - // Extract query parameters val queryParams = data.queryParameterNames queryParams.forEach { param -> val value = data.getQueryParameter(param) - // Handle your parameters here Log.d("LibrePods", "Parameter: $param = $value") } - // Process the magic keys addition handleAddMagicKeys(data) } } @@ -207,8 +225,7 @@ class MainActivity : ComponentActivity() { } private fun handleAddMagicKeys(uri: Uri) { - val context = this - val sharedPreferences = getSharedPreferences("settings", Context.MODE_PRIVATE) + val sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) val irkHex = uri.getQueryParameter("irk") val encKeyHex = uri.getQueryParameter("enc_key") @@ -217,13 +234,13 @@ class MainActivity : ComponentActivity() { if (irkHex != null && validateHexInput(irkHex)) { val irkBytes = hexStringToByteArray(irkHex) val irkBase64 = Base64.encode(irkBytes) - sharedPreferences.edit().putString("IRK", irkBase64).apply() + sharedPreferences.edit {putString("IRK", irkBase64)} } if (encKeyHex != null && validateHexInput(encKeyHex)) { val encKeyBytes = hexStringToByteArray(encKeyHex) val encKeyBase64 = Base64.encode(encKeyBytes) - sharedPreferences.edit().putString("ENC_KEY", encKeyBase64).apply() + sharedPreferences.edit { putString("ENC_KEY", encKeyBase64)} } Toast.makeText(this, "Magic keys added successfully!", Toast.LENGTH_SHORT).show() @@ -247,6 +264,7 @@ class MainActivity : ComponentActivity() { } } +@ExperimentalHazeMaterialsApi @SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag") @OptIn(ExperimentalPermissionsApi::class) @Composable @@ -291,94 +309,146 @@ fun Main() { if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) { val context = LocalContext.current - context.startService(Intent(context, AirPodsService::class.java)) val navController = rememberNavController() - val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE) - val isAvailableChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - if (key == "CrossDeviceIsAvailable") { - Log.d("MainActivity", "CrossDeviceIsAvailable changed") - isRemotelyConnected.value = sharedPreferences.getBoolean("CrossDeviceIsAvailable", false) - } - } - sharedPreferences.registerOnSharedPreferenceChangeListener(isAvailableChangeListener) - Log.d("MainActivity", "CrossDeviceIsAvailable: ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | isAvailable: ${CrossDevice.isAvailable}") - isRemotelyConnected.value = sharedPreferences.getBoolean("CrossDeviceIsAvailable", false) || CrossDevice.isAvailable - Log.d("MainActivity", "isRemotelyConnected: ${isRemotelyConnected.value}") Box ( modifier = Modifier - .padding(0.dp) .fillMaxSize() - .background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)) - ) { - NavHost( - navController = navController, - startDestination = if (hookAvailable) "settings" else "onboarding", - enterTransition = { - slideInHorizontally( - initialOffsetX = { it }, - animationSpec = tween(durationMillis = 300) - ) + fadeIn(animationSpec = tween(durationMillis = 300)) - }, - exitTransition = { - slideOutHorizontally( - targetOffsetX = { -it/4 }, - animationSpec = tween(durationMillis = 300) - ) + fadeOut(animationSpec = tween(durationMillis = 150)) - }, - popEnterTransition = { - slideInHorizontally( - initialOffsetX = { -it/4 }, - animationSpec = tween(durationMillis = 300) - ) + fadeIn(animationSpec = tween(durationMillis = 300)) - }, - popExitTransition = { - slideOutHorizontally( - targetOffsetX = { it }, - animationSpec = tween(durationMillis = 300) - ) + fadeOut(animationSpec = tween(durationMillis = 150)) - } + ){ + val backButtonBackdrop = rememberLayerBackdrop() + Box ( + modifier = Modifier + .fillMaxSize() + .background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)) + .layerBackdrop(backButtonBackdrop) ) { - composable("settings") { - if (airPodsService.value != null) { - AirPodsSettingsScreen( - dev = airPodsService.value?.device, - service = airPodsService.value!!, + NavHost( + navController = navController, + startDestination = if (hookAvailable) "settings" else "onboarding", + enterTransition = { + slideInHorizontally( + initialOffsetX = { it }, + animationSpec = tween(durationMillis = 300) + ) // + fadeIn(animationSpec = tween(durationMillis = 300)) + }, + exitTransition = { + slideOutHorizontally( + targetOffsetX = { -it/4 }, + animationSpec = tween(durationMillis = 300) + ) // + fadeOut(animationSpec = tween(durationMillis = 150)) + }, + popEnterTransition = { + slideInHorizontally( + initialOffsetX = { -it/4 }, + animationSpec = tween(durationMillis = 300) + ) // + fadeIn(animationSpec = tween(durationMillis = 300)) + }, + popExitTransition = { + slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(durationMillis = 300) + ) // + fadeOut(animationSpec = tween(durationMillis = 150)) + } + ) { + composable("settings") { + if (airPodsService.value != null) { + AirPodsSettingsScreen( + dev = airPodsService.value?.device, + service = airPodsService.value!!, + navController = navController, + isConnected = isConnected.value, + isRemotelyConnected = isRemotelyConnected.value + ) + } + } + composable("debug") { + DebugScreen(navController = navController) + } + composable("long_press/{bud}") { navBackStackEntry -> + LongPress( navController = navController, - isConnected = isConnected.value, - isRemotelyConnected = isRemotelyConnected.value + name = navBackStackEntry.arguments?.getString("bud")!! ) } + composable("rename") { + RenameScreen(navController) + } + composable("app_settings") { + AppSettingsScreen(navController) + } + composable("troubleshooting") { + TroubleshootingScreen(navController) + } + composable("head_tracking") { + HeadTrackingScreen(navController) + } + composable("onboarding") { + Onboarding(navController, context) + } + composable("accessibility") { + AccessibilitySettingsScreen(navController) + } + composable("transparency_customization") { + TransparencySettingsScreen(navController) + } + composable("hearing_aid") { + HearingAidScreen(navController) + } + composable("hearing_aid_adjustments") { + HearingAidAdjustmentsScreen(navController) + } + composable("adaptive_strength") { + AdaptiveStrengthScreen(navController) + } + composable("camera_control") { + CameraControlScreen(navController) + } + composable("open_source_licenses") { + OpenSourceLicensesScreen(navController) + } + composable("update_hearing_test") { + UpdateHearingTestScreen(navController) + } + composable("version_info") { + VersionScreen(navController) + } + composable("hearing_protection") { + HearingProtectionScreen(navController) + } } - composable("debug") { - DebugScreen(navController = navController) + } + + val showBackButton = remember{ mutableStateOf(false) } + + LaunchedEffect(navController) { + navController.addOnDestinationChangedListener { _, destination, _ -> + showBackButton.value = destination.route != "settings" && destination.route != "onboarding" + Log.d("MainActivity", "Navigated to ${destination.route}, showBackButton: ${showBackButton.value}") } - composable("long_press/{bud}") { navBackStackEntry -> - LongPress( - navController = navController, - name = navBackStackEntry.arguments?.getString("bud")!! + } + + AnimatedVisibility( + visible = showBackButton.value, + enter = fadeIn(animationSpec = tween()) + scaleIn(initialScale = 0f, animationSpec = tween()), + exit = fadeOut(animationSpec = tween()) + scaleOut(targetScale = 0.5f, animationSpec = tween(100)), + modifier = Modifier + .align(Alignment.TopStart) + .padding( + start = 8.dp, + top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp + ) + ) { + StyledIconButton( + onClick = { navController.popBackStack() }, + icon = "􀯶", + darkMode = isSystemInDarkTheme(), + backdrop = backButtonBackdrop ) - } - composable("rename") { navBackStackEntry -> - RenameScreen(navController) - } - composable("app_settings") { - AppSettingsScreen(navController) - } - composable("troubleshooting") { - TroubleshootingScreen(navController) - } - composable("head_tracking") { - HeadTrackingScreen(navController) - } - composable("onboarding") { - Onboarding(navController, context) - } } } - serviceConnection = remember { + serviceConnection = remember { object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { val binder = service as AirPodsService.LocalBinder @@ -499,7 +569,7 @@ fun PermissionsScreen( Spacer(modifier = Modifier.height(8.dp)) Text( - text = "The following permissions are required to use the app. Please grant them to continue.", + text = stringResource(R.string.permissions_required), style = TextStyle( fontSize = 16.sp, fontWeight = FontWeight.Normal, @@ -587,7 +657,7 @@ fun PermissionsScreen( onClick = { val intent = Intent( Settings.ACTION_MANAGE_OVERLAY_PERMISSION, - Uri.parse("package:${context.packageName}") + "package:${context.packageName}".toUri() ) context.startActivity(intent) onOverlaySettingsReturn() @@ -617,9 +687,9 @@ fun PermissionsScreen( Button( onClick = { - val editor = context.getSharedPreferences("settings", MODE_PRIVATE).edit() - editor.putBoolean("overlay_permission_skipped", true) - editor.apply() + context.getSharedPreferences("settings", MODE_PRIVATE).edit { + putBoolean("overlay_permission_skipped", true) + } val intent = Intent(context, MainActivity::class.java) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) @@ -676,7 +746,11 @@ fun PermissionCard( modifier = Modifier .size(40.dp) .clip(RoundedCornerShape(8.dp)) - .background(if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(alpha = 0.15f)), + .background( + if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy( + alpha = 0.15f + ) + ), contentAlignment = Alignment.Center ) { Icon( diff --git a/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt index d8a79d2a0..bd46412b5 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt @@ -1,3 +1,21 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + @file:OptIn(ExperimentalEncodingApi::class) package me.kavishdevar.librepods @@ -133,7 +151,7 @@ class QuickSettingsDialogActivity : ComponentActivity() { window.setGravity(Gravity.BOTTOM) Intent(this, AirPodsService::class.java).also { intent -> - bindService(intent, connection, Context.BIND_AUTO_CREATE) + bindService(intent, connection, BIND_AUTO_CREATE) } setContent { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AboutCard.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AboutCard.kt new file mode 100644 index 000000000..264f941b2 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AboutCard.kt @@ -0,0 +1,205 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +@file:OptIn(ExperimentalEncodingApi::class) + +package me.kavishdevar.librepods.composables + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.layout.onGloballyPositioned +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.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.NavigationButton +import me.kavishdevar.librepods.services.ServiceManager +import kotlin.io.encoding.ExperimentalEncodingApi + +@Composable +fun AboutCard(navController: NavController) { + val isDarkTheme = isSystemInDarkTheme() + val textColor = if (isDarkTheme) Color.White else Color.Black + val service = ServiceManager.getService() + if (service == null) return + val airpodsInstance = service.airpodsInstance + if (airpodsInstance == null) return + val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + + Box( + modifier = Modifier + .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) + .padding(horizontal = 16.dp, vertical = 4.dp) + ){ + Text( + text = stringResource(R.string.about), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = textColor.copy(alpha = 0.6f) + ) + ) + } + + val rowHeight = remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + + Column( + modifier = Modifier + .clip(RoundedCornerShape(28.dp)) + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(28.dp)) + .padding(top = 2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .onGloballyPositioned { coordinates -> + rowHeight.value = with(density) { coordinates.size.height.toDp() } + }, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.model_name), + style = TextStyle( + fontSize = 16.sp, + color = textColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = airpodsInstance.model.displayName, + style = TextStyle( + fontSize = 16.sp, + color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier + .padding(horizontal = 12.dp) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.model_name), + style = TextStyle( + fontSize = 16.sp, + color = textColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = airpodsInstance.actualModelNumber, + style = TextStyle( + fontSize = 16.sp, + color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier + .padding(horizontal = 12.dp) + ) + val serialNumbers = listOf( + airpodsInstance.serialNumber?: "", + "􀀛 ${airpodsInstance.leftSerialNumber}", + "􀀧 ${airpodsInstance.rightSerialNumber}" + ) + val serialNumber = remember { mutableStateOf(0) } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.serial_number), + style = TextStyle( + fontSize = 16.sp, + color = textColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + ) + Text( + text = serialNumbers[serialNumber.value], + style = TextStyle( + fontSize = 16.sp, + color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + serialNumber.value = (serialNumber.value + 1) % serialNumbers.size + } + ) + } + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier + .padding(horizontal = 12.dp) + ) + NavigationButton( + to = "version_info", + navController = navController, + name = stringResource(R.string.version), + currentState = airpodsInstance.version3, + independent = false, + height = rowHeight.value + 32.dp + ) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt deleted file mode 100644 index 75cbd2a66..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt +++ /dev/null @@ -1,221 +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 Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.composables - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.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.FontWeight -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.services.ServiceManager -import me.kavishdevar.librepods.utils.AACPManager -import kotlin.io.encoding.ExperimentalEncodingApi - -@Composable -fun AccessibilitySettings() { - val isDarkTheme = isSystemInDarkTheme() - val textColor = if (isDarkTheme) Color.White else Color.Black - val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - val service = ServiceManager.getService()!! - Text( - text = stringResource(R.string.accessibility).uppercase(), - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Light, - color = textColor.copy(alpha = 0.6f) - ), - modifier = Modifier.padding(8.dp, bottom = 2.dp) - ) - - Column( - modifier = Modifier - .fillMaxWidth() - .background(backgroundColor, RoundedCornerShape(14.dp)) - .padding(top = 2.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp) - ) { - Text( - text = stringResource(R.string.tone_volume), - modifier = Modifier - .padding(end = 8.dp, bottom = 2.dp, start = 2.dp) - .fillMaxWidth(), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = textColor - ) - ) - - ToneVolumeSlider() - } - - val pressSpeedOptions = mapOf( - 0.toByte() to "Default", - 1.toByte() to "Slower", - 2.toByte() to "Slowest" - ) - val selectedPressSpeedValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0) - var selectedPressSpeed by remember { mutableStateOf(pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]) } - DropdownMenuComponent( - label = "Press Speed", - options = pressSpeedOptions.values.toList(), - selectedOption = selectedPressSpeed.toString(), - onOptionSelected = { newValue -> - selectedPressSpeed = newValue - service.aacpManager.sendControlCommand( - identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value, - value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte() - ) - }, - textColor = textColor - ) - - val pressAndHoldDurationOptions = mapOf( - 0.toByte() to "Default", - 1.toByte() to "Slower", - 2.toByte() to "Slowest" - ) - - val selectedPressAndHoldDurationValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0) - var selectedPressAndHoldDuration by remember { mutableStateOf(pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] ?: pressAndHoldDurationOptions[0]) } - DropdownMenuComponent( - label = "Press and Hold Duration", - options = pressAndHoldDurationOptions.values.toList(), - selectedOption = selectedPressAndHoldDuration.toString(), - onOptionSelected = { newValue -> - selectedPressAndHoldDuration = newValue - service.aacpManager.sendControlCommand( - identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value, - value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte() - ) - }, - textColor = textColor - ) - - val volumeSwipeSpeedOptions = mapOf( - 1.toByte() to "Default", - 2.toByte() to "Longer", - 3.toByte() to "Longest" - ) - val selectedVolumeSwipeSpeedValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0) - var selectedVolumeSwipeSpeed by remember { mutableStateOf(volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] ?: volumeSwipeSpeedOptions[1]) } - DropdownMenuComponent( - label = "Volume Swipe Speed", - options = volumeSwipeSpeedOptions.values.toList(), - selectedOption = selectedVolumeSwipeSpeed.toString(), - onOptionSelected = { newValue -> - selectedVolumeSwipeSpeed = newValue - service.aacpManager.sendControlCommand( - identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value, - value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 1.toByte() - ) - }, - textColor = textColor - ) - - SinglePodANCSwitch() - VolumeControlSwitch() - } -} - -@Composable -fun DropdownMenuComponent( - label: String, - options: List, - selectedOption: String, - onOptionSelected: (String) -> Unit, - textColor: Color -) { - var expanded by remember { mutableStateOf(false) } - - Column ( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp) - ) { - Text( - text = label, - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = textColor - ) - ) - - Box( - modifier = Modifier - .fillMaxWidth() - .clickable { expanded = true } - .padding(8.dp) - ) { - Text( - text = selectedOption, - modifier = Modifier.padding(16.dp), - color = textColor - ) - } - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - options.forEach { option -> - DropdownMenuItem( - onClick = { - onOptionSelected(option) - expanded = false - }, - text = { Text(text = option) } - ) - } - } - } -} - -@Preview -@Composable -fun AccessibilitySettingsPreview() { - AccessibilitySettings() -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AdaptiveStrengthSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AdaptiveStrengthSlider.kt deleted file mode 100644 index ad6dc8d3e..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AdaptiveStrengthSlider.kt +++ /dev/null @@ -1,158 +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 Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.composables - -import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -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.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import me.kavishdevar.librepods.services.ServiceManager -import me.kavishdevar.librepods.utils.AACPManager -import kotlin.io.encoding.ExperimentalEncodingApi -import kotlin.math.roundToInt - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AdaptiveStrengthSlider() { - val sliderValue = remember { mutableFloatStateOf(0f) } - val service = ServiceManager.getService()!! - LaunchedEffect(sliderValue) { - val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find { - it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH - }?.value?.takeIf { it.isNotEmpty() }?.get(0) - sliderValueFromAACP?.toFloat()?.let { sliderValue.floatValue = (100 - it) } - } - - val isDarkTheme = isSystemInDarkTheme() - - val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFFD9D9D9) - val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) - val labelTextColor = if (isDarkTheme) Color.White else Color.Black - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Slider( - value = sliderValue.floatValue, - onValueChange = { - sliderValue.floatValue = it - }, - valueRange = 0f..100f, - onValueChangeFinished = { - sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat() - service.aacpManager.sendControlCommand( - identifier = AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value, - value = (100 - sliderValue.floatValue).toInt() - ) - }, - modifier = Modifier - .fillMaxWidth() - .height(36.dp), - colors = SliderDefaults.colors( - thumbColor = thumbColor, - inactiveTrackColor = trackColor - ), - thumb = { - Box( - modifier = Modifier - .size(24.dp) - .shadow(4.dp, CircleShape) - .background(thumbColor, CircleShape) - ) - }, - track = { - Box( - modifier = Modifier - .fillMaxWidth() - .height(12.dp), - contentAlignment = Alignment.CenterStart - ) - { - Box( - modifier = Modifier - .fillMaxWidth() - .height(4.dp) - .background(trackColor, RoundedCornerShape(4.dp)) - ) - } - - } - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "Less Noise", - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Light, - color = labelTextColor - ), - modifier = Modifier.padding(start = 4.dp) - ) - Text( - text = "More Noise", - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Light, - color = labelTextColor - ), - modifier = Modifier.padding(end = 4.dp) - ) - } - } -} - -@Preview -@Composable -fun AdaptiveStrengthSliderPreview() { - AdaptiveStrengthSlider() -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt index 5e7cf6371..8a0da0c67 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt @@ -22,13 +22,16 @@ package me.kavishdevar.librepods.composables import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -36,63 +39,108 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.AACPManager +import me.kavishdevar.librepods.utils.ATTHandles +import me.kavishdevar.librepods.utils.Capability import kotlin.io.encoding.ExperimentalEncodingApi @Composable -fun AudioSettings() { +fun AudioSettings(navController: NavController) { val isDarkTheme = isSystemInDarkTheme() val textColor = if (isDarkTheme) Color.White else Color.Black - - Text( - text = stringResource(R.string.audio).uppercase(), - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Light, - color = textColor.copy(alpha = 0.6f) - ), - modifier = Modifier.padding(8.dp, bottom = 2.dp) - ) + val service = ServiceManager.getService() + if (service == null) return + val airpodsInstance = service.airpodsInstance + if (airpodsInstance == null) return + if (!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME) && + !airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS) && + !airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) && + !airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO) + ) { + return + } + Box( + modifier = Modifier + .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) + .padding(horizontal = 16.dp, vertical = 4.dp) + ){ + Text( + text = stringResource(R.string.audio), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = textColor.copy(alpha = 0.6f) + ) + ) + } val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) Column( modifier = Modifier + .clip(RoundedCornerShape(28.dp)) .fillMaxWidth() - .background(backgroundColor, RoundedCornerShape(14.dp)) + .background(backgroundColor, RoundedCornerShape(28.dp)) .padding(top = 2.dp) ) { - ConversationalAwarenessSwitch() + if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME)) { + StyledToggle( + label = stringResource(R.string.personalized_volume), + description = stringResource(R.string.personalized_volume_description), + controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG, + independent = false + ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 10.dp) - ) { - Text( - text = stringResource(R.string.adaptive_audio), + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), modifier = Modifier - .padding(end = 8.dp, bottom = 2.dp, start = 2.dp) - .fillMaxWidth(), - style = TextStyle( - fontSize = 16.sp, - color = textColor - ) + .padding(horizontal = 12.dp) ) - Text( - text = stringResource(R.string.adaptive_audio_description), + } + + if (airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS)) { + StyledToggle( + label = stringResource(R.string.conversational_awareness), + description = stringResource(R.string.conversational_awareness_description), + controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, + independent = false + ) + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier + .padding(horizontal = 12.dp) + ) + } + + if (airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){ + StyledToggle( + label = stringResource(R.string.loud_sound_reduction), + description = stringResource(R.string.loud_sound_reduction_description), + attHandle = ATTHandles.LOUD_SOUND_REDUCTION, + independent = false + ) + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), modifier = Modifier - .padding(bottom = 8.dp, top = 2.dp) - .padding(end = 2.dp, start = 2.dp) - .fillMaxWidth(), - style = TextStyle( - fontSize = 12.sp, - color = textColor.copy(alpha = 0.6f) - ) + .padding(horizontal = 12.dp) ) + } - AdaptiveStrengthSlider() + if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)) { + NavigationButton( + to = "adaptive_strength", + name = stringResource(R.string.adaptive_audio), + navController = navController, + independent = false + ) } } } @@ -100,5 +148,5 @@ fun AudioSettings() { @Preview @Composable fun AudioSettingsPreview() { - AudioSettings() + AudioSettings(rememberNavController()) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryIndicator.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryIndicator.kt index 130f71aff..3beef1c00 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryIndicator.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryIndicator.kt @@ -1,17 +1,17 @@ /* * 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 Affero General Public License as published * by the Free Software Foundation, either version 3 of the License. - * + * * 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 Affero General Public License for more details. - * + * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ @@ -19,31 +19,30 @@ package me.kavishdevar.librepods.composables -import androidx.compose.animation.core.animateFloatAsState +import android.content.res.Configuration +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement +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.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -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.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.height +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -51,85 +50,79 @@ import androidx.compose.ui.unit.sp import me.kavishdevar.librepods.R @Composable -fun BatteryIndicator(batteryPercentage: Int, charging: Boolean = false) { - val batteryOutlineColor = Color(0xFFBFBFBF) - val batteryFillColor = if (batteryPercentage > 30) Color(0xFF30D158) else Color(0xFFFC3C3C) - val batteryTextColor = MaterialTheme.colorScheme.onSurface +fun BatteryIndicator( + batteryPercentage: Int, + charging: Boolean = false, + prefix: String = "", + previousCharging: Boolean = false, +) { + val isDarkTheme = isSystemInDarkTheme() + val backgroundColor = if (isDarkTheme) Color.Black else Color(0xFFF2F2F7) + val batteryTextColor = if (isDarkTheme) Color.White else Color.Black + val batteryFillColor = if (batteryPercentage > 25) + if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759) + else if (isDarkTheme) Color(0xFFFC4244) else Color(0xFFfe373C) - val batteryWidth = 40.dp - val batteryHeight = 15.dp - val batteryCornerRadius = 4.dp - val tipWidth = 5.dp - val tipHeight = batteryHeight * 0.375f + val initialScale = if (previousCharging) 1f else 0f + val scaleAnim = remember { Animatable(initialScale) } + val targetScale = if (charging) 1f else 0f - val animatedFillWidth by animateFloatAsState(targetValue = batteryPercentage / 100f) - val animatedScale by animateFloatAsState(targetValue = if (charging) 1.2f else 1f) + LaunchedEffect(previousCharging, charging) { + scaleAnim.animateTo(targetScale, animationSpec = tween(durationMillis = 250)) + } Column( + modifier = Modifier + .background(backgroundColor), // just for haze to work horizontalAlignment = Alignment.CenterHorizontally ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(0.dp), - modifier = Modifier.padding(bottom = 4.dp) + Box( + modifier = Modifier.padding(bottom = 4.dp), + contentAlignment = Alignment.Center ) { - Box( - modifier = Modifier - .width(batteryWidth) - .height(batteryHeight) - ) { - Box ( - modifier = Modifier - .fillMaxSize() - .border(1.dp, batteryOutlineColor, RoundedCornerShape(batteryCornerRadius)) - ) - Box( - modifier = Modifier - .fillMaxHeight() - .padding(2.dp) - .width(batteryWidth * animatedFillWidth) - .background(batteryFillColor, RoundedCornerShape(2.dp)) - ) - if (charging) { - Text( - text = "\uDBC0\uDEE6", - fontSize = 16.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = Color.White, - modifier = Modifier - .scale(animatedScale) - .fillMaxSize(), - textAlign = TextAlign.Center - ) - } - } - Box( - modifier = Modifier - .width(tipWidth) - .height(tipHeight) - .padding(start = 1.dp) - .background( - batteryOutlineColor, - RoundedCornerShape( - topStart = 0.dp, - topEnd = 12.dp, - bottomStart = 0.dp, - bottomEnd = 12.dp - ) - ) + CircularProgressIndicator( + progress = { batteryPercentage / 100f }, + modifier = Modifier.size(40.dp), + color = batteryFillColor, + gapSize = 0.dp, + strokeCap = StrokeCap.Round, + strokeWidth = 4.dp, + trackColor = if (isDarkTheme) Color(0xFF0E0E0F) else Color(0xFFE3E3E8) + ) + + Text( + text = "\uDBC0\uDEE6", + style = TextStyle( + fontSize = 12.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = batteryFillColor, + textAlign = TextAlign.Center + ), + modifier = Modifier.scale(scaleAnim.value) ) } + Spacer(modifier = Modifier.height(4.dp)) + Text( - text = "$batteryPercentage%", + text = "$prefix $batteryPercentage%", color = batteryTextColor, - style = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold) + style = TextStyle( + fontSize = 16.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + textAlign = TextAlign.Center + ), ) } } -@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun BatteryIndicatorPreview() { - BatteryIndicator(batteryPercentage = 48, charging = true) -} \ No newline at end of file + val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7) + Box( + modifier = Modifier.background(bg) + ) { + BatteryIndicator(batteryPercentage = 24, charging = true, prefix = "\uDBC6\uDCE5", previousCharging = false) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt index c4740d7cb..62893f700 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt @@ -24,14 +24,19 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.res.Configuration import android.os.Build import android.util.Log import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -39,7 +44,7 @@ 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.graphics.ImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.imageResource @@ -57,6 +62,9 @@ import kotlin.io.encoding.ExperimentalEncodingApi @Composable fun BatteryView(service: AirPodsService, preview: Boolean = false) { val batteryStatus = remember { mutableStateOf>(listOf()) } + + val previousBatteryStatus = remember { mutableStateOf>(listOf()) } + @Suppress("DEPRECATION") val batteryReceiver = remember { object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -96,15 +104,43 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) { } } + previousBatteryStatus.value = batteryStatus.value batteryStatus.value = service.getBattery() if (preview) { - batteryStatus.value = listOf( - Battery(BatteryComponent.LEFT, 100, BatteryStatus.CHARGING), - Battery(BatteryComponent.RIGHT, 50, BatteryStatus.NOT_CHARGING), - Battery(BatteryComponent.CASE, 5, BatteryStatus.CHARGING) + batteryStatus.value = listOf( + Battery(BatteryComponent.LEFT, 100, BatteryStatus.NOT_CHARGING), + Battery(BatteryComponent.RIGHT, 94, BatteryStatus.CHARGING), + Battery(BatteryComponent.CASE, 40, BatteryStatus.CHARGING) ) + previousBatteryStatus.value = batteryStatus.value + } + + val left = batteryStatus.value.find { it.component == BatteryComponent.LEFT } + val right = batteryStatus.value.find { it.component == BatteryComponent.RIGHT } + val case = batteryStatus.value.find { it.component == BatteryComponent.CASE } + val leftLevel = left?.level ?: 0 + val rightLevel = right?.level ?: 0 + val caseLevel = case?.level ?: 0 + val leftCharging = left?.status == BatteryStatus.CHARGING + val rightCharging = right?.status == BatteryStatus.CHARGING + val caseCharging = case?.status == BatteryStatus.CHARGING + + val prevLeft = previousBatteryStatus.value.find { it.component == BatteryComponent.LEFT } + val prevRight = previousBatteryStatus.value.find { it.component == BatteryComponent.RIGHT } + val prevCase = previousBatteryStatus.value.find { it.component == BatteryComponent.CASE } + val prevLeftCharging = prevLeft?.status == BatteryStatus.CHARGING + val prevRightCharging = prevRight?.status == BatteryStatus.CHARGING + val prevCaseCharging = prevCase?.status == BatteryStatus.CHARGING + + val singleDisplayed = remember { mutableStateOf(false) } + + val airpodsInstance = service.airpodsInstance + if (airpodsInstance == null) { + return } + val budsRes = airpodsInstance.model.budsRes + val caseRes = airpodsInstance.model.caseRes Row { Column ( @@ -113,47 +149,52 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) { horizontalAlignment = Alignment.CenterHorizontally ) { Image ( - bitmap = ImageBitmap.imageResource(R.drawable.pro_2_buds), + bitmap = ImageBitmap.imageResource(budsRes), contentDescription = stringResource(R.string.buds), modifier = Modifier .fillMaxWidth() - .scale(0.80f) + .padding(8.dp) + ) + if ( + leftCharging == rightCharging && + (leftLevel - rightLevel) in -3..3 ) - val left = batteryStatus.value.find { it.component == BatteryComponent.LEFT } - val right = batteryStatus.value.find { it.component == BatteryComponent.RIGHT } - if ((right?.status == BatteryStatus.CHARGING && left?.status == BatteryStatus.CHARGING) || (left?.status == BatteryStatus.NOT_CHARGING && right?.status == BatteryStatus.NOT_CHARGING)) { - BatteryIndicator(right.level.let { left.level.coerceAtMost(it) }, left.status == BatteryStatus.CHARGING) + BatteryIndicator( + leftLevel.coerceAtMost(rightLevel), + leftCharging, + previousCharging = (prevLeftCharging && prevRightCharging) + ) + singleDisplayed.value = true } else { + singleDisplayed.value = false Row ( modifier = Modifier .fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { -// if (left?.status != BatteryStatus.DISCONNECTED) { - if (left?.level != null) { + if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) { BatteryIndicator( - left.level, - left.status == BatteryStatus.CHARGING + leftLevel, + leftCharging, + "\uDBC6\uDCE5", + previousCharging = prevLeftCharging ) } -// } -// if (left?.status != BatteryStatus.DISCONNECTED && right?.status != BatteryStatus.DISCONNECTED) { - if (left?.level != null && right?.level != null) + if (leftLevel > 0 && rightLevel > 0) { Spacer(modifier = Modifier.width(16.dp)) } -// } -// if (right?.status != BatteryStatus.DISCONNECTED) { - if (right?.level != null) + if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED) { BatteryIndicator( - right.level, - right.status == BatteryStatus.CHARGING + rightLevel, + rightCharging, + "\uDBC6\uDCE8", + previousCharging = prevRightCharging ) } -// } } } } @@ -163,26 +204,32 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) { .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { - val case = batteryStatus.value.find { it.component == BatteryComponent.CASE } - Image( - bitmap = ImageBitmap.imageResource(R.drawable.pro_2_case), + bitmap = ImageBitmap.imageResource(caseRes), contentDescription = stringResource(R.string.case_alt), modifier = Modifier .fillMaxWidth() - .scale(1.25f) + .padding(8.dp) ) -// if (case?.status != BatteryStatus.DISCONNECTED) { - if (case?.level != null) { - BatteryIndicator(case.level, case.status == BatteryStatus.CHARGING) + if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) { + BatteryIndicator( + caseLevel, + caseCharging, + prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else "", + previousCharging = prevCaseCharging + ) } -// } } } } -@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun BatteryViewPreview() { - BatteryView(AirPodsService(), preview = true) + val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7) + Box( + modifier = Modifier.background(bg) + ) { + BatteryView(AirPodsService(), preview = true) + } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt new file mode 100644 index 000000000..616e8ac14 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt @@ -0,0 +1,470 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +@file:OptIn(ExperimentalEncodingApi::class) + +package me.kavishdevar.librepods.composables + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.AACPManager +import kotlin.io.encoding.ExperimentalEncodingApi + +@ExperimentalHazeMaterialsApi +@Composable +fun CallControlSettings(hazeState: HazeState) { + val isDarkTheme = isSystemInDarkTheme() + val textColor = if (isDarkTheme) Color.White else Color.Black + val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + Box( + modifier = Modifier + .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) + .padding(horizontal = 16.dp, vertical = 4.dp) + ){ + Text( + text = stringResource(R.string.call_controls), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = textColor.copy(alpha = 0.6f) + ) + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(28.dp)) + .padding(top = 2.dp) + ) { + val service = ServiceManager.getService()!! + val callControlEnabledValue = service.aacpManager.controlCommandStatusList.find { + it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG + }?.value ?: byteArrayOf(0x00, 0x03) + + val pressOnceText = stringResource(R.string.press_once) + val pressTwiceText = stringResource(R.string.press_twice) + + var flipped by remember { + mutableStateOf( + callControlEnabledValue.contentEquals( + byteArrayOf( + 0x00, + 0x02 + ) + ) + ) + } + var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) } + var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) } + + var showSinglePressDropdown by remember { mutableStateOf(false) } + var touchOffsetSingle by remember { mutableStateOf(null) } + var boxPositionSingle by remember { mutableStateOf(Offset.Zero) } + var lastDismissTimeSingle by remember { mutableLongStateOf(0L) } + var parentHoveredIndexSingle by remember { mutableStateOf(null) } + var parentDragActiveSingle by remember { mutableStateOf(false) } + + var showDoublePressDropdown by remember { mutableStateOf(false) } + var touchOffsetDouble by remember { mutableStateOf(null) } + var boxPositionDouble by remember { mutableStateOf(Offset.Zero) } + var lastDismissTimeDouble by remember { mutableLongStateOf(0L) } + var parentHoveredIndexDouble by remember { mutableStateOf(null) } + var parentDragActiveDouble by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + val listener = object : AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) == + AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG + ) { + val newFlipped = controlCommand.value.contentEquals(byteArrayOf(0x00, 0x02)) + flipped = newFlipped + singlePressAction = if (newFlipped) pressTwiceText else pressOnceText + doublePressAction = if (newFlipped) pressOnceText else pressTwiceText + Log.d( + "CallControlSettings", + "Control command received, flipped: $newFlipped" + ) + } + } + } + + service.aacpManager.registerControlCommandListener( + AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG, + listener + ) + } + + DisposableEffect(Unit) { + onDispose { + service.aacpManager.controlCommandListeners[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.clear() + } + } + LaunchedEffect(flipped) { + Log.d("CallControlSettings", "Call control flipped: $flipped") + } + + val density = LocalDensity.current + val itemHeightPx = with(density) { 48.dp.toPx() } + + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .height(58.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.answer_call), + fontSize = 16.sp, + color = textColor, + modifier = Modifier.padding(bottom = 4.dp) + ) + Text( + text = stringResource(R.string.press_once), + fontSize = 16.sp, + color = textColor.copy(alpha = 0.6f) + ) + } + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier + .padding(horizontal = 12.dp) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .height(58.dp) + .pointerInput(Unit) { + detectTapGestures { offset -> + val now = System.currentTimeMillis() + if (showSinglePressDropdown) { + showSinglePressDropdown = false + lastDismissTimeSingle = now + } else { + if (now - lastDismissTimeSingle > 250L) { + touchOffsetSingle = offset + showSinglePressDropdown = true + } + } + } + } + .pointerInput(Unit) { + detectDragGesturesAfterLongPress( + onDragStart = { offset -> + val now = System.currentTimeMillis() + touchOffsetSingle = offset + if (!showSinglePressDropdown && now - lastDismissTimeSingle > 250L) { + showSinglePressDropdown = true + } + lastDismissTimeSingle = now + parentDragActiveSingle = true + parentHoveredIndexSingle = 0 + }, + onDrag = { change, _ -> + val current = change.position + val touch = touchOffsetSingle ?: current + val posInPopupY = current.y - touch.y + val idx = (posInPopupY / itemHeightPx).toInt() + parentHoveredIndexSingle = idx + }, + onDragEnd = { + parentDragActiveSingle = false + parentHoveredIndexSingle?.let { idx -> + val options = listOf(pressOnceText, pressTwiceText) + if (idx in options.indices) { + val option = options[idx] + singlePressAction = option + doublePressAction = + if (option == pressOnceText) pressTwiceText else pressOnceText + showSinglePressDropdown = false + lastDismissTimeSingle = System.currentTimeMillis() + val bytes = if (option == pressOnceText) byteArrayOf( + 0x00, + 0x03 + ) else byteArrayOf(0x00, 0x02) + service.aacpManager.sendControlCommand(0x24, bytes) + } + } + parentHoveredIndexSingle = null + }, + onDragCancel = { + parentDragActiveSingle = false + parentHoveredIndexSingle = null + } + ) + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.mute_unmute), + fontSize = 16.sp, + color = textColor, + modifier = Modifier.padding(bottom = 4.dp) + ) + Box( + modifier = Modifier.onGloballyPositioned { coordinates -> + boxPositionSingle = coordinates.positionInParent() + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = singlePressAction, + style = TextStyle( + fontSize = 16.sp, + color = textColor.copy(alpha = 0.8f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = "􀆏", + style = TextStyle( + fontSize = 16.sp, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier + .padding(start = 6.dp) + ) + } + + StyledDropdown( + expanded = showSinglePressDropdown, + onDismissRequest = { + showSinglePressDropdown = false + lastDismissTimeSingle = System.currentTimeMillis() + }, + options = listOf(pressOnceText, pressTwiceText), + selectedOption = singlePressAction, + touchOffset = touchOffsetSingle, + boxPosition = boxPositionSingle, + externalHoveredIndex = parentHoveredIndexSingle, + externalDragActive = parentDragActiveSingle, + onOptionSelected = { option -> + singlePressAction = option + doublePressAction = + if (option == pressOnceText) pressTwiceText else pressOnceText + showSinglePressDropdown = false + val bytes = if (option == pressOnceText) byteArrayOf( + 0x00, + 0x03 + ) else byteArrayOf(0x00, 0x02) + service.aacpManager.sendControlCommand(0x24, bytes) + }, + hazeState = hazeState + ) + } + } + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier + .padding(horizontal = 12.dp) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .height(58.dp) + .pointerInput(Unit) { + detectTapGestures { offset -> + val now = System.currentTimeMillis() + if (showDoublePressDropdown) { + showDoublePressDropdown = false + lastDismissTimeDouble = now + } else { + if (now - lastDismissTimeDouble > 250L) { + touchOffsetDouble = offset + showDoublePressDropdown = true + } + } + } + } + .pointerInput(Unit) { + detectDragGesturesAfterLongPress( + onDragStart = { offset -> + val now = System.currentTimeMillis() + touchOffsetDouble = offset + if (!showDoublePressDropdown && now - lastDismissTimeDouble > 250L) { + showDoublePressDropdown = true + } + lastDismissTimeDouble = now + parentDragActiveDouble = true + parentHoveredIndexDouble = 0 + }, + onDrag = { change, _ -> + val current = change.position + val touch = touchOffsetDouble ?: current + val posInPopupY = current.y - touch.y + val idx = (posInPopupY / itemHeightPx).toInt() + parentHoveredIndexDouble = idx + }, + onDragEnd = { + parentDragActiveDouble = false + parentHoveredIndexDouble?.let { idx -> + val options = listOf(pressOnceText, pressTwiceText) + if (idx in options.indices) { + val option = options[idx] + doublePressAction = option + singlePressAction = + if (option == pressOnceText) pressTwiceText else pressOnceText + showDoublePressDropdown = false + lastDismissTimeDouble = System.currentTimeMillis() + val bytes = if (option == pressOnceText) byteArrayOf( + 0x00, + 0x02 + ) else byteArrayOf(0x00, 0x03) + service.aacpManager.sendControlCommand(0x24, bytes) + } + } + parentHoveredIndexDouble = null + }, + onDragCancel = { + parentDragActiveDouble = false + parentHoveredIndexDouble = null + } + ) + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.hang_up), + fontSize = 16.sp, + color = textColor, + modifier = Modifier.padding(bottom = 4.dp) + ) + Box( + modifier = Modifier.onGloballyPositioned { coordinates -> + boxPositionDouble = coordinates.positionInParent() + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = doublePressAction, + style = TextStyle( + fontSize = 16.sp, + color = textColor.copy(alpha = 0.8f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = "􀆏", + style = TextStyle( + fontSize = 16.sp, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier + .padding(start = 6.dp) + ) + } + + StyledDropdown( + expanded = showDoublePressDropdown, + onDismissRequest = { + showDoublePressDropdown = false + lastDismissTimeDouble = System.currentTimeMillis() + }, + options = listOf(pressOnceText, pressTwiceText), + selectedOption = doublePressAction, + touchOffset = touchOffsetDouble, + boxPosition = boxPositionDouble, + externalHoveredIndex = parentHoveredIndexDouble, + externalDragActive = parentDragActiveDouble, + onOptionSelected = { option -> + doublePressAction = option + singlePressAction = + if (option == pressOnceText) pressTwiceText else pressOnceText + showDoublePressDropdown = false + val bytes = if (option == pressOnceText) byteArrayOf( + 0x00, + 0x02 + ) else byteArrayOf(0x00, 0x03) + service.aacpManager.sendControlCommand(0x24, bytes) + }, + hazeState = hazeState + ) + } + } + } + } +} + +@ExperimentalHazeMaterialsApi +@Preview +@Composable +fun CallControlSettingsPreview() { + CallControlSettings(HazeState()) +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConfirmationDialog.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConfirmationDialog.kt new file mode 100644 index 000000000..7a40f3d59 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConfirmationDialog.kt @@ -0,0 +1,217 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.composables + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidthIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.materials.CupertinoMaterials +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import me.kavishdevar.librepods.R + +@ExperimentalHazeMaterialsApi +@Composable +fun ConfirmationDialog( + showDialog: MutableState, + title: String, + message: String, + confirmText: String = "Enable", + dismissText: String = "Cancel", + onConfirm: () -> Unit, + onDismiss: () -> Unit = { showDialog.value = false }, + hazeState: HazeState, +) { + val isDarkTheme = isSystemInDarkTheme() + val textColor = if (isDarkTheme) Color.White else Color.Black + val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) + if (showDialog.value) { + Dialog(onDismissRequest = { showDialog.value = false }) { + Box( + modifier = Modifier + // .fillMaxWidth(0.75f) + .requiredWidthIn(min = 200.dp, max = 360.dp) + .background(Color.Transparent, RoundedCornerShape(14.dp)) + .clip(RoundedCornerShape(14.dp)) + .hazeEffect( + hazeState, + style = CupertinoMaterials.regular( + containerColor = if (isDarkTheme) Color(0xFF1C1C1E).copy(alpha = 0.95f) else Color.White.copy(alpha = 0.95f) + ) + ) + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(24.dp)) + Text( + title, + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = textColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp) + ) + androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(12.dp)) + Text( + message, + style = TextStyle( + fontSize = 14.sp, + color = textColor.copy(alpha = 0.8f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp) + ) + androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier.fillMaxWidth() + ) + var leftPressed by remember { mutableStateOf(false) } + var rightPressed by remember { mutableStateOf(false) } + val pressedColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + val position = event.changes.first().position + val width = size.width.toFloat() + val height = size.height.toFloat() + val isWithinBounds = position.y >= 0 && position.y <= height + val isLeft = position.x < width / 2 + event.changes.first().consume() + when (event.type) { + PointerEventType.Press -> { + if (isWithinBounds) { + leftPressed = isLeft + rightPressed = !isLeft + } else { + leftPressed = false + rightPressed = false + } + } + PointerEventType.Move -> { + if (isWithinBounds) { + leftPressed = isLeft + rightPressed = !isLeft + } else { + leftPressed = false + rightPressed = false + } + } + PointerEventType.Release -> { + if (isWithinBounds) { + if (leftPressed) { + onDismiss() + } else if (rightPressed) { + onConfirm() + } + } + leftPressed = false + rightPressed = false + } + } + } + } + }, + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .background(if (leftPressed) pressedColor else Color.Transparent), + contentAlignment = Alignment.Center + ) { + Text( + text = dismissText, + style = TextStyle( + color = accentColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + Box( + modifier = Modifier + .width(1.dp) + .fillMaxHeight() + .background(Color(0x40888888)) + ) + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .background(if (rightPressed) pressedColor else Color.Transparent), + contentAlignment = Alignment.Center + ) { + Text( + text = confirmText, + style = TextStyle( + color = accentColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + } + } + } + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt new file mode 100644 index 000000000..4d07eeaae --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt @@ -0,0 +1,82 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +@file:OptIn(ExperimentalEncodingApi::class) + +package me.kavishdevar.librepods.composables + +import android.content.Context.MODE_PRIVATE +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +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.utils.AACPManager +import kotlin.io.encoding.ExperimentalEncodingApi + +@Composable +fun ConnectionSettings() { + val isDarkTheme = isSystemInDarkTheme() + val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + + Column( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(28.dp)) + .padding(top = 2.dp) + ) { + StyledToggle( + label = stringResource(R.string.ear_detection), + controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG, + sharedPreferenceKey = "automatic_ear_detection", + sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE), + independent = false + ) + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier + .padding(horizontal = 12.dp) + ) + + StyledToggle( + label = stringResource(R.string.automatically_connect), + description = stringResource(R.string.automatically_connect_description), + controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG, + sharedPreferenceKey = "automatic_connection_ctrl_cmd", + sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE), + independent = false + ) + } +} + +@Preview +@Composable +fun ConnectionSettingsPreview() { + ConnectionSettings() +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterButton.kt index 226268292..6de28766f 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterButton.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterButton.kt @@ -15,7 +15,9 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ - + +@file:Suppress("unused") + package me.kavishdevar.librepods.composables import androidx.compose.animation.animateColorAsState diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt deleted file mode 100644 index 8c2aa7d98..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt +++ /dev/null @@ -1,133 +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 Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.composables - -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.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import me.kavishdevar.librepods.services.ServiceManager -import me.kavishdevar.librepods.utils.AACPManager -import kotlin.io.encoding.ExperimentalEncodingApi - -@Composable -fun ConversationalAwarenessSwitch() { - val service = ServiceManager.getService()!! - val conversationEnabledValue = service.aacpManager.controlCommandStatusList.find { - it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG - }?.value?.takeIf { it.isNotEmpty() }?.get(0) - var conversationalAwarenessEnabled by remember { - mutableStateOf( - conversationEnabledValue == 1.toByte() - ) - } - - fun updateConversationalAwareness(enabled: Boolean) { - conversationalAwarenessEnabled = enabled - service.aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value, - enabled - ) - } - - val isDarkTheme = isSystemInDarkTheme() - val textColor = if (isDarkTheme) Color.White else Color.Black - - val isPressed = remember { mutableStateOf(false) } - - Row( - modifier = Modifier - .fillMaxWidth() - .background( - shape = RoundedCornerShape(14.dp), - color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent - ) - .padding(horizontal = 12.dp, vertical = 12.dp) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - isPressed.value = true - tryAwaitRelease() - isPressed.value = false - } - ) - } - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - updateConversationalAwareness(!conversationalAwarenessEnabled) - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(end = 4.dp) - ) { - Text( - text = "Conversational Awareness", - fontSize = 16.sp, - color = textColor - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Lowers media volume and reduces background noise when you start speaking to other people.", - fontSize = 12.sp, - color = textColor.copy(0.6f), - lineHeight = 14.sp, - ) - } - StyledSwitch( - checked = conversationalAwarenessEnabled, - onCheckedChange = { - updateConversationalAwareness(it) - }, - ) - } -} - -@Preview -@Composable -fun ConversationalAwarenessSwitchPreview() { - ConversationalAwarenessSwitch() -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/CustomDropdown.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/CustomDropdown.kt deleted file mode 100644 index a4d37b6b6..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/CustomDropdown.kt +++ /dev/null @@ -1,182 +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 Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package me.kavishdevar.librepods.composables - -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalDensity -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.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties -import me.kavishdevar.librepods.R - -class DropdownItem(val name: String, val onSelect: () -> Unit) { - fun select() { - onSelect() - } -} - -@Composable -fun CustomDropdown(name: String, description: String = "", items: List) { - val isDarkTheme = isSystemInDarkTheme() - val textColor = if (isDarkTheme) Color.White else Color.Black - val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - var expanded by remember { mutableStateOf(false) } - var offset by remember { mutableStateOf(IntOffset.Zero) } - var popupHeight by remember { mutableStateOf(0.dp) } - - val animatedHeight by animateDpAsState( - targetValue = if (expanded) popupHeight else 0.dp, - animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow) - ) - val animatedScale by animateFloatAsState( - targetValue = if (expanded) 1f else 0f, - animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow) - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .background( - shape = RoundedCornerShape(14.dp), - color = Color.Transparent - ) - .padding(horizontal = 12.dp, vertical = 12.dp) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - expanded = true - } - .onGloballyPositioned { coordinates -> - val windowPosition = coordinates.localToWindow(Offset.Zero) - offset = IntOffset(windowPosition.x.toInt(), windowPosition.y.toInt() + coordinates.size.height) - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(end = 4.dp) - ) { - Text( - text = name, - fontSize = 16.sp, - color = textColor, - maxLines = 1 - ) - if (description.isNotEmpty()) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = description, - fontSize = 12.sp, - color = textColor.copy(0.6f), - lineHeight = 14.sp, - maxLines = 1 - ) - } - } - Text( - text = "\uDBC0\uDD8F", - fontSize = 16.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor - ) - } - - if (expanded) { - Popup( - alignment = Alignment.TopStart, - offset = offset , - properties = PopupProperties(focusable = true), - onDismissRequest = { expanded = false } - ) { - val density = LocalDensity.current - Column( - modifier = Modifier - .background(backgroundColor, RoundedCornerShape(8.dp)) - .padding(8.dp) - .widthIn(max = 50.dp) - .height(animatedHeight) - .scale(animatedScale) - .onGloballyPositioned { coordinates -> - popupHeight = with(density) { coordinates.size.height.toDp() } - } - ) { - items.forEach { item -> - Text( - text = item.name, - modifier = Modifier - .fillMaxWidth() - .clickable { - item.select() - expanded = false - } - .padding(8.dp), - color = textColor - ) - } - } - } - } -} - -@Preview -@Composable -fun CustomDropdownPreview() { - CustomDropdown( - name = "Volume Swipe Speed", - items = listOf( - DropdownItem("Always On") { }, - DropdownItem("Off") { }, - DropdownItem("Only when speaking") { } - ) - ) -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/HearingHealthSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/HearingHealthSettings.kt new file mode 100644 index 000000000..725acad05 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/HearingHealthSettings.kt @@ -0,0 +1,109 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +@file:OptIn(ExperimentalEncodingApi::class) + +package me.kavishdevar.librepods.composables + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.NavigationButton +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.Capability +import kotlin.io.encoding.ExperimentalEncodingApi + +@Composable +fun HearingHealthSettings(navController: NavController) { + val service = ServiceManager.getService() + if (service == null) return + val airpodsInstance = service.airpodsInstance + if (airpodsInstance == null) return + if (airpodsInstance.model.capabilities.contains(Capability.HEARING_AID)) { + val isDarkTheme = isSystemInDarkTheme() + val textColor = if (isDarkTheme) Color.White else Color.Black + val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + + if (airpodsInstance.model.capabilities.contains(Capability.PPE)) { + Box( + modifier = Modifier + .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) + .padding(horizontal = 16.dp, vertical = 4.dp) + ){ + Text( + text = stringResource(R.string.hearing_health), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = textColor.copy(alpha = 0.6f) + ) + ) + } + Column( + modifier = Modifier + .clip(RoundedCornerShape(28.dp)) + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(28.dp)) + .padding(top = 2.dp) + ) { + NavigationButton( + to = "hearing_protection", + name = stringResource(R.string.hearing_protection), + navController = navController, + independent = false + ) + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier + .padding(horizontal = 12.dp) + ) + + NavigationButton( + to = "hearing_aid", + name = stringResource(R.string.hearing_aid), + navController = navController, + independent = false + ) + } + } else { + NavigationButton( + to = "hearing_aid", + name = stringResource(R.string.hearing_aid), + navController = navController + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt deleted file mode 100644 index f3e320b84..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt +++ /dev/null @@ -1,130 +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 Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.composables - -import android.content.SharedPreferences -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import me.kavishdevar.librepods.services.AirPodsService -import me.kavishdevar.librepods.utils.AACPManager -import kotlin.io.encoding.ExperimentalEncodingApi - -@Composable -fun IndependentToggle(name: String, service: AirPodsService? = null, functionName: String? = null, sharedPreferences: SharedPreferences, default: Boolean = false, controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers? = null) { - val isDarkTheme = isSystemInDarkTheme() - val textColor = if (isDarkTheme) Color.White else Color.Black - val snakeCasedName = - controlCommandIdentifier?.name ?: name.replace(Regex("[\\W\\s]+"), "_").lowercase() - var checked by remember { mutableStateOf(default) } - - if (controlCommandIdentifier != null) { - checked = service!!.aacpManager.controlCommandStatusList.find { - it.identifier == controlCommandIdentifier - }?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte() - } - - var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } - val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) - - fun cb() { - if (controlCommandIdentifier == null) { - sharedPreferences.edit().putBoolean(snakeCasedName, checked).apply() - } - if (functionName != null && service != null) { - val method = - service::class.java.getMethod(functionName, Boolean::class.java) - method.invoke(service, checked) - } - if (controlCommandIdentifier != null) { - service?.aacpManager?.sendControlCommand(identifier = controlCommandIdentifier.value, value = checked) - } - } - - LaunchedEffect(sharedPreferences) { - checked = sharedPreferences.getBoolean(snakeCasedName, true) - } - Box ( - modifier = Modifier - .padding(vertical = 8.dp) - .background(animatedBackgroundColor, RoundedCornerShape(14.dp)) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) - tryAwaitRelease() - backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - }, - onTap = { - checked = !checked - cb() - } - ) - }, - ) - { - Row( - modifier = Modifier - .fillMaxWidth() - .height(55.dp) - .padding(horizontal = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = name, modifier = Modifier.weight(1f), fontSize = 16.sp, color = textColor) - StyledSwitch( - checked = checked, - onCheckedChange = { - checked = it - cb() - }, - ) - } - } -} - -@Preview -@Composable -fun IndependentTogglePreview() { - IndependentToggle("Test", AirPodsService(), "test", LocalContext.current.getSharedPreferences("preview", 0), true) -} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt new file mode 100644 index 000000000..2c1b4a086 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt @@ -0,0 +1,297 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +@file:OptIn(ExperimentalEncodingApi::class) + +package me.kavishdevar.librepods.composables + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.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.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.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.AACPManager +import kotlin.io.encoding.ExperimentalEncodingApi + +@ExperimentalHazeMaterialsApi +@Composable +fun MicrophoneSettings(hazeState: HazeState) { + val isDarkTheme = isSystemInDarkTheme() + val textColor = if (isDarkTheme) Color.White else Color.Black + val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + + Column( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(28.dp)) + .padding(top = 2.dp) + ) { + val service = ServiceManager.getService()!! + val micModeValue = service.aacpManager.controlCommandStatusList.find { + it.identifier == AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE + }?.value?.get(0) ?: 0x00.toByte() + + var selectedMode by remember { + mutableStateOf( + when (micModeValue) { + 0x00.toByte() -> "Automatic" + 0x01.toByte() -> "Always Right" + 0x02.toByte() -> "Always Left" + else -> "Automatic" + } + ) + } + var showDropdown by remember { mutableStateOf(false) } + var touchOffset by remember { mutableStateOf(null) } + var boxPosition by remember { mutableStateOf(Offset.Zero) } + var lastDismissTime by remember { mutableLongStateOf(0L) } + val reopenThresholdMs = 250L + + val listener = object : AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) == + AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE + ) { + selectedMode = when (controlCommand.value[0]) { + 0x00.toByte() -> "Automatic" + 0x01.toByte() -> "Always Right" + 0x02.toByte() -> "Always Left" + else -> "Automatic" + } + Log.d("MicrophoneSettings", "Microphone mode received: $selectedMode") + } + } + } + + LaunchedEffect(Unit) { + service.aacpManager.registerControlCommandListener( + AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE, + listener + ) + } + + DisposableEffect(Unit) { + onDispose { + service.aacpManager.unregisterControlCommandListener( + AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE, + listener + ) + } + } + + val density = LocalDensity.current + val itemHeightPx = with(density) { 48.dp.toPx() } + var parentHoveredIndex by remember { mutableStateOf(null) } + var parentDragActive by remember { mutableStateOf(false) } + val microphoneAutomaticText = stringResource(R.string.microphone_automatic) + val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right) + val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .height(58.dp) + .pointerInput(Unit) { + detectTapGestures { offset -> + val now = System.currentTimeMillis() + if (showDropdown) { + showDropdown = false + lastDismissTime = now + } else { + if (now - lastDismissTime > reopenThresholdMs) { + touchOffset = offset + showDropdown = true + } + } + } + } + .pointerInput(Unit) { + detectDragGesturesAfterLongPress( + onDragStart = { offset -> + val now = System.currentTimeMillis() + touchOffset = offset + if (!showDropdown && now - lastDismissTime > reopenThresholdMs) { + showDropdown = true + } + lastDismissTime = now + parentDragActive = true + parentHoveredIndex = 0 + }, + onDrag = { change, _ -> + val current = change.position + val touch = touchOffset ?: current + val posInPopupY = current.y - touch.y + val idx = (posInPopupY / itemHeightPx).toInt() + parentHoveredIndex = idx + }, + onDragEnd = { + parentDragActive = false + parentHoveredIndex?.let { idx -> + val options = listOf( + microphoneAutomaticText, + microphoneAlwaysRightText, + microphoneAlwaysLeftText + ) + if (idx in options.indices) { + val option = options[idx] + selectedMode = option + showDropdown = false + lastDismissTime = System.currentTimeMillis() + val byteValue = when (option) { + options[0] -> 0x00 + options[1] -> 0x01 + options[2] -> 0x02 + else -> 0x00 + } + service.aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value, + byteArrayOf(byteValue.toByte()) + ) + } + } + parentHoveredIndex = null + }, + onDragCancel = { + parentDragActive = false + parentHoveredIndex = null + } + ) + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.microphone_mode), + style = TextStyle( + fontSize = 16.sp, + color = textColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier.padding(bottom = 4.dp) + ) + Box( + modifier = Modifier.onGloballyPositioned { coordinates -> + boxPosition = coordinates.positionInParent() + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = selectedMode, + style = TextStyle( + fontSize = 16.sp, + color = textColor.copy(alpha = 0.8f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = "􀆏", + style = TextStyle( + fontSize = 16.sp, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier + .padding(start = 6.dp) + ) + } + + val microphoneAutomaticText = stringResource(R.string.microphone_automatic) + val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right) + val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left) + + StyledDropdown( + expanded = showDropdown, + onDismissRequest = { + showDropdown = false + lastDismissTime = System.currentTimeMillis() + }, + options = listOf( + microphoneAutomaticText, + microphoneAlwaysRightText, + microphoneAlwaysLeftText + ), + selectedOption = selectedMode, + touchOffset = touchOffset, + boxPosition = boxPosition, + externalHoveredIndex = parentHoveredIndex, + externalDragActive = parentDragActive, + onOptionSelected = { option -> + selectedMode = option + showDropdown = false + val byteValue = when (option) { + microphoneAutomaticText -> 0x00 + microphoneAlwaysRightText -> 0x01 + microphoneAlwaysLeftText -> 0x02 + else -> 0x00 + } + service.aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value, + byteArrayOf(byteValue.toByte()) + ) + }, + hazeState = hazeState + ) + } + } + } +} + +@ExperimentalHazeMaterialsApi +@Preview +@Composable +fun MicrophoneSettingsPreview() { + MicrophoneSettings(HazeState()) +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/NameField.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/NameField.kt deleted file mode 100644 index 399adc449..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/NameField.kt +++ /dev/null @@ -1,154 +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 Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package me.kavishdevar.librepods.composables - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.navigation.NavController -import androidx.navigation.compose.rememberNavController - -@Composable -fun NameField( - name: String, - value: String, - navController: NavController -) { - var isFocused by remember { mutableStateOf(false) } - - val isDarkTheme = isSystemInDarkTheme() - - var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } - val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) - - val textColor = if (isDarkTheme) Color.White else Color.Black - val cursorColor = if (isFocused) { - if (isDarkTheme) Color.White else Color.Black - } else { - Color.Transparent - } - - Box ( - modifier = Modifier - .background(animatedBackgroundColor, RoundedCornerShape(14.dp)) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) - tryAwaitRelease() - backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - }, - onTap = { - navController.navigate("rename") - } - ) - } - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .height(55.dp) - .background( - animatedBackgroundColor, - RoundedCornerShape(14.dp) - ) - .padding(horizontal = 16.dp, vertical = 8.dp) - - ) { - Text( - text = name, - style = TextStyle( - fontSize = 16.sp, - color = textColor - ) - ) - BasicTextField( - value = value, - textStyle = TextStyle( - color = textColor.copy(alpha = 0.75f), - fontSize = 16.sp, - textAlign = TextAlign.End - ), - onValueChange = {}, - singleLine = true, - enabled = false, - cursorBrush = SolidColor(cursorColor), - modifier = Modifier - .fillMaxWidth() - .padding(start = 8.dp) - .onFocusChanged { focusState -> - isFocused = focusState.isFocused - }, - decorationBox = { innerTextField -> - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End - ) { - innerTextField() - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = "Edit name", - tint = textColor.copy(alpha = 0.75f), - modifier = Modifier - .size(32.dp) - ) - } - } - ) - } - } -} - -@Preview -@Composable -fun StyledTextFieldPreview() { - NameField(name = "Name", value = "AirPods Pro", rememberNavController()) -} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/NavigationButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/NavigationButton.kt index 28e71bfda..8d96a54d0 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/NavigationButton.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/NavigationButton.kt @@ -1,17 +1,17 @@ /* * 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 Affero General Public License as published * by the Free Software Foundation, either version 3 of the License. - * + * * 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 Affero General Public License for more details. - * + * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ @@ -23,77 +23,133 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowRight -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.sp import androidx.navigation.NavController - +import me.kavishdevar.librepods.R @Composable -fun NavigationButton(to: String, name: String, navController: NavController) { +fun NavigationButton( + to: String, + name: String, + navController: NavController, onClick: (() -> Unit)? = null, + independent: Boolean = true, + title: String? = null, + description: String? = null, + currentState: String? = null, + height: Dp = 58.dp, +) { val isDarkTheme = isSystemInDarkTheme() var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) - - Row( - modifier = Modifier - .background(animatedBackgroundColor, RoundedCornerShape(14.dp)) - .height(55.dp) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) - tryAwaitRelease() - backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - }, - onTap = { - navController.navigate(to) - } + Column { + if (title != null) { + Box( + modifier = Modifier + .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) + .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp) + ){ + Text( + text = title, + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f), + ) ) } - ) { - Text( - text = name, - modifier = Modifier.padding(16.dp), - color = if (isDarkTheme) Color.White else Color.Black - ) - Spacer(modifier = Modifier.weight(1f)) - IconButton( - onClick = { navController.navigate(to) }, - colors = IconButtonDefaults.iconButtonColors( - containerColor = Color.Transparent, - contentColor = if (isDarkTheme) Color.White else Color.Black - ), + } + Row( modifier = Modifier - .padding(start = 16.dp) - .fillMaxHeight() + .background(animatedBackgroundColor, RoundedCornerShape(if (independent) 28.dp else 0.dp)) + .height(height) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) + tryAwaitRelease() + backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + }, + onTap = { + if (onClick != null) onClick() else navController.navigate(to) + } + ) + } + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, ) { - @Suppress("DEPRECATION") - Icon( - imageVector = Icons.Default.KeyboardArrowRight, - contentDescription = name + Text( + text = name, + style = TextStyle( + fontSize = 16.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = if (isDarkTheme) Color.White else Color.Black, + ) + ) + Spacer(modifier = Modifier.weight(1f)) + if (currentState != null) { + Text( + text = currentState, + style = TextStyle( + fontSize = 16.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f), + ) + ) + } + Text( + text = "􀯻", + style = TextStyle( + fontSize = 16.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f) + ), + modifier = Modifier + .padding(start = if (currentState != null) 6.dp else 0.dp) ) } + if (description != null) { + Box( + modifier = Modifier + .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) // because blur effect doesn't work for some reason + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text( + text = description, + style = TextStyle( + fontSize = 12.sp, + fontWeight = FontWeight.Light, + color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + // modifier = Modifier.padding(horizontal = 16.dp) + ) + } + } } } @@ -101,4 +157,4 @@ fun NavigationButton(to: String, name: String, navController: NavController) { @Composable fun NavigationButtonPreview() { NavigationButton("to", "Name", NavController(LocalContext.current)) -} \ No newline at end of file +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt index afb17964b..1dd01b4d2 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt @@ -179,16 +179,21 @@ fun NoiseControlSettings( } else { context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter) } - - Text( - text = stringResource(R.string.noise_control).uppercase(), - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Light, - color = textColor.copy(alpha = 0.6f), - ), - modifier = Modifier.padding(8.dp, bottom = 2.dp) - ) + Box( + modifier = Modifier + .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) + .padding(horizontal = 16.dp, vertical = 4.dp) + ){ + Text( + text = stringResource(R.string.noise_control), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = textColor.copy(alpha = 0.6f), + ) + ) + } + @Suppress("COMPOSE_APPLIER_CALL_MISMATCH") BoxWithConstraints( modifier = Modifier .fillMaxWidth() @@ -240,7 +245,7 @@ fun NoiseControlSettings( modifier = Modifier .fillMaxWidth() .height(60.dp) - .background(backgroundColor, RoundedCornerShape(14.dp)) + .background(backgroundColor, RoundedCornerShape(28.dp)) ) { Row( modifier = Modifier.fillMaxWidth() @@ -333,7 +338,7 @@ fun NoiseControlSettings( modifier = Modifier .fillMaxSize() .padding(3.dp) - .background(selectedBackground, RoundedCornerShape(12.dp)) + .background(selectedBackground, RoundedCornerShape(26.dp)) ) } @@ -399,7 +404,6 @@ fun NoiseControlSettings( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 4.dp) .padding(top = 4.dp) ) { if (offListeningMode.value) { @@ -407,7 +411,6 @@ fun NoiseControlSettings( text = stringResource(R.string.off), style = TextStyle(fontSize = 12.sp, color = textColor), textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f) ) } @@ -415,21 +418,18 @@ fun NoiseControlSettings( text = stringResource(R.string.transparency), style = TextStyle(fontSize = 12.sp, color = textColor), textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f) ) Text( text = stringResource(R.string.adaptive), style = TextStyle(fontSize = 12.sp, color = textColor), textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f) ) Text( text = stringResource(R.string.noise_cancellation), style = TextStyle(fontSize = 12.sp, color = textColor), textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f) ) } @@ -437,7 +437,7 @@ fun NoiseControlSettings( } } -@Preview() +@Preview @Composable fun NoiseControlSettingsPreview() { NoiseControlSettings(AirPodsService()) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt index eb83542ec..4c5deeae7 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt @@ -19,34 +19,22 @@ package me.kavishdevar.librepods.composables import android.content.Context -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.tween +import android.content.res.Configuration import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -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 @@ -65,12 +53,6 @@ fun PressAndHoldSettings(navController: NavController) { val isDarkTheme = isSystemInDarkTheme() val textColor = if (isDarkTheme) Color.White else Color.Black val dividerColor = Color(0x40888888) - var leftBackgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } - var rightBackgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } - - val animationSpec = tween(durationMillis = 500) - val animatedLeftBackgroundColor by animateColorAsState(targetValue = leftBackgroundColor, animationSpec = animationSpec) - val animatedRightBackgroundColor by animateColorAsState(targetValue = rightBackgroundColor, animationSpec = animationSpec) val context = LocalContext.current val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) @@ -89,143 +71,51 @@ fun PressAndHoldSettings(navController: NavController) { StemAction.DIGITAL_ASSISTANT -> "Digital Assistant" else -> "INVALID!!" } - - Text( - text = stringResource(R.string.press_and_hold_airpods).uppercase(), - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Light, - color = textColor.copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - modifier = Modifier.padding(8.dp, bottom = 2.dp) - ) - - Spacer(modifier = Modifier.height(1.dp)) - + Box( + modifier = Modifier + .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) + .padding(horizontal = 16.dp, vertical = 4.dp) + ){ + Text( + text = stringResource(R.string.press_and_hold_airpods), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } Column( modifier = Modifier .fillMaxWidth() - .background(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF), RoundedCornerShape(14.dp)) + .background(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF), RoundedCornerShape(28.dp)) + .clip(RoundedCornerShape(28.dp)) ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(55.dp) - .background(animatedLeftBackgroundColor, RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - leftBackgroundColor = dividerColor - tryAwaitRelease() - leftBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - }, - onTap = { - navController.navigate("long_press/Left") - } - ) - }, - contentAlignment = Alignment.Center - ) { - Row( - modifier = Modifier - .padding(start = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.left), - style = TextStyle( - fontSize = 18.sp, - color = textColor, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - ) - Spacer(modifier = Modifier.weight(1f)) - Text( - text = leftActionText, - style = TextStyle( - fontSize = 18.sp, - color = textColor.copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - ) - IconButton( - onClick = { - navController.navigate("long_press/Left") - } - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = "go", - tint = textColor - ) - } - } - } + NavigationButton( + to = "long_press/Left", + name = stringResource(R.string.left), + navController = navController, + independent = false, + currentState = leftActionText, + ) HorizontalDivider( - thickness = 1.5.dp, + thickness = 1.dp, color = dividerColor, modifier = Modifier - .padding(start = 16.dp) + .padding(horizontal = 16.dp) + ) + NavigationButton( + to = "long_press/Right", + name = stringResource(R.string.right), + navController = navController, + independent = false, + currentState = rightActionText, ) - Box( - modifier = Modifier - .fillMaxWidth() - .height(55.dp) - .background(animatedRightBackgroundColor, RoundedCornerShape(bottomEnd = 14.dp, bottomStart = 14.dp)) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - rightBackgroundColor = dividerColor - tryAwaitRelease() - rightBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - }, - onTap = { - navController.navigate("long_press/Right") - } - ) - }, - contentAlignment = Alignment.Center - ) { - Row( - modifier = Modifier - .padding(start = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.right), - style = TextStyle( - fontSize = 18.sp, - color = textColor, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - ) - Spacer(modifier = Modifier.weight(1f)) - Text( - text = rightActionText, - style = TextStyle( - fontSize = 18.sp, - color = textColor.copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - ) - IconButton( - onClick = { - navController.navigate("long_press/Right") - } - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = "go", - tint = textColor - ) - } - } - } } } -@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun PressAndHoldSettingsPreview() { PressAndHoldSettings(navController = NavController(LocalContext.current)) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/SinglePodANCSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/SinglePodANCSwitch.kt deleted file mode 100644 index 370be0db9..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/SinglePodANCSwitch.kt +++ /dev/null @@ -1,133 +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 Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.composables - -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.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import me.kavishdevar.librepods.services.ServiceManager -import me.kavishdevar.librepods.utils.AACPManager -import kotlin.io.encoding.ExperimentalEncodingApi - -@Composable -fun SinglePodANCSwitch() { - val service = ServiceManager.getService()!! - val singleANCEnabledValue = service.aacpManager.controlCommandStatusList.find { - it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE - }?.value?.takeIf { it.isNotEmpty() }?.get(0) - var singleANCEnabled by remember { - mutableStateOf( - singleANCEnabledValue == 1.toByte() - ) - } - - fun updateSingleEnabled(enabled: Boolean) { - singleANCEnabled = enabled - service.aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE.value, - enabled - ) - } - - val isDarkTheme = isSystemInDarkTheme() - val textColor = if (isDarkTheme) Color.White else Color.Black - - val isPressed = remember { mutableStateOf(false) } - - Row( - modifier = Modifier - .fillMaxWidth() - .background( - shape = RoundedCornerShape(14.dp), - color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent - ) - .padding(horizontal = 12.dp, vertical = 12.dp) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - isPressed.value = true - tryAwaitRelease() - isPressed.value = false - } - ) - } - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - updateSingleEnabled(!singleANCEnabled) - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(end = 4.dp) - ) { - Text( - text = "Noise Cancellation with Single AirPod", - fontSize = 16.sp, - color = textColor - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear.", - fontSize = 12.sp, - color = textColor.copy(0.6f), - lineHeight = 14.sp, - ) - } - StyledSwitch( - checked = singleANCEnabled, - onCheckedChange = { - updateSingleEnabled(it) - }, - ) - } -} - -@Preview -@Composable -fun SinglePodANCSwitchPreview() { - SinglePodANCSwitch() -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledButton.kt new file mode 100644 index 000000000..01438bdfc --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledButton.kt @@ -0,0 +1,284 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.composables + +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.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +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.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +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.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.refraction +import com.kyant.backdrop.effects.vibrancy +import com.kyant.backdrop.highlight.Highlight +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.utils.inspectDragGestures +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.tanh + +@Composable +fun StyledButton( + onClick: () -> Unit, + backdrop: Backdrop, + modifier: Modifier = Modifier, + isInteractive: Boolean = true, + tint: Color = Color.Unspecified, + surfaceColor: Color = Color.Unspecified, + maxScale: Float = 0.1f, + content: @Composable RowScope.() -> Unit, +) { + val animationScope = rememberCoroutineScope() + val progressAnimation = remember { Animatable(0f) } + var pressStartPosition by remember { mutableStateOf(Offset.Zero) } + val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) } + var isPressed by remember { mutableStateOf(false) } + + val interactiveHighlightShader = remember { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + RuntimeShader( + """ +uniform float2 size; +layout(color) uniform half4 color; +uniform float radius; +uniform float2 offset; + +half4 main(float2 coord) { + float2 center = offset; + float dist = distance(coord, center); + float intensity = smoothstep(radius, radius * 0.5, dist); + return color * intensity; +}""" + ) + } else { + null + } + } + + Row( + modifier + .then( + if (!isInteractive) { + Modifier.drawBackdrop( + backdrop = backdrop, + shape = { RoundedCornerShape(28f.dp) }, + effects = { + blur(16f.dp.toPx()) + }, + layerBlock = null, + onDrawSurface = { + if (tint.isSpecified) { + drawRect(tint, blendMode = BlendMode.Hue) + drawRect(tint.copy(alpha = 0.75f)) + } else { + drawRect(Color.White.copy(0.1f)) + } + if (surfaceColor.isSpecified) { + val color = if (!isInteractive && isPressed) { + Color( + red = surfaceColor.red * 0.5f, + green = surfaceColor.green * 0.5f, + blue = surfaceColor.blue * 0.5f, + alpha = surfaceColor.alpha + ) + } else { + surfaceColor + } + drawRect(color) + } + }, + onDrawFront = null, + highlight = { Highlight.Ambient.copy(alpha = 0f) } + ) + } else { + Modifier.drawBackdrop( + backdrop = backdrop, + shape = { RoundedCornerShape(28f.dp) }, + effects = { + vibrancy() + blur(2f.dp.toPx()) + refraction(12f.dp.toPx(), 24f.dp.toPx()) + }, + layerBlock = { + val width = size.width + val height = size.height + + val progress = progressAnimation.value + val scale = lerp(1f, 1f + maxScale, progress) + + val maxOffset = size.minDimension + val initialDerivative = 0.05f + val offset = offsetAnimation.value + translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset) + translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset) + + val maxDragScale = 0.1f + val offsetAngle = atan2(offset.y, offset.x) + scaleX = + scale + + maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) * + (width / height).fastCoerceAtMost(1f) + scaleY = + scale + + maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) * + (height / width).fastCoerceAtMost(1f) + }, + onDrawSurface = { + if (tint.isSpecified) { + drawRect(tint, blendMode = BlendMode.Hue) + drawRect(tint.copy(alpha = 0.75f)) + } else { + drawRect(Color.White.copy(0.1f)) + } + if (surfaceColor.isSpecified) { + val color = if (!isInteractive && isPressed) { + Color( + red = surfaceColor.red * 0.5f, + green = surfaceColor.green * 0.5f, + blue = surfaceColor.blue * 0.5f, + alpha = surfaceColor.alpha + ) + } else { + surfaceColor + } + drawRect(color) + } + }, + onDrawFront = { + 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 + ) + 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 + ) + } + } + } + ) + } + ) + .clickable( + interactionSource = null, + indication = null, + role = Role.Button, + onClick = onClick + ) + .then( + if (isInteractive) { + Modifier.pointerInput(animationScope) { + val progressAnimationSpec = spring(0.5f, 300f, 0.001f) + val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold) + val onDragStop: () -> Unit = { + animationScope.launch { + launch { progressAnimation.animateTo(0f, progressAnimationSpec) } + launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) } + } + } + inspectDragGestures( + onDragStart = { down -> + pressStartPosition = down.position + animationScope.launch { + launch { progressAnimation.animateTo(1f, progressAnimationSpec) } + launch { offsetAnimation.snapTo(Offset.Zero) } + } + }, + onDragEnd = { onDragStop() }, + onDragCancel = onDragStop + ) { _, dragAmount -> + animationScope.launch { + offsetAnimation.snapTo(offsetAnimation.value + dragAmount) + } + } + } + } else { + Modifier.pointerInput(Unit) { + detectTapGestures( + onPress = { + isPressed = true + tryAwaitRelease() + isPressed = false + }, + onTap = { + onClick() + } + ) + } + } + ) + .height(48f.dp) + .padding(horizontal = 16f.dp), + horizontalArrangement = Arrangement.spacedBy(8f.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + content = content + ) +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledDropdown.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledDropdown.kt new file mode 100644 index 000000000..73cf74493 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledDropdown.kt @@ -0,0 +1,244 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.composables + +import android.annotation.SuppressLint +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import dev.chrisbanes.haze.HazeEffectScope +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeTint +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.materials.CupertinoMaterials +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import me.kavishdevar.librepods.R + +@ExperimentalHazeMaterialsApi +@Composable +fun StyledDropdown( + expanded: Boolean, + onDismissRequest: () -> Unit, + options: List, + selectedOption: String, + touchOffset: Offset?, + boxPosition: Offset, + onOptionSelected: (String) -> Unit, + externalHoveredIndex: Int? = null, + externalDragActive: Boolean = false, + hazeState: HazeState, + @SuppressLint("ModifierParameter") modifier: Modifier = Modifier +) { + if (expanded) { + val relativeOffset = touchOffset?.let { it - boxPosition } ?: Offset.Zero + Popup( + offset = IntOffset(relativeOffset.x.toInt(), relativeOffset.y.toInt()), + onDismissRequest = onDismissRequest + ) { + AnimatedVisibility( + visible = true, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() + ) { + Card( + modifier = modifier + .padding(8.dp) + .width(300.dp) + .background(Color.Transparent) + .clip(RoundedCornerShape(8.dp)), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + var hoveredIndex by remember { mutableStateOf(null) } + val itemHeight = 48.dp + + var popupSize by remember { mutableStateOf(IntSize(0, 0)) } + var lastDragPosition by remember { mutableStateOf(null) } + + LaunchedEffect(externalHoveredIndex, externalDragActive) { + if (externalDragActive) { + hoveredIndex = externalHoveredIndex + } + } + + Column( + modifier = Modifier + .onGloballyPositioned { coordinates -> + popupSize = coordinates.size + } + .pointerInput(popupSize) { + detectDragGestures( + onDragStart = { offset -> + hoveredIndex = (offset.y / itemHeight.toPx()).toInt() + lastDragPosition = offset + }, + onDrag = { change, _ -> + val y = change.position.y + hoveredIndex = (y / itemHeight.toPx()).toInt() + lastDragPosition = change.position + }, + onDragEnd = { + val pos = lastDragPosition + val withinBounds = pos != null && + pos.x >= 0f && pos.y >= 0f && + pos.x <= popupSize.width.toFloat() && pos.y <= popupSize.height.toFloat() + + if (withinBounds) { + hoveredIndex?.let { idx -> + if (idx in options.indices) { + onOptionSelected(options[idx]) + } + } + onDismissRequest() + } else { + hoveredIndex = null + } + } + ) + } + ) { + options.forEachIndexed { index, text -> + val isHovered = + if (externalDragActive && externalHoveredIndex != null) { + index == externalHoveredIndex + } else { + index == hoveredIndex + } + val isSystemInDarkTheme = isSystemInDarkTheme() + Box( + modifier = Modifier + .fillMaxWidth() + .height(itemHeight) + .background( + Color.Transparent + ) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + onOptionSelected(text) + onDismissRequest() + } + .hazeEffect( + state = hazeState, + style = CupertinoMaterials.regular(), + block = fun HazeEffectScope.() { + alpha = 1f + backgroundColor = if (isSystemInDarkTheme) { + Color(0xB02C2C2E) + } else { + Color(0xB0FFFFFF) + } + tints = if (isHovered) listOf( + HazeTint( + color = if (isSystemInDarkTheme) Color(0x338A8A8A) else Color(0x40D9D9D9) + ) + ) else listOf() + }) + .padding(horizontal = 12.dp), + contentAlignment = Alignment.CenterStart + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text, + style = TextStyle( + fontSize = 16.sp, + color = if (isSystemInDarkTheme()) Color.White else Color.Black.copy(alpha = 0.75f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Checkbox( + checked = text == selectedOption, + onCheckedChange = { onOptionSelected(text) }, + colors = CheckboxDefaults.colors().copy( + checkedCheckmarkColor = Color(0xFF007AFF), + uncheckedCheckmarkColor = Color.Transparent, + checkedBoxColor = Color.Transparent, + uncheckedBoxColor = Color.Transparent, + checkedBorderColor = Color.Transparent, + uncheckedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent, + disabledCheckedBoxColor = Color.Transparent, + disabledUncheckedBoxColor = Color.Transparent, + disabledUncheckedBorderColor = Color.Transparent + ) + ) + } + } + + if (index != options.lastIndex) { + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier.padding(start = 12.dp, end = 0.dp) + ) + } + } + } + } + } + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledIconButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledIconButton.kt new file mode 100644 index 000000000..5f7071879 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledIconButton.kt @@ -0,0 +1,260 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.composables + +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.isSystemInDarkTheme +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.BlurEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawOutline +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.rememberGraphicsLayer +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastCoerceAtMost +import androidx.compose.ui.util.fastCoerceIn +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.blur +import com.kyant.backdrop.effects.refractionWithDispersion +import com.kyant.backdrop.highlight.Highlight +import com.kyant.backdrop.shadow.Shadow +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.utils.inspectDragGestures +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.tanh + +@Composable +fun StyledIconButton( + onClick: () -> Unit, + icon: String, + darkMode: Boolean, + tint: Color = Color.Unspecified, + backdrop: LayerBackdrop = rememberLayerBackdrop(), + modifier: Modifier = Modifier, +) { + val animationScope = rememberCoroutineScope() + val progressAnimationSpec = spring(0.5f, 300f, 0.001f) + val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold) + val progressAnimation = remember { Animatable(0f) } + val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) } + var pressStartPosition by remember { mutableStateOf(Offset.Zero) } + val innerShadowLayer = rememberGraphicsLayer().apply { + compositingStrategy = CompositingStrategy.Offscreen + } + + val interactiveHighlightShader = remember { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + RuntimeShader( + """ +uniform float2 size; +layout(color) uniform half4 color; +uniform float radius; +uniform float2 offset; + +half4 main(float2 coord) { + float2 center = offset; + float dist = distance(coord, center); + float intensity = smoothstep(radius, radius * 0.5, dist); + return color * intensity; +}""" + ) + } else { + null + } + } + val isDarkTheme = isSystemInDarkTheme() + TextButton( + onClick = onClick, + shape = RoundedCornerShape(56.dp), + modifier = modifier + .padding(horizontal = 12.dp) + .drawBackdrop( + backdrop = backdrop, + shape = { RoundedCornerShape(56.dp) }, + highlight = { Highlight.Ambient.copy(alpha = if (isDarkTheme) 1f else 0f) }, + shadow = { + Shadow( + radius = 12f.dp, + color = Color.Black.copy(if (isDarkTheme) 0.08f else 0.2f) + ) + }, + layerBlock = { + val width = size.width + val height = size.height + + val progress = progressAnimation.value + val scale = lerp(1f, 1.5f, progress) + + val maxOffset = size.minDimension + val initialDerivative = 0.05f + val offset = offsetAnimation.value + translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset) + translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset) + + val maxDragScale = 0.1f + val offsetAngle = atan2(offset.y, offset.x) + scaleX = + scale + + maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) * + (width / height).fastCoerceAtMost(1f) + scaleY = + scale + + maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) * + (height / width).fastCoerceAtMost(1f) + }, + onDrawSurface = { + val progress = progressAnimation.value.coerceIn(0f, 1f) + + val shape = RoundedCornerShape(56.dp) + val outline = shape.createOutline(size, layoutDirection, this) + val innerShadowOffset = 4f.dp.toPx() + val innerShadowBlurRadius = 4f.dp.toPx() + + innerShadowLayer.alpha = progress + innerShadowLayer.renderEffect = + BlurEffect( + innerShadowBlurRadius, + innerShadowBlurRadius, + TileMode.Decal + ) + innerShadowLayer.record { + drawOutline(outline, Color.Black.copy(0.2f)) + translate(0f, innerShadowOffset) { + drawOutline( + outline, + Color.Transparent, + blendMode = BlendMode.Clear + ) + } + } + drawLayer(innerShadowLayer) + + drawRect( + (if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(progress.coerceIn(0.15f, 0.35f)) + ) + }, + onDrawFront = { + 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 + ) + 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 + ) + } + } + }, + effects = { + refractionWithDispersion(6f.dp.toPx(), size.height / 2f) + // blur(24f, TileMode.Decal) + }, + ) + .pointerInput(animationScope) { + val onDragStop: () -> Unit = { + animationScope.launch { + launch { progressAnimation.animateTo(0f, progressAnimationSpec) } + launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) } + } + } + inspectDragGestures( + onDragStart = { down -> + pressStartPosition = down.position + animationScope.launch { + launch { progressAnimation.animateTo(1f, progressAnimationSpec) } + launch { offsetAnimation.snapTo(Offset.Zero) } + } + }, + onDragEnd = { onDragStop() }, + onDragCancel = onDragStop + ) { _, dragAmount -> + animationScope.launch { + offsetAnimation.snapTo(offsetAnimation.value + dragAmount) + } + } + } + .size(48.dp), + ) { + Text( + text = icon, + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = if (tint.isSpecified) tint else if (darkMode) Color.White else Color.Black, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledScaffold.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledScaffold.kt new file mode 100644 index 000000000..6c034f90f --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledScaffold.kt @@ -0,0 +1,168 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.composables + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.draw.shadow +import androidx.compose.ui.graphics.Color +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.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import androidx.navigation.NavController +import com.kyant.backdrop.backdrops.LayerBackdrop +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import dev.chrisbanes.haze.HazeProgressive +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeTint +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.rememberHazeState +import me.kavishdevar.librepods.R + +@ExperimentalHazeMaterialsApi +@Composable +fun StyledScaffold( + title: String, + actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(), + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + content: @Composable (spacerValue: Dp, hazeState: HazeState) -> Unit +) { + val isDarkTheme = isSystemInDarkTheme() + val hazeState = rememberHazeState(blurEnabled = true) + + Scaffold( + containerColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7), + snackbarHost = { SnackbarHost(snackbarHostState) }, + modifier = Modifier + .then(if (!isDarkTheme) Modifier.shadow(elevation = 36.dp, shape = RoundedCornerShape(52.dp), ambientColor = Color.Black, spotColor = Color.Black) else Modifier) + .clip(RoundedCornerShape(52.dp)) + ) { paddingValues -> + val topPadding = paddingValues.calculateTopPadding() + val bottomPadding = paddingValues.calculateBottomPadding() + val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current) + val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(start = startPadding, end = endPadding, bottom = bottomPadding) + ) { + val backdrop = rememberLayerBackdrop() + Box( + modifier = Modifier + .zIndex(2f) + .height(64.dp + topPadding) + .fillMaxWidth() + .layerBackdrop(backdrop) + .hazeEffect(state = hazeState) { + tints = listOf(HazeTint(color = if (isDarkTheme) Color.Black else Color.White)) + progressive = HazeProgressive.verticalGradient(startIntensity = 1f, endIntensity = 0f) + } + ) { + Column(modifier = Modifier.fillMaxSize()) { + Spacer(modifier = Modifier.height(topPadding + 12.dp)) + Text( + text = title, + style = TextStyle( + fontSize = 20.sp, + fontWeight = FontWeight.Medium, + color = if (isDarkTheme) Color.White else Color.Black, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } + Row( + modifier = Modifier + .zIndex(3f) + .padding(top = topPadding, end = 8.dp) + .align(Alignment.TopEnd) + ) { + actionButtons.forEach { actionButton -> + actionButton(backdrop) + } + } + + content(topPadding + 64.dp, hazeState) + } + } +} + + +@ExperimentalHazeMaterialsApi +@Composable +fun StyledScaffold( + title: String, + actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(), + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + content: @Composable () -> Unit +) { + StyledScaffold( + title = title, + actionButtons = actionButtons, + snackbarHostState = snackbarHostState, + ) { _, _ -> + content() + } +} + +@ExperimentalHazeMaterialsApi +@Composable +fun StyledScaffold( + title: String, + actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(), + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + content: @Composable (spacerValue: Dp) -> Unit +) { + StyledScaffold( + title = title, + actionButtons = actionButtons, + snackbarHostState = snackbarHostState, + ) { spacerValue, _ -> + content(spacerValue) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSelectList.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSelectList.kt new file mode 100644 index 000000000..58e196c86 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSelectList.kt @@ -0,0 +1,184 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.composables + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import me.kavishdevar.librepods.R + +data class SelectItem( + val name: String, + val description: String? = null, + val iconRes: Int? = null, + val selected: Boolean, + val onClick: () -> Unit, + val enabled: Boolean = true +) + +data class SelectItem2( + val name: String, + val description: String? = null, + val iconRes: Int? = null, + val selected: () -> Boolean, + val onClick: () -> Unit, + val enabled: Boolean = true +) + + +@Composable +fun StyledSelectList( + items: List, + modifier: Modifier = Modifier +) { + val isDarkTheme = isSystemInDarkTheme() + val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + val textColor = if (isDarkTheme) Color.White else Color.Black + + Column( + modifier = modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(28.dp)), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val visibleItems = items.filter { it.enabled } + visibleItems.forEachIndexed { index, item -> + val isFirst = index == 0 + val isLast = index == visibleItems.size - 1 + val hasIcon = item.iconRes != null + + val shape = when { + isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) + isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp) + else -> RoundedCornerShape(0.dp) + } + var itemBackgroundColor by remember { mutableStateOf(backgroundColor) } + val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500)) + + Row( + modifier = Modifier + .height(if (hasIcon) 72.dp else 55.dp) + .background(animatedBackgroundColor, shape) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + itemBackgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) + tryAwaitRelease() + itemBackgroundColor = backgroundColor + item.onClick() + } + ) + } + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + if (hasIcon) { + Icon( + painter = painterResource(item.iconRes!!), + contentDescription = "Icon", + tint = Color(0xFF007AFF), + modifier = Modifier + .height(48.dp) + .wrapContentWidth() + ) + } + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = 2.dp) + .padding(start = if (hasIcon) 8.dp else 4.dp) + ) { + Text( + item.name, + fontSize = 16.sp, + color = textColor, + fontFamily = FontFamily(Font(R.font.sf_pro)), + ) + item.description?.let { + Text( + it, + fontSize = 14.sp, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)), + ) + } + } + val floatAnimateState by animateFloatAsState( + targetValue = if (item.selected) 1f else 0f, + animationSpec = tween(durationMillis = 300) + ) + Text( + text = "􀆅", + style = TextStyle( + fontSize = 20.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = Color(0xFF007AFF).copy(alpha = floatAnimateState), + ), + modifier = Modifier.padding(end = 4.dp) + ) + } + if (!isLast) { + if (hasIcon) { + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier.padding(start = 72.dp, end = 20.dp) + ) + } else { + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier.padding(start = 20.dp, end = 20.dp) + ) + } + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt new file mode 100644 index 000000000..3aba12635 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt @@ -0,0 +1,587 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.composables + +import android.content.res.Configuration +import android.util.Log +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.spring +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 +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableFloatState +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.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.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.input.pointer.util.addPointerInputChange +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInParent +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.tooling.preview.Preview +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 +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberCombinedBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.refractionWithDispersion +import com.kyant.backdrop.highlight.Highlight +import com.kyant.backdrop.shadow.InnerShadow +import com.kyant.backdrop.shadow.Shadow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.utils.inspectDragGestures +import kotlin.math.abs +import kotlin.math.roundToInt + +@Composable +fun rememberMomentumAnimation( + maxScale: Float, + progressAnimationSpec: FiniteAnimationSpec = + spring(1f, 1000f, 0.01f), + velocityAnimationSpec: FiniteAnimationSpec = + spring(0.5f, 250f, 5f), + scaleXAnimationSpec: FiniteAnimationSpec = + spring(0.4f, 400f, 0.01f), + scaleYAnimationSpec: FiniteAnimationSpec = + spring(0.6f, 400f, 0.01f) +): MomentumAnimation { + val animationScope = rememberCoroutineScope() + return remember( + maxScale, + animationScope, + progressAnimationSpec, + velocityAnimationSpec, + scaleXAnimationSpec, + scaleYAnimationSpec + ) { + MomentumAnimation( + maxScale = maxScale, + animationScope = animationScope, + progressAnimationSpec = progressAnimationSpec, + velocityAnimationSpec = velocityAnimationSpec, + scaleXAnimationSpec = scaleXAnimationSpec, + scaleYAnimationSpec = scaleYAnimationSpec + ) + } +} + +class MomentumAnimation( + val maxScale: Float, + private val animationScope: CoroutineScope, + private val progressAnimationSpec: FiniteAnimationSpec, + private val velocityAnimationSpec: FiniteAnimationSpec, + private val scaleXAnimationSpec: FiniteAnimationSpec, + private val scaleYAnimationSpec: FiniteAnimationSpec +) { + + private val velocityTracker = VelocityTracker() + + private val progressAnimation = Animatable(0f) + private val velocityAnimation = Animatable(0f) + private val scaleXAnimation = Animatable(1f) + private val scaleYAnimation = Animatable(1f) + + val progress: Float get() = progressAnimation.value + val velocity: Float get() = velocityAnimation.value + val scaleX: Float get() = scaleXAnimation.value + val scaleY: Float get() = scaleYAnimation.value + + var isDragging: Boolean by mutableStateOf(false) + private set + + val modifier: Modifier = Modifier.pointerInput(Unit) { + inspectDragGestures( + onDragStart = { + isDragging = true + velocityTracker.resetTracking() + startPressingAnimation() + }, + onDragEnd = { change -> + isDragging = false + val velocity = velocityTracker.calculateVelocity() + updateVelocity(velocity) + velocityTracker.addPointerInputChange(change) + velocityTracker.resetTracking() + endPressingAnimation() + settleVelocity() + }, + onDragCancel = { + isDragging = false + velocityTracker.resetTracking() + endPressingAnimation() + settleVelocity() + } + ) { change, _ -> + isDragging = true + velocityTracker.addPointerInputChange(change) + val velocity = velocityTracker.calculateVelocity() + updateVelocity(velocity) + } + } + + private fun updateVelocity(velocity: Velocity) { + animationScope.launch { velocityAnimation.animateTo(velocity.x, velocityAnimationSpec) } + } + + private fun settleVelocity() { + animationScope.launch { velocityAnimation.animateTo(0f, velocityAnimationSpec) } + } + + fun startPressingAnimation() { + animationScope.launch { + launch { progressAnimation.animateTo(1f, progressAnimationSpec) } + launch { scaleXAnimation.animateTo(maxScale, scaleXAnimationSpec) } + launch { scaleYAnimation.animateTo(maxScale, scaleYAnimationSpec) } + } + } + + fun endPressingAnimation() { + animationScope.launch { + launch { progressAnimation.animateTo(0f, progressAnimationSpec) } + launch { scaleXAnimation.animateTo(1f, scaleXAnimationSpec) } + launch { scaleYAnimation.animateTo(1f, scaleYAnimationSpec) } + } + } +} + +@Composable +fun StyledSlider( + label: String? = null, + mutableFloatState: MutableFloatState, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange, + backdrop: Backdrop = rememberLayerBackdrop(), + snapPoints: List = emptyList(), + snapThreshold: Float = 0.05f, + startIcon: String? = null, + endIcon: String? = null, + startLabel: String? = null, + endLabel: String? = null, + independent: Boolean = false, + description: String? = null +) { + val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + val isLightTheme = !isSystemInDarkTheme() + val accentColor = + if (isLightTheme) Color(0xFF0088FF) + else Color(0xFF0091FF) + val trackColor = + if (isLightTheme) Color(0xFF787878).copy(0.2f) + else Color(0xFF787880).copy(0.36f) + val labelTextColor = if (isLightTheme) Color.Black else Color.White + + val fraction by remember { + derivedStateOf { + ((mutableFloatState.floatValue - valueRange.start) / (valueRange.endInclusive - valueRange.start)) + .fastCoerceIn(0f, 1f) + } + } + + val sliderBackdrop = rememberLayerBackdrop() + val trackWidthState = remember { mutableFloatStateOf(0f) } + val trackPositionState = remember { mutableFloatStateOf(0f) } + val startIconWidthState = remember { mutableFloatStateOf(0f) } + val endIconWidthState = remember { mutableFloatStateOf(0f) } + val density = LocalDensity.current + + val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f) + + val content = @Composable { + Box( + Modifier + .fillMaxWidth(if (startIcon == null && endIcon == null) 0.95f else 1f) + ) { + Box( + Modifier + .padding(vertical = 4.dp) + .layerBackdrop(sliderBackdrop) + .fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth(1f) + .padding(vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (startLabel != null || endLabel != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = startLabel ?: "", + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = endLabel ?: "", + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + Spacer(modifier = Modifier.height(12.dp)) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .then(if (startIcon == null && endIcon == null) Modifier.padding(horizontal = 8.dp) else Modifier), + ) { + Row( + 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)) + ), + modifier = Modifier + .padding(horizontal = 12.dp) + .onGloballyPositioned { + startIconWidthState.floatValue = it.size.width.toFloat() + } + ) + } + Box( + Modifier + .weight(1f) + .onSizeChanged { trackWidthState.floatValue = it.width.toFloat() } + .onGloballyPositioned { + trackPositionState.floatValue = + it.positionInParent().y + it.size.height / 2f + } + ) { + Box( + Modifier + .clip(RoundedCornerShape(28.dp)) + .background(trackColor) + .height(6f.dp) + .fillMaxWidth() + ) + + Box( + Modifier + .clip(RoundedCornerShape(28.dp)) + .background(accentColor) + .height(6f.dp) + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val fraction = fraction + val width = + (fraction * constraints.maxWidth).fastRoundToInt() + layout(width, placeable.height) { + placeable.place(0, 0) + } + } + ) + } + if (endIcon != null) { + Text( + text = endIcon, + style = TextStyle( + fontSize = 18.sp, + fontWeight = FontWeight.Normal, + color = accentColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier + .padding(horizontal = 12.dp) + .onGloballyPositioned { + endIconWidthState.floatValue = it.size.width.toFloat() + } + ) + } + } + if (snapPoints.isNotEmpty() && startLabel != null && endLabel != null) Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + if (snapPoints.isNotEmpty()) { + val trackWidth = if (startIcon != null && endIcon != null) trackWidthState.floatValue - with(density) { 6.dp.toPx() } * 2 else trackWidthState.floatValue- with(density) { 22.dp.toPx() } + val startOffset = + if (startIcon != null) startIconWidthState.floatValue + with( + density + ) { 34.dp.toPx() } else with(density) { 14.dp.toPx() } + Box( + Modifier + .fillMaxWidth() + ) { + snapPoints.forEach { point -> + val pointFraction = + ((point - valueRange.start) / (valueRange.endInclusive - valueRange.start)) + .fastCoerceIn(0f, 1f) + Box( + Modifier + .graphicsLayer { + translationX = + startOffset + pointFraction * trackWidth - 4.dp.toPx() + } + .size(2.dp) + .background( + trackColor, + CircleShape + ) + ) + } + } + } + } + } + } + } + + Box( + Modifier + .graphicsLayer { +// val startOffset = +// if (startIcon != null) startIconWidthState.floatValue + with(density) { 24.dp.toPx() } else with(density) { 12.dp.toPx() } +// translationX = +// startOffset + fraction * trackWidthState.floatValue - size.width / 2f + val startOffset = + if (startIcon != null) + startIconWidthState.floatValue + with(density) { 24.dp.toPx() } + else + with(density) { 8.dp.toPx() } + + translationX = + (startOffset + fraction * trackWidthState.floatValue - size.width / 2f) + .fastCoerceIn( + startOffset - size.width / 4f, + startOffset + trackWidthState.floatValue - size.width * 3f / 4f + ) + translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() } + } + .draggable( + rememberDraggableState { delta -> + val trackWidth = trackWidthState.floatValue + if (trackWidth > 0f) { + val targetFraction = fraction + delta / trackWidth + val targetValue = + lerp(valueRange.start, valueRange.endInclusive, targetFraction) + .fastCoerceIn(valueRange.start, valueRange.endInclusive) + val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose( + targetValue, + snapPoints, + snapThreshold + ) else targetValue + onValueChange(snappedValue) + } + }, + Orientation.Horizontal, + startDragImmediately = true, + onDragStarted = { + // Remove this block as momentumAnimation handles pressing + }, + onDragStopped = { + // Remove this block as momentumAnimation handles pressing + onValueChange((mutableFloatState.floatValue * 100).roundToInt() / 100f) + } + ) + .then(momentumAnimation.modifier) + .drawBackdrop( + rememberCombinedBackdrop(backdrop, sliderBackdrop), + { RoundedCornerShape(28.dp) }, + highlight = { + val progress = momentumAnimation.progress + Highlight.Ambient.copy(alpha = progress) + }, + shadow = { + Shadow( + radius = 4f.dp, + color = Color.Black.copy(0.05f) + ) + }, + innerShadow = { + val progress = momentumAnimation.progress + InnerShadow( + radius = 4f.dp * progress, + alpha = progress + ) + }, + layerBlock = { + scaleX = momentumAnimation.scaleX + scaleY = momentumAnimation.scaleY + val velocity = momentumAnimation.velocity / 5000f + scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.15f, 0.15f) + scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.15f, 0.15f) + }, + onDrawSurface = { + val progress = momentumAnimation.progress + drawRect(Color.White.copy(alpha = 1f - progress)) + }, + effects = { + val progress = momentumAnimation.progress + blur(8f.dp.toPx() * (1f - progress)) + refractionWithDispersion( + height = 6f.dp.toPx() * progress, + amount = size.height / 2f * progress + ) + } + ) + .size(40f.dp, 24f.dp) + ) + } + } + + if (independent) { + + Column ( + modifier = Modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + 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)) + ), + modifier = Modifier.padding(horizontal = 18.dp, vertical = 4.dp) + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(28.dp)) + .padding(horizontal = 8.dp, vertical = 0.dp) + .heightIn(min = 58.dp), + contentAlignment = Alignment.Center + ) { + content() + } + + 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)) + ), + modifier = Modifier + .padding(horizontal = 18.dp, vertical = 4.dp) + ) + } + } + } else { + if (label != null) Log.w("StyledSlider", "Label is ignored when independent is false") + if (description != null) Log.w("StyledSlider", "Description is ignored when independent is false") + content() + } +} + +private fun snapIfClose(value: Float, points: List, threshold: Float = 0.05f): Float { + val nearest = points.minByOrNull { abs(it - value) } ?: value + return if (abs(nearest - value) <= threshold) nearest else value +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun StyledSliderPreview() { + val a = remember { mutableFloatStateOf(0.5f) } + Box( + Modifier + .background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF0F0F0)) + .padding(16.dp) + .fillMaxSize() + ) { + Box ( + Modifier.align(Alignment.Center) + ) + { + StyledSlider( + mutableFloatState = a, + onValueChange = { + a.floatValue = it + }, + valueRange = 0f..2f, + snapPoints = listOf(1f), + snapThreshold = 0.1f, + independent = true, + startIcon = "A", + endIcon = "B", + ) + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt index ba7a67cf7..621a4d34c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt @@ -1,43 +1,78 @@ /* * 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 Affero General Public License as published * by the Free Software Foundation, either version 3 of the License. - * + * * 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 Affero General Public License for more details. - * + * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ package me.kavishdevar.librepods.composables -import androidx.compose.animation.core.animateDpAsState +import android.content.res.Configuration +import androidx.compose.animation.Animatable +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +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.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.offset 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.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.BlurEffect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.drawscope.scale +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.rememberGraphicsLayer +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastCoerceIn +import androidx.compose.ui.util.lerp +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberCombinedBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.refractionWithDispersion +import com.kyant.backdrop.highlight.Highlight +import com.kyant.backdrop.shadow.Shadow +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch @Composable fun StyledSwitch( @@ -47,42 +82,220 @@ fun StyledSwitch( ) { val isDarkTheme = isSystemInDarkTheme() - val thumbColor = Color.White - val trackColor = if (enabled) ( - if (isDarkTheme) { - if (checked) Color(0xFF34C759) else Color(0xFF5B5B5E) - } else { - if (checked) Color(0xFF34C759) else Color(0xFFD1D1D6) - } - ) else { - if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) - } + val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) + val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) + val trackWidth = 64.dp + val trackHeight = 28.dp + val thumbHeight = 24.dp + val thumbWidth = 39.dp - val thumbOffsetX by animateDpAsState(targetValue = if (checked) 20.dp else 0.dp, label = "Test") + val backdrop = rememberLayerBackdrop() + val switchBackdrop = rememberLayerBackdrop() + val fraction by remember { + derivedStateOf { if (checked) 1f else 0f } + } + val animatedFraction = remember { Animatable(fraction) } + val trackWidthPx = remember { mutableFloatStateOf(0f) } + val density = LocalDensity.current + val animationScope = rememberCoroutineScope() + val progressAnimationSpec = spring(0.5f, 300f, 0.001f) + val colorAnimationSpec = tween(200, easing = FastOutSlowInEasing) + val progressAnimation = remember { Animatable(0f) } + val innerShadowLayer = rememberGraphicsLayer().apply { + compositingStrategy = CompositingStrategy.Offscreen + } + val animatedTrackColor = remember { Animatable(if (checked) onColor else offColor) } + val totalDrag = remember { mutableFloatStateOf(0f) } + val tapThreshold = 10f + val isFirstComposition = remember { mutableStateOf(true) } + LaunchedEffect(checked) { + if (!isFirstComposition.value) { + coroutineScope { + launch { + val targetColor = if (checked) onColor else offColor + animatedTrackColor.animateTo(targetColor, colorAnimationSpec) + } + launch { + val targetFrac = if (checked) 1f else 0f + animatedFraction.animateTo(targetFrac, progressAnimationSpec) + } + if (progressAnimation.value > 0f) return@coroutineScope + launch { + progressAnimation.animateTo(1f, tween(175, easing = FastOutSlowInEasing)) + progressAnimation.animateTo(0f, tween(175, easing = FastOutSlowInEasing)) + } + } + } + isFirstComposition.value = false + } Box( modifier = Modifier - .width(51.dp) - .height(31.dp) - .clip(RoundedCornerShape(15.dp)) - .background(trackColor) // Dynamic track background - .padding(horizontal = 3.dp), + .width(trackWidth) + .height(trackHeight), contentAlignment = Alignment.CenterStart ) { Box( modifier = Modifier - .offset(x = thumbOffsetX) - .size(27.dp) - .clip(CircleShape) - .background(thumbColor) - .clickable { if (enabled) onCheckedChange(!checked) } + .layerBackdrop(switchBackdrop) + .clip(RoundedCornerShape(trackHeight / 2)) + .background(animatedTrackColor.value) + .width(trackWidth) + .height(trackHeight) + .onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() } + ) + Box( + modifier = Modifier + .padding(horizontal = 2.dp) + .graphicsLayer { + translationX = animatedFraction.value * (trackWidthPx.floatValue - with(density) { thumbWidth.toPx() + 4.dp.toPx() }) + } + .then(if (enabled) Modifier.draggable( + rememberDraggableState { delta -> + if (trackWidthPx.floatValue > 0f) { + val newFraction = (animatedFraction.value + delta / trackWidthPx.floatValue).fastCoerceIn(-0.3f, 1.3f) + animationScope.launch { + animatedFraction.snapTo(newFraction) + } + totalDrag.floatValue += kotlin.math.abs(delta) + val newChecked = newFraction >= 0.5f + if (newChecked != checked) { + onCheckedChange(newChecked) + } + } + }, + Orientation.Horizontal, + startDragImmediately = true, + onDragStarted = { + totalDrag.floatValue = 0f + animationScope.launch { + progressAnimation.animateTo(1f, progressAnimationSpec) + } + }, + onDragStopped = { + animationScope.launch { + if (totalDrag.floatValue < tapThreshold) { + val newChecked = !checked + onCheckedChange(newChecked) + val snappedFraction = if (newChecked) 1f else 0f + coroutineScope { + launch { progressAnimation.animateTo(0f, progressAnimationSpec) } + launch { animatedFraction.animateTo(snappedFraction, progressAnimationSpec) } + } + } else { + val snappedFraction = if (animatedFraction.value >= 0.5f) 1f else 0f + onCheckedChange(snappedFraction >= 0.5f) + coroutineScope { + launch { progressAnimation.animateTo(0f, progressAnimationSpec) } + launch { animatedFraction.animateTo(snappedFraction, progressAnimationSpec) } + } + } + } + } + ) else Modifier) + .drawBackdrop( + rememberCombinedBackdrop(backdrop, switchBackdrop), + { RoundedCornerShape(thumbHeight / 2) }, + highlight = { + val progress = progressAnimation.value + Highlight.Ambient.copy( + alpha = progress + ) + }, + shadow = { + Shadow( + radius = 4f.dp, + color = Color.Black.copy(0.05f) + ) + }, + layerBlock = { + val progress = progressAnimation.value + val scale = lerp(1f, 1.5f, progress) + scaleX = scale + scaleY = scale + }, + onDrawBackdrop = { drawScope -> + drawIntoCanvas { canvas -> + canvas.save() + canvas.drawRect( + left = 0f, + top = 0f, + right = size.width, + bottom = size.height, + paint = Paint().apply { + color = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7) + } + ) + scale(0.7f) { + drawScope() + } + } + }, + onDrawSurface = { + val progress = progressAnimation.value.fastCoerceIn(0f, 1f) + + val shape = RoundedCornerShape(thumbHeight / 2) + val outline = shape.createOutline(size, layoutDirection, this) + val innerShadowOffset = 4f.dp.toPx() + val innerShadowBlurRadius = 4f.dp.toPx() + + innerShadowLayer.alpha = progress + innerShadowLayer.renderEffect = + BlurEffect( + innerShadowBlurRadius, + innerShadowBlurRadius, + TileMode.Decal + ) + innerShadowLayer.record { + drawOutline(outline, Color.Black.copy(0.2f)) + translate(0f, innerShadowOffset) { + drawOutline( + outline, + Color.Transparent, + blendMode = BlendMode.Clear + ) + } + } + drawLayer(innerShadowLayer) + + drawRect(Color.White.copy(1f - progress)) + }, + effects = { + refractionWithDispersion(6f.dp.toPx(), size.height / 2f) + } + ) + .width(thumbWidth) + .height(thumbHeight) ) } } -@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Composable fun StyledSwitchPreview() { - StyledSwitch(checked = true, onCheckedChange = {}) -} \ No newline at end of file + val isDarkTheme = isSystemInDarkTheme() + val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7) + Box( + modifier = Modifier + .background(backgroundColor) + .width(100.dp) + .height(150.dp), + contentAlignment = Alignment.Center + ) { + val checked = remember { mutableStateOf(true) } + StyledSwitch( + checked = checked.value, + onCheckedChange = { + checked.value = it + }, + enabled = true, + ) +// LaunchedEffect(Unit) { +// delay(1000) +// checked.value = false +// delay(1000) +// checked.value = true +// } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt new file mode 100644 index 000000000..2afd64b05 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt @@ -0,0 +1,682 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +@file:OptIn(ExperimentalEncodingApi::class) + +package me.kavishdevar.librepods.composables + +import android.content.SharedPreferences +import android.util.Log +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.edit +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.AACPManager +import me.kavishdevar.librepods.utils.ATTHandles +import kotlin.io.encoding.ExperimentalEncodingApi + +@Composable +fun StyledToggle( + title: String? = null, + label: String, + description: String? = null, + checkedState: MutableState = remember { mutableStateOf(false) } , + sharedPreferenceKey: String? = null, + sharedPreferences: SharedPreferences? = null, + independent: Boolean = true, + enabled: Boolean = true, + onCheckedChange: ((Boolean) -> Unit)? = null, +) { + val isDarkTheme = isSystemInDarkTheme() + val textColor = if (isDarkTheme) Color.White else Color.Black + var checked by checkedState + var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } + val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) + if (sharedPreferenceKey != null && sharedPreferences != null) { + checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked) + } + fun cb() { + if (sharedPreferences != null) { + if (sharedPreferenceKey == null) { + Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.") + return + } + sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) } + } + onCheckedChange?.invoke(checked) + } + + if (independent) { + Column(modifier = Modifier.padding(vertical = 8.dp)) { + if (title != null) { + Text( + text = title, + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = textColor.copy(alpha = 0.6f) + ), + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp) + ) + } + Box( + modifier = Modifier + .background(animatedBackgroundColor, RoundedCornerShape(28.dp)) + .padding(4.dp) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + backgroundColor = + if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) + tryAwaitRelease() + backgroundColor = + if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + }, + onTap = { + if (enabled) { + checked = !checked + cb() + } + } + ) + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(55.dp) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + modifier = Modifier.weight(1f), + style = TextStyle( + fontSize = 16.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Normal, + color = textColor + ) + ) + StyledSwitch( + checked = checked, + enabled = enabled, + onCheckedChange = { + if (enabled) { + checked = it + cb() + } + } + ) + } + } + if (description != null) { + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier + .padding(horizontal = 16.dp) + .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) + ) { + Text( + text = description, + style = TextStyle( + fontSize = 12.sp, + fontWeight = FontWeight.Light, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + } + } + } else { + val isPressed = remember { mutableStateOf(false) } + Row( + modifier = Modifier + .fillMaxWidth() + .background( + shape = RoundedCornerShape(28.dp), + color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent + ) + .padding(16.dp) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + isPressed.value = true + tryAwaitRelease() + isPressed.value = false + } + ) + } + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + if (enabled) { + checked = !checked + cb() + } + }, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 4.dp) + ) { + Text( + text = label, + style = TextStyle( + fontSize = 16.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Normal, + color = textColor + ) + ) + Spacer(modifier = Modifier.height(4.dp)) + if (description != null) { + Text( + text = description, + style = TextStyle( + fontSize = 12.sp, + color = textColor.copy(0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)), + ) + ) + } + } + StyledSwitch( + checked = checked, + enabled = enabled, + onCheckedChange = { + if (enabled) { + checked = it + cb() + } + } + ) + } + } +} + +@Composable +fun StyledToggle( + title: String? = null, + label: String, + description: String? = null, + controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers, + independent: Boolean = true, + enabled: Boolean = true, + sharedPreferenceKey: String? = null, + sharedPreferences: SharedPreferences? = null, + onCheckedChange: ((Boolean) -> Unit)? = null, +) { + val service = ServiceManager.getService() ?: return + val isDarkTheme = isSystemInDarkTheme() + val textColor = if (isDarkTheme) Color.White else Color.Black + val checkedValue = service.aacpManager.controlCommandStatusList.find { + it.identifier == controlCommandIdentifier + }?.value?.takeIf { it.isNotEmpty() }?.get(0) + var checked by remember { mutableStateOf(checkedValue == 1.toByte()) } + var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } + val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) + if (sharedPreferenceKey != null && sharedPreferences != null) { + checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked) + } + fun cb() { + service.aacpManager.sendControlCommand(identifier = controlCommandIdentifier.value, value = checked) + if (sharedPreferences != null) { + if (sharedPreferenceKey == null) { + Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.") + return + } + sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) } + } + onCheckedChange?.invoke(checked) + } + + val listener = remember { + object : AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + if (controlCommand.identifier == controlCommandIdentifier.value) { + Log.d("StyledToggle", "Received control command for $label: ${controlCommand.value}") + checked = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte() + } + } + } + } + LaunchedEffect(Unit) { + service.aacpManager.registerControlCommandListener(controlCommandIdentifier, listener) + } + DisposableEffect(Unit) { + onDispose { + service.aacpManager.unregisterControlCommandListener(controlCommandIdentifier, listener) + } + } + + if (independent) { + Column(modifier = Modifier.padding(vertical = 8.dp)) { + if (title != null) { + Text( + text = title, + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = textColor.copy(alpha = 0.6f) + ), + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp) + ) + } + Box( + modifier = Modifier + .background(animatedBackgroundColor, RoundedCornerShape(28.dp)) + .padding(4.dp) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + backgroundColor = + if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) + tryAwaitRelease() + backgroundColor = + if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + }, + onTap = { + if (enabled) { + checked = !checked + cb() + } + } + ) + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(55.dp) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + modifier = Modifier.weight(1f), + style = TextStyle( + fontSize = 16.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Normal, + color = textColor + ) + ) + StyledSwitch( + checked = checked, + enabled = enabled, + onCheckedChange = { + if (enabled) { + checked = it + cb() + } + } + ) + } + } + if (description != null) { + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier + .padding(horizontal = 16.dp) + .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) + ) { + Text( + text = description, + style = TextStyle( + fontSize = 12.sp, + fontWeight = FontWeight.Light, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + } + } + } else { + val isPressed = remember { mutableStateOf(false) } + Row( + modifier = Modifier + .fillMaxWidth() + .background( + shape = RoundedCornerShape(28.dp), + color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent + ) + .padding(16.dp) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + isPressed.value = true + tryAwaitRelease() + isPressed.value = false + } + ) + } + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + if (enabled) { + checked = !checked + cb() + } + }, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 4.dp) + ) { + Text( + text = label, + style = TextStyle( + fontSize = 16.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Normal, + color = textColor + ) + ) + Spacer(modifier = Modifier.height(4.dp)) + if (description != null) { + Text( + text = description, + style = TextStyle( + fontSize = 12.sp, + color = textColor.copy(0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)), + ) + ) + } + } + StyledSwitch( + checked = checked, + enabled = enabled, + onCheckedChange = { + if (enabled) { + checked = it + cb() + } + } + ) + } + } +} + +@Composable +fun StyledToggle( + title: String? = null, + label: String, + description: String? = null, + attHandle: ATTHandles, + independent: Boolean = true, + enabled: Boolean = true, + sharedPreferenceKey: String? = null, + sharedPreferences: SharedPreferences? = null, + onCheckedChange: ((Boolean) -> Unit)? = null, +) { + val attManager = ServiceManager.getService()?.attManager ?: return + val isDarkTheme = isSystemInDarkTheme() + val textColor = if (isDarkTheme) Color.White else Color.Black + val checkedValue = attManager.read(attHandle).getOrNull(0)?.toInt() + var checked by remember { mutableStateOf(checkedValue !=0) } + var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } + val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) + + attManager.enableNotifications(attHandle) + + if (sharedPreferenceKey != null && sharedPreferences != null) { + checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked) + } + + fun cb() { + if (sharedPreferences != null) { + if (sharedPreferenceKey == null) { + Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.") + return + } + sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) } + } + onCheckedChange?.invoke(checked) + } + + LaunchedEffect(checked) { + if (attManager.socket?.isConnected != true) return@LaunchedEffect + attManager.write(attHandle, if (checked) byteArrayOf(1) else byteArrayOf(0)) + } + + val listener = remember { + object : (ByteArray) -> Unit { + override fun invoke(value: ByteArray) { + if (value.isNotEmpty()) { + checked = value[0].toInt() != 0 + Log.d("StyledToggle", "Updated from notification for $label: enabled=$checked") + } else { + Log.w("StyledToggle", "Empty value in notification for $label") + } + } + } + } + + LaunchedEffect(Unit) { + attManager.registerListener(attHandle, listener) + } + + DisposableEffect(Unit) { + onDispose { + attManager.unregisterListener(attHandle, listener) + } + } + + if (independent) { + Column(modifier = Modifier.padding(vertical = 8.dp)) { + if (title != null) { + Text( + text = title, + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = textColor.copy(alpha = 0.6f) + ), + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp) + ) + } + Box( + modifier = Modifier + .background(animatedBackgroundColor, RoundedCornerShape(28.dp)) + .padding(4.dp) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + backgroundColor = + if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) + tryAwaitRelease() + backgroundColor = + if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + }, + onTap = { + if (enabled) { + checked = !checked + cb() + } + } + ) + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(55.dp) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + modifier = Modifier.weight(1f), + style = TextStyle( + fontSize = 16.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Normal, + color = textColor + ) + ) + StyledSwitch( + checked = checked, + enabled = enabled, + onCheckedChange = { + if (enabled) { + checked = it + cb() + } + } + ) + } + } + if (description != null) { + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier + .padding(horizontal = 16.dp) + .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) + ) { + Text( + text = description, + style = TextStyle( + fontSize = 12.sp, + fontWeight = FontWeight.Light, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + } + } + } else { + val isPressed = remember { mutableStateOf(false) } + Row( + modifier = Modifier + .fillMaxWidth() + .background( + shape = RoundedCornerShape(28.dp), + color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent + ) + .padding(16.dp) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + isPressed.value = true + tryAwaitRelease() + isPressed.value = false + } + ) + } + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + if (enabled) { + checked = !checked + cb() + } + }, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 4.dp) + ) { + Text( + text = label, + fontSize = 16.sp, + color = textColor + ) + Spacer(modifier = Modifier.height(4.dp)) + if (description != null) { + Text( + text = description, + fontSize = 12.sp, + color = textColor.copy(0.6f), + lineHeight = 14.sp, + ) + } + } + StyledSwitch( + checked = checked, + enabled = enabled, + onCheckedChange = { + if (enabled) { + checked = it + cb() + } + } + ) + } + } +} + +@Preview +@Composable +fun StyledTogglePreview() { + val context = LocalContext.current + val sharedPrefs = context.getSharedPreferences("preview", 0) + StyledToggle( + label = "Example Toggle", + description = "This is an example description for the styled toggle.", + sharedPreferences = sharedPrefs + ) +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt deleted file mode 100644 index 38e190ebb..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt +++ /dev/null @@ -1,165 +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 Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.composables - -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.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -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.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.services.ServiceManager -import me.kavishdevar.librepods.utils.AACPManager -import kotlin.io.encoding.ExperimentalEncodingApi -import kotlin.math.roundToInt - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ToneVolumeSlider() { - val service = ServiceManager.getService()!! - val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find { - it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME - }?.value?.takeIf { it.isNotEmpty() }?.get(0) - val sliderValue = remember { mutableFloatStateOf( - sliderValueFromAACP?.toFloat() ?: -1f - ) } - Log.d("ToneVolumeSlider", "Slider value: ${sliderValue.floatValue}") - - val isDarkTheme = isSystemInDarkTheme() - - val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491) - val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) - val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) - val labelTextColor = if (isDarkTheme) Color.White else Color.Black - - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "\uDBC0\uDEA1", - style = TextStyle( - fontSize = 16.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Light, - color = labelTextColor - ), - modifier = Modifier.padding(start = 4.dp) - ) - Slider( - value = sliderValue.floatValue, - onValueChange = { - sliderValue.floatValue = it - }, - valueRange = 0f..100f, - onValueChangeFinished = { - sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat() - service.aacpManager.sendControlCommand( - identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value, - value = byteArrayOf(sliderValue.floatValue.toInt().toByte(), - 0x50.toByte() - ) - ) - }, - modifier = Modifier - .weight(1f) - .height(36.dp), - colors = SliderDefaults.colors( - thumbColor = thumbColor, - activeTrackColor = activeTrackColor, - inactiveTrackColor = trackColor - ), - thumb = { - Box( - modifier = Modifier - .size(24.dp) - .shadow(4.dp, CircleShape) - .background(thumbColor, CircleShape) - ) - }, - track = { - Box ( - modifier = Modifier - .fillMaxWidth() - .height(12.dp), - contentAlignment = Alignment.CenterStart - ) - { - Box( - modifier = Modifier - .fillMaxWidth() - .height(4.dp) - .background(trackColor, RoundedCornerShape(4.dp)) - ) - Box( - modifier = Modifier - .fillMaxWidth(sliderValue.floatValue / 100) - .height(4.dp) - .background(activeTrackColor, RoundedCornerShape(4.dp)) - ) - } - } - ) - Text( - text = "\uDBC0\uDEA9", - style = TextStyle( - fontSize = 16.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Light, - color = labelTextColor - ), - modifier = Modifier.padding(end = 4.dp) - ) - } -} - -@Preview -@Composable -fun ToneVolumeSliderPreview() { - ToneVolumeSlider() -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/VolumeControlSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/VolumeControlSwitch.kt deleted file mode 100644 index 41bc9cc5a..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/VolumeControlSwitch.kt +++ /dev/null @@ -1,132 +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 Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.composables - -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.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import me.kavishdevar.librepods.services.ServiceManager -import me.kavishdevar.librepods.utils.AACPManager -import kotlin.io.encoding.ExperimentalEncodingApi - -@Composable -fun VolumeControlSwitch() { - val service = ServiceManager.getService()!! - val volumeControlEnabledValue = service.aacpManager.controlCommandStatusList.find { - it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE - }?.value?.takeIf { it.isNotEmpty() }?.get(0) - var volumeControlEnabled by remember { - mutableStateOf( - volumeControlEnabledValue == 1.toByte() - ) - } - fun updateVolumeControlEnabled(enabled: Boolean) { - volumeControlEnabled = enabled - service.aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE.value, - enabled - ) - } - - val isDarkTheme = isSystemInDarkTheme() - val textColor = if (isDarkTheme) Color.White else Color.Black - - val isPressed = remember { mutableStateOf(false) } - - Row( - modifier = Modifier - .fillMaxWidth() - .background( - shape = RoundedCornerShape(14.dp), - color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent - ) - .padding(horizontal = 12.dp, vertical = 12.dp) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - isPressed.value = true - tryAwaitRelease() - isPressed.value = false - } - ) - } - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - updateVolumeControlEnabled(!volumeControlEnabled) - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(end = 4.dp) - ) { - Text( - text = "Volume Control", - fontSize = 16.sp, - color = textColor - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem.", - fontSize = 12.sp, - color = textColor.copy(0.6f), - lineHeight = 14.sp, - ) - } - StyledSwitch( - checked = volumeControlEnabled, - onCheckedChange = { - updateVolumeControlEnabled(it) - }, - ) - } -} - -@Preview -@Composable -fun VolumeControlSwitchPreview() { - VolumeControlSwitch() -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/constants/Packets.kt b/android/app/src/main/java/me/kavishdevar/librepods/constants/Packets.kt index 6c8d661a9..943f52b85 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/constants/Packets.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/constants/Packets.kt @@ -182,21 +182,31 @@ class AirPodsNotifications { 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()) - } +// 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 { @@ -244,7 +254,7 @@ fun isHeadTrackingData(data: ByteArray): Boolean { ) for (i in prefixPattern.indices) { - if (data[i] != prefixPattern[i].toByte()) return false + if (data[i] != prefixPattern[i]) return false } if (data[10] != 0x44.toByte() && data[10] != 0x45.toByte()) return false diff --git a/android/app/src/main/java/me/kavishdevar/librepods/constants/StemAction.kt b/android/app/src/main/java/me/kavishdevar/librepods/constants/StemAction.kt index 3c5be4964..206fc3269 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/constants/StemAction.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/constants/StemAction.kt @@ -18,14 +18,12 @@ package me.kavishdevar.librepods.constants -import me.kavishdevar.librepods.constants.StemAction.entries import me.kavishdevar.librepods.utils.AACPManager enum class StemAction { PLAY_PAUSE, PREVIOUS_TRACK, NEXT_TRACK, - CAMERA_SHUTTER, DIGITAL_ASSISTANT, CYCLE_NOISE_CONTROL_MODES; companion object { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt new file mode 100644 index 000000000..0a37dfd32 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt @@ -0,0 +1,840 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.screens + +import android.annotation.SuppressLint +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +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.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.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.NavigationButton +import me.kavishdevar.librepods.composables.StyledDropdown +import me.kavishdevar.librepods.composables.StyledScaffold +import me.kavishdevar.librepods.composables.StyledSlider +import me.kavishdevar.librepods.composables.StyledToggle +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.AACPManager +import me.kavishdevar.librepods.utils.ATTHandles +import me.kavishdevar.librepods.utils.Capability +import me.kavishdevar.librepods.utils.RadareOffsetFinder +import kotlin.io.encoding.ExperimentalEncodingApi + +private var phoneMediaDebounceJob: Job? = null +private var toneVolumeDebounceJob: Job? = null +private const val TAG = "AccessibilitySettings" + +@SuppressLint("DefaultLocale") +@ExperimentalHazeMaterialsApi +@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) +@Composable +fun AccessibilitySettingsScreen(navController: NavController) { + val isDarkTheme = isSystemInDarkTheme() + val textColor = if (isDarkTheme) Color.White else Color.Black + val aacpManager = remember { ServiceManager.getService()?.aacpManager } + val isSdpOffsetAvailable = + remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) } + + val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491) + val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) + val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) + + val capabilities = remember { ServiceManager.getService()?.airpodsInstance?.model?.capabilities ?: emptySet() } + + val hearingAidEnabled = remember { mutableStateOf( + aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(1) == 0x01.toByte() && + aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }?.value?.getOrNull(0) == 0x01.toByte() + ) } + + val hearingAidListener = remember { + object : AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value || + controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) { + val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID } + val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG } + hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()) + } + } + } + } + + LaunchedEffect(Unit) { + aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener) + aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener) + } + + DisposableEffect(Unit) { + onDispose { + aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener) + aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener) + } + } + + val backdrop = rememberLayerBackdrop() + + StyledScaffold( + title = stringResource(R.string.accessibility) + ) { spacerHeight, hazeState -> + Column( + modifier = Modifier + .fillMaxSize() + .hazeSource(hazeState) + .layerBackdrop(backdrop) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(spacerHeight)) + val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + + val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) } + val phoneEQEnabled = remember { mutableStateOf(false) } + val mediaEQEnabled = remember { mutableStateOf(false) } + + val pressSpeedOptions = mapOf( + 0.toByte() to "Default", + 1.toByte() to "Slower", + 2.toByte() to "Slowest" + ) + val selectedPressSpeedValue = + aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() } + ?.get(0) + var selectedPressSpeed by remember { + mutableStateOf( + pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0] + ) + } + val selectedPressSpeedListener = object : AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value) { + val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) + selectedPressSpeed = pressSpeedOptions[newValue] ?: pressSpeedOptions[0] + } + } + } + LaunchedEffect(Unit) { + aacpManager?.registerControlCommandListener( + AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL, + selectedPressSpeedListener + ) + } + DisposableEffect(Unit) { + onDispose { + aacpManager?.unregisterControlCommandListener( + AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL, + selectedPressSpeedListener + ) + } + } + + val pressAndHoldDurationOptions = mapOf( + 0.toByte() to "Default", + 1.toByte() to "Slower", + 2.toByte() to "Slowest" + ) + val selectedPressAndHoldDurationValue = + aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() } + ?.get(0) + var selectedPressAndHoldDuration by remember { + mutableStateOf( + pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] + ?: pressAndHoldDurationOptions[0] + ) + } + val selectedPressAndHoldDurationListener = object : AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value) { + val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) + selectedPressAndHoldDuration = + pressAndHoldDurationOptions[newValue] ?: pressAndHoldDurationOptions[0] + } + } + } + LaunchedEffect(Unit) { + aacpManager?.registerControlCommandListener( + AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL, + selectedPressAndHoldDurationListener + ) + } + DisposableEffect(Unit) { + onDispose { + aacpManager?.unregisterControlCommandListener( + AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL, + selectedPressAndHoldDurationListener + ) + } + } + + val volumeSwipeSpeedOptions = mapOf( + 1.toByte() to "Default", + 2.toByte() to "Longer", + 3.toByte() to "Longest" + ) + val selectedVolumeSwipeSpeedValue = + aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() } + ?.get(0) + var selectedVolumeSwipeSpeed by remember { + mutableStateOf( + volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] + ?: volumeSwipeSpeedOptions[1] + ) + } + val selectedVolumeSwipeSpeedListener = object : AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value) { + val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) + selectedVolumeSwipeSpeed = + volumeSwipeSpeedOptions[newValue] ?: volumeSwipeSpeedOptions[1] + } + } + } + LaunchedEffect(Unit) { + aacpManager?.registerControlCommandListener( + AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL, + selectedVolumeSwipeSpeedListener + ) + } + DisposableEffect(Unit) { + onDispose { + aacpManager?.unregisterControlCommandListener( + AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL, + selectedVolumeSwipeSpeedListener + ) + } + } + + LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) { + phoneMediaDebounceJob?.cancel() + phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch { + delay(150) + val manager = ServiceManager.getService()?.aacpManager + if (manager == null) { + Log.w(TAG, "Cannot write EQ: AACPManager not available") + return@launch + } + try { + val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte() + val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte() + Log.d( + TAG, + "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})" + ) + manager.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte) + } catch (e: Exception) { + Log.w(TAG, "Error sending phone/media EQ: ${e.message}") + } + } + } + val toneVolumeValue = remember { mutableFloatStateOf( + aacpManager?.controlCommandStatusList?.find { + it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME + }?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toFloat() ?: 75f + ) } + LaunchedEffect(toneVolumeValue.floatValue) { + toneVolumeDebounceJob?.cancel() + toneVolumeDebounceJob = CoroutineScope(Dispatchers.IO).launch { + delay(150) + val manager = ServiceManager.getService()?.aacpManager + if (manager == null) { + Log.w(TAG, "Cannot write tone volume: AACPManager not available") + return@launch + } + try { + manager.sendControlCommand( + identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value, + value = byteArrayOf(toneVolumeValue.floatValue.toInt().toByte(), 0x50.toByte()) + ) + } catch (e: Exception) { + Log.w(TAG, "Error sending tone volume: ${e.message}") + } + } + } + + DropdownMenuComponent( + label = stringResource(R.string.press_speed), + description = stringResource(R.string.press_speed_description), + options = pressSpeedOptions.values.toList(), + selectedOption = selectedPressSpeed?: "Default", + onOptionSelected = { newValue -> + selectedPressSpeed = newValue + aacpManager?.sendControlCommand( + identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value, + value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() + ?: 0.toByte() + ) + }, + textColor = textColor, + hazeState = hazeState, + independent = true + ) + + DropdownMenuComponent( + label = stringResource(R.string.press_and_hold_duration), + description = stringResource(R.string.press_and_hold_duration_description), + options = pressAndHoldDurationOptions.values.toList(), + selectedOption = selectedPressAndHoldDuration?: "Default", + onOptionSelected = { newValue -> + selectedPressAndHoldDuration = newValue + aacpManager?.sendControlCommand( + identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value, + value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull() + ?: 0.toByte() + ) + }, + textColor = textColor, + hazeState = hazeState, + independent = true + ) + + StyledToggle( + title = stringResource(R.string.noise_control), + label = stringResource(R.string.noise_cancellation_single_airpod), + description = stringResource(R.string.noise_cancellation_single_airpod_description), + controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, + independent = true, + ) + + if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)) { + StyledToggle( + label = stringResource(R.string.loud_sound_reduction), + description = stringResource(R.string.loud_sound_reduction_description), + attHandle = ATTHandles.LOUD_SOUND_REDUCTION + ) + } + + if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) { + NavigationButton( + to = "transparency_customization", + name = stringResource(R.string.customize_transparency_mode), + navController = navController + ) + } + + StyledSlider( + label = stringResource(R.string.tone_volume), + description = stringResource(R.string.tone_volume_description), + mutableFloatState = toneVolumeValue, + onValueChange = { + toneVolumeValue.floatValue = it + }, + valueRange = 0f..100f, + snapPoints = listOf(75f), + startIcon = "\uDBC0\uDEA1", + endIcon = "\uDBC0\uDEA9", + independent = true + ) + + if (capabilities.contains(Capability.SWIPE_FOR_VOLUME)) { + StyledToggle( + label = stringResource(R.string.volume_control), + description = stringResource(R.string.volume_control_description), + controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, + ) + + DropdownMenuComponent( + label = stringResource(R.string.volume_swipe_speed), + description = stringResource(R.string.volume_swipe_speed_description), + options = volumeSwipeSpeedOptions.values.toList(), + selectedOption = selectedVolumeSwipeSpeed?: "Default", + onOptionSelected = { newValue -> + selectedVolumeSwipeSpeed = newValue + aacpManager?.sendControlCommand( + identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value, + value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() + ?: 1.toByte() + ) + }, + textColor = textColor, + hazeState = hazeState, + independent = true + ) + } + + if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) { +// Text( +// text = stringResource(R.string.apply_eq_to), +// style = TextStyle( +// fontSize = 14.sp, +// fontWeight = FontWeight.Bold, +// color = textColor.copy(alpha = 0.6f), +// fontFamily = FontFamily(Font(R.font.sf_pro)) +// ), +// modifier = Modifier.padding(8.dp, bottom = 0.dp) +// ) +// Column( +// modifier = Modifier +// .fillMaxWidth() +// .background(backgroundColor, RoundedCornerShape(28.dp)) +// .padding(vertical = 0.dp) +// ) { +// val darkModeLocal = isSystemInDarkTheme() +// +// val phoneShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) +// var phoneBackgroundColor by remember { +// mutableStateOf( +// if (darkModeLocal) Color( +// 0xFF1C1C1E +// ) else Color(0xFFFFFFFF) +// ) +// } +// val phoneAnimatedBackgroundColor by animateColorAsState( +// targetValue = phoneBackgroundColor, +// animationSpec = tween(durationMillis = 500) +// ) +// +// Row( +// modifier = Modifier +// .height(48.dp) +// .fillMaxWidth() +// .background(phoneAnimatedBackgroundColor, phoneShape) +// .pointerInput(Unit) { +// detectTapGestures( +// onPress = { +// phoneBackgroundColor = +// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9) +// tryAwaitRelease() +// phoneBackgroundColor = +// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) +// phoneEQEnabled.value = !phoneEQEnabled.value +// } +// ) +// } +// .padding(horizontal = 16.dp), +// verticalAlignment = Alignment.CenterVertically +// ) { +// Text( +// stringResource(R.string.phone), +// fontSize = 16.sp, +// color = textColor, +// fontFamily = FontFamily(Font(R.font.sf_pro)), +// modifier = Modifier.weight(1f) +// ) +// Checkbox( +// checked = phoneEQEnabled.value, +// onCheckedChange = { phoneEQEnabled.value = it }, +// colors = CheckboxDefaults.colors().copy( +// checkedCheckmarkColor = Color(0xFF007AFF), +// uncheckedCheckmarkColor = Color.Transparent, +// checkedBoxColor = Color.Transparent, +// uncheckedBoxColor = Color.Transparent, +// checkedBorderColor = Color.Transparent, +// uncheckedBorderColor = Color.Transparent +// ), +// modifier = Modifier +// .height(24.dp) +// .scale(1.5f) +// ) +// } +// +// HorizontalDivider( +// thickness = 1.dp, +// color = Color(0x40888888) +// ) +// +// val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp) +// var mediaBackgroundColor by remember { +// mutableStateOf( +// if (darkModeLocal) Color( +// 0xFF1C1C1E +// ) else Color(0xFFFFFFFF) +// ) +// } +// val mediaAnimatedBackgroundColor by animateColorAsState( +// targetValue = mediaBackgroundColor, +// animationSpec = tween(durationMillis = 500) +// ) +// +// Row( +// modifier = Modifier +// .height(48.dp) +// .fillMaxWidth() +// .background(mediaAnimatedBackgroundColor, mediaShape) +// .pointerInput(Unit) { +// detectTapGestures( +// onPress = { +// mediaBackgroundColor = +// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9) +// tryAwaitRelease() +// mediaBackgroundColor = +// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) +// mediaEQEnabled.value = !mediaEQEnabled.value +// } +// ) +// } +// .padding(horizontal = 16.dp), +// verticalAlignment = Alignment.CenterVertically +// ) { +// Text( +// stringResource(R.string.media), +// fontSize = 16.sp, +// color = textColor, +// fontFamily = FontFamily(Font(R.font.sf_pro)), +// modifier = Modifier.weight(1f) +// ) +// Checkbox( +// checked = mediaEQEnabled.value, +// onCheckedChange = { mediaEQEnabled.value = it }, +// colors = CheckboxDefaults.colors().copy( +// checkedCheckmarkColor = Color(0xFF007AFF), +// uncheckedCheckmarkColor = Color.Transparent, +// checkedBoxColor = Color.Transparent, +// uncheckedBoxColor = Color.Transparent, +// checkedBorderColor = Color.Transparent, +// uncheckedBorderColor = Color.Transparent +// ), +// modifier = Modifier +// .height(24.dp) +// .scale(1.5f) +// ) +// } +// } + + // EQ Settings. Don't seem to have an effect? + // Column( + // modifier = Modifier + // .fillMaxWidth() + // .background(backgroundColor, RoundedCornerShape(28.dp)) + // .padding(12.dp), + // horizontalAlignment = Alignment.CenterHorizontally + // ) { + // for (i in 0 until 8) { + // val eqPhoneValue = + // remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) } + // Row( + // horizontalArrangement = Arrangement.SpaceBetween, + // verticalAlignment = Alignment.CenterVertically, + // modifier = Modifier + // .fillMaxWidth() + // .height(38.dp) + // ) { + // Text( + // text = String.format("%.2f", eqPhoneValue.floatValue), + // fontSize = 12.sp, + // color = textColor, + // modifier = Modifier.padding(bottom = 4.dp) + // ) + + // Slider( + // value = eqPhoneValue.floatValue, + // onValueChange = { newVal -> + // eqPhoneValue.floatValue = newVal + // val newEQ = phoneMediaEQ.value.copyOf() + // newEQ[i] = eqPhoneValue.floatValue + // phoneMediaEQ.value = newEQ + // }, + // valueRange = 0f..100f, + // modifier = Modifier + // .fillMaxWidth(0.9f) + // .height(36.dp), + // colors = SliderDefaults.colors( + // thumbColor = thumbColor, + // activeTrackColor = activeTrackColor, + // inactiveTrackColor = trackColor + // ), + // thumb = { + // Box( + // modifier = Modifier + // .size(24.dp) + // .shadow(4.dp, CircleShape) + // .background(thumbColor, CircleShape) + // ) + // }, + // track = { + // Box( + // modifier = Modifier + // .fillMaxWidth() + // .height(12.dp), + // contentAlignment = Alignment.CenterStart + // ) + // { + // Box( + // modifier = Modifier + // .fillMaxWidth() + // .height(4.dp) + // .background(trackColor, RoundedCornerShape(4.dp)) + // ) + // Box( + // modifier = Modifier + // .fillMaxWidth(eqPhoneValue.floatValue / 100f) + // .height(4.dp) + // .background(activeTrackColor, RoundedCornerShape(4.dp)) + // ) + // } + // } + // ) + + // Text( + // text = stringResource(R.string.band_label, i + 1), + // fontSize = 12.sp, + // color = textColor, + // modifier = Modifier.padding(top = 4.dp) + // ) + // } + // } + // } + } + } + } +} + +@ExperimentalHazeMaterialsApi +@Composable +private fun DropdownMenuComponent( + label: String, + options: List, + selectedOption: String, + onOptionSelected: (String) -> Unit, + textColor: Color, + hazeState: HazeState, + description: String? = null, + independent: Boolean = true +) { + val density = LocalDensity.current + val itemHeightPx = with(density) { 48.dp.toPx() } + + var expanded by remember { mutableStateOf(false) } + var touchOffset by remember { mutableStateOf(null) } + var boxPosition by remember { mutableStateOf(Offset.Zero) } + var lastDismissTime by remember { mutableLongStateOf(0L) } + var parentHoveredIndex by remember { mutableStateOf(null) } + var parentDragActive by remember { mutableStateOf(false) } + + Column(modifier = Modifier.fillMaxWidth()){ + Column( + modifier = Modifier + .fillMaxWidth() + .then( + if (independent) { + if (description != null) { + Modifier.padding(top = 8.dp, bottom = 4.dp) + } else { + Modifier.padding(vertical = 8.dp) + } + } else Modifier + ) + .background( + if (independent) (if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) else Color.Transparent, + if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp) + ) + then( + if (independent) Modifier.padding(horizontal = 4.dp) else Modifier + ) + .clip(if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)) + ){ + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 12.dp) + .height(58.dp) + .pointerInput(Unit) { + detectTapGestures { offset -> + val now = System.currentTimeMillis() + if (expanded) { + expanded = false + lastDismissTime = now + } else { + if (now - lastDismissTime > 250L) { + touchOffset = offset + expanded = true + } + } + } + } + .pointerInput(Unit) { + detectDragGesturesAfterLongPress( + onDragStart = { offset -> + val now = System.currentTimeMillis() + touchOffset = offset + if (!expanded && now - lastDismissTime > 250L) { + expanded = true + } + lastDismissTime = now + parentDragActive = true + parentHoveredIndex = 0 + }, + onDrag = { change, _ -> + val current = change.position + val touch = touchOffset ?: current + val posInPopupY = current.y - touch.y + val idx = (posInPopupY / itemHeightPx).toInt() + parentHoveredIndex = idx + }, + onDragEnd = { + parentDragActive = false + parentHoveredIndex?.let { idx -> + if (idx in options.indices) { + onOptionSelected(options[idx]) + expanded = false + lastDismissTime = System.currentTimeMillis() + } + } + parentHoveredIndex = null + }, + onDragCancel = { + parentDragActive = false + parentHoveredIndex = null + } + ) + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ){ + Text( + text = label, + fontSize = 16.sp, + color = textColor, + modifier = Modifier.padding(bottom = 4.dp) + ) + if (!independent && description != null){ + Text( + text = description, + style = TextStyle( + fontSize = 12.sp, + fontWeight = FontWeight.Light, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier.padding(16.dp, top = 0.dp, bottom = 2.dp) + ) + } + } + Box( + modifier = Modifier.onGloballyPositioned { coordinates -> + boxPosition = coordinates.positionInParent() + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = selectedOption, + style = TextStyle( + fontSize = 16.sp, + color = textColor.copy(alpha = 0.8f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = "􀆏", + style = TextStyle( + fontSize = 16.sp, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier + .padding(start = 6.dp) + ) + } + + StyledDropdown( + expanded = expanded, + onDismissRequest = { + expanded = false + lastDismissTime = System.currentTimeMillis() + }, + options = options, + selectedOption = selectedOption, + touchOffset = touchOffset, + boxPosition = boxPosition, + externalHoveredIndex = parentHoveredIndex, + externalDragActive = parentDragActive, + onOptionSelected = { option -> + onOptionSelected(option) + expanded = false + }, + hazeState = hazeState + ) + } + } + } + if (independent && description != null){ + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7)) + ){ + Text( + text = description, + style = TextStyle( + fontSize = 12.sp, + fontWeight = FontWeight.Light, + color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AdaptiveStrengthScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AdaptiveStrengthScreen.kt new file mode 100644 index 000000000..e6e537b8a --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AdaptiveStrengthScreen.kt @@ -0,0 +1,135 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.screens + +import android.annotation.SuppressLint +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.StyledIconButton +import me.kavishdevar.librepods.composables.StyledScaffold +import me.kavishdevar.librepods.composables.StyledSlider +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.AACPManager +import kotlin.io.encoding.ExperimentalEncodingApi + +private var debounceJob: Job? = null + +@SuppressLint("DefaultLocale") +@ExperimentalHazeMaterialsApi +@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) +@Composable +fun AdaptiveStrengthScreen(navController: NavController) { + val isDarkTheme = isSystemInDarkTheme() + + val sliderValue = remember { mutableFloatStateOf(0f) } + val service = ServiceManager.getService()!! + + LaunchedEffect(sliderValue) { + val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find { + it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH + }?.value?.takeIf { it.isNotEmpty() }?.get(0) + sliderValueFromAACP?.toFloat()?.let { sliderValue.floatValue = (100 - it) } + } + + val listener = remember { + object : AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value) { + controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)?.toFloat()?.let { + sliderValue.floatValue = (100 - it) + } + } + } + } + } + + DisposableEffect(Unit) { + service.aacpManager.registerControlCommandListener( + AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH, + listener + ) + onDispose { + service.aacpManager.unregisterControlCommandListener( + AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH, + listener + ) + } + } + + val backdrop = rememberLayerBackdrop() + + StyledScaffold( + title = stringResource(R.string.customize_adaptive_audio) + ) { spacerHeight -> + Column( + modifier = Modifier + .fillMaxSize() + .layerBackdrop(backdrop) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(spacerHeight)) + StyledSlider( + label = stringResource(R.string.customize_adaptive_audio), + mutableFloatState = sliderValue, + onValueChange = { + sliderValue.floatValue = it + debounceJob?.cancel() + debounceJob = CoroutineScope(Dispatchers.Default).launch { + delay(300) + service.aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value, + (100 - it).toInt() + ) + } + }, + valueRange = 0f..100f, + snapPoints = listOf(0f, 50f, 100f), + startIcon = "􀊥", + endIcon = "􀊩", + independent = true, + description = stringResource(R.string.adaptive_audio_description) + ) + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt index 97bdaebda..90ef913a3 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt @@ -38,37 +38,21 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBarDefaults 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.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.drawBehind -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -81,28 +65,39 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.edit +import androidx.core.net.toUri import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController -import dev.chrisbanes.haze.HazeEffectScope +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.highlight.Highlight import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.materials.CupertinoMaterials import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import kotlinx.coroutines.launch import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.composables.AccessibilitySettings +import me.kavishdevar.librepods.composables.AboutCard import me.kavishdevar.librepods.composables.AudioSettings import me.kavishdevar.librepods.composables.BatteryView -import me.kavishdevar.librepods.composables.IndependentToggle -import me.kavishdevar.librepods.composables.NameField +import me.kavishdevar.librepods.composables.CallControlSettings +import me.kavishdevar.librepods.composables.ConfirmationDialog +import me.kavishdevar.librepods.composables.ConnectionSettings +import me.kavishdevar.librepods.composables.HearingHealthSettings +import me.kavishdevar.librepods.composables.MicrophoneSettings import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.composables.NoiseControlSettings import me.kavishdevar.librepods.composables.PressAndHoldSettings +import me.kavishdevar.librepods.composables.StyledButton +import me.kavishdevar.librepods.composables.StyledIconButton +import me.kavishdevar.librepods.composables.StyledScaffold +import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.utils.AACPManager +import me.kavishdevar.librepods.utils.Capability +import me.kavishdevar.librepods.utils.RadareOffsetFinder import kotlin.io.encoding.ExperimentalEncodingApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @@ -113,7 +108,6 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, var isLocallyConnected by remember { mutableStateOf(isConnected) } var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) } val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE) - val bleOnlyMode = sharedPreferences.getBoolean("ble_only_mode", false) var device by remember { mutableStateOf(dev) } var deviceName by remember { mutableStateOf( @@ -142,8 +136,6 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, } } - val verticalScrollState = rememberScrollState() - val hazeState = remember { HazeState() } val snackbarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() @@ -151,12 +143,6 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, isRemotelyConnected = connected } - fun showSnackbar(message: String) { - coroutineScope.launch { - snackbarHostState.showSnackbar(message) - } - } - val context = LocalContext.current val connectionReceiver = remember { @@ -218,212 +204,157 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, } } - @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") - Scaffold( - containerColor = if (isSystemInDarkTheme()) Color( - 0xFF000000 - ) else Color( - 0xFFF2F2F7 + LaunchedEffect(service) { + service.let { + it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply { + putParcelableArrayListExtra("data", ArrayList(it.getBattery())) + }) + it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply { + putExtra("data", it.getANC()) + }) + } + } + + val darkMode = isSystemInDarkTheme() + val hazeStateS = remember { mutableStateOf(HazeState()) } + + val showDialog = remember { mutableStateOf(!sharedPreferences.getBoolean("donationDialogShown", false)) } + + StyledScaffold( + title = deviceName.text, + actionButtons = listOf( + {scaffoldBackdrop -> + StyledIconButton( + onClick = { navController.navigate("app_settings") }, + icon = "􀍟", + darkMode = darkMode, + backdrop = scaffoldBackdrop + ) + } ), - topBar = { - val darkMode = isSystemInDarkTheme() - val mDensity = remember { mutableFloatStateOf(1f) } - CenterAlignedTopAppBar( - title = { - Text( - text = deviceName.text, - style = TextStyle( - fontSize = 20.sp, - fontWeight = FontWeight.Medium, - color = if (darkMode) Color.White else Color.Black, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) - ) - }, - modifier = Modifier - .hazeEffect( - state = hazeState, - style = CupertinoMaterials.thick(), - block = fun HazeEffectScope.() { - alpha = - if (verticalScrollState.value > 60.dp.value * mDensity.floatValue) 1f else 0f - }) - .drawBehind { - mDensity.floatValue = density - val strokeWidth = 0.7.dp.value * density - val y = size.height - strokeWidth / 2 - if (verticalScrollState.value > 60.dp.value * density) { - drawLine( - if (darkMode) Color.DarkGray else Color.LightGray, - Offset(0f, y), - Offset(size.width, y), - strokeWidth - ) - } - }, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = Color.Transparent - ), - actions = { - if (isRemotelyConnected) { - IconButton( - onClick = { - showSnackbar("Connected remotely to AirPods via Linux.") - }, - colors = IconButtonDefaults.iconButtonColors( - containerColor = Color.Transparent, - contentColor = if (isSystemInDarkTheme()) Color.White else Color.Black - ) - ) { - Icon( - imageVector = Icons.Default.Info, - contentDescription = "Info", - ) - } - } - IconButton( - onClick = { - navController.navigate("app_settings") - }, - colors = IconButtonDefaults.iconButtonColors( - containerColor = Color.Transparent, - contentColor = if (isSystemInDarkTheme()) Color.White else Color.Black - ) - ) { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = "Settings", - ) - } - } - ) - }, - snackbarHost = { SnackbarHost(snackbarHostState) } - ) { paddingValues -> + snackbarHostState = snackbarHostState + ) { spacerHeight, hazeState -> + hazeStateS.value = hazeState if (isLocallyConnected || isRemotelyConnected) { - Column( + val instance = service.airpodsInstance + if (instance == null) { + Text("Error: AirPods instance is null") + return@StyledScaffold + } + val capabilities = instance.model.capabilities + LazyColumn( modifier = Modifier - .hazeSource(hazeState) .fillMaxSize() + .hazeSource(hazeState) .padding(horizontal = 16.dp) - .verticalScroll( - state = verticalScrollState, - enabled = true, - ) ) { - Spacer(Modifier.height(75.dp)) - LaunchedEffect(service) { - service.let { - it.sendBroadcast(Intent(AirPodsNotifications.Companion.BATTERY_DATA).apply { - putParcelableArrayListExtra("data", ArrayList(it.getBattery())) - }) - it.sendBroadcast(Intent(AirPodsNotifications.Companion.ANC_DATA).apply { - putExtra("data", it.getANC()) - }) - } + item(key = "spacer_top") { Spacer(modifier = Modifier.height(spacerHeight)) } + item(key = "battery") { + BatteryView(service = service) } - val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE) - - Spacer(modifier = Modifier.height(64.dp)) - - BatteryView(service = service) + item(key = "spacer_battery") { Spacer(modifier = Modifier.height(32.dp)) } - Spacer(modifier = Modifier.height(32.dp)) - - // Show BLE-only mode indicator - if (bleOnlyMode) { - Text( - text = "BLE-only mode - advanced features disabled", - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - modifier = Modifier.padding(8.dp, bottom = 16.dp) + item(key = "name") { + NavigationButton( + to = "rename", + name = stringResource(R.string.name), + currentState = deviceName.text, + navController = navController, + independent = true ) } + val actAsAppleDeviceHookEnabled = RadareOffsetFinder.isSdpOffsetAvailable() + if (actAsAppleDeviceHookEnabled) { + item(key = "spacer_hearing_health") { Spacer(modifier = Modifier.height(32.dp)) } + item(key = "hearing_health") { + HearingHealthSettings(navController = navController) + } + } - // Only show name field when not in BLE-only mode - if (!bleOnlyMode) { - NameField( - name = stringResource(R.string.name), - value = deviceName.text, - navController = navController - ) + if (capabilities.contains(Capability.LISTENING_MODE)) { + item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "noise_control") { NoiseControlSettings(service = service) } } - // Only show L2CAP-dependent features when not in BLE-only mode - if (!bleOnlyMode) { - Spacer(modifier = Modifier.height(32.dp)) - NoiseControlSettings(service = service) + if (capabilities.contains(Capability.STEM_CONFIG)) { + item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "press_hold") { PressAndHoldSettings(navController = navController) } + } - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.head_gestures).uppercase(), - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Light, - color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - modifier = Modifier.padding(8.dp, bottom = 2.dp) - ) + item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "call_control") { CallControlSettings(hazeState = hazeState) } - Spacer(modifier = Modifier.height(2.dp)) - NavigationButton(to = "head_tracking", "Head Tracking", navController) + if (capabilities.contains(Capability.STEM_CONFIG)) { + item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "camera_control") { NavigationButton(to = "camera_control", name = stringResource(R.string.camera_remote), description = stringResource(R.string.camera_control_description), title = stringResource(R.string.camera_control), navController = navController) } + } - Spacer(modifier = Modifier.height(16.dp)) - PressAndHoldSettings(navController = navController) + item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "audio") { AudioSettings(navController = navController) } - Spacer(modifier = Modifier.height(16.dp)) - AudioSettings() + item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "connection") { ConnectionSettings() } - Spacer(modifier = Modifier.height(16.dp)) - IndependentToggle( - name = "Off Listening Mode", - service = service, - sharedPreferences = sharedPreferences, - default = false, - controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION - ) + item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "microphone") { MicrophoneSettings(hazeState) } - Spacer(modifier = Modifier.height(16.dp)) - AccessibilitySettings() + if (capabilities.contains(Capability.SLEEP_DETECTION)) { + item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "sleep_detection") { + StyledToggle( + label = stringResource(R.string.sleep_detection), + controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG + ) + } } - Spacer(modifier = Modifier.height(16.dp)) - IndependentToggle( - name = "Automatic Ear Detection", - service = service, - functionName = "setEarDetection", - sharedPreferences = sharedPreferences, - default = true, - ) + if (capabilities.contains(Capability.HEAD_GESTURES)) { + item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "head_tracking") { NavigationButton(to = "head_tracking", name = stringResource(R.string.head_gestures), navController = navController, currentState = if (sharedPreferences.getBoolean("head_gestures", false)) stringResource(R.string.on) else stringResource(R.string.off)) } + } - // Only show debug when not in BLE-only mode - if (!bleOnlyMode) { - Spacer(modifier = Modifier.height(16.dp)) - NavigationButton("debug", "Debug", navController) + item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "accessibility") { NavigationButton(to = "accessibility", name = stringResource(R.string.accessibility), navController = navController) } + + if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){ + item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "off_listening") { + StyledToggle( + label = stringResource(R.string.off_listening_mode), + controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION, + description = stringResource(R.string.off_listening_mode_description) + ) + } } - Spacer(Modifier.height(24.dp)) + item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) } + item(key = "about") { AboutCard(navController = navController) } + + item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "debug") { NavigationButton("debug", "Debug", navController) } + item(key = "spacer_bottom") { Spacer(Modifier.height(24.dp)) } } } else { + val backdrop = rememberLayerBackdrop() Column( modifier = Modifier .fillMaxSize() - .padding(horizontal = 8.dp) - .verticalScroll( - state = verticalScrollState, - enabled = true, - ), + .drawBackdrop( + backdrop = rememberLayerBackdrop(), + exportedBackdrop = backdrop, + shape = { RoundedCornerShape(0.dp) }, + highlight = { + Highlight.Ambient.copy(alpha = 0f) + } + ) + .hazeSource(hazeState) + .padding(horizontal = 8.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text( - text = "AirPods not connected", + text = stringResource(R.string.airpods_not_connected), style = TextStyle( fontSize = 24.sp, fontWeight = FontWeight.Medium, @@ -435,7 +366,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, ) Spacer(Modifier.height(24.dp)) Text( - text = "Please connect your AirPods to access settings.", + text = stringResource(R.string.airpods_not_connected_description), style = TextStyle( fontSize = 16.sp, fontWeight = FontWeight.Light, @@ -446,29 +377,65 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, modifier = Modifier.fillMaxWidth() ) Spacer(Modifier.height(32.dp)) - Button( + StyledButton( onClick = { navController.navigate("troubleshooting") }, - shape = RoundedCornerShape(10.dp), - colors = ButtonDefaults.buttonColors( - containerColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFF2F2F7), - contentColor = if (isSystemInDarkTheme()) Color.White else Color.Black, - ) + backdrop = backdrop, + modifier = Modifier + .fillMaxWidth(0.9f) ) { Text( text = "Troubleshoot Connection", style = TextStyle( fontSize = 16.sp, fontWeight = FontWeight.Medium, - fontFamily = FontFamily(Font(R.font.sf_pro)) + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = if (isSystemInDarkTheme()) Color.White else Color.Black + ) + ) + } + Spacer(Modifier.height(16.dp)) + StyledButton( + onClick = { + service.reconnectFromSavedMac() + }, + backdrop = backdrop, + modifier = Modifier + .fillMaxWidth(0.9f) + ) { + Text( + text = stringResource(R.string.reconnect_to_last_device), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = if (isSystemInDarkTheme()) Color.White else Color.Black ) ) } } } } + ConfirmationDialog( + showDialog = showDialog, + title = stringResource(R.string.support_librepods), + message = stringResource(R.string.support_dialog_description), + confirmText = stringResource(R.string.support_me) + " \uDBC0\uDEB5", + dismissText = stringResource(R.string.never_show_again), + onConfirm = { + val browserIntent = Intent( + Intent.ACTION_VIEW, + "https://github.com/sponsors/kavishdevar".toUri() + ) + context.startActivity(browserIntent) + sharedPreferences.edit { putBoolean("donationDialogShown", true) } + }, + onDismiss = { + sharedPreferences.edit { putBoolean("donationDialogShown", true) } + }, + hazeState = hazeStateS.value, + ) } - @Preview @Composable fun AirPodsSettingsScreenPreview() { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt index 308c28062..5dc271457 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt @@ -20,12 +20,12 @@ package me.kavishdevar.librepods.screens import android.content.Context import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -36,45 +36,31 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.draw.scale -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -83,18 +69,20 @@ 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.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.edit import androidx.navigation.NavController -import dev.chrisbanes.haze.HazeEffectScope -import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.hazeEffect +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.materials.CupertinoMaterials import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.launch import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.composables.StyledSwitch +import me.kavishdevar.librepods.composables.NavigationButton +import me.kavishdevar.librepods.composables.StyledScaffold +import me.kavishdevar.librepods.composables.StyledSlider +import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.RadareOffsetFinder import kotlin.io.encoding.Base64 @@ -105,256 +93,139 @@ import kotlin.math.roundToInt @Composable fun AppSettingsScreen(navController: NavController) { val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) - val name = remember { mutableStateOf(sharedPreferences.getString("name", "") ?: "") } + val isDarkTheme = isSystemInDarkTheme() val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() val scrollState = rememberScrollState() - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - val hazeState = remember { HazeState() } - var showResetDialog by remember { mutableStateOf(false) } - var showIrkDialog by remember { mutableStateOf(false) } - var showEncKeyDialog by remember { mutableStateOf(false) } - var irkValue by remember { mutableStateOf("") } - var encKeyValue by remember { mutableStateOf("") } - var irkError by remember { mutableStateOf(null) } - var encKeyError by remember { mutableStateOf(null) } + val showResetDialog = remember { mutableStateOf(false) } + val showIrkDialog = remember { mutableStateOf(false) } + val showEncKeyDialog = remember { mutableStateOf(false) } + val showCameraDialog = remember { mutableStateOf(false) } + val irkValue = remember { mutableStateOf("") } + val encKeyValue = remember { mutableStateOf("") } + val cameraPackageValue = remember { mutableStateOf("") } + val irkError = remember { mutableStateOf(null) } + val encKeyError = remember { mutableStateOf(null) } + val cameraPackageError = remember { mutableStateOf(null) } LaunchedEffect(Unit) { val savedIrk = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null) val savedEncKey = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null) + val savedCameraPackage = sharedPreferences.getString("custom_camera_package", null) if (savedIrk != null) { try { val decoded = Base64.decode(savedIrk) - irkValue = decoded.joinToString("") { "%02x".format(it) } + irkValue.value = decoded.joinToString("") { "%02x".format(it) } } catch (e: Exception) { - irkValue = "" + irkValue.value = "" + e.printStackTrace() } } if (savedEncKey != null) { try { val decoded = Base64.decode(savedEncKey) - encKeyValue = decoded.joinToString("") { "%02x".format(it) } + encKeyValue.value = decoded.joinToString("") { "%02x".format(it) } } catch (e: Exception) { - encKeyValue = "" + encKeyValue.value = "" + e.printStackTrace() } } + if (savedCameraPackage != null) { + cameraPackageValue.value = savedCameraPackage + } } - var showPhoneBatteryInWidget by remember { + val showPhoneBatteryInWidget = remember { mutableStateOf(sharedPreferences.getBoolean("show_phone_battery_in_widget", true)) } - var conversationalAwarenessPauseMusicEnabled by remember { + val conversationalAwarenessPauseMusicEnabled = remember { mutableStateOf(sharedPreferences.getBoolean("conversational_awareness_pause_music", false)) } - var relativeConversationalAwarenessVolumeEnabled by remember { + val relativeConversationalAwarenessVolumeEnabled = remember { mutableStateOf(sharedPreferences.getBoolean("relative_conversational_awareness_volume", true)) } - var openDialogForControlling by remember { + val openDialogForControlling = remember { mutableStateOf(sharedPreferences.getString("qs_click_behavior", "dialog") == "dialog") } - var disconnectWhenNotWearing by remember { + val disconnectWhenNotWearing = remember { mutableStateOf(sharedPreferences.getBoolean("disconnect_when_not_wearing", false)) } - var takeoverWhenDisconnected by remember { + val takeoverWhenDisconnected = remember { mutableStateOf(sharedPreferences.getBoolean("takeover_when_disconnected", true)) } - var takeoverWhenIdle by remember { + val takeoverWhenIdle = remember { mutableStateOf(sharedPreferences.getBoolean("takeover_when_idle", true)) } - var takeoverWhenMusic by remember { + val takeoverWhenMusic = remember { mutableStateOf(sharedPreferences.getBoolean("takeover_when_music", false)) } - var takeoverWhenCall by remember { + val takeoverWhenCall = remember { mutableStateOf(sharedPreferences.getBoolean("takeover_when_call", true)) } - var takeoverWhenRingingCall by remember { + val takeoverWhenRingingCall = remember { mutableStateOf(sharedPreferences.getBoolean("takeover_when_ringing_call", true)) } - var takeoverWhenMediaStart by remember { + val takeoverWhenMediaStart = remember { mutableStateOf(sharedPreferences.getBoolean("takeover_when_media_start", true)) } - var useAlternateHeadTrackingPackets by remember { + val useAlternateHeadTrackingPackets = remember { mutableStateOf(sharedPreferences.getBoolean("use_alternate_head_tracking_packets", false)) } - var bleOnlyMode by remember { - mutableStateOf(sharedPreferences.getBoolean("ble_only_mode", false)) - } - - // Ensure the default value is properly set if not exists - LaunchedEffect(Unit) { - if (!sharedPreferences.contains("ble_only_mode")) { - sharedPreferences.edit().putBoolean("ble_only_mode", false).apply() - } - } - - var mDensity by remember { mutableFloatStateOf(0f) } - fun validateHexInput(input: String): Boolean { val hexPattern = Regex("^[0-9a-fA-F]{32}$") return hexPattern.matches(input) } - Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - CenterAlignedTopAppBar( - modifier = Modifier.hazeEffect( - state = hazeState, - style = CupertinoMaterials.thick(), - block = fun HazeEffectScope.() { - alpha = - if (scrollState.value > 60.dp.value * mDensity) 1f else 0f - }) - .drawBehind { - mDensity = density - val strokeWidth = 0.7.dp.value * density - val y = size.height - strokeWidth / 2 - if (scrollState.value > 60.dp.value * density) { - drawLine( - if (isDarkTheme) Color.DarkGray else Color.LightGray, - Offset(0f, y), - Offset(size.width, y), - strokeWidth - ) - } - }, - title = { - Text( - text = stringResource(R.string.app_settings), - fontFamily = FontFamily(Font(R.font.sf_pro)), - ) - }, - navigationIcon = { - TextButton( - onClick = { - navController.popBackStack() - }, - shape = RoundedCornerShape(8.dp), - modifier = Modifier.width(180.dp) - ) { - Icon( - Icons.AutoMirrored.Filled.KeyboardArrowLeft, - contentDescription = "Back", - tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5), - modifier = Modifier.scale(1.5f) - ) - Text( - text = name.value, - style = TextStyle( - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - } - }, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = Color.Transparent - ), - scrollBehavior = scrollBehavior - ) - }, - containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) - else Color(0xFFF2F2F7), - ) { paddingValues -> - Column ( + val isProcessingSdp = remember { mutableStateOf(false) } + val actAsAppleDevice = remember { mutableStateOf(false) } + + BackHandler(enabled = isProcessingSdp.value) {} + + val backdrop = rememberLayerBackdrop() + + StyledScaffold( + title = stringResource(R.string.app_settings) + ) { spacerHeight, hazeState -> + Column( modifier = Modifier .fillMaxSize() - .padding(paddingValues) - .padding(horizontal = 16.dp) - .verticalScroll(scrollState) + .layerBackdrop(backdrop) .hazeSource(state = hazeState) + .verticalScroll(scrollState) + .padding(horizontal = 16.dp) ) { + Spacer(modifier = Modifier.height(spacerHeight)) + val isDarkTheme = isSystemInDarkTheme() val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val textColor = if (isDarkTheme) Color.White else Color.Black - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = "Widget".uppercase(), - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Light, - color = textColor.copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 8.dp) + StyledToggle( + title = stringResource(R.string.widget), + label = stringResource(R.string.show_phone_battery_in_widget), + description = stringResource(R.string.show_phone_battery_in_widget_description), + checkedState = showPhoneBatteryInWidget, + sharedPreferenceKey = "show_phone_battery_in_widget", + sharedPreferences = sharedPreferences, ) - Spacer(modifier = Modifier.height(2.dp)) - - Column ( - modifier = Modifier - .fillMaxWidth() - .background( - backgroundColor, - RoundedCornerShape(14.dp) - ) - .padding(horizontal = 16.dp, vertical = 4.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - showPhoneBatteryInWidget = !showPhoneBatteryInWidget - sharedPreferences.edit().putBoolean("show_phone_battery_in_widget", showPhoneBatteryInWidget).apply() - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(vertical = 8.dp) - .padding(end = 4.dp) - ) { - Text( - text = "Show phone battery in widget", - fontSize = 16.sp, - color = textColor - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Display your phone's battery level in the widget alongside AirPods battery", - fontSize = 14.sp, - color = textColor.copy(0.6f), - lineHeight = 16.sp, - ) - } - - StyledSwitch( - checked = showPhoneBatteryInWidget, - onCheckedChange = { - showPhoneBatteryInWidget = it - sharedPreferences.edit().putBoolean("show_phone_battery_in_widget", it).apply() - } - ) - } - } - Text( - text = "Connection Mode".uppercase(), + text = stringResource(R.string.conversational_awareness), style = TextStyle( fontSize = 14.sp, - fontWeight = FontWeight.Light, + fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.6f), fontFamily = FontFamily(Font(R.font.sf_pro)) ), - modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 24.dp) + modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp) ) Spacer(modifier = Modifier.height(2.dp)) @@ -364,689 +235,242 @@ fun AppSettingsScreen(navController: NavController) { .fillMaxWidth() .background( backgroundColor, - RoundedCornerShape(14.dp) + RoundedCornerShape(28.dp) ) - .padding(horizontal = 16.dp, vertical = 4.dp) + .padding(vertical = 4.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - bleOnlyMode = !bleOnlyMode - sharedPreferences.edit().putBoolean("ble_only_mode", bleOnlyMode).apply() - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(vertical = 8.dp) - .padding(end = 4.dp) - ) { - Text( - text = "BLE Only Mode", - fontSize = 16.sp, - color = textColor - ) - Text( - text = "Only use Bluetooth Low Energy for battery data and ear detection. Disables advanced features requiring L2CAP connection.", - fontSize = 13.sp, - color = textColor.copy(0.6f), - lineHeight = 16.sp, - ) - } - - StyledSwitch( - checked = bleOnlyMode, - onCheckedChange = { - bleOnlyMode = it - sharedPreferences.edit().putBoolean("ble_only_mode", it).apply() - } - ) - } - } - - Text( - text = "Conversational Awareness".uppercase(), - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Light, - color = textColor.copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 24.dp) - ) - - Spacer(modifier = Modifier.height(2.dp)) - - Column ( - modifier = Modifier - .fillMaxWidth() - .background( - backgroundColor, - RoundedCornerShape(14.dp) - ) - .padding(horizontal = 16.dp, vertical = 4.dp) - ) { - val sliderValue = remember { mutableFloatStateOf(0f) } - LaunchedEffect(sliderValue) { - if (sharedPreferences.contains("conversational_awareness_volume")) { - sliderValue.floatValue = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat() - } - } - fun updateConversationalAwarenessPauseMusic(enabled: Boolean) { - conversationalAwarenessPauseMusicEnabled = enabled - sharedPreferences.edit().putBoolean("conversational_awareness_pause_music", enabled).apply() + conversationalAwarenessPauseMusicEnabled.value = enabled + sharedPreferences.edit { putBoolean("conversational_awareness_pause_music", enabled)} } fun updateRelativeConversationalAwarenessVolume(enabled: Boolean) { - relativeConversationalAwarenessVolumeEnabled = enabled - sharedPreferences.edit().putBoolean("relative_conversational_awareness_volume", enabled).apply() + relativeConversationalAwarenessVolumeEnabled.value = enabled + sharedPreferences.edit { putBoolean("relative_conversational_awareness_volume", enabled)} } - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - updateConversationalAwarenessPauseMusic(!conversationalAwarenessPauseMusicEnabled) - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(vertical = 8.dp) - .padding(end = 4.dp) - ) { - Text( - text = stringResource(R.string.conversational_awareness_pause_music), - fontSize = 16.sp, - color = textColor - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.conversational_awareness_pause_music_description), - fontSize = 14.sp, - color = textColor.copy(0.6f), - lineHeight = 16.sp, - ) - } - - StyledSwitch( - checked = conversationalAwarenessPauseMusicEnabled, - onCheckedChange = { - updateConversationalAwarenessPauseMusic(it) - }, - ) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - updateRelativeConversationalAwarenessVolume(!relativeConversationalAwarenessVolumeEnabled) - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(vertical = 8.dp) - .padding(end = 4.dp) - ) { - Text( - text = stringResource(R.string.relative_conversational_awareness_volume), - fontSize = 16.sp, - color = textColor - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.relative_conversational_awareness_volume_description), - fontSize = 14.sp, - color = textColor.copy(0.6f), - lineHeight = 16.sp, - ) - } - - StyledSwitch( - checked = relativeConversationalAwarenessVolumeEnabled, - onCheckedChange = { - updateRelativeConversationalAwarenessVolume(it) - } - ) - } - - Text( - text = "Conversational Awareness Volume", - fontSize = 16.sp, - color = textColor, - modifier = Modifier.padding(top = 8.dp, bottom = 4.dp) + StyledToggle( + label = stringResource(R.string.conversational_awareness_pause_music), + description = stringResource(R.string.conversational_awareness_pause_music_description), + checkedState = conversationalAwarenessPauseMusicEnabled, + onCheckedChange = { updateConversationalAwarenessPauseMusic(it) }, + independent = false ) - val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFFD9D9D9) - val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) - val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) - - Slider( - value = sliderValue.floatValue, - onValueChange = { - sliderValue.floatValue = it - sharedPreferences.edit().putInt("conversational_awareness_volume", it.toInt()).apply() - }, - valueRange = 10f..85f, - onValueChangeFinished = { - sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat() - }, + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), modifier = Modifier - .fillMaxWidth() - .height(36.dp) - .padding(vertical = 4.dp), - colors = SliderDefaults.colors( - thumbColor = thumbColor, - activeTrackColor = activeTrackColor, - inactiveTrackColor = trackColor, - ), - thumb = { - Box( - modifier = Modifier - .size(24.dp) - .shadow(4.dp, CircleShape) - .background(thumbColor, CircleShape) - ) - }, - track = { - Box ( - modifier = Modifier - .fillMaxWidth() - .height(12.dp), - contentAlignment = Alignment.CenterStart - ) - { - Box( - modifier = Modifier - .fillMaxWidth() - .height(4.dp) - .background(trackColor, RoundedCornerShape(4.dp)) - ) - Box( - modifier = Modifier - .fillMaxWidth(((sliderValue.floatValue - 10) * 100) /7500) - .height(4.dp) - .background(if (conversationalAwarenessPauseMusicEnabled) trackColor else activeTrackColor, RoundedCornerShape(4.dp)) - ) - } - } + .padding(horizontal = 12.dp) ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "10%", - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Light, - color = textColor.copy(alpha = 0.7f) - ), - modifier = Modifier.padding(start = 4.dp) - ) - Text( - text = "85%", - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Light, - color = textColor.copy(alpha = 0.7f) - ), - modifier = Modifier.padding(end = 4.dp) - ) - } + StyledToggle( + label = stringResource(R.string.relative_conversational_awareness_volume), + description = stringResource(R.string.relative_conversational_awareness_volume_description), + checkedState = relativeConversationalAwarenessVolumeEnabled, + onCheckedChange = { updateRelativeConversationalAwarenessVolume(it) }, + independent = false + ) } - Text( - text = "Quick Settings Tile".uppercase(), - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Light, - color = textColor.copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 24.dp) - ) - - Spacer(modifier = Modifier.height(2.dp)) + Spacer(modifier = Modifier.height(16.dp)) - Column( - modifier = Modifier - .fillMaxWidth() - .background( - backgroundColor, - RoundedCornerShape(14.dp) - ) - .padding(horizontal = 16.dp, vertical = 4.dp) - ) { - fun updateQsClickBehavior(enabled: Boolean) { - openDialogForControlling = enabled - sharedPreferences.edit().putString("qs_click_behavior", if (enabled) "dialog" else "cycle").apply() - } + val conversationalAwarenessVolume = remember { mutableFloatStateOf(sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat()) } + LaunchedEffect(conversationalAwarenessVolume.floatValue) { + sharedPreferences.edit { putInt("conversational_awareness_volume", conversationalAwarenessVolume.floatValue.roundToInt()) } + } - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - updateQsClickBehavior(!openDialogForControlling) - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(vertical = 8.dp) - .padding(end = 4.dp) - ) { - Text( - text = "Open dialog for controlling", - fontSize = 16.sp, - color = textColor - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = if (openDialogForControlling) - "If disabled, clicking on the QS will cycle through modes" - else "If enabled, it will show a dialog for controlling noise control mode and conversational awareness", - fontSize = 14.sp, - color = textColor.copy(0.6f), - lineHeight = 16.sp, - ) - } + StyledSlider( + label = stringResource(R.string.conversational_awareness_volume), + mutableFloatState = conversationalAwarenessVolume, + valueRange = 10f..85f, + startLabel = "10%", + endLabel = "85%", + onValueChange = { newValue -> conversationalAwarenessVolume.floatValue = newValue }, + independent = true + ) - StyledSwitch( - checked = openDialogForControlling, - onCheckedChange = { - updateQsClickBehavior(it) - } - ) - } - } + Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Ear Detection".uppercase(), - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Light, - color = textColor.copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 24.dp) + NavigationButton( + to = "", + title = stringResource(R.string.camera_control), + name = stringResource(R.string.set_custom_camera_package), + navController = navController, + onClick = { showCameraDialog.value = true }, + independent = true, + description = stringResource(R.string.camera_control_app_description) ) - Spacer(modifier = Modifier.height(2.dp)) + Spacer(modifier = Modifier.height(16.dp)) - Column( - modifier = Modifier - .fillMaxWidth() - .background( - backgroundColor, - RoundedCornerShape(14.dp) - ) - .padding(horizontal = 16.dp, vertical = 4.dp) - ) { - fun updateDisconnectWhenNotWearing(enabled: Boolean) { - disconnectWhenNotWearing = enabled - sharedPreferences.edit().putBoolean("disconnect_when_not_wearing", enabled).apply() - } + StyledToggle( + title = stringResource(R.string.quick_settings_tile), + label = stringResource(R.string.open_dialog_for_controlling), + description = stringResource(R.string.open_dialog_for_controlling_description), + checkedState = openDialogForControlling, + onCheckedChange = { + openDialogForControlling.value = it + sharedPreferences.edit { putString("qs_click_behavior", if (it) "dialog" else "activity") } + }, + ) - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - updateDisconnectWhenNotWearing(!disconnectWhenNotWearing) - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(vertical = 8.dp) - .padding(end = 4.dp) - ) { - Text( - text = "Disconnect AirPods when not wearing", - fontSize = 16.sp, - color = textColor - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "You will still be able to control them with the app - this just disconnects the audio.", - fontSize = 14.sp, - color = textColor.copy(0.6f), - lineHeight = 16.sp, - ) - } + Spacer(modifier = Modifier.height(16.dp)) - StyledSwitch( - checked = disconnectWhenNotWearing, - onCheckedChange = { - updateDisconnectWhenNotWearing(it) - } - ) - } - } + StyledToggle( + title = stringResource(R.string.ear_detection), + label = stringResource(R.string.disconnect_when_not_wearing), + description = stringResource(R.string.disconnect_when_not_wearing_description), + checkedState = disconnectWhenNotWearing, + sharedPreferenceKey = "disconnect_when_not_wearing", + sharedPreferences = sharedPreferences, + ) Text( - text = stringResource(R.string.takeover_header).uppercase(), + text = stringResource(R.string.takeover_airpods_state), style = TextStyle( fontSize = 14.sp, - fontWeight = FontWeight.Light, + fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.6f), fontFamily = FontFamily(Font(R.font.sf_pro)) ), - modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 24.dp) + modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp) ) - Spacer(modifier = Modifier.height(2.dp)) - - Column( - modifier = Modifier - .fillMaxWidth() - .background( - backgroundColor, - RoundedCornerShape(14.dp) - ) - .padding(horizontal = 16.dp, vertical = 4.dp) - ) { - Text( - text = stringResource(R.string.takeover_airpods_state), - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = textColor, - modifier = Modifier.padding(top = 12.dp, bottom = 4.dp) - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - takeoverWhenDisconnected = !takeoverWhenDisconnected - sharedPreferences.edit().putBoolean("takeover_when_disconnected", takeoverWhenDisconnected).apply() - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(vertical = 8.dp) - .padding(end = 4.dp) - ) { - Text( - text = stringResource(R.string.takeover_disconnected), - fontSize = 16.sp, - color = textColor - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.takeover_disconnected_desc), - fontSize = 14.sp, - color = textColor.copy(0.6f), - lineHeight = 16.sp, - ) - } - - StyledSwitch( - checked = takeoverWhenDisconnected, - onCheckedChange = { - takeoverWhenDisconnected = it - sharedPreferences.edit().putBoolean("takeover_when_disconnected", it).apply() - } - ) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - takeoverWhenIdle = !takeoverWhenIdle - sharedPreferences.edit().putBoolean("takeover_when_idle", takeoverWhenIdle).apply() - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(vertical = 8.dp) - .padding(end = 4.dp) - ) { - Text( - text = stringResource(R.string.takeover_idle), - fontSize = 16.sp, - color = textColor - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.takeover_idle_desc), - fontSize = 14.sp, - color = textColor.copy(0.6f), - lineHeight = 16.sp, - ) - } - - StyledSwitch( - checked = takeoverWhenIdle, - onCheckedChange = { - takeoverWhenIdle = it - sharedPreferences.edit().putBoolean("takeover_when_idle", it).apply() - } - ) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - takeoverWhenMusic = !takeoverWhenMusic - sharedPreferences.edit().putBoolean("takeover_when_music", takeoverWhenMusic).apply() - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(vertical = 8.dp) - .padding(end = 4.dp) - ) { - Text( - text = stringResource(R.string.takeover_music), - fontSize = 16.sp, - color = textColor - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.takeover_music_desc), - fontSize = 14.sp, - color = textColor.copy(0.6f), - lineHeight = 16.sp, - ) - } + Spacer(modifier = Modifier.height(4.dp)) - StyledSwitch( - checked = takeoverWhenMusic, - onCheckedChange = { - takeoverWhenMusic = it - sharedPreferences.edit().putBoolean("takeover_when_music", it).apply() - } + Column( + modifier = Modifier + .fillMaxWidth() + .background( + backgroundColor, + RoundedCornerShape(28.dp) ) - } - - Row( + .padding(vertical = 4.dp) + ) { + StyledToggle( + label = stringResource(R.string.takeover_disconnected), + description = stringResource(R.string.takeover_disconnected_desc), + checkedState = takeoverWhenDisconnected, + onCheckedChange = { + takeoverWhenDisconnected.value = it + sharedPreferences.edit { putBoolean("takeover_when_disconnected", it)} + }, + independent = false + ) + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), modifier = Modifier - .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - takeoverWhenCall = !takeoverWhenCall - sharedPreferences.edit().putBoolean("takeover_when_call", takeoverWhenCall).apply() - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(vertical = 8.dp) - .padding(end = 4.dp) - ) { - Text( - text = stringResource(R.string.takeover_call), - fontSize = 16.sp, - color = textColor - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.takeover_call_desc), - fontSize = 14.sp, - color = textColor.copy(0.6f), - lineHeight = 16.sp, - ) - } + .padding(horizontal = 12.dp) + ) - StyledSwitch( - checked = takeoverWhenCall, - onCheckedChange = { - takeoverWhenCall = it - sharedPreferences.edit().putBoolean("takeover_when_call", it).apply() - } - ) - } + StyledToggle( + label = stringResource(R.string.takeover_idle), + description = stringResource(R.string.takeover_idle_desc), + checkedState = takeoverWhenIdle, + onCheckedChange = { + takeoverWhenIdle.value = it + sharedPreferences.edit { putBoolean("takeover_when_idle", it)} + }, + independent = false + ) + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier + .padding(horizontal = 12.dp) + ) - Spacer(modifier = Modifier.height(12.dp)) + StyledToggle( + label = stringResource(R.string.takeover_music), + description = stringResource(R.string.takeover_music_desc), + checkedState = takeoverWhenMusic, + onCheckedChange = { + takeoverWhenMusic.value = it + sharedPreferences.edit { putBoolean("takeover_when_music", it)} + }, + independent = false + ) + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier + .padding(horizontal = 12.dp) + ) - Text( - text = stringResource(R.string.takeover_phone_state), - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = textColor, - modifier = Modifier.padding(top = 8.dp, bottom = 4.dp) + StyledToggle( + label = stringResource(R.string.takeover_call), + description = stringResource(R.string.takeover_call_desc), + checkedState = takeoverWhenCall, + onCheckedChange = { + takeoverWhenCall.value = it + sharedPreferences.edit { putBoolean("takeover_when_call", it)} + }, + independent = false ) + } - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - takeoverWhenRingingCall = !takeoverWhenRingingCall - sharedPreferences.edit().putBoolean("takeover_when_ringing_call", takeoverWhenRingingCall).apply() - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(vertical = 8.dp) - .padding(end = 4.dp) - ) { - Text( - text = stringResource(R.string.takeover_ringing_call), - fontSize = 16.sp, - color = textColor - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.takeover_ringing_call_desc), - fontSize = 14.sp, - color = textColor.copy(0.6f), - lineHeight = 16.sp, - ) - } + Spacer(modifier = Modifier.height(16.dp)) - StyledSwitch( - checked = takeoverWhenRingingCall, - onCheckedChange = { - takeoverWhenRingingCall = it - sharedPreferences.edit().putBoolean("takeover_when_ringing_call", it).apply() - } + Text( + text = stringResource(R.string.takeover_phone_state), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .background( + backgroundColor, + RoundedCornerShape(28.dp) ) - } - - Row( + .padding(vertical = 4.dp) + ){ + StyledToggle( + label = stringResource(R.string.takeover_ringing_call), + description = stringResource(R.string.takeover_ringing_call_desc), + checkedState = takeoverWhenRingingCall, + onCheckedChange = { + takeoverWhenRingingCall.value = it + sharedPreferences.edit { putBoolean("takeover_when_ringing_call", it)} + }, + independent = false + ) + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), modifier = Modifier - .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - takeoverWhenMediaStart = !takeoverWhenMediaStart - sharedPreferences.edit().putBoolean("takeover_when_media_start", takeoverWhenMediaStart).apply() - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(vertical = 8.dp) - .padding(end = 4.dp) - ) { - Text( - text = stringResource(R.string.takeover_media_start), - fontSize = 16.sp, - color = textColor - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.takeover_media_start_desc), - fontSize = 14.sp, - color = textColor.copy(0.6f), - lineHeight = 16.sp, - ) - } + .padding(horizontal = 12.dp) + ) - StyledSwitch( - checked = takeoverWhenMediaStart, - onCheckedChange = { - takeoverWhenMediaStart = it - sharedPreferences.edit().putBoolean("takeover_when_media_start", it).apply() - } - ) - } + StyledToggle( + label = stringResource(R.string.takeover_media_start), + description = stringResource(R.string.takeover_media_start_desc), + checkedState = takeoverWhenMediaStart, + onCheckedChange = { + takeoverWhenMediaStart.value = it + sharedPreferences.edit { putBoolean("takeover_when_media_start", it)} + }, + independent = false + ) } Text( - text = "Advanced Options".uppercase(), + text = stringResource(R.string.advanced_options), style = TextStyle( fontSize = 14.sp, - fontWeight = FontWeight.Light, + fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.6f), fontFamily = FontFamily(Font(R.font.sf_pro)) ), - modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 24.dp) + modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp) ) Spacer(modifier = Modifier.height(2.dp)) @@ -1056,16 +480,18 @@ fun AppSettingsScreen(navController: NavController) { .fillMaxWidth() .background( backgroundColor, - RoundedCornerShape(14.dp) + RoundedCornerShape(28.dp) ) .padding(horizontal = 16.dp, vertical = 4.dp) ) { Row( modifier = Modifier .fillMaxWidth() - .clickable { - showIrkDialog = true - }, + .clickable ( + onClick = { showIrkDialog.value = true }, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ), verticalAlignment = Alignment.CenterVertically ) { Column( @@ -1075,13 +501,13 @@ fun AppSettingsScreen(navController: NavController) { .padding(end = 4.dp) ) { Text( - text = "Set Identity Resolving Key (IRK)", + text = stringResource(R.string.set_identity_resolving_key), fontSize = 16.sp, color = textColor ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "Manually set the IRK value used for resolving BLE random addresses", + text = stringResource(R.string.set_identity_resolving_key_description), fontSize = 14.sp, color = textColor.copy(0.6f), lineHeight = 16.sp, @@ -1089,45 +515,19 @@ fun AppSettingsScreen(navController: NavController) { } } - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - showEncKeyDialog = true - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(vertical = 8.dp) - .padding(end = 4.dp) - ) { - Text( - text = "Set Encryption Key", - fontSize = 16.sp, - color = textColor - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Manually set the ENC_KEY value used for decrypting BLE advertisements", - fontSize = 14.sp, - color = textColor.copy(0.6f), - lineHeight = 16.sp, - ) - } - } + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + ) Row( modifier = Modifier .fillMaxWidth() - .clickable( + .clickable ( + onClick = { showEncKeyDialog.value = true }, indication = null, interactionSource = remember { MutableInteractionSource() } - ) { - useAlternateHeadTrackingPackets = !useAlternateHeadTrackingPackets - sharedPreferences.edit().putBoolean("use_alternate_head_tracking_packets", useAlternateHeadTrackingPackets).apply() - }, + ), verticalAlignment = Alignment.CenterVertically ) { Column( @@ -1137,69 +537,84 @@ fun AppSettingsScreen(navController: NavController) { .padding(end = 4.dp) ) { Text( - text = "Use alternate head tracking packets", + text = stringResource(R.string.set_encryption_key), fontSize = 16.sp, color = textColor ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "Enable this if head tracking doesn't work for you. This sends different data to AirPods for requesting/stopping head tracking data.", + text = stringResource(R.string.set_encryption_key_description), fontSize = 14.sp, color = textColor.copy(0.6f), lineHeight = 16.sp, ) } - - StyledSwitch( - checked = useAlternateHeadTrackingPackets, - onCheckedChange = { - useAlternateHeadTrackingPackets = it - sharedPreferences.edit().putBoolean("use_alternate_head_tracking_packets", it).apply() - } - ) } + } - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - navController.navigate("troubleshooting") - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(vertical = 8.dp) - .padding(end = 4.dp) - ) { - Text( - text = stringResource(R.string.troubleshooting), - fontSize = 16.sp, - color = textColor - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.troubleshooting_description), - fontSize = 14.sp, - color = textColor.copy(0.6f), - lineHeight = 16.sp, - ) - } - } + Spacer(modifier = Modifier.height(16.dp)) + + StyledToggle( + label = stringResource(R.string.use_alternate_head_tracking_packets), + description = stringResource(R.string.use_alternate_head_tracking_packets_description), + checkedState = useAlternateHeadTrackingPackets, + onCheckedChange = { + useAlternateHeadTrackingPackets.value = it + sharedPreferences.edit { putBoolean("use_alternate_head_tracking_packets", it)} + }, + independent = true + ) + + Spacer(modifier = Modifier.height(16.dp)) + + NavigationButton( + to = "troubleshooting", + name = stringResource(R.string.troubleshooting), + navController = navController, + independent = true, + description = stringResource(R.string.troubleshooting_description) + ) + + LaunchedEffect(Unit) { + actAsAppleDevice.value = RadareOffsetFinder.isSdpOffsetAvailable() } + val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth) + + StyledToggle( + label = stringResource(R.string.act_as_an_apple_device), + description = stringResource(R.string.act_as_an_apple_device_description), + checkedState = actAsAppleDevice, + onCheckedChange = { + actAsAppleDevice.value = it + isProcessingSdp.value = true + coroutineScope.launch { + if (it) { + val radareOffsetFinder = RadareOffsetFinder(context) + val success = radareOffsetFinder.findSdpOffset() + if (success) { + Toast.makeText(context, restartBluetoothText, Toast.LENGTH_LONG).show() + } + } else { + RadareOffsetFinder.clearSdpOffset() + } + isProcessingSdp.value = false + } + }, + independent = true, + enabled = !isProcessingSdp.value + ) Spacer(modifier = Modifier.height(16.dp)) Button( - onClick = { showResetDialog = true }, + onClick = { showResetDialog.value = true }, modifier = Modifier .fillMaxWidth() .height(50.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.errorContainer ), - shape = RoundedCornerShape(14.dp) + shape = RoundedCornerShape(28.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -1213,7 +628,7 @@ fun AppSettingsScreen(navController: NavController) { ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "Reset Hook Offset", + text = stringResource(R.string.reset_hook_offset), color = MaterialTheme.colorScheme.onErrorContainer, style = TextStyle( fontSize = 16.sp, @@ -1224,11 +639,20 @@ fun AppSettingsScreen(navController: NavController) { } } + Spacer(modifier = Modifier.height(16.dp)) + + NavigationButton( + to = "open_source_licenses", + name = stringResource(R.string.open_source_licenses), + navController = navController, + independent = true + ) + Spacer(modifier = Modifier.height(32.dp)) - if (showResetDialog) { + if (showResetDialog.value) { AlertDialog( - onDismissRequest = { showResetDialog = false }, + onDismissRequest = { showResetDialog.value = false }, title = { Text( "Reset Hook Offset", @@ -1238,17 +662,19 @@ fun AppSettingsScreen(navController: NavController) { }, text = { Text( - "This will clear the current hook offset and require you to go through the setup process again. Are you sure you want to continue?", + stringResource(R.string.reset_hook_offset_description), fontFamily = FontFamily(Font(R.font.sf_pro)) ) }, confirmButton = { + val successText = stringResource(R.string.hook_offset_reset_success) + val failureText = stringResource(R.string.hook_offset_reset_failure) TextButton( onClick = { if (RadareOffsetFinder.clearHookOffsets()) { Toast.makeText( context, - "Hook offset has been reset. Redirecting to setup...", + successText, Toast.LENGTH_LONG ).show() @@ -1258,18 +684,18 @@ fun AppSettingsScreen(navController: NavController) { } else { Toast.makeText( context, - "Failed to reset hook offset", + failureText, Toast.LENGTH_SHORT ).show() } - showResetDialog = false + showResetDialog.value = false }, colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colorScheme.error ) ) { Text( - "Reset", + stringResource(R.string.reset), fontFamily = FontFamily(Font(R.font.sf_pro)), fontWeight = FontWeight.Medium ) @@ -1277,7 +703,7 @@ fun AppSettingsScreen(navController: NavController) { }, dismissButton = { TextButton( - onClick = { showResetDialog = false } + onClick = { showResetDialog.value = false } ) { Text( "Cancel", @@ -1289,12 +715,12 @@ fun AppSettingsScreen(navController: NavController) { ) } - if (showIrkDialog) { + if (showIrkDialog.value) { AlertDialog( - onDismissRequest = { showIrkDialog = false }, + onDismissRequest = { showIrkDialog.value = false }, title = { Text( - "Set Identity Resolving Key (IRK)", + stringResource(R.string.set_identity_resolving_key), fontFamily = FontFamily(Font(R.font.sf_pro)), fontWeight = FontWeight.Medium ) @@ -1302,19 +728,19 @@ fun AppSettingsScreen(navController: NavController) { text = { Column { Text( - "Enter 16-byte IRK as hex string (32 characters):", + stringResource(R.string.enter_irk_hex), fontFamily = FontFamily(Font(R.font.sf_pro)), modifier = Modifier.padding(bottom = 8.dp) ) OutlinedTextField( - value = irkValue, + value = irkValue.value, onValueChange = { - irkValue = it.lowercase().filter { char -> char.isDigit() || char in 'a'..'f' } - irkError = null + irkValue.value = it.lowercase().filter { char -> char.isDigit() || char in 'a'..'f' } + irkError.value = null }, modifier = Modifier.fillMaxWidth(), - isError = irkError != null, + isError = irkError.value != null, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Ascii, capitalization = KeyboardCapitalization.None @@ -1324,36 +750,38 @@ fun AppSettingsScreen(navController: NavController) { unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray ), supportingText = { - if (irkError != null) { - Text(irkError!!, color = MaterialTheme.colorScheme.error) + if (irkError.value != null) { + Text(stringResource(R.string.must_be_32_hex_chars), color = MaterialTheme.colorScheme.error) } }, - label = { Text("IRK Hex Value") } + label = { Text(stringResource(R.string.irk_hex_value)) } ) } }, confirmButton = { + val successText = stringResource(R.string.irk_set_success) + val errorText = stringResource(R.string.error_converting_hex) TextButton( onClick = { - if (!validateHexInput(irkValue)) { - irkError = "Must be exactly 32 hex characters" + if (!validateHexInput(irkValue.value)) { + irkError.value = "Must be exactly 32 hex characters" return@TextButton } try { val hexBytes = ByteArray(16) for (i in 0 until 16) { - val hexByte = irkValue.substring(i * 2, i * 2 + 2) + val hexByte = irkValue.value.substring(i * 2, i * 2 + 2) hexBytes[i] = hexByte.toInt(16).toByte() } val base64Value = Base64.encode(hexBytes) - sharedPreferences.edit().putString(AACPManager.Companion.ProximityKeyType.IRK.name, base64Value).apply() + sharedPreferences.edit { putString(AACPManager.Companion.ProximityKeyType.IRK.name, base64Value)} - Toast.makeText(context, "IRK has been set successfully", Toast.LENGTH_SHORT).show() - showIrkDialog = false + Toast.makeText(context, successText, Toast.LENGTH_SHORT).show() + showIrkDialog.value = false } catch (e: Exception) { - irkError = "Error converting hex: ${e.message}" + irkError.value = errorText + " " + (e.message ?: "Unknown error") } } ) { @@ -1366,7 +794,7 @@ fun AppSettingsScreen(navController: NavController) { }, dismissButton = { TextButton( - onClick = { showIrkDialog = false } + onClick = { showIrkDialog.value = false } ) { Text( "Cancel", @@ -1378,12 +806,12 @@ fun AppSettingsScreen(navController: NavController) { ) } - if (showEncKeyDialog) { + if (showEncKeyDialog.value) { AlertDialog( - onDismissRequest = { showEncKeyDialog = false }, + onDismissRequest = { showEncKeyDialog.value = false }, title = { Text( - "Set Encryption Key", + stringResource(R.string.set_encryption_key), fontFamily = FontFamily(Font(R.font.sf_pro)), fontWeight = FontWeight.Medium ) @@ -1391,19 +819,19 @@ fun AppSettingsScreen(navController: NavController) { text = { Column { Text( - "Enter 16-byte ENC_KEY as hex string (32 characters):", + stringResource(R.string.enter_enc_key_hex), fontFamily = FontFamily(Font(R.font.sf_pro)), modifier = Modifier.padding(bottom = 8.dp) ) OutlinedTextField( - value = encKeyValue, + value = encKeyValue.value, onValueChange = { - encKeyValue = it.lowercase().filter { char -> char.isDigit() || char in 'a'..'f' } - encKeyError = null + encKeyValue.value = it.lowercase().filter { char -> char.isDigit() || char in 'a'..'f' } + encKeyError.value = null }, modifier = Modifier.fillMaxWidth(), - isError = encKeyError != null, + isError = encKeyError.value != null, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Ascii, capitalization = KeyboardCapitalization.None @@ -1413,37 +841,119 @@ fun AppSettingsScreen(navController: NavController) { unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray ), supportingText = { - if (encKeyError != null) { - Text(encKeyError!!, color = MaterialTheme.colorScheme.error) + if (encKeyError.value != null) { + Text(stringResource(R.string.must_be_32_hex_chars), color = MaterialTheme.colorScheme.error) } }, - label = { Text("ENC_KEY Hex Value") } + label = { Text(stringResource(R.string.enc_key_hex_value)) } ) } }, confirmButton = { + val successText = stringResource(R.string.encryption_key_set_success) + val errorText = stringResource(R.string.error_converting_hex) TextButton( onClick = { - if (!validateHexInput(encKeyValue)) { - encKeyError = "Must be exactly 32 hex characters" + if (!validateHexInput(encKeyValue.value)) { + encKeyError.value = "Must be exactly 32 hex characters" return@TextButton } try { val hexBytes = ByteArray(16) for (i in 0 until 16) { - val hexByte = encKeyValue.substring(i * 2, i * 2 + 2) + val hexByte = encKeyValue.value.substring(i * 2, i * 2 + 2) hexBytes[i] = hexByte.toInt(16).toByte() } val base64Value = Base64.encode(hexBytes) - sharedPreferences.edit().putString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, base64Value).apply() + sharedPreferences.edit { putString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, base64Value)} - Toast.makeText(context, "Encryption key has been set successfully", Toast.LENGTH_SHORT).show() - showEncKeyDialog = false + Toast.makeText(context, successText, Toast.LENGTH_SHORT).show() + showEncKeyDialog.value = false } catch (e: Exception) { - encKeyError = "Error converting hex: ${e.message}" + encKeyError.value = errorText + " " + (e.message ?: "Unknown error") + } + } + ) { + Text( + "Save", + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Medium + ) + } + }, + dismissButton = { + TextButton( + onClick = { showEncKeyDialog.value = false } + ) { + Text( + "Cancel", + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Medium + ) + } + } + ) + } + + if (showCameraDialog.value) { + AlertDialog( + onDismissRequest = { showCameraDialog.value = false }, + title = { + Text( + stringResource(R.string.set_custom_camera_package), + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Medium + ) + }, + text = { + Column { + Text( + stringResource(R.string.enter_custom_camera_package), + fontFamily = FontFamily(Font(R.font.sf_pro)), + modifier = Modifier.padding(bottom = 8.dp) + ) + + OutlinedTextField( + value = cameraPackageValue.value, + onValueChange = { + cameraPackageValue.value = it + cameraPackageError.value = null + }, + modifier = Modifier.fillMaxWidth(), + isError = cameraPackageError.value != null, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Ascii, + capitalization = KeyboardCapitalization.None + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5), + unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray + ), + supportingText = { + if (cameraPackageError.value != null) { + Text(cameraPackageError.value!!, color = MaterialTheme.colorScheme.error) + } + }, + label = { Text(stringResource(R.string.custom_camera_package)) } + ) + } + }, + confirmButton = { + val successText = stringResource(R.string.custom_camera_package_set_success) + TextButton( + onClick = { + if (cameraPackageValue.value.isBlank()) { + sharedPreferences.edit { remove("custom_camera_package") } + Toast.makeText(context, successText, Toast.LENGTH_SHORT).show() + showCameraDialog.value = false + return@TextButton } + + sharedPreferences.edit { putString("custom_camera_package", cameraPackageValue.value) } + Toast.makeText(context, successText, Toast.LENGTH_SHORT).show() + showCameraDialog.value = false } ) { Text( @@ -1455,7 +965,7 @@ fun AppSettingsScreen(navController: NavController) { }, dismissButton = { TextButton( - onClick = { showEncKeyDialog = false } + onClick = { showCameraDialog.value = false } ) { Text( "Cancel", diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/CameraControlScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/CameraControlScreen.kt new file mode 100644 index 000000000..8f5c5295b --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/CameraControlScreen.kt @@ -0,0 +1,146 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.screens + +import android.annotation.SuppressLint +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.provider.Settings +import android.view.accessibility.AccessibilityManager +import android.accessibilityservice.AccessibilityServiceInfo +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.core.content.edit +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.SelectItem +import me.kavishdevar.librepods.composables.StyledIconButton +import me.kavishdevar.librepods.composables.StyledScaffold +import me.kavishdevar.librepods.composables.StyledSelectList +import me.kavishdevar.librepods.composables.StyledSlider +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.services.AppListenerService +import me.kavishdevar.librepods.utils.AACPManager +import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType +import kotlin.io.encoding.ExperimentalEncodingApi + +private var debounceJob: Job? = null + +@SuppressLint("DefaultLocale") +@ExperimentalHazeMaterialsApi +@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) +@Composable +fun CameraControlScreen(navController: NavController) { + val isDarkTheme = isSystemInDarkTheme() + val context = LocalContext.current + val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + + val service = ServiceManager.getService()!! + var currentCameraAction by remember { + mutableStateOf( + sharedPreferences.getString("camera_action", null)?.let { StemPressType.valueOf(it) } + ) + } + + fun isAppListenerServiceEnabled(context: Context): Boolean { + val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager + val enabledServices = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK) + val serviceComponent = ComponentName(context, AppListenerService::class.java) + return enabledServices.any { it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName && it.resolveInfo.serviceInfo.name == serviceComponent.className } + } + + val cameraOptions = listOf( + SelectItem( + name = stringResource(R.string.off), + selected = currentCameraAction == null, + onClick = { + sharedPreferences.edit { remove("camera_action") } + currentCameraAction = null + } + ), + SelectItem( + name = stringResource(R.string.press_once), + selected = currentCameraAction == StemPressType.SINGLE_PRESS, + onClick = { + if (!isAppListenerServiceEnabled(context)) { + context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) + } else { + sharedPreferences.edit { putString("camera_action", StemPressType.SINGLE_PRESS.name) } + currentCameraAction = StemPressType.SINGLE_PRESS + } + } + ), + SelectItem( + name = stringResource(R.string.press_and_hold_airpods), + selected = currentCameraAction == StemPressType.LONG_PRESS, + onClick = { + if (!isAppListenerServiceEnabled(context)) { + context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) + } else { + sharedPreferences.edit { putString("camera_action", StemPressType.LONG_PRESS.name) } + currentCameraAction = StemPressType.LONG_PRESS + } + } + ) + ) + + val backdrop = rememberLayerBackdrop() + + StyledScaffold( + title = stringResource(R.string.camera_control) + ) { spacerHeight -> + Column( + modifier = Modifier + .fillMaxSize() + .layerBackdrop(backdrop) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(spacerHeight)) + StyledSelectList(items = cameraOptions) + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt index 6529cbed3..27db1f8ea 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt @@ -29,10 +29,8 @@ import android.widget.Toast import androidx.annotation.RequiresApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row @@ -42,44 +40,30 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Send import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager @@ -91,15 +75,15 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController -import dev.chrisbanes.haze.HazeEffectScope -import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.hazeEffect +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.materials.CupertinoMaterials import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import kotlinx.coroutines.delay import kotlinx.coroutines.launch import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.StyledIconButton +import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.constants.BatteryStatus import me.kavishdevar.librepods.constants.isHeadTrackingData import me.kavishdevar.librepods.services.ServiceManager @@ -303,52 +287,24 @@ fun parseOutgoingPacket(bytes: ByteArray, rawData: String): PacketInfo { } } -@Composable -fun IOSCheckbox( - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier -) { - Box( - modifier = modifier - .size(24.dp) - .clickable { onCheckedChange(!checked) }, - contentAlignment = Alignment.Center - ) { - if (checked) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = "Checked", - tint = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5), - modifier = Modifier.size(20.dp) - ) - } - } -} - @RequiresApi(Build.VERSION_CODES.Q) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, ExperimentalFoundationApi::class) @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "UnspecifiedRegisterReceiverFlag") @Composable fun DebugScreen(navController: NavController) { - val hazeState = remember { HazeState() } val context = LocalContext.current val listState = rememberLazyListState() - val scrollOffset by remember { derivedStateOf { listState.firstVisibleItemScrollOffset } } val focusManager = LocalFocusManager.current val coroutineScope = rememberCoroutineScope() - val showMenu = remember { mutableStateOf(false) } - val airPodsService = remember { ServiceManager.getService() } val packetLogs = airPodsService?.packetLogsFlow?.collectAsState(emptySet())?.value ?: emptySet() - val shouldScrollToBottom = remember { mutableStateOf(true) } - val refreshTrigger = remember { mutableStateOf(0) } - LaunchedEffect(refreshTrigger.value) { + val refreshTrigger = remember { mutableIntStateOf(0) } + LaunchedEffect(refreshTrigger.intValue) { while(true) { delay(1000) - refreshTrigger.value = refreshTrigger.value + 1 + refreshTrigger.intValue += 1 } } @@ -361,138 +317,39 @@ fun DebugScreen(navController: NavController) { Toast.makeText(context, "Packet copied to clipboard", Toast.LENGTH_SHORT).show() } - LaunchedEffect(packetLogs.size, refreshTrigger.value) { - if (shouldScrollToBottom.value && packetLogs.isNotEmpty()) { + LaunchedEffect(packetLogs.size, refreshTrigger.intValue) { + if (packetLogs.isNotEmpty()) { listState.animateScrollToItem(packetLogs.size - 1) } } - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { Text("Debug") }, - navigationIcon = { - TextButton( - onClick = { navController.popBackStack() }, - shape = RoundedCornerShape(8.dp), - ) { - val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) - Icon( - Icons.AutoMirrored.Filled.KeyboardArrowLeft, - contentDescription = "Back", - tint = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5), - modifier = Modifier.scale(1.5f) - ) - Text( - sharedPreferences.getString("name", "AirPods")!!, - style = TextStyle( - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - color = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - ) - } - }, - actions = { - Box { - IconButton(onClick = { showMenu.value = true }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = "More Options", - tint = if (isSystemInDarkTheme()) Color.White else Color.Black - ) - } - - DropdownMenu( - expanded = showMenu.value, - onDismissRequest = { showMenu.value = false }, - modifier = Modifier - .width(250.dp) - .background( - if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7) - ) - .padding(vertical = 4.dp) - ) { - DropdownMenuItem( - text = { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Text( - "Auto-scroll", - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Normal - ) - ) - Spacer(modifier = Modifier.weight(1f)) - IOSCheckbox( - checked = shouldScrollToBottom.value, - onCheckedChange = { shouldScrollToBottom.value = it } - ) - } - }, - onClick = { - shouldScrollToBottom.value = !shouldScrollToBottom.value - showMenu.value = false - } - ) - - HorizontalDivider( - color = if (isSystemInDarkTheme()) Color(0xFF3A3A3C) else Color(0xFFE5E5EA), - thickness = 0.5.dp - ) - - DropdownMenuItem( - text = { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Text( - "Clear logs", - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Normal - ) - ) - Spacer(modifier = Modifier.weight(1f)) - Icon( - imageVector = Icons.Default.Delete, - contentDescription = "Clear logs", - tint = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5) - ) - } - }, - onClick = { - ServiceManager.getService()?.clearLogs() - expandedItems.value = emptySet() - showMenu.value = false - } - ) - } - } - }, - modifier = Modifier.hazeEffect( - state = hazeState, - style = CupertinoMaterials.thick(), - block = fun HazeEffectScope.() { - alpha = if (scrollOffset > 0) 1f else 0f - }), - colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), - ) - }, - containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7), - ) { paddingValues -> + val isDarkTheme = isSystemInDarkTheme() + val backdrop = rememberLayerBackdrop() + StyledScaffold( + title = "Debug", + actionButtons = listOf( + {scaffoldBackdrop -> + StyledIconButton( + onClick = { + airPodsService?.clearLogs() + expandedItems.value = emptySet() + }, + icon = "􀈑", + darkMode = isDarkTheme, + backdrop = scaffoldBackdrop + ) + } + ), + ) { spacerHeight, hazeState -> Column( modifier = Modifier .fillMaxSize() .hazeSource(hazeState) - .padding(top = paddingValues.calculateTopPadding()) .navigationBarsPadding() + .layerBackdrop(backdrop) + .padding(horizontal = 16.dp) ) { + Spacer(modifier = Modifier.height(spacerHeight)) LazyColumn( state = listState, modifier = Modifier @@ -508,7 +365,7 @@ fun DebugScreen(navController: NavController) { Card( modifier = Modifier .fillMaxWidth() - .padding(vertical = 2.dp, horizontal = 4.dp) + .padding(vertical = 2.dp) .combinedClickable( onClick = { expandedItems.value = if (isExpanded) { @@ -527,67 +384,67 @@ fun DebugScreen(navController: NavController) { containerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7), ) ) { - Column(modifier = Modifier.padding(8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = if (isSent) Icons.AutoMirrored.Filled.KeyboardArrowLeft else Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = if (isSent) Color.Green else Color.Red, - modifier = Modifier.size(24.dp) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = if (isSent) "􀆉" else "􀆊", + style = TextStyle( + fontSize = 16.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = if (isSent) Color(0xFF4CD964) else Color(0xFFFF3B30) + ), + ) + Spacer(modifier = Modifier.width(4.dp)) + Column { + Text( + text = if (packetInfo.isUnknown) { + val shortenedData = packetInfo.rawData.take(60) + + (if (packetInfo.rawData.length > 60) "..." else "") + shortenedData + } else { + "${packetInfo.type}: ${packetInfo.description}" + }, + style = TextStyle( + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily(Font(R.font.hack)) + ) ) - Spacer(modifier = Modifier.width(4.dp)) - Column { + if (isExpanded) { + Spacer(modifier = Modifier.height(4.dp)) + + if (packetInfo.parsedData.isNotEmpty()) { + packetInfo.parsedData.forEach { (key, value) -> + Row { + Text( + text = "$key: ", + style = TextStyle( + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily(Font(R.font.hack)) + ), + color = Color.Gray + ) + Text( + text = value, + style = TextStyle( + fontSize = 12.sp, + fontFamily = FontFamily(Font(R.font.hack)) + ), + color = Color.Gray + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + } + Text( - text = if (packetInfo.isUnknown) { - val shortenedData = packetInfo.rawData.take(60) + - (if (packetInfo.rawData.length > 60) "..." else "") - shortenedData - } else { - "${packetInfo.type}: ${packetInfo.description}" - }, + text = "Raw: ${packetInfo.rawData}", style = TextStyle( fontSize = 12.sp, - fontWeight = FontWeight.Medium, fontFamily = FontFamily(Font(R.font.hack)) - ) + ), + color = Color.Gray ) - if (isExpanded) { - Spacer(modifier = Modifier.height(4.dp)) - - if (packetInfo.parsedData.isNotEmpty()) { - packetInfo.parsedData.forEach { (key, value) -> - Row { - Text( - text = "$key: ", - style = TextStyle( - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily(Font(R.font.hack)) - ), - color = Color.Gray - ) - Text( - text = value, - style = TextStyle( - fontSize = 12.sp, - fontFamily = FontFamily(Font(R.font.hack)) - ), - color = Color.Gray - ) - } - } - Spacer(modifier = Modifier.height(4.dp)) - } - - Text( - text = "Raw: ${packetInfo.rawData}", - style = TextStyle( - fontSize = 12.sp, - fontFamily = FontFamily(Font(R.font.hack)) - ), - color = Color.Gray - ) - } } } } @@ -626,7 +483,7 @@ fun DebugScreen(navController: NavController) { packet.value = TextFieldValue("") focusManager.clearFocus() - if (shouldScrollToBottom.value && packetLogs.isNotEmpty()) { + if (packetLogs.isNotEmpty()) { coroutineScope.launch { try { delay(100) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt index e7039dea4..6dcf5214f 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt @@ -41,25 +41,12 @@ 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.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -74,22 +61,16 @@ 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.drawBehind -import androidx.compose.ui.draw.scale 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.graphics.Path -import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.asAndroidPath import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -99,22 +80,22 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController -import dev.chrisbanes.haze.HazeEffectScope -import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.hazeEffect +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.materials.CupertinoMaterials import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.composables.IndependentToggle +import me.kavishdevar.librepods.composables.StyledButton +import me.kavishdevar.librepods.composables.StyledIconButton +import me.kavishdevar.librepods.composables.StyledScaffold +import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.utils.HeadTracking import kotlin.io.encoding.ExperimentalEncodingApi @@ -134,201 +115,124 @@ fun HeadTrackingScreen(navController: NavController) { ServiceManager.getService()?.stopHeadTracking() } } - val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) val isDarkTheme = isSystemInDarkTheme() - val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val textColor = if (isDarkTheme) Color.White else Color.Black val scrollState = rememberScrollState() - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - val hazeState = remember { HazeState() } - - var mDensity by remember { mutableFloatStateOf(0f) } - Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - CenterAlignedTopAppBar( - modifier = Modifier.hazeEffect( - state = hazeState, - style = CupertinoMaterials.thick(), - block = fun HazeEffectScope.() { - alpha = - if (scrollState.value > 60.dp.value * mDensity) 1f else 0f - }) - .drawBehind { - mDensity = density - val strokeWidth = 0.7.dp.value * density - val y = size.height - strokeWidth / 2 - if (scrollState.value > 60.dp.value * density) { - drawLine( - if (isDarkTheme) Color.DarkGray else Color.LightGray, - Offset(0f, y), - Offset(size.width, y), - strokeWidth - ) + val backdrop = rememberLayerBackdrop() + StyledScaffold( + title = stringResource(R.string.head_tracking), + actionButtons = listOf( + { scaffoldBackdrop -> + var isActive by remember { mutableStateOf(ServiceManager.getService()?.isHeadTrackingActive == true) } + StyledIconButton( + onClick = { + if (ServiceManager.getService()?.isHeadTrackingActive == false) { + ServiceManager.getService()?.startHeadTracking() + Log.d("HeadTrackingScreen", "Head tracking started") + } else { + ServiceManager.getService()?.stopHeadTracking() + Log.d("HeadTrackingScreen", "Head tracking stopped") } }, - title = { - Text( - stringResource(R.string.head_tracking), - fontFamily = FontFamily(Font(R.font.sf_pro)), - ) - }, - navigationIcon = { - TextButton( - onClick = { - navController.popBackStack() - if (ServiceManager.getService()?.isHeadTrackingActive == true) ServiceManager.getService()?.stopHeadTracking() - }, - shape = RoundedCornerShape(8.dp), - modifier = Modifier.width(180.dp) - ) { - Icon( - Icons.AutoMirrored.Filled.KeyboardArrowLeft, - contentDescription = "Back", - tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5), - modifier = Modifier.scale(1.5f) - ) - Text( - sharedPreferences.getString("name", "AirPods")!!, - style = TextStyle( - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - } - }, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = Color.Transparent - ), - actions = { - var isActive by remember { mutableStateOf(ServiceManager.getService()?.isHeadTrackingActive == true) } - IconButton( - onClick = { - if (ServiceManager.getService()?.isHeadTrackingActive == false) { - ServiceManager.getService()?.startHeadTracking() - Log.d("HeadTrackingScreen", "Head tracking started") - isActive = true - } else { - ServiceManager.getService()?.stopHeadTracking() - Log.d("HeadTrackingScreen", "Head tracking stopped") - isActive = false - } - }, - ) { - Icon( - if (isActive) { - ImageVector.Builder( - name = "Pause", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f - ).apply { - path( - fill = SolidColor(Color.Black), - pathBuilder = { - moveTo(6f, 5f) - lineTo(10f, 5f) - lineTo(10f, 19f) - lineTo(6f, 19f) - lineTo(6f, 5f) - moveTo(14f, 5f) - lineTo(18f, 5f) - lineTo(18f, 19f) - lineTo(14f, 19f) - lineTo(14f, 5f) - } - ) - }.build() - } else Icons.Filled.PlayArrow, - contentDescription = "Start", - tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5), - modifier = Modifier.scale(1.5f) - ) - } - }, - scrollBehavior = scrollBehavior - ) - }, - containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) - else Color(0xFFF2F2F7), - ) { paddingValues -> - Column ( + icon = if (isActive) "􀊅" else "􀊃", + darkMode = isDarkTheme, + backdrop = scaffoldBackdrop + ) + } + ), + ) { spacerHeight, hazeState -> + val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) + + var gestureText by remember { mutableStateOf("") } + val coroutineScope = rememberCoroutineScope() + + var lastClickTime by remember { mutableLongStateOf(0L) } + var shouldExplode by remember { mutableStateOf(false) } + Column( modifier = Modifier - .fillMaxSize() - .padding(paddingValues = paddingValues) - .padding(horizontal = 16.dp) - .padding(top = 8.dp) - .verticalScroll(scrollState) - .hazeSource(state = hazeState) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { - val sharedPreferences = - LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) - - var gestureText by remember { mutableStateOf("") } - val coroutineScope = rememberCoroutineScope() - - IndependentToggle(name = "Head Gestures", sharedPreferences = sharedPreferences) - Spacer(modifier = Modifier.height(2.dp)) - Text( - stringResource(R.string.head_gestures_details), - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Normal, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor.copy(0.6f) - ), - modifier = Modifier.padding(start = 4.dp) - ) + Column ( + modifier = Modifier + .fillMaxWidth() + .hazeSource(state = hazeState) + .layerBackdrop(backdrop) + .padding(top = 8.dp) + .padding(horizontal = 16.dp) + .verticalScroll(scrollState) + ) { + Spacer(modifier = Modifier.height(spacerHeight)) + StyledToggle( + label = "Head Gestures", + sharedPreferences = sharedPreferences, + sharedPreferenceKey = "head_gestures", + ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - "Head Orientation", - style = TextStyle( - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor - ), - modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp) - ) - HeadVisualization() - - Spacer(modifier = Modifier.height(16.dp)) - Text( - "Acceleration", - style = TextStyle( - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor - ), - modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp) - ) - AccelerationPlot() + Spacer(modifier = Modifier.height(2.dp)) + Text( + stringResource(R.string.head_gestures_details), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = textColor.copy(0.6f) + ), + modifier = Modifier.padding(start = 4.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + Text( + "Head Orientation", + style = TextStyle( + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = textColor + ), + modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp) + ) + HeadVisualization() + + Spacer(modifier = Modifier.height(16.dp)) + Text( + "Velocity", + style = TextStyle( + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = textColor + ), + modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp) + ) + AccelerationPlot() - Spacer(modifier = Modifier.height(16.dp)) - Button ( + Spacer(modifier = Modifier.height(16.dp)) + + LaunchedEffect(gestureText) { + if (gestureText.isNotEmpty()) { + lastClickTime = System.currentTimeMillis() + delay(3000) + if (System.currentTimeMillis() - lastClickTime >= 3000) { + shouldExplode = true + } + } + } + } + val gestureTextValue = stringResource(R.string.shake_your_head_or_nod) + StyledButton( onClick = { - gestureText = "Shake your head or nod!" + gestureText = gestureTextValue coroutineScope.launch { val accepted = ServiceManager.getService()?.testHeadGestures() ?: false gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected." } }, - modifier = Modifier - .fillMaxWidth() - .height(55.dp), - colors = ButtonDefaults.buttonColors( - containerColor = backgroundColor - ), - shape = RoundedCornerShape(8.dp) + backdrop = backdrop, + modifier = Modifier.fillMaxWidth(0.75f), + maxScale = 0.05f ) { Text( "Test Head Gestures", @@ -340,19 +244,6 @@ fun HeadTrackingScreen(navController: NavController) { ), ) } - var lastClickTime by remember { mutableLongStateOf(0L) } - var shouldExplode by remember { mutableStateOf(false) } - - LaunchedEffect(gestureText) { - if (gestureText.isNotEmpty()) { - lastClickTime = System.currentTimeMillis() - delay(3000) - if (System.currentTimeMillis() - lastClickTime >= 3000) { - shouldExplode = true - } - } - } - Box( contentAlignment = Alignment.Center, modifier = Modifier.padding(top = 12.dp, bottom = 24.dp) @@ -441,14 +332,13 @@ private fun ParticleText( if (particles.isEmpty()) { val random = Random(System.currentTimeMillis()) - for (i in 0..100) { + for (@Suppress("Unused")i in 0..100) { val x = centerX + random.nextFloat() * textBounds.width val y = centerY - textBounds.height / 2 + random.nextFloat() * textBounds.height val vx = (random.nextFloat() - 0.5f) * 20 val vy = (random.nextFloat() - 0.5f) * 20 particles.add(Particle(Offset(x, y), Offset(vx, vy))) } - textVisible = false } particles.forEach { particle -> @@ -518,14 +408,12 @@ private fun HeadVisualization() { fun rotate3D(point: Triple): Triple { val (x, y, z) = point val x1 = x * cosY - z * sinY - val y1 = y val z1 = x * sinY + z * cosY - val x2 = x1 - val y2 = y1 * cosP - z1 * sinP - val z2 = y1 * sinP + z1 * cosP + val y2 = y * cosP - z1 * sinP + val z2 = y * sinP + z1 * cosP - return Triple(x2, y2, z2) + return Triple(x1, y2, z2) } fun project(point: Triple): Pair { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt new file mode 100644 index 000000000..34cb87577 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt @@ -0,0 +1,341 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.screens + +import android.annotation.SuppressLint +import android.util.Log +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.StyledScaffold +import me.kavishdevar.librepods.composables.StyledSlider +import me.kavishdevar.librepods.composables.StyledToggle +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.AACPManager +import me.kavishdevar.librepods.utils.ATTHandles +import me.kavishdevar.librepods.utils.HearingAidSettings +import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse +import me.kavishdevar.librepods.utils.sendHearingAidSettings +import java.io.IOException +import kotlin.io.encoding.ExperimentalEncodingApi + +private var debounceJob: MutableState = mutableStateOf(null) +private const val TAG = "HearingAidAdjustments" + +@SuppressLint("DefaultLocale") +@ExperimentalHazeMaterialsApi +@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) +@Composable +fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) { + isSystemInDarkTheme() + val verticalScrollState = rememberScrollState() + val hazeState = remember { HazeState() } + val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available") + + val aacpManager = remember { ServiceManager.getService()?.aacpManager } + val backdrop = rememberLayerBackdrop() + StyledScaffold( + title = stringResource(R.string.adjustments) + ) { spacerHeight -> + Column( + modifier = Modifier + .hazeSource(hazeState) + .fillMaxSize() + .layerBackdrop(backdrop) + .verticalScroll(verticalScrollState) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(spacerHeight)) + + val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) } + val balanceSliderValue = remember { mutableFloatStateOf(0.5f) } + val toneSliderValue = remember { mutableFloatStateOf(0.5f) } + val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) } + val conversationBoostEnabled = remember { mutableStateOf(false) } + val eq = remember { mutableStateOf(FloatArray(8)) } + val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) } + + val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) } + val phoneEQEnabled = remember { mutableStateOf(false) } + val mediaEQEnabled = remember { mutableStateOf(false) } + + val initialLoadComplete = remember { mutableStateOf(false) } + + val initialReadSucceeded = remember { mutableStateOf(false) } + val initialReadAttempts = remember { mutableIntStateOf(0) } + + val hearingAidSettings = remember { + mutableStateOf( + HearingAidSettings( + leftEQ = eq.value, + rightEQ = eq.value, + leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2, + rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2, + leftTone = toneSliderValue.floatValue, + rightTone = toneSliderValue.floatValue, + leftConversationBoost = conversationBoostEnabled.value, + rightConversationBoost = conversationBoostEnabled.value, + leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, + rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, + netAmplification = amplificationSliderValue.floatValue, + balance = balanceSliderValue.floatValue, + ownVoiceAmplification = ownVoiceAmplification.floatValue + ) + ) + } + + val hearingAidEnabled = remember { + val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID } + val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG } + mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())) + } + + val hearingAidListener = remember { + object : AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value || + controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) { + val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID } + val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG } + hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()) + } + } + } + } + + val hearingAidATTListener = remember { + object : (ByteArray) -> Unit { + override fun invoke(value: ByteArray) { + val parsed = parseHearingAidSettingsResponse(value) + if (parsed != null) { + amplificationSliderValue.floatValue = parsed.netAmplification + balanceSliderValue.floatValue = parsed.balance + toneSliderValue.floatValue = parsed.leftTone + ambientNoiseReductionSliderValue.floatValue = parsed.leftAmbientNoiseReduction + conversationBoostEnabled.value = parsed.leftConversationBoost + eq.value = parsed.leftEQ.copyOf() + ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification + Log.d(TAG, "Updated hearing aid settings from notification") + } else { + Log.w(TAG, "Failed to parse hearing aid settings from notification") + } + } + } + } + + LaunchedEffect(Unit) { + aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener) + aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener) + } + + DisposableEffect(Unit) { + onDispose { + aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener) + aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener) + attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener) + } + } + + LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) { + if (!initialLoadComplete.value) { + Log.d(TAG, "Initial device load not complete - skipping send") + return@LaunchedEffect + } + + if (!initialReadSucceeded.value) { + Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds") + return@LaunchedEffect + } + + hearingAidSettings.value = HearingAidSettings( + leftEQ = eq.value, + rightEQ = eq.value, + leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f, + rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f, + leftTone = toneSliderValue.floatValue, + rightTone = toneSliderValue.floatValue, + leftConversationBoost = conversationBoostEnabled.value, + rightConversationBoost = conversationBoostEnabled.value, + leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, + rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, + netAmplification = amplificationSliderValue.floatValue, + balance = balanceSliderValue.floatValue, + ownVoiceAmplification = ownVoiceAmplification.floatValue + ) + Log.d(TAG, "Updated settings: ${hearingAidSettings.value}") + sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob) + } + + LaunchedEffect(Unit) { + Log.d(TAG, "Connecting to ATT...") + try { + attManager.enableNotifications(ATTHandles.HEARING_AID) + attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener) + + try { + if (aacpManager != null) { + Log.d(TAG, "Found AACPManager, reading cached EQ data") + val aacpEQ = aacpManager.eqData + if (aacpEQ.isNotEmpty()) { + eq.value = aacpEQ.copyOf() + phoneMediaEQ.value = aacpEQ.copyOf() + phoneEQEnabled.value = aacpManager.eqOnPhone + mediaEQEnabled.value = aacpManager.eqOnMedia + Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}") + } else { + Log.d(TAG, "AACPManager EQ data empty") + } + } else { + Log.d(TAG, "No AACPManager available") + } + } catch (e: Exception) { + Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}") + } + + var parsedSettings: HearingAidSettings? = null + for (attempt in 1..3) { + initialReadAttempts.intValue = attempt + try { + val data = attManager.read(ATTHandles.HEARING_AID) + parsedSettings = parseHearingAidSettingsResponse(data = data) + if (parsedSettings != null) { + Log.d(TAG, "Parsed settings on attempt $attempt") + break + } else { + Log.d(TAG, "Parsing returned null on attempt $attempt") + } + } catch (e: Exception) { + Log.w(TAG, "Read attempt $attempt failed: ${e.message}") + } + delay(200) + } + + if (parsedSettings != null) { + Log.d(TAG, "Initial hearing aid settings: $parsedSettings") + amplificationSliderValue.floatValue = parsedSettings.netAmplification + balanceSliderValue.floatValue = parsedSettings.balance + toneSliderValue.floatValue = parsedSettings.leftTone + ambientNoiseReductionSliderValue.floatValue = parsedSettings.leftAmbientNoiseReduction + conversationBoostEnabled.value = parsedSettings.leftConversationBoost + eq.value = parsedSettings.leftEQ.copyOf() + ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification + initialReadSucceeded.value = true + } else { + Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts") + } + } catch (e: IOException) { + e.printStackTrace() + } finally { + initialLoadComplete.value = true + } + } + + StyledSlider( + label = stringResource(R.string.amplification), + valueRange = -1f..1f, + mutableFloatState = amplificationSliderValue, + onValueChange = { + amplificationSliderValue.floatValue = it + }, + startIcon = "􀊥", + endIcon = "􀊩", + independent = true, + ) + + + StyledToggle( + label = stringResource(R.string.swipe_to_control_amplification), + controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, + description = stringResource(R.string.swipe_amplification_description) + ) + + StyledSlider( + label = stringResource(R.string.balance), + valueRange = -1f..1f, + mutableFloatState = balanceSliderValue, + onValueChange = { + balanceSliderValue.floatValue = it + }, + snapPoints = listOf(-1f, 0f, 1f), + startLabel = stringResource(R.string.left), + endLabel = stringResource(R.string.right), + independent = true, + ) + + StyledSlider( + label = stringResource(R.string.tone), + valueRange = -1f..1f, + mutableFloatState = toneSliderValue, + onValueChange = { + toneSliderValue.floatValue = it + }, + startLabel = stringResource(R.string.darker), + endLabel = stringResource(R.string.brighter), + independent = true, + ) + + StyledSlider( + label = stringResource(R.string.ambient_noise_reduction), + valueRange = 0f..1f, + mutableFloatState = ambientNoiseReductionSliderValue, + onValueChange = { + ambientNoiseReductionSliderValue.floatValue = it + }, + startLabel = stringResource(R.string.less), + endLabel = stringResource(R.string.more), + independent = true, + ) + + StyledToggle( + label = stringResource(R.string.conversation_boost), + checkedState = conversationBoostEnabled, + independent = true, + description = stringResource(R.string.conversation_boost_description) + ) + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt new file mode 100644 index 000000000..8e067c000 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt @@ -0,0 +1,294 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.screens + +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 +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.ConfirmationDialog +import me.kavishdevar.librepods.composables.NavigationButton +import me.kavishdevar.librepods.composables.StyledScaffold +import me.kavishdevar.librepods.composables.StyledToggle +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.AACPManager +import me.kavishdevar.librepods.utils.ATTHandles +import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse +import me.kavishdevar.librepods.utils.sendTransparencySettings +import kotlin.io.encoding.ExperimentalEncodingApi + +private const val TAG = "AccessibilitySettings" + +@SuppressLint("DefaultLocale") +@ExperimentalHazeMaterialsApi +@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) +@Composable +fun HearingAidScreen(navController: NavController) { + val isDarkTheme = isSystemInDarkTheme() + val textColor = if (isDarkTheme) Color.White else Color.Black + val verticalScrollState = rememberScrollState() + val snackbarHostState = remember { SnackbarHostState() } + val attManager = ServiceManager.getService()?.attManager ?: return + + val aacpManager = remember { ServiceManager.getService()?.aacpManager } + + val showDialog = remember { mutableStateOf(false) } + val backdrop = rememberLayerBackdrop() + val initialLoad = remember { mutableStateOf(true) } + + val hearingAidEnabled = remember { + val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID } + val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG } + mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())) + } + + val hazeStateS = remember { mutableStateOf(HazeState()) } // dont question this. i could possibly use something other than initializing it with an empty state and then replacing it with the the one provided by the scaffold + + StyledScaffold( + title = stringResource(R.string.hearing_aid), + snackbarHostState = snackbarHostState, + ) { spacerHeight, hazeState -> + Column( + modifier = Modifier + .layerBackdrop(backdrop) + .hazeSource(hazeState) + .fillMaxSize() + .verticalScroll(verticalScrollState) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + hazeStateS.value = hazeState + Spacer(modifier = Modifier.height(spacerHeight)) + + val hearingAidListener = remember { + object : AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value || + controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) { + val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID } + val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG } + hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()) + } + } + } + } + +// val mediaAssistEnabled = remember { mutableStateOf(false) } +// val adjustMediaEnabled = remember { mutableStateOf(false) } +// val adjustPhoneEnabled = remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener) + aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener) + } + + DisposableEffect(Unit) { + onDispose { + aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener) + aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener) + } + } + + LaunchedEffect(hearingAidEnabled.value) { + if (hearingAidEnabled.value && !initialLoad.value) { + showDialog.value = true + } else if (!hearingAidEnabled.value && !initialLoad.value) { + aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x02)) + aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x02.toByte()) + hearingAidEnabled.value = false + } + initialLoad.value = false + } + +// fun onAdjustPhoneChange(value: Boolean) { +// // TODO +// } + +// fun onAdjustMediaChange(value: Boolean) { +// // TODO +// } + + Text( + text = stringResource(R.string.hearing_aid), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier.padding(16.dp, bottom = 2.dp) + ) + + val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + + Column( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(28.dp)) + .clip( + RoundedCornerShape(28.dp) + ) + ) { + StyledToggle( + label = stringResource(R.string.hearing_aid), + checkedState = hearingAidEnabled, + independent = false + ) + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier + .padding(horizontal = 12.dp) + ) + NavigationButton( + to = "hearing_aid_adjustments", + name = stringResource(R.string.adjustments), + navController, + independent = false + ) + } + 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)) + ), + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + + NavigationButton( + to = "update_hearing_test", + name = stringResource(R.string.update_hearing_test), + navController, + independent = true + ) + + // not implemented yet + + // StyledToggle( + // title = stringResource(R.string.media_assist), + // label = stringResource(R.string.media_assist), + // checkedState = mediaAssistEnabled, + // independent = true, + // description = stringResource(R.string.media_assist_description) + // ) + + // Spacer(modifier = Modifier.height(8.dp)) + + // Column ( + // modifier = Modifier + // .fillMaxWidth() + // .background(backgroundColor, RoundedCornerShape(28.dp)) + // ) { + // StyledToggle( + // label = stringResource(R.string.adjust_media), + // checkedState = adjustMediaEnabled, + // onCheckedChange = { onAdjustMediaChange(it) }, + // independent = false + // ) + // HorizontalDivider( + // thickness = 1.dp, + // color = Color(0x40888888), + // modifier = Modifier + // .padding(horizontal = 12.dp) + // ) + + // StyledToggle( + // label = stringResource(R.string.adjust_calls), + // checkedState = adjustPhoneEnabled, + // onCheckedChange = { onAdjustPhoneChange(it) }, + // independent = false + // ) + // } + } + } + + ConfirmationDialog( + showDialog = showDialog, + title = "Enable Hearing Aid", + message = "Enabling Hearing Aid will disable Headphone Accommodation and Customized Transparency Mode.", + confirmText = "Enable", + dismissText = "Cancel", + onConfirm = { + showDialog.value = false + val enrolled = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(0) == 0x01.toByte() + if (!enrolled) { + aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01)) + } else { + aacpManager.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01)) + } + aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x01.toByte()) + hearingAidEnabled.value = true + CoroutineScope(Dispatchers.IO).launch { + try { + val data = attManager.read(ATTHandles.TRANSPARENCY) + val parsed = parseTransparencySettingsResponse(data) + val disabledSettings = parsed.copy(enabled = false) + sendTransparencySettings(attManager, disabledSettings) + } catch (e: Exception) { + Log.e(TAG, "Error disabling transparency: ${e.message}") + } + } + }, + hazeState = hazeStateS.value, + // backdrop = backdrop + ) +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingProtectionScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingProtectionScreen.kt new file mode 100644 index 000000000..432f38259 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingProtectionScreen.kt @@ -0,0 +1,90 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.screens + +import android.annotation.SuppressLint +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.Job +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.StyledScaffold +import me.kavishdevar.librepods.composables.StyledToggle +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.AACPManager +import me.kavishdevar.librepods.utils.ATTHandles +import kotlin.io.encoding.ExperimentalEncodingApi + +private var debounceJob: Job? = null + +@SuppressLint("DefaultLocale") +@ExperimentalHazeMaterialsApi +@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) +@Composable +fun HearingProtectionScreen(navController: NavController) { + val isDarkTheme = isSystemInDarkTheme() + val service = ServiceManager.getService() + if (service == null) return + + val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + val textColor = if (isDarkTheme) Color.White else Color.Black + + val backdrop = rememberLayerBackdrop() + + StyledScaffold( + title = stringResource(R.string.hearing_protection), + ) { spacerHeight -> + Column( + modifier = Modifier + .fillMaxSize() + .layerBackdrop(backdrop) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(spacerHeight)) + + StyledToggle( + title = stringResource(R.string.environmental_noise), + label = stringResource(R.string.loud_sound_reduction), + description = stringResource(R.string.loud_sound_reduction_description), + attHandle = ATTHandles.LOUD_SOUND_REDUCTION + ) + + Spacer(modifier = Modifier.height(12.dp)) + StyledToggle( + title = stringResource(R.string.workspace_use), + label = stringResource(R.string.ppe), + description = stringResource(R.string.workspace_use_description), + controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/Onboarding.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/Onboarding.kt index dc7a5402a..b8365ce97 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/Onboarding.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/Onboarding.kt @@ -39,25 +39,18 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -70,6 +63,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -78,13 +72,20 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.edit import androidx.navigation.NavController +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.StyledIconButton +import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.utils.RadareOffsetFinder +@ExperimentalHazeMaterialsApi @OptIn(ExperimentalMaterial3Api::class) @Composable fun Onboarding(navController: NavController, activityContext: Context) { @@ -103,7 +104,6 @@ fun Onboarding(navController: NavController, activityContext: Context) { var moduleEnabled by remember { mutableStateOf(false) } var bluetoothToggled by remember { mutableStateOf(false) } - var showMenu by remember { mutableStateOf(false) } var showSkipDialog by remember { mutableStateOf(false) } fun checkRootAccess() { @@ -113,7 +113,7 @@ fun Onboarding(navController: NavController, activityContext: Context) { withContext(Dispatchers.IO) { try { val process = Runtime.getRuntime().exec("su -c id") - val exitValue = process.waitFor() + val exitValue = process.waitFor() // no idea why i have this, probably don't need to do this withContext(Dispatchers.Main) { rootCheckPassed = (exitValue == 0) rootCheckFailed = (exitValue != 0) @@ -154,55 +154,31 @@ fun Onboarding(navController: NavController, activityContext: Context) { isComplete = true } } - - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { - Text( - "Setting Up", - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Medium - ) - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent - ), - actions = { - Box { - IconButton(onClick = { showMenu = true }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = "More Options" - ) - } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false } - ) { - DropdownMenuItem( - text = { Text("Skip Setup") }, - onClick = { - showMenu = false - showSkipDialog = true - } - ) - } - } - } - ) - }, - containerColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7) - ) { paddingValues -> + val backdrop = rememberLayerBackdrop() + StyledScaffold( + title = "Setting Up", + actionButtons = listOf( + {scaffoldBackdrop -> + StyledIconButton( + onClick = { + showSkipDialog = true + }, + icon = "􀊋", + darkMode = isDarkTheme, + backdrop = scaffoldBackdrop + ) + } + ) + ) { spacerHeight -> Column( modifier = Modifier .fillMaxSize() - .padding(paddingValues) - .padding(16.dp), + .layerBackdrop(backdrop) + .padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(spacerHeight)) Card( modifier = Modifier.fillMaxWidth(), @@ -226,7 +202,7 @@ fun Onboarding(navController: NavController, activityContext: Context) { Spacer(modifier = Modifier.height(24.dp)) Text( - text = "Root Access Required", + text = stringResource(R.string.root_access_required), style = TextStyle( fontSize = 22.sp, fontWeight = FontWeight.Bold, @@ -239,7 +215,7 @@ fun Onboarding(navController: NavController, activityContext: Context) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = "This app needs root access to hook onto the Bluetooth library", + text = stringResource(R.string.this_app_needs_root_access_to_hook_onto_the_bluetooth_library), style = TextStyle( fontSize = 16.sp, fontWeight = FontWeight.Normal, @@ -252,7 +228,7 @@ fun Onboarding(navController: NavController, activityContext: Context) { if (rootCheckFailed) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Root access was denied. Please grant root permissions.", + text = stringResource(R.string.root_access_denied), style = TextStyle( fontSize = 14.sp, fontWeight = FontWeight.Normal, @@ -299,7 +275,8 @@ fun Onboarding(navController: NavController, activityContext: Context) { Spacer(modifier = Modifier.height(24.dp)) AnimatedContent( - targetState = if (hasStarted) getStatusTitle(progressState, isComplete, moduleEnabled, bluetoothToggled) else "Setup Required", + targetState = if (hasStarted) getStatusTitle(progressState, + moduleEnabled, bluetoothToggled) else "Setup Required", transitionSpec = { fadeIn() togetherWith fadeOut() } ) { text -> Text( @@ -318,7 +295,7 @@ fun Onboarding(navController: NavController, activityContext: Context) { AnimatedContent( targetState = if (hasStarted) - getStatusDescription(progressState, isComplete, moduleEnabled, bluetoothToggled) + getStatusDescription(progressState, moduleEnabled, bluetoothToggled) else "AirPods functionality requires one-time setup for hooking into Bluetooth library", transitionSpec = { fadeIn() togetherWith fadeOut() } @@ -528,7 +505,7 @@ fun Onboarding(navController: NavController, activityContext: Context) { onClick = { showSkipDialog = false RadareOffsetFinder.clearHookOffsets() - sharedPreferences.edit().putBoolean("skip_setup", true).apply() + sharedPreferences.edit { putBoolean("skip_setup", true) } navController.navigate("settings") { popUpTo("onboarding") { inclusive = true } } @@ -607,7 +584,6 @@ private fun StatusIcon( private fun getStatusTitle( state: RadareOffsetFinder.ProgressState, - isComplete: Boolean, moduleEnabled: Boolean, bluetoothToggled: Boolean ): String { @@ -634,7 +610,6 @@ private fun getStatusTitle( private fun getStatusDescription( state: RadareOffsetFinder.ProgressState, - isComplete: Boolean, moduleEnabled: Boolean, bluetoothToggled: Boolean ): String { @@ -659,12 +634,10 @@ private fun getStatusDescription( } } +@ExperimentalHazeMaterialsApi @Preview @Composable fun OnboardingPreview() { Onboarding(navController = NavController(LocalContext.current), activityContext = LocalContext.current) } -private suspend fun delay(timeMillis: Long) { - kotlinx.coroutines.delay(timeMillis) -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/OpenSourceLicensesScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/OpenSourceLicensesScreen.kt new file mode 100644 index 000000000..34f255b1c --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/OpenSourceLicensesScreen.kt @@ -0,0 +1,93 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.screens + +import android.annotation.SuppressLint +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer +import com.mikepenz.aboutlibraries.ui.compose.produceLibraries +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.StyledIconButton +import me.kavishdevar.librepods.composables.StyledScaffold +import me.kavishdevar.librepods.composables.StyledSlider +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.AACPManager +import kotlin.io.encoding.ExperimentalEncodingApi + +private var debounceJob: Job? = null + +@SuppressLint("DefaultLocale") +@ExperimentalHazeMaterialsApi +@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) +@Composable +fun OpenSourceLicensesScreen(navController: NavController) { + val isDarkTheme = isSystemInDarkTheme() + val backdrop = rememberLayerBackdrop() + + StyledScaffold( + title = stringResource(R.string.open_source_licenses) + ) { spacerHeight -> + Column( + modifier = Modifier + .fillMaxSize() + .layerBackdrop(backdrop) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(spacerHeight)) + val context = androidx.compose.ui.platform.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() + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt index 4d2f2c8ad..c6b2e4091 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt @@ -23,6 +23,7 @@ package me.kavishdevar.librepods.screens import android.content.Context import android.util.Log import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures @@ -30,24 +31,17 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft -import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.Checkbox -import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -55,45 +49,54 @@ 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.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.edit import androidx.navigation.NavController +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.SelectItem +import me.kavishdevar.librepods.composables.StyledIconButton +import me.kavishdevar.librepods.composables.StyledScaffold +import me.kavishdevar.librepods.composables.StyledSelectList import me.kavishdevar.librepods.constants.StemAction import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.utils.AACPManager import kotlin.experimental.and import kotlin.io.encoding.ExperimentalEncodingApi -@Composable() +@Composable fun RightDivider() { HorizontalDivider( - thickness = 1.5.dp, + thickness = 1.dp, color = Color(0x40888888), modifier = Modifier - .padding(start = 72.dp) + .padding(start = 72.dp, end = 20.dp) ) } -@Composable() +@Composable fun RightDividerNoIcon() { HorizontalDivider( - thickness = 1.5.dp, + thickness = 1.dp, color = Color(0x40888888), modifier = Modifier - .padding(start = 20.dp) + .padding(start = 20.dp, end = 20.dp) ) } +@ExperimentalHazeMaterialsApi @OptIn(ExperimentalMaterial3Api::class) @Composable fun LongPress(navController: NavController, name: String) { @@ -107,146 +110,188 @@ fun LongPress(navController: NavController, name: String) { if (modesByte != null) { Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}") Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}") - Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x02) != 0.toByte()}") - Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x04) != 0.toByte()}") + Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}") + Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}") Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}") } val context = LocalContext.current val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) - val deviceName = sharedPreferences.getString("name", "AirPods Pro") val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action" val longPressActionPref = sharedPreferences.getString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name) Log.d("PressAndHoldSettingsScreen", "Long press action preference ($prefKey): $longPressActionPref") var longPressAction by remember { mutableStateOf(StemAction.valueOf(longPressActionPref ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) } - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { - Text( - name, - fontFamily = FontFamily(Font(R.font.sf_pro)), - ) - }, - navigationIcon = { - TextButton( - onClick = { - navController.popBackStack() - }, - shape = RoundedCornerShape(8.dp), - ) { - Icon( - Icons.AutoMirrored.Filled.KeyboardArrowLeft, - contentDescription = "Back", - tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5), - modifier = Modifier.scale(1.5f) - ) - Text( - deviceName?: "AirPods Pro", - style = TextStyle( - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent - ) - ) - }, - containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) - else Color(0xFFF2F2F7), - ) { paddingValues -> + val backdrop = rememberLayerBackdrop() + StyledScaffold( + title = name + ) { spacerHeight -> val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) Column ( modifier = Modifier + .layerBackdrop(backdrop) .fillMaxSize() - .padding(paddingValues = paddingValues) - .padding(horizontal = 16.dp) .padding(top = 8.dp) + .padding(horizontal = 16.dp) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(backgroundColor, RoundedCornerShape(14.dp)), - horizontalAlignment = Alignment.CenterHorizontally - ) { - LongPressActionElement( - name = "Noise Control", + Spacer(modifier = Modifier.height(spacerHeight)) + val actionItems = listOf( + SelectItem( + name = stringResource(R.string.noise_control), selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES, onClick = { longPressAction = StemAction.CYCLE_NOISE_CONTROL_MODES - sharedPreferences.edit().putString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name).apply() - }, - isFirst = true, - isLast = false - ) - RightDividerNoIcon() - LongPressActionElement( - name = "Digital Assistant", + sharedPreferences.edit { putString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name) } + } + ), + SelectItem( + name = stringResource(R.string.digital_assistant), selected = longPressAction == StemAction.DIGITAL_ASSISTANT, onClick = { longPressAction = StemAction.DIGITAL_ASSISTANT - sharedPreferences.edit().putString(prefKey, StemAction.DIGITAL_ASSISTANT.name).apply() - }, - isFirst = false, - isLast = true + sharedPreferences.edit { putString(prefKey, StemAction.DIGITAL_ASSISTANT.name) } + } ) - } + ) + StyledSelectList(items = actionItems) if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) { + Spacer(modifier = Modifier.height(32.dp)) Text( - text = "NOISE CONTROL", + text = stringResource(R.string.noise_control), style = TextStyle( fontSize = 14.sp, - fontWeight = FontWeight.Light, + fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.6f), ), fontFamily = FontFamily(Font(R.font.sf_pro)), modifier = Modifier - .padding(top = 32.dp, bottom = 4.dp) - .padding(horizontal = 8.dp) + .padding(horizontal = 18.dp) ) - Column( - modifier = Modifier - .fillMaxWidth() - .background(backgroundColor, RoundedCornerShape(14.dp)), - horizontalAlignment = Alignment.CenterHorizontally - ) { - val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find { - it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION - }?.value?.takeIf { it.isNotEmpty() }?.get(0) - val offListeningMode = offListeningModeValue == 1.toByte() - LongPressElement( - name = "Off", - enabled = offListeningMode, - resourceId = R.drawable.noise_cancellation, - isFirst = true) - if (offListeningMode) RightDivider() - LongPressElement( - name = "Transparency", - resourceId = R.drawable.transparency, - isFirst = !offListeningMode) - RightDivider() - LongPressElement( - name = "Adaptive", - resourceId = R.drawable.adaptive) - RightDivider() - LongPressElement( - name = "Noise Cancellation", - resourceId = R.drawable.noise_cancellation, - isLast = true) + Spacer(modifier = Modifier.height(8.dp)) + + val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find { + it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION + }?.value?.takeIf { it.isNotEmpty() }?.get(0) + Log.d("PressAndHoldSettingsScreen", "Allow Off state: $offListeningModeValue") + val allowOff = offListeningModeValue == 1.toByte() + Log.d("PressAndHoldSettingsScreen", "Allow Off option: $allowOff") + + val initialByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find { + it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS + }?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toInt() ?: sharedPreferences.getInt("long_press_byte", 0b0101) + var currentByte by remember { mutableStateOf(initialByte) } + + val listeningModeItems = mutableListOf() + if (allowOff) { + listeningModeItems.add( + SelectItem( + name = stringResource(R.string.off), + description = "Turns off noise management", + iconRes = R.drawable.noise_cancellation, + selected = (currentByte and 0x01) != 0, + onClick = { + val bit = 0x01 + val newValue = if ((currentByte and bit) != 0) { + val temp = currentByte and bit.inv() + if (countEnabledModes(temp) >= 2) temp else currentByte + } else { + currentByte or bit + } + ServiceManager.getService()!!.aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value, + newValue.toByte() + ) + sharedPreferences.edit { + putInt("long_press_byte", newValue) + } + currentByte = newValue + } + ) + ) } + listeningModeItems.addAll(listOf( + SelectItem( + name = stringResource(R.string.transparency), + description = "Lets in external sounds", + iconRes = R.drawable.transparency, + selected = (currentByte and 0x04) != 0, + onClick = { + val bit = 0x04 + val newValue = if ((currentByte and bit) != 0) { + val temp = currentByte and bit.inv() + if (countEnabledModes(temp) >= 2) temp else currentByte + } else { + currentByte or bit + } + ServiceManager.getService()!!.aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value, + newValue.toByte() + ) + sharedPreferences.edit { + putInt("long_press_byte", newValue) + } + currentByte = newValue + } + ), + SelectItem( + name = stringResource(R.string.adaptive), + description = "Dynamically adjust external noise", + iconRes = R.drawable.adaptive, + selected = (currentByte and 0x08) != 0, + onClick = { + val bit = 0x08 + val newValue = if ((currentByte and bit) != 0) { + val temp = currentByte and bit.inv() + if (countEnabledModes(temp) >= 2) temp else currentByte + } else { + currentByte or bit + } + ServiceManager.getService()!!.aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value, + newValue.toByte() + ) + sharedPreferences.edit { + putInt("long_press_byte", newValue) + } + currentByte = newValue + } + ), + SelectItem( + name = stringResource(R.string.noise_cancellation), + description = "Blocks out external sounds", + iconRes = R.drawable.noise_cancellation, + selected = (currentByte and 0x02) != 0, + onClick = { + val bit = 0x02 + val newValue = if ((currentByte and bit) != 0) { + val temp = currentByte and bit.inv() + if (countEnabledModes(temp) >= 2) temp else currentByte + } else { + currentByte or bit + } + ServiceManager.getService()!!.aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value, + newValue.toByte() + ) + sharedPreferences.edit { + putInt("long_press_byte", newValue) + } + currentByte = newValue + } + ) + )) + StyledSelectList(items = listeningModeItems) + Spacer(modifier = Modifier.height(8.dp)) Text( - "Press and hold the stem to cycle between the selected noise control modes.", - fontSize = 16.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor.copy(alpha = 0.6f), + text = stringResource(R.string.press_and_hold_noise_control_description), + style = TextStyle( + fontSize = 12.sp, + fontWeight = FontWeight.Light, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), modifier = Modifier - .padding(start = 16.dp, top = 4.dp) + .padding(horizontal = 18.dp) ) } } @@ -256,241 +301,11 @@ fun LongPress(navController: NavController, name: String) { }?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toString(2)}") } -@Composable -fun LongPressElement(name: String, enabled: Boolean = true, resourceId: Int, isFirst: Boolean = false, isLast: Boolean = false) { - val bit = when (name) { - "Off" -> 0x01 - "Transparency" -> 0x02 - "Noise Cancellation" -> 0x04 - "Adaptive" -> 0x08 - else -> -1 - } - val context = LocalContext.current - - val currentByteValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find { - it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS - }?.value?.takeIf { it.isNotEmpty() }?.get(0) - - val savedByte = context.getSharedPreferences("settings", Context.MODE_PRIVATE).getInt("long_press_byte", 0b0101.toInt()) - val byteValue = currentByteValue ?: (savedByte and 0xFF).toByte() - - val isChecked = (byteValue.toInt() and bit) != 0 - val checked = remember { mutableStateOf(isChecked) } - - Log.d("PressAndHoldSettingsScreen", "LongPressElement: $name, checked: ${checked.value}, byteValue: ${byteValue.toInt()}, in bits: ${byteValue.toInt().toString(2)}") - val darkMode = isSystemInDarkTheme() - val textColor = if (darkMode) Color.White else Color.Black - val desc = when (name) { - "Off" -> "Turns off noise management" - "Noise Cancellation" -> "Blocks out external sounds" - "Transparency" -> "Lets in external sounds" - "Adaptive" -> "Dynamically adjust external noise" - else -> "" - } - - 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++ - - Log.d("PressAndHoldSettingsScreen", "Byte: ${byteValue.toString(2)} Enabled modes: $count") - return count - } - - fun valueChanged(value: Boolean = !checked.value) { - val latestByteValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find { - it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS - }?.value?.takeIf { it.isNotEmpty() }?.get(0) - - val currentValue = (latestByteValue?.toInt() ?: byteValue.toInt()) and 0xFF - - Log.d("PressAndHoldSettingsScreen", "Current value: $currentValue (binary: ${Integer.toBinaryString(currentValue)}), bit: $bit, value: $value") - - if (!value) { - val newValue = currentValue and bit.inv() - - Log.d("PressAndHoldSettingsScreen", "Bit to disable: $bit, inverted: ${bit.inv()}, after AND: ${Integer.toBinaryString(newValue)}") - - val modeCount = countEnabledModes(newValue) - - Log.d("PressAndHoldSettingsScreen", "After disabling, enabled modes count: $modeCount") - - if (modeCount < 2) { - Log.d("PressAndHoldSettingsScreen", "Cannot disable $name mode - need at least 2 modes enabled") - return - } - - val updatedByte = newValue.toByte() - - Log.d("PressAndHoldSettingsScreen", "Sending updated byte: ${updatedByte.toInt() and 0xFF} (binary: ${Integer.toBinaryString(updatedByte.toInt() and 0xFF)})") - - ServiceManager.getService()!!.aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value, - updatedByte - ) - - context.getSharedPreferences("settings", Context.MODE_PRIVATE).edit() - .putInt("long_press_byte", newValue).apply() - - checked.value = false - Log.d("PressAndHoldSettingsScreen", "Updated: $name, enabled: false, byte: ${updatedByte.toInt() and 0xFF}, bits: ${Integer.toBinaryString(updatedByte.toInt() and 0xFF)}") - } else { - val newValue = currentValue or bit - val updatedByte = newValue.toByte() - - ServiceManager.getService()!!.aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value, - updatedByte - ) - - context.getSharedPreferences("settings", Context.MODE_PRIVATE).edit() - .putInt("long_press_byte", newValue).apply() - - checked.value = true - Log.d("PressAndHoldSettingsScreen", "Updated: $name, enabled: true, byte: ${updatedByte.toInt() and 0xFF}, bits: ${newValue.toString(2)}") - } - } - - val shape = when { - isFirst -> RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp) - isLast -> RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp) - else -> RoundedCornerShape(0.dp) - } - var backgroundColor by remember { mutableStateOf(if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } - val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) - if (!enabled) { - valueChanged(false) - } else { - Row( - modifier = Modifier - .height(72.dp) - .background(animatedBackgroundColor, shape) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9) - tryAwaitRelease() - backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - valueChanged() - }, - ) - } - .padding(horizontal = 16.dp, vertical = 0.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Icon( - painter = painterResource(resourceId), - contentDescription = "Icon", - tint = Color(0xFF007AFF), - modifier = Modifier - .height(48.dp) - .wrapContentWidth() - ) - Column ( - modifier = Modifier - .weight(1f) - .padding(vertical = 2.dp) - .padding(start = 8.dp) - ) - { - Text( - name, - fontSize = 16.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - ) - Text ( - desc, - fontSize = 14.sp, - color = textColor.copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)), - ) - } - Checkbox( - checked = checked.value, - onCheckedChange = { valueChanged() }, - colors = CheckboxDefaults.colors().copy( - checkedCheckmarkColor = Color(0xFF007AFF), - uncheckedCheckmarkColor = Color.Transparent, - checkedBoxColor = Color.Transparent, - uncheckedBoxColor = Color.Transparent, - checkedBorderColor = Color.Transparent, - uncheckedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent, - disabledCheckedBoxColor = Color.Transparent, - disabledUncheckedBoxColor = Color.Transparent, - disabledUncheckedBorderColor = Color.Transparent - ), - modifier = Modifier - .height(24.dp) - .scale(1.5f), - ) - } - } -} - -@Composable -fun LongPressActionElement( - name: String, - selected: Boolean, - onClick: () -> Unit, - isFirst: Boolean = false, - isLast: Boolean = false -) { - val darkMode = isSystemInDarkTheme() - val shape = when { - isFirst -> RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp) - isLast -> RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp) - else -> RoundedCornerShape(0.dp) - } - var backgroundColor by remember { mutableStateOf(if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } - val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) - Row( - modifier = Modifier - .height(48.dp) - .background(animatedBackgroundColor, shape) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9) - tryAwaitRelease() - backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - onClick() - } - ) - } - .padding(horizontal = 16.dp, vertical = 0.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - name, - fontSize = 16.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - modifier = Modifier - .weight(1f) - .padding(start = 4.dp) - ) - Checkbox( - checked = selected, - onCheckedChange = { onClick() }, - colors = CheckboxDefaults.colors().copy( - checkedCheckmarkColor = Color(0xFF007AFF), - uncheckedCheckmarkColor = Color.Transparent, - checkedBoxColor = Color.Transparent, - uncheckedBoxColor = Color.Transparent, - checkedBorderColor = Color.Transparent, - uncheckedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent, - disabledCheckedBoxColor = Color.Transparent, - disabledUncheckedBoxColor = Color.Transparent, - disabledUncheckedBorderColor = Color.Transparent - ), - modifier = Modifier - .height(24.dp) - .scale(1.5f), - ) - } +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/java/me/kavishdevar/librepods/screens/RenameScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt index 9601e9318..f58d09439 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt @@ -25,30 +25,22 @@ import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color @@ -60,18 +52,23 @@ import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.edit import androidx.navigation.NavController +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.StyledIconButton +import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.services.ServiceManager import kotlin.io.encoding.ExperimentalEncodingApi -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @Composable fun RenameScreen(navController: NavController) { val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) @@ -86,54 +83,18 @@ fun RenameScreen(navController: NavController) { name.value = name.value.copy(selection = TextRange(name.value.text.length)) } - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { - Text( - text = stringResource(R.string.name), - fontFamily = FontFamily(Font(R.font.sf_pro)), - ) - }, - navigationIcon = { - TextButton( - onClick = { - navController.popBackStack() - }, - shape = RoundedCornerShape(8.dp), - ) { - Icon( - Icons.AutoMirrored.Filled.KeyboardArrowLeft, - contentDescription = "Back", - tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5), - modifier = Modifier.scale(1.5f) - ) - Text( - text = name.value.text, - style = TextStyle( - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent - ) - ) - }, - containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) - else Color(0xFFF2F2F7), - ) { paddingValues -> - Column ( + val backdrop = rememberLayerBackdrop() + + StyledScaffold( + title = stringResource(R.string.name), + ) { spacerHeight -> + Column( modifier = Modifier .fillMaxSize() - .padding(paddingValues = paddingValues) + .layerBackdrop(backdrop) .padding(horizontal = 16.dp) - .padding(top = 8.dp) ) { + Spacer(modifier = Modifier.height(spacerHeight)) val isDarkTheme = isSystemInDarkTheme() val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val textColor = if (isDarkTheme) Color.White else Color.Black @@ -142,10 +103,10 @@ fun RenameScreen(navController: NavController) { verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .height(55.dp) + .height(58.dp) .background( backgroundColor, - RoundedCornerShape(14.dp) + RoundedCornerShape(28.dp) ) .padding(horizontal = 16.dp, vertical = 8.dp) ) { @@ -153,12 +114,13 @@ fun RenameScreen(navController: NavController) { value = name.value, onValueChange = { name.value = it - sharedPreferences.edit().putString("name", it.text).apply() + sharedPreferences.edit {putString("name", it.text)} ServiceManager.getService()?.setName(it.text) }, textStyle = TextStyle( - color = textColor, fontSize = 16.sp, + color = textColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) ), singleLine = true, cursorBrush = SolidColor(cursorColor), @@ -175,14 +137,15 @@ fun RenameScreen(navController: NavController) { IconButton( onClick = { name.value = TextFieldValue("") - sharedPreferences.edit().putString("name", "").apply() - ServiceManager.getService()?.setName("") } ) { - Icon( - Icons.Default.Clear, - contentDescription = "Clear", - tint = if (isDarkTheme) Color.White else Color.Black + 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) + ), ) } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt new file mode 100644 index 000000000..bc1d48e97 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt @@ -0,0 +1,448 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.screens + +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 +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +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.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.delay +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.StyledIconButton +import me.kavishdevar.librepods.composables.StyledScaffold +import me.kavishdevar.librepods.composables.StyledSlider +import me.kavishdevar.librepods.composables.StyledToggle +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.ATTHandles +import me.kavishdevar.librepods.utils.RadareOffsetFinder +import me.kavishdevar.librepods.utils.TransparencySettings +import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse +import me.kavishdevar.librepods.utils.sendTransparencySettings +import java.io.IOException +import kotlin.io.encoding.ExperimentalEncodingApi + +private const val TAG = "TransparencySettings" + +@SuppressLint("DefaultLocale") +@ExperimentalHazeMaterialsApi +@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) +@Composable +fun TransparencySettingsScreen(navController: NavController) { + val isDarkTheme = isSystemInDarkTheme() + val textColor = if (isDarkTheme) Color.White else Color.Black + val verticalScrollState = rememberScrollState() + val attManager = ServiceManager.getService()?.attManager ?: return + val aacpManager = remember { ServiceManager.getService()?.aacpManager } + val isSdpOffsetAvailable = + remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) } + + val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491) + val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) + val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) + + val backdrop = rememberLayerBackdrop() + + StyledScaffold( + title = stringResource(R.string.customize_transparency_mode) + ){ spacerHeight, hazeState -> + Column( + modifier = Modifier + .hazeSource(hazeState) + .layerBackdrop(backdrop) + .fillMaxSize() + .verticalScroll(verticalScrollState) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(spacerHeight)) + val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + + val enabled = remember { mutableStateOf(false) } + val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) } + val balanceSliderValue = remember { mutableFloatStateOf(0.5f) } + val toneSliderValue = remember { mutableFloatStateOf(0.5f) } + val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) } + val conversationBoostEnabled = remember { mutableStateOf(false) } + val eq = remember { mutableStateOf(FloatArray(8)) } + val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) } + + val initialLoadComplete = remember { mutableStateOf(false) } + + val initialReadSucceeded = remember { mutableStateOf(false) } + val initialReadAttempts = remember { mutableIntStateOf(0) } + + val transparencySettings = remember { + mutableStateOf( + TransparencySettings( + enabled = enabled.value, + leftEQ = eq.value, + rightEQ = eq.value, + leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2, + rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2, + leftTone = toneSliderValue.floatValue, + rightTone = toneSliderValue.floatValue, + leftConversationBoost = conversationBoostEnabled.value, + rightConversationBoost = conversationBoostEnabled.value, + leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, + rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, + netAmplification = amplificationSliderValue.floatValue, + balance = balanceSliderValue.floatValue + ) + ) + } + + val transparencyListener = remember { + object : (ByteArray) -> Unit { + override fun invoke(value: ByteArray) { + val parsed = parseTransparencySettingsResponse(value) + enabled.value = parsed.enabled + amplificationSliderValue.floatValue = parsed.netAmplification + balanceSliderValue.floatValue = parsed.balance + toneSliderValue.floatValue = parsed.leftTone + ambientNoiseReductionSliderValue.floatValue = + parsed.leftAmbientNoiseReduction + conversationBoostEnabled.value = parsed.leftConversationBoost + eq.value = parsed.leftEQ.copyOf() + Log.d(TAG, "Updated transparency settings from notification") + } + } + } + + LaunchedEffect( + enabled.value, + amplificationSliderValue.floatValue, + balanceSliderValue.floatValue, + toneSliderValue.floatValue, + conversationBoostEnabled.value, + ambientNoiseReductionSliderValue.floatValue, + eq.value, + initialLoadComplete.value, + initialReadSucceeded.value + ) { + if (!initialLoadComplete.value) { + Log.d(TAG, "Initial device load not complete - skipping send") + return@LaunchedEffect + } + + if (!initialReadSucceeded.value) { + Log.d( + TAG, + "Initial device read not successful yet - skipping send until read succeeds" + ) + return@LaunchedEffect + } + + transparencySettings.value = TransparencySettings( + enabled = enabled.value, + leftEQ = eq.value, + rightEQ = eq.value, + leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f, + rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f, + leftTone = toneSliderValue.floatValue, + rightTone = toneSliderValue.floatValue, + leftConversationBoost = conversationBoostEnabled.value, + rightConversationBoost = conversationBoostEnabled.value, + leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, + rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, + netAmplification = amplificationSliderValue.floatValue, + balance = balanceSliderValue.floatValue + ) + Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}") + sendTransparencySettings(attManager, transparencySettings.value) + } + + DisposableEffect(Unit) { + onDispose { + attManager.unregisterListener(ATTHandles.TRANSPARENCY, transparencyListener) + } + } + + LaunchedEffect(Unit) { + Log.d(TAG, "Connecting to ATT...") + try { + attManager.enableNotifications(ATTHandles.TRANSPARENCY) + attManager.registerListener(ATTHandles.TRANSPARENCY, transparencyListener) + + // If we have an AACP manager, prefer its EQ data to populate EQ controls first + try { + if (aacpManager != null) { + Log.d(TAG, "Found AACPManager, reading cached EQ data") + val aacpEQ = aacpManager.eqData + if (aacpEQ.isNotEmpty()) { + eq.value = aacpEQ.copyOf() + phoneMediaEQ.value = aacpEQ.copyOf() + Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}") + } else { + Log.d(TAG, "AACPManager EQ data empty") + } + } else { + Log.d(TAG, "No AACPManager available") + } + } catch (e: Exception) { + Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}") + } + + var parsedSettings: TransparencySettings? = null + for (attempt in 1..3) { + initialReadAttempts.intValue = attempt + try { + val data = attManager.read(ATTHandles.TRANSPARENCY) + parsedSettings = parseTransparencySettingsResponse(data = data) + Log.d(TAG, "Parsed settings on attempt $attempt") + } catch (e: Exception) { + Log.w(TAG, "Read attempt $attempt failed: ${e.message}") + } + delay(200) + } + + if (parsedSettings != null) { + Log.d(TAG, "Initial transparency settings: $parsedSettings") + enabled.value = parsedSettings.enabled + amplificationSliderValue.floatValue = parsedSettings.netAmplification + balanceSliderValue.floatValue = parsedSettings.balance + toneSliderValue.floatValue = parsedSettings.leftTone + ambientNoiseReductionSliderValue.floatValue = + parsedSettings.leftAmbientNoiseReduction + conversationBoostEnabled.value = parsedSettings.leftConversationBoost + eq.value = parsedSettings.leftEQ.copyOf() + initialReadSucceeded.value = true + } else { + Log.d( + TAG, + "Failed to read/parse initial transparency settings after ${initialReadAttempts.intValue} attempts" + ) + } + } catch (e: IOException) { + e.printStackTrace() + } finally { + initialLoadComplete.value = true + } + } + + // Only show transparency mode section if SDP offset is available + if (isSdpOffsetAvailable.value) { + StyledToggle( + label = stringResource(R.string.transparency_mode), + checkedState = enabled, + independent = true, + description = stringResource(R.string.customize_transparency_mode_description) + ) + Spacer(modifier = Modifier.height(4.dp)) + StyledSlider( + label = stringResource(R.string.amplification), + valueRange = -1f..1f, + mutableFloatState = amplificationSliderValue, + onValueChange = { + amplificationSliderValue.floatValue = it + }, + startIcon = "􀊥", + endIcon = "􀊩", + independent = true + ) + + StyledSlider( + label = stringResource(R.string.balance), + valueRange = -1f..1f, + mutableFloatState = balanceSliderValue, + onValueChange = { + balanceSliderValue.floatValue = it + }, + snapPoints = listOf(-1f, 0f, 1f), + startLabel = stringResource(R.string.left), + endLabel = stringResource(R.string.right), + independent = true, + ) + + StyledSlider( + label = stringResource(R.string.tone), + valueRange = -1f..1f, + mutableFloatState = toneSliderValue, + onValueChange = { + toneSliderValue.floatValue = it + }, + startLabel = stringResource(R.string.darker), + endLabel = stringResource(R.string.brighter), + independent = true, + ) + + StyledSlider( + label = stringResource(R.string.ambient_noise_reduction), + valueRange = 0f..1f, + mutableFloatState = ambientNoiseReductionSliderValue, + onValueChange = { + ambientNoiseReductionSliderValue.floatValue = it + }, + startLabel = stringResource(R.string.less), + endLabel = stringResource(R.string.more), + independent = true, + ) + + StyledToggle( + label = stringResource(R.string.conversation_boost), + checkedState = conversationBoostEnabled, + independent = true, + description = stringResource(R.string.conversation_boost_description) + ) + } + + // Only show transparency mode EQ section if SDP offset is available + if (isSdpOffsetAvailable.value) { + 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)) + ), + modifier = Modifier.padding(16.dp, bottom = 4.dp) + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(28.dp)) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween + ) { + for (i in 0 until 8) { + val eqValue = remember(eq.value[i]) { mutableFloatStateOf(eq.value[i]) } + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(38.dp) + ) { + Text( + text = String.format("%.2f", eqValue.floatValue), + fontSize = 12.sp, + color = textColor, + modifier = Modifier.padding(bottom = 4.dp) + ) + + Slider( + value = eqValue.floatValue, + onValueChange = { newVal -> + eqValue.floatValue = newVal + val newEQ = eq.value.copyOf() + newEQ[i] = eqValue.floatValue + eq.value = newEQ + }, + valueRange = 0f..100f, + modifier = Modifier + .fillMaxWidth(0.9f) + .height(36.dp), + colors = SliderDefaults.colors( + thumbColor = thumbColor, + activeTrackColor = activeTrackColor, + inactiveTrackColor = trackColor + ), + thumb = { + Box( + modifier = Modifier + .size(24.dp) + .shadow(4.dp, CircleShape) + .background(thumbColor, CircleShape) + ) + }, + track = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(12.dp), + contentAlignment = Alignment.CenterStart + ) + { + Box( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .background(trackColor, RoundedCornerShape(4.dp)) + ) + Box( + modifier = Modifier + .fillMaxWidth(eqValue.floatValue / 100f) + .height(4.dp) + .background( + activeTrackColor, + RoundedCornerShape(4.dp) + ) + ) + } + } + ) + + Text( + text = stringResource(R.string.band_label, i + 1), + fontSize = 12.sp, + color = textColor, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt index 747ed32f0..6b1dc92f6 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt @@ -1,5 +1,5 @@ /* - * LibrePods - AirPods liberated from Apple's ecosystem + * LibrePods - AirPods liberated from Apple’s ecosystem * * Copyright (C) 2025 LibrePods contributors * @@ -23,11 +23,8 @@ import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility -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.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically @@ -46,39 +43,27 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft -import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Share import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -87,14 +72,7 @@ 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.drawBehind -import androidx.compose.ui.draw.scale -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -102,23 +80,21 @@ 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.core.content.FileProvider import androidx.navigation.NavController -import dev.chrisbanes.haze.HazeEffectScope -import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.hazeEffect +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.materials.CupertinoMaterials import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.utils.LogCollector import java.io.File import java.text.SimpleDateFormat @@ -145,8 +121,6 @@ fun CustomIconButton( fun TroubleshootingScreen(navController: NavController) { val context = LocalContext.current val scrollState = rememberScrollState() - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - val hazeState = remember { HazeState() } val coroutineScope = rememberCoroutineScope() val logCollector = remember { LogCollector(context) } @@ -172,35 +146,13 @@ fun TroubleshootingScreen(navController: NavController) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) var showBottomSheet by remember { mutableStateOf(false) } - val sheetProgress by remember { - derivedStateOf { - if (!showBottomSheet) 0f else sheetState.targetValue.ordinal.toFloat() / 2f - } - } - - val contentScaleFactor by remember { - derivedStateOf { - 1.0f - (0.12f * sheetProgress) - } - } - - val contentScale by animateFloatAsState( - targetValue = contentScaleFactor, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium - ), - label = "contentScale" - ) - val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black val accentColor = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5) val buttonBgColor = if (isSystemInDarkTheme()) Color(0xFF333333) else Color(0xFFDDDDDD) var instructionText by remember { mutableStateOf("") } - var isDarkTheme = isSystemInDarkTheme() - var mDensity by remember { mutableFloatStateOf(0f) } + val isDarkTheme = isSystemInDarkTheme() LaunchedEffect(Unit) { withContext(Dispatchers.IO) { @@ -241,7 +193,7 @@ fun TroubleshootingScreen(navController: NavController) { LaunchedEffect(currentStep) { instructionText = when (currentStep) { 0 -> "First, let's ensure Xposed module is properly configured. Tap the button below to check Xposed scope settings." - 1 -> "Please put your AirPods in the case and close it, so they disconnect completely." + 1 -> "Please put your AirPods in the case and close it, so they disconnectForCD completely." 2 -> "Preparing to collect logs... Please wait." 3 -> "Now, open the AirPods case and connect your AirPods. Logs are being collected. Connection will be detected automatically, or you can manually stop logging when you're done." 4 -> "Log collection complete! You can now save or share the logs." @@ -257,88 +209,33 @@ fun TroubleshootingScreen(navController: NavController) { showBottomSheet = true } + val backdrop = rememberLayerBackdrop() + Box( modifier = Modifier.fillMaxSize() ) { - Scaffold( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - scaleX = contentScale - scaleY = contentScale - transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0.3f) - }, - topBar = { - CenterAlignedTopAppBar( - modifier = Modifier.hazeEffect( - state = hazeState, - style = CupertinoMaterials.thick(), - block = fun HazeEffectScope.() { - alpha = if (scrollState.value > 60.dp.value * mDensity) 1f else 0f - }) - .drawBehind { - mDensity = density - val strokeWidth = 0.7.dp.value * density - val y = size.height - strokeWidth / 2 - if (scrollState.value > 60.dp.value * density) { - drawLine( - if (isDarkTheme) Color.DarkGray else Color.LightGray, - Offset(0f, y), - Offset(size.width, y), - strokeWidth - ) - } - }, - title = { - Text( - text = stringResource(R.string.troubleshooting), - fontFamily = FontFamily(Font(R.font.sf_pro)), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - navigationIcon = { - TextButton( - onClick = { - navController.popBackStack() - }, - shape = RoundedCornerShape(8.dp), - ) { - Icon( - Icons.AutoMirrored.Filled.KeyboardArrowLeft, - contentDescription = "Back", - tint = accentColor, - modifier = Modifier.scale(1.5f) - ) - } - }, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = Color.Transparent - ), - scrollBehavior = scrollBehavior - ) - }, - containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7), - ) { paddingValues -> + StyledScaffold( + title = stringResource(R.string.troubleshooting) + ){ spacerHeight, hazeState -> Column( modifier = Modifier .fillMaxSize() - .padding(paddingValues) - .padding(horizontal = 16.dp) - .verticalScroll(scrollState) + .layerBackdrop(backdrop) .hazeSource(state = hazeState) + .verticalScroll(scrollState) + .padding(horizontal = 16.dp) ) { - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(spacerHeight)) Text( - text = stringResource(R.string.saved_logs).uppercase(), + text = stringResource(R.string.saved_logs), style = TextStyle( fontSize = 14.sp, - fontWeight = FontWeight.Light, + fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.6f), fontFamily = FontFamily(Font(R.font.sf_pro)) ), - modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 8.dp) + modifier = Modifier.padding(16.dp, bottom = 4.dp, top = 8.dp) ) Spacer(modifier = Modifier.height(2.dp)) @@ -349,7 +246,7 @@ fun TroubleshootingScreen(navController: NavController) { .fillMaxWidth() .background( backgroundColor, - RoundedCornerShape(14.dp) + RoundedCornerShape(28.dp) ) .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally @@ -366,7 +263,7 @@ fun TroubleshootingScreen(navController: NavController) { .fillMaxWidth() .background( backgroundColor, - RoundedCornerShape(14.dp) + RoundedCornerShape(28.dp) ) .padding(horizontal = 16.dp, vertical = 8.dp) ) { @@ -472,14 +369,14 @@ fun TroubleshootingScreen(navController: NavController) { Spacer(modifier = Modifier.height(16.dp)) Text( - text = "TROUBLESHOOTING STEPS".uppercase(), + text = stringResource(R.string.troubleshooting_steps), style = TextStyle( fontSize = 14.sp, fontWeight = FontWeight.Light, color = textColor.copy(alpha = 0.6f), fontFamily = FontFamily(Font(R.font.sf_pro)) ), - modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 8.dp) + modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 8.dp) ) Spacer(modifier = Modifier.height(2.dp)) @@ -489,7 +386,7 @@ fun TroubleshootingScreen(navController: NavController) { .fillMaxWidth() .background( backgroundColor, - RoundedCornerShape(14.dp) + RoundedCornerShape(28.dp) ) .padding(16.dp) ) { @@ -717,7 +614,9 @@ fun TroubleshootingScreen(navController: NavController) { Button( onClick = { selectedLogFile?.let { file -> - saveLauncher.launch("airpods_log_${System.currentTimeMillis()}.txt") + saveLauncher.launch( + file.absolutePath + ) } }, shape = RoundedCornerShape(10.dp), @@ -988,7 +887,7 @@ fun TroubleshootingScreen(navController: NavController) { Button( onClick = { selectedLogFile?.let { file -> - saveLauncher.launch("airpods_log_${System.currentTimeMillis()}.txt") + saveLauncher.launch(file.absolutePath) } }, shape = RoundedCornerShape(10.dp), diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/UpdateHearingTestScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/UpdateHearingTestScreen.kt new file mode 100644 index 000000000..9b1577128 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/UpdateHearingTestScreen.kt @@ -0,0 +1,359 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.screens + +import android.annotation.SuppressLint +import android.util.Log +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.StyledScaffold +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.AACPManager +import me.kavishdevar.librepods.utils.ATTHandles +import me.kavishdevar.librepods.utils.HearingAidSettings +import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse +import me.kavishdevar.librepods.utils.sendHearingAidSettings +import java.io.IOException +import kotlin.io.encoding.ExperimentalEncodingApi + +private var debounceJob: MutableState = mutableStateOf(null) +private const val TAG = "HearingAidAdjustments" + +@SuppressLint("DefaultLocale") +@ExperimentalHazeMaterialsApi +@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) +@Composable +fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) { + val verticalScrollState = rememberScrollState() + val attManager = ServiceManager.getService()?.attManager + if (attManager == null) { + Text( + text = stringResource(R.string.att_manager_is_null_try_reconnecting), + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + textAlign = TextAlign.Center + ) + return + } + + val aacpManager = remember { ServiceManager.getService()?.aacpManager } + val backdrop = rememberLayerBackdrop() + StyledScaffold( + title = stringResource(R.string.hearing_test) + ) { spacerHeight, hazeState -> + Column( + modifier = Modifier + .hazeSource(hazeState) + .fillMaxSize() + .layerBackdrop(backdrop) + .verticalScroll(verticalScrollState) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(spacerHeight)) + + Text( + text = stringResource(R.string.hearing_test_value_instruction), + fontSize = 16.sp, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + + val conversationBoostEnabled = remember { mutableStateOf(false) } + val leftEQ = remember { mutableStateOf(FloatArray(8)) } + val rightEQ = remember { mutableStateOf(FloatArray(8)) } + + val initialLoadComplete = remember { mutableStateOf(false) } + val initialReadSucceeded = remember { mutableStateOf(false) } + val initialReadAttempts = remember { mutableIntStateOf(0) } + + val hearingAidSettings = remember { + mutableStateOf( + HearingAidSettings( + leftEQ = leftEQ.value, + rightEQ = rightEQ.value, + leftAmplification = 0.5f, + rightAmplification = 0.5f, + leftTone = 0.5f, + rightTone = 0.5f, + leftConversationBoost = conversationBoostEnabled.value, + rightConversationBoost = conversationBoostEnabled.value, + leftAmbientNoiseReduction = 0.0f, + rightAmbientNoiseReduction = 0.0f, + netAmplification = 0.5f, + balance = 0.5f, + ownVoiceAmplification = 0.5f + ) + ) + } + + val hearingAidEnabled = remember { + val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID } + val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG } + mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())) + } + + val hearingAidListener = remember { + object : AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value || + controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) { + val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID } + val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG } + hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()) + } + } + } + } + + val hearingAidATTListener = remember { + object : (ByteArray) -> Unit { + override fun invoke(value: ByteArray) { + val parsed = parseHearingAidSettingsResponse(value) + if (parsed != null) { + leftEQ.value = parsed.leftEQ.copyOf() + rightEQ.value = parsed.rightEQ.copyOf() + conversationBoostEnabled.value = parsed.leftConversationBoost + Log.d(TAG, "Updated hearing aid settings from notification") + } else { + Log.w(TAG, "Failed to parse hearing aid settings from notification") + } + } + } + } + + LaunchedEffect(Unit) { + aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener) + aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener) + } + + DisposableEffect(Unit) { + onDispose { + aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener) + aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener) + attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener) + } + } + + LaunchedEffect(leftEQ.value, rightEQ.value, conversationBoostEnabled.value, initialLoadComplete.value, initialReadSucceeded.value) { + if (!initialLoadComplete.value) { + Log.d(TAG, "Initial device load not complete - skipping send") + return@LaunchedEffect + } + + if (!initialReadSucceeded.value) { + Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds") + return@LaunchedEffect + } + + hearingAidSettings.value = HearingAidSettings( + leftEQ = leftEQ.value, + rightEQ = rightEQ.value, + leftAmplification = 0.5f, + rightAmplification = 0.5f, + leftTone = 0.5f, + rightTone = 0.5f, + leftConversationBoost = conversationBoostEnabled.value, + rightConversationBoost = conversationBoostEnabled.value, + leftAmbientNoiseReduction = 0.0f, + rightAmbientNoiseReduction = 0.0f, + netAmplification = 0.5f, + balance = 0.5f, + ownVoiceAmplification = 0.5f + ) + Log.d(TAG, "Updated settings: ${hearingAidSettings.value}") + sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob) + } + + LaunchedEffect(Unit) { + Log.d(TAG, "Connecting to ATT...") + try { + attManager.enableNotifications(ATTHandles.HEARING_AID) + attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener) + + try { + if (aacpManager != null) { + Log.d(TAG, "Found AACPManager, reading cached EQ data") + val aacpEQ = aacpManager.eqData + if (aacpEQ.isNotEmpty()) { + leftEQ.value = aacpEQ.copyOf() + rightEQ.value = aacpEQ.copyOf() + Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}") + } else { + Log.d(TAG, "AACPManager EQ data empty") + } + } else { + Log.d(TAG, "No AACPManager available") + } + } catch (e: Exception) { + Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}") + } + + var parsedSettings: HearingAidSettings? = null + for (attempt in 1..3) { + initialReadAttempts.intValue = attempt + try { + val data = attManager.read(ATTHandles.HEARING_AID) + parsedSettings = parseHearingAidSettingsResponse(data = data) + if (parsedSettings != null) { + Log.d(TAG, "Parsed settings on attempt $attempt") + break + } else { + Log.d(TAG, "Parsing returned null on attempt $attempt") + } + } catch (e: Exception) { + Log.w(TAG, "Read attempt $attempt failed: ${e.message}") + } + delay(200) + } + + if (parsedSettings != null) { + Log.d(TAG, "Initial hearing aid settings: $parsedSettings") + leftEQ.value = parsedSettings.leftEQ.copyOf() + rightEQ.value = parsedSettings.rightEQ.copyOf() + conversationBoostEnabled.value = parsedSettings.leftConversationBoost + initialReadSucceeded.value = true + } else { + Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts") + } + } catch (e: IOException) { + e.printStackTrace() + } finally { + initialLoadComplete.value = true + } + } + + val frequencies = listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz") + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Spacer(modifier = Modifier.width(60.dp)) + Text( + text = stringResource(R.string.left), + fontSize = 18.sp, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + Text( + text = stringResource(R.string.right), + fontSize = 18.sp, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + } + + frequencies.forEachIndexed { index, freq -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = freq, + modifier = Modifier + .width(60.dp) + .align(Alignment.CenterVertically), + textAlign = TextAlign.End, + fontSize = 16.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + ) + OutlinedTextField( + value = leftEQ.value[index].toString(), + onValueChange = { newValue -> + val parsed = newValue.toFloatOrNull() + if (parsed != null) { + val newArray = leftEQ.value.copyOf() + newArray[index] = parsed + leftEQ.value = newArray + } + }, +// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + textStyle = TextStyle( + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontSize = 14.sp + ), + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = rightEQ.value[index].toString(), + onValueChange = { newValue -> + val parsed = newValue.toFloatOrNull() + if (parsed != null) { + val newArray = rightEQ.value.copyOf() + newArray[index] = parsed + rightEQ.value = newArray + } + }, +// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + textStyle = TextStyle( + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontSize = 14.sp + ), + modifier = Modifier.weight(1f) + ) + } + } + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/VersionInfoScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/VersionInfoScreen.kt new file mode 100644 index 000000000..73f7fa6cd --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/VersionInfoScreen.kt @@ -0,0 +1,192 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.screens + +import androidx.compose.foundation.background +import android.annotation.SuppressLint +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.Job +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.StyledScaffold +import me.kavishdevar.librepods.services.ServiceManager +import kotlin.io.encoding.ExperimentalEncodingApi + +private var debounceJob: Job? = null + +@SuppressLint("DefaultLocale") +@ExperimentalHazeMaterialsApi +@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) +@Composable +fun VersionScreen(navController: NavController) { + val isDarkTheme = isSystemInDarkTheme() + val service = ServiceManager.getService() + if (service == null) return + val airpodsInstance = service.airpodsInstance + if (airpodsInstance == null) return + + val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + val textColor = if (isDarkTheme) Color.White else Color.Black + + val backdrop = rememberLayerBackdrop() + + StyledScaffold( + title = stringResource(R.string.customize_adaptive_audio) + ) { spacerHeight -> + Column( + modifier = Modifier + .fillMaxSize() + .layerBackdrop(backdrop) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(spacerHeight)) + Box( + modifier = Modifier + .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) + .padding(horizontal = 16.dp, vertical = 4.dp) + ){ + Text( + text = stringResource(R.string.version), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = textColor.copy(alpha = 0.6f) + ) + ) + } + + Column( + modifier = Modifier + .clip(RoundedCornerShape(28.dp)) + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(28.dp)) + .padding(top = 2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.version) + " 1", + style = TextStyle( + fontSize = 16.sp, + color = textColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = airpodsInstance.version1 ?: "N/A", + style = TextStyle( + fontSize = 16.sp, + color = textColor.copy(0.8f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier + .padding(horizontal = 12.dp) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.version) + " 2", + style = TextStyle( + fontSize = 16.sp, + color = textColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = airpodsInstance.version2 ?: "N/A", + style = TextStyle( + fontSize = 16.sp, + color = textColor.copy(0.8f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier + .padding(horizontal = 12.dp) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.version) + " 3", + style = TextStyle( + fontSize = 16.sp, + color = textColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = airpodsInstance.version3 ?: "N/A", + style = TextStyle( + fontSize = 16.sp, + color = textColor.copy(0.8f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + } + } + } +} \ No newline at end of file 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 index f8cb4087d..d8d16d3b5 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -17,6 +17,7 @@ */ @file:OptIn(ExperimentalEncodingApi::class) +@file:Suppress("DEPRECATION") package me.kavishdevar.librepods.services @@ -29,6 +30,7 @@ import android.app.PendingIntent import android.app.Service import android.appwidget.AppWidgetManager import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothHeadset import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothProfile import android.bluetooth.BluetoothSocket @@ -41,6 +43,7 @@ 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 @@ -85,6 +88,9 @@ import me.kavishdevar.librepods.constants.StemAction import me.kavishdevar.librepods.constants.isHeadTrackingData import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType +import me.kavishdevar.librepods.utils.ATTManager +import me.kavishdevar.librepods.utils.AirPodsInstance +import me.kavishdevar.librepods.utils.AirPodsModels import me.kavishdevar.librepods.utils.BLEManager import me.kavishdevar.librepods.utils.BluetoothConnectionManager import me.kavishdevar.librepods.utils.CrossDevice @@ -122,6 +128,8 @@ import java.nio.ByteOrder import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi +private const val TAG = "AirPodsService" + object ServiceManager { @ExperimentalEncodingApi private var service: AirPodsService? = null @@ -143,8 +151,13 @@ object ServiceManager { @ExperimentalEncodingApi class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeListener { var macAddress = "" + var localMac = "" lateinit var aacpManager: AACPManager + var attManager: ATTManager? = null + 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, @@ -179,6 +192,21 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList var leftLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!, var rightLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!, + + var cameraAction: AACPManager.Companion.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 = "", ) private lateinit var config: ServiceConfig @@ -201,6 +229,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private var handleIncomingCallOnceConnected = false lateinit var bleManager: BLEManager + + private lateinit var socket: BluetoothSocket + private val bleStatusListener = object : BLEManager.AirPodsStatusListener { @SuppressLint("NewApi") override fun onDeviceStatusChanged( @@ -213,17 +244,17 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList sharedPreferences.edit { putString("mac_address", macAddress) } - Log.d("AirPodsBLEService", "BLE-only mode: stored MAC address ${device.address}") + Log.d(TAG, "BLE-only mode: stored MAC address ${device.address}") } if (device.connectionState == "Disconnected" && !config.bleOnlyMode) { - Log.d("AirPodsBLEService", "Seems no device has taken over, we will.") + Log.d(TAG, "Seems no device has taken over, we will.") val bluetoothManager = getSystemService(BluetoothManager::class.java) val bluetoothDevice = bluetoothManager.adapter.getRemoteDevice(sharedPreferences.getString( "mac_address", "") ?: "") connectToSocket(bluetoothDevice) } - Log.d("AirPodsBLEService", "Device status changed") + Log.d(TAG, "Device status changed") if (isConnectedLocally) return val leftLevel = bleManager.getMostRecentStatus()?.leftBattery?: 0 val rightLevel = bleManager.getMostRecentStatus()?.rightBattery?: 0 @@ -244,14 +275,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } override fun onBroadcastFromNewAddress(device: BLEManager.AirPodsStatus) { - Log.d("AirPodsService", "New address detected") + Log.d(TAG, "New address detected") } override fun onLidStateChanged( lidOpen: Boolean, ) { if (lidOpen) { - Log.d("AirPodsBLEService", "Lid opened") + Log.d(TAG, "Lid opened") showPopup( this@AirPodsService, getSharedPreferences("settings", MODE_PRIVATE).getString("name", "AirPods Pro") ?: "AirPods" @@ -274,7 +305,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ) sendBatteryBroadcast() } else { - Log.d("AirPodsBLEService", "Lid closed") + Log.d(TAG, "Lid closed") } } @@ -283,11 +314,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList leftInEar: Boolean, rightInEar: Boolean ) { - Log.d("AirPodsBLEService", "Ear state changed - Left: $leftInEar, Right: $rightInEar") + 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("AirPodsBLEService", "BLE-only mode: ear detection from BLE data") + Log.d(TAG, "BLE-only mode: ear detection from BLE data") } } @@ -309,12 +340,21 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList caseCharging = caseCharging == true ) updateBattery() - Log.d("AirPodsBLEService", "Battery changed") + Log.d(TAG, "Battery changed") } + override fun onDeviceDisappeared() { + Log.d(TAG, "All disappeared") + updateNotificationContent( + false + ) + } } + + @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") override fun onCreate() { super.onCreate() + sharedPreferencesLogs = getSharedPreferences("packet_logs", MODE_PRIVATE) inMemoryLogs.addAll(sharedPreferencesLogs.getStringSet(packetLogKey, emptySet()) ?: emptySet()) @@ -327,483 +367,1022 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList initializeAACPManagerCallback() sharedPreferences.registerOnSharedPreferenceChangeListener(this) - } - fun cameraOpened() { - Log.d("AirPodsService", "Camera opened, gonna handle stem presses and take action if enabled") - val isCameraShutterUsed = listOf( - config.leftSinglePressAction, - config.rightSinglePressAction, - config.leftDoublePressAction, - config.rightDoublePressAction, - config.leftTriplePressAction, - config.rightTriplePressAction, - config.leftLongPressAction, - config.rightLongPressAction - ).any { it == StemAction.CAMERA_SHUTTER } - - if (isCameraShutterUsed) { - Log.d("AirPodsService", "Camera opened, setting up stem actions") - cameraActive = true - setupStemActions(isCameraActive = true) - } - } - - fun cameraClosed() { - cameraActive = false - setupStemActions() - } + val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "settings", "get", "secure", "bluetooth_address")) + val output = process.inputStream.bufferedReader().use { it.readLine() } + localMac = output.trim() - fun isCustomAction( - action: StemAction?, - default: StemAction?, - isCameraActive: Boolean = false - ): Boolean { - Log.d("AirPodsService", "Checking if action $action is custom against default $default, camera active: $isCameraActive") - return action != default && (action != StemAction.CAMERA_SHUTTER || isCameraActive) - } + 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") + } - fun setupStemActions(isCameraActive: Boolean = false) { - 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] + bleManager = BLEManager(this) + bleManager.setAirPodsStatusListener(bleStatusListener) - val singlePressCustomized = isCustomAction(config.leftSinglePressAction, singlePressDefault, isCameraActive) || - isCustomAction(config.rightSinglePressAction, singlePressDefault, isCameraActive) - val doublePressCustomized = isCustomAction(config.leftDoublePressAction, doublePressDefault, isCameraActive) || - isCustomAction(config.rightDoublePressAction, doublePressDefault, isCameraActive) - val triplePressCustomized = isCustomAction(config.leftTriplePressAction, triplePressDefault, isCameraActive) || - isCustomAction(config.rightTriplePressAction, triplePressDefault, isCameraActive) - val longPressCustomized = isCustomAction(config.leftLongPressAction, longPressDefault, isCameraActive) || - isCustomAction(config.rightLongPressAction, longPressDefault, isCameraActive) - Log.d("AirPodsService", "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, - ) - } + sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) - @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())) - }) - updateBattery() - updateNotificationContent( - true, - this@AirPodsService.getSharedPreferences("settings", MODE_PRIVATE) - .getString("name", device?.name), - batteryNotification.getBattery() + 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 ) - 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) - } - } + // AirPods state-based takeover + if (!contains("takeover_when_disconnected")) putBoolean( + "takeover_when_disconnected", + true + ) + if (!contains("takeover_when_idle")) putBoolean("takeover_when_idle", true) + if (!contains("takeover_when_music")) putBoolean("takeover_when_music", false) + if (!contains("takeover_when_call")) putBoolean("takeover_when_call", true) + + // Phone state-based takeover + if (!contains("takeover_when_ringing_call")) putBoolean( + "takeover_when_ringing_call", + true + ) + if (!contains("takeover_when_media_start")) putBoolean( + "takeover_when_media_start", + true + ) - 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) - }) - Log.d( - "AirPodsParser", - "Ear Detection: ${earDetectionNotification.status[0]} ${earDetectionNotification.status[1]}" + 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 ) - processEarDetectionChange(earDetection) - } - override fun onConversationAwarenessReceived(conversationAwareness: ByteArray) { - conversationAwarenessNotification.setData(conversationAwareness) - sendBroadcast(Intent(AirPodsNotifications.CA_DATA).apply { - putExtra("data", conversationAwarenessNotification.status) - }) + if (!contains("textColor")) putLong("textColor", -1L) - if (conversationAwarenessNotification.status == 1.toByte() || conversationAwarenessNotification.status == 2.toByte()) { - MediaController.startSpeaking() - } else if (conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) { - MediaController.stopSpeaking() - } + if (!contains("qs_click_behavior")) putString("qs_click_behavior", "cycle") + if (!contains("name")) putString("name", "AirPods") - Log.d( - "AirPodsParser", - "Conversation Awareness: ${conversationAwarenessNotification.status}" + 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") - 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 onDeviceMetadataReceived(deviceMetadata: ByteArray) { + initializeConfig() - } + ancModeReceiver = 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 allowOffModeValue = aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION } + val allowOffMode = allowOffModeValue?.value?.takeIf { it.isNotEmpty() }?.get(0) == 0x01.toByte() - @SuppressLint("NewApi") - override fun onHeadTrackingReceived(headTracking: ByteArray) { - if (isHeadTrackingActive) { - HeadTracking.processPacket(headTracking) - processHeadTrackingData(headTracking) - } - } + val nextMode = if (allowOffMode) { + when (currentMode) { + 1 -> 2 + 2 -> 3 + 3 -> 4 + 4 -> 1 + else -> 1 + } + } else { + when (currentMode) { + 1 -> 2 + 2 -> 3 + 3 -> 4 + 4 -> 2 + else -> 2 + } + } - 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)) + aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, + nextMode + ) + Log.d(TAG, "Cycling ANC mode from $currentMode to $nextMode (offListeningMode: $allowOffMode)") } } } + } - override fun onStemPressReceived(stemPress: ByteArray) { - val (stemPressType, bud) = aacpManager.parseStemPressResponse(stemPress) - - Log.d("AirPodsParser", "Stem press received: $stemPressType on $bud") - - val action = getActionFor(bud, stemPressType) - Log.d("AirPodsParser", "$bud $stemPressType action: $action") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(ancModeReceiver, ancModeFilter, RECEIVER_EXPORTED) + } else { + @Suppress("UnspecifiedRegisterReceiverFlag") + registerReceiver(ancModeReceiver, ancModeFilter) + } + 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") + } - action?.let { executeStemAction(it) } - } + sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) + macAddress = sharedPreferences.getString("mac_address", "") ?: "" - override fun onUnknownPacketReceived(packet: ByteArray) { - Log.d("AACPManager", "Unknown packet received: ${packet.joinToString(" ") { "%02X".format(it) }}") + telephonyManager = getSystemService(TELEPHONY_SERVICE) as TelephonyManager + phoneStateListener = object : PhoneStateListener() { + @SuppressLint("SwitchIntDef", "NewApi") + override fun onCallStateChanged(state: Int, phoneNumber: String?) { + super.onCallStateChanged(state, phoneNumber) + 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 { + takeOver("call") + } + if (config.headGestures) { + callNumber = phoneNumber + 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( + Dispatchers.IO).launch { + takeOver("call") + } + isInCall = true + } + TelephonyManager.CALL_STATE_IDLE -> { + isInCall = false + callNumber = null + gestureDetector?.stopDetection() + } + } } - }) - } - - 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 } - } + telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE) - 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.CAMERA_SHUTTER -> Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27")) - 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")) + 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) } } - } - - private fun processEarDetectionChange(earDetection: ByteArray) { - var inEar = false - var 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() - ) + 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") + } - 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)) - } + 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 (newInEarData == listOf(false, false) && islandWindow?.isVisible == true) { - islandWindow?.close() - } + if (config.deviceName == "AirPods" && device?.name != null) { + config.deviceName = device?.name ?: "AirPods" + sharedPreferences.edit { putString("name", config.deviceName) } + } - 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) + Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString()) + if (!CrossDevice.isAvailable) { + Log.d(TAG, "${config.deviceName} connected") + CoroutineScope(Dispatchers.IO).launch { + connectToSocket(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) + attManager?.disconnect() + attManager = null } } - - 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()) { - inEarData = newInEarData - if (inEar == true) { - if (!justEnabledA2dp) { - justEnabledA2dp = false - MediaController.sendPlay() - MediaController.iPausedTheMedia = false + } + 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() } - } 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) { + val showIslandIntentFilter = IntentFilter().apply { + addAction("me.kavishdevar.librepods.cross_device_island") + addAction(AirPodsNotifications.DISCONNECT_RECEIVERS) + } - Log.d("MediaController", "A2DP connected, sending play command") - MediaController.sendPlay() - MediaController.iPausedTheMedia = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(showIslandReceiver, showIslandIntentFilter, RECEIVER_EXPORTED) + } else { + @Suppress("UnspecifiedRegisterReceiverFlag") + registerReceiver(showIslandReceiver, showIslandIntentFilter) + } - context.unregisterReceiver(this) - } - } - } + val deviceIntentFilter = IntentFilter().apply { + addAction(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) + addAction(AirPodsNotifications.AIRPODS_DISCONNECTED) } - 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) + registerReceiver(connectionReceiver, deviceIntentFilter, RECEIVER_EXPORTED) + registerReceiver(bluetoothReceiver, serviceIntentFilter, RECEIVER_EXPORTED) } else { - registerReceiver(a2dpConnectionStateReceiver, a2dpIntentFilter) + @Suppress("UnspecifiedRegisterReceiverFlag") + registerReceiver(connectionReceiver, deviceIntentFilter) + registerReceiver(bluetoothReceiver, serviceIntentFilter) } - } - 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), - textColor = sharedPreferences.getLong("textColor", -1L), - qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle", - bleOnlyMode = sharedPreferences.getBoolean("ble_only_mode", false), + val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter - // AirPods state-based takeover - takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", true), - takeoverWhenIdle = sharedPreferences.getBoolean("takeover_when_idle", true), - takeoverWhenMusic = sharedPreferences.getBoolean("takeover_when_music", false), - takeoverWhenCall = sharedPreferences.getBoolean("takeover_when_call", true), + 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(device) + } + setMetadatas(device) + macAddress = device.address + sharedPreferences.edit { + putString("mac_address", macAddress) + } + } + this@AirPodsService.sendBroadcast( + Intent(AirPodsNotifications.AIRPODS_CONNECTED) + ) + } + } + bluetoothAdapter.closeProfileProxy(profile, proxy) + } - // Phone state-based takeover - takeoverWhenRingingCall = sharedPreferences.getBoolean("takeover_when_ringing_call", true), - takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", true), + override fun onServiceDisconnected(profile: Int) {} + }, + BluetoothProfile.A2DP + ) + } + } + } - // 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")!!, + if (!isConnectedLocally && !CrossDevice.isAvailable) { + clearPacketLogs() + } - 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")!!, + CoroutineScope(Dispatchers.IO).launch { + bleManager.startScanning() + } + } - 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")!!, + @Suppress("unused") + fun cameraOpened() { + Log.d(TAG, "Camera opened, gonna handle stem presses and take action if enabled") + cameraActive = true + setupStemActions() + } - 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")!! - ) + @Suppress("unused") + fun cameraClosed() { + cameraActive = false + setupStemActions() } - override fun onSharedPreferenceChanged(preferences: SharedPreferences?, key: String?) { - if (preferences == null || key == null) return + fun isCustomAction( + action: StemAction?, + default: StemAction? + ): Boolean { + return action != default + } - when(key) { - "name" -> config.deviceName = preferences.getString(key, "AirPods") ?: "AirPods" - "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) - "textColor" -> config.textColor = preferences.getLong(key, -1L) - "qs_click_behavior" -> config.qsClickBehavior = preferences.getString(key, "cycle") ?: "cycle" - "ble_only_mode" -> config.bleOnlyMode = preferences.getBoolean(key, false) + 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] - // 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) + 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, + ) + } - // Phone state-based takeover - "takeover_when_ringing_call" -> config.takeoverWhenRingingCall = preferences.getBoolean(key, true) - "takeover_when_media_start" -> config.takeoverWhenMediaStart = preferences.getBoolean(key, true) + @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())) + }) + updateBattery() + updateNotificationContent( + true, + this@AirPodsService.getSharedPreferences("settings", MODE_PRIVATE) + .getString("name", device?.name), + batteryNotification.getBattery() + ) + CrossDevice.sendRemotePacket(batteryInfo) + CrossDevice.batteryBytes = batteryInfo - "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() + 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) + } } - "left_double_press_action" -> { - config.leftDoublePressAction = StemAction.fromString( - preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK" - )!! - setupStemActions() + + 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) + }) + Log.d( + "AirPodsParser", + "Ear Detection: ${earDetectionNotification.status[0]} ${earDetectionNotification.status[1]}" + ) + processEarDetectionChange(earDetection) } - "right_double_press_action" -> { - config.rightDoublePressAction = StemAction.fromString( - preferences.getString(key, "NEXT_TRACK") ?: "NEXT_TRACK" - )!! - setupStemActions() + + override fun onConversationAwarenessReceived(conversationAwareness: ByteArray) { + conversationAwarenessNotification.setData(conversationAwareness) + sendBroadcast(Intent(AirPodsNotifications.CA_DATA).apply { + putExtra("data", conversationAwarenessNotification.status) + }) + + if (conversationAwarenessNotification.status == 1.toByte() || conversationAwarenessNotification.status == 2.toByte()) { + MediaController.startSpeaking() + } else if (conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) { + MediaController.stopSpeaking() + } + + Log.d( + "AirPodsParser", + "Conversation Awareness: ${conversationAwarenessNotification.status}" + ) } - "left_triple_press_action" -> { - config.leftTriplePressAction = StemAction.fromString( - preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK" - )!! - setupStemActions() + + 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() + } } - "right_triple_press_action" -> { - config.rightTriplePressAction = StemAction.fromString( - preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK" - )!! - setupStemActions() + + 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 + ) + } } - "left_long_press_action" -> { - config.leftLongPressAction = StemAction.fromString( - preferences.getString(key, "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES" - )!! - setupStemActions() + + 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() } - "right_long_press_action" -> { - config.rightLongPressAction = StemAction.fromString( - preferences.getString(key, "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT" - )!! - setupStemActions() + + 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 + ) } - } - if (key == "mac_address") { - macAddress = preferences.getString(key, "") ?: "" - } - } + 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("airpods_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, + aacpManager = aacpManager, + attManager = attManager + ) + } + } - private fun logPacket(packet: ByteArray, source: String) { - val packetHex = packet.joinToString(" ") { "%02X".format(it) } - val logEntry = "$source: $packetHex" + @SuppressLint("NewApi") + override fun onHeadTrackingReceived(headTracking: ByteArray) { + if (isHeadTrackingActive) { + HeadTracking.processPacket(headTracking) + processHeadTrackingData(headTracking) + } + } - synchronized(inMemoryLogs) { - inMemoryLogs.add(logEntry) - if (inMemoryLogs.size > maxLogEntries) { - inMemoryLogs.iterator().next().let { - inMemoryLogs.remove(it) + 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)) + } } } - _packetLogsFlow.value = inMemoryLogs.toSet() - } + override fun onStemPressReceived(stemPress: ByteArray) { + val (stemPressType, bud) = aacpManager.parseStemPressResponse(stemPress) - CoroutineScope(Dispatchers.IO).launch { - val logs = sharedPreferencesLogs.getStringSet(packetLogKey, mutableSetOf())?.toMutableSet() - ?: mutableSetOf() - logs.add(logEntry) + 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 (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??? + } + } - if (logs.size > maxLogEntries) { - val toKeep = logs.toList().takeLast(maxLogEntries).toSet() - sharedPreferencesLogs.edit { putStringSet(packetLogKey, toKeep) } - } else { - sharedPreferencesLogs.edit { putStringSet(packetLogKey, logs) } + 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 onUnknownPacketReceived(packet: ByteArray) { + Log.d("AACPManager", "Unknown packet received: ${packet.joinToString(" ") { "%02X".format(it) }}") + } + }) } - fun getPacketLogs(): Set { - return inMemoryLogs.toSet() + 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 clearPacketLogs() { - synchronized(inMemoryLogs) { - inMemoryLogs.clear() - _packetLogsFlow.value = emptySet() + 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")) + } } - sharedPreferencesLogs.edit { remove(packetLogKey) } } - fun clearLogs() { - clearPacketLogs() + 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) + } + } + + 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), + textColor = sharedPreferences.getLong("textColor", -1L), + qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle", + + // AirPods state-based takeover + takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", true), + takeoverWhenIdle = sharedPreferences.getBoolean("takeover_when_idle", true), + takeoverWhenMusic = sharedPreferences.getBoolean("takeover_when_music", false), + takeoverWhenCall = sharedPreferences.getBoolean("takeover_when_call", true), + + // Phone state-based takeover + takeoverWhenRingingCall = sharedPreferences.getBoolean("takeover_when_ringing_call", true), + takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", true), + + // 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 { AACPManager.Companion.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", "") ?: "", + ) + } + + override fun onSharedPreferenceChanged(preferences: SharedPreferences?, key: String?) { + if (preferences == null || key == null) return + + when(key) { + "name" -> config.deviceName = preferences.getString(key, "AirPods") ?: "AirPods" + "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) + "textColor" -> config.textColor = preferences.getLong(key, -1L) + "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 { AACPManager.Companion.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, "") ?: "" + } + + if (key == "mac_address") { + macAddress = 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() } @@ -815,7 +1394,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private var isInCall = false private var callNumber: String? = null - @RequiresApi(Build.VERSION_CODES.Q) private fun initGestureDetector() { if (gestureDetector == null) { gestureDetector = GestureDetector(this) @@ -826,7 +1404,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList var popupShown = false fun showPopup(service: Service, name: String) { if (!Settings.canDrawOverlays(service)) { - Log.d("AirPodsService", "No permission for SYSTEM_ALERT_WINDOW") + Log.d(TAG, "No permission for SYSTEM_ALERT_WINDOW") return } if (popupShown) { @@ -840,15 +1418,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList var islandOpen = false var islandWindow: IslandWindow? = null @SuppressLint("MissingPermission") - fun showIsland(service: Service, batteryPercentage: Int, type: IslandType = IslandType.CONNECTED) { - Log.d("AirPodsService", "Showing island window") + fun showIsland(service: Service, batteryPercentage: Int, type: IslandType = IslandType.CONNECTED, reversed: Boolean = false, otherDeviceName: String? = null) { + Log.d(TAG, "Showing island window") if (!Settings.canDrawOverlays(service)) { - Log.d("AirPodsService", "No permission for SYSTEM_ALERT_WINDOW") + 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) + islandWindow!!.show(sharedPreferences.getString("name", "AirPods Pro").toString(), batteryPercentage, this@AirPodsService, type, reversed, otherDeviceName) } } @@ -879,11 +1457,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } - fun setPhoneBatteryInWidget(enabled: Boolean) { - widgetMobileBatteryEnabled = enabled - updateBattery() - } - @OptIn(ExperimentalMaterial3Api::class) fun startForegroundNotification() { val disconnectedNotificationChannel = NotificationChannel( @@ -905,7 +1478,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ).apply { description = "Notifications about problems connecting to AirPods protocol" enableLights(true) - lightColor = android.graphics.Color.RED + lightColor = Color.RED enableVibration(true) } @@ -992,7 +1565,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } fun setBatteryMetadata() { - device?.let { + device?.let { it -> SystemApisUtils.setMetadata( it, it.METADATA_UNTETHERED_CASE_BATTERY, @@ -1032,7 +1605,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val componentName = ComponentName(this, BatteryWidget::class.java) val widgetIds = appWidgetManager.getAppWidgetIds(componentName) - val remoteViews = RemoteViews(packageName, R.layout.battery_widget).also { + 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) @@ -1099,7 +1672,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (widgetMobileBatteryEnabled) View.VISIBLE else View.GONE ) if (widgetMobileBatteryEnabled) { - val batteryManager = getSystemService(BatteryManager::class.java) + val batteryManager = getSystemService(BatteryManager::class.java) val batteryLevel = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) val charging = @@ -1136,7 +1709,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList 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 { + 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() @@ -1192,7 +1765,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList batteryList: List? = null ) { val notificationManager = getSystemService(NotificationManager::class.java) - var updatedNotification: Notification? = null + var updatedNotification: Notification? val notificationIntent = Intent(this, MainActivity::class.java) val pendingIntent = PendingIntent.getActivity( @@ -1202,11 +1775,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - if (!::socket.isInitialized && !config.bleOnlyMode) { + if (!::socket.isInitialized) { return } if (connected && (config.bleOnlyMode || socket.isConnected)) { - updatedNotification = NotificationCompat.Builder(this, "airpods_connection_status") + val updatedNotificationBuilder = NotificationCompat.Builder(this, "airpods_connection_status") .setSmallIcon(R.drawable.airpods) .setContentTitle(airpodsName ?: config.deviceName) .setContentText( @@ -1239,10 +1812,25 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList .setCategory(Notification.CATEGORY_STATUS) .setPriority(NotificationCompat.PRIORITY_LOW) .setOngoing(true) - .build() - - notificationManager.notify(2, updatedNotification) + 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) { updatedNotification = NotificationCompat.Builder(this, "background_service_status") @@ -1258,12 +1846,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList notificationManager.notify(1, updatedNotification) notificationManager.cancel(2) } else if (!config.bleOnlyMode && !socket.isConnected && isConnectedLocally) { - Log.d("AirPodsService", " Socket not connected") showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?") } } - @RequiresApi(Build.VERSION_CODES.Q) fun handleIncomingCall() { if (isInCall) return if (config.headGestures) { @@ -1278,11 +1864,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList handleIncomingCallOnceConnected = false } } + } } - @OptIn(ExperimentalCoroutinesApi::class) - @RequiresApi(Build.VERSION_CODES.Q) + @OptIn(ExperimentalCoroutinesApi::class) suspend fun testHeadGestures(): Boolean { return suspendCancellableCoroutine { continuation -> gestureDetector?.startDetection(doNotStop = true) { accepted -> @@ -1359,7 +1945,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } private lateinit var connectionReceiver: BroadcastReceiver - private lateinit var disconnectionReceiver: BroadcastReceiver private fun resToUri(resId: Int): Uri? { return try { @@ -1369,7 +1954,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList .appendPath(applicationContext.resources.getResourceTypeName(resId)) .appendPath(applicationContext.resources.getResourceEntryName(resId)) .build() - } catch (e: Resources.NotFoundException) { + } catch (_: Resources.NotFoundException) { null } } @@ -1389,7 +1974,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList @Suppress("PrivatePropertyName") private val ACTION_ASI_UPDATE_BLUETOOTH_DATA = "batterywidget.impl.action.update_bluetooth_data" - @Suppress("MissingPermission") + @Suppress("MissingPermission", "unused") fun broadcastBatteryInformation() { if (device == null) return @@ -1416,531 +2001,279 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ) // Broadcast vendor-specific event - val intent = Intent(android.bluetooth.BluetoothHeadset.ACTION_VENDOR_SPECIFIC_HEADSET_EVENT).apply { - putExtra(android.bluetooth.BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD, VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV) - putExtra(android.bluetooth.BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD_TYPE, android.bluetooth.BluetoothHeadset.AT_CMD_TYPE_SET) - putExtra(android.bluetooth.BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_ARGS, arguments) - putExtra(BluetoothDevice.EXTRA_DEVICE, device) - putExtra(BluetoothDevice.EXTRA_NAME, device?.name) - addCategory("${android.bluetooth.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("AirPodsService", "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("AirPodsService", "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("AirPodsService", "Failed to send ASI battery level broadcast: ${e.message}") - } - - Log.d("AirPodsService", "Broadcast battery level $batteryUnified% to system") - } - - private fun setMetadatas(d: BluetoothDevice) { - d.let{ device -> - val metadataSet = SystemApisUtils.setMetadata( - device, - device.METADATA_MAIN_ICON, - resToUri(R.drawable.pro_2).toString().toByteArray() - ) && - SystemApisUtils.setMetadata( - device, - device.METADATA_MODEL_NAME, - "AirPods Pro (2 Gen.)".toByteArray() - ) && - SystemApisUtils.setMetadata( - device, - device.METADATA_DEVICE_TYPE, - device.DEVICE_TYPE_UNTETHERED_HEADSET.toByteArray() - ) && - SystemApisUtils.setMetadata( - device, - device.METADATA_UNTETHERED_CASE_ICON, - resToUri(R.drawable.pro_2_case).toString().toByteArray() - ) && - SystemApisUtils.setMetadata( - device, - device.METADATA_UNTETHERED_RIGHT_ICON, - resToUri(R.drawable.pro_2_right).toString().toByteArray() - ) && - SystemApisUtils.setMetadata( - device, - device.METADATA_UNTETHERED_LEFT_ICON, - resToUri(R.drawable.pro_2_left).toString().toByteArray() - ) && - SystemApisUtils.setMetadata( - device, - device.METADATA_MANUFACTURER_NAME, - "Apple".toByteArray() - ) && - SystemApisUtils.setMetadata( - device, - device.METADATA_COMPANION_APP, - "me.kavisdevar.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("AirPodsService", "Metadata set: $metadataSet") - } - } - - @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 != null && !action.isEmpty()) { - Log.d("AirPodsService", "Received bluetooth connection broadcast") - if (ServiceManager.getService()?.isConnectedLocally == true) { - Log.d("AirPodsService", "Checking if audio should be connected") - ServiceManager.getService()?.manuallyCheckForAudioSource() - return - } - if (BluetoothDevice.ACTION_ACL_CONNECTED == action) { - val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") - bluetoothDevice.fetchUuidsWithSdp() - if (bluetoothDevice.uuids != null) { - if (bluetoothDevice.uuids.contains(uuid)) { - val intent = - Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) - intent.putExtra("name", name) - intent.putExtra("device", bluetoothDevice) - context?.sendBroadcast(intent) - } - } - } - } - } - } - - val ancModeFilter = IntentFilter("me.kavishdevar.librepods.SET_ANC_MODE") - var ancModeReceiver: BroadcastReceiver? = null - - @SuppressLint("InlinedApi", "MissingPermission", "UnspecifiedRegisterReceiverFlag") - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Log.d("AirPodsService", "Service started") - ServiceManager.setService(this) - startForegroundNotification() - initGestureDetector() - - bleManager = BLEManager(this) - bleManager.setAirPodsStatusListener(bleStatusListener) - - sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) - - with(sharedPreferences) { - val editor = edit() - - if (!contains("conversational_awareness_pause_music")) editor.putBoolean("conversational_awareness_pause_music", false) - if (!contains("personalized_volume")) editor.putBoolean("personalized_volume", false) - if (!contains("automatic_ear_detection")) editor.putBoolean("automatic_ear_detection", true) - if (!contains("long_press_nc")) editor.putBoolean("long_press_nc", true) - if (!contains("off_listening_mode")) editor.putBoolean("off_listening_mode", false) - if (!contains("show_phone_battery_in_widget")) editor.putBoolean("show_phone_battery_in_widget", true) - if (!contains("single_anc")) editor.putBoolean("single_anc", true) - if (!contains("long_press_transparency")) editor.putBoolean("long_press_transparency", true) - if (!contains("conversational_awareness")) editor.putBoolean("conversational_awareness", true) - if (!contains("relative_conversational_awareness_volume")) editor.putBoolean("relative_conversational_awareness_volume", true) - if (!contains("long_press_adaptive")) editor.putBoolean("long_press_adaptive", true) - if (!contains("loud_sound_reduction")) editor.putBoolean("loud_sound_reduction", true) - if (!contains("long_press_off")) editor.putBoolean("long_press_off", false) - if (!contains("volume_control")) editor.putBoolean("volume_control", true) - if (!contains("head_gestures")) editor.putBoolean("head_gestures", true) - if (!contains("disconnect_when_not_wearing")) editor.putBoolean("disconnect_when_not_wearing", false) - - // AirPods state-based takeover - if (!contains("takeover_when_disconnected")) editor.putBoolean("takeover_when_disconnected", true) - if (!contains("takeover_when_idle")) editor.putBoolean("takeover_when_idle", true) - if (!contains("takeover_when_music")) editor.putBoolean("takeover_when_music", false) - if (!contains("takeover_when_call")) editor.putBoolean("takeover_when_call", true) - - // Phone state-based takeover - if (!contains("takeover_when_ringing_call")) editor.putBoolean("takeover_when_ringing_call", true) - if (!contains("takeover_when_media_start")) editor.putBoolean("takeover_when_media_start", true) - - if (!contains("adaptive_strength")) editor.putInt("adaptive_strength", 51) - if (!contains("tone_volume")) editor.putInt("tone_volume", 75) - if (!contains("conversational_awareness_volume")) editor.putInt("conversational_awareness_volume", 43) - - if (!contains("textColor")) editor.putLong("textColor", -1L) - - if (!contains("qs_click_behavior")) editor.putString("qs_click_behavior", "cycle") - if (!contains("name")) editor.putString("name", "AirPods") - if (!contains("ble_only_mode")) editor.putBoolean("ble_only_mode", false) - - if (!contains("left_single_press_action")) editor.putString("left_single_press_action", - StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!.name) - if (!contains("right_single_press_action")) editor.putString("right_single_press_action", - StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!.name) - if (!contains("left_double_press_action")) editor.putString("left_double_press_action", - StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!.name) - if (!contains("right_double_press_action")) editor.putString("right_double_press_action", - StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!.name) - if (!contains("left_triple_press_action")) editor.putString("left_triple_press_action", - StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!.name) - if (!contains("right_triple_press_action")) editor.putString("right_triple_press_action", - StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!.name) - if (!contains("left_long_press_action")) editor.putString("left_long_press_action", - StemAction.defaultActions[StemPressType.LONG_PRESS]!!.name) - if (!contains("right_long_press_action")) editor.putString("right_long_press_action", - StemAction.defaultActions[StemPressType.LONG_PRESS]!!.name) - - editor.apply() - } - - initializeConfig() - - ancModeReceiver = 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 allowOffModeValue = aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION } - val allowOffMode = allowOffModeValue?.value?.takeIf { it.isNotEmpty() }?.get(0) == 0x01.toByte() - - val nextMode = if (allowOffMode) { - when (currentMode) { - 1 -> 2 - 2 -> 3 - 3 -> 4 - 4 -> 1 - else -> 1 - } - } else { - when (currentMode) { - 1 -> 2 - 2 -> 3 - 3 -> 4 - 4 -> 2 - else -> 2 - } - } - - aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, - nextMode - ) - Log.d("AirPodsService", "Cycling ANC mode from $currentMode to $nextMode (offListeningMode: $allowOffMode)") - } - } - } - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(ancModeReceiver, ancModeFilter, RECEIVER_EXPORTED) - } else { - registerReceiver(ancModeReceiver, ancModeFilter) - } - val audioManager = - this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager - MediaController.initialize( - audioManager, - this@AirPodsService.getSharedPreferences( - "settings", - MODE_PRIVATE - ) - ) - Log.d("AirPodsService", "Initializing CrossDevice") - CoroutineScope(Dispatchers.IO).launch { - CrossDevice.init(this@AirPodsService) - Log.d("AirPodsService", "CrossDevice initialized") - } - - sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) - macAddress = sharedPreferences.getString("mac_address", "") ?: "" - - telephonyManager = getSystemService(TELEPHONY_SERVICE) as TelephonyManager - phoneStateListener = object : PhoneStateListener() { - @SuppressLint("SwitchIntDef") - override fun onCallStateChanged(state: Int, phoneNumber: String?) { - super.onCallStateChanged(state, phoneNumber) - 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 { - takeOver("call") - } - if (config.headGestures) { - callNumber = phoneNumber - 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( - Dispatchers.IO).launch { - takeOver("call") - } - isInCall = true - } - TelephonyManager.CALL_STATE_IDLE -> { - isInCall = false - callNumber = null - gestureDetector?.stopDetection() - } - } - } - } - telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE) - - 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 { - 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") - } - - 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 && !config.bleOnlyMode) { - Log.d("AirPodsService", "${config.deviceName} connected") - CoroutineScope(Dispatchers.IO).launch { - connectToSocket(device!!) - } - Log.d("AirPodsService", "Setting metadata") - setMetadatas(device!!) - isConnectedLocally = true - macAddress = device!!.address - sharedPreferences.edit { - putString("mac_address", macAddress) - } - } else if (config.bleOnlyMode) { - Log.d("AirPodsService", "BLE-only mode: skipping L2CAP connection") - macAddress = device!!.address - sharedPreferences.edit { - putString("mac_address", macAddress) - } - } - } else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) { - device = null - isConnectedLocally = false - popupShown = false - updateNotificationContent(false) - } - } - } - 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 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}") } - val showIslandIntentFilter = IntentFilter().apply { - addAction("me.kavishdevar.librepods.cross_device_island") - addAction(AirPodsNotifications.DISCONNECT_RECEIVERS) + // Broadcast battery level changes + val batteryIntent = Intent(ACTION_BATTERY_LEVEL_CHANGED).apply { + putExtra(BluetoothDevice.EXTRA_DEVICE, device) + putExtra(EXTRA_BATTERY_LEVEL, batteryUnified) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(showIslandReceiver, showIslandIntentFilter, RECEIVER_EXPORTED) - } else { - registerReceiver(showIslandReceiver, showIslandIntentFilter) + 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}") } - val deviceIntentFilter = IntentFilter().apply { - addAction(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) - addAction(AirPodsNotifications.AIRPODS_DISCONNECTED) + // 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) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(connectionReceiver, deviceIntentFilter, RECEIVER_EXPORTED) - registerReceiver(bluetoothReceiver, serviceIntentFilter, RECEIVER_EXPORTED) - } else { - registerReceiver(connectionReceiver, deviceIntentFilter) - registerReceiver(bluetoothReceiver, serviceIntentFilter) + try { + sendBroadcastAsUser(statusIntent, UserHandle.getUserHandleForUid(-1)) + } catch (e: Exception) { + Log.e(TAG, "Failed to send ASI battery level broadcast: ${e.message}") } - val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter + Log.d(TAG, "Broadcast battery level $batteryUnified% to system") + } - 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 { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.A2DP) { - val connectedDevices = proxy.connectedDevices - if (connectedDevices.isNotEmpty()) { - if (!CrossDevice.isAvailable && !config.bleOnlyMode) { - CoroutineScope(Dispatchers.IO).launch { - connectToSocket(device) - } - setMetadatas(device) - macAddress = device.address - sharedPreferences.edit { - putString("mac_address", macAddress) - } - } else if (config.bleOnlyMode) { - Log.d("AirPodsService", "BLE-only mode: skipping L2CAP connection") - macAddress = device.address - sharedPreferences.edit { - putString("mac_address", macAddress) - } - } - this@AirPodsService.sendBroadcast( - Intent(AirPodsNotifications.AIRPODS_CONNECTED) - ) - } - } - bluetoothAdapter.closeProfileProxy(profile, proxy) - } + private fun setMetadatas(d: BluetoothDevice) { + 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.kavisdevar.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 instance is not of type AirPodsInstance, skipping metadata setting") + } + } + } - override fun onServiceDisconnected(profile: Int) {} - }, - BluetoothProfile.A2DP + @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 != null && !action.isEmpty()) { + Log.d(TAG, "Received bluetooth connection broadcast: action=$action") + if (ServiceManager.getService()?.isConnectedLocally == true) { + Log.d(TAG, "Device is already connected locally, checking if we should keep audio connected") + if (ServiceManager.getService()?.socket?.isConnected == true) ServiceManager.getService()?.manuallyCheckForAudioSource() else Log.d(TAG, "We're not connected, ignoring") + return + } + if (BluetoothDevice.ACTION_ACL_CONNECTED == action) { + val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") + bluetoothDevice.fetchUuidsWithSdp() + if (bluetoothDevice.uuids != null) { + if (bluetoothDevice.uuids.contains(uuid)) { + val intent = + Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) + intent.putExtra("name", name) + intent.putExtra("device", bluetoothDevice) + context?.sendBroadcast(intent) + } + } } } } + } - if (!isConnectedLocally && !CrossDevice.isAvailable) { - clearPacketLogs() - } + val ancModeFilter = IntentFilter("me.kavishdevar.librepods.SET_ANC_MODE") + var ancModeReceiver: BroadcastReceiver? = null - CoroutineScope(Dispatchers.IO).launch { - bleManager.startScanning() + @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 } - private lateinit var socket: BluetoothSocket - fun manuallyCheckForAudioSource() { - val shouldResume = MediaController.getMusicActive() - if (earDetectionNotification.status[0] != 0.toByte() && earDetectionNotification.status[1] != 0.toByte()) { + val shouldResume = MediaController.getMusicActive() // todo: for some reason we lose this info after disconnecting, probably android dispatches some event. haven't investigated yet. + if (airpodsInstance == null) return + Log.d(TAG, "disconnectedBecauseReversed: $disconnectedBecauseReversed, otherDeviceTookOver: $otherDeviceTookOver") + if ((earDetectionNotification.status[0] != 0.toByte() && earDetectionNotification.status[1] != 0.toByte()) || disconnectedBecauseReversed || otherDeviceTookOver) { Log.d( - "AirPodsService", - "For some reason, Android connected to the audio profile itself even after disconnecting. Disconnecting audio profile again!" + TAG, + "For some reason, Android connected to the audio profile itself even after disconnecting. Disconnecting audio profile again! I will resume: $shouldResume" ) disconnectAudio(this, device, shouldResume = shouldResume) } } @RequiresApi(Build.VERSION_CODES.R) - @SuppressLint("MissingPermission") - fun takeOver(takingOverFor: String) { + @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 + } + Log.d(TAG, "owns connection: ${aacpManager.getControlCommandStatus(AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION)?.value?.get(0)?.toInt()}") if (isConnectedLocally) { - Log.d("AirPodsService", "Already connected locally, skipping") + 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("AirPodsService", "CrossDevice is available, continuing") + Log.d(TAG, "CrossDevice is available, continuing") } else if (bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true) { - Log.d("AirPodsService", "At least one AirPod is in ear, continuing") + Log.d(TAG, "At least one AirPod is in ear, continuing") } else { - Log.d("AirPodsService", "CrossDevice not available and AirPods not in ear, skipping") + Log.d(TAG, "CrossDevice not available and AirPods not in ear, skipping") return } @@ -1951,7 +2284,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } if (!shouldTakeOverPState) { - Log.d("AirPodsService", "Not taking over audio, phone state takeover disabled") + Log.d(TAG, "Not taking over audio, phone state takeover disabled") return } @@ -1966,31 +2299,31 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } if (!shouldTakeOver) { - Log.d("AirPodsService", "Not taking over audio, airpods state takeover disabled") + Log.d(TAG, "Not taking over audio, airpods state takeover disabled") return } if (takingOverFor == "music") { - Log.d("AirPodsService", "Pausing music so that it doesn't play through speakers") - MediaController.pausedForCrossDevice = true + Log.d(TAG, "Pausing music so that it doesn't play through speakers") + MediaController.pausedWhileTakingOver = true MediaController.sendPause(true) } else { handleIncomingCallOnceConnected = true } - Log.d("AirPodsService", "Taking over audio") + Log.d(TAG, "Taking over audio") CrossDevice.sendRemotePacket(CrossDevicePackets.REQUEST_DISCONNECT.packet) - Log.d("AirPodsService", macAddress) + Log.d(TAG, macAddress) sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false) } - device = getSystemService(BluetoothManager::class.java).adapter.bondedDevices.find { + device = getSystemService(BluetoothManager::class.java).adapter.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("AirPodsService", "BLE-only mode: showing connecting status without L2CAP connection") + Log.d(TAG, "BLE-only mode: showing connecting status without L2CAP connection") updateNotificationContent( true, config.deviceName, @@ -2004,7 +2337,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList isConnectedLocally = true } } - showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!), IslandType.TAKING_OVER) @@ -2021,11 +2353,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ) val constructors = BluetoothSocket::class.java.declaredConstructors - Log.d("AirPodsService", "BluetoothSocket has ${constructors.size} constructors:") + Log.d(TAG, "BluetoothSocket has ${constructors.size} constructors:") constructors.forEachIndexed { index, constructor -> val params = constructor.parameterTypes.joinToString(", ") { it.simpleName } - Log.d("AirPodsService", "Constructor $index: ($params)") + Log.d(TAG, "Constructor $index: ($params)") } var lastException: Exception? = null @@ -2033,33 +2365,32 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList for ((index, params) in constructorSpecs.withIndex()) { try { - Log.d("AirPodsService", "Trying constructor signature #${index + 1}") + Log.d(TAG, "Trying constructor signature #${index + 1}") attemptedConstructors++ return HiddenApiBypass.newInstance(BluetoothSocket::class.java, *params) as BluetoothSocket } catch (e: Exception) { - Log.e("AirPodsService", "Constructor signature #${index + 1} failed: ${e.message}") + Log.e(TAG, "Constructor signature #${index + 1} failed: ${e.message}") lastException = e } } val errorMessage = "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures" - Log.e("AirPodsService", errorMessage) + Log.e(TAG, errorMessage) showSocketConnectionFailureNotification(errorMessage) throw lastException ?: IllegalStateException(errorMessage) } - @RequiresApi(Build.VERSION_CODES.R) @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") - fun connectToSocket(device: BluetoothDevice) { - Log.d("AirPodsService", " Connecting to socket") + fun connectToSocket(device: BluetoothDevice, manual: Boolean = false) { + Log.d(TAG, " Connecting to socket") HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;") val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") - if (isConnectedLocally != true && !CrossDevice.isAvailable) { + if (!isConnectedLocally && !CrossDevice.isAvailable) { socket = try { createBluetoothSocket(device, uuid) } catch (e: Exception) { - Log.e("AirPodsService", "Failed to create BluetoothSocket: ${e.message}") - showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.message}") + Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}") + showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}") return } @@ -2073,29 +2404,66 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList BluetoothConnectionManager.setCurrentConnection(socket, device) + attManager = ATTManager(device) + attManager!!.connect() + + // 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, + aacpManager = aacpManager, + attManager = attManager + ) + } + } + updateNotificationContent( true, config.deviceName, batteryNotification.getBattery() ) - Log.d("AirPodsService", " Socket connected") + Log.d(TAG, " Socket connected") } catch (e: Exception) { - Log.d("AirPodsService", " Socket not connected") - showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?") - throw e + 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("AirPodsService", " Socket not connected") - showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?") + } + 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 - socket.let { it -> + socket.let { aacpManager.sendPacket(aacpManager.createHandshakePacket()) aacpManager.sendSetFeatureFlagsPacket() aacpManager.sendNotificationRequest() - Log.d("AirPodsService", "Requesting proximity keys") + Log.d(TAG, "Requesting proximity keys") aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte()) CoroutineScope(Dispatchers.IO).launch { aacpManager.sendPacket(aacpManager.createHandshakePacket()) @@ -2104,6 +2472,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList 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({ @@ -2121,11 +2491,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList setupStemActions() - while (socket.isConnected == true) { - socket.let { + while (socket.isConnected) { + socket.let { it -> val buffer = ByteArray(1024) val bytesRead = it.inputStream.read(buffer) - var data: ByteArray = byteArrayOf() + var data: ByteArray if (bytesRead > 0) { data = buffer.copyOfRange(0, bytesRead) sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply { @@ -2150,6 +2520,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } else if (bytesRead == -1) { Log.d("AirPods Service", "Socket closed (bytesRead = -1)") sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED)) + aacpManager.disconnected() return@launch } } @@ -2157,13 +2528,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList Log.d("AirPods Service", "Socket closed") isConnectedLocally = false socket.close() + aacpManager.disconnected() + updateNotificationContent(false) sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED)) } } } catch (e: Exception) { e.printStackTrace() - Log.d("AirPodsService", "Failed to connect to socket: ${e.message}") - showSocketConnectionFailureNotification("Failed to establish connection: ${e.message}") + Log.d(TAG, "Failed to connect to socket: ${e.message}") + showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}") isConnectedLocally = false this@AirPodsService.device = device updateNotificationContent(false) @@ -2171,11 +2544,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } - fun disconnect() { + fun disconnectForCD() { if (!this::socket.isInitialized) return socket.close() - MediaController.pausedForCrossDevice = false - Log.d("AirPodsService", "Disconnected from AirPods, showing island.") + 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 @@ -2196,12 +2569,40 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList CrossDevice.isAvailable = true } + fun disconnectAirPods() { + if (!this::socket.isInitialized) return + socket.close() + isConnectedLocally = false + aacpManager.disconnected() + attManager?.disconnect() + updateNotificationContent(false) + sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED)) + + 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) + 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 @@ -2230,7 +2631,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (profile == BluetoothProfile.A2DP) { try { if (proxy.getConnectionState(device) == BluetoothProfile.STATE_DISCONNECTED) { - Log.d("AirPodsService", "Already disconnected from A2DP") + Log.d(TAG, "Already disconnected from A2DP") return } val method = @@ -2285,7 +2686,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList e.printStackTrace() } finally { bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) - if (MediaController.pausedForCrossDevice) { + if (MediaController.pausedWhileTakingOver) { MediaController.sendPlay() } } @@ -2323,13 +2724,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } updateNotificationContent(true, name, batteryNotification.getBattery()) - Log.d("AirPodsService", "setName: $name") + Log.d(TAG, "setName: $name") } @SuppressLint("MissingPermission") override fun onDestroy() { clearPacketLogs() - Log.d("AirPodsService", "Service stopped is being destroyed for some reason!") + Log.d(TAG, "Service stopped is being destroyed for some reason!") sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) @@ -2348,11 +2749,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } catch (e: Exception) { e.printStackTrace() } - try { - unregisterReceiver(disconnectionReceiver) - } catch (e: Exception) { - e.printStackTrace() - } try { unregisterReceiver(earReceiver) } catch (e: Exception) { @@ -2374,6 +2770,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList fun startHeadTracking() { isHeadTrackingActive = true val useAlternatePackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", false) + 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 { @@ -2392,17 +2794,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList isHeadTrackingActive = false } - fun shouldTakeOverBasedOnAirPodsState(connectionState: String): Boolean { - if (CrossDevice.isAvailable) return true - - return when (connectionState) { - "Disconnected" -> config.takeoverWhenDisconnected - "Idle" -> config.takeoverWhenIdle - "Music" -> config.takeoverWhenMusic - "Call", "Ringing", "Hanging Up" -> config.takeoverWhenCall - else -> false + @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 { + connectToSocket(device!!, manual = true) + } } } + } private fun Int.dpToPx(): Int { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt new file mode 100644 index 000000000..a7ad97760 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt @@ -0,0 +1,98 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +@file:OptIn(ExperimentalEncodingApi::class) + +package me.kavishdevar.librepods.services + + +import android.accessibilityservice.AccessibilityService +import android.util.Log +import android.view.accessibility.AccessibilityEvent +import kotlin.io.encoding.ExperimentalEncodingApi + +private const val TAG="AppListenerService" + +val cameraPackages = mutableSetOf( + "com.google.android.GoogleCamera", + "com.sec.android.app.camera", + "com.android.camera", + "com.oppo.camera", + "com.motorola.camera2", + "org.codeaurora.snapcam" +) + +var cameraOpen = false +private var currentCustomPackage: String? = null + +class AppListenerService : AccessibilityService() { + private lateinit var prefs: android.content.SharedPreferences + private val preferenceChangeListener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> + if (key == "custom_camera_package") { + val newPackage = sharedPreferences.getString(key, null) + currentCustomPackage?.let { cameraPackages.remove(it) } + if (newPackage != null && newPackage.isNotBlank()) { + cameraPackages.add(newPackage) + } + currentCustomPackage = newPackage + } + } + + override fun onCreate() { + super.onCreate() + prefs = getSharedPreferences("settings", MODE_PRIVATE) + val customPackage = prefs.getString("custom_camera_package", null) + if (customPackage != null && customPackage.isNotBlank()) { + cameraPackages.add(customPackage) + currentCustomPackage = customPackage + } + prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener) + } + + override fun onDestroy() { + super.onDestroy() + prefs.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + } + + override fun onAccessibilityEvent(ev: AccessibilityEvent?) { + try { + if (ev?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { + val pkg = ev.packageName?.toString() ?: return + if (pkg == "com.android.systemui") return // after camera opens, systemui is opened, probably for the privacy indicators + Log.d(TAG, "Package: $pkg, cameraOpen: $cameraOpen") + if (pkg in cameraPackages) { + Log.d(TAG, "Camera app opened: $pkg") + if (!cameraOpen) cameraOpen = true + ServiceManager.getService()?.cameraOpened() + } else { + if (cameraOpen) { + cameraOpen = false + ServiceManager.getService()?.cameraClosed() + } else { + Log.d(TAG, "ignoring") + } + } + // Log.d(TAG, "Opened: $pkg") + } + } catch(e: Exception) { + Log.e(TAG, "Error in onAccessibilityEvent: ${e.message}") + } + } + + override fun onInterrupt() {} +} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt index 7f651f6bc..cd5392ddc 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt @@ -1,5 +1,5 @@ /* - * LibrePods - AirPods liberated from Apple's ecosystem + * LibrePods - AirPods liberated from Apple’s ecosystem * * Copyright (C) 2025 LibrePods contributors * @@ -21,9 +21,8 @@ package me.kavishdevar.librepods.utils import android.util.Log -import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdentifiers.entries -import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressBudType.entries -import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType.entries +import java.nio.ByteBuffer +import java.nio.ByteOrder import kotlin.io.encoding.ExperimentalEncodingApi /** @@ -35,19 +34,27 @@ class AACPManager { companion object { private const val TAG = "AACPManager" + @Suppress("unused") object Opcodes { - const val SET_FEATURE_FLAGS: Byte = 0x4d - const val REQUEST_NOTIFICATIONS: Byte = 0x0f + 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 DEVICE_METADATA: Byte = 0x1d + const val CONVERSATION_AWARENESS: Byte = 0x4B + const val INFORMATION: Byte = 0x1D const val RENAME: Byte = 0x1E 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 EQ_DATA: 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 } private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00) @@ -75,7 +82,7 @@ class AACPManager { } } -// @Suppress("unused") + // @Suppress("unused") enum class ControlCommandIdentifiers(val value: Byte) { MIC_MODE(0x01), BUTTON_SEND_MODE(0x05), @@ -106,7 +113,15 @@ class AACPManager { SIRI_MULTITONE_CONFIG(0x32), HEARING_ASSIST_CONFIG(0x33), ALLOW_OFF_OPTION(0x34), - STEM_CONFIG(0x39); + 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); + companion object { fun fromByte(byte: Byte): ControlCommandIdentifiers? = entries.find { it.value == byte } @@ -119,7 +134,8 @@ class AACPManager { companion object { fun fromByte(byte: Byte): ProximityKeyType = - ProximityKeyType.entries.find { it.value == byte }?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte") + ProximityKeyType.entries.find { it.value == byte } + ?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte") } } @@ -144,15 +160,79 @@ class AACPManager { 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 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) { 0.0f } + private set + + var eqOnPhone: Boolean = false + private set + + var eqOnMedia: Boolean = false + private set fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? { return controlCommandStatusList.find { it.identifier == identifier } } - private fun setControlCommandStatusValue(identifier: ControlCommandIdentifiers, value: ByteArray) { + private fun setControlCommandStatusValue( + identifier: ControlCommandIdentifiers, + value: ByteArray + ) { val existingStatus = getControlCommandStatus(identifier) if (existingStatus == value) { controlCommandStatusList.remove(existingStatus) @@ -164,6 +244,10 @@ class AACPManager { 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 { @@ -171,11 +255,16 @@ class AACPManager { fun onEarDetectionReceived(earDetection: ByteArray) fun onConversationAwarenessReceived(conversationAwareness: ByteArray) fun onControlCommandReceived(controlCommand: ByteArray) - fun onDeviceMetadataReceived(deviceMetadata: 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 parseStemPressResponse(data: ByteArray): Pair { @@ -186,8 +275,10 @@ class AACPManager { 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]}") + 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) } @@ -195,10 +286,20 @@ class AACPManager { fun onControlCommandReceived(controlCommand: ControlCommand) } - fun registerControlCommandListener(identifier: ControlCommandIdentifiers, callback: ControlCommandListener) { + 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) { @@ -246,7 +347,10 @@ class AACPManager { } fun sendControlCommand(identifier: Byte, value: Boolean): Boolean { - val controlPacket = createControlCommandPacket(identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02)) + val controlPacket = createControlCommandPacket( + identifier, + if (value) byteArrayOf(0x01) else byteArrayOf(0x02) + ) setControlCommandStatusValue( ControlCommandIdentifiers.fromByte(identifier) ?: return false, if (value) byteArrayOf(0x01) else byteArrayOf(0x02) @@ -264,7 +368,10 @@ class AACPManager { } fun parseProximityKeysResponse(data: ByteArray): Map { - Log.d(TAG, "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}") + 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") } @@ -290,7 +397,12 @@ class AACPManager { System.arraycopy(data, offset, key, 0, keyLength) keys[ProximityKeyType.fromByte(keyType)] = key offset += keyLength - Log.d(TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${key.joinToString(" ") { "%02X".format(it) }}") + Log.d( + TAG, + "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${ + key.joinToString(" ") { "%02X".format(it) } + }" + ) } return keys } @@ -309,11 +421,21 @@ class AACPManager { @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) }}") + 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) }}") + Log.w( + TAG, + "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}" + ) return } @@ -323,51 +445,165 @@ class AACPManager { Opcodes.BATTERY_INFO -> { callback?.onBatteryInfoReceived(packet) } + Opcodes.CONTROL_COMMAND -> { val controlCommand = ControlCommand.fromByteArray(packet) setControlCommandStatusValue( ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return, controlCommand.value ) - Log.d(TAG, "Control command received: ${controlCommand.identifier.toHexString()} - ${controlCommand.value.joinToString(" ") { "%02X".format(it) }}") - Log.d(TAG, "Control command list is now: ${ - controlCommandStatusList.joinToString(", ") { "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${it.value.joinToString(" ") { "%02X".format(it) }}" } + Log.d( + TAG, + "Control command received: ${controlCommand.identifier.toHexString()} - ${ + controlCommand.value.joinToString(" ") { "%02X".format(it) } + }" + ) + Log.d( + TAG, "Control command list is now: ${ + controlCommandStatusList.joinToString(", ") { it -> + "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${ + it.value.joinToString( + " " + ) { "%02X".format(it) } + }" + } }") - val controlCommandIdentifier = ControlCommandIdentifiers.fromByte(controlCommand.identifier) + val controlCommandIdentifier = + ControlCommandIdentifiers.fromByte(controlCommand.identifier) if (controlCommandIdentifier != null) { controlCommandListeners[controlCommandIdentifier]?.forEach { listener -> listener.onControlCommandReceived(controlCommand) } } else { - Log.w(TAG, "Unknown control command identifier: ${controlCommand.identifier.toHexString()}") + 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.DEVICE_METADATA -> { - callback?.onDeviceMetadataReceived(packet) - } + Opcodes.HEADTRACKING -> { if (packet.size < 70) { - Log.w(TAG, "Received HEADTRACKING packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}") + 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.EQ_DATA -> { + if (packet.size != 140) { + Log.w( + TAG, + "Received EQ_DATA packet of unexpected size: ${packet.size}, expected 140" + ) + return + } + if (packet[6] != 0x84.toByte()) { + Log.w( + TAG, + "Received EQ_DATA 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 enabled. just directly the EQ... weird. + // the EQs are little endian floats + val eq1 = ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() + val eq2 = ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() + val eq3 = ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() + val eq4 = 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") + } + + Opcodes.INFORMATION -> { + Log.e(TAG, "Parsing Information Packet") + val information = parseInformationPacket(packet) + callback?.onDeviceInformationReceived(information) + } else -> { + Log.d(TAG, "Unknown opcode received: ${opcode.toHexString()}") callback?.onUnknownPacketReceived(packet) } } @@ -380,6 +616,8 @@ class AACPManager { 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 } @@ -409,7 +647,28 @@ class AACPManager { 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, + 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 } @@ -417,7 +676,27 @@ class AACPManager { 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 + 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 } @@ -429,7 +708,29 @@ class AACPManager { 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 + 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 } @@ -437,7 +738,27 @@ class AACPManager { 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 + 0x00, + 0x00, + 0x10, + 0x00, + 0x0F, + 0x00, + 0x08, + 0x75, + 0x42, + 0x0B, + 0x08, + 0x10, + 0x10, + 0x02, + 0x1A, + 0x05, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00 ) return opcode + data } @@ -459,6 +780,265 @@ class AACPManager { 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 a 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, @@ -500,8 +1080,9 @@ class AACPManager { val value = ByteArray(4) System.arraycopy(data, 3, value, 0, 4) - val trimmedValue = value.takeWhile { it != 0x00.toByte() }.toByteArray() - return ControlCommand(identifier, trimmedValue) + val trimmedValue = value.dropLastWhile { it == 0x00.toByte() }.toByteArray() + val finalValue = if (trimmedValue.isEmpty()) byteArrayOf(0x00) else trimmedValue + return ControlCommand(identifier, finalValue) } } } @@ -523,14 +1104,19 @@ class AACPManager { ) } - @OptIn(ExperimentalStdlibApi::class) - fun sendPacket(packet: ByteArray): Boolean { + @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 = ControlCommand.fromByteArray(packet) - Log.d(TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${controlCommand.value.joinToString(" ") { "%02X".format(it) }}") + Log.d( + TAG, + "Control command: ${controlCommand.identifier.toHexString()} - ${ + controlCommand.value.joinToString(" ") { "%02X".format(it) } + }" + ) setControlCommandStatusValue( ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false, controlCommand.value @@ -551,4 +1137,138 @@ class AACPManager { 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) { + throw IllegalArgumentException("Data array too short to parse all connected devices") + } + 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) ?: "", + ) + } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt new file mode 100644 index 000000000..41c6116f8 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt @@ -0,0 +1,233 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + + /* This is a very basic ATT (Attribute Protocol) implementation. I have only implemented + * what is necessary for LibrePods to function, i.e. reading and writing characteristics, + * and receiving notifications. It is not a complete implementation of the ATT protocol. + */ + +package me.kavishdevar.librepods.utils + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothSocket +import android.os.ParcelUuid +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.lsposed.hiddenapibypass.HiddenApiBypass +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +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), + HEARING_AID(ATTHandles.HEARING_AID.value + 1), +} + +class ATTManager(private val device: BluetoothDevice) { + companion object { + private const val TAG = "ATTManager" + + private const val OPCODE_READ_REQUEST: Byte = 0x0A + private const val OPCODE_WRITE_REQUEST: Byte = 0x12 + private const val OPCODE_HANDLE_VALUE_NTF: Byte = 0x1B + } + + var socket: BluetoothSocket? = null + private var input: InputStream? = null + private var output: OutputStream? = null + private val listeners = mutableMapOf Unit>>() + private var notificationJob: kotlinx.coroutines.Job? = null + + // queue for non-notification PDUs (responses to requests) + private val responses = LinkedBlockingQueue() + + @SuppressLint("MissingPermission") + fun connect() { + HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;") + val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000") + + socket = createBluetoothSocket(device, uuid) + socket!!.connect() + input = socket!!.inputStream + output = socket!!.outputStream + Log.d(TAG, "Connected to ATT") + + notificationJob = CoroutineScope(Dispatchers.IO).launch { + while (socket?.isConnected == true) { + try { + val pdu = readPDU() + if (pdu.isNotEmpty() && pdu[0] == OPCODE_HANDLE_VALUE_NTF) { + // notification -> dispatch to listeners + val handle = (pdu[1].toInt() and 0xFF) or ((pdu[2].toInt() and 0xFF) shl 8) + val value = pdu.copyOfRange(3, pdu.size) + listeners[handle]?.forEach { listener -> + try { + listener(value) + Log.d(TAG, "Dispatched notification for handle $handle to listener, with value ${value.joinToString(" ") { String.format("%02X", it) }}") + } catch (e: Exception) { + Log.w(TAG, "Error in listener for handle $handle: ${e.message}") + } + } + } else { + // not a notification -> treat as a response for pending request(s) + responses.put(pdu) + } + } catch (e: Exception) { + Log.w(TAG, "Error reading notification/response: ${e.message}") + if (socket?.isConnected != true) break + } + } + } + } + + fun disconnect() { + try { + notificationJob?.cancel() + socket?.close() + } catch (e: Exception) { + Log.w(TAG, "Error closing socket: ${e.message}") + } + } + + fun registerListener(handle: ATTHandles, listener: (ByteArray) -> Unit) { + listeners.getOrPut(handle.value) { mutableListOf() }.add(listener) + } + + fun unregisterListener(handle: ATTHandles, listener: (ByteArray) -> Unit) { + listeners[handle.value]?.remove(listener) + } + + fun enableNotifications(handle: ATTHandles) { + write(ATTCCCDHandles.valueOf(handle.name), byteArrayOf(0x01, 0x00)) + } + + fun read(handle: ATTHandles): ByteArray { + val lsb = (handle.value and 0xFF).toByte() + val msb = ((handle.value shr 8) and 0xFF).toByte() + val pdu = byteArrayOf(OPCODE_READ_REQUEST, lsb, msb) + writeRaw(pdu) + // wait for response placed into responses queue by the reader coroutine + return readResponse() + } + + fun write(handle: ATTHandles, value: ByteArray) { + val lsb = (handle.value and 0xFF).toByte() + val msb = ((handle.value shr 8) and 0xFF).toByte() + val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value + writeRaw(pdu) + // usually a Write Response (0x13) will arrive; wait for it (but discard return) + try { + readResponse() + } catch (e: Exception) { + Log.w(TAG, "No write response received: ${e.message}") + } + } + + fun write(handle: ATTCCCDHandles, value: ByteArray) { + val lsb = (handle.value and 0xFF).toByte() + val msb = ((handle.value shr 8) and 0xFF).toByte() + val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value + writeRaw(pdu) + // usually a Write Response (0x13) will arrive; wait for it (but discard return) + try { + readResponse() + } catch (e: Exception) { + Log.w(TAG, "No write response received: ${e.message}") + } + } + + private fun writeRaw(pdu: ByteArray) { + output?.write(pdu) + output?.flush() + Log.d(TAG, "writeRaw: ${pdu.joinToString(" ") { String.format("%02X", it) }}") + } + + // rename / specialize: read raw PDU directly from input stream (blocking) + private fun readPDU(): ByteArray { + val inp = input ?: throw IllegalStateException("Not connected") + val buffer = ByteArray(512) + val len = inp.read(buffer) + if (len == -1) { + disconnect() + throw IllegalStateException("End of stream reached") + } + val data = buffer.copyOfRange(0, len) + Log.d(TAG, "readPDU: ${data.joinToString(" ") { String.format("%02X", it) }}") + return data + } + + // wait for a response PDU produced by the background reader + private fun readResponse(timeoutMs: Long = 2000): ByteArray { + try { + val resp = responses.poll(timeoutMs, TimeUnit.MILLISECONDS) + ?: throw IllegalStateException("No response read from ATT socket within $timeoutMs ms") + Log.d(TAG, "readResponse: ${resp.joinToString(" ") { String.format("%02X", it) }}") + return resp.copyOfRange(1, resp.size) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw IllegalStateException("Interrupted while waiting for ATT response", e) + } + } + + private fun createBluetoothSocket(device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket { + val type = 3 // L2CAP + val constructorSpecs = listOf( + arrayOf(device, type, true, true, 31, uuid), + arrayOf(device, type, 1, true, true, 31, uuid), + arrayOf(type, 1, true, true, device, 31, uuid), + arrayOf(type, true, true, device, 31, uuid) + ) + + val constructors = BluetoothSocket::class.java.declaredConstructors + Log.d("ATTManager", "BluetoothSocket has ${constructors.size} constructors:") + + constructors.forEachIndexed { index, constructor -> + val params = constructor.parameterTypes.joinToString(", ") { it.simpleName } + Log.d("ATTManager", "Constructor $index: ($params)") + } + + var lastException: Exception? = null + var attemptedConstructors = 0 + + for ((index, params) in constructorSpecs.withIndex()) { + try { + Log.d("ATTManager", "Trying constructor signature #${index + 1}") + attemptedConstructors++ + return HiddenApiBypass.newInstance(BluetoothSocket::class.java, *params) as BluetoothSocket + } catch (e: Exception) { + Log.e("ATTManager", "Constructor signature #${index + 1} failed: ${e.message}") + lastException = e + } + } + + val errorMessage = "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures" + Log.e("ATTManager", errorMessage) + throw lastException ?: IllegalStateException(errorMessage) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/AirPods.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/AirPods.kt new file mode 100644 index 000000000..41281308a --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/AirPods.kt @@ -0,0 +1,232 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.utils + +import me.kavishdevar.librepods.utils.AACPManager +import me.kavishdevar.librepods.utils.ATTManager +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, + budsRes = R.drawable.airpods_1_buds, + leftBudsRes = R.drawable.airpods_1_left, + rightBudsRes = R.drawable.airpods_1_right, + caseRes = R.drawable.airpods_1_case, + capabilities = emptySet() +) + +class AirPods2: AirPodsBase( + modelNumber = listOf("A2032", "A2031"), + name = "AirPods 2", + budCaseRes = R.drawable.airpods_2, + budsRes = R.drawable.airpods_2_buds, + leftBudsRes = R.drawable.airpods_2_left, + rightBudsRes = R.drawable.airpods_2_right, + caseRes = R.drawable.airpods_2_case, + capabilities = emptySet() +) + +class AirPods3: AirPodsBase( + modelNumber = listOf("A2565", "A2564"), + name = "AirPods 3", + budCaseRes = R.drawable.airpods_3, + budsRes = R.drawable.airpods_3_buds, + leftBudsRes = R.drawable.airpods_3_left, + rightBudsRes = R.drawable.airpods_3_right, + caseRes = R.drawable.airpods_3_case, + capabilities = setOf( + Capability.HEAD_GESTURES + ) +) + +class AirPods4: AirPodsBase( + modelNumber = listOf("A3053", "A3050", "A3054"), + name = "AirPods 4", + budCaseRes = R.drawable.airpods_4, + budsRes = R.drawable.airpods_4_buds, + leftBudsRes = R.drawable.airpods_4_left, + rightBudsRes = R.drawable.airpods_4_right, + caseRes = R.drawable.airpods_4_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, + budsRes = R.drawable.airpods_4_buds, + leftBudsRes = R.drawable.airpods_4_left, + rightBudsRes = R.drawable.airpods_4_right, + caseRes = R.drawable.airpods_4_case, + capabilities = setOf( + Capability.LISTENING_MODE, + Capability.CONVERSATION_AWARENESS, + Capability.HEAD_GESTURES, + Capability.ADAPTIVE_AUDIO, + Capability.SLEEP_DETECTION, + Capability.ADAPTIVE_VOLUME + ) +) + +class AirPodsPro1: AirPodsBase( + modelNumber = listOf("A2084", "A2083"), + name = "AirPods Pro 1", + displayName = "AirPods Pro", + budCaseRes = R.drawable.airpods_pro_1, + budsRes = R.drawable.airpods_pro_1_buds, + leftBudsRes = R.drawable.airpods_pro_1_left, + rightBudsRes = R.drawable.airpods_pro_1_right, + caseRes = R.drawable.airpods_pro_1_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, + budsRes = R.drawable.airpods_pro_2_buds, + leftBudsRes = R.drawable.airpods_pro_2_left, + rightBudsRes = R.drawable.airpods_pro_2_right, + 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 + ) +) + +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, + budsRes = R.drawable.airpods_pro_2_buds, + leftBudsRes = R.drawable.airpods_pro_2_left, + rightBudsRes = R.drawable.airpods_pro_2_right, + 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 + ) +) + +class AirPodsPro3: AirPodsBase( + modelNumber = listOf("A3063", "A3064", "A3065"), + name = "AirPods Pro 3", + displayName = "AirPods Pro", + budCaseRes = R.drawable.airpods_pro_3, + budsRes = R.drawable.airpods_pro_3_buds, + leftBudsRes = R.drawable.airpods_pro_3_left, + rightBudsRes = R.drawable.airpods_pro_3_right, + caseRes = R.drawable.airpods_pro_3_case, + capabilities = setOf( + Capability.LISTENING_MODE, + Capability.CONVERSATION_AWARENESS, + 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?, + val aacpManager: AACPManager, + val attManager: ATTManager? +) + +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 } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt index c62b24aa6..5553e217c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt @@ -30,7 +30,6 @@ import android.content.SharedPreferences import android.os.Handler import android.os.Looper import android.util.Log -import me.kavishdevar.librepods.services.ServiceManager import javax.crypto.Cipher import javax.crypto.spec.SecretKeySpec import kotlin.io.encoding.Base64 @@ -70,6 +69,7 @@ class BLEManager(private val context: Context) { fun onLidStateChanged(lidOpen: Boolean) fun onEarStateChanged(device: AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean) fun onBatteryChanged(device: AirPodsStatus) + fun onDeviceDisappeared() } private var mBluetoothLeScanner: BluetoothLeScanner? = null @@ -223,12 +223,13 @@ class BLEManager(private val context: Context) { } } + @SuppressLint("GetInstance") private fun decryptLastBytes(data: ByteArray, key: ByteArray): ByteArray? { return try { if (data.size < 16) { return null } - + val block = data.copyOfRange(data.size - 16, data.size) val cipher = Cipher.getInstance("AES/ECB/NoPadding") val secretKey = SecretKeySpec(key, "AES") @@ -302,7 +303,7 @@ class BLEManager(private val context: Context) { if (previousGlobalState != parsedStatus.lidOpen) { listener.onLidStateChanged(parsedStatus.lidOpen) - Log.d(TAG, "Lid state changed from ${previousGlobalState} to ${parsedStatus.lidOpen}") + Log.d(TAG, "Lid state changed from $previousGlobalState to ${parsedStatus.lidOpen}") } } @@ -335,7 +336,7 @@ class BLEManager(private val context: Context) { val model = modelNames[modelId] ?: "Unknown ($modelId)" val status = data[5].toInt() and 0xFF - val flagsCase = data[7].toInt() and 0xFF +// val flagsCase = data[7].toInt() and 0xFF val lid = data[8].toInt() and 0xFF val color = colorNames[data[9].toInt()] ?: "Unknown" val conn = connStates[data[10].toInt()] ?: "Unknown (${data[10].toInt()})" @@ -348,13 +349,13 @@ class BLEManager(private val context: Context) { val isRightInEar = if (xorFactor) (status and 0x02) != 0 else (status and 0x08) != 0 val isFlipped = !primaryLeft - + val leftByteIndex = if (isFlipped) 2 else 1 val rightByteIndex = if (isFlipped) 1 else 2 - + val (isLeftCharging, leftBattery) = formatBattery(decrypted[leftByteIndex].toInt() and 0xFF) val (isRightCharging, rightBattery) = formatBattery(decrypted[rightByteIndex].toInt() and 0xFF) - + val rawCaseBatteryByte = decrypted[3].toInt() and 0xFF val (isCaseCharging, rawCaseBattery) = formatBattery(rawCaseBatteryByte) @@ -389,6 +390,7 @@ class BLEManager(private val context: Context) { private fun cleanupStaleDevices() { val now = System.currentTimeMillis() val staleCutoff = now - STALE_DEVICE_TIMEOUT_MS + val hadDevices = deviceStatusMap.isNotEmpty() val staleDevices = deviceStatusMap.filter { it.value.lastSeen < staleCutoff } @@ -396,6 +398,10 @@ class BLEManager(private val context: Context) { deviceStatusMap.remove(device.key) Log.d(TAG, "Removed stale device from tracking: ${device.key}") } + + if (hadDevices && deviceStatusMap.isEmpty()) { + airPodsStatusListener?.onDeviceDisappeared() + } } private fun checkLidStateTimeout() { @@ -442,10 +448,10 @@ class BLEManager(private val context: Context) { val isRightInEar = if (xorFactor) (status and 0x02) != 0 else (status and 0x08) != 0 val isFlipped = !primaryLeft - + val leftBatteryNibble = if (isFlipped) (podsBattery shr 4) and 0x0F else podsBattery and 0x0F val rightBatteryNibble = if (isFlipped) podsBattery and 0x0F else (podsBattery shr 4) and 0x0F - + val caseBattery = flagsCase and 0x0F val flags = (flagsCase shr 4) and 0x0F @@ -483,8 +489,8 @@ class BLEManager(private val context: Context) { companion object { private const val TAG = "AirPodsBLE" - private const val CLEANUP_INTERVAL_MS = 30000L - private const val STALE_DEVICE_TIMEOUT_MS = 60000L - private const val LID_CLOSE_TIMEOUT_MS = 2000L + private const val CLEANUP_INTERVAL_MS = 10000L + private const val STALE_DEVICE_TIMEOUT_MS = 15000L + private const val LID_CLOSE_TIMEOUT_MS = 2500L } } 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 index 145c89f37..633ee4665 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothCryptography.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothCryptography.kt @@ -1,7 +1,7 @@ /* - * LibrePods - AirPods liberated from Apple's ecosystem + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2025 LibrePods Contributors + * Copyright (C) 2025 LibrePods contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published @@ -18,6 +18,7 @@ package me.kavishdevar.librepods.utils +import android.annotation.SuppressLint import javax.crypto.Cipher import javax.crypto.spec.SecretKeySpec @@ -26,10 +27,10 @@ import javax.crypto.spec.SecretKeySpec * 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 @@ -44,11 +45,12 @@ object BluetoothCryptography { /** * 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() @@ -60,7 +62,7 @@ object BluetoothCryptography { /** * 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 diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt index f5130ea54..3e91c2838 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt @@ -34,6 +34,7 @@ import android.content.Intent import android.content.SharedPreferences import android.os.ParcelUuid import android.util.Log +import androidx.core.content.edit import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -76,7 +77,7 @@ object CrossDevice { CoroutineScope(Dispatchers.IO).launch { Log.d("CrossDevice", "Initializing CrossDevice") sharedPreferences = context.getSharedPreferences("packet_logs", Context.MODE_PRIVATE) - sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply() + sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false)} this@CrossDevice.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter this@CrossDevice.bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser // startAdvertising() @@ -111,7 +112,7 @@ object CrossDevice { } } - @SuppressLint("MissingPermission") + @SuppressLint("MissingPermission", "unused") private fun startAdvertising() { CoroutineScope(Dispatchers.IO).launch { val settings = AdvertiseSettings.Builder() @@ -147,7 +148,7 @@ object CrossDevice { fun setAirPodsConnected(connected: Boolean) { if (connected) { isAvailable = false - sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply() + sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false)} clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_CONNECTED.packet) } else { clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DISCONNECTED.packet) @@ -168,7 +169,7 @@ object CrossDevice { val logEntry = "$source: $packetHex" val logs = sharedPreferences.getStringSet(PACKET_LOG_KEY, mutableSetOf())?.toMutableSet() ?: mutableSetOf() logs.add(logEntry) - sharedPreferences.edit().putStringSet(PACKET_LOG_KEY, logs).apply() + sharedPreferences.edit { putStringSet(PACKET_LOG_KEY, logs)} } @SuppressLint("MissingPermission") @@ -199,7 +200,7 @@ object CrossDevice { notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!) break } else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet) || packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet + CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) { - ServiceManager.getService()?.disconnect() + ServiceManager.getService()?.disconnectForCD() disconnectionRequested = true CoroutineScope(Dispatchers.IO).launch { delay(1000) @@ -207,10 +208,10 @@ object CrossDevice { } } else if (packet.contentEquals(CrossDevicePackets.AIRPODS_CONNECTED.packet)) { isAvailable = true - sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply() + sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", true)} } else if (packet.contentEquals(CrossDevicePackets.AIRPODS_DISCONNECTED.packet)) { isAvailable = false - sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply() + sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false)} } else if (packet.contentEquals(CrossDevicePackets.REQUEST_BATTERY_BYTES.packet)) { Log.d("CrossDevice", "Received battery request, battery data: ${batteryBytes.joinToString("") { "%02x".format(it) }}") sendRemotePacket(batteryBytes) @@ -223,7 +224,7 @@ object CrossDevice { } else { if (packet.sliceArray(0..3).contentEquals(CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) { isAvailable = true - sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply() + sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", true) } if (packet.size % 2 == 0) { val half = packet.size / 2 if (packet.sliceArray(0 until half).contentEquals(packet.sliceArray(half until packet.size))) { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/DragUtils.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/DragUtils.kt new file mode 100644 index 000000000..55b1eef23 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/DragUtils.kt @@ -0,0 +1,102 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.utils + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.util.fastFirstOrNull + +suspend fun PointerInputScope.inspectDragGestures( + onDragStart: (down: PointerInputChange) -> Unit = {}, + onDragEnd: (change: PointerInputChange) -> Unit = {}, + onDragCancel: () -> Unit = {}, + onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit +) { + awaitEachGesture { + val initialDown = awaitFirstDown(false, PointerEventPass.Initial) + + val down = awaitFirstDown(false) + + onDragStart(down) + onDrag(initialDown, Offset.Zero) + val upEvent = + drag( + pointerId = initialDown.id, + onDrag = { onDrag(it, it.positionChange()) } + ) + if (upEvent == null) { + onDragCancel() + } else { + onDragEnd(upEvent) + } + } +} + +private suspend inline fun AwaitPointerEventScope.drag( + pointerId: PointerId, + onDrag: (PointerInputChange) -> Unit +): PointerInputChange? { + val isPointerUp = currentEvent.changes.fastFirstOrNull { it.id == pointerId }?.pressed != true + if (isPointerUp) { + return null + } + var pointer = pointerId + while (true) { + val change = awaitDragOrUp(pointer) ?: return null + if (change.isConsumed) { + return null + } + if (change.changedToUpIgnoreConsumed()) { + return change + } + onDrag(change) + pointer = change.id + } +} + +private suspend inline fun AwaitPointerEventScope.awaitDragOrUp( + pointerId: PointerId +): PointerInputChange? { + var pointer = pointerId + while (true) { + val event = awaitPointerEvent() + val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null + if (dragEvent.changedToUpIgnoreConsumed()) { + val otherDown = event.changes.fastFirstOrNull { it.pressed } + if (otherDown == null) { + return dragEvent + } else { + pointer = otherDown.id + } + } else { + val hasDragged = dragEvent.previousPosition != dragEvent.position + if (hasDragged) { + return dragEvent + } + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt index 03e975a7c..804d4cb61 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt @@ -1,3 +1,21 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + @file:OptIn(ExperimentalEncodingApi::class) package me.kavishdevar.librepods.utils @@ -21,7 +39,6 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.pow -@RequiresApi(Build.VERSION_CODES.Q) class GestureDetector( private val airPodsService: AirPodsService ) { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt index 711bcbec8..b7d406842 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt @@ -1,3 +1,21 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + @file:Suppress("PrivatePropertyName") package me.kavishdevar.librepods.utils @@ -12,8 +30,7 @@ import androidx.annotation.RequiresApi import me.kavishdevar.librepods.R import java.util.concurrent.atomic.AtomicBoolean -@RequiresApi(Build.VERSION_CODES.Q) -class GestureFeedback(private val context: Context) { +class GestureFeedback(context: Context) { private val TAG = "GestureFeedback" @@ -25,8 +42,7 @@ class GestureFeedback(private val context: Context) { AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .setFlags(AudioAttributes.FLAG_LOW_LATENCY or - AudioAttributes.FLAG_AUDIBILITY_ENFORCED) + .setFlags(AudioAttributes.FLAG_AUDIBILITY_ENFORCED) .build() ) .build() diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt index 859f49b7e..ad2d41841 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt @@ -1,3 +1,21 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + package me.kavishdevar.librepods.utils import kotlinx.coroutines.flow.MutableStateFlow diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/HearingAidEnums.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/HearingAidEnums.kt new file mode 100644 index 000000000..b405f8432 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/HearingAidEnums.kt @@ -0,0 +1,190 @@ +/* + * 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 Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.utils + +import android.util.Log +import androidx.compose.runtime.MutableState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.ByteOrder + +private const val TAG = "HearingAidUtils" + +data class HearingAidSettings( + val leftEQ: FloatArray, + val rightEQ: FloatArray, + val leftAmplification: Float, + val rightAmplification: Float, + val leftTone: Float, + val rightTone: Float, + val leftConversationBoost: Boolean, + val rightConversationBoost: Boolean, + val leftAmbientNoiseReduction: Float, + val rightAmbientNoiseReduction: Float, + val netAmplification: Float, + val balance: Float, + val ownVoiceAmplification: Float +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as HearingAidSettings + + if (leftAmplification != other.leftAmplification) return false + if (rightAmplification != other.rightAmplification) return false + if (leftTone != other.leftTone) return false + if (rightTone != other.rightTone) return false + if (leftConversationBoost != other.leftConversationBoost) return false + if (rightConversationBoost != other.rightConversationBoost) return false + if (leftAmbientNoiseReduction != other.leftAmbientNoiseReduction) return false + if (rightAmbientNoiseReduction != other.rightAmbientNoiseReduction) return false + if (!leftEQ.contentEquals(other.leftEQ)) return false + if (!rightEQ.contentEquals(other.rightEQ)) return false + if (ownVoiceAmplification != other.ownVoiceAmplification) return false + + return true + } + + override fun hashCode(): Int { + var result = leftAmplification.hashCode() + result = 31 * result + rightAmplification.hashCode() + result = 31 * result + leftTone.hashCode() + result = 31 * result + rightTone.hashCode() + result = 31 * result + leftConversationBoost.hashCode() + result = 31 * result + rightConversationBoost.hashCode() + result = 31 * result + leftAmbientNoiseReduction.hashCode() + result = 31 * result + rightAmbientNoiseReduction.hashCode() + result = 31 * result + leftEQ.contentHashCode() + result = 31 * result + rightEQ.contentHashCode() + result = 31 * result + ownVoiceAmplification.hashCode() + return result + } +} + +fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? { + if (data.size < 104) return null + val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) + + buffer.get() // skip 0x02 + buffer.get() // skip 0x02 + buffer.getShort() // skip 0x60 0x00 + + val leftEQ = FloatArray(8) + for (i in 0..7) { + leftEQ[i] = buffer.float + } + val leftAmplification = buffer.float + val leftTone = buffer.float + val leftConvFloat = buffer.float + val leftConversationBoost = leftConvFloat > 0.5f + val leftAmbientNoiseReduction = buffer.float + + val rightEQ = FloatArray(8) + for (i in 0..7) { + rightEQ[i] = buffer.float + } + val rightAmplification = buffer.float + val rightTone = buffer.float + val rightConvFloat = buffer.float + val rightConversationBoost = rightConvFloat > 0.5f + val rightAmbientNoiseReduction = buffer.float + + val ownVoiceAmplification = buffer.float + + val avg = (leftAmplification + rightAmplification) / 2 + val amplification = avg.coerceIn(-1f, 1f) + val diff = rightAmplification - leftAmplification + val balance = diff.coerceIn(-1f, 1f) + + return HearingAidSettings( + leftEQ = leftEQ, + rightEQ = rightEQ, + leftAmplification = leftAmplification, + rightAmplification = rightAmplification, + leftTone = leftTone, + rightTone = rightTone, + leftConversationBoost = leftConversationBoost, + rightConversationBoost = rightConversationBoost, + leftAmbientNoiseReduction = leftAmbientNoiseReduction, + rightAmbientNoiseReduction = rightAmbientNoiseReduction, + netAmplification = amplification, + balance = balance, + ownVoiceAmplification = ownVoiceAmplification + ) +} + +fun sendHearingAidSettings( + attManager: ATTManager, + hearingAidSettings: HearingAidSettings, + debounceJob: MutableState +) { + debounceJob.value?.cancel() + debounceJob.value = CoroutineScope(Dispatchers.IO).launch { + delay(100) + try { + val currentData = attManager.read(ATTHandles.HEARING_AID) + Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}") + if (currentData.size < 104) { + Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings") + return@launch + } + val buffer = ByteBuffer.wrap(currentData).order(ByteOrder.LITTLE_ENDIAN) + + // for some reason + buffer.put(2, 0x64) + + // Left EQ + for (i in 0..7) { + buffer.putFloat(4 + i * 4, hearingAidSettings.leftEQ[i]) + } + + // Left ear adjustments + buffer.putFloat(36, hearingAidSettings.leftAmplification) + buffer.putFloat(40, hearingAidSettings.leftTone) + buffer.putFloat(44, if (hearingAidSettings.leftConversationBoost) 1.0f else 0.0f) + buffer.putFloat(48, hearingAidSettings.leftAmbientNoiseReduction) + + // Right EQ + for (i in 0..7) { + buffer.putFloat(52 + i * 4, hearingAidSettings.rightEQ[i]) + } + + // Right ear adjustments + buffer.putFloat(84, hearingAidSettings.rightAmplification) + buffer.putFloat(88, hearingAidSettings.rightTone) + buffer.putFloat(92, if (hearingAidSettings.rightConversationBoost) 1.0f else 0.0f) + buffer.putFloat(96, hearingAidSettings.rightAmbientNoiseReduction) + + // Own voice amplification + buffer.putFloat(100, hearingAidSettings.ownVoiceAmplification) + + Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}") + + attManager.write(ATTHandles.HEARING_AID, currentData) + } catch (e: IOException) { + e.printStackTrace() + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt index 978294ea0..0d143a7e6 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt @@ -33,7 +33,6 @@ import android.content.IntentFilter import android.content.res.Resources import android.graphics.PixelFormat import android.graphics.drawable.GradientDrawable -import android.net.Uri import android.os.Build import android.os.Handler import android.os.Looper @@ -49,11 +48,12 @@ 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 import android.widget.ProgressBar import android.widget.TextView import android.widget.VideoView -import androidx.core.content.ContextCompat.getString +import androidx.core.net.toUri import androidx.dynamicanimation.animation.DynamicAnimation import androidx.dynamicanimation.animation.SpringAnimation import androidx.dynamicanimation.animation.SpringForce @@ -70,6 +70,7 @@ enum class IslandType { CONNECTED, TAKING_OVER, MOVED_TO_REMOTE, + MOVED_TO_OTHER_DEVICE, } class IslandWindow(private val context: Context) { @@ -107,7 +108,12 @@ class IslandWindow(private val context: Context) { private val batteryReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == AirPodsNotifications.BATTERY_DATA) { - val batteryList = intent.getParcelableArrayListExtra("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 { @@ -131,8 +137,8 @@ class IslandWindow(private val context: Context) { val leftLevel = leftBattery?.level ?: 0 val rightLevel = rightBattery?.level ?: 0 - val leftStatus = leftBattery?.status ?: BatteryStatus.DISCONNECTED - val rightStatus = rightBattery?.status ?: BatteryStatus.DISCONNECTED + leftBattery?.status ?: BatteryStatus.DISCONNECTED + rightBattery?.status ?: BatteryStatus.DISCONNECTED val batteryText = islandView.findViewById(R.id.island_battery_text) val batteryProgressBar = islandView.findViewById(R.id.island_battery_progress) @@ -155,8 +161,10 @@ class IslandWindow(private val context: Context) { } } - @SuppressLint("SetTextI18s", "ClickableViewAccessibility", "UnspecifiedRegisterReceiverFlag") - fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED) { + @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 @@ -173,10 +181,10 @@ class IslandWindow(private val context: Context) { val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT } when { - leftBattery?.level ?: 0 > 0 && rightBattery?.level ?: 0 > 0 -> + (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 + (leftBattery?.level ?: 0) > 0 -> leftBattery!!.level + (rightBattery?.level ?: 0) > 0 -> rightBattery!!.level batteryPercentage > 0 -> batteryPercentage else -> null } @@ -197,6 +205,26 @@ class IslandWindow(private val context: Context) { batteryProgressBar.isIndeterminate = false islandView.findViewById(R.id.island_device_name).text = name + val actionButton = islandView.findViewById(R.id.island_action_button) + val batteryBg = islandView.findViewById(R.id.island_battery_bg) + 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") + } + close() + } + batteryText.visibility = View.GONE + batteryProgressBar.visibility = View.GONE + batteryBg.visibility = View.GONE + } else { + actionButton.visibility = View.GONE + batteryText.visibility = View.VISIBLE + batteryProgressBar.visibility = View.VISIBLE + batteryBg.visibility = View.VISIBLE + } + val batteryIntentFilter = IntentFilter(AirPodsNotifications.BATTERY_DATA) batteryIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -280,7 +308,7 @@ class IslandWindow(private val context: Context) { if (isDraggingDown && deltaY > 0) { val stretchAmount = (deltaY * 0.5f).coerceAtMost(200f) - applyCustomStretchEffect(stretchAmount, deltaY) + applyCustomStretchEffect(stretchAmount) } } @@ -294,7 +322,7 @@ class IslandWindow(private val context: Context) { if (isBeingDragged) { val currentTranslationY = containerView.translationY - val significantVelocity = abs(yVelocity) > 800 + abs(yVelocity) > 800 val significantDrag = abs(dragDistance) > 80 when { @@ -323,18 +351,28 @@ class IslandWindow(private val context: Context) { when (type) { IslandType.CONNECTED -> { - islandView.findViewById(R.id.island_connected_text).text = getString(context, R.string.island_connected_text) + islandView.findViewById(R.id.island_connected_text).text = context.getString(R.string.island_connected_text) } IslandType.TAKING_OVER -> { - islandView.findViewById(R.id.island_connected_text).text = getString(context, R.string.island_taking_over_text) + islandView.findViewById(R.id.island_connected_text).text = context.getString(R.string.island_taking_over_text) } IslandType.MOVED_TO_REMOTE -> { - islandView.findViewById(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_remote_text) + islandView.findViewById(R.id.island_connected_text).text = context.getString(R.string.island_moved_to_remote_text) + } + IslandType.MOVED_TO_OTHER_DEVICE -> { + if (otherDeviceName == null || otherDeviceName.isEmpty()) { + e("IslandWindow", "Other device name is null or empty for MOVED_TO_OTHER_DEVICE type") + } + if (reversed) { + islandView.findViewById(R.id.island_connected_text).text = context.getString(R.string.island_moved_to_other_device_reversed_text) + } else { + islandView.findViewById(R.id.island_connected_text).text = context.getString(R.string.island_moved_to_other_device_text, otherDeviceName) + } } } val videoView = islandView.findViewById(R.id.island_video_view) - val videoUri = Uri.parse("android.resource://me.kavishdevar.librepods/${R.raw.island}") + val videoUri = "android.resource://me.kavishdevar.librepods/${R.raw.island}".toUri() videoView.setVideoURI(videoUri) videoView.setOnPreparedListener { mediaPlayer -> mediaPlayer.isLooping = true @@ -382,13 +420,13 @@ class IslandWindow(private val context: Context) { } } - private fun applyCustomStretchEffect(stretchAmount: Float, dragY: Float) { + private fun applyCustomStretchEffect(stretchAmount: Float) { try { val mainLayout = islandView.findViewById(R.id.island_window_layout) - val connectedText = islandView.findViewById(R.id.island_connected_text) + islandView.findViewById(R.id.island_connected_text) val deviceText = islandView.findViewById(R.id.island_device_name) - val batteryView = islandView.findViewById(R.id.island_battery_container) - val videoView = islandView.findViewById(R.id.island_video_view) + islandView.findViewById(R.id.island_battery_container) + islandView.findViewById(R.id.island_video_view) val stretchFactor = 1f + (stretchAmount / 300f).coerceAtMost(4.0f) val newMinHeight = (initialHeight * stretchFactor).toInt() @@ -443,7 +481,7 @@ class IslandWindow(private val context: Context) { .setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY) .setStiffness(dynamicStiffness) - resetStretchEffects(velocity) + resetStretchEffects() if (params != null) { params!!.height = WindowManager.LayoutParams.WRAP_CONTENT @@ -457,7 +495,7 @@ class IslandWindow(private val context: Context) { springAnimation.start() } - private fun resetStretchEffects(velocity: Float) { + private fun resetStretchEffects() { try { val mainLayout = islandView.findViewById(R.id.island_window_layout) val deviceText = islandView.findViewById(R.id.island_device_name) @@ -547,7 +585,7 @@ class IslandWindow(private val context: Context) { stretchAnimator.interpolator = OvershootInterpolator(0.5f) stretchAnimator.addUpdateListener { animation -> val progress = animation.animatedValue as Float - animateCustomStretch(progress, expandDuration) + animateCustomStretch(progress) } val normalizeAnimator = ValueAnimator.ofFloat(1.0f, 0.0f) @@ -574,7 +612,7 @@ class IslandWindow(private val context: Context) { normalizeAnimator.start() } - private fun animateCustomStretch(progress: Float, duration: Long) { + private fun animateCustomStretch(progress: Float) { try { val mainLayout = islandView.findViewById(R.id.island_window_layout) val connectedText = islandView.findViewById(R.id.island_connected_text) @@ -604,6 +642,10 @@ class IslandWindow(private val context: Context) { } fun close() { + if (Looper.myLooper() != Looper.getMainLooper()) { + Handler(Looper.getMainLooper()).post { close() } + return + } try { if (isClosing) return isClosing = true @@ -611,13 +653,13 @@ class IslandWindow(private val context: Context) { try { context.unregisterReceiver(batteryReceiver) } catch (e: Exception) { - e.printStackTrace() +// e.printStackTrace() } ServiceManager.getService()?.islandOpen = false autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return) - resetStretchEffects(0f) + resetStretchEffects() val videoView = islandView.findViewById(R.id.island_video_view) try { @@ -647,7 +689,15 @@ class IslandWindow(private val context: Context) { } private fun cleanupAndRemoveView() { - containerView.visibility = View.GONE + if (Looper.myLooper() != Looper.getMainLooper()) { + Handler(Looper.getMainLooper()).post { cleanupAndRemoveView() } + return + } + try { + containerView.visibility = View.GONE + } catch (e: Exception) { + e("IslandWindow", "Error setting visibility: $e") + } try { if (containerView.parent != null) { windowManager.removeView(containerView) @@ -662,6 +712,10 @@ class IslandWindow(private val context: Context) { } fun forceClose() { + if (Looper.myLooper() != Looper.getMainLooper()) { + Handler(Looper.getMainLooper()).post { forceClose() } + return + } try { if (isClosing) return isClosing = true @@ -669,7 +723,7 @@ class IslandWindow(private val context: Context) { try { context.unregisterReceiver(batteryReceiver) } catch (e: Exception) { - // Silent catch - receiver might already be unregistered + e.printStackTrace() } ServiceManager.getService()?.islandOpen = false diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/KotlinModule.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/KotlinModule.kt index 1fc4d8ab5..e2d5046cf 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/KotlinModule.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/KotlinModule.kt @@ -1,5 +1,6 @@ package me.kavishdevar.librepods.utils +import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.content.pm.ApplicationInfo @@ -17,6 +18,7 @@ import android.widget.FrameLayout import android.widget.ImageButton import android.widget.ImageView import android.widget.LinearLayout +import androidx.core.net.toUri import io.github.libxposed.api.XposedInterface import io.github.libxposed.api.XposedInterface.AfterHookCallback import io.github.libxposed.api.XposedModule @@ -27,7 +29,7 @@ import io.github.libxposed.api.annotations.XposedHooker private const val TAG = "AirPodsHook" private lateinit var module: KotlinModule - +@SuppressLint("DiscouragedApi", "PrivateApi") class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModule(base, param) { init { Log.i(TAG, "AirPodsHook module initialized at :: ${param.processName}") @@ -60,7 +62,7 @@ class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModul val updateIconMethod = headerControllerClass.getDeclaredMethod( "updateIcon", - android.widget.ImageView::class.java, + ImageView::class.java, String::class.java) hook(updateIconMethod, BluetoothIconHooker::class.java) @@ -89,7 +91,7 @@ class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModul val updateIconMethod = headerControllerClass.getDeclaredMethod( "updateIcon", - android.widget.ImageView::class.java, + ImageView::class.java, String::class.java) hook(updateIconMethod, BluetoothIconHooker::class.java) @@ -209,7 +211,7 @@ class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModul val imageView = callback.args[0] as ImageView val iconUri = callback.args[1] as String - val uri = android.net.Uri.parse(iconUri) + val uri = iconUri.toUri() if (uri.toString().startsWith("android.resource://me.kavishdevar.librepods")) { Log.i(TAG, "Handling AirPods icon URI: $uri") @@ -571,10 +573,10 @@ class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModul addView(icon) - if (isSelected) { - background = createSelectedBackground(context) + background = if (isSelected) { + createSelectedBackground(context) } else { - background = null + null } setOnClickListener { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/LogCollector.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/LogCollector.kt index 8ce65ab6d..d03ca48c6 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/LogCollector.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/LogCollector.kt @@ -1,5 +1,5 @@ /* - * LibrePods - AirPods liberated from Apple's ecosystem + * LibrePods - AirPods liberated from Apple’s ecosystem * * Copyright (C) 2025 LibrePods contributors * @@ -19,8 +19,6 @@ package me.kavishdevar.librepods.utils import android.content.Context -import android.content.Intent -import android.net.Uri import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.BufferedReader @@ -30,7 +28,7 @@ import java.io.InputStreamReader class LogCollector(private val context: Context) { private var isCollecting = false private var logProcess: Process? = null - + suspend fun openXposedSettings(context: Context) { withContext(Dispatchers.IO) { val command = if (android.os.Build.VERSION.SDK_INT >= 29) { @@ -38,42 +36,50 @@ class LogCollector(private val context: Context) { } else { "am broadcast -a android.provider.Telephony.SECRET_CODE -d android_secret_code://5776733 android" } - + executeRootCommand(command) } } - + suspend fun clearLogs() { withContext(Dispatchers.IO) { executeRootCommand("logcat -c") } } - + suspend fun killBluetoothService() { withContext(Dispatchers.IO) { executeRootCommand("killall com.android.bluetooth") } } - + + private suspend fun getBluetoothUID(): String? { + val pkgs = listOf("com.android.bluetooth", "com.google.android.bluetooth") + for (pkg in pkgs) { + val uid = executeRootCommand( + "dumpsys package $pkg | grep -m 1 \"uid=\" | sed -E 's/.*uid=([0-9]+).*/\\1/'" + ).trim() + if (uid.isNotEmpty()) return uid + } + return null + } + private suspend fun getPackageUIDs(): Pair { return withContext(Dispatchers.IO) { - val btUid = executeRootCommand("dumpsys package com.android.bluetooth | grep -m 1 \"uid=\" | sed -E 's/.*uid=([0-9]+).*/\\1/'") - .trim() - .takeIf { it.isNotEmpty() } - + val btUid = getBluetoothUID() val appUid = executeRootCommand("dumpsys package me.kavishdevar.librepods | grep -m 1 \"uid=\" | sed -E 's/.*uid=([0-9]+).*/\\1/'") .trim() .takeIf { it.isNotEmpty() } - + Pair(btUid, appUid) } } - + suspend fun startLogCollection(listener: (String) -> Unit, connectionDetectedCallback: () -> Unit): String { return withContext(Dispatchers.IO) { isCollecting = true val (btUid, appUid) = getPackageUIDs() - + val uidFilter = buildString { if (!btUid.isNullOrEmpty() && !appUid.isNullOrEmpty()) { append("$btUid,$appUid") @@ -83,33 +89,33 @@ class LogCollector(private val context: Context) { append(appUid) } } - + val command = if (uidFilter.isNotEmpty()) { "su -c logcat --uid=$uidFilter -v threadtime" } else { "su -c logcat -v threadtime" } - + val logs = StringBuilder() try { logProcess = Runtime.getRuntime().exec(command) val reader = BufferedReader(InputStreamReader(logProcess!!.inputStream)) var line: String? = null var connectionDetected = false - + while (isCollecting && reader.readLine().also { line = it } != null) { line?.let { if (it.contains("")) { connectionDetected = true @@ -118,7 +124,7 @@ class LogCollector(private val context: Context) { connectionDetected = true connectionDetectedCallback() } else if (it.contains("")) { - } + } else if (it.contains("AirPodsService") && it.contains("Connected to device")) { connectionDetected = true connectionDetectedCallback() @@ -139,17 +145,17 @@ class LogCollector(private val context: Context) { logs.append("Error collecting logs: ${e.message}").append("\n") e.printStackTrace() } - + logs.toString() } } - + fun stopLogCollection() { isCollecting = false logProcess?.destroy() logProcess = null } - + suspend fun saveLogToInternalStorage(fileName: String, content: String): File? { return withContext(Dispatchers.IO) { try { @@ -157,7 +163,7 @@ class LogCollector(private val context: Context) { if (!logsDir.exists()) { logsDir.mkdir() } - + val file = File(logsDir, fileName) file.writeText(content) return@withContext file @@ -167,31 +173,31 @@ class LogCollector(private val context: Context) { } } } - + suspend fun addLogMarker(markerType: LogMarkerType, details: String = "") { withContext(Dispatchers.IO) { val timestamp = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", java.util.Locale.US) .format(java.util.Date()) - + val marker = when (markerType) { LogMarkerType.START -> " [$timestamp] Beginning connection test" LogMarkerType.SUCCESS -> " [$timestamp] Connection test completed successfully" LogMarkerType.FAILURE -> " [$timestamp] Connection test failed" LogMarkerType.CUSTOM -> " [$timestamp]" } - + val command = "log -t AirPodsService \"$marker\"" executeRootCommand(command) } } - + enum class LogMarkerType { START, SUCCESS, FAILURE, CUSTOM } - + private suspend fun executeRootCommand(command: String): String { return withContext(Dispatchers.IO) { try { @@ -199,11 +205,11 @@ class LogCollector(private val context: Context) { val reader = BufferedReader(InputStreamReader(process.inputStream)) val output = StringBuilder() var line: String? - + while (reader.readLine().also { line = it } != null) { output.append(line).append("\n") } - + process.waitFor() output.toString() } catch (e: Exception) { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt index c7193495a..778a09783 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt @@ -26,6 +26,7 @@ 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 @@ -41,12 +42,30 @@ object MediaController { private val handler = Handler(Looper.getMainLooper()) private lateinit var preferenceChangeListener: SharedPreferences.OnSharedPreferenceChangeListener - var pausedForCrossDevice = false + var pausedWhileTakingOver = false + var pausedForOtherDevice = false + + private var lastSelfActionAt: Long = 0L + private const val SELF_ACTION_IGNORE_MS = 800L + private const val PLAYBACK_DEBOUNCE_MS = 300L + private var lastPlaybackCallbackAt: Long = 0L + private var lastKnownIsMusicActive: Boolean? = null + + private const val PAUSED_FOR_OTHER_DEVICE_CLEAR_MS = 500L + private val clearPausedForOtherDeviceRunnable = Runnable { + pausedForOtherDevice = false + Log.d("MediaController", "Cleared pausedForOtherDevice after timeout, resuming normal playback monitoring") + } private var relativeVolume: Boolean = false private var conversationalAwarenessVolume: Int = 2 private var conversationalAwarenessPauseMusic: Boolean = false + var recentlyLostOwnership: Boolean = false + + private var lastPlayWithReplay: Boolean = false + private var lastPlayTime: Long = 0L + fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) { if (this::audioManager.isInitialized) { return @@ -81,17 +100,103 @@ object MediaController { @RequiresApi(Build.VERSION_CODES.R) override fun onPlaybackConfigChanged(configs: MutableList?) { super.onPlaybackConfigChanged(configs) - Log.d("MediaController", "Playback config changed, iPausedTheMedia: $iPausedTheMedia") + val now = SystemClock.uptimeMillis() + val isActive = audioManager.isMusicActive + Log.d("MediaController", "Playback config changed, iPausedTheMedia: $iPausedTheMedia, isActive: $isActive, pausedForOtherDevice: $pausedForOtherDevice, lastKnownIsMusicActive: $lastKnownIsMusicActive") + + if (!isActive && lastPlayWithReplay && now - lastPlayTime < 2500L) { + Log.d("MediaController", "Music paused shortly after play with replay; retrying play") + lastPlayWithReplay = false + sendPlay() + lastKnownIsMusicActive = true + return + } + + if (now - lastPlaybackCallbackAt < PLAYBACK_DEBOUNCE_MS) { + Log.d("MediaController", "Ignoring playback callback due to debounce (${now - lastPlaybackCallbackAt}ms)") + lastPlaybackCallbackAt = now + return + } + lastPlaybackCallbackAt = now + + if (now - lastSelfActionAt < SELF_ACTION_IGNORE_MS) { + Log.d("MediaController", "Ignoring playback callback because it's likely caused by our own action (${now - lastSelfActionAt}ms since last self-action)") + lastKnownIsMusicActive = isActive + return + } + + Log.d("MediaController", "Configs received: ${configs?.size ?: 0} configurations") + val currentActiveContentTypes = configs?.flatMap { config -> + Log.d("MediaController", "Processing config: ${config}, audioAttributes: ${config.audioAttributes}") + config.audioAttributes?.let { attrs -> + val contentType = attrs.contentType + Log.d("MediaController", "Config content type: $contentType") + listOf(contentType) + } ?: run { + Log.d("MediaController", "Config has no audioAttributes") + emptyList() + } + }?.toSet() ?: emptySet() + + Log.d("MediaController", "Current active content types: $currentActiveContentTypes") + + val hasNewMusicOrMovie = currentActiveContentTypes.any { contentType -> + contentType == android.media.AudioAttributes.CONTENT_TYPE_MUSIC || + contentType == android.media.AudioAttributes.CONTENT_TYPE_MOVIE + } + + Log.d("MediaController", "Has new music or movie: $hasNewMusicOrMovie") + + if (pausedForOtherDevice) { + handler.removeCallbacks(clearPausedForOtherDeviceRunnable) + handler.postDelayed(clearPausedForOtherDeviceRunnable, PAUSED_FOR_OTHER_DEVICE_CLEAR_MS) + + if (isActive) { + Log.d("MediaController", "Detected play while pausedForOtherDevice; attempting to take over") + if (!recentlyLostOwnership && hasNewMusicOrMovie) { + pausedForOtherDevice = false + userPlayedTheMedia = true + if (!pausedWhileTakingOver) { + ServiceManager.getService()?.takeOver("music") + } + } else { + Log.d("MediaController", "Skipping take-over due to recent ownership loss or no new music/movie") + } + } else { + Log.d("MediaController", "Still not active while pausedForOtherDevice; will clear state after timeout") + } + + lastKnownIsMusicActive = isActive + return + } + if (configs != null && !iPausedTheMedia) { - Log.d("MediaController", "Seems like the user changed the state of media themselves, now I won't play until the ear detection pauses it.") + ServiceManager.getService()?.aacpManager?.sendMediaInformataion( + ServiceManager.getService()?.localMac ?: return, + isActive + ) + Log.d("MediaController", "User changed media state themselves; will wait for ear detection pause before auto-play") handler.postDelayed({ userPlayedTheMedia = audioManager.isMusicActive - }, 7) // i have no idea why android sends an event a hundred times after the user does something. + if (audioManager.isMusicActive) { + pausedForOtherDevice = false + } + }, 7) } - Log.d("MediaController", "pausedforcrossdevice: $pausedForCrossDevice") - if (!pausedForCrossDevice && audioManager.isMusicActive) { - ServiceManager.getService()?.takeOver("music") + + Log.d("MediaController", "pausedWhileTakingOver: $pausedWhileTakingOver") + if (!pausedWhileTakingOver && isActive && hasNewMusicOrMovie) { + if (lastKnownIsMusicActive != true) { + if (!recentlyLostOwnership) { + Log.d("MediaController", "Music/movie is active and not pausedWhileTakingOver; requesting takeOver") + ServiceManager.getService()?.takeOver("music") + } else { + Log.d("MediaController", "Skipping take-over due to recent ownership loss") + } + } } + + lastKnownIsMusicActive = hasNewMusicOrMovie && isActive } } @@ -126,6 +231,7 @@ object MediaController { KeyEvent.KEYCODE_MEDIA_PREVIOUS ) ) + lastSelfActionAt = SystemClock.uptimeMillis() } @Synchronized @@ -143,6 +249,7 @@ object MediaController { KeyEvent.KEYCODE_MEDIA_NEXT ) ) + lastSelfActionAt = SystemClock.uptimeMillis() } @Synchronized @@ -163,13 +270,18 @@ object MediaController { KeyEvent.KEYCODE_MEDIA_PAUSE ) ) + lastSelfActionAt = SystemClock.uptimeMillis() } } @Synchronized - fun sendPlay() { - Log.d("MediaController", "Sending play with iPausedTheMedia: $iPausedTheMedia") - if (iPausedTheMedia) { + fun sendPlay(replayWhenPaused: Boolean = false, force: Boolean = false) { + Log.d("MediaController", "Sending play with iPausedTheMedia: $iPausedTheMedia, replayWhenPaused: $replayWhenPaused, force: $force") + if (replayWhenPaused) { + lastPlayWithReplay = true + lastPlayTime = SystemClock.uptimeMillis() + } + if (iPausedTheMedia || force) { // very creative, ik. thanks. Log.d("MediaController", "Sending play and setting userPlayedTheMedia to false") userPlayedTheMedia = false audioManager.dispatchMediaKeyEvent( @@ -184,14 +296,15 @@ object MediaController { KeyEvent.KEYCODE_MEDIA_PLAY ) ) + lastSelfActionAt = SystemClock.uptimeMillis() } if (!audioManager.isMusicActive) { Log.d("MediaController", "Setting iPausedTheMedia to false") iPausedTheMedia = false } - if (pausedForCrossDevice) { - Log.d("MediaController", "Setting pausedForCrossDevice to false") - pausedForCrossDevice = false + if (pausedWhileTakingOver) { + Log.d("MediaController", "Setting pausedWhileTakingOver to false") + pausedWhileTakingOver = false } } @@ -209,7 +322,7 @@ object MediaController { } else { initialVolume!! } - smoothVolumeTransition(initialVolume!!, targetVolume.toInt()) + smoothVolumeTransition(initialVolume!!, targetVolume) if (conversationalAwarenessPauseMusic) { sendPause(force = true) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/PopupWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/PopupWindow.kt index d050cbad8..1d54aa951 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/PopupWindow.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/PopupWindow.kt @@ -49,7 +49,6 @@ import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.constants.Battery import me.kavishdevar.librepods.constants.BatteryComponent import me.kavishdevar.librepods.constants.BatteryStatus -import kotlin.collections.find @SuppressLint("InflateParams", "ClickableViewAccessibility") class PopupWindow( @@ -172,7 +171,12 @@ class PopupWindow( batteryUpdateReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == AirPodsNotifications.BATTERY_DATA) { - val batteryList = intent.getParcelableArrayListExtra("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) } @@ -272,7 +276,4 @@ class PopupWindow( onCloseCallback() } } - - val isShowing: Boolean - get() = mView.parent != null && !isClosing } 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 index e6a28e82e..50ede42eb 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt @@ -45,6 +45,7 @@ class RadareOffsetFinder(context: Context) { 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" @@ -74,10 +75,11 @@ class RadareOffsetFinder(context: Context) { try { val process = Runtime.getRuntime().exec(arrayOf( "su", "-c", - "setprop $HOOK_OFFSET_PROP '' && " + - "setprop $CFG_REQ_OFFSET_PROP '' && " + - "setprop $CSM_CONFIG_OFFSET_PROP '' && " + - "setprop $PEER_INFO_REQ_OFFSET_PROP ''" + "/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() @@ -92,6 +94,44 @@ class RadareOffsetFinder(context: Context) { } 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 { + 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") @@ -122,7 +162,7 @@ class RadareOffsetFinder(context: Context) { } _progressState.value = ProgressState.CheckingExisting try { - val process = Runtime.getRuntime().exec(arrayOf("getprop", HOOK_OFFSET_PROP)) + val process = Runtime.getRuntime().exec(arrayOf("/system/bin/getprop", HOOK_OFFSET_PROP)) val reader = BufferedReader(InputStreamReader(process.inputStream)) val propValue = reader.readLine() process.waitFor() @@ -422,6 +462,8 @@ class RadareOffsetFinder(context: Context) { // 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) @@ -473,7 +515,7 @@ class RadareOffsetFinder(context: Context) { if (offset > 0L) { val hexString = "0x${offset.toString(16)}" Runtime.getRuntime().exec(arrayOf( - "su", "-c", "setprop $CFG_REQ_OFFSET_PROP $hexString" + "su", "-c", "/system/bin/setprop $CFG_REQ_OFFSET_PROP $hexString" )).waitFor() Log.d(TAG, "Saved l2cu_process_our_cfg_req offset: $hexString") } @@ -518,7 +560,7 @@ class RadareOffsetFinder(context: Context) { if (offset > 0L) { val hexString = "0x${offset.toString(16)}" Runtime.getRuntime().exec(arrayOf( - "su", "-c", "setprop $CSM_CONFIG_OFFSET_PROP $hexString" + "su", "-c", "/system/bin/setprop $CSM_CONFIG_OFFSET_PROP $hexString" )).waitFor() Log.d(TAG, "Saved l2c_csm_config offset: $hexString") } @@ -563,7 +605,7 @@ class RadareOffsetFinder(context: Context) { if (offset > 0L) { val hexString = "0x${offset.toString(16)}" Runtime.getRuntime().exec(arrayOf( - "su", "-c", "setprop $PEER_INFO_REQ_OFFSET_PROP $hexString" + "su", "-c", "/system/bin/setprop $PEER_INFO_REQ_OFFSET_PROP $hexString" )).waitFor() Log.d(TAG, "Saved l2cu_send_peer_info_req offset: $hexString") } @@ -572,19 +614,64 @@ class RadareOffsetFinder(context: Context) { } } + 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", "setprop $HOOK_OFFSET_PROP $hexString" + "su", "-c", "/system/bin/setprop $HOOK_OFFSET_PROP $hexString" )) val exitCode = process.waitFor() if (exitCode == 0) { val verifyProcess = Runtime.getRuntime().exec(arrayOf( - "getprop", HOOK_OFFSET_PROP + "/system/bin/getprop", HOOK_OFFSET_PROP )) val propValue = BufferedReader(InputStreamReader(verifyProcess.inputStream)).readLine() verifyProcess.waitFor() @@ -613,4 +700,57 @@ class RadareOffsetFinder(context: Context) { 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/utils/TransparencyUtils.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/TransparencyUtils.kt new file mode 100644 index 000000000..0ceaa9ea6 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/TransparencyUtils.kt @@ -0,0 +1,179 @@ +/* + * LibrePods - AirPods liberated from Apple’s ecosystem + * + * Copyright (C) 2025 LibrePods contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.utils + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.ByteOrder + +data class TransparencySettings( + val enabled: Boolean, + val leftEQ: FloatArray, + val rightEQ: FloatArray, + val leftAmplification: Float, + val rightAmplification: Float, + val leftTone: Float, + val rightTone: Float, + val leftConversationBoost: Boolean, + val rightConversationBoost: Boolean, + val leftAmbientNoiseReduction: Float, + val rightAmbientNoiseReduction: Float, + val netAmplification: Float, + val balance: Float, + val ownVoiceAmplification: Float? = null +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as TransparencySettings + + if (enabled != other.enabled) return false + if (leftAmplification != other.leftAmplification) return false + if (rightAmplification != other.rightAmplification) return false + if (leftTone != other.leftTone) return false + if (rightTone != other.rightTone) return false + if (leftConversationBoost != other.leftConversationBoost) return false + if (rightConversationBoost != other.rightConversationBoost) return false + if (leftAmbientNoiseReduction != other.leftAmbientNoiseReduction) return false + if (rightAmbientNoiseReduction != other.rightAmbientNoiseReduction) return false + if (!leftEQ.contentEquals(other.leftEQ)) return false + if (!rightEQ.contentEquals(other.rightEQ)) return false + if (ownVoiceAmplification != other.ownVoiceAmplification) return false + + return true + } + + override fun hashCode(): Int { + var result = enabled.hashCode() + result = 31 * result + leftAmplification.hashCode() + result = 31 * result + rightAmplification.hashCode() + result = 31 * result + leftTone.hashCode() + result = 31 * result + rightTone.hashCode() + result = 31 * result + leftConversationBoost.hashCode() + result = 31 * result + rightConversationBoost.hashCode() + result = 31 * result + leftAmbientNoiseReduction.hashCode() + result = 31 * result + rightAmbientNoiseReduction.hashCode() + result = 31 * result + leftEQ.contentHashCode() + result = 31 * result + rightEQ.contentHashCode() + result = 31 * result + (ownVoiceAmplification?.hashCode() ?: 0) + return result + } +} + +fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings { + val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) + + val enabled = buffer.float + + val leftEQ = FloatArray(8) + for (i in 0..7) { + leftEQ[i] = buffer.float + } + val leftAmplification = buffer.float + val leftTone = buffer.float + val leftConvFloat = buffer.float + val leftConversationBoost = leftConvFloat > 0.5f + val leftAmbientNoiseReduction = buffer.float + + val rightEQ = FloatArray(8) + for (i in 0..7) { + rightEQ[i] = buffer.float + } + + val rightAmplification = buffer.float + val rightTone = buffer.float + val rightConvFloat = buffer.float + val rightConversationBoost = rightConvFloat > 0.5f + val rightAmbientNoiseReduction = buffer.float + + val ownVoiceAmplification = if (buffer.remaining() >= 4) { + buffer.float + } else { + null + } + + val avg = (leftAmplification + rightAmplification) / 2 + val amplification = avg.coerceIn(-1f, 1f) + val diff = rightAmplification - leftAmplification + val balance = diff.coerceIn(-1f, 1f) + + return TransparencySettings( + enabled = enabled > 0.5f, + leftEQ = leftEQ, + rightEQ = rightEQ, + leftAmplification = leftAmplification, + rightAmplification = rightAmplification, + leftTone = leftTone, + rightTone = rightTone, + leftConversationBoost = leftConversationBoost, + rightConversationBoost = rightConversationBoost, + leftAmbientNoiseReduction = leftAmbientNoiseReduction, + rightAmbientNoiseReduction = rightAmbientNoiseReduction, + netAmplification = amplification, + balance = balance, + ownVoiceAmplification = ownVoiceAmplification + ) +} + +private var debounceJob: Job? = null + +fun sendTransparencySettings(attManager: ATTManager, transparencySettings: TransparencySettings) { + debounceJob?.cancel() + debounceJob = CoroutineScope(Dispatchers.IO).launch { + delay(100) + try { + val buffer = ByteBuffer.allocate( + if (transparencySettings.ownVoiceAmplification != null) 104 else 100 + ).order(ByteOrder.LITTLE_ENDIAN) + + buffer.putFloat(if (transparencySettings.enabled) 1.0f else 0.0f) + + for (eq in transparencySettings.leftEQ) { + buffer.putFloat(eq) + } + buffer.putFloat(transparencySettings.leftAmplification) + buffer.putFloat(transparencySettings.leftTone) + buffer.putFloat(if (transparencySettings.leftConversationBoost) 1.0f else 0.0f) + buffer.putFloat(transparencySettings.leftAmbientNoiseReduction) + + for (eq in transparencySettings.rightEQ) { + buffer.putFloat(eq) + } + buffer.putFloat(transparencySettings.rightAmplification) + buffer.putFloat(transparencySettings.rightTone) + buffer.putFloat(if (transparencySettings.rightConversationBoost) 1.0f else 0.0f) + buffer.putFloat(transparencySettings.rightAmbientNoiseReduction) + + if (transparencySettings.ownVoiceAmplification != null) { + buffer.putFloat(transparencySettings.ownVoiceAmplification) + } + + val data = buffer.array() + attManager.write(ATTHandles.TRANSPARENCY, value = data) + } catch (e: IOException) { + e.printStackTrace() + } + } +} diff --git a/android/app/src/main/res/drawable/pro_2.png b/android/app/src/main/res-apple/drawable/airpods_1.png similarity index 100% rename from android/app/src/main/res/drawable/pro_2.png rename to android/app/src/main/res-apple/drawable/airpods_1.png diff --git a/android/app/src/main/res/drawable/pro_2_buds.png b/android/app/src/main/res-apple/drawable/airpods_1_buds.png similarity index 100% rename from android/app/src/main/res/drawable/pro_2_buds.png rename to android/app/src/main/res-apple/drawable/airpods_1_buds.png diff --git a/android/app/src/main/res-apple/drawable/airpods_1_case.png b/android/app/src/main/res-apple/drawable/airpods_1_case.png new file mode 100644 index 000000000..d904a2fed Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_1_case.png differ diff --git a/android/app/src/main/res/drawable/pro_2_left.png b/android/app/src/main/res-apple/drawable/airpods_1_left.png similarity index 100% rename from android/app/src/main/res/drawable/pro_2_left.png rename to android/app/src/main/res-apple/drawable/airpods_1_left.png diff --git a/android/app/src/main/res/drawable/pro_2_right.png b/android/app/src/main/res-apple/drawable/airpods_1_right.png similarity index 100% rename from android/app/src/main/res/drawable/pro_2_right.png rename to android/app/src/main/res-apple/drawable/airpods_1_right.png diff --git a/android/app/src/main/res-apple/drawable/airpods_2.png b/android/app/src/main/res-apple/drawable/airpods_2.png new file mode 100644 index 000000000..681ee750a Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_2.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_2_buds.png b/android/app/src/main/res-apple/drawable/airpods_2_buds.png new file mode 100644 index 000000000..8bea6a255 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_2_buds.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_2_case.png b/android/app/src/main/res-apple/drawable/airpods_2_case.png new file mode 100644 index 000000000..d904a2fed Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_2_case.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_2_left.png b/android/app/src/main/res-apple/drawable/airpods_2_left.png new file mode 100644 index 000000000..88e13948e Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_2_left.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_2_right.png b/android/app/src/main/res-apple/drawable/airpods_2_right.png new file mode 100644 index 000000000..76495bee9 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_2_right.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_3.png b/android/app/src/main/res-apple/drawable/airpods_3.png new file mode 100644 index 000000000..681ee750a Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_3.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_3_buds.png b/android/app/src/main/res-apple/drawable/airpods_3_buds.png new file mode 100644 index 000000000..8bea6a255 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_3_buds.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_3_case.png b/android/app/src/main/res-apple/drawable/airpods_3_case.png new file mode 100644 index 000000000..d904a2fed Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_3_case.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_3_left.png b/android/app/src/main/res-apple/drawable/airpods_3_left.png new file mode 100644 index 000000000..88e13948e Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_3_left.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_3_right.png b/android/app/src/main/res-apple/drawable/airpods_3_right.png new file mode 100644 index 000000000..76495bee9 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_3_right.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_4.png b/android/app/src/main/res-apple/drawable/airpods_4.png new file mode 100644 index 000000000..681ee750a Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_4.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_4_buds.png b/android/app/src/main/res-apple/drawable/airpods_4_buds.png new file mode 100644 index 000000000..8bea6a255 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_4_buds.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_4_case.png b/android/app/src/main/res-apple/drawable/airpods_4_case.png new file mode 100644 index 000000000..d904a2fed Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_4_case.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_4_left.png b/android/app/src/main/res-apple/drawable/airpods_4_left.png new file mode 100644 index 000000000..88e13948e Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_4_left.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_4_right.png b/android/app/src/main/res-apple/drawable/airpods_4_right.png new file mode 100644 index 000000000..76495bee9 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_4_right.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_pro_1.png b/android/app/src/main/res-apple/drawable/airpods_pro_1.png new file mode 100644 index 000000000..681ee750a Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_pro_1.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_pro_1_buds.png b/android/app/src/main/res-apple/drawable/airpods_pro_1_buds.png new file mode 100644 index 000000000..8bea6a255 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_pro_1_buds.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_pro_1_case.png b/android/app/src/main/res-apple/drawable/airpods_pro_1_case.png new file mode 100644 index 000000000..d904a2fed Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_pro_1_case.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_pro_1_left.png b/android/app/src/main/res-apple/drawable/airpods_pro_1_left.png new file mode 100644 index 000000000..88e13948e Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_pro_1_left.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_pro_1_right.png b/android/app/src/main/res-apple/drawable/airpods_pro_1_right.png new file mode 100644 index 000000000..76495bee9 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_pro_1_right.png differ 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 new file mode 100644 index 000000000..681ee750a Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_pro_2.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/airpods_pro_2_buds.png new file mode 100644 index 000000000..8bea6a255 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_pro_2_buds.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_pro_2_case.png b/android/app/src/main/res-apple/drawable/airpods_pro_2_case.png new file mode 100644 index 000000000..d904a2fed Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_pro_2_case.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_pro_2_left.png b/android/app/src/main/res-apple/drawable/airpods_pro_2_left.png new file mode 100644 index 000000000..88e13948e Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_pro_2_left.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_pro_2_right.png b/android/app/src/main/res-apple/drawable/airpods_pro_2_right.png new file mode 100644 index 000000000..76495bee9 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_pro_2_right.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_pro_3.png b/android/app/src/main/res-apple/drawable/airpods_pro_3.png new file mode 100644 index 000000000..681ee750a Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_pro_3.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_pro_3_buds.png b/android/app/src/main/res-apple/drawable/airpods_pro_3_buds.png new file mode 100644 index 000000000..8bea6a255 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_pro_3_buds.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_pro_3_case.png b/android/app/src/main/res-apple/drawable/airpods_pro_3_case.png new file mode 100644 index 000000000..d904a2fed Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_pro_3_case.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_pro_3_left.png b/android/app/src/main/res-apple/drawable/airpods_pro_3_left.png new file mode 100644 index 000000000..88e13948e Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_pro_3_left.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_pro_3_right.png b/android/app/src/main/res-apple/drawable/airpods_pro_3_right.png new file mode 100644 index 000000000..76495bee9 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_pro_3_right.png differ diff --git a/android/app/src/main/res/font/sf_pro.otf b/android/app/src/main/res-apple/font/sf_pro.otf similarity index 100% rename from android/app/src/main/res/font/sf_pro.otf rename to android/app/src/main/res-apple/font/sf_pro.otf diff --git a/android/app/src/main/res/drawable/app_widget_background.xml b/android/app/src/main/res/drawable/app_widget_background.xml new file mode 100644 index 000000000..785445c66 --- /dev/null +++ b/android/app/src/main/res/drawable/app_widget_background.xml @@ -0,0 +1,10 @@ + + + + + + \ 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 new file mode 100644 index 000000000..11a09f9ba --- /dev/null +++ b/android/app/src/main/res/drawable/app_widget_inner_view_background.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ic_undo.xml b/android/app/src/main/res/drawable/ic_undo.xml new file mode 100644 index 000000000..a3f745b76 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_undo.xml @@ -0,0 +1,11 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_undo_button_bg.xml b/android/app/src/main/res/drawable/ic_undo_button_bg.xml new file mode 100644 index 000000000..c238ba1a8 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_undo_button_bg.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android/app/src/main/res/drawable/pro_2_case.png b/android/app/src/main/res/drawable/pro_2_case.png deleted file mode 100644 index f104605c4..000000000 Binary files a/android/app/src/main/res/drawable/pro_2_case.png and /dev/null differ diff --git a/android/app/src/main/res/layout/island_window.xml b/android/app/src/main/res/layout/island_window.xml index fd8b8ef59..c804737b5 100644 --- a/android/app/src/main/res/layout/island_window.xml +++ b/android/app/src/main/res/layout/island_window.xml @@ -12,7 +12,9 @@ android:orientation="horizontal" android:outlineAmbientShadowColor="#4EFFFFFF" android:outlineSpotShadowColor="#4EFFFFFF" - android:padding="8dp"> + android:padding="8dp" + android:clipToPadding="false" + android:clipChildren="false"> + android:gravity="center" + android:clipChildren="false"> + + - \ No newline at end of file + diff --git a/android/app/src/main/res/layout/noise_control_widget.xml b/android/app/src/main/res/layout/noise_control_widget.xml index 6b53bb1bd..baa2f4bdd 100644 --- a/android/app/src/main/res/layout/noise_control_widget.xml +++ b/android/app/src/main/res/layout/noise_control_widget.xml @@ -4,12 +4,14 @@ android:id="@+id/noise_control_widget" android:layout_width="match_parent" android:layout_height="match_parent" - android:theme="@style/Theme.LibrePods.AppWidgetContainer"> + android:theme="@style/Theme.LibrePods.AppWidgetContainer" + tools:ignore="ContentDescription,NestedWeights"> + android:textSize="12sp" + tools:ignore="NestedWeights" /> + android:textSize="12sp" + tools:ignore="NestedWeights" /> + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 000000000..8fde45638 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/raw/aboutlibraries.json b/android/app/src/main/res/raw/aboutlibraries.json new file mode 100644 index 000000000..b507c7bf1 --- /dev/null +++ b/android/app/src/main/res/raw/aboutlibraries.json @@ -0,0 +1,4521 @@ +{ + "libraries": [ + { + "uniqueId": "androidx.activity:activity", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.10.1", + "description": "Provides the base Activity subclass and the relevant hooks to build a composable structure on top.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Activity", + "website": "https://developer.android.com/jetpack/androidx/releases/activity#1.10.1", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.activity:activity-compose", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.10.1", + "description": "Compose integration with Activity", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Activity Compose", + "website": "https://developer.android.com/jetpack/androidx/releases/activity#1.10.1", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.activity:activity-ktx", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.10.1", + "description": "Kotlin extensions for 'activity' artifact", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Activity Kotlin Extensions", + "website": "https://developer.android.com/jetpack/androidx/releases/activity#1.10.1", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.annotation:annotation", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.1", + "description": "Provides source annotations for tooling and readability.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Annotation", + "website": "https://developer.android.com/jetpack/androidx/releases/annotation#1.9.1", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.annotation:annotation-experimental", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.4.1", + "description": "Java annotation for use on unstable Android API surfaces. When used in conjunction with the Experimental annotation lint checks, this annotation provides functional parity with Kotlin's Experimental annotation.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Experimental annotation", + "website": "https://developer.android.com/jetpack/androidx/releases/annotation#1.4.1", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.annotation:annotation-jvm", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.1", + "description": "Provides source annotations for tooling and readability.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Annotation", + "website": "https://developer.android.com/jetpack/androidx/releases/annotation#1.9.1", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.appcompat:appcompat", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.7.0", + "description": "Provides backwards-compatible implementations of UI-related Android SDK functionality, including dark mode and Material theming.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "AppCompat", + "website": "https://developer.android.com/jetpack/androidx/releases/appcompat#1.7.0", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.appcompat:appcompat-resources", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.7.0", + "description": "Provides backward-compatible implementations of resource-related Android SDKfunctionality, including color state list theming.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "AppCompat Resources", + "website": "https://developer.android.com/jetpack/androidx/releases/appcompat#1.7.0", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.arch.core:core-common", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.2.0", + "description": "Android Arch-Common", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Android Arch-Common", + "website": "https://developer.android.com/jetpack/androidx/releases/arch-core#2.2.0", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.arch.core:core-runtime", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.2.0", + "description": "Android Arch-Runtime", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Android Arch-Runtime", + "website": "https://developer.android.com/jetpack/androidx/releases/arch-core#2.2.0", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.autofill:autofill", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.0.0", + "description": "AndroidX Autofill", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "http://source.android.com" + }, + "name": "AndroidX Autofill", + "website": "https://developer.android.com/jetpack/androidx", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.cardview:cardview", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.0.0", + "description": "Android Support CardView v7", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "http://source.android.com" + }, + "name": "Support CardView v7", + "website": "http://developer.android.com/tools/extras/support-library.html", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.collection:collection", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.5.0", + "description": "Standalone efficient collections.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "collections", + "website": "https://developer.android.com/jetpack/androidx/releases/collection#1.5.0", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.collection:collection-jvm", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.5.0", + "description": "Standalone efficient collections.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "collections", + "website": "https://developer.android.com/jetpack/androidx/releases/collection#1.5.0", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.collection:collection-ktx", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.5.0", + "description": "Kotlin extensions for 'collection' artifact", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Collections Kotlin Extensions", + "website": "https://developer.android.com/jetpack/androidx/releases/collection#1.5.0", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.animation:animation", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose animation library", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Animation", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-animation#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.animation:animation-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose animation library", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Animation", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-animation#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.animation:animation-core", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Animation engine and animation primitives that are the building blocks of the Compose animation library", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Animation Core", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-animation#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.animation:animation-core-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Animation engine and animation primitives that are the building blocks of the Compose animation library", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Animation Core", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-animation#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.foundation:foundation", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Higher level abstractions of the Compose UI primitives. This library is design system agnostic, providing the high-level building blocks for both application and design-system developers", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Foundation", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.foundation:foundation-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Higher level abstractions of the Compose UI primitives. This library is design system agnostic, providing the high-level building blocks for both application and design-system developers", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Foundation", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.foundation:foundation-layout", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose layout implementations", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Layouts", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.foundation:foundation-layout-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose layout implementations", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Layouts", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.material3:material3", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.3.2", + "description": "Compose Material You Design Components library", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Material3 Components", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-material3#1.3.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.material3:material3-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.3.2", + "description": "Compose Material You Design Components library", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Material3 Components", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-material3#1.3.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.material:material", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.7.8", + "description": "Compose Material Design Components library", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Material Components", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-material#1.7.8", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.material:material-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.7.8", + "description": "Compose Material Design Components library", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Material Components", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-material#1.7.8", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.material:material-icons-core", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.7.8", + "description": "Compose Material Design core icons. This module contains the most commonly used set of Material icons.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Material Icons Core", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-material#1.7.8", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.material:material-icons-core-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.7.8", + "description": "Compose Material Design core icons. This module contains the most commonly used set of Material icons.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Material Icons Core", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-material#1.7.8", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.material:material-ripple", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.8.2", + "description": "Material ripple used to build interactive components", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Material Ripple", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-material#1.8.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.material:material-ripple-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.8.2", + "description": "Material ripple used to build interactive components", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Material Ripple", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-material#1.8.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.runtime:runtime", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Tree composition support for code generated by the Compose compiler plugin and corresponding public API", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Runtime", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.runtime:runtime-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Tree composition support for code generated by the Compose compiler plugin and corresponding public API", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Runtime", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.runtime:runtime-annotation", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Provides Compose-specific annotations used by the compiler and tooling", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Runtime Annotation", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.runtime:runtime-annotation-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Provides Compose-specific annotations used by the compiler and tooling", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Runtime Annotation", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.runtime:runtime-saveable", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose components that allow saving and restoring the local ui state", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Saveable", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.runtime:runtime-saveable-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose components that allow saving and restoring the local ui state", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Saveable", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.ui:ui", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose UI primitives. This library contains the primitives that form the Compose UI Toolkit, such as drawing, measurement and layout.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose UI", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.ui:ui-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose UI primitives. This library contains the primitives that form the Compose UI Toolkit, such as drawing, measurement and layout.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose UI", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.ui:ui-geometry", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose classes related to dimensions without units", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Geometry", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.ui:ui-geometry-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose classes related to dimensions without units", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Geometry", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.ui:ui-graphics", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose graphics", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Graphics", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.ui:ui-graphics-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose graphics", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Graphics", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.ui:ui-text", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose Text primitives and utilities", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose UI Text", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.ui:ui-text-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose Text primitives and utilities", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose UI Text", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.ui:ui-tooling", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose tooling library. This library exposes information to our tools for better IDE support.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Tooling", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.ui:ui-tooling-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose tooling library. This library exposes information to our tools for better IDE support.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Tooling", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.ui:ui-tooling-data", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose tooling library data. This library provides data about compose for different tooling purposes.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Tooling Data", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.ui:ui-tooling-data-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose tooling library data. This library provides data about compose for different tooling purposes.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Tooling Data", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.ui:ui-tooling-preview", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose tooling library API. This library provides the API required to declare @Preview composables in user apps.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose UI Preview Tooling", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.ui:ui-tooling-preview-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose tooling library API. This library provides the API required to declare @Preview composables in user apps.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose UI Preview Tooling", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.ui:ui-unit", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose classes for simple units", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Unit", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.ui:ui-unit-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Compose classes for simple units", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Unit", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.ui:ui-util", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Internal Compose utilities used by other modules", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Util", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose.ui:ui-util-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.9.2", + "description": "Internal Compose utilities used by other modules", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Util", + "website": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.9.2", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.compose:compose-bom", + "funding": [ + + ], + "developers": [ + + ], + "artifactVersion": "2025.04.00", + "description": "A compatible set of Jetpack Compose libraries.", + "name": "Jetpack Compose Libraries BOM", + "website": "https://developer.android.com/jetpack", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.concurrent:concurrent-futures", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.1.0", + "description": "Androidx implementation of Guava's ListenableFuture", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "http://source.android.com" + }, + "name": "AndroidX Futures", + "website": "https://developer.android.com/topic/libraries/architecture/index.html", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.constraintlayout:constraintlayout", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.2.1", + "description": "This library offers a flexible and adaptable way to position and animate widgets", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "ConstraintLayout", + "website": "https://developer.android.com/jetpack/androidx/releases/constraintlayout#2.2.1", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.constraintlayout:constraintlayout-core", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.1.1", + "description": "This library contains engines and algorithms for constraint based layout and complex animations (it is used by the ConstraintLayout library)", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "ConstraintLayout Core", + "website": "https://developer.android.com/jetpack/androidx/releases/constraintlayout#1.1.1", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.coordinatorlayout:coordinatorlayout", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.1.0", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren't a part of the framework APIs. Compatible on devices running API 14 or later.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "http://source.android.com" + }, + "name": "Support Coordinator Layout", + "website": "https://developer.android.com/jetpack/androidx", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.core:core", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.17.0", + "description": "Provides backward-compatible implementations of Android platform APIs and features.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Core", + "website": "https://developer.android.com/jetpack/androidx/releases/core#1.17.0", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.core:core-ktx", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.17.0", + "description": "Kotlin extensions for 'core' artifact", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Core Kotlin Extensions", + "website": "https://developer.android.com/jetpack/androidx/releases/core#1.17.0", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.core:core-viewtree", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.0.0", + "description": "Provides ViewTree extensions packaged for use by other core androidx libraries", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "androidx.core:core-viewtree", + "website": "https://developer.android.com/jetpack/androidx/releases/core#1.0.0", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.cursoradapter:cursoradapter", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.0.0", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren't a part of the framework APIs. Compatible on devices running API 14 or later.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "http://source.android.com" + }, + "name": "Support Cursor Adapter", + "website": "http://developer.android.com/tools/extras/support-library.html", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.customview:customview", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.1.0", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren't a part of the framework APIs. Compatible on devices running API 14 or later.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "http://source.android.com" + }, + "name": "Support Custom View", + "website": "https://developer.android.com/jetpack/androidx", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.customview:customview-poolingcontainer", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.0.0", + "description": "Utilities for listening to the lifecycle of containers that manage their child Views' lifecycle, such as RecyclerView", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "androidx.customview:poolingcontainer", + "website": "https://developer.android.com/jetpack/androidx/releases/customview#1.0.0", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.databinding:viewbinding", + "funding": [ + + ], + "developers": [ + + ], + "artifactVersion": "8.13.0", + "description": "", + "name": "androidx.databinding:viewbinding", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.drawerlayout:drawerlayout", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.1.1", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren't a part of the framework APIs. Compatible on devices running API 14 or later.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "http://source.android.com" + }, + "name": "Support Drawer Layout", + "website": "https://developer.android.com/jetpack/androidx", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.dynamicanimation:dynamicanimation", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.1.0", + "description": "Physics-based animation in support library, where the animations are driven by physics force. You can use this Animation library to create smooth and realistic animations.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "DynamicAnimation", + "website": "https://developer.android.com/jetpack/androidx/releases/dynamicanimation#1.1.0", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.emoji2:emoji2", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.4.0", + "description": "Core library to enable emoji compatibility in Kitkat and newer devices to avoid the empty emoji characters.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Emoji2", + "website": "https://developer.android.com/jetpack/androidx/releases/emoji2#1.4.0", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.emoji2:emoji2-views-helper", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.4.0", + "description": "Provide helper classes for Emoji2 views.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Emoji2 Views Helper", + "website": "https://developer.android.com/jetpack/androidx/releases/emoji2#1.4.0", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.fragment:fragment", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.6.2", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren't a part of the framework APIs. Compatible on devices running API 14 or later.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Support fragment", + "website": "https://developer.android.com/jetpack/androidx/releases/fragment#1.6.2", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.fragment:fragment-ktx", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.6.2", + "description": "Kotlin extensions for 'fragment' artifact", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Fragment Kotlin Extensions", + "website": "https://developer.android.com/jetpack/androidx/releases/fragment#1.6.2", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.graphics:graphics-path", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.0.1", + "description": "Query segment data for android.graphics.Path objects", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Android Graphics Path", + "website": "https://developer.android.com/jetpack/androidx/releases/graphics#1.0.1", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.graphics:graphics-shapes", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.0.1", + "description": "create and render rounded polygonal shapes", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Graphics Shapes", + "website": "https://developer.android.com/jetpack/androidx/releases/graphics#1.0.1", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.graphics:graphics-shapes-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.0.1", + "description": "create and render rounded polygonal shapes", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Graphics Shapes", + "website": "https://developer.android.com/jetpack/androidx/releases/graphics#1.0.1", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.interpolator:interpolator", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.0.0", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren't a part of the framework APIs. Compatible on devices running API 14 or later.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "http://source.android.com" + }, + "name": "Support Interpolators", + "website": "http://developer.android.com/tools/extras/support-library.html", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.lifecycle:lifecycle-common", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Android Lifecycle-Common", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Lifecycle-Common", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.lifecycle:lifecycle-common-java8", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Android Lifecycle-Common for Java 8 Language", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Lifecycle-Common for Java 8", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.lifecycle:lifecycle-common-jvm", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Android Lifecycle-Common", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Lifecycle-Common", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.lifecycle:lifecycle-livedata", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Android Lifecycle LiveData", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Lifecycle LiveData", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.lifecycle:lifecycle-livedata-core", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Android Lifecycle LiveData Core", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Lifecycle LiveData Core", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.lifecycle:lifecycle-livedata-core-ktx", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Kotlin extensions for 'livedata-core' artifact", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "LiveData Core Kotlin Extensions", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.lifecycle:lifecycle-livedata-ktx", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Kotlin extensions for 'livedata' artifact", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "LiveData Kotlin Extensions", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.lifecycle:lifecycle-process", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Android Lifecycle Process", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Lifecycle Process", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.lifecycle:lifecycle-runtime", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Android Lifecycle Runtime", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Lifecycle Runtime", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.lifecycle:lifecycle-runtime-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Android Lifecycle Runtime", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Lifecycle Runtime", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.lifecycle:lifecycle-runtime-compose", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Compose integration with Lifecycle", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Lifecycle Runtime Compose", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.lifecycle:lifecycle-runtime-compose-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Compose integration with Lifecycle", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Lifecycle Runtime Compose", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.lifecycle:lifecycle-runtime-ktx", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Kotlin extensions for 'lifecycle' artifact", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Lifecycle Kotlin Extensions", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.lifecycle:lifecycle-runtime-ktx-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Kotlin extensions for 'lifecycle' artifact", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Lifecycle Kotlin Extensions", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.lifecycle:lifecycle-viewmodel", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Android Lifecycle ViewModel", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Lifecycle ViewModel", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.lifecycle:lifecycle-viewmodel-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Android Lifecycle ViewModel", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Lifecycle ViewModel", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.lifecycle:lifecycle-viewmodel-compose", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Compose integration with Lifecycle ViewModel", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Lifecycle ViewModel Compose", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.lifecycle:lifecycle-viewmodel-compose-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Compose integration with Lifecycle ViewModel", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Lifecycle ViewModel Compose", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.lifecycle:lifecycle-viewmodel-ktx", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Kotlin extensions for 'viewmodel' artifact", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Lifecycle ViewModel Kotlin Extensions", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.lifecycle:lifecycle-viewmodel-savedstate", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Android Lifecycle ViewModel", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Lifecycle ViewModel with SavedState", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.lifecycle:lifecycle-viewmodel-savedstate-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Android Lifecycle ViewModel", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Lifecycle ViewModel with SavedState", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.loader:loader", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.0.0", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren't a part of the framework APIs. Compatible on devices running API 14 or later.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "http://source.android.com" + }, + "name": "Support loader", + "website": "http://developer.android.com/tools/extras/support-library.html", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.navigation:navigation-common", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Android Navigation-Common", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Navigation Common", + "website": "https://developer.android.com/jetpack/androidx/releases/navigation#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.navigation:navigation-common-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Android Navigation-Common", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Navigation Common", + "website": "https://developer.android.com/jetpack/androidx/releases/navigation#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.navigation:navigation-compose", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Compose integration with Navigation", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Navigation", + "website": "https://developer.android.com/jetpack/androidx/releases/navigation#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.navigation:navigation-compose-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Compose integration with Navigation", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Compose Navigation", + "website": "https://developer.android.com/jetpack/androidx/releases/navigation#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.navigation:navigation-fragment", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Android Navigation-Fragment", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Navigation Fragment", + "website": "https://developer.android.com/jetpack/androidx/releases/navigation#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.navigation:navigation-runtime", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Android Navigation-Runtime", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Navigation Runtime", + "website": "https://developer.android.com/jetpack/androidx/releases/navigation#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.navigation:navigation-runtime-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "2.9.4", + "description": "Android Navigation-Runtime", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Navigation Runtime", + "website": "https://developer.android.com/jetpack/androidx/releases/navigation#2.9.4", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.profileinstaller:profileinstaller", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.4.0", + "description": "Allows libraries to prepopulate ahead of time compilation traces to be read by ART", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Profile Installer", + "website": "https://developer.android.com/jetpack/androidx/releases/profileinstaller#1.4.0", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.recyclerview:recyclerview", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.4.0", + "description": "Android Support RecyclerView", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "RecyclerView", + "website": "https://developer.android.com/jetpack/androidx/releases/recyclerview#1.4.0", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.resourceinspection:resourceinspection-annotation", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.0.1", + "description": "Annotation processors for Android resource and layout inspection", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Android Resource Inspection - Annotations", + "website": "https://developer.android.com/jetpack/androidx/releases/resourceinspection#1.0.1", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.savedstate:savedstate", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.3.3", + "description": "Android Lifecycle Saved State", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Saved State", + "website": "https://developer.android.com/jetpack/androidx/releases/savedstate#1.3.3", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.savedstate:savedstate-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.3.3", + "description": "Android Lifecycle Saved State", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Saved State", + "website": "https://developer.android.com/jetpack/androidx/releases/savedstate#1.3.3", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.savedstate:savedstate-compose", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.3.3", + "description": "Compose integration with Saved State", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Saved State Compose", + "website": "https://developer.android.com/jetpack/androidx/releases/savedstate#1.3.3", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.savedstate:savedstate-compose-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.3.3", + "description": "Compose integration with Saved State", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Saved State Compose", + "website": "https://developer.android.com/jetpack/androidx/releases/savedstate#1.3.3", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.savedstate:savedstate-ktx", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.3.3", + "description": "Kotlin extensions for 'savedstate' artifact", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "SavedState Kotlin Extensions", + "website": "https://developer.android.com/jetpack/androidx/releases/savedstate#1.3.3", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.slidingpanelayout:slidingpanelayout", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.2.0", + "description": "SlidingPaneLayout offers a responsive, two pane layout that automatically switches between overlapping panes on smaller devices to a side by side view on larger devices.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Support Sliding Pane Layout", + "website": "https://developer.android.com/jetpack/androidx/releases/slidingpanelayout#1.2.0", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.startup:startup-runtime", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.1.1", + "description": "Android App Startup Runtime", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Android App Startup Runtime", + "website": "https://developer.android.com/jetpack/androidx/releases/startup#1.1.1", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.tracing:tracing", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.3.0", + "description": "Android Tracing", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Tracing", + "website": "https://developer.android.com/jetpack/androidx/releases/tracing#1.3.0", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.tracing:tracing-android", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.3.0", + "description": "Android Tracing", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Tracing", + "website": "https://developer.android.com/jetpack/androidx/releases/tracing#1.3.0", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.tracing:tracing-ktx", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.3.0", + "description": "Android Tracing", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Tracing Kotlin Extensions", + "website": "https://developer.android.com/jetpack/androidx/releases/tracing#1.3.0", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.transition:transition", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.5.0", + "description": "Android Transition Support Library", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Transition", + "website": "https://developer.android.com/jetpack/androidx/releases/transition#1.5.0", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "name": "The Android Open Source Project" + } + }, + { + "uniqueId": "androidx.vectordrawable:vectordrawable", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.1.0", + "description": "Android Support VectorDrawable", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "http://source.android.com" + }, + "name": "Support VectorDrawable", + "website": "https://developer.android.com/jetpack/androidx", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.vectordrawable:vectordrawable-animated", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.1.0", + "description": "Android Support AnimatedVectorDrawable", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "http://source.android.com" + }, + "name": "Support AnimatedVectorDrawable", + "website": "https://developer.android.com/jetpack/androidx", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.versionedparcelable:versionedparcelable", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.1.1", + "description": "Provides a stable but relatively compact binary serialization format that can be passed across processes or persisted safely.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "http://source.android.com" + }, + "name": "VersionedParcelable", + "website": "http://developer.android.com/tools/extras/support-library.html", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.viewpager2:viewpager2", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.1.0-beta02", + "description": "AndroidX Widget ViewPager2", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "ViewPager2", + "website": "https://developer.android.com/jetpack/androidx/releases/viewpager2#1.1.0-beta02", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.viewpager:viewpager", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.0.0", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren't a part of the framework APIs. Compatible on devices running API 14 or later.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "http://source.android.com" + }, + "name": "Support View Pager", + "website": "http://developer.android.com/tools/extras/support-library.html", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "androidx.window:window", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.0.0", + "description": "WindowManager Jetpack library. Currently only provides additional functionality on foldable devices.", + "scm": { + "connection": "scm:git:https://android.googlesource.com/platform/frameworks/support", + "url": "https://cs.android.com/androidx/platform/frameworks/support" + }, + "name": "Jetpack WindowManager Library", + "website": "https://developer.android.com/jetpack/androidx/releases/window#1.0.0", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "com.google.accompanist:accompanist-permissions", + "funding": [ + + ], + "developers": [ + { + "name": "Google" + } + ], + "artifactVersion": "0.36.0", + "description": "Utilities for Jetpack Compose", + "scm": { + "connection": "scm:git:git://github.com/google/accompanist.git", + "url": "https://github.com/google/accompanist/", + "developerConnection": "scm:git:git://github.com/google/accompanist.git" + }, + "name": "Accompanist Permissions", + "website": "https://github.com/google/accompanist/", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "com.google.android.material:material", + "funding": [ + + ], + "developers": [ + { + "name": "The Android Open Source Project" + } + ], + "artifactVersion": "1.13.0", + "description": "Material Components for Android is a static library that you can add to your Android application in order to use APIs that provide implementations of the Material Design specification. Compatible on devices running API 21 or later.", + "scm": { + "connection": "scm:git:https://github.com/material-components/material-components-android.git", + "url": "https://github.com/material-components/material-components-android" + }, + "name": "Material Components for Android", + "website": "https://github.com/material-components/material-components-android", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "com.google.errorprone:error_prone_annotations", + "funding": [ + + ], + "developers": [ + { + "name": "Eddie Aftandilian" + } + ], + "artifactVersion": "2.15.0", + "description": "Error Prone is a static analysis tool for Java that catches common programming mistakes at compile-time.", + "scm": { + "connection": "scm:git:https://github.com/google/error-prone.git/error_prone_annotations", + "url": "https://github.com/google/error-prone/error_prone_annotations", + "developerConnection": "scm:git:git@github.com:google/error-prone.git/error_prone_annotations" + }, + "name": "error-prone annotations", + "website": "https://errorprone.info/error_prone_annotations", + "licenses": [ + "Apache-2.0" + ], + "organization": { + "url": "http://www.google.com", + "name": "Google LLC" + } + }, + { + "uniqueId": "com.google.guava:listenablefuture", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "http://www.google.com", + "name": "Kevin Bourrillion" + } + ], + "artifactVersion": "1.0", + "description": "Contains Guava's com.google.common.util.concurrent.ListenableFuture class,\n without any of its other classes -- but is also available in a second\n \"version\" that omits the class to avoid conflicts with the copy in Guava\n itself. The idea is:\n\n - If users want only ListenableFuture, they depend on listenablefuture-1.0.\n\n - If users want all of Guava, they depend on guava, which, as of Guava\n 27.0, depends on\n listenablefuture-9999.0-empty-to-avoid-conflict-with-guava. The 9999.0-...\n version number is enough for some build systems (notably, Gradle) to select\n that empty artifact over the \"real\" listenablefuture-1.0 -- avoiding a\n conflict with the copy of ListenableFuture in guava itself. If users are\n using an older version of Guava or a build system other than Gradle, they\n may see class conflicts. If so, they can solve them by manually excluding\n the listenablefuture artifact or manually forcing their build systems to\n use 9999.0-....", + "scm": { + "connection": "scm:git:https://github.com/google/guava.git/listenablefuture", + "url": "https://github.com/google/guava/listenablefuture", + "developerConnection": "scm:git:git@github.com:google/guava.git/listenablefuture" + }, + "name": "Guava ListenableFuture only", + "website": "https://github.com/google/guava/listenablefuture", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "com.mikepenz:aboutlibraries-android", + "funding": [ + + ], + "developers": [ + { + "name": "Mike Penz" + } + ], + "artifactVersion": "13.0.0-rc01", + "description": "AboutLibraries automatically detects all dependencies of a project and collects their information including the license. Optionally visualising it via the provided ui components.", + "scm": { + "connection": "scm:git@github.com:mikepenz/AboutLibraries.git", + "url": "https://github.com/mikepenz/AboutLibraries", + "developerConnection": "scm:git@github.com:mikepenz/AboutLibraries.git" + }, + "name": "AboutLibraries View Library", + "website": "https://github.com/mikepenz/AboutLibraries", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "com.mikepenz:aboutlibraries-compose-core", + "funding": [ + + ], + "developers": [ + { + "name": "Mike Penz" + } + ], + "artifactVersion": "13.0.0-rc01", + "description": "AboutLibraries automatically detects all dependencies of a project and collects their information including the license. Optionally visualising it via the provided ui components.", + "scm": { + "connection": "scm:git@github.com:mikepenz/AboutLibraries.git", + "url": "https://github.com/mikepenz/AboutLibraries", + "developerConnection": "scm:git@github.com:mikepenz/AboutLibraries.git" + }, + "name": "AboutLibraries Compose UI Library", + "website": "https://github.com/mikepenz/AboutLibraries", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "com.mikepenz:aboutlibraries-compose-core-android", + "funding": [ + + ], + "developers": [ + { + "name": "Mike Penz" + } + ], + "artifactVersion": "13.0.0-rc01", + "description": "AboutLibraries automatically detects all dependencies of a project and collects their information including the license. Optionally visualising it via the provided ui components.", + "scm": { + "connection": "scm:git@github.com:mikepenz/AboutLibraries.git", + "url": "https://github.com/mikepenz/AboutLibraries", + "developerConnection": "scm:git@github.com:mikepenz/AboutLibraries.git" + }, + "name": "AboutLibraries Compose UI Library", + "website": "https://github.com/mikepenz/AboutLibraries", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "com.mikepenz:aboutlibraries-compose-m3", + "funding": [ + + ], + "developers": [ + { + "name": "Mike Penz" + } + ], + "artifactVersion": "13.0.0-rc01", + "description": "AboutLibraries automatically detects all dependencies of a project and collects their information including the license. Optionally visualising it via the provided ui components.", + "scm": { + "connection": "scm:git@github.com:mikepenz/AboutLibraries.git", + "url": "https://github.com/mikepenz/AboutLibraries", + "developerConnection": "scm:git@github.com:mikepenz/AboutLibraries.git" + }, + "name": "AboutLibraries Compose Material 3 Library", + "website": "https://github.com/mikepenz/AboutLibraries", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "com.mikepenz:aboutlibraries-compose-m3-android", + "funding": [ + + ], + "developers": [ + { + "name": "Mike Penz" + } + ], + "artifactVersion": "13.0.0-rc01", + "description": "AboutLibraries automatically detects all dependencies of a project and collects their information including the license. Optionally visualising it via the provided ui components.", + "scm": { + "connection": "scm:git@github.com:mikepenz/AboutLibraries.git", + "url": "https://github.com/mikepenz/AboutLibraries", + "developerConnection": "scm:git@github.com:mikepenz/AboutLibraries.git" + }, + "name": "AboutLibraries Compose Material 3 Library", + "website": "https://github.com/mikepenz/AboutLibraries", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "com.mikepenz:aboutlibraries-core", + "funding": [ + + ], + "developers": [ + { + "name": "Mike Penz" + } + ], + "artifactVersion": "13.0.0-rc01", + "description": "AboutLibraries automatically detects all dependencies of a project and collects their information including the license. Optionally visualising it via the provided ui components.", + "scm": { + "connection": "scm:git@github.com:mikepenz/AboutLibraries.git", + "url": "https://github.com/mikepenz/AboutLibraries", + "developerConnection": "scm:git@github.com:mikepenz/AboutLibraries.git" + }, + "name": "AboutLibraries Core Library", + "website": "https://github.com/mikepenz/AboutLibraries", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "com.mikepenz:aboutlibraries-core-android", + "funding": [ + + ], + "developers": [ + { + "name": "Mike Penz" + } + ], + "artifactVersion": "13.0.0-rc01", + "description": "AboutLibraries automatically detects all dependencies of a project and collects their information including the license. Optionally visualising it via the provided ui components.", + "scm": { + "connection": "scm:git@github.com:mikepenz/AboutLibraries.git", + "url": "https://github.com/mikepenz/AboutLibraries", + "developerConnection": "scm:git@github.com:mikepenz/AboutLibraries.git" + }, + "name": "AboutLibraries Core Library", + "website": "https://github.com/mikepenz/AboutLibraries", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "com.mikepenz:fastadapter", + "funding": [ + + ], + "developers": [ + { + "name": "Mike Penz" + } + ], + "artifactVersion": "5.7.0", + "description": "The bullet proof, fast and easy to use adapter library, which minimizes developing time to a fraction...", + "scm": { + "connection": "scm:git@github.com:mikepenz/FastAdapter.git", + "url": "https://github.com/mikepenz/FastAdapter", + "developerConnection": "scm:git@github.com:mikepenz/FastAdapter.git" + }, + "name": "FastAdapter Library", + "website": "https://github.com/mikepenz/FastAdapter", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "dev.chrisbanes.haze:haze", + "funding": [ + + ], + "developers": [ + { + "name": "Chris Banes" + } + ], + "artifactVersion": "1.6.10", + "description": "Haze", + "scm": { + "connection": "scm:git:git://github.com/chrisbanes/haze.git", + "url": "https://github.com/chrisbanes/haze/", + "developerConnection": "scm:git:git://github.com/chrisbanes/haze.git" + }, + "name": "Haze", + "website": "https://github.com/chrisbanes/haze/", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "dev.chrisbanes.haze:haze-android", + "funding": [ + + ], + "developers": [ + { + "name": "Chris Banes" + } + ], + "artifactVersion": "1.6.10", + "description": "Haze", + "scm": { + "connection": "scm:git:git://github.com/chrisbanes/haze.git", + "url": "https://github.com/chrisbanes/haze/", + "developerConnection": "scm:git:git://github.com/chrisbanes/haze.git" + }, + "name": "Haze", + "website": "https://github.com/chrisbanes/haze/", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "dev.chrisbanes.haze:haze-materials", + "funding": [ + + ], + "developers": [ + { + "name": "Chris Banes" + } + ], + "artifactVersion": "1.6.10", + "description": "Haze", + "scm": { + "connection": "scm:git:git://github.com/chrisbanes/haze.git", + "url": "https://github.com/chrisbanes/haze/", + "developerConnection": "scm:git:git://github.com/chrisbanes/haze.git" + }, + "name": "Haze Materials", + "website": "https://github.com/chrisbanes/haze/", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "dev.chrisbanes.haze:haze-materials-android", + "funding": [ + + ], + "developers": [ + { + "name": "Chris Banes" + } + ], + "artifactVersion": "1.6.10", + "description": "Haze", + "scm": { + "connection": "scm:git:git://github.com/chrisbanes/haze.git", + "url": "https://github.com/chrisbanes/haze/", + "developerConnection": "scm:git:git://github.com/chrisbanes/haze.git" + }, + "name": "Haze Materials", + "website": "https://github.com/chrisbanes/haze/", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.androidx.lifecycle:lifecycle-common", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "2.9.4-rc01", + "description": "Android Lifecycle-Common", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Lifecycle-Common", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.androidx.lifecycle:lifecycle-runtime", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "2.9.4-rc01", + "description": "Android Lifecycle Runtime", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Lifecycle Runtime", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "2.9.4-rc01", + "description": "Compose integration with Lifecycle", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Lifecycle Runtime Compose", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "2.9.4-rc01", + "description": "Android Lifecycle ViewModel", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Lifecycle ViewModel", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "2.9.4-rc01", + "description": "Android Lifecycle ViewModel", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Lifecycle ViewModel with SavedState", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.androidx.savedstate:savedstate", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.3.4-rc01", + "description": "Android Lifecycle Saved State", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Saved State", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.androidx.savedstate:savedstate-compose", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.3.4-rc01", + "description": "Compose integration with Saved State", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Saved State Compose", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.compose.animation:animation", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.9.0-rc02", + "description": "Compose animation library", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Compose Animation", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.compose.animation:animation-core", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.9.0-rc02", + "description": "Animation engine and animation primitives that are the building blocks of the Compose animation library", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Compose Animation Core", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.compose.annotation-internal:annotation", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.9.0-rc02", + "description": "Provides source annotations for tooling and readability.", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Annotation", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.compose.collection-internal:collection", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.9.0-rc02", + "description": "Standalone efficient collections.", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "collections", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.compose.foundation:foundation", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.9.0-rc02", + "description": "Higher level abstractions of the Compose UI primitives. This library is design system agnostic, providing the high-level building blocks for both application and design-system developers", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Compose Foundation", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.compose.foundation:foundation-layout", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.9.0-rc02", + "description": "Compose layout implementations", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Compose Layouts", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.compose.material3:material3", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.8.2", + "description": "Compose Material You Design Components library", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Compose Material3 Components", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.compose.material:material-ripple", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.8.2", + "description": "Material ripple used to build interactive components", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Compose Material Ripple", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.compose.runtime:runtime", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.9.0-rc02", + "description": "Tree composition support for code generated by the Compose compiler plugin and corresponding public API", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Compose Runtime", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.compose.runtime:runtime-saveable", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.9.0-rc02", + "description": "Compose components that allow saving and restoring the local ui state", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Compose Saveable", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.compose.ui:ui", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.9.0-rc02", + "description": "Compose UI primitives. This library contains the primitives that form the Compose UI Toolkit, such as drawing, measurement and layout.", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Compose UI primitives", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.compose.ui:ui-backhandler", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.8.2", + "description": "Provides BackHandler in Compose Multiplatform projects", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Compose Multiplatform BackHandler", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.compose.ui:ui-backhandler-android", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.8.2", + "description": "Provides BackHandler in Compose Multiplatform projects", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Compose Multiplatform BackHandler", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.compose.ui:ui-backhandler-android-debug", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.8.2", + "description": "Provides BackHandler in Compose Multiplatform projects", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Compose Multiplatform BackHandler", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.compose.ui:ui-geometry", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.9.0-rc02", + "description": "Compose classes related to dimensions without units", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Compose Geometry", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.compose.ui:ui-graphics", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.9.0-rc02", + "description": "Compose graphics", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Compose Graphics", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.compose.ui:ui-text", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.9.0-rc02", + "description": "Compose Text primitives and utilities", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Compose UI Text", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.compose.ui:ui-tooling-preview", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.9.0-rc02", + "description": "Compose tooling library API. This library provides the API required to declare @Preview composables in user apps.", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Compose UI Preview Tooling", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.compose.ui:ui-unit", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.9.0-rc02", + "description": "Compose classes for simple units", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Compose Unit", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.compose.ui:ui-util", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Compose Multiplatform Team" + } + ], + "artifactVersion": "1.9.0-rc02", + "description": "Internal Compose utilities used by other modules", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", + "url": "https://github.com/JetBrains/compose-jb", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git" + }, + "name": "Compose Util", + "website": "https://github.com/JetBrains/compose-jb", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.kotlin:kotlin-android-extensions-runtime", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Kotlin Team" + } + ], + "artifactVersion": "2.1.10", + "description": "Kotlin Android Extensions Runtime", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/kotlin.git", + "url": "https://github.com/JetBrains/kotlin", + "developerConnection": "scm:git:https://github.com/JetBrains/kotlin.git" + }, + "name": "Kotlin Android Extensions Runtime", + "website": "https://kotlinlang.org/", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.kotlin:kotlin-bom", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "JetBrains Team" + } + ], + "artifactVersion": "1.8.22", + "description": "Kotlin is a statically typed programming language that compiles to JVM byte codes and JavaScript", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/kotlin.git", + "url": "https://github.com/JetBrains/kotlin", + "developerConnection": "scm:git:https://github.com/JetBrains/kotlin.git" + }, + "name": "Kotlin Libraries bill-of-materials", + "website": "https://kotlinlang.org/", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.kotlin:kotlin-parcelize-runtime", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Kotlin Team" + } + ], + "artifactVersion": "2.1.10", + "description": "Runtime library for the Parcelize compiler plugin", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/kotlin.git", + "url": "https://github.com/JetBrains/kotlin", + "developerConnection": "scm:git:https://github.com/JetBrains/kotlin.git" + }, + "name": "Parcelize Runtime", + "website": "https://kotlinlang.org/", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.kotlin:kotlin-stdlib", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Kotlin Team" + } + ], + "artifactVersion": "2.2.20", + "description": "Kotlin Standard Library", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/kotlin.git", + "url": "https://github.com/JetBrains/kotlin", + "developerConnection": "scm:git:https://github.com/JetBrains/kotlin.git" + }, + "name": "Kotlin Stdlib", + "website": "https://kotlinlang.org/", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.kotlin:kotlin-stdlib-common", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "Kotlin Team" + } + ], + "artifactVersion": "2.2.20", + "description": "Kotlin Common Standard Library (legacy, use kotlin-stdlib instead)", + "scm": { + "connection": "scm:git:https://github.com/JetBrains/kotlin.git", + "url": "https://github.com/JetBrains/kotlin", + "developerConnection": "scm:git:https://github.com/JetBrains/kotlin.git" + }, + "name": "Kotlin Stdlib Common", + "website": "https://kotlinlang.org/", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.kotlinx:kotlinx-collections-immutable", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "JetBrains Team" + } + ], + "artifactVersion": "0.4.0", + "description": "Kotlin Immutable Collections multiplatform library", + "scm": { + "url": "https://github.com/Kotlin/kotlinx.collections.immutable" + }, + "name": "kotlinx-collections-immutable", + "website": "https://github.com/Kotlin/kotlinx.collections.immutable", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.kotlinx:kotlinx-collections-immutable-jvm", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "JetBrains Team" + } + ], + "artifactVersion": "0.4.0", + "description": "Kotlin Immutable Collections multiplatform library", + "scm": { + "url": "https://github.com/Kotlin/kotlinx.collections.immutable" + }, + "name": "kotlinx-collections-immutable", + "website": "https://github.com/Kotlin/kotlinx.collections.immutable", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.kotlinx:kotlinx-coroutines-android", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "JetBrains Team" + } + ], + "artifactVersion": "1.10.2", + "description": "Coroutines support libraries for Kotlin", + "scm": { + "url": "https://github.com/Kotlin/kotlinx.coroutines" + }, + "name": "kotlinx-coroutines-android", + "website": "https://github.com/Kotlin/kotlinx.coroutines", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.kotlinx:kotlinx-coroutines-bom", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "JetBrains Team" + } + ], + "artifactVersion": "1.10.2", + "description": "Coroutines support libraries for Kotlin", + "scm": { + "url": "https://github.com/Kotlin/kotlinx.coroutines" + }, + "name": "kotlinx-coroutines-bom", + "website": "https://github.com/Kotlin/kotlinx.coroutines", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.kotlinx:kotlinx-coroutines-core", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "JetBrains Team" + } + ], + "artifactVersion": "1.10.2", + "description": "Coroutines support libraries for Kotlin", + "scm": { + "url": "https://github.com/Kotlin/kotlinx.coroutines" + }, + "name": "kotlinx-coroutines-core", + "website": "https://github.com/Kotlin/kotlinx.coroutines", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "JetBrains Team" + } + ], + "artifactVersion": "1.10.2", + "description": "Coroutines support libraries for Kotlin", + "scm": { + "url": "https://github.com/Kotlin/kotlinx.coroutines" + }, + "name": "kotlinx-coroutines-core", + "website": "https://github.com/Kotlin/kotlinx.coroutines", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.kotlinx:kotlinx-serialization-bom", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "JetBrains Team" + } + ], + "artifactVersion": "1.9.0", + "description": "Kotlin multiplatform serialization runtime library", + "scm": { + "url": "https://github.com/Kotlin/kotlinx.serialization" + }, + "name": "kotlinx-serialization-bom", + "website": "https://github.com/Kotlin/kotlinx.serialization", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.kotlinx:kotlinx-serialization-core", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "JetBrains Team" + } + ], + "artifactVersion": "1.9.0", + "description": "Kotlin multiplatform serialization runtime library", + "scm": { + "url": "https://github.com/Kotlin/kotlinx.serialization" + }, + "name": "kotlinx-serialization-core", + "website": "https://github.com/Kotlin/kotlinx.serialization", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.kotlinx:kotlinx-serialization-core-jvm", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "JetBrains Team" + } + ], + "artifactVersion": "1.9.0", + "description": "Kotlin multiplatform serialization runtime library", + "scm": { + "url": "https://github.com/Kotlin/kotlinx.serialization" + }, + "name": "kotlinx-serialization-core", + "website": "https://github.com/Kotlin/kotlinx.serialization", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.kotlinx:kotlinx-serialization-json", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "JetBrains Team" + } + ], + "artifactVersion": "1.9.0", + "description": "Kotlin multiplatform serialization runtime library", + "scm": { + "url": "https://github.com/Kotlin/kotlinx.serialization" + }, + "name": "kotlinx-serialization-json", + "website": "https://github.com/Kotlin/kotlinx.serialization", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains.kotlinx:kotlinx-serialization-json-jvm", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "JetBrains Team" + } + ], + "artifactVersion": "1.9.0", + "description": "Kotlin multiplatform serialization runtime library", + "scm": { + "url": "https://github.com/Kotlin/kotlinx.serialization" + }, + "name": "kotlinx-serialization-json", + "website": "https://github.com/Kotlin/kotlinx.serialization", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jetbrains:annotations", + "funding": [ + + ], + "developers": [ + { + "organisationUrl": "https://www.jetbrains.com", + "name": "JetBrains Team" + } + ], + "artifactVersion": "26.0.2", + "description": "A set of annotations used for code inspection support and code documentation.", + "scm": { + "connection": "scm:git:git://github.com/JetBrains/java-annotations.git", + "url": "https://github.com/JetBrains/java-annotations", + "developerConnection": "scm:git:ssh://github.com:JetBrains/java-annotations.git" + }, + "name": "JetBrains Java Annotations", + "website": "https://github.com/JetBrains/java-annotations", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.jspecify:jspecify", + "funding": [ + + ], + "developers": [ + { + "name": "Kevin Bourrillion" + } + ], + "artifactVersion": "1.0.0", + "description": "An artifact of well-named and well-specified annotations to power static analysis checks", + "scm": { + "connection": "scm:git:git@github.com:jspecify/jspecify.git", + "url": "https://github.com/jspecify/jspecify/", + "developerConnection": "scm:git:git@github.com:jspecify/jspecify.git" + }, + "name": "JSpecify annotations", + "website": "http://jspecify.org/", + "licenses": [ + "Apache-2.0" + ] + }, + { + "uniqueId": "org.lsposed.hiddenapibypass:hiddenapibypass", + "funding": [ + + ], + "developers": [ + { + "name": "LSPosed" + } + ], + "artifactVersion": "6.1", + "description": "Bypass restrictions on non-SDK interfaces", + "scm": { + "connection": "scm:git:https://github.com/LSPosed/AndroidHiddenApiBypass.git", + "url": "https://github.com/LSPosed/AndroidHiddenApiBypass" + }, + "name": "Android Hidden Api Bypass", + "website": "https://github.com/LSPosed/AndroidHiddenApiBypass", + "licenses": [ + "Apache-2.0" + ] + } + ], + "licenses": { + "Apache-2.0": { + "content": "Apache License\nVersion 2.0, January 2004\nhttp://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n\"License\" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.\n\n\"Licensor\" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.\n\n\"Legal Entity\" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, \"control\" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.\n\n\"You\" (or \"Your\") shall mean an individual or Legal Entity exercising permissions granted by this License.\n\n\"Source\" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.\n\n\"Object\" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.\n\n\"Work\" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).\n\n\"Derivative Works\" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.\n\n\"Contribution\" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, \"submitted\" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as \"Not a Contribution.\"\n\n\"Contributor\" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:\n\n (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.\n\n You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\nTo apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets \"[]\" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same \"printed page\" as the copyright notice for easier identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\nhttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.", + "hash": "Apache-2.0", + "internalHash": "Apache-2.0", + "url": "https://spdx.org/licenses/Apache-2.0.html", + "spdxId": "Apache-2.0", + "name": "Apache License 2.0" + } + } +} \ No newline at end of file diff --git a/android/app/src/main/res/raw/blip_yes.wav b/android/app/src/main/res/raw/blip_yes.wav index b796b5998..e69de29bb 100644 Binary files a/android/app/src/main/res/raw/blip_yes.wav and b/android/app/src/main/res/raw/blip_yes.wav differ diff --git a/android/app/src/main/res/values-v21/styles.xml b/android/app/src/main/res/values-v21/styles.xml deleted file mode 100644 index 6d4e9a140..000000000 --- a/android/app/src/main/res/values-v21/styles.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 86747678c..99720ca61 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -1,7 +1,5 @@ - LibrePods 让你的 AirPods 摆脱苹果的生态系统。 - GATT 测试 在主屏幕上即可查看 AirPods 的电池状态! 辅助功能 提示音音量 @@ -21,13 +19,10 @@ 头部手势 左耳 右耳 - 根据环境调整媒体音量 对话感知 当你开始与他人交谈时,会降低媒体音量并减少背景噪音。 个性化音量 根据环境自动调整媒体音量。 - 减少噪音 - 增加噪音 单只 AirPod 主动降噪 仅佩戴一只 AirPod 时也能开启主动降噪。 音量控制 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 7f5fdb0c2..4f6b82b44 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,39 +1,38 @@ - + LibrePods - Liberate your AirPods from Apple\'s ecosystem. - GATT Testing + Liberate your AirPods from Apple\'s ecosystem. See your AirPods battery status right from your home screen! Accessibility Tone Volume + Adjust the tone volume of sound effects played by AirPods. Audio Adaptive Audio + Customize Adaptive Audio Adaptive audio dynamically responds to your environment and cancels or allows external noise. You can customize Adaptive Audio to allow more or less noise. Buds Case Test Name - Noise Control + Listening Mode Off Transparency Adaptive Noise Cancellation Press and Hold AirPods + Press and hold the stem to cycle between the selected listening modes. Head Gestures Left Right - Adjusts the volume of media in response to your environment Conversational Awareness Lowers media volume and reduces background noise when you start speaking to other people. Personalized Volume Adjusts the volume of media in response to your environment. - Less Noise - More Noise Noise Cancellation with Single AirPod - Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear. + Allow AirPods to be put in noise cancellation mode when only one AirPod is in your ear. Volume Control Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem. AirPods not connected - Please connect your AirPods to access settings. If you\'re stuck here, then try reopening the app again after closing it from the recents.\n(DO NOT KILL THE APP!) + Please connect your AirPods to access settings. Back Customizations Relative volume @@ -45,8 +44,10 @@ Control Noise Control Mode directly from your Home Screen. Connected Connected to Linux - Moved to phone + Connected Moved to Linux + Moved to %1$s + Reconnect from notification Head Tracking Nod to answer calls, and shake your head to decline. General @@ -79,4 +80,134 @@ Your phone starts ringing Starting media playback Your phone starts playing media + Undo + You can customize Transparency mode for your AirPods Pro to help you hear what\'s around you. + Loud Sound Reduction can actively reduce your exposure to loud environmental noises when in Transparency and Adaptive mode. Loud Sound Reduction is not active in Off mode. + Loud Sound Reduction + Call Controls + Connect to this device automatically + When enabled, AirPods will try to connect to this device automatically. Else, they will try to autoconnect only when last connected. + Pause media when falling asleep + Off Listening Mode + When this is on, AirPods listening modes will include an Off option. Loud sound levels are not reduced when the listening mode is set to Off. + Microphone + Microphone Mode + Automatic + Always Right + Always Left + Answer call + Mute/Unmute + Hang Up + Press Once + Press Twice + Hearing Aid + Adjustments + Swipe to control amplification + When in Transparency and no media is playing, swipe up and down on the Touch controls of your AirPods Pro to increase or decrease the amplification of environmental sounds. + Transparency Mode + Customize Transparency Mode + Press Speed + Adjust the speed required to press two or three times on your AirPods. + Press and Hold Duration + Adjust the duration required to press and hold on your AirPods. + Volume Swipe Speed + To prevent unintended volume adjustments, select preferred wait time between swipes. + Equalizer + Apply EQ to + Phone + Media + Band %d + Default + Slower + Slowest + Longer + Longest + Darker + Brighter + Less + More + Amplification + Balance + Tone + Ambient Noise Reduction + Conversation Boost + Conversation Boost focuses your AirPods Pro on the person talking in front of you, making it easier to hear in a face-to-face conversation. + AirPods can use the results of a hearing test to make adjustments that improve the clarity of voices and sounds around you.\n\nHearing Aid is only intended for people with perceived mild to moderate hearing loss. + Media Assist + AirPods Pro can use the results of a hearing test to make adjustments that improve the clarity of music, video, and calls. + Adjust Music and Video + Adjust Calls + Widget + Show phone battery in widget + Display your phone\'s battery level in the widget alongside AirPods battery + Conversational Awareness Volume + Quick Settings Tile + Open dialog for controlling + If disabled, clicking on the QS will cycle through modes. If enabled, it will show a dialog for controlling noise control mode and conversational awareness + Disconnect AirPods when not wearing + You will still be able to control them with the app - this just disconnects the audio. + Advanced Options + Set Identity Resolving Key (IRK) + Manually set the IRK value used for resolving BLE random addresses + Set Encryption Key + Manually set the ENC_KEY value used for decrypting BLE advertisements + Use alternate head tracking packets + Enable this if head tracking doesn\'t work for you. This sends different data to AirPods for requesting/stopping head tracking data. + Act as an Apple device + Enables multi-device connectivity and Accessibility features like customizing transparency mode (amplification, tone, ambient noise reduction, conversation boost, and EQ) + Might be unstable!! A maximum of two devices can be connected to your AirPods. If you are using with an Apple device like an iPad or Mac, then please connect that device first and then your Android. + Reset Hook Offset + This will clear the current hook offset and require you to go through the setup process again. Are you sure you want to continue? + Reset + Hook offset has been reset. Redirecting to setup... + Failed to reset hook offset + IRK has been set successfully + Encryption key has been set successfully + IRK Hex Value + ENC_KEY Hex Value + Enter 16-byte IRK as hex string (32 characters): + Enter 16-byte ENC_KEY as hex string (32 characters): + Must be exactly 32 hex characters + Error converting hex: + Found offset please restart the Bluetooth process + Digital Assistant + On + Camera Remote + Camera Control + Capture a photo, start or stop recording, and more using either Press Once or Press and Hold. When using AirPods for camera actions, if you select Press Once, media control gestures will be unavailable, and if you select Press and Hold, listening mode and Digital Assistant gestures will be unavailable. + Set a custom app package for camera detection + Set Custom Camera appid + Enter the application id of the camera app: + Custom Camera appid + Custom camera appid set successfully + Camera listener + Listener service for LibrePods to detect when the camera is active to activate camera control on AirPods. + Open Source Licenses + Update Hearing Test + Update Hearing Test Result + ATT Manager is null, Try reconnecting. + The following permissions are required to use the app. Please grant them to continue. + Shake your head or nod! + Root Access Required + This app needs root access to hook onto the Bluetooth library + Root access was denied. Please grant root permissions. + Troubleshooting Steps + Please enter the loss values in dbHL + About + Model Name + Model Number + Serial Number + Version + Hearing Health + Hearing Protection + Workspace Use + EN 352 Protection + EN 352 Protection limits the maximum level of media to 82 dBA, and meets applicable EN 352 Standard requirements for personal hearing protection. + Environmental Noise + Reconnect to last connected device + Disconnect + Support me + Never show again + I recently lost my left AirPod. If you\'ve found LibrePods useful, consider supporting me on GitHub Sponsors so I can buy a replacement and continue working on this project- even a little amount goes a long way. Thank you for your support! + Support LibrePods diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 30e8f43cd..df666a4b2 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -1,12 +1,6 @@ - + - + diff --git a/android/app/src/main/res/xml/app_listener_service_config.xml b/android/app/src/main/res/xml/app_listener_service_config.xml new file mode 100644 index 000000000..866d9bb04 --- /dev/null +++ b/android/app/src/main/res/xml/app_listener_service_config.xml @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 952b93066..31555c091 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -3,4 +3,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.aboutLibraries) apply false } \ No newline at end of file diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 415d5ceb0..95f9ba628 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,21 +1,23 @@ [versions] accompanistPermissions = "0.36.0" -agp = "8.8.2" +agp = "8.9.1" hiddenapibypass = "6.1" kotlin = "2.1.10" -coreKtx = "1.16.0" +coreKtx = "1.17.0" lifecycleRuntimeKtx = "2.8.7" activityCompose = "1.10.1" composeBom = "2025.04.00" annotations = "26.0.2" navigationCompose = "2.8.9" constraintlayout = "2.2.1" -haze = "1.5.3" -hazeMaterials = "1.5.3" -sliceBuilders = "1.1.0-alpha02" -sliceCore = "1.1.0-alpha02" -sliceView = "1.1.0-alpha02" +haze = "1.6.10" +hazeMaterials = "1.6.10" dynamicanimation = "1.1.0" +foundationLayout = "1.9.1" +uiTooling = "1.9.1" +mockk = "1.14.3" +ui = "1.9.2" +aboutLibraries = "13.0.0-rc01" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } @@ -33,13 +35,16 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } haze = { group = "dev.chrisbanes.haze", name = "haze", version.ref = "haze" } haze-materials = { group = "dev.chrisbanes.haze", name = "haze-materials", version.ref = "hazeMaterials" } -androidx-slice-builders = { group = "androidx.slice", name = "slice-builders", version.ref = "sliceBuilders" } -androidx-slice-core = { group = "androidx.slice", name = "slice-core", version.ref = "sliceCore" } -androidx-slice-view = { group = "androidx.slice", name = "slice-view", version.ref = "sliceView" } androidx-dynamicanimation = { group = "androidx.dynamicanimation", name = "dynamicanimation", version.ref = "dynamicanimation" } +androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiTooling" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" } +aboutlibraries = { group = "com.mikepenz", name = "aboutlibraries", version.ref = "aboutLibraries" } +aboutlibraries-compose-m3 = { group = "com.mikepenz", name = "aboutlibraries-compose-m3", version.ref = "aboutLibraries" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } - +aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutLibraries" } \ No newline at end of file diff --git a/android/imgs/accessibility.png b/android/imgs/accessibility.png new file mode 100644 index 000000000..9017d8231 Binary files /dev/null and b/android/imgs/accessibility.png differ diff --git a/android/imgs/audio-connected-island.png b/android/imgs/audio-connected-island.png deleted file mode 100644 index a71917613..000000000 Binary files a/android/imgs/audio-connected-island.png and /dev/null differ diff --git a/android/imgs/cd-connected-remotely-island.png b/android/imgs/cd-connected-remotely-island.png deleted file mode 100644 index 211888ddf..000000000 Binary files a/android/imgs/cd-connected-remotely-island.png and /dev/null differ diff --git a/android/imgs/cd-moved-to-phone-island.png b/android/imgs/cd-moved-to-phone-island.png deleted file mode 100644 index 81bdfb0b5..000000000 Binary files a/android/imgs/cd-moved-to-phone-island.png and /dev/null differ diff --git a/android/imgs/customizations-1.png b/android/imgs/customizations-1.png index b18993d15..3fe3a4ba5 100644 Binary files a/android/imgs/customizations-1.png and b/android/imgs/customizations-1.png differ diff --git a/android/imgs/customizations-2.png b/android/imgs/customizations-2.png index 777f51778..237876a8d 100644 Binary files a/android/imgs/customizations-2.png and b/android/imgs/customizations-2.png differ diff --git a/android/imgs/head-tracking-and-gestures.png b/android/imgs/head-tracking-and-gestures.png index 0d1a6179d..ef5e646a4 100644 Binary files a/android/imgs/head-tracking-and-gestures.png and b/android/imgs/head-tracking-and-gestures.png differ diff --git a/android/imgs/hearing-aid-adjustments.png b/android/imgs/hearing-aid-adjustments.png new file mode 100644 index 000000000..b24afb4e7 Binary files /dev/null and b/android/imgs/hearing-aid-adjustments.png differ diff --git a/android/imgs/hearing-aid.png b/android/imgs/hearing-aid.png new file mode 100644 index 000000000..ce2540945 Binary files /dev/null and b/android/imgs/hearing-aid.png differ diff --git a/android/imgs/hearing-test.png b/android/imgs/hearing-test.png new file mode 100644 index 000000000..b2fde72f6 Binary files /dev/null and b/android/imgs/hearing-test.png differ diff --git a/android/imgs/long-press.png b/android/imgs/long-press.png index 480a0caf6..e23bdecbc 100644 Binary files a/android/imgs/long-press.png and b/android/imgs/long-press.png differ diff --git a/android/imgs/settings-1.png b/android/imgs/settings-1.png index bbe52e412..7e193536c 100644 Binary files a/android/imgs/settings-1.png and b/android/imgs/settings-1.png differ diff --git a/android/imgs/settings-2.png b/android/imgs/settings-2.png index 4a0cd6388..bf2b8a617 100644 Binary files a/android/imgs/settings-2.png and b/android/imgs/settings-2.png differ diff --git a/android/imgs/transparency.png b/android/imgs/transparency.png new file mode 100644 index 000000000..fd98b3c61 Binary files /dev/null and b/android/imgs/transparency.png differ diff --git a/docs/control_commands.md b/docs/control_commands.md index bd5bc49bd..88a01c1f1 100644 --- a/docs/control_commands.md +++ b/docs/control_commands.md @@ -4,7 +4,7 @@ AACP uses opcode `9` for control commands. opcodes are 16 bit integers that spec An AACP packet is formated as: -`04 00 04 00 [opcode, little endianness] [data]` +`04 00 04 00 [opcode, little endian] [data]` So, our control commands becomes @@ -14,51 +14,221 @@ So, our control commands becomes Bytes that are not used are set to `0x00`. From what I've observed, the `data3` and `data4` are never used, and hence always zero. And, the `data2` is usually used when the configuration can be different for the two buds: like, to change the long press mode. Or, if there can be two "state" variables for the same feature: like the Hearing Aid feature. -## Control Commands -These commands - -| Command identifier | Description | Format | -|--------------|---------------------|--------| -| 0x01 | Mic Mode | Single value (1 byte) | -| 0x05 | Button Send Mode | Single value (1 byte) | -| 0x12 | VoiceTrigger for Siri | Single Value (1 byte): `0x01` = enabled, `0x01` = disabled | -| 0x14 | SingleClickMode | Single value (1 byte) | -| 0x15 | DoubleClickMode | Single value (1 byte) | -| 0x16 | ClickHoldMode | Two values (2 bytes; First byte = right bud Second byte = for left): `0x01` = Noise control `0x05` = Siri | -| 0x17 | DoubleClickInterval | Single value (1 byte): 0x00 = Default, `0x01` = Slower, `0x02` = Slowest| -| 0x18 | ClickHoldInterval | Single value (1 byte): 0x00 = Default, `0x01` = Slower, `0x02` = Slowest| -| 0x1A | ListeningModeConfigs | Single value (1 byte): bitmask, Off mode = `0x01`, ANC=`0x02`, Transparency = 0x04, Adaptive = `0x08` | -| 0x1B | OneBudANCMode | Single value (1 byte): `0x01` = enabled, `0x02` = disabled | -| 0x1C | CrownRotationDirection | Single value (1 byte): `0x01` = reversed, `0x02` = default | -| 0x0D | ListeningMode | Single value (1 byte): 1 = Off, 2 = noise cancellation, 3 = transparency, 4 = adaptive | -| 0x1E | AutoAnswerMode | Single value (1 byte) | -| 0x1F | Chime Volume | Single value (1 byte): 0 to 100| -| 0x23 | VolumeSwipeInterval | Single value (1 byte): 0x00 = Default, `0x01` = Longer, `0x02` = Longest | -| 0x24 | Call Management Config | Single value (1 byte) | -| 0x25 | VolumeSwipeMode | Single value (1 byte): `0x01` = enabled, `0x02` = disabled | -| 0x26 | Adaptive Volume Config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled | -| 0x27 | Software Mute config | Single value (1 byte) | -| 0x28 | Conversation Detect config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled | -| 0x29 | SSL | Single value (1 byte) | -| 0x2C | Hearing Aid Enrolled and Hearing Aid Enabled | Two values (2 bytes; First byte - enrolled, Second byte = enabled): `0x01` = enabled, `0x02` = disabled | -| 0x2E | AutoANC Strength | Single value (1 byte): 0 to 100| -| 0x2F | HPS Gain Swipe | Single value (1 byte) | -| 0x30 | HRM enable/disable state | Single value (1 byte) | -| 0x31 | In Case Tone config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled | -| 0x32 | Siri Multitone config | Single value (1 byte) | -| 0x33 | Hearing Assist config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled | -| 0x34 | Allow Off Option for Listening Mode config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled | -| 0x35 | Sleep Detection config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled | -| 0x36 | Allow Auto Connect | Single value (1 byte): `0x01` = allow, `0x02` = disallow | -| 0x39 | Raw Gestures config | Single value (1 byte): bitmask, single press = `0x01`, double press = `0x02`, triple press = `0x04`, long press = `0x08` | -| 0x3C | System Siri message config | Single value (1 byte) | -| 0x3E | Uplink EQ Bud config | Single value (1 byte) | -| 0x3F | Uplink EQ Source config | Single value (1 byte) | -| 0x40 | In Case Tone Volume | Single value (1 byte): 0 to 100 | -| 0x41 | Disable Button Input config | Single value (1 byte) | +## Identifiers and details +| Command identifier | Description | +|--------------|---------------------| +| 0x01 | Mic Mode | +| 0x05 | Button Send Mode | +| 0x06 | Owns connection | +| 0x0A | Ear Detection | +| 0x12 | VoiceTrigger for Siri | +| 0x14 | SingleClickMode | +| 0x15 | DoubleClickMode | +| 0x16 | ClickHoldMode | +| 0x17 | DoubleClickInterval | +| 0x18 | ClickHoldInterval | +| 0x1A | ListeningModeConfigs | +| 0x1B | OneBudANCMode | +| 0x1C | CrownRotationDirection | +| 0x0D | ListeningMode | +| 0x1E | AutoAnswerMode | +| 0x1F | Chime Volume | +| 0x20 | Connect Automatically | +| 0x23 | VolumeSwipeInterval | +| 0x24 | Call Management Config | +| 0x25 | VolumeSwipeMode | +| 0x26 | Adaptive Volume Config | +| 0x27 | Software Mute config | +| 0x28 | Conversation Detect config | +| 0x29 | SSL | +| 0x2C | Hearing Aid Enrolled and Hearing Aid Enabled | +| 0x2E | AutoANC Strength | +| 0x2F | HPS Gain Swipe | +| 0x30 | HRM enable/disable state | +| 0x31 | In Case Tone config | +| 0x32 | Siri Multitone config | +| 0x33 | Hearing Assist config | +| 0x34 | Allow Off Option for Listening Mode config | +| 0x35 | Sleep Detection config | +| 0x36 | Allow Auto Connect | +| 0x37 | PPE Toggle config | +| 0x38 | Personal Protective Equipment Cap Level config | +| 0x39 | Raw Gestures config | +| 0x3A | Temporary Pairing Config | +| 0x3B | Dynamic End of Charge config | +| 0x3C | System Siri message config | +| 0x3D | Hearing Aid Generic config | +| 0x3E | Uplink EQ Bud config | +| 0x3F | Uplink EQ Source config | +| 0x40 | In Case Tone Volume | +| 0x41 | Disable Button Input config | +## Command Details + +### 0x01 - Mic Mode +Format: Single value (1 byte) +Values: `0x00` = Automatic, `0x01` = Right, `0x02` = Left. + +### 0x05 - Button Send Mode +Format: Single value (1 byte) +Additional notes: Logged as "Set Button Send Mode: %d". May involve context updates for button handling. + +### 0x06 - Owns connection +Format: Single value (1 byte) +Values: `0x01` = own, `0x00` = doesn't own. + +### 0x0A - Ear Detection +Format: Single value (1 byte) +Values: `0x01` = enabled, `0x02` = disabled. + +### 0x12 - VoiceTrigger for Siri +Format: Single value (1 byte) +Values: `0x01` = enabled, `0x02` = disabled. + +### 0x14 - SingleClickMode +Format: Single value (1 byte) + +### 0x15 - DoubleClickMode +Format: Single value (1 byte) + +### 0x16 - ClickHoldMode +Format: Two values (2 bytes; First byte = right bud, Second byte = left bud). +Values: `0x01` = Noise control, `0x05` = Siri. + +### 0x17 - DoubleClickInterval +Format: Single value (1 byte) +Values: 0x00 = Default, `0x01` = Slower, `0x02` = Slowest. + +### 0x18 - ClickHoldInterval +Format: Single value (1 byte) +Values: 0x00 = Default, `0x01` = Slower, `0x02` = Slowest. + +### 0x1A - ListeningModeConfigs +Format: Single value (1 byte) +Values: Bitmask, Off mode = `0x01`, ANC=`0x02`, Transparency = 0x04, Adaptive = `0x08`. + +### 0x1B - OneBudANCMode +Format: Single value (1 byte) +Values: `0x01` = enabled, `0x02` = disabled + +### 0x1C - CrownRotationDirection +Format: Single value (1 byte) +Values: `0x01` = reversed, `0x02` = default. + +### 0x0D - ListeningMode +Format: Single value (1 byte) +Values: 1 = Off, 2 = noise cancellation, 3 = transparency, 4 = adaptive. + +### 0x1E - AutoAnswerMode +Format: Single value (1 byte) + +### 0x1F - Chime Volume +Format: Single value (1 byte) +Values: 0 to 100. + +### 0x20 - Connect Automatically +Format: Single value (1 byte) +Values: `0x01` = enabled, `0x02` = disabled. + +### 0x23 - VolumeSwipeInterval +Format: Single value (1 byte) +Values: 0x00 = Default, `0x01` = Longer, `0x02` = Longest. + +### 0x24 - Call Management Config +Format: Single value (1 byte) + +### 0x25 - VolumeSwipeMode +Format: Single value (1 byte) +Values: `0x01` = enabled, `0x02` = disabled + +### 0x26 - Adaptive Volume Config +Format: Single value (1 byte) +Values: `0x01` = enabled, `0x02` = disabled + +### 0x27 - Software Mute config +Format: Single value (1 byte) + +### 0x28 - Conversation Detect config +Format: Single value (1 byte) +Values: `0x01` = enabled, `0x02` = disabled + +### 0x29 - SSL +Format: Single value (1 byte) + +### 0x2C - Hearing Aid Enrolled and Hearing Aid Enabled +Format: Two values (2 bytes; First byte - enrolled, Second byte = enabled) +Values: `0x01` = enabled, `0x02` = disabled + +### 0x2E - AutoANC Strength +Format: Single value (1 byte) +Values: 0 to 100. + +### 0x2F - HPS Gain Swipe (swipe to adjust amplification) +Format: Single value (1 byte) + +### 0x30 - HRM enable/disable state +Format: Single value (1 byte) + +### 0x31 - In Case Tone config +Format: Single value (1 byte) +Values: `0x01` = enabled, `0x02` = disabled + +### 0x32 - Siri Multitone config +Format: Single value (1 byte) + +### 0x33 - Hearing Assist config +Format: Single value (1 byte) +Values: `0x01` = enabled, `0x02` = disabled + +### 0x34 - Allow Off Option for Listening Mode config +Format: Single value (1 byte) +Values: `0x01` = enabled, `0x02` = disabled + +### 0x35 - Sleep Detection config +Format: Single value (1 byte) +Values: `0x01` = enabled, `0x02` = disabled + +### 0x36 - Allow Auto Connect +Format: Single value (1 byte) +Values: `0x01` = allow, `0x02` = disallow + +### 0x37 - PPE Toggle config +Format: Single value (1 byte) + +### 0x38 - Personal Protective Equipment Cap Level config +Format: Single value (1 byte) + +### 0x39 - Raw Gestures config +Format: Single value (1 byte) +Values: Bitmask, single press = `0x01`, double press = `0x02`, triple press = `0x04`, long press = `0x08`. + +### 0x3A - Temporary Pairing Config +Format: Single value (1 byte) +Values: `0x01` = Temporary, `0x02` = Permanent + +### 0x3B - Dynamic End of Charge config +Format: Single value (1 byte) + +### 0x3C - System Siri message config +Format: Single value (1 byte) + +### 0x3D - Hearing Aid Generic config +Format: Single value (1 byte) + +### 0x3E - Uplink EQ Bud config +Format: Single value (1 byte) + +### 0x3F - Uplink EQ Source config +Format: Single value (1 byte) + +### 0x40 - In Case Tone Volume +Format: Single value (1 byte) +Values: 0 to 100. + +### 0x41 - Disable Button Input config +Format: Single value (1 byte) > [!NOTE] -> - These identifiers have been extracted from the macOS 15.4 Beta (24E5238a)'s bluetooth stack. -> - I have already added the ranges of values a command takes that I know of. Feel free to experiemnt by sending the packets for which the range/values are not given here. +> - These identifiers have been extracted from the iOS 19.1 Beta (23B5044l)'s bluetooth stack. +> - I have already added the ranges of values a command takes that I know of. Feel free to experiment by sending the packets for which the range/values are not given here.