android: improve debugging

This commit is contained in:
Kavish Devar
2025-04-08 08:36:08 +05:30
parent 33ba7a2f2d
commit 42f91c4c46
2 changed files with 272 additions and 58 deletions

View File

@@ -21,12 +21,18 @@
package me.kavishdevar.aln.screens package me.kavishdevar.aln.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.widget.Toast
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -43,10 +49,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -64,11 +74,13 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -83,10 +95,27 @@ import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.CupertinoMaterials import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineScope
import me.kavishdevar.aln.R import me.kavishdevar.aln.R
import me.kavishdevar.aln.services.ServiceManager import me.kavishdevar.aln.services.ServiceManager
import me.kavishdevar.aln.utils.BatteryStatus import me.kavishdevar.aln.utils.BatteryStatus
import me.kavishdevar.aln.utils.isHeadTrackingData import me.kavishdevar.aln.utils.isHeadTrackingData
import me.kavishdevar.aln.composables.StyledSwitch
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.imePadding
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.material.icons.filled.Check
import androidx.compose.ui.input.pointer.PointerInputChange
data class PacketInfo( data class PacketInfo(
val type: String, val type: String,
@@ -286,8 +315,31 @@ fun parseOutgoingPacket(bytes: ByteArray, rawData: String): PacketInfo {
} }
} }
@Composable
fun IOSCheckbox(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(24.dp)
.clickable { onCheckedChange(!checked) },
contentAlignment = Alignment.Center
) {
if (checked) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Checked",
tint = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5),
modifier = Modifier.size(20.dp)
)
}
}
}
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, ExperimentalFoundationApi::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "UnspecifiedRegisterReceiverFlag") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "UnspecifiedRegisterReceiverFlag")
@Composable @Composable
fun DebugScreen(navController: NavController) { fun DebugScreen(navController: NavController) {
@@ -295,13 +347,37 @@ fun DebugScreen(navController: NavController) {
val context = LocalContext.current val context = LocalContext.current
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scrollOffset by remember { derivedStateOf { listState.firstVisibleItemScrollOffset } } val scrollOffset by remember { derivedStateOf { listState.firstVisibleItemScrollOffset } }
val packetLogsFlow = remember { MutableStateFlow(emptySet<String>()) } val focusManager = LocalFocusManager.current
val coroutineScope = rememberCoroutineScope()
val showMenu = remember { mutableStateOf(false) }
val airPodsService = remember { ServiceManager.getService() }
val packetLogs = airPodsService?.packetLogsFlow?.collectAsState(emptySet())?.value ?: emptySet()
val shouldScrollToBottom = remember { mutableStateOf(true) }
val refreshTrigger = remember { mutableStateOf(0) }
LaunchedEffect(refreshTrigger.value) {
while(true) {
delay(1000)
refreshTrigger.value = refreshTrigger.value + 1
}
}
val expandedItems = remember { mutableStateOf(setOf<Int>()) } val expandedItems = remember { mutableStateOf(setOf<Int>()) }
LaunchedEffect(Unit) { fun copyToClipboard(text: String) {
ServiceManager.getService()?.packetLogsFlow?.collect { packetLogsFlow.value = it } val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Packet Data", text)
clipboard.setPrimaryClip(clip)
Toast.makeText(context, "Packet copied to clipboard", Toast.LENGTH_SHORT).show()
}
LaunchedEffect(packetLogs.size, refreshTrigger.value) {
if (shouldScrollToBottom.value && packetLogs.isNotEmpty()) {
listState.animateScrollToItem(packetLogs.size - 1)
}
} }
val packetLogs = packetLogsFlow.collectAsState(setOf()).value
Scaffold( Scaffold(
topBar = { topBar = {
@@ -309,9 +385,7 @@ fun DebugScreen(navController: NavController) {
title = { Text("Debug") }, title = { Text("Debug") },
navigationIcon = { navigationIcon = {
TextButton( TextButton(
onClick = { onClick = { navController.popBackStack() },
navController.popBackStack()
},
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
) { ) {
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
@@ -332,31 +406,105 @@ fun DebugScreen(navController: NavController) {
) )
} }
}, },
actions = {
Box {
IconButton(onClick = { showMenu.value = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "More Options",
tint = if (isSystemInDarkTheme()) Color.White else Color.Black
)
}
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
modifier = Modifier modifier = Modifier
.hazeChild( .width(250.dp)
.background(
if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7)
)
.padding(vertical = 4.dp)
) {
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
"Auto-scroll",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal
)
)
Spacer(modifier = Modifier.weight(1f))
IOSCheckbox(
checked = shouldScrollToBottom.value,
onCheckedChange = { shouldScrollToBottom.value = it }
)
}
},
onClick = {
shouldScrollToBottom.value = !shouldScrollToBottom.value
showMenu.value = false
}
)
HorizontalDivider(
color = if (isSystemInDarkTheme()) Color(0xFF3A3A3C) else Color(0xFFE5E5EA),
thickness = 0.5.dp
)
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
"Clear logs",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal
)
)
Spacer(modifier = Modifier.weight(1f))
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Clear logs",
tint = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5)
)
}
},
onClick = {
ServiceManager.getService()?.clearLogs()
expandedItems.value = emptySet()
showMenu.value = false
}
)
}
}
},
modifier = Modifier.hazeChild(
state = hazeState, state = hazeState,
style = CupertinoMaterials.thick(), style = CupertinoMaterials.thick(),
block = { block = {
alpha = if (scrollOffset > 0) { alpha = if (scrollOffset > 0) 1f else 0f
1f
} else {
0f
}
} }
), ),
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
containerColor = Color.Transparent
),
) )
}, },
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7),
else Color(0xFFF2F2F7),
) { paddingValues -> ) { paddingValues ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.haze(hazeState) .haze(hazeState)
.padding(top = paddingValues.calculateTopPadding()) .padding(top = paddingValues.calculateTopPadding())
.navigationBarsPadding()
) { ) {
LazyColumn( LazyColumn(
state = listState, state = listState,
@@ -374,13 +522,18 @@ fun DebugScreen(navController: NavController) {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 2.dp, horizontal = 4.dp) .padding(vertical = 2.dp, horizontal = 4.dp)
.clickable { .combinedClickable(
onClick = {
expandedItems.value = if (isExpanded) { expandedItems.value = if (isExpanded) {
expandedItems.value - index expandedItems.value - index
} else { } else {
expandedItems.value + index expandedItems.value + index
} }
}, },
onLongClick = {
copyToClipboard(packetInfo.rawData)
}
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
shape = RoundedCornerShape(4.dp), shape = RoundedCornerShape(4.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
@@ -476,8 +629,27 @@ fun DebugScreen(navController: NavController) {
trailingIcon = { trailingIcon = {
IconButton( IconButton(
onClick = { onClick = {
if (packet.value.text.isNotBlank()) {
airPodsService?.value?.sendPacket(packet.value.text) airPodsService?.value?.sendPacket(packet.value.text)
packet.value = TextFieldValue("") packet.value = TextFieldValue("")
focusManager.clearFocus()
if (shouldScrollToBottom.value && packetLogs.isNotEmpty()) {
coroutineScope.launch {
try {
delay(100)
listState.animateScrollToItem(
index = (packetLogs.size - 1).coerceAtLeast(0),
scrollOffset = 0
)
} catch (e: Exception) {
listState.scrollToItem(
index = (packetLogs.size - 1).coerceAtLeast(0)
)
}
}
}
}
} }
) { ) {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")

View File

@@ -136,30 +136,57 @@ class AirPodsService : Service() {
private lateinit var telephonyManager: TelephonyManager private lateinit var telephonyManager: TelephonyManager
private lateinit var phoneStateListener: PhoneStateListener private lateinit var phoneStateListener: PhoneStateListener
private val maxLogEntries = 1000
private val inMemoryLogs = mutableSetOf<String>()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
sharedPreferencesLogs = getSharedPreferences("packet_logs", MODE_PRIVATE) sharedPreferencesLogs = getSharedPreferences("packet_logs", MODE_PRIVATE)
inMemoryLogs.addAll(sharedPreferencesLogs.getStringSet(packetLogKey, emptySet()) ?: emptySet())
_packetLogsFlow.value = inMemoryLogs.toSet()
} }
private fun logPacket(packet: ByteArray, source: String) { private fun logPacket(packet: ByteArray, source: String) {
val packetHex = packet.joinToString(" ") { "%02X".format(it) } val packetHex = packet.joinToString(" ") { "%02X".format(it) }
val logEntry = "$source: $packetHex" val logEntry = "$source: $packetHex"
val logs =
sharedPreferencesLogs.getStringSet(packetLogKey, mutableSetOf())?.toMutableSet() synchronized(inMemoryLogs) {
inMemoryLogs.add(logEntry)
if (inMemoryLogs.size > maxLogEntries) {
inMemoryLogs.iterator().next()?.let {
inMemoryLogs.remove(it)
}
}
_packetLogsFlow.value = inMemoryLogs.toSet()
}
// Save to SharedPreferences less frequently - only needed for persistence between sessions
CoroutineScope(Dispatchers.IO).launch {
val logs = sharedPreferencesLogs.getStringSet(packetLogKey, mutableSetOf())?.toMutableSet()
?: mutableSetOf() ?: mutableSetOf()
logs.add(logEntry) logs.add(logEntry)
_packetLogsFlow.value = logs // Limit SharedPreferences size
if (logs.size > maxLogEntries) {
val toKeep = logs.toList().takeLast(maxLogEntries).toSet()
sharedPreferencesLogs.edit { putStringSet(packetLogKey, toKeep) }
} else {
sharedPreferencesLogs.edit { putStringSet(packetLogKey, logs) } sharedPreferencesLogs.edit { putStringSet(packetLogKey, logs) }
} }
}
}
fun getPacketLogs(): Set<String> { fun getPacketLogs(): Set<String> {
return sharedPreferencesLogs.getStringSet(packetLogKey, emptySet()) ?: emptySet() return inMemoryLogs.toSet()
} }
private fun clearPacketLogs() { private fun clearPacketLogs() {
sharedPreferencesLogs.edit { remove(packetLogKey).apply() } synchronized(inMemoryLogs) {
inMemoryLogs.clear()
_packetLogsFlow.value = emptySet()
}
sharedPreferencesLogs.edit { remove(packetLogKey) }
} }
fun clearLogs() { fun clearLogs() {
@@ -1237,6 +1264,9 @@ class AirPodsService : Service() {
fun sendPacket(packet: String) { fun sendPacket(packet: String) {
val fromHex = packet.split(" ").map { it.toInt(16).toByte() } val fromHex = packet.split(" ").map { it.toInt(16).toByte() }
try {
logPacket(fromHex.toByteArray(), "Sent")
if (!isConnectedLocally && CrossDevice.isAvailable) { if (!isConnectedLocally && CrossDevice.isAvailable) {
CrossDevice.sendRemotePacket(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + fromHex.toByteArray()) CrossDevice.sendRemotePacket(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + fromHex.toByteArray())
return return
@@ -1245,11 +1275,19 @@ class AirPodsService : Service() {
val byteArray = fromHex.toByteArray() val byteArray = fromHex.toByteArray()
socket.outputStream?.write(byteArray) socket.outputStream?.write(byteArray)
socket.outputStream?.flush() socket.outputStream?.flush()
logPacket(byteArray, "Sent") } else {
Log.d("AirPodsService", "Cannot send packet: Socket not initialized or connected")
}
} catch (e: Exception) {
Log.e("AirPodsService", "Error sending packet: ${e.message}")
} }
} }
fun sendPacket(packet: ByteArray) { fun sendPacket(packet: ByteArray) {
try {
// Always log the packet
logPacket(packet, "Sent")
if (!isConnectedLocally && CrossDevice.isAvailable) { if (!isConnectedLocally && CrossDevice.isAvailable) {
CrossDevice.sendRemotePacket(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet) CrossDevice.sendRemotePacket(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet)
return return
@@ -1257,7 +1295,11 @@ class AirPodsService : Service() {
if (this::socket.isInitialized && socket.isConnected && socket.outputStream != null) { if (this::socket.isInitialized && socket.isConnected && socket.outputStream != null) {
socket.outputStream?.write(packet) socket.outputStream?.write(packet)
socket.outputStream?.flush() socket.outputStream?.flush()
logPacket(packet, "Sent") } else {
Log.d("AirPodsService", "Cannot send packet: Socket not initialized or connected")
}
} catch (e: Exception) {
Log.e("AirPodsService", "Error sending packet: ${e.message}")
} }
} }