diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f3b3d4..b5d6b55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 - name: Export APK_NAME for later use - run: echo "APK_NAME=ALN-$(echo ${{ github.sha }} | cut -c1-7).apk" >> $GITHUB_ENV + run: echo "APK_NAME=LibrePods-$(echo ${{ github.sha }} | cut -c1-7).apk" >> $GITHUB_ENV - name: Rename .apk file run: mv "./Debug APK/debug/"*.apk "./$APK_NAME" - name: Decode keystore file diff --git a/AAP Definitions.md b/AAP Definitions.md index c5baa19..802a21e 100644 --- a/AAP Definitions.md +++ b/AAP Definitions.md @@ -429,8 +429,8 @@ Once tracking is active, the AirPods stream sensor packets with the following co # LICENSE -AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! -Copyright (C) 2024 Kavish Devar +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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fa26ba..ca8385c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ ## btl2capfix v0.0.3 -- ([#34](https://github.com/kavishdevar/aln/pull/34)) @devnoname120 Add on-device libbluetooth patcher using a Magisk/KernelSU module (arm64-only) +- ([#34](https://github.com/kavishdevar/librepods/pull/34)) @devnoname120 Add on-device libbluetooth patcher using a Magisk/KernelSU module (arm64-only) -_[See more here](https://github.com/kavishdevar/aln/releases)_ +_[See more here](https://github.com/kavishdevar/librepods/releases)_ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0584667..91629e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Welcome to AirPods Like Normal contributing guide +# Welcome to LibrePods contributing guide -Thank you for considering a contribution to AirPods Like Normal! Your support helps bring Apple-exclusive AirPods features to Linux and Android. +Thank you for considering a contribution to LibrePods! Your support helps bring Apple-exclusive AirPods features to Linux and Android. Read our [Code of Conduct](./CODE_OF_CONDUCT.md) to keep our community approachable and respectful. @@ -25,11 +25,11 @@ To develop for the Android App, Android Studio is the preferred IDE. And you can #### Create a new issue -If you find a bug or want to suggest a feature, check if an issue already exists by searching through our [existing issues](https://github.com/kavishdevar/aln/issues). If no relevant issue exists, open a new one and fill in the details. +If you find a bug or want to suggest a feature, check if an issue already exists by searching through our [existing issues](https://github.com/kavishdevar/librepods/issues). If no relevant issue exists, open a new one and fill in the details. #### Solve an issue -Browse our [issues list](https://github.com/kavishdevar/aln/issues) to find an interesting issue to work on. Use labels to filter issues and pick one that matches your expertise. If you’d like to work on an issue, open a PR with your solution. +Browse our [issues list](https://github.com/kavishdevar/librepods/issues) to find an interesting issue to work on. Use labels to filter issues and pick one that matches your expertise. If you’d like to work on an issue, open a PR with your solution. ### Make Changes @@ -37,7 +37,7 @@ Browse our [issues list](https://github.com/kavishdevar/aln/issues) to find an i 1. Fork the repository and clone it to your local environment. ``` -git clone https://github.com/kavishdevar/aln.git +git clone https://github.com/kavishdevar/librepods.git cd AirPods-Like-Normal ``` 2. Create a working branch to start your changes. @@ -67,4 +67,4 @@ Once your PR is open, a team member will review it. They may ask questions or re ### Your PR is merged! -Congratulations! :tada: Once merged, your contributions will be publicly available in AirPodsLikeNormal. \ No newline at end of file +Congratulations! :tada: Once merged, your contributions will be publicly available in LibrePods. \ No newline at end of file diff --git a/README.md b/README.md index 7777c61..9f65e86 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,58 @@ -# ALN - AirPodsLikeNormal -*Bringing AirPods' Apple-exclusive features on linux and android!* +# LibrePods -## [XDAForums Thread](https://xdaforums.com/t/app-root-for-now-airpodslikenormal-unlock-apple-exclusive-airpods-features-on-android.4707585/) +![LibrePods Banner](/imgs/banner.png) -## Tested device(s) -- AirPods Pro 2 +*AirPods liberated from Apple's ecosystem* -Other devices might work too. Features like ear detection and battery should be available for any AirPods! Although the app will show unsupported features/settings. I will not be able test any other devices than the ones I already have (i.e. the AirPods Pro 2). +[![XDA Thread](https://img.shields.io/badge/XDA_Forums-Thread-orange)](https://xdaforums.com/t/app-root-for-now-airpodslikenormal-unlock-apple-exclusive-airpods-features-on-android.4707585/) -## Features +## What is LibrePods? -Check the [pinned issue](https://github.com/kavishdevar/aln/issues/20) for a list. +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. +## Device Compatibility -## CrossDevice Stuff +| Status | Device | Features | +|--------|--------|----------| +| ✅ | AirPods Pro (2nd Gen) | Fully supported and tested | +| ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work | -> [!IMPORTANT] -> This feature is still in development and might not work as expected. No support is provided for this feature. +Most features should work with any AirPods. Currently, testing is only performed with AirPods Pro 2. -### Features +## Key Features -- **Battery Status**: Get battery status on any device when you connect your AirPods to one of them. -- **Control AirPods**: Control your AirPods from either of your device when you connect to one, like changing the noise control mode, toggling conversational awareness, and more. -- **Automatic Device Switching**: Automatically switch between your Linux and Android device, like when you receive a call, start playing music on Android while you're connected to Linux, and more! +- **Noise Control Modes**: Easily switch between noise control modes without having to reach out to your AirPods to long press +- **Ear Detection**: Controls your music automatically when you put your AirPods in or take them out, and switch to phone speaker when you take them out +- **Battery Status**: Accurate battery levels +- **Head Gestures**: Answer calls just by nodding your head +- **Conversational Awareness**: Volume automatically lowers when you speak +- **Other customizations**: + - Rename your AirPods + - Customize long-press actions + - Few accessibility features + - And more! -Check out the demo below! +See our [pinned issue](https://github.com/kavishdevar/librepods/issues/20) for a complete feature list and roadmap. -https://github.com/user-attachments/assets/d08f8a51-cd52-458b-8e55-9b44f4d5f3ab +## Platform Support +### Linux -## Linux +The Linux version runs as a system tray app. Connect your AirPods and enjoy: -The Linux version is a simple tray-app, with a modern and adaptive ui. Still WIP, but most things work (battery, ear-detection, auto-pause, rename, etc.) +- Battery monitoring +- Ear detection with auto-pause +- Device renaming +- Media controls -Check out the README file in [linux](/linux) folder for more info. +> [!NOTE] +> Work in progress, but core functionality is stable and usable. -## Android +For installation and detailed info, see the [Linux README](/linux/README.md). -> Can I use aln without root? +### Android -**No, it's not possible to use aln without root.** You will have to root your device if you want to use aln, and there is no way around it. **No exceptions.** - -### Screenshots +#### Screenshots | | | | |-------------------|-------------------|-------------------| @@ -50,46 +61,75 @@ Check out the README file in [linux](/linux) folder for more info. | ![Long Press Configuration](/android/imgs/long-press.png) | ![Widget](/android/imgs/widget.png) | ![Customizations](/android/imgs/customizations.png) | | ![audio-popup](/android/imgs/audio-connected-island.png) | | | -### Installation - -Currently, there's a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238) that prevents the app from working (upvote the issue - click the '+1' icon on the top right corner of IssueTracker). +#### Root Requirement > [!CAUTION] -> Until Google merges the fix **you will only be able to use aln if you are rooted**. There are **no exceptions**, don't ask about it. +> **You must have a rooted device to use LibrePods on Android.** This is due to a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238). Please upvote the issue by clicking the '+1' icon on the IssueTracker page. +> +> There are **no exceptions** to the root requirement until Google merges the fix. -In order to use aln you will have to install the module using your favorite root manager in OverlayFS mode (KernelSU, Apatch, or Magisk). If you don't know what this means, no support is provided: you will have to search by yourself on Google or ask in some Android rooting communities on Telegram. +#### Installation Methods -The module to install is available in the releases section under the name `btl2capfix.zip`. +##### Method 1: Xposed Module (Recommended) +This method is less intrusive and should be tried first: -### Android – features +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 -#### Renaming the Airpods -When you rename the Airpods using the app, you'll need to re-pair it with your phone. Currently, user-level apps cannot directly rename a Bluetooth device. After re-pairing, your phone will display the updated name! +##### Method 2: Root Module (Backup Option) +If the Xposed method doesn't work for you: -#### Noise Control Modes +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 -- Active Noise Cancellation (ANC): Blocks external sounds using microphones and advanced algorithms for an immersive audio experience; ideal for noisy environments. -- Transparency Mode: Allows external sounds to blend with audio for situational awareness; best for environments where you need to stay alert. -- Off Mode: Disables noise control for a natural listening experience, conserving battery in quiet settings. -- Adaptive Transparency: Dynamically reduces sudden loud noises while maintaining environmental awareness, adjusting seamlessly to fluctuating noise levels. +##### Method 3: Patching it yourself +If you prefer to patch the Bluetooth stack yourself, follow these steps: + +1. Look for the library in use by running `lsof | grep libbluetooth` +2. Find the library path (e.g., `/system/lib64/libbluetooth_jni.so`) +3. Find the `l2c_fcr_chk_chan_modes` function in the library +4. Patch the function to always return `1` (true) +5. Repack the library and push it back to the device. You can do this by creating a root module yourself. +6. Reboot your device + +If you're unfamiliar with these steps, search for tutorials online or ask in Android rooting communities. + +#### A few notes + +- Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, louds sounds are not reduced! + +- If you have take both AirPods out, the app will automatically switch to the phone speaker. But, Android might keep on trying to connect to the AirPods because the phone is still connected to them, just the A2DP profile is not connected. The app tries to disconnect the A2DP profile as soon as it detects that Android has connected again if they're not in the ear. + +- 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] -> Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, louds sounds are not reduced! +> This feature is still in early development and might not work as expected. No support is provided for this feature yet. -#### Conversational Awareness +### Features in Development -Automatically lowers audio volume and enhances voices when you start speaking, making it easier to engage in conversations without removing your AirPods. +- **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 -#### Automatic Ear Detection +Check out the demo below: -Recognizes when the AirPods are in your ears to automatically play or pause audio and adjust functionality accordingly. - -## Check out the packet definitions at [AAP Definitions](/AAP%20Definitions.md) +https://github.com/user-attachments/assets/d08f8a51-cd52-458b-8e55-9b44f4d5f3ab # License -AirPodsLikeNormal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! -Copyright (C) 2024 Kavish Devar +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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e97992e..05f1c78 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,21 +6,15 @@ plugins { } android { - namespace = "me.kavishdevar.aln" + namespace = "me.kavishdevar.librepods" compileSdk = 35 defaultConfig { - applicationId = "me.kavishdevar.aln" + applicationId = "me.kavishdevar.librepods" minSdk = 28 targetSdk = 35 - versionCode = 3 - versionName = "0.0.3" - - externalNativeBuild { - cmake { - cppFlags += "" - } - } + versionCode = 4 + versionName = "0.1.0" } buildTypes { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d5c7988..4c1f11b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,8 @@ + @@ -37,7 +39,7 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/Theme.ALN" + android:theme="@style/Theme.LibrePods" tools:ignore="UnusedAttribute" tools:targetApi="31"> + android:theme="@style/Theme.LibrePods"> @@ -75,7 +77,7 @@ + android:theme="@style/Theme.LibrePods"> @@ -83,6 +85,15 @@ + + (p_ccb); - LOGI("Original FCR mode: 0x%02x", ccb->our_cfg.fcr.mode); ccb->our_cfg.fcr.mode = 0; - ccb->our_cfg.fcr_present = true; - ccb->peer_cfg.fcr.mode = 0; ccb->peer_cfg.fcr_present = true; @@ -141,14 +144,38 @@ uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) { return 1; } +void fake_l2cu_process_our_cfg_req(tL2C_CCB* p_ccb, tL2CAP_CFG_INFO* p_cfg) { + original_l2cu_process_our_cfg_req(p_ccb, p_cfg); + p_ccb->our_cfg.fcr.mode = 0x00; + LOGI("Set FCR mode to Basic Mode in outgoing config request"); +} + +void fake_l2c_csm_config(tL2C_CCB* p_ccb, uint8_t event, void* p_data) { + // Call the original function first to handle the specific code path where the FCR mode is checked + original_l2c_csm_config(p_ccb, event, p_data); + + // Check if this happens during CONFIG_RSP event handling + if (event == L2CEVT_L2CAP_CONFIG_RSP) { + p_ccb->our_cfg.fcr.mode = p_ccb->peer_cfg.fcr.mode; + LOGI("Forced compatibility in l2c_csm_config: set our_mode=%d to match peer_mode=%d", + p_ccb->our_cfg.fcr.mode, p_ccb->peer_cfg.fcr.mode); + } +} + +// Replacement function that does nothing +void fake_l2cu_send_peer_info_req(tL2C_LCB* p_lcb, uint16_t info_type) { + LOGI("Intercepted l2cu_send_peer_info_req for info_type 0x%04x - doing nothing", info_type); + // Just return without doing anything + return; +} + uintptr_t loadHookOffset([[maybe_unused]] const char* package_name) { - const char* property_name = "persist.aln.hook_offset"; + const char* property_name = "persist.librepods.hook_offset"; char value[PROP_VALUE_MAX] = {0}; int len = __system_property_get(property_name, value); if (len > 0) { LOGI("Read hook offset from property: %s", value); - uintptr_t offset; char* endptr = nullptr; @@ -172,6 +199,96 @@ uintptr_t loadHookOffset([[maybe_unused]] const char* package_name) { return 0x00a55e30; } +uintptr_t loadL2cuProcessCfgReqOffset() { + const char* property_name = "persist.librepods.cfg_req_offset"; + char value[PROP_VALUE_MAX] = {0}; + + int len = __system_property_get(property_name, value); + if (len > 0) { + LOGI("Read l2cu_process_our_cfg_req offset from property: %s", value); + uintptr_t offset; + char* endptr = nullptr; + + const char* parse_start = value; + if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) { + parse_start = value + 2; + } + + errno = 0; + offset = strtoul(parse_start, &endptr, 16); + + if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) { + LOGI("Parsed l2cu_process_our_cfg_req offset: 0x%x", offset); + return offset; + } + + LOGE("Failed to parse l2cu_process_our_cfg_req offset from property value: %s", value); + } + + // Return 0 if not found - we'll skip this hook + return 0; +} + +uintptr_t loadL2cCsmConfigOffset() { + const char* property_name = "persist.librepods.csm_config_offset"; + char value[PROP_VALUE_MAX] = {0}; + + int len = __system_property_get(property_name, value); + if (len > 0) { + LOGI("Read l2c_csm_config offset from property: %s", value); + uintptr_t offset; + char* endptr = nullptr; + + const char* parse_start = value; + if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) { + parse_start = value + 2; + } + + errno = 0; + offset = strtoul(parse_start, &endptr, 16); + + if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) { + LOGI("Parsed l2c_csm_config offset: 0x%x", offset); + return offset; + } + + LOGE("Failed to parse l2c_csm_config offset from property value: %s", value); + } + + // Return 0 if not found - we'll skip this hook + return 0; +} + +uintptr_t loadL2cuSendPeerInfoReqOffset() { + const char* property_name = "persist.librepods.peer_info_req_offset"; + char value[PROP_VALUE_MAX] = {0}; + + int len = __system_property_get(property_name, value); + if (len > 0) { + LOGI("Read l2cu_send_peer_info_req offset from property: %s", value); + uintptr_t offset; + char* endptr = nullptr; + + const char* parse_start = value; + if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) { + parse_start = value + 2; + } + + errno = 0; + offset = strtoul(parse_start, &endptr, 16); + + if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) { + LOGI("Parsed l2cu_send_peer_info_req offset: 0x%x", offset); + return offset; + } + + LOGE("Failed to parse l2cu_send_peer_info_req offset from property value: %s", value); + } + + // Return 0 if not found - we'll skip this hook + return 0; +} + uintptr_t getModuleBase(const char *module_name) { FILE *fp; char line[1024]; @@ -211,20 +328,84 @@ bool findAndHookFunction([[maybe_unused]] const char *library_path) { return false; } - uintptr_t offset = loadHookOffset(nullptr); + // Load all offsets from system properties - no hardcoding + uintptr_t l2c_fcr_offset = loadHookOffset(nullptr); + uintptr_t l2cu_process_our_cfg_req_offset = loadL2cuProcessCfgReqOffset(); + uintptr_t l2c_csm_config_offset = loadL2cCsmConfigOffset(); + uintptr_t l2cu_send_peer_info_req_offset = loadL2cuSendPeerInfoReqOffset(); - void* target = reinterpret_cast(base_addr + offset); - LOGI("Using offset: 0x%x, base: %p, target: %p", offset, (void*)base_addr, target); + bool success = false; - int result = hook_func(target, (void*)fake_l2c_fcr_chk_chan_modes, (void**)&original_l2c_fcr_chk_chan_modes); + // Hook l2c_fcr_chk_chan_modes - this is our primary hook + if (l2c_fcr_offset > 0) { + void* target = reinterpret_cast(base_addr + l2c_fcr_offset); + LOGI("Hooking l2c_fcr_chk_chan_modes at offset: 0x%x, base: %p, target: %p", + l2c_fcr_offset, (void*)base_addr, target); - if (result == 0) { + int result = hook_func(target, (void*)fake_l2c_fcr_chk_chan_modes, (void**)&original_l2c_fcr_chk_chan_modes); + if (result != 0) { + LOGE("Failed to hook l2c_fcr_chk_chan_modes, error: %d", result); + return false; + } LOGI("Successfully hooked l2c_fcr_chk_chan_modes"); - return true; + success = true; } else { - LOGE("Failed to hook function, error: %d", result); + LOGE("No valid offset for l2c_fcr_chk_chan_modes found, cannot proceed"); return false; } + + // Hook l2cu_process_our_cfg_req if offset is available + if (l2cu_process_our_cfg_req_offset > 0) { + void* target = reinterpret_cast(base_addr + l2cu_process_our_cfg_req_offset); + LOGI("Hooking l2cu_process_our_cfg_req at offset: 0x%x, base: %p, target: %p", + l2cu_process_our_cfg_req_offset, (void*)base_addr, target); + + int result = hook_func(target, (void*)fake_l2cu_process_our_cfg_req, (void**)&original_l2cu_process_our_cfg_req); + if (result != 0) { + LOGE("Failed to hook l2cu_process_our_cfg_req, error: %d", result); + // Continue even if this hook fails + } else { + LOGI("Successfully hooked l2cu_process_our_cfg_req"); + } + } else { + LOGI("Skipping l2cu_process_our_cfg_req hook as offset is not available"); + } + + // Hook l2c_csm_config if offset is available + if (l2c_csm_config_offset > 0) { + void* target = reinterpret_cast(base_addr + l2c_csm_config_offset); + LOGI("Hooking l2c_csm_config at offset: 0x%x, base: %p, target: %p", + l2c_csm_config_offset, (void*)base_addr, target); + + int result = hook_func(target, (void*)fake_l2c_csm_config, (void**)&original_l2c_csm_config); + if (result != 0) { + LOGE("Failed to hook l2c_csm_config, error: %d", result); + // Continue even if this hook fails + } else { + LOGI("Successfully hooked l2c_csm_config"); + } + } else { + LOGI("Skipping l2c_csm_config hook as offset is not available"); + } + + // Hook l2cu_send_peer_info_req if offset is available + if (l2cu_send_peer_info_req_offset > 0) { + void* target = reinterpret_cast(base_addr + l2cu_send_peer_info_req_offset); + LOGI("Hooking l2cu_send_peer_info_req at offset: 0x%x, base: %p, target: %p", + l2cu_send_peer_info_req_offset, (void*)base_addr, target); + + int result = hook_func(target, (void*)fake_l2cu_send_peer_info_req, (void**)&original_l2cu_send_peer_info_req); + if (result != 0) { + LOGE("Failed to hook l2cu_send_peer_info_req, error: %d", result); + // Continue even if this hook fails + } else { + LOGI("Successfully hooked l2cu_send_peer_info_req"); + } + } else { + LOGI("Skipping l2cu_send_peer_info_req hook as offset is not available"); + } + + return success; } void on_library_loaded(const char *name, [[maybe_unused]] void *handle) { diff --git a/android/app/src/main/cpp/l2c_fcr_hook.h b/android/app/src/main/cpp/l2c_fcr_hook.h index b7bfac4..cff43d4 100644 --- a/android/app/src/main/cpp/l2c_fcr_hook.h +++ b/android/app/src/main/cpp/l2c_fcr_hook.h @@ -17,5 +17,12 @@ typedef struct { [[maybe_unused]] typedef NativeOnModuleLoaded (*NativeInit)(const NativeAPIEntries *entries); +typedef struct t_l2c_ccb tL2C_CCB; +typedef struct t_l2c_lcb tL2C_LCB; + uintptr_t loadHookOffset(const char* package_name); uintptr_t getModuleBase(const char *module_name); +uintptr_t loadL2cuProcessCfgReqOffset(); +uintptr_t loadL2cCsmConfigOffset(); +uintptr_t loadL2cuSendPeerInfoReqOffset(); +bool findAndHookFunction(const char *library_path); diff --git a/android/app/src/main/java/me/kavishdevar/aln/services/AirPodsQSService.kt b/android/app/src/main/java/me/kavishdevar/aln/services/AirPodsQSService.kt deleted file mode 100644 index b4f6148..0000000 --- a/android/app/src/main/java/me/kavishdevar/aln/services/AirPodsQSService.kt +++ /dev/null @@ -1,142 +0,0 @@ -/* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! - * - * Copyright (C) 2024 Kavish Devar - * - * 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.aln.services - -import android.annotation.SuppressLint -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.os.Build -import android.service.quicksettings.Tile -import android.service.quicksettings.TileService -import android.util.Log -import me.kavishdevar.aln.utils.AirPodsNotifications -import me.kavishdevar.aln.utils.NoiseControlMode - -class AirPodsQSService: TileService() { - private val ancModes = listOf(NoiseControlMode.NOISE_CANCELLATION.name, NoiseControlMode.TRANSPARENCY.name, NoiseControlMode.ADAPTIVE.name) - private var currentModeIndex = 2 - private lateinit var ancStatusReceiver: BroadcastReceiver - private lateinit var availabilityReceiver: BroadcastReceiver - - @SuppressLint("InlinedApi", "UnspecifiedRegisterReceiverFlag") - override fun onStartListening() { - super.onStartListening() - currentModeIndex = (ServiceManager.getService()?.getANC()?.minus(1)) ?: -1 - if (currentModeIndex == -1) { - currentModeIndex = 2 - } - - if (ServiceManager.getService() == null) { - qsTile.state = Tile.STATE_UNAVAILABLE - qsTile.updateTile() - } - if (ServiceManager.getService()?.isConnectedLocally == true) { - qsTile.state = Tile.STATE_ACTIVE - qsTile.updateTile() - } - else { - qsTile.state = Tile.STATE_UNAVAILABLE - qsTile.updateTile() - } - - ancStatusReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val ancStatus = intent.getIntExtra("data", 4) - currentModeIndex = if (ancStatus == 2) 0 else if (ancStatus == 3) 1 else if (ancStatus == 4) 2 else 2 - updateTile() - } - } - - availabilityReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == AirPodsNotifications.Companion.AIRPODS_CONNECTED) { - qsTile.state = Tile.STATE_ACTIVE - qsTile.updateTile() - } - else if (intent.action == AirPodsNotifications.Companion.AIRPODS_DISCONNECTED) { - qsTile.state = Tile.STATE_UNAVAILABLE - qsTile.updateTile() - } - } - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver( - ancStatusReceiver, - IntentFilter(AirPodsNotifications.Companion.ANC_DATA), RECEIVER_EXPORTED - ) - } else { - registerReceiver( - ancStatusReceiver, - IntentFilter(AirPodsNotifications.Companion.ANC_DATA) - ) - } - qsTile.state = if (ServiceManager.getService()?.isConnectedLocally == true) Tile.STATE_ACTIVE else Tile.STATE_UNAVAILABLE - val ancIndex = ServiceManager.getService()?.getANC() - currentModeIndex = if (ancIndex != null) { if (ancIndex == 2) 0 else if (ancIndex == 3) 1 else if (ancIndex == 4) 2 else 2 } else 0 - updateTile() - } - - override fun onStopListening() { - super.onStopListening() - try { - unregisterReceiver(ancStatusReceiver) - } - catch ( - _: IllegalArgumentException - ) - { - Log.e("QuickSettingTileService", "Receiver not registered") - } - try { - unregisterReceiver(availabilityReceiver) - } - catch ( - _: IllegalArgumentException - ) - { - Log.e("QuickSettingTileService", "Receiver not registered") - } - } - - override fun onClick() { - super.onClick() - Log.d("QuickSettingTileService", "ANC tile clicked") - currentModeIndex = (currentModeIndex + 1) % ancModes.size - Log.d("QuickSettingTileService", "New mode index: $currentModeIndex, would be set to ${currentModeIndex + 1}") - switchAncMode() - } - - private fun updateTile() { - val currentMode = ancModes[currentModeIndex % ancModes.size] - qsTile.label = currentMode.replace("_", " ").lowercase().replaceFirstChar { it.uppercase() } - qsTile.state = Tile.STATE_ACTIVE - qsTile.updateTile() - } - - private fun switchAncMode() { - val airPodsService = ServiceManager.getService() - Log.d("QuickSettingTileService", "Setting ANC mode to ${currentModeIndex + 2}") - airPodsService?.setANCMode(currentModeIndex + 2) - Log.d("QuickSettingTileService", "ANC mode set to ${currentModeIndex + 2}") - updateTile() - } -} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/aln/utils/KotlinModule.kt b/android/app/src/main/java/me/kavishdevar/aln/utils/KotlinModule.kt deleted file mode 100644 index c8f280e..0000000 --- a/android/app/src/main/java/me/kavishdevar/aln/utils/KotlinModule.kt +++ /dev/null @@ -1,41 +0,0 @@ -package me.kavishdevar.aln.utils - -import android.content.pm.ApplicationInfo -import android.util.Log -import io.github.libxposed.api.XposedInterface -import io.github.libxposed.api.XposedModule -import io.github.libxposed.api.XposedModuleInterface -import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam - -private const val TAG = "AirPodsHook" -private lateinit var module: KotlinModule - -class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModule(base, param) { - init { - Log.i(TAG, "AirPodsHook module initialized at :: ${param.processName}") - module = this - } - - override fun onPackageLoaded(param: XposedModuleInterface.PackageLoadedParam) { - super.onPackageLoaded(param) - Log.i(TAG, "onPackageLoaded :: ${param.packageName}") - - if (param.packageName == "com.android.bluetooth") { - Log.i(TAG, "Bluetooth app detected, hooking l2c_fcr_chk_chan_modes") - - try { - if (param.isFirstPackage) { - Log.i(TAG, "Loading native library for Bluetooth hook") - System.loadLibrary("l2c_fcr_hook") - Log.i(TAG, "Native library loaded successfully") - } - } catch (e: Exception) { - Log.e(TAG, "Failed to load native library: ${e.message}", e) - } - } - } - - override fun getApplicationInfo(): ApplicationInfo { - return super.applicationInfo - } -} diff --git a/android/app/src/main/java/me/kavishdevar/aln/CustomDeviceActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt similarity index 97% rename from android/app/src/main/java/me/kavishdevar/aln/CustomDeviceActivity.kt rename to android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt index 7cead86..98398aa 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/CustomDeviceActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln +package me.kavishdevar.librepods import android.Manifest import android.annotation.SuppressLint @@ -47,7 +47,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import me.kavishdevar.aln.ui.theme.ALNTheme +import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import org.lsposed.hiddenapibypass.HiddenApiBypass import java.util.UUID @@ -57,7 +57,7 @@ class CustomDevice : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { - ALNTheme { + LibrePodsTheme { val connect = remember { mutableStateOf(false) } Scaffold( modifier = Modifier.fillMaxSize(), diff --git a/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt similarity index 89% rename from android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt rename to android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt index 6035db3..ce25b1b 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln +package me.kavishdevar.librepods import android.annotation.SuppressLint import android.content.BroadcastReceiver @@ -100,18 +100,18 @@ 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 me.kavishdevar.aln.screens.AirPodsSettingsScreen -import me.kavishdevar.aln.screens.AppSettingsScreen -import me.kavishdevar.aln.screens.DebugScreen -import me.kavishdevar.aln.screens.HeadTrackingScreen -import me.kavishdevar.aln.screens.LongPress -import me.kavishdevar.aln.screens.Onboarding -import me.kavishdevar.aln.screens.RenameScreen -import me.kavishdevar.aln.services.AirPodsService -import me.kavishdevar.aln.ui.theme.ALNTheme -import me.kavishdevar.aln.utils.AirPodsNotifications -import me.kavishdevar.aln.utils.CrossDevice -import me.kavishdevar.aln.utils.RadareOffsetFinder +import me.kavishdevar.librepods.screens.AirPodsSettingsScreen +import me.kavishdevar.librepods.screens.AppSettingsScreen +import me.kavishdevar.librepods.screens.DebugScreen +import me.kavishdevar.librepods.screens.HeadTrackingScreen +import me.kavishdevar.librepods.screens.LongPress +import me.kavishdevar.librepods.screens.Onboarding +import me.kavishdevar.librepods.screens.RenameScreen +import me.kavishdevar.librepods.services.AirPodsService +import me.kavishdevar.librepods.ui.theme.LibrePodsTheme +import me.kavishdevar.librepods.utils.AirPodsNotifications +import me.kavishdevar.librepods.utils.CrossDevice +import me.kavishdevar.librepods.utils.RadareOffsetFinder lateinit var serviceConnection: ServiceConnection lateinit var connectionStatusReceiver: BroadcastReceiver @@ -129,7 +129,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { - ALNTheme { + LibrePodsTheme { getSharedPreferences("settings", MODE_PRIVATE).edit().putLong("textColor", MaterialTheme.colorScheme.onSurface.toArgb().toLong()).apply() Main() @@ -180,6 +180,7 @@ fun Main() { val hookAvailable = RadareOffsetFinder(LocalContext.current).isHookOffsetAvailable() val context = LocalContext.current var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) } + val overlaySkipped = remember { mutableStateOf(context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("overlay_permission_skipped", false)) } val permissionState = rememberMultiplePermissionsState( permissions = listOf( @@ -197,7 +198,7 @@ fun Main() { canDrawOverlays = Settings.canDrawOverlays(context) } - if (permissionState.allPermissionsGranted && canDrawOverlays) { + if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) { val context = LocalContext.current context.startService(Intent(context, AirPodsService::class.java)) @@ -310,7 +311,7 @@ fun Main() { } } -@OptIn(ExperimentalPermissionsApi::class) +@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) @Composable fun PermissionsScreen( permissionState: MultiplePermissionsState, @@ -325,6 +326,8 @@ fun PermissionsScreen( val scrollState = rememberScrollState() + val basicPermissionsGranted = permissionState.permissions.all { it.status.isGranted } + val infiniteTransition = rememberInfiniteTransition(label = "pulse") val pulseScale by infiniteTransition.animateFloat( initialValue = 1f, @@ -514,6 +517,39 @@ fun PermissionsScreen( ), ) } + + if (!canDrawOverlays && basicPermissionsGranted) { + Spacer(modifier = Modifier.height(12.dp)) + + Button( + onClick = { + val editor = context.getSharedPreferences("settings", MODE_PRIVATE).edit() + editor.putBoolean("overlay_permission_skipped", true) + editor.apply() + + val intent = Intent(context, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + context.startActivity(intent) + }, + modifier = Modifier + .fillMaxWidth() + .height(55.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF757575) + ), + shape = RoundedCornerShape(8.dp) + ) { + Text( + "Continue without overlay", + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = Color.White + ), + ) + } + } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt new file mode 100644 index 0000000..c6d1214 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt @@ -0,0 +1,621 @@ +package me.kavishdevar.librepods + +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.ServiceConnection +import android.media.AudioManager +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.util.Log +import android.view.Gravity +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +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 kotlinx.coroutines.launch +import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush +import me.kavishdevar.librepods.composables.IconAreaSize +import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton +import me.kavishdevar.librepods.composables.VerticalVolumeSlider +import me.kavishdevar.librepods.services.AirPodsService +import me.kavishdevar.librepods.ui.theme.LibrePodsTheme +import me.kavishdevar.librepods.utils.AirPodsNotifications +import me.kavishdevar.librepods.utils.NoiseControlMode +import kotlin.math.abs + +data class DismissAnimationValues( + val offsetY: Dp = 0.dp, + val scale: Float = 1f, + val alpha: Float = 1f +) + +class QuickSettingsDialogActivity : ComponentActivity() { + + private var airPodsService: AirPodsService? = null + private var isBound = false + + private var isNoiseControlExpandedState by mutableStateOf(false) + + private val connection = object : ServiceConnection { + override fun onServiceConnected(className: ComponentName, service: IBinder) { + val binder = service as AirPodsService.LocalBinder + airPodsService = binder.getService() + isBound = true + Log.d("QSActivity", "Service bound") + setContent { + LibrePodsTheme { + DraggableDismissBox( + onDismiss = { finish() }, + onlyCollapseWhenClicked = { + if (isNoiseControlExpandedState) { + isNoiseControlExpandedState = false + true + } else { + false + } + } + ) { + if (isBound && airPodsService != null) { + NewControlCenterDialogContent( + service = airPodsService, + isNoiseControlExpanded = isNoiseControlExpandedState, + onNoiseControlExpandedChange = { isNoiseControlExpandedState = it } + ) + } else { + } + } + } + } + } + + override fun onServiceDisconnected(arg0: ComponentName) { + isBound = false + airPodsService = null + Log.d("QSActivity", "Service unbound") + finish() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL) + window.addFlags(WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH) + window.addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND) + window.setGravity(Gravity.BOTTOM) + + Intent(this, AirPodsService::class.java).also { intent -> + bindService(intent, connection, Context.BIND_AUTO_CREATE) + } + + setContent { + LibrePodsTheme { + DraggableDismissBox( + onDismiss = { finish() }, + onlyCollapseWhenClicked = { + if (isNoiseControlExpandedState) { + isNoiseControlExpandedState = false + true + } else { + false + } + } + ) { + if (isBound && airPodsService != null) { + NewControlCenterDialogContent( + service = airPodsService, + isNoiseControlExpanded = isNoiseControlExpandedState, + onNoiseControlExpandedChange = { isNoiseControlExpandedState = it } + ) + } else { + } + } + } + } + } + + override fun onDestroy() { + super.onDestroy() + if (isBound) { + unbindService(connection) + isBound = false + } + } +} + +@Composable +fun DraggableDismissBox( + onDismiss: () -> Unit, + onlyCollapseWhenClicked: () -> Boolean, + content: @Composable () -> Unit +) { + val coroutineScope = rememberCoroutineScope() + val density = LocalDensity.current + + var dragOffset by remember { mutableFloatStateOf(0f) } + var isDragging by remember { mutableStateOf(false) } + val dismissThreshold = 400f + + val animatedOffset = remember { Animatable(0f) } + val animatedScale = remember { Animatable(1f) } + val animatedAlpha = remember { Animatable(1f) } + + val backgroundAlpha by animateFloatAsState( + targetValue = if (isDragging) { + val dragProgress = (abs(dragOffset) / 800f).coerceIn(0f, 0.8f) + 1f - dragProgress + } else 1f, + label = "BackgroundFade" + ) + + LaunchedEffect(isDragging) { + if (!isDragging) { + if (abs(dragOffset) < dismissThreshold) { + val springSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessHigh, + visibilityThreshold = 0.1f + ) + launch { animatedOffset.animateTo(0f, springSpec) } + launch { animatedScale.animateTo(1f, springSpec) } + launch { animatedAlpha.animateTo(1f, tween(100)) } + dragOffset = 0f + } + } + } + + LaunchedEffect(dragOffset, isDragging) { + if (isDragging) { + val dragDirection = if (dragOffset > 0) 1f else -1f + val dragProgress = (abs(dragOffset) / 1000f).coerceIn(0f, 0.5f) + + animatedOffset.snapTo(dragOffset) + animatedScale.snapTo(1f - dragProgress * 0.3f) + animatedAlpha.snapTo(1f - dragProgress * 0.7f) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f * backgroundAlpha)) + .pointerInput(Unit) { + detectVerticalDragGestures( + onDragStart = { isDragging = true }, + onDragEnd = { + isDragging = false + if (abs(dragOffset) > dismissThreshold) { + coroutineScope.launch { + val direction = if (dragOffset > 0) 1f else -1f + + launch { + animatedOffset.animateTo( + direction * 1500f, + tween(350, easing = FastOutSlowInEasing) + ) + } + launch { animatedScale.animateTo(0.7f, tween(350)) } + launch { animatedAlpha.animateTo(0f, tween(250)) } + + kotlinx.coroutines.delay(350) + onDismiss() + } + } + }, + onDragCancel = { isDragging = false }, + onVerticalDrag = { change, dragAmount -> + change.consume() + dragOffset += dragAmount + } + ) + } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + onlyCollapseWhenClicked() + }, + contentAlignment = Alignment.BottomCenter + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer( + translationY = animatedOffset.value, + scaleX = animatedScale.value, + scaleY = animatedScale.value, + alpha = animatedAlpha.value + ), + contentAlignment = Alignment.BottomCenter + ) { + content() + } + } +} + +@Composable +fun NewControlCenterDialogContent( + service: AirPodsService?, + isNoiseControlExpanded: Boolean, + onNoiseControlExpandedChange: (Boolean) -> Unit +) { + val context = LocalContext.current + val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + val textColor = Color.White + + var currentAncMode by remember { mutableStateOf(NoiseControlMode.TRANSPARENCY) } + var isConvAwarenessEnabled by remember { mutableStateOf(false) } + + val isOffModeEnabled = remember { sharedPreferences.getBoolean("off_listening_mode", true) } + val availableModes = remember(isOffModeEnabled) { + mutableListOf( + NoiseControlMode.TRANSPARENCY, + NoiseControlMode.ADAPTIVE, + NoiseControlMode.NOISE_CANCELLATION + ).apply { + if (isOffModeEnabled) { + add(0, NoiseControlMode.OFF) + } + } + } + + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) } + var currentVolumeInt by remember { mutableIntStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)) } + val animatedVolumeFraction by animateFloatAsState( + targetValue = currentVolumeInt.toFloat() / maxVolume.toFloat(), + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMediumLow + ), + label = "VolumeAnimation" + ) + var liveDragFraction by remember { mutableFloatStateOf(animatedVolumeFraction) } + var isDraggingVolume by remember { mutableStateOf(false) } + LaunchedEffect(animatedVolumeFraction, isDraggingVolume) { + if (!isDraggingVolume) { + liveDragFraction = animatedVolumeFraction + } + } + + DisposableEffect(service, availableModes) { + val ancReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == AirPodsNotifications.ANC_DATA && service != null) { + val newModeOrdinal = intent.getIntExtra("data", NoiseControlMode.TRANSPARENCY.ordinal + 1) - 1 + val newMode = NoiseControlMode.entries.getOrElse(newModeOrdinal) { NoiseControlMode.TRANSPARENCY } + if (availableModes.contains(newMode)) { + currentAncMode = newMode + } else if (newMode == NoiseControlMode.OFF && !isOffModeEnabled) { + currentAncMode = NoiseControlMode.TRANSPARENCY + } + Log.d("QSActivity", "ANC Receiver updated mode to: $currentAncMode (available: ${availableModes.joinToString()})") + } + } + } + val filter = IntentFilter(AirPodsNotifications.ANC_DATA) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(ancReceiver, filter, Context.RECEIVER_EXPORTED) + } else { + context.registerReceiver(ancReceiver, filter) + } + + service?.let { + val initialModeOrdinal = it.getANC().minus(1) ?: NoiseControlMode.TRANSPARENCY.ordinal + var initialMode = NoiseControlMode.entries.getOrElse(initialModeOrdinal) { NoiseControlMode.TRANSPARENCY } + if (!availableModes.contains(initialMode)) { + initialMode = NoiseControlMode.TRANSPARENCY + } + currentAncMode = initialMode + isConvAwarenessEnabled = sharedPreferences.getBoolean("conversational_awareness", true) + Log.d("QSActivity", "Initial ANC: $currentAncMode, ConvAware: $isConvAwarenessEnabled") + } + + onDispose { + context.unregisterReceiver(ancReceiver) + } + } + + DisposableEffect(Unit) { + val volumeReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == "android.media.VOLUME_CHANGED_ACTION") { + val newVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + if (newVolume != currentVolumeInt) { + currentVolumeInt = newVolume + Log.d("QSActivity", "Volume Receiver updated volume to: $currentVolumeInt") + } + } + } + } + val filter = IntentFilter("android.media.VOLUME_CHANGED_ACTION") + context.registerReceiver(volumeReceiver, filter) + onDispose { + context.unregisterReceiver(volumeReceiver) + } + } + + val deviceName = remember { sharedPreferences.getString("name", "AirPods") ?: "AirPods" } + + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.Transparent) + .padding(horizontal = 24.dp) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent() + } + } + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween + ) { + if (service != null) { + Spacer(modifier = Modifier.weight(1f)) + + Column( + modifier = Modifier + .weight(2f) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(id = R.drawable.airpods), + contentDescription = "Device Icon", + tint = textColor.copy(alpha = 0.8f), + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = deviceName, + color = textColor, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + + Spacer(modifier = Modifier.height(32.dp)) + + VerticalVolumeSlider( + displayFraction = animatedVolumeFraction, + maxVolume = maxVolume, + onVolumeChange = { newVolume -> + currentVolumeInt = newVolume + try { + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVolume, 0) + } catch (e: Exception) { Log.e("QSActivity", "Failed to set volume", e) } + }, + initialFraction = animatedVolumeFraction, + onDragStateChange = { dragging -> isDraggingVolume = dragging }, + baseSliderHeight = 400.dp, + baseSliderWidth = 145.dp, + baseCornerRadius = 48.dp, + maxStretchFactor = 1.15f, + minCompressionFactor = 0.875f, + stretchSensitivity = 0.3f, + compressionSensitivity = 0.3f, + cornerRadiusChangeFactor = -0.5f, + directionalStretchRatio = 0.75f, + modifier = Modifier + .width(145.dp) + .padding(vertical = 8.dp) + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 72.dp) + .animateContentSize( + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ) + ), + contentAlignment = Alignment.Center + ) { + Crossfade( + targetState = isNoiseControlExpanded, + animationSpec = tween(durationMillis = 300), + label = "NoiseControlCrossfade" + ) { expanded -> + if (expanded) { + ControlCenterNoiseControlSegmentedButton( + availableModes = availableModes, + selectedMode = currentAncMode, + onModeSelected = { newMode -> + service.setANCMode(newMode.ordinal + 1) + currentAncMode = newMode + }, + modifier = Modifier.fillMaxWidth(0.8f) + ) + } else { + Row( + modifier = Modifier.fillMaxWidth(0.85f), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + val noiseControlButtonBrush = if (currentAncMode == NoiseControlMode.ADAPTIVE) { + AdaptiveRainbowBrush + } else { + null + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.weight(1f) + ) { + Box( + modifier = Modifier + .size(IconAreaSize) + .clip(CircleShape) + .background( + brush = noiseControlButtonBrush ?: + Brush.linearGradient(colors = listOf(Color(0xFF0A84FF), Color(0xFF0A84FF))) + ) + .clickable( + onClick = { onNoiseControlExpandedChange(true) }, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = getModeIconRes(currentAncMode)), + contentDescription = getModeLabel(currentAncMode), + tint = Color.White, + modifier = Modifier.size(32.dp) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = getModeLabel(currentAncMode), + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } + + Spacer(modifier = Modifier.width(24.dp)) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.weight(1f) + ) { + Box( + modifier = Modifier + .size(IconAreaSize) + .clip(CircleShape) + .background( + Brush.linearGradient( + colors = listOf( + if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E), + if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E) + ) + ) + ) + .clickable( + onClick = { + val newState = !isConvAwarenessEnabled + service.setCAEnabled(newState) + isConvAwarenessEnabled = newState + }, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.airpods), + contentDescription = "Conversational Awareness", + tint = Color.White, + modifier = Modifier.size(32.dp) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Conversational\nAwareness", + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + lineHeight = 14.sp + ) + } + } + } + } + } + + } else { + Spacer(modifier = Modifier.weight(1f)) + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text("Loading...", color = textColor) + } + Spacer(modifier = Modifier.weight(1f)) + } + } +} + +private fun getModeIconRes(mode: NoiseControlMode): Int { + return when (mode) { + NoiseControlMode.OFF -> R.drawable.noise_cancellation + NoiseControlMode.TRANSPARENCY -> R.drawable.transparency + NoiseControlMode.ADAPTIVE -> R.drawable.adaptive + NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation + } +} + +private fun getModeLabel(mode: NoiseControlMode): String { + return when (mode) { + NoiseControlMode.OFF -> "Off" + NoiseControlMode.TRANSPARENCY -> "Transparency" + NoiseControlMode.ADAPTIVE -> "Adaptive" + NoiseControlMode.NOISE_CANCELLATION -> "Noise Cancel" + } +} diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/AccessibilitySettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt similarity index 96% rename from android/app/src/main/java/me/kavishdevar/aln/composables/AccessibilitySettings.kt rename to android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt index ab126eb..929d45d 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/AccessibilitySettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.composables +package me.kavishdevar.librepods.composables import android.content.Context import android.content.SharedPreferences @@ -45,8 +45,8 @@ 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.aln.R -import me.kavishdevar.aln.services.AirPodsService +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.services.AirPodsService @Composable fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPreferences) { diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/AdaptiveStrengthSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AdaptiveStrengthSlider.kt similarity index 95% rename from android/app/src/main/java/me/kavishdevar/aln/composables/AdaptiveStrengthSlider.kt rename to android/app/src/main/java/me/kavishdevar/librepods/composables/AdaptiveStrengthSlider.kt index a898bf1..56e5295 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/AdaptiveStrengthSlider.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AdaptiveStrengthSlider.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.composables +package me.kavishdevar.librepods.composables import android.content.Context import android.content.SharedPreferences @@ -50,7 +50,7 @@ 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.aln.services.AirPodsService +import me.kavishdevar.librepods.services.AirPodsService import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/AudioSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt similarity index 93% rename from android/app/src/main/java/me/kavishdevar/aln/composables/AudioSettings.kt rename to android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt index 4c9afa9..157e597 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/AudioSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.composables +package me.kavishdevar.librepods.composables import android.content.Context import android.content.SharedPreferences @@ -37,8 +37,8 @@ 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.aln.R -import me.kavishdevar.aln.services.AirPodsService +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.services.AirPodsService @Composable fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences) { diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/BatteryIndicator.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryIndicator.kt similarity index 95% rename from android/app/src/main/java/me/kavishdevar/aln/composables/BatteryIndicator.kt rename to android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryIndicator.kt index 26248fa..130f71a 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/BatteryIndicator.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryIndicator.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.composables +package me.kavishdevar.librepods.composables import androidx.compose.animation.core.animateFloatAsState @@ -48,7 +48,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import me.kavishdevar.aln.R +import me.kavishdevar.librepods.R @Composable fun BatteryIndicator(batteryPercentage: Int, charging: Boolean = false) { diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/BatteryView.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt similarity index 93% rename from android/app/src/main/java/me/kavishdevar/aln/composables/BatteryView.kt rename to android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt index 8f66bbf..dd2cfa5 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/BatteryView.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.composables +package me.kavishdevar.librepods.composables import android.content.BroadcastReceiver import android.content.Context @@ -44,12 +44,12 @@ import androidx.compose.ui.res.imageResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import me.kavishdevar.aln.R -import me.kavishdevar.aln.services.AirPodsService -import me.kavishdevar.aln.utils.AirPodsNotifications -import me.kavishdevar.aln.utils.Battery -import me.kavishdevar.aln.utils.BatteryComponent -import me.kavishdevar.aln.utils.BatteryStatus +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.services.AirPodsService +import me.kavishdevar.librepods.utils.AirPodsNotifications +import me.kavishdevar.librepods.utils.Battery +import me.kavishdevar.librepods.utils.BatteryComponent +import me.kavishdevar.librepods.utils.BatteryStatus @Composable fun BatteryView(service: AirPodsService, preview: Boolean = false) { 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 new file mode 100644 index 0000000..31df084 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterButton.kt @@ -0,0 +1,104 @@ +/* + * LibrePods - AirPods liberated from Apple’s ecosystem + * + * Copyright (C) 2025 Kavish Devar + * + * 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.foundation.background +import androidx.compose.foundation.clickable +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.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +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 + +private val SelectedColorBlue = Color(0xFF0A84FF) +private val UnselectedColor = Color(0x593C3C3E) +private val TextColor = Color.White +private val IconTint = Color.White + +@Composable +fun ControlCenterButton( + label: String, + icon: Painter, + onClick: () -> Unit, + modifier: Modifier = Modifier, + iconAreaSize: Dp, + isSelected: Boolean, + backgroundBrush: Brush? = null +) { + val targetBackgroundColor = if (isSelected) SelectedColorBlue else UnselectedColor + val backgroundColor by animateColorAsState( + targetValue = targetBackgroundColor, + label = "ButtonBackground" + ) + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(iconAreaSize) + .clip(CircleShape) + .background(backgroundBrush ?: Brush.linearGradient(colors=listOf(backgroundColor, backgroundColor))) + .clickable( + onClick = onClick, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = icon, + contentDescription = null, + tint = IconTint, + modifier = Modifier.size(32.dp) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = label, + color = TextColor, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + maxLines = 2 + ) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterNoiseControlSegmentedButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterNoiseControlSegmentedButton.kt new file mode 100644 index 0000000..9daece6 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterNoiseControlSegmentedButton.kt @@ -0,0 +1,242 @@ +/* + * LibrePods - AirPods liberated from Apple’s ecosystem + * + * Copyright (C) 2025 Kavish Devar + * + * 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.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.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.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +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 me.kavishdevar.librepods.R +import me.kavishdevar.librepods.utils.NoiseControlMode + +private val ContainerColor = Color(0x593C3C3E) +private val SelectedIndicatorColorGray = Color(0xFF6C6C6E) +private val SelectedIndicatorColorBlue = Color(0xFF0A84FF) +private val TextColor = Color.White +private val IconTintUnselected = Color.White +private val IconTintSelected = Color.White + +internal val AdaptiveRainbowBrush = Brush.sweepGradient( + colors = listOf( + Color(0xFFB03A2F), Color(0xFFB07A2F), Color(0xFFB0A22F), Color(0xFF6AB02F), + Color(0xFF2FAAB0), Color(0xFF2F5EB0), Color(0xFF7D2FB0), Color(0xFFB02F7D), + Color(0xFFB03A2F) + ) +) + +internal val IconAreaSize = 72.dp +private val IconSize = 42.dp +private val IconRowHeight = IconAreaSize + 12.dp +private val TextRowHeight = 24.dp +private val TextSize = 12.sp + +@Composable +fun ControlCenterNoiseControlSegmentedButton( + modifier: Modifier = Modifier, + availableModes: List, + selectedMode: NoiseControlMode, + onModeSelected: (NoiseControlMode) -> Unit +) { + val selectedIndex = availableModes.indexOf(selectedMode).coerceAtLeast(0) + val density = LocalDensity.current + var iconRowWidthPx by remember { mutableStateOf(0f) } + val itemCount = availableModes.size + + val itemSlotWidthPx = remember(iconRowWidthPx, itemCount) { + if (itemCount > 0 && iconRowWidthPx > 0) { + iconRowWidthPx / itemCount + } else { + 0f + } + } + val itemSlotWidthDp = remember(itemSlotWidthPx) { with(density) { itemSlotWidthPx.toDp() } } + val iconAreaSizePx = remember { with(density) { IconAreaSize.toPx() } } + + val targetIndicatorStartPx = remember(selectedIndex, itemSlotWidthPx, iconAreaSizePx) { + if (itemSlotWidthPx > 0) { + val slotCenterPx = (selectedIndex + 0.5f) * itemSlotWidthPx + slotCenterPx - (iconAreaSizePx / 2f) + } else { + 0f + } + } + + val indicatorOffset: Dp by animateDpAsState( + targetValue = with(density) { targetIndicatorStartPx.toDp() }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMedium + ), + label = "IndicatorOffset" + ) + + val indicatorBackground = remember(selectedMode) { + when (selectedMode) { + NoiseControlMode.ADAPTIVE -> AdaptiveRainbowBrush + NoiseControlMode.OFF -> Brush.linearGradient(colors=listOf(SelectedIndicatorColorGray, SelectedIndicatorColorGray)) + NoiseControlMode.TRANSPARENCY, + NoiseControlMode.NOISE_CANCELLATION -> Brush.linearGradient(colors=listOf(SelectedIndicatorColorBlue, SelectedIndicatorColorBlue)) + } + } + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(IconRowHeight) + .clip(CircleShape) + .background(ContainerColor) + .onSizeChanged { iconRowWidthPx = it.width.toFloat() }, + contentAlignment = Alignment.Center + ) { + Box( + Modifier + .align(Alignment.CenterStart) + .offset(x = indicatorOffset) + .size(IconAreaSize) + .clip(CircleShape) + .background(indicatorBackground) + ) + + Row( + modifier = Modifier.fillMaxWidth().align(Alignment.Center), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceAround + ) { + availableModes.forEach { mode -> + val isSelected = selectedMode == mode + NoiseControlIconItem( + modifier = Modifier.size(IconAreaSize), + mode = mode, + isSelected = isSelected, + onClick = { onModeSelected(mode) } + ) + } + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .height(TextRowHeight), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically + ) { + availableModes.forEach { mode -> + val isSelected = selectedMode == mode + Text( + text = getModeLabel(mode), + color = TextColor, + fontSize = TextSize, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + textAlign = TextAlign.Center, + modifier = Modifier.width(itemSlotWidthDp.coerceAtLeast(1.dp)) + ) + } + } + } +} + +@Composable +private fun NoiseControlIconItem( + modifier: Modifier = Modifier, + mode: NoiseControlMode, + isSelected: Boolean, + onClick: () -> Unit +) { + val iconRes = remember(mode) { getModeIconRes(mode) } + + val tint = IconTintUnselected + + Box( + modifier = modifier + .clip(CircleShape) + .clickable( + onClick = onClick, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = getModeLabel(mode), + tint = if (isSelected && mode == NoiseControlMode.ADAPTIVE) IconTintSelected else tint, + modifier = Modifier.size(IconSize) + ) + } +} + + +private fun getModeIconRes(mode: NoiseControlMode): Int { + return when (mode) { + NoiseControlMode.OFF -> R.drawable.noise_cancellation + NoiseControlMode.TRANSPARENCY -> R.drawable.transparency + NoiseControlMode.ADAPTIVE -> R.drawable.adaptive + NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation + } +} + +private fun getModeLabel(mode: NoiseControlMode): String { + return when (mode) { + NoiseControlMode.OFF -> "Off" + NoiseControlMode.TRANSPARENCY -> "Transparency" + NoiseControlMode.ADAPTIVE -> "Adaptive" + NoiseControlMode.NOISE_CANCELLATION -> "Noise Cancellation" + } +} + diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/ConversationalAwarenessSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt similarity index 94% rename from android/app/src/main/java/me/kavishdevar/aln/composables/ConversationalAwarenessSwitch.kt rename to android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt index 6476062..de2f8ec 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/ConversationalAwarenessSwitch.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.composables +package me.kavishdevar.librepods.composables import android.content.SharedPreferences import androidx.compose.foundation.background @@ -45,7 +45,7 @@ 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.aln.services.AirPodsService +import me.kavishdevar.librepods.services.AirPodsService @Composable fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) { diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/CustomDropdown.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/CustomDropdown.kt similarity index 96% rename from android/app/src/main/java/me/kavishdevar/aln/composables/CustomDropdown.kt rename to android/app/src/main/java/me/kavishdevar/librepods/composables/CustomDropdown.kt index ba7f8eb..a4d37b6 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/CustomDropdown.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/CustomDropdown.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.composables +package me.kavishdevar.librepods.composables import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState @@ -55,7 +55,7 @@ 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.aln.R +import me.kavishdevar.librepods.R class DropdownItem(val name: String, val onSelect: () -> Unit) { fun select() { diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/IndependentToggle.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt similarity index 95% rename from android/app/src/main/java/me/kavishdevar/aln/composables/IndependentToggle.kt rename to android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt index 47bb8fa..f310f29 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/IndependentToggle.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.composables +package me.kavishdevar.librepods.composables import android.content.SharedPreferences import androidx.compose.animation.animateColorAsState @@ -45,7 +45,7 @@ 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.aln.services.AirPodsService +import me.kavishdevar.librepods.services.AirPodsService @Composable fun IndependentToggle(name: String, service: AirPodsService? = null, functionName: String? = null, sharedPreferences: SharedPreferences, default: Boolean = false) { diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/LoudSoundReductionSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt similarity index 94% rename from android/app/src/main/java/me/kavishdevar/aln/composables/LoudSoundReductionSwitch.kt rename to android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt index 0cb817c..2f971f9 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/LoudSoundReductionSwitch.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.composables +package me.kavishdevar.librepods.composables import android.content.SharedPreferences import androidx.compose.foundation.background @@ -45,7 +45,7 @@ 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.aln.services.AirPodsService +import me.kavishdevar.librepods.services.AirPodsService @Composable fun LoudSoundReductionSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) { diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/NameField.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/NameField.kt similarity index 96% rename from android/app/src/main/java/me/kavishdevar/aln/composables/NameField.kt rename to android/app/src/main/java/me/kavishdevar/librepods/composables/NameField.kt index 8b4f6c7..399adc4 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/NameField.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/NameField.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.composables +package me.kavishdevar.librepods.composables import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/NavigationButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/NavigationButton.kt similarity index 95% rename from android/app/src/main/java/me/kavishdevar/aln/composables/NavigationButton.kt rename to android/app/src/main/java/me/kavishdevar/librepods/composables/NavigationButton.kt index 2f74e89..28e71bf 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/NavigationButton.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/NavigationButton.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.composables +package me.kavishdevar.librepods.composables import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/NoiseControlButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlButton.kt similarity index 91% rename from android/app/src/main/java/me/kavishdevar/aln/composables/NoiseControlButton.kt rename to android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlButton.kt index 63bdfa0..504c9d0 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/NoiseControlButton.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlButton.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.composables +package me.kavishdevar.librepods.composables import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -35,7 +35,7 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.res.imageResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import me.kavishdevar.aln.R +import me.kavishdevar.librepods.R @Composable fun NoiseControlButton( diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/NoiseControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt similarity index 91% rename from android/app/src/main/java/me/kavishdevar/aln/composables/NoiseControlSettings.kt rename to android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt index d7d6289..4b7b111 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/NoiseControlSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.composables +package me.kavishdevar.librepods.composables import android.annotation.SuppressLint import android.content.BroadcastReceiver @@ -72,15 +72,18 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex -import me.kavishdevar.aln.R -import me.kavishdevar.aln.services.AirPodsService -import me.kavishdevar.aln.utils.AirPodsNotifications -import me.kavishdevar.aln.utils.NoiseControlMode +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.services.AirPodsService +import me.kavishdevar.librepods.utils.AirPodsNotifications +import me.kavishdevar.librepods.utils.NoiseControlMode import kotlin.math.roundToInt @SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope") @Composable -fun NoiseControlSettings(service: AirPodsService) { +fun NoiseControlSettings( + service: AirPodsService, + onModeSelectedCallback: () -> Unit = {} // Callback parameter remains, but won't finish activity +) { val context = LocalContext.current val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) val offListeningMode = remember { mutableStateOf(sharedPreferences.getBoolean("off_listening_mode", true)) } @@ -113,13 +116,27 @@ fun NoiseControlSettings(service: AirPodsService) { val d3a = remember { mutableFloatStateOf(0f) } fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) { - if (!received && !offListeningMode.value && mode == NoiseControlMode.OFF) { - noiseControlMode.value = NoiseControlMode.ADAPTIVE + val previousMode = noiseControlMode.value // Store previous mode + + // Ensure the mode is valid if 'Off' is disabled + val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) { + // If trying to select OFF but it's disabled, default to Transparency or Adaptive + NoiseControlMode.TRANSPARENCY // Or ADAPTIVE, based on preference } else { - noiseControlMode.value = mode + mode } - if (!received) service.setANCMode(mode.ordinal + 1) - when (noiseControlMode.value) { + + noiseControlMode.value = targetMode // Update internal state immediately + + // Only call service if the mode was manually selected (!received) + // and the target mode is actually different from the previous mode + if (!received && targetMode != previousMode) { + service.setANCMode(targetMode.ordinal + 1) + // onModeSelectedCallback() // REMOVE this call to keep dialog open + } + + // Update divider alphas based on the *new* mode + when (noiseControlMode.value) { // Use the updated noiseControlMode.value NoiseControlMode.NOISE_CANCELLATION -> { d1a.floatValue = 1f d2a.floatValue = 1f @@ -312,9 +329,10 @@ fun NoiseControlSettings(service: AirPodsService) { 1 -> if (offListeningMode.value) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE 2 -> if (offListeningMode.value) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION 3 -> NoiseControlMode.NOISE_CANCELLATION - else -> null + else -> noiseControlMode.value // Keep current if index is invalid } - newMode?.let { onModeSelected(it) } + // Call onModeSelected which now handles service call but not callback + onModeSelected(newMode) } ) ) { @@ -429,5 +447,5 @@ fun NoiseControlSettings(service: AirPodsService) { @Preview() @Composable fun NoiseControlSettingsPreview() { - NoiseControlSettings(AirPodsService()) + NoiseControlSettings(AirPodsService()) {} } \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/PersonalizedVolumeSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt similarity index 94% rename from android/app/src/main/java/me/kavishdevar/aln/composables/PersonalizedVolumeSwitch.kt rename to android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt index f631ca3..31379dc 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/PersonalizedVolumeSwitch.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.composables +package me.kavishdevar.librepods.composables import android.content.SharedPreferences import androidx.compose.foundation.background @@ -45,7 +45,7 @@ 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.aln.services.AirPodsService +import me.kavishdevar.librepods.services.AirPodsService @Composable fun PersonalizedVolumeSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) { diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/PressAndHoldSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt similarity index 97% rename from android/app/src/main/java/me/kavishdevar/aln/composables/PressAndHoldSettings.kt rename to android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt index 6a0f20e..e3a73ae 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/PressAndHoldSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.composables +package me.kavishdevar.librepods.composables import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween @@ -56,7 +56,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController -import me.kavishdevar.aln.R +import me.kavishdevar.librepods.R @Composable fun PressAndHoldSettings(navController: NavController) { diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/SinglePodANCSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/SinglePodANCSwitch.kt similarity index 94% rename from android/app/src/main/java/me/kavishdevar/aln/composables/SinglePodANCSwitch.kt rename to android/app/src/main/java/me/kavishdevar/librepods/composables/SinglePodANCSwitch.kt index e572b62..14410c7 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/SinglePodANCSwitch.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/SinglePodANCSwitch.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.composables +package me.kavishdevar.librepods.composables import android.content.SharedPreferences import androidx.compose.foundation.background @@ -45,7 +45,7 @@ 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.aln.services.AirPodsService +import me.kavishdevar.librepods.services.AirPodsService @Composable fun SinglePodANCSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) { diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/StyledSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt similarity index 93% rename from android/app/src/main/java/me/kavishdevar/aln/composables/StyledSwitch.kt rename to android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt index a631bcc..ba7a67c 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/StyledSwitch.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.composables +package me.kavishdevar.librepods.composables import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/ToneVolumeSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt similarity index 95% rename from android/app/src/main/java/me/kavishdevar/aln/composables/ToneVolumeSlider.kt rename to android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt index f3fd318..69d5edb 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/ToneVolumeSlider.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.composables +package me.kavishdevar.librepods.composables import android.content.SharedPreferences import androidx.compose.foundation.background @@ -50,8 +50,8 @@ 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.aln.R -import me.kavishdevar.aln.services.AirPodsService +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.services.AirPodsService import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/TransparencySettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/TransparencySettings.kt similarity index 98% rename from android/app/src/main/java/me/kavishdevar/aln/composables/TransparencySettings.kt rename to android/app/src/main/java/me/kavishdevar/librepods/composables/TransparencySettings.kt index 4a1d7f4..3eece59 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/TransparencySettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/TransparencySettings.kt @@ -1,4 +1,4 @@ -package me.kavishdevar.aln.composables +package me.kavishdevar.librepods.composables import android.content.SharedPreferences import androidx.compose.foundation.background @@ -35,8 +35,8 @@ import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import me.kavishdevar.aln.R -import me.kavishdevar.aln.services.AirPodsService +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.services.AirPodsService @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/VerticalVolumeSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/VerticalVolumeSlider.kt new file mode 100644 index 0000000..3c9bd41 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/VerticalVolumeSlider.kt @@ -0,0 +1,190 @@ +/* + * LibrePods - AirPods liberated from Apple’s ecosystem + * + * Copyright (C) 2025 Kavish Devar + * + * 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.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +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.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.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sign + +@Composable +fun VerticalVolumeSlider( + displayFraction: Float, + maxVolume: Int, + onVolumeChange: (Int) -> Unit, + initialFraction: Float, + onDragStateChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + baseSliderHeight: Dp = 400.dp, + baseSliderWidth: Dp = 145.dp, + baseCornerRadius: Dp = 45.dp, + maxStretchFactor: Float = 1.15f, + minCompressionFactor: Float = 0.875f, + stretchSensitivity: Float = 1.0f, + compressionSensitivity: Float = 1.0f, + cornerRadiusChangeFactor: Float = 0.2f, + directionalStretchRatio: Float = 0.75f +) { + val trackColor = Color(0x593C3C3E) + val progressColor = Color.White + + var dragFraction by remember { mutableFloatStateOf(initialFraction) } + var isDragging by remember { mutableStateOf(false) } + + var rawDragPosition by remember { mutableFloatStateOf(initialFraction) } + var overscrollAmount by remember { mutableFloatStateOf(0f) } + + val baseHeightPx = with(LocalDensity.current) { baseSliderHeight.toPx() } + + val animatedProgress by animateFloatAsState( + targetValue = dragFraction.coerceIn(0f, 1f), + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMedium + ), + label = "ProgressAnimation" + ) + + val animatedOverscroll by animateFloatAsState( + targetValue = overscrollAmount, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMediumLow + ), + label = "OverscrollAnimation" + ) + + val maxOverscrollEffect = (maxStretchFactor - 1f).coerceAtLeast(0f) + + val stretchMultiplier = stretchSensitivity + val compressionMultiplier = compressionSensitivity + + val overscrollDirection = sign(animatedOverscroll) + + val totalStretchAmount = (min(maxOverscrollEffect, abs(animatedOverscroll) * stretchMultiplier) * baseSliderHeight.value).dp + + val offsetY = if (abs(animatedOverscroll) > 0.001f) { + val asymmetricOffset = totalStretchAmount * (directionalStretchRatio - 0.5f) + (-overscrollDirection * asymmetricOffset.value).dp + } else { + 0.dp + } + + val heightStretch = baseSliderHeight + totalStretchAmount + + val widthCompression = baseSliderWidth * max( + minCompressionFactor, + 1f - min(1f - minCompressionFactor, abs(animatedOverscroll) * compressionMultiplier) + ) + + val dynamicCornerRadius = baseCornerRadius * (1f - min(cornerRadiusChangeFactor, abs(animatedOverscroll) * cornerRadiusChangeFactor * 2f)) + + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .height(heightStretch) + .width(widthCompression) + .offset(y = offsetY) + .clip(RoundedCornerShape(dynamicCornerRadius)) + .background(trackColor) + .pointerInput(Unit) { + detectTapGestures { offset -> + val newFraction = 1f - (offset.y / size.height).coerceIn(0f, 1f) + dragFraction = newFraction + rawDragPosition = newFraction + overscrollAmount = 0f + + val newVolume = (newFraction * maxVolume).roundToInt() + onVolumeChange(newVolume) + } + } + .draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { delta -> + rawDragPosition -= (delta / baseHeightPx) + + dragFraction = rawDragPosition.coerceIn(0f, 1f) + + overscrollAmount = when { + rawDragPosition > 1f -> min(1.0f, (rawDragPosition - 1f) * 2.0f) + rawDragPosition < 0f -> max(-1.0f, rawDragPosition * 2.0f) + else -> 0f + } + + val newVolume = (dragFraction * maxVolume).roundToInt() + onVolumeChange(newVolume) + }, + onDragStarted = { + isDragging = true + dragFraction = displayFraction + rawDragPosition = displayFraction + overscrollAmount = 0f + onDragStateChange(true) + }, + onDragStopped = { + isDragging = false + overscrollAmount = 0f + rawDragPosition = dragFraction + onDragStateChange(false) + } + ), + contentAlignment = Alignment.BottomCenter + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(animatedProgress) + .background(progressColor) + ) + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/VolumeControlSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/VolumeControlSwitch.kt similarity index 94% rename from android/app/src/main/java/me/kavishdevar/aln/composables/VolumeControlSwitch.kt rename to android/app/src/main/java/me/kavishdevar/librepods/composables/VolumeControlSwitch.kt index f668c0a..1acbef4 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/VolumeControlSwitch.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/VolumeControlSwitch.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.composables +package me.kavishdevar.librepods.composables import android.content.SharedPreferences import androidx.compose.foundation.background @@ -45,7 +45,7 @@ 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.aln.services.AirPodsService +import me.kavishdevar.librepods.services.AirPodsService @Composable fun VolumeControlSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) { diff --git a/android/app/src/main/java/me/kavishdevar/aln/receivers/BootReceiver.kt b/android/app/src/main/java/me/kavishdevar/librepods/receivers/BootReceiver.kt similarity index 85% rename from android/app/src/main/java/me/kavishdevar/aln/receivers/BootReceiver.kt rename to android/app/src/main/java/me/kavishdevar/librepods/receivers/BootReceiver.kt index cd2a20e..d7a1a23 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/receivers/BootReceiver.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/receivers/BootReceiver.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,12 +16,12 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.receivers +package me.kavishdevar.librepods.receivers import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import me.kavishdevar.aln.services.AirPodsService +import me.kavishdevar.librepods.services.AirPodsService class BootReceiver: BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { diff --git a/android/app/src/main/java/me/kavishdevar/aln/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt similarity index 92% rename from android/app/src/main/java/me/kavishdevar/aln/screens/AirPodsSettingsScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt index 427acfa..cc6391a 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.screens +package me.kavishdevar.librepods.screens import android.annotation.SuppressLint import android.bluetooth.BluetoothDevice @@ -84,18 +84,18 @@ import dev.chrisbanes.haze.hazeChild import dev.chrisbanes.haze.materials.CupertinoMaterials import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import kotlinx.coroutines.launch -import me.kavishdevar.aln.R -import me.kavishdevar.aln.composables.AccessibilitySettings -import me.kavishdevar.aln.composables.AudioSettings -import me.kavishdevar.aln.composables.BatteryView -import me.kavishdevar.aln.composables.IndependentToggle -import me.kavishdevar.aln.composables.NameField -import me.kavishdevar.aln.composables.NavigationButton -import me.kavishdevar.aln.composables.NoiseControlSettings -import me.kavishdevar.aln.composables.PressAndHoldSettings -import me.kavishdevar.aln.services.AirPodsService -import me.kavishdevar.aln.ui.theme.ALNTheme -import me.kavishdevar.aln.utils.AirPodsNotifications +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.AccessibilitySettings +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.NavigationButton +import me.kavishdevar.librepods.composables.NoiseControlSettings +import me.kavishdevar.librepods.composables.PressAndHoldSettings +import me.kavishdevar.librepods.services.AirPodsService +import me.kavishdevar.librepods.ui.theme.LibrePodsTheme +import me.kavishdevar.librepods.utils.AirPodsNotifications @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") @@ -147,11 +147,11 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, val bluetoothReceiver = remember { object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == "me.kavishdevar.aln.AIRPODS_CONNECTED_REMOTELY") { + if (intent?.action == "me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY") { coroutineScope.launch { handleRemoteConnection(true) } - } else if (intent?.action == "me.kavishdevar.aln.AIRPODS_DISCONNECTED_REMOTELY") { + } else if (intent?.action == "me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY") { coroutineScope.launch { handleRemoteConnection(false) } @@ -168,8 +168,8 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, DisposableEffect(Unit) { val filter = IntentFilter().apply { - addAction("me.kavishdevar.aln.AIRPODS_CONNECTED_REMOTELY") - addAction("me.kavishdevar.aln.AIRPODS_DISCONNECTED_REMOTELY") + addAction("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY") + addAction("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY") addAction(AirPodsNotifications.DISCONNECT_RECEIVERS) } @@ -399,7 +399,7 @@ fun AirPodsSettingsScreenPreview() { Column ( modifier = Modifier.height(2000.dp) ) { - ALNTheme ( + LibrePodsTheme ( darkTheme = true ) { AirPodsSettingsScreen(dev = null, service = AirPodsService(), navController = rememberNavController(), isConnected = true, isRemotelyConnected = false) diff --git a/android/app/src/main/java/me/kavishdevar/aln/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt similarity index 97% rename from android/app/src/main/java/me/kavishdevar/aln/screens/AppSettingsScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt index 82c6db8..67289af 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/screens/AppSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.screens +package me.kavishdevar.librepods.screens import android.content.Context import android.widget.Toast @@ -74,11 +74,11 @@ 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.aln.R -import me.kavishdevar.aln.composables.IndependentToggle -import me.kavishdevar.aln.composables.StyledSwitch -import me.kavishdevar.aln.services.ServiceManager -import me.kavishdevar.aln.utils.RadareOffsetFinder +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.IndependentToggle +import me.kavishdevar.librepods.composables.StyledSwitch +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.RadareOffsetFinder import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @@ -419,7 +419,7 @@ fun AppSettingsScreen(navController: NavController) { confirmButton = { TextButton( onClick = { - if (RadareOffsetFinder.clearHookOffset()) { + if (RadareOffsetFinder.clearHookOffsets()) { Toast.makeText( context, "Hook offset has been reset. Redirecting to setup...", diff --git a/android/app/src/main/java/me/kavishdevar/aln/screens/DebugScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt similarity index 98% rename from android/app/src/main/java/me/kavishdevar/aln/screens/DebugScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt index 1f07802..c41fe84 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/screens/DebugScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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,7 +18,7 @@ @file:OptIn(ExperimentalHazeMaterialsApi::class) -package me.kavishdevar.aln.screens +package me.kavishdevar.librepods.screens import android.annotation.SuppressLint import android.content.ClipData @@ -99,11 +99,11 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CoroutineScope -import me.kavishdevar.aln.R -import me.kavishdevar.aln.services.ServiceManager -import me.kavishdevar.aln.utils.BatteryStatus -import me.kavishdevar.aln.utils.isHeadTrackingData -import me.kavishdevar.aln.composables.StyledSwitch +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.BatteryStatus +import me.kavishdevar.librepods.utils.isHeadTrackingData +import me.kavishdevar.librepods.composables.StyledSwitch import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.imePadding import androidx.compose.ui.geometry.Offset diff --git a/android/app/src/main/java/me/kavishdevar/aln/screens/HeadTrackingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt similarity index 98% rename from android/app/src/main/java/me/kavishdevar/aln/screens/HeadTrackingScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt index aedf267..b60666b 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/screens/HeadTrackingScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.screens +package me.kavishdevar.librepods.screens import android.content.Context import android.os.Build @@ -99,10 +99,10 @@ import androidx.navigation.NavController import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import me.kavishdevar.aln.R -import me.kavishdevar.aln.composables.IndependentToggle -import me.kavishdevar.aln.services.ServiceManager -import me.kavishdevar.aln.utils.HeadTracking +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.IndependentToggle +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.HeadTracking import kotlin.math.abs import kotlin.math.cos import kotlin.math.sin diff --git a/android/app/src/main/java/me/kavishdevar/aln/screens/Onboarding.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/Onboarding.kt similarity index 88% rename from android/app/src/main/java/me/kavishdevar/aln/screens/Onboarding.kt rename to android/app/src/main/java/me/kavishdevar/librepods/screens/Onboarding.kt index 28acc34..dc7a540 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/screens/Onboarding.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/Onboarding.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.screens +package me.kavishdevar.librepods.screens import android.content.Context import android.util.Log @@ -39,18 +39,24 @@ 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 @@ -76,8 +82,8 @@ import androidx.navigation.NavController import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import me.kavishdevar.aln.R -import me.kavishdevar.aln.utils.RadareOffsetFinder +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.utils.RadareOffsetFinder @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -97,6 +103,9 @@ 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() { checkingRoot = true rootCheckFailed = false @@ -158,7 +167,29 @@ fun Onboarding(navController: NavController, activityContext: Context) { }, 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) @@ -477,6 +508,51 @@ fun Onboarding(navController: NavController, activityContext: Context) { } } } + + if (showSkipDialog) { + AlertDialog( + onDismissRequest = { showSkipDialog = false }, + title = { Text("Skip Setup") }, + text = { + Text( + "Have you installed the root module that patches the Bluetooth library directly? This option is for users who have manually patched their system instead of using the dynamic hook.", + style = TextStyle( + fontSize = 16.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + }, + confirmButton = { + val sharedPreferences = activityContext.getSharedPreferences("settings", Context.MODE_PRIVATE) + TextButton( + onClick = { + showSkipDialog = false + RadareOffsetFinder.clearHookOffsets() + sharedPreferences.edit().putBoolean("skip_setup", true).apply() + navController.navigate("settings") { + popUpTo("onboarding") { inclusive = true } + } + } + ) { + Text( + "Yes, Skip Setup", + color = accentColor, + fontWeight = FontWeight.Bold + ) + } + }, + dismissButton = { + TextButton( + onClick = { showSkipDialog = false } + ) { + Text("Cancel") + } + }, + containerColor = backgroundColor, + textContentColor = textColor, + titleContentColor = textColor + ) + } } } @@ -565,7 +641,7 @@ private fun getStatusDescription( return when (state) { is RadareOffsetFinder.ProgressState.Success -> { when { - !moduleEnabled -> "Please enable the ALN Xposed module in your Xposed manager (e.g. LSPosed). If already enabled, disable and re-enable it." + !moduleEnabled -> "Please enable the LibrePods Xposed module in your Xposed manager (e.g. LSPosed). If already enabled, disable and re-enable it." !bluetoothToggled -> "Please turn off and then turn on Bluetooth to apply the changes." else -> "All set! You can now use your AirPods with enhanced functionality." } diff --git a/android/app/src/main/java/me/kavishdevar/aln/screens/PressAndHoldSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt similarity index 98% rename from android/app/src/main/java/me/kavishdevar/aln/screens/PressAndHoldSettingsScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt index 1f52113..78f92f1 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/screens/PressAndHoldSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.screens +package me.kavishdevar.librepods.screens import android.content.Context import android.util.Log @@ -67,8 +67,8 @@ 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.aln.R -import me.kavishdevar.aln.services.ServiceManager +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.services.ServiceManager @Composable() fun RightDivider() { diff --git a/android/app/src/main/java/me/kavishdevar/aln/screens/RenameScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt similarity index 96% rename from android/app/src/main/java/me/kavishdevar/aln/screens/RenameScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt index def94bf..a5adb02 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/screens/RenameScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.screens +package me.kavishdevar.librepods.screens import android.content.Context import androidx.compose.foundation.background @@ -64,8 +64,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController -import me.kavishdevar.aln.R -import me.kavishdevar.aln.services.ServiceManager +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.services.ServiceManager @OptIn(ExperimentalMaterial3Api::class) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt new file mode 100644 index 0000000..277a221 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt @@ -0,0 +1,263 @@ +/* + * LibrePods - AirPods liberated from Apple’s ecosystem + * + * Copyright (C) 2025 Kavish Devar + * + * 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.services + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.SharedPreferences +import android.graphics.drawable.Icon +import android.os.Build +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import android.util.Log +import androidx.annotation.RequiresApi +import me.kavishdevar.librepods.QuickSettingsDialogActivity +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.utils.AirPodsNotifications +import me.kavishdevar.librepods.utils.NoiseControlMode + +@RequiresApi(Build.VERSION_CODES.Q) +class AirPodsQSService : TileService() { + + private lateinit var sharedPreferences: SharedPreferences + private var currentAncMode: Int = NoiseControlMode.OFF.ordinal + 1 + private var isAirPodsConnected: Boolean = false + + private val ancStatusReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == AirPodsNotifications.ANC_DATA) { + val newMode = intent.getIntExtra("data", NoiseControlMode.OFF.ordinal + 1) + Log.d("AirPodsQSService", "Received ANC update: $newMode") + currentAncMode = newMode + updateTile() + } + } + } + + private val availabilityReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + AirPodsNotifications.AIRPODS_CONNECTED -> { + Log.d("AirPodsQSService", "Received AIRPODS_CONNECTED") + isAirPodsConnected = true + currentAncMode = + ServiceManager.getService()?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1) + updateTile() + } + AirPodsNotifications.AIRPODS_DISCONNECTED -> { + Log.d("AirPodsQSService", "Received AIRPODS_DISCONNECTED") + isAirPodsConnected = false + updateTile() + } + } + } + } + + private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == "off_listening_mode") { + Log.d("AirPodsQSService", "Preference changed: $key") + if (currentAncMode == NoiseControlMode.OFF.ordinal + 1 && !isOffModeEnabled()) { + currentAncMode = NoiseControlMode.TRANSPARENCY.ordinal + 1 + } + updateTile() + } + } + + override fun onCreate() { + super.onCreate() + sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) + } + + @SuppressLint("InlinedApi", "UnspecifiedRegisterReceiverFlag") + override fun onStartListening() { + super.onStartListening() + Log.d("AirPodsQSService", "onStartListening") + + val service = ServiceManager.getService() + isAirPodsConnected = service?.isConnectedLocally == true + currentAncMode = service?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1) + + if (currentAncMode == NoiseControlMode.OFF.ordinal + 1 && !isOffModeEnabled()) { + currentAncMode = NoiseControlMode.TRANSPARENCY.ordinal + 1 + } + + val ancIntentFilter = IntentFilter(AirPodsNotifications.ANC_DATA) + val availabilityIntentFilter = IntentFilter().apply { + addAction(AirPodsNotifications.AIRPODS_CONNECTED) + addAction(AirPodsNotifications.AIRPODS_DISCONNECTED) + } + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(ancStatusReceiver, ancIntentFilter, RECEIVER_EXPORTED) + registerReceiver(availabilityReceiver, availabilityIntentFilter, RECEIVER_EXPORTED) + } else { + registerReceiver(ancStatusReceiver, ancIntentFilter) + registerReceiver(availabilityReceiver, availabilityIntentFilter) + } + sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener) + Log.d("AirPodsQSService", "Receivers registered") + } catch (e: Exception) { + Log.e("AirPodsQSService", "Error registering receivers: $e") + } + + updateTile() + } + + override fun onStopListening() { + super.onStopListening() + Log.d("AirPodsQSService", "onStopListening") + try { + unregisterReceiver(ancStatusReceiver) + unregisterReceiver(availabilityReceiver) + sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + Log.d("AirPodsQSService", "Receivers unregistered") + } catch (e: IllegalArgumentException) { + Log.e("AirPodsQSService", "Receiver not registered or already unregistered: $e") + } catch (e: Exception) { + Log.e("AirPodsQSService", "Error unregistering receivers: $e") + } + } + + override fun onClick() { + super.onClick() + Log.d("AirPodsQSService", "onClick - Current state: $isAirPodsConnected, Current mode: $currentAncMode") + if (!isAirPodsConnected) { + Log.d("AirPodsQSService", "Tile clicked but AirPods not connected.") + return + } + + val clickBehavior = sharedPreferences.getString("qs_click_behavior", "dialog") ?: "dialog" + + if (clickBehavior == "dialog") { + launchDialogActivity() + } else { + cycleAncMode() + } + } + + private fun launchDialogActivity() { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val pendingIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, QuickSettingsDialogActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + startActivityAndCollapse(pendingIntent) + } else { + @Suppress("DEPRECATION") + val intent = Intent(this, QuickSettingsDialogActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + } + startActivityAndCollapse(intent) + } + Log.d("AirPodsQSService", "Called startActivityAndCollapse for QuickSettingsDialogActivity") + } catch (e: Exception) { + Log.e("AirPodsQSService", "Error launching QuickSettingsDialogActivity: $e") + } + } + + private fun cycleAncMode() { + val service = ServiceManager.getService() + if (service == null) { + Log.d("AirPodsQSService", "Tile clicked (cycle mode) but service is null.") + return + } + val nextMode = getNextAncMode() + Log.d("AirPodsQSService", "Cycling ANC mode to: $nextMode") + service.setANCMode(nextMode) + } + + private fun updateTile() { + val tile = qsTile ?: return + Log.d("AirPodsQSService", "updateTile - Connected: $isAirPodsConnected, Mode: $currentAncMode") + + val deviceName = sharedPreferences.getString("name", "AirPods") ?: "AirPods" + + if (isAirPodsConnected) { + tile.state = Tile.STATE_ACTIVE + tile.label = getModeLabel(currentAncMode) + tile.subtitle = deviceName + tile.icon = Icon.createWithResource(this, getModeIcon(currentAncMode)) + } else { + tile.state = Tile.STATE_UNAVAILABLE + tile.label = "AirPods" + tile.subtitle = "Disconnected" + tile.icon = Icon.createWithResource(this, R.drawable.airpods) + } + + try { + tile.updateTile() + Log.d("AirPodsQSService", "Tile updated successfully") + } catch (e: Exception) { + Log.e("AirPodsQSService", "Error updating tile: $e") + } + } + + private fun isOffModeEnabled(): Boolean { + return sharedPreferences.getBoolean("off_listening_mode", true) + } + + private fun getAvailableModes(): List { + val modes = mutableListOf( + NoiseControlMode.TRANSPARENCY.ordinal + 1, + NoiseControlMode.ADAPTIVE.ordinal + 1, + NoiseControlMode.NOISE_CANCELLATION.ordinal + 1 + ) + if (isOffModeEnabled()) { + modes.add(0, NoiseControlMode.OFF.ordinal + 1) + } + return modes + } + + private fun getNextAncMode(): Int { + val availableModes = getAvailableModes() + val currentIndex = availableModes.indexOf(currentAncMode) + val nextIndex = (currentIndex + 1) % availableModes.size + return availableModes[nextIndex] + } + + private fun getModeLabel(mode: Int): String { + return when (mode) { + NoiseControlMode.OFF.ordinal + 1 -> "Off" + NoiseControlMode.TRANSPARENCY.ordinal + 1 -> "Transparency" + NoiseControlMode.ADAPTIVE.ordinal + 1 -> "Adaptive" + NoiseControlMode.NOISE_CANCELLATION.ordinal + 1 -> "Noise Cancellation" + else -> "Unknown" + } + } + + private fun getModeIcon(mode: Int): Int { + return when (mode) { + NoiseControlMode.OFF.ordinal + 1 -> R.drawable.noise_cancellation + NoiseControlMode.TRANSPARENCY.ordinal + 1 -> R.drawable.transparency + NoiseControlMode.ADAPTIVE.ordinal + 1 -> R.drawable.adaptive + NoiseControlMode.NOISE_CANCELLATION.ordinal + 1 -> R.drawable.noise_cancellation + else -> R.drawable.airpods + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/aln/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt similarity index 97% rename from android/app/src/main/java/me/kavishdevar/aln/services/AirPodsService.kt rename to android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 171d3d0..6102a6e 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * Copyright (C) 2025 Kavish Devar * * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.services +package me.kavishdevar.librepods.services import android.Manifest import android.annotation.SuppressLint @@ -46,6 +46,7 @@ import android.os.Handler import android.os.IBinder import android.os.Looper import android.os.ParcelUuid +import android.provider.Settings import android.telecom.TelecomManager import android.telephony.PhoneStateListener import android.telephony.TelephonyManager @@ -67,26 +68,26 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine -import me.kavishdevar.aln.MainActivity -import me.kavishdevar.aln.R -import me.kavishdevar.aln.utils.AirPodsNotifications -import me.kavishdevar.aln.utils.Battery -import me.kavishdevar.aln.utils.BatteryComponent -import me.kavishdevar.aln.utils.BatteryStatus -import me.kavishdevar.aln.utils.CrossDevice -import me.kavishdevar.aln.utils.CrossDevicePackets -import me.kavishdevar.aln.utils.Enums -import me.kavishdevar.aln.utils.GestureDetector -import me.kavishdevar.aln.utils.HeadTracking -import me.kavishdevar.aln.utils.IslandType -import me.kavishdevar.aln.utils.IslandWindow -import me.kavishdevar.aln.utils.LongPressPackets -import me.kavishdevar.aln.utils.MediaController -import me.kavishdevar.aln.utils.PopupWindow -import me.kavishdevar.aln.utils.RadareOffsetFinder -import me.kavishdevar.aln.utils.isHeadTrackingData -import me.kavishdevar.aln.widgets.BatteryWidget -import me.kavishdevar.aln.widgets.NoiseControlWidget +import me.kavishdevar.librepods.MainActivity +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.utils.AirPodsNotifications +import me.kavishdevar.librepods.utils.Battery +import me.kavishdevar.librepods.utils.BatteryComponent +import me.kavishdevar.librepods.utils.BatteryStatus +import me.kavishdevar.librepods.utils.CrossDevice +import me.kavishdevar.librepods.utils.CrossDevicePackets +import me.kavishdevar.librepods.utils.Enums +import me.kavishdevar.librepods.utils.GestureDetector +import me.kavishdevar.librepods.utils.HeadTracking +import me.kavishdevar.librepods.utils.IslandType +import me.kavishdevar.librepods.utils.IslandWindow +import me.kavishdevar.librepods.utils.LongPressPackets +import me.kavishdevar.librepods.utils.MediaController +import me.kavishdevar.librepods.utils.PopupWindow +import me.kavishdevar.librepods.utils.RadareOffsetFinder +import me.kavishdevar.librepods.utils.isHeadTrackingData +import me.kavishdevar.librepods.widgets.BatteryWidget +import me.kavishdevar.librepods.widgets.NoiseControlWidget import org.lsposed.hiddenapibypass.HiddenApiBypass import java.nio.ByteBuffer import java.nio.ByteOrder @@ -142,7 +143,7 @@ class AirPodsService : Service() { override fun onCreate() { super.onCreate() sharedPreferencesLogs = getSharedPreferences("packet_logs", MODE_PRIVATE) - + inMemoryLogs.addAll(sharedPreferencesLogs.getStringSet(packetLogKey, emptySet()) ?: emptySet()) _packetLogsFlow.value = inMemoryLogs.toSet() } @@ -150,7 +151,7 @@ class AirPodsService : Service() { private fun logPacket(packet: ByteArray, source: String) { val packetHex = packet.joinToString(" ") { "%02X".format(it) } val logEntry = "$source: $packetHex" - + synchronized(inMemoryLogs) { inMemoryLogs.add(logEntry) if (inMemoryLogs.size > maxLogEntries) { @@ -158,16 +159,15 @@ class AirPodsService : Service() { inMemoryLogs.remove(it) } } - + _packetLogsFlow.value = inMemoryLogs.toSet() } - - // Save to SharedPreferences less frequently - only needed for persistence between sessions + CoroutineScope(Dispatchers.IO).launch { val logs = sharedPreferencesLogs.getStringSet(packetLogKey, mutableSetOf())?.toMutableSet() ?: mutableSetOf() logs.add(logEntry) - // Limit SharedPreferences size + if (logs.size > maxLogEntries) { val toKeep = logs.toList().takeLast(maxLogEntries).toSet() sharedPreferencesLogs.edit { putStringSet(packetLogKey, toKeep) } @@ -213,6 +213,10 @@ class AirPodsService : Service() { var popupShown = false fun showPopup(service: Service, name: String) { + if (!Settings.canDrawOverlays(service)) { + Log.d("AirPodsService", "No permission for SYSTEM_ALERT_WINDOW") + return + } if (popupShown) { return } @@ -225,6 +229,10 @@ class AirPodsService : Service() { @SuppressLint("MissingPermission") fun showIsland(service: Service, batteryPercentage: Int, type: IslandType = IslandType.CONNECTED) { Log.d("AirPodsService", "Showing island window") + if (!Settings.canDrawOverlays(service)) { + Log.d("AirPodsService", "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) @@ -791,7 +799,7 @@ class AirPodsService : Service() { } val showIslandReceiver = object: BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == "me.kavishdevar.aln.cross_device_island") { + 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.Companion.DISCONNECT_RECEIVERS) { try { @@ -804,7 +812,7 @@ class AirPodsService : Service() { } val showIslandIntentFilter = IntentFilter().apply { - addAction("me.kavishdevar.aln.cross_device_island") + addAction("me.kavishdevar.librepods.cross_device_island") addAction(AirPodsNotifications.DISCONNECT_RECEIVERS) } @@ -1266,7 +1274,7 @@ class AirPodsService : Service() { val fromHex = packet.split(" ").map { it.toInt(16).toByte() } try { logPacket(fromHex.toByteArray(), "Sent") - + if (!isConnectedLocally && CrossDevice.isAvailable) { CrossDevice.sendRemotePacket(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + fromHex.toByteArray()) return @@ -1285,9 +1293,8 @@ class AirPodsService : Service() { fun sendPacket(packet: ByteArray) { try { - // Always log the packet logPacket(packet, "Sent") - + if (!isConnectedLocally && CrossDevice.isAvailable) { CrossDevice.sendRemotePacket(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet) return diff --git a/android/app/src/main/java/me/kavishdevar/aln/ui/theme/Color.kt b/android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Color.kt similarity index 82% rename from android/app/src/main/java/me/kavishdevar/aln/ui/theme/Color.kt rename to android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Color.kt index bfe3985..662186e 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/ui/theme/Color.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Color.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -17,7 +17,7 @@ */ -package me.kavishdevar.aln.ui.theme +package me.kavishdevar.librepods.ui.theme import androidx.compose.ui.graphics.Color diff --git a/android/app/src/main/java/me/kavishdevar/aln/ui/theme/Theme.kt b/android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Theme.kt similarity index 78% rename from android/app/src/main/java/me/kavishdevar/aln/ui/theme/Theme.kt rename to android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Theme.kt index b0d66da..31e4f12 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/ui/theme/Theme.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Theme.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.ui.theme +package me.kavishdevar.librepods.ui.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme @@ -38,21 +38,11 @@ private val LightColorScheme = lightColorScheme( primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40 - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ ) @Composable -fun ALNTheme( +fun LibrePodsTheme( darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, content: @Composable () -> Unit ) { diff --git a/android/app/src/main/java/me/kavishdevar/aln/ui/theme/Type.kt b/android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Type.kt similarity index 89% rename from android/app/src/main/java/me/kavishdevar/aln/ui/theme/Type.kt rename to android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Type.kt index 5d17c6e..79a5218 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/ui/theme/Type.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Type.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.ui.theme +package me.kavishdevar.librepods.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle diff --git a/android/app/src/main/java/me/kavishdevar/aln/utils/CrossDevice.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt similarity index 96% rename from android/app/src/main/java/me/kavishdevar/aln/utils/CrossDevice.kt rename to android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt index 1a7a421..bfbc611 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/utils/CrossDevice.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -17,7 +17,7 @@ */ -package me.kavishdevar.aln.utils +package me.kavishdevar.librepods.utils import android.annotation.SuppressLint import android.bluetooth.BluetoothAdapter @@ -37,7 +37,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import me.kavishdevar.aln.services.ServiceManager +import me.kavishdevar.librepods.services.ServiceManager import java.io.IOException import java.util.UUID @@ -77,7 +77,7 @@ object CrossDevice { sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply() this@CrossDevice.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter this@CrossDevice.bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser - startAdvertising() + // startAdvertising() startServer() initialized = true } @@ -255,7 +255,7 @@ object CrossDevice { ) if (earDetectionStatus == listOf(false, false) && newEarDetectionStatus.contains(true)) { ServiceManager.getService()?.applicationContext?.sendBroadcast( - Intent("me.kavishdevar.aln.cross_device_island") + Intent("me.kavishdevar.librepods.cross_device_island") ) } earDetectionStatus = newEarDetectionStatus @@ -276,11 +276,11 @@ object CrossDevice { } fun notifyAirPodsConnectedRemotely(context: Context) { - val intent = Intent("me.kavishdevar.aln.AIRPODS_CONNECTED_REMOTELY") + val intent = Intent("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY") context.sendBroadcast(intent) } fun notifyAirPodsDisconnectedRemotely(context: Context) { - val intent = Intent("me.kavishdevar.aln.AIRPODS_DISCONNECTED_REMOTELY") + val intent = Intent("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY") context.sendBroadcast(intent) } } diff --git a/android/app/src/main/java/me/kavishdevar/aln/utils/GestureDetector.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt similarity index 98% rename from android/app/src/main/java/me/kavishdevar/aln/utils/GestureDetector.kt rename to android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt index 4bb340f..d0b5dc4 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/utils/GestureDetector.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt @@ -1,4 +1,4 @@ -package me.kavishdevar.aln.utils +package me.kavishdevar.librepods.utils import android.os.Build import android.util.Log @@ -9,8 +9,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import me.kavishdevar.aln.services.AirPodsService -import me.kavishdevar.aln.services.ServiceManager +import me.kavishdevar.librepods.services.AirPodsService +import me.kavishdevar.librepods.services.ServiceManager import java.util.Collections import java.util.concurrent.CopyOnWriteArrayList import kotlin.math.abs diff --git a/android/app/src/main/java/me/kavishdevar/aln/utils/GestureFeedback.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt similarity index 99% rename from android/app/src/main/java/me/kavishdevar/aln/utils/GestureFeedback.kt rename to android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt index 0a32d7d..a6f39ba 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/utils/GestureFeedback.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt @@ -1,6 +1,6 @@ @file:Suppress("PrivatePropertyName") -package me.kavishdevar.aln.utils +package me.kavishdevar.librepods.utils import android.content.Context import android.media.AudioAttributes @@ -12,7 +12,7 @@ import android.os.Build import android.os.SystemClock import android.util.Log import androidx.annotation.RequiresApi -import me.kavishdevar.aln.R +import me.kavishdevar.librepods.R import java.util.concurrent.atomic.AtomicBoolean @RequiresApi(Build.VERSION_CODES.Q) diff --git a/android/app/src/main/java/me/kavishdevar/aln/utils/HeadOrientation.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt similarity index 98% rename from android/app/src/main/java/me/kavishdevar/aln/utils/HeadOrientation.kt rename to android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt index b266777..d2a9e87 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/utils/HeadOrientation.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt @@ -1,4 +1,4 @@ -package me.kavishdevar.aln.utils +package me.kavishdevar.librepods.utils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/android/app/src/main/java/me/kavishdevar/aln/utils/IslandWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt similarity index 95% rename from android/app/src/main/java/me/kavishdevar/aln/utils/IslandWindow.kt rename to android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt index e696277..78b1272 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/utils/IslandWindow.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.utils +package me.kavishdevar.librepods.utils import android.animation.Animator import android.animation.AnimatorListenerAdapter @@ -39,8 +39,8 @@ import android.widget.ProgressBar import android.widget.TextView import android.widget.VideoView import androidx.core.content.ContextCompat.getString -import me.kavishdevar.aln.R -import me.kavishdevar.aln.services.ServiceManager +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.services.ServiceManager enum class IslandType { CONNECTED, @@ -106,7 +106,7 @@ class IslandWindow(context: Context) { batteryProgressBar.isIndeterminate = false val videoView = islandView.findViewById(R.id.island_video_view) - val videoUri = Uri.parse("android.resource://me.kavishdevar.aln/${R.raw.island}") + val videoUri = Uri.parse("android.resource://me.kavishdevar.librepods/${R.raw.island}") videoView.setVideoURI(videoUri) videoView.setOnPreparedListener { mediaPlayer -> mediaPlayer.isLooping = true 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 new file mode 100644 index 0000000..78221c3 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/KotlinModule.kt @@ -0,0 +1,793 @@ +package me.kavishdevar.librepods.utils + +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.os.ParcelUuid +import android.util.Log +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.view.animation.AccelerateInterpolator +import android.view.animation.DecelerateInterpolator +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import io.github.libxposed.api.XposedInterface +import io.github.libxposed.api.XposedInterface.AfterHookCallback +import io.github.libxposed.api.XposedModule +import io.github.libxposed.api.XposedModuleInterface +import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam +import io.github.libxposed.api.annotations.AfterInvocation +import io.github.libxposed.api.annotations.XposedHooker + +private const val TAG = "AirPodsHook" +private lateinit var module: KotlinModule + +class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModule(base, param) { + init { + Log.i(TAG, "AirPodsHook module initialized at :: ${param.processName}") + module = this + } + + override fun onPackageLoaded(param: XposedModuleInterface.PackageLoadedParam) { + super.onPackageLoaded(param) + Log.i(TAG, "onPackageLoaded :: ${param.packageName}") + + if (param.packageName == "com.google.android.bluetooth" || param.packageName == "com.android.bluetooth") { + Log.i(TAG, "Bluetooth app detected, hooking l2c_fcr_chk_chan_modes") + + try { + if (param.isFirstPackage) { + Log.i(TAG, "Loading native library for Bluetooth hook") + System.loadLibrary("l2c_fcr_hook") + Log.i(TAG, "Native library loaded successfully") + } + } catch (e: Exception) { + Log.e(TAG, "Failed to load native library: ${e.message}", e) + } + } + + if (param.packageName == "com.android.settings") { + Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling") + try { + val headerControllerClass = param.classLoader.loadClass( + "com.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController") + + val updateIconMethod = headerControllerClass.getDeclaredMethod( + "updateIcon", + android.widget.ImageView::class.java, + String::class.java) + + hook(updateIconMethod, BluetoothIconHooker::class.java) + Log.i(TAG, "Successfully hooked updateIcon method in Bluetooth settings") + + try { + val displayPreferenceMethod = headerControllerClass.getDeclaredMethod( + "displayPreference", + param.classLoader.loadClass("androidx.preference.PreferenceScreen")) + + hook(displayPreferenceMethod, BluetoothSettingsAirPodsHooker::class.java) + Log.i(TAG, "Successfully hooked displayPreference for AirPods button injection") + } catch (e: Exception) { + Log.e(TAG, "Failed to hook displayPreference: ${e.message}", e) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to hook Bluetooth icon handler: ${e.message}", e) + } + } + + if (param.packageName == "com.android.systemui") { + Log.i(TAG, "SystemUI detected, hooking volume panel") + try { + val volumePanelViewClass = param.classLoader.loadClass("com.android.systemui.volume.VolumeDialogImpl") + + try { + val initDialogMethod = volumePanelViewClass.getDeclaredMethod("initDialog", Int::class.java) + hook(initDialogMethod, VolumeDialogInitHooker::class.java) + Log.i(TAG, "Hooked initDialog method successfully") + } catch (e: Exception) { + Log.e(TAG, "Failed to hook initDialog method: ${e.message}") + } + + try { + val showHMethod = volumePanelViewClass.getDeclaredMethod("showH", Int::class.java, Boolean::class.java, Int::class.java) + hook(showHMethod, VolumeDialogShowHooker::class.java) + Log.i(TAG, "Hooked showH method successfully") + } catch (e: Exception) { + Log.e(TAG, "Failed to hook showH method: ${e.message}") + } + + Log.i(TAG, "Volume panel hook setup attempted on multiple methods") + } catch (e: Exception) { + Log.e(TAG, "Failed to hook volume panel: ${e.message}", e) + } + } + } + + @XposedHooker + class VolumeDialogInitHooker : XposedInterface.Hooker { + companion object { + @JvmStatic + @AfterInvocation + fun afterInitDialog(callback: AfterHookCallback) { + try { + val volumeDialog = callback.thisObject + Log.i(TAG, "Volume dialog initialized, adding AirPods controls") + addAirPodsControlsToDialog(volumeDialog!!) + } catch (e: Exception) { + Log.e(TAG, "Error in initDialog hook: ${e.message}", e) + } + } + } + } + + @XposedHooker + class VolumeDialogShowHooker : XposedInterface.Hooker { + companion object { + @JvmStatic + @AfterInvocation + fun afterShowH(callback: AfterHookCallback) { + try { + val volumeDialog = callback.thisObject + Log.i(TAG, "Volume dialog shown, ensuring AirPods controls are added") + addAirPodsControlsToDialog(volumeDialog!!) + } catch (e: Exception) { + Log.e(TAG, "Error in showH hook: ${e.message}", e) + } + } + } + } + + @XposedHooker + class BluetoothSettingsAirPodsHooker : XposedInterface.Hooker { + companion object { + private const val AIRPODS_UUID = "74ec2172-0bad-4d01-8f77-997b2be0722a" + private const val LIBREPODS_PREFERENCE_KEY = "librepods_open_preference" + private const val ACTION_SET_ANC_MODE = "me.kavishdevar.librepods.SET_ANC_MODE" + private const val EXTRA_ANC_MODE = "anc_mode" + + private const val ANC_MODE_OFF = 1 + private const val ANC_MODE_NOISE_CANCELLATION = 2 + private const val ANC_MODE_TRANSPARENCY = 3 + private const val ANC_MODE_ADAPTIVE = 4 + + private var currentAncMode = ANC_MODE_NOISE_CANCELLATION + + @JvmStatic + @AfterInvocation + fun afterDisplayPreference(callback: AfterHookCallback) { + try { + val controller = callback.thisObject!! + val preferenceScreen = callback.args[0]!! + + val context = preferenceScreen.javaClass.getMethod("getContext").invoke(preferenceScreen) as Context + + val deviceField = controller.javaClass.getDeclaredField("mCachedDevice") + deviceField.isAccessible = true + val cachedDevice = deviceField.get(controller) ?: return + + val getDeviceMethod = cachedDevice.javaClass.getMethod("getDevice") + val bluetoothDevice = getDeviceMethod.invoke(cachedDevice) ?: return + + val uuidsMethod = bluetoothDevice.javaClass.getMethod("getUuids") + val uuids = uuidsMethod.invoke(bluetoothDevice) as? Array + + if (uuids != null) { + val isAirPods = uuids.any { it.uuid.toString() == AIRPODS_UUID } + + if (isAirPods) { + Log.i(TAG, "AirPods device detected in settings, injecting controls") + + val findPreferenceMethod = preferenceScreen.javaClass.getMethod("findPreference", CharSequence::class.java) + val existingPref = findPreferenceMethod.invoke(preferenceScreen, LIBREPODS_PREFERENCE_KEY) + + if (existingPref != null) { + Log.i(TAG, "LIBREPODS button already exists, skipping") + return + } + + val preferenceClass = preferenceScreen.javaClass.classLoader.loadClass("androidx.preference.Preference") + val preference = preferenceClass.getConstructor(Context::class.java).newInstance(context) + + val setKeyMethod = preferenceClass.getMethod("setKey", String::class.java) + setKeyMethod.invoke(preference, LIBREPODS_PREFERENCE_KEY) + + val setTitleMethod = preferenceClass.getMethod("setTitle", CharSequence::class.java) + setTitleMethod.invoke(preference, "Open LibrePods") + + val setSummaryMethod = preferenceClass.getMethod("setSummary", CharSequence::class.java) + setSummaryMethod.invoke(preference, "Control AirPods features") + + val setIconMethod = preferenceClass.getMethod("setIcon", Int::class.java) + setIconMethod.invoke(preference, android.R.drawable.ic_menu_manage) + + val setOrderMethod = preferenceClass.getMethod("setOrder", Int::class.java) + setOrderMethod.invoke(preference, 1000) + + val intent = Intent().apply { + setClassName("me.kavishdevar.librepods", "me.kavishdevar.librepods.MainActivity") + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + val setIntentMethod = preferenceClass.getMethod("setIntent", Intent::class.java) + setIntentMethod.invoke(preference, intent) + + val addPreferenceMethod = preferenceScreen.javaClass.getMethod("addPreference", preferenceClass) + addPreferenceMethod.invoke(preferenceScreen, preference) + + Log.i(TAG, "Successfully added Open LIBREPODS button to AirPods settings") + } + } + } catch (e: Exception) { + Log.e(TAG, "Error in BluetoothSettingsAirPodsHooker: ${e.message}", e) + e.printStackTrace() + } + } + } + } + + @XposedHooker + class BluetoothIconHooker : XposedInterface.Hooker { + companion object { + @JvmStatic + @AfterInvocation + fun afterUpdateIcon(callback: AfterHookCallback) { + Log.i(TAG, "BluetoothIconHooker called with args: ${callback.args.joinToString(", ")}") + try { + val imageView = callback.args[0] as ImageView + val iconUri = callback.args[1] as String + + val uri = android.net.Uri.parse(iconUri) + if (uri.toString().startsWith("android.resource://me.kavishdevar.librepods")) { + Log.i(TAG, "Handling AirPods icon URI: $uri") + + try { + val context = imageView.context + + android.os.Handler(android.os.Looper.getMainLooper()).post { + try { + val packageName = uri.authority + val packageContext = context.createPackageContext( + packageName, + Context.CONTEXT_IGNORE_SECURITY + ) + + val resPath = uri.pathSegments + if (resPath.size >= 2 && resPath[0] == "drawable") { + val resourceName = resPath[1] + val resourceId = packageContext.resources.getIdentifier( + resourceName, "drawable", packageName + ) + + if (resourceId != 0) { + val drawable = packageContext.resources.getDrawable( + resourceId, packageContext.theme + ) + + imageView.setImageDrawable(drawable) + imageView.alpha = 1.0f + + callback.result = null + + Log.i(TAG, "Successfully loaded icon from resource: $resourceName") + } else { + Log.e(TAG, "Resource not found: $resourceName") + } + } + } catch (e: Exception) { + Log.e(TAG, "Error loading resource from URI $uri: ${e.message}") + } + } + } catch (e: Exception) { + Log.e(TAG, "Error accessing context: ${e.message}") + } + } + } catch (e: Exception) { + Log.e(TAG, "Error in BluetoothIconHooker: ${e.message}") + e.printStackTrace() + } + } + } + } + + override fun getApplicationInfo(): ApplicationInfo { + return super.applicationInfo + } + + companion object { + private const val ANC_MODE_OFF = 1 + private const val ANC_MODE_NOISE_CANCELLATION = 2 + private const val ANC_MODE_TRANSPARENCY = 3 + private const val ANC_MODE_ADAPTIVE = 4 + + private var currentANCMode = ANC_MODE_NOISE_CANCELLATION + + private const val ACTION_SET_ANC_MODE = "me.kavishdevar.librepods.SET_ANC_MODE" + private const val EXTRA_ANC_MODE = "anc_mode" + private const val ANIMATION_DURATION = 250L + + private fun addAirPodsControlsToDialog(volumeDialog: Any) { + try { + val contextField = volumeDialog.javaClass.getDeclaredField("mContext") + contextField.isAccessible = true + val context = contextField.get(volumeDialog) as Context + + val dialogViewField = volumeDialog.javaClass.getDeclaredField("mDialogView") + dialogViewField.isAccessible = true + val dialogView = dialogViewField.get(volumeDialog) as ViewGroup + + val dialogRowsViewField = volumeDialog.javaClass.getDeclaredField("mDialogRowsView") + dialogRowsViewField.isAccessible = true + val dialogRowsView = dialogRowsViewField.get(volumeDialog) as ViewGroup + + Log.d(TAG, "Found dialogRowsView: ${dialogRowsView.javaClass.name}") + + val existingContainer = dialogView.findViewWithTag("airpods_container") + if (existingContainer != null) { + Log.d(TAG, "AirPods container already exists, ensuring visibility state") + val drawer = existingContainer.findViewWithTag("airpods_drawer_container") + drawer?.visibility = View.GONE + drawer?.alpha = 0f + drawer?.translationY = 0f + val button = existingContainer.findViewWithTag("airpods_button") + button?.visibility = View.VISIBLE + button?.alpha = 1f + if (button != null) { + updateMainButtonIcon(context, button, currentANCMode) + } + return + } + + val newAirPodsButton = ImageButton(context).apply { + tag = "airpods_button" + + try { + val airPodsPackage = context.createPackageContext( + "me.kavishdevar.librepods", + Context.CONTEXT_IGNORE_SECURITY + ) + val airPodsIconRes = airPodsPackage.resources.getIdentifier( + "airpods", "drawable", "me.kavishdevar.librepods") + + if (airPodsIconRes != 0) { + val airPodsDrawable = airPodsPackage.resources.getDrawable( + airPodsIconRes, airPodsPackage.theme) + setImageDrawable(airPodsDrawable) + } else { + setImageResource(android.R.drawable.ic_media_play) + Log.d(TAG, "Using fallback icon because airpods icon resource not found") + } + } catch (e: Exception) { + setImageResource(android.R.drawable.ic_media_play) + Log.e(TAG, "Failed to load AirPods icon: ${e.message}") + } + + val shape = GradientDrawable() + shape.shape = GradientDrawable.RECTANGLE + shape.setColor(Color.BLACK) + background = shape + + imageTintList = ColorStateList.valueOf(Color.WHITE) + scaleType = ImageView.ScaleType.CENTER_INSIDE + + setPadding(24, 24, 24, 24) + + val params = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + 90 + ) + params.gravity = Gravity.CENTER + params.setMargins(0, 0, 0, 0) + layoutParams = params + + setOnClickListener { + Log.d(TAG, "AirPods button clicked, toggling drawer") + val container = findAirPodsContainer(this) + val drawerContainer = container?.findViewWithTag("airpods_drawer_container") + if (drawerContainer != null && container != null) { + if (drawerContainer.visibility == View.VISIBLE) { + hideAirPodsDrawer(container, this, drawerContainer) + } else { + showAirPodsDrawer(container, this, drawerContainer) + } + } else { + Log.e(TAG, "Could not find container or drawer for toggle") + } + } + + contentDescription = "AirPods Settings" + } + + val airPodsContainer = FrameLayout(context).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + tag = "airpods_container" + } + + newAirPodsButton.setOnLongClickListener { + Log.d(TAG, "AirPods button long-pressed, opening QuickSettingsDialogActivity") + val intent = Intent().apply { + setClassName("me.kavishdevar.librepods", "me.kavishdevar.librepods.QuickSettingsDialogActivity") + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + context.startActivity(intent) + try { + val dismissMethod = volumeDialog.javaClass.getMethod("dismissH") + dismissMethod.invoke(volumeDialog) + } catch (e: Exception) { + Log.w(TAG, "Could not dismiss volume dialog: ${e.message}") + } + true + } + + val airPodsDrawer = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ).apply { + gravity = Gravity.TOP + } + tag = "airpods_drawer_container" + visibility = View.GONE + alpha = 0f + + val drawerShape = GradientDrawable() + drawerShape.shape = GradientDrawable.RECTANGLE + drawerShape.setColor(Color.BLACK) + background = drawerShape + + setPadding(16, 8, 16, 8) + } + + val buttonContainer = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ).apply { + gravity = Gravity.TOP + } + tag = "airpods_button_container" + } + + val modes = listOf(ANC_MODE_OFF, ANC_MODE_TRANSPARENCY, ANC_MODE_ADAPTIVE, ANC_MODE_NOISE_CANCELLATION) + for (mode in modes) { + val modeOption = createAncModeOption(context, mode, mode == currentANCMode, newAirPodsButton) + airPodsDrawer.addView(modeOption) + } + + buttonContainer.addView(newAirPodsButton) + + airPodsContainer.addView(airPodsDrawer) + airPodsContainer.addView(buttonContainer) + + val settingsViewField = try { + val field = volumeDialog.javaClass.getDeclaredField("mSettingsView") + field.isAccessible = true + field.get(volumeDialog) as? View + } catch (e: Exception) { + Log.e(TAG, "Failed to get settings view field: ${e.message}") + null + } + + if (settingsViewField != null && settingsViewField.parent is ViewGroup) { + val settingsParent = settingsViewField.parent as ViewGroup + val settingsIndex = findViewIndexInParent(settingsParent, settingsViewField) + + if (settingsIndex >= 0) { + settingsParent.addView(airPodsContainer, settingsIndex) + Log.i(TAG, "Added AirPods controls before settings button") + } else { + settingsParent.addView(airPodsContainer) + Log.i(TAG, "Added AirPods controls to the end of settings parent") + } + } else { + dialogView.addView(airPodsContainer) + Log.i(TAG, "Fallback: Added AirPods controls to dialog view") + } + + updateMainButtonIcon(context, newAirPodsButton, currentANCMode) + + Log.i(TAG, "Successfully added AirPods button and drawer to volume dialog") + } catch (e: Exception) { + Log.e(TAG, "Error adding AirPods button to volume panel: ${e.message}") + e.printStackTrace() + } + } + + private fun findViewIndexInParent(parent: ViewGroup, view: View): Int { + for (i in 0 until parent.childCount) { + if (parent.getChildAt(i) == view) { + return i + } + } + return -1 + } + + private fun updateMainButtonIcon(context: Context, button: ImageButton, mode: Int) { + try { + val pkgContext = context.createPackageContext( + "me.kavishdevar.librepods", + Context.CONTEXT_IGNORE_SECURITY + ) + + val resName = when (mode) { + ANC_MODE_OFF -> "noise_cancellation" + ANC_MODE_TRANSPARENCY -> "transparency" + ANC_MODE_ADAPTIVE -> "adaptive" + ANC_MODE_NOISE_CANCELLATION -> "noise_cancellation" + else -> "noise_cancellation" + } + + val resId = pkgContext.resources.getIdentifier( + resName, "drawable", "me.kavishdevar.librepods" + ) + + if (resId != 0) { + val drawable = pkgContext.resources.getDrawable(resId, pkgContext.theme) + button.setImageDrawable(drawable) + button.setColorFilter(Color.WHITE) + } else { + button.setImageResource(getIconResourceForMode(mode)) + button.setColorFilter(Color.WHITE) + } + } catch (e: Exception) { + button.setImageResource(getIconResourceForMode(mode)) + button.setColorFilter(Color.WHITE) + } + } + + private fun createAncModeOption(context: Context, mode: Int, isSelected: Boolean, mainButton: ImageButton): LinearLayout { + return LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + setMargins(0, 6, 0, 6) + } + gravity = Gravity.CENTER + setPadding(24, 16, 24, 16) + tag = "anc_mode_${mode}" + + val icon = ImageView(context).apply { + layoutParams = LinearLayout.LayoutParams(60, 60).apply { + gravity = Gravity.CENTER + } + tag = "mode_icon_$mode" + + try { + val packageContext = context.createPackageContext( + "me.kavishdevar.librepods", + Context.CONTEXT_IGNORE_SECURITY + ) + + val resourceName = when (mode) { + ANC_MODE_OFF -> "noise_cancellation" + ANC_MODE_TRANSPARENCY -> "transparency" + ANC_MODE_ADAPTIVE -> "adaptive" + ANC_MODE_NOISE_CANCELLATION -> "noise_cancellation" + else -> "noise_cancellation" + } + + val resourceId = packageContext.resources.getIdentifier( + resourceName, "drawable", "me.kavishdevar.librepods" + ) + + if (resourceId != 0) { + val drawable = packageContext.resources.getDrawable( + resourceId, packageContext.theme + ) + setImageDrawable(drawable) + } else { + setImageResource(getIconResourceForMode(mode)) + } + } catch (e: Exception) { + setImageResource(getIconResourceForMode(mode)) + Log.e(TAG, "Failed to load custom drawable for mode $mode: ${e.message}") + } + + if (isSelected) { + setColorFilter(Color.BLACK) + } else { + setColorFilter(Color.WHITE) + } + } + + addView(icon) + + if (isSelected) { + background = createSelectedBackground(context) + } else { + background = null + } + + setOnClickListener { + Log.d(TAG, "ANC mode selected: $mode (was: $currentANCMode)") + val container = findAirPodsContainer(this) + val drawerContainer = container?.findViewWithTag("airpods_drawer_container") + + if (currentANCMode == mode) { + if (drawerContainer != null && container != null) { + hideAirPodsDrawer(container, mainButton, drawerContainer) + } + return@setOnClickListener + } + + currentANCMode = mode + + val parentDrawer = parent as? ViewGroup + if (parentDrawer != null) { + for (i in 0 until parentDrawer.childCount) { + val child = parentDrawer.getChildAt(i) as? LinearLayout + if (child != null && child.tag.toString().startsWith("anc_mode_")) { + val childModeStr = child.tag.toString().substringAfter("anc_mode_") + val childMode = childModeStr.toIntOrNull() ?: -1 + val childIcon = child.findViewWithTag("mode_icon_${childMode}") + + if (childMode == mode) { + child.background = createSelectedBackground(context) + childIcon?.setColorFilter(Color.BLACK) + } else { + child.background = null + childIcon?.setColorFilter(Color.WHITE) + } + } + } + } + + val intent = Intent(ACTION_SET_ANC_MODE).apply { + setPackage("me.kavishdevar.librepods") + putExtra(EXTRA_ANC_MODE, mode) + } + context.sendBroadcast(intent) + Log.d(TAG, "Sent broadcast to change ANC mode to: ${getLabelForMode(currentANCMode)}") + + + updateMainButtonIcon(context, mainButton, mode) + + if (drawerContainer != null && container != null) { + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + hideAirPodsDrawer(container, mainButton, drawerContainer) + }, 50) + } + } + } + } + + private fun createSelectedBackground(context: Context): GradientDrawable { + return GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + setColor(Color.WHITE) + cornerRadius = 50f + } + } + + private fun findAirPodsContainer(view: View): ViewGroup? { + var current: View? = view + while (current != null) { + if (current is ViewGroup && current.tag == "airpods_container") { + return current + } + val parent = current.parent + if (parent is ViewGroup && parent.tag == "airpods_container") { + return parent + } + current = parent as? View + } + Log.w(TAG, "Could not find airpods_container ancestor") + return null + } + + private fun showAirPodsDrawer(container: ViewGroup, mainButton: ImageButton, drawerContainer: View) { + Log.d(TAG, "Showing AirPods drawer") + val selectedModeView = drawerContainer.findViewWithTag("anc_mode_$currentANCMode") + val selectedModeIcon = selectedModeView?.findViewWithTag("mode_icon_$currentANCMode") + val buttonContainer = container.findViewWithTag("airpods_button_container") + + if (selectedModeView == null || selectedModeIcon == null) { + Log.e(TAG, "Cannot find selected mode view or icon for show animation") + + drawerContainer.alpha = 0f + drawerContainer.visibility = View.VISIBLE + + drawerContainer.animate() + .alpha(1f) + .setDuration(ANIMATION_DURATION) + .start() + + buttonContainer?.animate() + ?.alpha(0f) + ?.setDuration(ANIMATION_DURATION / 2) + ?.setStartDelay(ANIMATION_DURATION / 2) + ?.withEndAction { + buttonContainer.visibility = View.GONE + } + ?.start() + + return + } + + drawerContainer.measure( + View.MeasureSpec.makeMeasureSpec(container.width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + ) + + val drawerHeight = drawerContainer.measuredHeight + + drawerContainer.alpha = 0f + drawerContainer.visibility = View.VISIBLE + drawerContainer.translationY = -drawerHeight.toFloat() + + drawerContainer.animate() + .translationY(0f) + .alpha(1f) + .setDuration(ANIMATION_DURATION) + .setInterpolator(DecelerateInterpolator()) + .start() + + buttonContainer?.animate() + ?.alpha(0f) + ?.setDuration(ANIMATION_DURATION / 2) + ?.setStartDelay(ANIMATION_DURATION / 3) + ?.withEndAction { + buttonContainer.visibility = View.GONE + } + ?.start() + } + + private fun hideAirPodsDrawer(container: ViewGroup, mainButton: ImageButton, drawerContainer: View) { + Log.d(TAG, "Hiding AirPods drawer") + val buttonContainer = container.findViewWithTag("airpods_button_container") + + if (buttonContainer != null && buttonContainer.visibility != View.VISIBLE) { + buttonContainer.alpha = 0f + buttonContainer.visibility = View.VISIBLE + } + + buttonContainer?.animate() + ?.alpha(1f) + ?.setDuration(ANIMATION_DURATION / 2) + ?.start() + + drawerContainer.animate() + .translationY(-drawerContainer.height.toFloat()) + .alpha(0f) + .setDuration(ANIMATION_DURATION) + .setInterpolator(AccelerateInterpolator()) + .setStartDelay(ANIMATION_DURATION / 4) + .withEndAction { + drawerContainer.visibility = View.GONE + drawerContainer.translationY = 0f + } + .start() + } + + private fun getIconResourceForMode(mode: Int): Int { + return when (mode) { + ANC_MODE_OFF -> android.R.drawable.ic_lock_silent_mode + ANC_MODE_TRANSPARENCY -> android.R.drawable.ic_lock_silent_mode_off + ANC_MODE_ADAPTIVE -> android.R.drawable.ic_menu_compass + ANC_MODE_NOISE_CANCELLATION -> android.R.drawable.ic_lock_idle_charging + else -> android.R.drawable.ic_lock_silent_mode_off + } + } + + private fun getLabelForMode(mode: Int): String { + return when (mode) { + ANC_MODE_OFF -> "Off" + ANC_MODE_TRANSPARENCY -> "Transparency" + ANC_MODE_ADAPTIVE -> "Adaptive" + ANC_MODE_NOISE_CANCELLATION -> "Noise Cancellation" + else -> "Unknown" + } + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/aln/utils/MediaController.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt similarity index 97% rename from android/app/src/main/java/me/kavishdevar/aln/utils/MediaController.kt rename to android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt index 0709c04..c888bf6 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/utils/MediaController.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.utils +package me.kavishdevar.librepods.utils import android.content.SharedPreferences import android.media.AudioManager @@ -25,7 +25,7 @@ import android.os.Handler import android.os.Looper import android.util.Log import android.view.KeyEvent -import me.kavishdevar.aln.services.ServiceManager +import me.kavishdevar.librepods.services.ServiceManager object MediaController { private var initialVolume: Int? = null diff --git a/android/app/src/main/java/me/kavishdevar/aln/utils/Packets.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/Packets.kt similarity index 94% rename from android/app/src/main/java/me/kavishdevar/aln/utils/Packets.kt rename to android/app/src/main/java/me/kavishdevar/librepods/utils/Packets.kt index ffd662e..2f1a41e 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/utils/Packets.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/Packets.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -19,7 +19,7 @@ @file:Suppress("unused") -package me.kavishdevar.aln.utils +package me.kavishdevar.librepods.utils import android.os.Parcelable import android.util.Log @@ -89,15 +89,15 @@ enum class NoiseControlMode { class AirPodsNotifications { companion object { - const val AIRPODS_CONNECTED = "me.kavishdevar.aln.AIRPODS_CONNECTED" - const val AIRPODS_DATA = "me.kavishdevar.aln.AIRPODS_DATA" - const val EAR_DETECTION_DATA = "me.kavishdevar.aln.EAR_DETECTION_DATA" - const val ANC_DATA = "me.kavishdevar.aln.ANC_DATA" - const val BATTERY_DATA = "me.kavishdevar.aln.BATTERY_DATA" - const val CA_DATA = "me.kavishdevar.aln.CA_DATA" - const val AIRPODS_DISCONNECTED = "me.kavishdevar.aln.AIRPODS_DISCONNECTED" - const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.aln.AIRPODS_CONNECTION_DETECTED" - const val DISCONNECT_RECEIVERS = "me.kavishdevar.aln.DISCONNECT_RECEIVERS" + const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED" + const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA" + const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA" + const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA" + const val BATTERY_DATA = "me.kavishdevar.librepods.BATTERY_DATA" + const val CA_DATA = "me.kavishdevar.librepods.CA_DATA" + const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED" + const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED" + const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS" } class EarDetection { diff --git a/android/app/src/main/java/me/kavishdevar/aln/utils/PopupWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/PopupWindow.kt similarity index 51% rename from android/app/src/main/java/me/kavishdevar/aln/utils/PopupWindow.kt rename to android/app/src/main/java/me/kavishdevar/librepods/utils/PopupWindow.kt index 714625e..81ca790 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/utils/PopupWindow.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/PopupWindow.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -17,14 +17,17 @@ */ -package me.kavishdevar.aln.utils +package me.kavishdevar.librepods.utils import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.ObjectAnimator +import android.animation.PropertyValuesHolder import android.annotation.SuppressLint import android.content.Context import android.graphics.PixelFormat +import android.os.Handler +import android.os.Looper import android.util.Log import android.view.Gravity import android.view.LayoutInflater @@ -37,15 +40,17 @@ import android.widget.ImageButton import android.widget.LinearLayout import android.widget.TextView import android.widget.VideoView -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import me.kavishdevar.aln.R +import me.kavishdevar.librepods.R @SuppressLint("InflateParams", "ClickableViewAccessibility") -class PopupWindow(context: Context) { +class PopupWindow( + private val context: Context, + private val onCloseCallback: () -> Unit = {} +) { private val mView: View + private var isClosing = false + private var autoCloseHandler = Handler(Looper.getMainLooper()) + private var autoCloseRunnable: Runnable? = null @Suppress("DEPRECATION") private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply { @@ -109,76 +114,117 @@ class PopupWindow(context: Context) { mWindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager } - @SuppressLint("InlinedApi", "SetTextI18n") + @SuppressLint("InlinedApi", "SetTextI18s") fun open(name: String = "AirPods Pro", batteryNotification: AirPodsNotifications.BatteryNotification) { try { - if (mView.windowToken == null) { - if (mView.parent == null) { - mWindowManager.addView(mView, mParams) - mView.findViewById(R.id.name).text = name - val vid = mView.findViewById(R.id.video) - - vid.setVideoPath("android.resource://me.kavishdevar.aln/" + R.raw.connected) - vid.resolveAdjustedSize(vid.width, vid.height) + if (mView.windowToken == null && mView.parent == null && !isClosing) { + mView.findViewById(R.id.name).text = name + + updateBatteryStatus(batteryNotification) + + val vid = mView.findViewById(R.id.video) + vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected) + vid.resolveAdjustedSize(vid.width, vid.height) + vid.start() + vid.setOnCompletionListener { vid.start() - vid.setOnCompletionListener { - vid.start() - } - - val batteryStatus = batteryNotification.getBattery() - val batteryLeftText = mView.findViewById(R.id.left_battery) - val batteryRightText = mView.findViewById(R.id.right_battery) - val batteryCaseText = mView.findViewById(R.id.case_battery) - - batteryLeftText.text = batteryStatus.find { it.component == BatteryComponent.LEFT }?.let { - "\uDBC3\uDC8E ${it.level}%" - } ?: "" - batteryRightText.text = batteryStatus.find { it.component == BatteryComponent.RIGHT }?.let { - "\uDBC3\uDC8D ${it.level}%" - } ?: "" - batteryCaseText.text = batteryStatus.find { it.component == BatteryComponent.CASE }?.let { - "\uDBC3\uDE6C ${it.level}%" - } ?: "" - - val displayMetrics = mView.context.resources.displayMetrics - val screenHeight = displayMetrics.heightPixels - - mView.translationY = screenHeight.toFloat() - ObjectAnimator.ofFloat(mView, "translationY", 0f).apply { - duration = 500 - interpolator = DecelerateInterpolator() - start() - } - - CoroutineScope(MainScope().coroutineContext).launch { - delay(12000) - close() - } } + + mWindowManager.addView(mView, mParams) + + val displayMetrics = mView.context.resources.displayMetrics + val screenHeight = displayMetrics.heightPixels + + mView.translationY = screenHeight.toFloat() + mView.alpha = 1f + + val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, screenHeight.toFloat(), 0f) + + ObjectAnimator.ofPropertyValuesHolder(mView, translationY).apply { + duration = 500 + interpolator = DecelerateInterpolator() + start() + } + + autoCloseRunnable = Runnable { close() } + autoCloseHandler.postDelayed(autoCloseRunnable!!, 12000) } } catch (e: Exception) { - Log.d("PopupService", e.toString()) + Log.e("PopupWindow", "Error opening popup: ${e.message}") + onCloseCallback() } } + @SuppressLint("SetTextI18n") + fun updateBatteryStatus(batteryNotification: AirPodsNotifications.BatteryNotification) { + val batteryStatus = batteryNotification.getBattery() + + val batteryLeftText = mView.findViewById(R.id.left_battery) + val batteryRightText = mView.findViewById(R.id.right_battery) + val batteryCaseText = mView.findViewById(R.id.case_battery) + + batteryLeftText.text = batteryStatus.find { it.component == BatteryComponent.LEFT }?.let { + if (it.status != BatteryStatus.DISCONNECTED) { + "\uDBC3\uDC8E ${it.level}%" + } else { + "" + } + } ?: "" + + batteryRightText.text = batteryStatus.find { it.component == BatteryComponent.RIGHT }?.let { + if (it.status != BatteryStatus.DISCONNECTED) { + "\uDBC3\uDC8D ${it.level}%" + } else { + "" + } + } ?: "" + + batteryCaseText.text = batteryStatus.find { it.component == BatteryComponent.CASE }?.let { + if (it.status != BatteryStatus.DISCONNECTED) { + "\uDBC3\uDE6C ${it.level}%" + } else { + "" + } + } ?: "" + } + fun close() { try { + if (isClosing) return + isClosing = true + + autoCloseRunnable?.let { autoCloseHandler.removeCallbacks(it) } + + val vid = mView.findViewById(R.id.video) + vid.stopPlayback() + ObjectAnimator.ofFloat(mView, "translationY", mView.height.toFloat()).apply { duration = 500 interpolator = AccelerateInterpolator() addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { try { - mWindowManager.removeView(mView) + mView.visibility = View.GONE + if (mView.parent != null) { + mWindowManager.removeView(mView) + } } catch (e: Exception) { - Log.d("PopupService", e.toString()) + Log.e("PopupWindow", "Error removing view: ${e.message}") + } finally { + isClosing = false + onCloseCallback() } } }) start() } } catch (e: Exception) { - Log.d("PopupService", e.toString()) + Log.e("PopupWindow", "Error closing popup: ${e.message}") + isClosing = false + onCloseCallback() } } + + val isShowing: Boolean + get() = mView.parent != null && !isClosing } diff --git a/android/app/src/main/java/me/kavishdevar/aln/utils/RadareOffsetFinder.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt similarity index 70% rename from android/app/src/main/java/me/kavishdevar/aln/utils/RadareOffsetFinder.kt rename to android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt index ef1a9c2..2ab4159 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/utils/RadareOffsetFinder.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package me.kavishdevar.aln.utils +package me.kavishdevar.librepods.utils import android.content.Context import android.util.Log @@ -25,6 +25,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.withContext +import me.kavishdevar.librepods.services.ServiceManager import java.io.BufferedReader import java.io.File import java.io.FileOutputStream @@ -36,9 +37,11 @@ import java.net.URL class RadareOffsetFinder(context: Context) { companion object { private const val TAG = "RadareOffsetFinder" - // Custom static build of radare2 for Android that doesn't need Termux. See: https://github.com/devnoname120/radare2/releases/tag/5.9.8-android-aln - private const val RADARE2_URL = "https://github.com/devnoname120/radare2/releases/download/5.9.8-android-aln/radare2-5.9.9-android-aarch64-aln.tar.gz" - private const val HOOK_OFFSET_PROP = "persist.aln.hook_offset" + private const val RADARE2_URL = "https://hc-cdn.hel1.your-objectstorage.com/s/v3/c9898243c42c0d3d1387de9a37d57ce9df77f9c9_radare2-5.9.9-android-aarch64.tar.gz" + private const val HOOK_OFFSET_PROP = "persist.librepods.hook_offset" + private const val CFG_REQ_OFFSET_PROP = "persist.librepods.cfg_req_offset" + private const val CSM_CONFIG_OFFSET_PROP = "persist.librepods.csm_config_offset" + private const val PEER_INFO_REQ_OFFSET_PROP = "persist.librepods.peer_info_req_offset" private const val EXTRACT_DIR = "/" private const val RADARE2_BIN_PATH = "$EXTRACT_DIR/data/local/tmp/aln_unzip/org.radare.radare2installer/radare2/bin" @@ -64,21 +67,25 @@ class RadareOffsetFinder(context: Context) { return null } - fun clearHookOffset(): Boolean { + fun clearHookOffsets(): Boolean { try { val process = Runtime.getRuntime().exec(arrayOf( - "su", "-c", "setprop $HOOK_OFFSET_PROP ''" + "su", "-c", + "setprop $HOOK_OFFSET_PROP '' && " + + "setprop $CFG_REQ_OFFSET_PROP '' && " + + "setprop $CSM_CONFIG_OFFSET_PROP '' && " + + "setprop $PEER_INFO_REQ_OFFSET_PROP ''" )) val exitCode = process.waitFor() if (exitCode == 0) { - Log.d(TAG, "Successfully cleared hook offset property") + Log.d(TAG, "Successfully cleared hook offset properties") return true } else { - Log.e(TAG, "Failed to clear hook offset property, exit code: $exitCode") + Log.e(TAG, "Failed to clear hook offset properties, exit code: $exitCode") } } catch (e: Exception) { - Log.e(TAG, "Error clearing hook offset property", e) + Log.e(TAG, "Error clearing hook offset properties", e) } return false } @@ -105,6 +112,11 @@ class RadareOffsetFinder(context: Context) { fun isHookOffsetAvailable(): Boolean { + Log.d(TAG, "Setup Skipped? " + ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE)?.getBoolean("skip_setup", false).toString()) + if (ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE)?.getBoolean("skip_setup", false) == true) { + Log.d(TAG, "Setup skipped, returning true.") + return true + } _progressState.value = ProgressState.CheckingExisting try { val process = Runtime.getRuntime().exec(arrayOf("getprop", HOOK_OFFSET_PROP)) @@ -404,6 +416,10 @@ class RadareOffsetFinder(context: Context) { Log.e(TAG, "rabin2 command failed with exit code $exitCode") } + // findAndSaveL2cuProcessCfgReqOffset(libraryPath, envSetup) + // findAndSaveL2cCsmConfigOffset(libraryPath, envSetup) + findAndSaveL2cuSendPeerInfoReqOffset(libraryPath, envSetup) + } catch (e: Exception) { Log.e(TAG, "Failed to find function offset", e) return@withContext 0L @@ -418,6 +434,141 @@ class RadareOffsetFinder(context: Context) { return@withContext offset } + private suspend fun findAndSaveL2cuProcessCfgReqOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) { + try { + val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2cu_process_our_cfg_req" + Log.d(TAG, "Running command: $command") + + val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) + val reader = BufferedReader(InputStreamReader(process.inputStream)) + val errorReader = BufferedReader(InputStreamReader(process.errorStream)) + + var line: String? + var offset = 0L + + while (reader.readLine().also { line = it } != null) { + Log.d(TAG, "rabin2 output: $line") + if (line?.contains("l2cu_process_our_cfg_req") == true) { + val parts = line.split(" ") + if (parts.isNotEmpty() && parts[0].startsWith("0x")) { + offset = parts[0].substring(2).toLong(16) + Log.d(TAG, "Found l2cu_process_our_cfg_req offset at ${parts[0]}") + break + } + } + } + + while (errorReader.readLine().also { line = it } != null) { + Log.d(TAG, "rabin2 error: $line") + } + + val exitCode = process.waitFor() + if (exitCode != 0) { + Log.e(TAG, "rabin2 command failed with exit code $exitCode") + } + + if (offset > 0L) { + val hexString = "0x${offset.toString(16)}" + Runtime.getRuntime().exec(arrayOf( + "su", "-c", "setprop $CFG_REQ_OFFSET_PROP $hexString" + )).waitFor() + Log.d(TAG, "Saved l2cu_process_our_cfg_req offset: $hexString") + } + } catch (e: Exception) { + Log.e(TAG, "Failed to find or save l2cu_process_our_cfg_req offset", e) + } + } + + private suspend fun findAndSaveL2cCsmConfigOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) { + try { + val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2c_csm_config" + Log.d(TAG, "Running command: $command") + + val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) + val reader = BufferedReader(InputStreamReader(process.inputStream)) + val errorReader = BufferedReader(InputStreamReader(process.errorStream)) + + var line: String? + var offset = 0L + + while (reader.readLine().also { line = it } != null) { + Log.d(TAG, "rabin2 output: $line") + if (line?.contains("l2c_csm_config") == true) { + val parts = line.split(" ") + if (parts.isNotEmpty() && parts[0].startsWith("0x")) { + offset = parts[0].substring(2).toLong(16) + Log.d(TAG, "Found l2c_csm_config offset at ${parts[0]}") + break + } + } + } + + while (errorReader.readLine().also { line = it } != null) { + Log.d(TAG, "rabin2 error: $line") + } + + val exitCode = process.waitFor() + if (exitCode != 0) { + Log.e(TAG, "rabin2 command failed with exit code $exitCode") + } + + if (offset > 0L) { + val hexString = "0x${offset.toString(16)}" + Runtime.getRuntime().exec(arrayOf( + "su", "-c", "setprop $CSM_CONFIG_OFFSET_PROP $hexString" + )).waitFor() + Log.d(TAG, "Saved l2c_csm_config offset: $hexString") + } + } catch (e: Exception) { + Log.e(TAG, "Failed to find or save l2c_csm_config offset", e) + } + } + + private suspend fun findAndSaveL2cuSendPeerInfoReqOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) { + try { + val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2cu_send_peer_info_req" + Log.d(TAG, "Running command: $command") + + val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) + val reader = BufferedReader(InputStreamReader(process.inputStream)) + val errorReader = BufferedReader(InputStreamReader(process.errorStream)) + + var line: String? + var offset = 0L + + while (reader.readLine().also { line = it } != null) { + Log.d(TAG, "rabin2 output: $line") + if (line?.contains("l2cu_send_peer_info_req") == true) { + val parts = line.split(" ") + if (parts.isNotEmpty() && parts[0].startsWith("0x")) { + offset = parts[0].substring(2).toLong(16) + Log.d(TAG, "Found l2cu_send_peer_info_req offset at ${parts[0]}") + break + } + } + } + + while (errorReader.readLine().also { line = it } != null) { + Log.d(TAG, "rabin2 error: $line") + } + + val exitCode = process.waitFor() + if (exitCode != 0) { + Log.e(TAG, "rabin2 command failed with exit code $exitCode") + } + + if (offset > 0L) { + val hexString = "0x${offset.toString(16)}" + Runtime.getRuntime().exec(arrayOf( + "su", "-c", "setprop $PEER_INFO_REQ_OFFSET_PROP $hexString" + )).waitFor() + Log.d(TAG, "Saved l2cu_send_peer_info_req offset: $hexString") + } + } catch (e: Exception) { + Log.e(TAG, "Failed to find or save l2cu_send_peer_info_req offset", e) + } + } + private suspend fun saveOffset(offset: Long): Boolean = withContext(Dispatchers.IO) { try { val hexString = "0x${offset.toString(16)}" diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/SystemAPIUtils.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/SystemAPIUtils.kt new file mode 100644 index 0000000..cb2a9b8 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/SystemAPIUtils.kt @@ -0,0 +1,285 @@ +package me.kavishdevar.librepods.utils + +import android.bluetooth.BluetoothDevice + +object SystemApisUtils { + + /** + * Device type which is used in METADATA_DEVICE_TYPE + * Indicates this Bluetooth device is an untethered headset. + * @hide + */ + val BluetoothDevice.DEVICE_TYPE_UNTETHERED_HEADSET: String + get() = "Untethered Headset" + + /** + * Maximum length of a metadata entry, this is to avoid exploding Bluetooth + * disk usage + * @hide + */ + val BluetoothDevice.METADATA_MAX_LENGTH: Int + get() = 2048 + + /** + * Manufacturer name of this Bluetooth device + * Data type should be {@String} as [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_MANUFACTURER_NAME: Int + get() = 0 + + /** + * Model name of this Bluetooth device + * Data type should be {@String} as [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_MODEL_NAME: Int + get() = 1 + + /** + * Software version of this Bluetooth device + * Data type should be {@String} as [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_SOFTWARE_VERSION: Int + get() = 2 + + /** + * Hardware version of this Bluetooth device + * Data type should be {@String} as [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_HARDWARE_VERSION: Int + get() = 3 + + /** + * Package name of the companion app, if any + * Data type should be {@String} as [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_COMPANION_APP: Int + get() = 4 + + /** + * URI to the main icon shown on the settings UI + * Data type should be [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_MAIN_ICON: Int + get() = 5 + + /** + * Whether this device is an untethered headset with left, right and case + * Data type should be {@String} as [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_IS_UNTETHERED_HEADSET: Int + get() = 6 + + /** + * URI to icon of the left headset + * Data type should be [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_UNTETHERED_LEFT_ICON: Int + get() = 7 + + /** + * URI to icon of the right headset + * Data type should be [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_UNTETHERED_RIGHT_ICON: Int + get() = 8 + + /** + * URI to icon of the headset charging case + * Data type should be [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_UNTETHERED_CASE_ICON: Int + get() = 9 + + /** + * Battery level of left headset + * Data type should be {@String} 0-100 as [Byte] array, otherwise + * as invalid. + * @hide + */ + val BluetoothDevice.METADATA_UNTETHERED_LEFT_BATTERY: Int + get() = 10 + + /** + * Battery level of rigth headset + * Data type should be {@String} 0-100 as [Byte] array, otherwise + * as invalid. + * @hide + */ + val BluetoothDevice.METADATA_UNTETHERED_RIGHT_BATTERY: Int + get() = 11 + + /** + * Battery level of the headset charging case + * Data type should be {@String} 0-100 as [Byte] array, otherwise + * as invalid. + * @hide + */ + val BluetoothDevice.METADATA_UNTETHERED_CASE_BATTERY: Int + get() = 12 + + /** + * Whether the left headset is charging + * Data type should be {@String} as [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_UNTETHERED_LEFT_CHARGING: Int + get() = 13 + + /** + * Whether the right headset is charging + * Data type should be {@String} as [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_UNTETHERED_RIGHT_CHARGING: Int + get() = 14 + + /** + * Whether the headset charging case is charging + * Data type should be {@String} as [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_UNTETHERED_CASE_CHARGING: Int + get() = 15 + + /** + * URI to the enhanced settings UI slice + * Data type should be {@String} as [Byte] array, null means + * the UI does not exist. + * @hide + */ + val BluetoothDevice.METADATA_ENHANCED_SETTINGS_UI_URI: Int + get() = 16 + + /** + * @hide + */ + val BluetoothDevice.COMPANION_TYPE_PRIMARY: String + get() = "COMPANION_PRIMARY" + + /** + * @hide + */ + val BluetoothDevice.COMPANION_TYPE_SECONDARY: String + get() = "COMPANION_SECONDARY" + + /** + * @hide + */ + val BluetoothDevice.COMPANION_TYPE_NONE: String + get() = "COMPANION_NONE" + + /** + * Type of the Bluetooth device, must be within the list of + * BluetoothDevice.DEVICE_TYPE_* + * Data type should be {@String} as [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_DEVICE_TYPE: Int + get() = 17 + + /** + * Battery level of the Bluetooth device, use when the Bluetooth device + * does not support HFP battery indicator. + * Data type should be {@String} as [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_MAIN_BATTERY: Int + get() = 18 + + /** + * Whether the device is charging. + * Data type should be {@String} as [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_MAIN_CHARGING: Int + get() = 19 + + /** + * The battery threshold of the Bluetooth device to show low battery icon. + * Data type should be {@String} as [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_MAIN_LOW_BATTERY_THRESHOLD: Int + get() = 20 + + /** + * The battery threshold of the left headset to show low battery icon. + * Data type should be {@String} as [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_UNTETHERED_LEFT_LOW_BATTERY_THRESHOLD: Int + get() = 21 + + /** + * The battery threshold of the right headset to show low battery icon. + * Data type should be {@String} as [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD: Int + get() = 22 + + /** + * The battery threshold of the case to show low battery icon. + * Data type should be {@String} as [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_UNTETHERED_CASE_LOW_BATTERY_THRESHOLD: Int + get() = 23 + + + /** + * The metadata of the audio spatial data. + * Data type should be [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_SPATIAL_AUDIO: Int + get() = 24 + + /** + * The metadata of the Fast Pair for any custmized feature. + * Data type should be [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_FAST_PAIR_CUSTOMIZED_FIELDS: Int + get() = 25 + + /** + * The metadata of the Fast Pair for LE Audio capable devices. + * Data type should be [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_LE_AUDIO: Int + get() = 26 + + /** + * The UUIDs (16-bit) of registered to CCC characteristics from Media Control services. + * Data type should be [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_GMCS_CCCD: Int + get() = 27 + + /** + * The UUIDs (16-bit) of registered to CCC characteristics from Telephony Bearer service. + * Data type should be [Byte] array. + * @hide + */ + val BluetoothDevice.METADATA_GTBS_CCCD: Int + get() = 28 + + const val BATTERY_LEVEL_UNKNOWN: Int = -1 + + const val ACTION_BLUETOOTH_HANDSFREE_BATTERY_CHANGED = "android.intent.action.BLUETOOTH_HANDSFREE_BATTERY_CHANGED" + const val EXTRA_SHOW_BT_HANDSFREE_BATTERY = "android.intent.extra.show_bluetooth_handsfree_battery" + const val EXTRA_BT_HANDSFREE_BATTERY_LEVEL = "android.intent.extra.bluetooth_handsfree_battery_level" +} diff --git a/android/app/src/main/java/me/kavishdevar/aln/widgets/BatteryWidget.kt b/android/app/src/main/java/me/kavishdevar/librepods/widgets/BatteryWidget.kt similarity index 79% rename from android/app/src/main/java/me/kavishdevar/aln/widgets/BatteryWidget.kt rename to android/app/src/main/java/me/kavishdevar/librepods/widgets/BatteryWidget.kt index fabd594..279bf46 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/widgets/BatteryWidget.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/widgets/BatteryWidget.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -17,7 +17,7 @@ */ -package me.kavishdevar.aln.widgets +package me.kavishdevar.librepods.widgets import android.app.PendingIntent import android.appwidget.AppWidgetManager @@ -26,9 +26,9 @@ import android.content.Context import android.content.Intent import android.widget.RemoteViews import androidx.compose.material3.ExperimentalMaterial3Api -import me.kavishdevar.aln.MainActivity -import me.kavishdevar.aln.R -import me.kavishdevar.aln.services.ServiceManager +import me.kavishdevar.librepods.MainActivity +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.services.ServiceManager class BatteryWidget : AppWidgetProvider() { override fun onUpdate( diff --git a/android/app/src/main/java/me/kavishdevar/aln/widgets/NoiseControlWidget.kt b/android/app/src/main/java/me/kavishdevar/librepods/widgets/NoiseControlWidget.kt similarity index 92% rename from android/app/src/main/java/me/kavishdevar/aln/widgets/NoiseControlWidget.kt rename to android/app/src/main/java/me/kavishdevar/librepods/widgets/NoiseControlWidget.kt index 918844f..b791f3c 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/widgets/NoiseControlWidget.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/widgets/NoiseControlWidget.kt @@ -1,7 +1,7 @@ /* - * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! + * LibrePods - AirPods liberated from Apple’s ecosystem * - * Copyright (C) 2024 Kavish Devar + * 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 @@ -17,7 +17,7 @@ */ -package me.kavishdevar.aln.widgets +package me.kavishdevar.librepods.widgets import android.app.PendingIntent import android.appwidget.AppWidgetManager @@ -25,8 +25,8 @@ import android.appwidget.AppWidgetProvider import android.content.Context import android.content.Intent import android.widget.RemoteViews -import me.kavishdevar.aln.R -import me.kavishdevar.aln.services.ServiceManager +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.services.ServiceManager class NoiseControlWidget : AppWidgetProvider() { override fun onUpdate( diff --git a/android/app/src/main/res/drawable-v24/ic_launcher_background.xml b/android/app/src/main/res/drawable-v24/ic_launcher_background.xml new file mode 100644 index 0000000..ff1817c --- /dev/null +++ b/android/app/src/main/res/drawable-v24/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml index 2b068d1..7148c08 100644 --- a/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -4,27 +4,36 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable-v24/ic_launcher_monochrome.xml b/android/app/src/main/res/drawable-v24/ic_launcher_monochrome.xml new file mode 100644 index 0000000..f1af758 --- /dev/null +++ b/android/app/src/main/res/drawable-v24/ic_launcher_monochrome.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index 07d5da9..0000000 --- a/android/app/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android/app/src/main/res/layout/battery_widget.xml b/android/app/src/main/res/layout/battery_widget.xml index a5110e0..df54f5d 100644 --- a/android/app/src/main/res/layout/battery_widget.xml +++ b/android/app/src/main/res/layout/battery_widget.xml @@ -1,12 +1,12 @@ + android:theme="@style/Theme.LibrePods.AppWidgetContainer"> - - - \ No newline at end of file + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 6f3b755..8fde456 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -2,5 +2,5 @@ - - \ No newline at end of file + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index c209e78..0000000 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp deleted file mode 100644 index b2dfe3d..0000000 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 4f0f1d6..0000000 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp deleted file mode 100644 index 62b611d..0000000 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 948a307..0000000 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp deleted file mode 100644 index 1b9a695..0000000 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index 28d4b77..0000000 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9287f50..0000000 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index aa7d642..0000000 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9126ae3..0000000 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/android/app/src/main/res/values-night-v31/themes.xml b/android/app/src/main/res/values-night-v31/themes.xml index 827ca82..80e1388 100644 --- a/android/app/src/main/res/values-night-v31/themes.xml +++ b/android/app/src/main/res/values-night-v31/themes.xml @@ -3,7 +3,7 @@ - diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml index 7a2a42e..952e4c9 100644 --- a/android/app/src/main/res/values-night/colors.xml +++ b/android/app/src/main/res/values-night/colors.xml @@ -4,4 +4,5 @@ @color/white #1C1B1E @color/white - \ No newline at end of file + #0B0B0B + diff --git a/android/app/src/main/res/values-v21/styles.xml b/android/app/src/main/res/values-v21/styles.xml index d1800ed..6d4e9a1 100644 --- a/android/app/src/main/res/values-v21/styles.xml +++ b/android/app/src/main/res/values-v21/styles.xml @@ -1,12 +1,12 @@ - - - diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 6e2a52b..5f4931d 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,13 +1,14 @@ - #FF000000 - #FFFFFFFF - #FFFFFF - @color/black - #87FFFFFF - @color/black - #FFE1F5FE - #FF81D4FA - #FF039BE5 - #FF01579B + #FF000000 + #FFFFFFFF + #FFFFFF + @color/black + #87FFFFFF + @color/black + #FFE1F5FE + #FF81D4FA + #FF039BE5 + #FF01579B + #FFFFFF diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 8969cae..cc67374 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - ALN + LibrePods GATT Testing See your AirPods battery status right from your home screen! Accessibility @@ -49,4 +49,14 @@ Moved to Linux Head Tracking Nod to answer calls, and shake your head to decline. + General + Quick Settings Tile Action + Show noise control dialog on tap. + Cycle through modes on tap. + Developer + Open AirPods Settings + Manage AirPods features and preferences + Automatic Ear Detection + Auto Play + Auto Pause diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 6cdb21d..30e8f43 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -1,11 +1,11 @@ - - diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index 548a11f..0f3f84e 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -1,14 +1,38 @@ - + - - + + - diff --git a/android/app/src/main/resources/META-INF/xposed/java_init.list b/android/app/src/main/resources/META-INF/xposed/java_init.list index 8968b1c..05a00eb 100644 --- a/android/app/src/main/resources/META-INF/xposed/java_init.list +++ b/android/app/src/main/resources/META-INF/xposed/java_init.list @@ -1 +1 @@ -me.kavishdevar.aln.utils.KotlinModule +me.kavishdevar.librepods.utils.KotlinModule diff --git a/android/app/src/main/resources/META-INF/xposed/scope.list b/android/app/src/main/resources/META-INF/xposed/scope.list index 18e0497..a2ac596 100644 --- a/android/app/src/main/resources/META-INF/xposed/scope.list +++ b/android/app/src/main/resources/META-INF/xposed/scope.list @@ -1,2 +1,6 @@ com.android.bluetooth -me.kavishdevar.aln \ No newline at end of file +me.kavishdevar.librepods +android +com.android.systemui +com.android.settings +com.google.android.bluetooth diff --git a/android/gradle.properties b/android/gradle.properties index 20e2a01..2c138d5 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -20,4 +20,6 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true + +android.javaCompile.suppressSourceTargetDeprecationWarning=true \ No newline at end of file diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 6d051a7..2a01f29 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,43 +1,40 @@ [versions] accompanistPermissions = "0.36.0" agp = "8.8.2" -hiddenapibypass = "4.3" -kotlin = "2.0.0" -coreKtx = "1.15.0" -junit = "4.13.2" -junitVersion = "1.2.1" -espressoCore = "3.6.1" +hiddenapibypass = "6.1" +kotlin = "2.1.10" +coreKtx = "1.16.0" lifecycleRuntimeKtx = "2.8.7" -activityCompose = "1.9.3" -composeBom = "2024.11.00" -annotations = "26.0.0" -navigationCompose = "2.8.4" -constraintlayout = "2.2.0" -haze = "1.1.1" -hazeMaterials = "1.1.1" +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" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } hiddenapibypass = { module = "org.lsposed.hiddenapibypass:hiddenapibypass", version.ref = "hiddenapibypass" } -junit = { group = "junit", name = "junit", version.ref = "junit" } -androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } -androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } -androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } -androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } annotations = { group = "org.jetbrains", name = "annotations", version.ref = "annotations" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } 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" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 8601bf4..7489fa0 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -20,5 +20,5 @@ dependencyResolutionManagement { } } -rootProject.name = "ALN" +rootProject.name = "LibrePods" include(":app") diff --git a/imgs/banner.png b/imgs/banner.png new file mode 100644 index 0000000..1406705 Binary files /dev/null and b/imgs/banner.png differ diff --git a/root-module/customize.sh b/root-module/customize.sh index a7234e4..9b01cd2 100644 --- a/root-module/customize.sh +++ b/root-module/customize.sh @@ -179,7 +179,7 @@ EOF ui_print "Created script for apex library handling." ui_print "You can now restart your device and test aln!" ui_print "Note: If your Bluetooth doesn't work anymore after restarting, then uninstall this module and report the issue at the link below." - ui_print "https://github.com/kavishdevar/aln/issues/new" + ui_print "https://github.com/kavishdevar/librepods/issues/new" fi else ui_print "Error: patched file missing." diff --git a/root-module/module.prop b/root-module/module.prop index 47ee2da..0193b14 100644 --- a/root-module/module.prop +++ b/root-module/module.prop @@ -4,4 +4,4 @@ version=v3 versionCode=3 author=@devnoname120 and @kavishdevar description=Fixes the Bluetooth L2CAP connection issue with AirPods -updateJson=https://raw.githubusercontent.com/kavishdevar/aln/main/update.json +updateJson=https://raw.githubusercontent.com/kavishdevar/librepods/main/update.json diff --git a/update.json b/update.json index 1fbd681..310ff94 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { "version": "v0.0.3", "versionCode": 3, - "zipUrl": "https://github.com/kavishdevar/aln/releases/download/v0.0.3/btl2capfix-v0.0.3.zip", - "changelog": "https://raw.githubusercontent.com/kavishdevar/aln/main/CHANGELOG.md" + "zipUrl": "https://github.com/kavishdevar/librepods/releases/download/v0.0.3/btl2capfix-v0.0.3.zip", + "changelog": "https://raw.githubusercontent.com/kavishdevar/librepods/main/CHANGELOG.md" }