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