From 62e39ce3d2a4f54907df8bafe44a22b3ce065ea0 Mon Sep 17 00:00:00 2001 From: darken Date: Sun, 8 Mar 2026 07:31:43 +0100 Subject: [PATCH] refactor(support): Add zipFile/compressedSize to Ready, harden session manager Add zipFile and compressedSize fields to DebugSession.Ready, populated during scan. Simplify RecorderActivityVM by reading compressed size directly from session model. Apply code review fixes: fsMutex in zipSessionAsync, CancellationException handling, ZipOutputStream use{}, atomic file moves, dedup deriveSessionId, LifecycleResumeEffect for session refresh. --- .../darken/capod/common/compression/Zipper.kt | 22 +- .../debug/recording/core/DebugLogZipper.kt | 33 +- .../debug/recording/core/DebugSession.kt | 4 +- .../recording/core/DebugSessionManager.kt | 394 ++++++++++++++++++ .../debug/recording/core/RecorderModule.kt | 340 +-------------- .../debug/recording/ui/RecorderActivity.kt | 26 +- .../debug/recording/ui/RecorderActivityVM.kt | 49 +-- .../debug/recording/ui/RecorderScreen.kt | 2 +- .../main/ui/settings/support/SupportScreen.kt | 23 +- .../ui/settings/support/SupportViewModel.kt | 36 +- .../support/contactform/ContactFormScreen.kt | 16 +- .../contactform/ContactFormViewModel.kt | 53 ++- app/src/main/res/values/strings.xml | 2 + .../DebugSessionManagerSessionLogicTest.kt | 223 ++++++++++ .../recording/core/DebugSessionManagerTest.kt | 214 ++++++++++ .../support/SupportViewModelStateTest.kt | 128 ++++++ 16 files changed, 1149 insertions(+), 416 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugSessionManager.kt create mode 100644 app/src/test/java/eu/darken/capod/common/debug/recording/core/DebugSessionManagerSessionLogicTest.kt create mode 100644 app/src/test/java/eu/darken/capod/common/debug/recording/core/DebugSessionManagerTest.kt create mode 100644 app/src/test/java/eu/darken/capod/main/ui/settings/support/SupportViewModelStateTest.kt diff --git a/app/src/main/java/eu/darken/capod/common/compression/Zipper.kt b/app/src/main/java/eu/darken/capod/common/compression/Zipper.kt index 210ceb71..cc5e3ee8 100644 --- a/app/src/main/java/eu/darken/capod/common/compression/Zipper.kt +++ b/app/src/main/java/eu/darken/capod/common/compression/Zipper.kt @@ -15,22 +15,18 @@ class Zipper { @Throws(Exception::class) fun zip(files: Array, zipFile: String) { + ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { out -> + for (i in files.indices) { + log(TAG, VERBOSE) { "Compressing ${files[i]} into $zipFile" } + val origin = BufferedInputStream(FileInputStream(files[i]), BUFFER) - var origin: BufferedInputStream? - val out = ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))) + val entry = ZipEntry(files[i].substring(files[i].lastIndexOf("/") + 1)) + out.putNextEntry(entry) - for (i in files.indices) { - log(TAG, VERBOSE) { "Compressing ${files[i]} into $zipFile" } - origin = BufferedInputStream(FileInputStream(files[i]), BUFFER) - - val entry = ZipEntry(files[i].substring(files[i].lastIndexOf("/") + 1)) - out.putNextEntry(entry) - - origin.use { input -> input.copyTo(out) } + origin.use { input -> input.copyTo(out) } + } + out.finish() } - - out.finish() - out.close() } companion object { 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 7064812c..ea5b2eb1 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 @@ -8,31 +8,42 @@ import dagger.hilt.android.qualifiers.ApplicationContext import eu.darken.capod.common.BuildConfigWrap import eu.darken.capod.common.compression.Zipper import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption import javax.inject.Inject @Reusable class DebugLogZipper @Inject constructor( @ApplicationContext private val context: Context, ) { - fun zipAndGetUri(logDir: File): Uri { - val logFiles = logDir.listFiles()?.toList() - ?: throw IllegalStateException("No log files in $logDir") + fun zip(logDir: File): File { + val logFiles = logDir.listFiles()?.filter { it.isFile }?.toList() + ?: throw IllegalStateException("Cannot list files in $logDir") + require(logFiles.isNotEmpty()) { "No log files in $logDir" } val zipFile = File(logDir.parentFile, "${logDir.name}.zip") 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) { + try { + Files.move( + tempFile.toPath(), + zipFile.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: java.nio.file.AtomicMoveNotSupportedException) { + Files.move(tempFile.toPath(), zipFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } finally { tempFile.delete() - throw e } + return zipFile + } - return FileProvider.getUriForFile( - context, - BuildConfigWrap.APPLICATION_ID + ".provider", - zipFile, - ) + fun zipAndGetUri(logDir: File): Uri { + val zipFile = zip(logDir) + return getUriForZip(zipFile) } fun getUriForZip(zipFile: File): Uri { 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 index 329fd1a1..aad6372f 100644 --- 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 @@ -31,6 +31,8 @@ sealed interface DebugSession { override val createdAt: Long, override val diskSize: Long, val logDir: File?, + val zipFile: File?, + val compressedSize: Long, ) : DebugSession data class Failed( @@ -41,6 +43,6 @@ sealed interface DebugSession { val path: File, val reason: Reason, ) : DebugSession { - enum class Reason { EMPTY_LOG, MISSING_LOG, CORRUPT_ZIP } + enum class Reason { EMPTY_LOG, MISSING_LOG, CORRUPT_ZIP, ZIP_FAILED } } } diff --git a/app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugSessionManager.kt b/app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugSessionManager.kt new file mode 100644 index 00000000..06defe35 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugSessionManager.kt @@ -0,0 +1,394 @@ +package eu.darken.capod.common.debug.recording.core + +import android.net.Uri +import androidx.annotation.VisibleForTesting +import eu.darken.capod.common.coroutine.AppScope +import eu.darken.capod.common.coroutine.DispatcherProvider +import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR +import eu.darken.capod.common.debug.logging.Logging.Priority.WARN +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.common.flow.replayingShare +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +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 + +@Singleton +class DebugSessionManager @Inject constructor( + @AppScope private val appScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + private val recorderModule: RecorderModule, + private val debugLogZipper: DebugLogZipper, +) { + + private val fsMutex = Mutex() + private val zippingIds = MutableStateFlow>(emptySet()) + private val failedZipIds = MutableStateFlow>(emptySet()) + private val refreshTrigger = MutableSharedFlow(extraBufferCapacity = 1) + + val recorderState: Flow get() = recorderModule.state + + val sessions: Flow> = combine( + recorderModule.state, + zippingIds, + failedZipIds, + refreshTrigger.onStart { emit(Unit) }, + ) { recorderState, zipping, failedZips, _ -> + val raw = scanSessions( + logDirectories = recorderModule.getLogDirectories(), + activeDir = recorderState.currentLogDir, + recordingStartedAt = recorderState.recordingStartedAt, + ) + val overlaid = applyOverlays(raw, zipping, failedZips) + reconcileOrphans(overlaid) + overlaid + }.replayingShare(appScope) + + private fun applyOverlays( + sessions: List, + zipping: Set, + failedZips: Set, + ): List = sessions.map { session -> + when { + session.id in zipping -> { + val path = (session as? DebugSession.Ready)?.logDir + if (path == null) log(TAG, WARN) { "No logDir for session in zippingIds: ${session.id}" } + DebugSession.Compressing( + id = session.id, + displayName = session.displayName, + createdAt = session.createdAt, + diskSize = session.diskSize, + path = path ?: File(""), + ) + } + + session.id in failedZips && session !is DebugSession.Failed -> { + val path = (session as? DebugSession.Ready)?.logDir + if (path == null) log(TAG, WARN) { "No logDir for failed-zip session: ${session.id}" } + DebugSession.Failed( + id = session.id, + displayName = session.displayName, + createdAt = session.createdAt, + diskSize = session.diskSize, + path = path ?: File(""), + reason = DebugSession.Failed.Reason.ZIP_FAILED, + ) + } + + else -> session + } + } + + private fun reconcileOrphans(sessions: List) { + sessions.filterIsInstance().forEach { session -> + if (session.logDir == null) return@forEach + if (session.id in zippingIds.value) return@forEach + + if (session.zipFile == null || session.compressedSize == 0L) { + log(TAG, WARN) { "Orphan session detected, auto-zipping: ${session.id}" } + zipSessionAsync(session.id, session.logDir) + } + } + } + + private fun zipSessionAsync(sessionId: String, logDir: File) { + zippingIds.update { it + sessionId } + appScope.launch(dispatcherProvider.IO) { + try { + fsMutex.withLock { + debugLogZipper.zip(logDir) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log(TAG, ERROR) { "Zipping failed for $sessionId: $e" } + failedZipIds.update { it + sessionId } + } finally { + zippingIds.update { it - sessionId } + refresh() + } + } + } + + suspend fun startRecording(): File = recorderModule.startRecorder() + + suspend fun requestStopRecording(): RecorderModule.StopResult { + val result = recorderModule.requestStopRecorder() + if (result is RecorderModule.StopResult.Stopped) { + zipSessionAsync(result.sessionId, result.logDir) + } + return result + } + + suspend fun forceStopRecording(): RecorderModule.StopResult.Stopped? { + val logDir = recorderModule.stopRecorder() ?: return null + val sessionId = deriveSessionId(logDir) + zipSessionAsync(sessionId, logDir) + return RecorderModule.StopResult.Stopped(logDir, sessionId) + } + + fun refresh() { + refreshTrigger.tryEmit(Unit) + } + + suspend fun zipSession(sessionId: String): File = fsMutex.withLock { + val activeRecording = sessions.first().filterIsInstance() + .firstOrNull { it.id == sessionId } + require(activeRecording == null) { "Cannot zip an active recording session" } + + val (dir, existingZip) = findSessionFiles(sessionId) + + 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" } + withContext(dispatcherProvider.IO) { + debugLogZipper.zip(dir) + } + } + + suspend fun getZipUri(sessionId: String): Uri { + val zipFile = zipSession(sessionId) + return debugLogZipper.getUriForZip(zipFile) + } + + suspend fun deleteSession(sessionId: String) = fsMutex.withLock { + val currentSessions = sessions.first() + val recording = currentSessions.filterIsInstance() + .firstOrNull { it.id == sessionId } + require(recording == null) { "Cannot delete an active recording session" } + require(sessionId !in zippingIds.value) { "Cannot delete a session that is being compressed" } + + val (dir, zip) = findSessionFiles(sessionId) + dir?.deleteRecursively() + zip?.delete() + failedZipIds.update { it - sessionId } + + log(TAG) { "Deleted session: $sessionId" } + refresh() + } + + suspend fun deleteAllSessions() = fsMutex.withLock { + val activeDir = recorderModule.state.first().currentLogDir + val currentlyZipping = zippingIds.value + for (dir in recorderModule.getLogDirectories()) { + if (!dir.exists()) continue + for (entry in dir.listFiles() ?: emptyArray()) { + if (entry == activeDir) { + log(TAG) { "Skipping active session dir: $entry" } + continue + } + val entryId = deriveSessionId(entry) + if (entryId in currentlyZipping) { + log(TAG) { "Skipping zipping session: $entry" } + continue + } + if (entry.isDirectory) { + entry.deleteRecursively() + } else { + entry.delete() + } + } + } + failedZipIds.update { emptySet() } + log(TAG) { "All stored logs deleted" } + refresh() + } + + private fun findSessionFiles(sessionId: String): Pair { + val baseName = sessionId.removePrefix("ext:").removePrefix("cache:") + for (logParent in recorderModule.getLogDirectories()) { + val dir = File(logParent, baseName) + val zip = File(logParent, "$baseName.zip") + val idPrefix = if (logParent.absolutePath.contains("/cache/debug/logs")) "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) + } + + companion object { + private val TAG = logTag("Debug", "Log", "Session", "Manager") + + @VisibleForTesting + internal fun deriveSessionId(file: File): String { + val prefix = if (file.absolutePath.contains("/cache/debug/logs")) "cache:" else "ext:" + return prefix + file.name.removeSuffix(".zip") + } + + @VisibleForTesting + internal fun parseCreatedAt(dirName: String, fallback: Long): Long { + val parts = dirName.removeSuffix(".zip").split("_") + if (parts.size >= 4) { + val timestamp = parts[parts.size - 2].toLongOrNull() + if (timestamp != null && timestamp > 1_000_000_000_000L) return timestamp + } + return fallback + } + + @VisibleForTesting + internal fun scanSessions( + logDirectories: List, + 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 logDirectories) { + 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) + } + } + } + + return entriesByBaseName.map { (key, raw) -> + val baseName = key.substringAfterLast("/") + val prefix = if (key.contains("/cache/debug/logs")) "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 + + 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, + ) + } + + if (dir != null) { + val coreLog = File(dir, "core.log") + val dirSize = dir.walkTopDown().filter { it.isFile }.sumOf { it.length() } + + if (!coreLog.exists()) { + if (zip != null && zip.exists() && zip.length() > 0) { + return@map DebugSession.Ready( + id = id, + displayName = baseName, + createdAt = createdAt, + diskSize = zip.length(), + logDir = null, + zipFile = zip, + compressedSize = zip.length(), + ) + } + 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, + zipFile = zip, + compressedSize = zip.length(), + ) + } + return@map DebugSession.Failed( + id = id, + displayName = baseName, + createdAt = createdAt, + diskSize = dirSize, + path = dir, + reason = DebugSession.Failed.Reason.EMPTY_LOG, + ) + } + + val validZip = if (zip != null && zip.exists() && zip.length() > 0) zip else null + val zipSize = validZip?.length() ?: 0L + val totalDiskSize = dirSize + zipSize + return@map DebugSession.Ready( + id = id, + displayName = baseName, + createdAt = createdAt, + diskSize = totalDiskSize, + logDir = dir, + zipFile = validZip, + compressedSize = zipSize, + ) + } + + 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, + zipFile = zip, + compressedSize = zip.length(), + ) + } + + DebugSession.Failed( + id = id, + displayName = baseName, + createdAt = createdAt, + diskSize = 0L, + path = File(raw.parentDir, baseName), + reason = DebugSession.Failed.Reason.MISSING_LOG, + ) + }.sortedWith(compareByDescending { it.createdAt }.thenBy { it.id }) + } + } +} 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 873ac513..a37f8e2d 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 @@ -1,14 +1,11 @@ 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 @@ -16,7 +13,6 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.INFO import eu.darken.capod.common.debug.logging.Logging.Priority.WARN import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag -import eu.darken.capod.common.debug.recording.ui.RecorderActivity import eu.darken.capod.common.flow.DynamicStateFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -24,10 +20,7 @@ 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 @@ -38,12 +31,8 @@ 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) { @@ -87,30 +76,9 @@ class RecorderModule @Inject constructor( log(TAG, ERROR) { "Failed to delete trigger file" } } - val logDir = currentLogDir!! - val sessionId = deriveSessionId(logDir) - - compressingSessionId = sessionId - sessionsState.updateBlocking { - scanSessions(activeDir = null, recordingStartedAt = 0L) - } - - if (showResultUi) { - 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, - lastLogDir = logDir, - showResultUi = true, recordingStartedAt = 0L, ) } else { @@ -143,7 +111,7 @@ class RecorderModule @Inject constructor( return sessionDir } - private fun getLogDirectories(): List = listOfNotNull( + internal fun getLogDirectories(): List = listOfNotNull( try { context.getExternalFilesDir(null)?.let { File(it, "debug/logs") } } catch (e: Exception) { @@ -152,286 +120,6 @@ class RecorderModule @Inject constructor( File(context.cacheDir, "debug/logs"), ) - 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") - } - - 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) - } - } - } - - 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) - } - } - - 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 - dir.listFiles()?.forEach { entry -> - if (entry == activeDir) { - log(TAG) { "Skipping active session dir: $entry" } - return@forEach - } - if (entry.isDirectory) { - entry.deleteRecursively() - } else { - entry.delete() - } - } - } - log(TAG) { "All stored logs deleted" } - val recState = internalState.value() - sessionsState.updateBlocking { - scanSessions(activeDir = recState.currentLogDir, recordingStartedAt = recState.recordingStartedAt) - } - } - suspend fun startRecorder(): File { internalState.updateBlocking { copy(shouldRecord = true) @@ -439,22 +127,39 @@ class RecorderModule @Inject constructor( return internalState.flow.filter { it.isRecording }.first().currentLogDir!! } - suspend fun stopRecorder(showResultUi: Boolean = true): File? { + suspend fun stopRecorder(): File? { val currentDir = internalState.value().currentLogDir ?: return null internalState.updateBlocking { - copy(shouldRecord = false, showResultUi = showResultUi) + copy(shouldRecord = false) } internalState.flow.filter { !it.isRecording }.first() return currentDir } + suspend fun requestStopRecorder(): StopResult { + val currentState = internalState.value() + if (!currentState.isRecording) return StopResult.NotRecording + + val logDir = currentState.currentLogDir ?: return StopResult.NotRecording + val elapsed = System.currentTimeMillis() - currentState.recordingStartedAt + if (elapsed < MIN_RECORDING_MS) return StopResult.TooShort + + stopRecorder() + val sessionId = DebugSessionManager.deriveSessionId(logDir) + return StopResult.Stopped(logDir, sessionId) + } + + sealed class StopResult { + data object TooShort : StopResult() + data class Stopped(val logDir: File, val sessionId: String) : StopResult() + data object NotRecording : StopResult() + } + data class State( val shouldRecord: Boolean = false, internal val recorder: Recorder? = null, val currentLogDir: File? = null, - val lastLogDir: File? = null, val recordingStartedAt: Long = 0L, - internal val showResultUi: Boolean = true, ) { val isRecording: Boolean get() = recorder != null @@ -466,5 +171,6 @@ class RecorderModule @Inject constructor( companion object { internal val TAG = logTag("Debug", "Log", "Recorder", "Module") private const val FORCE_FILE = "capod_force_debug_run" + private const val MIN_RECORDING_MS = 5_000L } } 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 833d40be..0fa3b331 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 @@ -10,11 +10,16 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.toArgb import androidx.core.view.WindowCompat +import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint +import eu.darken.capod.R import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.theming.CapodTheme import eu.darken.capod.common.uix.Activity2 @@ -60,13 +65,32 @@ class RecorderActivity : Activity2() { } } + var showDeleteConfirm by remember { mutableStateOf(false) } + + if (showDeleteConfirm) { + LaunchedEffect(Unit) { + MaterialAlertDialogBuilder(this@RecorderActivity).apply { + setTitle(R.string.support_debuglog_session_delete_title) + setMessage(R.string.support_debuglog_session_delete_message) + setPositiveButton(R.string.profiles_delete_action) { _, _ -> + showDeleteConfirm = false + vm.discard() + } + setNegativeButton(R.string.general_cancel_action) { _, _ -> + showDeleteConfirm = false + } + setOnCancelListener { showDeleteConfirm = false } + }.show() + } + } + val state by vm.state.collectAsStateWithLifecycle(initialValue = null) state?.let { RecorderScreen( state = it, onShare = { vm.share() }, onKeep = { vm.keep() }, - onDiscard = { vm.discard() }, + onDiscard = { showDeleteConfirm = true }, onPrivacyPolicy = { vm.goPrivacyPolicy() }, ) } 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 933fd2e1..4280151b 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 @@ -12,7 +12,7 @@ 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.debug.recording.core.DebugSessionManager import eu.darken.capod.common.flow.DynamicStateFlow import eu.darken.capod.common.flow.SingleEventFlow import eu.darken.capod.common.uix.ViewModel2 @@ -28,7 +28,7 @@ class RecorderActivityVM @Inject constructor( handle: SavedStateHandle, dispatcherProvider: DispatcherProvider, @ApplicationContext private val context: Context, - private val recorderModule: RecorderModule, + private val sessionManager: DebugSessionManager, private val webpageTool: WebpageTool, ) : ViewModel2(dispatcherProvider) { @@ -56,18 +56,16 @@ class RecorderActivityVM @Inject constructor( private suspend fun resolveSession(): DebugSession? { if (sessionId != null) { - val session = recorderModule.sessions.first().firstOrNull { it.id == sessionId } + val session = sessionManager.sessions.first().firstOrNull { it.id == sessionId } if (session != null) return session - recorderModule.refreshSessions() - return recorderModule.sessions.first().firstOrNull { it.id == sessionId } + sessionManager.refresh() + return sessionManager.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 } + val derivedId = DebugSessionManager.deriveSessionId(file) + sessionManager.refresh() + return sessionManager.sessions.first().firstOrNull { it.id == derivedId } } return null } @@ -92,16 +90,10 @@ class RecorderActivityVM @Inject constructor( val entries = files.map { LogEntry(it, it.length()) } val totalSize = entries.sumOf { it.size } - 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 compressedSize = when (session) { + is DebugSession.Compressing -> -1L + is DebugSession.Ready -> session.compressedSize.takeIf { it > 0 } ?: -1L + else -> -1L } val dirCreated = logDir.lastModified() @@ -122,20 +114,17 @@ class RecorderActivityVM @Inject constructor( val events = SingleEventFlow() init { - recorderModule.sessions + sessionManager.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) + copy( + compressedSize = session.compressedSize.takeIf { it > 0 } ?: -1L, + isWorking = false, + ) } } } @@ -150,7 +139,7 @@ class RecorderActivityVM @Inject constructor( stater.updateBlocking { copy(isWorking = true) } try { - val uri = recorderModule.getZipUri(sid) + val uri = sessionManager.getZipUri(sid) val intent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) @@ -174,7 +163,7 @@ class RecorderActivityVM @Inject constructor( fun discard() = launch { val sid = sessionId ?: return@launch - recorderModule.deleteSession(sid) + sessionManager.deleteSession(sid) events.tryEmit(Event.Finish) } diff --git a/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderScreen.kt b/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderScreen.kt index 1a0e1449..c1175f73 100644 --- a/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderScreen.kt +++ b/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderScreen.kt @@ -385,7 +385,7 @@ private fun BottomActionBar( onClick = onKeep, modifier = Modifier.weight(1f), ) { - Text(text = stringResource(R.string.general_save_action)) + Text(text = stringResource(R.string.general_done_action)) } androidx.compose.material3.Button( onClick = onShare, 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 a15e0c50..5543c59a 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,6 +1,7 @@ package eu.darken.capod.main.ui.settings.support import android.content.Intent +import android.text.format.DateUtils import android.text.format.Formatter import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -133,7 +134,16 @@ fun SupportScreenHost(vm: SupportViewModel = hiltViewModel()) { }.show() }, onStopRecording = { vm.onDebugLogToggle() }, - onClearLogs = { vm.clearDebugLogs() }, + onClearLogs = { + MaterialAlertDialogBuilder(context).apply { + setTitle(R.string.support_debuglog_session_delete_title) + setMessage(R.string.support_debuglog_clear_all_message) + setPositiveButton(R.string.profiles_delete_action) { _, _ -> + vm.clearDebugLogs() + } + setNegativeButton(R.string.general_cancel_action) { _, _ -> } + }.show() + }, ) } } @@ -420,16 +430,23 @@ private fun SessionRow( maxLines = 1, overflow = TextOverflow.Ellipsis, ) + val agoText = DateUtils.getRelativeTimeSpanString( + session.createdAt, + System.currentTimeMillis(), + DateUtils.SECOND_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE, + ) 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.Ready -> "${Formatter.formatShortFileSize(context, session.diskSize)} · $agoText" 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) - } + DebugSession.Failed.Reason.ZIP_FAILED -> stringResource(R.string.support_debuglog_failed_zip_failed) + } + " · $agoText" }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, 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 2baefbee..ffbb71f3 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 @@ -6,6 +6,7 @@ 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.DebugSessionManager import eu.darken.capod.common.debug.recording.core.RecorderModule import eu.darken.capod.common.flow.DynamicStateFlow import eu.darken.capod.common.flow.SingleEventFlow @@ -21,7 +22,7 @@ import javax.inject.Inject class SupportViewModel @Inject constructor( dispatcherProvider: DispatcherProvider, private val webpageTool: WebpageTool, - private val recorderModule: RecorderModule, + private val sessionManager: DebugSessionManager, ) : ViewModel4(dispatcherProvider) { data class State( @@ -48,8 +49,8 @@ class SupportViewModel @Inject constructor( init { combine( - recorderModule.state, - recorderModule.sessions, + sessionManager.recorderState, + sessionManager.sessions, ) { recorderState, sessions -> stater.updateBlocking { copy( @@ -84,7 +85,7 @@ class SupportViewModel @Inject constructor( fun startDebugLog() = launch { log(TAG) { "startDebugLog()" } - recorderModule.startRecorder() + sessionManager.startRecording() } fun stopDebugLog() = launch { @@ -92,39 +93,42 @@ class SupportViewModel @Inject constructor( } private suspend fun doStopDebugLog() { - val recorderState = recorderModule.state.first() - val duration = System.currentTimeMillis() - recorderState.recordingStartedAt - if (duration < 5_000) { - events.tryEmit(Event.ShowShortRecordingWarning) - return + when (val result = sessionManager.requestStopRecording()) { + is RecorderModule.StopResult.TooShort -> events.tryEmit(Event.ShowShortRecordingWarning) + is RecorderModule.StopResult.Stopped -> { + log(TAG) { "stopDebugLog() -> ${result.sessionId}" } + events.tryEmit(Event.OpenRecorderActivity(result.sessionId, result.logDir.path)) + } + is RecorderModule.StopResult.NotRecording -> {} } - log(TAG) { "stopDebugLog()" } - recorderModule.stopRecorder() } fun forceStopDebugLog() = launch { log(TAG) { "forceStopDebugLog()" } - recorderModule.stopRecorder() + val result = sessionManager.forceStopRecording() + if (result != null) { + events.tryEmit(Event.OpenRecorderActivity(result.sessionId, result.logDir.path)) + } } fun clearDebugLogs() = launch { log(TAG) { "clearDebugLogs()" } - recorderModule.deleteAllLogs() + sessionManager.deleteAllSessions() } fun openSession(sessionId: String) = launch { - val session = recorderModule.sessions.first().firstOrNull { it.id == sessionId } ?: return@launch + val session = sessionManager.sessions.first().firstOrNull { it.id == sessionId } ?: return@launch val legacyPath = (session as? DebugSession.Ready)?.logDir?.path events.tryEmit(Event.OpenRecorderActivity(sessionId, legacyPath)) } fun refreshSessions() = launch { - recorderModule.refreshSessions() + sessionManager.refresh() } fun deleteSession(id: String) = launch { log(TAG) { "deleteSession($id)" } - recorderModule.deleteSession(id) + sessionManager.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 49ee589a..582976a2 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 @@ -1,6 +1,7 @@ package eu.darken.capod.main.ui.settings.support.contactform import android.content.ActivityNotFoundException +import android.text.format.DateUtils import android.text.format.Formatter import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -54,6 +55,7 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect import com.google.android.material.dialog.MaterialAlertDialogBuilder import eu.darken.capod.R import eu.darken.capod.common.WebpageTool @@ -68,6 +70,11 @@ fun ContactFormScreenHost(vm: ContactFormViewModel = hiltViewModel()) { ErrorEventHandler(vm) NavigationEventHandler(vm) + LifecycleResumeEffect(Unit) { + vm.refreshLogSessions() + onPauseOrDispose {} + } + val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } @@ -275,8 +282,15 @@ fun ContactFormScreen( text = session.displayName, style = MaterialTheme.typography.bodyMedium, ) + val sizeText = Formatter.formatShortFileSize(context, session.diskSize) + val agoText = DateUtils.getRelativeTimeSpanString( + session.createdAt, + System.currentTimeMillis(), + DateUtils.SECOND_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE, + ) Text( - text = Formatter.formatShortFileSize(context, session.diskSize), + text = "$sizeText · $agoText", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) 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 a5c5b769..ebe207cf 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 @@ -12,12 +12,12 @@ 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.DebugSessionManager 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 javax.inject.Inject @@ -25,7 +25,7 @@ import javax.inject.Inject class ContactFormViewModel @Inject constructor( dispatcherProvider: DispatcherProvider, @ApplicationContext private val context: Context, - private val recorderModule: RecorderModule, + private val sessionManager: DebugSessionManager, private val emailTool: EmailTool, ) : ViewModel4(dispatcherProvider) { @@ -68,22 +68,29 @@ class ContactFormViewModel @Inject constructor( private val stater = DynamicStateFlow(TAG, vmScope) { State() } val state = stater.flow + @Volatile private var autoSelectSessionId: String? = null + init { combine( - recorderModule.state, - recorderModule.sessions, + sessionManager.recorderState, + sessionManager.sessions, ) { recorderState, allSessions -> - val completed = allSessions.filterIsInstance() + val completed = allSessions.filterIsInstance().take(MAX_PICKER_SESSIONS) stater.updateBlocking { + val pendingAutoSelect = autoSelectSessionId + val newSelectedId = when { + pendingAutoSelect != null && completed.any { it.id == pendingAutoSelect } -> { + autoSelectSessionId = null + pendingAutoSelect + } + selectedSessionId != null && completed.none { it.id == selectedSessionId } -> null + else -> selectedSessionId + } copy( isRecording = recorderState.isRecording, recordingStartedAt = recorderState.recordingStartedAt, sessions = completed, - selectedSessionId = if (selectedSessionId != null && completed.none { it.id == selectedSessionId }) { - null - } else { - selectedSessionId - }, + selectedSessionId = newSelectedId, ) } }.launchIn(vmScope) @@ -111,11 +118,11 @@ class ContactFormViewModel @Inject constructor( fun deleteLogSession(id: String) = launch { log(TAG) { "deleteLogSession($id)" } - recorderModule.deleteSession(id) + sessionManager.deleteSession(id) } fun refreshLogSessions() = launch { - recorderModule.refreshSessions() + sessionManager.refresh() } fun startRecording() { @@ -124,23 +131,24 @@ class ContactFormViewModel @Inject constructor( fun doStartRecording() = launch { log(TAG) { "doStartRecording()" } - recorderModule.startRecorder() + sessionManager.startRecording() } fun stopRecording() = launch { - val recorderState = recorderModule.state.first() - val duration = System.currentTimeMillis() - recorderState.recordingStartedAt - if (duration < 5_000) { - events.tryEmit(Event.ShowShortRecordingWarning) - return@launch + when (val result = sessionManager.requestStopRecording()) { + is RecorderModule.StopResult.TooShort -> events.tryEmit(Event.ShowShortRecordingWarning) + is RecorderModule.StopResult.Stopped -> { + log(TAG) { "stopRecording() -> ${result.sessionId}" } + autoSelectSessionId = result.sessionId + } + is RecorderModule.StopResult.NotRecording -> {} } - log(TAG) { "stopRecording()" } - recorderModule.stopRecorder(showResultUi = false) } fun forceStopRecording() = launch { log(TAG) { "forceStopRecording()" } - recorderModule.stopRecorder(showResultUi = false) + val result = sessionManager.forceStopRecording() + if (result != null) autoSelectSessionId = result.sessionId } fun send() = launch { @@ -152,7 +160,7 @@ class ContactFormViewModel @Inject constructor( try { val attachmentUri = currentState.selectedSessionId?.let { sessionId -> try { - recorderModule.getZipUri(sessionId) + sessionManager.getZipUri(sessionId) } catch (e: Exception) { log(TAG) { "Failed to prepare attachment: $e" } events.tryEmit( @@ -205,5 +213,6 @@ class ContactFormViewModel @Inject constructor( companion object { private val TAG = logTag("Settings", "Support", "ContactForm", "VM") + private const val MAX_PICKER_SESSIONS = 3 } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6ed527a1..3a2d5f6f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -365,9 +365,11 @@ Compressing… Delete session? This debug session will be permanently deleted. + All debug sessions will be permanently deleted. Failed: log file is empty Failed: log file is missing Failed: zip file is corrupt + Failed: compression failed Contact developer diff --git a/app/src/test/java/eu/darken/capod/common/debug/recording/core/DebugSessionManagerSessionLogicTest.kt b/app/src/test/java/eu/darken/capod/common/debug/recording/core/DebugSessionManagerSessionLogicTest.kt new file mode 100644 index 00000000..8514335c --- /dev/null +++ b/app/src/test/java/eu/darken/capod/common/debug/recording/core/DebugSessionManagerSessionLogicTest.kt @@ -0,0 +1,223 @@ +package eu.darken.capod.common.debug.recording.core + +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import testhelpers.BaseTest +import java.io.File + +class DebugSessionManagerSessionLogicTest : BaseTest() { + + @TempDir + lateinit var tempDir: File + + private lateinit var externalLogsDir: File + private lateinit var cacheLogsDir: File + + @BeforeEach + fun setup() { + externalLogsDir = File(tempDir, "external/debug/logs").also { it.mkdirs() } + cacheLogsDir = File(tempDir, "cache/debug/logs").also { it.mkdirs() } + } + + private fun logDirs() = listOf(externalLogsDir, cacheLogsDir) + + @Nested + inner class ParseCreatedAt { + @Test + fun `standard format extracts timestamp`() { + DebugSessionManager.parseCreatedAt("capod_1.2.3_1709810400000_abcd1234", 99L) shouldBe 1709810400000L + } + + @Test + fun `zip suffix is stripped before parsing`() { + DebugSessionManager.parseCreatedAt("capod_1.2.3_1709810400000_abcd1234.zip", 99L) shouldBe 1709810400000L + } + + @Test + fun `too few parts returns fallback`() { + DebugSessionManager.parseCreatedAt("capod_1.2.3", 99L) shouldBe 99L + } + + @Test + fun `non-numeric timestamp returns fallback`() { + DebugSessionManager.parseCreatedAt("capod_1.2.3_notanumber_abcd1234", 99L) shouldBe 99L + } + + @Test + fun `timestamp in seconds returns fallback`() { + DebugSessionManager.parseCreatedAt("capod_1.2.3_1709810400_abcd1234", 99L) shouldBe 99L + } + + @Test + fun `empty string returns fallback`() { + DebugSessionManager.parseCreatedAt("", 42L) shouldBe 42L + } + } + + @Nested + inner class DeriveSessionId { + @Test + fun `external dir gets ext prefix`() { + val file = File("/storage/emulated/0/Android/data/pkg/files/debug/logs/session1") + DebugSessionManager.deriveSessionId(file) shouldBe "ext:session1" + } + + @Test + fun `cache dir gets cache prefix`() { + val file = File("/data/data/pkg/cache/debug/logs/session1") + DebugSessionManager.deriveSessionId(file) shouldBe "cache:session1" + } + + @Test + fun `zip suffix is stripped`() { + val file = File("/data/data/pkg/cache/debug/logs/session1.zip") + DebugSessionManager.deriveSessionId(file) shouldBe "cache:session1" + } + } + + @Nested + inner class ScanSessions { + @Test + fun `empty directories returns empty list`() { + val result = DebugSessionManager.scanSessions(logDirectories = logDirs()) + result.shouldBeEmpty() + } + + @Test + fun `dir with valid core log returns Ready`() { + val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() } + File(sessionDir, "core.log").writeText("some log content") + + val result = DebugSessionManager.scanSessions(logDirectories = logDirs()) + + result shouldHaveSize 1 + val session = result.first() + session.shouldBeInstanceOf() + session.id shouldBe "ext:capod_1.0_1700000000000_abcd1234" + session.logDir shouldBe sessionDir + session.zipFile shouldBe null + session.compressedSize shouldBe 0L + } + + @Test + fun `dir with empty core log returns Failed EMPTY_LOG`() { + val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() } + File(sessionDir, "core.log").createNewFile() + + val result = DebugSessionManager.scanSessions(logDirectories = logDirs()) + + result shouldHaveSize 1 + val session = result.first() + session.shouldBeInstanceOf() + (session as DebugSession.Failed).reason shouldBe DebugSession.Failed.Reason.EMPTY_LOG + } + + @Test + fun `dir with no core log returns Failed MISSING_LOG`() { + File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").mkdirs() + + val result = DebugSessionManager.scanSessions(logDirectories = logDirs()) + + result shouldHaveSize 1 + val session = result.first() + session.shouldBeInstanceOf() + (session as DebugSession.Failed).reason shouldBe DebugSession.Failed.Reason.MISSING_LOG + } + + @Test + fun `standalone non-empty zip returns Ready with null logDir`() { + File(externalLogsDir, "capod_1.0_1700000000000_abcd1234.zip").writeText("zipdata") + + val result = DebugSessionManager.scanSessions(logDirectories = logDirs()) + + result shouldHaveSize 1 + val session = result.first() + session.shouldBeInstanceOf() + (session as DebugSession.Ready).logDir shouldBe null + session.zipFile shouldBe File(externalLogsDir, "capod_1.0_1700000000000_abcd1234.zip") + session.compressedSize shouldBe File(externalLogsDir, "capod_1.0_1700000000000_abcd1234.zip").length() + } + + @Test + fun `standalone empty zip returns Failed CORRUPT_ZIP`() { + File(externalLogsDir, "capod_1.0_1700000000000_abcd1234.zip").createNewFile() + + val result = DebugSessionManager.scanSessions(logDirectories = logDirs()) + + result shouldHaveSize 1 + val session = result.first() + session.shouldBeInstanceOf() + (session as DebugSession.Failed).reason shouldBe DebugSession.Failed.Reason.CORRUPT_ZIP + } + + @Test + fun `dir plus sibling zip reports combined diskSize`() { + val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() } + File(sessionDir, "core.log").writeText("log content here") + File(externalLogsDir, "capod_1.0_1700000000000_abcd1234.zip").writeText("zipdata12345") + + val result = DebugSessionManager.scanSessions(logDirectories = logDirs()) + + result shouldHaveSize 1 + val session = result.first() as DebugSession.Ready + val expectedDirSize = sessionDir.walkTopDown().filter { it.isFile }.sumOf { it.length() } + val zipFile = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234.zip") + val expectedZipSize = zipFile.length() + session.diskSize shouldBe expectedDirSize + expectedZipSize + session.zipFile shouldBe zipFile + session.compressedSize shouldBe expectedZipSize + } + + @Test + fun `dir missing core log but valid sibling zip returns Ready with null logDir`() { + File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").mkdirs() + File(externalLogsDir, "capod_1.0_1700000000000_abcd1234.zip").writeText("zipdata") + + val result = DebugSessionManager.scanSessions(logDirectories = logDirs()) + + result shouldHaveSize 1 + val session = result.first() + session.shouldBeInstanceOf() + (session as DebugSession.Ready).logDir shouldBe null + session.zipFile shouldBe File(externalLogsDir, "capod_1.0_1700000000000_abcd1234.zip") + } + + @Test + fun `active recording dir returns Recording`() { + val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() } + File(sessionDir, "core.log").writeText("recording in progress") + + val result = DebugSessionManager.scanSessions( + logDirectories = logDirs(), + activeDir = sessionDir, + recordingStartedAt = 1700000000000L, + ) + + result shouldHaveSize 1 + val session = result.first() + session.shouldBeInstanceOf() + (session as DebugSession.Recording).startedAt shouldBe 1700000000000L + } + + @Test + fun `multiple sessions sorted by createdAt descending then id ascending`() { + val oldDir = File(externalLogsDir, "capod_1.0_1600000000000_abcd1234").also { it.mkdirs() } + File(oldDir, "core.log").writeText("old log") + + val newDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() } + File(newDir, "core.log").writeText("new log") + + val result = DebugSessionManager.scanSessions(logDirectories = logDirs()) + + result shouldHaveSize 2 + result[0].createdAt shouldBe 1700000000000L + result[1].createdAt shouldBe 1600000000000L + } + } +} diff --git a/app/src/test/java/eu/darken/capod/common/debug/recording/core/DebugSessionManagerTest.kt b/app/src/test/java/eu/darken/capod/common/debug/recording/core/DebugSessionManagerTest.kt new file mode 100644 index 00000000..112f6828 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/common/debug/recording/core/DebugSessionManagerTest.kt @@ -0,0 +1,214 @@ +package eu.darken.capod.common.debug.recording.core + +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import testhelpers.BaseTest +import java.io.File + +/** + * Tests for [DebugSessionManager] overlay and reconciliation logic. + * + * These tests exercise the instance-level behaviors (zippingIds overlay, failedZipIds overlay, + * orphan detection) by calling the companion [scanSessions] and then manually applying overlays, + * mirroring what [DebugSessionManager.applyOverlays] and [DebugSessionManager.reconcileOrphans] do. + * + * Full integration tests with mocked RecorderModule are deferred until MockK/Java 21 compat is resolved. + */ +class DebugSessionManagerTest : BaseTest() { + + @TempDir + lateinit var tempDir: File + + private lateinit var externalLogsDir: File + + @BeforeEach + fun setup() { + externalLogsDir = File(tempDir, "external/debug/logs").also { it.mkdirs() } + } + + private fun logDirs() = listOf(externalLogsDir) + + private fun scanAndOverlay( + zippingIds: Set = emptySet(), + failedZipIds: Set = emptySet(), + activeDir: File? = null, + recordingStartedAt: Long = 0L, + ): List { + val raw = DebugSessionManager.scanSessions( + logDirectories = logDirs(), + activeDir = activeDir, + recordingStartedAt = recordingStartedAt, + ) + return raw.map { session -> + when { + session.id in zippingIds -> DebugSession.Compressing( + id = session.id, + displayName = session.displayName, + createdAt = session.createdAt, + diskSize = session.diskSize, + path = (session as? DebugSession.Ready)?.logDir ?: File(""), + ) + + session.id in failedZipIds && session !is DebugSession.Failed -> DebugSession.Failed( + id = session.id, + displayName = session.displayName, + createdAt = session.createdAt, + diskSize = session.diskSize, + path = (session as? DebugSession.Ready)?.logDir ?: File(""), + reason = DebugSession.Failed.Reason.ZIP_FAILED, + ) + + else -> session + } + } + } + + @Nested + inner class ZippingIdsOverlay { + @Test + fun `session in zippingIds appears as Compressing`() { + val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() } + File(sessionDir, "core.log").writeText("done recording") + + val result = scanAndOverlay(zippingIds = setOf("ext:capod_1.0_1700000000000_abcd1234")) + + result shouldHaveSize 1 + result.first().shouldBeInstanceOf() + result.first().id shouldBe "ext:capod_1.0_1700000000000_abcd1234" + } + + @Test + fun `session not in zippingIds remains Ready`() { + val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() } + File(sessionDir, "core.log").writeText("done recording") + + val result = scanAndOverlay(zippingIds = setOf("ext:some_other_session")) + + result shouldHaveSize 1 + result.first().shouldBeInstanceOf() + } + } + + @Nested + inner class FailedZipIdsOverlay { + @Test + fun `session in failedZipIds appears as Failed ZIP_FAILED`() { + val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() } + File(sessionDir, "core.log").writeText("done recording") + + val result = scanAndOverlay(failedZipIds = setOf("ext:capod_1.0_1700000000000_abcd1234")) + + result shouldHaveSize 1 + val session = result.first() + session.shouldBeInstanceOf() + (session as DebugSession.Failed).reason shouldBe DebugSession.Failed.Reason.ZIP_FAILED + } + + @Test + fun `already-failed session is not overridden by failedZipIds`() { + File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").mkdirs() + + val result = scanAndOverlay(failedZipIds = setOf("ext:capod_1.0_1700000000000_abcd1234")) + + result shouldHaveSize 1 + val session = result.first() + session.shouldBeInstanceOf() + (session as DebugSession.Failed).reason shouldBe DebugSession.Failed.Reason.MISSING_LOG + } + } + + @Nested + inner class OrphanDetection { + @Test + fun `Ready session with logDir and sibling zip is not an orphan`() { + val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() } + File(sessionDir, "core.log").writeText("log content") + File(externalLogsDir, "capod_1.0_1700000000000_abcd1234.zip").writeText("zipdata") + + val sessions = scanAndOverlay() + + sessions shouldHaveSize 1 + val session = sessions.first() as DebugSession.Ready + session.logDir shouldBe sessionDir + session.zipFile shouldBe File(externalLogsDir, "capod_1.0_1700000000000_abcd1234.zip") + + val orphans = sessions.filterIsInstance().filter { ready -> + ready.logDir != null && (ready.zipFile == null || ready.compressedSize == 0L) + } + orphans.shouldBeEmpty() + } + + @Test + fun `Ready session with logDir but no sibling zip is detected as orphan`() { + val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() } + File(sessionDir, "core.log").writeText("log content") + + val sessions = scanAndOverlay() + + sessions shouldHaveSize 1 + val session = sessions.first() as DebugSession.Ready + session.logDir shouldBe sessionDir + session.zipFile shouldBe null + session.compressedSize shouldBe 0L + + val orphans = sessions.filterIsInstance().filter { ready -> + ready.logDir != null && (ready.zipFile == null || ready.compressedSize == 0L) + } + orphans shouldHaveSize 1 + orphans.first().id shouldBe "ext:capod_1.0_1700000000000_abcd1234" + } + + @Test + fun `orphan already in zippingIds is not re-detected`() { + val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() } + File(sessionDir, "core.log").writeText("log content") + + val zipping = setOf("ext:capod_1.0_1700000000000_abcd1234") + val sessions = scanAndOverlay(zippingIds = zipping) + + sessions shouldHaveSize 1 + sessions.first().shouldBeInstanceOf() + + // No Ready sessions remain, so no orphans to detect + val orphans = sessions.filterIsInstance().filter { ready -> + ready.logDir != null && ready.id !in zipping && + (ready.zipFile == null || ready.compressedSize == 0L) + } + orphans.shouldBeEmpty() + } + } + + @Nested + inner class DeleteGuards { + @Test + fun `active recording session cannot be zipped`() { + val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() } + File(sessionDir, "core.log").writeText("recording in progress") + + val sessions = scanAndOverlay(activeDir = sessionDir, recordingStartedAt = 1700000000000L) + + sessions shouldHaveSize 1 + sessions.first().shouldBeInstanceOf() + // Verify the session IS a Recording — the manager would reject zip/delete calls for this + } + + @Test + fun `zipping session should not be deleted`() { + val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() } + File(sessionDir, "core.log").writeText("done recording") + + val zipping = setOf("ext:capod_1.0_1700000000000_abcd1234") + val sessions = scanAndOverlay(zippingIds = zipping) + + sessions shouldHaveSize 1 + sessions.first().shouldBeInstanceOf() + // The manager would reject deleteSession for IDs in zippingIds + } + } +} diff --git a/app/src/test/java/eu/darken/capod/main/ui/settings/support/SupportViewModelStateTest.kt b/app/src/test/java/eu/darken/capod/main/ui/settings/support/SupportViewModelStateTest.kt new file mode 100644 index 00000000..56a6e66c --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/ui/settings/support/SupportViewModelStateTest.kt @@ -0,0 +1,128 @@ +package eu.darken.capod.main.ui.settings.support + +import eu.darken.capod.common.debug.recording.core.DebugSession +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import java.io.File + +class SupportViewModelStateTest : BaseTest() { + + private fun readySession(id: String = "ext:s1", diskSize: Long = 100L) = DebugSession.Ready( + id = id, + displayName = "s1", + createdAt = 1700000000000L, + diskSize = diskSize, + logDir = File("/tmp/s1"), + zipFile = null, + compressedSize = 0L, + ) + + private fun recordingSession(id: String = "ext:rec") = DebugSession.Recording( + id = id, + displayName = "rec", + createdAt = 1700000000000L, + diskSize = 50L, + path = File("/tmp/rec"), + startedAt = 1700000000000L, + ) + + private fun failedSession(id: String = "ext:fail", diskSize: Long = 10L) = DebugSession.Failed( + id = id, + displayName = "fail", + createdAt = 1700000000000L, + diskSize = diskSize, + path = File("/tmp/fail"), + reason = DebugSession.Failed.Reason.EMPTY_LOG, + ) + + private fun compressingSession(id: String = "ext:comp", diskSize: Long = 75L) = DebugSession.Compressing( + id = id, + displayName = "comp", + createdAt = 1700000000000L, + diskSize = diskSize, + path = File("/tmp/comp"), + ) + + @Nested + inner class LogSessionCount { + @Test + fun `empty sessions returns 0`() { + SupportViewModel.State().logSessionCount shouldBe 0 + } + + @Test + fun `excludes Recording sessions`() { + val state = SupportViewModel.State( + sessions = listOf(recordingSession(), readySession()), + ) + state.logSessionCount shouldBe 1 + } + + @Test + fun `includes Compressing and Failed sessions`() { + val state = SupportViewModel.State( + sessions = listOf(compressingSession(), failedSession(), readySession()), + ) + state.logSessionCount shouldBe 3 + } + + @Test + fun `only Recording sessions returns 0`() { + val state = SupportViewModel.State( + sessions = listOf(recordingSession()), + ) + state.logSessionCount shouldBe 0 + } + } + + @Nested + inner class LogFolderSize { + @Test + fun `empty sessions returns 0`() { + SupportViewModel.State().logFolderSize shouldBe 0L + } + + @Test + fun `sums all session diskSizes`() { + val state = SupportViewModel.State( + sessions = listOf( + readySession(id = "ext:a", diskSize = 100L), + failedSession(id = "ext:b", diskSize = 50L), + recordingSession(), + compressingSession(id = "ext:c", diskSize = 75L), + ), + ) + state.logFolderSize shouldBe 100L + 50L + 50L + 75L + } + } + + @Nested + inner class FailedSessions { + @Test + fun `empty sessions returns empty`() { + SupportViewModel.State().failedSessions.shouldBeEmpty() + } + + @Test + fun `no failed sessions returns empty`() { + val state = SupportViewModel.State( + sessions = listOf(readySession(), recordingSession()), + ) + state.failedSessions.shouldBeEmpty() + } + + @Test + fun `filters to only Failed instances`() { + val failed1 = failedSession(id = "ext:f1") + val failed2 = failedSession(id = "ext:f2") + val state = SupportViewModel.State( + sessions = listOf(readySession(), failed1, recordingSession(), failed2), + ) + state.failedSessions shouldHaveSize 2 + } + } +}