mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
refactor(support): Unify debug log session model and add session manager bottom sheet
Introduce DebugSession sealed interface as single source of truth for session lifecycle (Recording, Compressing, Ready, Failed). Auto-compress after recording stops. Replace scattered clear/delete actions with a session manager bottom sheet on the Support screen.
This commit is contained in:
@@ -19,7 +19,14 @@ class DebugLogZipper @Inject constructor(
|
||||
?: throw IllegalStateException("No log files in $logDir")
|
||||
|
||||
val zipFile = File(logDir.parentFile, "${logDir.name}.zip")
|
||||
Zipper().zip(logFiles.map { it.path }.toTypedArray(), zipFile.path)
|
||||
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) {
|
||||
tempFile.delete()
|
||||
throw e
|
||||
}
|
||||
|
||||
return FileProvider.getUriForFile(
|
||||
context,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package eu.darken.capod.common.debug.recording.core
|
||||
|
||||
import java.io.File
|
||||
|
||||
sealed interface DebugSession {
|
||||
val id: String
|
||||
val displayName: String
|
||||
val createdAt: Long
|
||||
val diskSize: Long
|
||||
|
||||
data class Recording(
|
||||
override val id: String,
|
||||
override val displayName: String,
|
||||
override val createdAt: Long,
|
||||
override val diskSize: Long,
|
||||
val path: File,
|
||||
val startedAt: Long,
|
||||
) : DebugSession
|
||||
|
||||
data class Compressing(
|
||||
override val id: String,
|
||||
override val displayName: String,
|
||||
override val createdAt: Long,
|
||||
override val diskSize: Long,
|
||||
val path: File,
|
||||
) : DebugSession
|
||||
|
||||
data class Ready(
|
||||
override val id: String,
|
||||
override val displayName: String,
|
||||
override val createdAt: Long,
|
||||
override val diskSize: Long,
|
||||
val logDir: File?,
|
||||
) : DebugSession
|
||||
|
||||
data class Failed(
|
||||
override val id: String,
|
||||
override val displayName: String,
|
||||
override val createdAt: Long,
|
||||
override val diskSize: Long,
|
||||
val path: File,
|
||||
val reason: Reason,
|
||||
) : DebugSession {
|
||||
enum class Reason { EMPTY_LOG, MISSING_LOG, CORRUPT_ZIP }
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ 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
|
||||
@@ -22,7 +24,10 @@ 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
|
||||
@@ -33,8 +38,12 @@ 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) {
|
||||
@@ -79,14 +88,24 @@ class RecorderModule @Inject constructor(
|
||||
}
|
||||
|
||||
val logDir = currentLogDir!!
|
||||
val sessionId = deriveSessionId(logDir)
|
||||
|
||||
compressingSessionId = sessionId
|
||||
sessionsState.updateBlocking {
|
||||
scanSessions(activeDir = null, recordingStartedAt = 0L)
|
||||
}
|
||||
|
||||
if (showResultUi) {
|
||||
val intent = RecorderActivity.getLaunchIntent(context, logDir.path).apply {
|
||||
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,
|
||||
@@ -124,7 +143,7 @@ class RecorderModule @Inject constructor(
|
||||
return sessionDir
|
||||
}
|
||||
|
||||
fun getLogDirectories(): List<File> = listOfNotNull(
|
||||
private fun getLogDirectories(): List<File> = listOfNotNull(
|
||||
try {
|
||||
context.getExternalFilesDir(null)?.let { File(it, "debug/logs") }
|
||||
} catch (e: Exception) {
|
||||
@@ -133,27 +152,264 @@ class RecorderModule @Inject constructor(
|
||||
File(context.cacheDir, "debug/logs"),
|
||||
)
|
||||
|
||||
fun getLogSessionCount(): Int {
|
||||
return getLogDirectories().sumOf { dir ->
|
||||
if (!dir.exists()) return@sumOf 0
|
||||
dir.listFiles()?.count { it.isDirectory || (it.isFile && it.extension == "zip") } ?: 0
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
fun getLogFolderSize(): Long {
|
||||
return getLogDirectories().sumOf { dir ->
|
||||
if (!dir.exists()) return@sumOf 0L
|
||||
dir.listFiles()?.sumOf { entry ->
|
||||
if (entry.isDirectory) {
|
||||
entry.walkTopDown().filter { it.isFile }.sumOf { it.length() }
|
||||
} else {
|
||||
entry.length()
|
||||
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)
|
||||
}
|
||||
} ?: 0L
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteAllLogs() {
|
||||
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
|
||||
@@ -170,6 +426,10 @@ class RecorderModule @Inject constructor(
|
||||
}
|
||||
}
|
||||
log(TAG) { "All stored logs deleted" }
|
||||
val recState = internalState.value()
|
||||
sessionsState.updateBlocking {
|
||||
scanSessions(activeDir = recState.currentLogDir, recordingStartedAt = recState.recordingStartedAt)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun startRecorder(): File {
|
||||
|
||||
@@ -34,7 +34,7 @@ class RecorderActivity : Activity2() {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (intent.getStringExtra(RECORD_PATH) == null) {
|
||||
if (intent.getStringExtra(RECORD_SESSION_ID) == null && intent.getStringExtra(RECORD_PATH) == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
@@ -76,11 +76,13 @@ class RecorderActivity : Activity2() {
|
||||
|
||||
companion object {
|
||||
internal val TAG = logTag("Debug", "Log", "RecorderActivity")
|
||||
const val RECORD_SESSION_ID = "sessionId"
|
||||
const val RECORD_PATH = "logPath"
|
||||
|
||||
fun getLaunchIntent(context: Context, path: String): Intent {
|
||||
fun getLaunchIntent(context: Context, sessionId: String, legacyPath: String? = null): Intent {
|
||||
val intent = Intent(context, RecorderActivity::class.java)
|
||||
intent.putExtra(RECORD_PATH, path)
|
||||
intent.putExtra(RECORD_SESSION_ID, sessionId)
|
||||
if (legacyPath != null) intent.putExtra(RECORD_PATH, legacyPath)
|
||||
return intent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,14 @@ import eu.darken.capod.common.WebpageTool
|
||||
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.DebugLogZipper
|
||||
import eu.darken.capod.common.debug.recording.core.DebugSession
|
||||
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.ViewModel2
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.plus
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
@@ -25,7 +28,7 @@ class RecorderActivityVM @Inject constructor(
|
||||
handle: SavedStateHandle,
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val debugLogZipper: DebugLogZipper,
|
||||
private val recorderModule: RecorderModule,
|
||||
private val webpageTool: WebpageTool,
|
||||
) : ViewModel2(dispatcherProvider) {
|
||||
|
||||
@@ -48,24 +51,57 @@ class RecorderActivityVM @Inject constructor(
|
||||
data object Finish : Event
|
||||
}
|
||||
|
||||
private val recordedDirPath = handle.get<String>(RecorderActivity.RECORD_PATH)
|
||||
private val logDir = recordedDirPath?.let { File(it) }
|
||||
private val sessionId: String? = handle.get<String>(RecorderActivity.RECORD_SESSION_ID)
|
||||
private val legacyPath: String? = handle.get<String>(RecorderActivity.RECORD_PATH)
|
||||
|
||||
private suspend fun resolveSession(): DebugSession? {
|
||||
if (sessionId != null) {
|
||||
val session = recorderModule.sessions.first().firstOrNull { it.id == sessionId }
|
||||
if (session != null) return session
|
||||
recorderModule.refreshSessions()
|
||||
return recorderModule.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 }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private val stater = DynamicStateFlow(TAG, vmScope + dispatcherProvider.IO) {
|
||||
val session = resolveSession()
|
||||
val logDir = when (session) {
|
||||
is DebugSession.Ready -> session.logDir
|
||||
is DebugSession.Compressing -> session.path
|
||||
is DebugSession.Failed -> session.path.takeIf { it.isDirectory }
|
||||
is DebugSession.Recording -> session.path
|
||||
null -> legacyPath?.let { File(it) }
|
||||
}
|
||||
|
||||
val isCompressing = session is DebugSession.Compressing
|
||||
|
||||
if (logDir == null || !logDir.exists()) {
|
||||
return@DynamicStateFlow State(logDir = null)
|
||||
return@DynamicStateFlow State(logDir = null, isWorking = isCompressing)
|
||||
}
|
||||
|
||||
val files = logDir.listFiles()?.toList() ?: emptyList()
|
||||
val entries = files.map { LogEntry(it, it.length()) }
|
||||
val totalSize = entries.sumOf { it.size }
|
||||
|
||||
val compressedSize = try {
|
||||
debugLogZipper.zipAndGetUri(logDir)
|
||||
File(logDir.parentFile, "${logDir.name}.zip").length()
|
||||
} catch (e: Exception) {
|
||||
log(TAG) { "Failed to zip: $e" }
|
||||
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 dirCreated = logDir.lastModified()
|
||||
@@ -78,26 +114,43 @@ class RecorderActivityVM @Inject constructor(
|
||||
totalSize = totalSize,
|
||||
compressedSize = compressedSize,
|
||||
recordingDurationSecs = durationSecs,
|
||||
isWorking = false,
|
||||
isWorking = isCompressing,
|
||||
)
|
||||
}
|
||||
val state = stater.flow
|
||||
|
||||
val events = SingleEventFlow<Event>()
|
||||
|
||||
init {
|
||||
recorderModule.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.launchIn(vmScope)
|
||||
}
|
||||
|
||||
fun share() = launch {
|
||||
val currentState = stater.flow.first()
|
||||
val dir = currentState.logDir ?: return@launch
|
||||
val sid = sessionId ?: return@launch
|
||||
|
||||
stater.updateBlocking { copy(isWorking = true) }
|
||||
|
||||
try {
|
||||
val zipFile = File(dir.parentFile, "${dir.name}.zip")
|
||||
val uri = if (zipFile.exists()) {
|
||||
debugLogZipper.getUriForZip(zipFile)
|
||||
} else {
|
||||
debugLogZipper.zipAndGetUri(dir)
|
||||
}
|
||||
val uri = recorderModule.getZipUri(sid)
|
||||
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
@@ -120,13 +173,8 @@ class RecorderActivityVM @Inject constructor(
|
||||
}
|
||||
|
||||
fun discard() = launch {
|
||||
val currentState = stater.flow.first()
|
||||
val dir = currentState.logDir ?: return@launch
|
||||
|
||||
dir.deleteRecursively()
|
||||
val zipFile = File(dir.parentFile, "${dir.name}.zip")
|
||||
if (zipFile.exists()) zipFile.delete()
|
||||
|
||||
val sid = sessionId ?: return@launch
|
||||
recorderModule.deleteSession(sid)
|
||||
events.tryEmit(Event.Finish)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
package eu.darken.capod.main.ui.settings.support
|
||||
|
||||
import android.content.Intent
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.twotone.MenuBook
|
||||
import androidx.compose.material.icons.twotone.BugReport
|
||||
import androidx.compose.material.icons.twotone.Cancel
|
||||
import androidx.compose.material.icons.automirrored.twotone.MenuBook
|
||||
import androidx.compose.material.icons.twotone.CheckCircle
|
||||
import androidx.compose.material.icons.twotone.Delete
|
||||
import androidx.compose.material.icons.twotone.FiberManualRecord
|
||||
import androidx.compose.material.icons.twotone.Settings
|
||||
import androidx.compose.material.icons.twotone.Warning
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -22,11 +41,14 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import eu.darken.capod.R
|
||||
@@ -35,17 +57,20 @@ import eu.darken.capod.common.WebpageTool
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import eu.darken.capod.common.debug.recording.core.DebugSession
|
||||
import eu.darken.capod.common.debug.recording.ui.RecorderActivity
|
||||
import eu.darken.capod.common.debug.recording.ui.RecorderConsentDialog
|
||||
import eu.darken.capod.common.error.ErrorEventHandler
|
||||
import eu.darken.capod.common.navigation.NavigationEventHandler
|
||||
import eu.darken.capod.common.settings.SettingsBaseItem
|
||||
import eu.darken.capod.common.settings.SettingsCategoryHeader
|
||||
|
||||
@Composable
|
||||
fun SupportScreenHost(vm: SupportViewModel = hiltViewModel()) {
|
||||
ErrorEventHandler(vm)
|
||||
NavigationEventHandler(vm)
|
||||
LifecycleResumeEffect(Unit) {
|
||||
vm.refreshLogSize()
|
||||
vm.refreshSessions()
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
|
||||
@@ -67,6 +92,13 @@ fun SupportScreenHost(vm: SupportViewModel = hiltViewModel()) {
|
||||
SupportViewModel.Event.ShowShortRecordingWarning -> {
|
||||
showShortRecordingWarning = true
|
||||
}
|
||||
|
||||
is SupportViewModel.Event.OpenRecorderActivity -> {
|
||||
val intent = RecorderActivity.getLaunchIntent(context, event.sessionId, event.legacyPath).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +121,18 @@ fun SupportScreenHost(vm: SupportViewModel = hiltViewModel()) {
|
||||
onWiki = { vm.openUrl("https://github.com/d4rken-org/capod/wiki") },
|
||||
onTroubleShooter = { vm.goToTroubleShooter() },
|
||||
onDebugLogToggle = { vm.onDebugLogToggle() },
|
||||
onOpenSession = { vm.openSession(it) },
|
||||
onDeleteSession = { id ->
|
||||
MaterialAlertDialogBuilder(context).apply {
|
||||
setTitle(R.string.support_debuglog_session_delete_title)
|
||||
setMessage(R.string.support_debuglog_session_delete_message)
|
||||
setPositiveButton(R.string.profiles_delete_action) { _, _ ->
|
||||
vm.deleteSession(id)
|
||||
}
|
||||
setNegativeButton(R.string.general_cancel_action) { _, _ -> }
|
||||
}.show()
|
||||
},
|
||||
onStopRecording = { vm.onDebugLogToggle() },
|
||||
onClearLogs = { vm.clearDebugLogs() },
|
||||
)
|
||||
}
|
||||
@@ -127,10 +171,14 @@ fun SupportScreen(
|
||||
onWiki: () -> Unit,
|
||||
onTroubleShooter: () -> Unit,
|
||||
onDebugLogToggle: () -> Unit,
|
||||
onOpenSession: (String) -> Unit,
|
||||
onDeleteSession: (String) -> Unit,
|
||||
onStopRecording: () -> Unit,
|
||||
onClearLogs: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var showSessionsSheet by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
@@ -215,19 +263,211 @@ fun SupportScreen(
|
||||
onClick = onDebugLogToggle,
|
||||
)
|
||||
}
|
||||
if (state.logFolderSize > 0 && !state.isRecording) {
|
||||
if (state.sessions.isNotEmpty()) {
|
||||
item {
|
||||
val nonRecordingSessions = state.sessions.count { it !is DebugSession.Recording }
|
||||
val logSizeFormatted = Formatter.formatShortFileSize(context, state.logFolderSize)
|
||||
SettingsBaseItem(
|
||||
title = stringResource(R.string.support_debuglog_clear_action),
|
||||
subtitle = pluralStringResource(
|
||||
R.plurals.support_debuglog_folder_summary,
|
||||
state.logSessionCount,
|
||||
state.logSessionCount,
|
||||
logSizeFormatted,
|
||||
),
|
||||
title = stringResource(R.string.support_debuglog_sessions_label),
|
||||
subtitle = if (nonRecordingSessions > 0) {
|
||||
pluralStringResource(
|
||||
R.plurals.support_debuglog_folder_summary,
|
||||
nonRecordingSessions,
|
||||
nonRecordingSessions,
|
||||
logSizeFormatted,
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.support_debuglog_sessions_desc)
|
||||
},
|
||||
iconPainter = painterResource(R.drawable.ic_delete_sweep_24),
|
||||
onClick = onClearLogs,
|
||||
onClick = { showSessionsSheet = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showSessionsSheet) {
|
||||
DebugSessionsBottomSheet(
|
||||
sessions = state.sessions,
|
||||
onDismiss = { showSessionsSheet = false },
|
||||
onOpenSession = onOpenSession,
|
||||
onDeleteSession = onDeleteSession,
|
||||
onStopRecording = onStopRecording,
|
||||
onClearAll = {
|
||||
showSessionsSheet = false
|
||||
onClearLogs()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DebugSessionsBottomSheet(
|
||||
sessions: List<DebugSession>,
|
||||
onDismiss: () -> Unit,
|
||||
onOpenSession: (String) -> Unit,
|
||||
onDeleteSession: (String) -> Unit,
|
||||
onStopRecording: () -> Unit,
|
||||
onClearAll: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(bottom = 24.dp)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.support_debuglog_sessions_label),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
if (sessions.any { it !is DebugSession.Recording }) {
|
||||
IconButton(onClick = onClearAll) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_delete_sweep_24),
|
||||
contentDescription = stringResource(R.string.support_debuglog_clear_action),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
if (sessions.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.support_debuglog_sessions_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
|
||||
)
|
||||
} else {
|
||||
sessions.forEach { session ->
|
||||
SessionRow(
|
||||
session = session,
|
||||
context = context,
|
||||
onOpen = { onOpenSession(session.id) },
|
||||
onDelete = { onDeleteSession(session.id) },
|
||||
onStop = onStopRecording,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionRow(
|
||||
session: DebugSession,
|
||||
context: android.content.Context,
|
||||
onOpen: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onStop: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (session is DebugSession.Ready) Modifier.clickable(onClick = onOpen) else Modifier)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Leading icon
|
||||
when (session) {
|
||||
is DebugSession.Recording -> Icon(
|
||||
imageVector = Icons.TwoTone.FiberManualRecord,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
|
||||
is DebugSession.Compressing -> CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
|
||||
is DebugSession.Ready -> Icon(
|
||||
imageVector = Icons.TwoTone.CheckCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
is DebugSession.Failed -> Icon(
|
||||
imageVector = Icons.TwoTone.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// Text content
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = session.displayName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
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.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)
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// Trailing action
|
||||
when (session) {
|
||||
is DebugSession.Recording -> {
|
||||
IconButton(onClick = onStop) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Cancel,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DebugSession.Compressing -> {
|
||||
// No action during compression
|
||||
}
|
||||
|
||||
is DebugSession.Ready -> {
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DebugSession.Failed -> {
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -247,6 +487,9 @@ private fun SupportScreenPreview() = PreviewWrapper {
|
||||
onWiki = {},
|
||||
onTroubleShooter = {},
|
||||
onDebugLogToggle = {},
|
||||
onOpenSession = {},
|
||||
onDeleteSession = {},
|
||||
onStopRecording = {},
|
||||
onClearLogs = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,14 +5,15 @@ import eu.darken.capod.common.WebpageTool
|
||||
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.flow.DynamicStateFlow
|
||||
import eu.darken.capod.common.flow.SingleEventFlow
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.uix.ViewModel4
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -27,39 +28,38 @@ class SupportViewModel @Inject constructor(
|
||||
val isRecording: Boolean = false,
|
||||
val currentLogPath: File? = null,
|
||||
val recordingStartedAt: Long = 0L,
|
||||
val logFolderSize: Long = 0L,
|
||||
val logSessionCount: Int = 0,
|
||||
)
|
||||
val sessions: List<DebugSession> = emptyList(),
|
||||
) {
|
||||
val logSessionCount: Int get() = sessions.count { it !is DebugSession.Recording }
|
||||
val logFolderSize: Long get() = sessions.sumOf { it.diskSize }
|
||||
val failedSessions: List<DebugSession.Failed> get() = sessions.filterIsInstance<DebugSession.Failed>()
|
||||
}
|
||||
|
||||
sealed interface Event {
|
||||
data object ShowConsentDialog : Event
|
||||
data object ShowShortRecordingWarning : Event
|
||||
data class OpenRecorderActivity(val sessionId: String, val legacyPath: String?) : Event
|
||||
}
|
||||
|
||||
val events = SingleEventFlow<Event>()
|
||||
|
||||
private val stater = DynamicStateFlow(TAG, vmScope) {
|
||||
State(
|
||||
logFolderSize = recorderModule.getLogFolderSize(),
|
||||
logSessionCount = recorderModule.getLogSessionCount(),
|
||||
)
|
||||
}
|
||||
private val stater = DynamicStateFlow(TAG, vmScope) { State() }
|
||||
val state = stater.flow
|
||||
|
||||
init {
|
||||
recorderModule.state
|
||||
.onEach { recorderState ->
|
||||
stater.updateBlocking {
|
||||
copy(
|
||||
isRecording = recorderState.isRecording,
|
||||
currentLogPath = recorderState.currentLogPath,
|
||||
recordingStartedAt = recorderState.recordingStartedAt,
|
||||
logFolderSize = recorderModule.getLogFolderSize(),
|
||||
logSessionCount = recorderModule.getLogSessionCount(),
|
||||
)
|
||||
}
|
||||
combine(
|
||||
recorderModule.state,
|
||||
recorderModule.sessions,
|
||||
) { recorderState, sessions ->
|
||||
stater.updateBlocking {
|
||||
copy(
|
||||
isRecording = recorderState.isRecording,
|
||||
currentLogPath = recorderState.currentLogPath,
|
||||
recordingStartedAt = recorderState.recordingStartedAt,
|
||||
sessions = sessions,
|
||||
)
|
||||
}
|
||||
.launchIn(vmScope)
|
||||
}.launchIn(vmScope)
|
||||
}
|
||||
|
||||
fun openUrl(url: String) {
|
||||
@@ -100,32 +100,31 @@ class SupportViewModel @Inject constructor(
|
||||
}
|
||||
log(TAG) { "stopDebugLog()" }
|
||||
recorderModule.stopRecorder()
|
||||
doRefreshLogSize()
|
||||
}
|
||||
|
||||
fun forceStopDebugLog() = launch {
|
||||
log(TAG) { "forceStopDebugLog()" }
|
||||
recorderModule.stopRecorder()
|
||||
doRefreshLogSize()
|
||||
}
|
||||
|
||||
fun clearDebugLogs() = launch {
|
||||
log(TAG) { "clearDebugLogs()" }
|
||||
recorderModule.deleteAllLogs()
|
||||
doRefreshLogSize()
|
||||
}
|
||||
|
||||
fun refreshLogSize() = launch {
|
||||
doRefreshLogSize()
|
||||
fun openSession(sessionId: String) = launch {
|
||||
val session = recorderModule.sessions.first().firstOrNull { it.id == sessionId } ?: return@launch
|
||||
val legacyPath = (session as? DebugSession.Ready)?.logDir?.path
|
||||
events.tryEmit(Event.OpenRecorderActivity(sessionId, legacyPath))
|
||||
}
|
||||
|
||||
private suspend fun doRefreshLogSize() {
|
||||
stater.updateBlocking {
|
||||
copy(
|
||||
logFolderSize = recorderModule.getLogFolderSize(),
|
||||
logSessionCount = recorderModule.getLogSessionCount(),
|
||||
)
|
||||
}
|
||||
fun refreshSessions() = launch {
|
||||
recorderModule.refreshSessions()
|
||||
}
|
||||
|
||||
fun deleteSession(id: String) = launch {
|
||||
log(TAG) { "deleteSession($id)" }
|
||||
recorderModule.deleteSession(id)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
+9
-9
@@ -130,12 +130,12 @@ fun ContactFormScreenHost(vm: ContactFormViewModel = hiltViewModel()) {
|
||||
onDescriptionChange = { vm.updateDescription(it) },
|
||||
onExpectedChange = { vm.updateExpectedBehavior(it) },
|
||||
onSelectSession = { vm.selectLogSession(it) },
|
||||
onDeleteSession = { path ->
|
||||
onDeleteSession = { id ->
|
||||
MaterialAlertDialogBuilder(context).apply {
|
||||
setTitle(R.string.support_contact_debuglog_delete_title)
|
||||
setMessage(R.string.support_contact_debuglog_delete_message)
|
||||
setPositiveButton(R.string.profiles_delete_action) { _, _ ->
|
||||
vm.deleteLogSession(path)
|
||||
vm.deleteLogSession(id)
|
||||
}
|
||||
setNegativeButton(R.string.general_cancel_action) { _, _ -> }
|
||||
}.show()
|
||||
@@ -156,8 +156,8 @@ fun ContactFormScreen(
|
||||
onCategoryChange: (Category) -> Unit,
|
||||
onDescriptionChange: (String) -> Unit,
|
||||
onExpectedChange: (String) -> Unit,
|
||||
onSelectSession: (java.io.File) -> Unit,
|
||||
onDeleteSession: (java.io.File) -> Unit,
|
||||
onSelectSession: (String) -> Unit,
|
||||
onDeleteSession: (String) -> Unit,
|
||||
onStartRecording: () -> Unit,
|
||||
onStopRecording: () -> Unit,
|
||||
onSend: () -> Unit,
|
||||
@@ -255,7 +255,7 @@ fun ContactFormScreen(
|
||||
)
|
||||
} else {
|
||||
state.sessions.forEach { session ->
|
||||
val isSelected = state.selectedSessionPath == session.path
|
||||
val isSelected = state.selectedSessionId == session.id
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -264,7 +264,7 @@ fun ContactFormScreen(
|
||||
) {
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
onClick = { onSelectSession(session.path) },
|
||||
onClick = { onSelectSession(session.id) },
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -272,16 +272,16 @@ fun ContactFormScreen(
|
||||
.padding(start = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = session.path.name,
|
||||
text = session.displayName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = Formatter.formatShortFileSize(context, session.size),
|
||||
text = Formatter.formatShortFileSize(context, session.diskSize),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onDeleteSession(session.path) }) {
|
||||
IconButton(onClick = { onDeleteSession(session.id) }) {
|
||||
Icon(
|
||||
Icons.TwoTone.Delete,
|
||||
contentDescription = null,
|
||||
|
||||
+30
-75
@@ -11,15 +11,14 @@ import eu.darken.capod.common.SupportLinks
|
||||
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.DebugLogZipper
|
||||
import eu.darken.capod.common.debug.recording.core.DebugSession
|
||||
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 kotlinx.coroutines.flow.onEach
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -27,18 +26,11 @@ class ContactFormViewModel @Inject constructor(
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val recorderModule: RecorderModule,
|
||||
private val debugLogZipper: DebugLogZipper,
|
||||
private val emailTool: EmailTool,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
enum class Category { QUESTION, FEATURE, BUG }
|
||||
|
||||
data class LogSessionItem(
|
||||
val path: File,
|
||||
val size: Long,
|
||||
val lastModified: Long,
|
||||
)
|
||||
|
||||
data class State(
|
||||
val category: Category = Category.QUESTION,
|
||||
val description: String = "",
|
||||
@@ -46,8 +38,8 @@ class ContactFormViewModel @Inject constructor(
|
||||
val isSending: Boolean = false,
|
||||
val isRecording: Boolean = false,
|
||||
val recordingStartedAt: Long = 0L,
|
||||
val sessions: List<LogSessionItem> = emptyList(),
|
||||
val selectedSessionPath: File? = null,
|
||||
val sessions: List<DebugSession.Ready> = emptyList(),
|
||||
val selectedSessionId: String? = null,
|
||||
) {
|
||||
val isBug: Boolean get() = category == Category.BUG
|
||||
|
||||
@@ -73,45 +65,28 @@ class ContactFormViewModel @Inject constructor(
|
||||
|
||||
val events = SingleEventFlow<Event>()
|
||||
|
||||
private val stater = DynamicStateFlow(TAG, vmScope) {
|
||||
State(sessions = loadLogSessions())
|
||||
}
|
||||
private val stater = DynamicStateFlow(TAG, vmScope) { State() }
|
||||
val state = stater.flow
|
||||
|
||||
init {
|
||||
recorderModule.state
|
||||
.onEach { recorderState ->
|
||||
stater.updateBlocking {
|
||||
copy(
|
||||
isRecording = recorderState.isRecording,
|
||||
recordingStartedAt = recorderState.recordingStartedAt,
|
||||
sessions = loadLogSessions(activeDir = recorderState.currentLogDir),
|
||||
)
|
||||
}
|
||||
combine(
|
||||
recorderModule.state,
|
||||
recorderModule.sessions,
|
||||
) { recorderState, allSessions ->
|
||||
val completed = allSessions.filterIsInstance<DebugSession.Ready>()
|
||||
stater.updateBlocking {
|
||||
copy(
|
||||
isRecording = recorderState.isRecording,
|
||||
recordingStartedAt = recorderState.recordingStartedAt,
|
||||
sessions = completed,
|
||||
selectedSessionId = if (selectedSessionId != null && completed.none { it.id == selectedSessionId }) {
|
||||
null
|
||||
} else {
|
||||
selectedSessionId
|
||||
},
|
||||
)
|
||||
}
|
||||
.launchIn(vmScope)
|
||||
}
|
||||
|
||||
private fun loadLogSessions(activeDir: File? = null): List<LogSessionItem> {
|
||||
return recorderModule.getLogDirectories()
|
||||
.flatMap { dir ->
|
||||
if (!dir.exists()) return@flatMap emptyList()
|
||||
val entries = dir.listFiles() ?: return@flatMap emptyList()
|
||||
entries.filter { it != activeDir && (it.isDirectory || (it.isFile && it.extension == "zip")) }
|
||||
.map { entry ->
|
||||
val size = if (entry.isDirectory) {
|
||||
entry.walkTopDown().filter { it.isFile }.sumOf { it.length() }
|
||||
} else {
|
||||
entry.length()
|
||||
}
|
||||
LogSessionItem(
|
||||
path = entry,
|
||||
size = size,
|
||||
lastModified = entry.lastModified(),
|
||||
)
|
||||
}
|
||||
}
|
||||
.sortedByDescending { it.lastModified }
|
||||
}.launchIn(vmScope)
|
||||
}
|
||||
|
||||
fun updateCategory(category: Category) = launch {
|
||||
@@ -130,29 +105,17 @@ class ContactFormViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun selectLogSession(path: File) = launch {
|
||||
stater.updateBlocking { copy(selectedSessionPath = path) }
|
||||
fun selectLogSession(id: String) = launch {
|
||||
stater.updateBlocking { copy(selectedSessionId = id) }
|
||||
}
|
||||
|
||||
fun deleteLogSession(path: File) = launch {
|
||||
log(TAG) { "deleteLogSession($path)" }
|
||||
if (path.isDirectory) {
|
||||
path.deleteRecursively()
|
||||
val zip = File(path.parentFile, "${path.name}.zip")
|
||||
if (zip.exists()) zip.delete()
|
||||
} else {
|
||||
path.delete()
|
||||
}
|
||||
stater.updateBlocking {
|
||||
copy(
|
||||
sessions = loadLogSessions(),
|
||||
selectedSessionPath = if (selectedSessionPath == path) null else selectedSessionPath,
|
||||
)
|
||||
}
|
||||
fun deleteLogSession(id: String) = launch {
|
||||
log(TAG) { "deleteLogSession($id)" }
|
||||
recorderModule.deleteSession(id)
|
||||
}
|
||||
|
||||
fun refreshLogSessions() = launch {
|
||||
stater.updateBlocking { copy(sessions = loadLogSessions()) }
|
||||
recorderModule.refreshSessions()
|
||||
}
|
||||
|
||||
fun startRecording() {
|
||||
@@ -173,13 +136,11 @@ class ContactFormViewModel @Inject constructor(
|
||||
}
|
||||
log(TAG) { "stopRecording()" }
|
||||
recorderModule.stopRecorder(showResultUi = false)
|
||||
stater.updateBlocking { copy(sessions = loadLogSessions()) }
|
||||
}
|
||||
|
||||
fun forceStopRecording() = launch {
|
||||
log(TAG) { "forceStopRecording()" }
|
||||
recorderModule.stopRecorder(showResultUi = false)
|
||||
stater.updateBlocking { copy(sessions = loadLogSessions()) }
|
||||
}
|
||||
|
||||
fun send() = launch {
|
||||
@@ -189,15 +150,9 @@ class ContactFormViewModel @Inject constructor(
|
||||
stater.updateBlocking { copy(isSending = true) }
|
||||
|
||||
try {
|
||||
val attachmentUri = currentState.selectedSessionPath?.let { sessionPath ->
|
||||
val attachmentUri = currentState.selectedSessionId?.let { sessionId ->
|
||||
try {
|
||||
if (sessionPath.isDirectory) {
|
||||
debugLogZipper.zipAndGetUri(sessionPath)
|
||||
} else if (sessionPath.extension == "zip" && sessionPath.exists()) {
|
||||
debugLogZipper.getUriForZip(sessionPath)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
recorderModule.getZipUri(sessionId)
|
||||
} catch (e: Exception) {
|
||||
log(TAG) { "Failed to prepare attachment: $e" }
|
||||
events.tryEmit(
|
||||
|
||||
@@ -350,7 +350,7 @@
|
||||
<item quantity="one">%1$d file ready (%2$s)</item>
|
||||
<item quantity="other">%1$d files ready (%2$s)</item>
|
||||
</plurals>
|
||||
<string name="debug_debuglog_screen_discard_action">Discard</string>
|
||||
<string name="debug_debuglog_screen_discard_action">Delete</string>
|
||||
|
||||
<!-- Log management -->
|
||||
<plurals name="support_debuglog_folder_summary">
|
||||
@@ -358,6 +358,16 @@
|
||||
<item quantity="other">%1$d debug logs (%2$s)</item>
|
||||
</plurals>
|
||||
<string name="support_debuglog_clear_action">Clear stored debug logs</string>
|
||||
<string name="support_debuglog_sessions_label">Debug sessions</string>
|
||||
<string name="support_debuglog_sessions_desc">Manage recorded debug log sessions</string>
|
||||
<string name="support_debuglog_sessions_empty">No debug sessions</string>
|
||||
<string name="support_debuglog_session_recording">Recording…</string>
|
||||
<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_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>
|
||||
|
||||
<!-- Contact form -->
|
||||
<string name="support_contact_label">Contact developer</string>
|
||||
|
||||
Reference in New Issue
Block a user