mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
feat(support): Polish support screen, recorder UI, and settings
Reorder support screen items with category headers, add log session count to clear action, fix contact form hint strings, filter active recordings from log picker, convert RecorderActivity to Compose with proper system bar insets and Material 3 color tokens, refresh log metadata on resume, add wiki link to settings, and move support entry under Other category
This commit is contained in:
@@ -122,6 +122,7 @@ internal fun SettingsIndexContent() = PreviewWrapper {
|
||||
onDeviceManager = {},
|
||||
onReactions = {},
|
||||
onSupport = {},
|
||||
onWiki = {},
|
||||
onChangelog = {},
|
||||
onHelpTranslate = {},
|
||||
onAcknowledgements = {},
|
||||
|
||||
@@ -133,6 +133,13 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
fun getLogFolderSize(): Long {
|
||||
return getLogDirectories().sumOf { dir ->
|
||||
if (!dir.exists()) return@sumOf 0L
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package eu.darken.capod.common.debug.recording.ui
|
||||
|
||||
import android.text.format.Formatter
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import eu.darken.capod.databinding.DebugRecorderLogfileItemBinding
|
||||
import java.io.File
|
||||
|
||||
class LogFileAdapter : ListAdapter<LogFileAdapter.Item, LogFileAdapter.VH>(DIFF) {
|
||||
|
||||
data class Item(
|
||||
val file: File,
|
||||
val size: Long,
|
||||
)
|
||||
|
||||
class VH(val binding: DebugRecorderLogfileItemBinding) : RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val binding = DebugRecorderLogfileItemBinding.inflate(
|
||||
LayoutInflater.from(parent.context), parent, false
|
||||
)
|
||||
return VH(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
val item = getItem(position)
|
||||
holder.binding.apply {
|
||||
fileName.text = item.file.name
|
||||
filePath.text = item.file.path
|
||||
fileSize.text = Formatter.formatShortFileSize(root.context, item.size)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DIFF = object : DiffUtil.ItemCallback<Item>() {
|
||||
override fun areItemsTheSame(oldItem: Item, newItem: Item) = oldItem.file == newItem.file
|
||||
override fun areContentsTheSame(oldItem: Item, newItem: Item) = oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,26 +3,33 @@ package eu.darken.capod.common.debug.recording.ui
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.text.format.Formatter
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.view.WindowCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.common.compose.waitForState
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.error.asErrorDialogBuilder
|
||||
import eu.darken.capod.common.theming.CapodTheme
|
||||
import eu.darken.capod.common.uix.Activity2
|
||||
import eu.darken.capod.databinding.DebugRecordingActivityBinding
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.main.core.currentThemeState
|
||||
import eu.darken.capod.main.core.themeState
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class RecorderActivity : Activity2() {
|
||||
|
||||
private lateinit var ui: DebugRecordingActivityBinding
|
||||
private val vm: RecorderActivityVM by viewModels()
|
||||
private val logFileAdapter = LogFileAdapter()
|
||||
|
||||
@Inject lateinit var generalSettings: GeneralSettings
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
@@ -33,55 +40,39 @@ class RecorderActivity : Activity2() {
|
||||
return
|
||||
}
|
||||
|
||||
ui = DebugRecordingActivityBinding.inflate(layoutInflater)
|
||||
setContentView(ui.root)
|
||||
setContent {
|
||||
val themeState by generalSettings.themeState.collectAsState(initial = generalSettings.currentThemeState)
|
||||
CapodTheme(state = themeState) {
|
||||
val backgroundColor = MaterialTheme.colorScheme.background
|
||||
val useDarkIcons = backgroundColor.luminance() > 0.5f
|
||||
SideEffect {
|
||||
window.decorView.setBackgroundColor(backgroundColor.toArgb())
|
||||
val insetsController = WindowCompat.getInsetsController(window, window.decorView)
|
||||
insetsController.isAppearanceLightStatusBars = useDarkIcons
|
||||
insetsController.isAppearanceLightNavigationBars = useDarkIcons
|
||||
}
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(ui.root) { view, insets ->
|
||||
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
view.updatePadding(left = systemBars.left, right = systemBars.right)
|
||||
insets
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
vm.events.collect { event ->
|
||||
when (event) {
|
||||
is RecorderActivityVM.Event.ShareIntent -> startActivity(event.intent)
|
||||
is RecorderActivityVM.Event.Finish -> finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ui.logFilesList.apply {
|
||||
layoutManager = LinearLayoutManager(this@RecorderActivity)
|
||||
adapter = logFileAdapter
|
||||
}
|
||||
|
||||
vm.state.observe2 { state ->
|
||||
ui.loadingIndicator.isVisible = state.isWorking
|
||||
ui.actionShare.isEnabled = !state.isWorking
|
||||
ui.shareLoading.isVisible = state.isWorking
|
||||
|
||||
ui.sessionPath.text = state.logDir?.path ?: ""
|
||||
|
||||
val fileCount = state.logEntries.size
|
||||
val compressedText = if (state.compressedSize >= 0) {
|
||||
"ZIP: ${Formatter.formatShortFileSize(this, state.compressedSize)}"
|
||||
} else {
|
||||
"..."
|
||||
val state by waitForState(vm.state)
|
||||
state?.let {
|
||||
RecorderScreen(
|
||||
state = it,
|
||||
onShare = { vm.share() },
|
||||
onKeep = { vm.keep() },
|
||||
onDiscard = { vm.discard() },
|
||||
onPrivacyPolicy = { vm.goPrivacyPolicy() },
|
||||
)
|
||||
}
|
||||
}
|
||||
ui.logFilesCaption.text = resources.getQuantityString(
|
||||
eu.darken.capod.R.plurals.debug_debuglog_screen_log_files_ready,
|
||||
fileCount,
|
||||
fileCount,
|
||||
compressedText,
|
||||
)
|
||||
ui.fileCountBadge.text = fileCount.toString()
|
||||
|
||||
logFileAdapter.submitList(state.logEntries)
|
||||
}
|
||||
|
||||
vm.errorEvents.observe2 {
|
||||
it.asErrorDialogBuilder(this).show()
|
||||
}
|
||||
|
||||
ui.privacyPolicy.setOnClickListener { vm.goPrivacyPolicy() }
|
||||
ui.actionShare.setOnClickListener { vm.share() }
|
||||
ui.actionKeep.setOnClickListener { vm.keep() }
|
||||
ui.actionDiscard.setOnClickListener { vm.discard() }
|
||||
|
||||
vm.shareEvent.observe2 { startActivity(it) }
|
||||
vm.finishEvent.observe2 { finish() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -13,11 +13,9 @@ 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.flow.DynamicStateFlow
|
||||
import eu.darken.capod.common.flow.onError
|
||||
import eu.darken.capod.common.livedata.SingleLiveEvent
|
||||
import eu.darken.capod.common.uix.ViewModel3
|
||||
import eu.darken.capod.common.flow.SingleEventFlow
|
||||
import eu.darken.capod.common.uix.ViewModel2
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.plus
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
@@ -29,7 +27,26 @@ class RecorderActivityVM @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val debugLogZipper: DebugLogZipper,
|
||||
private val webpageTool: WebpageTool,
|
||||
) : ViewModel3(dispatcherProvider) {
|
||||
) : ViewModel2(dispatcherProvider) {
|
||||
|
||||
data class LogEntry(
|
||||
val file: File,
|
||||
val size: Long,
|
||||
)
|
||||
|
||||
data class State(
|
||||
val logDir: File? = null,
|
||||
val logEntries: List<LogEntry> = emptyList(),
|
||||
val totalSize: Long = 0L,
|
||||
val compressedSize: Long = -1L,
|
||||
val recordingDurationSecs: Long = 0L,
|
||||
val isWorking: Boolean = true,
|
||||
)
|
||||
|
||||
sealed interface Event {
|
||||
data class ShareIntent(val intent: Intent) : Event
|
||||
data object Finish : Event
|
||||
}
|
||||
|
||||
private val recordedDirPath = handle.get<String>(RecorderActivity.RECORD_PATH)
|
||||
private val logDir = recordedDirPath?.let { File(it) }
|
||||
@@ -40,37 +57,33 @@ class RecorderActivityVM @Inject constructor(
|
||||
}
|
||||
|
||||
val files = logDir.listFiles()?.toList() ?: emptyList()
|
||||
val entries = files.map { LogFileAdapter.Item(it, it.length()) }
|
||||
val entries = files.map { LogEntry(it, it.length()) }
|
||||
val totalSize = entries.sumOf { it.size }
|
||||
|
||||
val compressedSize = try {
|
||||
val zipFile = File(logDir.parentFile, "${logDir.name}.zip")
|
||||
debugLogZipper.zipAndGetUri(logDir)
|
||||
zipFile.length()
|
||||
File(logDir.parentFile, "${logDir.name}.zip").length()
|
||||
} catch (e: Exception) {
|
||||
log(TAG) { "Failed to zip: $e" }
|
||||
-1L
|
||||
}
|
||||
|
||||
val dirCreated = logDir.lastModified()
|
||||
val latestFileModified = files.maxOfOrNull { it.lastModified() } ?: dirCreated
|
||||
val durationSecs = ((latestFileModified - dirCreated) / 1000).coerceAtLeast(0)
|
||||
|
||||
State(
|
||||
logDir = logDir,
|
||||
logEntries = entries,
|
||||
totalSize = totalSize,
|
||||
compressedSize = compressedSize,
|
||||
recordingDurationSecs = durationSecs,
|
||||
isWorking = false,
|
||||
)
|
||||
}
|
||||
val state = stater.asLiveData2()
|
||||
val state = stater.flow
|
||||
|
||||
val shareEvent = SingleLiveEvent<Intent>()
|
||||
val finishEvent = SingleLiveEvent<Unit>()
|
||||
|
||||
init {
|
||||
stater.flow
|
||||
.onEach { log(TAG) { "State: $it" } }
|
||||
.onError { errorEvents.postValue(it) }
|
||||
.launchInViewModel()
|
||||
}
|
||||
val events = SingleEventFlow<Event>()
|
||||
|
||||
fun share() = launch {
|
||||
val currentState = stater.flow.first()
|
||||
@@ -79,7 +92,12 @@ class RecorderActivityVM @Inject constructor(
|
||||
stater.updateBlocking { copy(isWorking = true) }
|
||||
|
||||
try {
|
||||
val uri = debugLogZipper.zipAndGetUri(dir)
|
||||
val zipFile = File(dir.parentFile, "${dir.name}.zip")
|
||||
val uri = if (zipFile.exists()) {
|
||||
debugLogZipper.getUriForZip(zipFile)
|
||||
} else {
|
||||
debugLogZipper.zipAndGetUri(dir)
|
||||
}
|
||||
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
@@ -91,14 +109,14 @@ class RecorderActivityVM @Inject constructor(
|
||||
}
|
||||
|
||||
val chooserIntent = Intent.createChooser(intent, context.getString(R.string.support_debuglog_label))
|
||||
shareEvent.postValue(chooserIntent)
|
||||
events.tryEmit(Event.ShareIntent(chooserIntent))
|
||||
} finally {
|
||||
stater.updateBlocking { copy(isWorking = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun keep() {
|
||||
finishEvent.postValue(Unit)
|
||||
events.tryEmit(Event.Finish)
|
||||
}
|
||||
|
||||
fun discard() = launch {
|
||||
@@ -109,21 +127,13 @@ class RecorderActivityVM @Inject constructor(
|
||||
val zipFile = File(dir.parentFile, "${dir.name}.zip")
|
||||
if (zipFile.exists()) zipFile.delete()
|
||||
|
||||
finishEvent.postValue(Unit)
|
||||
events.tryEmit(Event.Finish)
|
||||
}
|
||||
|
||||
fun goPrivacyPolicy() {
|
||||
webpageTool.open(PrivacyPolicy.URL)
|
||||
}
|
||||
|
||||
data class State(
|
||||
val logDir: File? = null,
|
||||
val logEntries: List<LogFileAdapter.Item> = emptyList(),
|
||||
val totalSize: Long = 0L,
|
||||
val compressedSize: Long = -1L,
|
||||
val isWorking: Boolean = true,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Debug", "Recorder", "VM")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
package eu.darken.capod.common.debug.recording.ui
|
||||
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.BugReport
|
||||
import androidx.compose.material.icons.twotone.Description
|
||||
import androidx.compose.material.icons.twotone.Email
|
||||
import androidx.compose.material.icons.twotone.Folder
|
||||
import androidx.compose.material.icons.twotone.Info
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
fun RecorderScreen(
|
||||
state: RecorderActivityVM.State,
|
||||
onShare: () -> Unit,
|
||||
onKeep: () -> Unit,
|
||||
onDiscard: () -> Unit,
|
||||
onPrivacyPolicy: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
if (state.isWorking) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal))
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 80.dp),
|
||||
) {
|
||||
// Hero section
|
||||
HeroSection()
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
// Sensitive information card
|
||||
SensitiveInfoCard(onPrivacyPolicy = onPrivacyPolicy)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Session path card
|
||||
SessionPathCard(path = state.logDir?.path ?: "")
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Log files section
|
||||
LogFilesSection(
|
||||
entries = state.logEntries,
|
||||
compressedSize = state.compressedSize,
|
||||
recordingDurationSecs = state.recordingDurationSecs,
|
||||
context = context,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom action bar
|
||||
BottomActionBar(
|
||||
isWorking = state.isWorking,
|
||||
onDiscard = onDiscard,
|
||||
onKeep = onKeep,
|
||||
onShare = onShare,
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeroSection() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 32.dp, bottom = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(48.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.BugReport,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.debug_debuglog_screen_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.debug_debuglog_screen_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SensitiveInfoCard(onPrivacyPolicy: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Info,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.debug_debuglog_screen_sensitive_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.debug_debuglog_sensitive_information_message),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
TextButton(
|
||||
onClick = onPrivacyPolicy,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_privacy_policy_label),
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionPathCard(path: String) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Folder,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.debug_debuglog_screen_session_path_label),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
) {
|
||||
Text(
|
||||
text = path,
|
||||
modifier = Modifier.padding(14.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogFilesSection(
|
||||
entries: List<RecorderActivityVM.LogEntry>,
|
||||
compressedSize: Long,
|
||||
recordingDurationSecs: Long,
|
||||
context: android.content.Context,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// Header row
|
||||
val compressedText = if (compressedSize >= 0) {
|
||||
"ZIP: ${Formatter.formatShortFileSize(context, compressedSize)}"
|
||||
} else {
|
||||
"..."
|
||||
}
|
||||
val durationText = formatDuration(recordingDurationSecs)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Description,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.debug_debuglog_screen_log_files_label),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = pluralStringResource(
|
||||
R.plurals.debug_debuglog_screen_log_files_ready,
|
||||
entries.size,
|
||||
entries.size,
|
||||
compressedText,
|
||||
) + " \u00B7 $durationText",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
) {
|
||||
Text(
|
||||
text = entries.size.toString(),
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// File list
|
||||
entries.forEach { entry ->
|
||||
LogFileItem(entry = entry, context = context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogFileItem(
|
||||
entry: RecorderActivityVM.LogEntry,
|
||||
context: android.content.Context,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(start = 36.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Description,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.padding(top = 2.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = entry.file.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = entry.file.path,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
) {
|
||||
Text(
|
||||
text = Formatter.formatShortFileSize(context, entry.size),
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomActionBar(
|
||||
isWorking: Boolean,
|
||||
onDiscard: () -> Unit,
|
||||
onKeep: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
|
||||
shadowElevation = 8.dp,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDiscard,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(text = stringResource(R.string.debug_debuglog_screen_discard_action))
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = onKeep,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(text = stringResource(R.string.general_save_action))
|
||||
}
|
||||
androidx.compose.material3.Button(
|
||||
onClick = onShare,
|
||||
enabled = !isWorking,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
if (isWorking) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Email,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||
Text(text = stringResource(R.string.general_share_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDuration(totalSecs: Long): String {
|
||||
val mins = totalSecs / 60
|
||||
val secs = totalSecs % 60
|
||||
return if (mins > 0) "${mins}m ${secs}s" else "${secs}s"
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun RecorderScreenPreview() = PreviewWrapper {
|
||||
RecorderScreen(
|
||||
state = RecorderActivityVM.State(
|
||||
logDir = File("/storage/emulated/0/Android/data/eu.darken.capod/files/debug/logs/capod_1.0_123_abc12345"),
|
||||
logEntries = listOf(
|
||||
RecorderActivityVM.LogEntry(File("/path/core.log"), 6400L),
|
||||
),
|
||||
compressedSize = 1200L,
|
||||
recordingDurationSecs = 3,
|
||||
isWorking = false,
|
||||
),
|
||||
onShare = {},
|
||||
onKeep = {},
|
||||
onDiscard = {},
|
||||
onPrivacyPolicy = {},
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
|
||||
import androidx.compose.material.icons.twotone.Book
|
||||
import androidx.compose.material.icons.automirrored.twotone.MenuBook
|
||||
import androidx.compose.material.icons.twotone.DevicesOther
|
||||
import androidx.compose.material.icons.twotone.Favorite
|
||||
import androidx.compose.material.icons.twotone.Settings
|
||||
@@ -49,6 +50,7 @@ fun SettingsScreenHost(vm: SettingsViewModel = hiltViewModel()) {
|
||||
onDeviceManager = { vm.navTo(Nav.Main.DeviceManager) },
|
||||
onReactions = { vm.navTo(Nav.Settings.Reactions) },
|
||||
onSupport = { vm.navTo(Nav.Settings.Support) },
|
||||
onWiki = { vm.openUrl("https://github.com/d4rken-org/capod/wiki") },
|
||||
onChangelog = { vm.openUrl("https://capod.darken.eu/changelog") },
|
||||
onHelpTranslate = { vm.openUrl("https://crowdin.com/project/capod") },
|
||||
onAcknowledgements = { vm.navTo(Nav.Settings.Acknowledgements) },
|
||||
@@ -67,6 +69,7 @@ fun SettingsScreen(
|
||||
onDeviceManager: () -> Unit,
|
||||
onReactions: () -> Unit,
|
||||
onSupport: () -> Unit,
|
||||
onWiki: () -> Unit,
|
||||
onChangelog: () -> Unit,
|
||||
onHelpTranslate: () -> Unit,
|
||||
onAcknowledgements: () -> Unit,
|
||||
@@ -128,6 +131,9 @@ fun SettingsScreen(
|
||||
onClick = onReactions,
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.settings_category_other_label))
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = stringResource(R.string.settings_support_label),
|
||||
@@ -137,7 +143,12 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.settings_category_other_label))
|
||||
SettingsBaseItem(
|
||||
title = stringResource(R.string.settings_wiki_label),
|
||||
subtitle = stringResource(R.string.settings_wiki_description),
|
||||
icon = Icons.AutoMirrored.TwoTone.MenuBook,
|
||||
onClick = onWiki,
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
@@ -185,6 +196,7 @@ private fun SettingsScreenPreview() = PreviewWrapper {
|
||||
onDeviceManager = {},
|
||||
onReactions = {},
|
||||
onSupport = {},
|
||||
onWiki = {},
|
||||
onChangelog = {},
|
||||
onHelpTranslate = {},
|
||||
onAcknowledgements = {},
|
||||
|
||||
@@ -20,9 +20,11 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
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.hilt.navigation.compose.hiltViewModel
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
@@ -41,6 +43,10 @@ import eu.darken.capod.common.settings.SettingsCategoryHeader
|
||||
fun SupportScreenHost(vm: SupportViewModel = hiltViewModel()) {
|
||||
ErrorEventHandler(vm)
|
||||
NavigationEventHandler(vm)
|
||||
LifecycleResumeEffect(Unit) {
|
||||
vm.refreshLogSize()
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -65,11 +71,11 @@ fun SupportScreenHost(vm: SupportViewModel = hiltViewModel()) {
|
||||
}
|
||||
|
||||
if (showShortRecordingWarning) {
|
||||
ShortRecordingWarningDialog(context) {
|
||||
showShortRecordingWarning = false
|
||||
vm.forceStopDebugLog()
|
||||
}
|
||||
// Dismiss on cancel handled internally
|
||||
ShortRecordingWarningDialog(
|
||||
context = context,
|
||||
onDismiss = { showShortRecordingWarning = false },
|
||||
onStopAnyway = { vm.forceStopDebugLog() },
|
||||
)
|
||||
}
|
||||
|
||||
state?.let {
|
||||
@@ -89,24 +95,22 @@ fun SupportScreenHost(vm: SupportViewModel = hiltViewModel()) {
|
||||
@Composable
|
||||
private fun ShortRecordingWarningDialog(
|
||||
context: android.content.Context,
|
||||
onDismiss: () -> Unit,
|
||||
onStopAnyway: () -> Unit,
|
||||
) {
|
||||
var dismissed by remember { mutableStateOf(false) }
|
||||
if (!dismissed) {
|
||||
LaunchedEffect(Unit) {
|
||||
MaterialAlertDialogBuilder(context).apply {
|
||||
setTitle(R.string.debug_debuglog_short_recording_title)
|
||||
setMessage(R.string.debug_debuglog_short_recording_message)
|
||||
setPositiveButton(R.string.debug_debuglog_short_recording_continue) { _, _ ->
|
||||
dismissed = true
|
||||
}
|
||||
setNegativeButton(R.string.debug_debuglog_short_recording_stop) { _, _ ->
|
||||
dismissed = true
|
||||
onStopAnyway()
|
||||
}
|
||||
setOnCancelListener { dismissed = true }
|
||||
}.show()
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
MaterialAlertDialogBuilder(context).apply {
|
||||
setTitle(R.string.debug_debuglog_short_recording_title)
|
||||
setMessage(R.string.debug_debuglog_short_recording_message)
|
||||
setPositiveButton(R.string.debug_debuglog_short_recording_continue) { _, _ ->
|
||||
onDismiss()
|
||||
}
|
||||
setNegativeButton(R.string.debug_debuglog_short_recording_stop) { _, _ ->
|
||||
onDismiss()
|
||||
onStopAnyway()
|
||||
}
|
||||
setOnCancelListener { onDismiss() }
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,10 +148,21 @@ fun SupportScreen(
|
||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = stringResource(R.string.support_contact_label),
|
||||
subtitle = stringResource(R.string.support_contact_desc),
|
||||
iconPainter = painterResource(R.drawable.ic_contact_support_24),
|
||||
onClick = onContactDeveloper,
|
||||
title = stringResource(R.string.troubleshooter_title),
|
||||
subtitle = stringResource(R.string.troubleshooter_summary),
|
||||
icon = Icons.TwoTone.Settings,
|
||||
onClick = onTroubleShooter,
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.settings_category_gethelp_label))
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = stringResource(R.string.issue_tracker_label),
|
||||
subtitle = stringResource(R.string.issue_tracker_description),
|
||||
iconPainter = painterResource(R.drawable.ic_github_onsurface),
|
||||
onClick = onIssueTracker,
|
||||
)
|
||||
}
|
||||
item {
|
||||
@@ -160,25 +175,16 @@ fun SupportScreen(
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = stringResource(R.string.issue_tracker_label),
|
||||
subtitle = stringResource(R.string.issue_tracker_description),
|
||||
iconPainter = painterResource(R.drawable.ic_github_onsurface),
|
||||
onClick = onIssueTracker,
|
||||
title = stringResource(R.string.support_contact_label),
|
||||
subtitle = stringResource(R.string.support_contact_desc),
|
||||
iconPainter = painterResource(R.drawable.ic_contact_support_24),
|
||||
onClick = onContactDeveloper,
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.settings_category_other_label))
|
||||
SettingsCategoryHeader(text = stringResource(R.string.settings_category_debug_label))
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = stringResource(R.string.troubleshooter_title),
|
||||
subtitle = stringResource(R.string.troubleshooter_summary),
|
||||
icon = Icons.TwoTone.Settings,
|
||||
onClick = onTroubleShooter,
|
||||
)
|
||||
}
|
||||
item {
|
||||
val logSizeFormatted = Formatter.formatShortFileSize(context, state.logFolderSize)
|
||||
SettingsBaseItem(
|
||||
title = if (state.isRecording) {
|
||||
stringResource(R.string.debug_debuglog_stop_action)
|
||||
@@ -188,7 +194,7 @@ fun SupportScreen(
|
||||
subtitle = if (state.isRecording) {
|
||||
state.currentLogPath?.path
|
||||
} else {
|
||||
stringResource(R.string.support_debuglog_folder_size, logSizeFormatted)
|
||||
stringResource(R.string.support_debuglog_desc)
|
||||
},
|
||||
icon = if (state.isRecording) {
|
||||
Icons.TwoTone.Cancel
|
||||
@@ -200,8 +206,15 @@ fun SupportScreen(
|
||||
}
|
||||
if (state.logFolderSize > 0 && !state.isRecording) {
|
||||
item {
|
||||
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,
|
||||
),
|
||||
iconPainter = painterResource(R.drawable.ic_delete_sweep_24),
|
||||
onClick = onClearLogs,
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ 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.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import java.io.File
|
||||
@@ -27,6 +28,7 @@ class SupportViewModel @Inject constructor(
|
||||
val currentLogPath: File? = null,
|
||||
val recordingStartedAt: Long = 0L,
|
||||
val logFolderSize: Long = 0L,
|
||||
val logSessionCount: Int = 0,
|
||||
)
|
||||
|
||||
sealed interface Event {
|
||||
@@ -37,7 +39,10 @@ class SupportViewModel @Inject constructor(
|
||||
val events = SingleEventFlow<Event>()
|
||||
|
||||
private val stater = DynamicStateFlow(TAG, vmScope) {
|
||||
State(logFolderSize = recorderModule.getLogFolderSize())
|
||||
State(
|
||||
logFolderSize = recorderModule.getLogFolderSize(),
|
||||
logSessionCount = recorderModule.getLogSessionCount(),
|
||||
)
|
||||
}
|
||||
val state = stater.flow
|
||||
|
||||
@@ -50,6 +55,7 @@ class SupportViewModel @Inject constructor(
|
||||
currentLogPath = recorderState.currentLogPath,
|
||||
recordingStartedAt = recorderState.recordingStartedAt,
|
||||
logFolderSize = recorderModule.getLogFolderSize(),
|
||||
logSessionCount = recorderModule.getLogSessionCount(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -86,32 +92,39 @@ class SupportViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun doStopDebugLog() {
|
||||
val currentState = stater.value()
|
||||
val duration = System.currentTimeMillis() - currentState.recordingStartedAt
|
||||
val recorderState = recorderModule.state.first()
|
||||
val duration = System.currentTimeMillis() - recorderState.recordingStartedAt
|
||||
if (duration < 5_000) {
|
||||
events.tryEmit(Event.ShowShortRecordingWarning)
|
||||
return
|
||||
}
|
||||
log(TAG) { "stopDebugLog()" }
|
||||
recorderModule.stopRecorder()
|
||||
refreshLogSize()
|
||||
doRefreshLogSize()
|
||||
}
|
||||
|
||||
fun forceStopDebugLog() = launch {
|
||||
log(TAG) { "forceStopDebugLog()" }
|
||||
recorderModule.stopRecorder()
|
||||
refreshLogSize()
|
||||
doRefreshLogSize()
|
||||
}
|
||||
|
||||
fun clearDebugLogs() = launch {
|
||||
log(TAG) { "clearDebugLogs()" }
|
||||
recorderModule.deleteAllLogs()
|
||||
refreshLogSize()
|
||||
doRefreshLogSize()
|
||||
}
|
||||
|
||||
private suspend fun refreshLogSize() {
|
||||
fun refreshLogSize() = launch {
|
||||
doRefreshLogSize()
|
||||
}
|
||||
|
||||
private suspend fun doRefreshLogSize() {
|
||||
stater.updateBlocking {
|
||||
copy(logFolderSize = recorderModule.getLogFolderSize())
|
||||
copy(
|
||||
logFolderSize = recorderModule.getLogFolderSize(),
|
||||
logSessionCount = recorderModule.getLogSessionCount(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-5
@@ -16,6 +16,7 @@ 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.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import java.io.File
|
||||
@@ -84,19 +85,19 @@ class ContactFormViewModel @Inject constructor(
|
||||
copy(
|
||||
isRecording = recorderState.isRecording,
|
||||
recordingStartedAt = recorderState.recordingStartedAt,
|
||||
sessions = loadLogSessions(),
|
||||
sessions = loadLogSessions(activeDir = recorderState.currentLogDir),
|
||||
)
|
||||
}
|
||||
}
|
||||
.launchIn(vmScope)
|
||||
}
|
||||
|
||||
private fun loadLogSessions(): List<LogSessionItem> {
|
||||
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.isDirectory || (it.isFile && it.extension == "zip") }
|
||||
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() }
|
||||
@@ -164,8 +165,8 @@ class ContactFormViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun stopRecording() = launch {
|
||||
val currentState = stater.value()
|
||||
val duration = System.currentTimeMillis() - currentState.recordingStartedAt
|
||||
val recorderState = recorderModule.state.first()
|
||||
val duration = System.currentTimeMillis() - recorderState.recordingStartedAt
|
||||
if (duration < 5_000) {
|
||||
events.tryEmit(Event.ShowShortRecordingWarning)
|
||||
return@launch
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="?attr/colorSecondaryContainer" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
@@ -1,41 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/file_name"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textAppearance="?textAppearanceBodyMedium"
|
||||
tools:text="core.log" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/file_size"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?textAppearanceBodySmall"
|
||||
android:textColor="?colorOnSurfaceVariant"
|
||||
tools:text="2.4 MB" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/file_path"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?textAppearanceBodySmall"
|
||||
android:textColor="?colorOnSurfaceVariant"
|
||||
android:textIsSelectable="true"
|
||||
tools:text="/storage/emulated/0/.../core.log" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -1,305 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:paddingBottom="88dp"
|
||||
android:clipToPadding="false">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<!-- Hero section -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="24dp"
|
||||
android:paddingBottom="24dp">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
style="@style/Widget.Material3.CardView.Filled"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="64dp"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:cardBackgroundColor="?colorPrimaryContainer">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_gravity="center"
|
||||
android:src="@drawable/ic_bug_report_24"
|
||||
app:tint="?colorOnPrimaryContainer" />
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/debug_debuglog_screen_title"
|
||||
android:textAppearance="?textAppearanceHeadlineMedium"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/debug_debuglog_screen_subtitle"
|
||||
android:textAppearance="?textAppearanceBodyMedium"
|
||||
android:textColor="?colorOnSurfaceVariant" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Warning card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
style="@style/Widget.Material3.CardView.Filled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardBackgroundColor="?colorSecondaryContainer">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:src="@drawable/ic_info_outline_24"
|
||||
app:tint="?colorOnSecondaryContainer" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/debug_debuglog_screen_sensitive_title"
|
||||
android:textAppearance="?textAppearanceTitleSmall"
|
||||
android:textColor="?colorOnSecondaryContainer"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/debug_debuglog_sensitive_information_message"
|
||||
android:textAppearance="?textAppearanceBodySmall"
|
||||
android:textColor="?colorOnSecondaryContainer" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/privacy_policy"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_privacy_policy_label"
|
||||
android:textColor="?colorOnSecondaryContainer" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Session Info card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
style="@style/Widget.Material3.CardView.Filled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardBackgroundColor="?colorSurfaceContainer">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:src="@drawable/ic_folder_24"
|
||||
app:tint="?colorOnSurface" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/debug_debuglog_screen_session_path_label"
|
||||
android:textAppearance="?textAppearanceTitleSmall" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
style="@style/Widget.Material3.CardView.Filled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
app:cardBackgroundColor="?colorSurfaceVariant"
|
||||
app:cardCornerRadius="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/session_path"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="monospace"
|
||||
android:padding="12dp"
|
||||
android:textAppearance="?textAppearanceBodySmall"
|
||||
android:textIsSelectable="true"
|
||||
tools:text="/storage/emulated/0/Android/data/.../debug/logs/capod_1.0_123_abc12345" />
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Log Files header card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
style="@style/Widget.Material3.CardView.Filled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardBackgroundColor="?colorSurfaceContainer">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:src="@drawable/ic_description_24"
|
||||
app:tint="?colorOnSurface" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/debug_debuglog_screen_log_files_label"
|
||||
android:textAppearance="?textAppearanceTitleSmall"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/file_count_badge"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/badge_background"
|
||||
android:paddingHorizontal="10dp"
|
||||
android:paddingVertical="4dp"
|
||||
android:textAppearance="?textAppearanceLabelSmall"
|
||||
android:textColor="?colorOnSecondaryContainer"
|
||||
tools:text="2" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/log_files_caption"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="36dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textAppearance="?textAppearanceBodySmall"
|
||||
android:textColor="?colorOnSurfaceVariant"
|
||||
tools:text="2 files ready (ZIP: 745 KB)" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/log_files_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:nestedScrollingEnabled="false" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/loading_indicator"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- Bottom action bar -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/bottom_bar"
|
||||
style="@style/Widget.Material3.CardView.Elevated"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
app:cardCornerRadius="0dp"
|
||||
app:cardElevation="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="12dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/action_discard"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/debug_debuglog_screen_discard_action" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/action_keep"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/general_save_action" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/action_share"
|
||||
style="@style/Widget.Material3.Button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/general_share_action" />
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/share_loading"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_gravity="center"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -84,6 +84,9 @@
|
||||
<string name="settings_support_label">Support</string>
|
||||
<string name="settings_support_description">If you need some help.</string>
|
||||
|
||||
<string name="settings_wiki_label">Wiki</string>
|
||||
<string name="settings_wiki_description">FAQ & Guides</string>
|
||||
|
||||
<string name="issue_tracker_label">Issue tracker</string>
|
||||
<string name="issue_tracker_description">A public issue tracker for bug reports and feature requests (english only).</string>
|
||||
|
||||
@@ -96,6 +99,8 @@
|
||||
<string name="settings_privacy_policy_desc">Handling data responsibly.</string>
|
||||
<string name="settings_licenses_label">Licenses</string>
|
||||
<string name="settings_category_other_label">Other</string>
|
||||
<string name="settings_category_gethelp_label">Get help</string>
|
||||
<string name="settings_category_debug_label">Debug</string>
|
||||
<string name="settings_general_label">Settings</string>
|
||||
<string name="settings_general_description">General tweaks that affect the whole app.</string>
|
||||
<string name="settings_acknowledgements_label">Acknowledgements</string>
|
||||
@@ -327,24 +332,27 @@
|
||||
<string name="debug_debuglog_screen_discard_action">Discard</string>
|
||||
|
||||
<!-- Log management -->
|
||||
<string name="support_debuglog_folder_size">Debug logs (%s)</string>
|
||||
<plurals name="support_debuglog_folder_summary">
|
||||
<item quantity="one">%1$d debug log (%2$s)</item>
|
||||
<item quantity="other">%1$d debug logs (%2$s)</item>
|
||||
</plurals>
|
||||
<string name="support_debuglog_clear_action">Clear stored debug logs</string>
|
||||
|
||||
<!-- Contact form -->
|
||||
<string name="support_contact_label">Contact developer</string>
|
||||
<string name="support_contact_desc">Fill out a form to send the developer an email.</string>
|
||||
<string name="support_contact_welcome">I read every message myself and do my best to reply. Since I handle development, bug fixes, and support on my own, it can take a little time. Thanks for your patience.</string>
|
||||
<string name="support_contact_welcome">I read every message myself and do my best to reply, but since I work on this alone, it can take a little time. Thanks for your patience.</string>
|
||||
<string name="support_contact_footer">Your message will be sent via email. Device and setup information is attached automatically. You can attach screenshots or a video to the email. Some parts of the email will be in English so the developer can easily understand it.</string>
|
||||
<string name="support_contact_category_label">Category</string>
|
||||
<string name="support_contact_category_question_label">Question</string>
|
||||
<string name="support_contact_category_feature_label">Feature request</string>
|
||||
<string name="support_contact_category_bug_label">Bug report</string>
|
||||
<string name="support_contact_description_label">Description</string>
|
||||
<string name="support_contact_description_question_hint">Describe your question in detail. Please be specific (minimum 20 words).</string>
|
||||
<string name="support_contact_description_feature_hint">Describe the feature you would like to see. Please be specific (minimum 20 words).</string>
|
||||
<string name="support_contact_description_bug_hint">Describe what happened and how to reproduce the issue. Please be specific (minimum 20 words).</string>
|
||||
<string name="support_contact_description_question_hint">Describe your question in detail. Please be specific.</string>
|
||||
<string name="support_contact_description_feature_hint">Describe the feature you would like to see. Please be specific.</string>
|
||||
<string name="support_contact_description_bug_hint">Describe what happened and how to reproduce the issue. Please be specific.</string>
|
||||
<string name="support_contact_expected_label">Expected behavior</string>
|
||||
<string name="support_contact_expected_hint">Describe what you expected to happen (minimum 10 words).</string>
|
||||
<string name="support_contact_expected_hint">Describe what you expected to happen.</string>
|
||||
<plurals name="support_contact_word_count">
|
||||
<item quantity="one">%1$d / %2$d word</item>
|
||||
<item quantity="other">%1$d / %2$d words</item>
|
||||
|
||||
Reference in New Issue
Block a user