diff --git a/app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugLogZipper.kt b/app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugLogZipper.kt index 5f8085e1..7064812c 100644 --- a/app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugLogZipper.kt +++ b/app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugLogZipper.kt @@ -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, diff --git a/app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugSession.kt b/app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugSession.kt new file mode 100644 index 00000000..329fd1a1 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugSession.kt @@ -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 } + } +} diff --git a/app/src/main/java/eu/darken/capod/common/debug/recording/core/RecorderModule.kt b/app/src/main/java/eu/darken/capod/common/debug/recording/core/RecorderModule.kt index a8580955..873ac513 100644 --- a/app/src/main/java/eu/darken/capod/common/debug/recording/core/RecorderModule.kt +++ b/app/src/main/java/eu/darken/capod/common/debug/recording/core/RecorderModule.kt @@ -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 = listOfNotNull( + private fun getLogDirectories(): List = 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> = 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 { + + data class RawEntry(val dir: File?, val zip: File?, val parentDir: File) + + val entriesByBaseName = mutableMapOf() + + 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 { + 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() + .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 { diff --git a/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivity.kt b/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivity.kt index 4e1f86ba..833d40be 100644 --- a/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivity.kt +++ b/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivity.kt @@ -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 } } diff --git a/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivityVM.kt b/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivityVM.kt index 4535e15f..933fd2e1 100644 --- a/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivityVM.kt +++ b/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivityVM.kt @@ -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(RecorderActivity.RECORD_PATH) - private val logDir = recordedDirPath?.let { File(it) } + private val sessionId: String? = handle.get(RecorderActivity.RECORD_SESSION_ID) + private val legacyPath: String? = handle.get(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() + 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) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportScreen.kt index 1ef95291..a15e0c50 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportScreen.kt @@ -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, + 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 = {}, ) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportViewModel.kt index 60746655..2baefbee 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportViewModel.kt @@ -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 = emptyList(), + ) { + val logSessionCount: Int get() = sessions.count { it !is DebugSession.Recording } + val logFolderSize: Long get() = sessions.sumOf { it.diskSize } + val failedSessions: List get() = sessions.filterIsInstance() + } sealed interface Event { data object ShowConsentDialog : Event data object ShowShortRecordingWarning : Event + data class OpenRecorderActivity(val sessionId: String, val legacyPath: String?) : Event } val events = SingleEventFlow() - 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 { diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormScreen.kt index 5ca29a3c..49ee589a 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormScreen.kt @@ -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, diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormViewModel.kt index c7c7f6e8..a5c5b769 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormViewModel.kt @@ -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 = emptyList(), - val selectedSessionPath: File? = null, + val sessions: List = emptyList(), + val selectedSessionId: String? = null, ) { val isBug: Boolean get() = category == Category.BUG @@ -73,45 +65,28 @@ class ContactFormViewModel @Inject constructor( val events = SingleEventFlow() - 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() + 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 { - 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( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7f8df2a4..6ed527a1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -350,7 +350,7 @@ %1$d file ready (%2$s) %1$d files ready (%2$s) - Discard + Delete @@ -358,6 +358,16 @@ %1$d debug logs (%2$s) Clear stored debug logs + Debug sessions + Manage recorded debug log sessions + No debug sessions + Recording… + Compressing… + Delete session? + This debug session will be permanently deleted. + Failed: log file is empty + Failed: log file is missing + Failed: zip file is corrupt Contact developer