From 8675d73a98cec5a4c5e1b18794135137c0b8ee85 Mon Sep 17 00:00:00 2001 From: darken Date: Mon, 9 Mar 2026 18:12:52 +0100 Subject: [PATCH] fix(debug): Persist recording session across app restarts The debug recording trigger file now stores the session directory path and start timestamp. On app restart, the recorder resumes into the same session directory and log file instead of creating a new one, preserving the original start time so the short-recording guard doesn't reset. --- .../debug/recording/core/RecorderModule.kt | 101 +++++++++++-- .../recording/core/RecorderModuleStateTest.kt | 5 + .../core/RecorderModuleTriggerFileTest.kt | 134 ++++++++++++++++++ 3 files changed, 229 insertions(+), 11 deletions(-) create mode 100644 app/src/test/java/eu/darken/capod/common/debug/recording/core/RecorderModuleTriggerFileTest.kt 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) + } + } +}