Compare commits
68 Commits
linux-v0.1
...
release-ni
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9469c2d62 | ||
|
|
b799cd1710 | ||
|
|
c7dc545ed4 | ||
|
|
342745ee2e | ||
|
|
8b49440d6b | ||
|
|
993f022087 | ||
|
|
650b128d5d | ||
|
|
395feabb13 | ||
|
|
6914dabe59 | ||
|
|
78ae31c898 | ||
|
|
b43e5f7526 | ||
|
|
9d60dc3682 | ||
|
|
c2ebbef14b | ||
|
|
3a388da48e | ||
|
|
bdb93efec6 | ||
|
|
504e70371b | ||
|
|
48b715af68 | ||
|
|
5ec300aad8 | ||
|
|
e158ba1b27 | ||
|
|
147e511659 | ||
|
|
e9da7a2a50 | ||
|
|
1076218ccc | ||
|
|
55cb69f880 | ||
|
|
5bc1dd2e1d | ||
|
|
1152f45a6c | ||
|
|
3f582b8fcf | ||
|
|
08738a1293 | ||
|
|
8dc7a97c43 | ||
|
|
d9795c4d28 | ||
|
|
56307c98e3 | ||
|
|
ab55096051 | ||
|
|
86a6a28dc1 | ||
|
|
7e5ee6726f | ||
|
|
5f08edd49c | ||
|
|
29a35ceebe | ||
|
|
173e06c5e7 | ||
|
|
26de42243f | ||
|
|
8760757b76 | ||
|
|
4bc76de750 | ||
|
|
4751f70579 | ||
|
|
ce229bec6e | ||
|
|
fe69082e11 | ||
|
|
3ace0e1831 | ||
|
|
ecfdc05dbf | ||
|
|
5aeb47b835 | ||
|
|
3cca786cf9 | ||
|
|
6fd3cc1eb0 | ||
|
|
bb69a74a8e | ||
|
|
71a1f834cb | ||
|
|
63baa153da | ||
|
|
5eff5b9d77 | ||
|
|
b5103a28e7 | ||
|
|
3699ee6bee | ||
|
|
032b94e3ae | ||
|
|
5c9beeb26d | ||
|
|
65d074efe0 | ||
|
|
93328d281e | ||
|
|
792629acb9 | ||
|
|
5bef8c384e | ||
|
|
9e6d97198b | ||
|
|
c53356f77e | ||
|
|
fa00620b5b | ||
|
|
aecbb066b5 | ||
|
|
0e9aadd672 | ||
|
|
df9f443173 | ||
|
|
d1bf5407c9 | ||
|
|
4ee9b2732f | ||
|
|
86551be86b |
2
.github/workflows/ci-android.yml
vendored
@@ -4,8 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
paths:
|
||||
- 'android/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release:
|
||||
|
||||
87
.github/workflows/ci-linux-rust.yml
vendored
@@ -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 }}
|
||||
9
.github/workflows/ci-linux.yml
vendored
@@ -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
|
||||
68
README.md
@@ -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
|
||||
|
||||
| | | |
|
||||
| -------------------------------------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
| | | |
|
||||
|-------------------|-------------------|-------------------|
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  | |
|
||||
| | | |
|
||||
|
||||
|
||||
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
|
||||
|
||||
[](https://star-history.com/#kavishdevar/librepods&Date)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ fun ConnectionSettings() {
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(horizontal= 12.dp)
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
),
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import 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))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) ?: "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import 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 }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
|
||||
@@ -196,7 +196,7 @@ object MediaController {
|
||||
}
|
||||
}
|
||||
|
||||
lastKnownIsMusicActive = hasNewMusicOrMovie && isActive
|
||||
lastKnownIsMusicActive = isActive
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 605 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 605 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 605 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 605 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 605 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 605 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 605 KiB After Width: | Height: | Size: 605 KiB |
BIN
android/app/src/main/res/drawable/pro_2_case.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 300 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 142 KiB |
7
linux-rust/.gitignore
vendored
@@ -1,7 +0,0 @@
|
||||
**/*.flatpak
|
||||
repo
|
||||
dist
|
||||
build-dir
|
||||
vendor
|
||||
.cargo
|
||||
.flatpak-builder
|
||||