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
@@ -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
}
}
}