diff --git a/app/src/main/java/com/geeksville/mesh/ui/debug/Debug.kt b/app/src/main/java/com/geeksville/mesh/ui/debug/Debug.kt index 62ecd8d99..1577b30dd 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/debug/Debug.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/debug/Debug.kt @@ -250,12 +250,6 @@ private fun DebugItemHeader(log: UiMeshLog, searchText: String, isSelected: Bool } } CopyIconButton(valueToCopy = fullLogText, modifier = Modifier.padding(start = 8.dp)) - Icon( - imageVector = Icons.Outlined.FileDownload, - contentDescription = stringResource(id = R.string.logs), - tint = Color.Gray.copy(alpha = 0.6f), - modifier = Modifier.padding(end = 8.dp), - ) val dateAnnotatedString = rememberAnnotatedString(text = log.formattedReceivedDate, searchText = searchText) Text( text = dateAnnotatedString, @@ -315,6 +309,131 @@ private fun rememberAnnotatedLogMessage(log: UiMeshLog, searchText: String): Ann } } +@Composable +fun DebugMenuActions(viewModel: DebugViewModel = hiltViewModel(), modifier: Modifier = Modifier) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val logs by viewModel.meshLog.collectAsStateWithLifecycle() + var showDeleteLogsDialog by remember { mutableStateOf(false) } + + IconButton(onClick = { scope.launch { exportAllLogs(context, logs) } }, modifier = modifier.padding(4.dp)) { + Icon( + imageVector = Icons.Outlined.FileDownload, + contentDescription = stringResource(id = R.string.debug_logs_export), + ) + } + IconButton(onClick = { showDeleteLogsDialog = true }, modifier = modifier.padding(4.dp)) { + Icon(imageVector = Icons.Default.Delete, contentDescription = stringResource(id = R.string.debug_clear)) + } + if (showDeleteLogsDialog) { + SimpleAlertDialog( + title = R.string.debug_clear, + text = R.string.debug_clear_logs_confirm, + onConfirm = { + showDeleteLogsDialog = false + viewModel.deleteAllLogs() + }, + onDismiss = { showDeleteLogsDialog = false }, + ) + } +} + +private suspend fun exportAllLogs(context: Context, logs: List) = withContext(Dispatchers.IO) { + try { + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val fileName = "meshtastic_debug_$timestamp.txt" + + // Get the Downloads directory + val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + val logFile = File(downloadsDir, fileName) + + // Create the file and write logs + OutputStreamWriter(FileOutputStream(logFile), StandardCharsets.UTF_8).use { writer -> + logs.forEach { log -> + writer.write("${log.formattedReceivedDate} [${log.messageType}]\n") + writer.write(log.logMessage) + if (!log.decodedPayload.isNullOrBlank()) { + writer.write("\n\nDecoded Payload:\n{") + writer.write("\n") + writer.write(log.decodedPayload) + writer.write("\n}") + } + writer.write("\n\n") + } + } + + // Notify user of success + withContext(Dispatchers.Main) { + Toast.makeText(context, "Logs exported to ${logFile.absolutePath}", Toast.LENGTH_LONG).show() + } + } catch (e: SecurityException) { + withContext(Dispatchers.Main) { + Toast.makeText(context, "Permission denied: Cannot write to Downloads folder", Toast.LENGTH_LONG).show() + warn("Error:SecurityException: " + e.toString()) + } + } catch (e: IOException) { + withContext(Dispatchers.Main) { + Toast.makeText(context, "Failed to write log file: ${e.message}", Toast.LENGTH_LONG).show() + } + warn("Error:IOException: " + e.toString()) + } +} + +@Composable +private fun DecodedPayloadBlock( + decodedPayload: String, + isSelected: Boolean, + colorScheme: ColorScheme, + searchText: String = "", + modifier: Modifier = Modifier, +) { + val commonTextStyle = + TextStyle(fontSize = if (isSelected) 10.sp else 8.sp, fontWeight = FontWeight.Bold, color = colorScheme.primary) + + Column(modifier = modifier) { + Text( + text = stringResource(id = R.string.debug_decoded_payload), + style = commonTextStyle, + modifier = Modifier.padding(top = 8.dp, bottom = 4.dp), + ) + Text(text = "{", style = commonTextStyle, modifier = Modifier.padding(start = 8.dp, bottom = 2.dp)) + val annotatedPayload = rememberAnnotatedDecodedPayload(decodedPayload, searchText, colorScheme) + Text( + text = annotatedPayload, + softWrap = true, + style = + TextStyle( + fontSize = if (isSelected) 10.sp else 8.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onSurface.copy(alpha = 0.8f), + ), + modifier = Modifier.padding(start = 16.dp, bottom = 0.dp), + ) + Text(text = "}", style = commonTextStyle, modifier = Modifier.padding(start = 8.dp, bottom = 4.dp)) + } +} + +@Composable +private fun rememberAnnotatedDecodedPayload( + decodedPayload: String, + searchText: String, + colorScheme: ColorScheme, +): AnnotatedString { + val highlightStyle = SpanStyle(background = colorScheme.primary.copy(alpha = 0.3f), color = colorScheme.onSurface) + return remember(decodedPayload, searchText) { + buildAnnotatedString { + append(decodedPayload) + if (searchText.isNotEmpty()) { + searchText.split(" ").forEach { term -> + Regex(Regex.escape(term), RegexOption.IGNORE_CASE).findAll(decodedPayload).forEach { match -> + addStyle(style = highlightStyle, start = match.range.first, end = match.range.last + 1) + } + } + } + } + } +} + @PreviewLightDark @Composable private fun DebugPacketPreview() { @@ -592,128 +711,3 @@ private fun DebugScreenWithSampleDataPreview() { } } } - -@Composable -fun DebugMenuActions(viewModel: DebugViewModel = hiltViewModel(), modifier: Modifier = Modifier) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val logs by viewModel.meshLog.collectAsStateWithLifecycle() - var showDeleteLogsDialog by remember { mutableStateOf(false) } - - IconButton(onClick = { scope.launch { exportAllLogs(context, logs) } }, modifier = modifier.padding(4.dp)) { - Icon( - imageVector = Icons.Outlined.FileDownload, - contentDescription = stringResource(id = R.string.debug_logs_export), - ) - } - IconButton(onClick = { showDeleteLogsDialog = true }, modifier = modifier.padding(4.dp)) { - Icon(imageVector = Icons.Default.Delete, contentDescription = stringResource(id = R.string.debug_clear)) - } - if (showDeleteLogsDialog) { - SimpleAlertDialog( - title = R.string.debug_clear, - text = R.string.debug_clear_logs_confirm, - onConfirm = { - showDeleteLogsDialog = false - viewModel.deleteAllLogs() - }, - onDismiss = { showDeleteLogsDialog = false }, - ) - } -} - -private suspend fun exportAllLogs(context: Context, logs: List) = withContext(Dispatchers.IO) { - try { - val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) - val fileName = "meshtastic_debug_$timestamp.txt" - - // Get the Downloads directory - val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - val logFile = File(downloadsDir, fileName) - - // Create the file and write logs - OutputStreamWriter(FileOutputStream(logFile), StandardCharsets.UTF_8).use { writer -> - logs.forEach { log -> - writer.write("${log.formattedReceivedDate} [${log.messageType}]\n") - writer.write(log.logMessage) - if (!log.decodedPayload.isNullOrBlank()) { - writer.write("\n\nDecoded Payload:\n{") - writer.write("\n") - writer.write(log.decodedPayload) - writer.write("\n}") - } - writer.write("\n\n") - } - } - - // Notify user of success - withContext(Dispatchers.Main) { - Toast.makeText(context, "Logs exported to ${logFile.absolutePath}", Toast.LENGTH_LONG).show() - } - } catch (e: SecurityException) { - withContext(Dispatchers.Main) { - Toast.makeText(context, "Permission denied: Cannot write to Downloads folder", Toast.LENGTH_LONG).show() - warn("Error:SecurityException: " + e.toString()) - } - } catch (e: IOException) { - withContext(Dispatchers.Main) { - Toast.makeText(context, "Failed to write log file: ${e.message}", Toast.LENGTH_LONG).show() - } - warn("Error:IOException: " + e.toString()) - } -} - -@Composable -private fun DecodedPayloadBlock( - decodedPayload: String, - isSelected: Boolean, - colorScheme: ColorScheme, - searchText: String = "", - modifier: Modifier = Modifier, -) { - val commonTextStyle = - TextStyle(fontSize = if (isSelected) 10.sp else 8.sp, fontWeight = FontWeight.Bold, color = colorScheme.primary) - - Column(modifier = modifier) { - Text( - text = stringResource(id = R.string.debug_decoded_payload), - style = commonTextStyle, - modifier = Modifier.padding(top = 8.dp, bottom = 4.dp), - ) - Text(text = "{", style = commonTextStyle, modifier = Modifier.padding(start = 8.dp, bottom = 2.dp)) - val annotatedPayload = rememberAnnotatedDecodedPayload(decodedPayload, searchText, colorScheme) - Text( - text = annotatedPayload, - softWrap = true, - style = - TextStyle( - fontSize = if (isSelected) 10.sp else 8.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.8f), - ), - modifier = Modifier.padding(start = 16.dp, bottom = 0.dp), - ) - Text(text = "}", style = commonTextStyle, modifier = Modifier.padding(start = 8.dp, bottom = 4.dp)) - } -} - -@Composable -private fun rememberAnnotatedDecodedPayload( - decodedPayload: String, - searchText: String, - colorScheme: ColorScheme, -): AnnotatedString { - val highlightStyle = SpanStyle(background = colorScheme.primary.copy(alpha = 0.3f), color = colorScheme.onSurface) - return remember(decodedPayload, searchText) { - buildAnnotatedString { - append(decodedPayload) - if (searchText.isNotEmpty()) { - searchText.split(" ").forEach { term -> - Regex(Regex.escape(term), RegexOption.IGNORE_CASE).findAll(decodedPayload).forEach { match -> - addStyle(style = highlightStyle, start = match.range.first, end = match.range.last + 1) - } - } - } - } - } -}