From b609c1aceda102d41a3376840824c053d21e6551 Mon Sep 17 00:00:00 2001 From: darken Date: Mon, 2 Mar 2026 07:23:22 +0100 Subject: [PATCH] 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. --- app/src/main/AndroidManifest.xml | 2 +- .../java/eu/darken/capod/common/EmailTool.kt | 20 +- .../eu/darken/capod/common/SupportLinks.kt | 5 + .../debug/recording/core/DebugLogZipper.kt | 38 ++ .../debug/recording/core/RecorderModule.kt | 108 ++++- .../debug/recording/ui/LogFileAdapter.kt | 43 ++ .../debug/recording/ui/RecorderActivity.kt | 58 ++- .../debug/recording/ui/RecorderActivityVM.kt | 133 ++--- .../eu/darken/capod/common/navigation/Nav.kt | 3 + .../main/ui/settings/support/SupportScreen.kt | 99 +++- .../ui/settings/support/SupportViewModel.kt | 87 +++- .../contactform/ContactFormNavigation.kt | 26 + .../support/contactform/ContactFormScreen.kt | 458 ++++++++++++++++++ .../contactform/ContactFormViewModel.kt | 253 ++++++++++ .../main/res/drawable/badge_background.xml | 6 + .../main/res/drawable/ic_bug_report_24.xml | 9 + .../res/drawable/ic_contact_support_24.xml | 9 + .../main/res/drawable/ic_delete_sweep_24.xml | 9 + .../main/res/drawable/ic_description_24.xml | 9 + app/src/main/res/drawable/ic_folder_24.xml | 9 + .../main/res/drawable/ic_info_outline_24.xml | 9 + .../layout/debug_recorder_logfile_item.xml | 41 ++ .../res/layout/debug_recording_activity.xml | 396 +++++++++++---- app/src/main/res/values/strings.xml | 49 ++ app/src/main/res/xml/file_provider_paths.xml | 3 + 25 files changed, 1667 insertions(+), 215 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/common/SupportLinks.kt create mode 100644 app/src/main/java/eu/darken/capod/common/debug/recording/core/DebugLogZipper.kt create mode 100644 app/src/main/java/eu/darken/capod/common/debug/recording/ui/LogFileAdapter.kt create mode 100644 app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormNavigation.kt create mode 100644 app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormScreen.kt create mode 100644 app/src/main/java/eu/darken/capod/main/ui/settings/support/contactform/ContactFormViewModel.kt create mode 100644 app/src/main/res/drawable/badge_background.xml create mode 100644 app/src/main/res/drawable/ic_bug_report_24.xml create mode 100644 app/src/main/res/drawable/ic_contact_support_24.xml create mode 100644 app/src/main/res/drawable/ic_delete_sweep_24.xml create mode 100644 app/src/main/res/drawable/ic_description_24.xml create mode 100644 app/src/main/res/drawable/ic_folder_24.xml create mode 100644 app/src/main/res/drawable/ic_info_outline_24.xml create mode 100644 app/src/main/res/layout/debug_recorder_logfile_item.xml 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