19 Commits

Author SHA1 Message Date
Kavish Devar
69439257ce android: bump version 2025-05-12 17:16:47 +05:30
Kavish Devar
810a3c90e4 android: add troubleshooter for easier log access 2025-05-12 16:50:26 +05:30
Kavish Devar
0611509782 android: fix the socket error notification showing up even when it connection suceeds 2025-05-11 21:04:42 +05:30
Kavish Devar
116f7dda92 android: separated actual battery notifications from persistent service notif; better error handling when socket isn't connected 2025-05-11 20:42:54 +05:30
Kavish Devar
51ca4c12d1 android: add app description 2025-05-11 20:41:34 +05:30
Kavish Devar
8e670c2481 android: fix last commit; update copyright notice to "LibrePods Contributors" 2025-05-11 19:59:56 +05:30
Kavish Devar
aec9c7192e android: make customizations screen and head tracking screen scrollable 2025-05-11 19:46:43 +05:30
Kavish Devar
01432ce9c7 andoid: add option to not disconnect airpods when none are worn 2025-05-11 19:40:57 +05:30
Kavish Devar
9baa3c9b60 android: update haze uses 2025-05-11 19:38:55 +05:30
Kavish Devar
364a6f4b64 android: fix ear detection when none are in use and either or both are worn
Music would start playing when neither are in ear, but even one is worn. This happens even when the music was not playing when they were removed (or, connected first)
2025-05-11 18:52:33 +05:30
Kavish Devar
9b96218fa9 android: fix mediacontroller fallback volume for conversational awareness 2025-05-10 08:15:00 +05:30
Kavish Devar
98aef13395 android: add sharedpreference listeners to service 2025-05-10 08:13:56 +05:30
Kavish Devar
42e0f48b8b android: fix sharedpreference listener for conversational awareness customizations 2025-05-10 07:55:14 +05:30
Kavish Devar
4c73200f35 android: improve conversational awareness (fixes #122) 2025-05-09 22:37:39 +05:30
Kavish Devar
06de276dca android: initialize shared pref keys on first launch 2025-05-09 22:37:03 +05:30
Kavish Devar
7ffcd68ad9 android: listen for battery in the connected popup window (fix #117) 2025-05-09 09:47:54 +05:30
Kavish Devar
295c49fdc6 android: listen for airpods connection in UI (fix #118) 2025-05-09 09:41:26 +05:30
Kavish Devar
b95962d722 android: rephrase text when requesting permissions 2025-05-09 09:19:02 +05:30
Kavish Devar
45ed8a3a88 android: listen for intents to set anc mode 2025-05-09 08:56:10 +05:30
19 changed files with 2204 additions and 259 deletions

View File

@@ -13,8 +13,8 @@ android {
applicationId = "me.kavishdevar.librepods"
minSdk = 28
targetSdk = 35
versionCode = 5
versionName = "0.1.0-rc.2"
versionCode = 6
versionName = "0.1.0-rc.3"
}
buildTypes {

View File

@@ -31,6 +31,7 @@
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<protected-broadcast android:name="batterywidget.impl.action.update_bluetooth_data" />
@@ -44,6 +45,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.LibrePods"
android:description="@string/app_description"
tools:ignore="UnusedAttribute"
tools:targetApi="31">
<receiver
@@ -125,6 +127,16 @@
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -107,6 +107,7 @@ import me.kavishdevar.librepods.screens.HeadTrackingScreen
import me.kavishdevar.librepods.screens.LongPress
import me.kavishdevar.librepods.screens.Onboarding
import me.kavishdevar.librepods.screens.RenameScreen
import me.kavishdevar.librepods.screens.TroubleshootingScreen
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AirPodsNotifications
@@ -277,6 +278,9 @@ fun Main() {
composable("app_settings") {
AppSettingsScreen(navController)
}
composable("troubleshooting") {
TroubleshootingScreen(navController)
}
composable("head_tracking") {
HeadTrackingScreen(navController)
}
@@ -407,7 +411,7 @@ fun PermissionsScreen(
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "To provide the best AirPods experience, we need a few permissions",
text = "The following permissions are required to use the app. Please grant them to continue.",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,

View File

@@ -1,7 +1,7 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 Kavish Devar
* 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

View File

@@ -1,7 +1,7 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 Kavish Devar
* 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

View File

@@ -1,7 +1,7 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 Kavish Devar
* 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

View File

@@ -36,11 +36,15 @@ 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.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -78,9 +82,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
@@ -102,6 +108,7 @@ import me.kavishdevar.librepods.utils.AirPodsNotifications
@Composable
fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
navController: NavController, isConnected: Boolean, isRemotelyConnected: Boolean) {
var isLocallyConnected by remember { mutableStateOf(isConnected) }
var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
var device by remember { mutableStateOf(dev) }
@@ -113,6 +120,10 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
)
}
LaunchedEffect(service) {
isLocallyConnected = service.isConnectedLocally
}
val nameChangeListener = remember {
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == "name") {
@@ -144,22 +155,37 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
}
val context = LocalContext.current
val bluetoothReceiver = remember {
val connectionReceiver = remember {
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == "me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY") {
coroutineScope.launch {
handleRemoteConnection(true)
when (intent?.action) {
"me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY" -> {
coroutineScope.launch {
handleRemoteConnection(true)
}
}
} else if (intent?.action == "me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY") {
coroutineScope.launch {
handleRemoteConnection(false)
"me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY" -> {
coroutineScope.launch {
handleRemoteConnection(false)
}
}
} else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
try {
context?.unregisterReceiver(this)
} catch (e: IllegalArgumentException) {
e.printStackTrace()
AirPodsNotifications.AIRPODS_CONNECTED -> {
coroutineScope.launch {
isLocallyConnected = true
}
}
AirPodsNotifications.AIRPODS_DISCONNECTED -> {
coroutineScope.launch {
isLocallyConnected = false
}
}
AirPodsNotifications.DISCONNECT_RECEIVERS -> {
try {
context?.unregisterReceiver(this)
} catch (e: IllegalArgumentException) {
e.printStackTrace()
}
}
}
}
@@ -170,16 +196,22 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
val filter = IntentFilter().apply {
addAction("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY")
addAction("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY")
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(bluetoothReceiver, filter, RECEIVER_EXPORTED)
context.registerReceiver(connectionReceiver, filter, RECEIVER_EXPORTED)
} else {
context.registerReceiver(bluetoothReceiver, filter)
context.registerReceiver(connectionReceiver, filter)
}
onDispose {
context.unregisterReceiver(bluetoothReceiver)
try {
context.unregisterReceiver(connectionReceiver)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
@@ -206,14 +238,13 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
)
},
modifier = Modifier
.hazeChild(
.hazeEffect(
state = hazeState,
style = CupertinoMaterials.thick(),
block = {
block = fun HazeEffectScope.() {
alpha =
if (verticalScrollState.value > 60.dp.value * mDensity.floatValue) 1f else 0f
}
)
})
.drawBehind {
mDensity.floatValue = density
val strokeWidth = 0.7.dp.value * density
@@ -266,10 +297,10 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { paddingValues ->
if (isConnected == true || isRemotelyConnected == true) {
if (isLocallyConnected || isRemotelyConnected) {
Column(
modifier = Modifier
.haze(hazeState)
.hazeSource(hazeState)
.fillMaxSize()
.padding(horizontal = 16.dp)
.verticalScroll(
@@ -387,6 +418,24 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(32.dp))
Button(
onClick = { navController.navigate("troubleshooting") },
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFF2F2F7),
contentColor = if (isSystemInDarkTheme()) Color.White else Color.Black,
)
) {
Text(
text = "Troubleshoot Connection",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
}

View File

@@ -35,8 +35,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.Refresh
@@ -62,9 +64,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
@@ -75,26 +80,70 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.IndependentToggle
import me.kavishdevar.librepods.composables.StyledSwitch
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun AppSettingsScreen(navController: NavController) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val name = remember { mutableStateOf(sharedPreferences.getString("name", "") ?: "") }
val isDarkTheme = isSystemInDarkTheme()
val context = LocalContext.current
val scrollState = rememberScrollState()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val hazeState = remember { HazeState() }
var showResetDialog by remember { mutableStateOf(false) }
var showPhoneBatteryInWidget by remember {
mutableStateOf(sharedPreferences.getBoolean("show_phone_battery_in_widget", true))
}
var conversationalAwarenessPauseMusicEnabled by remember {
mutableStateOf(sharedPreferences.getBoolean("conversational_awareness_pause_music", false))
}
var relativeConversationalAwarenessVolumeEnabled by remember {
mutableStateOf(sharedPreferences.getBoolean("relative_conversational_awareness_volume", true))
}
var openDialogForControlling by remember {
mutableStateOf(sharedPreferences.getString("qs_click_behavior", "dialog") == "dialog")
}
var disconnectWhenNotWearing by remember {
mutableStateOf(sharedPreferences.getBoolean("disconnect_when_not_wearing", false))
}
var mDensity by remember { mutableFloatStateOf(0f) }
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
CenterAlignedTopAppBar(
modifier = Modifier.hazeEffect(
state = hazeState,
style = CupertinoMaterials.thick(),
block = fun HazeEffectScope.() {
alpha =
if (scrollState.value > 60.dp.value * mDensity) 1f else 0f
})
.drawBehind {
mDensity = density
val strokeWidth = 0.7.dp.value * density
val y = size.height - strokeWidth / 2
if (scrollState.value > 60.dp.value * density) {
drawLine(
if (isDarkTheme) Color.DarkGray else Color.LightGray,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
}
},
title = {
Text(
text = stringResource(R.string.app_settings),
@@ -129,9 +178,10 @@ fun AppSettingsScreen(navController: NavController) {
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
)
),
scrollBehavior = scrollBehavior
)
},
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
@@ -141,24 +191,25 @@ fun AppSettingsScreen(navController: NavController) {
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 12.dp)
.padding(horizontal = 16.dp)
.verticalScroll(scrollState)
.hazeSource(state = hazeState)
) {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
IndependentToggle("Show phone battery in widget", ServiceManager.getService()!!, "setPhoneBatteryInWidget", sharedPreferences)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.conversational_awareness_customization).uppercase(),
text = "Widget".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 8.dp)
)
Spacer(modifier = Modifier.height(2.dp))
@@ -166,44 +217,88 @@ fun AppSettingsScreen(navController: NavController) {
Column (
modifier = Modifier
.fillMaxWidth()
.height(275.sp.value.dp)
.background(
backgroundColor,
RoundedCornerShape(14.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
showPhoneBatteryInWidget = !showPhoneBatteryInWidget
sharedPreferences.edit().putBoolean("show_phone_battery_in_widget", showPhoneBatteryInWidget).apply()
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp)
.padding(end = 4.dp)
) {
Text(
text = "Show phone battery in widget",
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Display your phone's battery level in the widget alongside AirPods battery",
fontSize = 14.sp,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
)
}
StyledSwitch(
checked = showPhoneBatteryInWidget,
onCheckedChange = {
showPhoneBatteryInWidget = it
sharedPreferences.edit().putBoolean("show_phone_battery_in_widget", it).apply()
}
)
}
}
Text(
text = "Conversational Awareness".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 24.dp)
)
Spacer(modifier = Modifier.height(2.dp))
Column (
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor,
RoundedCornerShape(14.dp)
)
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
val sliderValue = remember { mutableFloatStateOf(0f) }
LaunchedEffect(sliderValue) {
if (sharedPreferences.contains("conversational_awareness_volume")) {
sliderValue.floatValue = sharedPreferences.getInt("conversational_awareness_volume", 0).toFloat()
sliderValue.floatValue = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat()
}
}
LaunchedEffect(sliderValue.floatValue) {
sharedPreferences.edit().putInt("conversational_awareness_volume", sliderValue.floatValue.toInt()).apply()
}
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFFD9D9D9)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
var conversationalAwarenessPauseMusicEnabled by remember {
mutableStateOf(
sharedPreferences.getBoolean("conversational_awareness_pause_music", true)
)
}
fun updateConversationalAwarenessPauseMusic(enabled: Boolean) {
conversationalAwarenessPauseMusicEnabled = enabled
sharedPreferences.edit().putBoolean("conversational_awareness_pause_music", enabled).apply()
}
var relativeConversationalAwarenessVolumeEnabled by remember {
mutableStateOf(
sharedPreferences.getBoolean("relative_conversational_awareness_volume", true)
)
}
fun updateRelativeConversationalAwarenessVolume(enabled: Boolean) {
relativeConversationalAwarenessVolumeEnabled = enabled
sharedPreferences.edit().putBoolean("relative_conversational_awareness_volume", enabled).apply()
@@ -212,11 +307,6 @@ fun AppSettingsScreen(navController: NavController) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(85.sp.value.dp)
.background(
shape = RoundedCornerShape(14.dp),
color = Color.Transparent
)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
@@ -228,6 +318,7 @@ fun AppSettingsScreen(navController: NavController) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp)
.padding(end = 4.dp)
) {
Text(
@@ -255,11 +346,6 @@ fun AppSettingsScreen(navController: NavController) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(85.sp.value.dp)
.background(
shape = RoundedCornerShape(14.dp),
color = Color.Transparent
)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
@@ -271,6 +357,7 @@ fun AppSettingsScreen(navController: NavController) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp)
.padding(end = 4.dp)
) {
Text(
@@ -295,20 +382,31 @@ fun AppSettingsScreen(navController: NavController) {
)
}
Text(
text = "Conversational Awareness Volume",
fontSize = 16.sp,
color = textColor,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
)
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFFD9D9D9)
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
Slider(
value = sliderValue.floatValue,
onValueChange = {
sliderValue.floatValue = it
sharedPreferences.edit().putInt("conversational_awareness_volume", it.toInt()).apply()
},
valueRange = 10f..85f,
onValueChangeFinished = {
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
},
modifier = Modifier
.weight(1f)
.height(36.dp),
.fillMaxWidth()
.height(36.dp)
.padding(vertical = 4.dp),
colors = SliderDefaults.colors(
thumbColor = thumbColor,
activeTrackColor = activeTrackColor,
@@ -347,7 +445,9 @@ fun AppSettingsScreen(navController: NavController) {
)
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
@@ -355,7 +455,7 @@ fun AppSettingsScreen(navController: NavController) {
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = labelTextColor
color = textColor.copy(alpha = 0.7f)
),
modifier = Modifier.padding(start = 4.dp)
)
@@ -364,14 +464,25 @@ fun AppSettingsScreen(navController: NavController) {
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = labelTextColor
color = textColor.copy(alpha = 0.7f)
),
modifier = Modifier.padding(end = 4.dp)
)
}
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Quick Settings Tile".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 24.dp)
)
Spacer(modifier = Modifier.height(2.dp))
Column(
modifier = Modifier
@@ -380,14 +491,8 @@ fun AppSettingsScreen(navController: NavController) {
backgroundColor,
RoundedCornerShape(14.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
var openDialogForControlling by remember {
mutableStateOf(
sharedPreferences.getString("qs_click_behavior", "dialog") == "dialog"
)
}
fun updateQsClickBehavior(enabled: Boolean) {
openDialogForControlling = enabled
sharedPreferences.edit().putString("qs_click_behavior", if (enabled) "dialog" else "cycle").apply()
@@ -407,7 +512,7 @@ fun AppSettingsScreen(navController: NavController) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 16.dp)
.padding(vertical = 8.dp)
.padding(end = 4.dp)
) {
Text(
@@ -435,7 +540,126 @@ fun AppSettingsScreen(navController: NavController) {
}
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Ear Detection".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 24.dp)
)
Spacer(modifier = Modifier.height(2.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor,
RoundedCornerShape(14.dp)
)
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
fun updateDisconnectWhenNotWearing(enabled: Boolean) {
disconnectWhenNotWearing = enabled
sharedPreferences.edit().putBoolean("disconnect_when_not_wearing", enabled).apply()
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
updateDisconnectWhenNotWearing(!disconnectWhenNotWearing)
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp)
.padding(end = 4.dp)
) {
Text(
text = "Disconnect AirPods when not wearing",
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "You will still be able to control them with the app - this just disconnects the audio.",
fontSize = 14.sp,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
)
}
StyledSwitch(
checked = disconnectWhenNotWearing,
onCheckedChange = {
updateDisconnectWhenNotWearing(it)
}
)
}
}
Text(
text = "Advanced Options".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 24.dp)
)
Spacer(modifier = Modifier.height(2.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor,
RoundedCornerShape(14.dp)
)
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
navController.navigate("troubleshooting")
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp)
.padding(end = 4.dp)
) {
Text(
text = stringResource(R.string.troubleshooting),
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.troubleshooting_description),
fontSize = 14.sp,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { showResetDialog = true },
@@ -470,6 +694,8 @@ fun AppSettingsScreen(navController: NavController) {
}
}
Spacer(modifier = Modifier.height(32.dp))
if (showResetDialog) {
AlertDialog(
onDismissRequest = { showResetDialog = false },

View File

@@ -40,6 +40,7 @@ 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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -49,6 +50,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Send
@@ -89,33 +91,18 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineScope
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.BatteryStatus
import me.kavishdevar.librepods.utils.isHeadTrackingData
import me.kavishdevar.librepods.composables.StyledSwitch
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.imePadding
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.material.icons.filled.Check
import androidx.compose.ui.input.pointer.PointerInputChange
data class PacketInfo(
val type: String,
@@ -349,13 +336,13 @@ fun DebugScreen(navController: NavController) {
val scrollOffset by remember { derivedStateOf { listState.firstVisibleItemScrollOffset } }
val focusManager = LocalFocusManager.current
val coroutineScope = rememberCoroutineScope()
val showMenu = remember { mutableStateOf(false) }
val airPodsService = remember { ServiceManager.getService() }
val packetLogs = airPodsService?.packetLogsFlow?.collectAsState(emptySet())?.value ?: emptySet()
val shouldScrollToBottom = remember { mutableStateOf(true) }
val refreshTrigger = remember { mutableStateOf(0) }
LaunchedEffect(refreshTrigger.value) {
while(true) {
@@ -363,16 +350,16 @@ fun DebugScreen(navController: NavController) {
refreshTrigger.value = refreshTrigger.value + 1
}
}
val expandedItems = remember { mutableStateOf(setOf<Int>()) }
fun copyToClipboard(text: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Packet Data", text)
clipboard.setPrimaryClip(clip)
Toast.makeText(context, "Packet copied to clipboard", Toast.LENGTH_SHORT).show()
}
LaunchedEffect(packetLogs.size, refreshTrigger.value) {
if (shouldScrollToBottom.value && packetLogs.isNotEmpty()) {
listState.animateScrollToItem(packetLogs.size - 1)
@@ -415,7 +402,7 @@ fun DebugScreen(navController: NavController) {
tint = if (isSystemInDarkTheme()) Color.White else Color.Black
)
}
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
@@ -446,17 +433,17 @@ fun DebugScreen(navController: NavController) {
)
}
},
onClick = {
onClick = {
shouldScrollToBottom.value = !shouldScrollToBottom.value
showMenu.value = false
}
)
HorizontalDivider(
color = if (isSystemInDarkTheme()) Color(0xFF3A3A3C) else Color(0xFFE5E5EA),
thickness = 0.5.dp
)
DropdownMenuItem(
text = {
Row(
@@ -478,7 +465,7 @@ fun DebugScreen(navController: NavController) {
)
}
},
onClick = {
onClick = {
ServiceManager.getService()?.clearLogs()
expandedItems.value = emptySet()
showMenu.value = false
@@ -487,13 +474,12 @@ fun DebugScreen(navController: NavController) {
}
}
},
modifier = Modifier.hazeChild(
modifier = Modifier.hazeEffect(
state = hazeState,
style = CupertinoMaterials.thick(),
block = {
block = fun HazeEffectScope.() {
alpha = if (scrollOffset > 0) 1f else 0f
}
),
}),
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
)
},
@@ -502,7 +488,7 @@ fun DebugScreen(navController: NavController) {
Column(
modifier = Modifier
.fillMaxSize()
.haze(hazeState)
.hazeSource(hazeState)
.padding(top = paddingValues.calculateTopPadding())
.navigationBarsPadding()
) {
@@ -633,7 +619,7 @@ fun DebugScreen(navController: NavController) {
airPodsService?.value?.sendPacket(packet.value.text)
packet.value = TextFieldValue("")
focusManager.clearFocus()
if (shouldScrollToBottom.value && packetLogs.isNotEmpty()) {
coroutineScope.launch {
try {
@@ -643,6 +629,7 @@ fun DebugScreen(navController: NavController) {
scrollOffset = 0
)
} catch (e: Exception) {
e.printStackTrace()
listState.scrollToItem(
index = (packetLogs.size - 1).coerceAtLeast(0)
)

View File

@@ -40,7 +40,9 @@ 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.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.PlayArrow
@@ -70,6 +72,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
@@ -84,6 +87,7 @@ import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
@@ -98,6 +102,12 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -110,6 +120,7 @@ import kotlin.math.cos
import kotlin.math.sin
import kotlin.random.Random
@ExperimentalHazeMaterialsApi
@RequiresApi(Build.VERSION_CODES.Q)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable
@@ -124,9 +135,36 @@ fun HeadTrackingScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val scrollState = rememberScrollState()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val hazeState = remember { HazeState() }
var mDensity by remember { mutableFloatStateOf(0f) }
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
CenterAlignedTopAppBar(
modifier = Modifier.hazeEffect(
state = hazeState,
style = CupertinoMaterials.thick(),
block = fun HazeEffectScope.() {
alpha =
if (scrollState.value > 60.dp.value * mDensity) 1f else 0f
})
.drawBehind {
mDensity = density
val strokeWidth = 0.7.dp.value * density
val y = size.height - strokeWidth / 2
if (scrollState.value > 60.dp.value * density) {
drawLine(
if (isDarkTheme) Color.DarkGray else Color.LightGray,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
}
},
title = {
Text(
stringResource(R.string.head_tracking),
@@ -162,7 +200,7 @@ fun HeadTrackingScreen(navController: NavController) {
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
),
actions = {
@@ -211,7 +249,8 @@ fun HeadTrackingScreen(navController: NavController) {
modifier = Modifier.scale(1.5f)
)
}
}
},
scrollBehavior = scrollBehavior
)
},
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
@@ -223,6 +262,8 @@ fun HeadTrackingScreen(navController: NavController) {
.padding(paddingValues = paddingValues)
.padding(horizontal = 16.dp)
.padding(top = 8.dp)
.verticalScroll(scrollState)
.hazeSource(state = hazeState)
) {
val sharedPreferences =
LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
@@ -311,7 +352,7 @@ fun HeadTrackingScreen(navController: NavController) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(top = 12.dp)
modifier = Modifier.padding(top = 12.dp, bottom = 24.dp)
) {
AnimatedContent(
targetState = gestureText,
@@ -806,6 +847,7 @@ private fun AccelerationPlot() {
}
}
@ExperimentalHazeMaterialsApi
@RequiresApi(Build.VERSION_CODES.Q)
@Preview
@Composable

View File

@@ -1,7 +1,7 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 Kavish Devar
* 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

View File

@@ -1,7 +1,7 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 Kavish Devar
* 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
@@ -26,7 +26,6 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.appwidget.AppWidgetManager
import android.bluetooth.BluetoothAssignedNumbers.APPLE
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
@@ -71,7 +70,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import me.kavishdevar.librepods.MainActivity
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.AirPodsNotifications
@@ -88,12 +89,10 @@ import me.kavishdevar.librepods.utils.IslandWindow
import me.kavishdevar.librepods.utils.LongPressPackets
import me.kavishdevar.librepods.utils.MediaController
import me.kavishdevar.librepods.utils.PopupWindow
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import me.kavishdevar.librepods.utils.SystemApisUtils
import me.kavishdevar.librepods.utils.SystemApisUtils.DEVICE_TYPE_UNTETHERED_HEADSET
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_COMPANION_APP
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_DEVICE_TYPE
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_MAIN_BATTERY
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_MAIN_ICON
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_MANUFACTURER_NAME
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_MODEL_NAME
@@ -146,9 +145,36 @@ object ServiceManager {
}
// @Suppress("unused")
class AirPodsService : Service() {
class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeListener {
var macAddress = ""
data class ServiceConfig(
var deviceName: String = "AirPods",
var earDetectionEnabled: Boolean = true,
var conversationalAwarenessPauseMusic: Boolean = false,
var personalizedVolume: Boolean = false,
var longPressNC: Boolean = true,
var offListeningMode: Boolean = false,
var showPhoneBatteryInWidget: Boolean = true,
var singleANC: Boolean = true,
var longPressTransparency: Boolean = true,
var conversationalAwareness: Boolean = true,
var relativeConversationalAwarenessVolume: Boolean = true,
var longPressAdaptive: Boolean = true,
var loudSoundReduction: Boolean = true,
var longPressOff: Boolean = false,
var volumeControl: Boolean = true,
var headGestures: Boolean = true,
var disconnectWhenNotWearing: Boolean = false,
var adaptiveStrength: Int = 51,
var toneVolume: Int = 75,
var conversationalAwarenessVolume: Int = 43,
var textColor: Long = -1L,
var qsClickBehavior: String = "cycle"
)
private lateinit var config: ServiceConfig
inner class LocalBinder : Binder() {
fun getService(): AirPodsService = this@AirPodsService
}
@@ -170,6 +196,78 @@ class AirPodsService : Service() {
inMemoryLogs.addAll(sharedPreferencesLogs.getStringSet(packetLogKey, emptySet()) ?: emptySet())
_packetLogsFlow.value = inMemoryLogs.toSet()
sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
initializeConfig()
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
}
private fun initializeConfig() {
config = ServiceConfig(
deviceName = sharedPreferences.getString("name", "AirPods") ?: "AirPods",
earDetectionEnabled = sharedPreferences.getBoolean("automatic_ear_detection", true),
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false),
personalizedVolume = sharedPreferences.getBoolean("personalized_volume", false),
longPressNC = sharedPreferences.getBoolean("long_press_nc", true),
offListeningMode = sharedPreferences.getBoolean("off_listening_mode", false),
showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", true),
singleANC = sharedPreferences.getBoolean("single_anc", true),
longPressTransparency = sharedPreferences.getBoolean("long_press_transparency", true),
conversationalAwareness = sharedPreferences.getBoolean("conversational_awareness", true),
relativeConversationalAwarenessVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", true),
longPressAdaptive = sharedPreferences.getBoolean("long_press_adaptive", true),
loudSoundReduction = sharedPreferences.getBoolean("loud_sound_reduction", true),
longPressOff = sharedPreferences.getBoolean("long_press_off", false),
volumeControl = sharedPreferences.getBoolean("volume_control", true),
headGestures = sharedPreferences.getBoolean("head_gestures", true),
disconnectWhenNotWearing = sharedPreferences.getBoolean("disconnect_when_not_wearing", false),
adaptiveStrength = sharedPreferences.getInt("adaptive_strength", 51),
toneVolume = sharedPreferences.getInt("tone_volume", 75),
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43),
textColor = sharedPreferences.getLong("textColor", -1L),
qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle"
)
}
override fun onSharedPreferenceChanged(preferences: SharedPreferences?, key: String?) {
if (preferences == null || key == null) return
when(key) {
"name" -> config.deviceName = preferences.getString(key, "AirPods") ?: "AirPods"
"automatic_ear_detection" -> config.earDetectionEnabled = preferences.getBoolean(key, true)
"conversational_awareness_pause_music" -> config.conversationalAwarenessPauseMusic = preferences.getBoolean(key, false)
"personalized_volume" -> config.personalizedVolume = preferences.getBoolean(key, false)
"long_press_nc" -> config.longPressNC = preferences.getBoolean(key, true)
"off_listening_mode" -> {
config.offListeningMode = preferences.getBoolean(key, false)
updateNoiseControlWidget()
}
"show_phone_battery_in_widget" -> {
config.showPhoneBatteryInWidget = preferences.getBoolean(key, true)
widgetMobileBatteryEnabled = config.showPhoneBatteryInWidget
updateBattery()
}
"single_anc" -> config.singleANC = preferences.getBoolean(key, true)
"long_press_transparency" -> config.longPressTransparency = preferences.getBoolean(key, true)
"conversational_awareness" -> config.conversationalAwareness = preferences.getBoolean(key, true)
"relative_conversational_awareness_volume" -> config.relativeConversationalAwarenessVolume = preferences.getBoolean(key, true)
"long_press_adaptive" -> config.longPressAdaptive = preferences.getBoolean(key, true)
"loud_sound_reduction" -> config.loudSoundReduction = preferences.getBoolean(key, true)
"long_press_off" -> config.longPressOff = preferences.getBoolean(key, false)
"volume_control" -> config.volumeControl = preferences.getBoolean(key, true)
"head_gestures" -> config.headGestures = preferences.getBoolean(key, true)
"disconnect_when_not_wearing" -> config.disconnectWhenNotWearing = preferences.getBoolean(key, false)
"adaptive_strength" -> config.adaptiveStrength = preferences.getInt(key, 51)
"tone_volume" -> config.toneVolume = preferences.getInt(key, 75)
"conversational_awareness_volume" -> config.conversationalAwarenessVolume = preferences.getInt(key, 43)
"textColor" -> config.textColor = preferences.getLong(key, -1L)
"qs_click_behavior" -> config.qsClickBehavior = preferences.getString(key, "cycle") ?: "cycle"
}
if (key == "mac_address") {
macAddress = preferences.getString(key, "") ?: ""
}
}
private fun logPacket(packet: ByteArray, source: String) {
@@ -299,7 +397,7 @@ class AirPodsService : Service() {
if (bluetoothDevice.uuids != null) {
if (bluetoothDevice.uuids.contains(uuid)) {
val intent =
Intent(AirPodsNotifications.Companion.AIRPODS_CONNECTION_DETECTED)
Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
intent.putExtra("name", name)
intent.putExtra("device", bluetoothDevice)
context?.sendBroadcast(intent)
@@ -337,13 +435,33 @@ class AirPodsService : Service() {
@OptIn(ExperimentalMaterial3Api::class)
fun startForegroundNotification() {
val notificationChannel = NotificationChannel(
val disconnectedNotificationChannel = NotificationChannel(
"background_service_status",
"Background Service Status",
NotificationManager.IMPORTANCE_LOW
)
val connectedNotificationChannel = NotificationChannel(
"airpods_connection_status",
"AirPods Connection Status",
NotificationManager.IMPORTANCE_LOW,
)
val socketFailureChannel = NotificationChannel(
"socket_connection_failure",
"AirPods Socket Connection Issues",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Notifications about problems connecting to AirPods protocol"
enableLights(true)
lightColor = android.graphics.Color.RED
enableVibration(true)
}
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(notificationChannel)
notificationManager.createNotificationChannel(disconnectedNotificationChannel)
notificationManager.createNotificationChannel(connectedNotificationChannel)
notificationManager.createNotificationChannel(socketFailureChannel)
val notificationIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
@@ -353,11 +471,22 @@ class AirPodsService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notificationSettingsIntent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
putExtra(Settings.EXTRA_CHANNEL_ID, "background_service_status")
}
val pendingIntentNotifDisable = PendingIntent.getActivity(
this,
0,
notificationSettingsIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, "background_service_status")
.setSmallIcon(R.drawable.airpods)
.setContentTitle("AirPods not connected")
.setContentText("Tap to open app")
.setContentIntent(pendingIntent)
.setContentTitle("Background Service Running")
.setContentText("Useless notification, disable it by clicking on it.")
.setContentIntent(pendingIntentNotifDisable)
.setCategory(Notification.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
@@ -370,15 +499,42 @@ class AirPodsService : Service() {
}
}
@OptIn(ExperimentalMaterial3Api::class)
private fun showSocketConnectionFailureNotification(errorMessage: String) {
val notificationManager = getSystemService(NotificationManager::class.java)
val notificationIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this,
0,
notificationIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, "socket_connection_failure")
.setSmallIcon(R.drawable.airpods)
.setContentTitle("AirPods Connection Issue")
.setContentText("Unable to connect to AirPods over L2CAP")
.setStyle(NotificationCompat.BigTextStyle()
.bigText("Your AirPods are connected via Bluetooth, but LibrePods couldn't connect to AirPods using L2CAP. " +
"Error: $errorMessage"))
.setContentIntent(pendingIntent)
.setCategory(Notification.CATEGORY_ERROR)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.build()
notificationManager.notify(3, notification)
}
fun sendANCBroadcast() {
sendBroadcast(Intent(AirPodsNotifications.Companion.ANC_DATA).apply {
sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
putExtra("data", ancNotification.status)
})
}
fun sendBatteryBroadcast() {
sendBroadcast(Intent(AirPodsNotifications.Companion.BATTERY_DATA).apply {
sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery()))
})
}
@@ -541,7 +697,7 @@ class AirPodsService : Service() {
it.setInt(
R.id.widget_transparency_button,
"setBackgroundResource",
if (ancStatus == 3) (if (sharedPreferences.getBoolean("off_listening_mode", true)) R.drawable.widget_button_checked_shape_middle else R.drawable.widget_button_checked_shape_start) else (if (sharedPreferences.getBoolean("off_listening_mode", true)) R.drawable.widget_button_shape_middle else R.drawable.widget_button_shape_start)
if (ancStatus == 3) (if (config.offListeningMode) R.drawable.widget_button_checked_shape_middle else R.drawable.widget_button_checked_shape_start) else (if (config.offListeningMode) R.drawable.widget_button_shape_middle else R.drawable.widget_button_shape_start)
)
it.setInt(
R.id.widget_adaptive_button,
@@ -555,19 +711,19 @@ class AirPodsService : Service() {
)
it.setViewVisibility(
R.id.widget_off_button,
if (sharedPreferences.getBoolean("off_listening_mode", true)) View.VISIBLE else View.GONE
if (config.offListeningMode) View.VISIBLE else View.GONE
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
it.setViewLayoutMargin(
R.id.widget_transparency_button,
RemoteViews.MARGIN_START,
if (sharedPreferences.getBoolean("off_listening_mode", true)) 2f else 12f,
if (config.offListeningMode) 2f else 12f,
TypedValue.COMPLEX_UNIT_DIP
)
} else {
it.setViewPadding(
R.id.widget_transparency_button,
if (sharedPreferences.getBoolean("off_listening_mode", true)) 2.dpToPx() else 12.dpToPx(),
if (config.offListeningMode) 2.dpToPx() else 12.dpToPx(),
12.dpToPx(),
2.dpToPx(),
12.dpToPx()
@@ -595,10 +751,10 @@ class AirPodsService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
if (connected) {
updatedNotification = NotificationCompat.Builder(this, "background_service_status")
if (connected && socket.isConnected) {
updatedNotification = NotificationCompat.Builder(this, "airpods_connection_status")
.setSmallIcon(R.drawable.airpods)
.setContentTitle(airpodsName)
.setContentTitle(airpodsName ?: config.deviceName)
.setContentText(
"""${
batteryList?.find { it.component == BatteryComponent.LEFT }?.let {
@@ -626,11 +782,15 @@ class AirPodsService : Service() {
} ?: ""
}""")
.setContentIntent(pendingIntent)
.setCategory(Notification.CATEGORY_SERVICE)
.setCategory(Notification.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.build()
} else {
notificationManager.notify(2, updatedNotification)
notificationManager.cancel(1)
} else if (!connected) {
updatedNotification = NotificationCompat.Builder(this, "background_service_status")
.setSmallIcon(R.drawable.airpods)
.setContentTitle("AirPods not connected")
@@ -640,22 +800,26 @@ class AirPodsService : Service() {
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.build()
}
notificationManager.notify(1, updatedNotification)
notificationManager.notify(1, updatedNotification)
notificationManager.cancel(2)
} else if (!socket.isConnected && isConnectedLocally) {
Log.d("AirPodsService", "<LogCollector:Complete:Failed> Socket not connected")
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
}
}
@RequiresApi(Build.VERSION_CODES.Q)
fun handleIncomingCall() {
if (isInCall) return
initGestureDetector()
gestureDetector?.startDetection { accepted ->
if (accepted) {
answerCall()
} else {
rejectCall()
if (config.headGestures) {
initGestureDetector()
gestureDetector?.startDetection { accepted ->
if (accepted) {
answerCall()
} else {
rejectCall()
}
}
}
}
@@ -901,13 +1065,93 @@ class AirPodsService : Service() {
Log.d("AirPodsService", "Metadata set: $metadataSet")
}
}
val ancModeFilter = IntentFilter("me.kavishdevar.librepods.SET_ANC_MODE")
var ancModeReceiver: BroadcastReceiver? = null
@SuppressLint("InlinedApi", "MissingPermission", "UnspecifiedRegisterReceiverFlag")
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d("AirPodsService", "Service started")
ServiceManager.setService(this)
startForegroundNotification()
initGestureDetector()
sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
with(sharedPreferences) {
val editor = edit()
if (!contains("conversational_awareness_pause_music")) editor.putBoolean("conversational_awareness_pause_music", false)
if (!contains("personalized_volume")) editor.putBoolean("personalized_volume", false)
if (!contains("automatic_ear_detection")) editor.putBoolean("automatic_ear_detection", true)
if (!contains("long_press_nc")) editor.putBoolean("long_press_nc", true)
if (!contains("off_listening_mode")) editor.putBoolean("off_listening_mode", false)
if (!contains("show_phone_battery_in_widget")) editor.putBoolean("show_phone_battery_in_widget", true)
if (!contains("single_anc")) editor.putBoolean("single_anc", true)
if (!contains("long_press_transparency")) editor.putBoolean("long_press_transparency", true)
if (!contains("conversational_awareness")) editor.putBoolean("conversational_awareness", true)
if (!contains("relative_conversational_awareness_volume")) editor.putBoolean("relative_conversational_awareness_volume", true)
if (!contains("long_press_adaptive")) editor.putBoolean("long_press_adaptive", true)
if (!contains("loud_sound_reduction")) editor.putBoolean("loud_sound_reduction", true)
if (!contains("long_press_off")) editor.putBoolean("long_press_off", false)
if (!contains("volume_control")) editor.putBoolean("volume_control", true)
if (!contains("head_gestures")) editor.putBoolean("head_gestures", true)
if (!contains("disconnect_when_not_wearing")) editor.putBoolean("disconnect_when_not_wearing", false)
if (!contains("adaptive_strength")) editor.putInt("adaptive_strength", 51)
if (!contains("tone_volume")) editor.putInt("tone_volume", 75)
if (!contains("conversational_awareness_volume")) editor.putInt("conversational_awareness_volume", 43)
if (!contains("textColor")) editor.putLong("textColor", -1L)
if (!contains("qs_click_behavior")) editor.putString("qs_click_behavior", "cycle")
if (!contains("name")) editor.putString("name", "AirPods")
editor.apply()
}
initializeConfig()
ancModeReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == "me.kavishdevar.librepods.SET_ANC_MODE") {
if (intent.hasExtra("mode")) {
val mode = intent.getIntExtra("mode", -1)
if (mode in 1..4) {
setANCMode(mode)
}
} else {
val currentMode = ancNotification.status
val offListeningMode = config.offListeningMode
val nextMode = if (offListeningMode) {
when (currentMode) {
1 -> 2
2 -> 3
3 -> 4
4 -> 1
else -> 1
}
} else {
when (currentMode) {
1 -> 2
2 -> 3
3 -> 4
4 -> 2
else -> 2
}
}
setANCMode(nextMode)
Log.d("AirPodsService", "Cycling ANC mode from $currentMode to $nextMode (offListeningMode: $offListeningMode)")
}
}
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(ancModeReceiver, ancModeFilter, RECEIVER_EXPORTED)
} else {
registerReceiver(ancModeReceiver, ancModeFilter)
}
val audioManager =
this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager
MediaController.initialize(
@@ -933,14 +1177,19 @@ class AirPodsService : Service() {
super.onCallStateChanged(state, phoneNumber)
when (state) {
TelephonyManager.CALL_STATE_RINGING -> {
if (CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) takeOver()
if (sharedPreferences.getBoolean("head_gestures", false)) {
if (CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) CoroutineScope(Dispatchers.IO).launch {
takeOver()
}
if (config.headGestures) {
callNumber = phoneNumber
handleIncomingCall()
}
}
TelephonyManager.CALL_STATE_OFFHOOK -> {
if (CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) takeOver()
if (CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) CoroutineScope(
Dispatchers.IO).launch {
takeOver()
}
isInCall = true
}
TelephonyManager.CALL_STATE_IDLE -> {
@@ -953,7 +1202,7 @@ class AirPodsService : Service() {
}
telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE)
if (sharedPreferences.getBoolean("show_phone_battery_in_widget", true)) {
if (config.showPhoneBatteryInWidget) {
widgetMobileBatteryEnabled = true
val batteryChangedIntentFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
batteryChangedIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
@@ -982,26 +1231,25 @@ class AirPodsService : Service() {
connectionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AirPodsNotifications.Companion.AIRPODS_CONNECTION_DETECTED) {
if (intent?.action == AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) {
device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra("device", BluetoothDevice::class.java)!!
} else {
intent.getParcelableExtra("device") as BluetoothDevice?
}
val name = this@AirPodsService.getSharedPreferences("settings", MODE_PRIVATE)
.getString("name", device?.name)
if (this@AirPodsService.getSharedPreferences("settings", MODE_PRIVATE)
.getString("name", null) == null
) {
this@AirPodsService.getSharedPreferences("settings", MODE_PRIVATE).edit {
putString("name", name)
}
if (config.deviceName == "AirPods" && device?.name != null) {
config.deviceName = device?.name ?: "AirPods"
sharedPreferences.edit { putString("name", config.deviceName) }
}
Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString())
if (!CrossDevice.isAvailable) {
Log.d("AirPodsService", "$name connected")
showPopup(this@AirPodsService, name.toString())
connectToSocket(device!!)
Log.d("AirPodsService", "${config.deviceName} connected")
showPopup(this@AirPodsService, config.deviceName)
CoroutineScope(Dispatchers.IO).launch {
connectToSocket(device!!)
}
Log.d("AirPodsService", "Setting metadata")
setMetadatas(device!!)
isConnectedLocally = true
@@ -1009,13 +1257,8 @@ class AirPodsService : Service() {
sharedPreferences.edit {
putString("mac_address", macAddress)
}
updateNotificationContent(
true,
name.toString(),
batteryNotification.getBattery()
)
}
} else if (intent?.action == AirPodsNotifications.Companion.AIRPODS_DISCONNECTED) {
} else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) {
device = null
isConnectedLocally = false
popupShown = false
@@ -1027,7 +1270,7 @@ class AirPodsService : Service() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == "me.kavishdevar.librepods.cross_device_island") {
showIsland(this@AirPodsService, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!))
} else if (intent?.action == AirPodsNotifications.Companion.DISCONNECT_RECEIVERS) {
} else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
try {
context?.unregisterReceiver(this)
} catch (e: Exception) {
@@ -1049,8 +1292,8 @@ class AirPodsService : Service() {
}
val deviceIntentFilter = IntentFilter().apply {
addAction(AirPodsNotifications.Companion.AIRPODS_CONNECTION_DETECTED)
addAction(AirPodsNotifications.Companion.AIRPODS_DISCONNECTED)
addAction(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -1075,7 +1318,9 @@ class AirPodsService : Service() {
val connectedDevices = proxy.connectedDevices
if (connectedDevices.isNotEmpty()) {
if (!CrossDevice.isAvailable) {
connectToSocket(device)
CoroutineScope(Dispatchers.IO).launch {
connectToSocket(device)
}
setMetadatas(device)
macAddress = device.address
sharedPreferences.edit {
@@ -1083,7 +1328,7 @@ class AirPodsService : Service() {
}
}
this@AirPodsService.sendBroadcast(
Intent(AirPodsNotifications.Companion.AIRPODS_CONNECTED)
Intent(AirPodsNotifications.AIRPODS_CONNECTED)
)
}
}
@@ -1119,7 +1364,7 @@ class AirPodsService : Service() {
@RequiresApi(Build.VERSION_CODES.R)
@SuppressLint("MissingPermission")
fun takeOver() {
fun takeOver() {
Log.d("AirPodsService", "Taking over audio")
CrossDevice.sendRemotePacket(CrossDevicePackets.REQUEST_DISCONNECT.packet)
Log.d("AirPodsService", macAddress)
@@ -1157,10 +1402,12 @@ class AirPodsService : Service() {
}
var lastException: Exception? = null
var attemptedConstructors = 0
for ((index, params) in constructorSpecs.withIndex()) {
try {
Log.d("AirPodsService", "Trying constructor signature #${index + 1}")
attemptedConstructors++
return HiddenApiBypass.newInstance(BluetoothSocket::class.java, *params) as BluetoothSocket
} catch (e: Exception) {
Log.e("AirPodsService", "Constructor signature #${index + 1} failed: ${e.message}")
@@ -1168,30 +1415,52 @@ class AirPodsService : Service() {
}
}
sendToast("Failed to create BluetoothSocket!")
throw lastException ?: IllegalStateException("Failed to create BluetoothSocket")
val errorMessage = "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
Log.e("AirPodsService", errorMessage)
showSocketConnectionFailureNotification(errorMessage)
throw lastException ?: IllegalStateException(errorMessage)
}
@RequiresApi(Build.VERSION_CODES.R)
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
fun connectToSocket(device: BluetoothDevice) {
Log.d("AirPodsService", "<LogCollector:Start> Connecting to socket")
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
val isHooked = RadareOffsetFinder(this).isHookOffsetAvailable()
assert(isHooked) { "Hook offset not available, stopping" }
if (isConnectedLocally != true && !CrossDevice.isAvailable) {
socket = try {
createBluetoothSocket(device, uuid)
} catch (e: Exception) {
Log.e("AirPodsService", "Failed to create BluetoothSocket: ${e.message}")
showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.message}")
return
}
try {
socket.connect()
runBlocking {
withTimeout(5000L) {
try {
socket.connect()
isConnectedLocally = true
this@AirPodsService.device = device
updateNotificationContent(
true,
config.deviceName,
batteryNotification.getBattery()
)
Log.d("AirPodsService", "<LogCollector:Complete:Success> Socket connected")
} catch (e: Exception) {
Log.d("AirPodsService", "<LogCollector:Complete:Failed> Socket not connected")
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
throw e
}
}
if (!socket.isConnected) {
Log.d("AirPodsService", "<LogCollector:Complete:Failed> Socket not connected")
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
}
}
this@AirPodsService.device = device
isConnectedLocally = true
socket.let { it ->
it.outputStream.write(Enums.HANDSHAKE.value)
it.outputStream.flush()
@@ -1222,7 +1491,7 @@ class AirPodsService : Service() {
it.outputStream.flush()
}, 5000)
sendBroadcast(
Intent(AirPodsNotifications.Companion.AIRPODS_CONNECTED)
Intent(AirPodsNotifications.AIRPODS_CONNECTED)
.putExtra("device", device)
)
while (socket.isConnected == true) {
@@ -1232,7 +1501,7 @@ class AirPodsService : Service() {
var data: ByteArray = byteArrayOf()
if (bytesRead > 0) {
data = buffer.copyOfRange(0, bytesRead)
sendBroadcast(Intent(AirPodsNotifications.Companion.AIRPODS_DATA).apply {
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply {
putExtra("data", buffer.copyOfRange(0, bytesRead))
})
val bytes = buffer.copyOfRange(0, bytesRead)
@@ -1249,7 +1518,7 @@ class AirPodsService : Service() {
}
} else if (bytesRead == -1) {
Log.d("AirPods Service", "Socket closed (bytesRead = -1)")
sendBroadcast(Intent(AirPodsNotifications.Companion.AIRPODS_DISCONNECTED))
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
return@launch
}
var inEar = false
@@ -1257,7 +1526,7 @@ class AirPodsService : Service() {
processData(data)
if (earDetectionNotification.isEarDetectionData(data)) {
earDetectionNotification.setStatus(data)
sendBroadcast(Intent(AirPodsNotifications.Companion.EAR_DETECTION_DATA).apply {
sendBroadcast(Intent(AirPodsNotifications.EAR_DETECTION_DATA).apply {
val list = earDetectionNotification.status
val bytes = ByteArray(2)
bytes[0] = list[0]
@@ -1272,7 +1541,7 @@ class AirPodsService : Service() {
earReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val data = intent.getByteArrayExtra("data")
if (data != null && earDetectionEnabled) {
if (data != null && config.earDetectionEnabled) {
inEar =
if (data.find { it == 0x02.toByte() } != null || data.find { it == 0x03.toByte() } != null) {
data[0] == 0x00.toByte() || data[1] == 0x00.toByte()
@@ -1296,40 +1565,39 @@ class AirPodsService : Service() {
) {
connectAudio(this@AirPodsService, device)
justEnabledA2dp = true
val bluetoothAdapter =
this@AirPodsService.getSystemService(
BluetoothManager::class.java
).adapter
bluetoothAdapter.getProfileProxy(
this@AirPodsService,
object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(
profile: Int,
proxy: BluetoothProfile
) {
if (profile == BluetoothProfile.A2DP) {
val connectedDevices =
proxy.connectedDevices
if (connectedDevices.isNotEmpty()) {
MediaController.sendPlay()
}
val a2dpConnectionStateReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == "android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") {
val state = intent.getIntExtra(BluetoothProfile.EXTRA_STATE, BluetoothProfile.STATE_DISCONNECTED)
val previousState = intent.getIntExtra(BluetoothProfile.EXTRA_PREVIOUS_STATE, BluetoothProfile.STATE_DISCONNECTED)
val device = intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE)
Log.d("MediaController", "A2DP state changed: $previousState -> $state for device: ${device?.address}")
if (state == BluetoothProfile.STATE_CONNECTED &&
previousState != BluetoothProfile.STATE_CONNECTED &&
device?.address == this@AirPodsService.device?.address) {
Log.d("MediaController", "A2DP connected, sending play command")
MediaController.sendPlay()
MediaController.iPausedTheMedia = false
context.unregisterReceiver(this)
}
bluetoothAdapter.closeProfileProxy(
profile,
proxy
)
}
override fun onServiceDisconnected(
profile: Int
) {
}
},
BluetoothProfile.A2DP
)
}
}
val a2dpIntentFilter = IntentFilter("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(a2dpConnectionStateReceiver, a2dpIntentFilter, RECEIVER_EXPORTED)
} else {
registerReceiver(a2dpConnectionStateReceiver, a2dpIntentFilter)
}
} else if (newInEarData == listOf(false, false)) {
disconnectAudio(this@AirPodsService, device)
MediaController.sendPause(force = true)
if (config.disconnectWhenNotWearing) {
disconnectAudio(this@AirPodsService, device)
}
}
if (inEarData.contains(false) && newInEarData == listOf(
@@ -1384,7 +1652,7 @@ class AirPodsService : Service() {
}
val earIntentFilter =
IntentFilter(AirPodsNotifications.Companion.EAR_DETECTION_DATA)
IntentFilter(AirPodsNotifications.EAR_DETECTION_DATA)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
this@AirPodsService.registerReceiver(
earReceiver, earIntentFilter,
@@ -1407,7 +1675,7 @@ class AirPodsService : Service() {
CrossDevice.sendRemotePacket(data)
CrossDevice.batteryBytes = data
batteryNotification.setBattery(data)
sendBroadcast(Intent(AirPodsNotifications.Companion.BATTERY_DATA).apply {
sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
putParcelableArrayListExtra(
"data",
ArrayList(batteryNotification.getBattery())
@@ -1438,7 +1706,7 @@ class AirPodsService : Service() {
)
) {
conversationAwarenessNotification.setData(data)
sendBroadcast(Intent(AirPodsNotifications.Companion.CA_DATA).apply {
sendBroadcast(Intent(AirPodsNotifications.CA_DATA).apply {
putExtra("data", conversationAwarenessNotification.status)
})
@@ -1462,12 +1730,16 @@ class AirPodsService : Service() {
Log.d("AirPods Service", "Socket closed")
isConnectedLocally = false
socket.close()
sendBroadcast(Intent(AirPodsNotifications.Companion.AIRPODS_DISCONNECTED))
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
}
}
} catch (e: Exception) {
e.printStackTrace()
Log.d("AirPodsService", "Failed to connect to socket")
Log.d("AirPodsService", "Failed to connect to socket: ${e.message}")
showSocketConnectionFailureNotification("Failed to establish connection: ${e.message}")
isConnectedLocally = false
this@AirPodsService.device = device
updateNotificationContent(false)
}
}
}
@@ -1511,7 +1783,7 @@ class AirPodsService : Service() {
socket.outputStream?.write(byteArray)
socket.outputStream?.flush()
} else {
Log.d("AirPodsService", "Cannot send packet: Socket not initialized or connected")
Log.d("AirPodsService", "Can't send packet: Socket not initialized or connected")
}
} catch (e: Exception) {
Log.e("AirPodsService", "Error sending packet: ${e.message}")
@@ -1526,11 +1798,11 @@ class AirPodsService : Service() {
CrossDevice.sendRemotePacket(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet)
return
}
if (this::socket.isInitialized && socket.isConnected && socket.outputStream != null) {
if (this::socket.isInitialized && socket.isConnected && socket.outputStream != null && isConnectedLocally) {
socket.outputStream?.write(packet)
socket.outputStream?.flush()
} else {
Log.d("AirPodsService", "Cannot send packet: Socket not initialized or connected")
Log.d("AirPodsService", "Can't send packet: Socket not initialized or connected")
}
} catch (e: Exception) {
Log.e("AirPodsService", "Error sending packet: ${e.message}")
@@ -1578,6 +1850,11 @@ class AirPodsService : Service() {
0x00
)
)
if (config.offListeningMode != enabled) {
config.offListeningMode = enabled
sharedPreferences.edit { putBoolean("off_listening_mode", enabled) }
}
updateNoiseControlWidget()
}
@@ -1597,6 +1874,11 @@ class AirPodsService : Service() {
0x00
)
sendPacket(bytes)
if (config.adaptiveStrength != strength) {
config.adaptiveStrength = strength
sharedPreferences.edit { putInt("adaptive_strength", strength) }
}
}
fun setPressSpeed(speed: Int) {
@@ -1662,12 +1944,22 @@ class AirPodsService : Service() {
0x00
)
sendPacket(bytes)
if (config.volumeControl != enabled) {
config.volumeControl = enabled
sharedPreferences.edit { putBoolean("volume_control", enabled) }
}
}
fun setToneVolume(volume: Int) {
val bytes =
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1F, volume.toByte(), 0x50, 0x00, 0x00)
sendPacket(bytes)
if (config.toneVolume != volume) {
config.toneVolume = volume
sharedPreferences.edit { putInt("tone_volume", volume) }
}
}
val earDetectionNotification = AirPodsNotifications.EarDetection()
@@ -1676,10 +1968,11 @@ class AirPodsService : Service() {
val conversationAwarenessNotification =
AirPodsNotifications.ConversationalAwarenessNotification()
var earDetectionEnabled = true
fun setEarDetection(enabled: Boolean) {
earDetectionEnabled = enabled
if (config.earDetectionEnabled != enabled) {
config.earDetectionEnabled = enabled
sharedPreferences.edit { putBoolean("automatic_ear_detection", enabled) }
}
}
fun getBattery(): List<Battery> {
@@ -1786,6 +2079,12 @@ class AirPodsService : Service() {
) + nameBytes
sendPacket(bytes)
val hex = bytes.joinToString(" ") { "%02X".format(it) }
if (config.deviceName != name) {
config.deviceName = name
sharedPreferences.edit { putString("name", name) }
}
updateNotificationContent(true, name, batteryNotification.getBattery())
Log.d("AirPodsService", "setName: $name, sent packet: $hex")
}
@@ -1798,12 +2097,22 @@ class AirPodsService : Service() {
"04 00 04 00 17 00 00 00 10 00 12 00 08 E${if (enabled) "6" else "5"} 05 10 02 42 0B 08 50 10 02 1A 05 02 ${if (enabled) "32" else "00"} 00 00 00"
bytes = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray()
sendPacket(bytes)
if (config.personalizedVolume != enabled) {
config.personalizedVolume = enabled
sharedPreferences.edit { putBoolean("personalized_volume", enabled) }
}
}
fun setLoudSoundReduction(enabled: Boolean) {
val hex = "52 1B 00 0${if (enabled) "1" else "0"}"
val bytes = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray()
sendPacket(bytes)
if (config.loudSoundReduction != enabled) {
config.loudSoundReduction = enabled
sharedPreferences.edit { putBoolean("loud_sound_reduction", enabled) }
}
}
fun findChangedIndex(oldArray: BooleanArray, newArray: BooleanArray): Int {
@@ -1957,11 +2266,19 @@ class AirPodsService : Service() {
override fun onDestroy() {
clearPacketLogs()
Log.d("AirPodsService", "Service stopped is being destroyed for some reason!")
sharedPreferences.unregisterOnSharedPreferenceChangeListener(this)
try {
unregisterReceiver(bluetoothReceiver)
} catch (e: Exception) {
e.printStackTrace()
}
try {
unregisterReceiver(ancModeReceiver)
} catch (e: Exception) {
e.printStackTrace()
}
try {
unregisterReceiver(connectionReceiver)
} catch (e: Exception) {

View File

@@ -0,0 +1,215 @@
/*
* LibrePods - AirPods liberated from Apple's ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.utils
import android.content.Context
import android.content.Intent
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
class LogCollector(private val context: Context) {
private var isCollecting = false
private var logProcess: Process? = null
suspend fun openXposedSettings(context: Context) {
withContext(Dispatchers.IO) {
val command = if (android.os.Build.VERSION.SDK_INT >= 29) {
"am broadcast -a android.telephony.action.SECRET_CODE -d android_secret_code://5776733 android"
} else {
"am broadcast -a android.provider.Telephony.SECRET_CODE -d android_secret_code://5776733 android"
}
executeRootCommand(command)
}
}
suspend fun clearLogs() {
withContext(Dispatchers.IO) {
executeRootCommand("logcat -c")
}
}
suspend fun killBluetoothService() {
withContext(Dispatchers.IO) {
executeRootCommand("killall com.android.bluetooth")
}
}
private suspend fun getPackageUIDs(): Pair<String?, String?> {
return withContext(Dispatchers.IO) {
val btUid = executeRootCommand("dumpsys package com.android.bluetooth | grep -m 1 \"uid=\" | sed -E 's/.*uid=([0-9]+).*/\\1/'")
.trim()
.takeIf { it.isNotEmpty() }
val appUid = executeRootCommand("dumpsys package me.kavishdevar.librepods | grep -m 1 \"uid=\" | sed -E 's/.*uid=([0-9]+).*/\\1/'")
.trim()
.takeIf { it.isNotEmpty() }
Pair(btUid, appUid)
}
}
suspend fun startLogCollection(listener: (String) -> Unit, connectionDetectedCallback: () -> Unit): String {
return withContext(Dispatchers.IO) {
isCollecting = true
val (btUid, appUid) = getPackageUIDs()
val uidFilter = buildString {
if (!btUid.isNullOrEmpty() && !appUid.isNullOrEmpty()) {
append("$btUid,$appUid")
} else if (!btUid.isNullOrEmpty()) {
append(btUid)
} else if (!appUid.isNullOrEmpty()) {
append(appUid)
}
}
val command = if (uidFilter.isNotEmpty()) {
"su -c logcat --uid=$uidFilter -v threadtime"
} else {
"su -c logcat -v threadtime"
}
val logs = StringBuilder()
try {
logProcess = Runtime.getRuntime().exec(command)
val reader = BufferedReader(InputStreamReader(logProcess!!.inputStream))
var line: String? = null
var connectionDetected = false
while (isCollecting && reader.readLine().also { line = it } != null) {
line?.let {
if (it.contains("<LogCollector:")) {
logs.append("\n=============\n")
}
logs.append(it).append("\n")
listener(it)
if (it.contains("<LogCollector:")) {
logs.append("=============\n\n")
}
if (!connectionDetected) {
if (it.contains("<LogCollector:Complete:Success>")) {
connectionDetected = true
connectionDetectedCallback()
} else if (it.contains("<LogCollector:Complete:Failed>")) {
connectionDetected = true
connectionDetectedCallback()
} else if (it.contains("<LogCollector:Start>")) {
}
else if (it.contains("AirPodsService") && it.contains("Connected to device")) {
connectionDetected = true
connectionDetectedCallback()
} else if (it.contains("AirPodsService") && it.contains("Connection failed")) {
connectionDetected = true
connectionDetectedCallback()
} else if (it.contains("AirPodsService") && it.contains("Device disconnected")) {
}
else if (it.contains("BluetoothService") && it.contains("CONNECTION_STATE_CONNECTED")) {
connectionDetected = true
connectionDetectedCallback()
} else if (it.contains("BluetoothService") && it.contains("CONNECTION_STATE_DISCONNECTED")) {
}
}
}
}
} catch (e: Exception) {
logs.append("Error collecting logs: ${e.message}").append("\n")
e.printStackTrace()
}
logs.toString()
}
}
fun stopLogCollection() {
isCollecting = false
logProcess?.destroy()
logProcess = null
}
suspend fun saveLogToInternalStorage(fileName: String, content: String): File? {
return withContext(Dispatchers.IO) {
try {
val logsDir = File(context.filesDir, "logs")
if (!logsDir.exists()) {
logsDir.mkdir()
}
val file = File(logsDir, fileName)
file.writeText(content)
return@withContext file
} catch (e: Exception) {
e.printStackTrace()
return@withContext null
}
}
}
suspend fun addLogMarker(markerType: LogMarkerType, details: String = "") {
withContext(Dispatchers.IO) {
val timestamp = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", java.util.Locale.US)
.format(java.util.Date())
val marker = when (markerType) {
LogMarkerType.START -> "<LogCollector:Start> [$timestamp] Beginning connection test"
LogMarkerType.SUCCESS -> "<LogCollector:Complete:Success> [$timestamp] Connection test completed successfully"
LogMarkerType.FAILURE -> "<LogCollector:Complete:Failed> [$timestamp] Connection test failed"
LogMarkerType.CUSTOM -> "<LogCollector:Custom:$details> [$timestamp]"
}
val command = "log -t AirPodsService \"$marker\""
executeRootCommand(command)
}
}
enum class LogMarkerType {
START,
SUCCESS,
FAILURE,
CUSTOM
}
private suspend fun executeRootCommand(command: String): String {
return withContext(Dispatchers.IO) {
try {
val process = Runtime.getRuntime().exec("su -c $command")
val reader = BufferedReader(InputStreamReader(process.inputStream))
val output = StringBuilder()
var line: String?
while (reader.readLine().also { line = it } != null) {
output.append(line).append("\n")
}
process.waitFor()
output.toString()
} catch (e: Exception) {
e.printStackTrace()
""
}
}
}
}

View File

@@ -21,10 +21,12 @@ package me.kavishdevar.librepods.utils
import android.content.SharedPreferences
import android.media.AudioManager
import android.media.AudioPlaybackConfiguration
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.KeyEvent
import androidx.annotation.RequiresApi
import me.kavishdevar.librepods.services.ServiceManager
object MediaController {
@@ -34,11 +36,12 @@ object MediaController {
var userPlayedTheMedia = false
private lateinit var sharedPreferences: SharedPreferences
private val handler = Handler(Looper.getMainLooper())
private lateinit var preferenceChangeListener: SharedPreferences.OnSharedPreferenceChangeListener
var pausedForCrossDevice = false
private var relativeVolume: Boolean = false
private var conversationalAwarenessVolume: Int = 1/12
private var conversationalAwarenessVolume: Int = 2
private var conversationalAwarenessPauseMusic: Boolean = false
fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) {
@@ -49,16 +52,16 @@ object MediaController {
this.sharedPreferences = sharedPreferences
Log.d("MediaController", "Initializing MediaController")
relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false)
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 100/12)
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) / 0.4).toInt())
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false)
sharedPreferences.registerOnSharedPreferenceChangeListener { _, key ->
preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
when (key) {
"relative_conversational_awareness_volume" -> {
relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false)
}
"conversational_awareness_volume" -> {
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 100/12)
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * 0.4).toInt())
}
"conversational_awareness_pause_music" -> {
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false)
@@ -66,17 +69,19 @@ object MediaController {
}
}
sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
audioManager.registerAudioPlaybackCallback(cb, null)
}
val cb = object : AudioManager.AudioPlaybackCallback() {
@RequiresApi(Build.VERSION_CODES.R)
override fun onPlaybackConfigChanged(configs: MutableList<AudioPlaybackConfiguration>?) {
super.onPlaybackConfigChanged(configs)
Log.d("MediaController", "Playback config changed, iPausedTheMedia: $iPausedTheMedia")
if (configs != null && !iPausedTheMedia) {
Log.d("MediaController", "Seems like the user changed the state of media themselves, now I won't play until the ear detection pauses it.")
handler.postDelayed({
iPausedTheMedia = !audioManager.isMusicActive
userPlayedTheMedia = audioManager.isMusicActive
}, 7) // i have no idea why android sends an event a hundred times after the user does something.
}
@@ -92,9 +97,9 @@ object MediaController {
@Synchronized
fun sendPause(force: Boolean = false) {
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia")
if ((audioManager.isMusicActive && !userPlayedTheMedia) || force) {
iPausedTheMedia = true
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia, isMusicActive: ${audioManager.isMusicActive}, force: $force")
if ((audioManager.isMusicActive) && (!userPlayedTheMedia || force)) {
iPausedTheMedia = if (force) audioManager.isMusicActive else true
userPlayedTheMedia = false
audioManager.dispatchMediaKeyEvent(
KeyEvent(
@@ -134,11 +139,18 @@ object MediaController {
@Synchronized
fun startSpeaking() {
Log.d("MediaController", "Starting speaking")
Log.d("MediaController", "Starting speaking max vol: ${audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)}, current vol: ${audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)}, conversationalAwarenessVolume: $conversationalAwarenessVolume, relativeVolume: $relativeVolume")
if (initialVolume == null) {
initialVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
Log.d("MediaController", "Initial Volume Set: $initialVolume")
val targetVolume = if (relativeVolume) initialVolume!! * conversationalAwarenessVolume * 1/100 else if ( initialVolume!! > audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume * 1/100) audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume * 1/100 else initialVolume!!
Log.d("MediaController", "Initial Volume: $initialVolume")
val targetVolume = if (relativeVolume) {
(initialVolume!! * conversationalAwarenessVolume / 100)
} else if (initialVolume!! > (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume / 100)) {
(audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume / 100)
} else {
initialVolume!!
}
smoothVolumeTransition(initialVolume!!, targetVolume.toInt())
if (conversationalAwarenessPauseMusic) {
sendPause(force = true)
@@ -160,6 +172,7 @@ object MediaController {
}
private fun smoothVolumeTransition(fromVolume: Int, toVolume: Int) {
Log.d("MediaController", "Smooth volume transition from $fromVolume to $toVolume")
val step = if (fromVolume < toVolume) 1 else -1
val delay = 50L
var currentVolume = fromVolume

View File

@@ -24,8 +24,12 @@ import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.animation.PropertyValuesHolder
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.PixelFormat
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
@@ -51,6 +55,7 @@ class PopupWindow(
private var isClosing = false
private var autoCloseHandler = Handler(Looper.getMainLooper())
private var autoCloseRunnable: Runnable? = null
private var batteryUpdateReceiver: BroadcastReceiver? = null
@Suppress("DEPRECATION")
private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply {
@@ -145,6 +150,8 @@ class PopupWindow(
interpolator = DecelerateInterpolator()
start()
}
registerBatteryUpdateReceiver()
autoCloseRunnable = Runnable { close() }
autoCloseHandler.postDelayed(autoCloseRunnable!!, 12000)
@@ -155,15 +162,43 @@ class PopupWindow(
}
}
@SuppressLint("SetTextI18n")
fun updateBatteryStatus(batteryNotification: AirPodsNotifications.BatteryNotification) {
val batteryStatus = batteryNotification.getBattery()
private fun registerBatteryUpdateReceiver() {
batteryUpdateReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AirPodsNotifications.BATTERY_DATA) {
val batteryList = intent.getParcelableArrayListExtra<Battery>("data")
if (batteryList != null) {
updateBatteryStatusFromList(batteryList)
}
}
}
}
val filter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(batteryUpdateReceiver, filter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(batteryUpdateReceiver, filter)
}
}
private fun unregisterBatteryUpdateReceiver() {
batteryUpdateReceiver?.let {
try {
context.unregisterReceiver(it)
batteryUpdateReceiver = null
} catch (e: Exception) {
Log.e("PopupWindow", "Error unregistering battery receiver: ${e.message}")
}
}
}
private fun updateBatteryStatusFromList(batteryList: List<Battery>) {
val batteryLeftText = mView.findViewById<TextView>(R.id.left_battery)
val batteryRightText = mView.findViewById<TextView>(R.id.right_battery)
val batteryCaseText = mView.findViewById<TextView>(R.id.case_battery)
batteryLeftText.text = batteryStatus.find { it.component == BatteryComponent.LEFT }?.let {
batteryLeftText.text = batteryList.find { it.component == BatteryComponent.LEFT }?.let {
if (it.status != BatteryStatus.DISCONNECTED) {
"\uDBC3\uDC8E ${it.level}%"
} else {
@@ -171,7 +206,7 @@ class PopupWindow(
}
} ?: ""
batteryRightText.text = batteryStatus.find { it.component == BatteryComponent.RIGHT }?.let {
batteryRightText.text = batteryList.find { it.component == BatteryComponent.RIGHT }?.let {
if (it.status != BatteryStatus.DISCONNECTED) {
"\uDBC3\uDC8D ${it.level}%"
} else {
@@ -179,7 +214,7 @@ class PopupWindow(
}
} ?: ""
batteryCaseText.text = batteryStatus.find { it.component == BatteryComponent.CASE }?.let {
batteryCaseText.text = batteryList.find { it.component == BatteryComponent.CASE }?.let {
if (it.status != BatteryStatus.DISCONNECTED) {
"\uDBC3\uDE6C ${it.level}%"
} else {
@@ -188,12 +223,19 @@ class PopupWindow(
} ?: ""
}
@SuppressLint("SetTextI18s")
fun updateBatteryStatus(batteryNotification: AirPodsNotifications.BatteryNotification) {
val batteryStatus = batteryNotification.getBattery()
updateBatteryStatusFromList(batteryStatus)
}
fun close() {
try {
if (isClosing) return
isClosing = true
autoCloseRunnable?.let { autoCloseHandler.removeCallbacks(it) }
unregisterBatteryUpdateReceiver()
val vid = mView.findViewById<VideoView>(R.id.video)
vid.stopPlayback()

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM19,19L5,19L5,5h11.17L19,7.83L19,19zM12,12c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3zM6,6h9v4L6,10z" />
</vector>

View File

@@ -1,5 +1,6 @@
<resources>
<string name="app_name" translatable="false">LibrePods</string>
<string name="app_description" translatable="false">Liberate your AirPods from Apple\'s ecosystem.</string>
<string name="title_activity_custom_device" translatable="false">GATT Testing</string>
<string name="app_widget_description">See your AirPods battery status right from your home screen!</string>
<string name="accessibility">Accessibility</string>
@@ -34,8 +35,7 @@
<string name="airpods_not_connected">AirPods not connected</string>
<string name="airpods_not_connected_description">Please connect your AirPods to access settings. If you\'re stuck here, then try reopening the app again after closing it from the recents.\n(DO NOT KILL THE APP!)</string>
<string name="back">Back</string>
<string name="app_settings">App Settings</string>
<string name="conversational_awareness_customization">Conversational Awareness</string>
<string name="app_settings">Customizations</string>
<string name="relative_conversational_awareness_volume">Relative volume</string>
<string name="relative_conversational_awareness_volume_description">Reduces to a percentage of the current volume instead of the maximum volume.</string>
<string name="conversational_awareness_pause_music">Pause Music</string>
@@ -59,4 +59,9 @@
<string name="ear_detection">Automatic Ear Detection</string>
<string name="auto_play">Auto Play</string>
<string name="auto_pause">Auto Pause</string>
<string name="troubleshooting">Troubleshooting</string>
<string name="troubleshooting_description">Collect logs to diagnose issues with AirPods connection</string>
<string name="collect_logs">Collect Logs</string>
<string name="saved_logs">Saved Logs</string>
<string name="no_logs_found">No saved logs found</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path name="logs" path="logs/"/>
</paths>