68 Commits

Author SHA1 Message Date
Kavish Devar
d9469c2d62 android: not use relative paths for executing commands
i hope it's the same across all skins
2025-10-01 01:34:30 +05:30
Kavish Devar
b799cd1710 android: add option to change camera app id 2025-10-01 01:24:28 +05:30
Kavish Devar
c7dc545ed4 android: add camera control, finally
i got too lazy to find out how to listen to app openings earlier, wasn't too hard
2025-10-01 01:10:37 +05:30
Kavish Devar
342745ee2e android: add accessiblity service for camera control 2025-09-30 23:53:29 +05:30
Kavish Devar
8b49440d6b android: update styled slider thumb 2025-09-30 11:33:46 +05:30
Kavish Devar
993f022087 android: ui tweaks 2025-09-30 11:07:16 +05:30
Kavish Devar
650b128d5d docs: change section title in control cmd doc
Updated section title from 'Control Commands' to 'Identifiers and details'.
2025-09-29 17:18:37 +05:30
Kavish Devar
395feabb13 docs: new control cmds '25 (again) 2025-09-29 16:55:01 +05:30
Kavish Devar
6914dabe59 docs: app3 compatibility 2025-09-29 14:34:27 +05:30
Kavish Devar
78ae31c898 docs: update demo video position 2025-09-29 01:47:44 +05:30
Kavish Devar
b43e5f7526 docs: add new screenshots for android 2025-09-29 01:45:29 +05:30
Kavish Devar
9d60dc3682 docs: add demo video 2025-09-29 01:31:31 +05:30
Kavish Devar
c2ebbef14b docs: update README with new features 2025-09-29 01:00:41 +05:30
Kavish Devar
3a388da48e android: hide media assist, not implemented 2025-09-29 00:22:46 +05:30
Kavish Devar
bdb93efec6 android: prevent hearing aid turning off itself 2025-09-29 00:22:01 +05:30
Kavish Devar
504e70371b android: bring back original confirmation dialog
too lazy to fix/implement properly the glassy one
2025-09-29 00:17:02 +05:30
Kavish Devar
48b715af68 android: fix text color in troubshooting button and pressandhold settings 2025-09-28 18:13:52 +05:30
Kavish Devar
5ec300aad8 android: use lazycolumn in airpods settings for better performance and navigation transitions 2025-09-28 17:10:49 +05:30
Kavish Devar
e158ba1b27 android: don't crash if att not available 2025-09-28 17:01:42 +05:30
Kavish Devar
147e511659 android: show head gestures status in the navigation button 2025-09-28 16:03:55 +05:30
Kavish Devar
e9da7a2a50 android: fix crash in head gestures screen 2025-09-28 16:01:56 +05:30
Kavish Devar
1076218ccc android: add A16's new bluetooth identifier for log collection
just why...
2025-09-28 15:48:36 +05:30
Kavish Devar
55cb69f880 android: remove fade from transition 2025-09-28 15:41:43 +05:30
Kavish Devar
5bc1dd2e1d android: fix switch styling 2025-09-28 13:44:00 +05:30
Kavish Devar
1152f45a6c remove bleonly mode, use CAPod instead 2025-09-28 12:30:09 +05:30
Kavish Devar
3f582b8fcf remove bleonly mode, use CAPod instead 2025-09-28 12:27:42 +05:30
Kavish Devar
08738a1293 android: liquidglass, maybe?
the switch and icon button took quite a while. i forgot the order of modifiers matters!
2025-09-28 12:27:05 +05:30
Kavish Devar
8dc7a97c43 android: update usages for toggle 2025-09-26 14:03:47 +05:30
Kavish Devar
d9795c4d28 merge main into multi-device-and-accessibility 2025-09-26 03:38:29 +05:30
Kavish Devar
56307c98e3 android: revert accidental capitalization on toggle label 2025-09-26 03:27:32 +05:30
Kavish Devar
ab55096051 android: move padding to StyledScaffold's content
because haze needs it
2025-09-26 03:26:25 +05:30
Kavish Devar
86a6a28dc1 android: a very big commit
refactoring ui, mostly
2025-09-26 03:22:01 +05:30
Kavish Devar
7e5ee6726f android: small ui tweaks 2025-09-23 23:58:06 +05:30
Kavish Devar
5f08edd49c android: remove unused strings 2025-09-23 11:14:31 +05:30
Kavish Devar
29a35ceebe android: remove customdeviceactivity from manifest 2025-09-23 11:03:55 +05:30
Kavish Devar
173e06c5e7 android: fix hearing aid parsing 2025-09-23 02:53:10 +05:30
Kavish Devar
26de42243f android: little more liquid glass 2025-09-23 01:20:41 +05:30
Kavish Devar
8760757b76 android: improve liquid glass sliders 2025-09-23 00:27:39 +05:30
Kavish Devar
4bc76de750 android: liquidglass sliders 2025-09-23 00:03:03 +05:30
Kavish Devar
4751f70579 android: add hearing aid adjustments 2025-09-22 14:54:54 +05:30
Kavish Devar
ce229bec6e android: add media assist options in hearing aid
ui only
2025-09-22 10:44:48 +05:30
Kavish Devar
fe69082e11 android: add ui for hearing stuff
mostly copied from the transparency settings, which are now updated to match ios <26 ui
2025-09-22 00:59:39 +05:30
Kavish Devar
3ace0e1831 android: move attmanager to service to avoid trying to connect multiple times 2025-09-21 22:15:44 +05:30
Kavish Devar
ecfdc05dbf android: improve dropdowns
ai generated
2025-09-21 01:34:42 +05:30
Kavish Devar
5aeb47b835 android: add microphone setting
also, un-hardcoded strings, and updated text sizes
2025-09-20 22:55:35 +05:30
Kavish Devar
3cca786cf9 docs: a few more control cmds 2025-09-20 01:45:06 +05:30
Kavish Devar
6fd3cc1eb0 android: a small ui fix 2025-09-20 01:44:36 +05:30
Kavish Devar
bb69a74a8e android: add a few options
ik not the right branch/pr but, eh, i am not merging this hook until i test further, and if i don't merge, conflicts, a lot of 'em
2025-09-20 01:43:24 +05:30
Kavish Devar
71a1f834cb android: add delay before starting head tracking again 2025-09-19 23:38:38 +05:30
Kavish Devar
63baa153da android: fix text color in selectors 2025-09-19 18:16:02 +05:30
Kavish Devar
5eff5b9d77 android: update eq sliders style 2025-09-19 18:12:56 +05:30
Kavish Devar
b5103a28e7 android: remove unused composable 2025-09-19 18:10:00 +05:30
Kavish Devar
3699ee6bee android: fix track color in tone volume 2025-09-19 18:08:31 +05:30
Kavish Devar
032b94e3ae android: use device name sent by the connected device in island 2025-09-19 16:27:32 +05:30
Kavish Devar
5c9beeb26d android: add header to ATTManager 2025-09-19 14:29:55 +05:30
Kavish Devar
65d074efe0 android: bring back some accessiblity settings and add listeners for all config 2025-09-19 13:11:04 +05:30
Kavish Devar
93328d281e android: fix balance NaN error when amplification L/R is both zero 2025-09-18 13:56:06 +05:30
Kavish Devar
792629acb9 docs: add 'has ownership' control cmd 2025-09-15 20:01:46 +05:30
Kavish Devar
5bef8c384e android: add toggle for DID hook 2025-09-15 19:59:43 +05:30
Kavish Devar
9e6d97198b android: add EQ settings for phone and media 2025-09-15 11:49:00 +05:30
Kavish Devar
c53356f77e android: implement the accessiblity settings page 2025-09-11 12:21:23 +05:30
Kavish Devar
fa00620b5b android: clean up a lot of stuff 2025-09-10 12:38:27 +05:30
Kavish Devar
aecbb066b5 android: clean up main service and remove minimum API on head gestures 2025-09-10 11:32:48 +05:30
Kavish Devar
0e9aadd672 android: clean up a bit of AI gen'd code 2025-09-10 11:24:51 +05:30
Kavish Devar
df9f443173 android: add basic multidevice capabilities
use at your own risk, may or may not work
2025-09-10 10:03:52 +05:30
Kavish Devar
d1bf5407c9 android: don't start service every time MainActivity is launched 2025-09-09 16:33:07 +05:30
Kavish Devar
4ee9b2732f docs: update transparency mode format 2025-09-08 00:24:15 +05:30
Kavish Devar
86551be86b android: add accessibility stuff
adds option for customizing transparency mode, amplification, tone, etc.
2025-09-08 00:23:45 +05:30
139 changed files with 1194 additions and 20444 deletions

View File

@@ -4,8 +4,6 @@ on:
push:
branches:
- '*'
paths:
- 'android/**'
workflow_dispatch:
inputs:
release:

View File

@@ -1,87 +0,0 @@
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 }}

View File

@@ -1,10 +1,9 @@
name: Build LibrePods Linux
on:
workflow_dispatch:
# push:
# branches:
# - '*'
push:
branches:
- '*'
jobs:
build-linux:
@@ -34,4 +33,4 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: librepods-linux
path: linux/build/librepods
path: linux/build/librepods

View File

@@ -15,11 +15,11 @@ LibrePods unlocks Apple's exclusive AirPods features on non-Apple devices. Get a
## Device Compatibility
| Status | Device | Features |
| ------ | --------------------- | ---------------------------------------------------------- |
| ✅ | AirPods Pro (2nd Gen) | Fully supported and tested |
| ✅ | AirPods Pro (3rd Gen) | Fully supported (except heartrate monitoring) |
| ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work |
| Status | Device | Features |
|--------|--------|----------|
| ✅ | AirPods Pro (2nd Gen) | Fully supported and tested |
| ✅ | AirPods Pro (3rd Gen) | Fully supported (except heartrate monitoring) |
| ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work |
Most features should work with any AirPods. Currently, I've only got AirPods Pro 2 to test with.
@@ -62,13 +62,13 @@ For installation and detailed info, see the [Linux README](/linux/README.md).
#### Screenshots
| | | |
| -------------------------------------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------- |
| ![Settings 1](/android/imgs/settings-1.png) | ![Settings 2](/android/imgs/settings-2.png) | ![Debug Screen](/android/imgs/debug.png) |
| ![Battery Notification and QS Tile for NC Mode](/android/imgs/notification-and-qs.png) | ![Popup](/android/imgs/popup.png) | ![Head Tracking and Gestures](/android/imgs/head-tracking-and-gestures.png) |
| ![Long Press Configuration](/android/imgs/long-press.png) | ![Widget](/android/imgs/widget.png) | ![Customizations 1](/android/imgs/customizations-1.png) |
| ![Customizations 2](/android/imgs/customizations-2.png) | ![accessibility](/android/imgs/accessibility.png) | ![transparency](/android/imgs/transparency.png) |
| ![hearing-aid](/android/imgs/hearing-aid.png) | ![hearing-test](/android/imgs/hearing-test.png) | ![hearing-aid-adjustments](/android/imgs/hearing-aid-adjustments.png) |
| | | |
|-------------------|-------------------|-------------------|
| ![Settings 1](/android/imgs/settings-1.png) | ![Settings 2](/android/imgs/settings-2.png) | ![Debug Screen](/android/imgs/debug.png) |
| ![Battery Notification and QS Tile for NC Mode](/android/imgs/notification-and-qs.png) | ![Popup](/android/imgs/popup.png) | ![Head Tracking and Gestures](/android/imgs/head-tracking-and-gestures.png) |
| ![Long Press Configuration](/android/imgs/long-press.png) | ![Widget](/android/imgs/widget.png) | ![Customizations 1](/android/imgs/customizations-1.png) |
| ![Customizations 2](/android/imgs/customizations-2.png) | ![accessibility](/android/imgs/accessibility.png) |![transparency](/android/imgs/transparency.png) |
|![hearing-aid](/android/imgs/hearing-aid.png) |![hearing-aid-adjustments](/android/imgs/hearing-aid-adjustments.png) | |
here's a very unprofessional demo video
@@ -78,12 +78,10 @@ https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
#### Root Requirement
> [!CAUTION]
> **You must have a rooted device with Xposed to use LibrePods on Android.** This is due to a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238). Please upvote the issue by clicking the '+1' icon on the IssueTracker page.
> **You must have a rooted device to use LibrePods on Android.** This is due to a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238). Please upvote the issue by clicking the '+1' icon on the IssueTracker page.
>
> There are **no exceptions** to the root requirement until Google merges the fix.
Until then, you must xposed. I used to provide a non-xposed method too, where the module used overlayfs to replace the bluetooth library with a locally patched one, but that was broken due to how various devices handled overlayfs and a patched library. With xposed, you can also enable the DID hook enabling a few extra features.
## Bluetooth DID (Device Identification) Hook
Turns out, if you change the manufacturerid to that of Apple, you get access to several special features!
@@ -96,20 +94,50 @@ Upto two devices can be simultaneously connected to AirPods, for audio and contr
Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured.
All hearing aid customizations can be done from Android, including setting the audiogram result. The app doesn't provide a way to take a hearing test because it requires much more precision. It is much better to use an already available audiogram result.
The hearing aid feature can now also be used. Currently it can only be used to adjust the settings, not actually take a hearing test because it requires much more precision. It is much better to use an already available audiogram result.
To enable these features, enable App Settings -> `act as Apple Device`.
#### Installation Methods
##### Method 1: Xposed Module (Recommended)
This method is less intrusive and should be tried first:
1. Install LSPosed, or another Xposed provider on your rooted device
2. Download the LibrePods app from the releases section, and install it.
3. Enable the Xposed module for the bluetooth app in your Xposed manager.
4. Disable unmount modules for the Bluetooth app if enabled.
5. Follow the instructions in the app to set up the module.
6. Open the app and connect your AirPods
##### Method 2: Root Module (Backup Option)
If the Xposed method doesn't work for you:
1. Download the `btl2capfix.zip` module from the releases section
2. Install it using your preferred root manager (KernelSU, Apatch, or Magisk).
3. Disable Unmount modules for the Bluetooth aop if enabled.
4. Reboot your device
5. Connect your AirPods
##### Method 3: Patching it yourself
If you prefer to patch the Bluetooth stack yourself, follow these steps:
1. Look for the library in use by running `lsof | grep libbluetooth`
2. Find the library path (e.g., `/system/lib64/libbluetooth_jni.so`)
3. Find the `l2c_fcr_chk_chan_modes` function in the library
4. Patch the function to always return `1` (true)
5. Repack the library and push it back to the device. You can do this by creating a root module yourself.
6. Reboot your device
If you're unfamiliar with these steps, search for tutorials online or ask in Android rooting communities.
#### A few notes
- Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, louds sounds are not reduced.
- Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, louds sounds are not reduced!
- If you have take both AirPods out, the app will automatically switch to the phone speaker. But, Android might keep on trying to connect to the AirPods because the phone is still connected to them, just the A2DP profile is not connected. The app tries to disconnect the A2DP profile as soon as it detects that Android has connected again if they're not in the ear.
- When renaming your AirPods through the app, you'll need to re-pair them with your phone for the name change to take effect. This is a limitation of how Bluetooth device naming works on Android.
- If you want the AirPods icon and battery status to show in Android Settings app, install the app as a system app by using the root module.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=kavishdevar/librepods&type=Date)](https://star-history.com/#kavishdevar/librepods&Date)

View File

@@ -2,20 +2,19 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.aboutLibraries)
id("kotlin-parcelize")
}
android {
namespace = "me.kavishdevar.librepods"
compileSdk = 36
compileSdk = 35
defaultConfig {
applicationId = "me.kavishdevar.librepods"
minSdk = 28
targetSdk = 36
versionCode = 8
versionName = "0.2.0"
targetSdk = 35
versionCode = 7
versionName = "0.1.0-rc.4"
}
buildTypes {
@@ -44,11 +43,6 @@ android {
version = "3.22.1"
}
}
sourceSets {
getByName("main") {
res.srcDirs("src/main/res", "src/main/res-apple")
}
}
}
dependencies {
@@ -71,19 +65,9 @@ dependencies {
implementation(libs.androidx.compose.ui)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.foundation.layout)
implementation(libs.aboutlibraries)
implementation(libs.aboutlibraries.compose.m3)
// compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
// implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.aar"))))
compileOnly(files("libs/libxposed-api-100.aar"))
debugImplementation(files("libs/backdrop-debug.aar"))
releaseImplementation(files("libs/backdrop-release.aar"))
}
aboutLibraries {
export{
prettyPrint = true
excludeFields = listOf("generated")
outputFile = file("src/main/res/raw/aboutlibraries.json")
}
}

View File

@@ -35,6 +35,8 @@
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.LOCAL_MAC_ADDRESS"
tools:ignore="ProtectedPermissions" />
<application
android:allowBackup="true"
@@ -60,7 +62,6 @@
android:name="android.appwidget.provider"
android:resource="@xml/noise_control_widget_info" />
</receiver>
<receiver
android:name=".widgets.BatteryWidget"
android:exported="false">

View File

@@ -27,6 +27,7 @@ import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.Intent
import android.content.ServiceConnection
import android.content.SharedPreferences
import android.net.Uri
import android.os.Build
import android.os.Bundle
@@ -37,16 +38,11 @@ import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.Canvas
@@ -91,8 +87,6 @@ import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
@@ -110,10 +104,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen
@@ -124,17 +115,14 @@ import me.kavishdevar.librepods.screens.DebugScreen
import me.kavishdevar.librepods.screens.HeadTrackingScreen
import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.screens.HearingAidScreen
import me.kavishdevar.librepods.screens.HearingProtectionScreen
import me.kavishdevar.librepods.screens.LongPress
import me.kavishdevar.librepods.screens.Onboarding
import me.kavishdevar.librepods.screens.OpenSourceLicensesScreen
import me.kavishdevar.librepods.screens.RenameScreen
import me.kavishdevar.librepods.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.screens.TroubleshootingScreen
import me.kavishdevar.librepods.screens.UpdateHearingTestScreen
import me.kavishdevar.librepods.screens.VersionScreen
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.CrossDevice
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -312,139 +300,104 @@ fun Main() {
val navController = rememberNavController()
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
val isAvailableChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == "CrossDeviceIsAvailable") {
Log.d("MainActivity", "CrossDeviceIsAvailable changed")
isRemotelyConnected.value = sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)
}
}
sharedPreferences.registerOnSharedPreferenceChangeListener(isAvailableChangeListener)
Log.d("MainActivity", "CrossDeviceIsAvailable: ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | isAvailable: ${CrossDevice.isAvailable}")
isRemotelyConnected.value = sharedPreferences.getBoolean("CrossDeviceIsAvailable", false) || CrossDevice.isAvailable
Log.d("MainActivity", "isRemotelyConnected: ${isRemotelyConnected.value}")
Box (
modifier = Modifier
.padding(0.dp)
.fillMaxSize()
){
val backButtonBackdrop = rememberLayerBackdrop()
Box (
modifier = Modifier
.fillMaxSize()
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
.layerBackdrop(backButtonBackdrop)
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
) {
NavHost(
navController = navController,
startDestination = if (hookAvailable) "settings" else "onboarding",
enterTransition = {
slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(durationMillis = 300)
) // + fadeIn(animationSpec = tween(durationMillis = 300))
},
exitTransition = {
slideOutHorizontally(
targetOffsetX = { -it/4 },
animationSpec = tween(durationMillis = 300)
) // + fadeOut(animationSpec = tween(durationMillis = 150))
},
popEnterTransition = {
slideInHorizontally(
initialOffsetX = { -it/4 },
animationSpec = tween(durationMillis = 300)
) // + fadeIn(animationSpec = tween(durationMillis = 300))
},
popExitTransition = {
slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(durationMillis = 300)
) // + fadeOut(animationSpec = tween(durationMillis = 150))
}
) {
NavHost(
navController = navController,
startDestination = if (hookAvailable) "settings" else "onboarding",
enterTransition = {
slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(durationMillis = 300)
) // + fadeIn(animationSpec = tween(durationMillis = 300))
},
exitTransition = {
slideOutHorizontally(
targetOffsetX = { -it/4 },
animationSpec = tween(durationMillis = 300)
) // + fadeOut(animationSpec = tween(durationMillis = 150))
},
popEnterTransition = {
slideInHorizontally(
initialOffsetX = { -it/4 },
animationSpec = tween(durationMillis = 300)
) // + fadeIn(animationSpec = tween(durationMillis = 300))
},
popExitTransition = {
slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(durationMillis = 300)
) // + fadeOut(animationSpec = tween(durationMillis = 150))
}
) {
composable("settings") {
if (airPodsService.value != null) {
AirPodsSettingsScreen(
dev = airPodsService.value?.device,
service = airPodsService.value!!,
navController = navController,
isConnected = isConnected.value,
isRemotelyConnected = isRemotelyConnected.value
)
}
}
composable("debug") {
DebugScreen(navController = navController)
}
composable("long_press/{bud}") { navBackStackEntry ->
LongPress(
composable("settings") {
if (airPodsService.value != null) {
AirPodsSettingsScreen(
dev = airPodsService.value?.device,
service = airPodsService.value!!,
navController = navController,
name = navBackStackEntry.arguments?.getString("bud")!!
isConnected = isConnected.value,
isRemotelyConnected = isRemotelyConnected.value
)
}
composable("rename") {
RenameScreen(navController)
}
composable("app_settings") {
AppSettingsScreen(navController)
}
composable("troubleshooting") {
TroubleshootingScreen(navController)
}
composable("head_tracking") {
HeadTrackingScreen(navController)
}
composable("onboarding") {
Onboarding(navController, context)
}
composable("accessibility") {
AccessibilitySettingsScreen(navController)
}
composable("transparency_customization") {
TransparencySettingsScreen(navController)
}
composable("hearing_aid") {
HearingAidScreen(navController)
}
composable("hearing_aid_adjustments") {
HearingAidAdjustmentsScreen(navController)
}
composable("adaptive_strength") {
AdaptiveStrengthScreen(navController)
}
composable("camera_control") {
CameraControlScreen(navController)
}
composable("open_source_licenses") {
OpenSourceLicensesScreen(navController)
}
composable("update_hearing_test") {
UpdateHearingTestScreen(navController)
}
composable("version_info") {
VersionScreen(navController)
}
composable("hearing_protection") {
HearingProtectionScreen(navController)
}
}
}
val showBackButton = remember{ mutableStateOf(false) }
LaunchedEffect(navController) {
navController.addOnDestinationChangedListener { _, destination, _ ->
showBackButton.value = destination.route != "settings" && destination.route != "onboarding"
Log.d("MainActivity", "Navigated to ${destination.route}, showBackButton: ${showBackButton.value}")
composable("debug") {
DebugScreen(navController = navController)
}
}
AnimatedVisibility(
visible = showBackButton.value,
enter = fadeIn(animationSpec = tween()) + scaleIn(initialScale = 0f, animationSpec = tween()),
exit = fadeOut(animationSpec = tween()) + scaleOut(targetScale = 0.5f, animationSpec = tween(100)),
modifier = Modifier
.align(Alignment.TopStart)
.padding(
start = 8.dp,
top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
)
) {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isSystemInDarkTheme(),
backdrop = backButtonBackdrop
composable("long_press/{bud}") { navBackStackEntry ->
LongPress(
navController = navController,
name = navBackStackEntry.arguments?.getString("bud")!!
)
}
composable("rename") {
RenameScreen(navController)
}
composable("app_settings") {
AppSettingsScreen(navController)
}
composable("troubleshooting") {
TroubleshootingScreen(navController)
}
composable("head_tracking") {
HeadTrackingScreen(navController)
}
composable("onboarding") {
Onboarding(navController, context)
}
composable("accessibility") {
AccessibilitySettingsScreen(navController)
}
composable("transparency_customization") {
TransparencySettingsScreen(navController)
}
composable("hearing_aid") {
HearingAidScreen(navController)
}
composable("hearing_aid_adjustments") {
HearingAidAdjustmentsScreen(navController)
}
composable("adaptive_strength") {
AdaptiveStrengthScreen(navController)
}
composable("camera_control") {
CameraControlScreen(navController)
}
}
}
@@ -569,7 +522,7 @@ fun PermissionsScreen(
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.permissions_required),
text = "The following permissions are required to use the app. Please grant them to continue.",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
@@ -746,11 +699,7 @@ fun PermissionCard(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(8.dp))
.background(
if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(
alpha = 0.15f
)
),
.background(if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center
) {
Icon(

View File

@@ -1,205 +0,0 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* 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.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun AboutCard(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.about),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
)
)
}
val rowHeight = remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.onGloballyPositioned { coordinates ->
rowHeight.value = with(density) { coordinates.size.height.toDp() }
},
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.model_name),
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.model.displayName,
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.model_name),
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.actualModelNumber,
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
val serialNumbers = listOf(
airpodsInstance.serialNumber?: "",
"􀀛 ${airpodsInstance.leftSerialNumber}",
"􀀧 ${airpodsInstance.rightSerialNumber}"
)
val serialNumber = remember { mutableStateOf(0) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.serial_number),
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
Text(
text = serialNumbers[serialNumber.value],
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
serialNumber.value = (serialNumber.value + 1) % serialNumbers.size
}
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
NavigationButton(
to = "version_info",
navController = navController,
name = stringResource(R.string.version),
currentState = airpodsInstance.version3,
independent = false,
height = rowHeight.value + 32.dp
)
}
}

View File

@@ -42,27 +42,15 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.Capability
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun AudioSettings(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
if (!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME) &&
!airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS) &&
!airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) &&
!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)
) {
return
}
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
@@ -88,60 +76,52 @@ fun AudioSettings(navController: NavController) {
.padding(top = 2.dp)
) {
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME)) {
StyledToggle(
label = stringResource(R.string.personalized_volume),
description = stringResource(R.string.personalized_volume_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
independent = false
)
StyledToggle(
label = stringResource(R.string.personalized_volume),
description = stringResource(R.string.personalized_volume_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal= 12.dp)
)
if (airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS)) {
StyledToggle(
label = stringResource(R.string.conversational_awareness),
description = stringResource(R.string.conversational_awareness_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
}
StyledToggle(
label = stringResource(R.string.conversational_awareness),
description = stringResource(R.string.conversational_awareness_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal= 12.dp)
)
if (airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){
StyledToggle(
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
attHandle = ATTHandles.LOUD_SOUND_REDUCTION,
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
}
StyledToggle(
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
attHandle = ATTHandles.LOUD_SOUND_REDUCTION,
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal= 12.dp)
)
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)) {
NavigationButton(
to = "adaptive_strength",
name = stringResource(R.string.adaptive_audio),
navController = navController,
independent = false
)
}
NavigationButton(
to = "adaptive_strength",
name = stringResource(R.string.adaptive_audio),
navController = navController,
independent = false
)
}
}

View File

@@ -135,13 +135,6 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
val singleDisplayed = remember { mutableStateOf(false) }
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) {
return
}
val budsRes = airpodsInstance.model.budsRes
val caseRes = airpodsInstance.model.caseRes
Row {
Column (
modifier = Modifier
@@ -149,7 +142,7 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
horizontalAlignment = Alignment.CenterHorizontally
) {
Image (
bitmap = ImageBitmap.imageResource(budsRes),
bitmap = ImageBitmap.imageResource(R.drawable.pro_2_buds),
contentDescription = stringResource(R.string.buds),
modifier = Modifier
.fillMaxWidth()
@@ -205,7 +198,7 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
bitmap = ImageBitmap.imageResource(caseRes),
bitmap = ImageBitmap.imageResource(R.drawable.pro_2_case),
contentDescription = stringResource(R.string.case_alt),
modifier = Modifier
.fillMaxWidth()

View File

@@ -180,13 +180,7 @@ fun ConfirmationDialog(
.background(if (leftPressed) pressedColor else Color.Transparent),
contentAlignment = Alignment.Center
) {
Text(
text = dismissText,
style = TextStyle(
color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(dismissText, color = accentColor)
}
Box(
modifier = Modifier
@@ -201,17 +195,11 @@ fun ConfirmationDialog(
.background(if (rightPressed) pressedColor else Color.Transparent),
contentAlignment = Alignment.Center
) {
Text(
text = confirmText,
style = TextStyle(
color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(confirmText, color = accentColor)
}
}
}
}
}
}
}
}

View File

@@ -61,7 +61,7 @@ fun ConnectionSettings() {
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
.padding(horizontal= 12.dp)
)
StyledToggle(

View File

@@ -1,109 +0,0 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* 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.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.Capability
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun HearingHealthSettings(navController: NavController) {
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
if (airpodsInstance.model.capabilities.contains(Capability.HEARING_AID)) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
if (airpodsInstance.model.capabilities.contains(Capability.PPE)) {
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.hearing_health),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
)
)
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
NavigationButton(
to = "hearing_protection",
name = stringResource(R.string.hearing_protection),
navController = navController,
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
NavigationButton(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid),
navController = navController,
independent = false
)
}
} else {
NavigationButton(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid),
navController = navController
)
}
}
}

View File

@@ -47,7 +47,6 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
@@ -60,8 +59,7 @@ fun NavigationButton(
independent: Boolean = true,
title: String? = null,
description: String? = null,
currentState: String? = null,
height: Dp = 58.dp,
currentState: String? = null
) {
val isDarkTheme = isSystemInDarkTheme()
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
@@ -86,7 +84,7 @@ fun NavigationButton(
Row(
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(if (independent) 28.dp else 0.dp))
.height(height)
.height(58.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {

View File

@@ -127,8 +127,8 @@ half4 main(float2 coord) {
highlight = { Highlight.Ambient.copy(alpha = if (isDarkTheme) 1f else 0f) },
shadow = {
Shadow(
radius = 12f.dp,
color = Color.Black.copy(if (isDarkTheme) 0.08f else 0.2f)
radius = 48f.dp,
color = Color.Black.copy(if (isDarkTheme) 0.08f else 0.4f)
)
},
layerBlock = {
@@ -136,7 +136,8 @@ half4 main(float2 coord) {
val height = size.height
val progress = progressAnimation.value
val scale = lerp(1f, 1.5f, progress)
val maxScale = 0.1f
val scale = lerp(1f, 1f + maxScale, progress)
val maxOffset = size.minDimension
val initialDerivative = 0.05f
@@ -219,7 +220,7 @@ half4 main(float2 coord) {
},
effects = {
refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
// blur(24f, TileMode.Decal)
blur(24f, TileMode.Decal)
},
)
.pointerInput(animationScope) {

View File

@@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
@@ -36,8 +35,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextStyle
@@ -49,10 +46,6 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeTint
@@ -65,7 +58,8 @@ import me.kavishdevar.librepods.R
@Composable
fun StyledScaffold(
title: String,
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
navigationButton: @Composable () -> Unit = {},
actionButtons: List<@Composable () -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (spacerValue: Dp, hazeState: HazeState) -> Unit
) {
@@ -74,10 +68,7 @@ fun StyledScaffold(
Scaffold(
containerColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7),
snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = Modifier
.then(if (!isDarkTheme) Modifier.shadow(elevation = 36.dp, shape = RoundedCornerShape(52.dp), ambientColor = Color.Black, spotColor = Color.Black) else Modifier)
.clip(RoundedCornerShape(52.dp))
snackbarHost = { SnackbarHost(snackbarHostState) }
) { paddingValues ->
val topPadding = paddingValues.calculateTopPadding()
val bottomPadding = paddingValues.calculateBottomPadding()
@@ -89,21 +80,23 @@ fun StyledScaffold(
.fillMaxSize()
.padding(start = startPadding, end = endPadding, bottom = bottomPadding)
) {
val backdrop = rememberLayerBackdrop()
Box(
modifier = Modifier
.zIndex(2f)
.height(64.dp + topPadding)
.fillMaxWidth()
.layerBackdrop(backdrop)
.hazeEffect(state = hazeState) {
tints = listOf(HazeTint(color = if (isDarkTheme) Color.Black else Color.White))
progressive = HazeProgressive.verticalGradient(startIntensity = 1f, endIntensity = 0f)
}
) {
Column(modifier = Modifier.fillMaxSize()) {
Spacer(modifier = Modifier.height(topPadding + 12.dp))
Text(
Spacer(modifier = Modifier.height(topPadding))
Box(
modifier = Modifier.fillMaxWidth()
) {
navigationButton()
Text(
text = title,
style = TextStyle(
fontSize = 20.sp,
@@ -111,19 +104,15 @@ fun StyledScaffold(
color = if (isDarkTheme) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.align(Alignment.Center),
textAlign = TextAlign.Center
)
}
}
Row(
modifier = Modifier
.zIndex(3f)
.padding(top = topPadding, end = 8.dp)
.align(Alignment.TopEnd)
) {
actionButtons.forEach { actionButton ->
actionButton(backdrop)
Row(
modifier = Modifier.align(Alignment.CenterEnd)
) {
actionButtons.forEach { it() }
}
}
}
}
@@ -137,14 +126,16 @@ fun StyledScaffold(
@Composable
fun StyledScaffold(
title: String,
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
navigationButton: @Composable () -> Unit = {},
actionButtons: List<@Composable () -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable () -> Unit
) {
StyledScaffold(
title = title,
navigationButton = navigationButton,
actionButtons = actionButtons,
snackbarHostState = snackbarHostState,
snackbarHostState = snackbarHostState
) { _, _ ->
content()
}
@@ -154,14 +145,16 @@ fun StyledScaffold(
@Composable
fun StyledScaffold(
title: String,
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
navigationButton: @Composable () -> Unit = {},
actionButtons: List<@Composable () -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (spacerValue: Dp) -> Unit
) {
StyledScaffold(
title = title,
navigationButton = navigationButton,
actionButtons = actionButtons,
snackbarHostState = snackbarHostState,
snackbarHostState = snackbarHostState
) { spacerValue, _ ->
content(spacerValue)
}

View File

@@ -106,28 +106,17 @@ fun StyledSwitch(
compositingStrategy = CompositingStrategy.Offscreen
}
val animatedTrackColor = remember { Animatable(if (checked) onColor else offColor) }
val totalDrag = remember { mutableFloatStateOf(0f) }
val tapThreshold = 10f
val isFirstComposition = remember { mutableStateOf(true) }
LaunchedEffect(checked) {
if (!isFirstComposition.value) {
coroutineScope {
launch {
val targetColor = if (checked) onColor else offColor
animatedTrackColor.animateTo(targetColor, colorAnimationSpec)
}
launch {
val targetFrac = if (checked) 1f else 0f
animatedFraction.animateTo(targetFrac, progressAnimationSpec)
}
if (progressAnimation.value > 0f) return@coroutineScope
launch {
progressAnimation.animateTo(1f, tween(175, easing = FastOutSlowInEasing))
progressAnimation.animateTo(0f, tween(175, easing = FastOutSlowInEasing))
}
coroutineScope {
launch {
val targetColor = if (checked) onColor else offColor
animatedTrackColor.animateTo(targetColor, colorAnimationSpec)
}
launch {
val targetFrac = if (checked) 1f else 0f
animatedFraction.animateTo(targetFrac, progressAnimationSpec)
}
}
isFirstComposition.value = false
}
Box(
@@ -158,7 +147,6 @@ fun StyledSwitch(
animationScope.launch {
animatedFraction.snapTo(newFraction)
}
totalDrag.floatValue += kotlin.math.abs(delta)
val newChecked = newFraction >= 0.5f
if (newChecked != checked) {
onCheckedChange(newChecked)
@@ -168,28 +156,17 @@ fun StyledSwitch(
Orientation.Horizontal,
startDragImmediately = true,
onDragStarted = {
totalDrag.floatValue = 0f
animationScope.launch {
progressAnimation.animateTo(1f, progressAnimationSpec)
}
},
onDragStopped = {
animationScope.launch {
if (totalDrag.floatValue < tapThreshold) {
val newChecked = !checked
onCheckedChange(newChecked)
val snappedFraction = if (newChecked) 1f else 0f
coroutineScope {
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { animatedFraction.animateTo(snappedFraction, progressAnimationSpec) }
}
} else {
val snappedFraction = if (animatedFraction.value >= 0.5f) 1f else 0f
onCheckedChange(snappedFraction >= 0.5f)
coroutineScope {
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { animatedFraction.animateTo(snappedFraction, progressAnimationSpec) }
}
val snappedFraction = if (animatedFraction.value >= 0.5f) 1f else 0f
onCheckedChange(snappedFraction >= 0.5f)
coroutineScope {
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { animatedFraction.animateTo(snappedFraction, progressAnimationSpec) }
}
}
}

View File

@@ -59,6 +59,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
@@ -472,12 +473,30 @@ fun StyledToggle(
val attManager = ServiceManager.getService()?.attManager ?: return
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val checkedValue = attManager.read(attHandle).getOrNull(0)?.toInt()
var checked by remember { mutableStateOf(checkedValue !=0) }
var checked by remember { mutableStateOf(false) }
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
attManager.enableNotifications(attHandle)
LaunchedEffect(Unit) {
attManager.enableNotifications(attHandle)
var parsed = false
for (attempt in 1..3) {
try {
val data = attManager.read(attHandle)
checked = data[0].toInt() != 0
Log.d("StyledToggle", "Read attempt $attempt for $label: enabled=$checked")
parsed = true
break
} catch (e: Exception) {
Log.w("StyledToggle", "Read attempt $attempt for $label failed: ${e.message}")
}
delay(200)
}
if (!parsed) {
Log.d("StyledToggle", "Failed to read state for $label after 3 attempts")
}
}
if (sharedPreferenceKey != null && sharedPreferences != null) {
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)

View File

@@ -20,6 +20,8 @@ package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures
@@ -33,10 +35,17 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -50,6 +59,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
@@ -77,13 +88,13 @@ import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.StyledDropdown
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.Capability
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -106,8 +117,6 @@ fun AccessibilitySettingsScreen(navController: NavController) {
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
val capabilities = remember { ServiceManager.getService()?.airpodsInstance?.model?.capabilities ?: emptySet<Capability>() }
val hearingAidEnabled = remember { mutableStateOf(
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(1) == 0x01.toByte() &&
aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }?.value?.getOrNull(0) == 0x01.toByte()
@@ -141,7 +150,15 @@ fun AccessibilitySettingsScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.accessibility)
title = stringResource(R.string.accessibility),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
},
) { spacerHeight, hazeState ->
Column(
modifier = Modifier
@@ -362,13 +379,11 @@ fun AccessibilitySettingsScreen(navController: NavController) {
independent = true,
)
if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)) {
StyledToggle(
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
attHandle = ATTHandles.LOUD_SOUND_REDUCTION
)
}
StyledToggle(
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
attHandle = ATTHandles.LOUD_SOUND_REDUCTION
)
if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
NavigationButton(
@@ -392,254 +407,251 @@ fun AccessibilitySettingsScreen(navController: NavController) {
independent = true
)
if (capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
StyledToggle(
label = stringResource(R.string.volume_control),
description = stringResource(R.string.volume_control_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE,
)
StyledToggle(
label = stringResource(R.string.volume_control),
description = stringResource(R.string.volume_control_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE,
)
DropdownMenuComponent(
label = stringResource(R.string.volume_swipe_speed),
description = stringResource(R.string.volume_swipe_speed_description),
options = volumeSwipeSpeedOptions.values.toList(),
selectedOption = selectedVolumeSwipeSpeed?: "Default",
onOptionSelected = { newValue ->
selectedVolumeSwipeSpeed = newValue
aacpManager?.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value,
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 1.toByte()
)
},
textColor = textColor,
hazeState = hazeState,
independent = true
)
}
DropdownMenuComponent(
label = stringResource(R.string.volume_swipe_speed),
description = stringResource(R.string.volume_swipe_speed_description),
options = volumeSwipeSpeedOptions.values.toList(),
selectedOption = selectedVolumeSwipeSpeed?: "Default",
onOptionSelected = { newValue ->
selectedVolumeSwipeSpeed = newValue
aacpManager?.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value,
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 1.toByte()
)
},
textColor = textColor,
hazeState = hazeState,
independent = true
)
if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
// Text(
// text = stringResource(R.string.apply_eq_to),
// style = TextStyle(
// fontSize = 14.sp,
// fontWeight = FontWeight.Bold,
// color = textColor.copy(alpha = 0.6f),
// fontFamily = FontFamily(Font(R.font.sf_pro))
// ),
// modifier = Modifier.padding(8.dp, bottom = 0.dp)
// )
// Column(
// modifier = Modifier
// .fillMaxWidth()
// .background(backgroundColor, RoundedCornerShape(28.dp))
// .padding(vertical = 0.dp)
// ) {
// val darkModeLocal = isSystemInDarkTheme()
//
// val phoneShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
// var phoneBackgroundColor by remember {
// mutableStateOf(
// if (darkModeLocal) Color(
// 0xFF1C1C1E
// ) else Color(0xFFFFFFFF)
// )
// }
// val phoneAnimatedBackgroundColor by animateColorAsState(
// targetValue = phoneBackgroundColor,
// animationSpec = tween(durationMillis = 500)
// )
//
// Row(
// modifier = Modifier
// .height(48.dp)
// .fillMaxWidth()
// .background(phoneAnimatedBackgroundColor, phoneShape)
// .pointerInput(Unit) {
// detectTapGestures(
// onPress = {
// phoneBackgroundColor =
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
// tryAwaitRelease()
// phoneBackgroundColor =
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
// phoneEQEnabled.value = !phoneEQEnabled.value
// }
// )
// }
// .padding(horizontal = 16.dp),
// verticalAlignment = Alignment.CenterVertically
// ) {
// Text(
// stringResource(R.string.phone),
// fontSize = 16.sp,
// color = textColor,
// fontFamily = FontFamily(Font(R.font.sf_pro)),
// modifier = Modifier.weight(1f)
// )
// Checkbox(
// checked = phoneEQEnabled.value,
// onCheckedChange = { phoneEQEnabled.value = it },
// colors = CheckboxDefaults.colors().copy(
// checkedCheckmarkColor = Color(0xFF007AFF),
// uncheckedCheckmarkColor = Color.Transparent,
// checkedBoxColor = Color.Transparent,
// uncheckedBoxColor = Color.Transparent,
// checkedBorderColor = Color.Transparent,
// uncheckedBorderColor = Color.Transparent
// ),
// modifier = Modifier
// .height(24.dp)
// .scale(1.5f)
// )
// }
//
// HorizontalDivider(
// thickness = 1.dp,
// color = Color(0x40888888)
// )
//
// val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
// var mediaBackgroundColor by remember {
// mutableStateOf(
// if (darkModeLocal) Color(
// 0xFF1C1C1E
// ) else Color(0xFFFFFFFF)
// )
// }
// val mediaAnimatedBackgroundColor by animateColorAsState(
// targetValue = mediaBackgroundColor,
// animationSpec = tween(durationMillis = 500)
// )
//
// Row(
// modifier = Modifier
// .height(48.dp)
// .fillMaxWidth()
// .background(mediaAnimatedBackgroundColor, mediaShape)
// .pointerInput(Unit) {
// detectTapGestures(
// onPress = {
// mediaBackgroundColor =
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
// tryAwaitRelease()
// mediaBackgroundColor =
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
// mediaEQEnabled.value = !mediaEQEnabled.value
// }
// )
// }
// .padding(horizontal = 16.dp),
// verticalAlignment = Alignment.CenterVertically
// ) {
// Text(
// stringResource(R.string.media),
// fontSize = 16.sp,
// color = textColor,
// fontFamily = FontFamily(Font(R.font.sf_pro)),
// modifier = Modifier.weight(1f)
// )
// Checkbox(
// checked = mediaEQEnabled.value,
// onCheckedChange = { mediaEQEnabled.value = it },
// colors = CheckboxDefaults.colors().copy(
// checkedCheckmarkColor = Color(0xFF007AFF),
// uncheckedCheckmarkColor = Color.Transparent,
// checkedBoxColor = Color.Transparent,
// uncheckedBoxColor = Color.Transparent,
// checkedBorderColor = Color.Transparent,
// uncheckedBorderColor = Color.Transparent
// ),
// modifier = Modifier
// .height(24.dp)
// .scale(1.5f)
// )
// }
// }
Text(
text = stringResource(R.string.apply_eq_to),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 0.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(vertical = 0.dp)
) {
val darkModeLocal = isSystemInDarkTheme()
// EQ Settings. Don't seem to have an effect?
// Column(
// modifier = Modifier
// .fillMaxWidth()
// .background(backgroundColor, RoundedCornerShape(28.dp))
// .padding(12.dp),
// horizontalAlignment = Alignment.CenterHorizontally
// ) {
// for (i in 0 until 8) {
// val eqPhoneValue =
// remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) }
// Row(
// horizontalArrangement = Arrangement.SpaceBetween,
// verticalAlignment = Alignment.CenterVertically,
// modifier = Modifier
// .fillMaxWidth()
// .height(38.dp)
// ) {
// Text(
// text = String.format("%.2f", eqPhoneValue.floatValue),
// fontSize = 12.sp,
// color = textColor,
// modifier = Modifier.padding(bottom = 4.dp)
// )
val phoneShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
var phoneBackgroundColor by remember {
mutableStateOf(
if (darkModeLocal) Color(
0xFF1C1C1E
) else Color(0xFFFFFFFF)
)
}
val phoneAnimatedBackgroundColor by animateColorAsState(
targetValue = phoneBackgroundColor,
animationSpec = tween(durationMillis = 500)
)
// Slider(
// value = eqPhoneValue.floatValue,
// onValueChange = { newVal ->
// eqPhoneValue.floatValue = newVal
// val newEQ = phoneMediaEQ.value.copyOf()
// newEQ[i] = eqPhoneValue.floatValue
// phoneMediaEQ.value = newEQ
// },
// valueRange = 0f..100f,
// modifier = Modifier
// .fillMaxWidth(0.9f)
// .height(36.dp),
// colors = SliderDefaults.colors(
// thumbColor = thumbColor,
// activeTrackColor = activeTrackColor,
// inactiveTrackColor = trackColor
// ),
// thumb = {
// Box(
// modifier = Modifier
// .size(24.dp)
// .shadow(4.dp, CircleShape)
// .background(thumbColor, CircleShape)
// )
// },
// track = {
// Box(
// modifier = Modifier
// .fillMaxWidth()
// .height(12.dp),
// contentAlignment = Alignment.CenterStart
// )
// {
// Box(
// modifier = Modifier
// .fillMaxWidth()
// .height(4.dp)
// .background(trackColor, RoundedCornerShape(4.dp))
// )
// Box(
// modifier = Modifier
// .fillMaxWidth(eqPhoneValue.floatValue / 100f)
// .height(4.dp)
// .background(activeTrackColor, RoundedCornerShape(4.dp))
// )
// }
// }
// )
Row(
modifier = Modifier
.height(48.dp)
.fillMaxWidth()
.background(phoneAnimatedBackgroundColor, phoneShape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
phoneBackgroundColor =
if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
phoneBackgroundColor =
if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
phoneEQEnabled.value = !phoneEQEnabled.value
}
)
}
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
stringResource(R.string.phone),
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier.weight(1f)
)
Checkbox(
checked = phoneEQEnabled.value,
onCheckedChange = { phoneEQEnabled.value = it },
colors = CheckboxDefaults.colors().copy(
checkedCheckmarkColor = Color(0xFF007AFF),
uncheckedCheckmarkColor = Color.Transparent,
checkedBoxColor = Color.Transparent,
uncheckedBoxColor = Color.Transparent,
checkedBorderColor = Color.Transparent,
uncheckedBorderColor = Color.Transparent
),
modifier = Modifier
.height(24.dp)
.scale(1.5f)
)
}
// Text(
// text = stringResource(R.string.band_label, i + 1),
// fontSize = 12.sp,
// color = textColor,
// modifier = Modifier.padding(top = 4.dp)
// )
// }
// }
// }
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888)
)
val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
var mediaBackgroundColor by remember {
mutableStateOf(
if (darkModeLocal) Color(
0xFF1C1C1E
) else Color(0xFFFFFFFF)
)
}
val mediaAnimatedBackgroundColor by animateColorAsState(
targetValue = mediaBackgroundColor,
animationSpec = tween(durationMillis = 500)
)
Row(
modifier = Modifier
.height(48.dp)
.fillMaxWidth()
.background(mediaAnimatedBackgroundColor, mediaShape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
mediaBackgroundColor =
if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
mediaBackgroundColor =
if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
mediaEQEnabled.value = !mediaEQEnabled.value
}
)
}
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
stringResource(R.string.media),
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier.weight(1f)
)
Checkbox(
checked = mediaEQEnabled.value,
onCheckedChange = { mediaEQEnabled.value = it },
colors = CheckboxDefaults.colors().copy(
checkedCheckmarkColor = Color(0xFF007AFF),
uncheckedCheckmarkColor = Color.Transparent,
checkedBoxColor = Color.Transparent,
uncheckedBoxColor = Color.Transparent,
checkedBorderColor = Color.Transparent,
uncheckedBorderColor = Color.Transparent
),
modifier = Modifier
.height(24.dp)
.scale(1.5f)
)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
for (i in 0 until 8) {
val eqPhoneValue =
remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) }
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(38.dp)
) {
Text(
text = String.format("%.2f", eqPhoneValue.floatValue),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(bottom = 4.dp)
)
Slider(
value = eqPhoneValue.floatValue,
onValueChange = { newVal ->
eqPhoneValue.floatValue = newVal
val newEQ = phoneMediaEQ.value.copyOf()
newEQ[i] = eqPhoneValue.floatValue
phoneMediaEQ.value = newEQ
},
valueRange = 0f..100f,
modifier = Modifier
.fillMaxWidth(0.9f)
.height(36.dp),
colors = SliderDefaults.colors(
thumbColor = thumbColor,
activeTrackColor = activeTrackColor,
inactiveTrackColor = trackColor
),
thumb = {
Box(
modifier = Modifier
.size(24.dp)
.shadow(4.dp, CircleShape)
.background(thumbColor, CircleShape)
)
},
track = {
Box(
modifier = Modifier
.fillMaxWidth()
.height(12.dp),
contentAlignment = Alignment.CenterStart
)
{
Box(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.background(trackColor, RoundedCornerShape(4.dp))
)
Box(
modifier = Modifier
.fillMaxWidth(eqPhoneValue.floatValue / 100f)
.height(4.dp)
.background(activeTrackColor, RoundedCornerShape(4.dp))
)
}
}
)
Text(
text = stringResource(R.string.band_label, i + 1),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
}
}
}

View File

@@ -99,7 +99,15 @@ fun AdaptiveStrengthScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.customize_adaptive_audio)
title = stringResource(R.string.customize_adaptive_audio),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
) { spacerHeight ->
Column(
modifier = Modifier

View File

@@ -65,25 +65,20 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.highlight.Highlight
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.AboutCard
import me.kavishdevar.librepods.composables.AudioSettings
import me.kavishdevar.librepods.composables.BatteryView
import me.kavishdevar.librepods.composables.CallControlSettings
import me.kavishdevar.librepods.composables.ConfirmationDialog
import me.kavishdevar.librepods.composables.ConnectionSettings
import me.kavishdevar.librepods.composables.HearingHealthSettings
import me.kavishdevar.librepods.composables.MicrophoneSettings
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.NoiseControlSettings
@@ -96,8 +91,6 @@ import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.Capability
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@@ -203,58 +196,46 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
}
}
}
LaunchedEffect(service) {
service.let {
it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
putParcelableArrayListExtra("data", ArrayList(it.getBattery()))
})
it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
putExtra("data", it.getANC())
})
}
}
val darkMode = isSystemInDarkTheme()
val hazeStateS = remember { mutableStateOf(HazeState()) }
val showDialog = remember { mutableStateOf(!sharedPreferences.getBoolean("donationDialogShown", false)) }
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = deviceName.text,
actionButtons = listOf(
{scaffoldBackdrop ->
StyledIconButton(
onClick = { navController.navigate("app_settings") },
icon = "􀍟",
darkMode = darkMode,
backdrop = scaffoldBackdrop
)
}
),
actionButtons = listOf {
StyledIconButton(
onClick = { navController.navigate("app_settings") },
icon = "􀍟",
darkMode = darkMode,
backdrop = backdrop
)
},
snackbarHostState = snackbarHostState
) { spacerHeight, hazeState ->
hazeStateS.value = hazeState
if (isLocallyConnected || isRemotelyConnected) {
val instance = service.airpodsInstance
if (instance == null) {
Text("Error: AirPods instance is null")
return@StyledScaffold
}
val capabilities = instance.model.capabilities
LazyColumn(
modifier = Modifier
.fillMaxSize()
.hazeSource(hazeState)
.padding(horizontal = 16.dp)
.layerBackdrop(backdrop)
) {
item(key = "spacer_top") { Spacer(modifier = Modifier.height(spacerHeight)) }
item(key = "battery") {
item { Spacer(modifier = Modifier.height(spacerHeight)) }
item {
LaunchedEffect(service) {
service.let {
it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
putParcelableArrayListExtra("data", ArrayList(it.getBattery()))
})
it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
putExtra("data", it.getANC())
})
}
}
BatteryView(service = service)
}
item(key = "spacer_battery") { Spacer(modifier = Modifier.height(32.dp)) }
item { Spacer(modifier = Modifier.height(32.dp)) }
item(key = "name") {
item {
NavigationButton(
to = "rename",
name = stringResource(R.string.name),
@@ -263,76 +244,61 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
independent = true
)
}
val actAsAppleDeviceHookEnabled = RadareOffsetFinder.isSdpOffsetAvailable()
if (actAsAppleDeviceHookEnabled) {
item(key = "spacer_hearing_health") { Spacer(modifier = Modifier.height(32.dp)) }
item(key = "hearing_health") {
HearingHealthSettings(navController = navController)
}
item { Spacer(modifier = Modifier.height(32.dp)) }
item { NavigationButton(to = "hearing_aid", name = stringResource(R.string.hearing_aid), navController = navController) }
item { Spacer(modifier = Modifier.height(16.dp)) }
item { NoiseControlSettings(service = service) }
item { Spacer(modifier = Modifier.height(16.dp)) }
item { PressAndHoldSettings(navController = navController) }
item { Spacer(modifier = Modifier.height(16.dp)) }
item { CallControlSettings(hazeState = hazeState) }
item { Spacer(modifier = Modifier.height(16.dp)) }
item { NavigationButton(to = "camera_control", name = stringResource(R.string.camera_remote), description = stringResource(R.string.camera_control_description), title = stringResource(R.string.camera_control), navController = navController) }
item { Spacer(modifier = Modifier.height(16.dp)) }
item { AudioSettings(navController = navController) }
item { Spacer(modifier = Modifier.height(16.dp)) }
item { ConnectionSettings() }
item { Spacer(modifier = Modifier.height(16.dp)) }
item { MicrophoneSettings(hazeState) }
item { Spacer(modifier = Modifier.height(16.dp)) }
item {
StyledToggle(
label = stringResource(R.string.sleep_detection),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
)
}
if (capabilities.contains(Capability.LISTENING_MODE)) {
item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "noise_control") { NoiseControlSettings(service = service) }
item { Spacer(modifier = Modifier.height(16.dp)) }
item {
NavigationButton(to = "head_tracking", name = stringResource(R.string.head_gestures), navController = navController, currentState = if (sharedPreferences.getBoolean("head_gestures", false)) stringResource(R.string.on) else stringResource(R.string.off))
}
if (capabilities.contains(Capability.STEM_CONFIG)) {
item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "press_hold") { PressAndHoldSettings(navController = navController) }
item { Spacer(modifier = Modifier.height(16.dp)) }
item { NavigationButton(to = "accessibility", name = stringResource(R.string.accessibility), navController = navController) }
item { Spacer(modifier = Modifier.height(16.dp)) }
item {
StyledToggle(
label = stringResource(R.string.off_listening_mode),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
description = stringResource(R.string.off_listening_mode_description)
)
}
item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "call_control") { CallControlSettings(hazeState = hazeState) }
// an about card- everything but the version number is unknown - will add later if i find out
if (capabilities.contains(Capability.STEM_CONFIG)) {
item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "camera_control") { NavigationButton(to = "camera_control", name = stringResource(R.string.camera_remote), description = stringResource(R.string.camera_control_description), title = stringResource(R.string.camera_control), navController = navController) }
}
item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "audio") { AudioSettings(navController = navController) }
item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "connection") { ConnectionSettings() }
item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "microphone") { MicrophoneSettings(hazeState) }
if (capabilities.contains(Capability.SLEEP_DETECTION)) {
item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "sleep_detection") {
StyledToggle(
label = stringResource(R.string.sleep_detection),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
)
}
}
if (capabilities.contains(Capability.HEAD_GESTURES)) {
item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "head_tracking") { NavigationButton(to = "head_tracking", name = stringResource(R.string.head_gestures), navController = navController, currentState = if (sharedPreferences.getBoolean("head_gestures", false)) stringResource(R.string.on) else stringResource(R.string.off)) }
}
item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "accessibility") { NavigationButton(to = "accessibility", name = stringResource(R.string.accessibility), navController = navController) }
if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){
item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "off_listening") {
StyledToggle(
label = stringResource(R.string.off_listening_mode),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
description = stringResource(R.string.off_listening_mode_description)
)
}
}
item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) }
item(key = "about") { AboutCard(navController = navController) }
item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "debug") { NavigationButton("debug", "Debug", navController) }
item(key = "spacer_bottom") { Spacer(Modifier.height(24.dp)) }
item { Spacer(modifier = Modifier.height(16.dp)) }
item { NavigationButton("debug", "Debug", navController) }
item { Spacer(Modifier.height(24.dp)) }
}
}
else {
@@ -348,7 +314,6 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
Highlight.Ambient.copy(alpha = 0f)
}
)
.hazeSource(hazeState)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
@@ -379,9 +344,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
Spacer(Modifier.height(32.dp))
StyledButton(
onClick = { navController.navigate("troubleshooting") },
backdrop = backdrop,
modifier = Modifier
.fillMaxWidth(0.9f)
backdrop = backdrop
) {
Text(
text = "Troubleshoot Connection",
@@ -389,51 +352,13 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isSystemInDarkTheme()) Color.White else Color.Black
)
)
}
Spacer(Modifier.height(16.dp))
StyledButton(
onClick = {
service.reconnectFromSavedMac()
},
backdrop = backdrop,
modifier = Modifier
.fillMaxWidth(0.9f)
) {
Text(
text = stringResource(R.string.reconnect_to_last_device),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isSystemInDarkTheme()) Color.White else Color.Black
color = if (isSystemInDarkTheme()) Color.White else Color.Black
)
)
}
}
}
}
ConfirmationDialog(
showDialog = showDialog,
title = stringResource(R.string.support_librepods),
message = stringResource(R.string.support_dialog_description),
confirmText = stringResource(R.string.support_me) + " \uDBC0\uDEB5",
dismissText = stringResource(R.string.never_show_again),
onConfirm = {
val browserIntent = Intent(
Intent.ACTION_VIEW,
"https://github.com/sponsors/kavishdevar".toUri()
)
context.startActivity(browserIntent)
sharedPreferences.edit { putBoolean("donationDialogShown", true) }
},
onDismiss = {
sharedPreferences.edit { putBoolean("donationDialogShown", true) }
},
hazeState = hazeStateS.value,
)
}
@Preview

View File

@@ -80,6 +80,7 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle
@@ -192,7 +193,15 @@ fun AppSettingsScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.app_settings)
title = stringResource(R.string.app_settings),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
) { spacerHeight, hazeState ->
Column(
modifier = Modifier
@@ -291,7 +300,7 @@ fun AppSettingsScreen(navController: NavController) {
)
Spacer(modifier = Modifier.height(16.dp))
NavigationButton(
to = "",
title = stringResource(R.string.camera_control),
@@ -639,15 +648,6 @@ fun AppSettingsScreen(navController: NavController) {
}
}
Spacer(modifier = Modifier.height(16.dp))
NavigationButton(
to = "open_source_licenses",
name = stringResource(R.string.open_source_licenses),
navController = navController,
independent = true
)
Spacer(modifier = Modifier.height(32.dp))
if (showResetDialog.value) {

View File

@@ -130,7 +130,15 @@ fun CameraControlScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.camera_control)
title = stringResource(R.string.camera_control),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
) { spacerHeight ->
Column(
modifier = Modifier

View File

@@ -327,8 +327,16 @@ fun DebugScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = "Debug",
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
},
actionButtons = listOf(
{scaffoldBackdrop ->
{
StyledIconButton(
onClick = {
airPodsService?.clearLogs()
@@ -336,7 +344,7 @@ fun DebugScreen(navController: NavController) {
},
icon = "􀈑",
darkMode = isDarkTheme,
backdrop = scaffoldBackdrop
backdrop = backdrop
)
}
),

View File

@@ -121,10 +121,18 @@ fun HeadTrackingScreen(navController: NavController) {
val scrollState = rememberScrollState()
val backdrop = rememberLayerBackdrop()
StyledScaffold(
StyledScaffold (
title = stringResource(R.string.head_tracking),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
},
actionButtons = listOf(
{ scaffoldBackdrop ->
{
var isActive by remember { mutableStateOf(ServiceManager.getService()?.isHeadTrackingActive == true) }
StyledIconButton(
onClick = {
@@ -138,7 +146,7 @@ fun HeadTrackingScreen(navController: NavController) {
},
icon = if (isActive) "􀊅" else "􀊃",
darkMode = isDarkTheme,
backdrop = scaffoldBackdrop
backdrop = backdrop
)
}
),
@@ -221,10 +229,9 @@ fun HeadTrackingScreen(navController: NavController) {
}
}
}
val gestureTextValue = stringResource(R.string.shake_your_head_or_nod)
StyledButton(
onClick = {
gestureText = gestureTextValue
gestureText = "Shake your head or nod!"
coroutineScope.launch {
val accepted = ServiceManager.getService()?.testHeadGestures() ?: false
gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected."

View File

@@ -33,7 +33,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -47,22 +46,26 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.HearingAidSettings
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.utils.sendHearingAidSettings
import me.kavishdevar.librepods.utils.ATTManager
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
private var debounceJob: Job? = null
private const val TAG = "HearingAidAdjustments"
@SuppressLint("DefaultLocale")
@@ -70,7 +73,7 @@ private const val TAG = "HearingAidAdjustments"
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) {
isSystemInDarkTheme()
val isDarkTheme = isSystemInDarkTheme()
val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
@@ -78,7 +81,15 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.adjustments)
title = stringResource(R.string.adjustments),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
) { spacerHeight ->
Column(
modifier = Modifier
@@ -207,7 +218,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
sendHearingAidSettings(attManager, hearingAidSettings.value)
}
LaunchedEffect(Unit) {
@@ -339,3 +350,150 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
}
}
}
private data class HearingAidSettings(
val leftEQ: FloatArray,
val rightEQ: FloatArray,
val leftAmplification: Float,
val rightAmplification: Float,
val leftTone: Float,
val rightTone: Float,
val leftConversationBoost: Boolean,
val rightConversationBoost: Boolean,
val leftAmbientNoiseReduction: Float,
val rightAmbientNoiseReduction: Float,
val netAmplification: Float,
val balance: Float,
val ownVoiceAmplification: Float
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as HearingAidSettings
if (leftAmplification != other.leftAmplification) return false
if (rightAmplification != other.rightAmplification) return false
if (leftTone != other.leftTone) return false
if (rightTone != other.rightTone) return false
if (leftConversationBoost != other.leftConversationBoost) return false
if (rightConversationBoost != other.rightConversationBoost) return false
if (leftAmbientNoiseReduction != other.leftAmbientNoiseReduction) return false
if (rightAmbientNoiseReduction != other.rightAmbientNoiseReduction) return false
if (!leftEQ.contentEquals(other.leftEQ)) return false
if (!rightEQ.contentEquals(other.rightEQ)) return false
if (ownVoiceAmplification != other.ownVoiceAmplification) return false
return true
}
override fun hashCode(): Int {
var result = leftAmplification.hashCode()
result = 31 * result + rightAmplification.hashCode()
result = 31 * result + leftTone.hashCode()
result = 31 * result + rightTone.hashCode()
result = 31 * result + leftConversationBoost.hashCode()
result = 31 * result + rightConversationBoost.hashCode()
result = 31 * result + leftAmbientNoiseReduction.hashCode()
result = 31 * result + rightAmbientNoiseReduction.hashCode()
result = 31 * result + leftEQ.contentHashCode()
result = 31 * result + rightEQ.contentHashCode()
result = 31 * result + ownVoiceAmplification.hashCode()
return result
}
}
private fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? {
if (data.size < 104) return null
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
buffer.get() // skip 0x02
buffer.get() // skip 0x02
buffer.getShort() // skip 0x60 0x00
val leftEQ = FloatArray(8)
for (i in 0..7) {
leftEQ[i] = buffer.float
}
val leftAmplification = buffer.float
val leftTone = buffer.float
val leftConvFloat = buffer.float
val leftConversationBoost = leftConvFloat > 0.5f
val leftAmbientNoiseReduction = buffer.float
val rightEQ = FloatArray(8)
for (i in 0..7) {
rightEQ[i] = buffer.float
}
val rightAmplification = buffer.float
val rightTone = buffer.float
val rightConvFloat = buffer.float
val rightConversationBoost = rightConvFloat > 0.5f
val rightAmbientNoiseReduction = buffer.float
val ownVoiceAmplification = buffer.float
val avg = (leftAmplification + rightAmplification) / 2
val amplification = avg.coerceIn(-1f, 1f)
val diff = rightAmplification - leftAmplification
val balance = diff.coerceIn(-1f, 1f)
return HearingAidSettings(
leftEQ = leftEQ,
rightEQ = rightEQ,
leftAmplification = leftAmplification,
rightAmplification = rightAmplification,
leftTone = leftTone,
rightTone = rightTone,
leftConversationBoost = leftConversationBoost,
rightConversationBoost = rightConversationBoost,
leftAmbientNoiseReduction = leftAmbientNoiseReduction,
rightAmbientNoiseReduction = rightAmbientNoiseReduction,
netAmplification = amplification,
balance = balance,
ownVoiceAmplification = ownVoiceAmplification
)
}
private fun sendHearingAidSettings(
attManager: ATTManager,
hearingAidSettings: HearingAidSettings
) {
debounceJob?.cancel()
debounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(100)
try {
val currentData = attManager.read(ATTHandles.HEARING_AID)
Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
if (currentData.size < 104) {
Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings")
return@launch
}
val buffer = ByteBuffer.wrap(currentData).order(ByteOrder.LITTLE_ENDIAN)
// for some reason
buffer.put(2, 0x64)
// Left ear adjustments
buffer.putFloat(36, hearingAidSettings.leftAmplification)
buffer.putFloat(40, hearingAidSettings.leftTone)
buffer.putFloat(44, if (hearingAidSettings.leftConversationBoost) 1.0f else 0.0f)
buffer.putFloat(48, hearingAidSettings.leftAmbientNoiseReduction)
// Right ear adjustments
buffer.putFloat(84, hearingAidSettings.rightAmplification)
buffer.putFloat(88, hearingAidSettings.rightTone)
buffer.putFloat(92, if (hearingAidSettings.rightConversationBoost) 1.0f else 0.0f)
buffer.putFloat(96, hearingAidSettings.rightAmbientNoiseReduction)
// Own voice amplification
buffer.putFloat(100, hearingAidSettings.ownVoiceAmplification)
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
attManager.write(ATTHandles.HEARING_AID, currentData)
} catch (e: IOException) {
e.printStackTrace()
}
}
}

View File

@@ -63,6 +63,7 @@ import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.ConfirmationDialog
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
@@ -82,6 +83,7 @@ fun HearingAidScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
val snackbarHostState = remember { SnackbarHostState() }
val attManager = ServiceManager.getService()?.attManager ?: return
@@ -97,12 +99,19 @@ fun HearingAidScreen(navController: NavController) {
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
}
val hazeStateS = remember { mutableStateOf(HazeState()) } // dont question this. i could possibly use something other than initializing it with an empty state and then replacing it with the the one provided by the scaffold
StyledScaffold(
title = stringResource(R.string.hearing_aid),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
},
actionButtons = emptyList(),
snackbarHostState = snackbarHostState,
) { spacerHeight, hazeState ->
) { spacerHeight ->
Column(
modifier = Modifier
.layerBackdrop(backdrop)
@@ -112,7 +121,6 @@ fun HearingAidScreen(navController: NavController) {
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
hazeStateS.value = hazeState
Spacer(modifier = Modifier.height(spacerHeight))
val hearingAidListener = remember {
@@ -128,9 +136,9 @@ fun HearingAidScreen(navController: NavController) {
}
}
// val mediaAssistEnabled = remember { mutableStateOf(false) }
// val adjustMediaEnabled = remember { mutableStateOf(false) }
// val adjustPhoneEnabled = remember { mutableStateOf(false) }
val mediaAssistEnabled = remember { mutableStateOf(false) }
val adjustMediaEnabled = remember { mutableStateOf(false) }
val adjustPhoneEnabled = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
@@ -155,13 +163,13 @@ fun HearingAidScreen(navController: NavController) {
initialLoad.value = false
}
// fun onAdjustPhoneChange(value: Boolean) {
// // TODO
// }
fun onAdjustPhoneChange(value: Boolean) {
// TODO
}
// fun onAdjustMediaChange(value: Boolean) {
// // TODO
// }
fun onAdjustMediaChange(value: Boolean) {
// TODO
}
Text(
text = stringResource(R.string.hearing_aid),
@@ -214,13 +222,6 @@ fun HearingAidScreen(navController: NavController) {
)
Spacer(modifier = Modifier.height(16.dp))
NavigationButton(
to = "update_hearing_test",
name = stringResource(R.string.update_hearing_test),
navController,
independent = true
)
// not implemented yet
// StyledToggle(
@@ -288,7 +289,7 @@ fun HearingAidScreen(navController: NavController) {
}
}
},
hazeState = hazeStateS.value,
hazeState = hazeState,
// backdrop = backdrop
)
}

View File

@@ -1,90 +0,0 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* 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.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun HearingProtectionScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val service = ServiceManager.getService()
if (service == null) return
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.hearing_protection),
) { spacerHeight ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
StyledToggle(
title = stringResource(R.string.environmental_noise),
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
attHandle = ATTHandles.LOUD_SOUND_REDUCTION
)
Spacer(modifier = Modifier.height(12.dp))
StyledToggle(
title = stringResource(R.string.workspace_use),
label = stringResource(R.string.ppe),
description = stringResource(R.string.workspace_use_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG
)
}
}
}

View File

@@ -63,7 +63,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -112,7 +111,7 @@ fun Onboarding(navController: NavController, activityContext: Context) {
kotlinx.coroutines.MainScope().launch {
withContext(Dispatchers.IO) {
try {
val process = Runtime.getRuntime().exec("su -c id")
val process = Runtime.getRuntime().exec("/system/bin/su -c id")
val exitValue = process.waitFor() // no idea why i have this, probably don't need to do this
withContext(Dispatchers.Main) {
rootCheckPassed = (exitValue == 0)
@@ -158,14 +157,14 @@ fun Onboarding(navController: NavController, activityContext: Context) {
StyledScaffold(
title = "Setting Up",
actionButtons = listOf(
{scaffoldBackdrop ->
{
StyledIconButton(
onClick = {
showSkipDialog = true
},
icon = "􀊋",
darkMode = isDarkTheme,
backdrop = scaffoldBackdrop
backdrop = backdrop
)
}
)
@@ -202,7 +201,7 @@ fun Onboarding(navController: NavController, activityContext: Context) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.root_access_required),
text = "Root Access Required",
style = TextStyle(
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
@@ -215,7 +214,7 @@ fun Onboarding(navController: NavController, activityContext: Context) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.this_app_needs_root_access_to_hook_onto_the_bluetooth_library),
text = "This app needs root access to hook onto the Bluetooth library",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
@@ -228,7 +227,7 @@ fun Onboarding(navController: NavController, activityContext: Context) {
if (rootCheckFailed) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.root_access_denied),
text = "Root access was denied. Please grant root permissions.",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Normal,

View File

@@ -1,93 +0,0 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* 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.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun OpenSourceLicensesScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.open_source_licenses)
) { spacerHeight ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
val context = androidx.compose.ui.platform.LocalContext.current
val libraries by produceLibraries {
context.resources.openRawResource(R.raw.aboutlibraries)
.bufferedReader()
.use { it.readText() }
}
LibrariesContainer(
libraries = libraries,
modifier = Modifier
.padding(0.dp)
.fillMaxSize()
)
}
}
}

View File

@@ -110,8 +110,8 @@ fun LongPress(navController: NavController, name: String) {
if (modesByte != null) {
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 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
@@ -122,7 +122,15 @@ fun LongPress(navController: NavController, name: String) {
var longPressAction by remember { mutableStateOf(StemAction.valueOf(longPressActionPref ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) }
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = name
title = name,
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
) { spacerHeight ->
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column (
@@ -214,9 +222,9 @@ fun LongPress(navController: NavController, name: String) {
name = stringResource(R.string.transparency),
description = "Lets in external sounds",
iconRes = R.drawable.transparency,
selected = (currentByte and 0x04) != 0,
selected = (currentByte and 0x02) != 0,
onClick = {
val bit = 0x04
val bit = 0x02
val newValue = if ((currentByte and bit) != 0) {
val temp = currentByte and bit.inv()
if (countEnabledModes(temp) >= 2) temp else currentByte
@@ -260,9 +268,9 @@ fun LongPress(navController: NavController, name: String) {
name = stringResource(R.string.noise_cancellation),
description = "Blocks out external sounds",
iconRes = R.drawable.noise_cancellation,
selected = (currentByte and 0x02) != 0,
selected = (currentByte and 0x04) != 0,
onClick = {
val bit = 0x02
val bit = 0x04
val newValue = if ((currentByte and bit) != 0) {
val temp = currentByte and bit.inv()
if (countEnabledModes(temp) >= 2) temp else currentByte

View File

@@ -87,6 +87,14 @@ fun RenameScreen(navController: NavController) {
StyledScaffold(
title = stringResource(R.string.name),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
},
) { spacerHeight ->
Column(
modifier = Modifier

View File

@@ -100,7 +100,15 @@ fun TransparencySettingsScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.customize_transparency_mode)
title = stringResource(R.string.customize_transparency_mode),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
){ spacerHeight, hazeState ->
Column(
modifier = Modifier

View File

@@ -94,6 +94,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.utils.LogCollector
import java.io.File
@@ -215,7 +216,15 @@ fun TroubleshootingScreen(navController: NavController) {
modifier = Modifier.fillMaxSize()
) {
StyledScaffold(
title = stringResource(R.string.troubleshooting)
title = stringResource(R.string.troubleshooting),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
){ spacerHeight, hazeState ->
Column(
modifier = Modifier
@@ -369,7 +378,7 @@ fun TroubleshootingScreen(navController: NavController) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.troubleshooting_steps),
text = "TROUBLESHOOTING STEPS",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,

View File

@@ -1,359 +0,0 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* 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.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.HearingAidSettings
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.utils.sendHearingAidSettings
import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
private const val TAG = "HearingAidAdjustments"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
val verticalScrollState = rememberScrollState()
val attManager = ServiceManager.getService()?.attManager
if (attManager == null) {
Text(
text = stringResource(R.string.att_manager_is_null_try_reconnecting),
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
textAlign = TextAlign.Center
)
return
}
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.hearing_test)
) { spacerHeight, hazeState ->
Column(
modifier = Modifier
.hazeSource(hazeState)
.fillMaxSize()
.layerBackdrop(backdrop)
.verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
Text(
text = stringResource(R.string.hearing_test_value_instruction),
fontSize = 16.sp,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
val conversationBoostEnabled = remember { mutableStateOf(false) }
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
val initialLoadComplete = remember { mutableStateOf(false) }
val initialReadSucceeded = remember { mutableStateOf(false) }
val initialReadAttempts = remember { mutableIntStateOf(0) }
val hearingAidSettings = remember {
mutableStateOf(
HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
leftAmplification = 0.5f,
rightAmplification = 0.5f,
leftTone = 0.5f,
rightTone = 0.5f,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = 0.0f,
rightAmbientNoiseReduction = 0.0f,
netAmplification = 0.5f,
balance = 0.5f,
ownVoiceAmplification = 0.5f
)
)
}
val hearingAidEnabled = remember {
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
}
val hearingAidListener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
}
}
}
}
val hearingAidATTListener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
val parsed = parseHearingAidSettingsResponse(value)
if (parsed != null) {
leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf()
conversationBoostEnabled.value = parsed.leftConversationBoost
Log.d(TAG, "Updated hearing aid settings from notification")
} else {
Log.w(TAG, "Failed to parse hearing aid settings from notification")
}
}
}
}
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
}
DisposableEffect(Unit) {
onDispose {
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
}
}
LaunchedEffect(leftEQ.value, rightEQ.value, conversationBoostEnabled.value, initialLoadComplete.value, initialReadSucceeded.value) {
if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect
}
if (!initialReadSucceeded.value) {
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
return@LaunchedEffect
}
hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
leftAmplification = 0.5f,
rightAmplification = 0.5f,
leftTone = 0.5f,
rightTone = 0.5f,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = 0.0f,
rightAmbientNoiseReduction = 0.0f,
netAmplification = 0.5f,
balance = 0.5f,
ownVoiceAmplification = 0.5f
)
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
}
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
attManager.enableNotifications(ATTHandles.HEARING_AID)
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
try {
if (aacpManager != null) {
Log.d(TAG, "Found AACPManager, reading cached EQ data")
val aacpEQ = aacpManager.eqData
if (aacpEQ.isNotEmpty()) {
leftEQ.value = aacpEQ.copyOf()
rightEQ.value = aacpEQ.copyOf()
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
} else {
Log.d(TAG, "AACPManager EQ data empty")
}
} else {
Log.d(TAG, "No AACPManager available")
}
} catch (e: Exception) {
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
}
var parsedSettings: HearingAidSettings? = null
for (attempt in 1..3) {
initialReadAttempts.intValue = attempt
try {
val data = attManager.read(ATTHandles.HEARING_AID)
parsedSettings = parseHearingAidSettingsResponse(data = data)
if (parsedSettings != null) {
Log.d(TAG, "Parsed settings on attempt $attempt")
break
} else {
Log.d(TAG, "Parsing returned null on attempt $attempt")
}
} catch (e: Exception) {
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
}
delay(200)
}
if (parsedSettings != null) {
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
leftEQ.value = parsedSettings.leftEQ.copyOf()
rightEQ.value = parsedSettings.rightEQ.copyOf()
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
initialReadSucceeded.value = true
} else {
Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts")
}
} catch (e: IOException) {
e.printStackTrace()
} finally {
initialLoadComplete.value = true
}
}
val frequencies = listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Spacer(modifier = Modifier.width(60.dp))
Text(
text = stringResource(R.string.left),
fontSize = 18.sp,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
Text(
text = stringResource(R.string.right),
fontSize = 18.sp,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
}
frequencies.forEachIndexed { index, freq ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = freq,
modifier = Modifier
.width(60.dp)
.align(Alignment.CenterVertically),
textAlign = TextAlign.End,
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
OutlinedTextField(
value = leftEQ.value[index].toString(),
onValueChange = { newValue ->
val parsed = newValue.toFloatOrNull()
if (parsed != null) {
val newArray = leftEQ.value.copyOf()
newArray[index] = parsed
leftEQ.value = newArray
}
},
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
textStyle = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontSize = 14.sp
),
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = rightEQ.value[index].toString(),
onValueChange = { newValue ->
val parsed = newValue.toFloatOrNull()
if (parsed != null) {
val newArray = rightEQ.value.copyOf()
newArray[index] = parsed
rightEQ.value = newArray
}
},
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
textStyle = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontSize = 14.sp
),
modifier = Modifier.weight(1f)
)
}
}
}
}
}

View File

@@ -1,192 +0,0 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* 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 androidx.compose.foundation.background
import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun VersionScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.customize_adaptive_audio)
) { spacerHeight ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.version),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
)
)
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version) + " 1",
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.version1 ?: "N/A",
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version) + " 2",
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.version2 ?: "N/A",
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version) + " 3",
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.version3 ?: "N/A",
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
}
}

View File

@@ -89,8 +89,6 @@ import me.kavishdevar.librepods.constants.isHeadTrackingData
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType
import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.utils.AirPodsInstance
import me.kavishdevar.librepods.utils.AirPodsModels
import me.kavishdevar.librepods.utils.BLEManager
import me.kavishdevar.librepods.utils.BluetoothConnectionManager
import me.kavishdevar.librepods.utils.CrossDevice
@@ -154,7 +152,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var localMac = ""
lateinit var aacpManager: AACPManager
var attManager: ATTManager? = null
var airpodsInstance: AirPodsInstance? = null
var cameraActive = false
private var disconnectedBecauseReversed = false
private var otherDeviceTookOver = false
@@ -194,19 +191,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var rightLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!,
var cameraAction: AACPManager.Companion.StemPressType? = null,
// AirPods device information
var airpodsName: String = "",
var airpodsModelNumber: String = "",
var airpodsManufacturer: String = "",
var airpodsSerialNumber: String = "",
var airpodsLeftSerialNumber: String = "",
var airpodsRightSerialNumber: String = "",
var airpodsVersion1: String = "",
var airpodsVersion2: String = "",
var airpodsVersion3: String = "",
var airpodsHardwareRevision: String = "",
var airpodsUpdaterIdentifier: String = "",
)
private lateinit var config: ServiceConfig
@@ -229,9 +213,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
private var handleIncomingCallOnceConnected = false
lateinit var bleManager: BLEManager
private lateinit var socket: BluetoothSocket
private val bleStatusListener = object : BLEManager.AirPodsStatusListener {
@SuppressLint("NewApi")
override fun onDeviceStatusChanged(
@@ -368,7 +349,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "settings", "get", "secure", "bluetooth_address"))
val process = Runtime.getRuntime().exec(arrayOf("/system/bin/su", "-c", "settings", "get", "secure", "bluetooth_address"))
val output = process.inputStream.bufferedReader().use { it.readLine() }
localMac = output.trim()
@@ -945,54 +926,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
)
}
override fun onDeviceInformationReceived(deviceInformation: AACPManager.Companion.AirPodsInformation) {
Log.d(
"AirPodsParser",
"Device Information: name: ${deviceInformation.name}, modelNumber: ${deviceInformation.modelNumber}, manufacturer: ${deviceInformation.manufacturer}, serialNumber: ${deviceInformation.serialNumber}, version1: ${deviceInformation.version1}, version2: ${deviceInformation.version2}, hardwareRevision: ${deviceInformation.hardwareRevision}, updaterIdentifier: ${deviceInformation.updaterIdentifier}, leftSerialNumber: ${deviceInformation.leftSerialNumber}, rightSerialNumber: ${deviceInformation.rightSerialNumber}, version3: ${deviceInformation.version3}"
)
// Store in SharedPreferences
sharedPreferences.edit {
putString("airpods_name", deviceInformation.name)
putString("airpods_model_number", deviceInformation.modelNumber)
putString("airpods_manufacturer", deviceInformation.manufacturer)
putString("airpods_serial_number", deviceInformation.serialNumber)
putString("airpods_left_serial_number", deviceInformation.leftSerialNumber)
putString("airpods_right_serial_number", deviceInformation.rightSerialNumber)
putString("airpods_version1", deviceInformation.version1)
putString("airpods_version2", deviceInformation.version2)
putString("airpods_version3", deviceInformation.version3)
putString("airpods_hardware_revision", deviceInformation.hardwareRevision)
putString("airpods_updater_identifier", deviceInformation.updaterIdentifier)
}
// Update config
config.airpodsName = deviceInformation.name
config.airpodsModelNumber = deviceInformation.modelNumber
config.airpodsManufacturer = deviceInformation.manufacturer
config.airpodsSerialNumber = deviceInformation.serialNumber
config.airpodsLeftSerialNumber = deviceInformation.leftSerialNumber
config.airpodsRightSerialNumber = deviceInformation.rightSerialNumber
config.airpodsVersion1 = deviceInformation.version1
config.airpodsVersion2 = deviceInformation.version2
config.airpodsVersion3 = deviceInformation.version3
config.airpodsHardwareRevision = deviceInformation.hardwareRevision
config.airpodsUpdaterIdentifier = deviceInformation.updaterIdentifier
override fun onDeviceMetadataReceived(deviceMetadata: ByteArray) {
val model = AirPodsModels.getModelByModelNumber(config.airpodsModelNumber)
if (model != null) {
airpodsInstance = AirPodsInstance(
name = config.airpodsName,
model = model,
actualModelNumber = config.airpodsModelNumber,
serialNumber = config.airpodsSerialNumber,
leftSerialNumber = config.airpodsLeftSerialNumber,
rightSerialNumber = config.airpodsRightSerialNumber,
version1 = config.airpodsVersion1,
version2 = config.airpodsVersion2,
version3 = config.airpodsVersion3,
aacpManager = aacpManager,
attManager = attManager
)
}
}
@SuppressLint("NewApi")
@@ -1019,7 +954,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
Log.d("AirPodsParser", "Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}")
if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) {
Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27"))
Runtime.getRuntime().exec(arrayOf("/system/bin/su", "-c", "input keyevent 27"))
} else {
val action = getActionFor(bud, stemPressType)
Log.d("AirPodsParser", "$bud $stemPressType action: $action")
@@ -1035,9 +970,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
byteArrayOf(0x00)
)
// this also means that the other device has start playing the audio, and if that's true, we can again start listening for audio config changes
// Log.d(TAG, "Another device started playing audio, listening for audio config changes again")
// MediaController.pausedForOtherDevice = false
// future me: what the heck is this? this just means it will not be taking over again if audio source doesn't change???
Log.d(TAG, "Another device started playing audio, listening for audio config changes again")
MediaController.pausedForOtherDevice = false
}
}
@@ -1230,19 +1164,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
rightLongPressAction = StemAction.fromString(sharedPreferences.getString("right_long_press_action", "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT")!!,
cameraAction = sharedPreferences.getString("camera_action", null)?.let { AACPManager.Companion.StemPressType.valueOf(it) },
// AirPods device information
airpodsName = sharedPreferences.getString("airpods_name", "") ?: "",
airpodsModelNumber = sharedPreferences.getString("airpods_model_number", "") ?: "",
airpodsManufacturer = sharedPreferences.getString("airpods_manufacturer", "") ?: "",
airpodsSerialNumber = sharedPreferences.getString("airpods_serial_number", "") ?: "",
airpodsLeftSerialNumber = sharedPreferences.getString("airpods_left_serial_number", "") ?: "",
airpodsRightSerialNumber = sharedPreferences.getString("airpods_right_serial_number", "") ?: "",
airpodsVersion1 = sharedPreferences.getString("airpods_version1", "") ?: "",
airpodsVersion2 = sharedPreferences.getString("airpods_version2", "") ?: "",
airpodsVersion3 = sharedPreferences.getString("airpods_version3", "") ?: "",
airpodsHardwareRevision = sharedPreferences.getString("airpods_hardware_revision", "") ?: "",
airpodsUpdaterIdentifier = sharedPreferences.getString("airpods_updater_identifier", "") ?: "",
)
}
@@ -1324,19 +1245,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
setupStemActions()
}
"camera_action" -> config.cameraAction = preferences.getString(key, null)?.let { AACPManager.Companion.StemPressType.valueOf(it) }
// AirPods device information
"airpods_name" -> config.airpodsName = preferences.getString(key, "") ?: ""
"airpods_model_number" -> config.airpodsModelNumber = preferences.getString(key, "") ?: ""
"airpods_manufacturer" -> config.airpodsManufacturer = preferences.getString(key, "") ?: ""
"airpods_serial_number" -> config.airpodsSerialNumber = preferences.getString(key, "") ?: ""
"airpods_left_serial_number" -> config.airpodsLeftSerialNumber = preferences.getString(key, "") ?: ""
"airpods_right_serial_number" -> config.airpodsRightSerialNumber = preferences.getString(key, "") ?: ""
"airpods_version1" -> config.airpodsVersion1 = preferences.getString(key, "") ?: ""
"airpods_version2" -> config.airpodsVersion2 = preferences.getString(key, "") ?: ""
"airpods_version3" -> config.airpodsVersion3 = preferences.getString(key, "") ?: ""
"airpods_hardware_revision" -> config.airpodsHardwareRevision = preferences.getString(key, "") ?: ""
"airpods_updater_identifier" -> config.airpodsUpdaterIdentifier = preferences.getString(key, "") ?: ""
}
if (key == "mac_address") {
@@ -1846,6 +1754,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
notificationManager.notify(1, updatedNotification)
notificationManager.cancel(2)
} else if (!config.bleOnlyMode && !socket.isConnected && isConnectedLocally) {
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected")
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
}
}
@@ -2056,17 +1965,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
private fun setMetadatas(d: BluetoothDevice) {
d.let{ device ->
val instance = airpodsInstance
if (instance != null) {
val metadataSet = SystemApisUtils.setMetadata(
val metadataSet = SystemApisUtils.setMetadata(
device,
device.METADATA_MAIN_ICON,
resToUri(instance.model.budCaseRes).toString().toByteArray()
resToUri(R.drawable.pro_2).toString().toByteArray()
) &&
SystemApisUtils.setMetadata(
device,
device.METADATA_MODEL_NAME,
instance.model.name.toByteArray()
"AirPods Pro (2 Gen.)".toByteArray()
) &&
SystemApisUtils.setMetadata(
device,
@@ -2076,22 +1983,22 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
SystemApisUtils.setMetadata(
device,
device.METADATA_UNTETHERED_CASE_ICON,
resToUri(instance.model.caseRes).toString().toByteArray()
resToUri(R.drawable.pro_2_case).toString().toByteArray()
) &&
SystemApisUtils.setMetadata(
device,
device.METADATA_UNTETHERED_RIGHT_ICON,
resToUri(instance.model.rightBudsRes).toString().toByteArray()
resToUri(R.drawable.pro_2_right).toString().toByteArray()
) &&
SystemApisUtils.setMetadata(
device,
device.METADATA_UNTETHERED_LEFT_ICON,
resToUri(instance.model.leftBudsRes).toString().toByteArray()
resToUri(R.drawable.pro_2_left).toString().toByteArray()
) &&
SystemApisUtils.setMetadata(
device,
device.METADATA_MANUFACTURER_NAME,
instance.model.manufacturer.toByteArray()
"Apple".toByteArray()
) &&
SystemApisUtils.setMetadata(
device,
@@ -2113,10 +2020,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
device.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD,
"20".toByteArray()
)
Log.d(TAG, "Metadata set: $metadataSet")
} else {
Log.w(TAG, "AirPods instance is not of type AirPodsInstance, skipping metadata setting")
}
Log.d(TAG, "Metadata set: $metadataSet")
}
}
@@ -2138,10 +2042,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val name = context?.getSharedPreferences("settings", MODE_PRIVATE)
?.getString("name", bluetoothDevice?.name)
if (bluetoothDevice != null && action != null && !action.isEmpty()) {
Log.d(TAG, "Received bluetooth connection broadcast: action=$action")
Log.d(TAG, "Received bluetooth connection broadcast")
if (ServiceManager.getService()?.isConnectedLocally == true) {
Log.d(TAG, "Device is already connected locally, checking if we should keep audio connected")
if (ServiceManager.getService()?.socket?.isConnected == true) ServiceManager.getService()?.manuallyCheckForAudioSource() else Log.d(TAG, "We're not connected, ignoring")
ServiceManager.getService()?.manuallyCheckForAudioSource()
return
}
if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
@@ -2178,14 +2081,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
return START_STICKY
}
private lateinit var socket: BluetoothSocket
fun manuallyCheckForAudioSource() {
val shouldResume = MediaController.getMusicActive() // todo: for some reason we lose this info after disconnecting, probably android dispatches some event. haven't investigated yet.
if (airpodsInstance == null) return
Log.d(TAG, "disconnectedBecauseReversed: $disconnectedBecauseReversed, otherDeviceTookOver: $otherDeviceTookOver")
if ((earDetectionNotification.status[0] != 0.toByte() && earDetectionNotification.status[1] != 0.toByte()) || disconnectedBecauseReversed || otherDeviceTookOver) {
val shouldResume = MediaController.getMusicActive()
if ((earDetectionNotification.status[0] != 0.toByte() && earDetectionNotification.status[1] != 0.toByte()) || disconnectedBecauseReversed || otherDeviceTookOver) {
Log.d(
TAG,
"For some reason, Android connected to the audio profile itself even after disconnecting. Disconnecting audio profile again! I will resume: $shouldResume"
"For some reason, Android connected to the audio profile itself even after disconnecting. Disconnecting audio profile again!"
)
disconnectAudio(this, device, shouldResume = shouldResume)
}
@@ -2381,7 +2284,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
fun connectToSocket(device: BluetoothDevice, manual: Boolean = false) {
fun connectToSocket(device: BluetoothDevice) {
Log.d(TAG, "<LogCollector:Start> Connecting to socket")
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
@@ -2390,7 +2293,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
createBluetoothSocket(device, uuid)
} catch (e: Exception) {
Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}")
showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}")
showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.message}")
return
}
@@ -2407,26 +2310,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
attManager = ATTManager(device)
attManager!!.connect()
// Create AirPodsInstance from stored config if available
if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) {
val model = AirPodsModels.getModelByModelNumber(config.airpodsModelNumber)
if (model != null) {
airpodsInstance = AirPodsInstance(
name = config.airpodsName,
model = model,
actualModelNumber = config.airpodsModelNumber,
serialNumber = config.airpodsSerialNumber,
leftSerialNumber = config.airpodsLeftSerialNumber,
rightSerialNumber = config.airpodsRightSerialNumber,
version1 = config.airpodsVersion1,
version2 = config.airpodsVersion2,
version3 = config.airpodsVersion3,
aacpManager = aacpManager,
attManager = attManager
)
}
}
updateNotificationContent(
true,
config.deviceName,
@@ -2434,29 +2317,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
)
Log.d(TAG, "<LogCollector:Complete:Success> Socket connected")
} catch (e: Exception) {
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected, ${e.message}")
if (manual) {
sendToast(
"Couldn't connect to socket: ${e.localizedMessage}"
)
} else {
showSocketConnectionFailureNotification("Couldn't connect to socket: ${e.localizedMessage}")
}
return@withTimeout
// throw e // lol how did i not catch this before... gonna comment this line instead of removing to preserve history
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected")
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
throw e
}
}
}
if (!socket.isConnected) {
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected")
if (manual) {
sendToast(
"Couldn't connect to socket: timeout."
)
} else {
showSocketConnectionFailureNotification("Couldn't connect to socket: Timeout")
if (!socket.isConnected) {
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected")
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
}
return
}
this@AirPodsService.device = device
socket.let {
@@ -2536,7 +2405,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} catch (e: Exception) {
e.printStackTrace()
Log.d(TAG, "Failed to connect to socket: ${e.message}")
showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}")
showSocketConnectionFailureNotification("Failed to establish connection: ${e.message}")
isConnectedLocally = false
this@AirPodsService.device = device
updateNotificationContent(false)
@@ -2544,7 +2413,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
fun disconnectForCD() {
fun disconnect() {
if (!this::socket.isInitialized) return
socket.close()
MediaController.pausedWhileTakingOver = false
@@ -2569,33 +2438,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
CrossDevice.isAvailable = true
}
fun disconnectAirPods() {
if (!this::socket.isInitialized) return
socket.close()
isConnectedLocally = false
aacpManager.disconnected()
attManager?.disconnect()
updateNotificationContent(false)
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.A2DP) {
val connectedDevices = proxy.connectedDevices
if (connectedDevices.isNotEmpty()) {
MediaController.sendPause()
}
}
bluetoothAdapter.closeProfileProxy(profile, proxy)
}
override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP)
Log.d(TAG, "Disconnected AirPods upon user request")
}
val earDetectionNotification = AirPodsNotifications.EarDetection()
val ancNotification = AirPodsNotifications.ANC()
val batteryNotification = AirPodsNotifications.BatteryNotification()
@@ -2794,19 +2636,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
isHeadTrackingActive = false
}
@SuppressLint("MissingPermission")
fun reconnectFromSavedMac(){
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
device = bluetoothAdapter.bondedDevices.find {
it.address == macAddress
}
if (device != null) {
CoroutineScope(Dispatchers.IO).launch {
connectToSocket(device!!, manual = true)
}
}
}
}
private fun Int.dpToPx(): Int {

View File

@@ -42,7 +42,7 @@ class AACPManager {
const val CONTROL_COMMAND: Byte = 0x09
const val EAR_DETECTION: Byte = 0x06
const val CONVERSATION_AWARENESS: Byte = 0x4B
const val INFORMATION: Byte = 0x1D
const val DEVICE_METADATA: Byte = 0x1D
const val RENAME: Byte = 0x1E
const val HEADTRACKING: Byte = 0x17
const val PROXIMITY_KEYS_REQ: Byte = 0x30
@@ -118,9 +118,7 @@ class AACPManager {
ALLOW_AUTO_CONNECT(0x36), // not sure what this does, AUTOMATIC_CONNECTION is the only one used, but this is newer... so ¯\_(ツ)_/¯
EAR_DETECTION_CONFIG(0x0A),
AUTOMATIC_CONNECTION_CONFIG(0x20),
OWNS_CONNECTION(0x06),
PPE_TOGGLE_CONFIG(0x37),
PPE_CAP_LEVEL_CONFIG(0x38);
OWNS_CONNECTION(0x06);
companion object {
fun fromByte(byte: Byte): ControlCommandIdentifiers? =
@@ -183,20 +181,6 @@ class AACPManager {
val info2: Byte,
var type: String?
)
data class AirPodsInformation(
val name: String,
val modelNumber: String,
val manufacturer: String,
val serialNumber: String,
val version1: String,
val version2: String,
val hardwareRevision: String,
val updaterIdentifier: String,
val leftSerialNumber: String,
val rightSerialNumber: String,
val version3: String
)
}
var controlCommandStatusList: MutableList<ControlCommandStatus> =
@@ -255,7 +239,7 @@ class AACPManager {
fun onEarDetectionReceived(earDetection: ByteArray)
fun onConversationAwarenessReceived(conversationAwareness: ByteArray)
fun onControlCommandReceived(controlCommand: ByteArray)
fun onDeviceInformationReceived(deviceInformation: AirPodsInformation)
fun onDeviceMetadataReceived(deviceMetadata: ByteArray)
fun onHeadTrackingReceived(headTracking: ByteArray)
fun onUnknownPacketReceived(packet: ByteArray)
fun onProximityKeysReceived(proximityKeys: ByteArray)
@@ -497,6 +481,10 @@ class AACPManager {
callback?.onConversationAwarenessReceived(packet)
}
Opcodes.DEVICE_METADATA -> {
callback?.onDeviceMetadataReceived(packet)
}
Opcodes.HEADTRACKING -> {
if (packet.size < 70) {
Log.w(
@@ -596,14 +584,8 @@ class AACPManager {
eqData = FloatArray(8) { i -> eq1.get(i) }
Log.d(TAG, "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia")
}
Opcodes.INFORMATION -> {
Log.e(TAG, "Parsing Information Packet")
val information = parseInformationPacket(packet)
callback?.onDeviceInformationReceived(information)
}
else -> {
Log.d(TAG, "Unknown opcode received: ${opcode.toHexString()}")
callback?.onUnknownPacketReceived(packet)
}
}
@@ -782,9 +764,7 @@ class AACPManager {
fun sendMediaInformationNewDevice(selfMacAddress: String, targetMacAddress: String): Boolean {
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
// throw IllegalArgumentException("MAC address must be 6 bytes")
Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress")
return false
throw IllegalArgumentException("MAC address must be 6 bytes")
}
Log.d(TAG, "SELFMAC: ${selfMacAddress}, TARGETMAC: $targetMacAddress")
Log.d(TAG, "Sending Media Information packet to $targetMacAddress")
@@ -824,9 +804,7 @@ class AACPManager {
fun sendHijackRequest(selfMacAddress: String): Boolean {
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
// throw IllegalArgumentException("MAC address must be 6 bytes")
Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress")
return false
throw IllegalArgumentException("MAC address must be 6 bytes")
}
var success = false
for (connectedDevice in connectedDevices) {
@@ -867,9 +845,7 @@ class AACPManager {
fun sendMediaInformataion(selfMacAddress: String, streamingState: Boolean = false): Boolean {
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
// throw IllegalArgumentException("MAC address must be 6 bytes")
Log.d(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress")
return false
throw IllegalArgumentException("MAC address must be 6 bytes")
}
Log.d(TAG, "SELFMAC: $selfMacAddress")
val targetMac = connectedDevices.find { it.mac != selfMacAddress }?.mac
@@ -928,9 +904,7 @@ class AACPManager {
fun sendSmartRoutingShowUI(selfMacAddress: String): Boolean {
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
// throw IllegalArgumentException("MAC address must be 6 bytes")
Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress")
return false
throw IllegalArgumentException("MAC address must be 6 bytes")
}
val targetMac = connectedDevices.find { it.mac != selfMacAddress }?.mac
@@ -1006,9 +980,7 @@ class AACPManager {
fun sendAddTiPiDevice(selfMacAddress: String, targetMacAddress: String): Boolean {
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
// throw IllegalArgumentException("MAC address must be 6 bytes")
Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress")
return false
throw IllegalArgumentException("MAC address must be 6 bytes")
}
Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress")
return sendDataPacket(createAddTiPiDevicePacket(selfMacAddress, targetMacAddress))
@@ -1236,39 +1208,4 @@ class AACPManager {
connectedDevices = listOf()
audioSource = null
}
fun parseInformationPacket(packet: ByteArray): AirPodsInformation {
val data = packet.sliceArray(6 until packet.size)
var index = 0
while (index < data.size && data[index] != 0x00.toByte()) index++
val strings = mutableListOf<String>()
while (index < data.size) {
// skip 0x00 bytes
while (index < data.size && data[index] == 0x00.toByte()) index++
if (index >= data.size) break
val start = index
// find next 0x00 byte
while (index < data.size && data[index] != 0x00.toByte()) index++
val str = data.sliceArray(start until index).decodeToString()
strings.add(str)
}
strings.removeAt(0) // I'm too lazy to adjust, just removing the first empty string
return AirPodsInformation(
name = strings.getOrNull(0) ?: "",
modelNumber = strings.getOrNull(1) ?: "",
manufacturer = strings.getOrNull(2) ?: "",
serialNumber = strings.getOrNull(3) ?: "",
version1 = strings.getOrNull(4) ?: "",
version2 = strings.getOrNull(5) ?: "",
hardwareRevision = strings.getOrNull(6) ?: "",
updaterIdentifier = strings.getOrNull(7) ?: "",
leftSerialNumber = strings.getOrNull(8) ?: "",
rightSerialNumber = strings.getOrNull(9) ?: "",
version3 = strings.getOrNull(10) ?: "",
)
}
}

View File

@@ -1,233 +0,0 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* 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 me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.R
open class AirPodsBase(
val modelNumber: List<String>,
val name: String,
val displayName: String = "AirPods",
val manufacturer: String = "Apple Inc.",
val budCaseRes: Int,
val budsRes: Int,
val leftBudsRes: Int,
val rightBudsRes: Int,
val caseRes: Int,
val capabilities: Set<Capability>
)
enum class Capability {
LISTENING_MODE,
CONVERSATION_AWARENESS,
STEM_CONFIG,
HEAD_GESTURES,
LOUD_SOUND_REDUCTION,
PPE,
SLEEP_DETECTION,
HEARING_AID,
ADAPTIVE_AUDIO,
ADAPTIVE_VOLUME,
SWIPE_FOR_VOLUME,
HRM
}
class AirPods: AirPodsBase(
modelNumber = listOf("A1523", "A1722"),
name = "AirPods 1",
budCaseRes = R.drawable.airpods_1,
budsRes = R.drawable.airpods_1_buds,
leftBudsRes = R.drawable.airpods_1_left,
rightBudsRes = R.drawable.airpods_1_right,
caseRes = R.drawable.airpods_1_case,
capabilities = emptySet()
)
class AirPods2: AirPodsBase(
modelNumber = listOf("A2032", "A2031"),
name = "AirPods 2",
budCaseRes = R.drawable.airpods_2,
budsRes = R.drawable.airpods_2_buds,
leftBudsRes = R.drawable.airpods_2_left,
rightBudsRes = R.drawable.airpods_2_right,
caseRes = R.drawable.airpods_2_case,
capabilities = emptySet()
)
class AirPods3: AirPodsBase(
modelNumber = listOf("A2565", "A2564"),
name = "AirPods 3",
budCaseRes = R.drawable.airpods_3,
budsRes = R.drawable.airpods_3_buds,
leftBudsRes = R.drawable.airpods_3_left,
rightBudsRes = R.drawable.airpods_3_right,
caseRes = R.drawable.airpods_3_case,
capabilities = setOf(
Capability.HEAD_GESTURES
)
)
class AirPods4: AirPodsBase(
modelNumber = listOf("A3053", "A3050", "A3054"),
name = "AirPods 4",
budCaseRes = R.drawable.airpods_4,
budsRes = R.drawable.airpods_4_buds,
leftBudsRes = R.drawable.airpods_4_left,
rightBudsRes = R.drawable.airpods_4_right,
caseRes = R.drawable.airpods_4_case,
capabilities = setOf(
Capability.HEAD_GESTURES,
Capability.SLEEP_DETECTION,
Capability.ADAPTIVE_VOLUME
)
)
class AirPods4ANC: AirPodsBase(
modelNumber = listOf("A3056", "A3055", "A3057"),
name = "AirPods 4 (ANC)",
budCaseRes = R.drawable.airpods_4,
budsRes = R.drawable.airpods_4_buds,
leftBudsRes = R.drawable.airpods_4_left,
rightBudsRes = R.drawable.airpods_4_right,
caseRes = R.drawable.airpods_4_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.HEAD_GESTURES,
Capability.ADAPTIVE_AUDIO,
Capability.SLEEP_DETECTION,
Capability.ADAPTIVE_VOLUME
)
)
class AirPodsPro1: AirPodsBase(
modelNumber = listOf("A2084", "A2083"),
name = "AirPods Pro 1",
displayName = "AirPods Pro",
budCaseRes = R.drawable.airpods_pro_1,
budsRes = R.drawable.airpods_pro_1_buds,
leftBudsRes = R.drawable.airpods_pro_1_left,
rightBudsRes = R.drawable.airpods_pro_1_right,
caseRes = R.drawable.airpods_pro_1_case,
capabilities = setOf(
Capability.LISTENING_MODE
)
)
class AirPodsPro2Lightning: AirPodsBase(
modelNumber = listOf("A2931", "A2699", "A2698"),
name = "AirPods Pro 2 with Magsafe Charging Case (Lightning)",
displayName = "AirPods Pro",
budCaseRes = R.drawable.airpods_pro_2,
budsRes = R.drawable.airpods_pro_2_buds,
leftBudsRes = R.drawable.airpods_pro_2_left,
rightBudsRes = R.drawable.airpods_pro_2_right,
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.STEM_CONFIG,
Capability.LOUD_SOUND_REDUCTION,
Capability.SLEEP_DETECTION,
Capability.HEARING_AID,
Capability.ADAPTIVE_AUDIO,
Capability.ADAPTIVE_VOLUME,
Capability.SWIPE_FOR_VOLUME
)
)
class AirPodsPro2USBC: AirPodsBase(
modelNumber = listOf("A3047", "A3048", "A3049"),
name = "AirPods Pro 2 with Magsafe Charging Case (USB-C)",
displayName = "AirPods Pro",
budCaseRes = R.drawable.airpods_pro_2,
budsRes = R.drawable.airpods_pro_2_buds,
leftBudsRes = R.drawable.airpods_pro_2_left,
rightBudsRes = R.drawable.airpods_pro_2_right,
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.STEM_CONFIG,
Capability.LOUD_SOUND_REDUCTION,
Capability.SLEEP_DETECTION,
Capability.HEARING_AID,
Capability.ADAPTIVE_AUDIO,
Capability.ADAPTIVE_VOLUME,
Capability.SWIPE_FOR_VOLUME
)
)
class AirPodsPro3: AirPodsBase(
modelNumber = listOf("A3063", "A3064", "A3065"),
name = "AirPods Pro 3",
displayName = "AirPods Pro",
budCaseRes = R.drawable.airpods_pro_3,
budsRes = R.drawable.airpods_pro_3_buds,
leftBudsRes = R.drawable.airpods_pro_3_left,
rightBudsRes = R.drawable.airpods_pro_3_right,
caseRes = R.drawable.airpods_pro_3_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.HEAD_GESTURES,
Capability.STEM_CONFIG,
Capability.LOUD_SOUND_REDUCTION,
Capability.PPE,
Capability.SLEEP_DETECTION,
Capability.HEARING_AID,
Capability.ADAPTIVE_AUDIO,
Capability.ADAPTIVE_VOLUME,
Capability.SWIPE_FOR_VOLUME,
Capability.HRM
)
)
data class AirPodsInstance(
val name: String,
val model: AirPodsBase,
val actualModelNumber: String,
val serialNumber: String?,
val leftSerialNumber: String?,
val rightSerialNumber: String?,
val version1: String?,
val version2: String?,
val version3: String?,
val aacpManager: AACPManager,
val attManager: ATTManager?
)
object AirPodsModels {
val models: List<AirPodsBase> = listOf(
AirPods(),
AirPods2(),
AirPods3(),
AirPods4(),
AirPods4ANC(),
AirPodsPro1(),
AirPodsPro2Lightning(),
AirPodsPro2USBC(),
AirPodsPro3()
)
fun getModelByModelNumber(modelNumber: String): AirPodsBase? {
return models.find { modelNumber in it.modelNumber }
}
}

View File

@@ -390,7 +390,6 @@ class BLEManager(private val context: Context) {
private fun cleanupStaleDevices() {
val now = System.currentTimeMillis()
val staleCutoff = now - STALE_DEVICE_TIMEOUT_MS
val hadDevices = deviceStatusMap.isNotEmpty()
val staleDevices = deviceStatusMap.filter { it.value.lastSeen < staleCutoff }
@@ -399,7 +398,7 @@ class BLEManager(private val context: Context) {
Log.d(TAG, "Removed stale device from tracking: ${device.key}")
}
if (hadDevices && deviceStatusMap.isEmpty()) {
if (deviceStatusMap.isEmpty()) {
airPodsStatusListener?.onDeviceDisappeared()
}
}

View File

@@ -200,7 +200,7 @@ object CrossDevice {
notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!)
break
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet) || packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet + CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
ServiceManager.getService()?.disconnectForCD()
ServiceManager.getService()?.disconnect()
disconnectionRequested = true
CoroutineScope(Dispatchers.IO).launch {
delay(1000)

View File

@@ -1,190 +0,0 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* 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.util.Log
import androidx.compose.runtime.MutableState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
private const val TAG = "HearingAidUtils"
data class HearingAidSettings(
val leftEQ: FloatArray,
val rightEQ: FloatArray,
val leftAmplification: Float,
val rightAmplification: Float,
val leftTone: Float,
val rightTone: Float,
val leftConversationBoost: Boolean,
val rightConversationBoost: Boolean,
val leftAmbientNoiseReduction: Float,
val rightAmbientNoiseReduction: Float,
val netAmplification: Float,
val balance: Float,
val ownVoiceAmplification: Float
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as HearingAidSettings
if (leftAmplification != other.leftAmplification) return false
if (rightAmplification != other.rightAmplification) return false
if (leftTone != other.leftTone) return false
if (rightTone != other.rightTone) return false
if (leftConversationBoost != other.leftConversationBoost) return false
if (rightConversationBoost != other.rightConversationBoost) return false
if (leftAmbientNoiseReduction != other.leftAmbientNoiseReduction) return false
if (rightAmbientNoiseReduction != other.rightAmbientNoiseReduction) return false
if (!leftEQ.contentEquals(other.leftEQ)) return false
if (!rightEQ.contentEquals(other.rightEQ)) return false
if (ownVoiceAmplification != other.ownVoiceAmplification) return false
return true
}
override fun hashCode(): Int {
var result = leftAmplification.hashCode()
result = 31 * result + rightAmplification.hashCode()
result = 31 * result + leftTone.hashCode()
result = 31 * result + rightTone.hashCode()
result = 31 * result + leftConversationBoost.hashCode()
result = 31 * result + rightConversationBoost.hashCode()
result = 31 * result + leftAmbientNoiseReduction.hashCode()
result = 31 * result + rightAmbientNoiseReduction.hashCode()
result = 31 * result + leftEQ.contentHashCode()
result = 31 * result + rightEQ.contentHashCode()
result = 31 * result + ownVoiceAmplification.hashCode()
return result
}
}
fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? {
if (data.size < 104) return null
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
buffer.get() // skip 0x02
buffer.get() // skip 0x02
buffer.getShort() // skip 0x60 0x00
val leftEQ = FloatArray(8)
for (i in 0..7) {
leftEQ[i] = buffer.float
}
val leftAmplification = buffer.float
val leftTone = buffer.float
val leftConvFloat = buffer.float
val leftConversationBoost = leftConvFloat > 0.5f
val leftAmbientNoiseReduction = buffer.float
val rightEQ = FloatArray(8)
for (i in 0..7) {
rightEQ[i] = buffer.float
}
val rightAmplification = buffer.float
val rightTone = buffer.float
val rightConvFloat = buffer.float
val rightConversationBoost = rightConvFloat > 0.5f
val rightAmbientNoiseReduction = buffer.float
val ownVoiceAmplification = buffer.float
val avg = (leftAmplification + rightAmplification) / 2
val amplification = avg.coerceIn(-1f, 1f)
val diff = rightAmplification - leftAmplification
val balance = diff.coerceIn(-1f, 1f)
return HearingAidSettings(
leftEQ = leftEQ,
rightEQ = rightEQ,
leftAmplification = leftAmplification,
rightAmplification = rightAmplification,
leftTone = leftTone,
rightTone = rightTone,
leftConversationBoost = leftConversationBoost,
rightConversationBoost = rightConversationBoost,
leftAmbientNoiseReduction = leftAmbientNoiseReduction,
rightAmbientNoiseReduction = rightAmbientNoiseReduction,
netAmplification = amplification,
balance = balance,
ownVoiceAmplification = ownVoiceAmplification
)
}
fun sendHearingAidSettings(
attManager: ATTManager,
hearingAidSettings: HearingAidSettings,
debounceJob: MutableState<Job?>
) {
debounceJob.value?.cancel()
debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
delay(100)
try {
val currentData = attManager.read(ATTHandles.HEARING_AID)
Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
if (currentData.size < 104) {
Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings")
return@launch
}
val buffer = ByteBuffer.wrap(currentData).order(ByteOrder.LITTLE_ENDIAN)
// for some reason
buffer.put(2, 0x64)
// Left EQ
for (i in 0..7) {
buffer.putFloat(4 + i * 4, hearingAidSettings.leftEQ[i])
}
// Left ear adjustments
buffer.putFloat(36, hearingAidSettings.leftAmplification)
buffer.putFloat(40, hearingAidSettings.leftTone)
buffer.putFloat(44, if (hearingAidSettings.leftConversationBoost) 1.0f else 0.0f)
buffer.putFloat(48, hearingAidSettings.leftAmbientNoiseReduction)
// Right EQ
for (i in 0..7) {
buffer.putFloat(52 + i * 4, hearingAidSettings.rightEQ[i])
}
// Right ear adjustments
buffer.putFloat(84, hearingAidSettings.rightAmplification)
buffer.putFloat(88, hearingAidSettings.rightTone)
buffer.putFloat(92, if (hearingAidSettings.rightConversationBoost) 1.0f else 0.0f)
buffer.putFloat(96, hearingAidSettings.rightAmbientNoiseReduction)
// Own voice amplification
buffer.putFloat(100, hearingAidSettings.ownVoiceAmplification)
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
attManager.write(ATTHandles.HEARING_AID, currentData)
} catch (e: IOException) {
e.printStackTrace()
}
}
}

View File

@@ -201,7 +201,7 @@ class LogCollector(private val context: Context) {
private suspend fun executeRootCommand(command: String): String {
return withContext(Dispatchers.IO) {
try {
val process = Runtime.getRuntime().exec("su -c $command")
val process = Runtime.getRuntime().exec("/system/bin/su -c $command")
val reader = BufferedReader(InputStreamReader(process.inputStream))
val output = StringBuilder()
var line: String?

View File

@@ -196,7 +196,7 @@ object MediaController {
}
}
lastKnownIsMusicActive = hasNewMusicOrMovie && isActive
lastKnownIsMusicActive = isActive
}
}

View File

@@ -74,7 +74,7 @@ class RadareOffsetFinder(context: Context) {
fun clearHookOffsets(): Boolean {
try {
val process = Runtime.getRuntime().exec(arrayOf(
"su", "-c",
"/system/bin/su", "-c",
"/system/bin/setprop $HOOK_OFFSET_PROP '' && " +
"/system/bin/setprop $CFG_REQ_OFFSET_PROP '' && " +
"/system/bin/setprop $CSM_CONFIG_OFFSET_PROP '' && " +
@@ -98,7 +98,7 @@ class RadareOffsetFinder(context: Context) {
fun clearSdpOffset(): Boolean {
try {
val process = Runtime.getRuntime().exec(arrayOf(
"su", "-c", "/system/bin/setprop $SDP_OFFSET_PROP ''"
"/system/bin/su", "-c", "/system/bin/setprop $SDP_OFFSET_PROP ''"
))
val exitCode = process.waitFor()
@@ -288,14 +288,14 @@ class RadareOffsetFinder(context: Context) {
}
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("/system/bin/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()
Runtime.getRuntime().exec(arrayOf("/system/bin/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")
arrayOf("/system/bin/su", "-c", "tar xvf ${radare2TarballFile.absolutePath} -C $EXTRACT_DIR")
)
val reader = BufferedReader(InputStreamReader(process.inputStream))
@@ -327,7 +327,7 @@ class RadareOffsetFinder(context: Context) {
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'")
arrayOf("/system/bin/su", "-c", "[ -d $EXTRACT_DIR/data/local/tmp/aln_unzip ] && echo 'exists'")
)
val dirExists = BufferedReader(InputStreamReader(checkDirProcess.inputStream)).readLine() == "exists"
checkDirProcess.waitFor()
@@ -338,7 +338,7 @@ class RadareOffsetFinder(context: Context) {
}
val tarProcess = Runtime.getRuntime().exec(
arrayOf("su", "-c", "tar tf ${radare2TarballFile.absolutePath}")
arrayOf("/system/bin/su", "-c", "tar tf ${radare2TarballFile.absolutePath}")
)
val tarFiles = BufferedReader(InputStreamReader(tarProcess.inputStream)).readLines()
.filter { it.isNotEmpty() }
@@ -352,7 +352,7 @@ class RadareOffsetFinder(context: Context) {
}
val findProcess = Runtime.getRuntime().exec(
arrayOf("su", "-c", "find $EXTRACT_DIR/data/local/tmp/aln_unzip -type f | sort")
arrayOf("/system/bin/su", "-c", "find $EXTRACT_DIR/data/local/tmp/aln_unzip -type f | sort")
)
val extractedFiles = BufferedReader(InputStreamReader(findProcess.inputStream)).readLines()
.filter { it.isNotEmpty() }
@@ -370,14 +370,14 @@ class RadareOffsetFinder(context: Context) {
val filePathInExtractDir = "$EXTRACT_DIR/$tarFile"
val fileCheckProcess = Runtime.getRuntime().exec(
arrayOf("su", "-c", "[ -f $filePathInExtractDir ] && echo 'exists'")
arrayOf("/system/bin/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()
Runtime.getRuntime().exec(arrayOf("/system/bin/su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor()
return@withContext false
}
}
@@ -394,13 +394,13 @@ class RadareOffsetFinder(context: Context) {
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")
arrayOf("/system/bin/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")
arrayOf("/system/bin/su", "-c", "chmod -R 755 $BUSYBOX_PATH")
).waitFor()
if (chmod1Result == 0 && chmod2Result == 0) {
@@ -421,8 +421,8 @@ class RadareOffsetFinder(context: Context) {
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()
@Suppress("LocalVariableName") val currentLD_LIBRARY_PATH = ProcessBuilder().command("/system/bin/su", "-c", "printenv LD_LIBRARY_PATH").start().inputStream.bufferedReader().readText().trim()
val currentPATH = ProcessBuilder().command("/system/bin/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"
@@ -431,7 +431,7 @@ class RadareOffsetFinder(context: Context) {
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 process = Runtime.getRuntime().exec(arrayOf("/system/bin/su", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
@@ -484,7 +484,7 @@ class RadareOffsetFinder(context: Context) {
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 process = Runtime.getRuntime().exec(arrayOf("/system/bin/su", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
@@ -515,7 +515,7 @@ class RadareOffsetFinder(context: Context) {
if (offset > 0L) {
val hexString = "0x${offset.toString(16)}"
Runtime.getRuntime().exec(arrayOf(
"su", "-c", "/system/bin/setprop $CFG_REQ_OFFSET_PROP $hexString"
"/system/bin/su", "-c", "/system/bin/setprop $CFG_REQ_OFFSET_PROP $hexString"
)).waitFor()
Log.d(TAG, "Saved l2cu_process_our_cfg_req offset: $hexString")
}
@@ -529,7 +529,7 @@ class RadareOffsetFinder(context: Context) {
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 process = Runtime.getRuntime().exec(arrayOf("/system/bin/su", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
@@ -560,7 +560,7 @@ class RadareOffsetFinder(context: Context) {
if (offset > 0L) {
val hexString = "0x${offset.toString(16)}"
Runtime.getRuntime().exec(arrayOf(
"su", "-c", "/system/bin/setprop $CSM_CONFIG_OFFSET_PROP $hexString"
"/system/bin/su", "-c", "/system/bin/setprop $CSM_CONFIG_OFFSET_PROP $hexString"
)).waitFor()
Log.d(TAG, "Saved l2c_csm_config offset: $hexString")
}
@@ -574,7 +574,7 @@ class RadareOffsetFinder(context: Context) {
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 process = Runtime.getRuntime().exec(arrayOf("/system/bin/su", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
@@ -605,7 +605,7 @@ class RadareOffsetFinder(context: Context) {
if (offset > 0L) {
val hexString = "0x${offset.toString(16)}"
Runtime.getRuntime().exec(arrayOf(
"su", "-c", "/system/bin/setprop $PEER_INFO_REQ_OFFSET_PROP $hexString"
"/system/bin/su", "-c", "/system/bin/setprop $PEER_INFO_REQ_OFFSET_PROP $hexString"
)).waitFor()
Log.d(TAG, "Saved l2cu_send_peer_info_req offset: $hexString")
}
@@ -619,7 +619,7 @@ class RadareOffsetFinder(context: Context) {
val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep DmSetLocalDiRecord"
Log.d(TAG, "Running command: $command")
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
val process = Runtime.getRuntime().exec(arrayOf("/system/bin/su", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
@@ -650,7 +650,7 @@ class RadareOffsetFinder(context: Context) {
if (offset > 0L) {
val hexString = "0x${offset.toString(16)}"
Runtime.getRuntime().exec(arrayOf(
"su", "-c", "/system/bin/setprop $SDP_OFFSET_PROP $hexString"
"/system/bin/su", "-c", "/system/bin/setprop $SDP_OFFSET_PROP $hexString"
)).waitFor()
Log.d(TAG, "Saved DmSetLocalDiRecord offset: $hexString")
}
@@ -665,7 +665,7 @@ class RadareOffsetFinder(context: Context) {
Log.d(TAG, "Saving offset to system property: $hexString")
val process = Runtime.getRuntime().exec(arrayOf(
"su", "-c", "/system/bin/setprop $HOOK_OFFSET_PROP $hexString"
"/system/bin/su", "-c", "/system/bin/setprop $HOOK_OFFSET_PROP $hexString"
))
val exitCode = process.waitFor()
@@ -694,7 +694,7 @@ class RadareOffsetFinder(context: Context) {
private fun cleanupExtractedFiles() {
try {
Runtime.getRuntime().exec(arrayOf("su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor()
Runtime.getRuntime().exec(arrayOf("/system/bin/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)
@@ -732,8 +732,8 @@ class RadareOffsetFinder(context: Context) {
return@withContext false
}
@Suppress("LocalVariableName") val currentLD_LIBRARY_PATH = ProcessBuilder().command("su", "-c", "printenv LD_LIBRARY_PATH").start().inputStream.bufferedReader().readText().trim()
val currentPATH = ProcessBuilder().command("su", "-c", "printenv PATH").start().inputStream.bufferedReader().readText().trim()
@Suppress("LocalVariableName") val currentLD_LIBRARY_PATH = ProcessBuilder().command("/system/bin/su", "-c", "printenv LD_LIBRARY_PATH").start().inputStream.bufferedReader().readText().trim()
val currentPATH = ProcessBuilder().command("/system/bin/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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

View File

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

Before

Width:  |  Height:  |  Size: 605 KiB

After

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -24,7 +24,7 @@
android:layout_marginTop="16dp"
android:fontFamily="@font/sf_pro"
android:gravity="center"
android:text="AirPods Pro"
android:text="Kavish's AirPods Pro"
android:textColor="@color/popup_text"
android:textSize="28sp"

File diff suppressed because one or more lines are too long

View File

@@ -1,207 +1,77 @@
<resources>
<string name="app_description" translatable="false">让你的 AirPods 摆脱苹果的生态系统。</string>
<string name="app_widget_description">在主屏幕上即可查看 AirPods 的电池状态!</string>
<string name="accessibility">辅助功能</string>
<string name="tone_volume">提示音音量</string>
<string name="tone_volume_description">调整 AirPods 播放的提示音音量。</string>
<string name="audio">音频</string>
<string name="adaptive_audio">自适应音频</string>
<string name="customize_adaptive_audio">自定义自适应音频</string>
<string name="adaptive_audio_description">自适应音频会根据环境动态调整,消除或允许外部噪音。你可以自定义允许的噪音多少。</string>
<string name="buds">耳机</string>
<string name="case_alt">充电盒</string>
<string name="test">测试</string>
<string name="name">名称</string>
<string name="noise_control">听音模式</string>
<string name="off">关闭</string>
<string name="transparency">通透模式</string>
<string name="adaptive">自适应</string>
<string name="noise_cancellation">主动降噪</string>
<string name="press_and_hold_airpods">按住 AirPods</string>
<string name="press_and_hold_noise_control_description">按住耳机柄以在选定的听音模式之间循环切换。</string>
<string name="head_gestures">头部手势</string>
<string name="left">左耳</string>
<string name="right">右耳</string>
<string name="conversational_awareness">对话感知</string>
<string name="conversational_awareness_description">当你开始与他人交谈时,会降低媒体音量并减少背景噪音。</string>
<string name="personalized_volume">个性化音量</string>
<string name="personalized_volume_description">根据环境自动调整媒体音量。</string>
<string name="noise_cancellation_single_airpod">单只 AirPod 主动降噪</string>
<string name="noise_cancellation_single_airpod_description">仅佩戴一只 AirPod 时也能开启主动降噪。</string>
<string name="volume_control">音量控制</string>
<string name="volume_control_description">通过在 AirPods Pro柄部传感器上下滑动调节音量。</string>
<string name="airpods_not_connected">AirPods 未连接</string>
<string name="airpods_not_connected_description">请连接 AirPods 以访问设置。</string>
<string name="back">返回</string>
<string name="app_settings">自定义</string>
<string name="relative_conversational_awareness_volume">相对音量</string>
<string name="relative_conversational_awareness_volume_description">降低到当前音量的百分比,而不是最大音量。</string>
<string name="conversational_awareness_pause_music">暂停音乐</string>
<string name="conversational_awareness_pause_music_description">当你开始说话时,音乐会自动暂停</string>
<string name="appwidget_text">示例</string>
<string name="add_widget">添加小组件</string>
<string name="noise_control_widget_description">直接在主屏幕上控制听音模式。</string>
<string name="island_connected_text">连接</string>
<string name="island_connected_remote_text">已连接到 Linux</string>
<string name="island_taking_over_text">已连接</string>
<string name="island_moved_to_remote_text">已切换到 Linux</string>
<string name="island_moved_to_other_device_text">已切换到 %1$s</string>
<string name="island_moved_to_other_device_reversed_text">从通知中重新连接</string>
<string name="head_tracking">头部追踪</string>
<string name="head_gestures_details">点头接听电话,摇头拒接。</string>
<string name="general_settings_header">通用</string>
<string name="qs_click_behavior_title">快捷设置磁贴操作</string>
<string name="qs_click_behavior_dialog_desc">点击时显示听音模式控制对话框。</string>
<string name="qs_click_behavior_cycle_desc">点击时循环切换模式。</string>
<string name="developer_options_header">开发者</string>
<string name="more_settings_title">打开 AirPods 设置</string>
<string name="more_settings_subtitle">管理 AirPods 功能与偏好</string>
<string name="ear_detection">自动入耳检测</string>
<string name="auto_play">自动播放</string>
<string name="auto_pause">自动暂停</string>
<string name="troubleshooting">故障排查</string>
<string name="troubleshooting_description">收集日志以诊断 AirPods 连接问题</string>
<string name="collect_logs">收集日志</string>
<string name="saved_logs">已保存的日志</string>
<string name="no_logs_found">未找到保存的日志</string>
<string name="takeover_header">自动连接偏好</string>
<string name="takeover_airpods_state">当 AirPods 状态为以下情况时连接:</string>
<string name="takeover_disconnected">未连接</string>
<string name="takeover_disconnected_desc">AirPods 未连接到任何设备</string>
<string name="takeover_idle">空闲</string>
<string name="takeover_idle_desc">某设备已连接 AirPods但未播放媒体或通话</string>
<string name="takeover_music">正在播放媒体</string>
<string name="takeover_music_desc">某设备正在 AirPods 上播放媒体</string>
<string name="takeover_call">正在通话</string>
<string name="takeover_call_desc">某设备正在使用 AirPods 通话</string>
<string name="takeover_phone_state">当手机处于以下状态时连接 AirPods</string>
<string name="takeover_ringing_call">来电中</string>
<string name="takeover_ringing_call_desc">手机开始响铃时</string>
<string name="takeover_media_start">开始播放媒体</string>
<string name="takeover_media_start_desc">手机开始播放媒体时</string>
<string name="undo">撤销</string>
<string name="customize_transparency_mode_description">你可以为你的 AirPods Pro 自定义通透模式,以帮助你听清周围的声音。</string>
<string name="loud_sound_reduction_description">在通透模式和自适应模式下,响度声音降低功能可以主动减少你暴露在响亮环境噪音中的时间。此功能在关闭模式下不生效。</string>
<string name="loud_sound_reduction">大声减弱</string>
<string name="call_controls">通话控制</string>
<string name="automatically_connect">自动连接到此设备</string>
<string name="automatically_connect_description">启用后AirPods 将尝试自动连接到此设备。否则,它们将仅在上次连接时尝试自动连接。</string>
<string name="sleep_detection">入睡时暂停媒体</string>
<string name="off_listening_mode">关闭听音模式</string>
<string name="off_listening_mode_description">开启后AirPods 的听音模式将包含一个“关闭”选项。当听音模式设置为“关闭”时,高音量将不会被降低。</string>
<string name="microphone">麦克风</string>
<string name="microphone_mode">麦克风模式</string>
<string name="microphone_automatic">自动</string>
<string name="microphone_always_right">始终右耳</string>
<string name="microphone_always_left">始终左耳</string>
<string name="answer_call">接听电话</string>
<string name="mute_unmute">静音/取消静音</string>
<string name="hang_up">挂断</string>
<string name="press_once">按一次</string>
<string name="press_twice">按两次</string>
<string name="hearing_aid">助听</string>
<string name="adjustments">调整</string>
<string name="swipe_to_control_amplification">滑动以控制放大</string>
<string name="swipe_amplification_description">在通透模式下且无媒体播放时,在 AirPods Pro 的触摸控件上向上或向下滑动,以增强或减弱环境音的放大效果。</string>
<string name="transparency_mode">通透模式</string>
<string name="customize_transparency_mode">自定义通透模式</string>
<string name="press_speed">按压速度</string>
<string name="press_speed_description">调整在 AirPods 上需要按两次或三次的速度。</string>
<string name="press_and_hold_duration">按住持续时间</string>
<string name="press_and_hold_duration_description">调整在 AirPods 上需要按住的持续时间。</string>
<string name="volume_swipe_speed">音量滑动速度</string>
<string name="volume_swipe_speed_description">为防止意外的音量调整,请选择滑动之间的首选等待时间。</string>
<string name="equalizer">均衡器</string>
<string name="apply_eq_to">将均衡器应用于</string>
<string name="phone">电话</string>
<string name="media">媒体</string>
<string name="band_label">频段 %d</string>
<string name="default_option">默认</string>
<string name="slower">较慢</string>
<string name="slowest">最慢</string>
<string name="longer">较长</string>
<string name="longest">最长</string>
<string name="darker">更暗</string>
<string name="brighter">更亮</string>
<string name="less">更少</string>
<string name="more">更多</string>
<string name="amplification">放大</string>
<string name="balance">平衡</string>
<string name="tone">音调</string>
<string name="ambient_noise_reduction">环境降噪</string>
<string name="conversation_boost">对话增强</string>
<string name="conversation_boost_description">对话增强功能可将你的 AirPods Pro 聚焦于正前方的讲话者,让你在面对面交谈时听得更清楚。</string>
<string name="hearing_aid_description">AirPods 可以使用听力测试的结果进行调整,以提高你周围语音和声音的清晰度。
助听功能仅适用于有轻度至中度听力损失感知的人群。</string>
<string name="media_assist">媒体辅助</string>
<string name="media_assist_description">AirPods Pro 可以使用听力测试的结果进行调整,以提高音乐、视频和通话的清晰度。</string>
<string name="adjust_media">调整音乐和视频</string>
<string name="adjust_calls">调整通话</string>
<string name="widget">小组件</string>
<string name="show_phone_battery_in_widget">在小组件中显示手机电量</string>
<string name="show_phone_battery_in_widget_description">在小组件中与 AirPods 电量一同显示手机电量。</string>
<string name="conversational_awareness_volume">对话感知音量</string>
<string name="quick_settings_tile">快捷设置磁贴</string>
<string name="open_dialog_for_controlling">打开控制对话框</string>
<string name="open_dialog_for_controlling_description">如果禁用,点击快捷设置将循环切换模式。如果启用,它将显示一个用于控制听音模式和对话感知的对话框。</string>
<string name="disconnect_when_not_wearing">未佩戴时断开 AirPods</string>
<string name="disconnect_when_not_wearing_description">你仍然可以通过应用控制它们 - 这只是断开音频连接。</string>
<string name="advanced_options">高级选项</string>
<string name="set_identity_resolving_key">设置身份解析密钥 (IRK)</string>
<string name="set_identity_resolving_key_description">手动设置用于解析蓝牙低功耗随机地址的 IRK 值。</string>
<string name="set_encryption_key">设置加密密钥</string>
<string name="set_encryption_key_description">手动设置用于解密蓝牙低功耗广播的ENC_KEY 值。</string>
<string name="use_alternate_head_tracking_packets">使用备用头部追踪数据包</string>
<string name="use_alternate_head_tracking_packets_description">如果头部追踪对你无效,请启用此选项。这将向 AirPods 发送不同的数据以请求/停止头部追踪数据。</string>
<string name="act_as_an_apple_device">模拟为 Apple 设备</string>
<string name="act_as_an_apple_device_description">启用多设备连接和辅助功能,例如自定义通透模式(放大、音调、环境降噪、对话增强和均衡器)</string>
<string name="act_as_an_apple_device_warning">可能不稳定!!你的 AirPods 最多可以连接两台设备。如果你正在与 iPad 或 Mac 等 Apple 设备一起使用,请先连接该设备,然后再连接你的 Android 设备。</string>
<string name="reset_hook_offset">重置钩子偏移</string>
<string name="reset_hook_offset_description">这将清除当前的钩子偏移,并要求你重新进行设置过程。你确定要继续吗?</string>
<string name="reset">重置</string>
<string name="hook_offset_reset_success">钩子偏移已重置。正在重定向到设置...</string>
<string name="hook_offset_reset_failure">重置钩子偏移失败</string>
<string name="irk_set_success">IRK 已成功设置</string>
<string name="encryption_key_set_success">加密密钥已成功设置</string>
<string name="irk_hex_value">IRK 十六进制值</string>
<string name="enc_key_hex_value">ENC_KEY 十六进制值</string>
<string name="enter_irk_hex">输入 16 字节 IRK 的十六进制字符串32 个字符):</string>
<string name="enter_enc_key_hex">输入 16 字节ENC_KEY 的十六进制字符串32 个字符):</string>
<string name="must_be_32_hex_chars">必须是 32 个十六进制字符</string>
<string name="error_converting_hex">十六进制转换错误:</string>
<string name="found_offset_restart_bluetooth">找到偏移量,请重启蓝牙进程</string>
<string name="digital_assistant">数字助理</string>
<string name="on">开启</string>
<string name="camera_remote">相机遥控</string>
<string name="camera_control">相机控制</string>
<string name="camera_control_description">使用按一次或按住来拍照、开始或停止录制等。当使用 AirPods 进行相机操作时,如果选择按一次,媒体控制手势将不可用;如果选择按住,听音模式和数字助理手势将不可用。</string>
<string name="camera_control_app_description">为相机检测设置自定义应用包</string>
<string name="set_custom_camera_package">设置自定义相机应用 ID</string>
<string name="enter_custom_camera_package">输入相机应用的应用程序 ID</string>
<string name="custom_camera_package">自定义相机应用 ID</string>
<string name="custom_camera_package_set_success">自定义相机应用 ID 设置成功</string>
<string name="app_listener_service_label">相机监听器</string>
<string name="app_listener_service_description">LibrePods 的监听服务,用于检测相机何时处于活动状态,以激活 AirPods 上的相机控制。</string>
<string name="open_source_licenses">开源许可证</string>
<string name="hearing_test">更新听力测试</string>
<string name="update_hearing_test">更新听力测试结果</string>
<string name="att_manager_is_null_try_reconnecting">ATT 管理器为空,请尝试重新连接。</string>
<string name="permissions_required">使用此应用需要以下权限。请授予它们以继续。</string>
<string name="shake_your_head_or_nod">摇摇头或点点头!</string>
<string name="root_access_required">需要 Root 权限</string>
<string name="this_app_needs_root_access_to_hook_onto_the_bluetooth_library">此应用需要 Root 权限才能挂钩到蓝牙库</string>
<string name="root_access_denied">Root 权限被拒绝。请授予 Root 权限。</string>
<string name="troubleshooting_steps">故障排除步骤</string>
<string name="hearing_test_value_instruction">请输入 dbHL 中的损失值</string>
<string name="hearing_health">听力健康</string>
<string name="hearing_protection">听力保护</string>
<string name="workspace_use">工作区使用</string>
<string name="ppe">EN 352 保护</string>
<string name="workspace_use_description">EN 352 保护将媒体的最大音量限制为 82 dBA并符合适用的 EN 352 个人听力保护标准要求。</string>
<string name="environmental_noise">环境噪音</string>
<string name="reconnect_to_last_device">重新连接到上次连接的设备</string>
<string name="disconnect">断开连接</string>
<string name="support_dialog_description">我最近丢了我的左耳 AirPod。如果你觉得 LibrePods 有用,请考虑在 GitHub Sponsors 上支持我,这样我就可以购买一个替换品并继续从事这个项目——即使是少量捐助也能发挥很大作用。感谢你的支持!</string>
<string name="support_librepods">支持 LibrePods</string>
</resources>
<resources>
<string name="app_description" translatable="false">让你的 AirPods 摆脱苹果的生态系统。</string>
<string name="app_widget_description">在主屏幕上即可查看 AirPods 的电池状态!</string>
<string name="accessibility">辅助功能</string>
<string name="tone_volume">提示音音量</string>
<string name="audio">音频</string>
<string name="adaptive_audio">自适应音频</string>
<string name="adaptive_audio_description">自适应音频会根据环境动态调整,消除或允许外部噪音。你可以自定义允许的噪音多少。</string>
<string name="buds">耳机</string>
<string name="case_alt">充电盒</string>
<string name="test">测试</string>
<string name="name">名称</string>
<string name="noise_control">噪音控制</string>
<string name="off">关闭</string>
<string name="transparency">通透模式</string>
<string name="adaptive">自适应</string>
<string name="noise_cancellation">主动降噪</string>
<string name="press_and_hold_airpods">按住 AirPods</string>
<string name="head_gestures">头部手势</string>
<string name="left">左耳</string>
<string name="right">右耳</string>
<string name="conversational_awareness">对话感知</string>
<string name="conversational_awareness_description">当你开始与他人交谈时,会降低媒体音量并减少背景噪音。</string>
<string name="personalized_volume">个性化音量</string>
<string name="personalized_volume_description">根据环境自动调整媒体音量。</string>
<string name="noise_cancellation_single_airpod">单只 AirPod 主动降噪</string>
<string name="noise_cancellation_single_airpod_description">仅佩戴一只 AirPod 时也能开启主动降噪。</string>
<string name="volume_control">音量控制</string>
<string name="volume_control_description">通过在 AirPods Pro 柄部传感器上下滑动调节音量。</string>
<string name="airpods_not_connected">AirPods 未连接</string>
<string name="airpods_not_connected_description">请连接 AirPods 以访问设置。如果卡在此处,请先关闭应用再重新打开。\n不要强制结束应用</string>
<string name="back">返回</string>
<string name="app_settings">自定义</string>
<string name="relative_conversational_awareness_volume">相对音量</string>
<string name="relative_conversational_awareness_volume_description">降低到当前音量的百分比,而不是最大音量。</string>
<string name="conversational_awareness_pause_music">暂停音乐</string>
<string name="conversational_awareness_pause_music_description">当你开始说话时,音乐会自动暂停。</string>
<string name="appwidget_text">示例</string>
<string name="add_widget">添加小组件</string>
<string name="noise_control_widget_description">在主屏幕直接控制噪音模式</string>
<string name="island_connected_text">已连接</string>
<string name="island_connected_remote_text">已连接到 Linux</string>
<string name="island_taking_over_text">已切换到手机</string>
<string name="island_moved_to_remote_text">切换到 Linux</string>
<string name="head_tracking">头部追踪</string>
<string name="head_gestures_details">点头接听电话,摇头拒接。</string>
<string name="general_settings_header">通用</string>
<string name="qs_click_behavior_title">快捷设置磁贴操作</string>
<string name="qs_click_behavior_dialog_desc">点击时显示噪音控制对话框。</string>
<string name="qs_click_behavior_cycle_desc">点击时循环切换模式。</string>
<string name="developer_options_header">开发者</string>
<string name="more_settings_title">打开 AirPods 设置</string>
<string name="more_settings_subtitle">管理 AirPods 功能与偏好</string>
<string name="ear_detection">自动入耳检测</string>
<string name="auto_play">自动播放</string>
<string name="auto_pause">自动暂停</string>
<string name="troubleshooting">故障排查</string>
<string name="troubleshooting_description">收集日志以诊断 AirPods 连接问题</string>
<string name="collect_logs">收集日志</string>
<string name="saved_logs">已保存的日志</string>
<string name="no_logs_found">未找到保存的日志</string>
<string name="takeover_header">自动连接偏好</string>
<string name="takeover_airpods_state">当 AirPods 状态为以下情况时连接:</string>
<string name="takeover_disconnected">未连接</string>
<string name="takeover_disconnected_desc">AirPods 未连接到任何设备</string>
<string name="takeover_idle">空闲</string>
<string name="takeover_idle_desc">某设备已连接 AirPods但未播放媒体或通话</string>
<string name="takeover_music">正在播放媒体</string>
<string name="takeover_music_desc">某设备正在 AirPods 上播放媒体</string>
<string name="takeover_call">正在通话</string>
<string name="takeover_call_desc">某设备正在使用 AirPods 通话</string>
<string name="takeover_phone_state">当手机处于以下状态时连接 AirPods</string>
<string name="takeover_ringing_call">来电中</string>
<string name="takeover_ringing_call_desc">手机开始响铃时</string>
<string name="takeover_media_start">开始播放媒体</string>
<string name="takeover_media_start_desc">手机开始播放媒体时</string>
</resources>

View File

@@ -1,4 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<resources
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="MissingTranslation">
<string name="app_name" translatable="false">LibrePods</string>
<string name="app_description">Liberate your AirPods from Apple\'s ecosystem.</string>
<string name="app_widget_description">See your AirPods battery status right from your home screen!</string>
@@ -82,7 +84,7 @@
<string name="takeover_media_start_desc">Your phone starts playing media</string>
<string name="undo">Undo</string>
<string name="customize_transparency_mode_description">You can customize Transparency mode for your AirPods Pro to help you hear what\'s around you.</string>
<string name="loud_sound_reduction_description">Loud Sound Reduction can actively reduce your exposure to loud environmental noises when in Transparency and Adaptive mode. Loud Sound Reduction is not active in Off mode.</string>
<string name="loud_sound_reduction_description">AirPods Pro automatically reduce your exposure to loud environmental noises when in Transparency and Adaptive mode.</string>
<string name="loud_sound_reduction">Loud Sound Reduction</string>
<string name="call_controls">Call Controls</string>
<string name="automatically_connect">Connect to this device automatically</string>
@@ -182,32 +184,4 @@
<string name="custom_camera_package_set_success">Custom camera appid set successfully</string>
<string name="app_listener_service_label">Camera listener</string>
<string name="app_listener_service_description">Listener service for LibrePods to detect when the camera is active to activate camera control on AirPods.</string>
<string name="open_source_licenses">Open Source Licenses</string>
<string name="hearing_test">Update Hearing Test</string>
<string name="update_hearing_test">Update Hearing Test Result</string>
<string name="att_manager_is_null_try_reconnecting">ATT Manager is null, Try reconnecting.</string>
<string name="permissions_required">The following permissions are required to use the app. Please grant them to continue.</string>
<string name="shake_your_head_or_nod">Shake your head or nod!</string>
<string name="root_access_required">Root Access Required</string>
<string name="this_app_needs_root_access_to_hook_onto_the_bluetooth_library">This app needs root access to hook onto the Bluetooth library</string>
<string name="root_access_denied">Root access was denied. Please grant root permissions.</string>
<string name="troubleshooting_steps">Troubleshooting Steps</string>
<string name="hearing_test_value_instruction">Please enter the loss values in dbHL</string>
<string name="about">About</string>
<string name="model_name">Model Name</string>
<string name="model_number">Model Number</string>
<string name="serial_number">Serial Number</string>
<string name="version">Version</string>
<string name="hearing_health">Hearing Health</string>
<string name="hearing_protection">Hearing Protection</string>
<string name="workspace_use">Workspace Use</string>
<string name="ppe">EN 352 Protection</string>
<string name="workspace_use_description">EN 352 Protection limits the maximum level of media to 82 dBA, and meets applicable EN 352 Standard requirements for personal hearing protection.</string>
<string name="environmental_noise">Environmental Noise</string>
<string name="reconnect_to_last_device">Reconnect to last connected device</string>
<string name="disconnect">Disconnect</string>
<string name="support_me">Support me</string>
<string name="never_show_again">Never show again</string>
<string name="support_dialog_description">I recently lost my left AirPod. If you\'ve found LibrePods useful, consider supporting me on GitHub Sponsors so I can buy a replacement and continue working on this project- even a little amount goes a long way. Thank you for your support!</string>
<string name="support_librepods">Support LibrePods</string>
</resources>

View File

@@ -3,5 +3,4 @@ plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.aboutLibraries) apply false
}

View File

@@ -1,9 +1,9 @@
[versions]
accompanistPermissions = "0.36.0"
agp = "8.9.1"
agp = "8.8.2"
hiddenapibypass = "6.1"
kotlin = "2.1.10"
coreKtx = "1.17.0"
coreKtx = "1.16.0"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.10.1"
composeBom = "2025.04.00"
@@ -17,7 +17,6 @@ foundationLayout = "1.9.1"
uiTooling = "1.9.1"
mockk = "1.14.3"
ui = "1.9.2"
aboutLibraries = "13.0.0-rc01"
[libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
@@ -40,11 +39,9 @@ androidx-compose-foundation-layout = { group = "androidx.compose.foundation", na
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiTooling" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" }
aboutlibraries = { group = "com.mikepenz", name = "aboutlibraries", version.ref = "aboutLibraries" }
aboutlibraries-compose-m3 = { group = "com.mikepenz", name = "aboutlibraries-compose-m3", version.ref = "aboutLibraries" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutLibraries" }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -1,7 +0,0 @@
**/*.flatpak
repo
dist
build-dir
vendor
.cargo
.flatpak-builder

Some files were not shown because too many files have changed in this diff Show More