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 @@
+
+
+
+