Skip to content

Commit 90836b5

Browse files
feat: Add setting menu to save patched app crash logs to file (#143)
2 parents 67a7595 + 05a5c59 commit 90836b5

2 files changed

Lines changed: 249 additions & 0 deletions

File tree

src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,11 @@ import app.morphe.gui.ui.theme.LocalMorpheFont
4040
import app.morphe.gui.ui.theme.LocalMorpheCorners
4141
import app.morphe.gui.ui.theme.MorpheColors
4242
import app.morphe.gui.ui.theme.ThemePreference
43+
import app.morphe.gui.util.AdbManager
44+
import app.morphe.gui.util.DeviceMonitor
4345
import app.morphe.gui.util.FileUtils
4446
import app.morphe.gui.util.Logger
47+
import kotlinx.coroutines.launch
4548
import app.morphe.patcher.apk.ApkSigner
4649
import java.awt.Desktop
4750
import java.awt.FileDialog
@@ -280,6 +283,18 @@ fun SettingsDialog(
280283

281284
SettingsDivider(borderColor)
282285

286+
// ── Patched App Runtime Logs ──
287+
PatchedAppRuntimeLogsSection(
288+
mono = mono,
289+
accentColor = accents.primary,
290+
borderColor = borderColor,
291+
enabled = !isPatching,
292+
expanded = collapsibleSectionStates["RUNTIME LOGS"] == true,
293+
onExpandedChange = { onCollapsibleSectionToggle("RUNTIME LOGS", it) }
294+
)
295+
296+
SettingsDivider(borderColor)
297+
283298
// ── Patch Sources ──
284299
PatchSourcesSection(
285300
sources = patchSources,
@@ -3118,3 +3133,160 @@ private fun resolveGitHubUrl(input: String): String? {
31183133

31193134
return null
31203135
}
3136+
3137+
// ── Patched App Runtime Logs Section ──
3138+
3139+
private sealed interface RuntimeLogsStatus {
3140+
data object Idle : RuntimeLogsStatus
3141+
data object Clearing : RuntimeLogsStatus
3142+
data object Saving : RuntimeLogsStatus
3143+
data object Cleared : RuntimeLogsStatus
3144+
data class Saved(val file: File, val lineCount: Int) : RuntimeLogsStatus
3145+
data class Error(val message: String) : RuntimeLogsStatus
3146+
}
3147+
3148+
@Composable
3149+
private fun PatchedAppRuntimeLogsSection(
3150+
mono: androidx.compose.ui.text.font.FontFamily,
3151+
accentColor: Color,
3152+
borderColor: Color,
3153+
enabled: Boolean = true,
3154+
expanded: Boolean = false,
3155+
onExpandedChange: (Boolean) -> Unit = {}
3156+
) {
3157+
val monitorState by DeviceMonitor.state.collectAsState()
3158+
val selectedDevice = monitorState.selectedDevice
3159+
val scope = rememberCoroutineScope()
3160+
val adbManager = remember { AdbManager() }
3161+
var status by remember { mutableStateOf<RuntimeLogsStatus>(RuntimeLogsStatus.Idle) }
3162+
3163+
val isWorking = status is RuntimeLogsStatus.Clearing || status is RuntimeLogsStatus.Saving
3164+
val deviceReady = selectedDevice?.isReady == true
3165+
val canAct = enabled && deviceReady && !isWorking
3166+
3167+
CollapsibleSection(
3168+
title = "PATCHED APP RUNTIME LOGS",
3169+
mono = mono,
3170+
expanded = expanded,
3171+
onExpandedChange = onExpandedChange
3172+
) {
3173+
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
3174+
Text(
3175+
text = "Capture logs from your phone after a patched app crashes or misbehaves. Clear before reproducing the bug, then save the filtered output to attach to a bug report.",
3176+
fontSize = 11.sp,
3177+
fontFamily = mono,
3178+
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
3179+
)
3180+
3181+
// Device row
3182+
if (deviceReady) {
3183+
Text(
3184+
text = "Device: ${selectedDevice.displayName}${selectedDevice.architecture?.let { " ($it)" } ?: ""}",
3185+
fontSize = 11.sp,
3186+
fontFamily = mono,
3187+
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
3188+
)
3189+
} else {
3190+
Text(
3191+
text = "No device connected. Plug in your phone with USB debugging enabled.",
3192+
fontSize = 11.sp,
3193+
fontFamily = mono,
3194+
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
3195+
)
3196+
}
3197+
3198+
ActionButton(
3199+
label = if (status is RuntimeLogsStatus.Clearing) "CLEARING…" else "CLEAR DEVICE LOGS",
3200+
icon = Icons.Default.DeleteSweep,
3201+
mono = mono,
3202+
borderColor = borderColor,
3203+
enabled = canAct,
3204+
onClick = {
3205+
val device = selectedDevice ?: return@ActionButton
3206+
status = RuntimeLogsStatus.Clearing
3207+
scope.launch {
3208+
val result = adbManager.clearLogcat(device.id)
3209+
status = result.fold(
3210+
onSuccess = { RuntimeLogsStatus.Cleared },
3211+
onFailure = { RuntimeLogsStatus.Error(it.message ?: "Failed to clear logs") }
3212+
)
3213+
}
3214+
}
3215+
)
3216+
3217+
ActionButton(
3218+
label = if (status is RuntimeLogsStatus.Saving) "SAVING…" else "SAVE DEVICE LOGS",
3219+
icon = Icons.Default.Save,
3220+
mono = mono,
3221+
borderColor = borderColor,
3222+
contentColor = accentColor,
3223+
enabled = canAct,
3224+
onClick = {
3225+
val device = selectedDevice ?: return@ActionButton
3226+
status = RuntimeLogsStatus.Saving
3227+
scope.launch {
3228+
val timestamp = SimpleDateFormat("yyyy-MM-dd-HHmmss", java.util.Locale.US).format(java.util.Date())
3229+
val outFile = File(FileUtils.getLogsDir(), "device-logcat-$timestamp.txt")
3230+
val result = adbManager.captureLogcat(device.id, outFile)
3231+
status = result.fold(
3232+
onSuccess = { count -> RuntimeLogsStatus.Saved(outFile, count) },
3233+
onFailure = { RuntimeLogsStatus.Error(it.message ?: "Failed to save logs") }
3234+
)
3235+
}
3236+
}
3237+
)
3238+
3239+
// Status line
3240+
when (val s = status) {
3241+
RuntimeLogsStatus.Idle, RuntimeLogsStatus.Clearing, RuntimeLogsStatus.Saving -> Unit
3242+
RuntimeLogsStatus.Cleared -> Text(
3243+
text = "Logs cleared on device.",
3244+
fontSize = 11.sp,
3245+
fontFamily = mono,
3246+
color = accentColor.copy(alpha = 0.85f)
3247+
)
3248+
is RuntimeLogsStatus.Saved -> Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
3249+
Text(
3250+
text = if (s.lineCount == 0)
3251+
"Nothing captured yet. Run the patched app on your phone, then save again."
3252+
else
3253+
"Saved ${s.lineCount} line(s) to ${s.file.name}",
3254+
fontSize = 11.sp,
3255+
fontFamily = mono,
3256+
color = if (s.lineCount == 0) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
3257+
else accentColor.copy(alpha = 0.85f)
3258+
)
3259+
if (s.lineCount > 0) {
3260+
val cornersLocal = LocalMorpheCorners.current
3261+
Text(
3262+
text = "OPEN LOGS",
3263+
fontSize = 10.sp,
3264+
fontFamily = mono,
3265+
fontWeight = FontWeight.SemiBold,
3266+
letterSpacing = 0.5.sp,
3267+
color = accentColor,
3268+
modifier = Modifier
3269+
.clip(RoundedCornerShape(cornersLocal.small))
3270+
.clickable {
3271+
try {
3272+
if (Desktop.isDesktopSupported()) {
3273+
Desktop.getDesktop().open(s.file.parentFile)
3274+
}
3275+
} catch (e: Exception) {
3276+
Logger.error("Failed to reveal logs folder", e)
3277+
}
3278+
}
3279+
.padding(horizontal = 10.dp, vertical = 6.dp)
3280+
)
3281+
}
3282+
}
3283+
is RuntimeLogsStatus.Error -> Text(
3284+
text = s.message,
3285+
fontSize = 11.sp,
3286+
fontFamily = mono,
3287+
color = MaterialTheme.colorScheme.error
3288+
)
3289+
}
3290+
}
3291+
}
3292+
}

src/main/kotlin/app/morphe/gui/util/AdbManager.kt

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,83 @@ class AdbManager {
237237
}
238238
}
239239

240+
/**
241+
* Clear the device's logcat buffers (main + crash).
242+
* Crash buffer clear is best-effort — older devices may not have it.
243+
*/
244+
suspend fun clearLogcat(deviceId: String): Result<Unit> = withContext(Dispatchers.IO) {
245+
val adb = findAdb() ?: return@withContext Result.failure(
246+
AdbException("ADB not found. Please install Android SDK Platform Tools.")
247+
)
248+
249+
try {
250+
val main = ProcessBuilder(adb, "-s", deviceId, "logcat", "-c")
251+
.redirectErrorStream(true)
252+
.start()
253+
val mainOutput = main.inputStream.bufferedReader().readText()
254+
if (main.waitFor() != 0) {
255+
return@withContext Result.failure(AdbException("Failed to clear logs: $mainOutput"))
256+
}
257+
258+
// Best-effort: also clear the crash buffer. Ignore failure.
259+
try {
260+
val crash = ProcessBuilder(adb, "-s", deviceId, "logcat", "-b", "crash", "-c")
261+
.redirectErrorStream(true)
262+
.start()
263+
crash.inputStream.bufferedReader().readText()
264+
crash.waitFor()
265+
} catch (_: Exception) { /* older devices may not have crash buffer */ }
266+
267+
Logger.info("Cleared logcat on $deviceId")
268+
Result.success(Unit)
269+
} catch (e: Exception) {
270+
Logger.error("Error clearing logcat", e)
271+
Result.failure(AdbException("Failed to clear logs: ${e.message}"))
272+
}
273+
}
274+
275+
/**
276+
* Capture a logcat snapshot from the device, filtered to lines that contain
277+
* "morphe:" or "AndroidRuntime", and write them to [outputFile].
278+
* Returns the number of lines written.
279+
*/
280+
suspend fun captureLogcat(deviceId: String, outputFile: File): Result<Int> = withContext(Dispatchers.IO) {
281+
val adb = findAdb() ?: return@withContext Result.failure(
282+
AdbException("ADB not found. Please install Android SDK Platform Tools.")
283+
)
284+
285+
try {
286+
val process = ProcessBuilder(adb, "-s", deviceId, "logcat", "-d", "-b", "main,crash")
287+
.redirectErrorStream(true)
288+
.start()
289+
290+
val kept = mutableListOf<String>()
291+
process.inputStream.bufferedReader().useLines { lines ->
292+
lines.forEach { line ->
293+
if (line.contains("morphe:", ignoreCase = true) || line.contains("AndroidRuntime")) {
294+
kept += line
295+
}
296+
}
297+
}
298+
val exitCode = process.waitFor()
299+
if (exitCode != 0) {
300+
return@withContext Result.failure(AdbException("logcat exited with code $exitCode"))
301+
}
302+
303+
if (kept.isEmpty()) {
304+
Logger.info("No matching logcat lines on $deviceId — skipping file write")
305+
} else {
306+
outputFile.parentFile?.mkdirs()
307+
outputFile.writeText(kept.joinToString("\n") + "\n")
308+
Logger.info("Captured ${kept.size} logcat line(s) to ${outputFile.absolutePath}")
309+
}
310+
Result.success(kept.size)
311+
} catch (e: Exception) {
312+
Logger.error("Error capturing logcat", e)
313+
Result.failure(AdbException("Failed to capture logs: ${e.message}"))
314+
}
315+
}
316+
240317
/**
241318
* Parse output from 'adb devices -l' command.
242319
* Example line: "XXXXXXXX device usb:1-1 product:flame model:Pixel_4 device:flame transport_id:1"

0 commit comments

Comments
 (0)