19 Commits

Author SHA1 Message Date
Kavish Devar
63b6e2a389 docs: add note for DID hook on android 2025-10-26 20:53:32 +05:30
Kavish Devar
9b950e13d6 android: add a (very important) support dialog
to not be invasive, this only shows up once, and never again.
2025-10-26 20:48:23 +05:30
Kavish Devar
55768beb7c android: improve connection handling 2025-10-26 20:06:06 +05:30
Kavish Devar
cea09b208a android: remove stray eq config in accessibility settings 2025-10-26 20:05:00 +05:30
Kavish Devar
02edb51e41 android: fix a2dp connection 2025-10-22 17:43:43 +05:30
Kavish Devar
10fc96dc94 android: add support for various models
still need to update images or find a way to fetch from apple's cdn
2025-10-22 16:25:09 +05:30
Kavish Devar
1a2f5138a9 android: parse device info 2025-10-22 12:52:46 +05:30
Kavish Devar
ee9de99204 android: fix haze for dialog when enabling hearing aid 2025-10-19 12:54:35 +05:30
Kavish Devar
c83ffca546 docs: add screenshot for hearing test 2025-10-16 13:16:38 +05:30
Kavish Devar
814eba8ed6 android: update title in hearing test screen 2025-10-16 13:13:27 +05:30
Kavish Devar
a9b78efd80 android: implement setting hearing test results 2025-10-16 13:11:21 +05:30
Kavish Devar
942ff82382 android: update animation time on switch tap 2025-10-10 20:54:01 +05:30
Kavish Devar
4a135fa463 android: move navigation button to activity level 2025-10-10 20:39:54 +05:30
Kavish Devar
39a64ec6f2 android: add opensource licenses
should've done this a long time ago!
2025-10-10 17:24:52 +05:30
Kavish Devar
b7cc27f4d3 android: remove unused LOCAL_ADDRESS permission 2025-10-06 18:32:40 +05:30
Kavish Devar
0e0af35103 android: don't crash if self MAC is not available 2025-10-01 20:04:35 +05:30
Kavish Devar
993ba1ba08 android: bump version 2025-10-01 09:50:21 +05:30
Kavish Devar
3a9c118353 android: revert to using relative paths for su
compatibility issues with magisk
2025-10-01 09:48:50 +05:30
Kavish Devar
37313fbb1c android: fix transparency and noise cancellation flags
huh... was it always like this?
2025-10-01 01:50:38 +05:30
85 changed files with 7155 additions and 938 deletions

View File

@@ -15,11 +15,11 @@ LibrePods unlocks Apple's exclusive AirPods features on non-Apple devices. Get a
## Device Compatibility ## Device Compatibility
| Status | Device | Features | | Status | Device | Features |
|--------|--------|----------| | ------ | --------------------- | ---------------------------------------------------------- |
| ✅ | AirPods Pro (2nd Gen) | Fully supported and tested | | ✅ | AirPods Pro (2nd Gen) | Fully supported and tested |
| ✅ | AirPods Pro (3rd Gen) | Fully supported (except heartrate monitoring) | | ✅ | AirPods Pro (3rd Gen) | Fully supported (except heartrate monitoring) |
| ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work | | ⚠️ | 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. 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 #### Screenshots
| | | | | | | |
|-------------------|-------------------|-------------------| | -------------------------------------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------- |
| ![Settings 1](/android/imgs/settings-1.png) | ![Settings 2](/android/imgs/settings-2.png) | ![Debug Screen](/android/imgs/debug.png) | | ![Settings 1](/android/imgs/settings-1.png) | ![Settings 2](/android/imgs/settings-2.png) | ![Debug Screen](/android/imgs/debug.png) |
| ![Battery Notification and QS Tile for NC Mode](/android/imgs/notification-and-qs.png) | ![Popup](/android/imgs/popup.png) | ![Head Tracking and Gestures](/android/imgs/head-tracking-and-gestures.png) | | ![Battery Notification and QS Tile for NC Mode](/android/imgs/notification-and-qs.png) | ![Popup](/android/imgs/popup.png) | ![Head Tracking and Gestures](/android/imgs/head-tracking-and-gestures.png) |
| ![Long Press Configuration](/android/imgs/long-press.png) | ![Widget](/android/imgs/widget.png) | ![Customizations 1](/android/imgs/customizations-1.png) | | ![Long Press Configuration](/android/imgs/long-press.png) | ![Widget](/android/imgs/widget.png) | ![Customizations 1](/android/imgs/customizations-1.png) |
| ![Customizations 2](/android/imgs/customizations-2.png) | ![accessibility](/android/imgs/accessibility.png) |![transparency](/android/imgs/transparency.png) | | ![Customizations 2](/android/imgs/customizations-2.png) | ![accessibility](/android/imgs/accessibility.png) | ![transparency](/android/imgs/transparency.png) |
|![hearing-aid](/android/imgs/hearing-aid.png) |![hearing-aid-adjustments](/android/imgs/hearing-aid-adjustments.png) | | | ![hearing-aid](/android/imgs/hearing-aid.png) | ![hearing-test](/android/imgs/hearing-test.png) | ![hearing-aid-adjustments](/android/imgs/hearing-aid-adjustments.png) |
here's a very unprofessional demo video here's a very unprofessional demo video
@@ -96,6 +96,9 @@ Accessibility settings like customizing transparency mode (amplification, balanc
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. 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.
>[!NOTE]
> To enable these features, enable App Settings -> `act as Apple Device`.
> This only works if you use the Xposed method or patch the library yourself. The root module method does not support this feature currently.
#### Installation Methods #### Installation Methods

View File

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

View File

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

View File

@@ -27,7 +27,6 @@ import android.content.Context
import android.content.Context.MODE_PRIVATE import android.content.Context.MODE_PRIVATE
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.content.SharedPreferences
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -38,11 +37,16 @@ import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween 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.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
@@ -87,6 +91,8 @@ import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext 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.res.vectorResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
@@ -104,7 +110,10 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState 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 dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen
@@ -115,14 +124,17 @@ import me.kavishdevar.librepods.screens.DebugScreen
import me.kavishdevar.librepods.screens.HeadTrackingScreen import me.kavishdevar.librepods.screens.HeadTrackingScreen
import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.screens.HearingAidScreen import me.kavishdevar.librepods.screens.HearingAidScreen
import me.kavishdevar.librepods.screens.HearingProtectionScreen
import me.kavishdevar.librepods.screens.LongPress import me.kavishdevar.librepods.screens.LongPress
import me.kavishdevar.librepods.screens.Onboarding import me.kavishdevar.librepods.screens.Onboarding
import me.kavishdevar.librepods.screens.OpenSourceLicensesScreen
import me.kavishdevar.librepods.screens.RenameScreen import me.kavishdevar.librepods.screens.RenameScreen
import me.kavishdevar.librepods.screens.TransparencySettingsScreen import me.kavishdevar.librepods.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.screens.TroubleshootingScreen 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.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.CrossDevice
import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -300,104 +312,139 @@ fun Main() {
val navController = rememberNavController() 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 ( Box (
modifier = Modifier modifier = Modifier
.padding(0.dp)
.fillMaxSize() .fillMaxSize()
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)) ){
) { val backButtonBackdrop = rememberLayerBackdrop()
NavHost( Box (
navController = navController, modifier = Modifier
startDestination = if (hookAvailable) "settings" else "onboarding", .fillMaxSize()
enterTransition = { .background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
slideInHorizontally( .layerBackdrop(backButtonBackdrop)
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") { NavHost(
if (airPodsService.value != null) { navController = navController,
AirPodsSettingsScreen( startDestination = if (hookAvailable) "settings" else "onboarding",
dev = airPodsService.value?.device, enterTransition = {
service = airPodsService.value!!, 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(
navController = navController, navController = navController,
isConnected = isConnected.value, name = navBackStackEntry.arguments?.getString("bud")!!
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)
}
} }
composable("debug") { }
DebugScreen(navController = 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("long_press/{bud}") { navBackStackEntry -> }
LongPress(
navController = navController, AnimatedVisibility(
name = navBackStackEntry.arguments?.getString("bud")!! 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("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)
}
} }
} }
@@ -522,7 +569,7 @@ fun PermissionsScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "The following permissions are required to use the app. Please grant them to continue.", text = stringResource(R.string.permissions_required),
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
@@ -699,7 +746,11 @@ fun PermissionCard(
modifier = Modifier modifier = Modifier
.size(40.dp) .size(40.dp)
.clip(RoundedCornerShape(8.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 contentAlignment = Alignment.Center
) { ) {
Icon( Icon(

View File

@@ -0,0 +1,205 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun AboutCard(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.about),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
)
)
}
val rowHeight = remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.onGloballyPositioned { coordinates ->
rowHeight.value = with(density) { coordinates.size.height.toDp() }
},
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.model_name),
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.model.displayName,
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.model_name),
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.actualModelNumber,
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
val serialNumbers = listOf(
airpodsInstance.serialNumber?: "",
"􀀛 ${airpodsInstance.leftSerialNumber}",
"􀀧 ${airpodsInstance.rightSerialNumber}"
)
val serialNumber = remember { mutableStateOf(0) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.serial_number),
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
Text(
text = serialNumbers[serialNumber.value],
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
serialNumber.value = (serialNumber.value + 1) % serialNumbers.size
}
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
NavigationButton(
to = "version_info",
navController = navController,
name = stringResource(R.string.version),
currentState = airpodsInstance.version3,
independent = false,
height = rowHeight.value + 32.dp
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,109 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.Capability
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun HearingHealthSettings(navController: NavController) {
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
if (airpodsInstance.model.capabilities.contains(Capability.HEARING_AID)) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
if (airpodsInstance.model.capabilities.contains(Capability.PPE)) {
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.hearing_health),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
)
)
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
NavigationButton(
to = "hearing_protection",
name = stringResource(R.string.hearing_protection),
navController = navController,
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
NavigationButton(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid),
navController = navController,
independent = false
)
}
} else {
NavigationButton(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid),
navController = navController
)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -59,7 +59,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.edit import androidx.core.content.edit
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
@@ -473,30 +472,12 @@ fun StyledToggle(
val attManager = ServiceManager.getService()?.attManager ?: return val attManager = ServiceManager.getService()?.attManager ?: return
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
var checked by remember { mutableStateOf(false) } val checkedValue = attManager.read(attHandle).getOrNull(0)?.toInt()
var checked by remember { mutableStateOf(checkedValue !=0) }
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
LaunchedEffect(Unit) { attManager.enableNotifications(attHandle)
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) { if (sharedPreferenceKey != null && sharedPreferences != null) {
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked) checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)

View File

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

View File

@@ -99,15 +99,7 @@ fun AdaptiveStrengthScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( 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 -> ) { spacerHeight ->
Column( Column(
modifier = Modifier modifier = Modifier

View File

@@ -65,20 +65,25 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.highlight.Highlight
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.AboutCard
import me.kavishdevar.librepods.composables.AudioSettings import me.kavishdevar.librepods.composables.AudioSettings
import me.kavishdevar.librepods.composables.BatteryView import me.kavishdevar.librepods.composables.BatteryView
import me.kavishdevar.librepods.composables.CallControlSettings import me.kavishdevar.librepods.composables.CallControlSettings
import me.kavishdevar.librepods.composables.ConfirmationDialog
import me.kavishdevar.librepods.composables.ConnectionSettings import me.kavishdevar.librepods.composables.ConnectionSettings
import me.kavishdevar.librepods.composables.HearingHealthSettings
import me.kavishdevar.librepods.composables.MicrophoneSettings import me.kavishdevar.librepods.composables.MicrophoneSettings
import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.NoiseControlSettings import me.kavishdevar.librepods.composables.NoiseControlSettings
@@ -91,6 +96,8 @@ import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.Capability
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@@ -196,46 +203,58 @@ 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 darkMode = isSystemInDarkTheme()
val backdrop = rememberLayerBackdrop() val hazeStateS = remember { mutableStateOf(HazeState()) }
val showDialog = remember { mutableStateOf(!sharedPreferences.getBoolean("donationDialogShown", false)) }
StyledScaffold( StyledScaffold(
title = deviceName.text, title = deviceName.text,
actionButtons = listOf { actionButtons = listOf(
StyledIconButton( {scaffoldBackdrop ->
onClick = { navController.navigate("app_settings") }, StyledIconButton(
icon = "􀍟", onClick = { navController.navigate("app_settings") },
darkMode = darkMode, icon = "􀍟",
backdrop = backdrop darkMode = darkMode,
) backdrop = scaffoldBackdrop
}, )
}
),
snackbarHostState = snackbarHostState snackbarHostState = snackbarHostState
) { spacerHeight, hazeState -> ) { spacerHeight, hazeState ->
hazeStateS.value = hazeState
if (isLocallyConnected || isRemotelyConnected) { if (isLocallyConnected || isRemotelyConnected) {
val instance = service.airpodsInstance
if (instance == null) {
Text("Error: AirPods instance is null")
return@StyledScaffold
}
val capabilities = instance.model.capabilities
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.hazeSource(hazeState) .hazeSource(hazeState)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.layerBackdrop(backdrop)
) { ) {
item { Spacer(modifier = Modifier.height(spacerHeight)) } item(key = "spacer_top") { Spacer(modifier = Modifier.height(spacerHeight)) }
item { item(key = "battery") {
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) BatteryView(service = service)
} }
item { Spacer(modifier = Modifier.height(32.dp)) } item(key = "spacer_battery") { Spacer(modifier = Modifier.height(32.dp)) }
item { item(key = "name") {
NavigationButton( NavigationButton(
to = "rename", to = "rename",
name = stringResource(R.string.name), name = stringResource(R.string.name),
@@ -244,61 +263,76 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
independent = true independent = true
) )
} }
val actAsAppleDeviceHookEnabled = RadareOffsetFinder.isSdpOffsetAvailable()
item { Spacer(modifier = Modifier.height(32.dp)) } if (actAsAppleDeviceHookEnabled) {
item { NavigationButton(to = "hearing_aid", name = stringResource(R.string.hearing_aid), navController = navController) } item(key = "spacer_hearing_health") { Spacer(modifier = Modifier.height(32.dp)) }
item(key = "hearing_health") {
item { Spacer(modifier = Modifier.height(16.dp)) } HearingHealthSettings(navController = navController)
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
)
} }
item { Spacer(modifier = Modifier.height(16.dp)) } if (capabilities.contains(Capability.LISTENING_MODE)) {
item { item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) }
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 = "noise_control") { NoiseControlSettings(service = service) }
} }
item { Spacer(modifier = Modifier.height(16.dp)) } if (capabilities.contains(Capability.STEM_CONFIG)) {
item { NavigationButton(to = "accessibility", name = stringResource(R.string.accessibility), navController = navController) } 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 {
StyledToggle(
label = stringResource(R.string.off_listening_mode),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
description = stringResource(R.string.off_listening_mode_description)
)
} }
// an about card- everything but the version number is unknown - will add later if i find out item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "call_control") { CallControlSettings(hazeState = hazeState) }
item { Spacer(modifier = Modifier.height(16.dp)) } if (capabilities.contains(Capability.STEM_CONFIG)) {
item { NavigationButton("debug", "Debug", navController) } item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) }
item { Spacer(Modifier.height(24.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)) }
} }
} }
else { else {
@@ -314,6 +348,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
Highlight.Ambient.copy(alpha = 0f) Highlight.Ambient.copy(alpha = 0f)
} }
) )
.hazeSource(hazeState)
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
@@ -344,7 +379,9 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
StyledButton( StyledButton(
onClick = { navController.navigate("troubleshooting") }, onClick = { navController.navigate("troubleshooting") },
backdrop = backdrop backdrop = backdrop,
modifier = Modifier
.fillMaxWidth(0.9f)
) { ) {
Text( Text(
text = "Troubleshoot Connection", text = "Troubleshoot Connection",
@@ -352,13 +389,51 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isSystemInDarkTheme()) Color.White else Color.Black 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
) )
) )
} }
} }
} }
} }
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 @Preview

View File

@@ -80,7 +80,6 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.composables.StyledToggle
@@ -193,15 +192,7 @@ fun AppSettingsScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( 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 -> ) { spacerHeight, hazeState ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -648,6 +639,15 @@ 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)) Spacer(modifier = Modifier.height(32.dp))
if (showResetDialog.value) { if (showResetDialog.value) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,90 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun HearingProtectionScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val service = ServiceManager.getService()
if (service == null) return
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.hearing_protection),
) { spacerHeight ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
StyledToggle(
title = stringResource(R.string.environmental_noise),
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
attHandle = ATTHandles.LOUD_SOUND_REDUCTION
)
Spacer(modifier = Modifier.height(12.dp))
StyledToggle(
title = stringResource(R.string.workspace_use),
label = stringResource(R.string.ppe),
description = stringResource(R.string.workspace_use_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG
)
}
}
}

View File

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

View File

@@ -0,0 +1,93 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun OpenSourceLicensesScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.open_source_licenses)
) { spacerHeight ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
val context = androidx.compose.ui.platform.LocalContext.current
val libraries by produceLibraries {
context.resources.openRawResource(R.raw.aboutlibraries)
.bufferedReader()
.use { it.readText() }
}
LibrariesContainer(
libraries = libraries,
modifier = Modifier
.padding(0.dp)
.fillMaxSize()
)
}
}
}

View File

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

View File

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

View File

@@ -100,15 +100,7 @@ fun TransparencySettingsScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( 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 -> ){ spacerHeight, hazeState ->
Column( Column(
modifier = Modifier modifier = Modifier

View File

@@ -94,7 +94,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.utils.LogCollector import me.kavishdevar.librepods.utils.LogCollector
import java.io.File import java.io.File
@@ -194,7 +193,7 @@ fun TroubleshootingScreen(navController: NavController) {
LaunchedEffect(currentStep) { LaunchedEffect(currentStep) {
instructionText = when (currentStep) { instructionText = when (currentStep) {
0 -> "First, let's ensure Xposed module is properly configured. Tap the button below to check Xposed scope settings." 0 -> "First, let's ensure Xposed module is properly configured. Tap the button below to check Xposed scope settings."
1 -> "Please put your AirPods in the case and close it, so they disconnect completely." 1 -> "Please put your AirPods in the case and close it, so they disconnectForCD completely."
2 -> "Preparing to collect logs... Please wait." 2 -> "Preparing to collect logs... Please wait."
3 -> "Now, open the AirPods case and connect your AirPods. Logs are being collected. Connection will be detected automatically, or you can manually stop logging when you're done." 3 -> "Now, open the AirPods case and connect your AirPods. Logs are being collected. Connection will be detected automatically, or you can manually stop logging when you're done."
4 -> "Log collection complete! You can now save or share the logs." 4 -> "Log collection complete! You can now save or share the logs."
@@ -216,15 +215,7 @@ fun TroubleshootingScreen(navController: NavController) {
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
StyledScaffold( StyledScaffold(
title = stringResource(R.string.troubleshooting), title = stringResource(R.string.troubleshooting)
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
){ spacerHeight, hazeState -> ){ spacerHeight, hazeState ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -378,7 +369,7 @@ fun TroubleshootingScreen(navController: NavController) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = "TROUBLESHOOTING STEPS", text = stringResource(R.string.troubleshooting_steps),
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Light, fontWeight = FontWeight.Light,

View File

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

View File

@@ -0,0 +1,192 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.screens
import androidx.compose.foundation.background
import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun VersionScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.customize_adaptive_audio)
) { spacerHeight ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.version),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
)
)
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version) + " 1",
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.version1 ?: "N/A",
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version) + " 2",
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.version2 ?: "N/A",
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version) + " 3",
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.version3 ?: "N/A",
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
}
}

View File

@@ -89,6 +89,8 @@ import me.kavishdevar.librepods.constants.isHeadTrackingData
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType
import me.kavishdevar.librepods.utils.ATTManager 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.BLEManager
import me.kavishdevar.librepods.utils.BluetoothConnectionManager import me.kavishdevar.librepods.utils.BluetoothConnectionManager
import me.kavishdevar.librepods.utils.CrossDevice import me.kavishdevar.librepods.utils.CrossDevice
@@ -152,6 +154,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var localMac = "" var localMac = ""
lateinit var aacpManager: AACPManager lateinit var aacpManager: AACPManager
var attManager: ATTManager? = null var attManager: ATTManager? = null
var airpodsInstance: AirPodsInstance? = null
var cameraActive = false var cameraActive = false
private var disconnectedBecauseReversed = false private var disconnectedBecauseReversed = false
private var otherDeviceTookOver = false private var otherDeviceTookOver = false
@@ -191,6 +194,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var rightLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!, var rightLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!,
var cameraAction: AACPManager.Companion.StemPressType? = null, 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 private lateinit var config: ServiceConfig
@@ -213,6 +229,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
private var handleIncomingCallOnceConnected = false private var handleIncomingCallOnceConnected = false
lateinit var bleManager: BLEManager lateinit var bleManager: BLEManager
private lateinit var socket: BluetoothSocket
private val bleStatusListener = object : BLEManager.AirPodsStatusListener { private val bleStatusListener = object : BLEManager.AirPodsStatusListener {
@SuppressLint("NewApi") @SuppressLint("NewApi")
override fun onDeviceStatusChanged( override fun onDeviceStatusChanged(
@@ -349,7 +368,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferences.registerOnSharedPreferenceChangeListener(this) sharedPreferences.registerOnSharedPreferenceChangeListener(this)
val process = Runtime.getRuntime().exec(arrayOf("/system/bin/su", "-c", "settings", "get", "secure", "bluetooth_address")) val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "settings", "get", "secure", "bluetooth_address"))
val output = process.inputStream.bufferedReader().use { it.readLine() } val output = process.inputStream.bufferedReader().use { it.readLine() }
localMac = output.trim() localMac = output.trim()
@@ -926,8 +945,54 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
) )
} }
override fun onDeviceMetadataReceived(deviceMetadata: ByteArray) { 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
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") @SuppressLint("NewApi")
@@ -954,7 +1019,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
Log.d("AirPodsParser", "Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}") Log.d("AirPodsParser", "Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}")
if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) { if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) {
Runtime.getRuntime().exec(arrayOf("/system/bin/su", "-c", "input keyevent 27")) Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27"))
} else { } else {
val action = getActionFor(bud, stemPressType) val action = getActionFor(bud, stemPressType)
Log.d("AirPodsParser", "$bud $stemPressType action: $action") Log.d("AirPodsParser", "$bud $stemPressType action: $action")
@@ -970,8 +1035,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
byteArrayOf(0x00) 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 // 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") // Log.d(TAG, "Another device started playing audio, listening for audio config changes again")
MediaController.pausedForOtherDevice = false // 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???
} }
} }
@@ -1164,6 +1230,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
rightLongPressAction = StemAction.fromString(sharedPreferences.getString("right_long_press_action", "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT")!!, 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) }, 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", "") ?: "",
) )
} }
@@ -1245,6 +1324,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
setupStemActions() setupStemActions()
} }
"camera_action" -> config.cameraAction = preferences.getString(key, null)?.let { AACPManager.Companion.StemPressType.valueOf(it) } "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") { if (key == "mac_address") {
@@ -1754,7 +1846,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
notificationManager.notify(1, updatedNotification) notificationManager.notify(1, updatedNotification)
notificationManager.cancel(2) notificationManager.cancel(2)
} else if (!config.bleOnlyMode && !socket.isConnected && isConnectedLocally) { } 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?") showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
} }
} }
@@ -1965,15 +2056,17 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
private fun setMetadatas(d: BluetoothDevice) { private fun setMetadatas(d: BluetoothDevice) {
d.let{ device -> d.let{ device ->
val metadataSet = SystemApisUtils.setMetadata( val instance = airpodsInstance
if (instance != null) {
val metadataSet = SystemApisUtils.setMetadata(
device, device,
device.METADATA_MAIN_ICON, device.METADATA_MAIN_ICON,
resToUri(R.drawable.pro_2).toString().toByteArray() resToUri(instance.model.budCaseRes).toString().toByteArray()
) && ) &&
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
device.METADATA_MODEL_NAME, device.METADATA_MODEL_NAME,
"AirPods Pro (2 Gen.)".toByteArray() instance.model.name.toByteArray()
) && ) &&
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
@@ -1983,22 +2076,22 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
device.METADATA_UNTETHERED_CASE_ICON, device.METADATA_UNTETHERED_CASE_ICON,
resToUri(R.drawable.pro_2_case).toString().toByteArray() resToUri(instance.model.caseRes).toString().toByteArray()
) && ) &&
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
device.METADATA_UNTETHERED_RIGHT_ICON, device.METADATA_UNTETHERED_RIGHT_ICON,
resToUri(R.drawable.pro_2_right).toString().toByteArray() resToUri(instance.model.rightBudsRes).toString().toByteArray()
) && ) &&
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
device.METADATA_UNTETHERED_LEFT_ICON, device.METADATA_UNTETHERED_LEFT_ICON,
resToUri(R.drawable.pro_2_left).toString().toByteArray() resToUri(instance.model.leftBudsRes).toString().toByteArray()
) && ) &&
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
device.METADATA_MANUFACTURER_NAME, device.METADATA_MANUFACTURER_NAME,
"Apple".toByteArray() instance.model.manufacturer.toByteArray()
) && ) &&
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
@@ -2020,7 +2113,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
device.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD, device.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD,
"20".toByteArray() "20".toByteArray()
) )
Log.d(TAG, "Metadata set: $metadataSet") Log.d(TAG, "Metadata set: $metadataSet")
} else {
Log.w(TAG, "AirPods instance is not of type AirPodsInstance, skipping metadata setting")
}
} }
} }
@@ -2042,9 +2138,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val name = context?.getSharedPreferences("settings", MODE_PRIVATE) val name = context?.getSharedPreferences("settings", MODE_PRIVATE)
?.getString("name", bluetoothDevice?.name) ?.getString("name", bluetoothDevice?.name)
if (bluetoothDevice != null && action != null && !action.isEmpty()) { if (bluetoothDevice != null && action != null && !action.isEmpty()) {
Log.d(TAG, "Received bluetooth connection broadcast") Log.d(TAG, "Received bluetooth connection broadcast: action=$action")
if (ServiceManager.getService()?.isConnectedLocally == true) { if (ServiceManager.getService()?.isConnectedLocally == true) {
ServiceManager.getService()?.manuallyCheckForAudioSource() 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")
return return
} }
if (BluetoothDevice.ACTION_ACL_CONNECTED == action) { if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
@@ -2081,14 +2178,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
return START_STICKY return START_STICKY
} }
private lateinit var socket: BluetoothSocket
fun manuallyCheckForAudioSource() { fun manuallyCheckForAudioSource() {
val shouldResume = MediaController.getMusicActive() val shouldResume = MediaController.getMusicActive() // todo: for some reason we lose this info after disconnecting, probably android dispatches some event. haven't investigated yet.
if ((earDetectionNotification.status[0] != 0.toByte() && earDetectionNotification.status[1] != 0.toByte()) || disconnectedBecauseReversed || otherDeviceTookOver) { if (airpodsInstance == null) return
Log.d(TAG, "disconnectedBecauseReversed: $disconnectedBecauseReversed, otherDeviceTookOver: $otherDeviceTookOver")
if ((earDetectionNotification.status[0] != 0.toByte() && earDetectionNotification.status[1] != 0.toByte()) || disconnectedBecauseReversed || otherDeviceTookOver) {
Log.d( Log.d(
TAG, TAG,
"For some reason, Android connected to the audio profile itself even after disconnecting. Disconnecting audio profile again!" "For some reason, Android connected to the audio profile itself even after disconnecting. Disconnecting audio profile again! I will resume: $shouldResume"
) )
disconnectAudio(this, device, shouldResume = shouldResume) disconnectAudio(this, device, shouldResume = shouldResume)
} }
@@ -2284,7 +2381,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
fun connectToSocket(device: BluetoothDevice) { fun connectToSocket(device: BluetoothDevice, manual: Boolean = false) {
Log.d(TAG, "<LogCollector:Start> Connecting to socket") Log.d(TAG, "<LogCollector:Start> Connecting to socket")
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;") HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
@@ -2293,7 +2390,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
createBluetoothSocket(device, uuid) createBluetoothSocket(device, uuid)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}") Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}")
showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.message}") showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}")
return return
} }
@@ -2310,6 +2407,26 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
attManager = ATTManager(device) attManager = ATTManager(device)
attManager!!.connect() 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( updateNotificationContent(
true, true,
config.deviceName, config.deviceName,
@@ -2317,15 +2434,29 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
) )
Log.d(TAG, "<LogCollector:Complete:Success> Socket connected") Log.d(TAG, "<LogCollector:Complete:Success> Socket connected")
} catch (e: Exception) { } catch (e: Exception) {
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected") Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected, ${e.message}")
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?") if (manual) {
throw e 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
} }
} }
if (!socket.isConnected) { }
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected") if (!socket.isConnected) {
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?") 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")
} }
return
} }
this@AirPodsService.device = device this@AirPodsService.device = device
socket.let { socket.let {
@@ -2405,7 +2536,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
Log.d(TAG, "Failed to connect to socket: ${e.message}") Log.d(TAG, "Failed to connect to socket: ${e.message}")
showSocketConnectionFailureNotification("Failed to establish connection: ${e.message}") showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}")
isConnectedLocally = false isConnectedLocally = false
this@AirPodsService.device = device this@AirPodsService.device = device
updateNotificationContent(false) updateNotificationContent(false)
@@ -2413,7 +2544,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
} }
fun disconnect() { fun disconnectForCD() {
if (!this::socket.isInitialized) return if (!this::socket.isInitialized) return
socket.close() socket.close()
MediaController.pausedWhileTakingOver = false MediaController.pausedWhileTakingOver = false
@@ -2438,6 +2569,33 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
CrossDevice.isAvailable = true 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 earDetectionNotification = AirPodsNotifications.EarDetection()
val ancNotification = AirPodsNotifications.ANC() val ancNotification = AirPodsNotifications.ANC()
val batteryNotification = AirPodsNotifications.BatteryNotification() val batteryNotification = AirPodsNotifications.BatteryNotification()
@@ -2636,6 +2794,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
isHeadTrackingActive = false 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 { private fun Int.dpToPx(): Int {

View File

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

View File

@@ -0,0 +1,232 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.utils
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.R
open class AirPodsBase(
val modelNumber: List<String>,
val name: String,
val displayName: String = "AirPods",
val manufacturer: String = "Apple Inc.",
val budCaseRes: Int,
val budsRes: Int,
val leftBudsRes: Int,
val rightBudsRes: Int,
val caseRes: Int,
val capabilities: Set<Capability>
)
enum class Capability {
LISTENING_MODE,
CONVERSATION_AWARENESS,
STEM_CONFIG,
HEAD_GESTURES,
LOUD_SOUND_REDUCTION,
PPE,
SLEEP_DETECTION,
HEARING_AID,
ADAPTIVE_AUDIO,
ADAPTIVE_VOLUME,
SWIPE_FOR_VOLUME,
HRM
}
class AirPods: AirPodsBase(
modelNumber = listOf("A1523", "A1722"),
name = "AirPods 1",
budCaseRes = R.drawable.airpods_1,
budsRes = R.drawable.airpods_1_buds,
leftBudsRes = R.drawable.airpods_1_left,
rightBudsRes = R.drawable.airpods_1_right,
caseRes = R.drawable.airpods_1_case,
capabilities = emptySet()
)
class AirPods2: AirPodsBase(
modelNumber = listOf("A2032", "A2031"),
name = "AirPods 2",
budCaseRes = R.drawable.airpods_2,
budsRes = R.drawable.airpods_2_buds,
leftBudsRes = R.drawable.airpods_2_left,
rightBudsRes = R.drawable.airpods_2_right,
caseRes = R.drawable.airpods_2_case,
capabilities = emptySet()
)
class AirPods3: AirPodsBase(
modelNumber = listOf("A2565", "A2564"),
name = "AirPods 3",
budCaseRes = R.drawable.airpods_3,
budsRes = R.drawable.airpods_3_buds,
leftBudsRes = R.drawable.airpods_3_left,
rightBudsRes = R.drawable.airpods_3_right,
caseRes = R.drawable.airpods_3_case,
capabilities = setOf(
Capability.HEAD_GESTURES
)
)
class AirPods4: AirPodsBase(
modelNumber = listOf("A3053", "A3050", "A3054"),
name = "AirPods 4",
budCaseRes = R.drawable.airpods_4,
budsRes = R.drawable.airpods_4_buds,
leftBudsRes = R.drawable.airpods_4_left,
rightBudsRes = R.drawable.airpods_4_right,
caseRes = R.drawable.airpods_4_case,
capabilities = setOf(
Capability.HEAD_GESTURES,
Capability.SLEEP_DETECTION,
Capability.ADAPTIVE_VOLUME
)
)
class AirPods4ANC: AirPodsBase(
modelNumber = listOf("A3056", "A3055", "A3057"),
name = "AirPods 4 (ANC)",
budCaseRes = R.drawable.airpods_4,
budsRes = R.drawable.airpods_4_buds,
leftBudsRes = R.drawable.airpods_4_left,
rightBudsRes = R.drawable.airpods_4_right,
caseRes = R.drawable.airpods_4_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.HEAD_GESTURES,
Capability.ADAPTIVE_AUDIO,
Capability.SLEEP_DETECTION,
Capability.ADAPTIVE_VOLUME
)
)
class AirPodsPro1: AirPodsBase(
modelNumber = listOf("A2084", "A2083"),
name = "AirPods Pro 1",
displayName = "AirPods Pro",
budCaseRes = R.drawable.airpods_pro_1,
budsRes = R.drawable.airpods_pro_1_buds,
leftBudsRes = R.drawable.airpods_pro_1_left,
rightBudsRes = R.drawable.airpods_pro_1_right,
caseRes = R.drawable.airpods_pro_1_case,
capabilities = setOf(
Capability.LISTENING_MODE
)
)
class AirPodsPro2Lightning: AirPodsBase(
modelNumber = listOf("A2931", "A2699", "A2698"),
name = "AirPods Pro 2 with Magsafe Charging Case (Lightning)",
displayName = "AirPods Pro",
budCaseRes = R.drawable.airpods_pro_2,
budsRes = R.drawable.airpods_pro_2_buds,
leftBudsRes = R.drawable.airpods_pro_2_left,
rightBudsRes = R.drawable.airpods_pro_2_right,
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.STEM_CONFIG,
Capability.LOUD_SOUND_REDUCTION,
Capability.SLEEP_DETECTION,
Capability.HEARING_AID,
Capability.ADAPTIVE_AUDIO,
Capability.ADAPTIVE_VOLUME,
Capability.SWIPE_FOR_VOLUME
)
)
class AirPodsPro2USBC: AirPodsBase(
modelNumber = listOf("A3047", "A3048", "A3049"),
name = "AirPods Pro 2 with Magsafe Charging Case (USB-C)",
displayName = "AirPods Pro",
budCaseRes = R.drawable.airpods_pro_2,
budsRes = R.drawable.airpods_pro_2_buds,
leftBudsRes = R.drawable.airpods_pro_2_left,
rightBudsRes = R.drawable.airpods_pro_2_right,
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.STEM_CONFIG,
Capability.LOUD_SOUND_REDUCTION,
Capability.SLEEP_DETECTION,
Capability.HEARING_AID,
Capability.ADAPTIVE_AUDIO,
Capability.ADAPTIVE_VOLUME,
Capability.SWIPE_FOR_VOLUME
)
)
class AirPodsPro3: AirPodsBase(
modelNumber = listOf("A3063", "A3064", "A3065"),
name = "AirPods Pro 3",
displayName = "AirPods Pro",
budCaseRes = R.drawable.airpods_pro_3,
budsRes = R.drawable.airpods_pro_3_buds,
leftBudsRes = R.drawable.airpods_pro_3_left,
rightBudsRes = R.drawable.airpods_pro_3_right,
caseRes = R.drawable.airpods_pro_3_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.STEM_CONFIG,
Capability.LOUD_SOUND_REDUCTION,
Capability.PPE,
Capability.SLEEP_DETECTION,
Capability.HEARING_AID,
Capability.ADAPTIVE_AUDIO,
Capability.ADAPTIVE_VOLUME,
Capability.SWIPE_FOR_VOLUME,
Capability.HRM
)
)
data class AirPodsInstance(
val name: String,
val model: AirPodsBase,
val actualModelNumber: String,
val serialNumber: String?,
val leftSerialNumber: String?,
val rightSerialNumber: String?,
val version1: String?,
val version2: String?,
val version3: String?,
val aacpManager: AACPManager,
val attManager: ATTManager?
)
object AirPodsModels {
val models: List<AirPodsBase> = listOf(
AirPods(),
AirPods2(),
AirPods3(),
AirPods4(),
AirPods4ANC(),
AirPodsPro1(),
AirPodsPro2Lightning(),
AirPodsPro2USBC(),
AirPodsPro3()
)
fun getModelByModelNumber(modelNumber: String): AirPodsBase? {
return models.find { modelNumber in it.modelNumber }
}
}

View File

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

View File

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

View File

@@ -0,0 +1,190 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.utils
import android.util.Log
import androidx.compose.runtime.MutableState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
private const val TAG = "HearingAidUtils"
data class HearingAidSettings(
val leftEQ: FloatArray,
val rightEQ: FloatArray,
val leftAmplification: Float,
val rightAmplification: Float,
val leftTone: Float,
val rightTone: Float,
val leftConversationBoost: Boolean,
val rightConversationBoost: Boolean,
val leftAmbientNoiseReduction: Float,
val rightAmbientNoiseReduction: Float,
val netAmplification: Float,
val balance: Float,
val ownVoiceAmplification: Float
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as HearingAidSettings
if (leftAmplification != other.leftAmplification) return false
if (rightAmplification != other.rightAmplification) return false
if (leftTone != other.leftTone) return false
if (rightTone != other.rightTone) return false
if (leftConversationBoost != other.leftConversationBoost) return false
if (rightConversationBoost != other.rightConversationBoost) return false
if (leftAmbientNoiseReduction != other.leftAmbientNoiseReduction) return false
if (rightAmbientNoiseReduction != other.rightAmbientNoiseReduction) return false
if (!leftEQ.contentEquals(other.leftEQ)) return false
if (!rightEQ.contentEquals(other.rightEQ)) return false
if (ownVoiceAmplification != other.ownVoiceAmplification) return false
return true
}
override fun hashCode(): Int {
var result = leftAmplification.hashCode()
result = 31 * result + rightAmplification.hashCode()
result = 31 * result + leftTone.hashCode()
result = 31 * result + rightTone.hashCode()
result = 31 * result + leftConversationBoost.hashCode()
result = 31 * result + rightConversationBoost.hashCode()
result = 31 * result + leftAmbientNoiseReduction.hashCode()
result = 31 * result + rightAmbientNoiseReduction.hashCode()
result = 31 * result + leftEQ.contentHashCode()
result = 31 * result + rightEQ.contentHashCode()
result = 31 * result + ownVoiceAmplification.hashCode()
return result
}
}
fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? {
if (data.size < 104) return null
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
buffer.get() // skip 0x02
buffer.get() // skip 0x02
buffer.getShort() // skip 0x60 0x00
val leftEQ = FloatArray(8)
for (i in 0..7) {
leftEQ[i] = buffer.float
}
val leftAmplification = buffer.float
val leftTone = buffer.float
val leftConvFloat = buffer.float
val leftConversationBoost = leftConvFloat > 0.5f
val leftAmbientNoiseReduction = buffer.float
val rightEQ = FloatArray(8)
for (i in 0..7) {
rightEQ[i] = buffer.float
}
val rightAmplification = buffer.float
val rightTone = buffer.float
val rightConvFloat = buffer.float
val rightConversationBoost = rightConvFloat > 0.5f
val rightAmbientNoiseReduction = buffer.float
val ownVoiceAmplification = buffer.float
val avg = (leftAmplification + rightAmplification) / 2
val amplification = avg.coerceIn(-1f, 1f)
val diff = rightAmplification - leftAmplification
val balance = diff.coerceIn(-1f, 1f)
return HearingAidSettings(
leftEQ = leftEQ,
rightEQ = rightEQ,
leftAmplification = leftAmplification,
rightAmplification = rightAmplification,
leftTone = leftTone,
rightTone = rightTone,
leftConversationBoost = leftConversationBoost,
rightConversationBoost = rightConversationBoost,
leftAmbientNoiseReduction = leftAmbientNoiseReduction,
rightAmbientNoiseReduction = rightAmbientNoiseReduction,
netAmplification = amplification,
balance = balance,
ownVoiceAmplification = ownVoiceAmplification
)
}
fun sendHearingAidSettings(
attManager: ATTManager,
hearingAidSettings: HearingAidSettings,
debounceJob: MutableState<Job?>
) {
debounceJob.value?.cancel()
debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
delay(100)
try {
val currentData = attManager.read(ATTHandles.HEARING_AID)
Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
if (currentData.size < 104) {
Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings")
return@launch
}
val buffer = ByteBuffer.wrap(currentData).order(ByteOrder.LITTLE_ENDIAN)
// for some reason
buffer.put(2, 0x64)
// Left EQ
for (i in 0..7) {
buffer.putFloat(4 + i * 4, hearingAidSettings.leftEQ[i])
}
// Left ear adjustments
buffer.putFloat(36, hearingAidSettings.leftAmplification)
buffer.putFloat(40, hearingAidSettings.leftTone)
buffer.putFloat(44, if (hearingAidSettings.leftConversationBoost) 1.0f else 0.0f)
buffer.putFloat(48, hearingAidSettings.leftAmbientNoiseReduction)
// Right EQ
for (i in 0..7) {
buffer.putFloat(52 + i * 4, hearingAidSettings.rightEQ[i])
}
// Right ear adjustments
buffer.putFloat(84, hearingAidSettings.rightAmplification)
buffer.putFloat(88, hearingAidSettings.rightTone)
buffer.putFloat(92, if (hearingAidSettings.rightConversationBoost) 1.0f else 0.0f)
buffer.putFloat(96, hearingAidSettings.rightAmbientNoiseReduction)
// Own voice amplification
buffer.putFloat(100, hearingAidSettings.ownVoiceAmplification)
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
attManager.write(ATTHandles.HEARING_AID, currentData)
} catch (e: IOException) {
e.printStackTrace()
}
}
}

View File

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

View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

Before

Width:  |  Height:  |  Size: 605 KiB

After

Width:  |  Height:  |  Size: 605 KiB

View File

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,4 @@
<resources <resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="MissingTranslation">
<string name="app_name" translatable="false">LibrePods</string> <string name="app_name" translatable="false">LibrePods</string>
<string name="app_description">Liberate your AirPods from Apple\'s ecosystem.</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> <string name="app_widget_description">See your AirPods battery status right from your home screen!</string>
@@ -84,7 +82,7 @@
<string name="takeover_media_start_desc">Your phone starts playing media</string> <string name="takeover_media_start_desc">Your phone starts playing media</string>
<string name="undo">Undo</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="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">AirPods Pro automatically reduce your exposure to loud environmental noises when in Transparency and Adaptive mode.</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">Loud Sound Reduction</string> <string name="loud_sound_reduction">Loud Sound Reduction</string>
<string name="call_controls">Call Controls</string> <string name="call_controls">Call Controls</string>
<string name="automatically_connect">Connect to this device automatically</string> <string name="automatically_connect">Connect to this device automatically</string>
@@ -184,4 +182,32 @@
<string name="custom_camera_package_set_success">Custom camera appid set successfully</string> <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_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="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> </resources>

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB