android: add troubleshooter for easier log access
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -414,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))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,6 +618,49 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
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 },
|
||||
modifier = Modifier
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -804,6 +804,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
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?")
|
||||
}
|
||||
}
|
||||
@@ -1423,6 +1424,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
@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")
|
||||
if (isConnectedLocally != true && !CrossDevice.isAvailable) {
|
||||
@@ -1446,12 +1448,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
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?")
|
||||
}
|
||||
}
|
||||
@@ -1779,7 +1784,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
socket.outputStream?.flush()
|
||||
} else {
|
||||
Log.d("AirPodsService", "Can't send packet: Socket not initialized or connected")
|
||||
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("AirPodsService", "Error sending packet: ${e.message}")
|
||||
@@ -1799,7 +1803,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
socket.outputStream?.flush()
|
||||
} else {
|
||||
Log.d("AirPodsService", "Can't send packet: Socket not initialized or connected")
|
||||
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("AirPodsService", "Error sending packet: ${e.message}")
|
||||
|
||||
@@ -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()
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user