From 810a3c90e472bfdea0db936b8f1a8aaf0c3d0b7a Mon Sep 17 00:00:00 2001 From: Kavish Devar Date: Mon, 12 May 2025 16:50:26 +0530 Subject: [PATCH] android: add troubleshooter for easier log access --- android/app/src/main/AndroidManifest.xml | 11 + .../me/kavishdevar/librepods/MainActivity.kt | 4 + .../screens/AirPodsSettingsScreen.kt | 22 + .../librepods/screens/AppSettingsScreen.kt | 43 + .../screens/TroubleshootingScreen.kt | 1019 +++++++++++++++++ .../librepods/services/AirPodsService.kt | 7 +- .../librepods/utils/LogCollector.kt | 215 ++++ android/app/src/main/res/drawable/ic_save.xml | 10 + android/app/src/main/res/values/strings.xml | 5 + android/app/src/main/res/xml/file_paths.xml | 4 + 10 files changed, 1338 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/utils/LogCollector.kt create mode 100644 android/app/src/main/res/drawable/ic_save.xml create mode 100644 android/app/src/main/res/xml/file_paths.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9f0bd44..ced71e7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -31,6 +31,7 @@ + @@ -126,6 +127,16 @@ + + + + diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt index c97e74a..27bbd06 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt @@ -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) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt index f46c908..149e987 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt @@ -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)) + ) + ) + } } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt index 21043d6..ad0f8a6 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt @@ -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 diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt new file mode 100644 index 0000000..747ed32 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt @@ -0,0 +1,1019 @@ +/* + * 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 . + */ + +package me.kavishdevar.librepods.screens + +import android.content.Intent +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +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.Clear +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +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.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +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.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.utils.LogCollector +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun CustomIconButton( + onClick: () -> Unit, + content: @Composable () -> Unit +) { + Box( + modifier = Modifier + .clickable(onClick = onClick) + .padding(8.dp), + contentAlignment = Alignment.Center + ) { + content() + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) +@Composable +fun TroubleshootingScreen(navController: NavController) { + val context = LocalContext.current + val scrollState = rememberScrollState() + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val hazeState = remember { HazeState() } + val coroutineScope = rememberCoroutineScope() + + val logCollector = remember { LogCollector(context) } + val savedLogs = remember { mutableStateListOf() } + + var isCollectingLogs by remember { mutableStateOf(false) } + var showTroubleshootingSteps by remember { mutableStateOf(false) } + var currentStep by remember { mutableIntStateOf(0) } + var logContent by remember { mutableStateOf("") } + var selectedLogFile by remember { mutableStateOf(null) } + var showDeleteDialog by remember { mutableStateOf(false) } + var showDeleteAllDialog by remember { mutableStateOf(false) } + var isLoadingLogContent by remember { mutableStateOf(false) } + var logContentLoaded by remember { mutableStateOf(false) } + + LaunchedEffect(isCollectingLogs) { + while (isCollectingLogs) { + delay(250) + delay(250) + } + } + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) + var showBottomSheet by remember { mutableStateOf(false) } + + val sheetProgress by remember { + derivedStateOf { + if (!showBottomSheet) 0f else sheetState.targetValue.ordinal.toFloat() / 2f + } + } + + val contentScaleFactor by remember { + derivedStateOf { + 1.0f - (0.12f * sheetProgress) + } + } + + val contentScale by animateFloatAsState( + targetValue = contentScaleFactor, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ), + label = "contentScale" + ) + + val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black + val accentColor = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5) + val buttonBgColor = if (isSystemInDarkTheme()) Color(0xFF333333) else Color(0xFFDDDDDD) + + var instructionText by remember { mutableStateOf("") } + var isDarkTheme = isSystemInDarkTheme() + var mDensity by remember { mutableFloatStateOf(0f) } + + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + val logsDir = File(context.filesDir, "logs") + if (logsDir.exists()) { + savedLogs.clear() + savedLogs.addAll(logsDir.listFiles()?.filter { it.name.endsWith(".txt") } + ?.sortedByDescending { it.lastModified() } ?: emptyList()) + } + } + } + + val saveLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("text/plain") + ) { uri -> + if (uri != null) { + coroutineScope.launch(Dispatchers.IO) { + try { + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(logContent.toByteArray()) + } + withContext(Dispatchers.Main) { + Toast.makeText(context, "Log saved successfully", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText( + context, + "Failed to save log: ${e.localizedMessage}", + Toast.LENGTH_SHORT + ).show() + } + } + } + } + } + + LaunchedEffect(currentStep) { + instructionText = when (currentStep) { + 0 -> "First, let's ensure Xposed module is properly configured. Tap the button below to check Xposed scope settings." + 1 -> "Please put your AirPods in the case and close it, so they disconnect completely." + 2 -> "Preparing to collect logs... Please wait." + 3 -> "Now, open the AirPods case and connect your AirPods. Logs are being collected. Connection will be detected automatically, or you can manually stop logging when you're done." + 4 -> "Log collection complete! You can now save or share the logs." + else -> "" + } + } + + fun openLogBottomSheet(file: File) { + selectedLogFile = file + logContent = "" + isLoadingLogContent = false + logContentLoaded = false + showBottomSheet = true + } + + Box( + modifier = Modifier.fillMaxSize() + ) { + Scaffold( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = contentScale + scaleY = contentScale + transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0.3f) + }, + 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.troubleshooting), + fontFamily = FontFamily(Font(R.font.sf_pro)), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { + TextButton( + onClick = { + navController.popBackStack() + }, + shape = RoundedCornerShape(8.dp), + ) { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = "Back", + tint = accentColor, + modifier = Modifier.scale(1.5f) + ) + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.Transparent + ), + scrollBehavior = scrollBehavior + ) + }, + containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7), + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 16.dp) + .verticalScroll(scrollState) + .hazeSource(state = hazeState) + ) { + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.saved_logs).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)) + + if (savedLogs.isEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + backgroundColor, + RoundedCornerShape(14.dp) + ) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.no_logs_found), + fontSize = 16.sp, + color = textColor + ) + } + } else { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + backgroundColor, + RoundedCornerShape(14.dp) + ) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Total Logs: ${savedLogs.size}", + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = textColor + ) + + if (savedLogs.size > 1) { + TextButton( + onClick = { showDeleteAllDialog = true }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Delete All") + } + } + } + + savedLogs.forEach { logFile -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .clickable { + openLogBottomSheet(logFile) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = logFile.name, + fontSize = 16.sp, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US) + .format(Date(logFile.lastModified())), + fontSize = 14.sp, + color = textColor.copy(alpha = 0.6f) + ) + } + + CustomIconButton( + onClick = { + selectedLogFile = logFile + showDeleteDialog = true + } + ) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + AnimatedVisibility( + visible = !showTroubleshootingSteps, + enter = fadeIn(animationSpec = tween(300)), + exit = fadeOut(animationSpec = tween(300)) + ) { + Button( + onClick = { showTroubleshootingSteps = true }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = buttonBgColor, + contentColor = textColor + ), + enabled = !isCollectingLogs + ) { + Text(stringResource(R.string.collect_logs)) + } + } + + AnimatedVisibility( + visible = showTroubleshootingSteps, + enter = fadeIn(animationSpec = tween(300)) + + slideInVertically(animationSpec = tween(300)) { it / 2 }, + exit = fadeOut(animationSpec = tween(300)) + + slideOutVertically(animationSpec = tween(300)) { it / 2 } + ) { + Column { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "TROUBLESHOOTING STEPS".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() + .background( + backgroundColor, + RoundedCornerShape(14.dp) + ) + .padding(16.dp) + ) { + val textAlpha = animateFloatAsState( + targetValue = 1f, + animationSpec = tween(durationMillis = 300), + label = "textAlpha" + ) + + Text( + text = instructionText, + fontSize = 16.sp, + color = textColor.copy(alpha = textAlpha.value), + lineHeight = 22.sp + ) + + Spacer(modifier = Modifier.height(16.dp)) + + when (currentStep) { + 0 -> { + Button( + onClick = { + coroutineScope.launch { + logCollector.openXposedSettings(context) + delay(2000) + currentStep = 1 + } + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = buttonBgColor, + contentColor = textColor + ) + ) { + Text("Open Xposed Settings") + } + } + + 1 -> { + Button( + onClick = { + currentStep = 2 + isCollectingLogs = true + + coroutineScope.launch { + try { + logCollector.clearLogs() + + logCollector.addLogMarker(LogCollector.LogMarkerType.START) + + logCollector.killBluetoothService() + + withContext(Dispatchers.Main) { + delay(500) + currentStep = 3 + } + + val timestamp = SimpleDateFormat( + "yyyyMMdd_HHmmss", + Locale.US + ).format(Date()) + + logContent = + logCollector.startLogCollection( + listener = { /* Removed live log display */ }, + connectionDetectedCallback = { + launch { + delay(5000) + withContext(Dispatchers.Main) { + if (isCollectingLogs) { + logCollector.stopLogCollection() + currentStep = 4 + isCollectingLogs = + false + } + } + } + } + ) + + val logFile = + logCollector.saveLogToInternalStorage( + "airpods_log_$timestamp.txt", + logContent + ) + logFile?.let { + withContext(Dispatchers.Main) { + savedLogs.add(0, it) + selectedLogFile = it + Toast.makeText( + context, + "Log saved: ${it.name}", + Toast.LENGTH_SHORT + ).show() + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText( + context, + "Error collecting logs: ${e.message}", + Toast.LENGTH_SHORT + ).show() + isCollectingLogs = false + currentStep = 0 + } + } + } + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = buttonBgColor, + contentColor = textColor + ) + ) { + Text("Continue") + } + } + + 2, 3 -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator( + color = accentColor + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = if (currentStep == 2) "Preparing..." else "Collecting logs...", + fontSize = 14.sp, + color = textColor + ) + + if (currentStep == 3) { + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { + coroutineScope.launch { + logCollector.addLogMarker( + LogCollector.LogMarkerType.CUSTOM, + "Manual stop requested by user" + ) + delay(1000) + logCollector.stopLogCollection() + delay(500) + + withContext(Dispatchers.Main) { + currentStep = 4 + isCollectingLogs = false + Toast.makeText( + context, + "Log collection stopped", + Toast.LENGTH_SHORT + ).show() + } + } + }, + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = buttonBgColor, + contentColor = textColor + ), + modifier = Modifier + .fillMaxWidth() + ) { + Text("Stop Collection") + } + } + } + } + + 4 -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Button( + onClick = { + selectedLogFile?.let { file -> + val fileUri = FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + file + ) + val shareIntent = + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra( + Intent.EXTRA_STREAM, + fileUri + ) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity( + Intent.createChooser( + shareIntent, + "Share log file" + ) + ) + } + }, + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = buttonBgColor, + contentColor = textColor + ), + modifier = Modifier.width(150.dp) + ) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "Share" + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Share") + } + + Spacer(modifier = Modifier.width(16.dp)) + + Button( + onClick = { + selectedLogFile?.let { file -> + saveLauncher.launch("airpods_log_${System.currentTimeMillis()}.txt") + } + }, + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = buttonBgColor, + contentColor = textColor + ), + modifier = Modifier.width(150.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_save), + contentDescription = "Save" + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Save") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { + currentStep = 0 + showTroubleshootingSteps = false + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = buttonBgColor, + contentColor = textColor + ) + ) { + Text("Done") + } + } + } + } + } + } + + if (showDeleteDialog && selectedLogFile != null) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text("Delete Log File") }, + text = { + Text("Are you sure you want to delete this log file? This action cannot be undone.") + }, + confirmButton = { + TextButton( + onClick = { + selectedLogFile?.let { file -> + if (file.delete()) { + savedLogs.remove(file) + Toast.makeText( + context, + "Log file deleted", + Toast.LENGTH_SHORT + ) + .show() + } else { + Toast.makeText( + context, + "Failed to delete log file", + Toast.LENGTH_SHORT + ).show() + } + } + showDeleteDialog = false + } + ) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text("Cancel") + } + } + ) + } + + if (showDeleteAllDialog) { + AlertDialog( + onDismissRequest = { showDeleteAllDialog = false }, + title = { Text("Delete All Logs") }, + text = { + Text("Are you sure you want to delete all log files? This action cannot be undone and will remove ${savedLogs.size} log files.") + }, + confirmButton = { + TextButton( + onClick = { + coroutineScope.launch(Dispatchers.IO) { + var deletedCount = 0 + savedLogs.forEach { file -> + if (file.delete()) { + deletedCount++ + } + } + withContext(Dispatchers.Main) { + if (deletedCount > 0) { + savedLogs.clear() + Toast.makeText( + context, + "Deleted $deletedCount log files", + Toast.LENGTH_SHORT + ).show() + } else { + Toast.makeText( + context, + "Failed to delete log files", + Toast.LENGTH_SHORT + ).show() + } + } + } + showDeleteAllDialog = false + } + ) { + Text("Delete All", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteAllDialog = false }) { + Text("Cancel") + } + } + ) + } + } + } + + if (showBottomSheet) { + ModalBottomSheet( + onDismissRequest = { showBottomSheet = false }, + sheetState = sheetState, + containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7), + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + tonalElevation = 8.dp + ) { + LaunchedEffect(selectedLogFile) { + if (!logContentLoaded) { + delay(300) + withContext(Dispatchers.IO) { + isLoadingLogContent = true + logContent = try { + selectedLogFile?.readText() ?: "" + } catch (e: Exception) { + "Error loading log content: ${e.message}" + } + isLoadingLogContent = false + logContentLoaded = true + } + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .padding(bottom = 32.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + ) { + Text( + text = selectedLogFile?.name ?: "Log Content", + style = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + color = textColor + ) + Text( + text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US) + .format(Date(selectedLogFile?.lastModified() ?: 0)), + fontSize = 14.sp, + color = textColor.copy(alpha = 0.7f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + } + + if (isLoadingLogContent) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = accentColor) + } + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + .background( + color = Color.Black, + shape = RoundedCornerShape(8.dp) + ) + ) { + val horizontalScrollState = rememberScrollState() + val verticalScrollState = rememberScrollState() + + Box( + modifier = Modifier + .fillMaxSize() + .padding(8.dp) + .horizontalScroll(horizontalScrollState) + .verticalScroll(verticalScrollState) + ) { + Text( + text = logContent, + fontSize = 14.sp, + color = Color.LightGray, + lineHeight = 20.sp, + fontFamily = FontFamily.Monospace, + softWrap = false + ) + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = { + selectedLogFile?.let { file -> + val fileUri = FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + file + ) + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_STREAM, fileUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity( + Intent.createChooser( + shareIntent, + "Share log file" + ) + ) + } + }, + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = buttonBgColor, + contentColor = textColor + ), + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "Share" + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Share") + } + + Button( + onClick = { + selectedLogFile?.let { file -> + saveLauncher.launch("airpods_log_${System.currentTimeMillis()}.txt") + } + }, + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = buttonBgColor, + contentColor = textColor + ), + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_save), + contentDescription = "Save" + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Save") + } + } + } + } + } + } + + DisposableEffect(Unit) { + onDispose { + logCollector.stopLogCollection() + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index f87e297..e7fb971 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -804,6 +804,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList notificationManager.notify(1, updatedNotification) notificationManager.cancel(2) } else if (!socket.isConnected && isConnectedLocally) { + Log.d("AirPodsService", " 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", " 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", " Socket connected") } catch (e: Exception) { + Log.d("AirPodsService", " Socket not connected") showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?") throw e } } if (!socket.isConnected) { + Log.d("AirPodsService", " 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}") diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/LogCollector.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/LogCollector.kt new file mode 100644 index 0000000..8ce65ab --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/LogCollector.kt @@ -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 . + */ + +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 { + 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("")) { + connectionDetected = true + connectionDetectedCallback() + } else if (it.contains("")) { + connectionDetected = true + connectionDetectedCallback() + } else if (it.contains("")) { + } + 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 -> " [$timestamp] Beginning connection test" + LogMarkerType.SUCCESS -> " [$timestamp] Connection test completed successfully" + LogMarkerType.FAILURE -> " [$timestamp] Connection test failed" + LogMarkerType.CUSTOM -> " [$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() + "" + } + } + } +} diff --git a/android/app/src/main/res/drawable/ic_save.xml b/android/app/src/main/res/drawable/ic_save.xml new file mode 100644 index 0000000..c41d228 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_save.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index bdb949e..e46b8b2 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -59,4 +59,9 @@ Automatic Ear Detection Auto Play Auto Pause + Troubleshooting + Collect logs to diagnose issues with AirPods connection + Collect Logs + Saved Logs + No saved logs found diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..4558e63 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + +