Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95a9bfbd79 | ||
|
|
7a265427b1 | ||
|
|
0a5fd6668d | ||
|
|
b8e9765aff | ||
|
|
62aabe80c1 | ||
|
|
dc0b06a369 | ||
|
|
96baebee28 | ||
|
|
c05a37bcca | ||
|
|
8a69dbe173 | ||
|
|
b783b86b7a | ||
|
|
445c999208 | ||
|
|
96e63cf35e | ||
|
|
5472e09293 | ||
|
|
e852182b48 | ||
|
|
5eb13ace0c | ||
|
|
2b1fb5b71e | ||
|
|
c95a619465 | ||
|
|
c4bc47c48a | ||
|
|
6a026ebab0 | ||
|
|
f3ed3bbc70 | ||
|
|
5fe123f544 | ||
|
|
09e1aa1530 | ||
|
|
fd917d3fd0 | ||
|
|
84891a0bdf | ||
|
|
4b3cc92e56 | ||
|
|
b89d6d9dc2 | ||
|
|
6985aa4a7b | ||
|
|
9161f8b294 | ||
|
|
4c0381968f | ||
|
|
69439257ce | ||
|
|
810a3c90e4 | ||
|
|
0611509782 | ||
|
|
116f7dda92 | ||
|
|
51ca4c12d1 | ||
|
|
8e670c2481 | ||
|
|
aec9c7192e | ||
|
|
01432ce9c7 | ||
|
|
9baa3c9b60 | ||
|
|
364a6f4b64 | ||
|
|
9b96218fa9 | ||
|
|
98aef13395 | ||
|
|
42e0f48b8b | ||
|
|
4c73200f35 | ||
|
|
06de276dca | ||
|
|
7ffcd68ad9 | ||
|
|
295c49fdc6 | ||
|
|
b95962d722 | ||
|
|
45ed8a3a88 |
@@ -4,6 +4,8 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- '*'
|
- '*'
|
||||||
|
paths:
|
||||||
|
- 'android/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
release:
|
release:
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
name: Linux Build & Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- linux/rust
|
||||||
|
tags:
|
||||||
|
- 'linux-v*'
|
||||||
|
paths:
|
||||||
|
- 'linux-rust/**'
|
||||||
|
- '.github/workflows/linux-build.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y pkg-config libdbus-1-dev libpulse-dev appstream just libfuse2
|
||||||
|
|
||||||
|
- name: Install AppImage tools
|
||||||
|
run: |
|
||||||
|
wget -q https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage -O /usr/local/bin/appimagetool
|
||||||
|
wget -q https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage -O /usr/local/bin/linuxdeploy
|
||||||
|
chmod +x /usr/local/bin/{appimagetool,linuxdeploy}
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Cache Cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
linux-rust/target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('linux-rust/Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Build AppImage and Binary
|
||||||
|
working-directory: linux-rust
|
||||||
|
run: |
|
||||||
|
cargo build --release --verbose
|
||||||
|
just
|
||||||
|
mkdir -p dist
|
||||||
|
cp target/release/librepods dist/librepods
|
||||||
|
mv dist/LibrePods-x86_64.AppImage dist/librepods-x86_64.AppImage
|
||||||
|
|
||||||
|
- name: Upload AppImage artifact
|
||||||
|
if: "!startsWith(github.ref, 'refs/tags/linux-v')"
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: librepods-x86_64.AppImage
|
||||||
|
path: linux-rust/dist/librepods-x86_64.AppImage
|
||||||
|
|
||||||
|
- name: Upload binary artifact
|
||||||
|
if: "!startsWith(github.ref, 'refs/tags/linux-v')"
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: librepods
|
||||||
|
path: linux-rust/dist/librepods
|
||||||
|
|
||||||
|
- name: Create tarball for Flatpak
|
||||||
|
if: startsWith(github.ref, 'refs/tags/linux-v')
|
||||||
|
working-directory: linux-rust
|
||||||
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#linux-v}"
|
||||||
|
just tarball "${VERSION}"
|
||||||
|
echo "VERSION=${VERSION}" >> $GITHUB_ENV
|
||||||
|
echo "TAR_PATH=linux-rust/dist/librepods-v${VERSION}-source.tar.gz" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Create GitHub Release (AppImage + binary + source)
|
||||||
|
if: startsWith(github.ref, 'refs/tags/linux-v')
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
files: |
|
||||||
|
linux-rust/dist/librepods-v${{ env.VERSION }}-source.tar.gz
|
||||||
|
linux-rust/dist/librepods-x86_64.AppImage
|
||||||
|
linux-rust/dist/librepods
|
||||||
|
generate_release_notes: true
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Build LibrePods Linux
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
# push:
|
||||||
|
# branches:
|
||||||
|
# - '*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y build-essential cmake ninja-build \
|
||||||
|
qt6-base-dev qt6-declarative-dev qt6-svg-dev \
|
||||||
|
qt6-tools-dev qt6-tools-dev-tools qt6-connectivity-dev \
|
||||||
|
libxkbcommon-dev
|
||||||
|
|
||||||
|
- name: Build project
|
||||||
|
working-directory: linux
|
||||||
|
run: |
|
||||||
|
mkdir build
|
||||||
|
cd build
|
||||||
|
cmake .. -G Ninja
|
||||||
|
ninja
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: librepods-linux
|
||||||
|
path: linux/build/librepods
|
||||||
+1
-1
@@ -184,7 +184,7 @@ Example packet:
|
|||||||
040004001d0002d5000400416972506f64732050726f004133303438004170706c6520496e632e0051584e524848595850360036312e313836383034303030323030303030302e323731330036312e313836383034303030323030303030302e3237313300312e302e3000636f6d2e6170706c652e6163636573736f72792e757064617465722e6170702e3731004859394c5432454632364a59004833504c5748444a32364b3000363335373533360089312a6567a5400f84a3ca234947efd40b90d78436ae5946748d70273e66066a2589300035333935303630363400```
|
040004001d0002d5000400416972506f64732050726f004133303438004170706c6520496e632e0051584e524848595850360036312e313836383034303030323030303030302e323731330036312e313836383034303030323030303030302e3237313300312e302e3000636f6d2e6170706c652e6163636573736f72792e757064617465722e6170702e3731004859394c5432454632364a59004833504c5748444a32364b3000363335373533360089312a6567a5400f84a3ca234947efd40b90d78436ae5946748d70273e66066a2589300035333935303630363400```
|
||||||
|
|
||||||
The packet contains device identification and version information followed by some encrypted data whose format is not known.
|
The packet contains device identification and version information followed by some encrypted data whose format is not known.
|
||||||
|
```
|
||||||
|
|
||||||
# Writing to the AirPods
|
# Writing to the AirPods
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "me.kavishdevar.librepods"
|
applicationId = "me.kavishdevar.librepods"
|
||||||
minSdk = 28
|
minSdk = 28
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 5
|
versionCode = 7
|
||||||
versionName = "0.1.0-rc.2"
|
versionName = "0.1.0-rc.4"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -61,5 +61,6 @@ dependencies {
|
|||||||
implementation(libs.androidx.constraintlayout)
|
implementation(libs.androidx.constraintlayout)
|
||||||
implementation(libs.haze)
|
implementation(libs.haze)
|
||||||
implementation(libs.haze.materials)
|
implementation(libs.haze.materials)
|
||||||
|
implementation(libs.androidx.dynamicanimation)
|
||||||
compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
|
compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
android:sharedUserId="android.uid.system"
|
|
||||||
android:sharedUserMaxSdkVersion="32"
|
|
||||||
tools:targetApi="33">
|
|
||||||
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.telephony"
|
android:name="android.hardware.telephony"
|
||||||
@@ -31,6 +28,11 @@
|
|||||||
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
tools:ignore="ScopedStorage" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||||
|
android:maxSdkVersion="30" />
|
||||||
|
|
||||||
<protected-broadcast android:name="batterywidget.impl.action.update_bluetooth_data" />
|
<protected-broadcast android:name="batterywidget.impl.action.update_bluetooth_data" />
|
||||||
|
|
||||||
@@ -44,6 +46,7 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.LibrePods"
|
android:theme="@style/Theme.LibrePods"
|
||||||
|
android:description="@string/app_description"
|
||||||
tools:ignore="UnusedAttribute"
|
tools:ignore="UnusedAttribute"
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
<receiver
|
<receiver
|
||||||
@@ -125,6 +128,16 @@
|
|||||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.provider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -3,10 +3,32 @@ cmake_minimum_required(VERSION 3.22.1)
|
|||||||
project("l2c_fcr_hook")
|
project("l2c_fcr_hook")
|
||||||
set(CMAKE_CXX_STANDARD 23)
|
set(CMAKE_CXX_STANDARD 23)
|
||||||
|
|
||||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
add_library(l2c_fcr_hook SHARED
|
||||||
l2c_fcr_hook.cpp
|
l2c_fcr_hook.cpp
|
||||||
l2c_fcr_hook.h)
|
|
||||||
|
|
||||||
target_link_libraries(${CMAKE_PROJECT_NAME}
|
xz/xz_crc32.c
|
||||||
|
xz/xz_crc64.c
|
||||||
|
xz/xz_sha256.c
|
||||||
|
xz/xz_dec_stream.c
|
||||||
|
xz/xz_dec_lzma2.c
|
||||||
|
xz/xz_dec_bcj.c
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(l2c_fcr_hook PRIVATE
|
||||||
|
xz
|
||||||
|
)
|
||||||
|
|
||||||
|
target_compile_definitions(l2c_fcr_hook PRIVATE
|
||||||
|
XZ_DEC_X86
|
||||||
|
XZ_DEC_ARM
|
||||||
|
XZ_DEC_ARMTHUMB
|
||||||
|
XZ_DEC_ARM64
|
||||||
|
XZ_DEC_ANY_CHECK
|
||||||
|
XZ_USE_CRC64
|
||||||
|
XZ_USE_SHA256
|
||||||
|
XZ_DEC_CONCATENATED
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(l2c_fcr_hook
|
||||||
android
|
android
|
||||||
log)
|
log)
|
||||||
@@ -1,417 +1,290 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
*
|
Copyright (C) 2025 LibrePods contributors
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
|
||||||
*
|
This program is free software: you can redistribute it and/or modify
|
||||||
* This program is free software: you can redistribute it and/or modify
|
it under the terms of the GNU General Public License as published by
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
any later version.
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <cstring>
|
|
||||||
#include <dlfcn.h>
|
|
||||||
#include <android/log.h>
|
#include <android/log.h>
|
||||||
#include <fstream>
|
#include <cstring>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <sys/system_properties.h>
|
#include <vector>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <elf.h>
|
||||||
|
|
||||||
#include "l2c_fcr_hook.h"
|
#include "l2c_fcr_hook.h"
|
||||||
|
|
||||||
#define LOG_TAG "AirPodsHook"
|
extern "C" {
|
||||||
|
#include "xz.h"
|
||||||
|
}
|
||||||
|
|
||||||
|
#define LOG_TAG "LibrePods"
|
||||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
||||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||||
|
|
||||||
static HookFunType hook_func = nullptr;
|
static HookFunType hook_func = nullptr;
|
||||||
#define L2CEVT_L2CAP_CONFIG_REQ 4
|
|
||||||
#define L2CEVT_L2CAP_CONFIG_RSP 15
|
|
||||||
|
|
||||||
struct t_l2c_lcb;
|
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void*) = nullptr;
|
||||||
typedef struct _BT_HDR {
|
|
||||||
uint16_t event;
|
|
||||||
uint16_t len;
|
|
||||||
uint16_t offset;
|
|
||||||
uint16_t layer_specific;
|
|
||||||
uint8_t data[];
|
|
||||||
} BT_HDR;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
uint8_t mode;
|
|
||||||
uint8_t tx_win_sz;
|
|
||||||
uint8_t max_transmit;
|
|
||||||
uint16_t rtrans_tout;
|
|
||||||
uint16_t mon_tout;
|
|
||||||
uint16_t mps;
|
|
||||||
} tL2CAP_FCR;
|
|
||||||
|
|
||||||
// Flow spec structure
|
|
||||||
typedef struct {
|
|
||||||
uint8_t qos_present;
|
|
||||||
uint8_t flow_direction;
|
|
||||||
uint8_t service_type;
|
|
||||||
uint32_t token_rate;
|
|
||||||
uint32_t token_bucket_size;
|
|
||||||
uint32_t peak_bandwidth;
|
|
||||||
uint32_t latency;
|
|
||||||
uint32_t delay_variation;
|
|
||||||
} FLOW_SPEC;
|
|
||||||
|
|
||||||
// Configuration info structure
|
|
||||||
typedef struct {
|
|
||||||
uint16_t result;
|
|
||||||
uint16_t mtu_present;
|
|
||||||
uint16_t mtu;
|
|
||||||
uint16_t flush_to_present;
|
|
||||||
uint16_t flush_to;
|
|
||||||
uint16_t qos_present;
|
|
||||||
FLOW_SPEC qos;
|
|
||||||
uint16_t fcr_present;
|
|
||||||
tL2CAP_FCR fcr;
|
|
||||||
uint16_t fcs_present;
|
|
||||||
uint16_t fcs;
|
|
||||||
uint16_t ext_flow_spec_present;
|
|
||||||
FLOW_SPEC ext_flow_spec;
|
|
||||||
} tL2CAP_CFG_INFO;
|
|
||||||
|
|
||||||
// Basic L2CAP link control block
|
|
||||||
typedef struct {
|
|
||||||
bool wait_ack;
|
|
||||||
// Other FCR fields - not needed for our specific hook
|
|
||||||
} tL2C_FCRB;
|
|
||||||
|
|
||||||
// Forward declarations for needed types
|
|
||||||
struct t_l2c_rcb;
|
|
||||||
struct t_l2c_lcb;
|
|
||||||
|
|
||||||
typedef struct t_l2c_ccb {
|
|
||||||
struct t_l2c_ccb* p_next_ccb; // Next CCB in the chain
|
|
||||||
struct t_l2c_ccb* p_prev_ccb; // Previous CCB in the chain
|
|
||||||
struct t_l2c_lcb* p_lcb; // Link this CCB belongs to
|
|
||||||
struct t_l2c_rcb* p_rcb; // Registration CB for this Channel
|
|
||||||
uint16_t local_cid; // Local CID
|
|
||||||
uint16_t remote_cid; // Remote CID
|
|
||||||
uint16_t p_lcb_next; // For linking CCBs to an LCB
|
|
||||||
uint8_t ccb_priority; // Channel priority
|
|
||||||
uint16_t tx_mps; // MPS for outgoing messages
|
|
||||||
uint16_t max_rx_mtu; // Max MTU we will receive
|
|
||||||
// State variables
|
|
||||||
bool in_use; // True when channel active
|
|
||||||
uint8_t chnl_state; // Channel state
|
|
||||||
uint8_t local_id; // Transaction ID for local trans
|
|
||||||
uint8_t remote_id; // Transaction ID for remote
|
|
||||||
uint8_t timer_entry; // Timer entry
|
|
||||||
uint8_t is_flushable; // True if flushable
|
|
||||||
// Configuration variables
|
|
||||||
uint16_t our_cfg_bits; // Bitmap of local config bits
|
|
||||||
uint16_t peer_cfg_bits; // Bitmap of peer config bits
|
|
||||||
uint16_t config_done; // Configuration bitmask
|
|
||||||
uint16_t remote_config_rsp_result; // Remote config response result
|
|
||||||
tL2CAP_CFG_INFO our_cfg; // Our saved configuration options
|
|
||||||
tL2CAP_CFG_INFO peer_cfg; // Peer's saved configuration options
|
|
||||||
// Additional control fields
|
|
||||||
uint8_t remote_credit_count; // Credits sent to peer
|
|
||||||
tL2C_FCRB fcrb; // FCR info
|
|
||||||
bool ecoc; // Enhanced Credit-based mode
|
|
||||||
} tL2C_CCB;
|
|
||||||
|
|
||||||
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void* p_ccb) = nullptr;
|
|
||||||
static void (*original_l2cu_process_our_cfg_req)(tL2C_CCB* p_ccb, tL2CAP_CFG_INFO* p_cfg) = nullptr;
|
|
||||||
static void (*original_l2c_csm_config)(tL2C_CCB* p_ccb, uint8_t event, void* p_data) = nullptr;
|
|
||||||
static void (*original_l2cu_send_peer_info_req)(tL2C_LCB* p_lcb, uint16_t info_type) = nullptr;
|
|
||||||
|
|
||||||
uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) {
|
uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) {
|
||||||
LOGI("l2c_fcr_chk_chan_modes hooked, returning true.");
|
LOGI("l2c_fcr_chk_chan_modes hooked");
|
||||||
|
uint8_t orig = 0;
|
||||||
|
if (original_l2c_fcr_chk_chan_modes)
|
||||||
|
orig = original_l2c_fcr_chk_chan_modes(p_ccb);
|
||||||
|
|
||||||
|
LOGI("Original returned %d, forcing 1", orig);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
void fake_l2cu_process_our_cfg_req(tL2C_CCB* p_ccb, tL2CAP_CFG_INFO* p_cfg) {
|
static bool decompressXZ(
|
||||||
original_l2cu_process_our_cfg_req(p_ccb, p_cfg);
|
const uint8_t* input,
|
||||||
p_ccb->our_cfg.fcr.mode = 0x00;
|
size_t input_size,
|
||||||
LOGI("Set FCR mode to Basic Mode in outgoing config request");
|
std::vector<uint8_t>& output) {
|
||||||
}
|
|
||||||
|
|
||||||
void fake_l2c_csm_config(tL2C_CCB* p_ccb, uint8_t event, void* p_data) {
|
xz_crc32_init();
|
||||||
// Call the original function first to handle the specific code path where the FCR mode is checked
|
#ifdef XZ_USE_CRC64
|
||||||
original_l2c_csm_config(p_ccb, event, p_data);
|
xz_crc64_init();
|
||||||
|
#endif
|
||||||
|
|
||||||
// Check if this happens during CONFIG_RSP event handling
|
struct xz_dec* dec = xz_dec_init(XZ_DYNALLOC, 64U << 20);
|
||||||
if (event == L2CEVT_L2CAP_CONFIG_RSP) {
|
if (!dec) return false;
|
||||||
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
|
struct xz_buf buf{};
|
||||||
void fake_l2cu_send_peer_info_req(tL2C_LCB* p_lcb, uint16_t info_type) {
|
buf.in = input;
|
||||||
LOGI("Intercepted l2cu_send_peer_info_req for info_type 0x%04x - doing nothing", info_type);
|
buf.in_pos = 0;
|
||||||
// Just return without doing anything
|
buf.in_size = input_size;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
uintptr_t loadHookOffset([[maybe_unused]] const char* package_name) {
|
output.resize(input_size * 8);
|
||||||
const char* property_name = "persist.librepods.hook_offset";
|
|
||||||
char value[PROP_VALUE_MAX] = {0};
|
|
||||||
|
|
||||||
int len = __system_property_get(property_name, value);
|
buf.out = output.data();
|
||||||
if (len > 0) {
|
buf.out_pos = 0;
|
||||||
LOGI("Read hook offset from property: %s", value);
|
buf.out_size = output.size();
|
||||||
uintptr_t offset;
|
|
||||||
char* endptr = nullptr;
|
|
||||||
|
|
||||||
const char* parse_start = value;
|
while (true) {
|
||||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
enum xz_ret ret = xz_dec_run(dec, &buf);
|
||||||
parse_start = value + 2;
|
|
||||||
|
if (ret == XZ_STREAM_END)
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (ret != XZ_OK) {
|
||||||
|
xz_dec_end(dec);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
errno = 0;
|
if (buf.out_pos == buf.out_size) {
|
||||||
offset = strtoul(parse_start, &endptr, 16);
|
size_t old = output.size();
|
||||||
|
output.resize(old * 2);
|
||||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
buf.out = output.data();
|
||||||
LOGI("Parsed offset: 0x%x", offset);
|
buf.out_size = output.size();
|
||||||
return offset;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LOGE("Failed to parse offset from property value: %s", value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LOGI("Using hardcoded fallback offset");
|
output.resize(buf.out_pos);
|
||||||
return 0x00a55e30;
|
xz_dec_end(dec);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
uintptr_t loadL2cuProcessCfgReqOffset() {
|
static bool getLibraryPath(const char* name, std::string& out) {
|
||||||
const char* property_name = "persist.librepods.cfg_req_offset";
|
FILE* fp = fopen("/proc/self/maps", "r");
|
||||||
char value[PROP_VALUE_MAX] = {0};
|
if (!fp) return false;
|
||||||
|
|
||||||
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];
|
char line[1024];
|
||||||
uintptr_t base_addr = 0;
|
|
||||||
|
|
||||||
fp = fopen("/proc/self/maps", "r");
|
|
||||||
if (!fp) {
|
|
||||||
LOGE("Failed to open /proc/self/maps");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (fgets(line, sizeof(line), fp)) {
|
while (fgets(line, sizeof(line), fp)) {
|
||||||
if (strstr(line, module_name)) {
|
if (strstr(line, name)) {
|
||||||
char *start_addr_str = line;
|
char* path = strchr(line, '/');
|
||||||
char *end_addr_str = strchr(line, '-');
|
if (path) {
|
||||||
if (end_addr_str) {
|
out = path;
|
||||||
*end_addr_str = '\0';
|
out.erase(out.find('\n'));
|
||||||
base_addr = strtoull(start_addr_str, nullptr, 16);
|
fclose(fp);
|
||||||
break;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose(fp);
|
fclose(fp);
|
||||||
return base_addr;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool findAndHookFunction([[maybe_unused]] const char *library_path) {
|
static uintptr_t getModuleBase(const char* name) {
|
||||||
|
FILE* fp = fopen("/proc/self/maps", "r");
|
||||||
|
if (!fp) return 0;
|
||||||
|
|
||||||
|
char line[1024];
|
||||||
|
uintptr_t base = 0;
|
||||||
|
|
||||||
|
while (fgets(line, sizeof(line), fp)) {
|
||||||
|
if (strstr(line, name)) {
|
||||||
|
base = strtoull(line, nullptr, 16);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose(fp);
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint64_t findSymbolOffset(
|
||||||
|
const std::vector<uint8_t>& elf,
|
||||||
|
const char* symbol_substring) {
|
||||||
|
|
||||||
|
auto* eh = reinterpret_cast<const Elf64_Ehdr*>(elf.data());
|
||||||
|
auto* shdr = reinterpret_cast<const Elf64_Shdr*>(
|
||||||
|
elf.data() + eh->e_shoff);
|
||||||
|
|
||||||
|
const char* shstr =
|
||||||
|
reinterpret_cast<const char*>(
|
||||||
|
elf.data() + shdr[eh->e_shstrndx].sh_offset);
|
||||||
|
|
||||||
|
const Elf64_Shdr* symtab = nullptr;
|
||||||
|
const Elf64_Shdr* strtab = nullptr;
|
||||||
|
|
||||||
|
for (int i = 0; i < eh->e_shnum; ++i) {
|
||||||
|
const char* secname = shstr + shdr[i].sh_name;
|
||||||
|
if (!strcmp(secname, ".symtab"))
|
||||||
|
symtab = &shdr[i];
|
||||||
|
if (!strcmp(secname, ".strtab"))
|
||||||
|
strtab = &shdr[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!symtab || !strtab)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
auto* symbols = reinterpret_cast<const Elf64_Sym*>(
|
||||||
|
elf.data() + symtab->sh_offset);
|
||||||
|
|
||||||
|
const char* strings =
|
||||||
|
reinterpret_cast<const char*>(
|
||||||
|
elf.data() + strtab->sh_offset);
|
||||||
|
|
||||||
|
size_t count = symtab->sh_size / sizeof(Elf64_Sym);
|
||||||
|
|
||||||
|
for (size_t i = 0; i < count; ++i) {
|
||||||
|
const char* name = strings + symbols[i].st_name;
|
||||||
|
|
||||||
|
if (strstr(name, symbol_substring) &&
|
||||||
|
ELF64_ST_TYPE(symbols[i].st_info) == STT_FUNC) {
|
||||||
|
|
||||||
|
LOGI("Resolved %s at 0x%lx",
|
||||||
|
name,
|
||||||
|
(unsigned long)symbols[i].st_value);
|
||||||
|
|
||||||
|
return symbols[i].st_value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool hookLibrary(const char* libname) {
|
||||||
|
|
||||||
if (!hook_func) {
|
if (!hook_func) {
|
||||||
LOGE("Hook function not initialized");
|
LOGE("hook_func not initialized");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
uintptr_t base_addr = getModuleBase("libbluetooth_jni.so");
|
std::string path;
|
||||||
if (!base_addr) {
|
if (!getLibraryPath(libname, path)) {
|
||||||
LOGE("Failed to get base address of libbluetooth_jni.so");
|
LOGE("Failed to locate %s", libname);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load all offsets from system properties - no hardcoding
|
int fd = open(path.c_str(), O_RDONLY);
|
||||||
uintptr_t l2c_fcr_offset = loadHookOffset(nullptr);
|
if (fd < 0) return false;
|
||||||
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();
|
|
||||||
|
|
||||||
bool success = false;
|
struct stat st{};
|
||||||
|
if (fstat(fd, &st) != 0) {
|
||||||
// Hook l2c_fcr_chk_chan_modes - this is our primary hook
|
close(fd);
|
||||||
if (l2c_fcr_offset > 0) {
|
|
||||||
void* target = reinterpret_cast<void*>(base_addr + l2c_fcr_offset);
|
|
||||||
LOGI("Hooking l2c_fcr_chk_chan_modes at offset: 0x%x, base: %p, target: %p",
|
|
||||||
l2c_fcr_offset, (void*)base_addr, target);
|
|
||||||
|
|
||||||
int result = hook_func(target, (void*)fake_l2c_fcr_chk_chan_modes, (void**)&original_l2c_fcr_chk_chan_modes);
|
|
||||||
if (result != 0) {
|
|
||||||
LOGE("Failed to hook l2c_fcr_chk_chan_modes, error: %d", result);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
LOGI("Successfully hooked l2c_fcr_chk_chan_modes");
|
|
||||||
success = true;
|
|
||||||
} else {
|
|
||||||
LOGE("No valid offset for l2c_fcr_chk_chan_modes found, cannot proceed");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hook l2cu_process_our_cfg_req if offset is available
|
std::vector<uint8_t> file(st.st_size);
|
||||||
if (l2cu_process_our_cfg_req_offset > 0) {
|
read(fd, file.data(), st.st_size);
|
||||||
void* target = reinterpret_cast<void*>(base_addr + l2cu_process_our_cfg_req_offset);
|
close(fd);
|
||||||
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);
|
auto* eh = reinterpret_cast<Elf64_Ehdr*>(file.data());
|
||||||
if (result != 0) {
|
auto* shdr = reinterpret_cast<Elf64_Shdr*>(
|
||||||
LOGE("Failed to hook l2cu_process_our_cfg_req, error: %d", result);
|
file.data() + eh->e_shoff);
|
||||||
// Continue even if this hook fails
|
|
||||||
} else {
|
const char* shstr =
|
||||||
LOGI("Successfully hooked l2cu_process_our_cfg_req");
|
reinterpret_cast<const char*>(
|
||||||
|
file.data() + shdr[eh->e_shstrndx].sh_offset);
|
||||||
|
|
||||||
|
for (int i = 0; i < eh->e_shnum; ++i) {
|
||||||
|
|
||||||
|
if (!strcmp(shstr + shdr[i].sh_name, ".gnu_debugdata")) {
|
||||||
|
|
||||||
|
std::vector<uint8_t> compressed(
|
||||||
|
file.begin() + shdr[i].sh_offset,
|
||||||
|
file.begin() + shdr[i].sh_offset + shdr[i].sh_size);
|
||||||
|
|
||||||
|
std::vector<uint8_t> decompressed;
|
||||||
|
|
||||||
|
if (!decompressXZ(
|
||||||
|
compressed.data(),
|
||||||
|
compressed.size(),
|
||||||
|
decompressed))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
uintptr_t base = getModuleBase(libname);
|
||||||
|
if (!base) return false;
|
||||||
|
|
||||||
|
uint64_t chk_offset =
|
||||||
|
findSymbolOffset(decompressed,
|
||||||
|
"l2c_fcr_chk_chan_modes");
|
||||||
|
|
||||||
|
if (chk_offset) {
|
||||||
|
void* target =
|
||||||
|
reinterpret_cast<void*>(base + chk_offset);
|
||||||
|
|
||||||
|
hook_func(target,
|
||||||
|
(void*)fake_l2c_fcr_chk_chan_modes,
|
||||||
|
(void**)&original_l2c_fcr_chk_chan_modes);
|
||||||
|
|
||||||
|
LOGI("Hooked l2c_fcr_chk_chan_modes");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
LOGI("Skipping l2cu_process_our_cfg_req hook as offset is not available");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hook l2c_csm_config if offset is available
|
return false;
|
||||||
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) {
|
static void on_library_loaded(const char* name, void*) {
|
||||||
|
|
||||||
if (strstr(name, "libbluetooth_jni.so")) {
|
if (strstr(name, "libbluetooth_jni.so")) {
|
||||||
LOGI("Detected Bluetooth library: %s", name);
|
LOGI("Bluetooth JNI loaded");
|
||||||
|
hookLibrary("libbluetooth_jni.so");
|
||||||
|
}
|
||||||
|
|
||||||
bool hooked = findAndHookFunction(name);
|
if (strstr(name, "libbluetooth_qti.so")) {
|
||||||
if (!hooked) {
|
LOGI("Bluetooth QTI loaded");
|
||||||
LOGE("Failed to hook Bluetooth library function");
|
hookLibrary("libbluetooth_qti.so");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "C" [[gnu::visibility("default")]] [[gnu::used]]
|
extern "C"
|
||||||
|
[[gnu::visibility("default")]]
|
||||||
|
[[gnu::used]]
|
||||||
NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) {
|
NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) {
|
||||||
LOGI("L2C FCR Hook module initialized");
|
|
||||||
|
|
||||||
hook_func = entries->hook_func;
|
LOGI("LibrePods initialized");
|
||||||
|
|
||||||
|
hook_func = (HookFunType)entries->hook_func;
|
||||||
|
|
||||||
return on_library_loaded;
|
return on_library_loaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,32 @@
|
|||||||
#pragma once
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
typedef int (*HookFunType)(void *func, void *replace, void **backup);
|
typedef int (*HookFunType)(void *func, void *replace, void **backup);
|
||||||
|
|
||||||
typedef int (*UnhookFunType)(void *func);
|
|
||||||
|
|
||||||
typedef void (*NativeOnModuleLoaded)(const char *name, void *handle);
|
typedef void (*NativeOnModuleLoaded)(const char *name, void *handle);
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
uint32_t version;
|
uint32_t version;
|
||||||
HookFunType hook_func;
|
void* hook_func;
|
||||||
UnhookFunType unhook_func;
|
void* unhook_func;
|
||||||
} NativeAPIEntries;
|
} NativeAPIEntries;
|
||||||
|
|
||||||
[[maybe_unused]] typedef NativeOnModuleLoaded (*NativeInit)(const NativeAPIEntries *entries);
|
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);
|
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
/* SPDX-License-Identifier: 0BSD */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* XZ decompressor
|
||||||
|
*
|
||||||
|
* Authors: Lasse Collin <lasse.collin@tukaani.org>
|
||||||
|
* Igor Pavlov <https://7-zip.org/>
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef XZ_H
|
||||||
|
#define XZ_H
|
||||||
|
|
||||||
|
#ifdef __KERNEL__
|
||||||
|
# include <linux/stddef.h>
|
||||||
|
# include <linux/types.h>
|
||||||
|
#else
|
||||||
|
# include <stddef.h>
|
||||||
|
# include <stdint.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* "#define XZ_EXTERN static" can be used to make extern functions static. */
|
||||||
|
#ifndef XZ_EXTERN
|
||||||
|
# define XZ_EXTERN extern
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/**
|
||||||
|
* enum xz_mode - Operation mode
|
||||||
|
*
|
||||||
|
* @XZ_SINGLE: Single-call mode. This uses less RAM than
|
||||||
|
* multi-call modes, because the LZMA2
|
||||||
|
* dictionary doesn't need to be allocated as
|
||||||
|
* part of the decoder state. All required data
|
||||||
|
* structures are allocated at initialization,
|
||||||
|
* so xz_dec_run() cannot return XZ_MEM_ERROR.
|
||||||
|
* @XZ_PREALLOC: Multi-call mode with preallocated LZMA2
|
||||||
|
* dictionary buffer. All data structures are
|
||||||
|
* allocated at initialization, so xz_dec_run()
|
||||||
|
* cannot return XZ_MEM_ERROR.
|
||||||
|
* @XZ_DYNALLOC: Multi-call mode. The LZMA2 dictionary is
|
||||||
|
* allocated once the required size has been
|
||||||
|
* parsed from the stream headers. If the
|
||||||
|
* allocation fails, xz_dec_run() will return
|
||||||
|
* XZ_MEM_ERROR.
|
||||||
|
*
|
||||||
|
* It is possible to enable support only for a subset of the above
|
||||||
|
* modes at compile time by defining XZ_DEC_SINGLE, XZ_DEC_PREALLOC,
|
||||||
|
* or XZ_DEC_DYNALLOC. The xz_dec kernel module is always compiled
|
||||||
|
* with support for all operation modes, but the preboot code may
|
||||||
|
* be built with fewer features to minimize code size.
|
||||||
|
*/
|
||||||
|
enum xz_mode {
|
||||||
|
XZ_SINGLE,
|
||||||
|
XZ_PREALLOC,
|
||||||
|
XZ_DYNALLOC
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* enum xz_ret - Return codes
|
||||||
|
* @XZ_OK: Everything is OK so far. More input or more
|
||||||
|
* output space is required to continue. This
|
||||||
|
* return code is possible only in multi-call mode
|
||||||
|
* (XZ_PREALLOC or XZ_DYNALLOC).
|
||||||
|
* @XZ_STREAM_END: Operation finished successfully.
|
||||||
|
* @XZ_UNSUPPORTED_CHECK: Integrity check type is not supported. Decoding
|
||||||
|
* is still possible in multi-call mode by simply
|
||||||
|
* calling xz_dec_run() again.
|
||||||
|
* Note that this return value is used only if
|
||||||
|
* XZ_DEC_ANY_CHECK was defined at build time,
|
||||||
|
* which is not used in the kernel. Unsupported
|
||||||
|
* check types return XZ_OPTIONS_ERROR if
|
||||||
|
* XZ_DEC_ANY_CHECK was not defined at build time.
|
||||||
|
* @XZ_MEM_ERROR: Allocating memory failed. This return code is
|
||||||
|
* possible only if the decoder was initialized
|
||||||
|
* with XZ_DYNALLOC. The amount of memory that was
|
||||||
|
* tried to be allocated was no more than the
|
||||||
|
* dict_max argument given to xz_dec_init().
|
||||||
|
* @XZ_MEMLIMIT_ERROR: A bigger LZMA2 dictionary would be needed than
|
||||||
|
* allowed by the dict_max argument given to
|
||||||
|
* xz_dec_init(). This return value is possible
|
||||||
|
* only in multi-call mode (XZ_PREALLOC or
|
||||||
|
* XZ_DYNALLOC); the single-call mode (XZ_SINGLE)
|
||||||
|
* ignores the dict_max argument.
|
||||||
|
* @XZ_FORMAT_ERROR: File format was not recognized (wrong magic
|
||||||
|
* bytes).
|
||||||
|
* @XZ_OPTIONS_ERROR: This implementation doesn't support the requested
|
||||||
|
* compression options. In the decoder this means
|
||||||
|
* that the header CRC32 matches, but the header
|
||||||
|
* itself specifies something that we don't support.
|
||||||
|
* @XZ_DATA_ERROR: Compressed data is corrupt.
|
||||||
|
* @XZ_BUF_ERROR: Cannot make any progress. Details are slightly
|
||||||
|
* different between multi-call and single-call
|
||||||
|
* mode; more information below.
|
||||||
|
*
|
||||||
|
* In multi-call mode, XZ_BUF_ERROR is returned when two consecutive calls
|
||||||
|
* to XZ code cannot consume any input and cannot produce any new output.
|
||||||
|
* This happens when there is no new input available, or the output buffer
|
||||||
|
* is full while at least one output byte is still pending. Assuming your
|
||||||
|
* code is not buggy, you can get this error only when decoding a compressed
|
||||||
|
* stream that is truncated or otherwise corrupt.
|
||||||
|
*
|
||||||
|
* In single-call mode, XZ_BUF_ERROR is returned only when the output buffer
|
||||||
|
* is too small or the compressed input is corrupt in a way that makes the
|
||||||
|
* decoder produce more output than the caller expected. When it is
|
||||||
|
* (relatively) clear that the compressed input is truncated, XZ_DATA_ERROR
|
||||||
|
* is used instead of XZ_BUF_ERROR.
|
||||||
|
*/
|
||||||
|
enum xz_ret {
|
||||||
|
XZ_OK,
|
||||||
|
XZ_STREAM_END,
|
||||||
|
XZ_UNSUPPORTED_CHECK,
|
||||||
|
XZ_MEM_ERROR,
|
||||||
|
XZ_MEMLIMIT_ERROR,
|
||||||
|
XZ_FORMAT_ERROR,
|
||||||
|
XZ_OPTIONS_ERROR,
|
||||||
|
XZ_DATA_ERROR,
|
||||||
|
XZ_BUF_ERROR
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* struct xz_buf - Passing input and output buffers to XZ code
|
||||||
|
* @in: Beginning of the input buffer. This may be NULL if and only
|
||||||
|
* if in_pos is equal to in_size.
|
||||||
|
* @in_pos: Current position in the input buffer. This must not exceed
|
||||||
|
* in_size.
|
||||||
|
* @in_size: Size of the input buffer
|
||||||
|
* @out: Beginning of the output buffer. This may be NULL if and only
|
||||||
|
* if out_pos is equal to out_size.
|
||||||
|
* @out_pos: Current position in the output buffer. This must not exceed
|
||||||
|
* out_size.
|
||||||
|
* @out_size: Size of the output buffer
|
||||||
|
*
|
||||||
|
* Only the contents of the output buffer from out[out_pos] onward, and
|
||||||
|
* the variables in_pos and out_pos are modified by the XZ code.
|
||||||
|
*/
|
||||||
|
struct xz_buf {
|
||||||
|
const uint8_t *in;
|
||||||
|
size_t in_pos;
|
||||||
|
size_t in_size;
|
||||||
|
|
||||||
|
uint8_t *out;
|
||||||
|
size_t out_pos;
|
||||||
|
size_t out_size;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* struct xz_dec - Opaque type to hold the XZ decoder state
|
||||||
|
*/
|
||||||
|
struct xz_dec;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xz_dec_init() - Allocate and initialize a XZ decoder state
|
||||||
|
* @mode: Operation mode
|
||||||
|
* @dict_max: Maximum size of the LZMA2 dictionary (history buffer) for
|
||||||
|
* multi-call decoding. This is ignored in single-call mode
|
||||||
|
* (mode == XZ_SINGLE). LZMA2 dictionary is always 2^n bytes
|
||||||
|
* or 2^n + 2^(n-1) bytes (the latter sizes are less common
|
||||||
|
* in practice), so other values for dict_max don't make sense.
|
||||||
|
* In the kernel, dictionary sizes of 64 KiB, 128 KiB, 256 KiB,
|
||||||
|
* 512 KiB, and 1 MiB are probably the only reasonable values,
|
||||||
|
* except for kernel and initramfs images where a bigger
|
||||||
|
* dictionary can be fine and useful.
|
||||||
|
*
|
||||||
|
* Single-call mode (XZ_SINGLE): xz_dec_run() decodes the whole stream at
|
||||||
|
* once. The caller must provide enough output space or the decoding will
|
||||||
|
* fail. The output space is used as the dictionary buffer, which is why
|
||||||
|
* there is no need to allocate the dictionary as part of the decoder's
|
||||||
|
* internal state.
|
||||||
|
*
|
||||||
|
* Because the output buffer is used as the workspace, streams encoded using
|
||||||
|
* a big dictionary are not a problem in single-call mode. It is enough that
|
||||||
|
* the output buffer is big enough to hold the actual uncompressed data; it
|
||||||
|
* can be smaller than the dictionary size stored in the stream headers.
|
||||||
|
*
|
||||||
|
* Multi-call mode with preallocated dictionary (XZ_PREALLOC): dict_max bytes
|
||||||
|
* of memory is preallocated for the LZMA2 dictionary. This way there is no
|
||||||
|
* risk that xz_dec_run() could run out of memory, since xz_dec_run() will
|
||||||
|
* never allocate any memory. Instead, if the preallocated dictionary is too
|
||||||
|
* small for decoding the given input stream, xz_dec_run() will return
|
||||||
|
* XZ_MEMLIMIT_ERROR. Thus, it is important to know what kind of data will be
|
||||||
|
* decoded to avoid allocating excessive amount of memory for the dictionary.
|
||||||
|
*
|
||||||
|
* Multi-call mode with dynamically allocated dictionary (XZ_DYNALLOC):
|
||||||
|
* dict_max specifies the maximum allowed dictionary size that xz_dec_run()
|
||||||
|
* may allocate once it has parsed the dictionary size from the stream
|
||||||
|
* headers. This way excessive allocations can be avoided while still
|
||||||
|
* limiting the maximum memory usage to a sane value to prevent running the
|
||||||
|
* system out of memory when decompressing streams from untrusted sources.
|
||||||
|
*
|
||||||
|
* On success, xz_dec_init() returns a pointer to struct xz_dec, which is
|
||||||
|
* ready to be used with xz_dec_run(). If memory allocation fails,
|
||||||
|
* xz_dec_init() returns NULL.
|
||||||
|
*/
|
||||||
|
XZ_EXTERN struct xz_dec *xz_dec_init(enum xz_mode mode, uint32_t dict_max);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xz_dec_run() - Run the XZ decoder for a single XZ stream
|
||||||
|
* @s: Decoder state allocated using xz_dec_init()
|
||||||
|
* @b: Input and output buffers
|
||||||
|
*
|
||||||
|
* The possible return values depend on build options and operation mode.
|
||||||
|
* See enum xz_ret for details.
|
||||||
|
*
|
||||||
|
* Note that if an error occurs in single-call mode (return value is not
|
||||||
|
* XZ_STREAM_END), b->in_pos and b->out_pos are not modified and the
|
||||||
|
* contents of the output buffer from b->out[b->out_pos] onward are
|
||||||
|
* undefined. This is true even after XZ_BUF_ERROR, because with some filter
|
||||||
|
* chains, there may be a second pass over the output buffer, and this pass
|
||||||
|
* cannot be properly done if the output buffer is truncated. Thus, you
|
||||||
|
* cannot give the single-call decoder a too small buffer and then expect to
|
||||||
|
* get that amount valid data from the beginning of the stream. You must use
|
||||||
|
* the multi-call decoder if you don't want to uncompress the whole stream.
|
||||||
|
*
|
||||||
|
* Use xz_dec_run() when XZ data is stored inside some other file format.
|
||||||
|
* The decoding will stop after one XZ stream has been decompressed. To
|
||||||
|
* decompress regular .xz files which might have multiple concatenated
|
||||||
|
* streams, use xz_dec_catrun() instead.
|
||||||
|
*/
|
||||||
|
XZ_EXTERN enum xz_ret xz_dec_run(struct xz_dec *s, struct xz_buf *b);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xz_dec_catrun() - Run the XZ decoder with support for concatenated streams
|
||||||
|
* @s: Decoder state allocated using xz_dec_init()
|
||||||
|
* @b: Input and output buffers
|
||||||
|
* @finish: This is an int instead of bool to avoid requiring stdbool.h.
|
||||||
|
* As long as more input might be coming, finish must be false.
|
||||||
|
* When the caller knows that it has provided all the input to
|
||||||
|
* the decoder (some possibly still in b->in), it must set finish
|
||||||
|
* to true. Only when finish is true can this function return
|
||||||
|
* XZ_STREAM_END to indicate successful decompression of the
|
||||||
|
* file. In single-call mode (XZ_SINGLE) finish is assumed to
|
||||||
|
* always be true; the caller-provided value is ignored.
|
||||||
|
*
|
||||||
|
* This is like xz_dec_run() except that this makes it easy to decode .xz
|
||||||
|
* files with multiple streams (multiple .xz files concatenated as is).
|
||||||
|
* The rarely-used Stream Padding feature is supported too, that is, there
|
||||||
|
* can be null bytes after or between the streams. The number of null bytes
|
||||||
|
* must be a multiple of four.
|
||||||
|
*
|
||||||
|
* When finish is false and b->in_pos == b->in_size, it is possible that
|
||||||
|
* XZ_BUF_ERROR isn't returned even when no progress is possible (XZ_OK is
|
||||||
|
* returned instead). This shouldn't matter because in this situation a
|
||||||
|
* reasonable caller will attempt to provide more input or set finish to
|
||||||
|
* true for the next xz_dec_catrun() call anyway.
|
||||||
|
*
|
||||||
|
* For any struct xz_dec that has been initialized for multi-call mode:
|
||||||
|
* Once decoding has been started with xz_dec_run() or xz_dec_catrun(),
|
||||||
|
* the same function must be used until xz_dec_reset() or xz_dec_end().
|
||||||
|
* Switching between the two decoding functions without resetting results
|
||||||
|
* in undefined behavior.
|
||||||
|
*
|
||||||
|
* xz_dec_catrun() is only available if XZ_DEC_CONCATENATED was defined
|
||||||
|
* at compile time.
|
||||||
|
*/
|
||||||
|
XZ_EXTERN enum xz_ret xz_dec_catrun(struct xz_dec *s, struct xz_buf *b,
|
||||||
|
int finish);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xz_dec_reset() - Reset an already allocated decoder state
|
||||||
|
* @s: Decoder state allocated using xz_dec_init()
|
||||||
|
*
|
||||||
|
* This function can be used to reset the multi-call decoder state without
|
||||||
|
* freeing and reallocating memory with xz_dec_end() and xz_dec_init().
|
||||||
|
*
|
||||||
|
* In single-call mode, xz_dec_reset() is always called in the beginning of
|
||||||
|
* xz_dec_run(). Thus, explicit call to xz_dec_reset() is useful only in
|
||||||
|
* multi-call mode.
|
||||||
|
*/
|
||||||
|
XZ_EXTERN void xz_dec_reset(struct xz_dec *s);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xz_dec_end() - Free the memory allocated for the decoder state
|
||||||
|
* @s: Decoder state allocated using xz_dec_init(). If s is NULL,
|
||||||
|
* this function does nothing.
|
||||||
|
*/
|
||||||
|
XZ_EXTERN void xz_dec_end(struct xz_dec *s);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DOC: MicroLZMA decompressor
|
||||||
|
*
|
||||||
|
* This MicroLZMA header format was created for use in EROFS but may be used
|
||||||
|
* by others too. **In most cases one needs the XZ APIs above instead.**
|
||||||
|
*
|
||||||
|
* The compressed format supported by this decoder is a raw LZMA stream
|
||||||
|
* whose first byte (always 0x00) has been replaced with bitwise-negation
|
||||||
|
* of the LZMA properties (lc/lp/pb) byte. For example, if lc/lp/pb is
|
||||||
|
* 3/0/2, the first byte is 0xA2. This way the first byte can never be 0x00.
|
||||||
|
* Just like with LZMA2, lc + lp <= 4 must be true. The LZMA end-of-stream
|
||||||
|
* marker must not be used. The unused values are reserved for future use.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* struct xz_dec_microlzma - Opaque type to hold the MicroLZMA decoder state
|
||||||
|
*/
|
||||||
|
struct xz_dec_microlzma;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xz_dec_microlzma_alloc() - Allocate memory for the MicroLZMA decoder
|
||||||
|
* @mode: XZ_SINGLE or XZ_PREALLOC
|
||||||
|
* @dict_size: LZMA dictionary size. This must be at least 4 KiB and
|
||||||
|
* at most 3 GiB.
|
||||||
|
*
|
||||||
|
* In contrast to xz_dec_init(), this function only allocates the memory
|
||||||
|
* and remembers the dictionary size. xz_dec_microlzma_reset() must be used
|
||||||
|
* before calling xz_dec_microlzma_run().
|
||||||
|
*
|
||||||
|
* The amount of allocated memory is a little less than 30 KiB with XZ_SINGLE.
|
||||||
|
* With XZ_PREALLOC also a dictionary buffer of dict_size bytes is allocated.
|
||||||
|
*
|
||||||
|
* On success, xz_dec_microlzma_alloc() returns a pointer to
|
||||||
|
* struct xz_dec_microlzma. If memory allocation fails or
|
||||||
|
* dict_size is invalid, NULL is returned.
|
||||||
|
*/
|
||||||
|
XZ_EXTERN struct xz_dec_microlzma *xz_dec_microlzma_alloc(enum xz_mode mode,
|
||||||
|
uint32_t dict_size);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xz_dec_microlzma_reset() - Reset the MicroLZMA decoder state
|
||||||
|
* @s: Decoder state allocated using xz_dec_microlzma_alloc()
|
||||||
|
* @comp_size: Compressed size of the input stream
|
||||||
|
* @uncomp_size: Uncompressed size of the input stream. A value smaller
|
||||||
|
* than the real uncompressed size of the input stream can
|
||||||
|
* be specified if uncomp_size_is_exact is set to false.
|
||||||
|
* uncomp_size can never be set to a value larger than the
|
||||||
|
* expected real uncompressed size because it would eventually
|
||||||
|
* result in XZ_DATA_ERROR.
|
||||||
|
* @uncomp_size_is_exact: This is an int instead of bool to avoid
|
||||||
|
* requiring stdbool.h. This should normally be set to true.
|
||||||
|
* When this is set to false, error detection is weaker.
|
||||||
|
*/
|
||||||
|
XZ_EXTERN void xz_dec_microlzma_reset(struct xz_dec_microlzma *s,
|
||||||
|
uint32_t comp_size, uint32_t uncomp_size,
|
||||||
|
int uncomp_size_is_exact);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xz_dec_microlzma_run() - Run the MicroLZMA decoder
|
||||||
|
* @s: Decoder state initialized using xz_dec_microlzma_reset()
|
||||||
|
* @b: Input and output buffers
|
||||||
|
*
|
||||||
|
* This works similarly to xz_dec_run() with a few important differences.
|
||||||
|
* Only the differences are documented here.
|
||||||
|
*
|
||||||
|
* The only possible return values are XZ_OK, XZ_STREAM_END, and
|
||||||
|
* XZ_DATA_ERROR. This function cannot return XZ_BUF_ERROR: if no progress
|
||||||
|
* is possible due to lack of input data or output space, this function will
|
||||||
|
* keep returning XZ_OK. Thus, the calling code must be written so that it
|
||||||
|
* will eventually provide input and output space matching (or exceeding)
|
||||||
|
* comp_size and uncomp_size arguments given to xz_dec_microlzma_reset().
|
||||||
|
* If the caller cannot do this (for example, if the input file is truncated
|
||||||
|
* or otherwise corrupt), the caller must detect this error by itself to
|
||||||
|
* avoid an infinite loop.
|
||||||
|
*
|
||||||
|
* If the compressed data seems to be corrupt, XZ_DATA_ERROR is returned.
|
||||||
|
* This can happen also when incorrect dictionary, uncompressed, or
|
||||||
|
* compressed sizes have been specified.
|
||||||
|
*
|
||||||
|
* With XZ_PREALLOC only: As an extra feature, b->out may be NULL to skip over
|
||||||
|
* uncompressed data. This way the caller doesn't need to provide a temporary
|
||||||
|
* output buffer for the bytes that will be ignored.
|
||||||
|
*
|
||||||
|
* With XZ_SINGLE only: In contrast to xz_dec_run(), the return value XZ_OK
|
||||||
|
* is also possible and thus XZ_SINGLE is actually a limited multi-call mode.
|
||||||
|
* After XZ_OK the bytes decoded so far may be read from the output buffer.
|
||||||
|
* It is possible to continue decoding but the variables b->out and b->out_pos
|
||||||
|
* MUST NOT be changed by the caller. Increasing the value of b->out_size is
|
||||||
|
* allowed to make more output space available; one doesn't need to provide
|
||||||
|
* space for the whole uncompressed data on the first call. The input buffer
|
||||||
|
* may be changed normally like with XZ_PREALLOC. This way input data can be
|
||||||
|
* provided from non-contiguous memory.
|
||||||
|
*/
|
||||||
|
XZ_EXTERN enum xz_ret xz_dec_microlzma_run(struct xz_dec_microlzma *s,
|
||||||
|
struct xz_buf *b);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xz_dec_microlzma_end() - Free the memory allocated for the decoder state
|
||||||
|
* @s: Decoder state allocated using xz_dec_microlzma_alloc().
|
||||||
|
* If s is NULL, this function does nothing.
|
||||||
|
*/
|
||||||
|
XZ_EXTERN void xz_dec_microlzma_end(struct xz_dec_microlzma *s);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Standalone build (userspace build or in-kernel build for boot time use)
|
||||||
|
* needs a CRC32 implementation. For normal in-kernel use, kernel's own
|
||||||
|
* CRC32 module is used instead, and users of this module don't need to
|
||||||
|
* care about the functions below.
|
||||||
|
*/
|
||||||
|
#ifndef XZ_INTERNAL_CRC32
|
||||||
|
# ifdef __KERNEL__
|
||||||
|
# define XZ_INTERNAL_CRC32 0
|
||||||
|
# else
|
||||||
|
# define XZ_INTERNAL_CRC32 1
|
||||||
|
# endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If CRC64 support has been enabled with XZ_USE_CRC64, a CRC64
|
||||||
|
* implementation is needed too.
|
||||||
|
*/
|
||||||
|
#ifndef XZ_USE_CRC64
|
||||||
|
# undef XZ_INTERNAL_CRC64
|
||||||
|
# define XZ_INTERNAL_CRC64 0
|
||||||
|
#endif
|
||||||
|
#ifndef XZ_INTERNAL_CRC64
|
||||||
|
# ifdef __KERNEL__
|
||||||
|
# error Using CRC64 in the kernel has not been implemented.
|
||||||
|
# else
|
||||||
|
# define XZ_INTERNAL_CRC64 1
|
||||||
|
# endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if XZ_INTERNAL_CRC32
|
||||||
|
/*
|
||||||
|
* This must be called before any other xz_* function to initialize
|
||||||
|
* the CRC32 lookup table.
|
||||||
|
*/
|
||||||
|
XZ_EXTERN void xz_crc32_init(void);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Update CRC32 value using the polynomial from IEEE-802.3. To start a new
|
||||||
|
* calculation, the third argument must be zero. To continue the calculation,
|
||||||
|
* the previously returned value is passed as the third argument.
|
||||||
|
*/
|
||||||
|
XZ_EXTERN uint32_t xz_crc32(const uint8_t *buf, size_t size, uint32_t crc);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if XZ_INTERNAL_CRC64
|
||||||
|
/*
|
||||||
|
* This must be called before any other xz_* function (except xz_crc32_init())
|
||||||
|
* to initialize the CRC64 lookup table.
|
||||||
|
*/
|
||||||
|
XZ_EXTERN void xz_crc64_init(void);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Update CRC64 value using the polynomial from ECMA-182. To start a new
|
||||||
|
* calculation, the third argument must be zero. To continue the calculation,
|
||||||
|
* the previously returned value is passed as the third argument.
|
||||||
|
*/
|
||||||
|
XZ_EXTERN uint64_t xz_crc64(const uint8_t *buf, size_t size, uint64_t crc);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/* SPDX-License-Identifier: 0BSD */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Private includes and definitions for userspace use of XZ Embedded
|
||||||
|
*
|
||||||
|
* Author: Lasse Collin <lasse.collin@tukaani.org>
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef XZ_CONFIG_H
|
||||||
|
#define XZ_CONFIG_H
|
||||||
|
|
||||||
|
/* Uncomment to enable building of xz_dec_catrun(). */
|
||||||
|
/* #define XZ_DEC_CONCATENATED */
|
||||||
|
|
||||||
|
/* Uncomment to enable CRC64 support. */
|
||||||
|
/* #define XZ_USE_CRC64 */
|
||||||
|
|
||||||
|
/* Uncomment as needed to enable BCJ filter decoders. */
|
||||||
|
/* #define XZ_DEC_X86 */
|
||||||
|
/* #define XZ_DEC_ARM */
|
||||||
|
/* #define XZ_DEC_ARMTHUMB */
|
||||||
|
/* #define XZ_DEC_ARM64 */
|
||||||
|
/* #define XZ_DEC_RISCV */
|
||||||
|
/* #define XZ_DEC_POWERPC */
|
||||||
|
/* #define XZ_DEC_IA64 */
|
||||||
|
/* #define XZ_DEC_SPARC */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Visual Studio 2013 update 2 supports only __inline, not inline.
|
||||||
|
* MSVC v19.0 / VS 2015 and newer support both.
|
||||||
|
*/
|
||||||
|
#if defined(_MSC_VER) && _MSC_VER < 1900 && !defined(inline)
|
||||||
|
# define inline __inline
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "xz.h"
|
||||||
|
|
||||||
|
#define kmalloc(size, flags) malloc(size)
|
||||||
|
#define kfree(ptr) free(ptr)
|
||||||
|
#define vmalloc(size) malloc(size)
|
||||||
|
#define vfree(ptr) free(ptr)
|
||||||
|
|
||||||
|
#define memeq(a, b, size) (memcmp(a, b, size) == 0)
|
||||||
|
#define memzero(buf, size) memset(buf, 0, size)
|
||||||
|
|
||||||
|
#ifndef min
|
||||||
|
# define min(x, y) ((x) < (y) ? (x) : (y))
|
||||||
|
#endif
|
||||||
|
#define min_t(type, x, y) min(x, y)
|
||||||
|
|
||||||
|
#ifndef fallthrough
|
||||||
|
# if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311
|
||||||
|
# define fallthrough [[fallthrough]]
|
||||||
|
# elif (defined(__GNUC__) && __GNUC__ >= 7) \
|
||||||
|
|| (defined(__clang_major__) && __clang_major__ >= 10)
|
||||||
|
# define fallthrough __attribute__((__fallthrough__))
|
||||||
|
# else
|
||||||
|
# define fallthrough do {} while (0)
|
||||||
|
# endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Some functions have been marked with __always_inline to keep the
|
||||||
|
* performance reasonable even when the compiler is optimizing for
|
||||||
|
* small code size. You may be able to save a few bytes by #defining
|
||||||
|
* __always_inline to plain inline, but don't complain if the code
|
||||||
|
* becomes slow.
|
||||||
|
*
|
||||||
|
* NOTE: System headers on GNU/Linux may #define this macro already,
|
||||||
|
* so if you want to change it, you need to #undef it first.
|
||||||
|
*/
|
||||||
|
#ifndef __always_inline
|
||||||
|
# ifdef __GNUC__
|
||||||
|
# define __always_inline \
|
||||||
|
inline __attribute__((__always_inline__))
|
||||||
|
# else
|
||||||
|
# define __always_inline inline
|
||||||
|
# endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Inline functions to access unaligned unsigned 32-bit integers */
|
||||||
|
#ifndef get_unaligned_le32
|
||||||
|
static inline uint32_t get_unaligned_le32(const uint8_t *buf)
|
||||||
|
{
|
||||||
|
return (uint32_t)buf[0]
|
||||||
|
| ((uint32_t)buf[1] << 8)
|
||||||
|
| ((uint32_t)buf[2] << 16)
|
||||||
|
| ((uint32_t)buf[3] << 24);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef get_unaligned_be32
|
||||||
|
static inline uint32_t get_unaligned_be32(const uint8_t *buf)
|
||||||
|
{
|
||||||
|
return (uint32_t)((uint32_t)buf[0] << 24)
|
||||||
|
| ((uint32_t)buf[1] << 16)
|
||||||
|
| ((uint32_t)buf[2] << 8)
|
||||||
|
| (uint32_t)buf[3];
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef put_unaligned_le32
|
||||||
|
static inline void put_unaligned_le32(uint32_t val, uint8_t *buf)
|
||||||
|
{
|
||||||
|
buf[0] = (uint8_t)val;
|
||||||
|
buf[1] = (uint8_t)(val >> 8);
|
||||||
|
buf[2] = (uint8_t)(val >> 16);
|
||||||
|
buf[3] = (uint8_t)(val >> 24);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef put_unaligned_be32
|
||||||
|
static inline void put_unaligned_be32(uint32_t val, uint8_t *buf)
|
||||||
|
{
|
||||||
|
buf[0] = (uint8_t)(val >> 24);
|
||||||
|
buf[1] = (uint8_t)(val >> 16);
|
||||||
|
buf[2] = (uint8_t)(val >> 8);
|
||||||
|
buf[3] = (uint8_t)val;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*
|
||||||
|
* To keep things simpler, use the generic unaligned methods also for
|
||||||
|
* aligned access. The only place where performance could matter is
|
||||||
|
* SHA-256 but files using SHA-256 aren't common.
|
||||||
|
*/
|
||||||
|
#ifndef get_le32
|
||||||
|
# define get_le32 get_unaligned_le32
|
||||||
|
#endif
|
||||||
|
#ifndef get_be32
|
||||||
|
# define get_be32 get_unaligned_be32
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// SPDX-License-Identifier: 0BSD
|
||||||
|
|
||||||
|
/*
|
||||||
|
* CRC32 using the polynomial from IEEE-802.3
|
||||||
|
*
|
||||||
|
* Authors: Lasse Collin <lasse.collin@tukaani.org>
|
||||||
|
* Igor Pavlov <https://7-zip.org/>
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This is not the fastest implementation, but it is pretty compact.
|
||||||
|
* The fastest versions of xz_crc32() on modern CPUs without hardware
|
||||||
|
* accelerated CRC instruction are 3-5 times as fast as this version,
|
||||||
|
* but they are bigger and use more memory for the lookup table.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "xz_private.h"
|
||||||
|
|
||||||
|
/*
|
||||||
|
* STATIC_RW_DATA is used in the pre-boot environment on some architectures.
|
||||||
|
* See <linux/decompress/mm.h> for details.
|
||||||
|
*/
|
||||||
|
#ifndef STATIC_RW_DATA
|
||||||
|
# define STATIC_RW_DATA static
|
||||||
|
#endif
|
||||||
|
|
||||||
|
STATIC_RW_DATA uint32_t xz_crc32_table[256];
|
||||||
|
|
||||||
|
XZ_EXTERN void xz_crc32_init(void)
|
||||||
|
{
|
||||||
|
const uint32_t poly = 0xEDB88320;
|
||||||
|
|
||||||
|
uint32_t i;
|
||||||
|
uint32_t j;
|
||||||
|
uint32_t r;
|
||||||
|
|
||||||
|
for (i = 0; i < 256; ++i) {
|
||||||
|
r = i;
|
||||||
|
for (j = 0; j < 8; ++j)
|
||||||
|
r = (r >> 1) ^ (poly & ~((r & 1) - 1));
|
||||||
|
|
||||||
|
xz_crc32_table[i] = r;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
XZ_EXTERN uint32_t xz_crc32(const uint8_t *buf, size_t size, uint32_t crc)
|
||||||
|
{
|
||||||
|
crc = ~crc;
|
||||||
|
|
||||||
|
while (size != 0) {
|
||||||
|
crc = xz_crc32_table[*buf++ ^ (crc & 0xFF)] ^ (crc >> 8);
|
||||||
|
--size;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ~crc;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// SPDX-License-Identifier: 0BSD
|
||||||
|
|
||||||
|
/*
|
||||||
|
* CRC64 using the polynomial from ECMA-182
|
||||||
|
*
|
||||||
|
* This file is similar to xz_crc32.c. See the comments there.
|
||||||
|
*
|
||||||
|
* Authors: Lasse Collin <lasse.collin@tukaani.org>
|
||||||
|
* Igor Pavlov <https://7-zip.org/>
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "xz_private.h"
|
||||||
|
|
||||||
|
#ifndef STATIC_RW_DATA
|
||||||
|
# define STATIC_RW_DATA static
|
||||||
|
#endif
|
||||||
|
|
||||||
|
STATIC_RW_DATA uint64_t xz_crc64_table[256];
|
||||||
|
|
||||||
|
XZ_EXTERN void xz_crc64_init(void)
|
||||||
|
{
|
||||||
|
/*
|
||||||
|
* The ULL suffix is needed for -std=gnu89 compatibility
|
||||||
|
* on 32-bit platforms.
|
||||||
|
*/
|
||||||
|
const uint64_t poly = 0xC96C5795D7870F42ULL;
|
||||||
|
|
||||||
|
uint32_t i;
|
||||||
|
uint32_t j;
|
||||||
|
uint64_t r;
|
||||||
|
|
||||||
|
for (i = 0; i < 256; ++i) {
|
||||||
|
r = i;
|
||||||
|
for (j = 0; j < 8; ++j)
|
||||||
|
r = (r >> 1) ^ (poly & ~((r & 1) - 1));
|
||||||
|
|
||||||
|
xz_crc64_table[i] = r;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
XZ_EXTERN uint64_t xz_crc64(const uint8_t *buf, size_t size, uint64_t crc)
|
||||||
|
{
|
||||||
|
crc = ~crc;
|
||||||
|
|
||||||
|
while (size != 0) {
|
||||||
|
crc = xz_crc64_table[*buf++ ^ (crc & 0xFF)] ^ (crc >> 8);
|
||||||
|
--size;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ~crc;
|
||||||
|
}
|
||||||
@@ -0,0 +1,738 @@
|
|||||||
|
// SPDX-License-Identifier: 0BSD
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Branch/Call/Jump (BCJ) filter decoders
|
||||||
|
*
|
||||||
|
* Authors: Lasse Collin <lasse.collin@tukaani.org>
|
||||||
|
* Igor Pavlov <https://7-zip.org/>
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "xz_private.h"
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The rest of the file is inside this ifdef. It makes things a little more
|
||||||
|
* convenient when building without support for any BCJ filters.
|
||||||
|
*/
|
||||||
|
#ifdef XZ_DEC_BCJ
|
||||||
|
|
||||||
|
struct xz_dec_bcj {
|
||||||
|
/* Type of the BCJ filter being used */
|
||||||
|
enum {
|
||||||
|
BCJ_X86 = 4, /* x86 or x86-64 */
|
||||||
|
BCJ_POWERPC = 5, /* Big endian only */
|
||||||
|
BCJ_IA64 = 6, /* Big or little endian */
|
||||||
|
BCJ_ARM = 7, /* Little endian only */
|
||||||
|
BCJ_ARMTHUMB = 8, /* Little endian only */
|
||||||
|
BCJ_SPARC = 9, /* Big or little endian */
|
||||||
|
BCJ_ARM64 = 10, /* AArch64 */
|
||||||
|
BCJ_RISCV = 11 /* RV32GQC_Zfh, RV64GQC_Zfh */
|
||||||
|
} type;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return value of the next filter in the chain. We need to preserve
|
||||||
|
* this information across calls, because we must not call the next
|
||||||
|
* filter anymore once it has returned XZ_STREAM_END.
|
||||||
|
*/
|
||||||
|
enum xz_ret ret;
|
||||||
|
|
||||||
|
/* True if we are operating in single-call mode. */
|
||||||
|
bool single_call;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Absolute position relative to the beginning of the uncompressed
|
||||||
|
* data (in a single .xz Block). We care only about the lowest 32
|
||||||
|
* bits so this doesn't need to be uint64_t even with big files.
|
||||||
|
*/
|
||||||
|
uint32_t pos;
|
||||||
|
|
||||||
|
/* x86 filter state */
|
||||||
|
uint32_t x86_prev_mask;
|
||||||
|
|
||||||
|
/* Temporary space to hold the variables from struct xz_buf */
|
||||||
|
uint8_t *out;
|
||||||
|
size_t out_pos;
|
||||||
|
size_t out_size;
|
||||||
|
|
||||||
|
struct {
|
||||||
|
/* Amount of already filtered data in the beginning of buf */
|
||||||
|
size_t filtered;
|
||||||
|
|
||||||
|
/* Total amount of data currently stored in buf */
|
||||||
|
size_t size;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Buffer to hold a mix of filtered and unfiltered data. This
|
||||||
|
* needs to be big enough to hold Alignment + 2 * Look-ahead:
|
||||||
|
*
|
||||||
|
* Type Alignment Look-ahead
|
||||||
|
* x86 1 4
|
||||||
|
* PowerPC 4 0
|
||||||
|
* IA-64 16 0
|
||||||
|
* ARM 4 0
|
||||||
|
* ARM-Thumb 2 2
|
||||||
|
* SPARC 4 0
|
||||||
|
*/
|
||||||
|
uint8_t buf[16];
|
||||||
|
} temp;
|
||||||
|
};
|
||||||
|
|
||||||
|
#ifdef XZ_DEC_X86
|
||||||
|
/*
|
||||||
|
* This is used to test the most significant byte of a memory address
|
||||||
|
* in an x86 instruction.
|
||||||
|
*/
|
||||||
|
static inline int bcj_x86_test_msbyte(uint8_t b)
|
||||||
|
{
|
||||||
|
return b == 0x00 || b == 0xFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
static size_t bcj_x86(struct xz_dec_bcj *s, uint8_t *buf, size_t size)
|
||||||
|
{
|
||||||
|
static const bool mask_to_allowed_status[8]
|
||||||
|
= { true, true, true, false, true, false, false, false };
|
||||||
|
|
||||||
|
static const uint8_t mask_to_bit_num[8] = { 0, 1, 2, 2, 3, 3, 3, 3 };
|
||||||
|
|
||||||
|
size_t i;
|
||||||
|
size_t prev_pos = (size_t)-1;
|
||||||
|
uint32_t prev_mask = s->x86_prev_mask;
|
||||||
|
uint32_t src;
|
||||||
|
uint32_t dest;
|
||||||
|
uint32_t j;
|
||||||
|
uint8_t b;
|
||||||
|
|
||||||
|
if (size <= 4)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
size -= 4;
|
||||||
|
for (i = 0; i < size; ++i) {
|
||||||
|
if ((buf[i] & 0xFE) != 0xE8)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
prev_pos = i - prev_pos;
|
||||||
|
if (prev_pos > 3) {
|
||||||
|
prev_mask = 0;
|
||||||
|
} else {
|
||||||
|
prev_mask = (prev_mask << (prev_pos - 1)) & 7;
|
||||||
|
if (prev_mask != 0) {
|
||||||
|
b = buf[i + 4 - mask_to_bit_num[prev_mask]];
|
||||||
|
if (!mask_to_allowed_status[prev_mask]
|
||||||
|
|| bcj_x86_test_msbyte(b)) {
|
||||||
|
prev_pos = i;
|
||||||
|
prev_mask = (prev_mask << 1) | 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prev_pos = i;
|
||||||
|
|
||||||
|
if (bcj_x86_test_msbyte(buf[i + 4])) {
|
||||||
|
src = get_unaligned_le32(buf + i + 1);
|
||||||
|
while (true) {
|
||||||
|
dest = src - (s->pos + (uint32_t)i + 5);
|
||||||
|
if (prev_mask == 0)
|
||||||
|
break;
|
||||||
|
|
||||||
|
j = mask_to_bit_num[prev_mask] * 8;
|
||||||
|
b = (uint8_t)(dest >> (24 - j));
|
||||||
|
if (!bcj_x86_test_msbyte(b))
|
||||||
|
break;
|
||||||
|
|
||||||
|
src = dest ^ (((uint32_t)1 << (32 - j)) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
dest &= 0x01FFFFFF;
|
||||||
|
dest |= (uint32_t)0 - (dest & 0x01000000);
|
||||||
|
put_unaligned_le32(dest, buf + i + 1);
|
||||||
|
i += 4;
|
||||||
|
} else {
|
||||||
|
prev_mask = (prev_mask << 1) | 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prev_pos = i - prev_pos;
|
||||||
|
s->x86_prev_mask = prev_pos > 3 ? 0 : prev_mask << (prev_pos - 1);
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef XZ_DEC_POWERPC
|
||||||
|
static size_t bcj_powerpc(struct xz_dec_bcj *s, uint8_t *buf, size_t size)
|
||||||
|
{
|
||||||
|
size_t i;
|
||||||
|
uint32_t instr;
|
||||||
|
|
||||||
|
size &= ~(size_t)3;
|
||||||
|
|
||||||
|
for (i = 0; i < size; i += 4) {
|
||||||
|
instr = get_unaligned_be32(buf + i);
|
||||||
|
if ((instr & 0xFC000003) == 0x48000001) {
|
||||||
|
instr &= 0x03FFFFFC;
|
||||||
|
instr -= s->pos + (uint32_t)i;
|
||||||
|
instr &= 0x03FFFFFC;
|
||||||
|
instr |= 0x48000001;
|
||||||
|
put_unaligned_be32(instr, buf + i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef XZ_DEC_IA64
|
||||||
|
static size_t bcj_ia64(struct xz_dec_bcj *s, uint8_t *buf, size_t size)
|
||||||
|
{
|
||||||
|
static const uint8_t branch_table[32] = {
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
4, 4, 6, 6, 0, 0, 7, 7,
|
||||||
|
4, 4, 0, 0, 4, 4, 0, 0
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The local variables take a little bit stack space, but it's less
|
||||||
|
* than what LZMA2 decoder takes, so it doesn't make sense to reduce
|
||||||
|
* stack usage here without doing that for the LZMA2 decoder too.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Loop counters */
|
||||||
|
size_t i;
|
||||||
|
size_t j;
|
||||||
|
|
||||||
|
/* Instruction slot (0, 1, or 2) in the 128-bit instruction word */
|
||||||
|
uint32_t slot;
|
||||||
|
|
||||||
|
/* Bitwise offset of the instruction indicated by slot */
|
||||||
|
uint32_t bit_pos;
|
||||||
|
|
||||||
|
/* bit_pos split into byte and bit parts */
|
||||||
|
uint32_t byte_pos;
|
||||||
|
uint32_t bit_res;
|
||||||
|
|
||||||
|
/* Address part of an instruction */
|
||||||
|
uint32_t addr;
|
||||||
|
|
||||||
|
/* Mask used to detect which instructions to convert */
|
||||||
|
uint32_t mask;
|
||||||
|
|
||||||
|
/* 41-bit instruction stored somewhere in the lowest 48 bits */
|
||||||
|
uint64_t instr;
|
||||||
|
|
||||||
|
/* Instruction normalized with bit_res for easier manipulation */
|
||||||
|
uint64_t norm;
|
||||||
|
|
||||||
|
size &= ~(size_t)15;
|
||||||
|
|
||||||
|
for (i = 0; i < size; i += 16) {
|
||||||
|
mask = branch_table[buf[i] & 0x1F];
|
||||||
|
for (slot = 0, bit_pos = 5; slot < 3; ++slot, bit_pos += 41) {
|
||||||
|
if (((mask >> slot) & 1) == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
byte_pos = bit_pos >> 3;
|
||||||
|
bit_res = bit_pos & 7;
|
||||||
|
instr = 0;
|
||||||
|
for (j = 0; j < 6; ++j)
|
||||||
|
instr |= (uint64_t)(buf[i + j + byte_pos])
|
||||||
|
<< (8 * j);
|
||||||
|
|
||||||
|
norm = instr >> bit_res;
|
||||||
|
|
||||||
|
if (((norm >> 37) & 0x0F) == 0x05
|
||||||
|
&& ((norm >> 9) & 0x07) == 0) {
|
||||||
|
addr = (norm >> 13) & 0x0FFFFF;
|
||||||
|
addr |= ((uint32_t)(norm >> 36) & 1) << 20;
|
||||||
|
addr <<= 4;
|
||||||
|
addr -= s->pos + (uint32_t)i;
|
||||||
|
addr >>= 4;
|
||||||
|
|
||||||
|
norm &= ~((uint64_t)0x8FFFFF << 13);
|
||||||
|
norm |= (uint64_t)(addr & 0x0FFFFF) << 13;
|
||||||
|
norm |= (uint64_t)(addr & 0x100000)
|
||||||
|
<< (36 - 20);
|
||||||
|
|
||||||
|
instr &= (1 << bit_res) - 1;
|
||||||
|
instr |= norm << bit_res;
|
||||||
|
|
||||||
|
for (j = 0; j < 6; j++)
|
||||||
|
buf[i + j + byte_pos]
|
||||||
|
= (uint8_t)(instr >> (8 * j));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef XZ_DEC_ARM
|
||||||
|
static size_t bcj_arm(struct xz_dec_bcj *s, uint8_t *buf, size_t size)
|
||||||
|
{
|
||||||
|
size_t i;
|
||||||
|
uint32_t addr;
|
||||||
|
|
||||||
|
size &= ~(size_t)3;
|
||||||
|
|
||||||
|
for (i = 0; i < size; i += 4) {
|
||||||
|
if (buf[i + 3] == 0xEB) {
|
||||||
|
addr = (uint32_t)buf[i] | ((uint32_t)buf[i + 1] << 8)
|
||||||
|
| ((uint32_t)buf[i + 2] << 16);
|
||||||
|
addr <<= 2;
|
||||||
|
addr -= s->pos + (uint32_t)i + 8;
|
||||||
|
addr >>= 2;
|
||||||
|
buf[i] = (uint8_t)addr;
|
||||||
|
buf[i + 1] = (uint8_t)(addr >> 8);
|
||||||
|
buf[i + 2] = (uint8_t)(addr >> 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef XZ_DEC_ARMTHUMB
|
||||||
|
static size_t bcj_armthumb(struct xz_dec_bcj *s, uint8_t *buf, size_t size)
|
||||||
|
{
|
||||||
|
size_t i;
|
||||||
|
uint32_t addr;
|
||||||
|
|
||||||
|
if (size < 4)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
size -= 4;
|
||||||
|
|
||||||
|
for (i = 0; i <= size; i += 2) {
|
||||||
|
if ((buf[i + 1] & 0xF8) == 0xF0
|
||||||
|
&& (buf[i + 3] & 0xF8) == 0xF8) {
|
||||||
|
addr = (((uint32_t)buf[i + 1] & 0x07) << 19)
|
||||||
|
| ((uint32_t)buf[i] << 11)
|
||||||
|
| (((uint32_t)buf[i + 3] & 0x07) << 8)
|
||||||
|
| (uint32_t)buf[i + 2];
|
||||||
|
addr <<= 1;
|
||||||
|
addr -= s->pos + (uint32_t)i + 4;
|
||||||
|
addr >>= 1;
|
||||||
|
buf[i + 1] = (uint8_t)(0xF0 | ((addr >> 19) & 0x07));
|
||||||
|
buf[i] = (uint8_t)(addr >> 11);
|
||||||
|
buf[i + 3] = (uint8_t)(0xF8 | ((addr >> 8) & 0x07));
|
||||||
|
buf[i + 2] = (uint8_t)addr;
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef XZ_DEC_SPARC
|
||||||
|
static size_t bcj_sparc(struct xz_dec_bcj *s, uint8_t *buf, size_t size)
|
||||||
|
{
|
||||||
|
size_t i;
|
||||||
|
uint32_t instr;
|
||||||
|
|
||||||
|
size &= ~(size_t)3;
|
||||||
|
|
||||||
|
for (i = 0; i < size; i += 4) {
|
||||||
|
instr = get_unaligned_be32(buf + i);
|
||||||
|
if ((instr >> 22) == 0x100 || (instr >> 22) == 0x1FF) {
|
||||||
|
instr <<= 2;
|
||||||
|
instr -= s->pos + (uint32_t)i;
|
||||||
|
instr >>= 2;
|
||||||
|
instr = ((uint32_t)0x40000000 - (instr & 0x400000))
|
||||||
|
| 0x40000000 | (instr & 0x3FFFFF);
|
||||||
|
put_unaligned_be32(instr, buf + i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef XZ_DEC_ARM64
|
||||||
|
static size_t bcj_arm64(struct xz_dec_bcj *s, uint8_t *buf, size_t size)
|
||||||
|
{
|
||||||
|
size_t i;
|
||||||
|
uint32_t instr;
|
||||||
|
uint32_t addr;
|
||||||
|
|
||||||
|
size &= ~(size_t)3;
|
||||||
|
|
||||||
|
for (i = 0; i < size; i += 4) {
|
||||||
|
instr = get_unaligned_le32(buf + i);
|
||||||
|
|
||||||
|
if ((instr >> 26) == 0x25) {
|
||||||
|
/* BL instruction */
|
||||||
|
addr = instr - ((s->pos + (uint32_t)i) >> 2);
|
||||||
|
instr = 0x94000000 | (addr & 0x03FFFFFF);
|
||||||
|
put_unaligned_le32(instr, buf + i);
|
||||||
|
|
||||||
|
} else if ((instr & 0x9F000000) == 0x90000000) {
|
||||||
|
/* ADRP instruction */
|
||||||
|
addr = ((instr >> 29) & 3) | ((instr >> 3) & 0x1FFFFC);
|
||||||
|
|
||||||
|
/* Only convert values in the range +/-512 MiB. */
|
||||||
|
if ((addr + 0x020000) & 0x1C0000)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
addr -= (s->pos + (uint32_t)i) >> 12;
|
||||||
|
|
||||||
|
instr &= 0x9000001F;
|
||||||
|
instr |= (addr & 3) << 29;
|
||||||
|
instr |= (addr & 0x03FFFC) << 3;
|
||||||
|
instr |= (0U - (addr & 0x020000)) & 0xE00000;
|
||||||
|
|
||||||
|
put_unaligned_le32(instr, buf + i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef XZ_DEC_RISCV
|
||||||
|
static size_t bcj_riscv(struct xz_dec_bcj *s, uint8_t *buf, size_t size)
|
||||||
|
{
|
||||||
|
size_t i;
|
||||||
|
uint32_t b1;
|
||||||
|
uint32_t b2;
|
||||||
|
uint32_t b3;
|
||||||
|
uint32_t instr;
|
||||||
|
uint32_t instr2;
|
||||||
|
uint32_t instr2_rs1;
|
||||||
|
uint32_t addr;
|
||||||
|
|
||||||
|
if (size < 8)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
size -= 8;
|
||||||
|
|
||||||
|
for (i = 0; i <= size; i += 2) {
|
||||||
|
instr = buf[i];
|
||||||
|
|
||||||
|
if (instr == 0xEF) {
|
||||||
|
/* JAL */
|
||||||
|
b1 = buf[i + 1];
|
||||||
|
if ((b1 & 0x0D) != 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
b2 = buf[i + 2];
|
||||||
|
b3 = buf[i + 3];
|
||||||
|
|
||||||
|
addr = ((b1 & 0xF0) << 13) | (b2 << 9) | (b3 << 1);
|
||||||
|
addr -= s->pos + (uint32_t)i;
|
||||||
|
|
||||||
|
buf[i + 1] = (uint8_t)((b1 & 0x0F)
|
||||||
|
| ((addr >> 8) & 0xF0));
|
||||||
|
|
||||||
|
buf[i + 2] = (uint8_t)(((addr >> 16) & 0x0F)
|
||||||
|
| ((addr >> 7) & 0x10)
|
||||||
|
| ((addr << 4) & 0xE0));
|
||||||
|
|
||||||
|
buf[i + 3] = (uint8_t)(((addr >> 4) & 0x7F)
|
||||||
|
| ((addr >> 13) & 0x80));
|
||||||
|
|
||||||
|
i += 4 - 2;
|
||||||
|
|
||||||
|
} else if ((instr & 0x7F) == 0x17) {
|
||||||
|
/* AUIPC */
|
||||||
|
instr |= (uint32_t)buf[i + 1] << 8;
|
||||||
|
instr |= (uint32_t)buf[i + 2] << 16;
|
||||||
|
instr |= (uint32_t)buf[i + 3] << 24;
|
||||||
|
|
||||||
|
if (instr & 0xE80) {
|
||||||
|
/* AUIPC's rd doesn't equal x0 or x2. */
|
||||||
|
instr2 = get_unaligned_le32(buf + i + 4);
|
||||||
|
|
||||||
|
if (((instr << 8) ^ (instr2 - 3)) & 0xF8003) {
|
||||||
|
i += 6 - 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
addr = (instr & 0xFFFFF000) + (instr2 >> 20);
|
||||||
|
|
||||||
|
instr = 0x17 | (2 << 7) | (instr2 << 12);
|
||||||
|
instr2 = addr;
|
||||||
|
} else {
|
||||||
|
/* AUIPC's rd equals x0 or x2. */
|
||||||
|
instr2_rs1 = instr >> 27;
|
||||||
|
|
||||||
|
if ((uint32_t)((instr - 0x3117) << 18)
|
||||||
|
>= (instr2_rs1 & 0x1D)) {
|
||||||
|
i += 4 - 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
addr = get_unaligned_be32(buf + i + 4);
|
||||||
|
addr -= s->pos + (uint32_t)i;
|
||||||
|
|
||||||
|
instr2 = (instr >> 12) | (addr << 20);
|
||||||
|
|
||||||
|
instr = 0x17 | (instr2_rs1 << 7)
|
||||||
|
| ((addr + 0x800) & 0xFFFFF000);
|
||||||
|
}
|
||||||
|
|
||||||
|
put_unaligned_le32(instr, buf + i);
|
||||||
|
put_unaligned_le32(instr2, buf + i + 4);
|
||||||
|
|
||||||
|
i += 8 - 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Apply the selected BCJ filter. Update *pos and s->pos to match the amount
|
||||||
|
* of data that got filtered.
|
||||||
|
*
|
||||||
|
* NOTE: This is implemented as a switch statement to avoid using function
|
||||||
|
* pointers, which could be problematic in the kernel boot code, which must
|
||||||
|
* avoid pointers to static data (at least on x86).
|
||||||
|
*/
|
||||||
|
static void bcj_apply(struct xz_dec_bcj *s,
|
||||||
|
uint8_t *buf, size_t *pos, size_t size)
|
||||||
|
{
|
||||||
|
size_t filtered;
|
||||||
|
|
||||||
|
buf += *pos;
|
||||||
|
size -= *pos;
|
||||||
|
|
||||||
|
switch (s->type) {
|
||||||
|
#ifdef XZ_DEC_X86
|
||||||
|
case BCJ_X86:
|
||||||
|
filtered = bcj_x86(s, buf, size);
|
||||||
|
break;
|
||||||
|
#endif
|
||||||
|
#ifdef XZ_DEC_POWERPC
|
||||||
|
case BCJ_POWERPC:
|
||||||
|
filtered = bcj_powerpc(s, buf, size);
|
||||||
|
break;
|
||||||
|
#endif
|
||||||
|
#ifdef XZ_DEC_IA64
|
||||||
|
case BCJ_IA64:
|
||||||
|
filtered = bcj_ia64(s, buf, size);
|
||||||
|
break;
|
||||||
|
#endif
|
||||||
|
#ifdef XZ_DEC_ARM
|
||||||
|
case BCJ_ARM:
|
||||||
|
filtered = bcj_arm(s, buf, size);
|
||||||
|
break;
|
||||||
|
#endif
|
||||||
|
#ifdef XZ_DEC_ARMTHUMB
|
||||||
|
case BCJ_ARMTHUMB:
|
||||||
|
filtered = bcj_armthumb(s, buf, size);
|
||||||
|
break;
|
||||||
|
#endif
|
||||||
|
#ifdef XZ_DEC_SPARC
|
||||||
|
case BCJ_SPARC:
|
||||||
|
filtered = bcj_sparc(s, buf, size);
|
||||||
|
break;
|
||||||
|
#endif
|
||||||
|
#ifdef XZ_DEC_ARM64
|
||||||
|
case BCJ_ARM64:
|
||||||
|
filtered = bcj_arm64(s, buf, size);
|
||||||
|
break;
|
||||||
|
#endif
|
||||||
|
#ifdef XZ_DEC_RISCV
|
||||||
|
case BCJ_RISCV:
|
||||||
|
filtered = bcj_riscv(s, buf, size);
|
||||||
|
break;
|
||||||
|
#endif
|
||||||
|
default:
|
||||||
|
/* Never reached but silence compiler warnings. */
|
||||||
|
filtered = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
*pos += filtered;
|
||||||
|
s->pos += filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Flush pending filtered data from temp to the output buffer.
|
||||||
|
* Move the remaining mixture of possibly filtered and unfiltered
|
||||||
|
* data to the beginning of temp.
|
||||||
|
*/
|
||||||
|
static void bcj_flush(struct xz_dec_bcj *s, struct xz_buf *b)
|
||||||
|
{
|
||||||
|
size_t copy_size;
|
||||||
|
|
||||||
|
copy_size = min_t(size_t, s->temp.filtered, b->out_size - b->out_pos);
|
||||||
|
memcpy(b->out + b->out_pos, s->temp.buf, copy_size);
|
||||||
|
b->out_pos += copy_size;
|
||||||
|
|
||||||
|
s->temp.filtered -= copy_size;
|
||||||
|
s->temp.size -= copy_size;
|
||||||
|
memmove(s->temp.buf, s->temp.buf + copy_size, s->temp.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The BCJ filter functions are primitive in sense that they process the
|
||||||
|
* data in chunks of 1-16 bytes. To hide this issue, this function does
|
||||||
|
* some buffering.
|
||||||
|
*/
|
||||||
|
XZ_EXTERN enum xz_ret xz_dec_bcj_run(struct xz_dec_bcj *s,
|
||||||
|
struct xz_dec_lzma2 *lzma2,
|
||||||
|
struct xz_buf *b)
|
||||||
|
{
|
||||||
|
size_t out_start;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Flush pending already filtered data to the output buffer. Return
|
||||||
|
* immediately if we couldn't flush everything, or if the next
|
||||||
|
* filter in the chain had already returned XZ_STREAM_END.
|
||||||
|
*/
|
||||||
|
if (s->temp.filtered > 0) {
|
||||||
|
bcj_flush(s, b);
|
||||||
|
if (s->temp.filtered > 0)
|
||||||
|
return XZ_OK;
|
||||||
|
|
||||||
|
if (s->ret == XZ_STREAM_END)
|
||||||
|
return XZ_STREAM_END;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If we have more output space than what is currently pending in
|
||||||
|
* temp, copy the unfiltered data from temp to the output buffer
|
||||||
|
* and try to fill the output buffer by decoding more data from the
|
||||||
|
* next filter in the chain. Apply the BCJ filter on the new data
|
||||||
|
* in the output buffer. If everything cannot be filtered, copy it
|
||||||
|
* to temp and rewind the output buffer position accordingly.
|
||||||
|
*
|
||||||
|
* This needs to be always run when temp.size == 0 to handle a special
|
||||||
|
* case where the output buffer is full and the next filter has no
|
||||||
|
* more output coming but hasn't returned XZ_STREAM_END yet.
|
||||||
|
*/
|
||||||
|
if (s->temp.size < b->out_size - b->out_pos || s->temp.size == 0) {
|
||||||
|
out_start = b->out_pos;
|
||||||
|
memcpy(b->out + b->out_pos, s->temp.buf, s->temp.size);
|
||||||
|
b->out_pos += s->temp.size;
|
||||||
|
|
||||||
|
s->ret = xz_dec_lzma2_run(lzma2, b);
|
||||||
|
if (s->ret != XZ_STREAM_END
|
||||||
|
&& (s->ret != XZ_OK || s->single_call))
|
||||||
|
return s->ret;
|
||||||
|
|
||||||
|
bcj_apply(s, b->out, &out_start, b->out_pos);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* As an exception, if the next filter returned XZ_STREAM_END,
|
||||||
|
* we can do that too, since the last few bytes that remain
|
||||||
|
* unfiltered are meant to remain unfiltered.
|
||||||
|
*/
|
||||||
|
if (s->ret == XZ_STREAM_END)
|
||||||
|
return XZ_STREAM_END;
|
||||||
|
|
||||||
|
s->temp.size = b->out_pos - out_start;
|
||||||
|
b->out_pos -= s->temp.size;
|
||||||
|
memcpy(s->temp.buf, b->out + b->out_pos, s->temp.size);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If there wasn't enough input to the next filter to fill
|
||||||
|
* the output buffer with unfiltered data, there's no point
|
||||||
|
* to try decoding more data to temp.
|
||||||
|
*/
|
||||||
|
if (b->out_pos + s->temp.size < b->out_size)
|
||||||
|
return XZ_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* We have unfiltered data in temp. If the output buffer isn't full
|
||||||
|
* yet, try to fill the temp buffer by decoding more data from the
|
||||||
|
* next filter. Apply the BCJ filter on temp. Then we hopefully can
|
||||||
|
* fill the actual output buffer by copying filtered data from temp.
|
||||||
|
* A mix of filtered and unfiltered data may be left in temp; it will
|
||||||
|
* be taken care on the next call to this function.
|
||||||
|
*/
|
||||||
|
if (b->out_pos < b->out_size) {
|
||||||
|
/* Make b->out{,_pos,_size} temporarily point to s->temp. */
|
||||||
|
s->out = b->out;
|
||||||
|
s->out_pos = b->out_pos;
|
||||||
|
s->out_size = b->out_size;
|
||||||
|
b->out = s->temp.buf;
|
||||||
|
b->out_pos = s->temp.size;
|
||||||
|
b->out_size = sizeof(s->temp.buf);
|
||||||
|
|
||||||
|
s->ret = xz_dec_lzma2_run(lzma2, b);
|
||||||
|
|
||||||
|
s->temp.size = b->out_pos;
|
||||||
|
b->out = s->out;
|
||||||
|
b->out_pos = s->out_pos;
|
||||||
|
b->out_size = s->out_size;
|
||||||
|
|
||||||
|
if (s->ret != XZ_OK && s->ret != XZ_STREAM_END)
|
||||||
|
return s->ret;
|
||||||
|
|
||||||
|
bcj_apply(s, s->temp.buf, &s->temp.filtered, s->temp.size);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If the next filter returned XZ_STREAM_END, we mark that
|
||||||
|
* everything is filtered, since the last unfiltered bytes
|
||||||
|
* of the stream are meant to be left as is.
|
||||||
|
*/
|
||||||
|
if (s->ret == XZ_STREAM_END)
|
||||||
|
s->temp.filtered = s->temp.size;
|
||||||
|
|
||||||
|
bcj_flush(s, b);
|
||||||
|
if (s->temp.filtered > 0)
|
||||||
|
return XZ_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
return s->ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
XZ_EXTERN struct xz_dec_bcj *xz_dec_bcj_create(bool single_call)
|
||||||
|
{
|
||||||
|
struct xz_dec_bcj *s = kmalloc(sizeof(*s), GFP_KERNEL);
|
||||||
|
if (s != NULL)
|
||||||
|
s->single_call = single_call;
|
||||||
|
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
XZ_EXTERN enum xz_ret xz_dec_bcj_reset(struct xz_dec_bcj *s, uint8_t id)
|
||||||
|
{
|
||||||
|
switch (id) {
|
||||||
|
#ifdef XZ_DEC_X86
|
||||||
|
case BCJ_X86:
|
||||||
|
#endif
|
||||||
|
#ifdef XZ_DEC_POWERPC
|
||||||
|
case BCJ_POWERPC:
|
||||||
|
#endif
|
||||||
|
#ifdef XZ_DEC_IA64
|
||||||
|
case BCJ_IA64:
|
||||||
|
#endif
|
||||||
|
#ifdef XZ_DEC_ARM
|
||||||
|
case BCJ_ARM:
|
||||||
|
#endif
|
||||||
|
#ifdef XZ_DEC_ARMTHUMB
|
||||||
|
case BCJ_ARMTHUMB:
|
||||||
|
#endif
|
||||||
|
#ifdef XZ_DEC_SPARC
|
||||||
|
case BCJ_SPARC:
|
||||||
|
#endif
|
||||||
|
#ifdef XZ_DEC_ARM64
|
||||||
|
case BCJ_ARM64:
|
||||||
|
#endif
|
||||||
|
#ifdef XZ_DEC_RISCV
|
||||||
|
case BCJ_RISCV:
|
||||||
|
#endif
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
/* Unsupported Filter ID */
|
||||||
|
return XZ_OPTIONS_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
s->type = id;
|
||||||
|
s->ret = XZ_OK;
|
||||||
|
s->pos = 0;
|
||||||
|
s->x86_prev_mask = 0;
|
||||||
|
s->temp.filtered = 0;
|
||||||
|
s->temp.size = 0;
|
||||||
|
|
||||||
|
return XZ_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,984 @@
|
|||||||
|
// SPDX-License-Identifier: 0BSD
|
||||||
|
|
||||||
|
/*
|
||||||
|
* .xz Stream decoder
|
||||||
|
*
|
||||||
|
* Author: Lasse Collin <lasse.collin@tukaani.org>
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "xz_private.h"
|
||||||
|
#include "xz_stream.h"
|
||||||
|
|
||||||
|
#ifdef XZ_USE_CRC64
|
||||||
|
# define IS_CRC64(check_type) ((check_type) == XZ_CHECK_CRC64)
|
||||||
|
#else
|
||||||
|
# define IS_CRC64(check_type) false
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef XZ_USE_SHA256
|
||||||
|
# define IS_SHA256(check_type) ((check_type) == XZ_CHECK_SHA256)
|
||||||
|
#else
|
||||||
|
# define IS_SHA256(check_type) false
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Hash used to validate the Index field */
|
||||||
|
struct xz_dec_hash {
|
||||||
|
vli_type unpadded;
|
||||||
|
vli_type uncompressed;
|
||||||
|
uint32_t crc32;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct xz_dec {
|
||||||
|
/* Position in dec_main() */
|
||||||
|
enum {
|
||||||
|
SEQ_STREAM_HEADER,
|
||||||
|
SEQ_BLOCK_START,
|
||||||
|
SEQ_BLOCK_HEADER,
|
||||||
|
SEQ_BLOCK_UNCOMPRESS,
|
||||||
|
SEQ_BLOCK_PADDING,
|
||||||
|
SEQ_BLOCK_CHECK,
|
||||||
|
SEQ_INDEX,
|
||||||
|
SEQ_INDEX_PADDING,
|
||||||
|
SEQ_INDEX_CRC32,
|
||||||
|
SEQ_STREAM_FOOTER,
|
||||||
|
SEQ_STREAM_PADDING
|
||||||
|
} sequence;
|
||||||
|
|
||||||
|
/* Position in variable-length integers and Check fields */
|
||||||
|
uint32_t pos;
|
||||||
|
|
||||||
|
/* Variable-length integer decoded by dec_vli() */
|
||||||
|
vli_type vli;
|
||||||
|
|
||||||
|
/* Saved in_pos and out_pos */
|
||||||
|
size_t in_start;
|
||||||
|
size_t out_start;
|
||||||
|
|
||||||
|
#ifdef XZ_USE_CRC64
|
||||||
|
/* CRC32 or CRC64 value in Block or CRC32 value in Index */
|
||||||
|
uint64_t crc;
|
||||||
|
#else
|
||||||
|
/* CRC32 value in Block or Index */
|
||||||
|
uint32_t crc;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Type of the integrity check calculated from uncompressed data */
|
||||||
|
enum xz_check check_type;
|
||||||
|
|
||||||
|
/* Operation mode */
|
||||||
|
enum xz_mode mode;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* True if the next call to xz_dec_run() is allowed to return
|
||||||
|
* XZ_BUF_ERROR.
|
||||||
|
*/
|
||||||
|
bool allow_buf_error;
|
||||||
|
|
||||||
|
/* Information stored in Block Header */
|
||||||
|
struct {
|
||||||
|
/*
|
||||||
|
* Value stored in the Compressed Size field, or
|
||||||
|
* VLI_UNKNOWN if Compressed Size is not present.
|
||||||
|
*/
|
||||||
|
vli_type compressed;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Value stored in the Uncompressed Size field, or
|
||||||
|
* VLI_UNKNOWN if Uncompressed Size is not present.
|
||||||
|
*/
|
||||||
|
vli_type uncompressed;
|
||||||
|
|
||||||
|
/* Size of the Block Header field */
|
||||||
|
uint32_t size;
|
||||||
|
} block_header;
|
||||||
|
|
||||||
|
/* Information collected when decoding Blocks */
|
||||||
|
struct {
|
||||||
|
/* Observed compressed size of the current Block */
|
||||||
|
vli_type compressed;
|
||||||
|
|
||||||
|
/* Observed uncompressed size of the current Block */
|
||||||
|
vli_type uncompressed;
|
||||||
|
|
||||||
|
/* Number of Blocks decoded so far */
|
||||||
|
vli_type count;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Hash calculated from the Block sizes. This is used to
|
||||||
|
* validate the Index field.
|
||||||
|
*/
|
||||||
|
struct xz_dec_hash hash;
|
||||||
|
} block;
|
||||||
|
|
||||||
|
/* Variables needed when verifying the Index field */
|
||||||
|
struct {
|
||||||
|
/* Position in dec_index() */
|
||||||
|
enum {
|
||||||
|
SEQ_INDEX_COUNT,
|
||||||
|
SEQ_INDEX_UNPADDED,
|
||||||
|
SEQ_INDEX_UNCOMPRESSED
|
||||||
|
} sequence;
|
||||||
|
|
||||||
|
/* Size of the Index in bytes */
|
||||||
|
vli_type size;
|
||||||
|
|
||||||
|
/* Number of Records (matches block.count in valid files) */
|
||||||
|
vli_type count;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Hash calculated from the Records (matches block.hash in
|
||||||
|
* valid files).
|
||||||
|
*/
|
||||||
|
struct xz_dec_hash hash;
|
||||||
|
} index;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Temporary buffer needed to hold Stream Header, Block Header,
|
||||||
|
* and Stream Footer. The Block Header is the biggest (1 KiB)
|
||||||
|
* so we reserve space according to that. buf[] has to be aligned
|
||||||
|
* to a multiple of four bytes; the size_t variables before it
|
||||||
|
* should guarantee this.
|
||||||
|
*/
|
||||||
|
struct {
|
||||||
|
size_t pos;
|
||||||
|
size_t size;
|
||||||
|
uint8_t buf[1024];
|
||||||
|
} temp;
|
||||||
|
|
||||||
|
struct xz_dec_lzma2 *lzma2;
|
||||||
|
|
||||||
|
#ifdef XZ_DEC_BCJ
|
||||||
|
struct xz_dec_bcj *bcj;
|
||||||
|
bool bcj_active;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef XZ_USE_SHA256
|
||||||
|
/*
|
||||||
|
* SHA-256 value in Block
|
||||||
|
*
|
||||||
|
* struct xz_sha256 is over a hundred bytes and it's only accessed
|
||||||
|
* from a few places. By putting the SHA-256 state near the end
|
||||||
|
* of struct xz_dec (somewhere after the "index" member) reduces
|
||||||
|
* code size at least on x86 and RISC-V. It's because the first bytes
|
||||||
|
* of the struct can be accessed with smaller instructions; the
|
||||||
|
* members that are accessed from many places should be at the top.
|
||||||
|
*/
|
||||||
|
struct xz_sha256 sha256;
|
||||||
|
#endif
|
||||||
|
};
|
||||||
|
|
||||||
|
#if defined(XZ_DEC_ANY_CHECK) || defined(XZ_USE_SHA256)
|
||||||
|
/* Sizes of the Check field with different Check IDs */
|
||||||
|
static const uint8_t check_sizes[16] = {
|
||||||
|
0,
|
||||||
|
4, 4, 4,
|
||||||
|
8, 8, 8,
|
||||||
|
16, 16, 16,
|
||||||
|
32, 32, 32,
|
||||||
|
64, 64, 64
|
||||||
|
};
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Fill s->temp by copying data starting from b->in[b->in_pos]. Caller
|
||||||
|
* must have set s->temp.pos and s->temp.size to indicate how much data
|
||||||
|
* we are supposed to copy into s->temp.buf. Return true once s->temp.pos
|
||||||
|
* has reached s->temp.size.
|
||||||
|
*/
|
||||||
|
static bool fill_temp(struct xz_dec *s, struct xz_buf *b)
|
||||||
|
{
|
||||||
|
size_t copy_size = min_t(size_t,
|
||||||
|
b->in_size - b->in_pos, s->temp.size - s->temp.pos);
|
||||||
|
|
||||||
|
memcpy(s->temp.buf + s->temp.pos, b->in + b->in_pos, copy_size);
|
||||||
|
b->in_pos += copy_size;
|
||||||
|
s->temp.pos += copy_size;
|
||||||
|
|
||||||
|
if (s->temp.pos == s->temp.size) {
|
||||||
|
s->temp.pos = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Decode a variable-length integer (little-endian base-128 encoding) */
|
||||||
|
static enum xz_ret dec_vli(struct xz_dec *s, const uint8_t *in,
|
||||||
|
size_t *in_pos, size_t in_size)
|
||||||
|
{
|
||||||
|
uint8_t byte;
|
||||||
|
|
||||||
|
if (s->pos == 0)
|
||||||
|
s->vli = 0;
|
||||||
|
|
||||||
|
while (*in_pos < in_size) {
|
||||||
|
byte = in[*in_pos];
|
||||||
|
++*in_pos;
|
||||||
|
|
||||||
|
s->vli |= (vli_type)(byte & 0x7F) << s->pos;
|
||||||
|
|
||||||
|
if ((byte & 0x80) == 0) {
|
||||||
|
/* Don't allow non-minimal encodings. */
|
||||||
|
if (byte == 0 && s->pos != 0)
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
|
||||||
|
s->pos = 0;
|
||||||
|
return XZ_STREAM_END;
|
||||||
|
}
|
||||||
|
|
||||||
|
s->pos += 7;
|
||||||
|
if (s->pos == 7 * VLI_BYTES_MAX)
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
return XZ_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Decode the Compressed Data field from a Block. Update and validate
|
||||||
|
* the observed compressed and uncompressed sizes of the Block so that
|
||||||
|
* they don't exceed the values possibly stored in the Block Header
|
||||||
|
* (validation assumes that no integer overflow occurs, since vli_type
|
||||||
|
* is normally uint64_t). Update the CRC32 or CRC64 value if presence of
|
||||||
|
* the CRC32 or CRC64 field was indicated in Stream Header.
|
||||||
|
*
|
||||||
|
* Once the decoding is finished, validate that the observed sizes match
|
||||||
|
* the sizes possibly stored in the Block Header. Update the hash and
|
||||||
|
* Block count, which are later used to validate the Index field.
|
||||||
|
*/
|
||||||
|
static enum xz_ret dec_block(struct xz_dec *s, struct xz_buf *b)
|
||||||
|
{
|
||||||
|
enum xz_ret ret;
|
||||||
|
|
||||||
|
s->in_start = b->in_pos;
|
||||||
|
s->out_start = b->out_pos;
|
||||||
|
|
||||||
|
#ifdef XZ_DEC_BCJ
|
||||||
|
if (s->bcj_active)
|
||||||
|
ret = xz_dec_bcj_run(s->bcj, s->lzma2, b);
|
||||||
|
else
|
||||||
|
#endif
|
||||||
|
ret = xz_dec_lzma2_run(s->lzma2, b);
|
||||||
|
|
||||||
|
s->block.compressed += b->in_pos - s->in_start;
|
||||||
|
s->block.uncompressed += b->out_pos - s->out_start;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* There is no need to separately check for VLI_UNKNOWN, since
|
||||||
|
* the observed sizes are always smaller than VLI_UNKNOWN.
|
||||||
|
*/
|
||||||
|
if (s->block.compressed > s->block_header.compressed
|
||||||
|
|| s->block.uncompressed
|
||||||
|
> s->block_header.uncompressed)
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
|
||||||
|
if (s->check_type == XZ_CHECK_CRC32)
|
||||||
|
s->crc = xz_crc32(b->out + s->out_start,
|
||||||
|
b->out_pos - s->out_start, s->crc);
|
||||||
|
#ifdef XZ_USE_CRC64
|
||||||
|
else if (s->check_type == XZ_CHECK_CRC64)
|
||||||
|
s->crc = xz_crc64(b->out + s->out_start,
|
||||||
|
b->out_pos - s->out_start, s->crc);
|
||||||
|
#endif
|
||||||
|
#ifdef XZ_USE_SHA256
|
||||||
|
else if (s->check_type == XZ_CHECK_SHA256)
|
||||||
|
xz_sha256_update(b->out + s->out_start,
|
||||||
|
b->out_pos - s->out_start, &s->sha256);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if (ret == XZ_STREAM_END) {
|
||||||
|
if (s->block_header.compressed != VLI_UNKNOWN
|
||||||
|
&& s->block_header.compressed
|
||||||
|
!= s->block.compressed)
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
|
||||||
|
if (s->block_header.uncompressed != VLI_UNKNOWN
|
||||||
|
&& s->block_header.uncompressed
|
||||||
|
!= s->block.uncompressed)
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
|
||||||
|
s->block.hash.unpadded += s->block_header.size
|
||||||
|
+ s->block.compressed;
|
||||||
|
|
||||||
|
#if defined(XZ_DEC_ANY_CHECK) || defined(XZ_USE_SHA256)
|
||||||
|
s->block.hash.unpadded += check_sizes[s->check_type];
|
||||||
|
#else
|
||||||
|
if (s->check_type == XZ_CHECK_CRC32)
|
||||||
|
s->block.hash.unpadded += 4;
|
||||||
|
else if (IS_CRC64(s->check_type))
|
||||||
|
s->block.hash.unpadded += 8;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
s->block.hash.uncompressed += s->block.uncompressed;
|
||||||
|
s->block.hash.crc32 = xz_crc32(
|
||||||
|
(const uint8_t *)&s->block.hash,
|
||||||
|
sizeof(s->block.hash), s->block.hash.crc32);
|
||||||
|
|
||||||
|
++s->block.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Update the Index size and the CRC32 value. */
|
||||||
|
static void index_update(struct xz_dec *s, const struct xz_buf *b)
|
||||||
|
{
|
||||||
|
size_t in_used = b->in_pos - s->in_start;
|
||||||
|
s->index.size += in_used;
|
||||||
|
s->crc = xz_crc32(b->in + s->in_start, in_used, s->crc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Decode the Number of Records, Unpadded Size, and Uncompressed Size
|
||||||
|
* fields from the Index field. That is, Index Padding and CRC32 are not
|
||||||
|
* decoded by this function.
|
||||||
|
*
|
||||||
|
* This can return XZ_OK (more input needed), XZ_STREAM_END (everything
|
||||||
|
* successfully decoded), or XZ_DATA_ERROR (input is corrupt).
|
||||||
|
*/
|
||||||
|
static enum xz_ret dec_index(struct xz_dec *s, struct xz_buf *b)
|
||||||
|
{
|
||||||
|
enum xz_ret ret;
|
||||||
|
|
||||||
|
do {
|
||||||
|
ret = dec_vli(s, b->in, &b->in_pos, b->in_size);
|
||||||
|
if (ret != XZ_STREAM_END) {
|
||||||
|
index_update(s, b);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (s->index.sequence) {
|
||||||
|
case SEQ_INDEX_COUNT:
|
||||||
|
s->index.count = s->vli;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Validate that the Number of Records field
|
||||||
|
* indicates the same number of Records as
|
||||||
|
* there were Blocks in the Stream.
|
||||||
|
*/
|
||||||
|
if (s->index.count != s->block.count)
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
|
||||||
|
s->index.sequence = SEQ_INDEX_UNPADDED;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SEQ_INDEX_UNPADDED:
|
||||||
|
s->index.hash.unpadded += s->vli;
|
||||||
|
s->index.sequence = SEQ_INDEX_UNCOMPRESSED;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SEQ_INDEX_UNCOMPRESSED:
|
||||||
|
s->index.hash.uncompressed += s->vli;
|
||||||
|
s->index.hash.crc32 = xz_crc32(
|
||||||
|
(const uint8_t *)&s->index.hash,
|
||||||
|
sizeof(s->index.hash),
|
||||||
|
s->index.hash.crc32);
|
||||||
|
--s->index.count;
|
||||||
|
s->index.sequence = SEQ_INDEX_UNPADDED;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} while (s->index.count > 0);
|
||||||
|
|
||||||
|
return XZ_STREAM_END;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Validate that the next four or eight input bytes match the value
|
||||||
|
* of s->crc. s->pos must be zero when starting to validate the first byte.
|
||||||
|
* The "bits" argument allows using the same code for both CRC32 and CRC64.
|
||||||
|
*/
|
||||||
|
static enum xz_ret crc_validate(struct xz_dec *s, struct xz_buf *b,
|
||||||
|
uint32_t bits)
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
if (b->in_pos == b->in_size)
|
||||||
|
return XZ_OK;
|
||||||
|
|
||||||
|
if (((s->crc >> s->pos) & 0xFF) != b->in[b->in_pos++])
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
|
||||||
|
s->pos += 8;
|
||||||
|
|
||||||
|
} while (s->pos < bits);
|
||||||
|
|
||||||
|
s->crc = 0;
|
||||||
|
s->pos = 0;
|
||||||
|
|
||||||
|
return XZ_STREAM_END;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef XZ_DEC_ANY_CHECK
|
||||||
|
/*
|
||||||
|
* Skip over the Check field when the Check ID is not supported.
|
||||||
|
* Returns true once the whole Check field has been skipped over.
|
||||||
|
*/
|
||||||
|
static bool check_skip(struct xz_dec *s, struct xz_buf *b)
|
||||||
|
{
|
||||||
|
while (s->pos < check_sizes[s->check_type]) {
|
||||||
|
if (b->in_pos == b->in_size)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
++b->in_pos;
|
||||||
|
++s->pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
s->pos = 0;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Decode the Stream Header field (the first 12 bytes of the .xz Stream). */
|
||||||
|
static enum xz_ret dec_stream_header(struct xz_dec *s)
|
||||||
|
{
|
||||||
|
if (!memeq(s->temp.buf, HEADER_MAGIC, HEADER_MAGIC_SIZE))
|
||||||
|
return XZ_FORMAT_ERROR;
|
||||||
|
|
||||||
|
if (xz_crc32(s->temp.buf + HEADER_MAGIC_SIZE, 2, 0)
|
||||||
|
!= get_le32(s->temp.buf + HEADER_MAGIC_SIZE + 2))
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
|
||||||
|
if (s->temp.buf[HEADER_MAGIC_SIZE] != 0)
|
||||||
|
return XZ_OPTIONS_ERROR;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Of integrity checks, we support none (Check ID = 0),
|
||||||
|
* CRC32 (Check ID = 1), and optionally CRC64 (Check ID = 4).
|
||||||
|
* However, if XZ_DEC_ANY_CHECK is defined, we will accept other
|
||||||
|
* check types too, but then the check won't be verified and
|
||||||
|
* a warning (XZ_UNSUPPORTED_CHECK) will be given.
|
||||||
|
*/
|
||||||
|
if (s->temp.buf[HEADER_MAGIC_SIZE + 1] > XZ_CHECK_MAX)
|
||||||
|
return XZ_OPTIONS_ERROR;
|
||||||
|
|
||||||
|
s->check_type = s->temp.buf[HEADER_MAGIC_SIZE + 1];
|
||||||
|
|
||||||
|
if (s->check_type > XZ_CHECK_CRC32 && !IS_CRC64(s->check_type)
|
||||||
|
&& !IS_SHA256(s->check_type)) {
|
||||||
|
#ifdef XZ_DEC_ANY_CHECK
|
||||||
|
return XZ_UNSUPPORTED_CHECK;
|
||||||
|
#else
|
||||||
|
return XZ_OPTIONS_ERROR;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
return XZ_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Decode the Stream Footer field (the last 12 bytes of the .xz Stream) */
|
||||||
|
static enum xz_ret dec_stream_footer(struct xz_dec *s)
|
||||||
|
{
|
||||||
|
if (!memeq(s->temp.buf + 10, FOOTER_MAGIC, FOOTER_MAGIC_SIZE))
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
|
||||||
|
if (xz_crc32(s->temp.buf + 4, 6, 0) != get_le32(s->temp.buf))
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Validate Backward Size. Note that we never added the size of the
|
||||||
|
* Index CRC32 field to s->index.size, thus we use s->index.size / 4
|
||||||
|
* instead of s->index.size / 4 - 1.
|
||||||
|
*/
|
||||||
|
if ((s->index.size >> 2) != get_le32(s->temp.buf + 4))
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
|
||||||
|
if (s->temp.buf[8] != 0 || s->temp.buf[9] != s->check_type)
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Use XZ_STREAM_END instead of XZ_OK to be more convenient
|
||||||
|
* for the caller.
|
||||||
|
*/
|
||||||
|
return XZ_STREAM_END;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Decode the Block Header and initialize the filter chain. */
|
||||||
|
static enum xz_ret dec_block_header(struct xz_dec *s)
|
||||||
|
{
|
||||||
|
enum xz_ret ret;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Validate the CRC32. We know that the temp buffer is at least
|
||||||
|
* eight bytes so this is safe.
|
||||||
|
*/
|
||||||
|
s->temp.size -= 4;
|
||||||
|
if (xz_crc32(s->temp.buf, s->temp.size, 0)
|
||||||
|
!= get_le32(s->temp.buf + s->temp.size))
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
|
||||||
|
s->temp.pos = 2;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Catch unsupported Block Flags. We support only one or two filters
|
||||||
|
* in the chain, so we catch that with the same test.
|
||||||
|
*/
|
||||||
|
#ifdef XZ_DEC_BCJ
|
||||||
|
if (s->temp.buf[1] & 0x3E)
|
||||||
|
#else
|
||||||
|
if (s->temp.buf[1] & 0x3F)
|
||||||
|
#endif
|
||||||
|
return XZ_OPTIONS_ERROR;
|
||||||
|
|
||||||
|
/* Compressed Size */
|
||||||
|
if (s->temp.buf[1] & 0x40) {
|
||||||
|
if (dec_vli(s, s->temp.buf, &s->temp.pos, s->temp.size)
|
||||||
|
!= XZ_STREAM_END)
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
|
||||||
|
s->block_header.compressed = s->vli;
|
||||||
|
} else {
|
||||||
|
s->block_header.compressed = VLI_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Uncompressed Size */
|
||||||
|
if (s->temp.buf[1] & 0x80) {
|
||||||
|
if (dec_vli(s, s->temp.buf, &s->temp.pos, s->temp.size)
|
||||||
|
!= XZ_STREAM_END)
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
|
||||||
|
s->block_header.uncompressed = s->vli;
|
||||||
|
} else {
|
||||||
|
s->block_header.uncompressed = VLI_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef XZ_DEC_BCJ
|
||||||
|
/* If there are two filters, the first one must be a BCJ filter. */
|
||||||
|
s->bcj_active = s->temp.buf[1] & 0x01;
|
||||||
|
if (s->bcj_active) {
|
||||||
|
if (s->temp.size - s->temp.pos < 2)
|
||||||
|
return XZ_OPTIONS_ERROR;
|
||||||
|
|
||||||
|
ret = xz_dec_bcj_reset(s->bcj, s->temp.buf[s->temp.pos++]);
|
||||||
|
if (ret != XZ_OK)
|
||||||
|
return ret;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* We don't support custom start offset,
|
||||||
|
* so Size of Properties must be zero.
|
||||||
|
*/
|
||||||
|
if (s->temp.buf[s->temp.pos++] != 0x00)
|
||||||
|
return XZ_OPTIONS_ERROR;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Valid Filter Flags always take at least two bytes. */
|
||||||
|
if (s->temp.size - s->temp.pos < 2)
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
|
||||||
|
/* Filter ID = LZMA2 */
|
||||||
|
if (s->temp.buf[s->temp.pos++] != 0x21)
|
||||||
|
return XZ_OPTIONS_ERROR;
|
||||||
|
|
||||||
|
/* Size of Properties = 1-byte Filter Properties */
|
||||||
|
if (s->temp.buf[s->temp.pos++] != 0x01)
|
||||||
|
return XZ_OPTIONS_ERROR;
|
||||||
|
|
||||||
|
/* Filter Properties contains LZMA2 dictionary size. */
|
||||||
|
if (s->temp.size - s->temp.pos < 1)
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
|
||||||
|
ret = xz_dec_lzma2_reset(s->lzma2, s->temp.buf[s->temp.pos++]);
|
||||||
|
if (ret != XZ_OK)
|
||||||
|
return ret;
|
||||||
|
|
||||||
|
/* The rest must be Header Padding. */
|
||||||
|
while (s->temp.pos < s->temp.size)
|
||||||
|
if (s->temp.buf[s->temp.pos++] != 0x00)
|
||||||
|
return XZ_OPTIONS_ERROR;
|
||||||
|
|
||||||
|
s->temp.pos = 0;
|
||||||
|
s->block.compressed = 0;
|
||||||
|
s->block.uncompressed = 0;
|
||||||
|
|
||||||
|
return XZ_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static enum xz_ret dec_main(struct xz_dec *s, struct xz_buf *b)
|
||||||
|
{
|
||||||
|
enum xz_ret ret;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Store the start position for the case when we are in the middle
|
||||||
|
* of the Index field.
|
||||||
|
*/
|
||||||
|
s->in_start = b->in_pos;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
switch (s->sequence) {
|
||||||
|
case SEQ_STREAM_HEADER:
|
||||||
|
/*
|
||||||
|
* Stream Header is copied to s->temp, and then
|
||||||
|
* decoded from there. This way if the caller
|
||||||
|
* gives us only little input at a time, we can
|
||||||
|
* still keep the Stream Header decoding code
|
||||||
|
* simple. Similar approach is used in many places
|
||||||
|
* in this file.
|
||||||
|
*/
|
||||||
|
if (!fill_temp(s, b))
|
||||||
|
return XZ_OK;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If dec_stream_header() returns
|
||||||
|
* XZ_UNSUPPORTED_CHECK, it is still possible
|
||||||
|
* to continue decoding if working in multi-call
|
||||||
|
* mode. Thus, update s->sequence before calling
|
||||||
|
* dec_stream_header().
|
||||||
|
*/
|
||||||
|
s->sequence = SEQ_BLOCK_START;
|
||||||
|
|
||||||
|
ret = dec_stream_header(s);
|
||||||
|
if (ret != XZ_OK)
|
||||||
|
return ret;
|
||||||
|
|
||||||
|
fallthrough;
|
||||||
|
|
||||||
|
case SEQ_BLOCK_START:
|
||||||
|
/* We need one byte of input to continue. */
|
||||||
|
if (b->in_pos == b->in_size)
|
||||||
|
return XZ_OK;
|
||||||
|
|
||||||
|
/* See if this is the beginning of the Index field. */
|
||||||
|
if (b->in[b->in_pos] == 0) {
|
||||||
|
s->in_start = b->in_pos++;
|
||||||
|
s->sequence = SEQ_INDEX;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Calculate the size of the Block Header and
|
||||||
|
* prepare to decode it.
|
||||||
|
*/
|
||||||
|
s->block_header.size
|
||||||
|
= ((uint32_t)b->in[b->in_pos] + 1) * 4;
|
||||||
|
|
||||||
|
s->temp.size = s->block_header.size;
|
||||||
|
s->temp.pos = 0;
|
||||||
|
s->sequence = SEQ_BLOCK_HEADER;
|
||||||
|
|
||||||
|
fallthrough;
|
||||||
|
|
||||||
|
case SEQ_BLOCK_HEADER:
|
||||||
|
if (!fill_temp(s, b))
|
||||||
|
return XZ_OK;
|
||||||
|
|
||||||
|
ret = dec_block_header(s);
|
||||||
|
if (ret != XZ_OK)
|
||||||
|
return ret;
|
||||||
|
|
||||||
|
#ifdef XZ_USE_SHA256
|
||||||
|
if (s->check_type == XZ_CHECK_SHA256)
|
||||||
|
xz_sha256_reset(&s->sha256);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
s->sequence = SEQ_BLOCK_UNCOMPRESS;
|
||||||
|
|
||||||
|
fallthrough;
|
||||||
|
|
||||||
|
case SEQ_BLOCK_UNCOMPRESS:
|
||||||
|
ret = dec_block(s, b);
|
||||||
|
if (ret != XZ_STREAM_END)
|
||||||
|
return ret;
|
||||||
|
|
||||||
|
s->sequence = SEQ_BLOCK_PADDING;
|
||||||
|
|
||||||
|
fallthrough;
|
||||||
|
|
||||||
|
case SEQ_BLOCK_PADDING:
|
||||||
|
/*
|
||||||
|
* Size of Compressed Data + Block Padding
|
||||||
|
* must be a multiple of four. We don't need
|
||||||
|
* s->block.compressed for anything else
|
||||||
|
* anymore, so we use it here to test the size
|
||||||
|
* of the Block Padding field.
|
||||||
|
*/
|
||||||
|
while (s->block.compressed & 3) {
|
||||||
|
if (b->in_pos == b->in_size)
|
||||||
|
return XZ_OK;
|
||||||
|
|
||||||
|
if (b->in[b->in_pos++] != 0)
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
|
||||||
|
++s->block.compressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
s->sequence = SEQ_BLOCK_CHECK;
|
||||||
|
|
||||||
|
fallthrough;
|
||||||
|
|
||||||
|
case SEQ_BLOCK_CHECK:
|
||||||
|
if (s->check_type == XZ_CHECK_CRC32) {
|
||||||
|
ret = crc_validate(s, b, 32);
|
||||||
|
if (ret != XZ_STREAM_END)
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
else if (IS_CRC64(s->check_type)) {
|
||||||
|
ret = crc_validate(s, b, 64);
|
||||||
|
if (ret != XZ_STREAM_END)
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
#ifdef XZ_USE_SHA256
|
||||||
|
else if (s->check_type == XZ_CHECK_SHA256) {
|
||||||
|
s->temp.size = 32;
|
||||||
|
if (!fill_temp(s, b))
|
||||||
|
return XZ_OK;
|
||||||
|
|
||||||
|
if (!xz_sha256_validate(s->temp.buf,
|
||||||
|
&s->sha256))
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
|
||||||
|
s->pos = 0;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#ifdef XZ_DEC_ANY_CHECK
|
||||||
|
else if (!check_skip(s, b)) {
|
||||||
|
return XZ_OK;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
s->sequence = SEQ_BLOCK_START;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SEQ_INDEX:
|
||||||
|
ret = dec_index(s, b);
|
||||||
|
if (ret != XZ_STREAM_END)
|
||||||
|
return ret;
|
||||||
|
|
||||||
|
s->sequence = SEQ_INDEX_PADDING;
|
||||||
|
|
||||||
|
fallthrough;
|
||||||
|
|
||||||
|
case SEQ_INDEX_PADDING:
|
||||||
|
while ((s->index.size + (b->in_pos - s->in_start))
|
||||||
|
& 3) {
|
||||||
|
if (b->in_pos == b->in_size) {
|
||||||
|
index_update(s, b);
|
||||||
|
return XZ_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (b->in[b->in_pos++] != 0)
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Finish the CRC32 value and Index size. */
|
||||||
|
index_update(s, b);
|
||||||
|
|
||||||
|
/* Compare the hashes to validate the Index field. */
|
||||||
|
if (!memeq(&s->block.hash, &s->index.hash,
|
||||||
|
sizeof(s->block.hash)))
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
|
||||||
|
s->sequence = SEQ_INDEX_CRC32;
|
||||||
|
|
||||||
|
fallthrough;
|
||||||
|
|
||||||
|
case SEQ_INDEX_CRC32:
|
||||||
|
ret = crc_validate(s, b, 32);
|
||||||
|
if (ret != XZ_STREAM_END)
|
||||||
|
return ret;
|
||||||
|
|
||||||
|
s->temp.size = STREAM_HEADER_SIZE;
|
||||||
|
s->sequence = SEQ_STREAM_FOOTER;
|
||||||
|
|
||||||
|
fallthrough;
|
||||||
|
|
||||||
|
case SEQ_STREAM_FOOTER:
|
||||||
|
if (!fill_temp(s, b))
|
||||||
|
return XZ_OK;
|
||||||
|
|
||||||
|
return dec_stream_footer(s);
|
||||||
|
|
||||||
|
case SEQ_STREAM_PADDING:
|
||||||
|
/* Never reached, only silencing a warning */
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Never reached */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* xz_dec_run() is a wrapper for dec_main() to handle some special cases in
|
||||||
|
* multi-call and single-call decoding.
|
||||||
|
*
|
||||||
|
* In multi-call mode, we must return XZ_BUF_ERROR when it seems clear that we
|
||||||
|
* are not going to make any progress anymore. This is to prevent the caller
|
||||||
|
* from calling us infinitely when the input file is truncated or otherwise
|
||||||
|
* corrupt. Since zlib-style API allows that the caller fills the input buffer
|
||||||
|
* only when the decoder doesn't produce any new output, we have to be careful
|
||||||
|
* to avoid returning XZ_BUF_ERROR too easily: XZ_BUF_ERROR is returned only
|
||||||
|
* after the second consecutive call to xz_dec_run() that makes no progress.
|
||||||
|
*
|
||||||
|
* In single-call mode, if we couldn't decode everything and no error
|
||||||
|
* occurred, either the input is truncated or the output buffer is too small.
|
||||||
|
* Since we know that the last input byte never produces any output, we know
|
||||||
|
* that if all the input was consumed and decoding wasn't finished, the file
|
||||||
|
* must be corrupt. Otherwise the output buffer has to be too small or the
|
||||||
|
* file is corrupt in a way that decoding it produces too big output.
|
||||||
|
*
|
||||||
|
* If single-call decoding fails, we reset b->in_pos and b->out_pos back to
|
||||||
|
* their original values. This is because with some filter chains there won't
|
||||||
|
* be any valid uncompressed data in the output buffer unless the decoding
|
||||||
|
* actually succeeds (that's the price to pay of using the output buffer as
|
||||||
|
* the workspace).
|
||||||
|
*/
|
||||||
|
XZ_EXTERN enum xz_ret xz_dec_run(struct xz_dec *s, struct xz_buf *b)
|
||||||
|
{
|
||||||
|
size_t in_start;
|
||||||
|
size_t out_start;
|
||||||
|
enum xz_ret ret;
|
||||||
|
|
||||||
|
if (DEC_IS_SINGLE(s->mode))
|
||||||
|
xz_dec_reset(s);
|
||||||
|
|
||||||
|
in_start = b->in_pos;
|
||||||
|
out_start = b->out_pos;
|
||||||
|
ret = dec_main(s, b);
|
||||||
|
|
||||||
|
if (DEC_IS_SINGLE(s->mode)) {
|
||||||
|
if (ret == XZ_OK)
|
||||||
|
ret = b->in_pos == b->in_size
|
||||||
|
? XZ_DATA_ERROR : XZ_BUF_ERROR;
|
||||||
|
|
||||||
|
if (ret != XZ_STREAM_END) {
|
||||||
|
b->in_pos = in_start;
|
||||||
|
b->out_pos = out_start;
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (ret == XZ_OK && in_start == b->in_pos
|
||||||
|
&& out_start == b->out_pos) {
|
||||||
|
if (s->allow_buf_error)
|
||||||
|
ret = XZ_BUF_ERROR;
|
||||||
|
|
||||||
|
s->allow_buf_error = true;
|
||||||
|
} else {
|
||||||
|
s->allow_buf_error = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef XZ_DEC_CONCATENATED
|
||||||
|
XZ_EXTERN enum xz_ret xz_dec_catrun(struct xz_dec *s, struct xz_buf *b,
|
||||||
|
int finish)
|
||||||
|
{
|
||||||
|
enum xz_ret ret;
|
||||||
|
|
||||||
|
if (DEC_IS_SINGLE(s->mode)) {
|
||||||
|
xz_dec_reset(s);
|
||||||
|
finish = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (s->sequence == SEQ_STREAM_PADDING) {
|
||||||
|
/*
|
||||||
|
* Skip Stream Padding. Its size must be a multiple
|
||||||
|
* of four bytes which is tracked with s->pos.
|
||||||
|
*/
|
||||||
|
while (true) {
|
||||||
|
if (b->in_pos == b->in_size) {
|
||||||
|
/*
|
||||||
|
* Note that if we are repeatedly
|
||||||
|
* given no input and finish is false,
|
||||||
|
* we will keep returning XZ_OK even
|
||||||
|
* though no progress is being made.
|
||||||
|
* The lack of XZ_BUF_ERROR support
|
||||||
|
* isn't a problem here because a
|
||||||
|
* reasonable caller will eventually
|
||||||
|
* provide more input or set finish
|
||||||
|
* to true.
|
||||||
|
*/
|
||||||
|
if (!finish)
|
||||||
|
return XZ_OK;
|
||||||
|
|
||||||
|
if (s->pos != 0)
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
|
||||||
|
return XZ_STREAM_END;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (b->in[b->in_pos] != 0x00) {
|
||||||
|
if (s->pos != 0)
|
||||||
|
return XZ_DATA_ERROR;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
++b->in_pos;
|
||||||
|
s->pos = (s->pos + 1) & 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* More input remains. It should be a new Stream.
|
||||||
|
*
|
||||||
|
* In single-call mode xz_dec_run() will always call
|
||||||
|
* xz_dec_reset(). Thus, we need to do it here only
|
||||||
|
* in multi-call mode.
|
||||||
|
*/
|
||||||
|
if (DEC_IS_MULTI(s->mode))
|
||||||
|
xz_dec_reset(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
ret = xz_dec_run(s, b);
|
||||||
|
|
||||||
|
if (ret != XZ_STREAM_END)
|
||||||
|
break;
|
||||||
|
|
||||||
|
s->sequence = SEQ_STREAM_PADDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
XZ_EXTERN struct xz_dec *xz_dec_init(enum xz_mode mode, uint32_t dict_max)
|
||||||
|
{
|
||||||
|
struct xz_dec *s = kmalloc(sizeof(*s), GFP_KERNEL);
|
||||||
|
if (s == NULL)
|
||||||
|
return NULL;
|
||||||
|
|
||||||
|
s->mode = mode;
|
||||||
|
|
||||||
|
#ifdef XZ_DEC_BCJ
|
||||||
|
s->bcj = xz_dec_bcj_create(DEC_IS_SINGLE(mode));
|
||||||
|
if (s->bcj == NULL)
|
||||||
|
goto error_bcj;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
s->lzma2 = xz_dec_lzma2_create(mode, dict_max);
|
||||||
|
if (s->lzma2 == NULL)
|
||||||
|
goto error_lzma2;
|
||||||
|
|
||||||
|
xz_dec_reset(s);
|
||||||
|
return s;
|
||||||
|
|
||||||
|
error_lzma2:
|
||||||
|
#ifdef XZ_DEC_BCJ
|
||||||
|
xz_dec_bcj_end(s->bcj);
|
||||||
|
error_bcj:
|
||||||
|
#endif
|
||||||
|
kfree(s);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
XZ_EXTERN void xz_dec_reset(struct xz_dec *s)
|
||||||
|
{
|
||||||
|
s->sequence = SEQ_STREAM_HEADER;
|
||||||
|
s->allow_buf_error = false;
|
||||||
|
s->pos = 0;
|
||||||
|
s->crc = 0;
|
||||||
|
memzero(&s->block, sizeof(s->block));
|
||||||
|
memzero(&s->index, sizeof(s->index));
|
||||||
|
s->temp.pos = 0;
|
||||||
|
s->temp.size = STREAM_HEADER_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
XZ_EXTERN void xz_dec_end(struct xz_dec *s)
|
||||||
|
{
|
||||||
|
if (s != NULL) {
|
||||||
|
xz_dec_lzma2_end(s->lzma2);
|
||||||
|
#ifdef XZ_DEC_BCJ
|
||||||
|
xz_dec_bcj_end(s->bcj);
|
||||||
|
#endif
|
||||||
|
kfree(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/* SPDX-License-Identifier: 0BSD */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* LZMA2 definitions
|
||||||
|
*
|
||||||
|
* Authors: Lasse Collin <lasse.collin@tukaani.org>
|
||||||
|
* Igor Pavlov <https://7-zip.org/>
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef XZ_LZMA2_H
|
||||||
|
#define XZ_LZMA2_H
|
||||||
|
|
||||||
|
/* Range coder constants */
|
||||||
|
#define RC_SHIFT_BITS 8
|
||||||
|
#define RC_TOP_BITS 24
|
||||||
|
#define RC_TOP_VALUE (1 << RC_TOP_BITS)
|
||||||
|
#define RC_BIT_MODEL_TOTAL_BITS 11
|
||||||
|
#define RC_BIT_MODEL_TOTAL (1 << RC_BIT_MODEL_TOTAL_BITS)
|
||||||
|
#define RC_MOVE_BITS 5
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Maximum number of position states. A position state is the lowest pb
|
||||||
|
* number of bits of the current uncompressed offset. In some places there
|
||||||
|
* are different sets of probabilities for different position states.
|
||||||
|
*/
|
||||||
|
#define POS_STATES_MAX (1 << 4)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This enum is used to track which LZMA symbols have occurred most recently
|
||||||
|
* and in which order. This information is used to predict the next symbol.
|
||||||
|
*
|
||||||
|
* Symbols:
|
||||||
|
* - Literal: One 8-bit byte
|
||||||
|
* - Match: Repeat a chunk of data at some distance
|
||||||
|
* - Long repeat: Multi-byte match at a recently seen distance
|
||||||
|
* - Short repeat: One-byte repeat at a recently seen distance
|
||||||
|
*
|
||||||
|
* The symbol names are in from STATE_oldest_older_previous. REP means
|
||||||
|
* either short or long repeated match, and NONLIT means any non-literal.
|
||||||
|
*/
|
||||||
|
enum lzma_state {
|
||||||
|
STATE_LIT_LIT,
|
||||||
|
STATE_MATCH_LIT_LIT,
|
||||||
|
STATE_REP_LIT_LIT,
|
||||||
|
STATE_SHORTREP_LIT_LIT,
|
||||||
|
STATE_MATCH_LIT,
|
||||||
|
STATE_REP_LIT,
|
||||||
|
STATE_SHORTREP_LIT,
|
||||||
|
STATE_LIT_MATCH,
|
||||||
|
STATE_LIT_LONGREP,
|
||||||
|
STATE_LIT_SHORTREP,
|
||||||
|
STATE_NONLIT_MATCH,
|
||||||
|
STATE_NONLIT_REP
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Total number of states */
|
||||||
|
#define STATES 12
|
||||||
|
|
||||||
|
/* The lowest 7 states indicate that the previous state was a literal. */
|
||||||
|
#define LIT_STATES 7
|
||||||
|
|
||||||
|
/* Indicate that the latest symbol was a literal. */
|
||||||
|
static inline void lzma_state_literal(enum lzma_state *state)
|
||||||
|
{
|
||||||
|
if (*state <= STATE_SHORTREP_LIT_LIT)
|
||||||
|
*state = STATE_LIT_LIT;
|
||||||
|
else if (*state <= STATE_LIT_SHORTREP)
|
||||||
|
*state -= 3;
|
||||||
|
else
|
||||||
|
*state -= 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Indicate that the latest symbol was a match. */
|
||||||
|
static inline void lzma_state_match(enum lzma_state *state)
|
||||||
|
{
|
||||||
|
*state = *state < LIT_STATES ? STATE_LIT_MATCH : STATE_NONLIT_MATCH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Indicate that the latest state was a long repeated match. */
|
||||||
|
static inline void lzma_state_long_rep(enum lzma_state *state)
|
||||||
|
{
|
||||||
|
*state = *state < LIT_STATES ? STATE_LIT_LONGREP : STATE_NONLIT_REP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Indicate that the latest symbol was a short match. */
|
||||||
|
static inline void lzma_state_short_rep(enum lzma_state *state)
|
||||||
|
{
|
||||||
|
*state = *state < LIT_STATES ? STATE_LIT_SHORTREP : STATE_NONLIT_REP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Test if the previous symbol was a literal. */
|
||||||
|
static inline bool lzma_state_is_literal(enum lzma_state state)
|
||||||
|
{
|
||||||
|
return state < LIT_STATES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Each literal coder is divided in three sections:
|
||||||
|
* - 0x001-0x0FF: Without match byte
|
||||||
|
* - 0x101-0x1FF: With match byte; match bit is 0
|
||||||
|
* - 0x201-0x2FF: With match byte; match bit is 1
|
||||||
|
*
|
||||||
|
* Match byte is used when the previous LZMA symbol was something else than
|
||||||
|
* a literal (that is, it was some kind of match).
|
||||||
|
*/
|
||||||
|
#define LITERAL_CODER_SIZE 0x300
|
||||||
|
|
||||||
|
/* Maximum number of literal coders */
|
||||||
|
#define LITERAL_CODERS_MAX (1 << 4)
|
||||||
|
|
||||||
|
/* Minimum length of a match is two bytes. */
|
||||||
|
#define MATCH_LEN_MIN 2
|
||||||
|
|
||||||
|
/* Match length is encoded with 4, 5, or 10 bits.
|
||||||
|
*
|
||||||
|
* Length Bits
|
||||||
|
* 2-9 4 = Choice=0 + 3 bits
|
||||||
|
* 10-17 5 = Choice=1 + Choice2=0 + 3 bits
|
||||||
|
* 18-273 10 = Choice=1 + Choice2=1 + 8 bits
|
||||||
|
*/
|
||||||
|
#define LEN_LOW_BITS 3
|
||||||
|
#define LEN_LOW_SYMBOLS (1 << LEN_LOW_BITS)
|
||||||
|
#define LEN_MID_BITS 3
|
||||||
|
#define LEN_MID_SYMBOLS (1 << LEN_MID_BITS)
|
||||||
|
#define LEN_HIGH_BITS 8
|
||||||
|
#define LEN_HIGH_SYMBOLS (1 << LEN_HIGH_BITS)
|
||||||
|
#define LEN_SYMBOLS (LEN_LOW_SYMBOLS + LEN_MID_SYMBOLS + LEN_HIGH_SYMBOLS)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Maximum length of a match is 273 which is a result of the encoding
|
||||||
|
* described above.
|
||||||
|
*/
|
||||||
|
#define MATCH_LEN_MAX (MATCH_LEN_MIN + LEN_SYMBOLS - 1)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Different sets of probabilities are used for match distances that have
|
||||||
|
* very short match length: Lengths of 2, 3, and 4 bytes have a separate
|
||||||
|
* set of probabilities for each length. The matches with longer length
|
||||||
|
* use a shared set of probabilities.
|
||||||
|
*/
|
||||||
|
#define DIST_STATES 4
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Get the index of the appropriate probability array for decoding
|
||||||
|
* the distance slot.
|
||||||
|
*/
|
||||||
|
static inline uint32_t lzma_get_dist_state(uint32_t len)
|
||||||
|
{
|
||||||
|
return len < DIST_STATES + MATCH_LEN_MIN
|
||||||
|
? len - MATCH_LEN_MIN : DIST_STATES - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The highest two bits of a 32-bit match distance are encoded using six bits.
|
||||||
|
* This six-bit value is called a distance slot. This way encoding a 32-bit
|
||||||
|
* value takes 6-36 bits, larger values taking more bits.
|
||||||
|
*/
|
||||||
|
#define DIST_SLOT_BITS 6
|
||||||
|
#define DIST_SLOTS (1 << DIST_SLOT_BITS)
|
||||||
|
|
||||||
|
/* Match distances up to 127 are fully encoded using probabilities. Since
|
||||||
|
* the highest two bits (distance slot) are always encoded using six bits,
|
||||||
|
* the distances 0-3 don't need any additional bits to encode, since the
|
||||||
|
* distance slot itself is the same as the actual distance. DIST_MODEL_START
|
||||||
|
* indicates the first distance slot where at least one additional bit is
|
||||||
|
* needed.
|
||||||
|
*/
|
||||||
|
#define DIST_MODEL_START 4
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Match distances greater than 127 are encoded in three pieces:
|
||||||
|
* - distance slot: the highest two bits
|
||||||
|
* - direct bits: 2-26 bits below the highest two bits
|
||||||
|
* - alignment bits: four lowest bits
|
||||||
|
*
|
||||||
|
* Direct bits don't use any probabilities.
|
||||||
|
*
|
||||||
|
* The distance slot value of 14 is for distances 128-191.
|
||||||
|
*/
|
||||||
|
#define DIST_MODEL_END 14
|
||||||
|
|
||||||
|
/* Distance slots that indicate a distance <= 127. */
|
||||||
|
#define FULL_DISTANCES_BITS (DIST_MODEL_END / 2)
|
||||||
|
#define FULL_DISTANCES (1 << FULL_DISTANCES_BITS)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* For match distances greater than 127, only the highest two bits and the
|
||||||
|
* lowest four bits (alignment) is encoded using probabilities.
|
||||||
|
*/
|
||||||
|
#define ALIGN_BITS 4
|
||||||
|
#define ALIGN_SIZE (1 << ALIGN_BITS)
|
||||||
|
#define ALIGN_MASK (ALIGN_SIZE - 1)
|
||||||
|
|
||||||
|
/* Total number of all probability variables */
|
||||||
|
#define PROBS_TOTAL (1846 + LITERAL_CODERS_MAX * LITERAL_CODER_SIZE)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* LZMA remembers the four most recent match distances. Reusing these
|
||||||
|
* distances tends to take less space than re-encoding the actual
|
||||||
|
* distance value.
|
||||||
|
*/
|
||||||
|
#define REPS 4
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/* SPDX-License-Identifier: 0BSD */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Private includes and definitions
|
||||||
|
*
|
||||||
|
* Author: Lasse Collin <lasse.collin@tukaani.org>
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef XZ_PRIVATE_H
|
||||||
|
#define XZ_PRIVATE_H
|
||||||
|
|
||||||
|
#ifdef __KERNEL__
|
||||||
|
# include <linux/xz.h>
|
||||||
|
# include <linux/kernel.h>
|
||||||
|
# include <linux/unaligned.h>
|
||||||
|
/* XZ_PREBOOT may be defined only via decompress_unxz.c. */
|
||||||
|
# ifndef XZ_PREBOOT
|
||||||
|
# include <linux/slab.h>
|
||||||
|
# include <linux/vmalloc.h>
|
||||||
|
# include <linux/string.h>
|
||||||
|
# ifdef CONFIG_XZ_DEC_X86
|
||||||
|
# define XZ_DEC_X86
|
||||||
|
# endif
|
||||||
|
# ifdef CONFIG_XZ_DEC_POWERPC
|
||||||
|
# define XZ_DEC_POWERPC
|
||||||
|
# endif
|
||||||
|
# ifdef CONFIG_XZ_DEC_IA64
|
||||||
|
# define XZ_DEC_IA64
|
||||||
|
# endif
|
||||||
|
# ifdef CONFIG_XZ_DEC_ARM
|
||||||
|
# define XZ_DEC_ARM
|
||||||
|
# endif
|
||||||
|
# ifdef CONFIG_XZ_DEC_ARMTHUMB
|
||||||
|
# define XZ_DEC_ARMTHUMB
|
||||||
|
# endif
|
||||||
|
# ifdef CONFIG_XZ_DEC_SPARC
|
||||||
|
# define XZ_DEC_SPARC
|
||||||
|
# endif
|
||||||
|
# ifdef CONFIG_XZ_DEC_ARM64
|
||||||
|
# define XZ_DEC_ARM64
|
||||||
|
# endif
|
||||||
|
# ifdef CONFIG_XZ_DEC_RISCV
|
||||||
|
# define XZ_DEC_RISCV
|
||||||
|
# endif
|
||||||
|
# ifdef CONFIG_XZ_DEC_MICROLZMA
|
||||||
|
# define XZ_DEC_MICROLZMA
|
||||||
|
# endif
|
||||||
|
# define memeq(a, b, size) (memcmp(a, b, size) == 0)
|
||||||
|
# define memzero(buf, size) memset(buf, 0, size)
|
||||||
|
# endif
|
||||||
|
# define get_le32(p) le32_to_cpup((const uint32_t *)(p))
|
||||||
|
#else
|
||||||
|
/*
|
||||||
|
* For userspace builds, use a separate header to define the required
|
||||||
|
* macros and functions. This makes it easier to adapt the code into
|
||||||
|
* different environments and avoids clutter in the Linux kernel tree.
|
||||||
|
*/
|
||||||
|
# include "xz_config.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* If no specific decoding mode is requested, enable support for all modes. */
|
||||||
|
#if !defined(XZ_DEC_SINGLE) && !defined(XZ_DEC_PREALLOC) \
|
||||||
|
&& !defined(XZ_DEC_DYNALLOC)
|
||||||
|
# define XZ_DEC_SINGLE
|
||||||
|
# define XZ_DEC_PREALLOC
|
||||||
|
# define XZ_DEC_DYNALLOC
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The DEC_IS_foo(mode) macros are used in "if" statements. If only some
|
||||||
|
* of the supported modes are enabled, these macros will evaluate to true or
|
||||||
|
* false at compile time and thus allow the compiler to omit unneeded code.
|
||||||
|
*/
|
||||||
|
#ifdef XZ_DEC_SINGLE
|
||||||
|
# define DEC_IS_SINGLE(mode) ((mode) == XZ_SINGLE)
|
||||||
|
#else
|
||||||
|
# define DEC_IS_SINGLE(mode) (false)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef XZ_DEC_PREALLOC
|
||||||
|
# define DEC_IS_PREALLOC(mode) ((mode) == XZ_PREALLOC)
|
||||||
|
#else
|
||||||
|
# define DEC_IS_PREALLOC(mode) (false)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef XZ_DEC_DYNALLOC
|
||||||
|
# define DEC_IS_DYNALLOC(mode) ((mode) == XZ_DYNALLOC)
|
||||||
|
#else
|
||||||
|
# define DEC_IS_DYNALLOC(mode) (false)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if !defined(XZ_DEC_SINGLE)
|
||||||
|
# define DEC_IS_MULTI(mode) (true)
|
||||||
|
#elif defined(XZ_DEC_PREALLOC) || defined(XZ_DEC_DYNALLOC)
|
||||||
|
# define DEC_IS_MULTI(mode) ((mode) != XZ_SINGLE)
|
||||||
|
#else
|
||||||
|
# define DEC_IS_MULTI(mode) (false)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If any of the BCJ filter decoders are wanted, define XZ_DEC_BCJ.
|
||||||
|
* XZ_DEC_BCJ is used to enable generic support for BCJ decoders.
|
||||||
|
*/
|
||||||
|
#ifndef XZ_DEC_BCJ
|
||||||
|
# if defined(XZ_DEC_X86) || defined(XZ_DEC_POWERPC) \
|
||||||
|
|| defined(XZ_DEC_IA64) \
|
||||||
|
|| defined(XZ_DEC_ARM) || defined(XZ_DEC_ARMTHUMB) \
|
||||||
|
|| defined(XZ_DEC_SPARC) || defined(XZ_DEC_ARM64) \
|
||||||
|
|| defined(XZ_DEC_RISCV)
|
||||||
|
# define XZ_DEC_BCJ
|
||||||
|
# endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct xz_sha256 {
|
||||||
|
/* Buffered input data */
|
||||||
|
uint8_t data[64];
|
||||||
|
|
||||||
|
/* Internal state and the final hash value */
|
||||||
|
uint32_t state[8];
|
||||||
|
|
||||||
|
/* Size of the input data */
|
||||||
|
uint64_t size;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Reset the SHA-256 state to prepare for a new calculation. */
|
||||||
|
XZ_EXTERN void xz_sha256_reset(struct xz_sha256 *s);
|
||||||
|
|
||||||
|
/* Update the SHA-256 state with new data. */
|
||||||
|
XZ_EXTERN void xz_sha256_update(const uint8_t *buf, size_t size,
|
||||||
|
struct xz_sha256 *s);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Finish the SHA-256 calculation. Compare the result with the first 32 bytes
|
||||||
|
* from buf. Return true if the values are equal and false if they aren't.
|
||||||
|
*/
|
||||||
|
XZ_EXTERN bool xz_sha256_validate(const uint8_t *buf, struct xz_sha256 *s);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Allocate memory for LZMA2 decoder. xz_dec_lzma2_reset() must be used
|
||||||
|
* before calling xz_dec_lzma2_run().
|
||||||
|
*/
|
||||||
|
XZ_EXTERN struct xz_dec_lzma2 *xz_dec_lzma2_create(enum xz_mode mode,
|
||||||
|
uint32_t dict_max);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Decode the LZMA2 properties (one byte) and reset the decoder. Return
|
||||||
|
* XZ_OK on success, XZ_MEMLIMIT_ERROR if the preallocated dictionary is not
|
||||||
|
* big enough, and XZ_OPTIONS_ERROR if props indicates something that this
|
||||||
|
* decoder doesn't support.
|
||||||
|
*/
|
||||||
|
XZ_EXTERN enum xz_ret xz_dec_lzma2_reset(struct xz_dec_lzma2 *s,
|
||||||
|
uint8_t props);
|
||||||
|
|
||||||
|
/* Decode raw LZMA2 stream from b->in to b->out. */
|
||||||
|
XZ_EXTERN enum xz_ret xz_dec_lzma2_run(struct xz_dec_lzma2 *s,
|
||||||
|
struct xz_buf *b);
|
||||||
|
|
||||||
|
/* Free the memory allocated for the LZMA2 decoder. */
|
||||||
|
XZ_EXTERN void xz_dec_lzma2_end(struct xz_dec_lzma2 *s);
|
||||||
|
|
||||||
|
#ifdef XZ_DEC_BCJ
|
||||||
|
/*
|
||||||
|
* Allocate memory for BCJ decoders. xz_dec_bcj_reset() must be used before
|
||||||
|
* calling xz_dec_bcj_run().
|
||||||
|
*/
|
||||||
|
XZ_EXTERN struct xz_dec_bcj *xz_dec_bcj_create(bool single_call);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Decode the Filter ID of a BCJ filter. This implementation doesn't
|
||||||
|
* support custom start offsets, so no decoding of Filter Properties
|
||||||
|
* is needed. Returns XZ_OK if the given Filter ID is supported.
|
||||||
|
* Otherwise XZ_OPTIONS_ERROR is returned.
|
||||||
|
*/
|
||||||
|
XZ_EXTERN enum xz_ret xz_dec_bcj_reset(struct xz_dec_bcj *s, uint8_t id);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Decode raw BCJ + LZMA2 stream. This must be used only if there actually is
|
||||||
|
* a BCJ filter in the chain. If the chain has only LZMA2, xz_dec_lzma2_run()
|
||||||
|
* must be called directly.
|
||||||
|
*/
|
||||||
|
XZ_EXTERN enum xz_ret xz_dec_bcj_run(struct xz_dec_bcj *s,
|
||||||
|
struct xz_dec_lzma2 *lzma2,
|
||||||
|
struct xz_buf *b);
|
||||||
|
|
||||||
|
/* Free the memory allocated for the BCJ filters. */
|
||||||
|
#define xz_dec_bcj_end(s) kfree(s)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
// SPDX-License-Identifier: 0BSD
|
||||||
|
|
||||||
|
/*
|
||||||
|
* SHA-256
|
||||||
|
*
|
||||||
|
* This is based on the XZ Utils version which is based public domain code
|
||||||
|
* from Crypto++ Library 5.5.1 released in 2007: https://www.cryptopp.com/
|
||||||
|
*
|
||||||
|
* Authors: Wei Dai
|
||||||
|
* Lasse Collin <lasse.collin@tukaani.org>
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "xz_private.h"
|
||||||
|
|
||||||
|
static inline uint32_t
|
||||||
|
rotr_32(uint32_t num, unsigned amount)
|
||||||
|
{
|
||||||
|
return (num >> amount) | (num << (32 - amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
#define blk0(i) (W[i] = get_be32(&data[4 * i]))
|
||||||
|
#define blk2(i) (W[i & 15] += s1(W[(i - 2) & 15]) + W[(i - 7) & 15] \
|
||||||
|
+ s0(W[(i - 15) & 15]))
|
||||||
|
|
||||||
|
#define Ch(x, y, z) (z ^ (x & (y ^ z)))
|
||||||
|
#define Maj(x, y, z) ((x & (y ^ z)) + (y & z))
|
||||||
|
|
||||||
|
#define a(i) T[(0 - i) & 7]
|
||||||
|
#define b(i) T[(1 - i) & 7]
|
||||||
|
#define c(i) T[(2 - i) & 7]
|
||||||
|
#define d(i) T[(3 - i) & 7]
|
||||||
|
#define e(i) T[(4 - i) & 7]
|
||||||
|
#define f(i) T[(5 - i) & 7]
|
||||||
|
#define g(i) T[(6 - i) & 7]
|
||||||
|
#define h(i) T[(7 - i) & 7]
|
||||||
|
|
||||||
|
#define R(i, j, blk) \
|
||||||
|
h(i) += S1(e(i)) + Ch(e(i), f(i), g(i)) + SHA256_K[i + j] + blk; \
|
||||||
|
d(i) += h(i); \
|
||||||
|
h(i) += S0(a(i)) + Maj(a(i), b(i), c(i))
|
||||||
|
#define R0(i) R(i, 0, blk0(i))
|
||||||
|
#define R2(i) R(i, j, blk2(i))
|
||||||
|
|
||||||
|
#define S0(x) rotr_32(x ^ rotr_32(x ^ rotr_32(x, 9), 11), 2)
|
||||||
|
#define S1(x) rotr_32(x ^ rotr_32(x ^ rotr_32(x, 14), 5), 6)
|
||||||
|
#define s0(x) (rotr_32(x ^ rotr_32(x, 11), 7) ^ (x >> 3))
|
||||||
|
#define s1(x) (rotr_32(x ^ rotr_32(x, 2), 17) ^ (x >> 10))
|
||||||
|
|
||||||
|
static const uint32_t SHA256_K[64] = {
|
||||||
|
0x428A2F98, 0x71374491, 0xB5C0FBCF, 0xE9B5DBA5,
|
||||||
|
0x3956C25B, 0x59F111F1, 0x923F82A4, 0xAB1C5ED5,
|
||||||
|
0xD807AA98, 0x12835B01, 0x243185BE, 0x550C7DC3,
|
||||||
|
0x72BE5D74, 0x80DEB1FE, 0x9BDC06A7, 0xC19BF174,
|
||||||
|
0xE49B69C1, 0xEFBE4786, 0x0FC19DC6, 0x240CA1CC,
|
||||||
|
0x2DE92C6F, 0x4A7484AA, 0x5CB0A9DC, 0x76F988DA,
|
||||||
|
0x983E5152, 0xA831C66D, 0xB00327C8, 0xBF597FC7,
|
||||||
|
0xC6E00BF3, 0xD5A79147, 0x06CA6351, 0x14292967,
|
||||||
|
0x27B70A85, 0x2E1B2138, 0x4D2C6DFC, 0x53380D13,
|
||||||
|
0x650A7354, 0x766A0ABB, 0x81C2C92E, 0x92722C85,
|
||||||
|
0xA2BFE8A1, 0xA81A664B, 0xC24B8B70, 0xC76C51A3,
|
||||||
|
0xD192E819, 0xD6990624, 0xF40E3585, 0x106AA070,
|
||||||
|
0x19A4C116, 0x1E376C08, 0x2748774C, 0x34B0BCB5,
|
||||||
|
0x391C0CB3, 0x4ED8AA4A, 0x5B9CCA4F, 0x682E6FF3,
|
||||||
|
0x748F82EE, 0x78A5636F, 0x84C87814, 0x8CC70208,
|
||||||
|
0x90BEFFFA, 0xA4506CEB, 0xBEF9A3F7, 0xC67178F2
|
||||||
|
};
|
||||||
|
|
||||||
|
static void
|
||||||
|
transform(uint32_t state[8], const uint8_t data[64])
|
||||||
|
{
|
||||||
|
uint32_t W[16];
|
||||||
|
uint32_t T[8];
|
||||||
|
unsigned int j;
|
||||||
|
|
||||||
|
/* Copy state[] to working vars. */
|
||||||
|
memcpy(T, state, sizeof(T));
|
||||||
|
|
||||||
|
/* The first 16 operations unrolled */
|
||||||
|
R0( 0); R0( 1); R0( 2); R0( 3);
|
||||||
|
R0( 4); R0( 5); R0( 6); R0( 7);
|
||||||
|
R0( 8); R0( 9); R0(10); R0(11);
|
||||||
|
R0(12); R0(13); R0(14); R0(15);
|
||||||
|
|
||||||
|
/* The remaining 48 operations partially unrolled */
|
||||||
|
for (j = 16; j < 64; j += 16) {
|
||||||
|
R2( 0); R2( 1); R2( 2); R2( 3);
|
||||||
|
R2( 4); R2( 5); R2( 6); R2( 7);
|
||||||
|
R2( 8); R2( 9); R2(10); R2(11);
|
||||||
|
R2(12); R2(13); R2(14); R2(15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add the working vars back into state[]. */
|
||||||
|
state[0] += a(0);
|
||||||
|
state[1] += b(0);
|
||||||
|
state[2] += c(0);
|
||||||
|
state[3] += d(0);
|
||||||
|
state[4] += e(0);
|
||||||
|
state[5] += f(0);
|
||||||
|
state[6] += g(0);
|
||||||
|
state[7] += h(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
XZ_EXTERN void xz_sha256_reset(struct xz_sha256 *s)
|
||||||
|
{
|
||||||
|
static const uint32_t initial_state[8] = {
|
||||||
|
0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A,
|
||||||
|
0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19
|
||||||
|
};
|
||||||
|
|
||||||
|
memcpy(s->state, initial_state, sizeof(initial_state));
|
||||||
|
s->size = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
XZ_EXTERN void xz_sha256_update(const uint8_t *buf, size_t size,
|
||||||
|
struct xz_sha256 *s)
|
||||||
|
{
|
||||||
|
size_t copy_start;
|
||||||
|
size_t copy_size;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copy the input data into a properly aligned temporary buffer.
|
||||||
|
* This way we can be called with arbitrarily sized buffers
|
||||||
|
* (no need to be a multiple of 64 bytes).
|
||||||
|
*
|
||||||
|
* Full 64-byte chunks could be processed directly from buf with
|
||||||
|
* unaligned access. It seemed to make very little difference in
|
||||||
|
* speed on x86-64 though. Thus it was omitted.
|
||||||
|
*/
|
||||||
|
while (size > 0) {
|
||||||
|
copy_start = s->size & 0x3F;
|
||||||
|
copy_size = 64 - copy_start;
|
||||||
|
if (copy_size > size)
|
||||||
|
copy_size = size;
|
||||||
|
|
||||||
|
memcpy(s->data + copy_start, buf, copy_size);
|
||||||
|
|
||||||
|
buf += copy_size;
|
||||||
|
size -= copy_size;
|
||||||
|
s->size += copy_size;
|
||||||
|
|
||||||
|
if ((s->size & 0x3F) == 0)
|
||||||
|
transform(s->state, s->data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
XZ_EXTERN bool xz_sha256_validate(const uint8_t *buf, struct xz_sha256 *s)
|
||||||
|
{
|
||||||
|
/*
|
||||||
|
* Add padding as described in RFC 3174 (it describes SHA-1 but
|
||||||
|
* the same padding style is used for SHA-256 too).
|
||||||
|
*/
|
||||||
|
size_t i = s->size & 0x3F;
|
||||||
|
s->data[i++] = 0x80;
|
||||||
|
|
||||||
|
while (i != 64 - 8) {
|
||||||
|
if (i == 64) {
|
||||||
|
transform(s->state, s->data);
|
||||||
|
i = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
s->data[i++] = 0x00;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Convert the message size from bytes to bits. */
|
||||||
|
s->size *= 8;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Store the message size in big endian byte order and
|
||||||
|
* calculate the final hash value.
|
||||||
|
*/
|
||||||
|
for (i = 0; i < 8; ++i)
|
||||||
|
s->data[64 - 8 + i] = (uint8_t)(s->size >> ((7 - i) * 8));
|
||||||
|
|
||||||
|
transform(s->state, s->data);
|
||||||
|
|
||||||
|
/* Compare if the hash value matches the first 32 bytes in buf. */
|
||||||
|
for (i = 0; i < 8; ++i)
|
||||||
|
if (get_unaligned_be32(buf + 4 * i) != s->state[i])
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/* SPDX-License-Identifier: 0BSD */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Definitions for handling the .xz file format
|
||||||
|
*
|
||||||
|
* Author: Lasse Collin <lasse.collin@tukaani.org>
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef XZ_STREAM_H
|
||||||
|
#define XZ_STREAM_H
|
||||||
|
|
||||||
|
#if defined(__KERNEL__) && !XZ_INTERNAL_CRC32
|
||||||
|
# include <linux/crc32.h>
|
||||||
|
# undef crc32
|
||||||
|
# define xz_crc32(buf, size, crc) \
|
||||||
|
(~crc32_le(~(uint32_t)(crc), buf, size))
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*
|
||||||
|
* See the .xz file format specification at
|
||||||
|
* https://tukaani.org/xz/xz-file-format.txt
|
||||||
|
* to understand the container format.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#define STREAM_HEADER_SIZE 12
|
||||||
|
|
||||||
|
#define HEADER_MAGIC "\3757zXZ"
|
||||||
|
#define HEADER_MAGIC_SIZE 6
|
||||||
|
|
||||||
|
#define FOOTER_MAGIC "YZ"
|
||||||
|
#define FOOTER_MAGIC_SIZE 2
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Variable-length integer can hold a 63-bit unsigned integer or a special
|
||||||
|
* value indicating that the value is unknown.
|
||||||
|
*
|
||||||
|
* Experimental: vli_type can be defined to uint32_t to save a few bytes
|
||||||
|
* in code size (no effect on speed). Doing so limits the uncompressed and
|
||||||
|
* compressed size of the file to less than 256 MiB and may also weaken
|
||||||
|
* error detection slightly.
|
||||||
|
*/
|
||||||
|
typedef uint64_t vli_type;
|
||||||
|
|
||||||
|
#define VLI_MAX ((vli_type)-1 / 2)
|
||||||
|
#define VLI_UNKNOWN ((vli_type)-1)
|
||||||
|
|
||||||
|
/* Maximum encoded size of a VLI */
|
||||||
|
#define VLI_BYTES_MAX (sizeof(vli_type) * 8 / 7)
|
||||||
|
|
||||||
|
/* Integrity Check types */
|
||||||
|
enum xz_check {
|
||||||
|
XZ_CHECK_NONE = 0,
|
||||||
|
XZ_CHECK_CRC32 = 1,
|
||||||
|
XZ_CHECK_CRC64 = 4,
|
||||||
|
XZ_CHECK_SHA256 = 10
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Maximum possible Check ID */
|
||||||
|
#define XZ_CHECK_MAX 15
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods
|
package me.kavishdevar.librepods
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
@@ -27,6 +29,7 @@ import android.content.Intent
|
|||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
@@ -105,13 +108,15 @@ import me.kavishdevar.librepods.screens.AppSettingsScreen
|
|||||||
import me.kavishdevar.librepods.screens.DebugScreen
|
import me.kavishdevar.librepods.screens.DebugScreen
|
||||||
import me.kavishdevar.librepods.screens.HeadTrackingScreen
|
import me.kavishdevar.librepods.screens.HeadTrackingScreen
|
||||||
import me.kavishdevar.librepods.screens.LongPress
|
import me.kavishdevar.librepods.screens.LongPress
|
||||||
import me.kavishdevar.librepods.screens.Onboarding
|
// import me.kavishdevar.librepods.screens.Onboarding
|
||||||
import me.kavishdevar.librepods.screens.RenameScreen
|
import me.kavishdevar.librepods.screens.RenameScreen
|
||||||
|
import me.kavishdevar.librepods.screens.TroubleshootingScreen
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.utils.CrossDevice
|
import me.kavishdevar.librepods.utils.CrossDevice
|
||||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
lateinit var serviceConnection: ServiceConnection
|
lateinit var serviceConnection: ServiceConnection
|
||||||
lateinit var connectionStatusReceiver: BroadcastReceiver
|
lateinit var connectionStatusReceiver: BroadcastReceiver
|
||||||
@@ -177,22 +182,35 @@ class MainActivity : ComponentActivity() {
|
|||||||
fun Main() {
|
fun Main() {
|
||||||
val isConnected = remember { mutableStateOf(false) }
|
val isConnected = remember { mutableStateOf(false) }
|
||||||
val isRemotelyConnected = remember { mutableStateOf(false) }
|
val isRemotelyConnected = remember { mutableStateOf(false) }
|
||||||
val hookAvailable = RadareOffsetFinder(LocalContext.current).isHookOffsetAvailable()
|
// val hookAvailable = RadareOffsetFinder(LocalContext.current).isHookOffsetAvailable()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
|
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
|
||||||
val overlaySkipped = remember { mutableStateOf(context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("overlay_permission_skipped", false)) }
|
val overlaySkipped = remember { mutableStateOf(context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("overlay_permission_skipped", false)) }
|
||||||
|
|
||||||
val permissionState = rememberMultiplePermissionsState(
|
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
permissions = listOf(
|
listOf(
|
||||||
"android.permission.BLUETOOTH_CONNECT",
|
"android.permission.BLUETOOTH_CONNECT",
|
||||||
"android.permission.BLUETOOTH_SCAN",
|
"android.permission.BLUETOOTH_SCAN",
|
||||||
"android.permission.BLUETOOTH",
|
"android.permission.BLUETOOTH",
|
||||||
"android.permission.BLUETOOTH_ADMIN",
|
"android.permission.BLUETOOTH_ADMIN",
|
||||||
"android.permission.BLUETOOTH_ADVERTISE",
|
"android.permission.BLUETOOTH_ADVERTISE"
|
||||||
"android.permission.POST_NOTIFICATIONS",
|
|
||||||
"android.permission.READ_PHONE_STATE",
|
|
||||||
"android.permission.ANSWER_PHONE_CALLS",
|
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
"android.permission.BLUETOOTH",
|
||||||
|
"android.permission.BLUETOOTH_ADMIN",
|
||||||
|
"android.permission.ACCESS_FINE_LOCATION"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val otherPermissions = listOf(
|
||||||
|
"android.permission.POST_NOTIFICATIONS",
|
||||||
|
"android.permission.READ_PHONE_STATE",
|
||||||
|
"android.permission.ANSWER_PHONE_CALLS"
|
||||||
|
)
|
||||||
|
val allPermissions = bluetoothPermissions + otherPermissions
|
||||||
|
|
||||||
|
val permissionState = rememberMultiplePermissionsState(
|
||||||
|
permissions = allPermissions
|
||||||
)
|
)
|
||||||
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
|
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
|
||||||
|
|
||||||
@@ -225,7 +243,7 @@ fun Main() {
|
|||||||
) {
|
) {
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = if (hookAvailable) "settings" else "onboarding",
|
startDestination = "settings", // if (hookAvailable) "settings" else "onboarding",
|
||||||
enterTransition = {
|
enterTransition = {
|
||||||
slideInHorizontally(
|
slideInHorizontally(
|
||||||
initialOffsetX = { it },
|
initialOffsetX = { it },
|
||||||
@@ -277,12 +295,15 @@ fun Main() {
|
|||||||
composable("app_settings") {
|
composable("app_settings") {
|
||||||
AppSettingsScreen(navController)
|
AppSettingsScreen(navController)
|
||||||
}
|
}
|
||||||
|
composable("troubleshooting") {
|
||||||
|
TroubleshootingScreen(navController)
|
||||||
|
}
|
||||||
composable("head_tracking") {
|
composable("head_tracking") {
|
||||||
HeadTrackingScreen(navController)
|
HeadTrackingScreen(navController)
|
||||||
}
|
}
|
||||||
composable("onboarding") {
|
// composable("onboarding") {
|
||||||
Onboarding(navController, context)
|
// Onboarding(navController, context)
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,7 +428,7 @@ fun PermissionsScreen(
|
|||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "To provide the best AirPods experience, we need a few permissions",
|
text = "The following permissions are required to use the app. Please grant them to continue.",
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods
|
package me.kavishdevar.librepods
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -72,16 +75,12 @@ import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedBu
|
|||||||
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
|
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.utils.NoiseControlMode
|
import me.kavishdevar.librepods.utils.NoiseControlMode
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
data class DismissAnimationValues(
|
|
||||||
val offsetY: Dp = 0.dp,
|
|
||||||
val scale: Float = 1f,
|
|
||||||
val alpha: Float = 1f
|
|
||||||
)
|
|
||||||
|
|
||||||
class QuickSettingsDialogActivity : ComponentActivity() {
|
class QuickSettingsDialogActivity : ComponentActivity() {
|
||||||
|
|
||||||
private var airPodsService: AirPodsService? = null
|
private var airPodsService: AirPodsService? = null
|
||||||
@@ -114,7 +113,6 @@ class QuickSettingsDialogActivity : ComponentActivity() {
|
|||||||
isNoiseControlExpanded = isNoiseControlExpandedState,
|
isNoiseControlExpanded = isNoiseControlExpandedState,
|
||||||
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
|
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +157,6 @@ class QuickSettingsDialogActivity : ComponentActivity() {
|
|||||||
isNoiseControlExpanded = isNoiseControlExpandedState,
|
isNoiseControlExpanded = isNoiseControlExpandedState,
|
||||||
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
|
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,7 +179,6 @@ fun DraggableDismissBox(
|
|||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val density = LocalDensity.current
|
|
||||||
|
|
||||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||||
var isDragging by remember { mutableStateOf(false) }
|
var isDragging by remember { mutableStateOf(false) }
|
||||||
@@ -218,7 +214,6 @@ fun DraggableDismissBox(
|
|||||||
|
|
||||||
LaunchedEffect(dragOffset, isDragging) {
|
LaunchedEffect(dragOffset, isDragging) {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
val dragDirection = if (dragOffset > 0) 1f else -1f
|
|
||||||
val dragProgress = (abs(dragOffset) / 1000f).coerceIn(0f, 0.5f)
|
val dragProgress = (abs(dragOffset) / 1000f).coerceIn(0f, 0.5f)
|
||||||
|
|
||||||
animatedOffset.snapTo(dragOffset)
|
animatedOffset.snapTo(dragOffset)
|
||||||
@@ -285,6 +280,7 @@ fun DraggableDismissBox(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||||
@Composable
|
@Composable
|
||||||
fun NewControlCenterDialogContent(
|
fun NewControlCenterDialogContent(
|
||||||
service: AirPodsService?,
|
service: AirPodsService?,
|
||||||
@@ -353,7 +349,7 @@ fun NewControlCenterDialogContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
service?.let {
|
service?.let {
|
||||||
val initialModeOrdinal = it.getANC().minus(1) ?: NoiseControlMode.TRANSPARENCY.ordinal
|
val initialModeOrdinal = it.getANC().minus(1)
|
||||||
var initialMode = NoiseControlMode.entries.getOrElse(initialModeOrdinal) { NoiseControlMode.TRANSPARENCY }
|
var initialMode = NoiseControlMode.entries.getOrElse(initialModeOrdinal) { NoiseControlMode.TRANSPARENCY }
|
||||||
if (!availableModes.contains(initialMode)) {
|
if (!availableModes.contains(initialMode)) {
|
||||||
initialMode = NoiseControlMode.TRANSPARENCY
|
initialMode = NoiseControlMode.TRANSPARENCY
|
||||||
@@ -482,7 +478,10 @@ fun NewControlCenterDialogContent(
|
|||||||
availableModes = availableModes,
|
availableModes = availableModes,
|
||||||
selectedMode = currentAncMode,
|
selectedMode = currentAncMode,
|
||||||
onModeSelected = { newMode ->
|
onModeSelected = { newMode ->
|
||||||
service.setANCMode(newMode.ordinal + 1)
|
service.aacpManager.sendControlCommand(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
|
||||||
|
value = newMode.ordinal + 1
|
||||||
|
)
|
||||||
currentAncMode = newMode
|
currentAncMode = newMode
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(0.8f)
|
modifier = Modifier.fillMaxWidth(0.8f)
|
||||||
@@ -560,7 +559,10 @@ fun NewControlCenterDialogContent(
|
|||||||
.clickable(
|
.clickable(
|
||||||
onClick = {
|
onClick = {
|
||||||
val newState = !isConvAwarenessEnabled
|
val newState = !isConvAwarenessEnabled
|
||||||
service.setCAEnabled(newState)
|
service.aacpManager.sendControlCommand(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value,
|
||||||
|
value = newState
|
||||||
|
)
|
||||||
isConvAwarenessEnabled = newState
|
isConvAwarenessEnabled = newState
|
||||||
},
|
},
|
||||||
indication = null,
|
indication = null,
|
||||||
|
|||||||
+57
-32
@@ -16,10 +16,10 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -38,7 +38,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -46,14 +45,16 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
fun AccessibilitySettings() {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
val service = ServiceManager.getService()!!
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.accessibility).uppercase(),
|
text = stringResource(R.string.accessibility).uppercase(),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
@@ -87,51 +88,75 @@ fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPref
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
ToneVolumeSlider(service = service, sharedPreferences = sharedPreferences)
|
ToneVolumeSlider()
|
||||||
}
|
}
|
||||||
|
|
||||||
val pressSpeedOptions = listOf("Default", "Slower", "Slowest")
|
val pressSpeedOptions = mapOf(
|
||||||
var selectedPressSpeed by remember { mutableStateOf(pressSpeedOptions[0]) }
|
0.toByte() to "Default",
|
||||||
|
1.toByte() to "Slower",
|
||||||
|
2.toByte() to "Slowest"
|
||||||
|
)
|
||||||
|
val selectedPressSpeedValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
|
var selectedPressSpeed by remember { mutableStateOf(pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]) }
|
||||||
DropdownMenuComponent(
|
DropdownMenuComponent(
|
||||||
label = "Press Speed",
|
label = "Press Speed",
|
||||||
options = pressSpeedOptions,
|
options = pressSpeedOptions.values.toList(),
|
||||||
selectedOption = selectedPressSpeed,
|
selectedOption = selectedPressSpeed.toString(),
|
||||||
onOptionSelected = {
|
onOptionSelected = { newValue ->
|
||||||
selectedPressSpeed = it
|
selectedPressSpeed = newValue
|
||||||
service.setPressSpeed(pressSpeedOptions.indexOf(it))
|
service.aacpManager.sendControlCommand(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value,
|
||||||
|
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte()
|
||||||
|
)
|
||||||
},
|
},
|
||||||
textColor = textColor
|
textColor = textColor
|
||||||
)
|
)
|
||||||
|
|
||||||
val pressAndHoldDurationOptions = listOf("Default", "Slower", "Slowest")
|
val pressAndHoldDurationOptions = mapOf(
|
||||||
var selectedPressAndHoldDuration by remember { mutableStateOf(pressAndHoldDurationOptions[0]) }
|
0.toByte() to "Default",
|
||||||
|
1.toByte() to "Slower",
|
||||||
|
2.toByte() to "Slowest"
|
||||||
|
)
|
||||||
|
|
||||||
|
val selectedPressAndHoldDurationValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
|
var selectedPressAndHoldDuration by remember { mutableStateOf(pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] ?: pressAndHoldDurationOptions[0]) }
|
||||||
DropdownMenuComponent(
|
DropdownMenuComponent(
|
||||||
label = "Press and Hold Duration",
|
label = "Press and Hold Duration",
|
||||||
options = pressAndHoldDurationOptions,
|
options = pressAndHoldDurationOptions.values.toList(),
|
||||||
selectedOption = selectedPressAndHoldDuration,
|
selectedOption = selectedPressAndHoldDuration.toString(),
|
||||||
onOptionSelected = {
|
onOptionSelected = { newValue ->
|
||||||
selectedPressAndHoldDuration = it
|
selectedPressAndHoldDuration = newValue
|
||||||
service.setPressAndHoldDuration(pressAndHoldDurationOptions.indexOf(it))
|
service.aacpManager.sendControlCommand(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value,
|
||||||
|
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte()
|
||||||
|
)
|
||||||
},
|
},
|
||||||
textColor = textColor
|
textColor = textColor
|
||||||
)
|
)
|
||||||
|
|
||||||
val volumeSwipeSpeedOptions = listOf("Default", "Longer", "Longest")
|
val volumeSwipeSpeedOptions = mapOf<Byte, String>(
|
||||||
var selectedVolumeSwipeSpeed by remember { mutableStateOf(volumeSwipeSpeedOptions[0]) }
|
1.toByte() to "Default",
|
||||||
|
2.toByte() to "Longer",
|
||||||
|
3.toByte() to "Longest"
|
||||||
|
)
|
||||||
|
val selectedVolumeSwipeSpeedValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
|
var selectedVolumeSwipeSpeed by remember { mutableStateOf(volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] ?: volumeSwipeSpeedOptions[1]) }
|
||||||
DropdownMenuComponent(
|
DropdownMenuComponent(
|
||||||
label = "Volume Swipe Speed",
|
label = "Volume Swipe Speed",
|
||||||
options = volumeSwipeSpeedOptions,
|
options = volumeSwipeSpeedOptions.values.toList(),
|
||||||
selectedOption = selectedVolumeSwipeSpeed,
|
selectedOption = selectedVolumeSwipeSpeed.toString(),
|
||||||
onOptionSelected = {
|
onOptionSelected = { newValue ->
|
||||||
selectedVolumeSwipeSpeed = it
|
selectedVolumeSwipeSpeed = newValue
|
||||||
service.setVolumeSwipeSpeed(volumeSwipeSpeedOptions.indexOf(it))
|
service.aacpManager.sendControlCommand(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value,
|
||||||
|
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 1.toByte()
|
||||||
|
)
|
||||||
},
|
},
|
||||||
textColor = textColor
|
textColor = textColor
|
||||||
)
|
)
|
||||||
|
|
||||||
SinglePodANCSwitch(service = service, sharedPreferences = sharedPreferences)
|
SinglePodANCSwitch()
|
||||||
VolumeControlSwitch(service = service, sharedPreferences = sharedPreferences)
|
VolumeControlSwitch()
|
||||||
// TransparencySettings(service = service, sharedPreferences = sharedPreferences)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,5 +217,5 @@ fun DropdownMenuComponent(
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun AccessibilitySettingsPreview() {
|
fun AccessibilitySettingsPreview() {
|
||||||
AccessibilitySettings(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
|
AccessibilitySettings()
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-13
@@ -16,10 +16,10 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -44,26 +44,26 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
fun AdaptiveStrengthSlider() {
|
||||||
val sliderValue = remember { mutableFloatStateOf(0f) }
|
val sliderValue = remember { mutableFloatStateOf(0f) }
|
||||||
|
val service = ServiceManager.getService()!!
|
||||||
LaunchedEffect(sliderValue) {
|
LaunchedEffect(sliderValue) {
|
||||||
if (sharedPreferences.contains("adaptive_strength")) {
|
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
|
||||||
sliderValue.floatValue = sharedPreferences.getInt("adaptive_strength", 0).toFloat()
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH
|
||||||
}
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
}
|
sliderValueFromAACP?.toFloat()?.let { sliderValue.floatValue = (100 - it) }
|
||||||
LaunchedEffect(sliderValue.floatValue) {
|
|
||||||
sharedPreferences.edit().putInt("adaptive_strength", sliderValue.floatValue.toInt()).apply()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
@@ -86,7 +86,10 @@ fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPre
|
|||||||
valueRange = 0f..100f,
|
valueRange = 0f..100f,
|
||||||
onValueChangeFinished = {
|
onValueChangeFinished = {
|
||||||
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
|
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
|
||||||
service.setAdaptiveStrength(100 - sliderValue.floatValue.toInt())
|
service.aacpManager.sendControlCommand(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value,
|
||||||
|
value = (100 - sliderValue.floatValue).toInt()
|
||||||
|
)
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -151,5 +154,5 @@ fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPre
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun AdaptiveStrengthSliderPreview() {
|
fun AdaptiveStrengthSliderPreview() {
|
||||||
AdaptiveStrengthSlider(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
|
AdaptiveStrengthSlider()
|
||||||
}
|
}
|
||||||
@@ -16,10 +16,10 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -30,7 +30,6 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -38,10 +37,10 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
fun AudioSettings() {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
@@ -64,9 +63,7 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
|
|||||||
.padding(top = 2.dp)
|
.padding(top = 2.dp)
|
||||||
) {
|
) {
|
||||||
|
|
||||||
PersonalizedVolumeSwitch(service = service, sharedPreferences = sharedPreferences)
|
ConversationalAwarenessSwitch()
|
||||||
ConversationalAwarenessSwitch(service = service, sharedPreferences = sharedPreferences)
|
|
||||||
LoudSoundReductionSwitch(service = service, sharedPreferences = sharedPreferences)
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -95,7 +92,7 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
AdaptiveStrengthSlider(service = service, sharedPreferences = sharedPreferences)
|
AdaptiveStrengthSlider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,5 +100,5 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioSettingsPreview() {
|
fun AudioSettingsPreview() {
|
||||||
AudioSettings(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
|
AudioSettings()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
@@ -50,6 +52,7 @@ import me.kavishdevar.librepods.utils.AirPodsNotifications
|
|||||||
import me.kavishdevar.librepods.utils.Battery
|
import me.kavishdevar.librepods.utils.Battery
|
||||||
import me.kavishdevar.librepods.utils.BatteryComponent
|
import me.kavishdevar.librepods.utils.BatteryComponent
|
||||||
import me.kavishdevar.librepods.utils.BatteryStatus
|
import me.kavishdevar.librepods.utils.BatteryStatus
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BatteryView(service: AirPodsService, preview: Boolean = false) {
|
fun BatteryView(service: AirPodsService, preview: Boolean = false) {
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
*
|
*
|
||||||
* Copyright (C) 2025 Kavish Devar
|
* Copyright (C) 2025 LibrePods Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
*
|
*
|
||||||
* Copyright (C) 2025 Kavish Devar
|
* Copyright (C) 2025 LibrePods Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
@@ -39,7 +39,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -88,7 +88,7 @@ fun ControlCenterNoiseControlSegmentedButton(
|
|||||||
) {
|
) {
|
||||||
val selectedIndex = availableModes.indexOf(selectedMode).coerceAtLeast(0)
|
val selectedIndex = availableModes.indexOf(selectedMode).coerceAtLeast(0)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
var iconRowWidthPx by remember { mutableStateOf(0f) }
|
var iconRowWidthPx by remember { mutableFloatStateOf(0f) }
|
||||||
val itemCount = availableModes.size
|
val itemCount = availableModes.size
|
||||||
|
|
||||||
val itemSlotWidthPx = remember(iconRowWidthPx, itemCount) {
|
val itemSlotWidthPx = remember(iconRowWidthPx, itemCount) {
|
||||||
|
|||||||
+16
-8
@@ -16,9 +16,10 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
@@ -41,24 +42,31 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
fun ConversationalAwarenessSwitch() {
|
||||||
|
val service = ServiceManager.getService()!!
|
||||||
|
val conversationEnabledValue = service.aacpManager.controlCommandStatusList.find {
|
||||||
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG
|
||||||
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
var conversationalAwarenessEnabled by remember {
|
var conversationalAwarenessEnabled by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
sharedPreferences.getBoolean("conversational_awareness", true)
|
conversationEnabledValue == 1.toByte()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateConversationalAwareness(enabled: Boolean) {
|
fun updateConversationalAwareness(enabled: Boolean) {
|
||||||
conversationalAwarenessEnabled = enabled
|
conversationalAwarenessEnabled = enabled
|
||||||
sharedPreferences.edit().putBoolean("conversational_awareness", enabled).apply()
|
service.aacpManager.sendControlCommand(
|
||||||
service.setCAEnabled(enabled)
|
AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value,
|
||||||
|
enabled
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
@@ -121,5 +129,5 @@ fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: Sh
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun ConversationalAwarenessSwitchPreview() {
|
fun ConversationalAwarenessSwitchPreview() {
|
||||||
ConversationalAwarenessSwitch(AirPodsService(), LocalContext.current.getSharedPreferences("preview", 0))
|
ConversationalAwarenessSwitch()
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-17
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
@@ -46,17 +48,40 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IndependentToggle(name: String, service: AirPodsService? = null, functionName: String? = null, sharedPreferences: SharedPreferences, default: Boolean = false) {
|
fun IndependentToggle(name: String, service: AirPodsService? = null, functionName: String? = null, sharedPreferences: SharedPreferences, default: Boolean = false, controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers? = null) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val snakeCasedName =
|
||||||
val snakeCasedName = name.replace(Regex("[\\W\\s]+"), "_").lowercase()
|
controlCommandIdentifier?.name ?: name.replace(Regex("[\\W\\s]+"), "_").lowercase()
|
||||||
var checked by remember { mutableStateOf(default) }
|
var checked by remember { mutableStateOf(default) }
|
||||||
|
|
||||||
|
if (controlCommandIdentifier != null) {
|
||||||
|
checked = service!!.aacpManager.controlCommandStatusList.find {
|
||||||
|
it.identifier == controlCommandIdentifier
|
||||||
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
|
||||||
|
}
|
||||||
|
|
||||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||||
|
|
||||||
|
fun cb() {
|
||||||
|
if (controlCommandIdentifier == null) {
|
||||||
|
sharedPreferences.edit().putBoolean(snakeCasedName, checked).apply()
|
||||||
|
}
|
||||||
|
if (functionName != null && service != null) {
|
||||||
|
val method =
|
||||||
|
service::class.java.getMethod(functionName, Boolean::class.java)
|
||||||
|
method.invoke(service, checked)
|
||||||
|
}
|
||||||
|
if (controlCommandIdentifier != null) {
|
||||||
|
service?.aacpManager?.sendControlCommand(identifier = controlCommandIdentifier.value, value = checked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(sharedPreferences) {
|
LaunchedEffect(sharedPreferences) {
|
||||||
checked = sharedPreferences.getBoolean(snakeCasedName, true)
|
checked = sharedPreferences.getBoolean(snakeCasedName, true)
|
||||||
}
|
}
|
||||||
@@ -73,14 +98,7 @@ fun IndependentToggle(name: String, service: AirPodsService? = null, functionNam
|
|||||||
},
|
},
|
||||||
onTap = {
|
onTap = {
|
||||||
checked = !checked
|
checked = !checked
|
||||||
sharedPreferences
|
cb()
|
||||||
.edit()
|
|
||||||
.putBoolean(snakeCasedName, checked)
|
|
||||||
.apply()
|
|
||||||
if (functionName != null && service != null) {
|
|
||||||
val method = service::class.java.getMethod(functionName, Boolean::class.java)
|
|
||||||
method.invoke(service, checked)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -98,12 +116,7 @@ fun IndependentToggle(name: String, service: AirPodsService? = null, functionNam
|
|||||||
checked = checked,
|
checked = checked,
|
||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
checked = it
|
checked = it
|
||||||
sharedPreferences.edit().putBoolean(snakeCasedName, it).apply()
|
cb()
|
||||||
if (functionName != null && service != null) {
|
|
||||||
val method =
|
|
||||||
service::class.java.getMethod(functionName, Boolean::class.java)
|
|
||||||
method.invoke(service, it)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
-126
@@ -1,126 +0,0 @@
|
|||||||
/*
|
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
*
|
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LoudSoundReductionSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
|
||||||
var loudSoundReductionEnabled by remember {
|
|
||||||
mutableStateOf(
|
|
||||||
sharedPreferences.getBoolean("loud_sound_reduction", true)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateLoudSoundReduction(enabled: Boolean) {
|
|
||||||
loudSoundReductionEnabled = enabled
|
|
||||||
sharedPreferences.edit().putBoolean("loud_sound_reduction", enabled).apply()
|
|
||||||
service.setLoudSoundReduction(enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
|
|
||||||
val isPressed = remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
shape = RoundedCornerShape(14.dp),
|
|
||||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
|
||||||
)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 12.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
isPressed.value = true
|
|
||||||
tryAwaitRelease()
|
|
||||||
isPressed.value = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
) {
|
|
||||||
updateLoudSoundReduction(!loudSoundReductionEnabled)
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Loud Sound Reduction",
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "Reduces loud sounds you are exposed to.",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
lineHeight = 14.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledSwitch(
|
|
||||||
checked = loudSoundReductionEnabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
updateLoudSoundReduction(it)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun LoudSoundReductionSwitchPreview() {
|
|
||||||
LoudSoundReductionSwitch(AirPodsService(), LocalContext.current.getSharedPreferences("preview", 0))
|
|
||||||
}
|
|
||||||
+21
-28
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
@@ -23,7 +25,6 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.animation.core.AnimationSpec
|
import androidx.compose.animation.core.AnimationSpec
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.Spring
|
||||||
@@ -50,7 +51,6 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.VerticalDivider
|
import androidx.compose.material3.VerticalDivider
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -74,34 +74,33 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.utils.NoiseControlMode
|
import me.kavishdevar.librepods.utils.NoiseControlMode
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
|
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
|
||||||
@Composable
|
@Composable
|
||||||
fun NoiseControlSettings(
|
fun NoiseControlSettings(
|
||||||
service: AirPodsService,
|
service: AirPodsService,
|
||||||
onModeSelectedCallback: () -> Unit = {} // Callback parameter remains, but won't finish activity
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
val offListeningModeConfigValue = service.aacpManager.controlCommandStatusList.find {
|
||||||
val offListeningMode = remember { mutableStateOf(sharedPreferences.getBoolean("off_listening_mode", true)) }
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||||
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
|
||||||
|
val offListeningMode = remember { mutableStateOf(offListeningModeConfigValue) }
|
||||||
|
|
||||||
val preferenceChangeListener = remember {
|
val offListeningModeListener = object: AACPManager.ControlCommandListener {
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||||
if (key == "off_listening_mode") {
|
offListeningMode.value = controlCommand.value[0] == 1.toByte()
|
||||||
offListeningMode.value = sharedPreferences.getBoolean("off_listening_mode", true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
service.aacpManager.registerControlCommandListener(
|
||||||
sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
|
AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
|
||||||
onDispose {
|
offListeningModeListener
|
||||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
|
||||||
@@ -116,27 +115,21 @@ fun NoiseControlSettings(
|
|||||||
val d3a = remember { mutableFloatStateOf(0f) }
|
val d3a = remember { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
|
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
|
||||||
val previousMode = noiseControlMode.value // Store previous mode
|
val previousMode = noiseControlMode.value
|
||||||
|
|
||||||
// Ensure the mode is valid if 'Off' is disabled
|
|
||||||
val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) {
|
val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) {
|
||||||
// If trying to select OFF but it's disabled, default to Transparency or Adaptive
|
NoiseControlMode.TRANSPARENCY
|
||||||
NoiseControlMode.TRANSPARENCY // Or ADAPTIVE, based on preference
|
|
||||||
} else {
|
} else {
|
||||||
mode
|
mode
|
||||||
}
|
}
|
||||||
|
|
||||||
noiseControlMode.value = targetMode // Update internal state immediately
|
noiseControlMode.value = targetMode
|
||||||
|
|
||||||
// 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) {
|
if (!received && targetMode != previousMode) {
|
||||||
service.setANCMode(targetMode.ordinal + 1)
|
service.aacpManager.sendControlCommand(identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, value = targetMode.ordinal + 1)
|
||||||
// onModeSelectedCallback() // REMOVE this call to keep dialog open
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update divider alphas based on the *new* mode
|
when (noiseControlMode.value) {
|
||||||
when (noiseControlMode.value) { // Use the updated noiseControlMode.value
|
|
||||||
NoiseControlMode.NOISE_CANCELLATION -> {
|
NoiseControlMode.NOISE_CANCELLATION -> {
|
||||||
d1a.floatValue = 1f
|
d1a.floatValue = 1f
|
||||||
d2a.floatValue = 1f
|
d2a.floatValue = 1f
|
||||||
@@ -447,5 +440,5 @@ fun NoiseControlSettings(
|
|||||||
@Preview()
|
@Preview()
|
||||||
@Composable
|
@Composable
|
||||||
fun NoiseControlSettingsPreview() {
|
fun NoiseControlSettingsPreview() {
|
||||||
NoiseControlSettings(AirPodsService()) {}
|
NoiseControlSettings(AirPodsService())
|
||||||
}
|
}
|
||||||
-126
@@ -1,126 +0,0 @@
|
|||||||
/*
|
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
*
|
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PersonalizedVolumeSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
|
||||||
var personalizedVolumeEnabled by remember {
|
|
||||||
mutableStateOf(
|
|
||||||
sharedPreferences.getBoolean("personalized_volume", true)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updatePersonalizedVolume(enabled: Boolean) {
|
|
||||||
personalizedVolumeEnabled = enabled
|
|
||||||
sharedPreferences.edit().putBoolean("personalized_volume", enabled).apply()
|
|
||||||
service.setPVEnabled(enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
|
|
||||||
val isPressed = remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
shape = RoundedCornerShape(14.dp),
|
|
||||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
|
||||||
)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 12.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
isPressed.value = true
|
|
||||||
tryAwaitRelease()
|
|
||||||
isPressed.value = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
) {
|
|
||||||
updatePersonalizedVolume(!personalizedVolumeEnabled)
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Personalized Volume",
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "Adjusts the volume of media in response to your environment.",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
lineHeight = 14.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledSwitch(
|
|
||||||
checked = personalizedVolumeEnabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
updatePersonalizedVolume(it)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun PersonalizedVolumeSwitchPreview() {
|
|
||||||
PersonalizedVolumeSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
|
|
||||||
}
|
|
||||||
+16
-8
@@ -16,9 +16,10 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
@@ -41,24 +42,31 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SinglePodANCSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
fun SinglePodANCSwitch() {
|
||||||
|
val service = ServiceManager.getService()!!
|
||||||
|
val singleANCEnabledValue = service.aacpManager.controlCommandStatusList.find {
|
||||||
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE
|
||||||
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
var singleANCEnabled by remember {
|
var singleANCEnabled by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
sharedPreferences.getBoolean("single_anc", true)
|
singleANCEnabledValue == 1.toByte()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateSingleEnabled(enabled: Boolean) {
|
fun updateSingleEnabled(enabled: Boolean) {
|
||||||
singleANCEnabled = enabled
|
singleANCEnabled = enabled
|
||||||
sharedPreferences.edit().putBoolean("single_anc", enabled).apply()
|
service.aacpManager.sendControlCommand(
|
||||||
service.setNoiseCancellationWithOnePod(enabled)
|
AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE.value,
|
||||||
|
enabled
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
@@ -121,5 +129,5 @@ fun SinglePodANCSwitch(service: AirPodsService, sharedPreferences: SharedPrefere
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun SinglePodANCSwitchPreview() {
|
fun SinglePodANCSwitchPreview() {
|
||||||
SinglePodANCSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
|
SinglePodANCSwitch()
|
||||||
}
|
}
|
||||||
+22
-17
@@ -16,9 +16,11 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
import android.util.Log
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -35,14 +37,12 @@ import androidx.compose.material3.Slider
|
|||||||
import androidx.compose.material3.SliderDefaults
|
import androidx.compose.material3.SliderDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -51,21 +51,22 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
fun ToneVolumeSlider() {
|
||||||
val sliderValue = remember { mutableFloatStateOf(0f) }
|
val service = ServiceManager.getService()!!
|
||||||
LaunchedEffect(sliderValue) {
|
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
|
||||||
if (sharedPreferences.contains("tone_volume")) {
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME
|
||||||
sliderValue.floatValue = sharedPreferences.getInt("tone_volume", 0).toFloat()
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
}
|
val sliderValue = remember { mutableFloatStateOf(
|
||||||
}
|
sliderValueFromAACP?.toFloat() ?: -1f
|
||||||
LaunchedEffect(sliderValue.floatValue) {
|
) }
|
||||||
sharedPreferences.edit().putInt("tone_volume", sliderValue.floatValue.toInt()).apply()
|
Log.d("ToneVolumeSlider", "Slider value: ${sliderValue.floatValue}")
|
||||||
}
|
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
|
||||||
@@ -74,7 +75,6 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
|
|||||||
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
|
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
|
||||||
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
|
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
@@ -99,7 +99,12 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
|
|||||||
valueRange = 0f..100f,
|
valueRange = 0f..100f,
|
||||||
onValueChangeFinished = {
|
onValueChangeFinished = {
|
||||||
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
|
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
|
||||||
service.setToneVolume(volume = sliderValue.floatValue.toInt())
|
service.aacpManager.sendControlCommand(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value,
|
||||||
|
value = byteArrayOf(sliderValue.floatValue.toInt().toByte(),
|
||||||
|
0x50.toByte()
|
||||||
|
)
|
||||||
|
)
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -156,5 +161,5 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun ToneVolumeSliderPreview() {
|
fun ToneVolumeSliderPreview() {
|
||||||
ToneVolumeSlider(AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
|
ToneVolumeSlider()
|
||||||
}
|
}
|
||||||
|
|||||||
-270
@@ -1,270 +0,0 @@
|
|||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Slider
|
|
||||||
import androidx.compose.material3.SliderDefaults
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun TransparencySettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
|
|
||||||
var transparencyModeCustomizationEnabled by remember { mutableStateOf(sharedPreferences.getBoolean("transparency_mode_customization", false)) }
|
|
||||||
var amplification by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_amplification", 0)) }
|
|
||||||
var balance by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_balance", 0)) }
|
|
||||||
var tone by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_tone", 0)) }
|
|
||||||
var ambientNoise by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_ambient_noise", 0)) }
|
|
||||||
var conversationBoostEnabled by remember { mutableStateOf(sharedPreferences.getBoolean("transparency_conversation_boost", false)) }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp, vertical = 12.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
) {
|
|
||||||
transparencyModeCustomizationEnabled = !transparencyModeCustomizationEnabled
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Transparency Mode",
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "You can customize Transparency mode for your AirPods Pro.",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
lineHeight = 14.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
StyledSwitch(
|
|
||||||
checked = transparencyModeCustomizationEnabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
transparencyModeCustomizationEnabled = it
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (transparencyModeCustomizationEnabled) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
SliderRow(
|
|
||||||
label = "Amplification",
|
|
||||||
value = amplification,
|
|
||||||
onValueChange = {
|
|
||||||
amplification = it
|
|
||||||
sharedPreferences.edit().putInt("transparency_amplification", it).apply()
|
|
||||||
},
|
|
||||||
isDarkTheme = isDarkTheme
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
SliderRow(
|
|
||||||
label = "Balance",
|
|
||||||
value = balance,
|
|
||||||
onValueChange = {
|
|
||||||
balance = it
|
|
||||||
sharedPreferences.edit().putInt("transparency_balance", it).apply()
|
|
||||||
},
|
|
||||||
isDarkTheme = isDarkTheme
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
SliderRow(
|
|
||||||
label = "Tone",
|
|
||||||
value = tone,
|
|
||||||
onValueChange = {
|
|
||||||
tone = it
|
|
||||||
sharedPreferences.edit().putInt("transparency_tone", it).apply()
|
|
||||||
},
|
|
||||||
isDarkTheme = isDarkTheme
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
SliderRow(
|
|
||||||
label = "Ambient Noise",
|
|
||||||
value = ambientNoise,
|
|
||||||
onValueChange = {
|
|
||||||
ambientNoise = it
|
|
||||||
sharedPreferences.edit().putInt("transparency_ambient_noise", it).apply()
|
|
||||||
},
|
|
||||||
isDarkTheme = isDarkTheme
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
) {
|
|
||||||
conversationBoostEnabled = !conversationBoostEnabled
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Conversation Boost",
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "Conversation Boost focuses your AirPods on the person in front of you, making it easier to hear in a face-to-face conversation.",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
lineHeight = 14.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
StyledSwitch(
|
|
||||||
checked = conversationBoostEnabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
conversationBoostEnabled = it
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun SliderRow(
|
|
||||||
label: String,
|
|
||||||
value: Int,
|
|
||||||
onValueChange: (Int) -> Unit,
|
|
||||||
isDarkTheme: Boolean
|
|
||||||
) {
|
|
||||||
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
|
|
||||||
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
|
||||||
val thumbColor = Color(0xFFFFFFFF)
|
|
||||||
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = labelTextColor,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "\uDBC0\uDEA1",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = labelTextColor,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(start = 4.dp)
|
|
||||||
)
|
|
||||||
Slider(
|
|
||||||
value = value.toFloat(),
|
|
||||||
onValueChange = {
|
|
||||||
onValueChange(it.toInt())
|
|
||||||
},
|
|
||||||
valueRange = 0f..100f,
|
|
||||||
onValueChangeFinished = {
|
|
||||||
onValueChange(value)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.height(36.dp),
|
|
||||||
colors = SliderDefaults.colors(
|
|
||||||
thumbColor = thumbColor,
|
|
||||||
activeTrackColor = activeTrackColor,
|
|
||||||
inactiveTrackColor = trackColor
|
|
||||||
),
|
|
||||||
thumb = {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(24.dp)
|
|
||||||
.shadow(4.dp, CircleShape)
|
|
||||||
.background(thumbColor, CircleShape)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
track = {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(12.dp),
|
|
||||||
contentAlignment = Alignment.CenterStart
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(4.dp)
|
|
||||||
.background(trackColor, RoundedCornerShape(4.dp))
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(value.toFloat() / 100)
|
|
||||||
.height(4.dp)
|
|
||||||
.background(activeTrackColor, RoundedCornerShape(4.dp))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "\uDBC0\uDEA9",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = labelTextColor,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(end = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
*
|
*
|
||||||
* Copyright (C) 2025 Kavish Devar
|
* Copyright (C) 2025 LibrePods Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
|||||||
+16
-8
@@ -16,9 +16,10 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
@@ -41,23 +42,30 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VolumeControlSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
fun VolumeControlSwitch() {
|
||||||
|
val service = ServiceManager.getService()!!
|
||||||
|
val volumeControlEnabledValue = service.aacpManager.controlCommandStatusList.find {
|
||||||
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE
|
||||||
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
var volumeControlEnabled by remember {
|
var volumeControlEnabled by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
sharedPreferences.getBoolean("volume_control", true)
|
volumeControlEnabledValue == 1.toByte()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
fun updateVolumeControlEnabled(enabled: Boolean) {
|
fun updateVolumeControlEnabled(enabled: Boolean) {
|
||||||
volumeControlEnabled = enabled
|
volumeControlEnabled = enabled
|
||||||
sharedPreferences.edit().putBoolean("volume_control", enabled).apply()
|
service.aacpManager.sendControlCommand(
|
||||||
service.setVolumeControl(enabled)
|
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE.value,
|
||||||
|
enabled
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
@@ -120,5 +128,5 @@ fun VolumeControlSwitch(service: AirPodsService, sharedPreferences: SharedPrefer
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun VolumeControlSwitchPreview() {
|
fun VolumeControlSwitchPreview() {
|
||||||
VolumeControlSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
|
VolumeControlSwitch()
|
||||||
}
|
}
|
||||||
@@ -16,11 +16,14 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.receivers
|
package me.kavishdevar.librepods.receivers
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
|
|
||||||
class BootReceiver: BroadcastReceiver() {
|
class BootReceiver: BroadcastReceiver() {
|
||||||
|
|||||||
+79
-28
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.screens
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
@@ -37,10 +39,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -78,9 +83,10 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import dev.chrisbanes.haze.HazeEffectScope
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.haze
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.hazeChild
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -95,13 +101,16 @@ import me.kavishdevar.librepods.composables.NoiseControlSettings
|
|||||||
import me.kavishdevar.librepods.composables.PressAndHoldSettings
|
import me.kavishdevar.librepods.composables.PressAndHoldSettings
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||||
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
||||||
@Composable
|
@Composable
|
||||||
fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
||||||
navController: NavController, isConnected: Boolean, isRemotelyConnected: Boolean) {
|
navController: NavController, isConnected: Boolean, isRemotelyConnected: Boolean) {
|
||||||
|
var isLocallyConnected by remember { mutableStateOf(isConnected) }
|
||||||
var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
|
var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
|
||||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
|
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
|
||||||
var device by remember { mutableStateOf(dev) }
|
var device by remember { mutableStateOf(dev) }
|
||||||
@@ -113,6 +122,10 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(service) {
|
||||||
|
isLocallyConnected = service.isConnectedLocally
|
||||||
|
}
|
||||||
|
|
||||||
val nameChangeListener = remember {
|
val nameChangeListener = remember {
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||||
if (key == "name") {
|
if (key == "name") {
|
||||||
@@ -144,22 +157,37 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
|||||||
}
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val bluetoothReceiver = remember {
|
|
||||||
|
val connectionReceiver = remember {
|
||||||
object : BroadcastReceiver() {
|
object : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
if (intent?.action == "me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY") {
|
when (intent?.action) {
|
||||||
coroutineScope.launch {
|
"me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY" -> {
|
||||||
handleRemoteConnection(true)
|
coroutineScope.launch {
|
||||||
|
handleRemoteConnection(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (intent?.action == "me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY") {
|
"me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY" -> {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
handleRemoteConnection(false)
|
handleRemoteConnection(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
|
AirPodsNotifications.AIRPODS_CONNECTED -> {
|
||||||
try {
|
coroutineScope.launch {
|
||||||
context?.unregisterReceiver(this)
|
isLocallyConnected = true
|
||||||
} catch (e: IllegalArgumentException) {
|
}
|
||||||
e.printStackTrace()
|
}
|
||||||
|
AirPodsNotifications.AIRPODS_DISCONNECTED -> {
|
||||||
|
coroutineScope.launch {
|
||||||
|
isLocallyConnected = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AirPodsNotifications.DISCONNECT_RECEIVERS -> {
|
||||||
|
try {
|
||||||
|
context?.unregisterReceiver(this)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,16 +198,22 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
|||||||
val filter = IntentFilter().apply {
|
val filter = IntentFilter().apply {
|
||||||
addAction("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY")
|
addAction("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY")
|
||||||
addAction("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY")
|
addAction("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY")
|
||||||
|
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
|
||||||
|
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
|
||||||
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
context.registerReceiver(bluetoothReceiver, filter, RECEIVER_EXPORTED)
|
context.registerReceiver(connectionReceiver, filter, RECEIVER_EXPORTED)
|
||||||
} else {
|
} else {
|
||||||
context.registerReceiver(bluetoothReceiver, filter)
|
context.registerReceiver(connectionReceiver, filter)
|
||||||
}
|
}
|
||||||
onDispose {
|
onDispose {
|
||||||
context.unregisterReceiver(bluetoothReceiver)
|
try {
|
||||||
|
context.unregisterReceiver(connectionReceiver)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,14 +240,13 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.hazeChild(
|
.hazeEffect(
|
||||||
state = hazeState,
|
state = hazeState,
|
||||||
style = CupertinoMaterials.thick(),
|
style = CupertinoMaterials.thick(),
|
||||||
block = {
|
block = fun HazeEffectScope.() {
|
||||||
alpha =
|
alpha =
|
||||||
if (verticalScrollState.value > 60.dp.value * mDensity.floatValue) 1f else 0f
|
if (verticalScrollState.value > 60.dp.value * mDensity.floatValue) 1f else 0f
|
||||||
}
|
})
|
||||||
)
|
|
||||||
.drawBehind {
|
.drawBehind {
|
||||||
mDensity.floatValue = density
|
mDensity.floatValue = density
|
||||||
val strokeWidth = 0.7.dp.value * density
|
val strokeWidth = 0.7.dp.value * density
|
||||||
@@ -266,10 +299,10 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
|||||||
},
|
},
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
if (isConnected == true || isRemotelyConnected == true) {
|
if (isLocallyConnected || isRemotelyConnected) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.haze(hazeState)
|
.hazeSource(hazeState)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.verticalScroll(
|
.verticalScroll(
|
||||||
@@ -324,7 +357,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
|||||||
PressAndHoldSettings(navController = navController)
|
PressAndHoldSettings(navController = navController)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
AudioSettings(service = service, sharedPreferences = sharedPreferences)
|
AudioSettings()
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
IndependentToggle(
|
IndependentToggle(
|
||||||
@@ -332,20 +365,20 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
|||||||
service = service,
|
service = service,
|
||||||
functionName = "setEarDetection",
|
functionName = "setEarDetection",
|
||||||
sharedPreferences = sharedPreferences,
|
sharedPreferences = sharedPreferences,
|
||||||
true
|
default = true
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
IndependentToggle(
|
IndependentToggle(
|
||||||
name = "Off Listening Mode",
|
name = "Off Listening Mode",
|
||||||
service = service,
|
service = service,
|
||||||
functionName = "setOffListeningMode",
|
|
||||||
sharedPreferences = sharedPreferences,
|
sharedPreferences = sharedPreferences,
|
||||||
false
|
default = false,
|
||||||
|
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
AccessibilitySettings(service = service, sharedPreferences = sharedPreferences)
|
AccessibilitySettings()
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
NavigationButton("debug", "Debug", navController)
|
NavigationButton("debug", "Debug", navController)
|
||||||
@@ -387,6 +420,24 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { navController.navigate("troubleshooting") },
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFF2F2F7),
|
||||||
|
contentColor = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Troubleshoot Connection",
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalHazeMaterialsApi::class)
|
@file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.screens
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
@@ -49,6 +50,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Send
|
import androidx.compose.material.icons.filled.Send
|
||||||
@@ -89,33 +91,19 @@ import androidx.compose.ui.text.input.TextFieldValue
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import dev.chrisbanes.haze.HazeEffectScope
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.haze
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.hazeChild
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.BatteryStatus
|
import me.kavishdevar.librepods.utils.BatteryStatus
|
||||||
import me.kavishdevar.librepods.utils.isHeadTrackingData
|
import me.kavishdevar.librepods.utils.isHeadTrackingData
|
||||||
import me.kavishdevar.librepods.composables.StyledSwitch
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
|
||||||
import androidx.compose.foundation.layout.imePadding
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
|
||||||
|
|
||||||
data class PacketInfo(
|
data class PacketInfo(
|
||||||
val type: String,
|
val type: String,
|
||||||
@@ -487,13 +475,12 @@ fun DebugScreen(navController: NavController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.hazeChild(
|
modifier = Modifier.hazeEffect(
|
||||||
state = hazeState,
|
state = hazeState,
|
||||||
style = CupertinoMaterials.thick(),
|
style = CupertinoMaterials.thick(),
|
||||||
block = {
|
block = fun HazeEffectScope.() {
|
||||||
alpha = if (scrollOffset > 0) 1f else 0f
|
alpha = if (scrollOffset > 0) 1f else 0f
|
||||||
}
|
}),
|
||||||
),
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -502,7 +489,7 @@ fun DebugScreen(navController: NavController) {
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.haze(hazeState)
|
.hazeSource(hazeState)
|
||||||
.padding(top = paddingValues.calculateTopPadding())
|
.padding(top = paddingValues.calculateTopPadding())
|
||||||
.navigationBarsPadding()
|
.navigationBarsPadding()
|
||||||
) {
|
) {
|
||||||
@@ -630,7 +617,12 @@ fun DebugScreen(navController: NavController) {
|
|||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (packet.value.text.isNotBlank()) {
|
if (packet.value.text.isNotBlank()) {
|
||||||
airPodsService?.value?.sendPacket(packet.value.text)
|
airPodsService?.value?.aacpManager?.sendPacket(
|
||||||
|
packet.value.text
|
||||||
|
.split(" ")
|
||||||
|
.map { it.toInt(16).toByte() }
|
||||||
|
.toByteArray()
|
||||||
|
)
|
||||||
packet.value = TextFieldValue("")
|
packet.value = TextFieldValue("")
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
|
|
||||||
@@ -643,6 +635,7 @@ fun DebugScreen(navController: NavController) {
|
|||||||
scrollOffset = 0
|
scrollOffset = 0
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
listState.scrollToItem(
|
listState.scrollToItem(
|
||||||
index = (packetLogs.size - 1).coerceAtLeast(0)
|
index = (packetLogs.size - 1).coerceAtLeast(0)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.screens
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -40,7 +42,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
@@ -70,6 +74,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
@@ -84,6 +89,7 @@ import androidx.compose.ui.graphics.nativeCanvas
|
|||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.graphics.vector.path
|
import androidx.compose.ui.graphics.vector.path
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
@@ -98,6 +104,12 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import dev.chrisbanes.haze.HazeEffectScope
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
||||||
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -105,11 +117,13 @@ import me.kavishdevar.librepods.R
|
|||||||
import me.kavishdevar.librepods.composables.IndependentToggle
|
import me.kavishdevar.librepods.composables.IndependentToggle
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.HeadTracking
|
import me.kavishdevar.librepods.utils.HeadTracking
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
@ExperimentalHazeMaterialsApi
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -124,9 +138,36 @@ fun HeadTrackingScreen(navController: NavController) {
|
|||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
val hazeState = remember { HazeState() }
|
||||||
|
|
||||||
|
var mDensity by remember { mutableFloatStateOf(0f) }
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
topBar = {
|
topBar = {
|
||||||
CenterAlignedTopAppBar(
|
CenterAlignedTopAppBar(
|
||||||
|
modifier = Modifier.hazeEffect(
|
||||||
|
state = hazeState,
|
||||||
|
style = CupertinoMaterials.thick(),
|
||||||
|
block = fun HazeEffectScope.() {
|
||||||
|
alpha =
|
||||||
|
if (scrollState.value > 60.dp.value * mDensity) 1f else 0f
|
||||||
|
})
|
||||||
|
.drawBehind {
|
||||||
|
mDensity = density
|
||||||
|
val strokeWidth = 0.7.dp.value * density
|
||||||
|
val y = size.height - strokeWidth / 2
|
||||||
|
if (scrollState.value > 60.dp.value * density) {
|
||||||
|
drawLine(
|
||||||
|
if (isDarkTheme) Color.DarkGray else Color.LightGray,
|
||||||
|
Offset(0f, y),
|
||||||
|
Offset(size.width, y),
|
||||||
|
strokeWidth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.head_tracking),
|
stringResource(R.string.head_tracking),
|
||||||
@@ -162,7 +203,7 @@ fun HeadTrackingScreen(navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
|
||||||
containerColor = Color.Transparent
|
containerColor = Color.Transparent
|
||||||
),
|
),
|
||||||
actions = {
|
actions = {
|
||||||
@@ -211,7 +252,8 @@ fun HeadTrackingScreen(navController: NavController) {
|
|||||||
modifier = Modifier.scale(1.5f)
|
modifier = Modifier.scale(1.5f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
scrollBehavior = scrollBehavior
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
|
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
|
||||||
@@ -223,6 +265,8 @@ fun HeadTrackingScreen(navController: NavController) {
|
|||||||
.padding(paddingValues = paddingValues)
|
.padding(paddingValues = paddingValues)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.padding(top = 8.dp)
|
.padding(top = 8.dp)
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
.hazeSource(state = hazeState)
|
||||||
) {
|
) {
|
||||||
val sharedPreferences =
|
val sharedPreferences =
|
||||||
LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||||
@@ -311,7 +355,7 @@ fun HeadTrackingScreen(navController: NavController) {
|
|||||||
|
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
modifier = Modifier.padding(top = 12.dp)
|
modifier = Modifier.padding(top = 12.dp, bottom = 24.dp)
|
||||||
) {
|
) {
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = gestureText,
|
targetState = gestureText,
|
||||||
@@ -806,6 +850,7 @@ private fun AccelerationPlot() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExperimentalHazeMaterialsApi
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -1,670 +0,0 @@
|
|||||||
/*
|
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
*
|
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.togetherWith
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material.icons.filled.Clear
|
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
|
||||||
import androidx.compose.material.icons.filled.Settings
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun Onboarding(navController: NavController, activityContext: Context) {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
|
||||||
|
|
||||||
val radareOffsetFinder = remember { RadareOffsetFinder(activityContext) }
|
|
||||||
val progressState by radareOffsetFinder.progressState.collectAsState()
|
|
||||||
var isComplete by remember { mutableStateOf(false) }
|
|
||||||
var hasStarted by remember { mutableStateOf(false) }
|
|
||||||
var rootCheckPassed by remember { mutableStateOf(false) }
|
|
||||||
var checkingRoot by remember { mutableStateOf(false) }
|
|
||||||
var rootCheckFailed by remember { mutableStateOf(false) }
|
|
||||||
var moduleEnabled by remember { mutableStateOf(false) }
|
|
||||||
var bluetoothToggled by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
|
||||||
var showSkipDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
fun checkRootAccess() {
|
|
||||||
checkingRoot = true
|
|
||||||
rootCheckFailed = false
|
|
||||||
kotlinx.coroutines.MainScope().launch {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val process = Runtime.getRuntime().exec("su -c id")
|
|
||||||
val exitValue = process.waitFor()
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
rootCheckPassed = (exitValue == 0)
|
|
||||||
rootCheckFailed = (exitValue != 0)
|
|
||||||
checkingRoot = false
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("Onboarding", "Root check failed", e)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
rootCheckPassed = false
|
|
||||||
rootCheckFailed = true
|
|
||||||
checkingRoot = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(hasStarted) {
|
|
||||||
if (hasStarted && rootCheckPassed) {
|
|
||||||
Log.d("Onboarding", "Checking if hook offset is available...")
|
|
||||||
val isHookReady = radareOffsetFinder.isHookOffsetAvailable()
|
|
||||||
Log.d("Onboarding", "Hook offset ready: $isHookReady")
|
|
||||||
|
|
||||||
if (isHookReady) {
|
|
||||||
Log.d("Onboarding", "Hook is ready")
|
|
||||||
isComplete = true
|
|
||||||
} else {
|
|
||||||
Log.d("Onboarding", "Hook not ready, starting setup process...")
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
radareOffsetFinder.setupAndFindOffset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(progressState) {
|
|
||||||
if (progressState is RadareOffsetFinder.ProgressState.Success) {
|
|
||||||
isComplete = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
CenterAlignedTopAppBar(
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
"Setting Up",
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = Color.Transparent
|
|
||||||
),
|
|
||||||
actions = {
|
|
||||||
Box {
|
|
||||||
IconButton(onClick = { showMenu = true }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.MoreVert,
|
|
||||||
contentDescription = "More Options"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = showMenu,
|
|
||||||
onDismissRequest = { showMenu = false }
|
|
||||||
) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Skip Setup") },
|
|
||||||
onClick = {
|
|
||||||
showMenu = false
|
|
||||||
showSkipDialog = true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
containerColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)
|
|
||||||
) { paddingValues ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(paddingValues)
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = backgroundColor),
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
if (!rootCheckPassed && !hasStarted) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Settings,
|
|
||||||
contentDescription = "Root Access",
|
|
||||||
tint = accentColor,
|
|
||||||
modifier = Modifier.size(50.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "Root Access Required",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 22.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "This app needs root access to hook onto the Bluetooth library",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (rootCheckFailed) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "Root access was denied. Please grant root permissions.",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = Color(0xFFFF453A)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = { checkRootAccess() },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(50.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = accentColor
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
enabled = !checkingRoot
|
|
||||||
) {
|
|
||||||
if (checkingRoot) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
color = Color.White,
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
"Check Root Access",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
StatusIcon(if (hasStarted) progressState else RadareOffsetFinder.ProgressState.Idle, isDarkTheme)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = if (hasStarted) getStatusTitle(progressState, isComplete, moduleEnabled, bluetoothToggled) else "Setup Required",
|
|
||||||
transitionSpec = { fadeIn() togetherWith fadeOut() }
|
|
||||||
) { text ->
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 22.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = if (hasStarted)
|
|
||||||
getStatusDescription(progressState, isComplete, moduleEnabled, bluetoothToggled)
|
|
||||||
else
|
|
||||||
"AirPods functionality requires one-time setup for hooking into Bluetooth library",
|
|
||||||
transitionSpec = { fadeIn() togetherWith fadeOut() }
|
|
||||||
) { text ->
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
if (!hasStarted) {
|
|
||||||
Button(
|
|
||||||
onClick = { hasStarted = true },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(50.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = accentColor
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Start Setup",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
when (progressState) {
|
|
||||||
is RadareOffsetFinder.ProgressState.DownloadProgress -> {
|
|
||||||
val progress = (progressState as RadareOffsetFinder.ProgressState.DownloadProgress).progress
|
|
||||||
val animatedProgress by animateFloatAsState(
|
|
||||||
targetValue = progress,
|
|
||||||
label = "Download Progress"
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { animatedProgress },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(8.dp),
|
|
||||||
strokeCap = StrokeCap.Round,
|
|
||||||
color = accentColor
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "${(progress * 100).toInt()}%",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is RadareOffsetFinder.ProgressState.Success -> {
|
|
||||||
if (!moduleEnabled) {
|
|
||||||
Button(
|
|
||||||
onClick = { moduleEnabled = true },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(50.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = accentColor
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"I've Enabled/Reactivated the Module",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (!bluetoothToggled) {
|
|
||||||
Button(
|
|
||||||
onClick = { bluetoothToggled = true },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(50.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = accentColor
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"I've Toggled Bluetooth",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
navController.navigate("settings") {
|
|
||||||
popUpTo("onboarding") { inclusive = true }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(50.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = accentColor
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Continue to Settings",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is RadareOffsetFinder.ProgressState.Idle,
|
|
||||||
is RadareOffsetFinder.ProgressState.Error -> {
|
|
||||||
// No specific UI for these states
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
LinearProgressIndicator(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(8.dp),
|
|
||||||
strokeCap = StrokeCap.Round,
|
|
||||||
color = accentColor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
|
|
||||||
if (progressState is RadareOffsetFinder.ProgressState.Error && !isComplete && hasStarted) {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
Log.d("Onboarding", "Trying to find offset again...")
|
|
||||||
kotlinx.coroutines.MainScope().launch {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
radareOffsetFinder.setupAndFindOffset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(55.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = accentColor
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Try Again",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showSkipDialog) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showSkipDialog = false },
|
|
||||||
title = { Text("Skip Setup") },
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
"Have you installed the root module that patches the Bluetooth library directly? This option is for users who have manually patched their system instead of using the dynamic hook.",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
val sharedPreferences = activityContext.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
showSkipDialog = false
|
|
||||||
RadareOffsetFinder.clearHookOffsets()
|
|
||||||
sharedPreferences.edit().putBoolean("skip_setup", true).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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun StatusIcon(
|
|
||||||
progressState: RadareOffsetFinder.ProgressState,
|
|
||||||
isDarkTheme: Boolean
|
|
||||||
) {
|
|
||||||
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
|
||||||
val errorColor = if (isDarkTheme) Color(0xFFFF453A) else Color(0xFFFF3B30)
|
|
||||||
val successColor = if (isDarkTheme) Color(0xFF30D158) else Color(0xFF34C759)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.size(80.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
when (progressState) {
|
|
||||||
is RadareOffsetFinder.ProgressState.Error -> {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Clear,
|
|
||||||
contentDescription = "Error",
|
|
||||||
tint = errorColor,
|
|
||||||
modifier = Modifier.size(50.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is RadareOffsetFinder.ProgressState.Success -> {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Check,
|
|
||||||
contentDescription = "Success",
|
|
||||||
tint = successColor,
|
|
||||||
modifier = Modifier.size(50.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is RadareOffsetFinder.ProgressState.Idle -> {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Settings,
|
|
||||||
contentDescription = "Settings",
|
|
||||||
tint = accentColor,
|
|
||||||
modifier = Modifier.size(50.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(50.dp),
|
|
||||||
color = accentColor,
|
|
||||||
strokeWidth = 4.dp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getStatusTitle(
|
|
||||||
state: RadareOffsetFinder.ProgressState,
|
|
||||||
isComplete: Boolean,
|
|
||||||
moduleEnabled: Boolean,
|
|
||||||
bluetoothToggled: Boolean
|
|
||||||
): String {
|
|
||||||
return when (state) {
|
|
||||||
is RadareOffsetFinder.ProgressState.Success -> {
|
|
||||||
when {
|
|
||||||
!moduleEnabled -> "Enable Xposed Module"
|
|
||||||
!bluetoothToggled -> "Toggle Bluetooth"
|
|
||||||
else -> "Setup Complete"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is RadareOffsetFinder.ProgressState.Idle -> "Getting Ready"
|
|
||||||
is RadareOffsetFinder.ProgressState.CheckingExisting -> "Checking if radare2 already downloaded"
|
|
||||||
is RadareOffsetFinder.ProgressState.Downloading -> "Downloading radare2"
|
|
||||||
is RadareOffsetFinder.ProgressState.DownloadProgress -> "Downloading radare2"
|
|
||||||
is RadareOffsetFinder.ProgressState.Extracting -> "Extracting radare2"
|
|
||||||
is RadareOffsetFinder.ProgressState.MakingExecutable -> "Setting executable permissions"
|
|
||||||
is RadareOffsetFinder.ProgressState.FindingOffset -> "Finding function offset"
|
|
||||||
is RadareOffsetFinder.ProgressState.SavingOffset -> "Saving offset"
|
|
||||||
is RadareOffsetFinder.ProgressState.Cleaning -> "Cleaning Up"
|
|
||||||
is RadareOffsetFinder.ProgressState.Error -> "Setup Failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getStatusDescription(
|
|
||||||
state: RadareOffsetFinder.ProgressState,
|
|
||||||
isComplete: Boolean,
|
|
||||||
moduleEnabled: Boolean,
|
|
||||||
bluetoothToggled: Boolean
|
|
||||||
): String {
|
|
||||||
return when (state) {
|
|
||||||
is RadareOffsetFinder.ProgressState.Success -> {
|
|
||||||
when {
|
|
||||||
!moduleEnabled -> "Please enable the LibrePods Xposed module in your Xposed manager (e.g. LSPosed). If already enabled, disable and re-enable it."
|
|
||||||
!bluetoothToggled -> "Please turn off and then turn on Bluetooth to apply the changes."
|
|
||||||
else -> "All set! You can now use your AirPods with enhanced functionality."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is RadareOffsetFinder.ProgressState.Idle -> "Preparing"
|
|
||||||
is RadareOffsetFinder.ProgressState.CheckingExisting -> "Checking if radare2 are already installed"
|
|
||||||
is RadareOffsetFinder.ProgressState.Downloading -> "Starting radare2 download"
|
|
||||||
is RadareOffsetFinder.ProgressState.DownloadProgress -> "Downloading radare2"
|
|
||||||
is RadareOffsetFinder.ProgressState.Extracting -> "Extracting radare2"
|
|
||||||
is RadareOffsetFinder.ProgressState.MakingExecutable -> "Setting executable permissions on radare2 binaries"
|
|
||||||
is RadareOffsetFinder.ProgressState.FindingOffset -> "Looking for the required Bluetooth function in system libraries"
|
|
||||||
is RadareOffsetFinder.ProgressState.SavingOffset -> "Saving the function offset"
|
|
||||||
is RadareOffsetFinder.ProgressState.Cleaning -> "Removing temporary extracted files"
|
|
||||||
is RadareOffsetFinder.ProgressState.Error -> state.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun OnboardingPreview() {
|
|
||||||
Onboarding(navController = NavController(LocalContext.current), activityContext = LocalContext.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun delay(timeMillis: Long) {
|
|
||||||
kotlinx.coroutines.delay(timeMillis)
|
|
||||||
}
|
|
||||||
+130
-41
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.screens
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -47,7 +49,6 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.MutableState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -69,6 +70,9 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.experimental.and
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable()
|
@Composable()
|
||||||
fun RightDivider() {
|
fun RightDivider() {
|
||||||
@@ -83,15 +87,23 @@ fun RightDivider() {
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LongPress(navController: NavController, name: String) {
|
fun LongPress(navController: NavController, name: String) {
|
||||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
|
||||||
val offChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_off", false)) }
|
|
||||||
val ncChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_nc", false)) }
|
|
||||||
val transparencyChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_transparency", false)) }
|
|
||||||
val adaptiveChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_adaptive", false)) }
|
|
||||||
Log.d("LongPress", "offChecked: ${offChecked.value}, ncChecked: ${ncChecked.value}, transparencyChecked: ${transparencyChecked.value}, adaptiveChecked: ${adaptiveChecked.value}")
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
|
val modesByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||||
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
||||||
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
|
|
||||||
|
if (modesByte != null) {
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x02) != 0.toByte()}")
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x04) != 0.toByte()}")
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
|
||||||
|
}
|
||||||
|
val context = LocalContext.current
|
||||||
|
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||||
|
val deviceName = sharedPreferences.getString("name", "AirPods Pro")
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
CenterAlignedTopAppBar(
|
CenterAlignedTopAppBar(
|
||||||
@@ -115,7 +127,7 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
modifier = Modifier.scale(1.5f)
|
modifier = Modifier.scale(1.5f)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
sharedPreferences.getString("name", "AirPods")!!,
|
deviceName?: "AirPods Pro",
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
@@ -159,14 +171,29 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
.background(backgroundColor, RoundedCornerShape(14.dp)),
|
.background(backgroundColor, RoundedCornerShape(14.dp)),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
val offListeningMode = sharedPreferences.getBoolean("off_listening_mode", false)
|
val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||||
LongPressElement("Off", offChecked, "long_press_off", offListeningMode, R.drawable.noise_cancellation, isFirst = true)
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||||
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
|
val offListeningMode = offListeningModeValue == 1.toByte()
|
||||||
|
LongPressElement(
|
||||||
|
name = "Off",
|
||||||
|
enabled = offListeningMode,
|
||||||
|
resourceId = R.drawable.noise_cancellation,
|
||||||
|
isFirst = true)
|
||||||
if (offListeningMode) RightDivider()
|
if (offListeningMode) RightDivider()
|
||||||
LongPressElement("Transparency", transparencyChecked, "long_press_transparency", resourceId = R.drawable.transparency, isFirst = !offListeningMode)
|
LongPressElement(
|
||||||
|
name = "Transparency",
|
||||||
|
resourceId = R.drawable.transparency,
|
||||||
|
isFirst = !offListeningMode)
|
||||||
RightDivider()
|
RightDivider()
|
||||||
LongPressElement("Adaptive", adaptiveChecked, "long_press_adaptive", resourceId = R.drawable.adaptive)
|
LongPressElement(
|
||||||
|
name = "Adaptive",
|
||||||
|
resourceId = R.drawable.adaptive)
|
||||||
RightDivider()
|
RightDivider()
|
||||||
LongPressElement("Noise Cancellation", ncChecked, "long_press_nc", resourceId = R.drawable.noise_cancellation, isLast = true)
|
LongPressElement(
|
||||||
|
name = "Noise Cancellation",
|
||||||
|
resourceId = R.drawable.noise_cancellation,
|
||||||
|
isLast = true)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
"Press and hold the stem to cycle between the selected noise control modes.",
|
"Press and hold the stem to cycle between the selected noise control modes.",
|
||||||
@@ -178,13 +205,33 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Current byte: ${ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||||
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
||||||
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toString(2)}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LongPressElement(name: String, checked: MutableState<Boolean>, id: String, enabled: Boolean = true, resourceId: Int, isFirst: Boolean = false, isLast: Boolean = false) {
|
fun LongPressElement(name: String, enabled: Boolean = true, resourceId: Int, isFirst: Boolean = false, isLast: Boolean = false) {
|
||||||
val sharedPreferences =
|
val bit = when (name) {
|
||||||
LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
"Off" -> 0x01
|
||||||
val offListeningMode = sharedPreferences.getBoolean("off_listening_mode", false)
|
"Transparency" -> 0x02
|
||||||
|
"Noise Cancellation" -> 0x04
|
||||||
|
"Adaptive" -> 0x08
|
||||||
|
else -> -1
|
||||||
|
}
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val currentByteValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||||
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
||||||
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
|
|
||||||
|
val savedByte = context.getSharedPreferences("settings", Context.MODE_PRIVATE).getInt("long_press_byte", 0b0101.toInt())
|
||||||
|
val byteValue = currentByteValue ?: (savedByte and 0xFF).toByte()
|
||||||
|
|
||||||
|
val isChecked = (byteValue.toInt() and bit) != 0
|
||||||
|
val checked = remember { mutableStateOf(isChecked) }
|
||||||
|
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "LongPressElement: $name, checked: ${checked.value}, byteValue: ${byteValue.toInt()}, in bits: ${byteValue.toInt().toString(2)}")
|
||||||
val darkMode = isSystemInDarkTheme()
|
val darkMode = isSystemInDarkTheme()
|
||||||
val textColor = if (darkMode) Color.White else Color.Black
|
val textColor = if (darkMode) Color.White else Color.Black
|
||||||
val desc = when (name) {
|
val desc = when (name) {
|
||||||
@@ -194,30 +241,72 @@ fun LongPressElement(name: String, checked: MutableState<Boolean>, id: String, e
|
|||||||
"Adaptive" -> "Dynamically adjust external noise"
|
"Adaptive" -> "Dynamically adjust external noise"
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
fun valueChanged(value: Boolean = !checked.value) {
|
|
||||||
val originalLongPressArray = booleanArrayOf(
|
fun countEnabledModes(byteValue: Int): Int {
|
||||||
sharedPreferences.getBoolean("long_press_off", false),
|
var count = 0
|
||||||
sharedPreferences.getBoolean("long_press_nc", false),
|
if ((byteValue and 0x01) != 0) count++
|
||||||
sharedPreferences.getBoolean("long_press_transparency", false),
|
if ((byteValue and 0x02) != 0) count++
|
||||||
sharedPreferences.getBoolean("long_press_adaptive", false)
|
if ((byteValue and 0x04) != 0) count++
|
||||||
)
|
if ((byteValue and 0x08) != 0) count++
|
||||||
if (!value && originalLongPressArray.count { it } <= 2) {
|
|
||||||
return
|
Log.d("PressAndHoldSettingsScreen", "Byte: ${byteValue.toString(2)} Enabled modes: $count")
|
||||||
}
|
return count
|
||||||
checked.value = value
|
|
||||||
with(sharedPreferences.edit()) {
|
|
||||||
putBoolean(id, checked.value)
|
|
||||||
apply()
|
|
||||||
}
|
|
||||||
val newLongPressArray = booleanArrayOf(
|
|
||||||
sharedPreferences.getBoolean("long_press_off", false),
|
|
||||||
sharedPreferences.getBoolean("long_press_nc", false),
|
|
||||||
sharedPreferences.getBoolean("long_press_transparency", false),
|
|
||||||
sharedPreferences.getBoolean("long_press_adaptive", false)
|
|
||||||
)
|
|
||||||
ServiceManager.getService()
|
|
||||||
?.updateLongPress(originalLongPressArray, newLongPressArray, offListeningMode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun valueChanged(value: Boolean = !checked.value) {
|
||||||
|
val latestByteValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||||
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
||||||
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
|
|
||||||
|
val currentValue = (latestByteValue?.toInt() ?: byteValue.toInt()) and 0xFF
|
||||||
|
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Current value: $currentValue (binary: ${Integer.toBinaryString(currentValue)}), bit: $bit, value: $value")
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
val newValue = currentValue and bit.inv()
|
||||||
|
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Bit to disable: $bit, inverted: ${bit.inv()}, after AND: ${Integer.toBinaryString(newValue)}")
|
||||||
|
|
||||||
|
val modeCount = countEnabledModes(newValue)
|
||||||
|
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "After disabling, enabled modes count: $modeCount")
|
||||||
|
|
||||||
|
if (modeCount < 2) {
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Cannot disable $name mode - need at least 2 modes enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val updatedByte = newValue.toByte()
|
||||||
|
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Sending updated byte: ${updatedByte.toInt() and 0xFF} (binary: ${Integer.toBinaryString(updatedByte.toInt() and 0xFF)})")
|
||||||
|
|
||||||
|
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
||||||
|
updatedByte
|
||||||
|
)
|
||||||
|
|
||||||
|
context.getSharedPreferences("settings", Context.MODE_PRIVATE).edit()
|
||||||
|
.putInt("long_press_byte", newValue).apply()
|
||||||
|
|
||||||
|
checked.value = false
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Updated: $name, enabled: false, byte: ${updatedByte.toInt() and 0xFF}, bits: ${Integer.toBinaryString(updatedByte.toInt() and 0xFF)}")
|
||||||
|
} else {
|
||||||
|
val newValue = currentValue or bit
|
||||||
|
val updatedByte = newValue.toByte()
|
||||||
|
|
||||||
|
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
||||||
|
updatedByte
|
||||||
|
)
|
||||||
|
|
||||||
|
context.getSharedPreferences("settings", Context.MODE_PRIVATE).edit()
|
||||||
|
.putInt("long_press_byte", newValue).apply()
|
||||||
|
|
||||||
|
checked.value = true
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Updated: $name, enabled: true, byte: ${updatedByte.toInt() and 0xFF}, bits: ${newValue.toString(2)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val shape = when {
|
val shape = when {
|
||||||
isFirst -> RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
|
isFirst -> RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
|
||||||
isLast -> RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
|
isLast -> RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
|
||||||
@@ -238,8 +327,8 @@ fun LongPressElement(name: String, checked: MutableState<Boolean>, id: String, e
|
|||||||
backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9)
|
backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9)
|
||||||
tryAwaitRelease()
|
tryAwaitRelease()
|
||||||
backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
valueChanged()
|
||||||
},
|
},
|
||||||
onTap = { valueChanged() }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(horizontal = 16.dp, vertical = 0.dp),
|
.padding(horizontal = 16.dp, vertical = 0.dp),
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.screens
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -66,6 +68,7 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
*
|
*
|
||||||
* Copyright (C) 2025 Kavish Devar
|
* Copyright (C) 2025 LibrePods Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.services
|
package me.kavishdevar.librepods.services
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
@@ -31,12 +33,12 @@ import android.service.quicksettings.Tile
|
|||||||
import android.service.quicksettings.TileService
|
import android.service.quicksettings.TileService
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import me.kavishdevar.librepods.MainActivity
|
|
||||||
import me.kavishdevar.librepods.QuickSettingsDialogActivity
|
import me.kavishdevar.librepods.QuickSettingsDialogActivity
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.utils.NoiseControlMode
|
import me.kavishdevar.librepods.utils.NoiseControlMode
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
class AirPodsQSService : TileService() {
|
class AirPodsQSService : TileService() {
|
||||||
@@ -171,10 +173,11 @@ class AirPodsQSService : TileService() {
|
|||||||
)
|
)
|
||||||
startActivityAndCollapse(pendingIntent)
|
startActivityAndCollapse(pendingIntent)
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
val intent = Intent(this, QuickSettingsDialogActivity::class.java).apply {
|
val intent = Intent(this, QuickSettingsDialogActivity::class.java).apply {
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||||
}
|
}
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
@SuppressLint("StartActivityAndCollapseDeprecated")
|
||||||
startActivityAndCollapse(intent)
|
startActivityAndCollapse(intent)
|
||||||
}
|
}
|
||||||
Log.d("AirPodsQSService", "Called startActivityAndCollapse for QuickSettingsDialogActivity")
|
Log.d("AirPodsQSService", "Called startActivityAndCollapse for QuickSettingsDialogActivity")
|
||||||
@@ -191,7 +194,10 @@ class AirPodsQSService : TileService() {
|
|||||||
}
|
}
|
||||||
val nextMode = getNextAncMode()
|
val nextMode = getNextAncMode()
|
||||||
Log.d("AirPodsQSService", "Cycling ANC mode to: $nextMode")
|
Log.d("AirPodsQSService", "Cycling ANC mode to: $nextMode")
|
||||||
service.setANCMode(nextMode)
|
service.aacpManager.sendControlCommand(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
|
||||||
|
nextMode
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateTile() {
|
private fun updateTile() {
|
||||||
@@ -263,41 +269,8 @@ class AirPodsQSService : TileService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
override fun onTileAdded() {
|
override fun onTileAdded() {
|
||||||
super.onTileAdded()
|
super.onTileAdded()
|
||||||
Log.d("AirPodsQSService", "Tile added")
|
Log.d("AirPodsQSService", "Tile added")
|
||||||
|
|
||||||
val intent = Intent(this, MainActivity::class.java).apply {
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
fun openMainActivity() {
|
|
||||||
Log.d("AirPodsQSService", "Opening MainActivity")
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
Intent(this, MainActivity::class.java).apply {
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
},
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
)
|
|
||||||
startActivityAndCollapse(pendingIntent)
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
val intent = Intent(this, MainActivity::class.java).apply {
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
startActivityAndCollapse(intent)
|
|
||||||
}
|
|
||||||
Log.d("AirPodsQSService", "Called startActivityAndCollapse for MainActivity")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("AirPodsQSService", "Error launching MainActivity: $e")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,478 @@
|
|||||||
|
/*
|
||||||
|
* LibrePods - AirPods liberated from Apple's ecosystem
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdentifiers.entries
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manager class for Apple Accessory Communication Protocol (AACP)
|
||||||
|
* This class is responsible for handling the L2CAP socket management,
|
||||||
|
* constructing and parsing packets for communication with Apple accessories.
|
||||||
|
*/
|
||||||
|
class AACPManager {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AACPManager"
|
||||||
|
|
||||||
|
object Opcodes {
|
||||||
|
const val SET_FEATURE_FLAGS: Byte = 0x4d
|
||||||
|
const val REQUEST_NOTIFICATIONS: Byte = 0x0f
|
||||||
|
const val BATTERY_INFO: Byte = 0x04
|
||||||
|
const val CONTROL_COMMAND: Byte = 0x09
|
||||||
|
const val EAR_DETECTION: Byte = 0x06
|
||||||
|
const val CONVERSATION_AWARENESS: Byte = 0x4b
|
||||||
|
const val DEVICE_METADATA: Byte = 0x1d
|
||||||
|
const val RENAME: Byte = 0x1E
|
||||||
|
const val HEADTRACKING: Byte = 0x17
|
||||||
|
const val PROXIMITY_KEYS_REQ: Byte = 0x30
|
||||||
|
const val PROXIMITY_KEYS_RSP: Byte = 0x31
|
||||||
|
}
|
||||||
|
|
||||||
|
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
|
||||||
|
|
||||||
|
data class ControlCommandStatus(
|
||||||
|
val identifier: ControlCommandIdentifiers,
|
||||||
|
val value: ByteArray
|
||||||
|
) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as ControlCommandStatus
|
||||||
|
|
||||||
|
if (identifier != other.identifier) return false
|
||||||
|
if (!value.contentEquals(other.value)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result: Int = identifier.hashCode()
|
||||||
|
result = 31 * result + value.contentHashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Suppress("unused")
|
||||||
|
enum class ControlCommandIdentifiers(val value: Byte) {
|
||||||
|
MIC_MODE(0x01),
|
||||||
|
BUTTON_SEND_MODE(0x05),
|
||||||
|
VOICE_TRIGGER(0x12),
|
||||||
|
SINGLE_CLICK_MODE(0x14),
|
||||||
|
DOUBLE_CLICK_MODE(0x15),
|
||||||
|
CLICK_HOLD_MODE(0x16),
|
||||||
|
DOUBLE_CLICK_INTERVAL(0x17),
|
||||||
|
CLICK_HOLD_INTERVAL(0x18),
|
||||||
|
LISTENING_MODE_CONFIGS(0x1A),
|
||||||
|
ONE_BUD_ANC_MODE(0x1B),
|
||||||
|
CROWN_ROTATION_DIRECTION(0x1C),
|
||||||
|
LISTENING_MODE(0x0D),
|
||||||
|
AUTO_ANSWER_MODE(0x1E),
|
||||||
|
CHIME_VOLUME(0x1F),
|
||||||
|
VOLUME_SWIPE_INTERVAL(0x23),
|
||||||
|
CALL_MANAGEMENT_CONFIG(0x24),
|
||||||
|
VOLUME_SWIPE_MODE(0x25),
|
||||||
|
ADAPTIVE_VOLUME_CONFIG(0x26),
|
||||||
|
SOFTWARE_MUTE_CONFIG(0x27),
|
||||||
|
CONVERSATION_DETECT_CONFIG(0x28),
|
||||||
|
SSL(0x29),
|
||||||
|
HEARING_AID(0x2C),
|
||||||
|
AUTO_ANC_STRENGTH(0x2E),
|
||||||
|
HPS_GAIN_SWIPE(0x2F),
|
||||||
|
HRM_STATE(0x30),
|
||||||
|
IN_CASE_TONE_CONFIG(0x31),
|
||||||
|
SIRI_MULTITONE_CONFIG(0x32),
|
||||||
|
HEARING_ASSIST_CONFIG(0x33),
|
||||||
|
ALLOW_OFF_OPTION(0x34);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromByte(byte: Byte): ControlCommandIdentifiers? =
|
||||||
|
entries.find { it.value == byte }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ProximityKeyType(val value: Byte) {
|
||||||
|
IRK(0x01),
|
||||||
|
ENC_KEY(0x04);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromByte(byte: Byte): ProximityKeyType =
|
||||||
|
ProximityKeyType.entries.find { it.value == byte }?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var controlCommandStatusList: MutableList<ControlCommandStatus> = mutableListOf<ControlCommandStatus>()
|
||||||
|
var controlCommandListeners: MutableMap<ControlCommandIdentifiers, MutableList<ControlCommandListener>> = mutableMapOf()
|
||||||
|
|
||||||
|
fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? {
|
||||||
|
return controlCommandStatusList.find { it.identifier == identifier }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setControlCommandStatusValue(identifier: ControlCommandIdentifiers, value: ByteArray) {
|
||||||
|
val existingStatus = getControlCommandStatus(identifier)
|
||||||
|
if (existingStatus == value) {
|
||||||
|
controlCommandStatusList.remove(existingStatus)
|
||||||
|
}
|
||||||
|
if (existingStatus != null) {
|
||||||
|
controlCommandStatusList.remove(existingStatus)
|
||||||
|
}
|
||||||
|
controlCommandListeners[identifier]?.forEach { listener ->
|
||||||
|
listener.onControlCommandReceived(ControlCommand(identifier.value, value))
|
||||||
|
}
|
||||||
|
controlCommandStatusList.add(ControlCommandStatus(identifier, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PacketCallback {
|
||||||
|
fun onBatteryInfoReceived(batteryInfo: ByteArray)
|
||||||
|
fun onEarDetectionReceived(earDetection: ByteArray)
|
||||||
|
fun onConversationAwarenessReceived(conversationAwareness: ByteArray)
|
||||||
|
fun onControlCommandReceived(controlCommand: ByteArray)
|
||||||
|
fun onDeviceMetadataReceived(deviceMetadata: ByteArray)
|
||||||
|
fun onHeadTrackingReceived(headTracking: ByteArray)
|
||||||
|
fun onUnknownPacketReceived(packet: ByteArray)
|
||||||
|
fun onProximityKeysReceived(proximityKeys: ByteArray)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ControlCommandListener {
|
||||||
|
fun onControlCommandReceived(controlCommand: ControlCommand)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun registerControlCommandListener(identifier: ControlCommandIdentifiers, callback: ControlCommandListener) {
|
||||||
|
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var callback: PacketCallback? = null
|
||||||
|
|
||||||
|
fun setPacketCallback(callback: PacketCallback) {
|
||||||
|
this.callback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createDataPacket(data: ByteArray): ByteArray {
|
||||||
|
return HEADER_BYTES + data
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createControlCommandPacket(identifier: Byte, data: ByteArray): ByteArray {
|
||||||
|
val opcode = byteArrayOf(Opcodes.CONTROL_COMMAND, 0x00)
|
||||||
|
val payload = ByteArray(7)
|
||||||
|
|
||||||
|
System.arraycopy(opcode, 0, payload, 0, 2)
|
||||||
|
payload[2] = identifier
|
||||||
|
|
||||||
|
val dataLength = minOf(data.size, 4)
|
||||||
|
System.arraycopy(data, 0, payload, 3, dataLength)
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendDataPacket(data: ByteArray): Boolean {
|
||||||
|
return sendPacket(createDataPacket(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean {
|
||||||
|
val controlPacket = createControlCommandPacket(identifier, value)
|
||||||
|
setControlCommandStatusValue(
|
||||||
|
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
||||||
|
value
|
||||||
|
)
|
||||||
|
return sendDataPacket(controlPacket)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
|
||||||
|
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
|
||||||
|
setControlCommandStatusValue(
|
||||||
|
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
||||||
|
byteArrayOf(value)
|
||||||
|
)
|
||||||
|
return sendDataPacket(controlPacket)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendControlCommand(identifier: Byte, value: Boolean): Boolean {
|
||||||
|
val controlPacket = createControlCommandPacket(identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02))
|
||||||
|
setControlCommandStatusValue(
|
||||||
|
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
||||||
|
if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
|
||||||
|
)
|
||||||
|
return sendDataPacket(controlPacket)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendControlCommand(identifier: Byte, value: Int): Boolean {
|
||||||
|
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value.toByte()))
|
||||||
|
setControlCommandStatusValue(
|
||||||
|
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
||||||
|
byteArrayOf(value.toByte())
|
||||||
|
)
|
||||||
|
return sendDataPacket(controlPacket)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> {
|
||||||
|
Log.d(TAG, "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
if (data.size < 4) {
|
||||||
|
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
|
||||||
|
}
|
||||||
|
if (data[4] != Opcodes.PROXIMITY_KEYS_RSP) {
|
||||||
|
throw IllegalArgumentException("Data array does not start with PROXIMITY_KEYS_RSP opcode")
|
||||||
|
}
|
||||||
|
val keyCount = data[6].toInt()
|
||||||
|
val keys = mutableMapOf<ProximityKeyType, ByteArray>()
|
||||||
|
var offset = 7
|
||||||
|
for (i in 0 until keyCount) {
|
||||||
|
Log.d(TAG, "Parsing Proximity Key $i")
|
||||||
|
if (offset + 3 >= data.size) {
|
||||||
|
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
|
||||||
|
}
|
||||||
|
val keyType = data[offset]
|
||||||
|
val keyLength = data[offset + 2].toInt()
|
||||||
|
Log.d(TAG, "Key Type: ${keyType.toString(16)}, Key Length: $keyLength")
|
||||||
|
offset += 4
|
||||||
|
if (offset + keyLength > data.size) {
|
||||||
|
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
|
||||||
|
}
|
||||||
|
val key = ByteArray(keyLength)
|
||||||
|
System.arraycopy(data, offset, key, 0, keyLength)
|
||||||
|
keys[ProximityKeyType.fromByte(keyType)] = key
|
||||||
|
offset += keyLength
|
||||||
|
Log.d(TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${key.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendRequestProximityKeys(type: Byte): Boolean {
|
||||||
|
Log.d(TAG, "Requesting proximity keys of type: ${type.toString(16)}")
|
||||||
|
return sendDataPacket(createRequestProximityKeysPacket(type))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createRequestProximityKeysPacket(type: Byte): ByteArray {
|
||||||
|
val opcode = byteArrayOf(Opcodes.PROXIMITY_KEYS_REQ, 0x00)
|
||||||
|
val data = byteArrayOf(type, 0x00)
|
||||||
|
return opcode + data
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalStdlibApi::class)
|
||||||
|
fun receivePacket(packet: ByteArray) {
|
||||||
|
if (!packet.toHexString().startsWith("04000400")) {
|
||||||
|
Log.w(TAG, "Received packet does not start with expected header: ${packet.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (packet.size < 6) {
|
||||||
|
Log.w(TAG, "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val opcode = packet[4]
|
||||||
|
|
||||||
|
when (opcode) {
|
||||||
|
Opcodes.BATTERY_INFO -> {
|
||||||
|
callback?.onBatteryInfoReceived(packet)
|
||||||
|
}
|
||||||
|
Opcodes.CONTROL_COMMAND -> {
|
||||||
|
val controlCommand = ControlCommand.fromByteArray(packet)
|
||||||
|
setControlCommandStatusValue(
|
||||||
|
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return,
|
||||||
|
controlCommand.value
|
||||||
|
)
|
||||||
|
Log.d(TAG, "Control command received: ${controlCommand.identifier.toHexString()} - ${controlCommand.value.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
Log.d(TAG, "Control command list is now: ${
|
||||||
|
controlCommandStatusList.joinToString(", ") { "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${it.value.joinToString(" ") { "%02X".format(it) }}" }
|
||||||
|
}")
|
||||||
|
|
||||||
|
val controlCommandIdentifier = ControlCommandIdentifiers.fromByte(controlCommand.identifier)
|
||||||
|
if (controlCommandIdentifier != null) {
|
||||||
|
controlCommandListeners[controlCommandIdentifier]?.forEach { listener ->
|
||||||
|
listener.onControlCommandReceived(controlCommand)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Unknown control command identifier: ${controlCommand.identifier.toHexString()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
callback?.onControlCommandReceived(packet)
|
||||||
|
}
|
||||||
|
Opcodes.EAR_DETECTION -> {
|
||||||
|
callback?.onEarDetectionReceived(packet)
|
||||||
|
}
|
||||||
|
Opcodes.CONVERSATION_AWARENESS -> {
|
||||||
|
callback?.onConversationAwarenessReceived(packet)
|
||||||
|
}
|
||||||
|
Opcodes.DEVICE_METADATA -> {
|
||||||
|
callback?.onDeviceMetadataReceived(packet)
|
||||||
|
}
|
||||||
|
Opcodes.HEADTRACKING -> {
|
||||||
|
if (packet.size < 70) {
|
||||||
|
Log.w(TAG, "Received HEADTRACKING packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback?.onHeadTrackingReceived(packet)
|
||||||
|
}
|
||||||
|
Opcodes.PROXIMITY_KEYS_RSP -> {
|
||||||
|
callback?.onProximityKeysReceived(packet)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
callback?.onUnknownPacketReceived(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendNotificationRequest(): Boolean {
|
||||||
|
return sendDataPacket(createRequestNotificationPacket())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createRequestNotificationPacket(): ByteArray {
|
||||||
|
val opcode = byteArrayOf(Opcodes.REQUEST_NOTIFICATIONS, 0x00)
|
||||||
|
val data = byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())
|
||||||
|
return opcode + data
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendSetFeatureFlagsPacket(): Boolean {
|
||||||
|
return sendDataPacket(createSetFeatureFlagsPacket())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createSetFeatureFlagsPacket(): ByteArray {
|
||||||
|
val opcode = byteArrayOf(Opcodes.SET_FEATURE_FLAGS, 0x00)
|
||||||
|
val data = byteArrayOf(0xFF.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
|
||||||
|
return opcode + data
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createHandshakePacket(): ByteArray {
|
||||||
|
return byteArrayOf(
|
||||||
|
0x00, 0x00, 0x04, 0x00,
|
||||||
|
0x01, 0x00, 0x02, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendStartHeadTracking(): Boolean {
|
||||||
|
return sendDataPacket(createStartHeadTrackingPacket())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createStartHeadTrackingPacket(): ByteArray {
|
||||||
|
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
|
||||||
|
val data = byteArrayOf(
|
||||||
|
0x00, 0x00, 0x10, 0x00, 0x10, 0x00, 0x08, 0xA1.toByte(), 0x02, 0x42, 0x0B, 0x08, 0x0E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00,
|
||||||
|
)
|
||||||
|
return opcode + data
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendStopHeadTracking(): Boolean {
|
||||||
|
return sendDataPacket(createStopHeadTrackingPacket())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createStopHeadTrackingPacket(): ByteArray {
|
||||||
|
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
|
||||||
|
val data = byteArrayOf(
|
||||||
|
0x00, 0x00, 0x10, 0x00, 0x11, 0x00, 0x08, 0x7E, 0x10, 0x02, 0x42, 0x0B, 0x08, 0x4E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00
|
||||||
|
)
|
||||||
|
return opcode + data
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendRename(name: String): Boolean {
|
||||||
|
return sendDataPacket(createRenamePacket(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createRenamePacket(name: String): ByteArray {
|
||||||
|
val nameBytes = name.toByteArray()
|
||||||
|
val size = nameBytes.size
|
||||||
|
val packet = ByteArray(5 + size)
|
||||||
|
packet[0] = Opcodes.RENAME
|
||||||
|
packet[1] = 0x00
|
||||||
|
packet[2] = size.toByte()
|
||||||
|
packet[3] = 0x00
|
||||||
|
System.arraycopy(nameBytes, 0, packet, 4, size)
|
||||||
|
|
||||||
|
return packet
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
data class ControlCommand(
|
||||||
|
val identifier: Byte,
|
||||||
|
val value: ByteArray
|
||||||
|
) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as ControlCommand
|
||||||
|
|
||||||
|
if (identifier != other.identifier) return false
|
||||||
|
if (!value.contentEquals(other.value)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result: Int = identifier.toInt()
|
||||||
|
result = 31 * result + value.contentHashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromByteArray(data: ByteArray): ControlCommand {
|
||||||
|
if (data.size < 4) {
|
||||||
|
throw IllegalArgumentException("Data array too short to parse ControlCommand")
|
||||||
|
}
|
||||||
|
if (data[0] == 0x04.toByte() && data[1] == 0x00.toByte() && data[2] == 0x04.toByte() && data[3] == 0x00.toByte()) {
|
||||||
|
val newData = ByteArray(data.size - 4)
|
||||||
|
System.arraycopy(data, 4, newData, 0, data.size - 4)
|
||||||
|
return fromByteArray(newData)
|
||||||
|
}
|
||||||
|
if (data[0] != Opcodes.CONTROL_COMMAND) {
|
||||||
|
throw IllegalArgumentException("Data array does not start with CONTROL_COMMAND opcode")
|
||||||
|
}
|
||||||
|
val identifier = data[2]
|
||||||
|
|
||||||
|
val value = ByteArray(4)
|
||||||
|
System.arraycopy(data, 3, value, 0, 4)
|
||||||
|
|
||||||
|
// drop trailing zeroes in the array, and return the bytearray of the reduced array
|
||||||
|
val trimmedValue = value.takeWhile { it != 0x00.toByte() }.toByteArray()
|
||||||
|
return ControlCommand(identifier, trimmedValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalStdlibApi::class)
|
||||||
|
fun sendPacket(packet: ByteArray): Boolean {
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
|
||||||
|
if (packet[4] == Opcodes.CONTROL_COMMAND) {
|
||||||
|
val controlCommand = ControlCommand.fromByteArray(packet)
|
||||||
|
Log.d(TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${controlCommand.value.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
setControlCommandStatusValue(
|
||||||
|
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false,
|
||||||
|
controlCommand.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val socket = BluetoothConnectionManager.getCurrentSocket()
|
||||||
|
if (socket?.isConnected == true) {
|
||||||
|
socket.outputStream?.write(packet)
|
||||||
|
socket.outputStream?.flush()
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Can't send packet: Socket not initialized or connected")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error sending packet: ${e.message}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
/*
|
||||||
|
* LibrePods - AirPods liberated from Apple's ecosystem
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 LibrePods Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.bluetooth.le.BluetoothLeScanner
|
||||||
|
import android.bluetooth.le.ScanCallback
|
||||||
|
import android.bluetooth.le.ScanFilter
|
||||||
|
import android.bluetooth.le.ScanResult
|
||||||
|
import android.bluetooth.le.ScanSettings
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manager for Bluetooth Low Energy scanning operations specifically for AirPods
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
class BLEManager(private val context: Context) {
|
||||||
|
|
||||||
|
data class AirPodsStatus(
|
||||||
|
val address: String,
|
||||||
|
val lastSeen: Long = System.currentTimeMillis(),
|
||||||
|
val paired: Boolean = false,
|
||||||
|
val model: String = "Unknown",
|
||||||
|
val leftBattery: Int? = null,
|
||||||
|
val rightBattery: Int? = null,
|
||||||
|
val caseBattery: Int? = null,
|
||||||
|
val isLeftInEar: Boolean = false,
|
||||||
|
val isRightInEar: Boolean = false,
|
||||||
|
val isLeftCharging: Boolean = false,
|
||||||
|
val isRightCharging: Boolean = false,
|
||||||
|
val isCaseCharging: Boolean = false,
|
||||||
|
val lidOpen: Boolean = false,
|
||||||
|
val color: String = "Unknown",
|
||||||
|
val connectionState: String = "Unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getMostRecentStatus(): AirPodsStatus? {
|
||||||
|
return deviceStatusMap.values.maxByOrNull { it.lastSeen }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AirPodsStatusListener {
|
||||||
|
fun onDeviceStatusChanged(device: AirPodsStatus, previousStatus: AirPodsStatus?)
|
||||||
|
fun onBroadcastFromNewAddress(device: AirPodsStatus)
|
||||||
|
fun onLidStateChanged(lidOpen: Boolean)
|
||||||
|
fun onEarStateChanged(device: AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean)
|
||||||
|
fun onBatteryChanged(device: AirPodsStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mBluetoothLeScanner: BluetoothLeScanner? = null
|
||||||
|
private var mScanCallback: ScanCallback? = null
|
||||||
|
private var airPodsStatusListener: AirPodsStatusListener? = null
|
||||||
|
private val deviceStatusMap = mutableMapOf<String, AirPodsStatus>()
|
||||||
|
private val verifiedAddresses = mutableSetOf<String>()
|
||||||
|
private val sharedPreferences: SharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||||
|
private var currentGlobalLidState: Boolean? = null
|
||||||
|
private var lastBroadcastTime: Long = 0
|
||||||
|
private val processedAddresses = mutableSetOf<String>()
|
||||||
|
|
||||||
|
private val lastValidCaseBatteryMap = mutableMapOf<String, Int>()
|
||||||
|
private val modelNames = mapOf(
|
||||||
|
0x0E20 to "AirPods Pro",
|
||||||
|
0x1420 to "AirPods Pro 2",
|
||||||
|
0x2420 to "AirPods Pro 2 (USB-C)",
|
||||||
|
0x0220 to "AirPods 1",
|
||||||
|
0x0F20 to "AirPods 2",
|
||||||
|
0x1320 to "AirPods 3",
|
||||||
|
0x1920 to "AirPods 4",
|
||||||
|
0x1B20 to "AirPods 4 (ANC)",
|
||||||
|
0x0A20 to "AirPods Max",
|
||||||
|
0x1F20 to "AirPods Max (USB-C)"
|
||||||
|
)
|
||||||
|
|
||||||
|
val colorNames = mapOf(
|
||||||
|
0x00 to "White", 0x01 to "Black", 0x02 to "Red", 0x03 to "Blue",
|
||||||
|
0x04 to "Pink", 0x05 to "Gray", 0x06 to "Silver", 0x07 to "Gold",
|
||||||
|
0x08 to "Rose Gold", 0x09 to "Space Gray", 0x0A to "Dark Blue",
|
||||||
|
0x0B to "Light Blue", 0x0C to "Yellow"
|
||||||
|
)
|
||||||
|
|
||||||
|
val connStates = mapOf(
|
||||||
|
0x00 to "Disconnected", 0x04 to "Idle", 0x05 to "Music",
|
||||||
|
0x06 to "Call", 0x07 to "Ringing", 0x09 to "Hanging Up", 0xFF to "Unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val cleanupHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val cleanupRunnable = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
cleanupStaleDevices()
|
||||||
|
checkLidStateTimeout()
|
||||||
|
cleanupHandler.postDelayed(this, CLEANUP_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAirPodsStatusListener(listener: AirPodsStatusListener) {
|
||||||
|
airPodsStatusListener = listener
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun startScanning() {
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "Starting BLE scanner")
|
||||||
|
|
||||||
|
val btManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
val btAdapter = btManager.adapter
|
||||||
|
|
||||||
|
if (btAdapter == null) {
|
||||||
|
Log.d(TAG, "No Bluetooth adapter available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mBluetoothLeScanner != null && mScanCallback != null) {
|
||||||
|
mBluetoothLeScanner?.stopScan(mScanCallback)
|
||||||
|
mScanCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!btAdapter.isEnabled) {
|
||||||
|
Log.d(TAG, "Bluetooth is disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mBluetoothLeScanner = btAdapter.bluetoothLeScanner
|
||||||
|
|
||||||
|
val scanSettings = ScanSettings.Builder()
|
||||||
|
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||||
|
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
|
||||||
|
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
|
||||||
|
.setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
|
||||||
|
.setReportDelay(500L)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val manufacturerData = ByteArray(27)
|
||||||
|
val manufacturerDataMask = ByteArray(27)
|
||||||
|
|
||||||
|
manufacturerData[0] = 7
|
||||||
|
manufacturerData[1] = 25
|
||||||
|
|
||||||
|
manufacturerDataMask[0] = -1
|
||||||
|
manufacturerDataMask[1] = -1
|
||||||
|
|
||||||
|
val scanFilter = ScanFilter.Builder()
|
||||||
|
.setManufacturerData(76, manufacturerData, manufacturerDataMask)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
mScanCallback = object : ScanCallback() {
|
||||||
|
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||||
|
processScanResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBatchScanResults(results: List<ScanResult>) {
|
||||||
|
processedAddresses.clear()
|
||||||
|
for (result in results) {
|
||||||
|
processScanResult(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onScanFailed(errorCode: Int) {
|
||||||
|
Log.e(TAG, "BLE scan failed with error code: $errorCode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mBluetoothLeScanner?.startScan(listOf(scanFilter), scanSettings, mScanCallback)
|
||||||
|
Log.d(TAG, "BLE scanner started successfully")
|
||||||
|
|
||||||
|
cleanupHandler.postDelayed(cleanupRunnable, CLEANUP_INTERVAL_MS)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.e(TAG, "Error starting BLE scanner", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun stopScanning() {
|
||||||
|
try {
|
||||||
|
if (mBluetoothLeScanner != null && mScanCallback != null) {
|
||||||
|
Log.d(TAG, "Stopping BLE scanner")
|
||||||
|
mBluetoothLeScanner?.stopScan(mScanCallback)
|
||||||
|
mScanCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupHandler.removeCallbacks(cleanupRunnable)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.e(TAG, "Error stopping BLE scanner", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
private fun getEncryptionKeyFromPreferences(): ByteArray? {
|
||||||
|
val keyBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null)
|
||||||
|
return if (keyBase64 != null) {
|
||||||
|
try {
|
||||||
|
Base64.decode(keyBase64)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to decode encryption key", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decryptLastBytes(data: ByteArray, key: ByteArray): ByteArray? {
|
||||||
|
return try {
|
||||||
|
if (data.size < 16) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val block = data.copyOfRange(data.size - 16, data.size)
|
||||||
|
val cipher = Cipher.getInstance("AES/ECB/NoPadding")
|
||||||
|
val secretKey = SecretKeySpec(key, "AES")
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, secretKey)
|
||||||
|
cipher.doFinal(block)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error decrypting data", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatBattery(byteVal: Int): Pair<Boolean, Int> {
|
||||||
|
val charging = (byteVal and 0x80) != 0
|
||||||
|
val level = byteVal and 0x7F
|
||||||
|
return Pair(charging, level)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processScanResult(result: ScanResult) {
|
||||||
|
try {
|
||||||
|
val scanRecord = result.scanRecord ?: return
|
||||||
|
val address = result.device.address
|
||||||
|
|
||||||
|
if (processedAddresses.contains(address)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return
|
||||||
|
if (manufacturerData.size <= 20) return
|
||||||
|
|
||||||
|
if (!verifiedAddresses.contains(address)) {
|
||||||
|
val irk = getIrkFromPreferences()
|
||||||
|
if (irk == null || !BluetoothCryptography.verifyRPA(address, irk)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
verifiedAddresses.add(address)
|
||||||
|
Log.d(TAG, "RPA verified and added to trusted list: $address")
|
||||||
|
}
|
||||||
|
|
||||||
|
processedAddresses.add(address)
|
||||||
|
lastBroadcastTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
val encryptionKey = getEncryptionKeyFromPreferences()
|
||||||
|
val decryptedData = if (encryptionKey != null) decryptLastBytes(manufacturerData, encryptionKey) else null
|
||||||
|
val parsedStatus = if (decryptedData != null && decryptedData.size == 16) {
|
||||||
|
parseProximityMessageWithDecryption(address, manufacturerData, decryptedData)
|
||||||
|
} else {
|
||||||
|
parseProximityMessage(address, manufacturerData)
|
||||||
|
}
|
||||||
|
|
||||||
|
val previousStatus = deviceStatusMap[address]
|
||||||
|
deviceStatusMap[address] = parsedStatus
|
||||||
|
|
||||||
|
airPodsStatusListener?.let { listener ->
|
||||||
|
if (previousStatus == null) {
|
||||||
|
listener.onBroadcastFromNewAddress(parsedStatus)
|
||||||
|
Log.d(TAG, "New AirPods device detected: $address")
|
||||||
|
|
||||||
|
if (currentGlobalLidState == null || currentGlobalLidState != parsedStatus.lidOpen) {
|
||||||
|
currentGlobalLidState = parsedStatus.lidOpen
|
||||||
|
listener.onLidStateChanged(parsedStatus.lidOpen)
|
||||||
|
Log.d(TAG, "Lid state ${if (parsedStatus.lidOpen) "opened" else "closed"} (detected from new device)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (parsedStatus != previousStatus) {
|
||||||
|
listener.onDeviceStatusChanged(parsedStatus, previousStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedStatus.lidOpen != previousStatus.lidOpen) {
|
||||||
|
val previousGlobalState = currentGlobalLidState
|
||||||
|
currentGlobalLidState = parsedStatus.lidOpen
|
||||||
|
|
||||||
|
if (previousGlobalState != parsedStatus.lidOpen) {
|
||||||
|
listener.onLidStateChanged(parsedStatus.lidOpen)
|
||||||
|
Log.d(TAG, "Lid state changed from ${previousGlobalState} to ${parsedStatus.lidOpen}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedStatus.isLeftInEar != previousStatus.isLeftInEar ||
|
||||||
|
parsedStatus.isRightInEar != previousStatus.isRightInEar) {
|
||||||
|
listener.onEarStateChanged(
|
||||||
|
parsedStatus,
|
||||||
|
parsedStatus.isLeftInEar,
|
||||||
|
parsedStatus.isRightInEar
|
||||||
|
)
|
||||||
|
Log.d(TAG, "Ear state changed - Left: ${parsedStatus.isLeftInEar}, Right: ${parsedStatus.isRightInEar}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedStatus.leftBattery != previousStatus.leftBattery ||
|
||||||
|
parsedStatus.rightBattery != previousStatus.rightBattery ||
|
||||||
|
parsedStatus.caseBattery != previousStatus.caseBattery) {
|
||||||
|
listener.onBatteryChanged(parsedStatus)
|
||||||
|
Log.d(TAG, "Battery changed - Left: ${parsedStatus.leftBattery}, Right: ${parsedStatus.rightBattery}, Case: ${parsedStatus.caseBattery}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.e(TAG, "Error processing scan result", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseProximityMessageWithDecryption(address: String, data: ByteArray, decrypted: ByteArray): AirPodsStatus {
|
||||||
|
val paired = data[2].toInt() == 1
|
||||||
|
val modelId = ((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF)
|
||||||
|
val model = modelNames[modelId] ?: "Unknown ($modelId)"
|
||||||
|
|
||||||
|
val status = data[5].toInt() and 0xFF
|
||||||
|
val flagsCase = data[7].toInt() and 0xFF
|
||||||
|
val lid = data[8].toInt() and 0xFF
|
||||||
|
val color = colorNames[data[9].toInt()] ?: "Unknown"
|
||||||
|
val conn = connStates[data[10].toInt()] ?: "Unknown (${data[10].toInt()})"
|
||||||
|
|
||||||
|
val primaryLeft = ((status shr 5) and 0x01) == 1
|
||||||
|
val thisInCase = ((status shr 6) and 0x01) == 1
|
||||||
|
val xorFactor = primaryLeft xor thisInCase
|
||||||
|
|
||||||
|
val isLeftInEar = if (xorFactor) (status and 0x08) != 0 else (status and 0x02) != 0
|
||||||
|
val isRightInEar = if (xorFactor) (status and 0x02) != 0 else (status and 0x08) != 0
|
||||||
|
|
||||||
|
val isFlipped = !primaryLeft
|
||||||
|
|
||||||
|
val leftByteIndex = if (isFlipped) 2 else 1
|
||||||
|
val rightByteIndex = if (isFlipped) 1 else 2
|
||||||
|
|
||||||
|
val (isLeftCharging, leftBattery) = formatBattery(decrypted[leftByteIndex].toInt() and 0xFF)
|
||||||
|
val (isRightCharging, rightBattery) = formatBattery(decrypted[rightByteIndex].toInt() and 0xFF)
|
||||||
|
|
||||||
|
val rawCaseBatteryByte = decrypted[3].toInt() and 0xFF
|
||||||
|
val (isCaseCharging, rawCaseBattery) = formatBattery(rawCaseBatteryByte)
|
||||||
|
|
||||||
|
val caseBattery = if (rawCaseBatteryByte == 0xFF || (isCaseCharging && rawCaseBattery == 127)) {
|
||||||
|
lastValidCaseBatteryMap[address]
|
||||||
|
} else {
|
||||||
|
lastValidCaseBatteryMap[address] = rawCaseBattery
|
||||||
|
rawCaseBattery
|
||||||
|
}
|
||||||
|
|
||||||
|
val lidOpen = ((lid shr 3) and 0x01) == 0
|
||||||
|
|
||||||
|
return AirPodsStatus(
|
||||||
|
address = address,
|
||||||
|
lastSeen = System.currentTimeMillis(),
|
||||||
|
paired = paired,
|
||||||
|
model = model,
|
||||||
|
leftBattery = leftBattery,
|
||||||
|
rightBattery = rightBattery,
|
||||||
|
caseBattery = caseBattery,
|
||||||
|
isLeftInEar = isLeftInEar,
|
||||||
|
isRightInEar = isRightInEar,
|
||||||
|
isLeftCharging = isLeftCharging,
|
||||||
|
isRightCharging = isRightCharging,
|
||||||
|
isCaseCharging = isCaseCharging,
|
||||||
|
lidOpen = lidOpen,
|
||||||
|
color = color,
|
||||||
|
connectionState = conn
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanupStaleDevices() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val staleCutoff = now - STALE_DEVICE_TIMEOUT_MS
|
||||||
|
|
||||||
|
val staleDevices = deviceStatusMap.filter { it.value.lastSeen < staleCutoff }
|
||||||
|
|
||||||
|
for (device in staleDevices) {
|
||||||
|
deviceStatusMap.remove(device.key)
|
||||||
|
Log.d(TAG, "Removed stale device from tracking: ${device.key}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkLidStateTimeout() {
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
if (currentTime - lastBroadcastTime > LID_CLOSE_TIMEOUT_MS && currentGlobalLidState == true) {
|
||||||
|
Log.d(TAG, "No broadcasts for ${LID_CLOSE_TIMEOUT_MS}ms, forcing lid state to closed")
|
||||||
|
currentGlobalLidState = false
|
||||||
|
airPodsStatusListener?.onLidStateChanged(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
private fun getIrkFromPreferences(): ByteArray? {
|
||||||
|
val irkBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null)
|
||||||
|
return if (irkBase64 != null) {
|
||||||
|
try {
|
||||||
|
Base64.decode(irkBase64)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to decode IRK", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseProximityMessage(address: String, data: ByteArray): AirPodsStatus {
|
||||||
|
val paired = data[2].toInt() == 1
|
||||||
|
val modelId = ((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF)
|
||||||
|
val model = modelNames[modelId] ?: "Unknown ($modelId)"
|
||||||
|
|
||||||
|
val status = data[5].toInt() and 0xFF
|
||||||
|
val podsBattery = data[6].toInt() and 0xFF
|
||||||
|
val flagsCase = data[7].toInt() and 0xFF
|
||||||
|
val lid = data[8].toInt() and 0xFF
|
||||||
|
val color = colorNames[data[9].toInt()] ?: "Unknown"
|
||||||
|
val conn = connStates[data[10].toInt()] ?: "Unknown (${data[10].toInt()})"
|
||||||
|
|
||||||
|
val primaryLeft = ((status shr 5) and 0x01) == 1
|
||||||
|
val thisInCase = ((status shr 6) and 0x01) == 1
|
||||||
|
val xorFactor = primaryLeft xor thisInCase
|
||||||
|
|
||||||
|
val isLeftInEar = if (xorFactor) (status and 0x08) != 0 else (status and 0x02) != 0
|
||||||
|
val isRightInEar = if (xorFactor) (status and 0x02) != 0 else (status and 0x08) != 0
|
||||||
|
|
||||||
|
val isFlipped = !primaryLeft
|
||||||
|
|
||||||
|
val leftBatteryNibble = if (isFlipped) (podsBattery shr 4) and 0x0F else podsBattery and 0x0F
|
||||||
|
val rightBatteryNibble = if (isFlipped) podsBattery and 0x0F else (podsBattery shr 4) and 0x0F
|
||||||
|
|
||||||
|
val caseBattery = flagsCase and 0x0F
|
||||||
|
val flags = (flagsCase shr 4) and 0x0F
|
||||||
|
|
||||||
|
val isLeftCharging = if (isFlipped) (flags and 0x02) != 0 else (flags and 0x01) != 0
|
||||||
|
val isRightCharging = if (isFlipped) (flags and 0x01) != 0 else (flags and 0x02) != 0
|
||||||
|
val isCaseCharging = (flags and 0x04) != 0
|
||||||
|
|
||||||
|
val lidOpen = ((lid shr 3) and 0x01) == 0
|
||||||
|
|
||||||
|
fun decodeBattery(n: Int): Int? = when (n) {
|
||||||
|
in 0x0..0x9 -> n * 10
|
||||||
|
in 0xA..0xE -> 100
|
||||||
|
0xF -> null
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
return AirPodsStatus(
|
||||||
|
address = address,
|
||||||
|
lastSeen = System.currentTimeMillis(),
|
||||||
|
paired = paired,
|
||||||
|
model = model,
|
||||||
|
leftBattery = decodeBattery(leftBatteryNibble),
|
||||||
|
rightBattery = decodeBattery(rightBatteryNibble),
|
||||||
|
caseBattery = decodeBattery(caseBattery),
|
||||||
|
isLeftInEar = isLeftInEar,
|
||||||
|
isRightInEar = isRightInEar,
|
||||||
|
isLeftCharging = isLeftCharging,
|
||||||
|
isRightCharging = isRightCharging,
|
||||||
|
isCaseCharging = isCaseCharging,
|
||||||
|
lidOpen = lidOpen,
|
||||||
|
color = color,
|
||||||
|
connectionState = conn
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AirPodsBLE"
|
||||||
|
private const val CLEANUP_INTERVAL_MS = 30000L
|
||||||
|
private const val STALE_DEVICE_TIMEOUT_MS = 60000L
|
||||||
|
private const val LID_CLOSE_TIMEOUT_MS = 2000L
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* LibrePods - AirPods liberated from Apple's ecosystem
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 LibrePods Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothDevice
|
||||||
|
import android.bluetooth.BluetoothSocket
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
object BluetoothConnectionManager {
|
||||||
|
private const val TAG = "BluetoothConnectionManager"
|
||||||
|
|
||||||
|
private var currentSocket: BluetoothSocket? = null
|
||||||
|
private var currentDevice: BluetoothDevice? = null
|
||||||
|
|
||||||
|
fun setCurrentConnection(socket: BluetoothSocket, device: BluetoothDevice) {
|
||||||
|
currentSocket = socket
|
||||||
|
currentDevice = device
|
||||||
|
Log.d(TAG, "Current connection set to device: ${device.address}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentSocket(): BluetoothSocket? {
|
||||||
|
return currentSocket
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
* LibrePods - AirPods liberated from Apple's ecosystem
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 LibrePods Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU 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.utils
|
||||||
|
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utilities for Bluetooth cryptography operations, particularly for
|
||||||
|
* verifying Resolvable Private Addresses (RPA) used by AirPods.
|
||||||
|
*/
|
||||||
|
object BluetoothCryptography {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies if the provided Bluetooth address is an RPA that matches the given Identity Resolving Key (IRK)
|
||||||
|
*
|
||||||
|
* @param addr The Bluetooth address to verify
|
||||||
|
* @param irk The Identity Resolving Key to use for verification
|
||||||
|
* @return true if the address is verified as an RPA matching the IRK
|
||||||
|
*/
|
||||||
|
fun verifyRPA(addr: String, irk: ByteArray): Boolean {
|
||||||
|
val rpa = addr.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray()
|
||||||
|
val prand = rpa.copyOfRange(3, 6)
|
||||||
|
val hash = rpa.copyOfRange(0, 3)
|
||||||
|
val computedHash = ah(irk, prand)
|
||||||
|
return hash.contentEquals(computedHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs E function (AES-128) as specified in Bluetooth Core Specification
|
||||||
|
*
|
||||||
|
* @param key The key for encryption
|
||||||
|
* @param data The data to encrypt
|
||||||
|
* @return The encrypted data
|
||||||
|
*/
|
||||||
|
fun e(key: ByteArray, data: ByteArray): ByteArray {
|
||||||
|
val swappedKey = key.reversedArray()
|
||||||
|
val swappedData = data.reversedArray()
|
||||||
|
val cipher = Cipher.getInstance("AES/ECB/NoPadding")
|
||||||
|
val secretKey = SecretKeySpec(swappedKey, "AES")
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
|
||||||
|
return cipher.doFinal(swappedData).reversedArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs the ah function as specified in Bluetooth Core Specification
|
||||||
|
*
|
||||||
|
* @param k The IRK key
|
||||||
|
* @param r The random part of the address
|
||||||
|
* @return The hash part of the address
|
||||||
|
*/
|
||||||
|
fun ah(k: ByteArray, r: ByteArray): ByteArray {
|
||||||
|
val rPadded = ByteArray(16)
|
||||||
|
r.copyInto(rPadded, 0, 0, 3)
|
||||||
|
val encrypted = e(k, rPadded)
|
||||||
|
return encrypted.copyOfRange(0, 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ import kotlinx.coroutines.launch
|
|||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
enum class CrossDevicePackets(val packet: ByteArray) {
|
enum class CrossDevicePackets(val packet: ByteArray) {
|
||||||
AIRPODS_CONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x01)),
|
AIRPODS_CONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x01)),
|
||||||
@@ -87,7 +89,7 @@ object CrossDevice {
|
|||||||
private fun startServer() {
|
private fun startServer() {
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
if (!bluetoothAdapter.isEnabled) return@launch
|
if (!bluetoothAdapter.isEnabled) return@launch
|
||||||
serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
|
// serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
|
||||||
Log.d("CrossDevice", "Server started")
|
Log.d("CrossDevice", "Server started")
|
||||||
while (serverSocket != null) {
|
while (serverSocket != null) {
|
||||||
if (!bluetoothAdapter.isEnabled) {
|
if (!bluetoothAdapter.isEnabled) {
|
||||||
@@ -233,7 +235,7 @@ object CrossDevice {
|
|||||||
Log.d("CrossDevice", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
|
Log.d("CrossDevice", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
|
||||||
if (ServiceManager.getService()?.isConnectedLocally == true) {
|
if (ServiceManager.getService()?.isConnectedLocally == true) {
|
||||||
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
|
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
|
||||||
ServiceManager.getService()?.sendPacket(packetInHex)
|
// ServiceManager.getService()?.sendPacket(packetInHex)
|
||||||
} else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) {
|
} else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) {
|
||||||
batteryBytes = trimmedPacket
|
batteryBytes = trimmedPacket
|
||||||
ServiceManager.getService()?.batteryNotification?.setBattery(trimmedPacket)
|
ServiceManager.getService()?.batteryNotification?.setBattery(trimmedPacket)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -13,6 +15,7 @@ import me.kavishdevar.librepods.services.AirPodsService
|
|||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
@@ -20,14 +23,11 @@ import kotlin.math.pow
|
|||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
class GestureDetector(
|
class GestureDetector(
|
||||||
private val airPodsService: AirPodsService,
|
private val airPodsService: AirPodsService
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "GestureDetector"
|
private const val TAG = "GestureDetector"
|
||||||
|
|
||||||
private const val START_CMD = "04 00 04 00 17 00 00 00 10 00 10 00 08 A1 02 42 0B 08 0E 10 02 1A 05 01 40 9C 00 00"
|
|
||||||
private const val STOP_CMD = "04 00 04 00 17 00 00 00 10 00 11 00 08 7E 10 02 42 0B 08 4E 10 02 1A 05 01 00 00 00 00"
|
|
||||||
|
|
||||||
private const val IMMEDIATE_FEEDBACK_THRESHOLD = 600
|
private const val IMMEDIATE_FEEDBACK_THRESHOLD = 600
|
||||||
private const val DIRECTION_CHANGE_SENSITIVITY = 150
|
private const val DIRECTION_CHANGE_SENSITIVITY = 150
|
||||||
|
|
||||||
@@ -87,13 +87,13 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
|
|||||||
isRunning = true
|
isRunning = true
|
||||||
gestureDetectedCallback = onGestureDetected
|
gestureDetectedCallback = onGestureDetected
|
||||||
|
|
||||||
|
Log.d(TAG, "started: ${airPodsService.startHeadTracking()}")
|
||||||
|
|
||||||
clearData()
|
clearData()
|
||||||
|
|
||||||
prevHorizontal = 0.0
|
prevHorizontal = 0.0
|
||||||
prevVertical = 0.0
|
prevVertical = 0.0
|
||||||
|
|
||||||
airPodsService.sendPacket(START_CMD)
|
|
||||||
|
|
||||||
detectionJob = CoroutineScope(Dispatchers.Default).launch {
|
detectionJob = CoroutineScope(Dispatchers.Default).launch {
|
||||||
while (isRunning) {
|
while (isRunning) {
|
||||||
delay(50)
|
delay(50)
|
||||||
@@ -117,7 +117,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
|
|||||||
Log.d(TAG, "Stopping gesture detection")
|
Log.d(TAG, "Stopping gesture detection")
|
||||||
isRunning = false
|
isRunning = false
|
||||||
|
|
||||||
if (!doNotStop) airPodsService.sendPacket(STOP_CMD)
|
if (!doNotStop) airPodsService.stopHeadTracking()
|
||||||
|
|
||||||
detectionJob?.cancel()
|
detectionJob?.cancel()
|
||||||
detectionJob = null
|
detectionJob = null
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ package me.kavishdevar.librepods.utils
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.media.AudioDeviceInfo
|
|
||||||
import android.media.AudioFocusRequest
|
|
||||||
import android.media.AudioManager
|
|
||||||
import android.media.SoundPool
|
import android.media.SoundPool
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
@@ -22,44 +19,6 @@ class GestureFeedback(private val context: Context) {
|
|||||||
|
|
||||||
private val soundsLoaded = AtomicBoolean(false)
|
private val soundsLoaded = AtomicBoolean(false)
|
||||||
|
|
||||||
private fun forceBluetoothRouting(audioManager: AudioManager) {
|
|
||||||
try {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
|
||||||
val bluetoothDevice = devices.find {
|
|
||||||
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
|
|
||||||
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
|
|
||||||
}
|
|
||||||
|
|
||||||
bluetoothDevice?.let { device ->
|
|
||||||
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
|
|
||||||
.setAudioAttributes(AudioAttributes.Builder()
|
|
||||||
.setUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY)
|
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
|
||||||
.build())
|
|
||||||
.build()
|
|
||||||
|
|
||||||
audioManager.requestAudioFocus(focusRequest)
|
|
||||||
|
|
||||||
if (!audioManager.isBluetoothScoOn) {
|
|
||||||
audioManager.isBluetoothScoOn = true
|
|
||||||
audioManager.startBluetoothSco()
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "Forced audio routing to Bluetooth device")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!audioManager.isBluetoothScoOn) {
|
|
||||||
audioManager.isBluetoothScoOn = true
|
|
||||||
audioManager.startBluetoothSco()
|
|
||||||
Log.d(TAG, "Started Bluetooth SCO")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to force Bluetooth routing", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val soundPool = SoundPool.Builder()
|
private val soundPool = SoundPool.Builder()
|
||||||
.setMaxStreams(3)
|
.setMaxStreams(3)
|
||||||
.setAudioAttributes(
|
.setAudioAttributes(
|
||||||
@@ -201,12 +160,4 @@ class GestureFeedback(private val context: Context) {
|
|||||||
Log.d(TAG, "Playing ${if (isYes) "YES" else "NO"} confirmation - streamID=$streamId")
|
Log.d(TAG, "Playing ${if (isYes) "YES" else "NO"} confirmation - streamID=$streamId")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun release() {
|
|
||||||
try {
|
|
||||||
soundPool.release()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error releasing resources", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ object HeadTracking {
|
|||||||
private fun calculateOrientation(o1: Int, o2: Int, o3: Int): Orientation {
|
private fun calculateOrientation(o1: Int, o2: Int, o3: Int): Orientation {
|
||||||
if (!isCalibrated) return Orientation()
|
if (!isCalibrated) return Orientation()
|
||||||
|
|
||||||
// Add offset before normalizationval
|
|
||||||
val o1Norm = (o1 + ORIENTATION_OFFSET) - o1Neutral
|
val o1Norm = (o1 + ORIENTATION_OFFSET) - o1Neutral
|
||||||
val o2Norm = (o2 + ORIENTATION_OFFSET) - o2Neutral
|
val o2Norm = (o2 + ORIENTATION_OFFSET) - o2Neutral
|
||||||
val o3Norm = (o3 + ORIENTATION_OFFSET) - o3Neutral
|
val o3Norm = (o3 + ORIENTATION_OFFSET) - o3Neutral
|
||||||
|
|||||||
@@ -16,57 +16,200 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
import android.animation.Animator
|
import android.animation.Animator
|
||||||
import android.animation.AnimatorListenerAdapter
|
import android.animation.AnimatorListenerAdapter
|
||||||
import android.animation.ObjectAnimator
|
import android.animation.ObjectAnimator
|
||||||
import android.animation.PropertyValuesHolder
|
import android.animation.PropertyValuesHolder
|
||||||
|
import android.animation.ValueAnimator
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.PixelFormat
|
import android.graphics.PixelFormat
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log.e
|
import android.util.Log.e
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.VelocityTracker
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
|
import android.view.animation.AccelerateInterpolator
|
||||||
import android.view.animation.AnticipateOvershootInterpolator
|
import android.view.animation.AnticipateOvershootInterpolator
|
||||||
|
import android.view.animation.DecelerateInterpolator
|
||||||
|
import android.view.animation.OvershootInterpolator
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.LinearLayout
|
||||||
import android.widget.ProgressBar
|
import android.widget.ProgressBar
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.VideoView
|
import android.widget.VideoView
|
||||||
import androidx.core.content.ContextCompat.getString
|
import androidx.core.content.ContextCompat.getString
|
||||||
|
import androidx.dynamicanimation.animation.DynamicAnimation
|
||||||
|
import androidx.dynamicanimation.animation.SpringAnimation
|
||||||
|
import androidx.dynamicanimation.animation.SpringForce
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
enum class IslandType {
|
enum class IslandType {
|
||||||
CONNECTED,
|
CONNECTED,
|
||||||
TAKING_OVER,
|
TAKING_OVER,
|
||||||
MOVED_TO_REMOTE,
|
MOVED_TO_REMOTE,
|
||||||
// CALL_GESTURE
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class IslandWindow(context: Context) {
|
class IslandWindow(private val context: Context) {
|
||||||
private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||||
@SuppressLint("InflateParams")
|
@SuppressLint("InflateParams")
|
||||||
private val islandView: View = LayoutInflater.from(context).inflate(R.layout.island_window, null)
|
private val islandView: View = LayoutInflater.from(context).inflate(R.layout.island_window, null)
|
||||||
private var isClosing = false
|
private var isClosing = false
|
||||||
|
private var params: WindowManager.LayoutParams? = null
|
||||||
|
|
||||||
|
private var initialY = 0f
|
||||||
|
private var initialTouchY = 0f
|
||||||
|
private var lastTouchY = 0f
|
||||||
|
private var velocityTracker: VelocityTracker? = null
|
||||||
|
private var isBeingDragged = false
|
||||||
|
private var autoCloseHandler: Handler? = null
|
||||||
|
private var autoCloseRunnable: Runnable? = null
|
||||||
|
private var initialHeight = 0
|
||||||
|
private var screenHeight = 0
|
||||||
|
private var isDraggingDown = false
|
||||||
|
private var lastMoveTime = 0L
|
||||||
|
private var yMovement = 0f
|
||||||
|
private var dragDistance = 0f
|
||||||
|
|
||||||
|
private var initialConnectedTextY = 0f
|
||||||
|
private var initialDeviceTextY = 0f
|
||||||
|
private var initialBatteryViewY = 0f
|
||||||
|
private var initialVideoViewY = 0f
|
||||||
|
private var initialTextSeparation = 0f
|
||||||
|
|
||||||
|
private val containerView = FrameLayout(context)
|
||||||
|
|
||||||
|
private lateinit var springAnimation: SpringAnimation
|
||||||
|
private val flingAnimator = ValueAnimator()
|
||||||
|
|
||||||
|
private val batteryReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
if (intent?.action == AirPodsNotifications.BATTERY_DATA) {
|
||||||
|
val batteryList = intent.getParcelableArrayListExtra<Battery>("data")
|
||||||
|
updateBatteryDisplay(batteryList)
|
||||||
|
} else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
|
||||||
|
try {
|
||||||
|
context?.unregisterReceiver(this)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val isVisible: Boolean
|
val isVisible: Boolean
|
||||||
get() = islandView.parent != null && islandView.visibility == View.VISIBLE
|
get() = containerView.parent != null && containerView.visibility == View.VISIBLE
|
||||||
|
|
||||||
@SuppressLint("SetTextI18n")
|
private fun updateBatteryDisplay(batteryList: ArrayList<Battery>?) {
|
||||||
|
if (batteryList == null || batteryList.isEmpty()) return
|
||||||
|
|
||||||
|
val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
|
||||||
|
val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT }
|
||||||
|
|
||||||
|
val leftLevel = leftBattery?.level ?: 0
|
||||||
|
val rightLevel = rightBattery?.level ?: 0
|
||||||
|
val leftStatus = leftBattery?.status ?: BatteryStatus.DISCONNECTED
|
||||||
|
val rightStatus = rightBattery?.status ?: BatteryStatus.DISCONNECTED
|
||||||
|
|
||||||
|
val batteryText = islandView.findViewById<TextView>(R.id.island_battery_text)
|
||||||
|
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
|
||||||
|
|
||||||
|
val displayBatteryLevel = when {
|
||||||
|
leftLevel > 0 && rightLevel > 0 -> minOf(leftLevel, rightLevel)
|
||||||
|
leftLevel > 0 -> leftLevel
|
||||||
|
rightLevel > 0 -> rightLevel
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayBatteryLevel != null) {
|
||||||
|
batteryText.text = "$displayBatteryLevel%"
|
||||||
|
batteryProgressBar.progress = displayBatteryLevel
|
||||||
|
batteryProgressBar.isIndeterminate = false
|
||||||
|
} else {
|
||||||
|
batteryText.text = "?"
|
||||||
|
batteryProgressBar.progress = 0
|
||||||
|
batteryProgressBar.isIndeterminate = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetTextI18s", "ClickableViewAccessibility")
|
||||||
fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED) {
|
fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED) {
|
||||||
if (ServiceManager.getService()?.islandOpen == true) return
|
if (ServiceManager.getService()?.islandOpen == true) return
|
||||||
else ServiceManager.getService()?.islandOpen = true
|
else ServiceManager.getService()?.islandOpen = true
|
||||||
|
|
||||||
val displayMetrics = Resources.getSystem().displayMetrics
|
val displayMetrics = Resources.getSystem().displayMetrics
|
||||||
val width = (displayMetrics.widthPixels * 0.95).toInt()
|
val width = (displayMetrics.widthPixels * 0.95).toInt()
|
||||||
|
screenHeight = displayMetrics.heightPixels
|
||||||
|
|
||||||
val params = WindowManager.LayoutParams(
|
val batteryList = ServiceManager.getService()?.getBattery()
|
||||||
|
val batteryText = islandView.findViewById<TextView>(R.id.island_battery_text)
|
||||||
|
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
|
||||||
|
|
||||||
|
val displayBatteryLevel = if (batteryList != null) {
|
||||||
|
val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
|
||||||
|
val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT }
|
||||||
|
|
||||||
|
when {
|
||||||
|
leftBattery?.level ?: 0 > 0 && rightBattery?.level ?: 0 > 0 ->
|
||||||
|
minOf(leftBattery!!.level, rightBattery!!.level)
|
||||||
|
leftBattery?.level ?: 0 > 0 -> leftBattery!!.level
|
||||||
|
rightBattery?.level ?: 0 > 0 -> rightBattery!!.level
|
||||||
|
batteryPercentage > 0 -> batteryPercentage
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
} else if (batteryPercentage > 0) {
|
||||||
|
batteryPercentage
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayBatteryLevel != null) {
|
||||||
|
batteryText.text = "$displayBatteryLevel%"
|
||||||
|
batteryProgressBar.progress = displayBatteryLevel
|
||||||
|
} else {
|
||||||
|
batteryText.text = "?"
|
||||||
|
batteryProgressBar.progress = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
batteryProgressBar.isIndeterminate = false
|
||||||
|
islandView.findViewById<TextView>(R.id.island_device_name).text = name
|
||||||
|
|
||||||
|
val batteryIntentFilter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
|
||||||
|
batteryIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
context.registerReceiver(batteryReceiver, batteryIntentFilter, Context.RECEIVER_EXPORTED)
|
||||||
|
} else {
|
||||||
|
context.registerReceiver(batteryReceiver, batteryIntentFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
ServiceManager.getService()?.sendBatteryBroadcast()
|
||||||
|
|
||||||
|
containerView.removeAllViews()
|
||||||
|
val containerParams = FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
containerView.addView(islandView, containerParams)
|
||||||
|
|
||||||
|
params = WindowManager.LayoutParams(
|
||||||
width,
|
width,
|
||||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||||
@@ -77,12 +220,100 @@ class IslandWindow(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
islandView.visibility = View.VISIBLE
|
islandView.visibility = View.VISIBLE
|
||||||
islandView.findViewById<TextView>(R.id.island_battery_text).text = "$batteryPercentage%"
|
containerView.visibility = View.VISIBLE
|
||||||
islandView.findViewById<TextView>(R.id.island_device_name).text = name
|
|
||||||
|
|
||||||
islandView.setOnClickListener {
|
containerView.setOnTouchListener { _, event ->
|
||||||
ServiceManager.getService()?.startMainActivity()
|
when (event.action) {
|
||||||
close()
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return@setOnTouchListener false)
|
||||||
|
flingAnimator.cancel()
|
||||||
|
|
||||||
|
velocityTracker?.recycle()
|
||||||
|
velocityTracker = VelocityTracker.obtain()
|
||||||
|
velocityTracker?.addMovement(event)
|
||||||
|
|
||||||
|
initialY = containerView.translationY
|
||||||
|
initialTouchY = event.rawY
|
||||||
|
lastTouchY = event.rawY
|
||||||
|
initialHeight = islandView.height
|
||||||
|
isBeingDragged = false
|
||||||
|
isDraggingDown = false
|
||||||
|
lastMoveTime = System.currentTimeMillis()
|
||||||
|
dragDistance = 0f
|
||||||
|
|
||||||
|
captureInitialPositions()
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
velocityTracker?.addMovement(event)
|
||||||
|
val deltaY = event.rawY - initialTouchY
|
||||||
|
val moveDelta = event.rawY - lastTouchY
|
||||||
|
dragDistance += abs(moveDelta)
|
||||||
|
|
||||||
|
isDraggingDown = moveDelta > 0
|
||||||
|
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
val timeDelta = currentTime - lastMoveTime
|
||||||
|
if (timeDelta > 0) {
|
||||||
|
yMovement = moveDelta / timeDelta * 10
|
||||||
|
}
|
||||||
|
lastMoveTime = currentTime
|
||||||
|
|
||||||
|
if (abs(deltaY) > 5 || isBeingDragged) {
|
||||||
|
isBeingDragged = true
|
||||||
|
|
||||||
|
// Cancel auto close timer when dragging starts
|
||||||
|
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return@setOnTouchListener false)
|
||||||
|
|
||||||
|
val dampedDeltaY = if (deltaY > 0) {
|
||||||
|
initialY + (deltaY * 0.6f)
|
||||||
|
} else {
|
||||||
|
initialY + (deltaY * 0.9f)
|
||||||
|
}
|
||||||
|
containerView.translationY = dampedDeltaY
|
||||||
|
|
||||||
|
if (isDraggingDown && deltaY > 0) {
|
||||||
|
val stretchAmount = (deltaY * 0.5f).coerceAtMost(200f)
|
||||||
|
applyCustomStretchEffect(stretchAmount, deltaY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastTouchY = event.rawY
|
||||||
|
true
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||||
|
velocityTracker?.addMovement(event)
|
||||||
|
velocityTracker?.computeCurrentVelocity(1000)
|
||||||
|
val yVelocity = velocityTracker?.yVelocity ?: 0f
|
||||||
|
|
||||||
|
if (isBeingDragged) {
|
||||||
|
val currentTranslationY = containerView.translationY
|
||||||
|
val significantVelocity = abs(yVelocity) > 800
|
||||||
|
val significantDrag = abs(dragDistance) > 80
|
||||||
|
|
||||||
|
when {
|
||||||
|
yVelocity < -1200 || (currentTranslationY < -80 && !isDraggingDown) -> {
|
||||||
|
animateDismissWithInertia(yVelocity)
|
||||||
|
}
|
||||||
|
yVelocity > 1200 || (isDraggingDown && significantDrag) -> {
|
||||||
|
animateExpandWithStretch(yVelocity)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
springBackWithInertia(yVelocity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (dragDistance < 10) {
|
||||||
|
resetAutoCloseTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
velocityTracker?.recycle()
|
||||||
|
velocityTracker = null
|
||||||
|
isBeingDragged = false
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (type) {
|
when (type) {
|
||||||
@@ -95,16 +326,8 @@ class IslandWindow(context: Context) {
|
|||||||
IslandType.MOVED_TO_REMOTE -> {
|
IslandType.MOVED_TO_REMOTE -> {
|
||||||
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_remote_text)
|
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_remote_text)
|
||||||
}
|
}
|
||||||
// IslandType.CALL_GESTURE -> {
|
|
||||||
// islandView.findViewById<TextView>(R.id.island_connected_text).text = "Incoming Call from $name"
|
|
||||||
// islandView.findViewById<TextView>(R.id.island_device_name).text = "Use Head Gestures to answer."
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
|
|
||||||
batteryProgressBar.progress = batteryPercentage
|
|
||||||
batteryProgressBar.isIndeterminate = false
|
|
||||||
|
|
||||||
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||||
val videoUri = Uri.parse("android.resource://me.kavishdevar.librepods/${R.raw.island}")
|
val videoUri = Uri.parse("android.resource://me.kavishdevar.librepods/${R.raw.island}")
|
||||||
videoView.setVideoURI(videoUri)
|
videoView.setVideoURI(videoUri)
|
||||||
@@ -113,19 +336,266 @@ class IslandWindow(context: Context) {
|
|||||||
videoView.start()
|
videoView.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
windowManager.addView(islandView, params)
|
windowManager.addView(containerView, params)
|
||||||
|
|
||||||
|
islandView.post {
|
||||||
|
initialHeight = islandView.height
|
||||||
|
captureInitialPositions()
|
||||||
|
}
|
||||||
|
|
||||||
|
springAnimation = SpringAnimation(containerView, DynamicAnimation.TRANSLATION_Y, 0f).apply {
|
||||||
|
spring = SpringForce(0f)
|
||||||
|
.setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
|
||||||
|
.setStiffness(SpringForce.STIFFNESS_MEDIUM)
|
||||||
|
}
|
||||||
|
|
||||||
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0.5f, 1f)
|
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0.5f, 1f)
|
||||||
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.5f, 1f)
|
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.5f, 1f)
|
||||||
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -200f, 0f)
|
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -200f, 0f)
|
||||||
ObjectAnimator.ofPropertyValuesHolder(islandView, scaleX, scaleY, translationY).apply {
|
ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply {
|
||||||
duration = 700
|
duration = 700
|
||||||
interpolator = AnticipateOvershootInterpolator()
|
interpolator = AnticipateOvershootInterpolator()
|
||||||
start()
|
start()
|
||||||
}
|
}
|
||||||
Handler(Looper.getMainLooper()).postDelayed({
|
|
||||||
close()
|
resetAutoCloseTimer()
|
||||||
}, 4500)
|
}
|
||||||
|
|
||||||
|
private fun captureInitialPositions() {
|
||||||
|
val connectedText = islandView.findViewById<TextView>(R.id.island_connected_text)
|
||||||
|
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
|
||||||
|
val batteryView = islandView.findViewById<FrameLayout>(R.id.island_battery_container)
|
||||||
|
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||||
|
|
||||||
|
connectedText.post {
|
||||||
|
initialConnectedTextY = connectedText.y
|
||||||
|
initialDeviceTextY = deviceText.y
|
||||||
|
initialTextSeparation = deviceText.y - (connectedText.y + connectedText.height)
|
||||||
|
|
||||||
|
if (batteryView != null) initialBatteryViewY = batteryView.y
|
||||||
|
initialVideoViewY = videoView.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyCustomStretchEffect(stretchAmount: Float, dragY: Float) {
|
||||||
|
try {
|
||||||
|
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
|
||||||
|
val connectedText = islandView.findViewById<TextView>(R.id.island_connected_text)
|
||||||
|
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
|
||||||
|
val batteryView = islandView.findViewById<FrameLayout>(R.id.island_battery_container)
|
||||||
|
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||||
|
|
||||||
|
val stretchFactor = 1f + (stretchAmount / 300f).coerceAtMost(4.0f)
|
||||||
|
val newMinHeight = (initialHeight * stretchFactor).toInt()
|
||||||
|
mainLayout.minimumHeight = newMinHeight
|
||||||
|
|
||||||
|
val textMarginIncrease = (stretchAmount * 0.8f).toInt()
|
||||||
|
|
||||||
|
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
|
||||||
|
deviceTextParams.topMargin = textMarginIncrease
|
||||||
|
deviceText.layoutParams = deviceTextParams
|
||||||
|
|
||||||
|
val background = mainLayout.background
|
||||||
|
if (background is GradientDrawable) {
|
||||||
|
val cornerRadius = 56f
|
||||||
|
background.cornerRadius = cornerRadius
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params != null) {
|
||||||
|
params!!.height = screenHeight
|
||||||
|
|
||||||
|
val containerParams = containerView.layoutParams
|
||||||
|
containerParams.height = screenHeight
|
||||||
|
containerView.layoutParams = containerParams
|
||||||
|
|
||||||
|
try {
|
||||||
|
windowManager.updateViewLayout(containerView, params)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetAutoCloseTimer() {
|
||||||
|
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
|
||||||
|
autoCloseHandler = Handler(Looper.getMainLooper())
|
||||||
|
autoCloseRunnable = Runnable { close() }
|
||||||
|
autoCloseHandler?.postDelayed(autoCloseRunnable!!, 4500)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun springBackWithInertia(velocity: Float) {
|
||||||
|
springAnimation.cancel()
|
||||||
|
flingAnimator.cancel()
|
||||||
|
|
||||||
|
springAnimation.setStartVelocity(velocity)
|
||||||
|
|
||||||
|
val baseStiffness = SpringForce.STIFFNESS_MEDIUM
|
||||||
|
val dynamicStiffness = baseStiffness * (1f + (abs(velocity) / 3000f))
|
||||||
|
springAnimation.spring = SpringForce(0f)
|
||||||
|
.setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
|
||||||
|
.setStiffness(dynamicStiffness)
|
||||||
|
|
||||||
|
resetStretchEffects(velocity)
|
||||||
|
|
||||||
|
if (params != null) {
|
||||||
|
params!!.height = WindowManager.LayoutParams.WRAP_CONTENT
|
||||||
|
try {
|
||||||
|
windowManager.updateViewLayout(containerView, params)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
springAnimation.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetStretchEffects(velocity: Float) {
|
||||||
|
try {
|
||||||
|
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
|
||||||
|
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
|
||||||
|
|
||||||
|
val heightAnimator = ValueAnimator.ofInt(mainLayout.minimumHeight, initialHeight)
|
||||||
|
heightAnimator.duration = 300
|
||||||
|
heightAnimator.interpolator = OvershootInterpolator(1.5f)
|
||||||
|
heightAnimator.addUpdateListener { animation ->
|
||||||
|
mainLayout.minimumHeight = animation.animatedValue as Int
|
||||||
|
}
|
||||||
|
|
||||||
|
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
|
||||||
|
val textMarginAnimator = ValueAnimator.ofInt(deviceTextParams.topMargin, 0)
|
||||||
|
textMarginAnimator.duration = 300
|
||||||
|
textMarginAnimator.interpolator = OvershootInterpolator(1.5f)
|
||||||
|
textMarginAnimator.addUpdateListener { animation ->
|
||||||
|
deviceTextParams.topMargin = animation.animatedValue as Int
|
||||||
|
deviceText.layoutParams = deviceTextParams
|
||||||
|
}
|
||||||
|
|
||||||
|
heightAnimator.start()
|
||||||
|
textMarginAnimator.start()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun animateDismissWithInertia(velocity: Float) {
|
||||||
|
springAnimation.cancel()
|
||||||
|
flingAnimator.cancel()
|
||||||
|
|
||||||
|
val baseDistance = -screenHeight
|
||||||
|
val velocityFactor = (abs(velocity) / 2000f).coerceIn(0.5f, 2.0f)
|
||||||
|
val targetDistance = baseDistance * velocityFactor
|
||||||
|
|
||||||
|
val baseDuration = 400L
|
||||||
|
val velocityDurationFactor = (1500f / (abs(velocity) + 1500f))
|
||||||
|
val duration = (baseDuration * velocityDurationFactor).toLong().coerceIn(200L, 500L)
|
||||||
|
|
||||||
|
flingAnimator.setFloatValues(containerView.translationY, targetDistance)
|
||||||
|
flingAnimator.duration = duration
|
||||||
|
flingAnimator.addUpdateListener { animation ->
|
||||||
|
containerView.translationY = animation.animatedValue as Float
|
||||||
|
|
||||||
|
val progress = animation.animatedFraction
|
||||||
|
containerView.scaleX = 1f - (progress * 0.5f)
|
||||||
|
containerView.scaleY = 1f - (progress * 0.5f)
|
||||||
|
|
||||||
|
containerView.alpha = 1f - progress
|
||||||
|
}
|
||||||
|
flingAnimator.addListener(object : AnimatorListenerAdapter() {
|
||||||
|
override fun onAnimationEnd(animation: Animator) {
|
||||||
|
forceClose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
flingAnimator.interpolator = DecelerateInterpolator(1.2f)
|
||||||
|
flingAnimator.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun animateExpandWithStretch(velocity: Float) {
|
||||||
|
springAnimation.cancel()
|
||||||
|
flingAnimator.cancel()
|
||||||
|
|
||||||
|
val baseDuration = 600L
|
||||||
|
val velocityFactor = (1800f / (abs(velocity) + 1800f)).coerceIn(0.5f, 1.5f)
|
||||||
|
val expandDuration = (baseDuration * velocityFactor).toLong().coerceIn(300L, 700L)
|
||||||
|
|
||||||
|
if (params != null) {
|
||||||
|
params!!.height = screenHeight
|
||||||
|
try {
|
||||||
|
windowManager.updateViewLayout(containerView, params)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val containerAnimator = ValueAnimator.ofFloat(containerView.translationY, screenHeight * 0.6f)
|
||||||
|
containerAnimator.duration = expandDuration
|
||||||
|
containerAnimator.interpolator = DecelerateInterpolator(0.8f)
|
||||||
|
containerAnimator.addUpdateListener { animation ->
|
||||||
|
containerView.translationY = animation.animatedValue as Float
|
||||||
|
}
|
||||||
|
|
||||||
|
val stretchAnimator = ValueAnimator.ofFloat(0f, 1f)
|
||||||
|
stretchAnimator.duration = expandDuration
|
||||||
|
stretchAnimator.interpolator = OvershootInterpolator(0.5f)
|
||||||
|
stretchAnimator.addUpdateListener { animation ->
|
||||||
|
val progress = animation.animatedValue as Float
|
||||||
|
animateCustomStretch(progress, expandDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalizeAnimator = ValueAnimator.ofFloat(1.0f, 0.0f)
|
||||||
|
normalizeAnimator.duration = 300
|
||||||
|
normalizeAnimator.startDelay = expandDuration - 150
|
||||||
|
normalizeAnimator.interpolator = AccelerateInterpolator(1.2f)
|
||||||
|
normalizeAnimator.addUpdateListener { animation ->
|
||||||
|
val progress = animation.animatedValue as Float
|
||||||
|
containerView.alpha = progress
|
||||||
|
|
||||||
|
if (progress < 0.7f) {
|
||||||
|
islandView.findViewById<VideoView>(R.id.island_video_view).visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalizeAnimator.addListener(object : AnimatorListenerAdapter() {
|
||||||
|
override fun onAnimationEnd(animation: Animator) {
|
||||||
|
ServiceManager.getService()?.startMainActivity()
|
||||||
|
forceClose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
containerAnimator.start()
|
||||||
|
stretchAnimator.start()
|
||||||
|
normalizeAnimator.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun animateCustomStretch(progress: Float, duration: Long) {
|
||||||
|
try {
|
||||||
|
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
|
||||||
|
val connectedText = islandView.findViewById<TextView>(R.id.island_connected_text)
|
||||||
|
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
|
||||||
|
|
||||||
|
val targetHeight = (screenHeight * 0.7f).toInt()
|
||||||
|
val currentHeight = initialHeight + ((targetHeight - initialHeight) * progress)
|
||||||
|
mainLayout.minimumHeight = currentHeight.toInt()
|
||||||
|
|
||||||
|
val mainLayoutParams = mainLayout.layoutParams
|
||||||
|
mainLayoutParams.height = LinearLayout.LayoutParams.MATCH_PARENT
|
||||||
|
mainLayout.layoutParams = mainLayoutParams
|
||||||
|
|
||||||
|
val targetMargin = (400 * progress).toInt()
|
||||||
|
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
|
||||||
|
deviceTextParams.topMargin = targetMargin
|
||||||
|
deviceText.layoutParams = deviceTextParams
|
||||||
|
|
||||||
|
val baseTextSize = 24f
|
||||||
|
deviceText.textSize = baseTextSize + (progress * 8f)
|
||||||
|
|
||||||
|
val baseSubTextSize = 16f
|
||||||
|
connectedText.textSize = baseSubTextSize + (progress * 4f)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun close() {
|
fun close() {
|
||||||
@@ -133,31 +603,82 @@ class IslandWindow(context: Context) {
|
|||||||
if (isClosing) return
|
if (isClosing) return
|
||||||
isClosing = true
|
isClosing = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.unregisterReceiver(batteryReceiver)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
|
||||||
ServiceManager.getService()?.islandOpen = false
|
ServiceManager.getService()?.islandOpen = false
|
||||||
|
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
|
||||||
|
|
||||||
|
resetStretchEffects(0f)
|
||||||
|
|
||||||
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||||
videoView.stopPlayback()
|
try {
|
||||||
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 1f, 0.5f)
|
videoView.stopPlayback()
|
||||||
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 1f, 0.5f)
|
} catch (e: Exception) {
|
||||||
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0f, -200f)
|
e.printStackTrace()
|
||||||
ObjectAnimator.ofPropertyValuesHolder(islandView, scaleX, scaleY, translationY).apply {
|
}
|
||||||
|
|
||||||
|
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, containerView.scaleX, 0.5f)
|
||||||
|
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, containerView.scaleY, 0.5f)
|
||||||
|
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, containerView.translationY, -200f)
|
||||||
|
ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply {
|
||||||
duration = 700
|
duration = 700
|
||||||
interpolator = AnticipateOvershootInterpolator()
|
interpolator = AnticipateOvershootInterpolator()
|
||||||
addListener(object : AnimatorListenerAdapter() {
|
addListener(object : AnimatorListenerAdapter() {
|
||||||
override fun onAnimationEnd(animation: Animator) {
|
override fun onAnimationEnd(animation: Animator) {
|
||||||
islandView.visibility = View.GONE
|
cleanupAndRemoveView()
|
||||||
try {
|
|
||||||
windowManager.removeView(islandView)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e("IslandWindow", "Error removing view: $e")
|
|
||||||
}
|
|
||||||
isClosing = false
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
start()
|
start()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
|
// Even if animation fails, ensure we cleanup
|
||||||
|
cleanupAndRemoveView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanupAndRemoveView() {
|
||||||
|
containerView.visibility = View.GONE
|
||||||
|
try {
|
||||||
|
if (containerView.parent != null) {
|
||||||
|
windowManager.removeView(containerView)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e("IslandWindow", "Error removing view: $e")
|
||||||
|
}
|
||||||
|
isClosing = false
|
||||||
|
// Make sure all animations are canceled
|
||||||
|
springAnimation.cancel()
|
||||||
|
flingAnimator.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forceClose() {
|
||||||
|
try {
|
||||||
|
if (isClosing) return
|
||||||
|
isClosing = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.unregisterReceiver(batteryReceiver)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Silent catch - receiver might already be unregistered
|
||||||
|
}
|
||||||
|
|
||||||
|
ServiceManager.getService()?.islandOpen = false
|
||||||
|
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
|
||||||
|
|
||||||
|
// Cancel all ongoing animations
|
||||||
|
springAnimation.cancel()
|
||||||
|
flingAnimator.cancel()
|
||||||
|
|
||||||
|
// Immediately remove the view without animations
|
||||||
|
cleanupAndRemoveView()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
isClosing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,35 @@ class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (param.packageName == "com.google.android.settings") {
|
||||||
|
Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling")
|
||||||
|
try {
|
||||||
|
val headerControllerClass = param.classLoader.loadClass(
|
||||||
|
"com.google.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController")
|
||||||
|
|
||||||
|
val updateIconMethod = headerControllerClass.getDeclaredMethod(
|
||||||
|
"updateIcon",
|
||||||
|
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.settings") {
|
if (param.packageName == "com.android.settings") {
|
||||||
Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling")
|
Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling")
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
/*
|
||||||
|
* LibrePods - AirPods liberated from Apple's ecosystem
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
|
||||||
|
class LogCollector(private val context: Context) {
|
||||||
|
private var isCollecting = false
|
||||||
|
private var logProcess: Process? = null
|
||||||
|
|
||||||
|
suspend fun openXposedSettings(context: Context) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val command = if (android.os.Build.VERSION.SDK_INT >= 29) {
|
||||||
|
"am broadcast -a android.telephony.action.SECRET_CODE -d android_secret_code://5776733 android"
|
||||||
|
} else {
|
||||||
|
"am broadcast -a android.provider.Telephony.SECRET_CODE -d android_secret_code://5776733 android"
|
||||||
|
}
|
||||||
|
|
||||||
|
executeRootCommand(command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clearLogs() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
executeRootCommand("logcat -c")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun killBluetoothService() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
executeRootCommand("killall com.android.bluetooth")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getPackageUIDs(): Pair<String?, String?> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
val btUid = executeRootCommand("dumpsys package com.android.bluetooth | grep -m 1 \"uid=\" | sed -E 's/.*uid=([0-9]+).*/\\1/'")
|
||||||
|
.trim()
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
val appUid = executeRootCommand("dumpsys package me.kavishdevar.librepods | grep -m 1 \"uid=\" | sed -E 's/.*uid=([0-9]+).*/\\1/'")
|
||||||
|
.trim()
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
Pair(btUid, appUid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun startLogCollection(listener: (String) -> Unit, connectionDetectedCallback: () -> Unit): String {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
isCollecting = true
|
||||||
|
val (btUid, appUid) = getPackageUIDs()
|
||||||
|
|
||||||
|
val uidFilter = buildString {
|
||||||
|
if (!btUid.isNullOrEmpty() && !appUid.isNullOrEmpty()) {
|
||||||
|
append("$btUid,$appUid")
|
||||||
|
} else if (!btUid.isNullOrEmpty()) {
|
||||||
|
append(btUid)
|
||||||
|
} else if (!appUid.isNullOrEmpty()) {
|
||||||
|
append(appUid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val command = if (uidFilter.isNotEmpty()) {
|
||||||
|
"su -c logcat --uid=$uidFilter -v threadtime"
|
||||||
|
} else {
|
||||||
|
"su -c logcat -v threadtime"
|
||||||
|
}
|
||||||
|
|
||||||
|
val logs = StringBuilder()
|
||||||
|
try {
|
||||||
|
logProcess = Runtime.getRuntime().exec(command)
|
||||||
|
val reader = BufferedReader(InputStreamReader(logProcess!!.inputStream))
|
||||||
|
var line: String? = null
|
||||||
|
var connectionDetected = false
|
||||||
|
|
||||||
|
while (isCollecting && reader.readLine().also { line = it } != null) {
|
||||||
|
line?.let {
|
||||||
|
if (it.contains("<LogCollector:")) {
|
||||||
|
logs.append("\n=============\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
logs.append(it).append("\n")
|
||||||
|
listener(it)
|
||||||
|
|
||||||
|
if (it.contains("<LogCollector:")) {
|
||||||
|
logs.append("=============\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connectionDetected) {
|
||||||
|
if (it.contains("<LogCollector:Complete:Success>")) {
|
||||||
|
connectionDetected = true
|
||||||
|
connectionDetectedCallback()
|
||||||
|
} else if (it.contains("<LogCollector:Complete:Failed>")) {
|
||||||
|
connectionDetected = true
|
||||||
|
connectionDetectedCallback()
|
||||||
|
} else if (it.contains("<LogCollector:Start>")) {
|
||||||
|
}
|
||||||
|
else if (it.contains("AirPodsService") && it.contains("Connected to device")) {
|
||||||
|
connectionDetected = true
|
||||||
|
connectionDetectedCallback()
|
||||||
|
} else if (it.contains("AirPodsService") && it.contains("Connection failed")) {
|
||||||
|
connectionDetected = true
|
||||||
|
connectionDetectedCallback()
|
||||||
|
} else if (it.contains("AirPodsService") && it.contains("Device disconnected")) {
|
||||||
|
}
|
||||||
|
else if (it.contains("BluetoothService") && it.contains("CONNECTION_STATE_CONNECTED")) {
|
||||||
|
connectionDetected = true
|
||||||
|
connectionDetectedCallback()
|
||||||
|
} else if (it.contains("BluetoothService") && it.contains("CONNECTION_STATE_DISCONNECTED")) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logs.append("Error collecting logs: ${e.message}").append("\n")
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
|
||||||
|
logs.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopLogCollection() {
|
||||||
|
isCollecting = false
|
||||||
|
logProcess?.destroy()
|
||||||
|
logProcess = null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveLogToInternalStorage(fileName: String, content: String): File? {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val logsDir = File(context.filesDir, "logs")
|
||||||
|
if (!logsDir.exists()) {
|
||||||
|
logsDir.mkdir()
|
||||||
|
}
|
||||||
|
|
||||||
|
val file = File(logsDir, fileName)
|
||||||
|
file.writeText(content)
|
||||||
|
return@withContext file
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addLogMarker(markerType: LogMarkerType, details: String = "") {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val timestamp = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", java.util.Locale.US)
|
||||||
|
.format(java.util.Date())
|
||||||
|
|
||||||
|
val marker = when (markerType) {
|
||||||
|
LogMarkerType.START -> "<LogCollector:Start> [$timestamp] Beginning connection test"
|
||||||
|
LogMarkerType.SUCCESS -> "<LogCollector:Complete:Success> [$timestamp] Connection test completed successfully"
|
||||||
|
LogMarkerType.FAILURE -> "<LogCollector:Complete:Failed> [$timestamp] Connection test failed"
|
||||||
|
LogMarkerType.CUSTOM -> "<LogCollector:Custom:$details> [$timestamp]"
|
||||||
|
}
|
||||||
|
|
||||||
|
val command = "log -t AirPodsService \"$marker\""
|
||||||
|
executeRootCommand(command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class LogMarkerType {
|
||||||
|
START,
|
||||||
|
SUCCESS,
|
||||||
|
FAILURE,
|
||||||
|
CUSTOM
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun executeRootCommand(command: String): String {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val process = Runtime.getRuntime().exec("su -c $command")
|
||||||
|
val reader = BufferedReader(InputStreamReader(process.inputStream))
|
||||||
|
val output = StringBuilder()
|
||||||
|
var line: String?
|
||||||
|
|
||||||
|
while (reader.readLine().also { line = it } != null) {
|
||||||
|
output.append(line).append("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
process.waitFor()
|
||||||
|
output.toString()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,16 +16,21 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.media.AudioPlaybackConfiguration
|
import android.media.AudioPlaybackConfiguration
|
||||||
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
object MediaController {
|
object MediaController {
|
||||||
private var initialVolume: Int? = null
|
private var initialVolume: Int? = null
|
||||||
@@ -34,11 +39,12 @@ object MediaController {
|
|||||||
var userPlayedTheMedia = false
|
var userPlayedTheMedia = false
|
||||||
private lateinit var sharedPreferences: SharedPreferences
|
private lateinit var sharedPreferences: SharedPreferences
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private lateinit var preferenceChangeListener: SharedPreferences.OnSharedPreferenceChangeListener
|
||||||
|
|
||||||
var pausedForCrossDevice = false
|
var pausedForCrossDevice = false
|
||||||
|
|
||||||
private var relativeVolume: Boolean = false
|
private var relativeVolume: Boolean = false
|
||||||
private var conversationalAwarenessVolume: Int = 1/12
|
private var conversationalAwarenessVolume: Int = 2
|
||||||
private var conversationalAwarenessPauseMusic: Boolean = false
|
private var conversationalAwarenessPauseMusic: Boolean = false
|
||||||
|
|
||||||
fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) {
|
fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) {
|
||||||
@@ -49,16 +55,16 @@ object MediaController {
|
|||||||
this.sharedPreferences = sharedPreferences
|
this.sharedPreferences = sharedPreferences
|
||||||
Log.d("MediaController", "Initializing MediaController")
|
Log.d("MediaController", "Initializing MediaController")
|
||||||
relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false)
|
relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false)
|
||||||
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 100/12)
|
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) / 0.4).toInt())
|
||||||
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false)
|
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false)
|
||||||
|
|
||||||
sharedPreferences.registerOnSharedPreferenceChangeListener { _, key ->
|
preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||||
when (key) {
|
when (key) {
|
||||||
"relative_conversational_awareness_volume" -> {
|
"relative_conversational_awareness_volume" -> {
|
||||||
relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false)
|
relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false)
|
||||||
}
|
}
|
||||||
"conversational_awareness_volume" -> {
|
"conversational_awareness_volume" -> {
|
||||||
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 100/12)
|
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * 0.4).toInt())
|
||||||
}
|
}
|
||||||
"conversational_awareness_pause_music" -> {
|
"conversational_awareness_pause_music" -> {
|
||||||
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false)
|
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false)
|
||||||
@@ -66,35 +72,39 @@ object MediaController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
|
||||||
|
|
||||||
audioManager.registerAudioPlaybackCallback(cb, null)
|
audioManager.registerAudioPlaybackCallback(cb, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
val cb = object : AudioManager.AudioPlaybackCallback() {
|
val cb = object : AudioManager.AudioPlaybackCallback() {
|
||||||
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
override fun onPlaybackConfigChanged(configs: MutableList<AudioPlaybackConfiguration>?) {
|
override fun onPlaybackConfigChanged(configs: MutableList<AudioPlaybackConfiguration>?) {
|
||||||
super.onPlaybackConfigChanged(configs)
|
super.onPlaybackConfigChanged(configs)
|
||||||
Log.d("MediaController", "Playback config changed, iPausedTheMedia: $iPausedTheMedia")
|
Log.d("MediaController", "Playback config changed, iPausedTheMedia: $iPausedTheMedia")
|
||||||
if (configs != null && !iPausedTheMedia) {
|
if (configs != null && !iPausedTheMedia) {
|
||||||
Log.d("MediaController", "Seems like the user changed the state of media themselves, now I won't play until the ear detection pauses it.")
|
Log.d("MediaController", "Seems like the user changed the state of media themselves, now I won't play until the ear detection pauses it.")
|
||||||
handler.postDelayed({
|
handler.postDelayed({
|
||||||
iPausedTheMedia = !audioManager.isMusicActive
|
|
||||||
userPlayedTheMedia = audioManager.isMusicActive
|
userPlayedTheMedia = audioManager.isMusicActive
|
||||||
}, 7) // i have no idea why android sends an event a hundred times after the user does something.
|
}, 7) // i have no idea why android sends an event a hundred times after the user does something.
|
||||||
}
|
}
|
||||||
Log.d("MediaController", "pausedforcrossdevice: $pausedForCrossDevice Ear detection status: ${ServiceManager.getService()?.earDetectionNotification?.status}, music active: ${audioManager.isMusicActive} and cross device available: ${CrossDevice.isAvailable}")
|
Log.d("MediaController", "pausedforcrossdevice: $pausedForCrossDevice")
|
||||||
if (!pausedForCrossDevice && CrossDevice.isAvailable && ServiceManager.getService()?.earDetectionNotification?.status?.contains(0x00) == true && audioManager.isMusicActive) {
|
if (!pausedForCrossDevice && audioManager.isMusicActive) {
|
||||||
Log.d("MediaController", "Pausing for cross device and taking over.")
|
ServiceManager.getService()?.takeOver("music")
|
||||||
sendPause(true)
|
|
||||||
pausedForCrossDevice = true
|
|
||||||
ServiceManager.getService()?.takeOver()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun getMusicActive(): Boolean {
|
||||||
|
return audioManager.isMusicActive
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun sendPause(force: Boolean = false) {
|
fun sendPause(force: Boolean = false) {
|
||||||
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia")
|
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia, isMusicActive: ${audioManager.isMusicActive}, force: $force")
|
||||||
if ((audioManager.isMusicActive && !userPlayedTheMedia) || force) {
|
if ((audioManager.isMusicActive) && (!userPlayedTheMedia || force)) {
|
||||||
iPausedTheMedia = true
|
iPausedTheMedia = if (force) audioManager.isMusicActive else true
|
||||||
userPlayedTheMedia = false
|
userPlayedTheMedia = false
|
||||||
audioManager.dispatchMediaKeyEvent(
|
audioManager.dispatchMediaKeyEvent(
|
||||||
KeyEvent(
|
KeyEvent(
|
||||||
@@ -130,15 +140,30 @@ object MediaController {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (!audioManager.isMusicActive) {
|
||||||
|
Log.d("MediaController", "Setting iPausedTheMedia to false")
|
||||||
|
iPausedTheMedia = false
|
||||||
|
}
|
||||||
|
if (pausedForCrossDevice) {
|
||||||
|
Log.d("MediaController", "Setting pausedForCrossDevice to false")
|
||||||
|
pausedForCrossDevice = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun startSpeaking() {
|
fun startSpeaking() {
|
||||||
Log.d("MediaController", "Starting speaking")
|
Log.d("MediaController", "Starting speaking max vol: ${audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)}, current vol: ${audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)}, conversationalAwarenessVolume: $conversationalAwarenessVolume, relativeVolume: $relativeVolume")
|
||||||
|
|
||||||
if (initialVolume == null) {
|
if (initialVolume == null) {
|
||||||
initialVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
initialVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||||
Log.d("MediaController", "Initial Volume Set: $initialVolume")
|
Log.d("MediaController", "Initial Volume: $initialVolume")
|
||||||
val targetVolume = if (relativeVolume) initialVolume!! * conversationalAwarenessVolume * 1/100 else if ( initialVolume!! > audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume * 1/100) audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume * 1/100 else initialVolume!!
|
val targetVolume = if (relativeVolume) {
|
||||||
|
(initialVolume!! * conversationalAwarenessVolume / 100)
|
||||||
|
} else if (initialVolume!! > (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume / 100)) {
|
||||||
|
(audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume / 100)
|
||||||
|
} else {
|
||||||
|
initialVolume!!
|
||||||
|
}
|
||||||
smoothVolumeTransition(initialVolume!!, targetVolume.toInt())
|
smoothVolumeTransition(initialVolume!!, targetVolume.toInt())
|
||||||
if (conversationalAwarenessPauseMusic) {
|
if (conversationalAwarenessPauseMusic) {
|
||||||
sendPause(force = true)
|
sendPause(force = true)
|
||||||
@@ -160,6 +185,7 @@ object MediaController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun smoothVolumeTransition(fromVolume: Int, toVolume: Int) {
|
private fun smoothVolumeTransition(fromVolume: Int, toVolume: Int) {
|
||||||
|
Log.d("MediaController", "Smooth volume transition from $fromVolume to $toVolume")
|
||||||
val step = if (fromVolume < toVolume) 1 else -1
|
val step = if (fromVolume < toVolume) 1 else -1
|
||||||
val delay = 50L
|
val delay = 50L
|
||||||
var currentVolume = fromVolume
|
var currentVolume = fromVolume
|
||||||
|
|||||||
@@ -136,10 +136,23 @@ class AirPodsNotifications {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setStatus(data: ByteArray) {
|
fun setStatus(data: ByteArray) {
|
||||||
if (data.size != 11) {
|
when (data.size) {
|
||||||
return
|
// if the whole packet is given
|
||||||
|
11 -> {
|
||||||
|
status = data[7].toInt()
|
||||||
|
}
|
||||||
|
// if only the data is given
|
||||||
|
1 -> {
|
||||||
|
status = data[0].toInt()
|
||||||
|
}
|
||||||
|
// if the value of control command is given
|
||||||
|
4 -> {
|
||||||
|
status = data[0].toInt()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Log.d("ANC", "Invalid ANC data size: ${data.size}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
status = data[7].toInt()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val name: String =
|
val name: String =
|
||||||
@@ -172,6 +185,19 @@ class AirPodsNotifications {
|
|||||||
return data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")
|
return data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setBatteryDirect(
|
||||||
|
leftLevel: Int,
|
||||||
|
leftCharging: Boolean,
|
||||||
|
rightLevel: Int,
|
||||||
|
rightCharging: Boolean,
|
||||||
|
caseLevel: Int,
|
||||||
|
caseCharging: Boolean
|
||||||
|
) {
|
||||||
|
first = Battery(BatteryComponent.LEFT, leftLevel, if (leftCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
|
||||||
|
second = Battery(BatteryComponent.RIGHT, rightLevel, if (rightCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
|
||||||
|
case = Battery(BatteryComponent.CASE, caseLevel, if (caseCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
|
||||||
|
}
|
||||||
|
|
||||||
fun setBattery(data: ByteArray) {
|
fun setBattery(data: ByteArray) {
|
||||||
if (data.size != 22) {
|
if (data.size != 22) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -24,8 +24,12 @@ import android.animation.AnimatorListenerAdapter
|
|||||||
import android.animation.ObjectAnimator
|
import android.animation.ObjectAnimator
|
||||||
import android.animation.PropertyValuesHolder
|
import android.animation.PropertyValuesHolder
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.graphics.PixelFormat
|
import android.graphics.PixelFormat
|
||||||
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -51,6 +55,7 @@ class PopupWindow(
|
|||||||
private var isClosing = false
|
private var isClosing = false
|
||||||
private var autoCloseHandler = Handler(Looper.getMainLooper())
|
private var autoCloseHandler = Handler(Looper.getMainLooper())
|
||||||
private var autoCloseRunnable: Runnable? = null
|
private var autoCloseRunnable: Runnable? = null
|
||||||
|
private var batteryUpdateReceiver: BroadcastReceiver? = null
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply {
|
private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply {
|
||||||
@@ -146,6 +151,8 @@ class PopupWindow(
|
|||||||
start()
|
start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerBatteryUpdateReceiver()
|
||||||
|
|
||||||
autoCloseRunnable = Runnable { close() }
|
autoCloseRunnable = Runnable { close() }
|
||||||
autoCloseHandler.postDelayed(autoCloseRunnable!!, 12000)
|
autoCloseHandler.postDelayed(autoCloseRunnable!!, 12000)
|
||||||
}
|
}
|
||||||
@@ -155,15 +162,43 @@ class PopupWindow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("SetTextI18n")
|
private fun registerBatteryUpdateReceiver() {
|
||||||
fun updateBatteryStatus(batteryNotification: AirPodsNotifications.BatteryNotification) {
|
batteryUpdateReceiver = object : BroadcastReceiver() {
|
||||||
val batteryStatus = batteryNotification.getBattery()
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
if (intent?.action == AirPodsNotifications.BATTERY_DATA) {
|
||||||
|
val batteryList = intent.getParcelableArrayListExtra<Battery>("data")
|
||||||
|
if (batteryList != null) {
|
||||||
|
updateBatteryStatusFromList(batteryList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val filter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
context.registerReceiver(batteryUpdateReceiver, filter, Context.RECEIVER_EXPORTED)
|
||||||
|
} else {
|
||||||
|
context.registerReceiver(batteryUpdateReceiver, filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterBatteryUpdateReceiver() {
|
||||||
|
batteryUpdateReceiver?.let {
|
||||||
|
try {
|
||||||
|
context.unregisterReceiver(it)
|
||||||
|
batteryUpdateReceiver = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("PopupWindow", "Error unregistering battery receiver: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateBatteryStatusFromList(batteryList: List<Battery>) {
|
||||||
val batteryLeftText = mView.findViewById<TextView>(R.id.left_battery)
|
val batteryLeftText = mView.findViewById<TextView>(R.id.left_battery)
|
||||||
val batteryRightText = mView.findViewById<TextView>(R.id.right_battery)
|
val batteryRightText = mView.findViewById<TextView>(R.id.right_battery)
|
||||||
val batteryCaseText = mView.findViewById<TextView>(R.id.case_battery)
|
val batteryCaseText = mView.findViewById<TextView>(R.id.case_battery)
|
||||||
|
|
||||||
batteryLeftText.text = batteryStatus.find { it.component == BatteryComponent.LEFT }?.let {
|
batteryLeftText.text = batteryList.find { it.component == BatteryComponent.LEFT }?.let {
|
||||||
if (it.status != BatteryStatus.DISCONNECTED) {
|
if (it.status != BatteryStatus.DISCONNECTED) {
|
||||||
"\uDBC3\uDC8E ${it.level}%"
|
"\uDBC3\uDC8E ${it.level}%"
|
||||||
} else {
|
} else {
|
||||||
@@ -171,7 +206,7 @@ class PopupWindow(
|
|||||||
}
|
}
|
||||||
} ?: ""
|
} ?: ""
|
||||||
|
|
||||||
batteryRightText.text = batteryStatus.find { it.component == BatteryComponent.RIGHT }?.let {
|
batteryRightText.text = batteryList.find { it.component == BatteryComponent.RIGHT }?.let {
|
||||||
if (it.status != BatteryStatus.DISCONNECTED) {
|
if (it.status != BatteryStatus.DISCONNECTED) {
|
||||||
"\uDBC3\uDC8D ${it.level}%"
|
"\uDBC3\uDC8D ${it.level}%"
|
||||||
} else {
|
} else {
|
||||||
@@ -179,7 +214,7 @@ class PopupWindow(
|
|||||||
}
|
}
|
||||||
} ?: ""
|
} ?: ""
|
||||||
|
|
||||||
batteryCaseText.text = batteryStatus.find { it.component == BatteryComponent.CASE }?.let {
|
batteryCaseText.text = batteryList.find { it.component == BatteryComponent.CASE }?.let {
|
||||||
if (it.status != BatteryStatus.DISCONNECTED) {
|
if (it.status != BatteryStatus.DISCONNECTED) {
|
||||||
"\uDBC3\uDE6C ${it.level}%"
|
"\uDBC3\uDE6C ${it.level}%"
|
||||||
} else {
|
} else {
|
||||||
@@ -188,12 +223,19 @@ class PopupWindow(
|
|||||||
} ?: ""
|
} ?: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetTextI18s")
|
||||||
|
fun updateBatteryStatus(batteryNotification: AirPodsNotifications.BatteryNotification) {
|
||||||
|
val batteryStatus = batteryNotification.getBattery()
|
||||||
|
updateBatteryStatusFromList(batteryStatus)
|
||||||
|
}
|
||||||
|
|
||||||
fun close() {
|
fun close() {
|
||||||
try {
|
try {
|
||||||
if (isClosing) return
|
if (isClosing) return
|
||||||
isClosing = true
|
isClosing = true
|
||||||
|
|
||||||
autoCloseRunnable?.let { autoCloseHandler.removeCallbacks(it) }
|
autoCloseRunnable?.let { autoCloseHandler.removeCallbacks(it) }
|
||||||
|
unregisterBatteryUpdateReceiver()
|
||||||
|
|
||||||
val vid = mView.findViewById<VideoView>(R.id.video)
|
val vid = mView.findViewById<VideoView>(R.id.video)
|
||||||
vid.stopPlayback()
|
vid.stopPlayback()
|
||||||
|
|||||||
@@ -1,613 +0,0 @@
|
|||||||
/*
|
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
*
|
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU 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.utils
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.runtime.NoLiveLiterals
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import java.io.BufferedReader
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.io.InputStreamReader
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.net.URL
|
|
||||||
|
|
||||||
@NoLiveLiterals
|
|
||||||
class RadareOffsetFinder(context: Context) {
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "RadareOffsetFinder"
|
|
||||||
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"
|
|
||||||
private const val RADARE2_LIB_PATH = "$EXTRACT_DIR/data/local/tmp/aln_unzip/org.radare.radare2installer/radare2/lib"
|
|
||||||
private const val BUSYBOX_PATH = "$EXTRACT_DIR/data/local/tmp/aln_unzip/busybox"
|
|
||||||
|
|
||||||
private val LIBRARY_PATHS = listOf(
|
|
||||||
"/apex/com.android.bt/lib64/libbluetooth_jni.so",
|
|
||||||
"/apex/com.android.btservices/lib64/libbluetooth_jni.so",
|
|
||||||
"/system/lib64/libbluetooth_jni.so",
|
|
||||||
"/system/lib64/libbluetooth_qti.so",
|
|
||||||
"/system_ext/lib64/libbluetooth_qti.so"
|
|
||||||
)
|
|
||||||
|
|
||||||
fun findBluetoothLibraryPath(): String? {
|
|
||||||
for (path in LIBRARY_PATHS) {
|
|
||||||
if (File(path).exists()) {
|
|
||||||
Log.d(TAG, "Found Bluetooth library at $path")
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Log.e(TAG, "Could not find Bluetooth library")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearHookOffsets(): Boolean {
|
|
||||||
try {
|
|
||||||
val process = Runtime.getRuntime().exec(arrayOf(
|
|
||||||
"su", "-c",
|
|
||||||
"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 properties")
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
Log.e(TAG, "Failed to clear hook offset properties, exit code: $exitCode")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error clearing hook offset properties", e)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val radare2TarballFile = File(context.cacheDir, "radare2.tar.gz")
|
|
||||||
|
|
||||||
private val _progressState = MutableStateFlow<ProgressState>(ProgressState.Idle)
|
|
||||||
val progressState: StateFlow<ProgressState> = _progressState
|
|
||||||
|
|
||||||
sealed class ProgressState {
|
|
||||||
object Idle : ProgressState()
|
|
||||||
object CheckingExisting : ProgressState()
|
|
||||||
object Downloading : ProgressState()
|
|
||||||
data class DownloadProgress(val progress: Float) : ProgressState()
|
|
||||||
object Extracting : ProgressState()
|
|
||||||
object MakingExecutable : ProgressState()
|
|
||||||
object FindingOffset : ProgressState()
|
|
||||||
object SavingOffset : ProgressState()
|
|
||||||
object Cleaning : ProgressState()
|
|
||||||
data class Error(val message: String) : ProgressState()
|
|
||||||
data class Success(val offset: Long) : ProgressState()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fun isHookOffsetAvailable(): Boolean {
|
|
||||||
Log.d(TAG, "Setup Skipped? " + ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE)?.getBoolean("skip_setup", false).toString())
|
|
||||||
if (ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE)?.getBoolean("skip_setup", false) == true) {
|
|
||||||
Log.d(TAG, "Setup skipped, returning true.")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
_progressState.value = ProgressState.CheckingExisting
|
|
||||||
try {
|
|
||||||
val process = Runtime.getRuntime().exec(arrayOf("getprop", HOOK_OFFSET_PROP))
|
|
||||||
val reader = BufferedReader(InputStreamReader(process.inputStream))
|
|
||||||
val propValue = reader.readLine()
|
|
||||||
process.waitFor()
|
|
||||||
|
|
||||||
if (propValue != null && propValue.isNotEmpty()) {
|
|
||||||
Log.d(TAG, "Hook offset property exists: $propValue")
|
|
||||||
_progressState.value = ProgressState.Idle
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error checking if offset property exists", e)
|
|
||||||
_progressState.value = ProgressState.Error("Failed to check if offset property exists: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "No hook offset available")
|
|
||||||
_progressState.value = ProgressState.Idle
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setupAndFindOffset(): Boolean {
|
|
||||||
val offset = findOffset()
|
|
||||||
return offset > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun findOffset(): Long = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
_progressState.value = ProgressState.Downloading
|
|
||||||
if (!downloadRadare2TarballIfNeeded()) {
|
|
||||||
_progressState.value = ProgressState.Error("Failed to download radare2 tarball")
|
|
||||||
Log.e(TAG, "Failed to download radare2 tarball")
|
|
||||||
return@withContext 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
_progressState.value = ProgressState.Extracting
|
|
||||||
if (!extractRadare2Tarball()) {
|
|
||||||
_progressState.value = ProgressState.Error("Failed to extract radare2 tarball")
|
|
||||||
Log.e(TAG, "Failed to extract radare2 tarball")
|
|
||||||
return@withContext 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
_progressState.value = ProgressState.MakingExecutable
|
|
||||||
if (!makeExecutable()) {
|
|
||||||
_progressState.value = ProgressState.Error("Failed to make binaries executable")
|
|
||||||
Log.e(TAG, "Failed to make binaries executable")
|
|
||||||
return@withContext 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
_progressState.value = ProgressState.FindingOffset
|
|
||||||
val offset = findFunctionOffset()
|
|
||||||
if (offset == 0L) {
|
|
||||||
_progressState.value = ProgressState.Error("Failed to find function offset")
|
|
||||||
Log.e(TAG, "Failed to find function offset")
|
|
||||||
return@withContext 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
_progressState.value = ProgressState.SavingOffset
|
|
||||||
if (!saveOffset(offset)) {
|
|
||||||
_progressState.value = ProgressState.Error("Failed to save offset")
|
|
||||||
Log.e(TAG, "Failed to save offset")
|
|
||||||
return@withContext 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
_progressState.value = ProgressState.Cleaning
|
|
||||||
cleanupExtractedFiles()
|
|
||||||
|
|
||||||
_progressState.value = ProgressState.Success(offset)
|
|
||||||
return@withContext offset
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_progressState.value = ProgressState.Error("Error: ${e.message}")
|
|
||||||
Log.e(TAG, "Error in findOffset", e)
|
|
||||||
return@withContext 0L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun downloadRadare2TarballIfNeeded(): Boolean = withContext(Dispatchers.IO) {
|
|
||||||
if (radare2TarballFile.exists() && radare2TarballFile.length() > 0) {
|
|
||||||
Log.d(TAG, "Radare2 tarball already downloaded to ${radare2TarballFile.absolutePath}")
|
|
||||||
return@withContext true
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val url = URL(RADARE2_URL)
|
|
||||||
val connection = url.openConnection() as HttpURLConnection
|
|
||||||
connection.connectTimeout = 60000
|
|
||||||
connection.readTimeout = 60000
|
|
||||||
|
|
||||||
val contentLength = connection.contentLength.toFloat()
|
|
||||||
val inputStream = connection.inputStream
|
|
||||||
val outputStream = FileOutputStream(radare2TarballFile)
|
|
||||||
|
|
||||||
val buffer = ByteArray(4096)
|
|
||||||
var bytesRead: Int
|
|
||||||
var totalBytesRead = 0L
|
|
||||||
|
|
||||||
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
|
|
||||||
outputStream.write(buffer, 0, bytesRead)
|
|
||||||
totalBytesRead += bytesRead
|
|
||||||
if (contentLength > 0) {
|
|
||||||
val progress = totalBytesRead.toFloat() / contentLength
|
|
||||||
_progressState.value = ProgressState.DownloadProgress(progress)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
outputStream.close()
|
|
||||||
inputStream.close()
|
|
||||||
|
|
||||||
Log.d(TAG, "Download successful to ${radare2TarballFile.absolutePath}")
|
|
||||||
return@withContext true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to download radare2 tarball", e)
|
|
||||||
return@withContext false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun extractRadare2Tarball(): Boolean = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val isAlreadyExtracted = checkIfAlreadyExtracted()
|
|
||||||
|
|
||||||
if (isAlreadyExtracted) {
|
|
||||||
Log.d(TAG, "Radare2 files already extracted correctly, skipping extraction")
|
|
||||||
return@withContext true
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "Removing existing extract directory")
|
|
||||||
Runtime.getRuntime().exec(arrayOf("su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor()
|
|
||||||
|
|
||||||
Runtime.getRuntime().exec(arrayOf("su", "-c", "mkdir -p $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor()
|
|
||||||
|
|
||||||
Log.d(TAG, "Extracting ${radare2TarballFile.absolutePath} to $EXTRACT_DIR")
|
|
||||||
|
|
||||||
val process = Runtime.getRuntime().exec(
|
|
||||||
arrayOf("su", "-c", "tar xvf ${radare2TarballFile.absolutePath} -C $EXTRACT_DIR")
|
|
||||||
)
|
|
||||||
|
|
||||||
val reader = BufferedReader(InputStreamReader(process.inputStream))
|
|
||||||
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
|
|
||||||
|
|
||||||
var line: String?
|
|
||||||
while (reader.readLine().also { line = it } != null) {
|
|
||||||
Log.d(TAG, "Extract output: $line")
|
|
||||||
}
|
|
||||||
|
|
||||||
while (errorReader.readLine().also { line = it } != null) {
|
|
||||||
Log.e(TAG, "Extract error: $line")
|
|
||||||
}
|
|
||||||
|
|
||||||
val exitCode = process.waitFor()
|
|
||||||
if (exitCode == 0) {
|
|
||||||
Log.d(TAG, "Extraction completed successfully")
|
|
||||||
return@withContext true
|
|
||||||
} else {
|
|
||||||
Log.e(TAG, "Extraction failed with exit code $exitCode")
|
|
||||||
return@withContext false
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to extract radare2", e)
|
|
||||||
return@withContext false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun checkIfAlreadyExtracted(): Boolean = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val checkDirProcess = Runtime.getRuntime().exec(
|
|
||||||
arrayOf("su", "-c", "[ -d $EXTRACT_DIR/data/local/tmp/aln_unzip ] && echo 'exists'")
|
|
||||||
)
|
|
||||||
val dirExists = BufferedReader(InputStreamReader(checkDirProcess.inputStream)).readLine() == "exists"
|
|
||||||
checkDirProcess.waitFor()
|
|
||||||
|
|
||||||
if (!dirExists) {
|
|
||||||
Log.d(TAG, "Extract directory doesn't exist, need to extract")
|
|
||||||
return@withContext false
|
|
||||||
}
|
|
||||||
|
|
||||||
val tarProcess = Runtime.getRuntime().exec(
|
|
||||||
arrayOf("su", "-c", "tar tf ${radare2TarballFile.absolutePath}")
|
|
||||||
)
|
|
||||||
val tarFiles = BufferedReader(InputStreamReader(tarProcess.inputStream)).readLines()
|
|
||||||
.filter { it.isNotEmpty() }
|
|
||||||
.map { it.trim() }
|
|
||||||
.toSet()
|
|
||||||
tarProcess.waitFor()
|
|
||||||
|
|
||||||
if (tarFiles.isEmpty()) {
|
|
||||||
Log.e(TAG, "Failed to get file list from tarball")
|
|
||||||
return@withContext false
|
|
||||||
}
|
|
||||||
|
|
||||||
val findProcess = Runtime.getRuntime().exec(
|
|
||||||
arrayOf("su", "-c", "find $EXTRACT_DIR/data/local/tmp/aln_unzip -type f | sort")
|
|
||||||
)
|
|
||||||
val extractedFiles = BufferedReader(InputStreamReader(findProcess.inputStream)).readLines()
|
|
||||||
.filter { it.isNotEmpty() }
|
|
||||||
.map { it.trim() }
|
|
||||||
.toSet()
|
|
||||||
findProcess.waitFor()
|
|
||||||
|
|
||||||
if (extractedFiles.isEmpty()) {
|
|
||||||
Log.d(TAG, "No files found in extract directory, need to extract")
|
|
||||||
return@withContext false
|
|
||||||
}
|
|
||||||
|
|
||||||
for (tarFile in tarFiles) {
|
|
||||||
if (tarFile.endsWith("/")) continue
|
|
||||||
|
|
||||||
val filePathInExtractDir = "$EXTRACT_DIR/$tarFile"
|
|
||||||
val fileCheckProcess = Runtime.getRuntime().exec(
|
|
||||||
arrayOf("su", "-c", "[ -f $filePathInExtractDir ] && echo 'exists'")
|
|
||||||
)
|
|
||||||
val fileExists = BufferedReader(InputStreamReader(fileCheckProcess.inputStream)).readLine() == "exists"
|
|
||||||
fileCheckProcess.waitFor()
|
|
||||||
|
|
||||||
if (!fileExists) {
|
|
||||||
Log.d(TAG, "File $filePathInExtractDir from tarball missing in extract directory")
|
|
||||||
Runtime.getRuntime().exec(arrayOf("su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor()
|
|
||||||
return@withContext false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "All ${tarFiles.size} files from tarball exist in extract directory")
|
|
||||||
return@withContext true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error checking extraction status", e)
|
|
||||||
return@withContext false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun makeExecutable(): Boolean = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
Log.d(TAG, "Making binaries executable in $RADARE2_BIN_PATH")
|
|
||||||
val chmod1Result = Runtime.getRuntime().exec(
|
|
||||||
arrayOf("su", "-c", "chmod -R 755 $RADARE2_BIN_PATH")
|
|
||||||
).waitFor()
|
|
||||||
|
|
||||||
Log.d(TAG, "Making binaries executable in $BUSYBOX_PATH")
|
|
||||||
|
|
||||||
val chmod2Result = Runtime.getRuntime().exec(
|
|
||||||
arrayOf("su", "-c", "chmod -R 755 $BUSYBOX_PATH")
|
|
||||||
).waitFor()
|
|
||||||
|
|
||||||
if (chmod1Result == 0 && chmod2Result == 0) {
|
|
||||||
Log.d(TAG, "Successfully made binaries executable")
|
|
||||||
return@withContext true
|
|
||||||
} else {
|
|
||||||
Log.e(TAG, "Failed to make binaries executable, exit codes: $chmod1Result, $chmod2Result")
|
|
||||||
return@withContext false
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error making binaries executable", e)
|
|
||||||
return@withContext false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun findFunctionOffset(): Long = withContext(Dispatchers.IO) {
|
|
||||||
val libraryPath = findBluetoothLibraryPath() ?: return@withContext 0L
|
|
||||||
var offset = 0L
|
|
||||||
|
|
||||||
try {
|
|
||||||
@Suppress("LocalVariableName") val currentLD_LIBRARY_PATH = ProcessBuilder().command("su", "-c", "printenv LD_LIBRARY_PATH").start().inputStream.bufferedReader().readText().trim()
|
|
||||||
val currentPATH = ProcessBuilder().command("su", "-c", "printenv PATH").start().inputStream.bufferedReader().readText().trim()
|
|
||||||
val envSetup = """
|
|
||||||
export LD_LIBRARY_PATH="$RADARE2_LIB_PATH:$currentLD_LIBRARY_PATH"
|
|
||||||
export PATH="$BUSYBOX_PATH:$RADARE2_BIN_PATH:$currentPATH"
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep fcr_chk_chan"
|
|
||||||
Log.d(TAG, "Running command: $command")
|
|
||||||
|
|
||||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
|
|
||||||
|
|
||||||
val reader = BufferedReader(InputStreamReader(process.inputStream))
|
|
||||||
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
|
|
||||||
|
|
||||||
var line: String?
|
|
||||||
|
|
||||||
while (reader.readLine().also { line = it } != null) {
|
|
||||||
Log.d(TAG, "rabin2 output: $line")
|
|
||||||
if (line?.contains("fcr_chk_chan") == true) {
|
|
||||||
val parts = line.split(" ")
|
|
||||||
if (parts.isNotEmpty() && parts[0].startsWith("0x")) {
|
|
||||||
offset = parts[0].substring(2).toLong(16)
|
|
||||||
Log.d(TAG, "Found offset at ${parts[0]}")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (errorReader.readLine().also { line = it } != null) {
|
|
||||||
Log.d(TAG, "rabin2 error: $line")
|
|
||||||
}
|
|
||||||
|
|
||||||
val exitCode = process.waitFor()
|
|
||||||
if (exitCode != 0) {
|
|
||||||
Log.e(TAG, "rabin2 command failed with exit code $exitCode")
|
|
||||||
}
|
|
||||||
|
|
||||||
// findAndSaveL2cuProcessCfgReqOffset(libraryPath, envSetup)
|
|
||||||
// findAndSaveL2cCsmConfigOffset(libraryPath, envSetup)
|
|
||||||
// findAndSaveL2cuSendPeerInfoReqOffset(libraryPath, envSetup)
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to find function offset", e)
|
|
||||||
return@withContext 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offset == 0L) {
|
|
||||||
Log.e(TAG, "Failed to extract function offset from output, aborting")
|
|
||||||
return@withContext 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "Successfully found offset: 0x${offset.toString(16)}")
|
|
||||||
return@withContext offset
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun findAndSaveL2cuProcessCfgReqOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2cu_process_our_cfg_req"
|
|
||||||
Log.d(TAG, "Running command: $command")
|
|
||||||
|
|
||||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
|
|
||||||
val reader = BufferedReader(InputStreamReader(process.inputStream))
|
|
||||||
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
|
|
||||||
|
|
||||||
var line: String?
|
|
||||||
var offset = 0L
|
|
||||||
|
|
||||||
while (reader.readLine().also { line = it } != null) {
|
|
||||||
Log.d(TAG, "rabin2 output: $line")
|
|
||||||
if (line?.contains("l2cu_process_our_cfg_req") == true) {
|
|
||||||
val parts = line.split(" ")
|
|
||||||
if (parts.isNotEmpty() && parts[0].startsWith("0x")) {
|
|
||||||
offset = parts[0].substring(2).toLong(16)
|
|
||||||
Log.d(TAG, "Found l2cu_process_our_cfg_req offset at ${parts[0]}")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (errorReader.readLine().also { line = it } != null) {
|
|
||||||
Log.d(TAG, "rabin2 error: $line")
|
|
||||||
}
|
|
||||||
|
|
||||||
val exitCode = process.waitFor()
|
|
||||||
if (exitCode != 0) {
|
|
||||||
Log.e(TAG, "rabin2 command failed with exit code $exitCode")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offset > 0L) {
|
|
||||||
val hexString = "0x${offset.toString(16)}"
|
|
||||||
Runtime.getRuntime().exec(arrayOf(
|
|
||||||
"su", "-c", "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)}"
|
|
||||||
Log.d(TAG, "Saving offset to system property: $hexString")
|
|
||||||
|
|
||||||
val process = Runtime.getRuntime().exec(arrayOf(
|
|
||||||
"su", "-c", "setprop $HOOK_OFFSET_PROP $hexString"
|
|
||||||
))
|
|
||||||
|
|
||||||
val exitCode = process.waitFor()
|
|
||||||
if (exitCode == 0) {
|
|
||||||
val verifyProcess = Runtime.getRuntime().exec(arrayOf(
|
|
||||||
"getprop", HOOK_OFFSET_PROP
|
|
||||||
))
|
|
||||||
val propValue = BufferedReader(InputStreamReader(verifyProcess.inputStream)).readLine()
|
|
||||||
verifyProcess.waitFor()
|
|
||||||
|
|
||||||
if (propValue != null && propValue.isNotEmpty()) {
|
|
||||||
Log.d(TAG, "Successfully saved offset to system property: $propValue")
|
|
||||||
return@withContext true
|
|
||||||
} else {
|
|
||||||
Log.e(TAG, "Property was set but couldn't be verified")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.e(TAG, "Failed to set property, exit code: $exitCode")
|
|
||||||
}
|
|
||||||
return@withContext false
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to save offset", e)
|
|
||||||
return@withContext false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cleanupExtractedFiles() {
|
|
||||||
try {
|
|
||||||
Runtime.getRuntime().exec(arrayOf("su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor()
|
|
||||||
Log.d(TAG, "Cleaned up extracted files at $EXTRACT_DIR/data/local/tmp/aln_unzip")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to cleanup extracted files", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.widgets
|
package me.kavishdevar.librepods.widgets
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ import android.appwidget.AppWidgetManager
|
|||||||
import android.appwidget.AppWidgetProvider
|
import android.appwidget.AppWidgetProvider
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
class BatteryWidget : AppWidgetProvider() {
|
class BatteryWidget : AppWidgetProvider() {
|
||||||
override fun onUpdate(
|
override fun onUpdate(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.widgets
|
package me.kavishdevar.librepods.widgets
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ import android.util.Log
|
|||||||
import android.widget.RemoteViews
|
import android.widget.RemoteViews
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
class NoiseControlWidget : AppWidgetProvider() {
|
class NoiseControlWidget : AppWidgetProvider() {
|
||||||
override fun onUpdate(
|
override fun onUpdate(
|
||||||
@@ -79,7 +82,12 @@ class NoiseControlWidget : AppWidgetProvider() {
|
|||||||
if (intent.action == "ACTION_SET_ANC_MODE") {
|
if (intent.action == "ACTION_SET_ANC_MODE") {
|
||||||
val mode = intent.getIntExtra("ANC_MODE", 1)
|
val mode = intent.getIntExtra("ANC_MODE", 1)
|
||||||
Log.d("NoiseControlWidget", "Setting ANC mode to $mode")
|
Log.d("NoiseControlWidget", "Setting ANC mode to $mode")
|
||||||
ServiceManager.getService()?.setANCMode(mode)
|
ServiceManager.getService()!!
|
||||||
|
.aacpManager
|
||||||
|
.sendControlCommand(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
|
||||||
|
mode.toByte()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM19,19L5,19L5,5h11.17L19,7.83L19,19zM12,12c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3zM6,6h9v4L6,10z" />
|
||||||
|
</vector>
|
||||||
@@ -2,10 +2,9 @@
|
|||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/island_window_layout"
|
android:id="@+id/island_window_layout"
|
||||||
android:layout_width="0dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="wrap_content"
|
||||||
android:layout_margin="16dp"
|
android:layout_margin="16dp"
|
||||||
android:layout_weight="0.95"
|
|
||||||
android:background="@drawable/island_background"
|
android:background="@drawable/island_background"
|
||||||
android:elevation="4dp"
|
android:elevation="4dp"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
@@ -24,7 +23,7 @@
|
|||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="wrap_content"
|
||||||
android:layout_margin="0dp"
|
android:layout_margin="0dp"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:gravity="bottom"
|
android:gravity="bottom"
|
||||||
@@ -38,12 +37,12 @@
|
|||||||
android:layout_margin="0dp"
|
android:layout_margin="0dp"
|
||||||
android:fontFamily="@font/sf_pro"
|
android:fontFamily="@font/sf_pro"
|
||||||
android:gravity="bottom"
|
android:gravity="bottom"
|
||||||
android:padding="0dp"
|
|
||||||
android:text="@string/island_connected_text"
|
|
||||||
android:textColor="#707072"
|
|
||||||
android:includeFontPadding="false"
|
android:includeFontPadding="false"
|
||||||
android:lineSpacingExtra="0dp"
|
android:lineSpacingExtra="0dp"
|
||||||
android:lineSpacingMultiplier="1"
|
android:lineSpacingMultiplier="1"
|
||||||
|
android:padding="0dp"
|
||||||
|
android:text="@string/island_connected_text"
|
||||||
|
android:textColor="#707072"
|
||||||
android:textSize="16sp" />
|
android:textSize="16sp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
@@ -53,19 +52,20 @@
|
|||||||
android:layout_margin="0dp"
|
android:layout_margin="0dp"
|
||||||
android:fontFamily="@font/sf_pro"
|
android:fontFamily="@font/sf_pro"
|
||||||
android:gravity="bottom"
|
android:gravity="bottom"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:lineSpacingExtra="0dp"
|
||||||
|
android:lineSpacingMultiplier="1"
|
||||||
android:padding="0dp"
|
android:padding="0dp"
|
||||||
android:text="AirPods Pro"
|
android:text="AirPods Pro"
|
||||||
android:textColor="@color/white"
|
android:textColor="@color/white"
|
||||||
android:textSize="24sp"
|
android:textSize="24sp"
|
||||||
android:includeFontPadding="false"
|
|
||||||
android:lineSpacingExtra="0dp"
|
|
||||||
android:lineSpacingMultiplier="1"
|
|
||||||
tools:ignore="HardcodedText" />
|
tools:ignore="HardcodedText" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
|
android:id="@+id/island_battery_container"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center">
|
android:gravity="center">
|
||||||
|
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name" translatable="false">LibrePods</string>
|
<string name="app_name" translatable="false">LibrePods</string>
|
||||||
|
<string name="app_description" translatable="false">Liberate your AirPods from Apple\'s ecosystem.</string>
|
||||||
<string name="title_activity_custom_device" translatable="false">GATT Testing</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="app_widget_description">See your AirPods battery status right from your home screen!</string>
|
||||||
<string name="accessibility">Accessibility</string>
|
<string name="accessibility">Accessibility</string>
|
||||||
@@ -34,8 +35,7 @@
|
|||||||
<string name="airpods_not_connected">AirPods not connected</string>
|
<string name="airpods_not_connected">AirPods not connected</string>
|
||||||
<string name="airpods_not_connected_description">Please connect your AirPods to access settings. If you\'re stuck here, then try reopening the app again after closing it from the recents.\n(DO NOT KILL THE APP!)</string>
|
<string name="airpods_not_connected_description">Please connect your AirPods to access settings. If you\'re stuck here, then try reopening the app again after closing it from the recents.\n(DO NOT KILL THE APP!)</string>
|
||||||
<string name="back">Back</string>
|
<string name="back">Back</string>
|
||||||
<string name="app_settings">App Settings</string>
|
<string name="app_settings">Customizations</string>
|
||||||
<string name="conversational_awareness_customization">Conversational Awareness</string>
|
|
||||||
<string name="relative_conversational_awareness_volume">Relative volume</string>
|
<string name="relative_conversational_awareness_volume">Relative volume</string>
|
||||||
<string name="relative_conversational_awareness_volume_description">Reduces to a percentage of the current volume instead of the maximum volume.</string>
|
<string name="relative_conversational_awareness_volume_description">Reduces to a percentage of the current volume instead of the maximum volume.</string>
|
||||||
<string name="conversational_awareness_pause_music">Pause Music</string>
|
<string name="conversational_awareness_pause_music">Pause Music</string>
|
||||||
@@ -59,4 +59,24 @@
|
|||||||
<string name="ear_detection">Automatic Ear Detection</string>
|
<string name="ear_detection">Automatic Ear Detection</string>
|
||||||
<string name="auto_play">Auto Play</string>
|
<string name="auto_play">Auto Play</string>
|
||||||
<string name="auto_pause">Auto Pause</string>
|
<string name="auto_pause">Auto Pause</string>
|
||||||
|
<string name="troubleshooting">Troubleshooting</string>
|
||||||
|
<string name="troubleshooting_description">Collect logs to diagnose issues with AirPods connection</string>
|
||||||
|
<string name="collect_logs">Collect Logs</string>
|
||||||
|
<string name="saved_logs">Saved Logs</string>
|
||||||
|
<string name="no_logs_found">No saved logs found</string>
|
||||||
|
<string name="takeover_header">Auto-Connect preferences</string>
|
||||||
|
<string name="takeover_airpods_state">Connect to your AirPods when its status is:</string>
|
||||||
|
<string name="takeover_disconnected">Disconnected</string>
|
||||||
|
<string name="takeover_disconnected_desc">AirPods are not connected to a device</string>
|
||||||
|
<string name="takeover_idle">Idle</string>
|
||||||
|
<string name="takeover_idle_desc">A device is connected to your AirPods, but not playing media or on a call</string>
|
||||||
|
<string name="takeover_music">Playing media</string>
|
||||||
|
<string name="takeover_music_desc">A device is playing media on your AirPods</string>
|
||||||
|
<string name="takeover_call">On call</string>
|
||||||
|
<string name="takeover_call_desc">A device is on a call with your AirPods</string>
|
||||||
|
<string name="takeover_phone_state">Connect to AirPods when your phone is:</string>
|
||||||
|
<string name="takeover_ringing_call">Receiving a call</string>
|
||||||
|
<string name="takeover_ringing_call_desc">Your phone starts ringing</string>
|
||||||
|
<string name="takeover_media_start">Starting media playback</string>
|
||||||
|
<string name="takeover_media_start_desc">Your phone starts playing media</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<files-path name="logs" path="logs/"/>
|
||||||
|
</paths>
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
com.android.bluetooth
|
com.android.bluetooth
|
||||||
me.kavishdevar.librepods
|
com.google.android.settings
|
||||||
android
|
|
||||||
com.android.systemui
|
|
||||||
com.android.settings
|
com.android.settings
|
||||||
com.google.android.bluetooth
|
com.google.android.bluetooth
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ hazeMaterials = "1.5.3"
|
|||||||
sliceBuilders = "1.1.0-alpha02"
|
sliceBuilders = "1.1.0-alpha02"
|
||||||
sliceCore = "1.1.0-alpha02"
|
sliceCore = "1.1.0-alpha02"
|
||||||
sliceView = "1.1.0-alpha02"
|
sliceView = "1.1.0-alpha02"
|
||||||
|
dynamicanimation = "1.1.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
|
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
|
||||||
@@ -35,6 +36,7 @@ haze-materials = { group = "dev.chrisbanes.haze", name = "haze-materials", versi
|
|||||||
androidx-slice-builders = { group = "androidx.slice", name = "slice-builders", version.ref = "sliceBuilders" }
|
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-core = { group = "androidx.slice", name = "slice-core", version.ref = "sliceCore" }
|
||||||
androidx-slice-view = { group = "androidx.slice", name = "slice-view", version.ref = "sliceView" }
|
androidx-slice-view = { group = "androidx.slice", name = "slice-view", version.ref = "sliceView" }
|
||||||
|
androidx-dynamicanimation = { group = "androidx.dynamicanimation", name = "dynamicanimation", version.ref = "dynamicanimation" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Control Commands
|
||||||
|
|
||||||
|
AACP uses opcode `9` for control commands. opcodes are 16 bit integers that specify the kind of action being done. The length of a control command is fixed to 7 bytes + 4 bytes header (`04 00 04 00`)
|
||||||
|
|
||||||
|
An AACP packet is formated as:
|
||||||
|
|
||||||
|
`04 00 04 00 [opcode, little endianness] [data]`
|
||||||
|
|
||||||
|
So, our control commands becomes
|
||||||
|
|
||||||
|
```
|
||||||
|
04 00 04 00 09 00 [identifier] [data1] [data2] [data3] [data4]
|
||||||
|
```
|
||||||
|
|
||||||
|
Bytes that are not used are set to `0x00`. From what I've observed, the `data3` and `data4` are never used, and hence always zero. And, the `data2` is usually used when the configuration can be different for the two buds: like, to change the long press mode. Or, if there can be two "state" variables for the same feature: like the Hearing Aid feature.
|
||||||
|
|
||||||
|
## Control Commands
|
||||||
|
These commands
|
||||||
|
|
||||||
|
| Command identifier | Description | Format |
|
||||||
|
|--------------|---------------------|--------|
|
||||||
|
| 0x01 | Mic Mode | Single value (1 byte) |
|
||||||
|
| 0x05 | Button Send Mode | Single value (1 byte) |
|
||||||
|
| 0x12 | VoiceTrigger for Siri | Single Value (1 byte): `0x01` = enabled, `0x01` = disabled |
|
||||||
|
| 0x14 | SingleClickMode | Single value (1 byte) |
|
||||||
|
| 0x15 | DoubleClickMode | Single value (1 byte) |
|
||||||
|
| 0x16 | ClickHoldMode | Two values (2 bytes; First byte = right bud Second byte = for left): `0x01` = Noise control `0x05` = Siri |
|
||||||
|
| 0x17 | DoubleClickInterval | Single value (1 byte): 0x00 = Default, `0x01` = Slower, `0x02` = Slowest|
|
||||||
|
| 0x18 | ClickHoldInterval | Single value (1 byte): 0x00 = Default, `0x01` = Slower, `0x02` = Slowest|
|
||||||
|
| 0x1A | ListeningModeConfigs | Single value (1 byte): bitwise OR of the selected modes. Off mode = `0x01`, ANC=`0x02`, Transparency = 0x04, Adaptive = `0x08` |
|
||||||
|
| 0x1B | OneBudANCMode | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
|
||||||
|
| 0x1C | CrownRotationDirection | Single value (1 byte): `0x01` = reversed, `0x02` = default |
|
||||||
|
| 0x0D | ListeningMode | Single value (1 byte): 1 = Off, 2 = noise cancellation, 3 = transparency, 4 = adaptive |
|
||||||
|
| 0x1E | AutoAnswerMode | Single value (1 byte) |
|
||||||
|
| 0x1F | Chime Volume | Single value (1 byte): 0 to 100|
|
||||||
|
| 0x23 | VolumeSwipeInterval | Single value (1 byte): 0x00 = Default, `0x01` = Longer, `0x02` = Longest |
|
||||||
|
| 0x24 | Call Management Config | Single value (1 byte) |
|
||||||
|
| 0x25 | VolumeSwipeMode | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
|
||||||
|
| 0x26 | Adaptive Volume Config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
|
||||||
|
| 0x27 | Software Mute config | Single value (1 byte) |
|
||||||
|
| 0x28 | Conversation Detect config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
|
||||||
|
| 0x29 | SSL | Single value (1 byte) |
|
||||||
|
| 0x2C | Hearing Aid Enrolled and Hearing Aid Enabled | Two values (2 bytes; First byte - enrolled, Second byte = enabled): `0x01` = enabled, `0x02` = disabled |
|
||||||
|
| 0x2E | AutoANC Strength | Single value (1 byte): 0 to 100|
|
||||||
|
| 0x2F | HPS Gain Swipe | Single value (1 byte) |
|
||||||
|
| 0x30 | HRM enable/disable state | Single value (1 byte) |
|
||||||
|
| 0x31 | In Case Tone config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
|
||||||
|
| 0x32 | Siri Multitone config | Single value (1 byte) |
|
||||||
|
| 0x33 | Hearing Assist config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
|
||||||
|
| 0x34 | Allow Off Option for Listening Mode config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
|
||||||
|
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> - These identifiers have been extracted from the macOS 15.4 Beta (24E5238a)'s bluetooth stack.
|
||||||
|
> - I have already added the ranges of values a command takes that I know of. Feel free to experiemnt by sending the packets for which the range/values are not given here.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#include <QByteArray>
|
||||||
|
|
||||||
|
// Control Command Header
|
||||||
|
namespace ControlCommand
|
||||||
|
{
|
||||||
|
static const QByteArray HEADER = QByteArray::fromHex("040004000900");
|
||||||
|
|
||||||
|
// Helper function to create control command packets
|
||||||
|
static QByteArray createCommand(quint8 identifier, quint8 data1 = 0x00, quint8 data2 = 0x00,
|
||||||
|
quint8 data3 = 0x00, quint8 data4 = 0x00)
|
||||||
|
{
|
||||||
|
QByteArray packet = HEADER;
|
||||||
|
packet.append(static_cast<char>(identifier));
|
||||||
|
packet.append(static_cast<char>(data1));
|
||||||
|
packet.append(static_cast<char>(data2));
|
||||||
|
packet.append(static_cast<char>(data3));
|
||||||
|
packet.append(static_cast<char>(data4));
|
||||||
|
return packet;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline std::optional<char> parseActive(const QByteArray &data)
|
||||||
|
{
|
||||||
|
if (!data.startsWith(ControlCommand::HEADER))
|
||||||
|
return std::nullopt;
|
||||||
|
|
||||||
|
return static_cast<quint8>(data.at(7));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
template <quint8 CommandId>
|
||||||
|
struct BasicControlCommand
|
||||||
|
{
|
||||||
|
static constexpr quint8 ID = CommandId;
|
||||||
|
static const QByteArray HEADER;
|
||||||
|
|
||||||
|
static const QByteArray ENABLED;
|
||||||
|
static const QByteArray DISABLED;
|
||||||
|
|
||||||
|
static QByteArray create(quint8 data1 = 0x00, quint8 data2 = 0x00,
|
||||||
|
quint8 data3 = 0x00, quint8 data4 = 0x00)
|
||||||
|
{
|
||||||
|
return ControlCommand::createCommand(ID, data1, data2, data3, data4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basically returns the byte at the index 7
|
||||||
|
static std::optional<bool> parseState(const QByteArray &data)
|
||||||
|
{
|
||||||
|
switch (ControlCommand::parseActive(data).value_or(0x00))
|
||||||
|
{
|
||||||
|
case 0x01: // Enabled
|
||||||
|
return true;
|
||||||
|
case 0x02: // Disabled
|
||||||
|
return false;
|
||||||
|
default:
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::optional<char> getValue(const QByteArray &data)
|
||||||
|
{
|
||||||
|
return ControlCommand::parseActive(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
template <quint8 CommandId>
|
||||||
|
const QByteArray BasicControlCommand<CommandId>::HEADER = ControlCommand::HEADER + static_cast<char>(CommandId);
|
||||||
|
|
||||||
|
template <quint8 CommandId>
|
||||||
|
const QByteArray BasicControlCommand<CommandId>::ENABLED = create(0x01);
|
||||||
|
|
||||||
|
template <quint8 CommandId>
|
||||||
|
const QByteArray BasicControlCommand<CommandId>::DISABLED = create(0x02);
|
||||||
@@ -22,6 +22,8 @@ qt_add_executable(applinux
|
|||||||
BluetoothMonitor.cpp
|
BluetoothMonitor.cpp
|
||||||
BluetoothMonitor.h
|
BluetoothMonitor.h
|
||||||
autostartmanager.hpp
|
autostartmanager.hpp
|
||||||
|
BasicControlCommand.hpp
|
||||||
|
deviceinfo.hpp
|
||||||
)
|
)
|
||||||
|
|
||||||
qt_add_qml_module(applinux
|
qt_add_qml_module(applinux
|
||||||
|
|||||||
+35
-22
@@ -94,46 +94,46 @@ ApplicationWindow {
|
|||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
PodColumn {
|
PodColumn {
|
||||||
isVisible: airPodsTrayApp.battery.leftPodAvailable
|
isVisible: airPodsTrayApp.deviceInfo.battery.leftPodAvailable
|
||||||
inEar: airPodsTrayApp.leftPodInEar
|
inEar: airPodsTrayApp.deviceInfo.leftPodInEar
|
||||||
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.podIcon
|
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon
|
||||||
batteryLevel: airPodsTrayApp.battery.leftPodLevel
|
batteryLevel: airPodsTrayApp.deviceInfo.battery.leftPodLevel
|
||||||
isCharging: airPodsTrayApp.battery.leftPodCharging
|
isCharging: airPodsTrayApp.deviceInfo.battery.leftPodCharging
|
||||||
indicator: "L"
|
indicator: "L"
|
||||||
}
|
}
|
||||||
|
|
||||||
PodColumn {
|
PodColumn {
|
||||||
isVisible: airPodsTrayApp.battery.rightPodAvailable
|
isVisible: airPodsTrayApp.deviceInfo.battery.rightPodAvailable
|
||||||
inEar: airPodsTrayApp.rightPodInEar
|
inEar: airPodsTrayApp.deviceInfo.rightPodInEar
|
||||||
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.podIcon
|
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon
|
||||||
batteryLevel: airPodsTrayApp.battery.rightPodLevel
|
batteryLevel: airPodsTrayApp.deviceInfo.battery.rightPodLevel
|
||||||
isCharging: airPodsTrayApp.battery.rightPodCharging
|
isCharging: airPodsTrayApp.deviceInfo.battery.rightPodCharging
|
||||||
indicator: "R"
|
indicator: "R"
|
||||||
}
|
}
|
||||||
|
|
||||||
PodColumn {
|
PodColumn {
|
||||||
isVisible: airPodsTrayApp.battery.caseAvailable
|
isVisible: airPodsTrayApp.deviceInfo.battery.caseAvailable
|
||||||
inEar: true
|
inEar: true
|
||||||
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.caseIcon
|
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.caseIcon
|
||||||
batteryLevel: airPodsTrayApp.battery.caseLevel
|
batteryLevel: airPodsTrayApp.deviceInfo.battery.caseLevel
|
||||||
isCharging: airPodsTrayApp.battery.caseCharging
|
isCharging: airPodsTrayApp.deviceInfo.battery.caseCharging
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SegmentedControl {
|
SegmentedControl {
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
model: ["Off", "Noise Cancellation", "Transparency", "Adaptive"]
|
model: ["Off", "Noise Cancellation", "Transparency", "Adaptive"]
|
||||||
currentIndex: airPodsTrayApp.noiseControlMode
|
currentIndex: airPodsTrayApp.deviceInfo.noiseControlMode
|
||||||
onCurrentIndexChanged: airPodsTrayApp.noiseControlMode = currentIndex
|
onCurrentIndexChanged: airPodsTrayApp.setNoiseControlModeInt(currentIndex)
|
||||||
visible: airPodsTrayApp.airpodsConnected
|
visible: airPodsTrayApp.airpodsConnected
|
||||||
}
|
}
|
||||||
|
|
||||||
Slider {
|
Slider {
|
||||||
visible: airPodsTrayApp.adaptiveModeActive
|
visible: airPodsTrayApp.deviceInfo.adaptiveModeActive
|
||||||
from: 0
|
from: 0
|
||||||
to: 100
|
to: 100
|
||||||
stepSize: 1
|
stepSize: 1
|
||||||
value: airPodsTrayApp.adaptiveNoiseLevel
|
value: airPodsTrayApp.deviceInfo.adaptiveNoiseLevel
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
id: debounceTimer
|
id: debounceTimer
|
||||||
@@ -153,8 +153,8 @@ ApplicationWindow {
|
|||||||
Switch {
|
Switch {
|
||||||
visible: airPodsTrayApp.airpodsConnected
|
visible: airPodsTrayApp.airpodsConnected
|
||||||
text: "Conversational Awareness"
|
text: "Conversational Awareness"
|
||||||
checked: airPodsTrayApp.conversationalAwareness
|
checked: airPodsTrayApp.deviceInfo.conversationalAwareness
|
||||||
onCheckedChanged: airPodsTrayApp.conversationalAwareness = checked
|
onCheckedChanged: airPodsTrayApp.setConversationalAwareness(checked)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +226,19 @@ ApplicationWindow {
|
|||||||
onCheckedChanged: airPodsTrayApp.notificationsEnabled = checked
|
onCheckedChanged: airPodsTrayApp.notificationsEnabled = checked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Switch {
|
||||||
|
visible: airPodsTrayApp.airpodsConnected
|
||||||
|
text: "One Bud ANC Mode"
|
||||||
|
checked: airPodsTrayApp.deviceInfo.oneBudANCMode
|
||||||
|
onCheckedChanged: airPodsTrayApp.deviceInfo.oneBudANCMode = checked
|
||||||
|
|
||||||
|
ToolTip {
|
||||||
|
visible: parent.hovered
|
||||||
|
text: "Enable ANC when using one AirPod\n(More noise reduction, but uses more battery)"
|
||||||
|
delay: 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
spacing: 5
|
spacing: 5
|
||||||
Label {
|
Label {
|
||||||
@@ -246,13 +259,13 @@ ApplicationWindow {
|
|||||||
|
|
||||||
TextField {
|
TextField {
|
||||||
id: newNameField
|
id: newNameField
|
||||||
placeholderText: airPodsTrayApp.deviceName
|
placeholderText: airPodsTrayApp.deviceInfo.deviceName
|
||||||
maximumLength: 32
|
maximumLength: 32
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
text: "Rename"
|
text: "Rename"
|
||||||
onClicked: airPodsTrayApp.renameAirPods(newNameField.text)
|
onClicked: airPodsTrayApp.deviceInfo.renameAirPods(newNameField.text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# ALN Linux app
|
# LibrePods Linux
|
||||||
|
|
||||||
A native Linux application to control your AirPods, with support for:
|
A native Linux application to control your AirPods, with support for:
|
||||||
|
|
||||||
|
|||||||
+92
-65
@@ -3,22 +3,25 @@
|
|||||||
#define AIRPODS_PACKETS_H
|
#define AIRPODS_PACKETS_H
|
||||||
|
|
||||||
#include <QByteArray>
|
#include <QByteArray>
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
#include "enums.h"
|
#include "enums.h"
|
||||||
|
#include "BasicControlCommand.hpp"
|
||||||
|
|
||||||
namespace AirPodsPackets
|
namespace AirPodsPackets
|
||||||
{
|
{
|
||||||
// Noise Control Mode Packets
|
// Noise Control Mode Packets
|
||||||
namespace NoiseControl
|
namespace NoiseControl
|
||||||
{
|
{
|
||||||
static const QByteArray HEADER = QByteArray::fromHex("0400040009000D"); // Added for parsing
|
using NoiseControlMode = AirpodsTrayApp::Enums::NoiseControlMode;
|
||||||
static const QByteArray OFF = HEADER + QByteArray::fromHex("01000000");
|
static const QByteArray HEADER = ControlCommand::HEADER + 0x0D;
|
||||||
static const QByteArray NOISE_CANCELLATION = HEADER + QByteArray::fromHex("02000000");
|
static const QByteArray OFF = ControlCommand::createCommand(0x0D, 0x01);
|
||||||
static const QByteArray TRANSPARENCY = HEADER + QByteArray::fromHex("03000000");
|
static const QByteArray NOISE_CANCELLATION = ControlCommand::createCommand(0x0D, 0x02);
|
||||||
static const QByteArray ADAPTIVE = HEADER + QByteArray::fromHex("04000000");
|
static const QByteArray TRANSPARENCY = ControlCommand::createCommand(0x0D, 0x03);
|
||||||
|
static const QByteArray ADAPTIVE = ControlCommand::createCommand(0x0D, 0x04);
|
||||||
|
|
||||||
static const QByteArray getPacketForMode(AirpodsTrayApp::Enums::NoiseControlMode mode)
|
static const QByteArray getPacketForMode(AirpodsTrayApp::Enums::NoiseControlMode mode)
|
||||||
{
|
{
|
||||||
using NoiseControlMode = AirpodsTrayApp::Enums::NoiseControlMode;
|
|
||||||
switch (mode)
|
switch (mode)
|
||||||
{
|
{
|
||||||
case NoiseControlMode::Off:
|
case NoiseControlMode::Off:
|
||||||
@@ -33,34 +36,86 @@ namespace AirPodsPackets
|
|||||||
return QByteArray();
|
return QByteArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Conversational Awareness Packets
|
inline std::optional<NoiseControlMode> parseMode(const QByteArray &data)
|
||||||
namespace ConversationalAwareness
|
|
||||||
{
|
|
||||||
static const QByteArray HEADER = QByteArray::fromHex("04000400090028"); // For command/status
|
|
||||||
static const QByteArray ENABLED = HEADER + QByteArray::fromHex("01000000"); // Command to enable
|
|
||||||
static const QByteArray DISABLED = HEADER + QByteArray::fromHex("02000000"); // Command to disable
|
|
||||||
static const QByteArray DATA_HEADER = QByteArray::fromHex("040004004B00020001"); // For received speech level data
|
|
||||||
|
|
||||||
static std::optional<bool> parseCAState(const QByteArray &data)
|
|
||||||
{
|
{
|
||||||
// Extract the status byte (index 7)
|
char mode = ControlCommand::parseActive(data).value_or(CHAR_MAX);
|
||||||
quint8 statusByte = static_cast<quint8>(data.at(HEADER.size())); // HEADER.size() is 7
|
if (mode < static_cast<quint8>(NoiseControlMode::MinValue) ||
|
||||||
|
mode > static_cast<quint8>(NoiseControlMode::MaxValue))
|
||||||
// Interpret the status byte
|
|
||||||
switch (statusByte)
|
|
||||||
{
|
{
|
||||||
case 0x01: // Enabled
|
|
||||||
return true;
|
|
||||||
case 0x02: // Disabled
|
|
||||||
return false;
|
|
||||||
default:
|
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
return static_cast<NoiseControlMode>(mode - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One Bud ANC Mode
|
||||||
|
namespace OneBudANCMode
|
||||||
|
{
|
||||||
|
using Type = BasicControlCommand<0x1B>;
|
||||||
|
static const QByteArray ENABLED = Type::ENABLED;
|
||||||
|
static const QByteArray DISABLED = Type::DISABLED;
|
||||||
|
static const QByteArray HEADER = Type::HEADER;
|
||||||
|
inline std::optional<bool> parseState(const QByteArray &data) { return Type::parseState(data); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume Swipe (partial - still needs custom interval function)
|
||||||
|
namespace VolumeSwipe
|
||||||
|
{
|
||||||
|
using Type = BasicControlCommand<0x25>;
|
||||||
|
static const QByteArray ENABLED = Type::ENABLED;
|
||||||
|
static const QByteArray DISABLED = Type::DISABLED;
|
||||||
|
static const QByteArray HEADER = Type::HEADER;
|
||||||
|
inline std::optional<bool> parseState(const QByteArray &data) { return Type::parseState(data); }
|
||||||
|
|
||||||
|
// Keep custom interval function
|
||||||
|
static QByteArray getIntervalPacket(quint8 interval)
|
||||||
|
{
|
||||||
|
return ControlCommand::createCommand(0x23, interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adaptive Volume Config
|
||||||
|
namespace AdaptiveVolume
|
||||||
|
{
|
||||||
|
using Type = BasicControlCommand<0x26>;
|
||||||
|
static const QByteArray ENABLED = Type::ENABLED;
|
||||||
|
static const QByteArray DISABLED = Type::DISABLED;
|
||||||
|
static const QByteArray HEADER = Type::HEADER;
|
||||||
|
inline std::optional<bool> parseState(const QByteArray &data) { return Type::parseState(data); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversational Awareness
|
||||||
|
namespace ConversationalAwareness
|
||||||
|
{
|
||||||
|
using Type = BasicControlCommand<0x28>;
|
||||||
|
static const QByteArray ENABLED = Type::ENABLED;
|
||||||
|
static const QByteArray DISABLED = Type::DISABLED;
|
||||||
|
static const QByteArray HEADER = Type::HEADER;
|
||||||
|
static const QByteArray DATA_HEADER = QByteArray::fromHex("040004004B00020001");
|
||||||
|
inline std::optional<bool> parseState(const QByteArray &data) { return Type::parseState(data); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hearing Assist
|
||||||
|
namespace HearingAssist
|
||||||
|
{
|
||||||
|
using Type = BasicControlCommand<0x33>;
|
||||||
|
static const QByteArray ENABLED = Type::ENABLED;
|
||||||
|
static const QByteArray DISABLED = Type::DISABLED;
|
||||||
|
static const QByteArray HEADER = Type::HEADER;
|
||||||
|
inline std::optional<bool> parseState(const QByteArray &data) { return Type::parseState(data); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow Off Option
|
||||||
|
namespace AllowOffOption
|
||||||
|
{
|
||||||
|
using Type = BasicControlCommand<0x34>;
|
||||||
|
static const QByteArray ENABLED = Type::ENABLED;
|
||||||
|
static const QByteArray DISABLED = Type::DISABLED;
|
||||||
|
static const QByteArray HEADER = Type::HEADER;
|
||||||
|
inline std::optional<bool> parseState(const QByteArray &data) { return Type::parseState(data); }
|
||||||
|
}
|
||||||
|
|
||||||
// Connection Packets
|
// Connection Packets
|
||||||
namespace Connection
|
namespace Connection
|
||||||
{
|
{
|
||||||
@@ -118,65 +173,37 @@ namespace AirPodsPackets
|
|||||||
{
|
{
|
||||||
MagicCloudKeys keys;
|
MagicCloudKeys keys;
|
||||||
|
|
||||||
// Expected size: header (7 bytes) + (1 (tag) + 2 (length) + 1 (reserved) + 16 (value)) * 2 = 47 bytes.
|
if (data.size() < 47 || !data.startsWith(MAGIC_CLOUD_KEYS_HEADER))
|
||||||
if (data.size() < 47)
|
|
||||||
{
|
{
|
||||||
return keys; // or handle error as needed
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check header
|
int index = MAGIC_CLOUD_KEYS_HEADER.size();
|
||||||
if (!data.startsWith(MAGIC_CLOUD_KEYS_HEADER))
|
|
||||||
{
|
|
||||||
return keys; // header mismatch
|
|
||||||
}
|
|
||||||
|
|
||||||
int index = MAGIC_CLOUD_KEYS_HEADER.size(); // Start after header (index 7)
|
// First TLV block (MagicAccIRK)
|
||||||
|
|
||||||
// --- TLV Block 1 (MagicAccIRK) ---
|
|
||||||
// Tag should be 0x01
|
|
||||||
if (static_cast<quint8>(data.at(index)) != 0x01)
|
if (static_cast<quint8>(data.at(index)) != 0x01)
|
||||||
{
|
return keys;
|
||||||
return keys; // unexpected tag
|
|
||||||
}
|
|
||||||
index += 1;
|
index += 1;
|
||||||
|
|
||||||
// Read length (2 bytes, big-endian)
|
|
||||||
quint16 len1 = (static_cast<quint8>(data.at(index)) << 8) | static_cast<quint8>(data.at(index + 1));
|
quint16 len1 = (static_cast<quint8>(data.at(index)) << 8) | static_cast<quint8>(data.at(index + 1));
|
||||||
if (len1 != 16)
|
if (len1 != 16)
|
||||||
{
|
return keys;
|
||||||
return keys; // invalid length
|
index += 3; // Skip length (2 bytes) and reserved byte (1 byte)
|
||||||
}
|
|
||||||
index += 2;
|
|
||||||
|
|
||||||
// Skip reserved byte
|
|
||||||
index += 1;
|
|
||||||
|
|
||||||
// Extract MagicAccIRK (16 bytes)
|
|
||||||
keys.magicAccIRK = data.mid(index, 16);
|
keys.magicAccIRK = data.mid(index, 16);
|
||||||
index += 16;
|
index += 16;
|
||||||
|
|
||||||
// --- TLV Block 2 (MagicAccEncKey) ---
|
// Second TLV block (MagicAccEncKey)
|
||||||
// Tag should be 0x04
|
|
||||||
if (static_cast<quint8>(data.at(index)) != 0x04)
|
if (static_cast<quint8>(data.at(index)) != 0x04)
|
||||||
{
|
return keys;
|
||||||
return keys; // unexpected tag
|
|
||||||
}
|
|
||||||
index += 1;
|
index += 1;
|
||||||
|
|
||||||
// Read length (2 bytes, big-endian)
|
|
||||||
quint16 len2 = (static_cast<quint8>(data.at(index)) << 8) | static_cast<quint8>(data.at(index + 1));
|
quint16 len2 = (static_cast<quint8>(data.at(index)) << 8) | static_cast<quint8>(data.at(index + 1));
|
||||||
if (len2 != 16)
|
if (len2 != 16)
|
||||||
{
|
return keys;
|
||||||
return keys; // invalid length
|
index += 3; // Skip length (2 bytes) and reserved byte (1 byte)
|
||||||
}
|
|
||||||
index += 2;
|
|
||||||
|
|
||||||
// Skip reserved byte
|
|
||||||
index += 1;
|
|
||||||
|
|
||||||
// Extract MagicAccEncKey (16 bytes)
|
|
||||||
keys.magicAccEncKey = data.mid(index, 16);
|
keys.magicAccEncKey = data.mid(index, 16);
|
||||||
index += 16;
|
|
||||||
|
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
#include <QByteArray>
|
#include <QByteArray>
|
||||||
#include <QMap>
|
#include <QMap>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QSettings>
|
||||||
|
#include "battery.hpp"
|
||||||
|
#include "enums.h"
|
||||||
|
|
||||||
|
using namespace AirpodsTrayApp::Enums;
|
||||||
|
|
||||||
|
class DeviceInfo : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
Q_PROPERTY(QString batteryStatus READ batteryStatus WRITE setBatteryStatus NOTIFY batteryStatusChanged)
|
||||||
|
Q_PROPERTY(QString earDetectionStatus READ earDetectionStatus WRITE setEarDetectionStatus NOTIFY earDetectionStatusChanged)
|
||||||
|
Q_PROPERTY(int noiseControlMode READ noiseControlModeInt WRITE setNoiseControlModeInt NOTIFY noiseControlModeChangedInt)
|
||||||
|
Q_PROPERTY(bool conversationalAwareness READ conversationalAwareness WRITE setConversationalAwareness NOTIFY conversationalAwarenessChanged)
|
||||||
|
Q_PROPERTY(int adaptiveNoiseLevel READ adaptiveNoiseLevel WRITE setAdaptiveNoiseLevel NOTIFY adaptiveNoiseLevelChanged)
|
||||||
|
Q_PROPERTY(QString deviceName READ deviceName WRITE setDeviceName NOTIFY deviceNameChanged)
|
||||||
|
Q_PROPERTY(Battery *battery READ getBattery CONSTANT)
|
||||||
|
Q_PROPERTY(bool primaryInEar READ isPrimaryInEar WRITE setPrimaryInEar NOTIFY primaryChanged)
|
||||||
|
Q_PROPERTY(bool secondaryInEar READ isSecondaryInEar WRITE setSecondaryInEar NOTIFY primaryChanged)
|
||||||
|
Q_PROPERTY(bool oneBudANCMode READ oneBudANCMode WRITE setOneBudANCMode NOTIFY oneBudANCModeChanged)
|
||||||
|
Q_PROPERTY(AirPodsModel model READ model WRITE setModel NOTIFY modelChanged)
|
||||||
|
Q_PROPERTY(bool adaptiveModeActive READ adaptiveModeActive NOTIFY noiseControlModeChangedInt)
|
||||||
|
Q_PROPERTY(QString podIcon READ podIcon NOTIFY modelChanged)
|
||||||
|
Q_PROPERTY(QString caseIcon READ caseIcon NOTIFY modelChanged)
|
||||||
|
Q_PROPERTY(bool leftPodInEar READ isLeftPodInEar NOTIFY primaryChanged)
|
||||||
|
Q_PROPERTY(bool rightPodInEar READ isRightPodInEar NOTIFY primaryChanged)
|
||||||
|
Q_PROPERTY(QString bluetoothAddress READ bluetoothAddress WRITE setBluetoothAddress NOTIFY bluetoothAddressChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit DeviceInfo(QObject *parent = nullptr) : QObject(parent), m_battery(new Battery(this)) {}
|
||||||
|
|
||||||
|
QString batteryStatus() const { return m_batteryStatus; }
|
||||||
|
void setBatteryStatus(const QString &status)
|
||||||
|
{
|
||||||
|
if (m_batteryStatus != status)
|
||||||
|
{
|
||||||
|
m_batteryStatus = status;
|
||||||
|
emit batteryStatusChanged(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString earDetectionStatus() const { return m_earDetectionStatus; }
|
||||||
|
void setEarDetectionStatus(const QString &status)
|
||||||
|
{
|
||||||
|
if (m_earDetectionStatus != status)
|
||||||
|
{
|
||||||
|
m_earDetectionStatus = status;
|
||||||
|
emit earDetectionStatusChanged(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NoiseControlMode noiseControlMode() const { return m_noiseControlMode; }
|
||||||
|
void setNoiseControlMode(NoiseControlMode mode)
|
||||||
|
{
|
||||||
|
if (m_noiseControlMode != mode)
|
||||||
|
{
|
||||||
|
m_noiseControlMode = mode;
|
||||||
|
emit noiseControlModeChanged(mode);
|
||||||
|
emit noiseControlModeChangedInt(static_cast<int>(mode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int noiseControlModeInt() const { return static_cast<int>(noiseControlMode()); }
|
||||||
|
void setNoiseControlModeInt(int mode) { setNoiseControlMode(static_cast<NoiseControlMode>(mode)); }
|
||||||
|
|
||||||
|
bool conversationalAwareness() const { return m_conversationalAwareness; }
|
||||||
|
void setConversationalAwareness(bool enabled)
|
||||||
|
{
|
||||||
|
if (m_conversationalAwareness != enabled)
|
||||||
|
{
|
||||||
|
m_conversationalAwareness = enabled;
|
||||||
|
emit conversationalAwarenessChanged(enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int adaptiveNoiseLevel() const { return m_adaptiveNoiseLevel; }
|
||||||
|
void setAdaptiveNoiseLevel(int level)
|
||||||
|
{
|
||||||
|
if (m_adaptiveNoiseLevel != level)
|
||||||
|
{
|
||||||
|
m_adaptiveNoiseLevel = level;
|
||||||
|
emit adaptiveNoiseLevelChanged(level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString deviceName() const { return m_deviceName; }
|
||||||
|
void setDeviceName(const QString &name)
|
||||||
|
{
|
||||||
|
if (m_deviceName != name)
|
||||||
|
{
|
||||||
|
m_deviceName = name;
|
||||||
|
emit deviceNameChanged(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Battery *getBattery() const { return m_battery; }
|
||||||
|
|
||||||
|
bool isPrimaryInEar() const { return m_primaryInEar; }
|
||||||
|
void setPrimaryInEar(bool inEar)
|
||||||
|
{
|
||||||
|
if (m_primaryInEar != inEar)
|
||||||
|
{
|
||||||
|
m_primaryInEar = inEar;
|
||||||
|
emit primaryChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isSecondaryInEar() const { return m_secoundaryInEar; }
|
||||||
|
void setSecondaryInEar(bool inEar)
|
||||||
|
{
|
||||||
|
if (m_secoundaryInEar != inEar)
|
||||||
|
{
|
||||||
|
m_secoundaryInEar = inEar;
|
||||||
|
emit primaryChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool oneBudANCMode() const { return m_oneBudANCMode; }
|
||||||
|
void setOneBudANCMode(bool enabled)
|
||||||
|
{
|
||||||
|
if (m_oneBudANCMode != enabled)
|
||||||
|
{
|
||||||
|
m_oneBudANCMode = enabled;
|
||||||
|
emit oneBudANCModeChanged(enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AirPodsModel model() const { return m_model; }
|
||||||
|
void setModel(AirPodsModel model)
|
||||||
|
{
|
||||||
|
if (m_model != model)
|
||||||
|
{
|
||||||
|
m_model = model;
|
||||||
|
emit modelChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray magicAccIRK() const { return m_magicAccIRK; }
|
||||||
|
void setMagicAccIRK(const QByteArray &irk) { m_magicAccIRK = irk; }
|
||||||
|
|
||||||
|
QByteArray magicAccEncKey() const { return m_magicAccEncKey; }
|
||||||
|
void setMagicAccEncKey(const QByteArray &key) { m_magicAccEncKey = key; }
|
||||||
|
|
||||||
|
QString modelNumber() const { return m_modelNumber; }
|
||||||
|
void setModelNumber(const QString &modelNumber) { m_modelNumber = modelNumber; }
|
||||||
|
|
||||||
|
QString manufacturer() const { return m_manufacturer; }
|
||||||
|
void setManufacturer(const QString &manufacturer) { m_manufacturer = manufacturer; }
|
||||||
|
|
||||||
|
QString bluetoothAddress() const { return m_bluetoothAddress; }
|
||||||
|
void setBluetoothAddress(const QString &address)
|
||||||
|
{
|
||||||
|
if (m_bluetoothAddress != address)
|
||||||
|
{
|
||||||
|
m_bluetoothAddress = address;
|
||||||
|
emit bluetoothAddressChanged(address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString podIcon() const { return getModelIcon(model()).first; }
|
||||||
|
QString caseIcon() const { return getModelIcon(model()).second; }
|
||||||
|
bool isLeftPodInEar() const
|
||||||
|
{
|
||||||
|
if (getBattery()->getPrimaryPod() == Battery::Component::Left) return isPrimaryInEar();
|
||||||
|
else return isSecondaryInEar();
|
||||||
|
}
|
||||||
|
bool isRightPodInEar() const
|
||||||
|
{
|
||||||
|
if (getBattery()->getPrimaryPod() == Battery::Component::Right) return isPrimaryInEar();
|
||||||
|
else return isSecondaryInEar();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool adaptiveModeActive() const { return noiseControlMode() == NoiseControlMode::Adaptive; }
|
||||||
|
bool oneOrMorePodsInCase() const { return earDetectionStatus().contains("In case"); }
|
||||||
|
bool oneOrMorePodsInEar() const { return isPrimaryInEar() || isSecondaryInEar(); }
|
||||||
|
|
||||||
|
void reset()
|
||||||
|
{
|
||||||
|
setDeviceName("");
|
||||||
|
setModel(AirPodsModel::Unknown);
|
||||||
|
m_battery->reset();
|
||||||
|
setBatteryStatus("");
|
||||||
|
setEarDetectionStatus("");
|
||||||
|
setPrimaryInEar(false);
|
||||||
|
setSecondaryInEar(false);
|
||||||
|
setNoiseControlMode(NoiseControlMode::Off);
|
||||||
|
setBluetoothAddress("");
|
||||||
|
}
|
||||||
|
|
||||||
|
void save() const
|
||||||
|
{
|
||||||
|
QSettings settings("AirpodsTrayApp", "DeviceInfo");
|
||||||
|
settings.beginGroup("DeviceInfo");
|
||||||
|
settings.setValue("deviceName", m_deviceName);
|
||||||
|
settings.setValue("bluetoothAddress", m_bluetoothAddress);
|
||||||
|
settings.setValue("magicAccIRK", m_magicAccIRK.toBase64());
|
||||||
|
settings.setValue("magicAccEncKey", m_magicAccEncKey.toBase64());
|
||||||
|
settings.endGroup();
|
||||||
|
}
|
||||||
|
|
||||||
|
void load()
|
||||||
|
{
|
||||||
|
QSettings settings("AirpodsTrayApp", "DeviceInfo");
|
||||||
|
settings.beginGroup("DeviceInfo");
|
||||||
|
setDeviceName(settings.value("deviceName", "").toString());
|
||||||
|
setBluetoothAddress(settings.value("bluetoothAddress", "").toString());
|
||||||
|
setMagicAccIRK(QByteArray::fromBase64(settings.value("magicAccIRK", "").toByteArray()));
|
||||||
|
setMagicAccEncKey(QByteArray::fromBase64(settings.value("magicAccEncKey", "").toByteArray()));
|
||||||
|
settings.endGroup();
|
||||||
|
}
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void batteryStatusChanged(const QString &status);
|
||||||
|
void earDetectionStatusChanged(const QString &status);
|
||||||
|
void noiseControlModeChanged(NoiseControlMode mode);
|
||||||
|
void noiseControlModeChangedInt(int mode);
|
||||||
|
void conversationalAwarenessChanged(bool enabled);
|
||||||
|
void adaptiveNoiseLevelChanged(int level);
|
||||||
|
void deviceNameChanged(const QString &name);
|
||||||
|
void primaryChanged();
|
||||||
|
void oneBudANCModeChanged(bool enabled);
|
||||||
|
void modelChanged();
|
||||||
|
void bluetoothAddressChanged(const QString &address);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_batteryStatus;
|
||||||
|
QString m_earDetectionStatus;
|
||||||
|
NoiseControlMode m_noiseControlMode = NoiseControlMode::Off;
|
||||||
|
bool m_conversationalAwareness = false;
|
||||||
|
int m_adaptiveNoiseLevel = 50;
|
||||||
|
QString m_deviceName;
|
||||||
|
Battery *m_battery;
|
||||||
|
bool m_primaryInEar = false;
|
||||||
|
bool m_secoundaryInEar = false;
|
||||||
|
QByteArray m_magicAccIRK;
|
||||||
|
QByteArray m_magicAccEncKey;
|
||||||
|
bool m_oneBudANCMode = false;
|
||||||
|
AirPodsModel m_model = AirPodsModel::Unknown;
|
||||||
|
QString m_modelNumber;
|
||||||
|
QString m_manufacturer;
|
||||||
|
QString m_bluetoothAddress;
|
||||||
|
};
|
||||||
+108
-203
@@ -10,6 +10,7 @@
|
|||||||
#include "battery.hpp"
|
#include "battery.hpp"
|
||||||
#include "BluetoothMonitor.h"
|
#include "BluetoothMonitor.h"
|
||||||
#include "autostartmanager.hpp"
|
#include "autostartmanager.hpp"
|
||||||
|
#include "deviceinfo.hpp"
|
||||||
|
|
||||||
using namespace AirpodsTrayApp::Enums;
|
using namespace AirpodsTrayApp::Enums;
|
||||||
|
|
||||||
@@ -17,19 +18,6 @@ Q_LOGGING_CATEGORY(airpodsApp, "airpodsApp")
|
|||||||
|
|
||||||
class AirPodsTrayApp : public QObject {
|
class AirPodsTrayApp : public QObject {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
Q_PROPERTY(QString batteryStatus READ batteryStatus NOTIFY batteryStatusChanged)
|
|
||||||
Q_PROPERTY(QString earDetectionStatus READ earDetectionStatus NOTIFY earDetectionStatusChanged)
|
|
||||||
Q_PROPERTY(int noiseControlMode READ noiseControlMode WRITE setNoiseControlMode NOTIFY noiseControlModeChanged)
|
|
||||||
Q_PROPERTY(bool conversationalAwareness READ conversationalAwareness WRITE setConversationalAwareness NOTIFY conversationalAwarenessChanged)
|
|
||||||
Q_PROPERTY(int adaptiveNoiseLevel READ adaptiveNoiseLevel WRITE setAdaptiveNoiseLevel NOTIFY adaptiveNoiseLevelChanged)
|
|
||||||
Q_PROPERTY(bool adaptiveModeActive READ adaptiveModeActive NOTIFY noiseControlModeChanged)
|
|
||||||
Q_PROPERTY(QString deviceName READ deviceName NOTIFY deviceNameChanged)
|
|
||||||
Q_PROPERTY(Battery* battery READ getBattery NOTIFY batteryStatusChanged)
|
|
||||||
Q_PROPERTY(bool oneOrMorePodsInCase READ oneOrMorePodsInCase NOTIFY earDetectionStatusChanged)
|
|
||||||
Q_PROPERTY(QString podIcon READ podIcon NOTIFY modelChanged)
|
|
||||||
Q_PROPERTY(QString caseIcon READ caseIcon NOTIFY modelChanged)
|
|
||||||
Q_PROPERTY(bool leftPodInEar READ isLeftPodInEar NOTIFY primaryChanged)
|
|
||||||
Q_PROPERTY(bool rightPodInEar READ isRightPodInEar NOTIFY primaryChanged)
|
|
||||||
Q_PROPERTY(bool airpodsConnected READ areAirpodsConnected NOTIFY airPodsStatusChanged)
|
Q_PROPERTY(bool airpodsConnected READ areAirpodsConnected NOTIFY airPodsStatusChanged)
|
||||||
Q_PROPERTY(int earDetectionBehavior READ earDetectionBehavior WRITE setEarDetectionBehavior NOTIFY earDetectionBehaviorChanged)
|
Q_PROPERTY(int earDetectionBehavior READ earDetectionBehavior WRITE setEarDetectionBehavior NOTIFY earDetectionBehaviorChanged)
|
||||||
Q_PROPERTY(bool crossDeviceEnabled READ crossDeviceEnabled WRITE setCrossDeviceEnabled NOTIFY crossDeviceEnabledChanged)
|
Q_PROPERTY(bool crossDeviceEnabled READ crossDeviceEnabled WRITE setCrossDeviceEnabled NOTIFY crossDeviceEnabledChanged)
|
||||||
@@ -37,23 +25,13 @@ class AirPodsTrayApp : public QObject {
|
|||||||
Q_PROPERTY(bool notificationsEnabled READ notificationsEnabled WRITE setNotificationsEnabled NOTIFY notificationsEnabledChanged)
|
Q_PROPERTY(bool notificationsEnabled READ notificationsEnabled WRITE setNotificationsEnabled NOTIFY notificationsEnabledChanged)
|
||||||
Q_PROPERTY(int retryAttempts READ retryAttempts WRITE setRetryAttempts NOTIFY retryAttemptsChanged)
|
Q_PROPERTY(int retryAttempts READ retryAttempts WRITE setRetryAttempts NOTIFY retryAttemptsChanged)
|
||||||
Q_PROPERTY(bool hideOnStart READ hideOnStart CONSTANT)
|
Q_PROPERTY(bool hideOnStart READ hideOnStart CONSTANT)
|
||||||
|
Q_PROPERTY(DeviceInfo *deviceInfo READ deviceInfo CONSTANT)
|
||||||
|
|
||||||
public:
|
public:
|
||||||
AirPodsTrayApp(bool debugMode, bool hideOnStart, QQmlApplicationEngine *parent = nullptr)
|
AirPodsTrayApp(bool debugMode, bool hideOnStart, QQmlApplicationEngine *parent = nullptr)
|
||||||
: QObject(parent)
|
: QObject(parent), debugMode(debugMode), m_settings(new QSettings("AirPodsTrayApp", "AirPodsTrayApp")), m_autoStartManager(new AutoStartManager(this)), m_hideOnStart(hideOnStart), parent(parent), m_deviceInfo(new DeviceInfo(this))
|
||||||
, debugMode(debugMode)
|
{
|
||||||
, m_battery(new Battery(this))
|
QLoggingCategory::setFilterRules(QString("airpodsApp.debug=%1").arg(debugMode ? "true" : "false"));
|
||||||
, monitor(new BluetoothMonitor(this))
|
|
||||||
, m_settings(new QSettings("AirPodsTrayApp", "AirPodsTrayApp"))
|
|
||||||
, m_autoStartManager(new AutoStartManager(this))
|
|
||||||
, m_hideOnStart(hideOnStart)
|
|
||||||
, parent(parent)
|
|
||||||
{
|
|
||||||
if (debugMode) {
|
|
||||||
QLoggingCategory::setFilterRules("airpodsApp.debug=true");
|
|
||||||
} else {
|
|
||||||
QLoggingCategory::setFilterRules("airpodsApp.debug=false");
|
|
||||||
}
|
|
||||||
LOG_INFO("Initializing AirPodsTrayApp");
|
LOG_INFO("Initializing AirPodsTrayApp");
|
||||||
|
|
||||||
// Initialize tray icon and connect signals
|
// Initialize tray icon and connect signals
|
||||||
@@ -62,25 +40,26 @@ public:
|
|||||||
connect(trayManager, &TrayIconManager::trayClicked, this, &AirPodsTrayApp::onTrayIconActivated);
|
connect(trayManager, &TrayIconManager::trayClicked, this, &AirPodsTrayApp::onTrayIconActivated);
|
||||||
connect(trayManager, &TrayIconManager::openApp, this, &AirPodsTrayApp::onOpenApp);
|
connect(trayManager, &TrayIconManager::openApp, this, &AirPodsTrayApp::onOpenApp);
|
||||||
connect(trayManager, &TrayIconManager::openSettings, this, &AirPodsTrayApp::onOpenSettings);
|
connect(trayManager, &TrayIconManager::openSettings, this, &AirPodsTrayApp::onOpenSettings);
|
||||||
connect(trayManager, &TrayIconManager::noiseControlChanged, this, qOverload<NoiseControlMode>(&AirPodsTrayApp::setNoiseControlMode));
|
connect(trayManager, &TrayIconManager::noiseControlChanged, this, &AirPodsTrayApp::setNoiseControlMode);
|
||||||
connect(trayManager, &TrayIconManager::conversationalAwarenessToggled, this, &AirPodsTrayApp::setConversationalAwareness);
|
connect(trayManager, &TrayIconManager::conversationalAwarenessToggled, this, &AirPodsTrayApp::setConversationalAwareness);
|
||||||
connect(this, &AirPodsTrayApp::batteryStatusChanged, trayManager, &TrayIconManager::updateBatteryStatus);
|
connect(m_deviceInfo, &DeviceInfo::batteryStatusChanged, trayManager, &TrayIconManager::updateBatteryStatus);
|
||||||
connect(this, &AirPodsTrayApp::noiseControlModeChanged, trayManager, &TrayIconManager::updateNoiseControlState);
|
connect(m_deviceInfo, &DeviceInfo::noiseControlModeChanged, trayManager, &TrayIconManager::updateNoiseControlState);
|
||||||
connect(this, &AirPodsTrayApp::conversationalAwarenessChanged, trayManager, &TrayIconManager::updateConversationalAwareness);
|
connect(m_deviceInfo, &DeviceInfo::conversationalAwarenessChanged, trayManager, &TrayIconManager::updateConversationalAwareness);
|
||||||
connect(trayManager, &TrayIconManager::notificationsEnabledChanged, this, &AirPodsTrayApp::saveNotificationsEnabled);
|
connect(trayManager, &TrayIconManager::notificationsEnabledChanged, this, &AirPodsTrayApp::saveNotificationsEnabled);
|
||||||
connect(trayManager, &TrayIconManager::notificationsEnabledChanged, this, &AirPodsTrayApp::notificationsEnabledChanged);
|
connect(trayManager, &TrayIconManager::notificationsEnabledChanged, this, &AirPodsTrayApp::notificationsEnabledChanged);
|
||||||
|
|
||||||
// Initialize MediaController and connect signals
|
// Initialize MediaController and connect signals
|
||||||
mediaController = new MediaController(this);
|
mediaController = new MediaController(this);
|
||||||
connect(this, &AirPodsTrayApp::earDetectionStatusChanged, mediaController, &MediaController::handleEarDetection);
|
connect(m_deviceInfo, &DeviceInfo::earDetectionStatusChanged, mediaController, &MediaController::handleEarDetection);
|
||||||
connect(mediaController, &MediaController::mediaStateChanged, this, &AirPodsTrayApp::handleMediaStateChange);
|
connect(mediaController, &MediaController::mediaStateChanged, this, &AirPodsTrayApp::handleMediaStateChange);
|
||||||
mediaController->initializeMprisInterface();
|
mediaController->initializeMprisInterface();
|
||||||
mediaController->followMediaChanges();
|
mediaController->followMediaChanges();
|
||||||
|
|
||||||
|
monitor = new BluetoothMonitor(this);
|
||||||
connect(monitor, &BluetoothMonitor::deviceConnected, this, &AirPodsTrayApp::bluezDeviceConnected);
|
connect(monitor, &BluetoothMonitor::deviceConnected, this, &AirPodsTrayApp::bluezDeviceConnected);
|
||||||
connect(monitor, &BluetoothMonitor::deviceDisconnected, this, &AirPodsTrayApp::bluezDeviceDisconnected);
|
connect(monitor, &BluetoothMonitor::deviceDisconnected, this, &AirPodsTrayApp::bluezDeviceDisconnected);
|
||||||
|
|
||||||
connect(m_battery, &Battery::primaryChanged, this, &AirPodsTrayApp::primaryChanged);
|
connect(m_deviceInfo->getBattery(), &Battery::primaryChanged, this, &AirPodsTrayApp::primaryChanged);
|
||||||
|
|
||||||
// Load settings
|
// Load settings
|
||||||
CrossDevice.isEnabled = loadCrossDeviceEnabled();
|
CrossDevice.isEnabled = loadCrossDeviceEnabled();
|
||||||
@@ -113,31 +92,6 @@ public:
|
|||||||
delete phoneSocket;
|
delete phoneSocket;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString batteryStatus() const { return m_batteryStatus; }
|
|
||||||
QString earDetectionStatus() const { return m_earDetectionStatus; }
|
|
||||||
int noiseControlMode() const { return static_cast<int>(m_noiseControlMode); }
|
|
||||||
bool conversationalAwareness() const { return m_conversationalAwareness; }
|
|
||||||
bool adaptiveModeActive() const { return m_noiseControlMode == NoiseControlMode::Adaptive; }
|
|
||||||
int adaptiveNoiseLevel() const { return m_adaptiveNoiseLevel; }
|
|
||||||
QString deviceName() const { return m_deviceName; }
|
|
||||||
Battery *getBattery() const { return m_battery; }
|
|
||||||
bool oneOrMorePodsInCase() const { return m_earDetectionStatus.contains("In case"); }
|
|
||||||
QString podIcon() const { return getModelIcon(m_model).first; }
|
|
||||||
QString caseIcon() const { return getModelIcon(m_model).second; }
|
|
||||||
bool isLeftPodInEar() const {
|
|
||||||
if (m_battery->getPrimaryPod() == Battery::Component::Left) {
|
|
||||||
return m_primaryInEar;
|
|
||||||
} else {
|
|
||||||
return m_secoundaryInEar;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bool isRightPodInEar() const {
|
|
||||||
if (m_battery->getPrimaryPod() == Battery::Component::Right) {
|
|
||||||
return m_primaryInEar;
|
|
||||||
} else {
|
|
||||||
return m_secoundaryInEar;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bool areAirpodsConnected() const { return socket && socket->isOpen() && socket->state() == QBluetoothSocket::SocketState::ConnectedState; }
|
bool areAirpodsConnected() const { return socket && socket->isOpen() && socket->state() == QBluetoothSocket::SocketState::ConnectedState; }
|
||||||
int earDetectionBehavior() const { return mediaController->getEarDetectionBehavior(); }
|
int earDetectionBehavior() const { return mediaController->getEarDetectionBehavior(); }
|
||||||
bool crossDeviceEnabled() const { return CrossDevice.isEnabled; }
|
bool crossDeviceEnabled() const { return CrossDevice.isEnabled; }
|
||||||
@@ -146,6 +100,7 @@ public:
|
|||||||
void setNotificationsEnabled(bool enabled) { trayManager->setNotificationsEnabled(enabled); }
|
void setNotificationsEnabled(bool enabled) { trayManager->setNotificationsEnabled(enabled); }
|
||||||
int retryAttempts() const { return m_retryAttempts; }
|
int retryAttempts() const { return m_retryAttempts; }
|
||||||
bool hideOnStart() const { return m_hideOnStart; }
|
bool hideOnStart() const { return m_hideOnStart; }
|
||||||
|
DeviceInfo *deviceInfo() const { return m_deviceInfo; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
bool debugMode;
|
bool debugMode;
|
||||||
@@ -197,34 +152,49 @@ public slots:
|
|||||||
void setNoiseControlMode(NoiseControlMode mode)
|
void setNoiseControlMode(NoiseControlMode mode)
|
||||||
{
|
{
|
||||||
LOG_INFO("Setting noise control mode to: " << mode);
|
LOG_INFO("Setting noise control mode to: " << mode);
|
||||||
if (m_noiseControlMode == mode)
|
|
||||||
{
|
|
||||||
LOG_INFO("Noise control mode is already " << mode);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
QByteArray packet = AirPodsPackets::NoiseControl::getPacketForMode(mode);
|
QByteArray packet = AirPodsPackets::NoiseControl::getPacketForMode(mode);
|
||||||
writePacketToSocket(packet, "Noise control mode packet written: ");
|
writePacketToSocket(packet, "Noise control mode packet written: ");
|
||||||
}
|
}
|
||||||
void setNoiseControlMode(int mode)
|
void setNoiseControlModeInt(int mode)
|
||||||
{
|
{
|
||||||
|
if (mode < 0 || mode > static_cast<int>(NoiseControlMode::Adaptive))
|
||||||
|
{
|
||||||
|
LOG_ERROR("Invalid noise control mode: " << mode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setNoiseControlMode(static_cast<NoiseControlMode>(mode));
|
setNoiseControlMode(static_cast<NoiseControlMode>(mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
void setConversationalAwareness(bool enabled)
|
void setConversationalAwareness(bool enabled)
|
||||||
{
|
{
|
||||||
if (m_conversationalAwareness == enabled)
|
|
||||||
{
|
|
||||||
LOG_INFO("Conversational awareness is already " << (enabled ? "enabled" : "disabled"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG_INFO("Setting conversational awareness to: " << (enabled ? "enabled" : "disabled"));
|
LOG_INFO("Setting conversational awareness to: " << (enabled ? "enabled" : "disabled"));
|
||||||
QByteArray packet = enabled ? AirPodsPackets::ConversationalAwareness::ENABLED
|
QByteArray packet = enabled ? AirPodsPackets::ConversationalAwareness::ENABLED
|
||||||
: AirPodsPackets::ConversationalAwareness::DISABLED;
|
: AirPodsPackets::ConversationalAwareness::DISABLED;
|
||||||
|
|
||||||
writePacketToSocket(packet, "Conversational awareness packet written: ");
|
writePacketToSocket(packet, "Conversational awareness packet written: ");
|
||||||
m_conversationalAwareness = enabled;
|
m_deviceInfo->setConversationalAwareness(enabled);
|
||||||
emit conversationalAwarenessChanged(enabled);
|
}
|
||||||
|
|
||||||
|
void setOneBudANCMode(bool enabled)
|
||||||
|
{
|
||||||
|
if (m_deviceInfo->oneBudANCMode() == enabled)
|
||||||
|
{
|
||||||
|
LOG_INFO("One Bud ANC mode is already " << (enabled ? "enabled" : "disabled"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_INFO("Setting One Bud ANC mode to: " << (enabled ? "enabled" : "disabled"));
|
||||||
|
QByteArray packet = enabled ? AirPodsPackets::OneBudANCMode::ENABLED
|
||||||
|
: AirPodsPackets::OneBudANCMode::DISABLED;
|
||||||
|
|
||||||
|
if (writePacketToSocket(packet, "One Bud ANC mode packet written: "))
|
||||||
|
{
|
||||||
|
m_deviceInfo->setOneBudANCMode(enabled);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LOG_ERROR("Failed to send One Bud ANC mode command: socket not open");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void setRetryAttempts(int attempts)
|
void setRetryAttempts(int attempts)
|
||||||
@@ -252,12 +222,11 @@ public slots:
|
|||||||
void setAdaptiveNoiseLevel(int level)
|
void setAdaptiveNoiseLevel(int level)
|
||||||
{
|
{
|
||||||
level = qBound(0, level, 100);
|
level = qBound(0, level, 100);
|
||||||
if (m_adaptiveNoiseLevel != level && adaptiveModeActive())
|
if (m_deviceInfo->adaptiveNoiseLevel() != level && m_deviceInfo->adaptiveModeActive())
|
||||||
{
|
{
|
||||||
m_adaptiveNoiseLevel = level;
|
|
||||||
QByteArray packet = AirPodsPackets::AdaptiveNoise::getPacket(level);
|
QByteArray packet = AirPodsPackets::AdaptiveNoise::getPacket(level);
|
||||||
writePacketToSocket(packet, "Adaptive noise level packet written: ");
|
writePacketToSocket(packet, "Adaptive noise level packet written: ");
|
||||||
emit adaptiveNoiseLevelChanged(level);
|
m_deviceInfo->setAdaptiveNoiseLevel(level);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +242,7 @@ public slots:
|
|||||||
LOG_WARN("Name is too long, must be 32 characters or less");
|
LOG_WARN("Name is too long, must be 32 characters or less");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newName == m_deviceName)
|
if (newName == m_deviceInfo->deviceName())
|
||||||
{
|
{
|
||||||
LOG_INFO("Name is already set to: " << newName);
|
LOG_INFO("Name is already set to: " << newName);
|
||||||
return;
|
return;
|
||||||
@@ -283,8 +252,7 @@ public slots:
|
|||||||
if (writePacketToSocket(packet, "Rename packet written: "))
|
if (writePacketToSocket(packet, "Rename packet written: "))
|
||||||
{
|
{
|
||||||
LOG_INFO("Sent rename command for new name: " << newName);
|
LOG_INFO("Sent rename command for new name: " << newName);
|
||||||
m_deviceName = newName;
|
m_deviceInfo->setDeviceName(newName);
|
||||||
emit deviceNameChanged(newName);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -410,45 +378,22 @@ private slots:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear the device name and model
|
// Clear the device name and model
|
||||||
m_deviceName.clear();
|
m_deviceInfo->reset();
|
||||||
connectedDeviceMacAddress.clear();
|
|
||||||
mediaController->setConnectedDeviceMacAddress(connectedDeviceMacAddress);
|
|
||||||
m_model = AirPodsModel::Unknown;
|
|
||||||
emit deviceNameChanged(m_deviceName);
|
|
||||||
emit modelChanged();
|
|
||||||
|
|
||||||
// Reset battery status
|
|
||||||
m_battery->reset();
|
|
||||||
m_batteryStatus.clear();
|
|
||||||
emit batteryStatusChanged(m_batteryStatus);
|
|
||||||
|
|
||||||
// Reset ear detection
|
|
||||||
m_earDetectionStatus.clear();
|
|
||||||
m_primaryInEar = false;
|
|
||||||
m_secoundaryInEar = false;
|
|
||||||
emit earDetectionStatusChanged(m_earDetectionStatus);
|
|
||||||
emit primaryChanged();
|
|
||||||
|
|
||||||
// Reset noise control mode
|
|
||||||
m_noiseControlMode = NoiseControlMode::Off;
|
|
||||||
emit noiseControlModeChanged(m_noiseControlMode);
|
|
||||||
|
|
||||||
mediaController->pause(); // Since the device is deconnected, we don't know if it was the active output device. Pause to be safe
|
|
||||||
emit airPodsStatusChanged();
|
|
||||||
|
|
||||||
// Show system notification
|
// Show system notification
|
||||||
trayManager->showNotification(
|
trayManager->showNotification(
|
||||||
tr("AirPods Disconnected"),
|
tr("AirPods Disconnected"),
|
||||||
tr("Your AirPods have been disconnected"));
|
tr("Your AirPods have been disconnected"));
|
||||||
|
trayManager->resetTrayIcon();
|
||||||
}
|
}
|
||||||
|
|
||||||
void bluezDeviceDisconnected(const QString &address, const QString &name)
|
void bluezDeviceDisconnected(const QString &address, const QString &name)
|
||||||
{
|
{
|
||||||
if (address == connectedDeviceMacAddress.replace("_", ":"))
|
if (address == m_deviceInfo->bluetoothAddress())
|
||||||
{
|
{
|
||||||
onDeviceDisconnected(QBluetoothAddress(address)); }
|
onDeviceDisconnected(QBluetoothAddress(address));
|
||||||
else {
|
} else {
|
||||||
LOG_WARN("Disconnected device does not match connected device: " << address << " != " << connectedDeviceMacAddress);
|
LOG_WARN("Disconnected device does not match connected device: " << address << " != " << m_deviceInfo->bluetoothAddress());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,43 +435,18 @@ private slots:
|
|||||||
return str;
|
return str;
|
||||||
};
|
};
|
||||||
|
|
||||||
m_deviceName = extractString();
|
m_deviceInfo->setDeviceName(extractString());
|
||||||
QString modelNumber = extractString();
|
m_deviceInfo->setModelNumber(extractString());
|
||||||
QString manufacturer = extractString();
|
m_deviceInfo->setManufacturer(extractString());
|
||||||
QString hardwareVersion = extractString();
|
|
||||||
QString firmwareVersion = extractString();
|
|
||||||
QString firmwareVersion2 = extractString();
|
|
||||||
QString softwareVersion = extractString();
|
|
||||||
QString appIdentifier = extractString();
|
|
||||||
QString serialNumber1 = extractString();
|
|
||||||
QString serialNumber2 = extractString();
|
|
||||||
QString unknownNumeric = extractString();
|
|
||||||
QString unknownHash = extractString();
|
|
||||||
QString trailingByte = extractString();
|
|
||||||
|
|
||||||
m_model = parseModelNumber(modelNumber);
|
|
||||||
|
|
||||||
|
m_deviceInfo->setModel(parseModelNumber(m_deviceInfo->modelNumber()));
|
||||||
emit modelChanged();
|
emit modelChanged();
|
||||||
m_model = parseModelNumber(modelNumber);
|
|
||||||
|
|
||||||
emit modelChanged();
|
|
||||||
emit deviceNameChanged(m_deviceName);
|
|
||||||
|
|
||||||
// Log extracted metadata
|
// Log extracted metadata
|
||||||
LOG_INFO("Parsed AirPods metadata:");
|
LOG_INFO("Parsed AirPods metadata:");
|
||||||
LOG_INFO("Device Name: " << m_deviceName);
|
LOG_INFO("Device Name: " << m_deviceInfo->deviceName());
|
||||||
LOG_INFO("Model Number: " << modelNumber);
|
LOG_INFO("Model Number: " << m_deviceInfo->modelNumber());
|
||||||
LOG_INFO("Manufacturer: " << manufacturer);
|
LOG_INFO("Manufacturer: " << m_deviceInfo->manufacturer());
|
||||||
LOG_INFO("Hardware Version: " << hardwareVersion);
|
|
||||||
LOG_INFO("Firmware Version: " << firmwareVersion);
|
|
||||||
LOG_INFO("Firmware Version2: " << firmwareVersion2);
|
|
||||||
LOG_INFO("Software Version: " << softwareVersion);
|
|
||||||
LOG_INFO("App Identifier: " << appIdentifier);
|
|
||||||
LOG_INFO("Serial Number 1: " << serialNumber1);
|
|
||||||
LOG_INFO("Serial Number 2: " << serialNumber2);
|
|
||||||
LOG_INFO("Unknown Numeric: " << unknownNumeric);
|
|
||||||
LOG_INFO("Unknown Hash: " << unknownHash);
|
|
||||||
LOG_INFO("Trailing Byte: " << trailingByte);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
QString getEarStatus(char value)
|
QString getEarStatus(char value)
|
||||||
@@ -592,7 +512,7 @@ private slots:
|
|||||||
this, handleError);
|
this, handleError);
|
||||||
|
|
||||||
localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"));
|
localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"));
|
||||||
connectedDeviceMacAddress = device.address().toString().replace(":", "_");
|
m_deviceInfo->setBluetoothAddress(device.address().toString());
|
||||||
notifyAndroidDevice();
|
notifyAndroidDevice();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,7 +529,7 @@ private slots:
|
|||||||
writePacketToSocket(AirPodsPackets::Connection::REQUEST_NOTIFICATIONS, "Request notifications packet written: ");
|
writePacketToSocket(AirPodsPackets::Connection::REQUEST_NOTIFICATIONS, "Request notifications packet written: ");
|
||||||
|
|
||||||
QTimer::singleShot(2000, this, [this]() {
|
QTimer::singleShot(2000, this, [this]() {
|
||||||
if (m_batteryStatus.isEmpty()) {
|
if (m_deviceInfo->batteryStatus().isEmpty()) {
|
||||||
writePacketToSocket(AirPodsPackets::Connection::REQUEST_NOTIFICATIONS, "Request notifications packet written: ");
|
writePacketToSocket(AirPodsPackets::Connection::REQUEST_NOTIFICATIONS, "Request notifications packet written: ");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -622,34 +542,26 @@ private slots:
|
|||||||
LOG_INFO("MagicAccIRK: " << keys.magicAccIRK.toHex());
|
LOG_INFO("MagicAccIRK: " << keys.magicAccIRK.toHex());
|
||||||
LOG_INFO("MagicAccEncKey: " << keys.magicAccEncKey.toHex());
|
LOG_INFO("MagicAccEncKey: " << keys.magicAccEncKey.toHex());
|
||||||
|
|
||||||
// Store the keys for later use if needed
|
// Store the keys
|
||||||
m_magicAccIRK = keys.magicAccIRK;
|
m_deviceInfo->setMagicAccIRK(keys.magicAccIRK);
|
||||||
m_magicAccEncKey = keys.magicAccEncKey;
|
m_deviceInfo->setMagicAccEncKey(keys.magicAccEncKey);
|
||||||
}
|
}
|
||||||
// Get CA state
|
// Get CA state
|
||||||
else if (data.startsWith(AirPodsPackets::ConversationalAwareness::HEADER)) {
|
else if (data.startsWith(AirPodsPackets::ConversationalAwareness::HEADER)) {
|
||||||
auto result = AirPodsPackets::ConversationalAwareness::parseCAState(data);
|
if (auto result = AirPodsPackets::ConversationalAwareness::parseState(data))
|
||||||
if (result.has_value()) {
|
{
|
||||||
m_conversationalAwareness = result.value();
|
m_deviceInfo->setConversationalAwareness(result.value());
|
||||||
LOG_INFO("Conversational awareness state received: " << m_conversationalAwareness);
|
LOG_INFO("Conversational awareness state received: " << m_deviceInfo->conversationalAwareness());
|
||||||
emit conversationalAwarenessChanged(m_conversationalAwareness);
|
|
||||||
} else {
|
|
||||||
LOG_ERROR("Failed to parse conversational awareness state");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Noise Control Mode
|
// Noise Control Mode
|
||||||
else if (data.size() == 11 && data.startsWith(AirPodsPackets::NoiseControl::HEADER))
|
else if (data.size() == 11 && data.startsWith(AirPodsPackets::NoiseControl::HEADER))
|
||||||
{
|
{
|
||||||
quint8 rawMode = data[7] - 1; // Offset still needed due to protocol
|
if (auto value = AirPodsPackets::NoiseControl::parseMode(data))
|
||||||
if (rawMode >= (int)NoiseControlMode::MinValue && rawMode <= (int)NoiseControlMode::MaxValue)
|
|
||||||
{
|
{
|
||||||
m_noiseControlMode = static_cast<NoiseControlMode>(rawMode);
|
LOG_INFO("Received noise control mode: " << value.value());
|
||||||
LOG_INFO("Noise control mode: " << rawMode);
|
m_deviceInfo->setNoiseControlMode(value.value());
|
||||||
emit noiseControlModeChanged(m_noiseControlMode);
|
LOG_INFO("Noise control mode received: " << m_deviceInfo->noiseControlMode());
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
LOG_ERROR("Invalid noise control mode value received: " << rawMode);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Ear Detection
|
// Ear Detection
|
||||||
@@ -657,28 +569,25 @@ private slots:
|
|||||||
{
|
{
|
||||||
char primary = data[6];
|
char primary = data[6];
|
||||||
char secondary = data[7];
|
char secondary = data[7];
|
||||||
m_primaryInEar = data[6] == 0x00;
|
m_deviceInfo->setPrimaryInEar(data[6] == 0x00);
|
||||||
m_secoundaryInEar = data[7] == 0x00;
|
m_deviceInfo->setSecondaryInEar(data[7] == 0x00);
|
||||||
m_earDetectionStatus = QString("Primary: %1, Secondary: %2")
|
m_deviceInfo->setEarDetectionStatus(QString("Primary: %1, Secondary: %2")
|
||||||
.arg(getEarStatus(primary), getEarStatus(secondary));
|
.arg(getEarStatus(primary), getEarStatus(secondary)));
|
||||||
LOG_INFO("Ear detection status: " << m_earDetectionStatus);
|
LOG_INFO("Ear detection status: " << m_deviceInfo->earDetectionStatus());
|
||||||
emit earDetectionStatusChanged(m_earDetectionStatus);
|
|
||||||
emit primaryChanged();
|
|
||||||
}
|
}
|
||||||
// Battery Status
|
// Battery Status
|
||||||
else if (data.size() == 22 && data.startsWith(AirPodsPackets::Parse::BATTERY_STATUS))
|
else if (data.size() == 22 && data.startsWith(AirPodsPackets::Parse::BATTERY_STATUS))
|
||||||
{
|
{
|
||||||
m_battery->parsePacket(data);
|
m_deviceInfo->getBattery()->parsePacket(data);
|
||||||
|
|
||||||
int leftLevel = m_battery->getState(Battery::Component::Left).level;
|
int leftLevel = m_deviceInfo->getBattery()->getState(Battery::Component::Left).level;
|
||||||
int rightLevel = m_battery->getState(Battery::Component::Right).level;
|
int rightLevel = m_deviceInfo->getBattery()->getState(Battery::Component::Right).level;
|
||||||
int caseLevel = m_battery->getState(Battery::Component::Case).level;
|
int caseLevel = m_deviceInfo->getBattery()->getState(Battery::Component::Case).level;
|
||||||
m_batteryStatus = QString("Left: %1%, Right: %2%, Case: %3%")
|
m_deviceInfo->setBatteryStatus(QString("Left: %1%, Right: %2%, Case: %3%")
|
||||||
.arg(leftLevel)
|
.arg(leftLevel)
|
||||||
.arg(rightLevel)
|
.arg(rightLevel)
|
||||||
.arg(caseLevel);
|
.arg(caseLevel));
|
||||||
LOG_INFO("Battery status: " << m_batteryStatus);
|
LOG_INFO("Battery status: " << m_deviceInfo->batteryStatus());
|
||||||
emit batteryStatusChanged(m_batteryStatus);
|
|
||||||
}
|
}
|
||||||
// Conversational Awareness Data
|
// Conversational Awareness Data
|
||||||
else if (data.size() == 10 && data.startsWith(AirPodsPackets::ConversationalAwareness::DATA_HEADER))
|
else if (data.size() == 10 && data.startsWith(AirPodsPackets::ConversationalAwareness::DATA_HEADER))
|
||||||
@@ -690,13 +599,20 @@ private slots:
|
|||||||
{
|
{
|
||||||
parseMetadata(data);
|
parseMetadata(data);
|
||||||
initiateMagicPairing();
|
initiateMagicPairing();
|
||||||
mediaController->setConnectedDeviceMacAddress(connectedDeviceMacAddress);
|
mediaController->setConnectedDeviceMacAddress(m_deviceInfo->bluetoothAddress().replace(":", "_"));
|
||||||
if (isLeftPodInEar() || isRightPodInEar()) // AirPods get added as output device only after this
|
if (m_deviceInfo->oneOrMorePodsInEar()) // AirPods get added as output device only after this
|
||||||
{
|
{
|
||||||
mediaController->activateA2dpProfile();
|
mediaController->activateA2dpProfile();
|
||||||
}
|
}
|
||||||
emit airPodsStatusChanged();
|
emit airPodsStatusChanged();
|
||||||
}
|
}
|
||||||
|
else if (data.startsWith(AirPodsPackets::OneBudANCMode::HEADER)) {
|
||||||
|
if (auto value = AirPodsPackets::OneBudANCMode::parseState(data))
|
||||||
|
{
|
||||||
|
m_deviceInfo->setOneBudANCMode(value.value());
|
||||||
|
LOG_INFO("One Bud ANC mode received: " << m_deviceInfo->oneBudANCMode());
|
||||||
|
}
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
LOG_DEBUG("Unrecognized packet format: " << data.toHex());
|
LOG_DEBUG("Unrecognized packet format: " << data.toHex());
|
||||||
@@ -792,7 +708,7 @@ private slots:
|
|||||||
socket->close();
|
socket->close();
|
||||||
LOG_INFO("Disconnected from AirPods");
|
LOG_INFO("Disconnected from AirPods");
|
||||||
QProcess process;
|
QProcess process;
|
||||||
process.start("bluetoothctl", QStringList() << "disconnect" << connectedDeviceMacAddress.replace("_", ":"));
|
process.start("bluetoothctl", QStringList() << "disconnect" << m_deviceInfo->bluetoothAddress());
|
||||||
process.waitForFinished();
|
process.waitForFinished();
|
||||||
QString output = process.readAllStandardOutput().trimmed();
|
QString output = process.readAllStandardOutput().trimmed();
|
||||||
LOG_INFO("Bluetoothctl output: " << output);
|
LOG_INFO("Bluetoothctl output: " << output);
|
||||||
@@ -817,14 +733,14 @@ private slots:
|
|||||||
QMetaObject::invokeMethod(this, "handlePhonePacket", Qt::QueuedConnection, Q_ARG(QByteArray, data));
|
QMetaObject::invokeMethod(this, "handlePhonePacket", Qt::QueuedConnection, Q_ARG(QByteArray, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
public:
|
public:
|
||||||
void handleMediaStateChange(MediaController::MediaState state) {
|
void handleMediaStateChange(MediaController::MediaState state) {
|
||||||
if (state == MediaController::MediaState::Playing) {
|
if (state == MediaController::MediaState::Playing) {
|
||||||
LOG_INFO("Media started playing, sending disconnect request to Android and taking over audio");
|
LOG_INFO("Media started playing, sending disconnect request to Android and taking over audio");
|
||||||
sendDisconnectRequestToAndroid();
|
sendDisconnectRequestToAndroid();
|
||||||
connectToAirPods(true);
|
connectToAirPods(true);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void sendDisconnectRequestToAndroid()
|
void sendDisconnectRequestToAndroid()
|
||||||
{
|
{
|
||||||
@@ -854,13 +770,13 @@ private slots:
|
|||||||
if (force) {
|
if (force) {
|
||||||
LOG_INFO("Forcing connection to AirPods");
|
LOG_INFO("Forcing connection to AirPods");
|
||||||
QProcess process;
|
QProcess process;
|
||||||
process.start("bluetoothctl", QStringList() << "connect" << connectedDeviceMacAddress.replace("_", ":"));
|
process.start("bluetoothctl", QStringList() << "connect" << m_deviceInfo->bluetoothAddress());
|
||||||
process.waitForFinished();
|
process.waitForFinished();
|
||||||
QString output = process.readAllStandardOutput().trimmed();
|
QString output = process.readAllStandardOutput().trimmed();
|
||||||
LOG_INFO("Bluetoothctl output: " << output);
|
LOG_INFO("Bluetoothctl output: " << output);
|
||||||
if (output.contains("Connection successful")) {
|
if (output.contains("Connection successful")) {
|
||||||
LOG_INFO("Connection successful, proceeding with L2CAP connection");
|
LOG_INFO("Connection successful, proceeding with L2CAP connection");
|
||||||
QBluetoothAddress btAddress(connectedDeviceMacAddress.replace("_", ":"));
|
QBluetoothAddress btAddress(m_deviceInfo->bluetoothAddress());
|
||||||
forceL2capConnection(btAddress);
|
forceL2capConnection(btAddress);
|
||||||
} else {
|
} else {
|
||||||
LOG_ERROR("Connection failed, cannot proceed with L2CAP connection");
|
LOG_ERROR("Connection failed, cannot proceed with L2CAP connection");
|
||||||
@@ -926,11 +842,11 @@ signals:
|
|||||||
void crossDeviceEnabledChanged(bool enabled);
|
void crossDeviceEnabledChanged(bool enabled);
|
||||||
void notificationsEnabledChanged(bool enabled);
|
void notificationsEnabledChanged(bool enabled);
|
||||||
void retryAttemptsChanged(int attempts);
|
void retryAttemptsChanged(int attempts);
|
||||||
|
void oneBudANCModeChanged(bool enabled);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QBluetoothSocket *socket = nullptr;
|
QBluetoothSocket *socket = nullptr;
|
||||||
QBluetoothSocket *phoneSocket = nullptr;
|
QBluetoothSocket *phoneSocket = nullptr;
|
||||||
QString connectedDeviceMacAddress;
|
|
||||||
QByteArray lastBatteryStatus;
|
QByteArray lastBatteryStatus;
|
||||||
QByteArray lastEarDetectionStatus;
|
QByteArray lastEarDetectionStatus;
|
||||||
MediaController* mediaController;
|
MediaController* mediaController;
|
||||||
@@ -940,19 +856,7 @@ private:
|
|||||||
AutoStartManager *m_autoStartManager;
|
AutoStartManager *m_autoStartManager;
|
||||||
int m_retryAttempts = 3;
|
int m_retryAttempts = 3;
|
||||||
bool m_hideOnStart = false;
|
bool m_hideOnStart = false;
|
||||||
|
DeviceInfo *m_deviceInfo;
|
||||||
QString m_batteryStatus;
|
|
||||||
QString m_earDetectionStatus;
|
|
||||||
NoiseControlMode m_noiseControlMode = NoiseControlMode::Off;
|
|
||||||
bool m_conversationalAwareness = false;
|
|
||||||
int m_adaptiveNoiseLevel = 50;
|
|
||||||
QString m_deviceName;
|
|
||||||
Battery *m_battery;
|
|
||||||
AirPodsModel m_model = AirPodsModel::Unknown;
|
|
||||||
bool m_primaryInEar = false;
|
|
||||||
bool m_secoundaryInEar = false;
|
|
||||||
QByteArray m_magicAccIRK;
|
|
||||||
QByteArray m_magicAccEncKey;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
int main(int argc, char *argv[]) {
|
int main(int argc, char *argv[]) {
|
||||||
@@ -997,6 +901,7 @@ int main(int argc, char *argv[]) {
|
|||||||
|
|
||||||
QQmlApplicationEngine engine;
|
QQmlApplicationEngine engine;
|
||||||
qmlRegisterType<Battery>("me.kavishdevar.Battery", 1, 0, "Battery");
|
qmlRegisterType<Battery>("me.kavishdevar.Battery", 1, 0, "Battery");
|
||||||
|
qmlRegisterType<DeviceInfo>("me.kavishdevar.DeviceInfo", 1, 0, "DeviceInfo");
|
||||||
AirPodsTrayApp *trayApp = new AirPodsTrayApp(debugMode, hideOnStart, &engine);
|
AirPodsTrayApp *trayApp = new AirPodsTrayApp(debugMode, hideOnStart, &engine);
|
||||||
engine.rootContext()->setContextProperty("airPodsTrayApp", trayApp);
|
engine.rootContext()->setContextProperty("airPodsTrayApp", trayApp);
|
||||||
trayApp->loadMainModule();
|
trayApp->loadMainModule();
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ public:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void resetTrayIcon()
|
||||||
|
{
|
||||||
|
trayIcon->setIcon(QIcon(":/icons/assets/airpods.png"));
|
||||||
|
trayIcon->setToolTip("");
|
||||||
|
}
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void notificationsEnabledChanged(bool enabled);
|
void notificationsEnabledChanged(bool enabled);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"version": "v0.1.0-rc.4",
|
||||||
|
"versionCode": 3,
|
||||||
|
"zipUrl": "https://github.com/kavishdevar/librepods/releases/download/v0.1.0-rc.4/LibrePods-v0.1.0-rc.4.zip",
|
||||||
|
"changelog": "https://raw.githubusercontent.com/kavishdevar/librepods/main/CHANGELOG.md"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user