Compare commits
19 Commits
release-ni
...
multi-devi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63b6e2a389 | ||
|
|
9b950e13d6 | ||
|
|
55768beb7c | ||
|
|
cea09b208a | ||
|
|
02edb51e41 | ||
|
|
10fc96dc94 | ||
|
|
1a2f5138a9 | ||
|
|
ee9de99204 | ||
|
|
c83ffca546 | ||
|
|
814eba8ed6 | ||
|
|
a9b78efd80 | ||
|
|
942ff82382 | ||
|
|
4a135fa463 | ||
|
|
39a64ec6f2 | ||
|
|
b7cc27f4d3 | ||
|
|
0e0af35103 | ||
|
|
993ba1ba08 | ||
|
|
3a9c118353 | ||
|
|
37313fbb1c |
27
README.md
@@ -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
|
||||||
|
|
||||||
| | | |
|
| | | |
|
||||||
|-------------------|-------------------|-------------------|
|
| -------------------------------------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||||
|  |  |  |
|
|  |  |  |
|
||||||
|  |  |  |
|
|  |  |  |
|
||||||
|  |  |  |
|
|  |  |  |
|
||||||
|  |  | |
|
|  |  |  |
|
||||||
| | | |
|
|  |  |  |
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
/*
|
||||||
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.composables.NavigationButton
|
||||||
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AboutCard(navController: NavController) {
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val service = ServiceManager.getService()
|
||||||
|
if (service == null) return
|
||||||
|
val airpodsInstance = service.airpodsInstance
|
||||||
|
if (airpodsInstance == null) return
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
){
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val rowHeight = remember { mutableStateOf(0.dp) }
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(28.dp))
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||||
|
.padding(top = 2.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
.onGloballyPositioned { coordinates ->
|
||||||
|
rowHeight.value = with(density) { coordinates.size.height.toDp() }
|
||||||
|
},
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.model_name),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = airpodsInstance.model.displayName,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.model_name),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = airpodsInstance.actualModelNumber,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
val serialNumbers = listOf(
|
||||||
|
airpodsInstance.serialNumber?: "",
|
||||||
|
" ${airpodsInstance.leftSerialNumber}",
|
||||||
|
" ${airpodsInstance.rightSerialNumber}"
|
||||||
|
)
|
||||||
|
val serialNumber = remember { mutableStateOf(0) }
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.serial_number),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = serialNumbers[serialNumber.value],
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null
|
||||||
|
) {
|
||||||
|
serialNumber.value = (serialNumber.value + 1) % serialNumbers.size
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
NavigationButton(
|
||||||
|
to = "version_info",
|
||||||
|
navController = navController,
|
||||||
|
name = stringResource(R.string.version),
|
||||||
|
currentState = airpodsInstance.version3,
|
||||||
|
independent = false,
|
||||||
|
height = rowHeight.value + 32.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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))
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/*
|
||||||
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.composables.NavigationButton
|
||||||
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.Capability
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HearingHealthSettings(navController: NavController) {
|
||||||
|
val service = ServiceManager.getService()
|
||||||
|
if (service == null) return
|
||||||
|
val airpodsInstance = service.airpodsInstance
|
||||||
|
if (airpodsInstance == null) return
|
||||||
|
if (airpodsInstance.model.capabilities.contains(Capability.HEARING_AID)) {
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
|
if (airpodsInstance.model.capabilities.contains(Capability.PPE)) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
){
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.hearing_health),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(28.dp))
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||||
|
.padding(top = 2.dp)
|
||||||
|
) {
|
||||||
|
NavigationButton(
|
||||||
|
to = "hearing_protection",
|
||||||
|
name = stringResource(R.string.hearing_protection),
|
||||||
|
navController = navController,
|
||||||
|
independent = false
|
||||||
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
NavigationButton(
|
||||||
|
to = "hearing_aid",
|
||||||
|
name = stringResource(R.string.hearing_aid),
|
||||||
|
navController = navController,
|
||||||
|
independent = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NavigationButton(
|
||||||
|
to = "hearing_aid",
|
||||||
|
name = stringResource(R.string.hearing_aid),
|
||||||
|
navController = navController
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,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 = {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.screens
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.composables.StyledToggle
|
||||||
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import me.kavishdevar.librepods.utils.ATTHandles
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
private var debounceJob: Job? = null
|
||||||
|
|
||||||
|
@SuppressLint("DefaultLocale")
|
||||||
|
@ExperimentalHazeMaterialsApi
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||||
|
@Composable
|
||||||
|
fun HearingProtectionScreen(navController: NavController) {
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val service = ServiceManager.getService()
|
||||||
|
if (service == null) return
|
||||||
|
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.hearing_protection),
|
||||||
|
) { spacerHeight ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
|
|
||||||
|
StyledToggle(
|
||||||
|
title = stringResource(R.string.environmental_noise),
|
||||||
|
label = stringResource(R.string.loud_sound_reduction),
|
||||||
|
description = stringResource(R.string.loud_sound_reduction_description),
|
||||||
|
attHandle = ATTHandles.LOUD_SOUND_REDUCTION
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
StyledToggle(
|
||||||
|
title = stringResource(R.string.workspace_use),
|
||||||
|
label = stringResource(R.string.ppe),
|
||||||
|
description = stringResource(R.string.workspace_use_description),
|
||||||
|
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,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,
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/*
|
||||||
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.screens
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
||||||
|
import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
|
||||||
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||||
|
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.composables.StyledSlider
|
||||||
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
private var debounceJob: Job? = null
|
||||||
|
|
||||||
|
@SuppressLint("DefaultLocale")
|
||||||
|
@ExperimentalHazeMaterialsApi
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||||
|
@Composable
|
||||||
|
fun OpenSourceLicensesScreen(navController: NavController) {
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.open_source_licenses)
|
||||||
|
) { spacerHeight ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
val libraries by produceLibraries {
|
||||||
|
context.resources.openRawResource(R.raw.aboutlibraries)
|
||||||
|
.bufferedReader()
|
||||||
|
.use { it.readText() }
|
||||||
|
}
|
||||||
|
LibrariesContainer(
|
||||||
|
libraries = libraries,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(0.dp)
|
||||||
|
.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,8 +110,8 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
if (modesByte != null) {
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
/*
|
||||||
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.screens
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import me.kavishdevar.librepods.utils.ATTHandles
|
||||||
|
import me.kavishdevar.librepods.utils.HearingAidSettings
|
||||||
|
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
|
||||||
|
import me.kavishdevar.librepods.utils.sendHearingAidSettings
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
|
||||||
|
private const val TAG = "HearingAidAdjustments"
|
||||||
|
|
||||||
|
@SuppressLint("DefaultLocale")
|
||||||
|
@ExperimentalHazeMaterialsApi
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||||
|
@Composable
|
||||||
|
fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
||||||
|
val verticalScrollState = rememberScrollState()
|
||||||
|
val attManager = ServiceManager.getService()?.attManager
|
||||||
|
if (attManager == null) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.att_manager_is_null_try_reconnecting),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.hearing_test)
|
||||||
|
) { spacerHeight, hazeState ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.hazeSource(hazeState)
|
||||||
|
.fillMaxSize()
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
.verticalScroll(verticalScrollState)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.hearing_test_value_instruction),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
|
||||||
|
val conversationBoostEnabled = remember { mutableStateOf(false) }
|
||||||
|
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
|
||||||
|
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
|
||||||
|
|
||||||
|
val initialLoadComplete = remember { mutableStateOf(false) }
|
||||||
|
val initialReadSucceeded = remember { mutableStateOf(false) }
|
||||||
|
val initialReadAttempts = remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
val hearingAidSettings = remember {
|
||||||
|
mutableStateOf(
|
||||||
|
HearingAidSettings(
|
||||||
|
leftEQ = leftEQ.value,
|
||||||
|
rightEQ = rightEQ.value,
|
||||||
|
leftAmplification = 0.5f,
|
||||||
|
rightAmplification = 0.5f,
|
||||||
|
leftTone = 0.5f,
|
||||||
|
rightTone = 0.5f,
|
||||||
|
leftConversationBoost = conversationBoostEnabled.value,
|
||||||
|
rightConversationBoost = conversationBoostEnabled.value,
|
||||||
|
leftAmbientNoiseReduction = 0.0f,
|
||||||
|
rightAmbientNoiseReduction = 0.0f,
|
||||||
|
netAmplification = 0.5f,
|
||||||
|
balance = 0.5f,
|
||||||
|
ownVoiceAmplification = 0.5f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val hearingAidEnabled = remember {
|
||||||
|
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
||||||
|
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
||||||
|
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
|
||||||
|
}
|
||||||
|
|
||||||
|
val hearingAidListener = remember {
|
||||||
|
object : AACPManager.ControlCommandListener {
|
||||||
|
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||||
|
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
|
||||||
|
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
|
||||||
|
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
||||||
|
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
||||||
|
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val hearingAidATTListener = remember {
|
||||||
|
object : (ByteArray) -> Unit {
|
||||||
|
override fun invoke(value: ByteArray) {
|
||||||
|
val parsed = parseHearingAidSettingsResponse(value)
|
||||||
|
if (parsed != null) {
|
||||||
|
leftEQ.value = parsed.leftEQ.copyOf()
|
||||||
|
rightEQ.value = parsed.rightEQ.copyOf()
|
||||||
|
conversationBoostEnabled.value = parsed.leftConversationBoost
|
||||||
|
Log.d(TAG, "Updated hearing aid settings from notification")
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
||||||
|
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
||||||
|
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
||||||
|
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(leftEQ.value, rightEQ.value, conversationBoostEnabled.value, initialLoadComplete.value, initialReadSucceeded.value) {
|
||||||
|
if (!initialLoadComplete.value) {
|
||||||
|
Log.d(TAG, "Initial device load not complete - skipping send")
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!initialReadSucceeded.value) {
|
||||||
|
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
hearingAidSettings.value = HearingAidSettings(
|
||||||
|
leftEQ = leftEQ.value,
|
||||||
|
rightEQ = rightEQ.value,
|
||||||
|
leftAmplification = 0.5f,
|
||||||
|
rightAmplification = 0.5f,
|
||||||
|
leftTone = 0.5f,
|
||||||
|
rightTone = 0.5f,
|
||||||
|
leftConversationBoost = conversationBoostEnabled.value,
|
||||||
|
rightConversationBoost = conversationBoostEnabled.value,
|
||||||
|
leftAmbientNoiseReduction = 0.0f,
|
||||||
|
rightAmbientNoiseReduction = 0.0f,
|
||||||
|
netAmplification = 0.5f,
|
||||||
|
balance = 0.5f,
|
||||||
|
ownVoiceAmplification = 0.5f
|
||||||
|
)
|
||||||
|
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||||
|
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
Log.d(TAG, "Connecting to ATT...")
|
||||||
|
try {
|
||||||
|
attManager.enableNotifications(ATTHandles.HEARING_AID)
|
||||||
|
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (aacpManager != null) {
|
||||||
|
Log.d(TAG, "Found AACPManager, reading cached EQ data")
|
||||||
|
val aacpEQ = aacpManager.eqData
|
||||||
|
if (aacpEQ.isNotEmpty()) {
|
||||||
|
leftEQ.value = aacpEQ.copyOf()
|
||||||
|
rightEQ.value = aacpEQ.copyOf()
|
||||||
|
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "AACPManager EQ data empty")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "No AACPManager available")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsedSettings: HearingAidSettings? = null
|
||||||
|
for (attempt in 1..3) {
|
||||||
|
initialReadAttempts.intValue = attempt
|
||||||
|
try {
|
||||||
|
val data = attManager.read(ATTHandles.HEARING_AID)
|
||||||
|
parsedSettings = parseHearingAidSettingsResponse(data = data)
|
||||||
|
if (parsedSettings != null) {
|
||||||
|
Log.d(TAG, "Parsed settings on attempt $attempt")
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Parsing returned null on attempt $attempt")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
|
||||||
|
}
|
||||||
|
delay(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedSettings != null) {
|
||||||
|
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
|
||||||
|
leftEQ.value = parsedSettings.leftEQ.copyOf()
|
||||||
|
rightEQ.value = parsedSettings.rightEQ.copyOf()
|
||||||
|
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
||||||
|
initialReadSucceeded.value = true
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts")
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
e.printStackTrace()
|
||||||
|
} finally {
|
||||||
|
initialLoadComplete.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val frequencies = listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.width(60.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.left),
|
||||||
|
fontSize = 18.sp,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.right),
|
||||||
|
fontSize = 18.sp,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
frequencies.forEachIndexed { index, freq ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = freq,
|
||||||
|
modifier = Modifier
|
||||||
|
.width(60.dp)
|
||||||
|
.align(Alignment.CenterVertically),
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = leftEQ.value[index].toString(),
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
val parsed = newValue.toFloatOrNull()
|
||||||
|
if (parsed != null) {
|
||||||
|
val newArray = leftEQ.value.copyOf()
|
||||||
|
newArray[index] = parsed
|
||||||
|
leftEQ.value = newArray
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
textStyle = TextStyle(
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontSize = 14.sp
|
||||||
|
),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = rightEQ.value[index].toString(),
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
val parsed = newValue.toFloatOrNull()
|
||||||
|
if (parsed != null) {
|
||||||
|
val newArray = rightEQ.value.copyOf()
|
||||||
|
newArray[index] = parsed
|
||||||
|
rightEQ.value = newArray
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
textStyle = TextStyle(
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontSize = 14.sp
|
||||||
|
),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
/*
|
||||||
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
private var debounceJob: Job? = null
|
||||||
|
|
||||||
|
@SuppressLint("DefaultLocale")
|
||||||
|
@ExperimentalHazeMaterialsApi
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||||
|
@Composable
|
||||||
|
fun VersionScreen(navController: NavController) {
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val service = ServiceManager.getService()
|
||||||
|
if (service == null) return
|
||||||
|
val airpodsInstance = service.airpodsInstance
|
||||||
|
if (airpodsInstance == null) return
|
||||||
|
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.customize_adaptive_audio)
|
||||||
|
) { spacerHeight ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
){
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.version),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(28.dp))
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||||
|
.padding(top = 2.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.version) + " 1",
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = airpodsInstance.version1 ?: "N/A",
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor.copy(0.8f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.version) + " 2",
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = airpodsInstance.version2 ?: "N/A",
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor.copy(0.8f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.version) + " 3",
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = airpodsInstance.version3 ?: "N/A",
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor.copy(0.8f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,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 {
|
||||||
|
|||||||
@@ -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) ?: "",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/*
|
||||||
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import me.kavishdevar.librepods.utils.ATTManager
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
|
||||||
|
open class AirPodsBase(
|
||||||
|
val modelNumber: List<String>,
|
||||||
|
val name: String,
|
||||||
|
val displayName: String = "AirPods",
|
||||||
|
val manufacturer: String = "Apple Inc.",
|
||||||
|
val budCaseRes: Int,
|
||||||
|
val budsRes: Int,
|
||||||
|
val leftBudsRes: Int,
|
||||||
|
val rightBudsRes: Int,
|
||||||
|
val caseRes: Int,
|
||||||
|
val capabilities: Set<Capability>
|
||||||
|
)
|
||||||
|
enum class Capability {
|
||||||
|
LISTENING_MODE,
|
||||||
|
CONVERSATION_AWARENESS,
|
||||||
|
STEM_CONFIG,
|
||||||
|
HEAD_GESTURES,
|
||||||
|
LOUD_SOUND_REDUCTION,
|
||||||
|
PPE,
|
||||||
|
SLEEP_DETECTION,
|
||||||
|
HEARING_AID,
|
||||||
|
ADAPTIVE_AUDIO,
|
||||||
|
ADAPTIVE_VOLUME,
|
||||||
|
SWIPE_FOR_VOLUME,
|
||||||
|
HRM
|
||||||
|
}
|
||||||
|
|
||||||
|
class AirPods: AirPodsBase(
|
||||||
|
modelNumber = listOf("A1523", "A1722"),
|
||||||
|
name = "AirPods 1",
|
||||||
|
budCaseRes = R.drawable.airpods_1,
|
||||||
|
budsRes = R.drawable.airpods_1_buds,
|
||||||
|
leftBudsRes = R.drawable.airpods_1_left,
|
||||||
|
rightBudsRes = R.drawable.airpods_1_right,
|
||||||
|
caseRes = R.drawable.airpods_1_case,
|
||||||
|
capabilities = emptySet()
|
||||||
|
)
|
||||||
|
|
||||||
|
class AirPods2: AirPodsBase(
|
||||||
|
modelNumber = listOf("A2032", "A2031"),
|
||||||
|
name = "AirPods 2",
|
||||||
|
budCaseRes = R.drawable.airpods_2,
|
||||||
|
budsRes = R.drawable.airpods_2_buds,
|
||||||
|
leftBudsRes = R.drawable.airpods_2_left,
|
||||||
|
rightBudsRes = R.drawable.airpods_2_right,
|
||||||
|
caseRes = R.drawable.airpods_2_case,
|
||||||
|
capabilities = emptySet()
|
||||||
|
)
|
||||||
|
|
||||||
|
class AirPods3: AirPodsBase(
|
||||||
|
modelNumber = listOf("A2565", "A2564"),
|
||||||
|
name = "AirPods 3",
|
||||||
|
budCaseRes = R.drawable.airpods_3,
|
||||||
|
budsRes = R.drawable.airpods_3_buds,
|
||||||
|
leftBudsRes = R.drawable.airpods_3_left,
|
||||||
|
rightBudsRes = R.drawable.airpods_3_right,
|
||||||
|
caseRes = R.drawable.airpods_3_case,
|
||||||
|
capabilities = setOf(
|
||||||
|
Capability.HEAD_GESTURES
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
class AirPods4: AirPodsBase(
|
||||||
|
modelNumber = listOf("A3053", "A3050", "A3054"),
|
||||||
|
name = "AirPods 4",
|
||||||
|
budCaseRes = R.drawable.airpods_4,
|
||||||
|
budsRes = R.drawable.airpods_4_buds,
|
||||||
|
leftBudsRes = R.drawable.airpods_4_left,
|
||||||
|
rightBudsRes = R.drawable.airpods_4_right,
|
||||||
|
caseRes = R.drawable.airpods_4_case,
|
||||||
|
capabilities = setOf(
|
||||||
|
Capability.HEAD_GESTURES,
|
||||||
|
Capability.SLEEP_DETECTION,
|
||||||
|
Capability.ADAPTIVE_VOLUME
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
class AirPods4ANC: AirPodsBase(
|
||||||
|
modelNumber = listOf("A3056", "A3055", "A3057"),
|
||||||
|
name = "AirPods 4 (ANC)",
|
||||||
|
budCaseRes = R.drawable.airpods_4,
|
||||||
|
budsRes = R.drawable.airpods_4_buds,
|
||||||
|
leftBudsRes = R.drawable.airpods_4_left,
|
||||||
|
rightBudsRes = R.drawable.airpods_4_right,
|
||||||
|
caseRes = R.drawable.airpods_4_case,
|
||||||
|
capabilities = setOf(
|
||||||
|
Capability.LISTENING_MODE,
|
||||||
|
Capability.CONVERSATION_AWARENESS,
|
||||||
|
Capability.HEAD_GESTURES,
|
||||||
|
Capability.ADAPTIVE_AUDIO,
|
||||||
|
Capability.SLEEP_DETECTION,
|
||||||
|
Capability.ADAPTIVE_VOLUME
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
class AirPodsPro1: AirPodsBase(
|
||||||
|
modelNumber = listOf("A2084", "A2083"),
|
||||||
|
name = "AirPods Pro 1",
|
||||||
|
displayName = "AirPods Pro",
|
||||||
|
budCaseRes = R.drawable.airpods_pro_1,
|
||||||
|
budsRes = R.drawable.airpods_pro_1_buds,
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_1_left,
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_1_right,
|
||||||
|
caseRes = R.drawable.airpods_pro_1_case,
|
||||||
|
capabilities = setOf(
|
||||||
|
Capability.LISTENING_MODE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
class AirPodsPro2Lightning: AirPodsBase(
|
||||||
|
modelNumber = listOf("A2931", "A2699", "A2698"),
|
||||||
|
name = "AirPods Pro 2 with Magsafe Charging Case (Lightning)",
|
||||||
|
displayName = "AirPods Pro",
|
||||||
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
|
capabilities = setOf(
|
||||||
|
Capability.LISTENING_MODE,
|
||||||
|
Capability.CONVERSATION_AWARENESS,
|
||||||
|
Capability.STEM_CONFIG,
|
||||||
|
Capability.LOUD_SOUND_REDUCTION,
|
||||||
|
Capability.SLEEP_DETECTION,
|
||||||
|
Capability.HEARING_AID,
|
||||||
|
Capability.ADAPTIVE_AUDIO,
|
||||||
|
Capability.ADAPTIVE_VOLUME,
|
||||||
|
Capability.SWIPE_FOR_VOLUME
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
class AirPodsPro2USBC: AirPodsBase(
|
||||||
|
modelNumber = listOf("A3047", "A3048", "A3049"),
|
||||||
|
name = "AirPods Pro 2 with Magsafe Charging Case (USB-C)",
|
||||||
|
displayName = "AirPods Pro",
|
||||||
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
|
capabilities = setOf(
|
||||||
|
Capability.LISTENING_MODE,
|
||||||
|
Capability.CONVERSATION_AWARENESS,
|
||||||
|
Capability.STEM_CONFIG,
|
||||||
|
Capability.LOUD_SOUND_REDUCTION,
|
||||||
|
Capability.SLEEP_DETECTION,
|
||||||
|
Capability.HEARING_AID,
|
||||||
|
Capability.ADAPTIVE_AUDIO,
|
||||||
|
Capability.ADAPTIVE_VOLUME,
|
||||||
|
Capability.SWIPE_FOR_VOLUME
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
class AirPodsPro3: AirPodsBase(
|
||||||
|
modelNumber = listOf("A3063", "A3064", "A3065"),
|
||||||
|
name = "AirPods Pro 3",
|
||||||
|
displayName = "AirPods Pro",
|
||||||
|
budCaseRes = R.drawable.airpods_pro_3,
|
||||||
|
budsRes = R.drawable.airpods_pro_3_buds,
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_3_left,
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_3_right,
|
||||||
|
caseRes = R.drawable.airpods_pro_3_case,
|
||||||
|
capabilities = setOf(
|
||||||
|
Capability.LISTENING_MODE,
|
||||||
|
Capability.CONVERSATION_AWARENESS,
|
||||||
|
Capability.STEM_CONFIG,
|
||||||
|
Capability.LOUD_SOUND_REDUCTION,
|
||||||
|
Capability.PPE,
|
||||||
|
Capability.SLEEP_DETECTION,
|
||||||
|
Capability.HEARING_AID,
|
||||||
|
Capability.ADAPTIVE_AUDIO,
|
||||||
|
Capability.ADAPTIVE_VOLUME,
|
||||||
|
Capability.SWIPE_FOR_VOLUME,
|
||||||
|
Capability.HRM
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AirPodsInstance(
|
||||||
|
val name: String,
|
||||||
|
val model: AirPodsBase,
|
||||||
|
val actualModelNumber: String,
|
||||||
|
val serialNumber: String?,
|
||||||
|
val leftSerialNumber: String?,
|
||||||
|
val rightSerialNumber: String?,
|
||||||
|
val version1: String?,
|
||||||
|
val version2: String?,
|
||||||
|
val version3: String?,
|
||||||
|
val aacpManager: AACPManager,
|
||||||
|
val attManager: ATTManager?
|
||||||
|
)
|
||||||
|
|
||||||
|
object AirPodsModels {
|
||||||
|
val models: List<AirPodsBase> = listOf(
|
||||||
|
AirPods(),
|
||||||
|
AirPods2(),
|
||||||
|
AirPods3(),
|
||||||
|
AirPods4(),
|
||||||
|
AirPods4ANC(),
|
||||||
|
AirPodsPro1(),
|
||||||
|
AirPodsPro2Lightning(),
|
||||||
|
AirPodsPro2USBC(),
|
||||||
|
AirPodsPro3()
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getModelByModelNumber(modelNumber: String): AirPodsBase? {
|
||||||
|
return models.find { modelNumber in it.modelNumber }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -390,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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/*
|
||||||
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.IOException
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
private const val TAG = "HearingAidUtils"
|
||||||
|
|
||||||
|
data class HearingAidSettings(
|
||||||
|
val leftEQ: FloatArray,
|
||||||
|
val rightEQ: FloatArray,
|
||||||
|
val leftAmplification: Float,
|
||||||
|
val rightAmplification: Float,
|
||||||
|
val leftTone: Float,
|
||||||
|
val rightTone: Float,
|
||||||
|
val leftConversationBoost: Boolean,
|
||||||
|
val rightConversationBoost: Boolean,
|
||||||
|
val leftAmbientNoiseReduction: Float,
|
||||||
|
val rightAmbientNoiseReduction: Float,
|
||||||
|
val netAmplification: Float,
|
||||||
|
val balance: Float,
|
||||||
|
val ownVoiceAmplification: Float
|
||||||
|
) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as HearingAidSettings
|
||||||
|
|
||||||
|
if (leftAmplification != other.leftAmplification) return false
|
||||||
|
if (rightAmplification != other.rightAmplification) return false
|
||||||
|
if (leftTone != other.leftTone) return false
|
||||||
|
if (rightTone != other.rightTone) return false
|
||||||
|
if (leftConversationBoost != other.leftConversationBoost) return false
|
||||||
|
if (rightConversationBoost != other.rightConversationBoost) return false
|
||||||
|
if (leftAmbientNoiseReduction != other.leftAmbientNoiseReduction) return false
|
||||||
|
if (rightAmbientNoiseReduction != other.rightAmbientNoiseReduction) return false
|
||||||
|
if (!leftEQ.contentEquals(other.leftEQ)) return false
|
||||||
|
if (!rightEQ.contentEquals(other.rightEQ)) return false
|
||||||
|
if (ownVoiceAmplification != other.ownVoiceAmplification) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = leftAmplification.hashCode()
|
||||||
|
result = 31 * result + rightAmplification.hashCode()
|
||||||
|
result = 31 * result + leftTone.hashCode()
|
||||||
|
result = 31 * result + rightTone.hashCode()
|
||||||
|
result = 31 * result + leftConversationBoost.hashCode()
|
||||||
|
result = 31 * result + rightConversationBoost.hashCode()
|
||||||
|
result = 31 * result + leftAmbientNoiseReduction.hashCode()
|
||||||
|
result = 31 * result + rightAmbientNoiseReduction.hashCode()
|
||||||
|
result = 31 * result + leftEQ.contentHashCode()
|
||||||
|
result = 31 * result + rightEQ.contentHashCode()
|
||||||
|
result = 31 * result + ownVoiceAmplification.hashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? {
|
||||||
|
if (data.size < 104) return null
|
||||||
|
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
|
||||||
|
buffer.get() // skip 0x02
|
||||||
|
buffer.get() // skip 0x02
|
||||||
|
buffer.getShort() // skip 0x60 0x00
|
||||||
|
|
||||||
|
val leftEQ = FloatArray(8)
|
||||||
|
for (i in 0..7) {
|
||||||
|
leftEQ[i] = buffer.float
|
||||||
|
}
|
||||||
|
val leftAmplification = buffer.float
|
||||||
|
val leftTone = buffer.float
|
||||||
|
val leftConvFloat = buffer.float
|
||||||
|
val leftConversationBoost = leftConvFloat > 0.5f
|
||||||
|
val leftAmbientNoiseReduction = buffer.float
|
||||||
|
|
||||||
|
val rightEQ = FloatArray(8)
|
||||||
|
for (i in 0..7) {
|
||||||
|
rightEQ[i] = buffer.float
|
||||||
|
}
|
||||||
|
val rightAmplification = buffer.float
|
||||||
|
val rightTone = buffer.float
|
||||||
|
val rightConvFloat = buffer.float
|
||||||
|
val rightConversationBoost = rightConvFloat > 0.5f
|
||||||
|
val rightAmbientNoiseReduction = buffer.float
|
||||||
|
|
||||||
|
val ownVoiceAmplification = buffer.float
|
||||||
|
|
||||||
|
val avg = (leftAmplification + rightAmplification) / 2
|
||||||
|
val amplification = avg.coerceIn(-1f, 1f)
|
||||||
|
val diff = rightAmplification - leftAmplification
|
||||||
|
val balance = diff.coerceIn(-1f, 1f)
|
||||||
|
|
||||||
|
return HearingAidSettings(
|
||||||
|
leftEQ = leftEQ,
|
||||||
|
rightEQ = rightEQ,
|
||||||
|
leftAmplification = leftAmplification,
|
||||||
|
rightAmplification = rightAmplification,
|
||||||
|
leftTone = leftTone,
|
||||||
|
rightTone = rightTone,
|
||||||
|
leftConversationBoost = leftConversationBoost,
|
||||||
|
rightConversationBoost = rightConversationBoost,
|
||||||
|
leftAmbientNoiseReduction = leftAmbientNoiseReduction,
|
||||||
|
rightAmbientNoiseReduction = rightAmbientNoiseReduction,
|
||||||
|
netAmplification = amplification,
|
||||||
|
balance = balance,
|
||||||
|
ownVoiceAmplification = ownVoiceAmplification
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendHearingAidSettings(
|
||||||
|
attManager: ATTManager,
|
||||||
|
hearingAidSettings: HearingAidSettings,
|
||||||
|
debounceJob: MutableState<Job?>
|
||||||
|
) {
|
||||||
|
debounceJob.value?.cancel()
|
||||||
|
debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
delay(100)
|
||||||
|
try {
|
||||||
|
val currentData = attManager.read(ATTHandles.HEARING_AID)
|
||||||
|
Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
|
if (currentData.size < 104) {
|
||||||
|
Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val buffer = ByteBuffer.wrap(currentData).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
|
||||||
|
// for some reason
|
||||||
|
buffer.put(2, 0x64)
|
||||||
|
|
||||||
|
// Left EQ
|
||||||
|
for (i in 0..7) {
|
||||||
|
buffer.putFloat(4 + i * 4, hearingAidSettings.leftEQ[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left ear adjustments
|
||||||
|
buffer.putFloat(36, hearingAidSettings.leftAmplification)
|
||||||
|
buffer.putFloat(40, hearingAidSettings.leftTone)
|
||||||
|
buffer.putFloat(44, if (hearingAidSettings.leftConversationBoost) 1.0f else 0.0f)
|
||||||
|
buffer.putFloat(48, hearingAidSettings.leftAmbientNoiseReduction)
|
||||||
|
|
||||||
|
// Right EQ
|
||||||
|
for (i in 0..7) {
|
||||||
|
buffer.putFloat(52 + i * 4, hearingAidSettings.rightEQ[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right ear adjustments
|
||||||
|
buffer.putFloat(84, hearingAidSettings.rightAmplification)
|
||||||
|
buffer.putFloat(88, hearingAidSettings.rightTone)
|
||||||
|
buffer.putFloat(92, if (hearingAidSettings.rightConversationBoost) 1.0f else 0.0f)
|
||||||
|
buffer.putFloat(96, hearingAidSettings.rightAmbientNoiseReduction)
|
||||||
|
|
||||||
|
// Own voice amplification
|
||||||
|
buffer.putFloat(100, hearingAidSettings.ownVoiceAmplification)
|
||||||
|
|
||||||
|
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
|
|
||||||
|
attManager.write(ATTHandles.HEARING_AID, currentData)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -201,7 +201,7 @@ class LogCollector(private val context: Context) {
|
|||||||
private suspend fun executeRootCommand(command: String): String {
|
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?
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ object MediaController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lastKnownIsMusicActive = isActive
|
lastKnownIsMusicActive = hasNewMusicOrMovie && isActive
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 605 KiB After Width: | Height: | Size: 605 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_2.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_2_buds.png
Normal file
|
After Width: | Height: | Size: 605 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_2_case.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_2_left.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_2_right.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_3.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_3_buds.png
Normal file
|
After Width: | Height: | Size: 605 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_3_case.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_3_left.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_3_right.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_4.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_4_buds.png
Normal file
|
After Width: | Height: | Size: 605 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_4_case.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_4_left.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_4_right.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_pro_1.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_pro_1_buds.png
Normal file
|
After Width: | Height: | Size: 605 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_pro_1_case.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_pro_1_left.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_pro_1_right.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_pro_2.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_pro_2_buds.png
Normal file
|
After Width: | Height: | Size: 605 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_pro_2_case.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_pro_2_left.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_pro_2_right.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_pro_3.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_pro_3_buds.png
Normal file
|
After Width: | Height: | Size: 605 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_pro_3_case.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_pro_3_left.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
android/app/src/main/res-apple/drawable/airpods_pro_3_right.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
@@ -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"
|
||||||
|
|||||||
4521
android/app/src/main/res/raw/aboutlibraries.json
Normal 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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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" }
|
||||||
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 74 KiB |
BIN
android/imgs/hearing-test.png
Normal file
|
After Width: | Height: | Size: 72 KiB |