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.
This commit is contained in:
darken
2026-03-09 04:53:01 +00:00
committed by Matthias Urhahn
parent 7df74e3b44
commit 62e39ce3d2
16 changed files with 1149 additions and 416 deletions
@@ -15,22 +15,18 @@ class Zipper {
@Throws(Exception::class)
fun zip(files: Array<String>, 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 {
@@ -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 {
@@ -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 }
}
}
@@ -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<Set<String>>(emptySet())
private val failedZipIds = MutableStateFlow<Set<String>>(emptySet())
private val refreshTrigger = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val recorderState: Flow<RecorderModule.State> get() = recorderModule.state
val sessions: Flow<List<DebugSession>> = 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<DebugSession>,
zipping: Set<String>,
failedZips: Set<String>,
): List<DebugSession> = 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<DebugSession>) {
sessions.filterIsInstance<DebugSession.Ready>().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<DebugSession.Recording>()
.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<DebugSession.Recording>()
.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<File?, File?> {
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<File>,
activeDir: File? = null,
recordingStartedAt: Long = 0L,
): List<DebugSession> {
data class RawEntry(val dir: File?, val zip: File?, val parentDir: File)
val entriesByBaseName = mutableMapOf<String, RawEntry>()
for (logParent in 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<DebugSession> { it.createdAt }.thenBy { it.id })
}
}
}
@@ -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<File> = listOfNotNull(
internal fun getLogDirectories(): List<File> = 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<List<DebugSession>> = sessionsState.flow
private fun deriveSessionId(file: File): String {
val prefix = if (file.absolutePath.contains("/cache/")) "cache:" else "ext:"
return prefix + file.name.removeSuffix(".zip")
}
private fun parseCreatedAt(dirName: String, fallback: Long): Long {
// Pattern: capod_{version}_{timestamp}_{installId}
// Parse from right: installId is last segment, timestamp is second-to-last
val parts = dirName.removeSuffix(".zip").split("_")
if (parts.size >= 4) {
// timestamp is second-to-last part
val timestamp = parts[parts.size - 2].toLongOrNull()
if (timestamp != null && timestamp > 1_000_000_000_000L) return timestamp
}
return fallback
}
private fun scanSessions(
activeDir: File? = null,
recordingStartedAt: Long = 0L,
): List<DebugSession> {
data class RawEntry(val dir: File?, val zip: File?, val parentDir: File)
val entriesByBaseName = mutableMapOf<String, RawEntry>()
for (logParent in getLogDirectories()) {
if (!logParent.exists()) continue
val files = logParent.listFiles() ?: continue
for (file in files) {
val baseName = file.name.removeSuffix(".zip")
val key = logParent.absolutePath + "/" + baseName
val existing = entriesByBaseName[key]
if (file.isDirectory) {
entriesByBaseName[key] = (existing ?: RawEntry(null, null, logParent)).copy(dir = file)
} else if (file.isFile && file.extension == "zip") {
entriesByBaseName[key] = (existing ?: RawEntry(null, null, logParent)).copy(zip = file)
}
}
}
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<File?, File?> {
val baseName = sessionId.removePrefix("ext:").removePrefix("cache:")
for (logParent in getLogDirectories()) {
val dir = File(logParent, baseName)
val zip = File(logParent, "$baseName.zip")
val idPrefix = if (logParent.absolutePath.contains("/cache/")) "cache:" else "ext:"
if (idPrefix + baseName == sessionId) {
val dirExists = dir.exists() && dir.isDirectory
val zipExists = zip.exists() && zip.isFile
if (dirExists || zipExists) {
return Pair(if (dirExists) dir else null, if (zipExists) zip else null)
}
}
}
return Pair(null, null)
}
private suspend fun autoCompress(sessionId: String) {
try {
zipSession(sessionId)
} catch (e: Exception) {
log(TAG, ERROR) { "Auto-compress failed for $sessionId: $e" }
} finally {
compressingSessionId = null
refreshSessions()
}
}
suspend fun zipSession(sessionId: String): File = fsMutex.withLock {
val (dir, existingZip) = findSessionFiles(sessionId)
// If zip already exists and is fresh, just return it
if (existingZip != null && existingZip.length() > 0) {
if (dir == null || existingZip.lastModified() >= dir.lastModified()) {
return@withLock existingZip
}
}
requireNotNull(dir) { "No log directory found for session $sessionId" }
val logFiles = dir.listFiles()?.toList()
?: throw IllegalStateException("No log files in $dir")
val targetZip = File(dir.parentFile, "${dir.name}.zip")
val tempZip = File(dir.parentFile, "${dir.name}.zip.tmp")
try {
Zipper().zip(logFiles.map { it.path }.toTypedArray(), tempZip.path)
tempZip.renameTo(targetZip)
} catch (e: Exception) {
tempZip.delete()
throw e
}
targetZip
}
suspend fun deleteSession(sessionId: String) = fsMutex.withLock {
val currentRecording = sessions.first().filterIsInstance<DebugSession.Recording>()
.firstOrNull { it.id == sessionId }
require(currentRecording == null) { "Cannot delete an active recording session" }
val (dir, zip) = findSessionFiles(sessionId)
dir?.deleteRecursively()
zip?.delete()
log(TAG) { "Deleted session: $sessionId" }
val recState = internalState.value()
sessionsState.updateBlocking {
scanSessions(activeDir = recState.currentLogDir, recordingStartedAt = recState.recordingStartedAt)
}
}
suspend fun getZipUri(sessionId: String): Uri {
val zipFile = zipSession(sessionId)
return debugLogZipper.getUriForZip(zipFile)
}
suspend fun deleteAllLogs() = fsMutex.withLock {
val activeDir = internalState.value().currentLogDir
getLogDirectories().forEach { dir ->
if (!dir.exists()) return@forEach
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
}
}
@@ -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() },
)
}
@@ -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<Event>()
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)
}
@@ -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,
@@ -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,
@@ -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 {
@@ -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,
)
@@ -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<DebugSession.Ready>()
val completed = allSessions.filterIsInstance<DebugSession.Ready>().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
}
}
+2
View File
@@ -365,9 +365,11 @@
<string name="support_debuglog_session_compressing">Compressing…</string>
<string name="support_debuglog_session_delete_title">Delete session?</string>
<string name="support_debuglog_session_delete_message">This debug session will be permanently deleted.</string>
<string name="support_debuglog_clear_all_message">All debug sessions will be permanently deleted.</string>
<string name="support_debuglog_failed_empty_log">Failed: log file is empty</string>
<string name="support_debuglog_failed_missing_log">Failed: log file is missing</string>
<string name="support_debuglog_failed_corrupt_zip">Failed: zip file is corrupt</string>
<string name="support_debuglog_failed_zip_failed">Failed: compression failed</string>
<!-- Contact form -->
<string name="support_contact_label">Contact developer</string>
@@ -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<DebugSession.Ready>()
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<DebugSession.Failed>()
(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<DebugSession.Failed>()
(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<DebugSession.Ready>()
(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<DebugSession.Failed>()
(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<DebugSession.Ready>()
(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<DebugSession.Recording>()
(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
}
}
}
@@ -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<String> = emptySet(),
failedZipIds: Set<String> = emptySet(),
activeDir: File? = null,
recordingStartedAt: Long = 0L,
): List<DebugSession> {
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<DebugSession.Compressing>()
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<DebugSession.Ready>()
}
}
@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<DebugSession.Failed>()
(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<DebugSession.Failed>()
(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<DebugSession.Ready>().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<DebugSession.Ready>().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<DebugSession.Compressing>()
// No Ready sessions remain, so no orphans to detect
val orphans = sessions.filterIsInstance<DebugSession.Ready>().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<DebugSession.Recording>()
// 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<DebugSession.Compressing>()
// The manager would reject deleteSession for IDs in zippingIds
}
}
}
@@ -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
}
}
}