diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 33a79d82..342d13d2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -119,7 +119,7 @@
+ android:theme="@style/AppTheme" />
,
val subject: String,
- val body: String
+ val body: String,
+ val attachment: Uri? = null,
)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/eu/darken/capod/common/SupportLinks.kt b/app/src/main/java/eu/darken/capod/common/SupportLinks.kt
new file mode 100644
index 00000000..03108c81
--- /dev/null
+++ b/app/src/main/java/eu/darken/capod/common/SupportLinks.kt
@@ -0,0 +1,5 @@
+package eu.darken.capod.common
+
+object SupportLinks {
+ const val SUPPORT_EMAIL = "support@darken.eu"
+}
diff --git a/app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugLogZipper.kt b/app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugLogZipper.kt
new file mode 100644
index 00000000..5f8085e1
--- /dev/null
+++ b/app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugLogZipper.kt
@@ -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,
+ )
+ }
+}
diff --git a/app/src/main/java/eu/darken/capod/common/debug/recording/core/RecorderModule.kt b/app/src/main/java/eu/darken/capod/common/debug/recording/core/RecorderModule.kt
index 0a3888d0..86a368c3 100644
--- a/app/src/main/java/eu/darken/capod/common/debug/recording/core/RecorderModule.kt
+++ b/app/src/main/java/eu/darken/capod/common/debug/recording/core/RecorderModule.kt
@@ -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 = 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"
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/eu/darken/capod/common/debug/recording/ui/LogFileAdapter.kt b/app/src/main/java/eu/darken/capod/common/debug/recording/ui/LogFileAdapter.kt
new file mode 100644
index 00000000..57510995
--- /dev/null
+++ b/app/src/main/java/eu/darken/capod/common/debug/recording/ui/LogFileAdapter.kt
@@ -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(DIFF) {
+
+ data class Item(
+ val file: File,
+ val size: Long,
+ )
+
+ class VH(val binding: DebugRecorderLogfileItemBinding) : RecyclerView.ViewHolder(binding.root)
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
+ val binding = DebugRecorderLogfileItemBinding.inflate(
+ LayoutInflater.from(parent.context), parent, false
+ )
+ return VH(binding)
+ }
+
+ override fun onBindViewHolder(holder: VH, position: Int) {
+ val item = getItem(position)
+ holder.binding.apply {
+ fileName.text = item.file.name
+ filePath.text = item.file.path
+ fileSize.text = Formatter.formatShortFileSize(root.context, item.size)
+ }
+ }
+
+ companion object {
+ private val DIFF = object : DiffUtil.ItemCallback- () {
+ override fun areItemsTheSame(oldItem: Item, newItem: Item) = oldItem.file == newItem.file
+ override fun areContentsTheSame(oldItem: Item, newItem: Item) = oldItem == newItem
+ }
+ }
+}
diff --git a/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivity.kt b/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivity.kt
index 4a709e28..a41bae87 100644
--- a/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivity.kt
+++ b/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivity.kt
@@ -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 {
diff --git a/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivityVM.kt b/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivityVM.kt
index 0c8b701e..662a3418 100644
--- a/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivityVM.kt
+++ b/app/src/main/java/eu/darken/capod/common/debug/recording/ui/RecorderActivityVM.kt
@@ -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(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(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()
+ val finishEvent = SingleLiveEvent()
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 = 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")
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/eu/darken/capod/common/navigation/Nav.kt b/app/src/main/java/eu/darken/capod/common/navigation/Nav.kt
index 05be5630..84b2e509 100644
--- a/app/src/main/java/eu/darken/capod/common/navigation/Nav.kt
+++ b/app/src/main/java/eu/darken/capod/common/navigation/Nav.kt
@@ -38,5 +38,8 @@ object Nav {
@Serializable
data object Acknowledgements : Settings
+
+ @Serializable
+ data object ContactSupport : Settings
}
}
diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportScreen.kt
index 92f03971..4e527935 100644
--- a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportScreen.kt
+++ b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportScreen.kt
@@ -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 = {},
)
}
diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportViewModel.kt
index 8916354d..1f171aa7 100644
--- a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportViewModel.kt
+++ b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportViewModel.kt
@@ -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()
+
+ 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 {
diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormNavigation.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormNavigation.kt
new file mode 100644
index 00000000..318acc7b
--- /dev/null
+++ b/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormNavigation.kt
@@ -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.setup() {
+ entry { ContactFormScreenHost() }
+ }
+
+ @Module
+ @InstallIn(SingletonComponent::class)
+ abstract class Mod {
+ @Binds
+ @IntoSet
+ abstract fun bind(entry: ContactFormNavigation): NavigationEntry
+ }
+}
diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormScreen.kt
new file mode 100644
index 00000000..48529b4d
--- /dev/null
+++ b/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormScreen.kt
@@ -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,
+ )
+}
diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormViewModel.kt
new file mode 100644
index 00000000..94fb1212
--- /dev/null
+++ b/app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormViewModel.kt
@@ -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 = 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()
+
+ 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 {
+ 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")
+ }
+}
diff --git a/app/src/main/res/drawable/badge_background.xml b/app/src/main/res/drawable/badge_background.xml
new file mode 100644
index 00000000..f921fd0a
--- /dev/null
+++ b/app/src/main/res/drawable/badge_background.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_bug_report_24.xml b/app/src/main/res/drawable/ic_bug_report_24.xml
new file mode 100644
index 00000000..4c238171
--- /dev/null
+++ b/app/src/main/res/drawable/ic_bug_report_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_contact_support_24.xml b/app/src/main/res/drawable/ic_contact_support_24.xml
new file mode 100644
index 00000000..eb55b7e6
--- /dev/null
+++ b/app/src/main/res/drawable/ic_contact_support_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_delete_sweep_24.xml b/app/src/main/res/drawable/ic_delete_sweep_24.xml
new file mode 100644
index 00000000..15609277
--- /dev/null
+++ b/app/src/main/res/drawable/ic_delete_sweep_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_description_24.xml b/app/src/main/res/drawable/ic_description_24.xml
new file mode 100644
index 00000000..6eb287ad
--- /dev/null
+++ b/app/src/main/res/drawable/ic_description_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_folder_24.xml b/app/src/main/res/drawable/ic_folder_24.xml
new file mode 100644
index 00000000..4cb57e91
--- /dev/null
+++ b/app/src/main/res/drawable/ic_folder_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_info_outline_24.xml b/app/src/main/res/drawable/ic_info_outline_24.xml
new file mode 100644
index 00000000..6c606061
--- /dev/null
+++ b/app/src/main/res/drawable/ic_info_outline_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/layout/debug_recorder_logfile_item.xml b/app/src/main/res/layout/debug_recorder_logfile_item.xml
new file mode 100644
index 00000000..acccd745
--- /dev/null
+++ b/app/src/main/res/layout/debug_recorder_logfile_item.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/debug_recording_activity.xml b/app/src/main/res/layout/debug_recording_activity.xml
index 195b750b..49db629c 100644
--- a/app/src/main/res/layout/debug_recording_activity.xml
+++ b/app/src/main/res/layout/debug_recording_activity.xml
@@ -1,119 +1,305 @@
-
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:fitsSystemWindows="true">
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:indeterminate="true"
+ android:visibility="gone" />
-
+
+
+
+ android:orientation="horizontal"
+ android:padding="12dp">
-
+
-
+
-
+
-
+
-
+
+
+
+
-
-
-
-
-
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 06721a99..ee12316f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -308,5 +308,54 @@
Discard
Keep Editing
+
+ Recording too short
+ A debug log needs to capture the issue while it happens. Keep recording, reproduce the problem, then stop the recording.
+ Continue recording
+ Stop anyway
+
+
+ Debug Log
+ Export debug information for troubleshooting
+ Sensitive Information
+ Session Path
+ Log Files
+
+
- %1$d file ready (%2$s)
+ - %1$d files ready (%2$s)
+
+ Discard
+
+
+ Debug logs (%s)
+ Clear stored debug logs
+
+
+ Contact developer
+ Fill out a form to send the developer an email.
+ I read every message myself and do my best to reply. Since I handle development, bug fixes, and support on my own, it can take a little time. Thanks for your patience.
+ Your message will be sent via email. Device and setup information is attached automatically. You can attach screenshots or a video to the email. Some parts of the email will be in English so the developer can easily understand it.
+ Category
+ Question
+ Feature request
+ Bug report
+ Description
+ Describe your question in detail. Please be specific (minimum 20 words).
+ Describe the feature you would like to see. Please be specific (minimum 20 words).
+ Describe what happened and how to reproduce the issue. Please be specific (minimum 20 words).
+ Expected behavior
+ Describe what you expected to happen (minimum 10 words).
+
+ - %1$d / %2$d word
+ - %1$d / %2$d words
+
+ Debug log
+ Attach an existing debug log, or record a new one.
+ No debug logs found. Record one first.
+ Failed to compress debug log
+ Open email app
+ No email app found on this device.
+ Delete debug log?
+ This debug log will be permanently deleted.
\ No newline at end of file
diff --git a/app/src/main/res/xml/file_provider_paths.xml b/app/src/main/res/xml/file_provider_paths.xml
index e65ff667..7e155645 100644
--- a/app/src/main/res/xml/file_provider_paths.xml
+++ b/app/src/main/res/xml/file_provider_paths.xml
@@ -3,4 +3,7 @@
+
\ No newline at end of file