diff --git a/app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugSession.kt b/app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugSession.kt index aad6372f..f84cb8ea 100644 --- a/app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugSession.kt +++ b/app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugSession.kt @@ -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, diff --git a/app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugSessionManager.kt b/app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugSessionManager.kt index 06defe35..cdeb18df 100644 --- a/app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugSessionManager.kt +++ b/app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugSessionManager.kt @@ -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>(emptySet()) private val failedZipIds = MutableStateFlow>(emptySet()) private val refreshTrigger = MutableSharedFlow(extraBufferCapacity = 1) + private val pendingAutoZips = mutableSetOf() val recorderState: Flow 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) { - sessions.filterIsInstance().forEach { session -> - if (session.logDir == null) return@forEach - if (session.id in zippingIds.value) return@forEach - - if (session.zipFile == null || session.compressedSize == 0L) { - log(TAG, WARN) { "Orphan session detected, auto-zipping: ${session.id}" } - zipSessionAsync(session.id, session.logDir) - } - } + private fun findOrphans( + sessions: List, + zipping: Set, + ): List> { + return sessions.filterIsInstance() + .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 { 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, + ) + } + } } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportScreen.kt index 5543c59a..ad85dcc4 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportScreen.kt @@ -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, diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormScreen.kt index 582976a2..0fd9fded 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormScreen.kt @@ -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, diff --git a/app/src/test/java/eu/darken/capod/common/debug/recording/core/DebugSessionManagerSessionLogicTest.kt b/app/src/test/java/eu/darken/capod/common/debug/recording/core/DebugSessionManagerSessionLogicTest.kt index a4c3250a..92933821 100644 --- a/app/src/test/java/eu/darken/capod/common/debug/recording/core/DebugSessionManagerSessionLogicTest.kt +++ b/app/src/test/java/eu/darken/capod/common/debug/recording/core/DebugSessionManagerSessionLogicTest.kt @@ -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() } @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 } } } diff --git a/app/src/test/java/eu/darken/capod/common/debug/recording/core/DebugSessionManagerTest.kt b/app/src/test/java/eu/darken/capod/common/debug/recording/core/DebugSessionManagerTest.kt index 112f6828..205e6f72 100644 --- a/app/src/test/java/eu/darken/capod/common/debug/recording/core/DebugSessionManagerTest.kt +++ b/app/src/test/java/eu/darken/capod/common/debug/recording/core/DebugSessionManagerTest.kt @@ -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. */ diff --git a/app/src/test/java/eu/darken/capod/main/ui/settings/support/SupportViewModelStateTest.kt b/app/src/test/java/eu/darken/capod/main/ui/settings/support/SupportViewModelStateTest.kt index 56a6e66c..6f76d373 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/settings/support/SupportViewModelStateTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/settings/support/SupportViewModelStateTest.kt @@ -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"), )