refactor(support): Unify debug log session model and add session manager bottom sheet

Introduce DebugSession sealed interface as single source of truth for session lifecycle (Recording, Compressing, Ready, Failed). Auto-compress after recording stops. Replace scattered clear/delete actions with a session manager bottom sheet on the Support screen.
This commit is contained in:
darken
2026-03-09 04:53:01 +00:00
committed by Matthias Urhahn
parent 06e5954ef5
commit 7df74e3b44
10 changed files with 745 additions and 175 deletions
@@ -19,7 +19,14 @@ class DebugLogZipper @Inject constructor(
?: throw IllegalStateException("No log files in $logDir")
val zipFile = File(logDir.parentFile, "${logDir.name}.zip")
Zipper().zip(logFiles.map { it.path }.toTypedArray(), zipFile.path)
val tempFile = File(logDir.parentFile, "${logDir.name}.zip.tmp")
try {
Zipper().zip(logFiles.map { it.path }.toTypedArray(), tempFile.path)
tempFile.renameTo(zipFile)
} catch (e: Exception) {
tempFile.delete()
throw e
}
return FileProvider.getUriForFile(
context,
@@ -0,0 +1,46 @@
package eu.darken.capod.common.debug.recording.core
import java.io.File
sealed interface DebugSession {
val id: String
val displayName: String
val createdAt: Long
val diskSize: Long
data class Recording(
override val id: String,
override val displayName: String,
override val createdAt: Long,
override val diskSize: Long,
val path: File,
val startedAt: Long,
) : DebugSession
data class Compressing(
override val id: String,
override val displayName: String,
override val createdAt: Long,
override val diskSize: Long,
val path: File,
) : DebugSession
data class Ready(
override val id: String,
override val displayName: String,
override val createdAt: Long,
override val diskSize: Long,
val logDir: File?,
) : DebugSession
data class Failed(
override val id: String,
override val displayName: String,
override val createdAt: Long,
override val diskSize: Long,
val path: File,
val reason: Reason,
) : DebugSession {
enum class Reason { EMPTY_LOG, MISSING_LOG, CORRUPT_ZIP }
}
}
@@ -2,11 +2,13 @@ package eu.darken.capod.common.debug.recording.core
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.InstallId
import eu.darken.capod.common.compression.Zipper
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
@@ -22,7 +24,10 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@@ -33,8 +38,12 @@ class RecorderModule @Inject constructor(
@AppScope private val appScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
private val installId: InstallId,
private val debugLogZipper: DebugLogZipper,
) {
private val fsMutex = Mutex()
@Volatile private var compressingSessionId: String? = null
private val triggerFile = try {
File(context.getExternalFilesDir(null), FORCE_FILE)
} catch (e: Exception) {
@@ -79,14 +88,24 @@ class RecorderModule @Inject constructor(
}
val logDir = currentLogDir!!
val sessionId = deriveSessionId(logDir)
compressingSessionId = sessionId
sessionsState.updateBlocking {
scanSessions(activeDir = null, recordingStartedAt = 0L)
}
if (showResultUi) {
val intent = RecorderActivity.getLaunchIntent(context, logDir.path).apply {
val intent = RecorderActivity.getLaunchIntent(context, sessionId, logDir.path).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
appScope.launch(dispatcherProvider.IO) {
autoCompress(sessionId)
}
copy(
recorder = null,
currentLogDir = null,
@@ -124,7 +143,7 @@ class RecorderModule @Inject constructor(
return sessionDir
}
fun getLogDirectories(): List<File> = listOfNotNull(
private fun getLogDirectories(): List<File> = listOfNotNull(
try {
context.getExternalFilesDir(null)?.let { File(it, "debug/logs") }
} catch (e: Exception) {
@@ -133,27 +152,264 @@ class RecorderModule @Inject constructor(
File(context.cacheDir, "debug/logs"),
)
fun getLogSessionCount(): Int {
return getLogDirectories().sumOf { dir ->
if (!dir.exists()) return@sumOf 0
dir.listFiles()?.count { it.isDirectory || (it.isFile && it.extension == "zip") } ?: 0
}
private val sessionsState = DynamicStateFlow(TAG, appScope + dispatcherProvider.IO) {
val recState = internalState.value()
scanSessions(activeDir = recState.currentLogDir, recordingStartedAt = recState.recordingStartedAt)
}
val sessions: Flow<List<DebugSession>> = sessionsState.flow
private fun deriveSessionId(file: File): String {
val prefix = if (file.absolutePath.contains("/cache/")) "cache:" else "ext:"
return prefix + file.name.removeSuffix(".zip")
}
fun getLogFolderSize(): Long {
return getLogDirectories().sumOf { dir ->
if (!dir.exists()) return@sumOf 0L
dir.listFiles()?.sumOf { entry ->
if (entry.isDirectory) {
entry.walkTopDown().filter { it.isFile }.sumOf { it.length() }
} else {
entry.length()
private fun parseCreatedAt(dirName: String, fallback: Long): Long {
// Pattern: capod_{version}_{timestamp}_{installId}
// Parse from right: installId is last segment, timestamp is second-to-last
val parts = dirName.removeSuffix(".zip").split("_")
if (parts.size >= 4) {
// timestamp is second-to-last part
val timestamp = parts[parts.size - 2].toLongOrNull()
if (timestamp != null && timestamp > 1_000_000_000_000L) return timestamp
}
return fallback
}
private fun scanSessions(
activeDir: File? = null,
recordingStartedAt: Long = 0L,
): List<DebugSession> {
data class RawEntry(val dir: File?, val zip: File?, val parentDir: File)
val entriesByBaseName = mutableMapOf<String, RawEntry>()
for (logParent in getLogDirectories()) {
if (!logParent.exists()) continue
val files = logParent.listFiles() ?: continue
for (file in files) {
val baseName = file.name.removeSuffix(".zip")
val key = logParent.absolutePath + "/" + baseName
val existing = entriesByBaseName[key]
if (file.isDirectory) {
entriesByBaseName[key] = (existing ?: RawEntry(null, null, logParent)).copy(dir = file)
} else if (file.isFile && file.extension == "zip") {
entriesByBaseName[key] = (existing ?: RawEntry(null, null, logParent)).copy(zip = file)
}
} ?: 0L
}
}
return entriesByBaseName.map { (key, raw) ->
val baseName = key.substringAfterLast("/")
val prefix = if (key.contains("/cache/")) "cache:" else "ext:"
val id = prefix + baseName
val fallbackTime = (raw.dir ?: raw.zip)?.lastModified() ?: 0L
val createdAt = parseCreatedAt(baseName, fallbackTime)
val dir = raw.dir
val zip = raw.zip
// Is this the currently recording session?
if (dir != null && dir == activeDir) {
val dirSize = dir.walkTopDown().filter { it.isFile }.sumOf { it.length() }
return@map DebugSession.Recording(
id = id,
displayName = baseName,
createdAt = createdAt,
diskSize = dirSize,
path = dir,
startedAt = recordingStartedAt,
)
}
// Check dir validity
if (dir != null) {
val coreLog = File(dir, "core.log")
val dirSize = dir.walkTopDown().filter { it.isFile }.sumOf { it.length() }
if (!coreLog.exists()) {
// Missing core.log — check if sibling zip is valid
if (zip != null && zip.exists() && zip.length() > 0) {
return@map DebugSession.Ready(
id = id,
displayName = baseName,
createdAt = createdAt,
diskSize = zip.length(),
logDir = null,
)
}
return@map DebugSession.Failed(
id = id,
displayName = baseName,
createdAt = createdAt,
diskSize = dirSize,
path = dir,
reason = DebugSession.Failed.Reason.MISSING_LOG,
)
}
if (coreLog.length() == 0L) {
if (zip != null && zip.exists() && zip.length() > 0) {
return@map DebugSession.Ready(
id = id,
displayName = baseName,
createdAt = createdAt,
diskSize = zip.length(),
logDir = null,
)
}
return@map DebugSession.Failed(
id = id,
displayName = baseName,
createdAt = createdAt,
diskSize = dirSize,
path = dir,
reason = DebugSession.Failed.Reason.EMPTY_LOG,
)
}
// Valid dir with core.log
val zipSize = if (zip != null && zip.exists()) zip.length() else 0L
val totalDiskSize = dirSize + zipSize
return@map DebugSession.Ready(
id = id,
displayName = baseName,
createdAt = createdAt,
diskSize = totalDiskSize,
logDir = dir,
)
}
// Standalone zip only
if (zip != null && zip.exists()) {
if (zip.length() == 0L) {
return@map DebugSession.Failed(
id = id,
displayName = baseName,
createdAt = createdAt,
diskSize = 0L,
path = zip,
reason = DebugSession.Failed.Reason.CORRUPT_ZIP,
)
}
return@map DebugSession.Ready(
id = id,
displayName = baseName,
createdAt = createdAt,
diskSize = zip.length(),
logDir = null,
)
}
// Should not happen, but handle defensively
DebugSession.Failed(
id = id,
displayName = baseName,
createdAt = createdAt,
diskSize = 0L,
path = File(key),
reason = DebugSession.Failed.Reason.MISSING_LOG,
)
}.map { session ->
if (session is DebugSession.Ready && session.id == compressingSessionId) {
DebugSession.Compressing(
id = session.id,
displayName = session.displayName,
createdAt = session.createdAt,
diskSize = session.diskSize,
path = session.logDir ?: File(""),
)
} else {
session
}
}.sortedByDescending { it.createdAt }
}
suspend fun refreshSessions() {
val recState = internalState.value()
sessionsState.updateBlocking {
scanSessions(activeDir = recState.currentLogDir, recordingStartedAt = recState.recordingStartedAt)
}
}
suspend fun deleteAllLogs() {
private fun findSessionFiles(sessionId: String): Pair<File?, File?> {
val baseName = sessionId.removePrefix("ext:").removePrefix("cache:")
for (logParent in getLogDirectories()) {
val dir = File(logParent, baseName)
val zip = File(logParent, "$baseName.zip")
val idPrefix = if (logParent.absolutePath.contains("/cache/")) "cache:" else "ext:"
if (idPrefix + baseName == sessionId) {
val dirExists = dir.exists() && dir.isDirectory
val zipExists = zip.exists() && zip.isFile
if (dirExists || zipExists) {
return Pair(if (dirExists) dir else null, if (zipExists) zip else null)
}
}
}
return Pair(null, null)
}
private suspend fun autoCompress(sessionId: String) {
try {
zipSession(sessionId)
} catch (e: Exception) {
log(TAG, ERROR) { "Auto-compress failed for $sessionId: $e" }
} finally {
compressingSessionId = null
refreshSessions()
}
}
suspend fun zipSession(sessionId: String): File = fsMutex.withLock {
val (dir, existingZip) = findSessionFiles(sessionId)
// If zip already exists and is fresh, just return it
if (existingZip != null && existingZip.length() > 0) {
if (dir == null || existingZip.lastModified() >= dir.lastModified()) {
return@withLock existingZip
}
}
requireNotNull(dir) { "No log directory found for session $sessionId" }
val logFiles = dir.listFiles()?.toList()
?: throw IllegalStateException("No log files in $dir")
val targetZip = File(dir.parentFile, "${dir.name}.zip")
val tempZip = File(dir.parentFile, "${dir.name}.zip.tmp")
try {
Zipper().zip(logFiles.map { it.path }.toTypedArray(), tempZip.path)
tempZip.renameTo(targetZip)
} catch (e: Exception) {
tempZip.delete()
throw e
}
targetZip
}
suspend fun deleteSession(sessionId: String) = fsMutex.withLock {
val currentRecording = sessions.first().filterIsInstance<DebugSession.Recording>()
.firstOrNull { it.id == sessionId }
require(currentRecording == null) { "Cannot delete an active recording session" }
val (dir, zip) = findSessionFiles(sessionId)
dir?.deleteRecursively()
zip?.delete()
log(TAG) { "Deleted session: $sessionId" }
val recState = internalState.value()
sessionsState.updateBlocking {
scanSessions(activeDir = recState.currentLogDir, recordingStartedAt = recState.recordingStartedAt)
}
}
suspend fun getZipUri(sessionId: String): Uri {
val zipFile = zipSession(sessionId)
return debugLogZipper.getUriForZip(zipFile)
}
suspend fun deleteAllLogs() = fsMutex.withLock {
val activeDir = internalState.value().currentLogDir
getLogDirectories().forEach { dir ->
if (!dir.exists()) return@forEach
@@ -170,6 +426,10 @@ class RecorderModule @Inject constructor(
}
}
log(TAG) { "All stored logs deleted" }
val recState = internalState.value()
sessionsState.updateBlocking {
scanSessions(activeDir = recState.currentLogDir, recordingStartedAt = recState.recordingStartedAt)
}
}
suspend fun startRecorder(): File {
@@ -34,7 +34,7 @@ class RecorderActivity : Activity2() {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
if (intent.getStringExtra(RECORD_PATH) == null) {
if (intent.getStringExtra(RECORD_SESSION_ID) == null && intent.getStringExtra(RECORD_PATH) == null) {
finish()
return
}
@@ -76,11 +76,13 @@ class RecorderActivity : Activity2() {
companion object {
internal val TAG = logTag("Debug", "Log", "RecorderActivity")
const val RECORD_SESSION_ID = "sessionId"
const val RECORD_PATH = "logPath"
fun getLaunchIntent(context: Context, path: String): Intent {
fun getLaunchIntent(context: Context, sessionId: String, legacyPath: String? = null): Intent {
val intent = Intent(context, RecorderActivity::class.java)
intent.putExtra(RECORD_PATH, path)
intent.putExtra(RECORD_SESSION_ID, sessionId)
if (legacyPath != null) intent.putExtra(RECORD_PATH, legacyPath)
return intent
}
}
@@ -11,11 +11,14 @@ import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.debug.recording.core.DebugLogZipper
import eu.darken.capod.common.debug.recording.core.DebugSession
import eu.darken.capod.common.debug.recording.core.RecorderModule
import eu.darken.capod.common.flow.DynamicStateFlow
import eu.darken.capod.common.flow.SingleEventFlow
import eu.darken.capod.common.uix.ViewModel2
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.plus
import java.io.File
import javax.inject.Inject
@@ -25,7 +28,7 @@ class RecorderActivityVM @Inject constructor(
handle: SavedStateHandle,
dispatcherProvider: DispatcherProvider,
@ApplicationContext private val context: Context,
private val debugLogZipper: DebugLogZipper,
private val recorderModule: RecorderModule,
private val webpageTool: WebpageTool,
) : ViewModel2(dispatcherProvider) {
@@ -48,24 +51,57 @@ class RecorderActivityVM @Inject constructor(
data object Finish : Event
}
private val recordedDirPath = handle.get<String>(RecorderActivity.RECORD_PATH)
private val logDir = recordedDirPath?.let { File(it) }
private val sessionId: String? = handle.get<String>(RecorderActivity.RECORD_SESSION_ID)
private val legacyPath: String? = handle.get<String>(RecorderActivity.RECORD_PATH)
private suspend fun resolveSession(): DebugSession? {
if (sessionId != null) {
val session = recorderModule.sessions.first().firstOrNull { it.id == sessionId }
if (session != null) return session
recorderModule.refreshSessions()
return recorderModule.sessions.first().firstOrNull { it.id == sessionId }
}
// Legacy fallback: derive ID from path
if (legacyPath != null) {
val file = File(legacyPath)
val prefix = if (file.absolutePath.contains("/cache/")) "cache:" else "ext:"
val derivedId = prefix + file.name.removeSuffix(".zip")
recorderModule.refreshSessions()
return recorderModule.sessions.first().firstOrNull { it.id == derivedId }
}
return null
}
private val stater = DynamicStateFlow(TAG, vmScope + dispatcherProvider.IO) {
val session = resolveSession()
val logDir = when (session) {
is DebugSession.Ready -> session.logDir
is DebugSession.Compressing -> session.path
is DebugSession.Failed -> session.path.takeIf { it.isDirectory }
is DebugSession.Recording -> session.path
null -> legacyPath?.let { File(it) }
}
val isCompressing = session is DebugSession.Compressing
if (logDir == null || !logDir.exists()) {
return@DynamicStateFlow State(logDir = null)
return@DynamicStateFlow State(logDir = null, isWorking = isCompressing)
}
val files = logDir.listFiles()?.toList() ?: emptyList()
val entries = files.map { LogEntry(it, it.length()) }
val totalSize = entries.sumOf { it.size }
val compressedSize = try {
debugLogZipper.zipAndGetUri(logDir)
File(logDir.parentFile, "${logDir.name}.zip").length()
} catch (e: Exception) {
log(TAG) { "Failed to zip: $e" }
val compressedSize = if (isCompressing) {
-1L
} else {
try {
val zipFile = session?.id?.let { recorderModule.zipSession(it) }
zipFile?.length() ?: -1L
} catch (e: Exception) {
log(TAG) { "Failed to zip: $e" }
-1L
}
}
val dirCreated = logDir.lastModified()
@@ -78,26 +114,43 @@ class RecorderActivityVM @Inject constructor(
totalSize = totalSize,
compressedSize = compressedSize,
recordingDurationSecs = durationSecs,
isWorking = false,
isWorking = isCompressing,
)
}
val state = stater.flow
val events = SingleEventFlow<Event>()
init {
recorderModule.sessions
.onEach { allSessions ->
val sid = sessionId ?: return@onEach
val session = allSessions.firstOrNull { it.id == sid } ?: return@onEach
if (session is DebugSession.Ready) {
stater.updateBlocking {
if (!isWorking) return@updateBlocking this
val zipSize = try {
recorderModule.zipSession(sid).length()
} catch (e: Exception) {
log(TAG) { "Failed to get zip size: $e" }
-1L
}
copy(compressedSize = zipSize, isWorking = false)
}
}
}
.launchIn(vmScope)
}
fun share() = launch {
val currentState = stater.flow.first()
val dir = currentState.logDir ?: return@launch
val sid = sessionId ?: return@launch
stater.updateBlocking { copy(isWorking = true) }
try {
val zipFile = File(dir.parentFile, "${dir.name}.zip")
val uri = if (zipFile.exists()) {
debugLogZipper.getUriForZip(zipFile)
} else {
debugLogZipper.zipAndGetUri(dir)
}
val uri = recorderModule.getZipUri(sid)
val intent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri)
@@ -120,13 +173,8 @@ class RecorderActivityVM @Inject constructor(
}
fun discard() = launch {
val currentState = stater.flow.first()
val dir = currentState.logDir ?: return@launch
dir.deleteRecursively()
val zipFile = File(dir.parentFile, "${dir.name}.zip")
if (zipFile.exists()) zipFile.delete()
val sid = sessionId ?: return@launch
recorderModule.deleteSession(sid)
events.tryEmit(Event.Finish)
}
@@ -1,20 +1,39 @@
package eu.darken.capod.main.ui.settings.support
import android.content.Intent
import android.text.format.Formatter
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
import androidx.compose.material.icons.automirrored.twotone.MenuBook
import androidx.compose.material.icons.twotone.BugReport
import androidx.compose.material.icons.twotone.Cancel
import androidx.compose.material.icons.automirrored.twotone.MenuBook
import androidx.compose.material.icons.twotone.CheckCircle
import androidx.compose.material.icons.twotone.Delete
import androidx.compose.material.icons.twotone.FiberManualRecord
import androidx.compose.material.icons.twotone.Settings
import androidx.compose.material.icons.twotone.Warning
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.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -22,11 +41,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.darken.capod.R
@@ -35,17 +57,20 @@ import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import eu.darken.capod.common.debug.recording.core.DebugSession
import eu.darken.capod.common.debug.recording.ui.RecorderActivity
import eu.darken.capod.common.debug.recording.ui.RecorderConsentDialog
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.settings.SettingsBaseItem
import eu.darken.capod.common.settings.SettingsCategoryHeader
@Composable
fun SupportScreenHost(vm: SupportViewModel = hiltViewModel()) {
ErrorEventHandler(vm)
NavigationEventHandler(vm)
LifecycleResumeEffect(Unit) {
vm.refreshLogSize()
vm.refreshSessions()
onPauseOrDispose {}
}
@@ -67,6 +92,13 @@ fun SupportScreenHost(vm: SupportViewModel = hiltViewModel()) {
SupportViewModel.Event.ShowShortRecordingWarning -> {
showShortRecordingWarning = true
}
is SupportViewModel.Event.OpenRecorderActivity -> {
val intent = RecorderActivity.getLaunchIntent(context, event.sessionId, event.legacyPath).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
}
}
}
@@ -89,6 +121,18 @@ fun SupportScreenHost(vm: SupportViewModel = hiltViewModel()) {
onWiki = { vm.openUrl("https://github.com/d4rken-org/capod/wiki") },
onTroubleShooter = { vm.goToTroubleShooter() },
onDebugLogToggle = { vm.onDebugLogToggle() },
onOpenSession = { vm.openSession(it) },
onDeleteSession = { id ->
MaterialAlertDialogBuilder(context).apply {
setTitle(R.string.support_debuglog_session_delete_title)
setMessage(R.string.support_debuglog_session_delete_message)
setPositiveButton(R.string.profiles_delete_action) { _, _ ->
vm.deleteSession(id)
}
setNegativeButton(R.string.general_cancel_action) { _, _ -> }
}.show()
},
onStopRecording = { vm.onDebugLogToggle() },
onClearLogs = { vm.clearDebugLogs() },
)
}
@@ -127,10 +171,14 @@ fun SupportScreen(
onWiki: () -> Unit,
onTroubleShooter: () -> Unit,
onDebugLogToggle: () -> Unit,
onOpenSession: (String) -> Unit,
onDeleteSession: (String) -> Unit,
onStopRecording: () -> Unit,
onClearLogs: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
var showSessionsSheet by remember { mutableStateOf(false) }
Scaffold(
modifier = modifier,
@@ -215,19 +263,211 @@ fun SupportScreen(
onClick = onDebugLogToggle,
)
}
if (state.logFolderSize > 0 && !state.isRecording) {
if (state.sessions.isNotEmpty()) {
item {
val nonRecordingSessions = state.sessions.count { it !is DebugSession.Recording }
val logSizeFormatted = Formatter.formatShortFileSize(context, state.logFolderSize)
SettingsBaseItem(
title = stringResource(R.string.support_debuglog_clear_action),
subtitle = pluralStringResource(
R.plurals.support_debuglog_folder_summary,
state.logSessionCount,
state.logSessionCount,
logSizeFormatted,
),
title = stringResource(R.string.support_debuglog_sessions_label),
subtitle = if (nonRecordingSessions > 0) {
pluralStringResource(
R.plurals.support_debuglog_folder_summary,
nonRecordingSessions,
nonRecordingSessions,
logSizeFormatted,
)
} else {
stringResource(R.string.support_debuglog_sessions_desc)
},
iconPainter = painterResource(R.drawable.ic_delete_sweep_24),
onClick = onClearLogs,
onClick = { showSessionsSheet = true },
)
}
}
}
}
if (showSessionsSheet) {
DebugSessionsBottomSheet(
sessions = state.sessions,
onDismiss = { showSessionsSheet = false },
onOpenSession = onOpenSession,
onDeleteSession = onDeleteSession,
onStopRecording = onStopRecording,
onClearAll = {
showSessionsSheet = false
onClearLogs()
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DebugSessionsBottomSheet(
sessions: List<DebugSession>,
onDismiss: () -> Unit,
onOpenSession: (String) -> Unit,
onDeleteSession: (String) -> Unit,
onStopRecording: () -> Unit,
onClearAll: () -> Unit,
) {
val context = LocalContext.current
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(modifier = Modifier.padding(bottom = 24.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.support_debuglog_sessions_label),
style = MaterialTheme.typography.titleMedium,
)
if (sessions.any { it !is DebugSession.Recording }) {
IconButton(onClick = onClearAll) {
Icon(
painter = painterResource(R.drawable.ic_delete_sweep_24),
contentDescription = stringResource(R.string.support_debuglog_clear_action),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
if (sessions.isEmpty()) {
Text(
text = stringResource(R.string.support_debuglog_sessions_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
)
} else {
sessions.forEach { session ->
SessionRow(
session = session,
context = context,
onOpen = { onOpenSession(session.id) },
onDelete = { onDeleteSession(session.id) },
onStop = onStopRecording,
)
}
}
}
}
}
@Composable
private fun SessionRow(
session: DebugSession,
context: android.content.Context,
onOpen: () -> Unit,
onDelete: () -> Unit,
onStop: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.then(if (session is DebugSession.Ready) Modifier.clickable(onClick = onOpen) else Modifier)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Leading icon
when (session) {
is DebugSession.Recording -> Icon(
imageVector = Icons.TwoTone.FiberManualRecord,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.error,
)
is DebugSession.Compressing -> CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
is DebugSession.Ready -> Icon(
imageVector = Icons.TwoTone.CheckCircle,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
is DebugSession.Failed -> Icon(
imageVector = Icons.TwoTone.Warning,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.error,
)
}
Spacer(modifier = Modifier.width(12.dp))
// Text content
Column(modifier = Modifier.weight(1f)) {
Text(
text = session.displayName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = when (session) {
is DebugSession.Recording -> stringResource(R.string.support_debuglog_session_recording)
is DebugSession.Compressing -> stringResource(R.string.support_debuglog_session_compressing)
is DebugSession.Ready -> Formatter.formatShortFileSize(context, session.diskSize)
is DebugSession.Failed -> when (session.reason) {
DebugSession.Failed.Reason.EMPTY_LOG -> stringResource(R.string.support_debuglog_failed_empty_log)
DebugSession.Failed.Reason.MISSING_LOG -> stringResource(R.string.support_debuglog_failed_missing_log)
DebugSession.Failed.Reason.CORRUPT_ZIP -> stringResource(R.string.support_debuglog_failed_corrupt_zip)
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Trailing action
when (session) {
is DebugSession.Recording -> {
IconButton(onClick = onStop) {
Icon(
imageVector = Icons.TwoTone.Cancel,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
}
}
is DebugSession.Compressing -> {
// No action during compression
}
is DebugSession.Ready -> {
IconButton(onClick = onDelete) {
Icon(
imageVector = Icons.TwoTone.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
is DebugSession.Failed -> {
IconButton(onClick = onDelete) {
Icon(
imageVector = Icons.TwoTone.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -247,6 +487,9 @@ private fun SupportScreenPreview() = PreviewWrapper {
onWiki = {},
onTroubleShooter = {},
onDebugLogToggle = {},
onOpenSession = {},
onDeleteSession = {},
onStopRecording = {},
onClearLogs = {},
)
}
@@ -5,14 +5,15 @@ import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.debug.recording.core.DebugSession
import eu.darken.capod.common.debug.recording.core.RecorderModule
import eu.darken.capod.common.flow.DynamicStateFlow
import eu.darken.capod.common.flow.SingleEventFlow
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.uix.ViewModel4
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import java.io.File
import javax.inject.Inject
@@ -27,39 +28,38 @@ class SupportViewModel @Inject constructor(
val isRecording: Boolean = false,
val currentLogPath: File? = null,
val recordingStartedAt: Long = 0L,
val logFolderSize: Long = 0L,
val logSessionCount: Int = 0,
)
val sessions: List<DebugSession> = emptyList(),
) {
val logSessionCount: Int get() = sessions.count { it !is DebugSession.Recording }
val logFolderSize: Long get() = sessions.sumOf { it.diskSize }
val failedSessions: List<DebugSession.Failed> get() = sessions.filterIsInstance<DebugSession.Failed>()
}
sealed interface Event {
data object ShowConsentDialog : Event
data object ShowShortRecordingWarning : Event
data class OpenRecorderActivity(val sessionId: String, val legacyPath: String?) : Event
}
val events = SingleEventFlow<Event>()
private val stater = DynamicStateFlow(TAG, vmScope) {
State(
logFolderSize = recorderModule.getLogFolderSize(),
logSessionCount = recorderModule.getLogSessionCount(),
)
}
private val stater = DynamicStateFlow(TAG, vmScope) { State() }
val state = stater.flow
init {
recorderModule.state
.onEach { recorderState ->
stater.updateBlocking {
copy(
isRecording = recorderState.isRecording,
currentLogPath = recorderState.currentLogPath,
recordingStartedAt = recorderState.recordingStartedAt,
logFolderSize = recorderModule.getLogFolderSize(),
logSessionCount = recorderModule.getLogSessionCount(),
)
}
combine(
recorderModule.state,
recorderModule.sessions,
) { recorderState, sessions ->
stater.updateBlocking {
copy(
isRecording = recorderState.isRecording,
currentLogPath = recorderState.currentLogPath,
recordingStartedAt = recorderState.recordingStartedAt,
sessions = sessions,
)
}
.launchIn(vmScope)
}.launchIn(vmScope)
}
fun openUrl(url: String) {
@@ -100,32 +100,31 @@ class SupportViewModel @Inject constructor(
}
log(TAG) { "stopDebugLog()" }
recorderModule.stopRecorder()
doRefreshLogSize()
}
fun forceStopDebugLog() = launch {
log(TAG) { "forceStopDebugLog()" }
recorderModule.stopRecorder()
doRefreshLogSize()
}
fun clearDebugLogs() = launch {
log(TAG) { "clearDebugLogs()" }
recorderModule.deleteAllLogs()
doRefreshLogSize()
}
fun refreshLogSize() = launch {
doRefreshLogSize()
fun openSession(sessionId: String) = launch {
val session = recorderModule.sessions.first().firstOrNull { it.id == sessionId } ?: return@launch
val legacyPath = (session as? DebugSession.Ready)?.logDir?.path
events.tryEmit(Event.OpenRecorderActivity(sessionId, legacyPath))
}
private suspend fun doRefreshLogSize() {
stater.updateBlocking {
copy(
logFolderSize = recorderModule.getLogFolderSize(),
logSessionCount = recorderModule.getLogSessionCount(),
)
}
fun refreshSessions() = launch {
recorderModule.refreshSessions()
}
fun deleteSession(id: String) = launch {
log(TAG) { "deleteSession($id)" }
recorderModule.deleteSession(id)
}
companion object {
@@ -130,12 +130,12 @@ fun ContactFormScreenHost(vm: ContactFormViewModel = hiltViewModel()) {
onDescriptionChange = { vm.updateDescription(it) },
onExpectedChange = { vm.updateExpectedBehavior(it) },
onSelectSession = { vm.selectLogSession(it) },
onDeleteSession = { path ->
onDeleteSession = { id ->
MaterialAlertDialogBuilder(context).apply {
setTitle(R.string.support_contact_debuglog_delete_title)
setMessage(R.string.support_contact_debuglog_delete_message)
setPositiveButton(R.string.profiles_delete_action) { _, _ ->
vm.deleteLogSession(path)
vm.deleteLogSession(id)
}
setNegativeButton(R.string.general_cancel_action) { _, _ -> }
}.show()
@@ -156,8 +156,8 @@ fun ContactFormScreen(
onCategoryChange: (Category) -> Unit,
onDescriptionChange: (String) -> Unit,
onExpectedChange: (String) -> Unit,
onSelectSession: (java.io.File) -> Unit,
onDeleteSession: (java.io.File) -> Unit,
onSelectSession: (String) -> Unit,
onDeleteSession: (String) -> Unit,
onStartRecording: () -> Unit,
onStopRecording: () -> Unit,
onSend: () -> Unit,
@@ -255,7 +255,7 @@ fun ContactFormScreen(
)
} else {
state.sessions.forEach { session ->
val isSelected = state.selectedSessionPath == session.path
val isSelected = state.selectedSessionId == session.id
Row(
modifier = Modifier
.fillMaxWidth()
@@ -264,7 +264,7 @@ fun ContactFormScreen(
) {
RadioButton(
selected = isSelected,
onClick = { onSelectSession(session.path) },
onClick = { onSelectSession(session.id) },
)
Column(
modifier = Modifier
@@ -272,16 +272,16 @@ fun ContactFormScreen(
.padding(start = 4.dp),
) {
Text(
text = session.path.name,
text = session.displayName,
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = Formatter.formatShortFileSize(context, session.size),
text = Formatter.formatShortFileSize(context, session.diskSize),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { onDeleteSession(session.path) }) {
IconButton(onClick = { onDeleteSession(session.id) }) {
Icon(
Icons.TwoTone.Delete,
contentDescription = null,
@@ -11,15 +11,14 @@ import eu.darken.capod.common.SupportLinks
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.debug.recording.core.DebugLogZipper
import eu.darken.capod.common.debug.recording.core.DebugSession
import eu.darken.capod.common.debug.recording.core.RecorderModule
import eu.darken.capod.common.flow.DynamicStateFlow
import eu.darken.capod.common.flow.SingleEventFlow
import eu.darken.capod.common.uix.ViewModel4
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import java.io.File
import javax.inject.Inject
@HiltViewModel
@@ -27,18 +26,11 @@ class ContactFormViewModel @Inject constructor(
dispatcherProvider: DispatcherProvider,
@ApplicationContext private val context: Context,
private val recorderModule: RecorderModule,
private val debugLogZipper: DebugLogZipper,
private val emailTool: EmailTool,
) : ViewModel4(dispatcherProvider) {
enum class Category { QUESTION, FEATURE, BUG }
data class LogSessionItem(
val path: File,
val size: Long,
val lastModified: Long,
)
data class State(
val category: Category = Category.QUESTION,
val description: String = "",
@@ -46,8 +38,8 @@ class ContactFormViewModel @Inject constructor(
val isSending: Boolean = false,
val isRecording: Boolean = false,
val recordingStartedAt: Long = 0L,
val sessions: List<LogSessionItem> = emptyList(),
val selectedSessionPath: File? = null,
val sessions: List<DebugSession.Ready> = emptyList(),
val selectedSessionId: String? = null,
) {
val isBug: Boolean get() = category == Category.BUG
@@ -73,45 +65,28 @@ class ContactFormViewModel @Inject constructor(
val events = SingleEventFlow<Event>()
private val stater = DynamicStateFlow(TAG, vmScope) {
State(sessions = loadLogSessions())
}
private val stater = DynamicStateFlow(TAG, vmScope) { State() }
val state = stater.flow
init {
recorderModule.state
.onEach { recorderState ->
stater.updateBlocking {
copy(
isRecording = recorderState.isRecording,
recordingStartedAt = recorderState.recordingStartedAt,
sessions = loadLogSessions(activeDir = recorderState.currentLogDir),
)
}
combine(
recorderModule.state,
recorderModule.sessions,
) { recorderState, allSessions ->
val completed = allSessions.filterIsInstance<DebugSession.Ready>()
stater.updateBlocking {
copy(
isRecording = recorderState.isRecording,
recordingStartedAt = recorderState.recordingStartedAt,
sessions = completed,
selectedSessionId = if (selectedSessionId != null && completed.none { it.id == selectedSessionId }) {
null
} else {
selectedSessionId
},
)
}
.launchIn(vmScope)
}
private fun loadLogSessions(activeDir: File? = null): List<LogSessionItem> {
return recorderModule.getLogDirectories()
.flatMap { dir ->
if (!dir.exists()) return@flatMap emptyList()
val entries = dir.listFiles() ?: return@flatMap emptyList()
entries.filter { it != activeDir && (it.isDirectory || (it.isFile && it.extension == "zip")) }
.map { entry ->
val size = if (entry.isDirectory) {
entry.walkTopDown().filter { it.isFile }.sumOf { it.length() }
} else {
entry.length()
}
LogSessionItem(
path = entry,
size = size,
lastModified = entry.lastModified(),
)
}
}
.sortedByDescending { it.lastModified }
}.launchIn(vmScope)
}
fun updateCategory(category: Category) = launch {
@@ -130,29 +105,17 @@ class ContactFormViewModel @Inject constructor(
}
}
fun selectLogSession(path: File) = launch {
stater.updateBlocking { copy(selectedSessionPath = path) }
fun selectLogSession(id: String) = launch {
stater.updateBlocking { copy(selectedSessionId = id) }
}
fun deleteLogSession(path: File) = launch {
log(TAG) { "deleteLogSession($path)" }
if (path.isDirectory) {
path.deleteRecursively()
val zip = File(path.parentFile, "${path.name}.zip")
if (zip.exists()) zip.delete()
} else {
path.delete()
}
stater.updateBlocking {
copy(
sessions = loadLogSessions(),
selectedSessionPath = if (selectedSessionPath == path) null else selectedSessionPath,
)
}
fun deleteLogSession(id: String) = launch {
log(TAG) { "deleteLogSession($id)" }
recorderModule.deleteSession(id)
}
fun refreshLogSessions() = launch {
stater.updateBlocking { copy(sessions = loadLogSessions()) }
recorderModule.refreshSessions()
}
fun startRecording() {
@@ -173,13 +136,11 @@ class ContactFormViewModel @Inject constructor(
}
log(TAG) { "stopRecording()" }
recorderModule.stopRecorder(showResultUi = false)
stater.updateBlocking { copy(sessions = loadLogSessions()) }
}
fun forceStopRecording() = launch {
log(TAG) { "forceStopRecording()" }
recorderModule.stopRecorder(showResultUi = false)
stater.updateBlocking { copy(sessions = loadLogSessions()) }
}
fun send() = launch {
@@ -189,15 +150,9 @@ class ContactFormViewModel @Inject constructor(
stater.updateBlocking { copy(isSending = true) }
try {
val attachmentUri = currentState.selectedSessionPath?.let { sessionPath ->
val attachmentUri = currentState.selectedSessionId?.let { sessionId ->
try {
if (sessionPath.isDirectory) {
debugLogZipper.zipAndGetUri(sessionPath)
} else if (sessionPath.extension == "zip" && sessionPath.exists()) {
debugLogZipper.getUriForZip(sessionPath)
} else {
null
}
recorderModule.getZipUri(sessionId)
} catch (e: Exception) {
log(TAG) { "Failed to prepare attachment: $e" }
events.tryEmit(
+11 -1
View File
@@ -350,7 +350,7 @@
<item quantity="one">%1$d file ready (%2$s)</item>
<item quantity="other">%1$d files ready (%2$s)</item>
</plurals>
<string name="debug_debuglog_screen_discard_action">Discard</string>
<string name="debug_debuglog_screen_discard_action">Delete</string>
<!-- Log management -->
<plurals name="support_debuglog_folder_summary">
@@ -358,6 +358,16 @@
<item quantity="other">%1$d debug logs (%2$s)</item>
</plurals>
<string name="support_debuglog_clear_action">Clear stored debug logs</string>
<string name="support_debuglog_sessions_label">Debug sessions</string>
<string name="support_debuglog_sessions_desc">Manage recorded debug log sessions</string>
<string name="support_debuglog_sessions_empty">No debug sessions</string>
<string name="support_debuglog_session_recording">Recording…</string>
<string name="support_debuglog_session_compressing">Compressing…</string>
<string name="support_debuglog_session_delete_title">Delete session?</string>
<string name="support_debuglog_session_delete_message">This debug session will be permanently deleted.</string>
<string name="support_debuglog_failed_empty_log">Failed: log file is empty</string>
<string name="support_debuglog_failed_missing_log">Failed: log file is missing</string>
<string name="support_debuglog_failed_corrupt_zip">Failed: zip file is corrupt</string>
<!-- Contact form -->
<string name="support_contact_label">Contact developer</string>