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.
This commit is contained in:
darken
2026-03-09 17:34:29 +00:00
committed by Matthias Urhahn
parent 3423786f06
commit 8675d73a98
3 changed files with 229 additions and 11 deletions
@@ -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<State> = 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<File, Long>? = 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<File, Long>? {
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
}
}
}
@@ -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
}
}
}
@@ -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)
}
}
}