feat(support): Add contact support form and enhanced debug recorder

Add structured contact form with category selection (Question/Feature/Bug), description with word count validation, expected behavior field for bugs, and debug log picker with inline recording.

Enhance RecorderActivity with full-screen hero layout, file list, Share/Keep/Discard actions. Migrate debug logs from flat files to session directories in external files dir. Add DebugLogZipper, EmailTool attachment support, log folder size display, and clear stored logs.
This commit is contained in:
darken
2026-03-03 13:20:31 +01:00
committed by Matthias Urhahn
parent b3b55f1ca3
commit b609c1aced
25 changed files with 1667 additions and 215 deletions
+1 -1
View File
@@ -119,7 +119,7 @@
<!-- Debug stuff-->
<activity
android:name=".common.debug.recording.ui.RecorderActivity"
android:theme="@style/AppThemeFloating" />
android:theme="@style/AppTheme" />
<service
android:name=".monitor.core.worker.MonitorService"
@@ -1,7 +1,9 @@
package eu.darken.capod.common
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.net.Uri
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
@@ -13,14 +15,21 @@ class EmailTool @Inject constructor(
fun build(email: Email, offerChooser: Boolean = false): Intent {
val intent = Intent(Intent.ACTION_SEND)
intent.type = "message/rfc822"
intent.putExtra(Intent.EXTRA_EMAIL, email.receipients.toTypedArray())
intent.addCategory(Intent.CATEGORY_DEFAULT)
intent.putExtra(Intent.EXTRA_SUBJECT, email.subject)
intent.putExtra(Intent.EXTRA_TEXT, email.body)
if (email.attachment != null) {
intent.type = "application/zip"
intent.putExtra(Intent.EXTRA_STREAM, email.attachment)
intent.clipData = ClipData.newRawUri("", email.attachment)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} else {
intent.type = "message/rfc822"
}
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
return if (offerChooser) Intent.createChooser(intent, null) else intent
}
@@ -28,6 +37,7 @@ class EmailTool @Inject constructor(
data class Email(
val receipients: List<String>,
val subject: String,
val body: String
val body: String,
val attachment: Uri? = null,
)
}
}
@@ -0,0 +1,5 @@
package eu.darken.capod.common
object SupportLinks {
const val SUPPORT_EMAIL = "support@darken.eu"
}
@@ -0,0 +1,38 @@
package eu.darken.capod.common.debug.recording.core
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.compression.Zipper
import java.io.File
import javax.inject.Inject
@Reusable
class DebugLogZipper @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun zipAndGetUri(logDir: File): Uri {
val logFiles = logDir.listFiles()?.toList()
?: throw IllegalStateException("No log files in $logDir")
val zipFile = File(logDir.parentFile, "${logDir.name}.zip")
Zipper().zip(logFiles.map { it.path }.toTypedArray(), zipFile.path)
return FileProvider.getUriForFile(
context,
BuildConfigWrap.APPLICATION_ID + ".provider",
zipFile,
)
}
fun getUriForZip(zipFile: File): Uri {
return FileProvider.getUriForFile(
context,
BuildConfigWrap.APPLICATION_ID + ".provider",
zipFile,
)
}
}
@@ -6,10 +6,12 @@ import android.os.Build
import android.os.Environment
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.InstallId
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.debug.recording.ui.RecorderActivity
@@ -30,6 +32,7 @@ class RecorderModule @Inject constructor(
@ApplicationContext private val context: Context,
@AppScope private val appScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
private val installId: InstallId,
) {
private val triggerFile = try {
@@ -54,32 +57,42 @@ class RecorderModule @Inject constructor(
internalState.updateBlocking {
if (!isRecording && shouldRecord) {
val sessionDir = createSessionDir()
val logFile = File(sessionDir, "core.log")
val newRecorder = Recorder()
newRecorder.start(createRecordingFilePath())
newRecorder.start(logFile)
triggerFile.createNewFile()
log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" }
log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION}" }
copy(
recorder = newRecorder
recorder = newRecorder,
currentLogDir = sessionDir,
recordingStartedAt = System.currentTimeMillis(),
)
} else if (!shouldRecord && isRecording) {
val currentLog = recorder!!.path!!
recorder.stop()
recorder!!.stop()
if (triggerFile.exists() && !triggerFile.delete()) {
log(TAG, ERROR) { "Failed to delete trigger file" }
}
val intent = RecorderActivity.getLaunchIntent(context, currentLog.path).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val logDir = currentLogDir!!
if (showResultUi) {
val intent = RecorderActivity.getLaunchIntent(context, logDir.path).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
context.startActivity(intent)
copy(
recorder = null,
lastLogPath = currentLog
currentLogDir = null,
lastLogDir = logDir,
showResultUi = true,
recordingStartedAt = 0L,
)
} else {
this
@@ -89,31 +102,92 @@ class RecorderModule @Inject constructor(
.launchIn(appScope)
}
private fun createRecordingFilePath() = File(
private fun createSessionDir(): File {
val timestamp = System.currentTimeMillis()
val installIdPrefix = installId.id.take(8)
val dirName = "capod_${BuildConfigWrap.VERSION_NAME}_${timestamp}_$installIdPrefix"
val primaryParent = try {
val dir = File(context.getExternalFilesDir(null), "debug/logs")
dir.mkdirs()
if (dir.canWrite()) dir else null
} catch (e: Exception) {
log(TAG, WARN) { "External files dir unavailable: $e" }
null
}
val parent = primaryParent ?: File(context.cacheDir, "debug/logs").also { it.mkdirs() }
val sessionDir = File(parent, dirName)
sessionDir.mkdirs()
log(TAG) { "Created session dir: $sessionDir" }
return sessionDir
}
fun getLogDirectories(): List<File> = listOfNotNull(
try {
context.getExternalFilesDir(null)?.let { File(it, "debug/logs") }
} catch (e: Exception) {
null
},
File(context.cacheDir, "debug/logs"),
"capod_logfile_${System.currentTimeMillis()}.log"
)
fun getLogFolderSize(): Long {
return getLogDirectories().sumOf { dir ->
if (!dir.exists()) return@sumOf 0L
dir.listFiles()?.sumOf { entry ->
if (entry.isDirectory) {
entry.walkTopDown().filter { it.isFile }.sumOf { it.length() }
} else {
entry.length()
}
} ?: 0L
}
}
suspend fun deleteAllLogs() {
val activeDir = internalState.value().currentLogDir
getLogDirectories().forEach { dir ->
if (!dir.exists()) return@forEach
dir.listFiles()?.forEach { entry ->
if (entry == activeDir) {
log(TAG) { "Skipping active session dir: $entry" }
return@forEach
}
if (entry.isDirectory) {
entry.deleteRecursively()
} else {
entry.delete()
}
}
}
log(TAG) { "All stored logs deleted" }
}
suspend fun startRecorder(): File {
internalState.updateBlocking {
copy(shouldRecord = true)
}
return internalState.flow.filter { it.isRecording }.first().currentLogPath!!
return internalState.flow.filter { it.isRecording }.first().currentLogDir!!
}
suspend fun stopRecorder(): File? {
val currentPath = internalState.value().currentLogPath ?: return null
suspend fun stopRecorder(showResultUi: Boolean = true): File? {
val currentDir = internalState.value().currentLogDir ?: return null
internalState.updateBlocking {
copy(shouldRecord = false)
copy(shouldRecord = false, showResultUi = showResultUi)
}
internalState.flow.filter { !it.isRecording }.first()
return currentPath
return currentDir
}
data class State(
val shouldRecord: Boolean = false,
internal val recorder: Recorder? = null,
val lastLogPath: File? = null,
val currentLogDir: File? = null,
val lastLogDir: File? = null,
val recordingStartedAt: Long = 0L,
internal val showResultUi: Boolean = true,
) {
val isRecording: Boolean
get() = recorder != null
@@ -126,4 +200,4 @@ class RecorderModule @Inject constructor(
internal val TAG = logTag("Debug", "Log", "Recorder", "Module")
private const val FORCE_FILE = "capod_force_debug_run"
}
}
}
@@ -0,0 +1,43 @@
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
}
}
}
@@ -4,8 +4,13 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.format.Formatter
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.core.view.isInvisible
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 dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.error.asErrorDialogBuilder
@@ -17,33 +22,66 @@ class RecorderActivity : Activity2() {
private lateinit var ui: DebugRecordingActivityBinding
private val vm: RecorderActivityVM by viewModels()
private val logFileAdapter = LogFileAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
if (intent.getStringExtra(RECORD_PATH) == null) {
finish()
return
}
ui = DebugRecordingActivityBinding.inflate(layoutInflater)
setContentView(ui.root)
ViewCompat.setOnApplyWindowInsetsListener(ui.root) { view, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.updatePadding(left = systemBars.left, right = systemBars.right)
insets
}
ui.logFilesList.apply {
layoutManager = LinearLayoutManager(this@RecorderActivity)
adapter = logFileAdapter
}
vm.state.observe2 { state ->
ui.loadingIndicator.isInvisible = !state.loading
ui.share.isInvisible = state.loading
ui.loadingIndicator.isVisible = state.isWorking
ui.actionShare.isEnabled = !state.isWorking
ui.shareLoading.isVisible = state.isWorking
ui.recordingPath.text = state.normalPath
ui.sessionPath.text = state.logDir?.path ?: ""
if (state.normalSize != -1L) {
ui.recordingSize.text = Formatter.formatShortFileSize(this, state.normalSize)
}
if (state.compressedSize != -1L) {
ui.recordingSizeCompressed.text = Formatter.formatShortFileSize(this, state.compressedSize)
val fileCount = state.logEntries.size
val compressedText = if (state.compressedSize >= 0) {
"ZIP: ${Formatter.formatShortFileSize(this, state.compressedSize)}"
} else {
"..."
}
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.share.setOnClickListener { vm.share() }
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 {
@@ -1,25 +1,22 @@
package eu.darken.capod.common.debug.recording.ui
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.compression.Zipper
import eu.darken.capod.common.PrivacyPolicy
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.debug.recording.core.DebugLogZipper
import eu.darken.capod.common.flow.DynamicStateFlow
import eu.darken.capod.common.flow.onError
import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.common.livedata.SingleLiveEvent
import eu.darken.capod.common.uix.ViewModel3
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.plus
import java.io.File
@@ -30,84 +27,104 @@ class RecorderActivityVM @Inject constructor(
handle: SavedStateHandle,
dispatcherProvider: DispatcherProvider,
@ApplicationContext private val context: Context,
private val debugLogZipper: DebugLogZipper,
private val webpageTool: WebpageTool,
) : ViewModel3(dispatcherProvider) {
private val recordedPath = handle.get<String>(RecorderActivity.RECORD_PATH)!!
private val pathCache = MutableStateFlow(recordedPath)
private val resultCacheObs = pathCache
.map { path -> Pair(path, File(path).length()) }
.replayingShare(vmScope)
private val recordedDirPath = handle.get<String>(RecorderActivity.RECORD_PATH)
private val logDir = recordedDirPath?.let { File(it) }
private val resultCacheCompressedObs = resultCacheObs
.map { uncompressed ->
val zipped = "${uncompressed.first}.zip"
Zipper().zip(arrayOf(uncompressed.first), zipped)
Pair(zipped, File(zipped).length())
private val stater = DynamicStateFlow(TAG, vmScope + dispatcherProvider.IO) {
if (logDir == null || !logDir.exists()) {
return@DynamicStateFlow State(logDir = null)
}
.replayingShare(vmScope + dispatcherProvider.IO)
private val stater = DynamicStateFlow(TAG, vmScope) { State() }
val files = logDir.listFiles()?.toList() ?: emptyList()
val entries = files.map { LogFileAdapter.Item(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()
} catch (e: Exception) {
log(TAG) { "Failed to zip: $e" }
-1L
}
State(
logDir = logDir,
logEntries = entries,
totalSize = totalSize,
compressedSize = compressedSize,
isWorking = false,
)
}
val state = stater.asLiveData2()
val shareEvent = SingleLiveEvent<Intent>()
val finishEvent = SingleLiveEvent<Unit>()
init {
resultCacheObs
.onEach { (path, size) ->
stater.updateBlocking { copy(normalPath = path, normalSize = size) }
}
.launchInViewModel()
resultCacheCompressedObs
.onEach { (path, size) ->
stater.updateBlocking {
copy(
compressedPath = path,
compressedSize = size,
loading = false
)
}
}
stater.flow
.onEach { log(TAG) { "State: $it" } }
.onError { errorEvents.postValue(it) }
.launchInViewModel()
}
fun share() = launch {
val (path, _) = resultCacheCompressedObs.first()
val currentState = stater.flow.first()
val dir = currentState.logDir ?: return@launch
val intent = Intent(Intent.ACTION_SEND).apply {
val uri = FileProvider.getUriForFile(
context,
BuildConfigWrap.APPLICATION_ID + ".provider",
File(path)
)
stater.updateBlocking { copy(isWorking = true) }
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
type = "application/zip"
try {
val uri = debugLogZipper.zipAndGetUri(dir)
addCategory(Intent.CATEGORY_DEFAULT)
putExtra(Intent.EXTRA_SUBJECT, "CAPod DebugLog - ${BuildConfigWrap.VERSION_DESCRIPTION})")
putExtra(Intent.EXTRA_TEXT, "Your text here.")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val intent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
type = "application/zip"
addCategory(Intent.CATEGORY_DEFAULT)
putExtra(Intent.EXTRA_SUBJECT, "CAPod DebugLog - ${dir.name}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val chooserIntent = Intent.createChooser(intent, context.getString(R.string.support_debuglog_label))
shareEvent.postValue(chooserIntent)
} finally {
stater.updateBlocking { copy(isWorking = false) }
}
}
fun keep() {
finishEvent.postValue(Unit)
}
val chooserIntent = Intent.createChooser(intent, context.getString(R.string.support_debuglog_label))
shareEvent.postValue(chooserIntent)
fun discard() = launch {
val currentState = stater.flow.first()
val dir = currentState.logDir ?: return@launch
dir.deleteRecursively()
val zipFile = File(dir.parentFile, "${dir.name}.zip")
if (zipFile.exists()) zipFile.delete()
finishEvent.postValue(Unit)
}
fun goPrivacyPolicy() {
webpageTool.open(PrivacyPolicy.URL)
}
data class State(
val normalPath: String? = null,
val normalSize: Long = -1L,
val compressedPath: String? = null,
val logDir: File? = null,
val logEntries: List<LogFileAdapter.Item> = emptyList(),
val totalSize: Long = 0L,
val compressedSize: Long = -1L,
val loading: Boolean = true
val isWorking: Boolean = true,
)
companion object {
private val TAG = logTag("Debug", "Recorder", "VM")
}
}
}
@@ -38,5 +38,8 @@ object Nav {
@Serializable
data object Acknowledgements : Settings
@Serializable
data object ContactSupport : Settings
}
}
@@ -1,5 +1,6 @@
package eu.darken.capod.main.ui.settings.support
import android.text.format.Formatter
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
@@ -14,52 +15,116 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.darken.capod.R
import eu.darken.capod.common.PrivacyPolicy
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.waitForState
import eu.darken.capod.common.debug.recording.ui.RecorderConsentDialog
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.settings.SettingsBaseItem
import eu.darken.capod.common.settings.SettingsCategoryHeader
@Composable
fun SupportScreenHost(vm: SupportViewModel = hiltViewModel()) {
ErrorEventHandler(vm)
NavigationEventHandler(vm)
val context = LocalContext.current
val state by waitForState(vm.state)
var showShortRecordingWarning by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
vm.events.collect { event ->
when (event) {
SupportViewModel.Event.ShowConsentDialog -> {
RecorderConsentDialog(context, WebpageTool(context)).showDialog {
vm.startDebugLog()
}
}
SupportViewModel.Event.ShowShortRecordingWarning -> {
showShortRecordingWarning = true
}
}
}
}
if (showShortRecordingWarning) {
ShortRecordingWarningDialog(context) {
showShortRecordingWarning = false
vm.forceStopDebugLog()
}
// Dismiss on cancel handled internally
}
state?.let {
SupportScreen(
state = it,
onNavigateUp = { vm.navUp() },
onContactDeveloper = { vm.goToContactSupport() },
onDiscord = { vm.openUrl("https://discord.gg/rrxxng35jq") },
onIssueTracker = { vm.openUrl("https://github.com/d4rken-org/capod/issues") },
onTroubleShooter = { vm.goToTroubleShooter() },
onDebugLogToggle = {
if (it.isRecording) vm.stopDebugLog()
else vm.startDebugLog()
},
onDebugLogToggle = { vm.onDebugLogToggle() },
onClearLogs = { vm.clearDebugLogs() },
)
}
}
@Composable
private fun ShortRecordingWarningDialog(
context: android.content.Context,
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()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SupportScreen(
state: SupportViewModel.State,
onNavigateUp: () -> Unit,
onContactDeveloper: () -> Unit,
onDiscord: () -> Unit,
onIssueTracker: () -> Unit,
onTroubleShooter: () -> Unit,
onDebugLogToggle: () -> Unit,
onClearLogs: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
Scaffold(
modifier = modifier,
topBar = {
@@ -77,6 +142,14 @@ fun SupportScreen(
},
) { innerPadding ->
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,
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.discord_label),
@@ -105,6 +178,7 @@ fun SupportScreen(
)
}
item {
val logSizeFormatted = Formatter.formatShortFileSize(context, state.logFolderSize)
SettingsBaseItem(
title = if (state.isRecording) {
stringResource(R.string.debug_debuglog_stop_action)
@@ -114,7 +188,7 @@ fun SupportScreen(
subtitle = if (state.isRecording) {
state.currentLogPath?.path
} else {
stringResource(R.string.debug_debuglog_record_action)
stringResource(R.string.support_debuglog_folder_size, logSizeFormatted)
},
icon = if (state.isRecording) {
Icons.TwoTone.Cancel
@@ -124,6 +198,15 @@ fun SupportScreen(
onClick = onDebugLogToggle,
)
}
if (state.logFolderSize > 0 && !state.isRecording) {
item {
SettingsBaseItem(
title = stringResource(R.string.support_debuglog_clear_action),
iconPainter = painterResource(R.drawable.ic_delete_sweep_24),
onClick = onClearLogs,
)
}
}
}
}
}
@@ -132,11 +215,13 @@ fun SupportScreen(
@Composable
private fun SupportScreenPreview() = PreviewWrapper {
SupportScreen(
state = SupportViewModel.State(isRecording = false, currentLogPath = null),
state = SupportViewModel.State(),
onNavigateUp = {},
onContactDeveloper = {},
onDiscord = {},
onIssueTracker = {},
onTroubleShooter = {},
onDebugLogToggle = {},
onClearLogs = {},
)
}
@@ -6,10 +6,12 @@ import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.debug.recording.core.RecorderModule
import eu.darken.capod.common.flow.shareLatest
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.map
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import java.io.File
import javax.inject.Inject
@@ -21,18 +23,38 @@ class SupportViewModel @Inject constructor(
) : ViewModel4(dispatcherProvider) {
data class State(
val isRecording: Boolean,
val currentLogPath: File?,
val isRecording: Boolean = false,
val currentLogPath: File? = null,
val recordingStartedAt: Long = 0L,
val logFolderSize: Long = 0L,
)
val state = recorderModule.state
.map {
State(
isRecording = it.isRecording,
currentLogPath = it.currentLogPath,
)
}
.shareLatest(scope = vmScope)
sealed interface Event {
data object ShowConsentDialog : Event
data object ShowShortRecordingWarning : Event
}
val events = SingleEventFlow<Event>()
private val stater = DynamicStateFlow(TAG, vmScope) {
State(logFolderSize = recorderModule.getLogFolderSize())
}
val state = stater.flow
init {
recorderModule.state
.onEach { recorderState ->
stater.updateBlocking {
copy(
isRecording = recorderState.isRecording,
currentLogPath = recorderState.currentLogPath,
recordingStartedAt = recorderState.recordingStartedAt,
logFolderSize = recorderModule.getLogFolderSize(),
)
}
}
.launchIn(vmScope)
}
fun openUrl(url: String) {
webpageTool.open(url)
@@ -42,14 +64,55 @@ class SupportViewModel @Inject constructor(
navTo(Nav.Main.TroubleShooter)
}
fun goToContactSupport() {
navTo(Nav.Settings.ContactSupport)
}
fun onDebugLogToggle() = launch {
if (stater.value().isRecording) {
doStopDebugLog()
} else {
events.tryEmit(Event.ShowConsentDialog)
}
}
fun startDebugLog() = launch {
log(TAG) { "startDebugLog()" }
recorderModule.startRecorder()
}
fun stopDebugLog() = launch {
doStopDebugLog()
}
private suspend fun doStopDebugLog() {
val currentState = stater.value()
val duration = System.currentTimeMillis() - currentState.recordingStartedAt
if (duration < 5_000) {
events.tryEmit(Event.ShowShortRecordingWarning)
return
}
log(TAG) { "stopDebugLog()" }
recorderModule.stopRecorder()
refreshLogSize()
}
fun forceStopDebugLog() = launch {
log(TAG) { "forceStopDebugLog()" }
recorderModule.stopRecorder()
refreshLogSize()
}
fun clearDebugLogs() = launch {
log(TAG) { "clearDebugLogs()" }
recorderModule.deleteAllLogs()
refreshLogSize()
}
private suspend fun refreshLogSize() {
stater.updateBlocking {
copy(logFolderSize = recorderModule.getLogFolderSize())
}
}
companion object {
@@ -0,0 +1,26 @@
package eu.darken.capod.main.ui.settings.support.contactform
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationEntry
import javax.inject.Inject
class ContactFormNavigation @Inject constructor() : NavigationEntry {
override fun EntryProviderScope<NavKey>.setup() {
entry<Nav.Settings.ContactSupport> { ContactFormScreenHost() }
}
@Module
@InstallIn(SingletonComponent::class)
abstract class Mod {
@Binds
@IntoSet
abstract fun bind(entry: ContactFormNavigation): NavigationEntry
}
}
@@ -0,0 +1,458 @@
package eu.darken.capod.main.ui.settings.support.contactform
import android.content.ActivityNotFoundException
import android.text.format.Formatter
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
import androidx.compose.material.icons.twotone.BugReport
import androidx.compose.material.icons.twotone.Cancel
import androidx.compose.material.icons.twotone.Delete
import androidx.compose.material.icons.twotone.Email
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.darken.capod.R
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.compose.waitForState
import eu.darken.capod.common.debug.recording.ui.RecorderConsentDialog
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.main.ui.settings.support.contactform.ContactFormViewModel.Category
@Composable
fun ContactFormScreenHost(vm: ContactFormViewModel = hiltViewModel()) {
ErrorEventHandler(vm)
NavigationEventHandler(vm)
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
var showShortRecordingWarning by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
vm.events.collect { event ->
when (event) {
is ContactFormViewModel.Event.OpenEmail -> {
try {
context.startActivity(event.intent)
} catch (e: ActivityNotFoundException) {
snackbarHostState.showSnackbar(
context.getString(R.string.support_contact_no_email_app)
)
}
}
is ContactFormViewModel.Event.ShowSnackbar -> {
snackbarHostState.showSnackbar(event.message)
}
ContactFormViewModel.Event.ShowConsentDialog -> {
RecorderConsentDialog(context, WebpageTool(context)).showDialog {
vm.doStartRecording()
}
}
ContactFormViewModel.Event.ShowShortRecordingWarning -> {
showShortRecordingWarning = true
}
}
}
}
if (showShortRecordingWarning) {
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) { _, _ ->
showShortRecordingWarning = false
}
setNegativeButton(R.string.debug_debuglog_short_recording_stop) { _, _ ->
showShortRecordingWarning = false
vm.forceStopRecording()
}
setOnCancelListener { showShortRecordingWarning = false }
}.show()
}
}
val state by waitForState(vm.state)
state?.let {
ContactFormScreen(
state = it,
snackbarHostState = snackbarHostState,
onNavigateUp = { vm.navUp() },
onCategoryChange = { vm.updateCategory(it) },
onDescriptionChange = { vm.updateDescription(it) },
onExpectedChange = { vm.updateExpectedBehavior(it) },
onSelectSession = { vm.selectLogSession(it) },
onDeleteSession = { path ->
MaterialAlertDialogBuilder(context).apply {
setTitle(R.string.support_contact_debuglog_delete_title)
setMessage(R.string.support_contact_debuglog_delete_message)
setPositiveButton(R.string.profiles_delete_action) { _, _ ->
vm.deleteLogSession(path)
}
setNegativeButton(R.string.general_cancel_action) { _, _ -> }
}.show()
},
onStartRecording = { vm.startRecording() },
onStopRecording = { vm.stopRecording() },
onSend = { vm.send() },
)
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun ContactFormScreen(
state: ContactFormViewModel.State,
snackbarHostState: SnackbarHostState,
onNavigateUp: () -> Unit,
onCategoryChange: (Category) -> Unit,
onDescriptionChange: (String) -> Unit,
onExpectedChange: (String) -> Unit,
onSelectSession: (java.io.File) -> Unit,
onDeleteSession: (java.io.File) -> Unit,
onStartRecording: () -> Unit,
onStopRecording: () -> Unit,
onSend: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(text = stringResource(R.string.support_contact_label)) },
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
contentDescription = null,
)
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Category Card
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
) {
Column(modifier = Modifier.padding(16.dp)) {
SectionHeader(
icon = { Icon(painterResource(R.drawable.ic_description_24), null, Modifier.size(20.dp)) },
title = stringResource(R.string.support_contact_category_label),
)
Spacer(modifier = Modifier.height(8.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = state.category == Category.QUESTION,
onClick = { onCategoryChange(Category.QUESTION) },
label = { Text(stringResource(R.string.support_contact_category_question_label)) },
)
FilterChip(
selected = state.category == Category.FEATURE,
onClick = { onCategoryChange(Category.FEATURE) },
label = { Text(stringResource(R.string.support_contact_category_feature_label)) },
)
FilterChip(
selected = state.category == Category.BUG,
onClick = { onCategoryChange(Category.BUG) },
label = { Text(stringResource(R.string.support_contact_category_bug_label)) },
)
}
}
}
// Debug Log Card (only for Bug)
if (state.isBug) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
) {
Column(modifier = Modifier.padding(16.dp)) {
SectionHeader(
icon = {
Icon(
Icons.TwoTone.BugReport,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
},
title = stringResource(R.string.support_contact_debuglog_label),
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.support_contact_debuglog_picker_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
if (state.sessions.isEmpty() && !state.isRecording) {
Text(
text = stringResource(R.string.support_contact_debuglog_picker_empty),
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp),
)
} else {
state.sessions.forEach { session ->
val isSelected = state.selectedSessionPath == session.path
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = isSelected,
onClick = { onSelectSession(session.path) },
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 4.dp),
) {
Text(
text = session.path.name,
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = Formatter.formatShortFileSize(context, session.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { onDeleteSession(session.path) }) {
Icon(
Icons.TwoTone.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
if (state.isRecording) {
androidx.compose.material3.FilledTonalButton(onClick = onStopRecording) {
Icon(Icons.TwoTone.Cancel, null, Modifier.size(18.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.debug_debuglog_stop_action))
}
} else {
androidx.compose.material3.FilledTonalButton(onClick = onStartRecording) {
Icon(Icons.TwoTone.BugReport, null, Modifier.size(18.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.debug_debuglog_record_action))
}
}
}
}
}
}
// Description Card
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
) {
Column(modifier = Modifier.padding(16.dp)) {
OutlinedTextField(
value = state.description,
onValueChange = onDescriptionChange,
label = { Text(stringResource(R.string.support_contact_description_label)) },
modifier = Modifier.fillMaxWidth(),
minLines = 4,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
)
Spacer(modifier = Modifier.height(4.dp))
WordCountText(
current = state.descriptionWords,
minimum = 20,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = when (state.category) {
Category.BUG -> stringResource(R.string.support_contact_description_bug_hint)
Category.FEATURE -> stringResource(R.string.support_contact_description_feature_hint)
Category.QUESTION -> stringResource(R.string.support_contact_description_question_hint)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Expected Behavior Card (only for Bug)
if (state.isBug) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
) {
Column(modifier = Modifier.padding(16.dp)) {
OutlinedTextField(
value = state.expectedBehavior,
onValueChange = onExpectedChange,
label = { Text(stringResource(R.string.support_contact_expected_label)) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
)
Spacer(modifier = Modifier.height(4.dp))
WordCountText(
current = state.expectedWords,
minimum = 10,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.support_contact_expected_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Personal Note Card
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Row(modifier = Modifier.padding(16.dp)) {
Icon(
painterResource(R.drawable.ic_contact_support_24),
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = stringResource(R.string.support_contact_welcome),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Send Button
androidx.compose.material3.Button(
onClick = onSend,
modifier = Modifier.fillMaxWidth(),
enabled = state.canSend,
) {
if (state.isSending) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
} else {
Icon(Icons.TwoTone.Email, null, Modifier.size(18.dp))
}
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.support_contact_send_action))
}
// Footer
Text(
text = stringResource(R.string.support_contact_footer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@Composable
private fun SectionHeader(
icon: @Composable () -> Unit,
title: String,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
icon()
Spacer(modifier = Modifier.width(8.dp))
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
)
}
}
@Composable
private fun WordCountText(
current: Int,
minimum: Int,
) {
val color = when {
current == 0 -> MaterialTheme.colorScheme.onSurfaceVariant
current < minimum -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.primary
}
Text(
text = pluralStringResource(R.plurals.support_contact_word_count, current, current, minimum),
style = MaterialTheme.typography.bodySmall,
color = color,
)
}
@@ -0,0 +1,253 @@
package eu.darken.capod.main.ui.settings.support.contactform
import android.content.Context
import android.content.Intent
import android.os.Build
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.EmailTool
import eu.darken.capod.common.SupportLinks
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.debug.recording.core.DebugLogZipper
import eu.darken.capod.common.debug.recording.core.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.launchIn
import kotlinx.coroutines.flow.onEach
import java.io.File
import javax.inject.Inject
@HiltViewModel
class ContactFormViewModel @Inject constructor(
dispatcherProvider: DispatcherProvider,
@ApplicationContext private val context: Context,
private val recorderModule: RecorderModule,
private val debugLogZipper: DebugLogZipper,
private val emailTool: EmailTool,
) : ViewModel4(dispatcherProvider) {
enum class Category { QUESTION, FEATURE, BUG }
data class LogSessionItem(
val path: File,
val size: Long,
val lastModified: Long,
)
data class State(
val category: Category = Category.QUESTION,
val description: String = "",
val expectedBehavior: String = "",
val isSending: Boolean = false,
val isRecording: Boolean = false,
val recordingStartedAt: Long = 0L,
val sessions: List<LogSessionItem> = emptyList(),
val selectedSessionPath: File? = null,
) {
val isBug: Boolean get() = category == Category.BUG
val descriptionWords: Int
get() = description.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() }.size
val expectedWords: Int
get() = expectedBehavior.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() }.size
val canSend: Boolean
get() = descriptionWords >= 20
&& (!isBug || expectedWords >= 10)
&& !isSending
&& !isRecording
}
sealed interface Event {
data class OpenEmail(val intent: Intent) : Event
data class ShowSnackbar(val message: String) : Event
data object ShowConsentDialog : Event
data object ShowShortRecordingWarning : Event
}
val events = SingleEventFlow<Event>()
private val stater = DynamicStateFlow(TAG, vmScope) {
State(sessions = loadLogSessions())
}
val state = stater.flow
init {
recorderModule.state
.onEach { recorderState ->
stater.updateBlocking {
copy(
isRecording = recorderState.isRecording,
recordingStartedAt = recorderState.recordingStartedAt,
sessions = loadLogSessions(),
)
}
}
.launchIn(vmScope)
}
private fun loadLogSessions(): 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") }
.map { entry ->
val size = if (entry.isDirectory) {
entry.walkTopDown().filter { it.isFile }.sumOf { it.length() }
} else {
entry.length()
}
LogSessionItem(
path = entry,
size = size,
lastModified = entry.lastModified(),
)
}
}
.sortedByDescending { it.lastModified }
}
fun updateCategory(category: Category) = launch {
stater.updateBlocking { copy(category = category) }
}
fun updateDescription(text: String) = launch {
if (text.length <= 5000) {
stater.updateBlocking { copy(description = text) }
}
}
fun updateExpectedBehavior(text: String) = launch {
if (text.length <= 5000) {
stater.updateBlocking { copy(expectedBehavior = text) }
}
}
fun selectLogSession(path: File) = launch {
stater.updateBlocking { copy(selectedSessionPath = path) }
}
fun deleteLogSession(path: File) = launch {
log(TAG) { "deleteLogSession($path)" }
if (path.isDirectory) {
path.deleteRecursively()
val zip = File(path.parentFile, "${path.name}.zip")
if (zip.exists()) zip.delete()
} else {
path.delete()
}
stater.updateBlocking {
copy(
sessions = loadLogSessions(),
selectedSessionPath = if (selectedSessionPath == path) null else selectedSessionPath,
)
}
}
fun refreshLogSessions() = launch {
stater.updateBlocking { copy(sessions = loadLogSessions()) }
}
fun startRecording() {
events.tryEmit(Event.ShowConsentDialog)
}
fun doStartRecording() = launch {
log(TAG) { "doStartRecording()" }
recorderModule.startRecorder()
}
fun stopRecording() = launch {
val currentState = stater.value()
val duration = System.currentTimeMillis() - currentState.recordingStartedAt
if (duration < 5_000) {
events.tryEmit(Event.ShowShortRecordingWarning)
return@launch
}
log(TAG) { "stopRecording()" }
recorderModule.stopRecorder(showResultUi = false)
stater.updateBlocking { copy(sessions = loadLogSessions()) }
}
fun forceStopRecording() = launch {
log(TAG) { "forceStopRecording()" }
recorderModule.stopRecorder(showResultUi = false)
stater.updateBlocking { copy(sessions = loadLogSessions()) }
}
fun send() = launch {
val currentState = stater.value()
if (!currentState.canSend) return@launch
stater.updateBlocking { copy(isSending = true) }
try {
val attachmentUri = currentState.selectedSessionPath?.let { sessionPath ->
try {
if (sessionPath.isDirectory) {
debugLogZipper.zipAndGetUri(sessionPath)
} else if (sessionPath.extension == "zip" && sessionPath.exists()) {
debugLogZipper.getUriForZip(sessionPath)
} else {
null
}
} catch (e: Exception) {
log(TAG) { "Failed to prepare attachment: $e" }
events.tryEmit(
Event.ShowSnackbar(context.getString(eu.darken.capod.R.string.support_contact_debuglog_zip_error))
)
null
}
}
val categoryTag = when (currentState.category) {
Category.QUESTION -> "Question"
Category.FEATURE -> "Feature"
Category.BUG -> "Bug"
}
val firstWords = currentState.description.trim()
.split("\\s+".toRegex())
.take(8)
.joinToString(" ")
val subject = "[CAPod][$categoryTag] $firstWords"
val body = buildString {
appendLine(currentState.description.trim())
if (currentState.isBug && currentState.expectedBehavior.isNotBlank()) {
appendLine()
appendLine("--- Expected behavior ---")
appendLine(currentState.expectedBehavior.trim())
}
appendLine()
appendLine("--- Device info ---")
appendLine("App: ${BuildConfigWrap.VERSION_DESCRIPTION}")
appendLine("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
appendLine("Device: ${Build.MANUFACTURER} ${Build.MODEL}")
}
val email = EmailTool.Email(
receipients = listOf(SupportLinks.SUPPORT_EMAIL),
subject = subject,
body = body,
attachment = attachmentUri,
)
val intent = emailTool.build(email, offerChooser = true)
events.tryEmit(Event.OpenEmail(intent))
} finally {
stater.updateBlocking { copy(isSending = false) }
}
}
companion object {
private val TAG = logTag("Settings", "Support", "ContactForm", "VM")
}
}
@@ -0,0 +1,6 @@
<?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>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09c-0.05,0.33 -0.09,0.66 -0.09,1v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M11.5,2C6.81,2 3,5.81 3,10.5S6.81,19 11.5,19h0.5v3c4.86,-2.34 8,-7 8,-11.5C20,5.81 16.19,2 11.5,2zM12.5,16.5h-2v-2h2v2zM12.5,13h-2c0,-3.25 3,-3 3,-5 0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2h-2c0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,2.5 -3,2.75 -3,5z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M15,16h4v2h-4zM15,8h7v2h-7zM15,12h6v2h-6zM3,18c0,1.1 0.9,2 2,2h6c1.1,0 2,-0.9 2,-2V8H3v10zM14,5h-3.5l-1,-1h-5l-1,1H0v2h14V5z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M14,2H6C4.9,2 4.01,2.9 4.01,4L4,20c0,1.1 0.89,2 1.99,2H18c1.1,0 2,-0.9 2,-2V8l-6,-6zM16,18H8v-2h8v2zM16,14H8v-2h8v2zM13,9V3.5L18.5,9H13z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M11,7h2v2h-2zM11,11h2v6h-2zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8z" />
</vector>
@@ -0,0 +1,41 @@
<?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,119 +1,305 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView style="@style/MyCardView"
xmlns:android="http://schemas.android.com/apk/res/android"
<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="wrap_content"
android:layout_height="wrap_content">
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<androidx.constraintlayout.widget.ConstraintLayout
<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:minWidth="320dp">
android:indeterminate="true"
android:visibility="gone" />
<TextView
android:id="@+id/label_path"
style="@style/TextAppearance.Material3.TitleSmall"
android:layout_width="wrap_content"
<!-- 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:layout_marginEnd="16dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:text="@string/debug_debuglog_file_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="HardcodedText" />
android:orientation="horizontal"
android:padding="12dp">
<TextView
android:id="@+id/recording_path"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:layout_marginStart="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/label_path"
tools:text="/storage/emulated/0/Android/data/eu.darken.capod/cache/log_files/some_log_file_123124.log" />
<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" />
<TextView
android:id="@+id/label_size"
style="@style/TextAppearance.Material3.TitleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="32dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:text="@string/debug_debuglog_size_label"
app:layout_constraintEnd_toStartOf="@+id/label_compressed_size"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/recording_path" />
<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" />
<TextView
android:id="@+id/recording_size"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="32dp"
app:layout_constraintEnd_toStartOf="@+id/recording_size_compressed"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toStartOf="@+id/label_size"
app:layout_constraintTop_toBottomOf="@+id/label_size"
tools:text="72 MB" />
<FrameLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1">
<TextView
android:id="@+id/label_compressed_size"
style="@style/TextAppearance.Material3.TitleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginTop="16dp"
android:text="@string/debug_debuglog_size_compressed_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toEndOf="@+id/label_size"
app:layout_constraintTop_toBottomOf="@+id/recording_path" />
<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" />
<TextView
android:id="@+id/recording_size_compressed"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="@+id/label_compressed_size"
app:layout_constraintStart_toEndOf="@+id/recording_size"
app:layout_constraintTop_toBottomOf="@+id/label_compressed_size"
tools:text="745 KB" />
<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.core.widget.ContentLoadingProgressBar
android:id="@+id/loading_indicator"
style="@style/Widget.Material3.CircularProgressIndicator.Medium"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="@+id/share"
app:layout_constraintEnd_toEndOf="@+id/share"
app:layout_constraintStart_toStartOf="@+id/share"
app:layout_constraintTop_toTopOf="@+id/share" />
<com.google.android.material.button.MaterialButton
android:id="@+id/share"
style="@style/Widget.Material3.Button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:layout_marginEnd="16dp"
android:layout_marginTop="16dp"
android:text="@string/general_share_action"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/recording_size_compressed" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
+49
View File
@@ -308,5 +308,54 @@
<string name="general_discard_action">Discard</string>
<string name="general_keep_editing_action">Keep Editing</string>
<!-- Short recording warning -->
<string name="debug_debuglog_short_recording_title">Recording too short</string>
<string name="debug_debuglog_short_recording_message">A debug log needs to capture the issue while it happens. Keep recording, reproduce the problem, then stop the recording.</string>
<string name="debug_debuglog_short_recording_continue">Continue recording</string>
<string name="debug_debuglog_short_recording_stop">Stop anyway</string>
<!-- Recorder screen -->
<string name="debug_debuglog_screen_title">Debug Log</string>
<string name="debug_debuglog_screen_subtitle">Export debug information for troubleshooting</string>
<string name="debug_debuglog_screen_sensitive_title">Sensitive Information</string>
<string name="debug_debuglog_screen_session_path_label">Session Path</string>
<string name="debug_debuglog_screen_log_files_label">Log Files</string>
<plurals name="debug_debuglog_screen_log_files_ready">
<item quantity="one">%1$d file ready (%2$s)</item>
<item quantity="other">%1$d files ready (%2$s)</item>
</plurals>
<string name="debug_debuglog_screen_discard_action">Discard</string>
<!-- Log management -->
<string name="support_debuglog_folder_size">Debug logs (%s)</string>
<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_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_expected_label">Expected behavior</string>
<string name="support_contact_expected_hint">Describe what you expected to happen (minimum 10 words).</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>
</plurals>
<string name="support_contact_debuglog_label">Debug log</string>
<string name="support_contact_debuglog_picker_hint">Attach an existing debug log, or record a new one.</string>
<string name="support_contact_debuglog_picker_empty">No debug logs found. Record one first.</string>
<string name="support_contact_debuglog_zip_error">Failed to compress debug log</string>
<string name="support_contact_send_action">Open email app</string>
<string name="support_contact_no_email_app">No email app found on this device.</string>
<string name="support_contact_debuglog_delete_title">Delete debug log?</string>
<string name="support_contact_debuglog_delete_message">This debug log will be permanently deleted.</string>
</resources>
@@ -3,4 +3,7 @@
<cache-path
name="debug_logs"
path="debug/logs" />
<external-files-path
name="debug_logs_external"
path="debug/logs" />
</paths>