a few small changes

This commit is contained in:
Kavish Devar
2025-05-03 21:04:40 +05:30
parent 7eafb7f013
commit 2bd0a3a20c
99 changed files with 3668 additions and 886 deletions

View File

@@ -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

View File

@@ -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 Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published

View File

@@ -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)_

View File

@@ -1,6 +1,6 @@
# Welcome to AirPods Like Normal contributing guide <!-- omit in toc -->
# Welcome to LibrePods contributing guide <!-- omit in toc -->
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 youd 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 youd 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.
Congratulations! :tada: Once merged, your contributions will be publicly available in LibrePods.

136
README.md
View File

@@ -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 Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published

View File

@@ -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 {

View File

@@ -5,6 +5,8 @@
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.INTERACT_ACROSS_USERS"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
@@ -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">
<receiver
@@ -67,7 +69,7 @@
android:name=".CustomDevice"
android:exported="true"
android:label="@string/title_activity_custom_device"
android:theme="@style/Theme.ALN">
android:theme="@style/Theme.LibrePods">
<intent-filter>
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
@@ -75,7 +77,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.ALN">
android:theme="@style/Theme.LibrePods">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -83,6 +85,15 @@
</intent-filter>
</activity>
<activity
android:name=".QuickSettingsDialogActivity"
android:exported="false"
android:theme="@style/Theme.TransparentDialog"
android:launchMode="singleTask"
android:excludeFromRecents="true"
android:taskAffinity=""
/>
<service
android:name=".services.AirPodsService"
android:enabled="true"

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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
@@ -30,12 +30,21 @@
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
static HookFunType hook_func = nullptr;
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void* p_ccb) = nullptr;
#define L2CEVT_L2CAP_CONFIG_REQ 4
#define L2CEVT_L2CAP_CONFIG_RSP 15
// Define all necessary structures for the L2CAP stack
// Define base FCR structure
// Forward declarations for types needed by the new hook
struct t_l2c_lcb;
typedef struct _BT_HDR {
uint16_t event;
uint16_t len;
uint16_t offset;
uint16_t layer_specific;
uint8_t data[];
} BT_HDR;
// Define base FCR structures
typedef struct {
uint8_t mode;
uint8_t tx_win_sz;
@@ -89,50 +98,44 @@ typedef struct t_l2c_ccb {
struct t_l2c_ccb* p_prev_ccb; // Previous CCB in the chain
struct t_l2c_lcb* p_lcb; // Link this CCB belongs to
struct t_l2c_rcb* p_rcb; // Registration CB for this Channel
uint16_t local_cid; // Local CID
uint16_t remote_cid; // Remote CID
uint16_t p_lcb_next; // For linking CCBs to an LCB
uint8_t ccb_priority; // Channel priority
uint16_t tx_mps; // MPS for outgoing messages
uint16_t max_rx_mtu; // Max MTU we will receive
// State variables
bool in_use; // True when channel active
uint8_t chnl_state; // Channel state
uint8_t local_id; // Transaction ID for local trans
uint8_t remote_id; // Transaction ID for remote
uint8_t timer_entry; // Timer entry
uint8_t is_flushable; // True if flushable
// Configuration variables
uint16_t our_cfg_bits; // Bitmap of local config bits
uint16_t peer_cfg_bits; // Bitmap of peer config bits
uint16_t config_done; // Configuration bitmask
uint16_t remote_config_rsp_result; // Remote config response result
tL2CAP_CFG_INFO our_cfg; // Our saved configuration options
tL2CAP_CFG_INFO peer_cfg; // Peer's saved configuration options
// Additional control fields
uint8_t remote_credit_count; // Credits sent to peer
tL2C_FCRB fcrb; // FCR info
bool ecoc; // Enhanced Credit-based mode
} tL2C_CCB;
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void* p_ccb) = nullptr;
static void (*original_l2cu_process_our_cfg_req)(tL2C_CCB* p_ccb, tL2CAP_CFG_INFO* p_cfg) = nullptr;
static void (*original_l2c_csm_config)(tL2C_CCB* p_ccb, uint8_t event, void* p_data) = nullptr;
static void (*original_l2cu_send_peer_info_req)(tL2C_LCB* p_lcb, uint16_t info_type) = nullptr;
uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) {
LOGI("l2c_fcr_chk_chan_modes hooked");
auto* ccb = static_cast<tL2C_CCB*>(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<void*>(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<void*>(base_addr + l2c_fcr_offset);
LOGI("Hooking l2c_fcr_chk_chan_modes at offset: 0x%x, base: %p, target: %p",
l2c_fcr_offset, (void*)base_addr, target);
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<void*>(base_addr + l2cu_process_our_cfg_req_offset);
LOGI("Hooking l2cu_process_our_cfg_req at offset: 0x%x, base: %p, target: %p",
l2cu_process_our_cfg_req_offset, (void*)base_addr, target);
int result = hook_func(target, (void*)fake_l2cu_process_our_cfg_req, (void**)&original_l2cu_process_our_cfg_req);
if (result != 0) {
LOGE("Failed to hook l2cu_process_our_cfg_req, error: %d", result);
// Continue even if this hook fails
} else {
LOGI("Successfully hooked l2cu_process_our_cfg_req");
}
} else {
LOGI("Skipping l2cu_process_our_cfg_req hook as offset is not available");
}
// Hook l2c_csm_config if offset is available
if (l2c_csm_config_offset > 0) {
void* target = reinterpret_cast<void*>(base_addr + l2c_csm_config_offset);
LOGI("Hooking l2c_csm_config at offset: 0x%x, base: %p, target: %p",
l2c_csm_config_offset, (void*)base_addr, target);
int result = hook_func(target, (void*)fake_l2c_csm_config, (void**)&original_l2c_csm_config);
if (result != 0) {
LOGE("Failed to hook l2c_csm_config, error: %d", result);
// Continue even if this hook fails
} else {
LOGI("Successfully hooked l2c_csm_config");
}
} else {
LOGI("Skipping l2c_csm_config hook as offset is not available");
}
// Hook l2cu_send_peer_info_req if offset is available
if (l2cu_send_peer_info_req_offset > 0) {
void* target = reinterpret_cast<void*>(base_addr + l2cu_send_peer_info_req_offset);
LOGI("Hooking l2cu_send_peer_info_req at offset: 0x%x, base: %p, target: %p",
l2cu_send_peer_info_req_offset, (void*)base_addr, target);
int result = hook_func(target, (void*)fake_l2cu_send_peer_info_req, (void**)&original_l2cu_send_peer_info_req);
if (result != 0) {
LOGE("Failed to hook l2cu_send_peer_info_req, error: %d", result);
// Continue even if this hook fails
} else {
LOGI("Successfully hooked l2cu_send_peer_info_req");
}
} else {
LOGI("Skipping l2cu_send_peer_info_req hook as offset is not available");
}
return success;
}
void on_library_loaded(const char *name, [[maybe_unused]] void *handle) {

View File

@@ -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);

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}

View File

@@ -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
}
}

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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(),

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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
),
)
}
}
}
}

View File

@@ -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"
}
}

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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) {

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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)

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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) {

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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) {

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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) {

View File

@@ -0,0 +1,104 @@
/*
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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
)
}
}

View File

@@ -0,0 +1,242 @@
/*
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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<NoiseControlMode>,
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"
}
}

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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) {

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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() {

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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) {

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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) {

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.aln.composables
package me.kavishdevar.librepods.composables
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.aln.composables
package me.kavishdevar.librepods.composables
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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(

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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()) {}
}

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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) {

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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) {

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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) {

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.aln.composables
package me.kavishdevar.librepods.composables
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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)

View File

@@ -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

View File

@@ -0,0 +1,190 @@
/*
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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)
)
}
}
}

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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) {

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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?) {

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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)

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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...",

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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."
}

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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() {

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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)

View File

@@ -0,0 +1,263 @@
/*
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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<Int> {
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
}
}
}

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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
) {

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.aln.ui.theme
package me.kavishdevar.librepods.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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)
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -1,4 +1,4 @@
package me.kavishdevar.aln.utils
package me.kavishdevar.librepods.utils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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<VideoView>(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

View File

@@ -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<ParcelUuid>
if (uuids != null) {
val isAirPods = uuids.any { it.uuid.toString() == AIRPODS_UUID }
if (isAirPods) {
Log.i(TAG, "AirPods device detected in settings, injecting controls")
val findPreferenceMethod = preferenceScreen.javaClass.getMethod("findPreference", CharSequence::class.java)
val existingPref = findPreferenceMethod.invoke(preferenceScreen, LIBREPODS_PREFERENCE_KEY)
if (existingPref != null) {
Log.i(TAG, "LIBREPODS button already exists, skipping")
return
}
val preferenceClass = preferenceScreen.javaClass.classLoader.loadClass("androidx.preference.Preference")
val preference = preferenceClass.getConstructor(Context::class.java).newInstance(context)
val setKeyMethod = preferenceClass.getMethod("setKey", String::class.java)
setKeyMethod.invoke(preference, LIBREPODS_PREFERENCE_KEY)
val setTitleMethod = preferenceClass.getMethod("setTitle", CharSequence::class.java)
setTitleMethod.invoke(preference, "Open LibrePods")
val setSummaryMethod = preferenceClass.getMethod("setSummary", CharSequence::class.java)
setSummaryMethod.invoke(preference, "Control AirPods features")
val setIconMethod = preferenceClass.getMethod("setIcon", Int::class.java)
setIconMethod.invoke(preference, android.R.drawable.ic_menu_manage)
val setOrderMethod = preferenceClass.getMethod("setOrder", Int::class.java)
setOrderMethod.invoke(preference, 1000)
val intent = Intent().apply {
setClassName("me.kavishdevar.librepods", "me.kavishdevar.librepods.MainActivity")
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
val setIntentMethod = preferenceClass.getMethod("setIntent", Intent::class.java)
setIntentMethod.invoke(preference, intent)
val addPreferenceMethod = preferenceScreen.javaClass.getMethod("addPreference", preferenceClass)
addPreferenceMethod.invoke(preferenceScreen, preference)
Log.i(TAG, "Successfully added Open LIBREPODS button to AirPods settings")
}
}
} catch (e: Exception) {
Log.e(TAG, "Error in BluetoothSettingsAirPodsHooker: ${e.message}", e)
e.printStackTrace()
}
}
}
}
@XposedHooker
class BluetoothIconHooker : XposedInterface.Hooker {
companion object {
@JvmStatic
@AfterInvocation
fun afterUpdateIcon(callback: AfterHookCallback) {
Log.i(TAG, "BluetoothIconHooker called with args: ${callback.args.joinToString(", ")}")
try {
val imageView = callback.args[0] as ImageView
val iconUri = callback.args[1] as String
val uri = 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<View>("airpods_container")
if (existingContainer != null) {
Log.d(TAG, "AirPods container already exists, ensuring visibility state")
val drawer = existingContainer.findViewWithTag<View>("airpods_drawer_container")
drawer?.visibility = View.GONE
drawer?.alpha = 0f
drawer?.translationY = 0f
val button = existingContainer.findViewWithTag<ImageButton>("airpods_button")
button?.visibility = View.VISIBLE
button?.alpha = 1f
if (button != null) {
updateMainButtonIcon(context, button, currentANCMode)
}
return
}
val newAirPodsButton = ImageButton(context).apply {
tag = "airpods_button"
try {
val airPodsPackage = context.createPackageContext(
"me.kavishdevar.librepods",
Context.CONTEXT_IGNORE_SECURITY
)
val airPodsIconRes = airPodsPackage.resources.getIdentifier(
"airpods", "drawable", "me.kavishdevar.librepods")
if (airPodsIconRes != 0) {
val airPodsDrawable = airPodsPackage.resources.getDrawable(
airPodsIconRes, airPodsPackage.theme)
setImageDrawable(airPodsDrawable)
} else {
setImageResource(android.R.drawable.ic_media_play)
Log.d(TAG, "Using fallback icon because airpods icon resource not found")
}
} catch (e: Exception) {
setImageResource(android.R.drawable.ic_media_play)
Log.e(TAG, "Failed to load AirPods icon: ${e.message}")
}
val shape = GradientDrawable()
shape.shape = GradientDrawable.RECTANGLE
shape.setColor(Color.BLACK)
background = shape
imageTintList = ColorStateList.valueOf(Color.WHITE)
scaleType = ImageView.ScaleType.CENTER_INSIDE
setPadding(24, 24, 24, 24)
val params = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
90
)
params.gravity = Gravity.CENTER
params.setMargins(0, 0, 0, 0)
layoutParams = params
setOnClickListener {
Log.d(TAG, "AirPods button clicked, toggling drawer")
val container = findAirPodsContainer(this)
val drawerContainer = container?.findViewWithTag<View>("airpods_drawer_container")
if (drawerContainer != null && container != null) {
if (drawerContainer.visibility == View.VISIBLE) {
hideAirPodsDrawer(container, this, drawerContainer)
} else {
showAirPodsDrawer(container, this, drawerContainer)
}
} else {
Log.e(TAG, "Could not find container or drawer for toggle")
}
}
contentDescription = "AirPods Settings"
}
val airPodsContainer = FrameLayout(context).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
tag = "airpods_container"
}
newAirPodsButton.setOnLongClickListener {
Log.d(TAG, "AirPods button long-pressed, opening QuickSettingsDialogActivity")
val intent = Intent().apply {
setClassName("me.kavishdevar.librepods", "me.kavishdevar.librepods.QuickSettingsDialogActivity")
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(intent)
try {
val dismissMethod = volumeDialog.javaClass.getMethod("dismissH")
dismissMethod.invoke(volumeDialog)
} catch (e: Exception) {
Log.w(TAG, "Could not dismiss volume dialog: ${e.message}")
}
true
}
val airPodsDrawer = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT
).apply {
gravity = Gravity.TOP
}
tag = "airpods_drawer_container"
visibility = View.GONE
alpha = 0f
val drawerShape = GradientDrawable()
drawerShape.shape = GradientDrawable.RECTANGLE
drawerShape.setColor(Color.BLACK)
background = drawerShape
setPadding(16, 8, 16, 8)
}
val buttonContainer = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT
).apply {
gravity = Gravity.TOP
}
tag = "airpods_button_container"
}
val modes = listOf(ANC_MODE_OFF, ANC_MODE_TRANSPARENCY, ANC_MODE_ADAPTIVE, ANC_MODE_NOISE_CANCELLATION)
for (mode in modes) {
val modeOption = createAncModeOption(context, mode, mode == currentANCMode, newAirPodsButton)
airPodsDrawer.addView(modeOption)
}
buttonContainer.addView(newAirPodsButton)
airPodsContainer.addView(airPodsDrawer)
airPodsContainer.addView(buttonContainer)
val settingsViewField = try {
val field = volumeDialog.javaClass.getDeclaredField("mSettingsView")
field.isAccessible = true
field.get(volumeDialog) as? View
} catch (e: Exception) {
Log.e(TAG, "Failed to get settings view field: ${e.message}")
null
}
if (settingsViewField != null && settingsViewField.parent is ViewGroup) {
val settingsParent = settingsViewField.parent as ViewGroup
val settingsIndex = findViewIndexInParent(settingsParent, settingsViewField)
if (settingsIndex >= 0) {
settingsParent.addView(airPodsContainer, settingsIndex)
Log.i(TAG, "Added AirPods controls before settings button")
} else {
settingsParent.addView(airPodsContainer)
Log.i(TAG, "Added AirPods controls to the end of settings parent")
}
} else {
dialogView.addView(airPodsContainer)
Log.i(TAG, "Fallback: Added AirPods controls to dialog view")
}
updateMainButtonIcon(context, newAirPodsButton, currentANCMode)
Log.i(TAG, "Successfully added AirPods button and drawer to volume dialog")
} catch (e: Exception) {
Log.e(TAG, "Error adding AirPods button to volume panel: ${e.message}")
e.printStackTrace()
}
}
private fun findViewIndexInParent(parent: ViewGroup, view: View): Int {
for (i in 0 until parent.childCount) {
if (parent.getChildAt(i) == view) {
return i
}
}
return -1
}
private fun updateMainButtonIcon(context: Context, button: ImageButton, mode: Int) {
try {
val pkgContext = context.createPackageContext(
"me.kavishdevar.librepods",
Context.CONTEXT_IGNORE_SECURITY
)
val resName = when (mode) {
ANC_MODE_OFF -> "noise_cancellation"
ANC_MODE_TRANSPARENCY -> "transparency"
ANC_MODE_ADAPTIVE -> "adaptive"
ANC_MODE_NOISE_CANCELLATION -> "noise_cancellation"
else -> "noise_cancellation"
}
val resId = pkgContext.resources.getIdentifier(
resName, "drawable", "me.kavishdevar.librepods"
)
if (resId != 0) {
val drawable = pkgContext.resources.getDrawable(resId, pkgContext.theme)
button.setImageDrawable(drawable)
button.setColorFilter(Color.WHITE)
} else {
button.setImageResource(getIconResourceForMode(mode))
button.setColorFilter(Color.WHITE)
}
} catch (e: Exception) {
button.setImageResource(getIconResourceForMode(mode))
button.setColorFilter(Color.WHITE)
}
}
private fun createAncModeOption(context: Context, mode: Int, isSelected: Boolean, mainButton: ImageButton): LinearLayout {
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, 6, 0, 6)
}
gravity = Gravity.CENTER
setPadding(24, 16, 24, 16)
tag = "anc_mode_${mode}"
val icon = ImageView(context).apply {
layoutParams = LinearLayout.LayoutParams(60, 60).apply {
gravity = Gravity.CENTER
}
tag = "mode_icon_$mode"
try {
val packageContext = context.createPackageContext(
"me.kavishdevar.librepods",
Context.CONTEXT_IGNORE_SECURITY
)
val resourceName = when (mode) {
ANC_MODE_OFF -> "noise_cancellation"
ANC_MODE_TRANSPARENCY -> "transparency"
ANC_MODE_ADAPTIVE -> "adaptive"
ANC_MODE_NOISE_CANCELLATION -> "noise_cancellation"
else -> "noise_cancellation"
}
val resourceId = packageContext.resources.getIdentifier(
resourceName, "drawable", "me.kavishdevar.librepods"
)
if (resourceId != 0) {
val drawable = packageContext.resources.getDrawable(
resourceId, packageContext.theme
)
setImageDrawable(drawable)
} else {
setImageResource(getIconResourceForMode(mode))
}
} catch (e: Exception) {
setImageResource(getIconResourceForMode(mode))
Log.e(TAG, "Failed to load custom drawable for mode $mode: ${e.message}")
}
if (isSelected) {
setColorFilter(Color.BLACK)
} else {
setColorFilter(Color.WHITE)
}
}
addView(icon)
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<View>("airpods_drawer_container")
if (currentANCMode == mode) {
if (drawerContainer != null && container != null) {
hideAirPodsDrawer(container, mainButton, drawerContainer)
}
return@setOnClickListener
}
currentANCMode = mode
val parentDrawer = parent as? ViewGroup
if (parentDrawer != null) {
for (i in 0 until parentDrawer.childCount) {
val child = parentDrawer.getChildAt(i) as? LinearLayout
if (child != null && child.tag.toString().startsWith("anc_mode_")) {
val childModeStr = child.tag.toString().substringAfter("anc_mode_")
val childMode = childModeStr.toIntOrNull() ?: -1
val childIcon = child.findViewWithTag<ImageView>("mode_icon_${childMode}")
if (childMode == mode) {
child.background = createSelectedBackground(context)
childIcon?.setColorFilter(Color.BLACK)
} else {
child.background = null
childIcon?.setColorFilter(Color.WHITE)
}
}
}
}
val intent = Intent(ACTION_SET_ANC_MODE).apply {
setPackage("me.kavishdevar.librepods")
putExtra(EXTRA_ANC_MODE, mode)
}
context.sendBroadcast(intent)
Log.d(TAG, "Sent broadcast to change ANC mode to: ${getLabelForMode(currentANCMode)}")
updateMainButtonIcon(context, mainButton, mode)
if (drawerContainer != null && container != null) {
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
hideAirPodsDrawer(container, mainButton, drawerContainer)
}, 50)
}
}
}
}
private fun createSelectedBackground(context: Context): GradientDrawable {
return GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
setColor(Color.WHITE)
cornerRadius = 50f
}
}
private fun findAirPodsContainer(view: View): ViewGroup? {
var current: View? = view
while (current != null) {
if (current is ViewGroup && current.tag == "airpods_container") {
return current
}
val parent = current.parent
if (parent is ViewGroup && parent.tag == "airpods_container") {
return parent
}
current = parent as? View
}
Log.w(TAG, "Could not find airpods_container ancestor")
return null
}
private fun showAirPodsDrawer(container: ViewGroup, mainButton: ImageButton, drawerContainer: View) {
Log.d(TAG, "Showing AirPods drawer")
val selectedModeView = drawerContainer.findViewWithTag<View>("anc_mode_$currentANCMode")
val selectedModeIcon = selectedModeView?.findViewWithTag<ImageView>("mode_icon_$currentANCMode")
val buttonContainer = container.findViewWithTag<View>("airpods_button_container")
if (selectedModeView == null || selectedModeIcon == null) {
Log.e(TAG, "Cannot find selected mode view or icon for show animation")
drawerContainer.alpha = 0f
drawerContainer.visibility = View.VISIBLE
drawerContainer.animate()
.alpha(1f)
.setDuration(ANIMATION_DURATION)
.start()
buttonContainer?.animate()
?.alpha(0f)
?.setDuration(ANIMATION_DURATION / 2)
?.setStartDelay(ANIMATION_DURATION / 2)
?.withEndAction {
buttonContainer.visibility = View.GONE
}
?.start()
return
}
drawerContainer.measure(
View.MeasureSpec.makeMeasureSpec(container.width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
)
val drawerHeight = drawerContainer.measuredHeight
drawerContainer.alpha = 0f
drawerContainer.visibility = View.VISIBLE
drawerContainer.translationY = -drawerHeight.toFloat()
drawerContainer.animate()
.translationY(0f)
.alpha(1f)
.setDuration(ANIMATION_DURATION)
.setInterpolator(DecelerateInterpolator())
.start()
buttonContainer?.animate()
?.alpha(0f)
?.setDuration(ANIMATION_DURATION / 2)
?.setStartDelay(ANIMATION_DURATION / 3)
?.withEndAction {
buttonContainer.visibility = View.GONE
}
?.start()
}
private fun hideAirPodsDrawer(container: ViewGroup, mainButton: ImageButton, drawerContainer: View) {
Log.d(TAG, "Hiding AirPods drawer")
val buttonContainer = container.findViewWithTag<View>("airpods_button_container")
if (buttonContainer != null && buttonContainer.visibility != View.VISIBLE) {
buttonContainer.alpha = 0f
buttonContainer.visibility = View.VISIBLE
}
buttonContainer?.animate()
?.alpha(1f)
?.setDuration(ANIMATION_DURATION / 2)
?.start()
drawerContainer.animate()
.translationY(-drawerContainer.height.toFloat())
.alpha(0f)
.setDuration(ANIMATION_DURATION)
.setInterpolator(AccelerateInterpolator())
.setStartDelay(ANIMATION_DURATION / 4)
.withEndAction {
drawerContainer.visibility = View.GONE
drawerContainer.translationY = 0f
}
.start()
}
private fun getIconResourceForMode(mode: Int): Int {
return when (mode) {
ANC_MODE_OFF -> android.R.drawable.ic_lock_silent_mode
ANC_MODE_TRANSPARENCY -> android.R.drawable.ic_lock_silent_mode_off
ANC_MODE_ADAPTIVE -> android.R.drawable.ic_menu_compass
ANC_MODE_NOISE_CANCELLATION -> android.R.drawable.ic_lock_idle_charging
else -> android.R.drawable.ic_lock_silent_mode_off
}
}
private fun getLabelForMode(mode: Int): String {
return when (mode) {
ANC_MODE_OFF -> "Off"
ANC_MODE_TRANSPARENCY -> "Transparency"
ANC_MODE_ADAPTIVE -> "Adaptive"
ANC_MODE_NOISE_CANCELLATION -> "Noise Cancellation"
else -> "Unknown"
}
}
}
}

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 {

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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<TextView>(R.id.name).text = name
val vid = mView.findViewById<VideoView>(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<TextView>(R.id.name).text = name
updateBatteryStatus(batteryNotification)
val vid = mView.findViewById<VideoView>(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<TextView>(R.id.left_battery)
val batteryRightText = mView.findViewById<TextView>(R.id.right_battery)
val batteryCaseText = mView.findViewById<TextView>(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<TextView>(R.id.left_battery)
val batteryRightText = mView.findViewById<TextView>(R.id.right_battery)
val batteryCaseText = mView.findViewById<TextView>(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<VideoView>(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
}

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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)}"

View File

@@ -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"
}

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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(

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples 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(

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="@color/colorBackground"
android:pathData="M0,0h108v108h-108z" />
</vector>

View File

@@ -4,27 +4,36 @@
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
<group
android:translateX="13.5"
android:translateY="13.5"
android:scaleX="0.75"
android:scaleY="0.75">
<path
android:pathData="M30.07 66.68l-1.73-1.32c-1.35-1.18-2.58-2.49-3.68-3.9l-0.12-0.17-1.28-1.92-0.72-1.24-1.02-2.3c-0.52-1.4-0.9-2.86-1.1-4.34l-0.06-0.45-0.08-1.13-0.07-1.28 0.03-0.79c0-0.37 0.04-0.75 0.1-1.12l0.02-0.14 0.14-0.52c0.2-0.72 0.8-1.27 1.54-1.4l0.3-0.01c0.2-0.01 0.4 0 0.6 0.07 0.22 0.06 0.43 0.18 0.62 0.33l0.62 0.52L26.1 47c1.61 1.26 3.3 2.4 5.09 3.37 0.6 0.33 1.23 0.66 1.77 0.93 0.87 0.43 1.77 0.82 2.68 1.18l0.9 0.36 0.45 0.14c0.7 0.2 1.42 0.36 2.15 0.46l0.56 0.04h0.14c0.12 0 0.18-0.14 0.1-0.23l-0.04-0.02L39.5 53l-0.76-0.41-1.24-0.64-3.39-1.47-2.78-1.62-1.44-0.87-1.69-1.35-1.96-1.58-1.92-1.89-1.36-1.73-0.1-0.13c-0.88-1.16-1.64-2.42-2.27-3.75l-0.71-2.22-0.57-1.89c-0.12-0.57-0.2-1.16-0.22-1.75l-0.05-1.14L19 25.88l0.01-0.11c0.07-0.78 0.2-1.54 0.4-2.3l0.3-1.01 0.38-1.1 0.4-0.87c0.06-0.15 0.14-0.28 0.23-0.4l0.05-0.07c0.1-0.13 0.22-0.25 0.35-0.34 0.22-0.16 0.47-0.25 0.73-0.29h0.04c0.1-0.01 0.22-0.02 0.33-0.01l0.21 0.01c0.12 0.01 0.25 0.03 0.36 0.06h0.03c0.26 0.07 0.5 0.2 0.72 0.36 0.14 0.1 0.26 0.23 0.36 0.37l0.41 0.54 1.54 1.77 1.81 2.08c0.88 0.9 1.82 1.73 2.83 2.5l0.8 0.6 2.16 1.23c0.15 0.09 0.3 0.15 0.47 0.2l1.66 0.53c0.34 0.11 0.53 0.47 0.44 0.81l-0.05 0.12-0.11 0.23c-0.24 0.47-0.36 0.98-0.36 1.5v2.3c0 1.27 0.18 2.52 0.54 3.73l0.5 1.68c0.63 1.66 1.48 3.23 2.54 4.66l0.48 0.66c0.96 1.3 2.04 2.51 3.22 3.62l3.22 3 1.69 1.27c0.2 0.15 0.44 0.27 0.69 0.34l0.17 0.04c0.3 0.09 0.55 0.25 0.75 0.49 0.22 0.27 0.34 0.6 0.34 0.95v33c0 0.18 0.13 0.33 0.31 0.36h0.07-0.07l-5.87-0.51-2.37-0.45C40 87.1 38.32 86.63 36.7 86l-2.44-1.3-1.01-0.73c-0.87-0.62-1.67-1.31-2.41-2.07-0.71-0.72-1.36-1.5-1.93-2.32l-0.66-0.94-0.98-1.39c-0.48-0.67-0.84-1.4-1.1-2.18-0.17-0.56-0.28-1.14-0.33-1.72l-0.01-0.08c-0.04-0.46-0.04-0.92 0-1.38l0.06-0.97V70.9c0-0.71 0.08-1.41 0.23-2.1l0.23-0.68c0.09-0.24 0.23-0.45 0.41-0.62 0.17-0.16 0.38-0.28 0.6-0.34l0.18-0.05c0.37-0.11 0.76-0.1 1.12 0.03 0.14 0.05 0.28 0.11 0.4 0.2l1.04 0.69 2.21 1.62 2.67 1.86 1.94 1.17 1.77 0.95c0.5 0.27 1.04 0.5 1.58 0.7l1.97 0.73 0.12 0.02h0.1c0.11 0 0.21-0.1 0.23-0.21 0-0.1-0.04-0.19-0.12-0.23l-0.94-0.57-1.68-1.05-3.72-2.05-2.88-2.13-3.28-2.16Z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="34.51"
android:startY="19.37"
android:endX="34.51"
android:endY="88.4">
<item
android:color="#FF64AB5D"
android:offset="0"/>
<item
android:color="#FF21395B"
android:offset="1"/>
</gradient>
</aapt:attr>
</path>
<path
android:strokeColor="@color/popup_text"
android:strokeWidth="0.5"
android:pathData="M30.07 66.68l-1.73-1.32c-1.35-1.18-2.58-2.49-3.68-3.9l-0.12-0.17-1.28-1.92-0.72-1.24-1.02-2.3c-0.52-1.4-0.9-2.86-1.1-4.34l-0.06-0.45-0.08-1.13-0.07-1.28 0.03-0.79c0-0.37 0.04-0.75 0.1-1.12l0.02-0.14 0.14-0.52c0.2-0.72 0.8-1.27 1.54-1.4l0.3-0.01c0.2-0.01 0.4 0 0.6 0.07 0.22 0.06 0.43 0.18 0.62 0.33l0.62 0.52L26.1 47c1.61 1.26 3.3 2.4 5.09 3.37 0.6 0.33 1.23 0.66 1.77 0.93 0.87 0.43 1.77 0.82 2.68 1.18l0.9 0.36 0.45 0.14c0.7 0.2 1.42 0.36 2.15 0.46l0.56 0.04h0.14c0.12 0 0.18-0.14 0.1-0.23l-0.04-0.02L39.5 53l-0.76-0.41-1.24-0.64-3.39-1.47-2.78-1.62-1.44-0.87-1.69-1.35-1.96-1.58-1.92-1.89-1.36-1.73-0.1-0.13c-0.88-1.16-1.64-2.42-2.27-3.75l-0.71-2.22-0.57-1.89c-0.12-0.57-0.2-1.16-0.22-1.75l-0.05-1.14L19 25.88l0.01-0.11c0.07-0.78 0.2-1.54 0.4-2.3l0.3-1.01 0.38-1.1 0.4-0.87c0.06-0.15 0.14-0.28 0.23-0.4l0.05-0.07c0.1-0.13 0.22-0.25 0.35-0.34 0.22-0.16 0.47-0.25 0.73-0.29h0.04c0.1-0.01 0.22-0.02 0.33-0.01l0.21 0.01c0.12 0.01 0.25 0.03 0.36 0.06h0.03c0.26 0.07 0.5 0.2 0.72 0.36 0.14 0.1 0.26 0.23 0.36 0.37l0.41 0.54 1.54 1.77 1.81 2.08c0.88 0.9 1.82 1.73 2.83 2.5l0.8 0.6 2.16 1.23c0.15 0.09 0.3 0.15 0.47 0.2l1.66 0.53c0.34 0.11 0.53 0.47 0.44 0.81l-0.05 0.12-0.11 0.23c-0.24 0.47-0.36 0.98-0.36 1.5v2.3c0 1.27 0.18 2.52 0.54 3.73l0.5 1.68c0.63 1.66 1.48 3.23 2.54 4.66l0.48 0.66c0.96 1.3 2.04 2.51 3.22 3.62l3.22 3 1.69 1.27c0.2 0.15 0.44 0.27 0.69 0.34l0.17 0.04c0.3 0.09 0.55 0.25 0.75 0.49 0.22 0.27 0.34 0.6 0.34 0.95v33c0 0.18 0.13 0.33 0.31 0.36h0.07-0.07l-5.87-0.51-2.37-0.45C40 87.1 38.32 86.63 36.7 86l-2.44-1.3-1.01-0.73c-0.87-0.62-1.67-1.31-2.41-2.07-0.71-0.72-1.36-1.5-1.93-2.32l-0.66-0.94-0.98-1.39c-0.48-0.67-0.84-1.4-1.1-2.18-0.17-0.56-0.28-1.14-0.33-1.72l-0.01-0.08c-0.04-0.46-0.04-0.92 0-1.38l0.06-0.97V70.9c0-0.71 0.08-1.41 0.23-2.1l0.23-0.68c0.09-0.24 0.23-0.45 0.41-0.62 0.17-0.16 0.38-0.28 0.6-0.34l0.18-0.05c0.37-0.11 0.76-0.1 1.12 0.03 0.14 0.05 0.28 0.11 0.4 0.2l1.04 0.69 2.21 1.62 2.67 1.86 1.94 1.17 1.77 0.95c0.5 0.27 1.04 0.5 1.58 0.7l1.97 0.73 0.12 0.02h0.1c0.11 0 0.21-0.1 0.23-0.21 0-0.1-0.04-0.19-0.12-0.23l-0.94-0.57-1.68-1.05-3.72-2.05-2.88-2.13-3.28-2.16Z"/>
<path
android:strokeColor="@color/popup_text"
android:strokeWidth="2"
android:pathData="M49.59 54.33v33.15 0.04c0 0.62 0.14 1.23 0.42 1.78m-0.42-34.97l-2.1-1.4-0.29-0.2c-1.67-1.17-3.26-2.46-4.75-3.86l-0.35-0.35c-1.54-1.53-2.88-3.24-4-5.1l-0.86-1.57c-0.45-0.82-0.82-1.68-1.1-2.57-0.46-1.43-0.7-2.92-0.7-4.42v-0.54-0.74c0-1.27 0.16-2.53 0.47-3.77 0.25-1 0.6-1.97 1.04-2.9l0.74-1.54 0.4-0.67c0.85-1.41 1.84-2.73 2.96-3.94l0.84-0.78c0.56-0.5 1.17-0.95 1.82-1.32l0.98-0.56 1.1-0.56 1.28-0.56 1-0.36c0.63-0.23 1.3-0.4 1.97-0.5 0.54-0.08 1.08-0.12 1.63-0.12h1.7 0.61c0.62 0 1.23 0.04 1.85 0.13 0.69 0.1 1.37 0.25 2.04 0.46l1.24 0.39 2.24 0.56 2.1 0.84 1.54 0.84 1.96 1.12 1.82 1.26 1.68 1.25 0.23 0.2c0.87 0.7 1.68 1.48 2.43 2.32l1.12 1.12 1.26 1.54 1.12 1.54 0.84 1.4 0.59 1.3M49.59 54.34l0.06 0.04c0.33 0.25 0.68 0.47 1.06 0.66l1.54 0.7 1.68 0.7 1.68 0.56 1.54 0.42 1.54 0.42 1.54 0.28 0.84 0.14 1.12 0.04 0.56-0.04h0.56m14.73-25.97l-1.01-0.05h-1.54-0.06c-0.8 0-1.58 0.14-2.32 0.42l-1.12 0.42-0.55 0.3c-0.66 0.35-1.28 0.78-1.86 1.26-0.54 0.45-1.04 0.94-1.49 1.48l-0.72 0.87-0.16 0.19c-0.83 1-1.57 2.05-2.22 3.17l-0.84 1.4-0.84 1.4-0.84 1.82-0.7 1.95-0.42 1.96-0.28 2.1v0.11c0 0.95 0.1 1.9 0.28 2.83l0.42 1.4c0.28 0.92 0.7 1.8 1.28 2.58l0.26 0.36m14.73-25.97l1.65 0.37c0.84 0.18 1.65 0.46 2.43 0.82l0.14 0.07c0.52 0.24 1.03 0.53 1.52 0.85l0.11 0.08c0.56 0.37 1.08 0.8 1.55 1.26 0.37 0.37 0.7 0.76 1 1.18l1.09 1.47c0.65 0.93 1.17 1.94 1.55 3l0.13 0.36 0.02 0.06c0.36 1.17 0.62 2.37 0.77 3.58v0.8c0 0.78-0.06 1.55-0.18 2.32-0.22 1.45-0.65 2.87-1.27 4.2l-0.13 0.27-0.89 1.64-1.54 2.1-0.42 0.5c-0.56 0.69-1.17 1.33-1.84 1.92l-0.06 0.05c-0.88 0.77-1.84 1.44-2.86 2l-0.25 0.11c-1.14 0.5-2.32 0.88-3.53 1.15l-0.17 0.04c-0.72 0.16-1.47 0.24-2.21 0.24h-0.79c-1.15 0-2.3-0.14-3.41-0.42l-1.82-0.7-1.68-0.7-0.62-0.33c-0.61-0.34-1.17-0.76-1.67-1.25-0.34-0.34-0.71-0.65-1.12-0.92l-0.23-0.15m0 0v1.81 0.84 1.4l-0.42 24.9v0.43c0 0.55-0.1 1.1-0.28 1.61M50 89.3l0.3 0.52c0.17 0.3 0.38 0.58 0.63 0.83 0.4 0.4 0.88 0.71 1.4 0.9l0.33 0.13 0.1 0.04c0.29 0.11 0.58 0.2 0.88 0.26 0.37 0.08 0.75 0.12 1.13 0.12h0.55 0.84H57h0.7c0.37 0 0.75-0.05 1.11-0.14l0.3-0.07c0.56-0.14 1.1-0.35 1.62-0.6l0.05-0.03 0.42-0.28 0.28-0.18c0.18-0.12 0.35-0.26 0.5-0.42 0.22-0.25 0.4-0.54 0.51-0.86l0.1-0.28M50 89.3l12.6-0.06M48.9 31.81l-0.86-0.65c-0.17-0.13-0.32-0.28-0.45-0.47-0.06-0.1-0.12-0.2-0.16-0.3l-0.14-0.32c-0.14-0.33-0.21-0.69-0.21-1.05 0-0.28 0.04-0.57 0.13-0.84L47.26 28l0.2-0.5 0.16-0.3c0.1-0.2 0.23-0.38 0.39-0.54 0.12-0.12 0.25-0.22 0.4-0.31l0.29-0.17c0.12-0.08 0.26-0.15 0.4-0.2l0.11-0.05c0.25-0.1 0.51-0.15 0.78-0.15 0.2 0 0.4 0.03 0.58 0.08l0.26 0.08c0.3 0.08 0.57 0.2 0.83 0.34l1.15 0.62 1.4 0.84 1.12 0.84 1.4 0.98 1.17 0.89 0.3 0.28c0.14 0.15 0.25 0.32 0.34 0.5l0.02 0.03c0.18 0.35 0.27 0.74 0.27 1.13v0.25 0.19c0 0.42-0.1 0.84-0.3 1.22-0.08 0.18-0.18 0.34-0.3 0.5l-0.1 0.12c-0.19 0.23-0.42 0.41-0.68 0.54-0.1 0.06-0.22 0.1-0.34 0.14l-0.21 0.06c-0.4 0.1-0.8 0.16-1.2 0.16h-0.37c-0.28 0-0.55-0.05-0.81-0.15l-0.12-0.05c-0.13-0.05-0.25-0.11-0.37-0.18l-1.5-0.88-1.82-1.25-1.82-1.26Zm36.96 17.06l0.06-0.42c0.05-0.37 0.05-0.74-0.01-1.11l-0.01-0.07c-0.03-0.14-0.06-0.29-0.11-0.43l-0.1-0.28c-0.07-0.23-0.2-0.43-0.37-0.6l-0.07-0.07c-0.15-0.15-0.34-0.26-0.55-0.31-0.16-0.04-0.32-0.05-0.48-0.02l-0.25 0.04c-0.23 0.04-0.46 0.1-0.67 0.22l-0.14 0.07c-0.25 0.12-0.49 0.28-0.7 0.46l-0.26 0.22c-0.27 0.23-0.51 0.48-0.74 0.75l-0.36 0.43-0.56 0.84-0.84 1.26-0.14 0.21c-0.28 0.42-0.51 0.87-0.7 1.33l-0.56 1.54-0.1 0.36c-0.12 0.4-0.18 0.84-0.18 1.27v0.39c0 0.24 0.04 0.47 0.11 0.7l0.08 0.22c0.06 0.18 0.16 0.35 0.3 0.49l0.05 0.05c0.1 0.1 0.23 0.18 0.37 0.23 0.14 0.04 0.28 0.06 0.43 0.04l0.36-0.06c0.26-0.03 0.52-0.11 0.76-0.23l0.44-0.22c0.4-0.2 0.77-0.45 1.11-0.74l0.47-0.4 0.03-0.04c0.73-0.81 1.37-1.69 1.93-2.62 0.37-0.65 0.69-1.33 0.95-2.04l0.17-0.48 0.28-0.98Z"/>
</group>
</vector>

View File

@@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:translateX="21.6"
android:translateY="21.6"
android:scaleX="0.6"
android:scaleY="0.6">
<group>
<path
android:strokeColor="@color/popup_text"
android:strokeWidth="3.25"
android:pathData="M49.64 54.5v33.52c0 0.21 0.17 0.38 0.38 0.38l-5.94-0.52-2.37-0.45C40 87.1 38.32 86.63 36.7 86l-2.44-1.3-1.01-0.73c-0.87-0.62-1.67-1.31-2.41-2.07-0.71-0.72-1.36-1.5-1.93-2.32l-0.66-0.94-0.98-1.39c-0.48-0.67-0.84-1.4-1.1-2.18-0.17-0.56-0.28-1.14-0.33-1.72l-0.01-0.08c-0.04-0.46-0.04-0.92 0-1.38l0.06-0.97V70.9c0-0.71 0.08-1.41 0.23-2.1l0.23-0.68c0.09-0.24 0.23-0.45 0.41-0.62 0.17-0.16 0.38-0.28 0.6-0.34l0.18-0.05c0.37-0.11 0.76-0.1 1.12 0.03 0.14 0.05 0.28 0.11 0.4 0.2l1.04 0.69 2.21 1.62 2.67 1.86 1.94 1.17 1.77 0.95c0.5 0.27 1.04 0.5 1.58 0.7l1.97 0.73 0.12 0.02h0.1c0.11 0 0.21-0.1 0.23-0.21 0-0.1-0.04-0.19-0.12-0.23l-0.94-0.57-1.68-1.05-3.72-2.05-2.88-2.13-3.28-2.16-1.73-1.32c-1.35-1.18-2.58-2.49-3.68-3.9l-0.12-0.17-1.28-1.92-0.72-1.24-1.02-2.3c-0.52-1.4-0.9-2.86-1.1-4.34l-0.06-0.45-0.08-1.13-0.07-1.28 0.03-0.79c0-0.37 0.04-0.75 0.1-1.12l0.02-0.14 0.14-0.52c0.2-0.72 0.8-1.27 1.54-1.4l0.3-0.01c0.2-0.01 0.4 0 0.6 0.07 0.22 0.06 0.43 0.18 0.62 0.33l0.62 0.52L26.1 47c1.61 1.26 3.3 2.4 5.09 3.37 0.6 0.33 1.23 0.66 1.77 0.93 0.87 0.43 1.77 0.82 2.68 1.18l0.9 0.36 0.45 0.14c0.7 0.2 1.42 0.36 2.15 0.46l0.56 0.04h0.14c0.12 0 0.18-0.14 0.1-0.23l-0.04-0.02L39.5 53l-0.76-0.41-1.24-0.64-3.39-1.47-2.78-1.62-1.44-0.87-1.69-1.35-1.96-1.58-1.92-1.89-1.36-1.73-0.1-0.13c-0.88-1.16-1.64-2.42-2.27-3.75l-0.71-2.22-0.57-1.89c-0.12-0.57-0.2-1.16-0.22-1.75l-0.05-1.14L19 25.88l0.01-0.11c0.07-0.78 0.2-1.54 0.4-2.3l0.3-1.01 0.38-1.1 0.4-0.87c0.06-0.15 0.14-0.28 0.23-0.4l0.05-0.07c0.1-0.13 0.22-0.25 0.35-0.34 0.22-0.16 0.47-0.25 0.73-0.29h0.04c0.1-0.01 0.22-0.02 0.33-0.01l0.21 0.01c0.12 0.01 0.25 0.03 0.36 0.06h0.03c0.26 0.07 0.5 0.2 0.72 0.36 0.14 0.1 0.26 0.23 0.36 0.37l0.41 0.54 1.54 1.77 1.81 2.08c0.88 0.9 1.82 1.73 2.83 2.5l0.8 0.6 0.09 0.05c1.53 0.87 3.14 1.58 4.8 2.1"/>
</group>
<path
android:strokeColor="@color/popup_text"
android:strokeWidth="3.75"
android:pathData="M49.59 54.33v33.15 0.04c0 0.62 0.14 1.23 0.42 1.78m-0.42-34.97l-2.1-1.4-0.29-0.2c-1.67-1.17-3.26-2.46-4.75-3.86l-0.35-0.35c-1.54-1.53-2.88-3.24-4-5.1l-0.86-1.57c-0.45-0.82-0.82-1.68-1.1-2.57-0.46-1.43-0.7-2.92-0.7-4.42v-0.54-0.74c0-1.27 0.16-2.53 0.47-3.77 0.25-1 0.6-1.97 1.04-2.9l0.74-1.54 0.4-0.67c0.85-1.41 1.84-2.73 2.96-3.94l0.84-0.78c0.56-0.5 1.17-0.95 1.82-1.32l0.98-0.56 1.1-0.56 1.28-0.56 1-0.36c0.63-0.23 1.3-0.4 1.97-0.5 0.54-0.08 1.08-0.12 1.63-0.12h1.7 0.61c0.62 0 1.23 0.04 1.85 0.13 0.69 0.1 1.37 0.25 2.04 0.46l1.24 0.39 2.24 0.56 2.1 0.84 1.54 0.84 1.96 1.12 1.82 1.26 1.68 1.25 0.23 0.2c0.87 0.7 1.68 1.48 2.43 2.32l1.12 1.12 1.26 1.54 1.12 1.54 0.84 1.4 0.59 1.3M49.59 54.34l0.06 0.04c0.33 0.25 0.68 0.47 1.06 0.66l1.54 0.7 1.68 0.7 1.68 0.56 1.54 0.42 1.54 0.42 1.54 0.28 0.84 0.14 1.12 0.04 0.56-0.04h0.56m14.73-25.97l-1.01-0.05h-1.54-0.06c-0.8 0-1.58 0.14-2.32 0.42l-1.12 0.42-0.55 0.3c-0.66 0.35-1.28 0.78-1.86 1.26-0.54 0.45-1.04 0.94-1.49 1.48l-0.72 0.87-0.16 0.19c-0.83 1-1.57 2.05-2.22 3.17l-0.84 1.4-0.84 1.4-0.84 1.82-0.7 1.95-0.42 1.96-0.28 2.1v0.11c0 0.95 0.1 1.9 0.28 2.83l0.42 1.4c0.28 0.92 0.7 1.8 1.28 2.58l0.26 0.36m14.73-25.97l1.65 0.37c0.84 0.18 1.65 0.46 2.43 0.82l0.14 0.07c0.52 0.24 1.03 0.53 1.52 0.85l0.11 0.08c0.56 0.37 1.08 0.8 1.55 1.26 0.37 0.37 0.7 0.76 1 1.18l1.09 1.47c0.65 0.93 1.17 1.94 1.55 3l0.13 0.36 0.02 0.06c0.36 1.17 0.62 2.37 0.77 3.58v0.8c0 0.78-0.06 1.55-0.18 2.32-0.22 1.45-0.65 2.87-1.27 4.2l-0.13 0.27-0.89 1.64-1.54 2.1-0.42 0.5c-0.56 0.69-1.17 1.33-1.84 1.92l-0.06 0.05c-0.88 0.77-1.84 1.44-2.86 2l-0.25 0.11c-1.14 0.5-2.32 0.88-3.53 1.15l-0.17 0.04c-0.72 0.16-1.47 0.24-2.21 0.24h-0.79c-1.15 0-2.3-0.14-3.41-0.42l-1.82-0.7-1.68-0.7-0.62-0.33c-0.61-0.34-1.17-0.76-1.67-1.25-0.34-0.34-0.71-0.65-1.12-0.92l-0.23-0.15m0 0v1.81 0.84 1.4l-0.42 24.9v0.43c0 0.55-0.1 1.1-0.28 1.61M50 89.3l0.3 0.52c0.17 0.3 0.38 0.58 0.63 0.83 0.4 0.4 0.88 0.71 1.4 0.9l0.33 0.13 0.1 0.04c0.29 0.11 0.58 0.2 0.88 0.26 0.37 0.08 0.75 0.12 1.13 0.12h0.55 0.84H57h0.7c0.37 0 0.75-0.05 1.11-0.14l0.3-0.07c0.56-0.14 1.1-0.35 1.62-0.6l0.05-0.03 0.42-0.28 0.28-0.18c0.18-0.12 0.35-0.26 0.5-0.42 0.22-0.25 0.4-0.54 0.51-0.86l0.1-0.28M50 89.3l12.6-0.06M48.9 31.81l-0.86-0.65c-0.17-0.13-0.32-0.28-0.45-0.47-0.06-0.1-0.12-0.2-0.16-0.3l-0.14-0.32c-0.14-0.33-0.21-0.69-0.21-1.05 0-0.28 0.04-0.57 0.13-0.84L47.26 28l0.2-0.5 0.16-0.3c0.1-0.2 0.23-0.38 0.39-0.54 0.12-0.12 0.25-0.22 0.4-0.31l0.29-0.17c0.12-0.08 0.26-0.15 0.4-0.2l0.11-0.05c0.25-0.1 0.51-0.15 0.78-0.15 0.2 0 0.4 0.03 0.58 0.08l0.26 0.08c0.3 0.08 0.57 0.2 0.83 0.34l1.15 0.62 1.4 0.84 1.12 0.84 1.4 0.98 1.17 0.89 0.3 0.28c0.14 0.15 0.25 0.32 0.34 0.5l0.02 0.03c0.18 0.35 0.27 0.74 0.27 1.13v0.25 0.19c0 0.42-0.1 0.84-0.3 1.22-0.08 0.18-0.18 0.34-0.3 0.5l-0.1 0.12c-0.19 0.23-0.42 0.41-0.68 0.54-0.1 0.06-0.22 0.1-0.34 0.14l-0.21 0.06c-0.4 0.1-0.8 0.16-1.2 0.16h-0.37c-0.28 0-0.55-0.05-0.81-0.15l-0.12-0.05c-0.13-0.05-0.25-0.11-0.37-0.18l-1.5-0.88-1.82-1.25-1.82-1.26Zm36.96 17.06l0.06-0.42c0.05-0.37 0.05-0.74-0.01-1.11l-0.01-0.07c-0.03-0.14-0.06-0.29-0.11-0.43l-0.1-0.28c-0.07-0.23-0.2-0.43-0.37-0.6l-0.07-0.07c-0.15-0.15-0.34-0.26-0.55-0.31-0.16-0.04-0.32-0.05-0.48-0.02l-0.25 0.04c-0.23 0.04-0.46 0.1-0.67 0.22l-0.14 0.07c-0.25 0.12-0.49 0.28-0.7 0.46l-0.26 0.22c-0.27 0.23-0.51 0.48-0.74 0.75l-0.36 0.43-0.56 0.84-0.84 1.26-0.14 0.21c-0.28 0.42-0.51 0.87-0.7 1.33l-0.56 1.54-0.1 0.36c-0.12 0.4-0.18 0.84-0.18 1.27v0.39c0 0.24 0.04 0.47 0.11 0.7l0.08 0.22c0.06 0.18 0.16 0.35 0.3 0.49l0.05 0.05c0.1 0.1 0.23 0.18 0.37 0.23 0.14 0.04 0.28 0.06 0.43 0.04l0.36-0.06c0.26-0.03 0.52-0.11 0.76-0.23l0.44-0.22c0.4-0.2 0.77-0.45 1.11-0.74l0.47-0.4 0.03-0.04c0.73-0.81 1.37-1.69 1.93-2.62 0.37-0.65 0.69-1.33 0.95-2.04l0.17-0.48 0.28-0.98Z"/>
</group>
</vector>

View File

@@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -1,12 +1,12 @@
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
style="@style/Widget.ALN.AppWidget.Container"
style="@style/Widget.LibrePods.AppWidget.Container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="0dp"
android:padding="0dp"
android:id="@+id/battery_widget"
android:theme="@style/Theme.ALN.AppWidgetContainer"
android:theme="@style/Theme.LibrePods.AppWidgetContainer"
android:background="@drawable/widget_background">
<LinearLayout

View File

@@ -1,10 +1,10 @@
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
style="@style/Widget.ALN.AppWidget.Container"
style="@style/Widget.LibrePods.AppWidget.Container"
android:id="@+id/noise_control_widget"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:theme="@style/Theme.ALN.AppWidgetContainer">
android:theme="@style/Theme.LibrePods.AppWidgetContainer">
<LinearLayout
android:id="@android:id/background"

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
<foreground android:drawable="@drawable/ic_launcher_monochrome" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

View File

@@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -3,7 +3,7 @@
<!--
Having themes.xml for night-v31 because of the priority order of the resource qualifiers.
-->
<style name="Theme.ALN.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault.DayNight">
<style name="Theme.LibrePods.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="appWidgetRadius">@android:dimen/system_app_widget_background_radius</item>
<item name="appWidgetInnerRadius">@android:dimen/system_app_widget_inner_radius</item>
</style>

View File

@@ -4,4 +4,5 @@
<color name="popup_text">@color/white</color>
<color name="widget_background">#1C1B1E</color>
<color name="widget_text">@color/white</color>
</resources>
<color name="colorBackground">#0B0B0B</color>
</resources>

View File

@@ -1,12 +1,12 @@
<resources>
<style name="Widget.ALN.AppWidget.Container" parent="android:Widget">
<style name="Widget.LibrePods.AppWidget.Container" parent="android:Widget">
<item name="android:id">@android:id/background</item>
<item name="android:padding">?attr/appWidgetPadding</item>
<item name="android:background">@drawable/app_widget_background</item>
</style>
<style name="Widget.ALN.AppWidget.InnerView" parent="android:Widget">
<style name="Widget.LibrePods.AppWidget.InnerView" parent="android:Widget">
<item name="android:padding">?attr/appWidgetPadding</item>
<item name="android:background">@drawable/app_widget_inner_view_background</item>
<item name="android:textColor">?android:attr/textColorPrimary</item>

View File

@@ -1,13 +1,13 @@
<resources>
<style name="Widget.ALN.AppWidget.Container" parent="android:Widget">
<style name="Widget.LibrePods.AppWidget.Container" parent="android:Widget">
<item name="android:id">@android:id/background</item>
<item name="android:padding">?attr/appWidgetPadding</item>
<item name="android:background">@drawable/app_widget_background</item>
<item name="android:clipToOutline">true</item>
</style>
<style name="Widget.ALN.AppWidget.InnerView" parent="android:Widget">
<style name="Widget.LibrePods.AppWidget.InnerView" parent="android:Widget">
<item name="android:padding">?attr/appWidgetPadding</item>
<item name="android:background">@drawable/app_widget_inner_view_background</item>
<item name="android:textColor">?android:attr/textColorPrimary</item>

View File

@@ -4,7 +4,7 @@
Having themes.xml for v31 variant because @android:dimen/system_app_widget_background_radius
and @android:dimen/system_app_widget_internal_padding requires API level 31
-->
<style name="Theme.ALN.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault.DayNight">
<style name="Theme.LibrePods.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="appWidgetRadius">@android:dimen/system_app_widget_background_radius</item>
<item name="appWidgetInnerRadius">@android:dimen/system_app_widget_inner_radius</item>
</style>

View File

@@ -1,13 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="popup_background">#FFFFFF</color>
<color name="popup_text">@color/black</color>
<color name="widget_background">#87FFFFFF</color>
<color name="widget_text">@color/black</color>
<color name="light_blue_50">#FFE1F5FE</color>
<color name="light_blue_200">#FF81D4FA</color>
<color name="light_blue_600">#FF039BE5</color>
<color name="light_blue_900">#FF01579B</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="popup_background">#FFFFFF</color>
<color name="popup_text">@color/black</color>
<color name="widget_background">#87FFFFFF</color>
<color name="widget_text">@color/black</color>
<color name="light_blue_50">#FFE1F5FE</color>
<color name="light_blue_200">#FF81D4FA</color>
<color name="light_blue_600">#FF039BE5</color>
<color name="light_blue_900">#FF01579B</color>
<color name="colorBackground">#FFFFFF</color>
</resources>

View File

@@ -1,5 +1,5 @@
<resources>
<string name="app_name" translatable="false">ALN</string>
<string name="app_name" translatable="false">LibrePods</string>
<string name="title_activity_custom_device" translatable="false">GATT Testing</string>
<string name="app_widget_description">See your AirPods battery status right from your home screen!</string>
<string name="accessibility">Accessibility</string>
@@ -49,4 +49,14 @@
<string name="island_moved_to_remote_text">Moved to Linux</string>
<string name="head_tracking">Head Tracking</string>
<string name="head_gestures_details">Nod to answer calls, and shake your head to decline.</string>
<string name="general_settings_header">General</string>
<string name="qs_click_behavior_title">Quick Settings Tile Action</string>
<string name="qs_click_behavior_dialog_desc">Show noise control dialog on tap.</string>
<string name="qs_click_behavior_cycle_desc">Cycle through modes on tap.</string>
<string name="developer_options_header">Developer</string>
<string name="more_settings_title">Open AirPods Settings</string>
<string name="more_settings_subtitle">Manage AirPods features and preferences</string>
<string name="ear_detection">Automatic Ear Detection</string>
<string name="auto_play">Auto Play</string>
<string name="auto_pause">Auto Pause</string>
</resources>

View File

@@ -1,11 +1,11 @@
<resources>
<style name="Widget.ALN.AppWidget.Container" parent="android:Widget">
<style name="Widget.LibrePods.AppWidget.Container" parent="android:Widget">
<item name="android:id">@android:id/background</item>
<item name="android:background">?android:attr/colorBackground</item>
</style>
<style name="Widget.ALN.AppWidget.InnerView" parent="android:Widget">
<style name="Widget.LibrePods.AppWidget.InnerView" parent="android:Widget">
<item name="android:background">?android:attr/colorBackground</item>
<item name="android:textColor">?android:attr/textColorPrimary</item>
</style>

View File

@@ -1,14 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.ALN" parent="android:Theme.Material.Light.NoActionBar" />
<!-- Base application theme. -->
<style name="Theme.LibrePods" parent="Theme.AppCompat.DayNight">
<!-- Customize your light theme here. -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
<item name="android:windowLightStatusBar" >true</item>
<item name="android:windowLightNavigationBar" >true</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
<style name="Theme.ALN.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault">
<!-- Theme for the transparent dialog activity -->
<style name="Theme.TransparentDialog" parent="Theme.AppCompat.Dialog">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowIsFloating">false</item> <!-- Set to false for full width -->
<item name="android:backgroundDimEnabled">true</item> <!-- Dim background -->
<item name="android:windowAnimationStyle">@android:style/Animation.Translucent</item> <!-- Optional: Add animation -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<!-- Request blur behind (Android 12+) -->
<item name="android:windowBlurBehindEnabled" tools:targetApi="s">true</item>
<item name="android:windowBlurBehindRadius" tools:targetApi="s">32dp</item> <!-- Optional: Adjust radius -->
</style>
<style name="Theme.LibrePods.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault">
<item name="appWidgetRadius">32dp</item>
<item name="appWidgetPadding">0dp</item>
</style>
<style name="Theme.ALN.AppWidgetContainer" parent="Theme.ALN.AppWidgetContainerParent">
<style name="Theme.LibrePods.AppWidgetContainer" parent="Theme.LibrePods.AppWidgetContainerParent">
<item name="appWidgetPadding">0dp</item>
</style>
</resources>

View File

@@ -1 +1 @@
me.kavishdevar.aln.utils.KotlinModule
me.kavishdevar.librepods.utils.KotlinModule

View File

@@ -1,2 +1,6 @@
com.android.bluetooth
me.kavishdevar.aln
me.kavishdevar.librepods
android
com.android.systemui
com.android.settings
com.google.android.bluetooth

View File

@@ -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
android.nonTransitiveRClass=true
android.javaCompile.suppressSourceTargetDeprecationWarning=true

View File

@@ -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" }

View File

@@ -20,5 +20,5 @@ dependencyResolutionManagement {
}
}
rootProject.name = "ALN"
rootProject.name = "LibrePods"
include(":app")

BIN
imgs/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

View File

@@ -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."

View File

@@ -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

View File

@@ -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"
}