24 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
Kavish Devar
d381adaa09 Merge remote-tracking branch 'refs/remotes/origin/main' 2025-05-08 23:51:03 +05:30
Kavish Devar
58dfed97b3 android: fix the xposed module
skip unecessary parsing the argument for debugging, just return true and hope that it works
2025-05-08 23:50:30 +05:30
Kavish Devar
48e2899564 [Linux] Use Qt 6.4 for compatibility with Debian Stable (#116) 2025-05-04 07:28:28 +05:30
E. S
7f7b439746 linux: Add Debian requirements to the README 2025-05-04 01:20:06 +03:00
E. S
0b4030dd9f linux: Use Qt 6.4 to support Debian 12 2025-05-04 01:18:17 +03:00
31 changed files with 2613 additions and 305 deletions

View File

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

View File

@@ -1,24 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
xmlns:tools="http://schemas.android.com/tools"
android:sharedUserId="android.uid.system"
android:sharedUserMaxSdkVersion="32"
tools:targetApi="33">
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.INTERACT_ACROSS_USERS"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission
android:name="android.permission.BLUETOOTH_PRIVILEGED"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.BATTERY_STATS"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.UPDATE_DEVICE_STATS"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
@@ -29,6 +31,9 @@
<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" />
<application
android:allowBackup="true"
@@ -40,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
@@ -121,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

@@ -32,9 +32,7 @@
static HookFunType hook_func = nullptr;
#define L2CEVT_L2CAP_CONFIG_REQ 4
#define L2CEVT_L2CAP_CONFIG_RSP 15
// Define all necessary structures for the L2CAP stack
// Forward declarations for types needed by the new hook
struct t_l2c_lcb;
typedef struct _BT_HDR {
uint16_t event;
@@ -44,7 +42,6 @@ typedef struct _BT_HDR {
uint8_t data[];
} BT_HDR;
// Define base FCR structures
typedef struct {
uint8_t mode;
uint8_t tx_win_sz;
@@ -130,17 +127,7 @@ static void (*original_l2c_csm_config)(tL2C_CCB* p_ccb, uint8_t event, void* p_d
static void (*original_l2cu_send_peer_info_req)(tL2C_LCB* p_lcb, uint16_t info_type) = nullptr;
uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) {
LOGI("l2c_fcr_chk_chan_modes hooked");
auto* ccb = static_cast<tL2C_CCB*>(p_ccb);
LOGI("Original FCR mode: 0x%02x", ccb->our_cfg.fcr.mode);
ccb->our_cfg.fcr.mode = 0;
ccb->our_cfg.fcr_present = true;
ccb->peer_cfg.fcr.mode = 0;
ccb->peer_cfg.fcr_present = true;
LOGI("FCR mode set to Basic Mode (0) for both local and peer config, here's the new desired FCR mode: 0x%02x, and the peer's FCR mode: 0x%02x", ccb->our_cfg.fcr.mode, ccb->peer_cfg.fcr.mode);
LOGI("l2c_fcr_chk_chan_modes hooked, returning true.");
return 1;
}

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
@@ -186,6 +187,8 @@ fun Main() {
permissions = listOf(
"android.permission.BLUETOOTH_CONNECT",
"android.permission.BLUETOOTH_SCAN",
"android.permission.BLUETOOTH",
"android.permission.BLUETOOTH_ADMIN",
"android.permission.BLUETOOTH_ADVERTISE",
"android.permission.POST_NOTIFICATIONS",
"android.permission.READ_PHONE_STATE",
@@ -275,6 +278,9 @@ fun Main() {
composable("app_settings") {
AppSettingsScreen(navController)
}
composable("troubleshooting") {
TroubleshootingScreen(navController)
}
composable("head_tracking") {
HeadTrackingScreen(navController)
}
@@ -405,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,
@@ -517,16 +523,16 @@ fun PermissionsScreen(
),
)
}
if (!canDrawOverlays && basicPermissionsGranted) {
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
val editor = context.getSharedPreferences("settings", MODE_PRIVATE).edit()
editor.putBoolean("overlay_permission_skipped", true)
editor.apply()
val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
context.startActivity(intent)

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,38 +64,86 @@ 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
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.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),
@@ -106,6 +156,7 @@ fun AppSettingsScreen(navController: NavController) {
navController.popBackStack()
},
shape = RoundedCornerShape(8.dp),
modifier = Modifier.width(180.dp)
) {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
@@ -121,12 +172,16 @@ fun AppSettingsScreen(navController: NavController) {
color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
)
),
scrollBehavior = scrollBehavior
)
},
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
@@ -136,68 +191,114 @@ 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
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
IndependentToggle("Show phone battery in widget", ServiceManager.getService()!!, "setPhoneBatteryInWidget", sharedPreferences)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Widget".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 = 8.dp)
)
Spacer(modifier = Modifier.height(2.dp))
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
Text(
text = stringResource(R.string.conversational_awareness_customization),
style = TextStyle(
fontSize = 20.sp,
color = textColor
),
modifier = Modifier
.padding(top = 12.dp, bottom = 4.dp)
)
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()
@@ -206,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() }
@@ -222,6 +318,7 @@ fun AppSettingsScreen(navController: NavController) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp)
.padding(end = 4.dp)
) {
Text(
@@ -249,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() }
@@ -265,6 +357,7 @@ fun AppSettingsScreen(navController: NavController) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp)
.padding(end = 4.dp)
) {
Text(
@@ -289,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,
@@ -341,7 +445,9 @@ fun AppSettingsScreen(navController: NavController) {
)
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
@@ -349,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)
)
@@ -358,14 +464,202 @@ 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
.fillMaxWidth()
.background(
backgroundColor,
RoundedCornerShape(14.dp)
)
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
fun updateQsClickBehavior(enabled: Boolean) {
openDialogForControlling = enabled
sharedPreferences.edit().putString("qs_click_behavior", if (enabled) "dialog" else "cycle").apply()
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
updateQsClickBehavior(!openDialogForControlling)
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp)
.padding(end = 4.dp)
) {
Text(
text = "Open dialog for controlling",
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = if (openDialogForControlling)
"If disabled, clicking on the QS will cycle through modes"
else "If enabled, it will show a dialog for controlling noise control mode and conversational awareness",
fontSize = 14.sp,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
)
}
StyledSwitch(
checked = openDialogForControlling,
onCheckedChange = {
updateQsClickBehavior(it)
}
)
}
}
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 },
@@ -400,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

@@ -39,7 +39,10 @@ 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.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.PlayArrow
@@ -69,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
@@ -83,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
@@ -92,10 +97,17 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
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
@@ -108,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
@@ -122,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),
@@ -138,6 +178,7 @@ fun HeadTrackingScreen(navController: NavController) {
if (ServiceManager.getService()?.isHeadTrackingActive == true) ServiceManager.getService()?.stopHeadTracking()
},
shape = RoundedCornerShape(8.dp),
modifier = Modifier.width(180.dp)
) {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
@@ -153,10 +194,13 @@ fun HeadTrackingScreen(navController: NavController) {
color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
),
actions = {
@@ -205,7 +249,8 @@ fun HeadTrackingScreen(navController: NavController) {
modifier = Modifier.scale(1.5f)
)
}
}
},
scrollBehavior = scrollBehavior
)
},
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
@@ -217,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)
@@ -305,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,
@@ -800,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
@@ -31,6 +31,8 @@ import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.compose.material3.ExperimentalMaterial3Api
import me.kavishdevar.librepods.MainActivity
import me.kavishdevar.librepods.QuickSettingsDialogActivity
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.AirPodsNotifications
@@ -260,4 +262,42 @@ class AirPodsQSService : TileService() {
else -> R.drawable.airpods
}
}
@ExperimentalMaterial3Api
override fun onTileAdded() {
super.onTileAdded()
Log.d("AirPodsQSService", "Tile added")
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
@ExperimentalMaterial3Api
fun openMainActivity() {
Log.d("AirPodsQSService", "Opening MainActivity")
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val pendingIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
startActivityAndCollapse(pendingIntent)
} else {
@Suppress("DEPRECATION")
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivityAndCollapse(intent)
}
Log.d("AirPodsQSService", "Called startActivityAndCollapse for MainActivity")
} catch (e: Exception) {
Log.e("AirPodsQSService", "Error launching MainActivity: $e")
}
}
}

View File

@@ -238,7 +238,7 @@ object CrossDevice {
batteryBytes = trimmedPacket
ServiceManager.getService()?.batteryNotification?.setBattery(trimmedPacket)
Log.d("CrossDevice", "Battery data: ${ServiceManager.getService()?.batteryNotification?.getBattery()[0]?.level}")
ServiceManager.getService()?.updateBatteryWidget()
ServiceManager.getService()?.updateBattery()
ServiceManager.getService()?.sendBatteryBroadcast()
ServiceManager.getService()?.sendBatteryNotification()
} else if (ServiceManager.getService()?.ancNotification?.isANCData(trimmedPacket) == true) {

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

@@ -416,9 +416,9 @@ class RadareOffsetFinder(context: Context) {
Log.e(TAG, "rabin2 command failed with exit code $exitCode")
}
// findAndSaveL2cuProcessCfgReqOffset(libraryPath, envSetup)
// findAndSaveL2cCsmConfigOffset(libraryPath, envSetup)
findAndSaveL2cuSendPeerInfoReqOffset(libraryPath, envSetup)
// findAndSaveL2cuProcessCfgReqOffset(libraryPath, envSetup)
// findAndSaveL2cCsmConfigOffset(libraryPath, envSetup)
// findAndSaveL2cuSendPeerInfoReqOffset(libraryPath, envSetup)
} catch (e: Exception) {
Log.e(TAG, "Failed to find function offset", e)

View File

@@ -1,6 +1,8 @@
package me.kavishdevar.librepods.utils
import android.bluetooth.BluetoothDevice
import android.util.Log
import org.lsposed.hiddenapibypass.HiddenApiBypass
object SystemApisUtils {
@@ -282,4 +284,23 @@ object SystemApisUtils {
const val ACTION_BLUETOOTH_HANDSFREE_BATTERY_CHANGED = "android.intent.action.BLUETOOTH_HANDSFREE_BATTERY_CHANGED"
const val EXTRA_SHOW_BT_HANDSFREE_BATTERY = "android.intent.extra.show_bluetooth_handsfree_battery"
const val EXTRA_BT_HANDSFREE_BATTERY_LEVEL = "android.intent.extra.bluetooth_handsfree_battery_level"
/**
* Helper method to set metadata using HiddenApiBypass
*/
fun setMetadata(device: BluetoothDevice, key: Int, value: ByteArray): Boolean {
return try {
val result = HiddenApiBypass.invoke(
BluetoothDevice::class.java,
device,
"setMetadata",
key,
value
) as Boolean
result
} catch (e: Exception) {
Log.e("SystemApisUtils", "Failed to set metadata for key $key", e)
false
}
}
}

View File

@@ -19,15 +19,9 @@
package me.kavishdevar.librepods.widgets
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.widget.RemoteViews
import androidx.compose.material3.ExperimentalMaterial3Api
import me.kavishdevar.librepods.MainActivity
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
class BatteryWidget : AppWidgetProvider() {
@@ -36,6 +30,6 @@ class BatteryWidget : AppWidgetProvider() {
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
ServiceManager.getService()?.updateBatteryWidget()
ServiceManager.getService()?.updateBattery()
}
}

View File

@@ -24,6 +24,7 @@ import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.util.Log
import android.widget.RemoteViews
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
@@ -77,6 +78,7 @@ class NoiseControlWidget : AppWidgetProvider() {
super.onReceive(context, intent)
if (intent.action == "ACTION_SET_ANC_MODE") {
val mode = intent.getIntExtra("ANC_MODE", 1)
Log.d("NoiseControlWidget", "Setting ANC mode to $mode")
ServiceManager.getService()?.setANCMode(mode)
}
}

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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

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>

View File

@@ -4,9 +4,9 @@ project(linux VERSION 0.1 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(Qt6 6.5 REQUIRED COMPONENTS Quick Widgets Bluetooth DBus)
find_package(Qt6 6.4 REQUIRED COMPONENTS Quick Widgets Bluetooth DBus)
qt_standard_project_setup(REQUIRES 6.5)
qt_standard_project_setup(REQUIRES 6.4)
qt_add_executable(applinux
main.cpp

View File

@@ -14,7 +14,13 @@ A native Linux application to control your AirPods, with support for:
2. Qt6 packages
```bash
sudo pacman -S qt6-base qt6-connectivity qt6-multimedia-ffmpeg qt6-multimedia # Arch Linux / EndeavourOS
# For Arch Linux / EndeavourOS
sudo pacman -S qt6-base qt6-connectivity qt6-multimedia-ffmpeg qt6-multimedia
# For Debian
sudo apt-get install qt6-base-dev qt6-declarative-dev qt6-connectivity-dev qt6-multimedia-dev \
qml6-module-qtquick-controls qml6-module-qtqml-workerscript qml6-module-qtquick-templates \
qml6-module-qtquick-window qml6-module-qtquick-layouts
```
## Setup

View File

@@ -8,7 +8,7 @@ set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
find_package(Qt6 6.5 REQUIRED COMPONENTS Core Bluetooth Widgets)
find_package(Qt6 6.4 REQUIRED COMPONENTS Core Bluetooth Widgets)
qt_add_executable(ble_monitor
main.cpp
@@ -26,4 +26,4 @@ install(TARGETS ble_monitor
BUNDLE DESTINATION .
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)
)

View File

@@ -367,7 +367,7 @@ private slots:
}
else
{
parent->loadFromModule("linux", "Main");
loadMainModule();
}
}
@@ -379,7 +379,7 @@ private slots:
}
else
{
parent->loadFromModule("linux", "Main");
loadMainModule();
}
}
@@ -908,6 +908,10 @@ private slots:
connectToPhone();
}
void loadMainModule() {
parent->load(QUrl(QStringLiteral("qrc:/linux/Main.qml")));
}
signals:
void noiseControlModeChanged(NoiseControlMode mode);
void earDetectionStatusChanged(const QString &status);
@@ -995,7 +999,7 @@ int main(int argc, char *argv[]) {
qmlRegisterType<Battery>("me.kavishdevar.Battery", 1, 0, "Battery");
AirPodsTrayApp *trayApp = new AirPodsTrayApp(debugMode, hideOnStart, &engine);
engine.rootContext()->setContextProperty("airPodsTrayApp", trayApp);
engine.loadFromModule("linux", "Main");
trayApp->loadMainModule();
QLocalServer server;
QLocalServer::removeServer("app_server");
@@ -1012,7 +1016,7 @@ int main(int argc, char *argv[]) {
QObject::connect(&server, &QLocalServer::newConnection, [&]() {
QLocalSocket* socket = server.nextPendingConnection();
// Handles Proper Connection
QObject::connect(socket, &QLocalSocket::readyRead, [socket, &engine]() {
QObject::connect(socket, &QLocalSocket::readyRead, [socket, &engine, &trayApp]() {
QString msg = socket->readAll();
// Check if the message is "reopen", if so, trigger onOpenApp function
if (msg == "reopen") {
@@ -1023,7 +1027,7 @@ int main(int argc, char *argv[]) {
}
else
{
engine.loadFromModule("linux", "Main");
trayApp->loadMainModule();
}
}
else