diff --git a/app/src/main/java/eu/darken/capod/common/debug/recording/core/RecorderModule.kt b/app/src/main/java/eu/darken/capod/common/debug/recording/core/RecorderModule.kt index 5be8eb7e..67aa6c0a 100644 --- a/app/src/main/java/eu/darken/capod/common/debug/recording/core/RecorderModule.kt +++ b/app/src/main/java/eu/darken/capod/common/debug/recording/core/RecorderModule.kt @@ -3,6 +3,7 @@ package eu.darken.capod.common.debug.recording.core import android.content.Context import android.os.Build import android.os.Environment +import androidx.annotation.VisibleForTesting import dagger.hilt.android.qualifiers.ApplicationContext import eu.darken.capod.common.BuildConfigWrap import eu.darken.capod.common.InstallId @@ -48,7 +49,12 @@ class RecorderModule @Inject constructor( private val internalState = DynamicStateFlow(TAG, appScope + dispatcherProvider.IO) { val triggerFileExists = triggerFile.exists() - State(shouldRecord = triggerFileExists) + val persistedInfo = if (triggerFileExists) readTriggerFile() else null + State( + shouldRecord = triggerFileExists, + persistedLogDir = persistedInfo?.first, + recordingStartedAt = persistedInfo?.second ?: 0L, + ) } val state: Flow = internalState.flow @@ -59,22 +65,44 @@ class RecorderModule @Inject constructor( internalState.updateBlocking { if (!isRecording && shouldRecord) { - val sessionDir = createSessionDir() + val isResume = persistedLogDir != null && persistedLogDir.exists() + val sessionDir = if (isResume) { + log(TAG, INFO) { "Resuming recording into existing session: $persistedLogDir" } + persistedLogDir + } else { + createSessionDir() + } val logFile = File(sessionDir, "core.log") val newRecorder = Recorder() newRecorder.start(logFile) - triggerFile.createNewFile() - log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" } - log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION}" } + if (!isResume) { + val startTime = System.currentTimeMillis() + writeTriggerFile(sessionDir, startTime) + log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" } + log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION}" } - this@RecorderModule.currentLogDir = sessionDir + this@RecorderModule.currentLogDir = sessionDir - copy( - recorder = newRecorder, - currentLogDir = sessionDir, - recordingStartedAt = System.currentTimeMillis(), - ) + copy( + recorder = newRecorder, + currentLogDir = sessionDir, + recordingStartedAt = startTime, + persistedLogDir = null, + ) + } else { + log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" } + log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION}" } + + this@RecorderModule.currentLogDir = sessionDir + + copy( + recorder = newRecorder, + currentLogDir = sessionDir, + recordingStartedAt = if (recordingStartedAt > 0L) recordingStartedAt else System.currentTimeMillis(), + persistedLogDir = null, + ) + } } else if (!shouldRecord && isRecording) { requireNotNull(recorder) { "Recorder is null despite isRecording" }.stop() @@ -170,6 +198,7 @@ class RecorderModule @Inject constructor( internal val recorder: Recorder? = null, val currentLogDir: File? = null, val recordingStartedAt: Long = 0L, + internal val persistedLogDir: File? = null, ) { val isRecording: Boolean get() = recorder != null @@ -178,9 +207,59 @@ class RecorderModule @Inject constructor( get() = recorder?.path } + internal fun readTriggerFile(): Pair? = try { + parseTriggerContent(triggerFile.readText()) + } catch (e: Exception) { + log(TAG, WARN) { "Failed to read trigger file: $e" } + null + } + + private fun writeTriggerFile(sessionDir: File, startTime: Long) { + try { + triggerFile.writeText("${sessionDir.absolutePath}\n$startTime") + } catch (e: Exception) { + log(TAG, WARN) { "Failed to write trigger file: $e" } + try { + triggerFile.createNewFile() + } catch (e2: Exception) { + log(TAG, ERROR) { "Failed to create trigger file fallback: $e2" } + } + } + } + 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 + + @VisibleForTesting + internal fun parseTriggerContent( + content: String, + now: Long = System.currentTimeMillis(), + ): Pair? { + val trimmed = content.trim() + if (trimmed.isEmpty()) return null + + val lines = trimmed.lines() + if (lines.size < 2) { + log(TAG, WARN) { "Trigger file has unexpected format: $trimmed" } + return null + } + + val dir = File(lines[0]) + val timestamp = lines[1].toLongOrNull() + + if (timestamp == null || timestamp !in 1..(now + 60_000L)) { + log(TAG, WARN) { "Trigger file has invalid timestamp: ts=$timestamp" } + return null + } + + if (!dir.exists()) { + log(TAG, WARN) { "Trigger file references non-existent dir: ${lines[0]}" } + return null + } + + return dir to timestamp + } } } diff --git a/app/src/test/java/eu/darken/capod/common/debug/recording/core/RecorderModuleStateTest.kt b/app/src/test/java/eu/darken/capod/common/debug/recording/core/RecorderModuleStateTest.kt index 8270e9b0..17fdb41c 100644 --- a/app/src/test/java/eu/darken/capod/common/debug/recording/core/RecorderModuleStateTest.kt +++ b/app/src/test/java/eu/darken/capod/common/debug/recording/core/RecorderModuleStateTest.kt @@ -33,5 +33,10 @@ class RecorderModuleStateTest : BaseTest() { fun `currentLogPath is null`() { RecorderModule.State().currentLogPath shouldBe null } + + @Test + fun `persistedLogDir is null`() { + RecorderModule.State().persistedLogDir shouldBe null + } } } diff --git a/app/src/test/java/eu/darken/capod/common/debug/recording/core/RecorderModuleTriggerFileTest.kt b/app/src/test/java/eu/darken/capod/common/debug/recording/core/RecorderModuleTriggerFileTest.kt new file mode 100644 index 00000000..2cfb267c --- /dev/null +++ b/app/src/test/java/eu/darken/capod/common/debug/recording/core/RecorderModuleTriggerFileTest.kt @@ -0,0 +1,134 @@ +package eu.darken.capod.common.debug.recording.core + +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.AfterEach +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 RecorderModuleTriggerFileTest : BaseTest() { + + @TempDir + lateinit var tempDir: File + + private val now = 1_700_000_000_000L + + @AfterEach + fun cleanup() { + tempDir.listFiles()?.forEach { it.deleteRecursively() } + } + + @Nested + inner class ParseTriggerContent { + + @Test + fun `valid content returns dir and timestamp`() { + val sessionDir = File(tempDir, "capod_session").also { it.mkdirs() } + val content = "${sessionDir.absolutePath}\n$now" + + val result = RecorderModule.parseTriggerContent(content, now = now) + + result shouldBe (sessionDir to now) + } + + @Test + fun `empty content returns null`() { + RecorderModule.parseTriggerContent("", now = now).shouldBeNull() + } + + @Test + fun `blank content returns null`() { + RecorderModule.parseTriggerContent(" \n ", now = now).shouldBeNull() + } + + @Test + fun `single line returns null`() { + RecorderModule.parseTriggerContent("/some/path", now = now).shouldBeNull() + } + + @Test + fun `non-numeric timestamp returns null`() { + val sessionDir = File(tempDir, "capod_session").also { it.mkdirs() } + val content = "${sessionDir.absolutePath}\nnotanumber" + + RecorderModule.parseTriggerContent(content, now = now).shouldBeNull() + } + + @Test + fun `zero timestamp returns null`() { + val sessionDir = File(tempDir, "capod_session").also { it.mkdirs() } + val content = "${sessionDir.absolutePath}\n0" + + RecorderModule.parseTriggerContent(content, now = now).shouldBeNull() + } + + @Test + fun `negative timestamp returns null`() { + val sessionDir = File(tempDir, "capod_session").also { it.mkdirs() } + val content = "${sessionDir.absolutePath}\n-1000" + + RecorderModule.parseTriggerContent(content, now = now).shouldBeNull() + } + + @Test + fun `future timestamp beyond skew returns null`() { + val sessionDir = File(tempDir, "capod_session").also { it.mkdirs() } + val futureTs = now + 120_000L + val content = "${sessionDir.absolutePath}\n$futureTs" + + RecorderModule.parseTriggerContent(content, now = now).shouldBeNull() + } + + @Test + fun `future timestamp within skew is accepted`() { + val sessionDir = File(tempDir, "capod_session").also { it.mkdirs() } + val nearFutureTs = now + 30_000L + val content = "${sessionDir.absolutePath}\n$nearFutureTs" + + val result = RecorderModule.parseTriggerContent(content, now = now) + + result shouldBe (sessionDir to nearFutureTs) + } + + @Test + fun `non-existent directory returns null`() { + val content = "/nonexistent/path/capod_session\n$now" + + RecorderModule.parseTriggerContent(content, now = now).shouldBeNull() + } + + @Test + fun `content with trailing whitespace is handled`() { + val sessionDir = File(tempDir, "capod_session").also { it.mkdirs() } + val content = " ${sessionDir.absolutePath}\n$now \n" + + val result = RecorderModule.parseTriggerContent(content, now = now) + + result shouldBe (sessionDir to now) + } + + @Test + fun `old timestamp is accepted`() { + val sessionDir = File(tempDir, "capod_session").also { it.mkdirs() } + val oldTs = 1_000L + val content = "${sessionDir.absolutePath}\n$oldTs" + + val result = RecorderModule.parseTriggerContent(content, now = now) + + result shouldBe (sessionDir to oldTs) + } + + @Test + fun `extra lines are ignored`() { + val sessionDir = File(tempDir, "capod_session").also { it.mkdirs() } + val content = "${sessionDir.absolutePath}\n$now\nextra\ndata" + + val result = RecorderModule.parseTriggerContent(content, now = now) + + result shouldBe (sessionDir to now) + } + } +}