mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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 {
|
||||
|
||||
+26
@@ -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
|
||||
}
|
||||
}
|
||||
+458
@@ -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,
|
||||
)
|
||||
}
|
||||
+253
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user