refactor(debug): Use Instant for createdAt, extract scanSessions helpers, add pendingAutoZips guard

This commit is contained in:
darken
2026-03-09 04:53:01 +00:00
committed by Matthias Urhahn
parent b2f8586dc9
commit 58bd0f91f5
7 changed files with 171 additions and 145 deletions
@@ -1,17 +1,18 @@
package eu.darken.capod.common.debug.recording.core
import java.io.File
import java.time.Instant
sealed interface DebugSession {
val id: String
val displayName: String
val createdAt: Long
val createdAt: Instant
val diskSize: Long
data class Recording(
override val id: String,
override val displayName: String,
override val createdAt: Long,
override val createdAt: Instant,
override val diskSize: Long,
val path: File,
val startedAt: Long,
@@ -20,7 +21,7 @@ sealed interface DebugSession {
data class Compressing(
override val id: String,
override val displayName: String,
override val createdAt: Long,
override val createdAt: Instant,
override val diskSize: Long,
val path: File,
) : DebugSession
@@ -28,7 +29,7 @@ sealed interface DebugSession {
data class Ready(
override val id: String,
override val displayName: String,
override val createdAt: Long,
override val createdAt: Instant,
override val diskSize: Long,
val logDir: File?,
val zipFile: File?,
@@ -38,7 +39,7 @@ sealed interface DebugSession {
data class Failed(
override val id: String,
override val displayName: String,
override val createdAt: Long,
override val createdAt: Instant,
override val diskSize: Long,
val path: File,
val reason: Reason,
@@ -5,6 +5,7 @@ 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.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
@@ -24,6 +25,9 @@ import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
import java.nio.file.Files
import java.nio.file.attribute.BasicFileAttributes
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@@ -39,6 +43,7 @@ class DebugSessionManager @Inject constructor(
private val zippingIds = MutableStateFlow<Set<String>>(emptySet())
private val failedZipIds = MutableStateFlow<Set<String>>(emptySet())
private val refreshTrigger = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
private val pendingAutoZips = mutableSetOf<String>()
val recorderState: Flow<RecorderModule.State> get() = recorderModule.state
@@ -54,7 +59,18 @@ class DebugSessionManager @Inject constructor(
recordingStartedAt = recorderState.recordingStartedAt,
)
val overlaid = applyOverlays(raw, zipping, failedZips)
reconcileOrphans(overlaid)
val orphans = findOrphans(overlaid, zipping)
if (orphans.isNotEmpty()) {
orphans.forEach { (id, _) -> pendingAutoZips.add(id) }
appScope.launch {
orphans.forEach { (id, dir) ->
log(TAG, INFO) { "Orphan session detected, auto-zipping: $id" }
zipSessionAsync(id, dir)
}
}
}
overlaid
}.replayingShare(appScope)
@@ -93,16 +109,14 @@ class DebugSessionManager @Inject constructor(
}
}
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 findOrphans(
sessions: List<DebugSession>,
zipping: Set<String>,
): List<Pair<String, File>> {
return sessions.filterIsInstance<DebugSession.Ready>()
.filter { it.logDir != null && it.id !in zipping && it.id !in pendingAutoZips }
.filter { it.zipFile == null || it.compressedSize == 0L }
.map { it.id to it.logDir!! }
}
private fun zipSessionAsync(sessionId: String, logDir: File) {
@@ -118,6 +132,7 @@ class DebugSessionManager @Inject constructor(
log(TAG, ERROR) { "Zipping failed for $sessionId: $e" }
failedZipIds.update { it + sessionId }
} finally {
pendingAutoZips.remove(sessionId)
zippingIds.update { it - sessionId }
refresh()
}
@@ -239,13 +254,21 @@ class DebugSessionManager @Inject constructor(
}
@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
internal fun parseCreatedAt(file: File): Instant = try {
val attrs = Files.readAttributes(file.toPath(), BasicFileAttributes::class.java)
attrs.creationTime().toInstant()
} catch (e: Exception) {
log(TAG, WARN) { "Failed to read creation time for ${file.name}: ${e.message}" }
Instant.ofEpochMilli(file.lastModified())
}
private fun computeDiskSize(file: File): Long {
if (!file.exists()) return 0L
return if (file.isDirectory) {
file.walkTopDown().filter { it.isFile }.sumOf { it.length() }
} else {
file.length()
}
return fallback
}
@VisibleForTesting
@@ -278,33 +301,44 @@ class DebugSessionManager @Inject constructor(
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 displayName = baseName
val dir = raw.dir
val zip = raw.zip
val referenceFile = dir ?: zip ?: File(raw.parentDir, baseName)
val createdAt = parseCreatedAt(referenceFile)
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,
)
}
when {
dir != null && dir == activeDir -> {
val dirSize = computeDiskSize(dir)
DebugSession.Recording(
id = id,
displayName = displayName,
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() }
dir != null && zip != null -> classifyWithZip(id, displayName, createdAt, dir, zip)
if (!coreLog.exists()) {
if (zip != null && zip.exists() && zip.length() > 0) {
return@map DebugSession.Ready(
dir != null -> classifyOrphan(id, displayName, createdAt, dir)
zip != null && zip.exists() -> {
if (zip.length() == 0L) {
DebugSession.Failed(
id = id,
displayName = baseName,
displayName = displayName,
createdAt = createdAt,
diskSize = 0L,
path = zip,
reason = DebugSession.Failed.Reason.CORRUPT_ZIP,
)
} else {
DebugSession.Ready(
id = id,
displayName = displayName,
createdAt = createdAt,
diskSize = zip.length(),
logDir = null,
@@ -312,83 +346,80 @@ class DebugSessionManager @Inject constructor(
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(
else -> DebugSession.Failed(
id = id,
displayName = baseName,
displayName = displayName,
createdAt = createdAt,
diskSize = totalDiskSize,
logDir = dir,
zipFile = validZip,
compressedSize = zipSize,
diskSize = 0L,
path = File(raw.parentDir, baseName),
reason = DebugSession.Failed.Reason.MISSING_LOG,
)
}
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 })
}
private fun classifyWithZip(
id: String,
displayName: String,
createdAt: Instant,
dir: File,
zip: File,
): DebugSession {
val coreLog = File(dir, "core.log")
val dirSize = computeDiskSize(dir)
val zipValid = zip.exists() && zip.length() > 0
val totalDiskSize = dirSize + (if (zipValid) zip.length() else 0L)
return when {
!coreLog.exists() && zipValid -> DebugSession.Ready(
id = id, displayName = displayName, createdAt = createdAt,
diskSize = zip.length(), logDir = null, zipFile = zip, compressedSize = zip.length(),
)
!coreLog.exists() -> DebugSession.Failed(
id = id, displayName = displayName, createdAt = createdAt,
diskSize = dirSize, path = dir, reason = DebugSession.Failed.Reason.MISSING_LOG,
)
coreLog.length() == 0L && zipValid -> DebugSession.Ready(
id = id, displayName = displayName, createdAt = createdAt,
diskSize = zip.length(), logDir = null, zipFile = zip, compressedSize = zip.length(),
)
coreLog.length() == 0L -> DebugSession.Failed(
id = id, displayName = displayName, createdAt = createdAt,
diskSize = dirSize, path = dir, reason = DebugSession.Failed.Reason.EMPTY_LOG,
)
else -> DebugSession.Ready(
id = id, displayName = displayName, createdAt = createdAt,
diskSize = totalDiskSize, logDir = dir, zipFile = if (zipValid) zip else null,
compressedSize = if (zipValid) zip.length() else 0L,
)
}
}
private fun classifyOrphan(
id: String,
displayName: String,
createdAt: Instant,
dir: File,
): DebugSession {
val coreLog = File(dir, "core.log")
val dirSize = computeDiskSize(dir)
return when {
!coreLog.exists() -> DebugSession.Failed(
id = id, displayName = displayName, createdAt = createdAt,
diskSize = dirSize, path = dir, reason = DebugSession.Failed.Reason.MISSING_LOG,
)
coreLog.length() == 0L -> DebugSession.Failed(
id = id, displayName = displayName, createdAt = createdAt,
diskSize = dirSize, path = dir, reason = DebugSession.Failed.Reason.EMPTY_LOG,
)
else -> DebugSession.Ready(
id = id, displayName = displayName, createdAt = createdAt,
diskSize = dirSize, logDir = dir, zipFile = null, compressedSize = 0L,
)
}
}
}
}
@@ -431,7 +431,7 @@ private fun SessionRow(
overflow = TextOverflow.Ellipsis,
)
val agoText = DateUtils.getRelativeTimeSpanString(
session.createdAt,
session.createdAt.toEpochMilli(),
System.currentTimeMillis(),
DateUtils.SECOND_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE,
@@ -284,7 +284,7 @@ fun ContactFormScreen(
)
val sizeText = Formatter.formatShortFileSize(context, session.diskSize)
val agoText = DateUtils.getRelativeTimeSpanString(
session.createdAt,
session.createdAt.toEpochMilli(),
System.currentTimeMillis(),
DateUtils.SECOND_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE,
@@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import testhelpers.BaseTest
import java.io.File
import java.time.Instant
class DebugSessionManagerSessionLogicTest : BaseTest() {
@@ -30,33 +31,19 @@ class DebugSessionManagerSessionLogicTest : BaseTest() {
@Nested
inner class ParseCreatedAt {
@Test
fun `standard format extracts timestamp`() {
DebugSessionManager.parseCreatedAt("capod_1.2.3_1709810400000_abcd1234", 99L) shouldBe 1709810400000L
fun `returns creation time from file attributes`() {
val file = File(externalLogsDir, "capod_1.2.3_1709810400000_abcd1234").also { it.mkdirs() }
val result = DebugSessionManager.parseCreatedAt(file)
// Should return a valid Instant (either from file attributes or lastModified fallback)
result.shouldBeInstanceOf<Instant>()
}
@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
fun `non-existent file returns epoch fallback`() {
val file = File(externalLogsDir, "nonexistent")
val result = DebugSessionManager.parseCreatedAt(file)
// Falls back to lastModified (0 for non-existent) → Instant.ofEpochMilli(0)
result shouldBe Instant.EPOCH
}
}
@@ -283,17 +270,20 @@ class DebugSessionManagerSessionLogicTest : BaseTest() {
@Test
fun `multiple sessions sorted by createdAt descending then id ascending`() {
// Create dirs with a time gap so filesystem timestamps differ
val oldDir = File(externalLogsDir, "capod_1.0_1600000000000_abcd1234").also { it.mkdirs() }
File(oldDir, "core.log").writeText("old log")
oldDir.setLastModified(1600000000000L)
val newDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() }
File(newDir, "core.log").writeText("new log")
newDir.setLastModified(1700000000000L)
val result = DebugSessionManager.scanSessions(logDirectories = logDirs())
result shouldHaveSize 2
result[0].createdAt shouldBe 1700000000000L
result[1].createdAt shouldBe 1600000000000L
// Newer session should come first (descending)
result[0].createdAt.isAfter(result[1].createdAt) shouldBe true
}
}
}
@@ -10,13 +10,14 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import testhelpers.BaseTest
import java.io.File
import java.time.Instant
/**
* 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.
* mirroring what [DebugSessionManager.applyOverlays] and [DebugSessionManager.findOrphans] do.
*
* Full integration tests with mocked RecorderModule are deferred until MockK/Java 21 compat is resolved.
*/
@@ -8,13 +8,16 @@ import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.io.File
import java.time.Instant
class SupportViewModelStateTest : BaseTest() {
private val testInstant = Instant.ofEpochMilli(1700000000000L)
private fun readySession(id: String = "ext:s1", diskSize: Long = 100L) = DebugSession.Ready(
id = id,
displayName = "s1",
createdAt = 1700000000000L,
createdAt = testInstant,
diskSize = diskSize,
logDir = File("/tmp/s1"),
zipFile = null,
@@ -24,7 +27,7 @@ class SupportViewModelStateTest : BaseTest() {
private fun recordingSession(id: String = "ext:rec") = DebugSession.Recording(
id = id,
displayName = "rec",
createdAt = 1700000000000L,
createdAt = testInstant,
diskSize = 50L,
path = File("/tmp/rec"),
startedAt = 1700000000000L,
@@ -33,7 +36,7 @@ class SupportViewModelStateTest : BaseTest() {
private fun failedSession(id: String = "ext:fail", diskSize: Long = 10L) = DebugSession.Failed(
id = id,
displayName = "fail",
createdAt = 1700000000000L,
createdAt = testInstant,
diskSize = diskSize,
path = File("/tmp/fail"),
reason = DebugSession.Failed.Reason.EMPTY_LOG,
@@ -42,7 +45,7 @@ class SupportViewModelStateTest : BaseTest() {
private fun compressingSession(id: String = "ext:comp", diskSize: Long = 75L) = DebugSession.Compressing(
id = id,
displayName = "comp",
createdAt = 1700000000000L,
createdAt = testInstant,
diskSize = diskSize,
path = File("/tmp/comp"),
)