mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
refactor(support): Add zipFile/compressedSize to Ready, harden session manager
Add zipFile and compressedSize fields to DebugSession.Ready, populated during scan. Simplify RecorderActivityVM by reading compressed size directly from session model. Apply code review fixes: fsMutex in zipSessionAsync, CancellationException handling, ZipOutputStream use{}, atomic file moves, dedup deriveSessionId, LifecycleResumeEffect for session refresh.
This commit is contained in:
@@ -15,22 +15,18 @@ class Zipper {
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun zip(files: Array<String>, zipFile: String) {
|
||||
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { out ->
|
||||
for (i in files.indices) {
|
||||
log(TAG, VERBOSE) { "Compressing ${files[i]} into $zipFile" }
|
||||
val origin = BufferedInputStream(FileInputStream(files[i]), BUFFER)
|
||||
|
||||
var origin: BufferedInputStream?
|
||||
val out = ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile)))
|
||||
val entry = ZipEntry(files[i].substring(files[i].lastIndexOf("/") + 1))
|
||||
out.putNextEntry(entry)
|
||||
|
||||
for (i in files.indices) {
|
||||
log(TAG, VERBOSE) { "Compressing ${files[i]} into $zipFile" }
|
||||
origin = BufferedInputStream(FileInputStream(files[i]), BUFFER)
|
||||
|
||||
val entry = ZipEntry(files[i].substring(files[i].lastIndexOf("/") + 1))
|
||||
out.putNextEntry(entry)
|
||||
|
||||
origin.use { input -> input.copyTo(out) }
|
||||
origin.use { input -> input.copyTo(out) }
|
||||
}
|
||||
out.finish()
|
||||
}
|
||||
|
||||
out.finish()
|
||||
out.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -8,31 +8,42 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.common.compression.Zipper
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class DebugLogZipper @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
fun zipAndGetUri(logDir: File): Uri {
|
||||
val logFiles = logDir.listFiles()?.toList()
|
||||
?: throw IllegalStateException("No log files in $logDir")
|
||||
fun zip(logDir: File): File {
|
||||
val logFiles = logDir.listFiles()?.filter { it.isFile }?.toList()
|
||||
?: throw IllegalStateException("Cannot list files in $logDir")
|
||||
require(logFiles.isNotEmpty()) { "No log files in $logDir" }
|
||||
|
||||
val zipFile = File(logDir.parentFile, "${logDir.name}.zip")
|
||||
val tempFile = File(logDir.parentFile, "${logDir.name}.zip.tmp")
|
||||
try {
|
||||
Zipper().zip(logFiles.map { it.path }.toTypedArray(), tempFile.path)
|
||||
tempFile.renameTo(zipFile)
|
||||
} catch (e: Exception) {
|
||||
try {
|
||||
Files.move(
|
||||
tempFile.toPath(),
|
||||
zipFile.toPath(),
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
)
|
||||
} catch (_: java.nio.file.AtomicMoveNotSupportedException) {
|
||||
Files.move(tempFile.toPath(), zipFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
throw e
|
||||
}
|
||||
return zipFile
|
||||
}
|
||||
|
||||
return FileProvider.getUriForFile(
|
||||
context,
|
||||
BuildConfigWrap.APPLICATION_ID + ".provider",
|
||||
zipFile,
|
||||
)
|
||||
fun zipAndGetUri(logDir: File): Uri {
|
||||
val zipFile = zip(logDir)
|
||||
return getUriForZip(zipFile)
|
||||
}
|
||||
|
||||
fun getUriForZip(zipFile: File): Uri {
|
||||
|
||||
@@ -31,6 +31,8 @@ sealed interface DebugSession {
|
||||
override val createdAt: Long,
|
||||
override val diskSize: Long,
|
||||
val logDir: File?,
|
||||
val zipFile: File?,
|
||||
val compressedSize: Long,
|
||||
) : DebugSession
|
||||
|
||||
data class Failed(
|
||||
@@ -41,6 +43,6 @@ sealed interface DebugSession {
|
||||
val path: File,
|
||||
val reason: Reason,
|
||||
) : DebugSession {
|
||||
enum class Reason { EMPTY_LOG, MISSING_LOG, CORRUPT_ZIP }
|
||||
enum class Reason { EMPTY_LOG, MISSING_LOG, CORRUPT_ZIP, ZIP_FAILED }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
package eu.darken.capod.common.debug.recording.core
|
||||
|
||||
import android.net.Uri
|
||||
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.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.replayingShare
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DebugSessionManager @Inject constructor(
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val recorderModule: RecorderModule,
|
||||
private val debugLogZipper: DebugLogZipper,
|
||||
) {
|
||||
|
||||
private val fsMutex = Mutex()
|
||||
private val zippingIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
private val failedZipIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
private val refreshTrigger = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
|
||||
val recorderState: Flow<RecorderModule.State> get() = recorderModule.state
|
||||
|
||||
val sessions: Flow<List<DebugSession>> = combine(
|
||||
recorderModule.state,
|
||||
zippingIds,
|
||||
failedZipIds,
|
||||
refreshTrigger.onStart { emit(Unit) },
|
||||
) { recorderState, zipping, failedZips, _ ->
|
||||
val raw = scanSessions(
|
||||
logDirectories = recorderModule.getLogDirectories(),
|
||||
activeDir = recorderState.currentLogDir,
|
||||
recordingStartedAt = recorderState.recordingStartedAt,
|
||||
)
|
||||
val overlaid = applyOverlays(raw, zipping, failedZips)
|
||||
reconcileOrphans(overlaid)
|
||||
overlaid
|
||||
}.replayingShare(appScope)
|
||||
|
||||
private fun applyOverlays(
|
||||
sessions: List<DebugSession>,
|
||||
zipping: Set<String>,
|
||||
failedZips: Set<String>,
|
||||
): List<DebugSession> = sessions.map { session ->
|
||||
when {
|
||||
session.id in zipping -> {
|
||||
val path = (session as? DebugSession.Ready)?.logDir
|
||||
if (path == null) log(TAG, WARN) { "No logDir for session in zippingIds: ${session.id}" }
|
||||
DebugSession.Compressing(
|
||||
id = session.id,
|
||||
displayName = session.displayName,
|
||||
createdAt = session.createdAt,
|
||||
diskSize = session.diskSize,
|
||||
path = path ?: File(""),
|
||||
)
|
||||
}
|
||||
|
||||
session.id in failedZips && session !is DebugSession.Failed -> {
|
||||
val path = (session as? DebugSession.Ready)?.logDir
|
||||
if (path == null) log(TAG, WARN) { "No logDir for failed-zip session: ${session.id}" }
|
||||
DebugSession.Failed(
|
||||
id = session.id,
|
||||
displayName = session.displayName,
|
||||
createdAt = session.createdAt,
|
||||
diskSize = session.diskSize,
|
||||
path = path ?: File(""),
|
||||
reason = DebugSession.Failed.Reason.ZIP_FAILED,
|
||||
)
|
||||
}
|
||||
|
||||
else -> session
|
||||
}
|
||||
}
|
||||
|
||||
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 zipSessionAsync(sessionId: String, logDir: File) {
|
||||
zippingIds.update { it + sessionId }
|
||||
appScope.launch(dispatcherProvider.IO) {
|
||||
try {
|
||||
fsMutex.withLock {
|
||||
debugLogZipper.zip(logDir)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "Zipping failed for $sessionId: $e" }
|
||||
failedZipIds.update { it + sessionId }
|
||||
} finally {
|
||||
zippingIds.update { it - sessionId }
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun startRecording(): File = recorderModule.startRecorder()
|
||||
|
||||
suspend fun requestStopRecording(): RecorderModule.StopResult {
|
||||
val result = recorderModule.requestStopRecorder()
|
||||
if (result is RecorderModule.StopResult.Stopped) {
|
||||
zipSessionAsync(result.sessionId, result.logDir)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun forceStopRecording(): RecorderModule.StopResult.Stopped? {
|
||||
val logDir = recorderModule.stopRecorder() ?: return null
|
||||
val sessionId = deriveSessionId(logDir)
|
||||
zipSessionAsync(sessionId, logDir)
|
||||
return RecorderModule.StopResult.Stopped(logDir, sessionId)
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
refreshTrigger.tryEmit(Unit)
|
||||
}
|
||||
|
||||
suspend fun zipSession(sessionId: String): File = fsMutex.withLock {
|
||||
val activeRecording = sessions.first().filterIsInstance<DebugSession.Recording>()
|
||||
.firstOrNull { it.id == sessionId }
|
||||
require(activeRecording == null) { "Cannot zip an active recording session" }
|
||||
|
||||
val (dir, existingZip) = findSessionFiles(sessionId)
|
||||
|
||||
if (existingZip != null && existingZip.length() > 0) {
|
||||
if (dir == null || existingZip.lastModified() >= dir.lastModified()) {
|
||||
return@withLock existingZip
|
||||
}
|
||||
}
|
||||
|
||||
requireNotNull(dir) { "No log directory found for session $sessionId" }
|
||||
withContext(dispatcherProvider.IO) {
|
||||
debugLogZipper.zip(dir)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getZipUri(sessionId: String): Uri {
|
||||
val zipFile = zipSession(sessionId)
|
||||
return debugLogZipper.getUriForZip(zipFile)
|
||||
}
|
||||
|
||||
suspend fun deleteSession(sessionId: String) = fsMutex.withLock {
|
||||
val currentSessions = sessions.first()
|
||||
val recording = currentSessions.filterIsInstance<DebugSession.Recording>()
|
||||
.firstOrNull { it.id == sessionId }
|
||||
require(recording == null) { "Cannot delete an active recording session" }
|
||||
require(sessionId !in zippingIds.value) { "Cannot delete a session that is being compressed" }
|
||||
|
||||
val (dir, zip) = findSessionFiles(sessionId)
|
||||
dir?.deleteRecursively()
|
||||
zip?.delete()
|
||||
failedZipIds.update { it - sessionId }
|
||||
|
||||
log(TAG) { "Deleted session: $sessionId" }
|
||||
refresh()
|
||||
}
|
||||
|
||||
suspend fun deleteAllSessions() = fsMutex.withLock {
|
||||
val activeDir = recorderModule.state.first().currentLogDir
|
||||
val currentlyZipping = zippingIds.value
|
||||
for (dir in recorderModule.getLogDirectories()) {
|
||||
if (!dir.exists()) continue
|
||||
for (entry in dir.listFiles() ?: emptyArray()) {
|
||||
if (entry == activeDir) {
|
||||
log(TAG) { "Skipping active session dir: $entry" }
|
||||
continue
|
||||
}
|
||||
val entryId = deriveSessionId(entry)
|
||||
if (entryId in currentlyZipping) {
|
||||
log(TAG) { "Skipping zipping session: $entry" }
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
entry.deleteRecursively()
|
||||
} else {
|
||||
entry.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
failedZipIds.update { emptySet() }
|
||||
log(TAG) { "All stored logs deleted" }
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun findSessionFiles(sessionId: String): Pair<File?, File?> {
|
||||
val baseName = sessionId.removePrefix("ext:").removePrefix("cache:")
|
||||
for (logParent in recorderModule.getLogDirectories()) {
|
||||
val dir = File(logParent, baseName)
|
||||
val zip = File(logParent, "$baseName.zip")
|
||||
val idPrefix = if (logParent.absolutePath.contains("/cache/debug/logs")) "cache:" else "ext:"
|
||||
if (idPrefix + baseName == sessionId) {
|
||||
val dirExists = dir.exists() && dir.isDirectory
|
||||
val zipExists = zip.exists() && zip.isFile
|
||||
if (dirExists || zipExists) {
|
||||
return Pair(if (dirExists) dir else null, if (zipExists) zip else null)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Pair(null, null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Debug", "Log", "Session", "Manager")
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun deriveSessionId(file: File): String {
|
||||
val prefix = if (file.absolutePath.contains("/cache/debug/logs")) "cache:" else "ext:"
|
||||
return prefix + file.name.removeSuffix(".zip")
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun scanSessions(
|
||||
logDirectories: List<File>,
|
||||
activeDir: File? = null,
|
||||
recordingStartedAt: Long = 0L,
|
||||
): List<DebugSession> {
|
||||
|
||||
data class RawEntry(val dir: File?, val zip: File?, val parentDir: File)
|
||||
|
||||
val entriesByBaseName = mutableMapOf<String, RawEntry>()
|
||||
|
||||
for (logParent in logDirectories) {
|
||||
if (!logParent.exists()) continue
|
||||
val files = logParent.listFiles() ?: continue
|
||||
for (file in files) {
|
||||
val baseName = file.name.removeSuffix(".zip")
|
||||
val key = logParent.absolutePath + "/" + baseName
|
||||
val existing = entriesByBaseName[key]
|
||||
if (file.isDirectory) {
|
||||
entriesByBaseName[key] = (existing ?: RawEntry(null, null, logParent)).copy(dir = file)
|
||||
} else if (file.isFile && file.extension == "zip") {
|
||||
entriesByBaseName[key] = (existing ?: RawEntry(null, null, logParent)).copy(zip = file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entriesByBaseName.map { (key, raw) ->
|
||||
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 dir = raw.dir
|
||||
val zip = raw.zip
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
if (dir != null) {
|
||||
val coreLog = File(dir, "core.log")
|
||||
val dirSize = dir.walkTopDown().filter { it.isFile }.sumOf { it.length() }
|
||||
|
||||
if (!coreLog.exists()) {
|
||||
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.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(
|
||||
id = id,
|
||||
displayName = baseName,
|
||||
createdAt = createdAt,
|
||||
diskSize = totalDiskSize,
|
||||
logDir = dir,
|
||||
zipFile = validZip,
|
||||
compressedSize = zipSize,
|
||||
)
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
package eu.darken.capod.common.debug.recording.core
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.common.InstallId
|
||||
import eu.darken.capod.common.compression.Zipper
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
@@ -16,7 +13,6 @@ 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
|
||||
import eu.darken.capod.common.debug.recording.ui.RecorderActivity
|
||||
import eu.darken.capod.common.flow.DynamicStateFlow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -24,10 +20,7 @@ import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -38,12 +31,8 @@ class RecorderModule @Inject constructor(
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val installId: InstallId,
|
||||
private val debugLogZipper: DebugLogZipper,
|
||||
) {
|
||||
|
||||
private val fsMutex = Mutex()
|
||||
@Volatile private var compressingSessionId: String? = null
|
||||
|
||||
private val triggerFile = try {
|
||||
File(context.getExternalFilesDir(null), FORCE_FILE)
|
||||
} catch (e: Exception) {
|
||||
@@ -87,30 +76,9 @@ class RecorderModule @Inject constructor(
|
||||
log(TAG, ERROR) { "Failed to delete trigger file" }
|
||||
}
|
||||
|
||||
val logDir = currentLogDir!!
|
||||
val sessionId = deriveSessionId(logDir)
|
||||
|
||||
compressingSessionId = sessionId
|
||||
sessionsState.updateBlocking {
|
||||
scanSessions(activeDir = null, recordingStartedAt = 0L)
|
||||
}
|
||||
|
||||
if (showResultUi) {
|
||||
val intent = RecorderActivity.getLaunchIntent(context, sessionId, logDir.path).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
appScope.launch(dispatcherProvider.IO) {
|
||||
autoCompress(sessionId)
|
||||
}
|
||||
|
||||
copy(
|
||||
recorder = null,
|
||||
currentLogDir = null,
|
||||
lastLogDir = logDir,
|
||||
showResultUi = true,
|
||||
recordingStartedAt = 0L,
|
||||
)
|
||||
} else {
|
||||
@@ -143,7 +111,7 @@ class RecorderModule @Inject constructor(
|
||||
return sessionDir
|
||||
}
|
||||
|
||||
private fun getLogDirectories(): List<File> = listOfNotNull(
|
||||
internal fun getLogDirectories(): List<File> = listOfNotNull(
|
||||
try {
|
||||
context.getExternalFilesDir(null)?.let { File(it, "debug/logs") }
|
||||
} catch (e: Exception) {
|
||||
@@ -152,286 +120,6 @@ class RecorderModule @Inject constructor(
|
||||
File(context.cacheDir, "debug/logs"),
|
||||
)
|
||||
|
||||
private val sessionsState = DynamicStateFlow(TAG, appScope + dispatcherProvider.IO) {
|
||||
val recState = internalState.value()
|
||||
scanSessions(activeDir = recState.currentLogDir, recordingStartedAt = recState.recordingStartedAt)
|
||||
}
|
||||
val sessions: Flow<List<DebugSession>> = sessionsState.flow
|
||||
|
||||
private fun deriveSessionId(file: File): String {
|
||||
val prefix = if (file.absolutePath.contains("/cache/")) "cache:" else "ext:"
|
||||
return prefix + file.name.removeSuffix(".zip")
|
||||
}
|
||||
|
||||
private fun parseCreatedAt(dirName: String, fallback: Long): Long {
|
||||
// Pattern: capod_{version}_{timestamp}_{installId}
|
||||
// Parse from right: installId is last segment, timestamp is second-to-last
|
||||
val parts = dirName.removeSuffix(".zip").split("_")
|
||||
if (parts.size >= 4) {
|
||||
// timestamp is second-to-last part
|
||||
val timestamp = parts[parts.size - 2].toLongOrNull()
|
||||
if (timestamp != null && timestamp > 1_000_000_000_000L) return timestamp
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
private fun scanSessions(
|
||||
activeDir: File? = null,
|
||||
recordingStartedAt: Long = 0L,
|
||||
): List<DebugSession> {
|
||||
|
||||
data class RawEntry(val dir: File?, val zip: File?, val parentDir: File)
|
||||
|
||||
val entriesByBaseName = mutableMapOf<String, RawEntry>()
|
||||
|
||||
for (logParent in getLogDirectories()) {
|
||||
if (!logParent.exists()) continue
|
||||
val files = logParent.listFiles() ?: continue
|
||||
for (file in files) {
|
||||
val baseName = file.name.removeSuffix(".zip")
|
||||
val key = logParent.absolutePath + "/" + baseName
|
||||
val existing = entriesByBaseName[key]
|
||||
if (file.isDirectory) {
|
||||
entriesByBaseName[key] = (existing ?: RawEntry(null, null, logParent)).copy(dir = file)
|
||||
} else if (file.isFile && file.extension == "zip") {
|
||||
entriesByBaseName[key] = (existing ?: RawEntry(null, null, logParent)).copy(zip = file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entriesByBaseName.map { (key, raw) ->
|
||||
val baseName = key.substringAfterLast("/")
|
||||
val prefix = if (key.contains("/cache/")) "cache:" else "ext:"
|
||||
val id = prefix + baseName
|
||||
val fallbackTime = (raw.dir ?: raw.zip)?.lastModified() ?: 0L
|
||||
val createdAt = parseCreatedAt(baseName, fallbackTime)
|
||||
|
||||
val dir = raw.dir
|
||||
val zip = raw.zip
|
||||
|
||||
// Is this the currently recording session?
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
// Check dir validity
|
||||
if (dir != null) {
|
||||
val coreLog = File(dir, "core.log")
|
||||
val dirSize = dir.walkTopDown().filter { it.isFile }.sumOf { it.length() }
|
||||
|
||||
if (!coreLog.exists()) {
|
||||
// Missing core.log — check if sibling zip is valid
|
||||
if (zip != null && zip.exists() && zip.length() > 0) {
|
||||
return@map DebugSession.Ready(
|
||||
id = id,
|
||||
displayName = baseName,
|
||||
createdAt = createdAt,
|
||||
diskSize = zip.length(),
|
||||
logDir = null,
|
||||
)
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
return@map DebugSession.Failed(
|
||||
id = id,
|
||||
displayName = baseName,
|
||||
createdAt = createdAt,
|
||||
diskSize = dirSize,
|
||||
path = dir,
|
||||
reason = DebugSession.Failed.Reason.EMPTY_LOG,
|
||||
)
|
||||
}
|
||||
|
||||
// Valid dir with core.log
|
||||
val zipSize = if (zip != null && zip.exists()) zip.length() else 0L
|
||||
val totalDiskSize = dirSize + zipSize
|
||||
return@map DebugSession.Ready(
|
||||
id = id,
|
||||
displayName = baseName,
|
||||
createdAt = createdAt,
|
||||
diskSize = totalDiskSize,
|
||||
logDir = dir,
|
||||
)
|
||||
}
|
||||
|
||||
// Standalone zip only
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
// Should not happen, but handle defensively
|
||||
DebugSession.Failed(
|
||||
id = id,
|
||||
displayName = baseName,
|
||||
createdAt = createdAt,
|
||||
diskSize = 0L,
|
||||
path = File(key),
|
||||
reason = DebugSession.Failed.Reason.MISSING_LOG,
|
||||
)
|
||||
}.map { session ->
|
||||
if (session is DebugSession.Ready && session.id == compressingSessionId) {
|
||||
DebugSession.Compressing(
|
||||
id = session.id,
|
||||
displayName = session.displayName,
|
||||
createdAt = session.createdAt,
|
||||
diskSize = session.diskSize,
|
||||
path = session.logDir ?: File(""),
|
||||
)
|
||||
} else {
|
||||
session
|
||||
}
|
||||
}.sortedByDescending { it.createdAt }
|
||||
}
|
||||
|
||||
suspend fun refreshSessions() {
|
||||
val recState = internalState.value()
|
||||
sessionsState.updateBlocking {
|
||||
scanSessions(activeDir = recState.currentLogDir, recordingStartedAt = recState.recordingStartedAt)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findSessionFiles(sessionId: String): Pair<File?, File?> {
|
||||
val baseName = sessionId.removePrefix("ext:").removePrefix("cache:")
|
||||
for (logParent in getLogDirectories()) {
|
||||
val dir = File(logParent, baseName)
|
||||
val zip = File(logParent, "$baseName.zip")
|
||||
val idPrefix = if (logParent.absolutePath.contains("/cache/")) "cache:" else "ext:"
|
||||
if (idPrefix + baseName == sessionId) {
|
||||
val dirExists = dir.exists() && dir.isDirectory
|
||||
val zipExists = zip.exists() && zip.isFile
|
||||
if (dirExists || zipExists) {
|
||||
return Pair(if (dirExists) dir else null, if (zipExists) zip else null)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Pair(null, null)
|
||||
}
|
||||
|
||||
private suspend fun autoCompress(sessionId: String) {
|
||||
try {
|
||||
zipSession(sessionId)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "Auto-compress failed for $sessionId: $e" }
|
||||
} finally {
|
||||
compressingSessionId = null
|
||||
refreshSessions()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun zipSession(sessionId: String): File = fsMutex.withLock {
|
||||
val (dir, existingZip) = findSessionFiles(sessionId)
|
||||
|
||||
// If zip already exists and is fresh, just return it
|
||||
if (existingZip != null && existingZip.length() > 0) {
|
||||
if (dir == null || existingZip.lastModified() >= dir.lastModified()) {
|
||||
return@withLock existingZip
|
||||
}
|
||||
}
|
||||
|
||||
requireNotNull(dir) { "No log directory found for session $sessionId" }
|
||||
|
||||
val logFiles = dir.listFiles()?.toList()
|
||||
?: throw IllegalStateException("No log files in $dir")
|
||||
|
||||
val targetZip = File(dir.parentFile, "${dir.name}.zip")
|
||||
val tempZip = File(dir.parentFile, "${dir.name}.zip.tmp")
|
||||
try {
|
||||
Zipper().zip(logFiles.map { it.path }.toTypedArray(), tempZip.path)
|
||||
tempZip.renameTo(targetZip)
|
||||
} catch (e: Exception) {
|
||||
tempZip.delete()
|
||||
throw e
|
||||
}
|
||||
|
||||
targetZip
|
||||
}
|
||||
|
||||
suspend fun deleteSession(sessionId: String) = fsMutex.withLock {
|
||||
val currentRecording = sessions.first().filterIsInstance<DebugSession.Recording>()
|
||||
.firstOrNull { it.id == sessionId }
|
||||
require(currentRecording == null) { "Cannot delete an active recording session" }
|
||||
|
||||
val (dir, zip) = findSessionFiles(sessionId)
|
||||
dir?.deleteRecursively()
|
||||
zip?.delete()
|
||||
|
||||
log(TAG) { "Deleted session: $sessionId" }
|
||||
val recState = internalState.value()
|
||||
sessionsState.updateBlocking {
|
||||
scanSessions(activeDir = recState.currentLogDir, recordingStartedAt = recState.recordingStartedAt)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getZipUri(sessionId: String): Uri {
|
||||
val zipFile = zipSession(sessionId)
|
||||
return debugLogZipper.getUriForZip(zipFile)
|
||||
}
|
||||
|
||||
suspend fun deleteAllLogs() = fsMutex.withLock {
|
||||
val activeDir = internalState.value().currentLogDir
|
||||
getLogDirectories().forEach { dir ->
|
||||
if (!dir.exists()) return@forEach
|
||||
dir.listFiles()?.forEach { entry ->
|
||||
if (entry == activeDir) {
|
||||
log(TAG) { "Skipping active session dir: $entry" }
|
||||
return@forEach
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
entry.deleteRecursively()
|
||||
} else {
|
||||
entry.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
log(TAG) { "All stored logs deleted" }
|
||||
val recState = internalState.value()
|
||||
sessionsState.updateBlocking {
|
||||
scanSessions(activeDir = recState.currentLogDir, recordingStartedAt = recState.recordingStartedAt)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun startRecorder(): File {
|
||||
internalState.updateBlocking {
|
||||
copy(shouldRecord = true)
|
||||
@@ -439,22 +127,39 @@ class RecorderModule @Inject constructor(
|
||||
return internalState.flow.filter { it.isRecording }.first().currentLogDir!!
|
||||
}
|
||||
|
||||
suspend fun stopRecorder(showResultUi: Boolean = true): File? {
|
||||
suspend fun stopRecorder(): File? {
|
||||
val currentDir = internalState.value().currentLogDir ?: return null
|
||||
internalState.updateBlocking {
|
||||
copy(shouldRecord = false, showResultUi = showResultUi)
|
||||
copy(shouldRecord = false)
|
||||
}
|
||||
internalState.flow.filter { !it.isRecording }.first()
|
||||
return currentDir
|
||||
}
|
||||
|
||||
suspend fun requestStopRecorder(): StopResult {
|
||||
val currentState = internalState.value()
|
||||
if (!currentState.isRecording) return StopResult.NotRecording
|
||||
|
||||
val logDir = currentState.currentLogDir ?: return StopResult.NotRecording
|
||||
val elapsed = System.currentTimeMillis() - currentState.recordingStartedAt
|
||||
if (elapsed < MIN_RECORDING_MS) return StopResult.TooShort
|
||||
|
||||
stopRecorder()
|
||||
val sessionId = DebugSessionManager.deriveSessionId(logDir)
|
||||
return StopResult.Stopped(logDir, sessionId)
|
||||
}
|
||||
|
||||
sealed class StopResult {
|
||||
data object TooShort : StopResult()
|
||||
data class Stopped(val logDir: File, val sessionId: String) : StopResult()
|
||||
data object NotRecording : StopResult()
|
||||
}
|
||||
|
||||
data class State(
|
||||
val shouldRecord: Boolean = false,
|
||||
internal val recorder: Recorder? = null,
|
||||
val currentLogDir: File? = null,
|
||||
val lastLogDir: File? = null,
|
||||
val recordingStartedAt: Long = 0L,
|
||||
internal val showResultUi: Boolean = true,
|
||||
) {
|
||||
val isRecording: Boolean
|
||||
get() = recorder != null
|
||||
@@ -466,5 +171,6 @@ class RecorderModule @Inject constructor(
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,16 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.theming.CapodTheme
|
||||
import eu.darken.capod.common.uix.Activity2
|
||||
@@ -60,13 +65,32 @@ class RecorderActivity : Activity2() {
|
||||
}
|
||||
}
|
||||
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDeleteConfirm) {
|
||||
LaunchedEffect(Unit) {
|
||||
MaterialAlertDialogBuilder(this@RecorderActivity).apply {
|
||||
setTitle(R.string.support_debuglog_session_delete_title)
|
||||
setMessage(R.string.support_debuglog_session_delete_message)
|
||||
setPositiveButton(R.string.profiles_delete_action) { _, _ ->
|
||||
showDeleteConfirm = false
|
||||
vm.discard()
|
||||
}
|
||||
setNegativeButton(R.string.general_cancel_action) { _, _ ->
|
||||
showDeleteConfirm = false
|
||||
}
|
||||
setOnCancelListener { showDeleteConfirm = false }
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
|
||||
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
|
||||
state?.let {
|
||||
RecorderScreen(
|
||||
state = it,
|
||||
onShare = { vm.share() },
|
||||
onKeep = { vm.keep() },
|
||||
onDiscard = { vm.discard() },
|
||||
onDiscard = { showDeleteConfirm = true },
|
||||
onPrivacyPolicy = { vm.goPrivacyPolicy() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.debug.recording.core.DebugSession
|
||||
import eu.darken.capod.common.debug.recording.core.RecorderModule
|
||||
import eu.darken.capod.common.debug.recording.core.DebugSessionManager
|
||||
import eu.darken.capod.common.flow.DynamicStateFlow
|
||||
import eu.darken.capod.common.flow.SingleEventFlow
|
||||
import eu.darken.capod.common.uix.ViewModel2
|
||||
@@ -28,7 +28,7 @@ class RecorderActivityVM @Inject constructor(
|
||||
handle: SavedStateHandle,
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val recorderModule: RecorderModule,
|
||||
private val sessionManager: DebugSessionManager,
|
||||
private val webpageTool: WebpageTool,
|
||||
) : ViewModel2(dispatcherProvider) {
|
||||
|
||||
@@ -56,18 +56,16 @@ class RecorderActivityVM @Inject constructor(
|
||||
|
||||
private suspend fun resolveSession(): DebugSession? {
|
||||
if (sessionId != null) {
|
||||
val session = recorderModule.sessions.first().firstOrNull { it.id == sessionId }
|
||||
val session = sessionManager.sessions.first().firstOrNull { it.id == sessionId }
|
||||
if (session != null) return session
|
||||
recorderModule.refreshSessions()
|
||||
return recorderModule.sessions.first().firstOrNull { it.id == sessionId }
|
||||
sessionManager.refresh()
|
||||
return sessionManager.sessions.first().firstOrNull { it.id == sessionId }
|
||||
}
|
||||
// Legacy fallback: derive ID from path
|
||||
if (legacyPath != null) {
|
||||
val file = File(legacyPath)
|
||||
val prefix = if (file.absolutePath.contains("/cache/")) "cache:" else "ext:"
|
||||
val derivedId = prefix + file.name.removeSuffix(".zip")
|
||||
recorderModule.refreshSessions()
|
||||
return recorderModule.sessions.first().firstOrNull { it.id == derivedId }
|
||||
val derivedId = DebugSessionManager.deriveSessionId(file)
|
||||
sessionManager.refresh()
|
||||
return sessionManager.sessions.first().firstOrNull { it.id == derivedId }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -92,16 +90,10 @@ class RecorderActivityVM @Inject constructor(
|
||||
val entries = files.map { LogEntry(it, it.length()) }
|
||||
val totalSize = entries.sumOf { it.size }
|
||||
|
||||
val compressedSize = if (isCompressing) {
|
||||
-1L
|
||||
} else {
|
||||
try {
|
||||
val zipFile = session?.id?.let { recorderModule.zipSession(it) }
|
||||
zipFile?.length() ?: -1L
|
||||
} catch (e: Exception) {
|
||||
log(TAG) { "Failed to zip: $e" }
|
||||
-1L
|
||||
}
|
||||
val compressedSize = when (session) {
|
||||
is DebugSession.Compressing -> -1L
|
||||
is DebugSession.Ready -> session.compressedSize.takeIf { it > 0 } ?: -1L
|
||||
else -> -1L
|
||||
}
|
||||
|
||||
val dirCreated = logDir.lastModified()
|
||||
@@ -122,20 +114,17 @@ class RecorderActivityVM @Inject constructor(
|
||||
val events = SingleEventFlow<Event>()
|
||||
|
||||
init {
|
||||
recorderModule.sessions
|
||||
sessionManager.sessions
|
||||
.onEach { allSessions ->
|
||||
val sid = sessionId ?: return@onEach
|
||||
val session = allSessions.firstOrNull { it.id == sid } ?: return@onEach
|
||||
if (session is DebugSession.Ready) {
|
||||
stater.updateBlocking {
|
||||
if (!isWorking) return@updateBlocking this
|
||||
val zipSize = try {
|
||||
recorderModule.zipSession(sid).length()
|
||||
} catch (e: Exception) {
|
||||
log(TAG) { "Failed to get zip size: $e" }
|
||||
-1L
|
||||
}
|
||||
copy(compressedSize = zipSize, isWorking = false)
|
||||
copy(
|
||||
compressedSize = session.compressedSize.takeIf { it > 0 } ?: -1L,
|
||||
isWorking = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +139,7 @@ class RecorderActivityVM @Inject constructor(
|
||||
stater.updateBlocking { copy(isWorking = true) }
|
||||
|
||||
try {
|
||||
val uri = recorderModule.getZipUri(sid)
|
||||
val uri = sessionManager.getZipUri(sid)
|
||||
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
@@ -174,7 +163,7 @@ class RecorderActivityVM @Inject constructor(
|
||||
|
||||
fun discard() = launch {
|
||||
val sid = sessionId ?: return@launch
|
||||
recorderModule.deleteSession(sid)
|
||||
sessionManager.deleteSession(sid)
|
||||
events.tryEmit(Event.Finish)
|
||||
}
|
||||
|
||||
|
||||
@@ -385,7 +385,7 @@ private fun BottomActionBar(
|
||||
onClick = onKeep,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(text = stringResource(R.string.general_save_action))
|
||||
Text(text = stringResource(R.string.general_done_action))
|
||||
}
|
||||
androidx.compose.material3.Button(
|
||||
onClick = onShare,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package eu.darken.capod.main.ui.settings.support
|
||||
|
||||
import android.content.Intent
|
||||
import android.text.format.DateUtils
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -133,7 +134,16 @@ fun SupportScreenHost(vm: SupportViewModel = hiltViewModel()) {
|
||||
}.show()
|
||||
},
|
||||
onStopRecording = { vm.onDebugLogToggle() },
|
||||
onClearLogs = { vm.clearDebugLogs() },
|
||||
onClearLogs = {
|
||||
MaterialAlertDialogBuilder(context).apply {
|
||||
setTitle(R.string.support_debuglog_session_delete_title)
|
||||
setMessage(R.string.support_debuglog_clear_all_message)
|
||||
setPositiveButton(R.string.profiles_delete_action) { _, _ ->
|
||||
vm.clearDebugLogs()
|
||||
}
|
||||
setNegativeButton(R.string.general_cancel_action) { _, _ -> }
|
||||
}.show()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -420,16 +430,23 @@ private fun SessionRow(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val agoText = DateUtils.getRelativeTimeSpanString(
|
||||
session.createdAt,
|
||||
System.currentTimeMillis(),
|
||||
DateUtils.SECOND_IN_MILLIS,
|
||||
DateUtils.FORMAT_ABBREV_RELATIVE,
|
||||
)
|
||||
Text(
|
||||
text = when (session) {
|
||||
is DebugSession.Recording -> stringResource(R.string.support_debuglog_session_recording)
|
||||
is DebugSession.Compressing -> stringResource(R.string.support_debuglog_session_compressing)
|
||||
is DebugSession.Ready -> Formatter.formatShortFileSize(context, session.diskSize)
|
||||
is DebugSession.Ready -> "${Formatter.formatShortFileSize(context, session.diskSize)} · $agoText"
|
||||
is DebugSession.Failed -> when (session.reason) {
|
||||
DebugSession.Failed.Reason.EMPTY_LOG -> stringResource(R.string.support_debuglog_failed_empty_log)
|
||||
DebugSession.Failed.Reason.MISSING_LOG -> stringResource(R.string.support_debuglog_failed_missing_log)
|
||||
DebugSession.Failed.Reason.CORRUPT_ZIP -> stringResource(R.string.support_debuglog_failed_corrupt_zip)
|
||||
}
|
||||
DebugSession.Failed.Reason.ZIP_FAILED -> stringResource(R.string.support_debuglog_failed_zip_failed)
|
||||
} + " · $agoText"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
|
||||
@@ -6,6 +6,7 @@ import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.debug.recording.core.DebugSession
|
||||
import eu.darken.capod.common.debug.recording.core.DebugSessionManager
|
||||
import eu.darken.capod.common.debug.recording.core.RecorderModule
|
||||
import eu.darken.capod.common.flow.DynamicStateFlow
|
||||
import eu.darken.capod.common.flow.SingleEventFlow
|
||||
@@ -21,7 +22,7 @@ import javax.inject.Inject
|
||||
class SupportViewModel @Inject constructor(
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
private val webpageTool: WebpageTool,
|
||||
private val recorderModule: RecorderModule,
|
||||
private val sessionManager: DebugSessionManager,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
data class State(
|
||||
@@ -48,8 +49,8 @@ class SupportViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
combine(
|
||||
recorderModule.state,
|
||||
recorderModule.sessions,
|
||||
sessionManager.recorderState,
|
||||
sessionManager.sessions,
|
||||
) { recorderState, sessions ->
|
||||
stater.updateBlocking {
|
||||
copy(
|
||||
@@ -84,7 +85,7 @@ class SupportViewModel @Inject constructor(
|
||||
|
||||
fun startDebugLog() = launch {
|
||||
log(TAG) { "startDebugLog()" }
|
||||
recorderModule.startRecorder()
|
||||
sessionManager.startRecording()
|
||||
}
|
||||
|
||||
fun stopDebugLog() = launch {
|
||||
@@ -92,39 +93,42 @@ class SupportViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun doStopDebugLog() {
|
||||
val recorderState = recorderModule.state.first()
|
||||
val duration = System.currentTimeMillis() - recorderState.recordingStartedAt
|
||||
if (duration < 5_000) {
|
||||
events.tryEmit(Event.ShowShortRecordingWarning)
|
||||
return
|
||||
when (val result = sessionManager.requestStopRecording()) {
|
||||
is RecorderModule.StopResult.TooShort -> events.tryEmit(Event.ShowShortRecordingWarning)
|
||||
is RecorderModule.StopResult.Stopped -> {
|
||||
log(TAG) { "stopDebugLog() -> ${result.sessionId}" }
|
||||
events.tryEmit(Event.OpenRecorderActivity(result.sessionId, result.logDir.path))
|
||||
}
|
||||
is RecorderModule.StopResult.NotRecording -> {}
|
||||
}
|
||||
log(TAG) { "stopDebugLog()" }
|
||||
recorderModule.stopRecorder()
|
||||
}
|
||||
|
||||
fun forceStopDebugLog() = launch {
|
||||
log(TAG) { "forceStopDebugLog()" }
|
||||
recorderModule.stopRecorder()
|
||||
val result = sessionManager.forceStopRecording()
|
||||
if (result != null) {
|
||||
events.tryEmit(Event.OpenRecorderActivity(result.sessionId, result.logDir.path))
|
||||
}
|
||||
}
|
||||
|
||||
fun clearDebugLogs() = launch {
|
||||
log(TAG) { "clearDebugLogs()" }
|
||||
recorderModule.deleteAllLogs()
|
||||
sessionManager.deleteAllSessions()
|
||||
}
|
||||
|
||||
fun openSession(sessionId: String) = launch {
|
||||
val session = recorderModule.sessions.first().firstOrNull { it.id == sessionId } ?: return@launch
|
||||
val session = sessionManager.sessions.first().firstOrNull { it.id == sessionId } ?: return@launch
|
||||
val legacyPath = (session as? DebugSession.Ready)?.logDir?.path
|
||||
events.tryEmit(Event.OpenRecorderActivity(sessionId, legacyPath))
|
||||
}
|
||||
|
||||
fun refreshSessions() = launch {
|
||||
recorderModule.refreshSessions()
|
||||
sessionManager.refresh()
|
||||
}
|
||||
|
||||
fun deleteSession(id: String) = launch {
|
||||
log(TAG) { "deleteSession($id)" }
|
||||
recorderModule.deleteSession(id)
|
||||
sessionManager.deleteSession(id)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
+15
-1
@@ -1,6 +1,7 @@
|
||||
package eu.darken.capod.main.ui.settings.support.contactform
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.text.format.DateUtils
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -54,6 +55,7 @@ import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.WebpageTool
|
||||
@@ -68,6 +70,11 @@ fun ContactFormScreenHost(vm: ContactFormViewModel = hiltViewModel()) {
|
||||
ErrorEventHandler(vm)
|
||||
NavigationEventHandler(vm)
|
||||
|
||||
LifecycleResumeEffect(Unit) {
|
||||
vm.refreshLogSessions()
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
@@ -275,8 +282,15 @@ fun ContactFormScreen(
|
||||
text = session.displayName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
val sizeText = Formatter.formatShortFileSize(context, session.diskSize)
|
||||
val agoText = DateUtils.getRelativeTimeSpanString(
|
||||
session.createdAt,
|
||||
System.currentTimeMillis(),
|
||||
DateUtils.SECOND_IN_MILLIS,
|
||||
DateUtils.FORMAT_ABBREV_RELATIVE,
|
||||
)
|
||||
Text(
|
||||
text = Formatter.formatShortFileSize(context, session.diskSize),
|
||||
text = "$sizeText · $agoText",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
+31
-22
@@ -12,12 +12,12 @@ import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.debug.recording.core.DebugSession
|
||||
import eu.darken.capod.common.debug.recording.core.DebugSessionManager
|
||||
import eu.darken.capod.common.debug.recording.core.RecorderModule
|
||||
import eu.darken.capod.common.flow.DynamicStateFlow
|
||||
import eu.darken.capod.common.flow.SingleEventFlow
|
||||
import eu.darken.capod.common.uix.ViewModel4
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -25,7 +25,7 @@ import javax.inject.Inject
|
||||
class ContactFormViewModel @Inject constructor(
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val recorderModule: RecorderModule,
|
||||
private val sessionManager: DebugSessionManager,
|
||||
private val emailTool: EmailTool,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
@@ -68,22 +68,29 @@ class ContactFormViewModel @Inject constructor(
|
||||
private val stater = DynamicStateFlow(TAG, vmScope) { State() }
|
||||
val state = stater.flow
|
||||
|
||||
@Volatile private var autoSelectSessionId: String? = null
|
||||
|
||||
init {
|
||||
combine(
|
||||
recorderModule.state,
|
||||
recorderModule.sessions,
|
||||
sessionManager.recorderState,
|
||||
sessionManager.sessions,
|
||||
) { recorderState, allSessions ->
|
||||
val completed = allSessions.filterIsInstance<DebugSession.Ready>()
|
||||
val completed = allSessions.filterIsInstance<DebugSession.Ready>().take(MAX_PICKER_SESSIONS)
|
||||
stater.updateBlocking {
|
||||
val pendingAutoSelect = autoSelectSessionId
|
||||
val newSelectedId = when {
|
||||
pendingAutoSelect != null && completed.any { it.id == pendingAutoSelect } -> {
|
||||
autoSelectSessionId = null
|
||||
pendingAutoSelect
|
||||
}
|
||||
selectedSessionId != null && completed.none { it.id == selectedSessionId } -> null
|
||||
else -> selectedSessionId
|
||||
}
|
||||
copy(
|
||||
isRecording = recorderState.isRecording,
|
||||
recordingStartedAt = recorderState.recordingStartedAt,
|
||||
sessions = completed,
|
||||
selectedSessionId = if (selectedSessionId != null && completed.none { it.id == selectedSessionId }) {
|
||||
null
|
||||
} else {
|
||||
selectedSessionId
|
||||
},
|
||||
selectedSessionId = newSelectedId,
|
||||
)
|
||||
}
|
||||
}.launchIn(vmScope)
|
||||
@@ -111,11 +118,11 @@ class ContactFormViewModel @Inject constructor(
|
||||
|
||||
fun deleteLogSession(id: String) = launch {
|
||||
log(TAG) { "deleteLogSession($id)" }
|
||||
recorderModule.deleteSession(id)
|
||||
sessionManager.deleteSession(id)
|
||||
}
|
||||
|
||||
fun refreshLogSessions() = launch {
|
||||
recorderModule.refreshSessions()
|
||||
sessionManager.refresh()
|
||||
}
|
||||
|
||||
fun startRecording() {
|
||||
@@ -124,23 +131,24 @@ class ContactFormViewModel @Inject constructor(
|
||||
|
||||
fun doStartRecording() = launch {
|
||||
log(TAG) { "doStartRecording()" }
|
||||
recorderModule.startRecorder()
|
||||
sessionManager.startRecording()
|
||||
}
|
||||
|
||||
fun stopRecording() = launch {
|
||||
val recorderState = recorderModule.state.first()
|
||||
val duration = System.currentTimeMillis() - recorderState.recordingStartedAt
|
||||
if (duration < 5_000) {
|
||||
events.tryEmit(Event.ShowShortRecordingWarning)
|
||||
return@launch
|
||||
when (val result = sessionManager.requestStopRecording()) {
|
||||
is RecorderModule.StopResult.TooShort -> events.tryEmit(Event.ShowShortRecordingWarning)
|
||||
is RecorderModule.StopResult.Stopped -> {
|
||||
log(TAG) { "stopRecording() -> ${result.sessionId}" }
|
||||
autoSelectSessionId = result.sessionId
|
||||
}
|
||||
is RecorderModule.StopResult.NotRecording -> {}
|
||||
}
|
||||
log(TAG) { "stopRecording()" }
|
||||
recorderModule.stopRecorder(showResultUi = false)
|
||||
}
|
||||
|
||||
fun forceStopRecording() = launch {
|
||||
log(TAG) { "forceStopRecording()" }
|
||||
recorderModule.stopRecorder(showResultUi = false)
|
||||
val result = sessionManager.forceStopRecording()
|
||||
if (result != null) autoSelectSessionId = result.sessionId
|
||||
}
|
||||
|
||||
fun send() = launch {
|
||||
@@ -152,7 +160,7 @@ class ContactFormViewModel @Inject constructor(
|
||||
try {
|
||||
val attachmentUri = currentState.selectedSessionId?.let { sessionId ->
|
||||
try {
|
||||
recorderModule.getZipUri(sessionId)
|
||||
sessionManager.getZipUri(sessionId)
|
||||
} catch (e: Exception) {
|
||||
log(TAG) { "Failed to prepare attachment: $e" }
|
||||
events.tryEmit(
|
||||
@@ -205,5 +213,6 @@ class ContactFormViewModel @Inject constructor(
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Settings", "Support", "ContactForm", "VM")
|
||||
private const val MAX_PICKER_SESSIONS = 3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,9 +365,11 @@
|
||||
<string name="support_debuglog_session_compressing">Compressing…</string>
|
||||
<string name="support_debuglog_session_delete_title">Delete session?</string>
|
||||
<string name="support_debuglog_session_delete_message">This debug session will be permanently deleted.</string>
|
||||
<string name="support_debuglog_clear_all_message">All debug sessions will be permanently deleted.</string>
|
||||
<string name="support_debuglog_failed_empty_log">Failed: log file is empty</string>
|
||||
<string name="support_debuglog_failed_missing_log">Failed: log file is missing</string>
|
||||
<string name="support_debuglog_failed_corrupt_zip">Failed: zip file is corrupt</string>
|
||||
<string name="support_debuglog_failed_zip_failed">Failed: compression failed</string>
|
||||
|
||||
<!-- Contact form -->
|
||||
<string name="support_contact_label">Contact developer</string>
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
package eu.darken.capod.common.debug.recording.core
|
||||
|
||||
import io.kotest.matchers.collections.shouldBeEmpty
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
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 DebugSessionManagerSessionLogicTest : BaseTest() {
|
||||
|
||||
@TempDir
|
||||
lateinit var tempDir: File
|
||||
|
||||
private lateinit var externalLogsDir: File
|
||||
private lateinit var cacheLogsDir: File
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
externalLogsDir = File(tempDir, "external/debug/logs").also { it.mkdirs() }
|
||||
cacheLogsDir = File(tempDir, "cache/debug/logs").also { it.mkdirs() }
|
||||
}
|
||||
|
||||
private fun logDirs() = listOf(externalLogsDir, cacheLogsDir)
|
||||
|
||||
@Nested
|
||||
inner class ParseCreatedAt {
|
||||
@Test
|
||||
fun `standard format extracts timestamp`() {
|
||||
DebugSessionManager.parseCreatedAt("capod_1.2.3_1709810400000_abcd1234", 99L) shouldBe 1709810400000L
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class DeriveSessionId {
|
||||
@Test
|
||||
fun `external dir gets ext prefix`() {
|
||||
val file = File("/storage/emulated/0/Android/data/pkg/files/debug/logs/session1")
|
||||
DebugSessionManager.deriveSessionId(file) shouldBe "ext:session1"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cache dir gets cache prefix`() {
|
||||
val file = File("/data/data/pkg/cache/debug/logs/session1")
|
||||
DebugSessionManager.deriveSessionId(file) shouldBe "cache:session1"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zip suffix is stripped`() {
|
||||
val file = File("/data/data/pkg/cache/debug/logs/session1.zip")
|
||||
DebugSessionManager.deriveSessionId(file) shouldBe "cache:session1"
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class ScanSessions {
|
||||
@Test
|
||||
fun `empty directories returns empty list`() {
|
||||
val result = DebugSessionManager.scanSessions(logDirectories = logDirs())
|
||||
result.shouldBeEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dir with valid core log returns Ready`() {
|
||||
val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() }
|
||||
File(sessionDir, "core.log").writeText("some log content")
|
||||
|
||||
val result = DebugSessionManager.scanSessions(logDirectories = logDirs())
|
||||
|
||||
result shouldHaveSize 1
|
||||
val session = result.first()
|
||||
session.shouldBeInstanceOf<DebugSession.Ready>()
|
||||
session.id shouldBe "ext:capod_1.0_1700000000000_abcd1234"
|
||||
session.logDir shouldBe sessionDir
|
||||
session.zipFile shouldBe null
|
||||
session.compressedSize shouldBe 0L
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dir with empty core log returns Failed EMPTY_LOG`() {
|
||||
val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() }
|
||||
File(sessionDir, "core.log").createNewFile()
|
||||
|
||||
val result = DebugSessionManager.scanSessions(logDirectories = logDirs())
|
||||
|
||||
result shouldHaveSize 1
|
||||
val session = result.first()
|
||||
session.shouldBeInstanceOf<DebugSession.Failed>()
|
||||
(session as DebugSession.Failed).reason shouldBe DebugSession.Failed.Reason.EMPTY_LOG
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dir with no core log returns Failed MISSING_LOG`() {
|
||||
File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").mkdirs()
|
||||
|
||||
val result = DebugSessionManager.scanSessions(logDirectories = logDirs())
|
||||
|
||||
result shouldHaveSize 1
|
||||
val session = result.first()
|
||||
session.shouldBeInstanceOf<DebugSession.Failed>()
|
||||
(session as DebugSession.Failed).reason shouldBe DebugSession.Failed.Reason.MISSING_LOG
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `standalone non-empty zip returns Ready with null logDir`() {
|
||||
File(externalLogsDir, "capod_1.0_1700000000000_abcd1234.zip").writeText("zipdata")
|
||||
|
||||
val result = DebugSessionManager.scanSessions(logDirectories = logDirs())
|
||||
|
||||
result shouldHaveSize 1
|
||||
val session = result.first()
|
||||
session.shouldBeInstanceOf<DebugSession.Ready>()
|
||||
(session as DebugSession.Ready).logDir shouldBe null
|
||||
session.zipFile shouldBe File(externalLogsDir, "capod_1.0_1700000000000_abcd1234.zip")
|
||||
session.compressedSize shouldBe File(externalLogsDir, "capod_1.0_1700000000000_abcd1234.zip").length()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `standalone empty zip returns Failed CORRUPT_ZIP`() {
|
||||
File(externalLogsDir, "capod_1.0_1700000000000_abcd1234.zip").createNewFile()
|
||||
|
||||
val result = DebugSessionManager.scanSessions(logDirectories = logDirs())
|
||||
|
||||
result shouldHaveSize 1
|
||||
val session = result.first()
|
||||
session.shouldBeInstanceOf<DebugSession.Failed>()
|
||||
(session as DebugSession.Failed).reason shouldBe DebugSession.Failed.Reason.CORRUPT_ZIP
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dir plus sibling zip reports combined diskSize`() {
|
||||
val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() }
|
||||
File(sessionDir, "core.log").writeText("log content here")
|
||||
File(externalLogsDir, "capod_1.0_1700000000000_abcd1234.zip").writeText("zipdata12345")
|
||||
|
||||
val result = DebugSessionManager.scanSessions(logDirectories = logDirs())
|
||||
|
||||
result shouldHaveSize 1
|
||||
val session = result.first() as DebugSession.Ready
|
||||
val expectedDirSize = sessionDir.walkTopDown().filter { it.isFile }.sumOf { it.length() }
|
||||
val zipFile = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234.zip")
|
||||
val expectedZipSize = zipFile.length()
|
||||
session.diskSize shouldBe expectedDirSize + expectedZipSize
|
||||
session.zipFile shouldBe zipFile
|
||||
session.compressedSize shouldBe expectedZipSize
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dir missing core log but valid sibling zip returns Ready with null logDir`() {
|
||||
File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").mkdirs()
|
||||
File(externalLogsDir, "capod_1.0_1700000000000_abcd1234.zip").writeText("zipdata")
|
||||
|
||||
val result = DebugSessionManager.scanSessions(logDirectories = logDirs())
|
||||
|
||||
result shouldHaveSize 1
|
||||
val session = result.first()
|
||||
session.shouldBeInstanceOf<DebugSession.Ready>()
|
||||
(session as DebugSession.Ready).logDir shouldBe null
|
||||
session.zipFile shouldBe File(externalLogsDir, "capod_1.0_1700000000000_abcd1234.zip")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active recording dir returns Recording`() {
|
||||
val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() }
|
||||
File(sessionDir, "core.log").writeText("recording in progress")
|
||||
|
||||
val result = DebugSessionManager.scanSessions(
|
||||
logDirectories = logDirs(),
|
||||
activeDir = sessionDir,
|
||||
recordingStartedAt = 1700000000000L,
|
||||
)
|
||||
|
||||
result shouldHaveSize 1
|
||||
val session = result.first()
|
||||
session.shouldBeInstanceOf<DebugSession.Recording>()
|
||||
(session as DebugSession.Recording).startedAt shouldBe 1700000000000L
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple sessions sorted by createdAt descending then id ascending`() {
|
||||
val oldDir = File(externalLogsDir, "capod_1.0_1600000000000_abcd1234").also { it.mkdirs() }
|
||||
File(oldDir, "core.log").writeText("old log")
|
||||
|
||||
val newDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() }
|
||||
File(newDir, "core.log").writeText("new log")
|
||||
|
||||
val result = DebugSessionManager.scanSessions(logDirectories = logDirs())
|
||||
|
||||
result shouldHaveSize 2
|
||||
result[0].createdAt shouldBe 1700000000000L
|
||||
result[1].createdAt shouldBe 1600000000000L
|
||||
}
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
package eu.darken.capod.common.debug.recording.core
|
||||
|
||||
import io.kotest.matchers.collections.shouldBeEmpty
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
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
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Full integration tests with mocked RecorderModule are deferred until MockK/Java 21 compat is resolved.
|
||||
*/
|
||||
class DebugSessionManagerTest : BaseTest() {
|
||||
|
||||
@TempDir
|
||||
lateinit var tempDir: File
|
||||
|
||||
private lateinit var externalLogsDir: File
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
externalLogsDir = File(tempDir, "external/debug/logs").also { it.mkdirs() }
|
||||
}
|
||||
|
||||
private fun logDirs() = listOf(externalLogsDir)
|
||||
|
||||
private fun scanAndOverlay(
|
||||
zippingIds: Set<String> = emptySet(),
|
||||
failedZipIds: Set<String> = emptySet(),
|
||||
activeDir: File? = null,
|
||||
recordingStartedAt: Long = 0L,
|
||||
): List<DebugSession> {
|
||||
val raw = DebugSessionManager.scanSessions(
|
||||
logDirectories = logDirs(),
|
||||
activeDir = activeDir,
|
||||
recordingStartedAt = recordingStartedAt,
|
||||
)
|
||||
return raw.map { session ->
|
||||
when {
|
||||
session.id in zippingIds -> DebugSession.Compressing(
|
||||
id = session.id,
|
||||
displayName = session.displayName,
|
||||
createdAt = session.createdAt,
|
||||
diskSize = session.diskSize,
|
||||
path = (session as? DebugSession.Ready)?.logDir ?: File(""),
|
||||
)
|
||||
|
||||
session.id in failedZipIds && session !is DebugSession.Failed -> DebugSession.Failed(
|
||||
id = session.id,
|
||||
displayName = session.displayName,
|
||||
createdAt = session.createdAt,
|
||||
diskSize = session.diskSize,
|
||||
path = (session as? DebugSession.Ready)?.logDir ?: File(""),
|
||||
reason = DebugSession.Failed.Reason.ZIP_FAILED,
|
||||
)
|
||||
|
||||
else -> session
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class ZippingIdsOverlay {
|
||||
@Test
|
||||
fun `session in zippingIds appears as Compressing`() {
|
||||
val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() }
|
||||
File(sessionDir, "core.log").writeText("done recording")
|
||||
|
||||
val result = scanAndOverlay(zippingIds = setOf("ext:capod_1.0_1700000000000_abcd1234"))
|
||||
|
||||
result shouldHaveSize 1
|
||||
result.first().shouldBeInstanceOf<DebugSession.Compressing>()
|
||||
result.first().id shouldBe "ext:capod_1.0_1700000000000_abcd1234"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session not in zippingIds remains Ready`() {
|
||||
val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() }
|
||||
File(sessionDir, "core.log").writeText("done recording")
|
||||
|
||||
val result = scanAndOverlay(zippingIds = setOf("ext:some_other_session"))
|
||||
|
||||
result shouldHaveSize 1
|
||||
result.first().shouldBeInstanceOf<DebugSession.Ready>()
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class FailedZipIdsOverlay {
|
||||
@Test
|
||||
fun `session in failedZipIds appears as Failed ZIP_FAILED`() {
|
||||
val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() }
|
||||
File(sessionDir, "core.log").writeText("done recording")
|
||||
|
||||
val result = scanAndOverlay(failedZipIds = setOf("ext:capod_1.0_1700000000000_abcd1234"))
|
||||
|
||||
result shouldHaveSize 1
|
||||
val session = result.first()
|
||||
session.shouldBeInstanceOf<DebugSession.Failed>()
|
||||
(session as DebugSession.Failed).reason shouldBe DebugSession.Failed.Reason.ZIP_FAILED
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `already-failed session is not overridden by failedZipIds`() {
|
||||
File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").mkdirs()
|
||||
|
||||
val result = scanAndOverlay(failedZipIds = setOf("ext:capod_1.0_1700000000000_abcd1234"))
|
||||
|
||||
result shouldHaveSize 1
|
||||
val session = result.first()
|
||||
session.shouldBeInstanceOf<DebugSession.Failed>()
|
||||
(session as DebugSession.Failed).reason shouldBe DebugSession.Failed.Reason.MISSING_LOG
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class OrphanDetection {
|
||||
@Test
|
||||
fun `Ready session with logDir and sibling zip is not an orphan`() {
|
||||
val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() }
|
||||
File(sessionDir, "core.log").writeText("log content")
|
||||
File(externalLogsDir, "capod_1.0_1700000000000_abcd1234.zip").writeText("zipdata")
|
||||
|
||||
val sessions = scanAndOverlay()
|
||||
|
||||
sessions shouldHaveSize 1
|
||||
val session = sessions.first() as DebugSession.Ready
|
||||
session.logDir shouldBe sessionDir
|
||||
session.zipFile shouldBe File(externalLogsDir, "capod_1.0_1700000000000_abcd1234.zip")
|
||||
|
||||
val orphans = sessions.filterIsInstance<DebugSession.Ready>().filter { ready ->
|
||||
ready.logDir != null && (ready.zipFile == null || ready.compressedSize == 0L)
|
||||
}
|
||||
orphans.shouldBeEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Ready session with logDir but no sibling zip is detected as orphan`() {
|
||||
val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() }
|
||||
File(sessionDir, "core.log").writeText("log content")
|
||||
|
||||
val sessions = scanAndOverlay()
|
||||
|
||||
sessions shouldHaveSize 1
|
||||
val session = sessions.first() as DebugSession.Ready
|
||||
session.logDir shouldBe sessionDir
|
||||
session.zipFile shouldBe null
|
||||
session.compressedSize shouldBe 0L
|
||||
|
||||
val orphans = sessions.filterIsInstance<DebugSession.Ready>().filter { ready ->
|
||||
ready.logDir != null && (ready.zipFile == null || ready.compressedSize == 0L)
|
||||
}
|
||||
orphans shouldHaveSize 1
|
||||
orphans.first().id shouldBe "ext:capod_1.0_1700000000000_abcd1234"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `orphan already in zippingIds is not re-detected`() {
|
||||
val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() }
|
||||
File(sessionDir, "core.log").writeText("log content")
|
||||
|
||||
val zipping = setOf("ext:capod_1.0_1700000000000_abcd1234")
|
||||
val sessions = scanAndOverlay(zippingIds = zipping)
|
||||
|
||||
sessions shouldHaveSize 1
|
||||
sessions.first().shouldBeInstanceOf<DebugSession.Compressing>()
|
||||
|
||||
// No Ready sessions remain, so no orphans to detect
|
||||
val orphans = sessions.filterIsInstance<DebugSession.Ready>().filter { ready ->
|
||||
ready.logDir != null && ready.id !in zipping &&
|
||||
(ready.zipFile == null || ready.compressedSize == 0L)
|
||||
}
|
||||
orphans.shouldBeEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class DeleteGuards {
|
||||
@Test
|
||||
fun `active recording session cannot be zipped`() {
|
||||
val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() }
|
||||
File(sessionDir, "core.log").writeText("recording in progress")
|
||||
|
||||
val sessions = scanAndOverlay(activeDir = sessionDir, recordingStartedAt = 1700000000000L)
|
||||
|
||||
sessions shouldHaveSize 1
|
||||
sessions.first().shouldBeInstanceOf<DebugSession.Recording>()
|
||||
// Verify the session IS a Recording — the manager would reject zip/delete calls for this
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zipping session should not be deleted`() {
|
||||
val sessionDir = File(externalLogsDir, "capod_1.0_1700000000000_abcd1234").also { it.mkdirs() }
|
||||
File(sessionDir, "core.log").writeText("done recording")
|
||||
|
||||
val zipping = setOf("ext:capod_1.0_1700000000000_abcd1234")
|
||||
val sessions = scanAndOverlay(zippingIds = zipping)
|
||||
|
||||
sessions shouldHaveSize 1
|
||||
sessions.first().shouldBeInstanceOf<DebugSession.Compressing>()
|
||||
// The manager would reject deleteSession for IDs in zippingIds
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package eu.darken.capod.main.ui.settings.support
|
||||
|
||||
import eu.darken.capod.common.debug.recording.core.DebugSession
|
||||
import io.kotest.matchers.collections.shouldBeEmpty
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
import java.io.File
|
||||
|
||||
class SupportViewModelStateTest : BaseTest() {
|
||||
|
||||
private fun readySession(id: String = "ext:s1", diskSize: Long = 100L) = DebugSession.Ready(
|
||||
id = id,
|
||||
displayName = "s1",
|
||||
createdAt = 1700000000000L,
|
||||
diskSize = diskSize,
|
||||
logDir = File("/tmp/s1"),
|
||||
zipFile = null,
|
||||
compressedSize = 0L,
|
||||
)
|
||||
|
||||
private fun recordingSession(id: String = "ext:rec") = DebugSession.Recording(
|
||||
id = id,
|
||||
displayName = "rec",
|
||||
createdAt = 1700000000000L,
|
||||
diskSize = 50L,
|
||||
path = File("/tmp/rec"),
|
||||
startedAt = 1700000000000L,
|
||||
)
|
||||
|
||||
private fun failedSession(id: String = "ext:fail", diskSize: Long = 10L) = DebugSession.Failed(
|
||||
id = id,
|
||||
displayName = "fail",
|
||||
createdAt = 1700000000000L,
|
||||
diskSize = diskSize,
|
||||
path = File("/tmp/fail"),
|
||||
reason = DebugSession.Failed.Reason.EMPTY_LOG,
|
||||
)
|
||||
|
||||
private fun compressingSession(id: String = "ext:comp", diskSize: Long = 75L) = DebugSession.Compressing(
|
||||
id = id,
|
||||
displayName = "comp",
|
||||
createdAt = 1700000000000L,
|
||||
diskSize = diskSize,
|
||||
path = File("/tmp/comp"),
|
||||
)
|
||||
|
||||
@Nested
|
||||
inner class LogSessionCount {
|
||||
@Test
|
||||
fun `empty sessions returns 0`() {
|
||||
SupportViewModel.State().logSessionCount shouldBe 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `excludes Recording sessions`() {
|
||||
val state = SupportViewModel.State(
|
||||
sessions = listOf(recordingSession(), readySession()),
|
||||
)
|
||||
state.logSessionCount shouldBe 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `includes Compressing and Failed sessions`() {
|
||||
val state = SupportViewModel.State(
|
||||
sessions = listOf(compressingSession(), failedSession(), readySession()),
|
||||
)
|
||||
state.logSessionCount shouldBe 3
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only Recording sessions returns 0`() {
|
||||
val state = SupportViewModel.State(
|
||||
sessions = listOf(recordingSession()),
|
||||
)
|
||||
state.logSessionCount shouldBe 0
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class LogFolderSize {
|
||||
@Test
|
||||
fun `empty sessions returns 0`() {
|
||||
SupportViewModel.State().logFolderSize shouldBe 0L
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sums all session diskSizes`() {
|
||||
val state = SupportViewModel.State(
|
||||
sessions = listOf(
|
||||
readySession(id = "ext:a", diskSize = 100L),
|
||||
failedSession(id = "ext:b", diskSize = 50L),
|
||||
recordingSession(),
|
||||
compressingSession(id = "ext:c", diskSize = 75L),
|
||||
),
|
||||
)
|
||||
state.logFolderSize shouldBe 100L + 50L + 50L + 75L
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class FailedSessions {
|
||||
@Test
|
||||
fun `empty sessions returns empty`() {
|
||||
SupportViewModel.State().failedSessions.shouldBeEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no failed sessions returns empty`() {
|
||||
val state = SupportViewModel.State(
|
||||
sessions = listOf(readySession(), recordingSession()),
|
||||
)
|
||||
state.failedSessions.shouldBeEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filters to only Failed instances`() {
|
||||
val failed1 = failedSession(id = "ext:f1")
|
||||
val failed2 = failedSession(id = "ext:f2")
|
||||
val state = SupportViewModel.State(
|
||||
sessions = listOf(readySession(), failed1, recordingSession(), failed2),
|
||||
)
|
||||
state.failedSessions shouldHaveSize 2
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user