From c760d5758d748933f926f1d66430435a09d3e92f Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 3 Mar 2026 13:14:26 +0100 Subject: [PATCH] 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 --- .../capod/screenshots/ScreenshotContent.kt | 1 + .../debug/recording/core/RecorderModule.kt | 7 + .../debug/recording/ui/LogFileAdapter.kt | 43 -- .../debug/recording/ui/RecorderActivity.kt | 101 ++-- .../debug/recording/ui/RecorderActivityVM.kt | 70 +-- .../debug/recording/ui/RecorderScreen.kt | 441 ++++++++++++++++++ .../capod/main/ui/settings/SettingsScreen.kt | 14 +- .../main/ui/settings/support/SupportScreen.kt | 93 ++-- .../ui/settings/support/SupportViewModel.kt | 29 +- .../contactform/ContactFormViewModel.kt | 11 +- .../main/res/drawable/badge_background.xml | 6 - .../layout/debug_recorder_logfile_item.xml | 41 -- .../res/layout/debug_recording_activity.xml | 305 ------------ app/src/main/res/values/strings.xml | 20 +- 14 files changed, 642 insertions(+), 540 deletions(-) delete mode 100644 app/src/main/java/eu/darken/capod/common/debug/recording/ui/LogFileAdapter.kt create mode 100644 app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderScreen.kt delete mode 100644 app/src/main/res/drawable/badge_background.xml delete mode 100644 app/src/main/res/layout/debug_recorder_logfile_item.xml delete mode 100644 app/src/main/res/layout/debug_recording_activity.xml diff --git a/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt b/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt index 2f01487e..01c467ed 100644 --- a/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt +++ b/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt @@ -122,6 +122,7 @@ internal fun SettingsIndexContent() = PreviewWrapper { onDeviceManager = {}, onReactions = {}, onSupport = {}, + onWiki = {}, onChangelog = {}, onHelpTranslate = {}, onAcknowledgements = {}, diff --git a/app/src/main/java/eu/darken/capod/common/debug/recording/core/RecorderModule.kt b/app/src/main/java/eu/darken/capod/common/debug/recording/core/RecorderModule.kt index 86a368c3..a8580955 100644 --- a/app/src/main/java/eu/darken/capod/common/debug/recording/core/RecorderModule.kt +++ b/app/src/main/java/eu/darken/capod/common/debug/recording/core/RecorderModule.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/common/debug/recording/ui/LogFileAdapter.kt b/app/src/main/java/eu/darken/capod/common/debug/recording/ui/LogFileAdapter.kt deleted file mode 100644 index 57510995..00000000 --- a/app/src/main/java/eu/darken/capod/common/debug/recording/ui/LogFileAdapter.kt +++ /dev/null @@ -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(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() { - override fun areItemsTheSame(oldItem: Item, newItem: Item) = oldItem.file == newItem.file - override fun areContentsTheSame(oldItem: Item, newItem: Item) = oldItem == newItem - } - } -} diff --git a/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivity.kt b/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivity.kt index a41bae87..4b1c8894 100644 --- a/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivity.kt +++ b/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivity.kt @@ -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 { diff --git a/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivityVM.kt b/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivityVM.kt index 662a3418..4535e15f 100644 --- a/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivityVM.kt +++ b/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivityVM.kt @@ -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 = 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(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() - val finishEvent = SingleLiveEvent() - - init { - stater.flow - .onEach { log(TAG) { "State: $it" } } - .onError { errorEvents.postValue(it) } - .launchInViewModel() - } + val events = SingleEventFlow() 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 = emptyList(), - val totalSize: Long = 0L, - val compressedSize: Long = -1L, - val isWorking: Boolean = true, - ) - companion object { private val TAG = logTag("Debug", "Recorder", "VM") } diff --git a/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderScreen.kt b/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderScreen.kt new file mode 100644 index 00000000..1a0e1449 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderScreen.kt @@ -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, + 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 = {}, + ) +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsScreen.kt index a1fc492e..78cb33ed 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsScreen.kt @@ -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 = {}, diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportScreen.kt index 4e527935..48670c1c 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportScreen.kt @@ -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, ) diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportViewModel.kt index 1f171aa7..60746655 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportViewModel.kt @@ -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() 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(), + ) } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormViewModel.kt index 94fb1212..c7c7f6e8 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormViewModel.kt @@ -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 { + private fun loadLogSessions(activeDir: File? = null): List { 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 diff --git a/app/src/main/res/drawable/badge_background.xml b/app/src/main/res/drawable/badge_background.xml deleted file mode 100644 index f921fd0a..00000000 --- a/app/src/main/res/drawable/badge_background.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout/debug_recorder_logfile_item.xml b/app/src/main/res/layout/debug_recorder_logfile_item.xml deleted file mode 100644 index acccd745..00000000 --- a/app/src/main/res/layout/debug_recorder_logfile_item.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/debug_recording_activity.xml b/app/src/main/res/layout/debug_recording_activity.xml deleted file mode 100644 index 49db629c..00000000 --- a/app/src/main/res/layout/debug_recording_activity.xml +++ /dev/null @@ -1,305 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ee12316f..095d2b4b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -84,6 +84,9 @@ Support If you need some help. + Wiki + FAQ & Guides + Issue tracker A public issue tracker for bug reports and feature requests (english only). @@ -96,6 +99,8 @@ Handling data responsibly. Licenses Other + Get help + Debug Settings General tweaks that affect the whole app. Acknowledgements @@ -327,24 +332,27 @@ Discard - Debug logs (%s) + + %1$d debug log (%2$s) + %1$d debug logs (%2$s) + Clear stored debug logs Contact developer Fill out a form to send the developer an email. - 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. + 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. 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. Category Question Feature request Bug report Description - Describe your question in detail. Please be specific (minimum 20 words). - Describe the feature you would like to see. Please be specific (minimum 20 words). - Describe what happened and how to reproduce the issue. Please be specific (minimum 20 words). + Describe your question in detail. Please be specific. + Describe the feature you would like to see. Please be specific. + Describe what happened and how to reproduce the issue. Please be specific. Expected behavior - Describe what you expected to happen (minimum 10 words). + Describe what you expected to happen. %1$d / %2$d word %1$d / %2$d words