package com.servicebook.ui.reportbug import android.os.Build import android.util.Log import android.util.Patterns import androidx.lifecycle.viewModelScope import com.servicebook.BuildConfig import com.servicebook.R import com.servicebook.ui.util.GatedViewModel import com.servicebook.ui.util.ViewModelDependencies import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.Locale enum class ReportType { BUG, SUGGESTION } data class ReportBugState( val type: ReportType = ReportType.BUG, val summary: String = "", val description: String = "", val contactEmail: String = "", val includeDeviceInfo: Boolean = true, val showDeviceInfo: Boolean = false, val isSubmitting: Boolean = false, val appVersion: String = "", val androidVersion: String = "", val locale: String = "", val deviceModel: String = "", val vaultConfigured: Boolean = false, val crashReportingEnabled: Boolean = true, ) { /** * NOTE: We use Patterns.EMAIL_ADDRESS but add a requirement for a dot in the domain * to be robust to Patterns.EMAIL_ADDRESS behavior across API levels. */ val isEmailInvalid: Boolean get() = contactEmail.trim().let { trimmed -> trimmed.isNotBlank() && ( !Patterns.EMAIL_ADDRESS.matcher(trimmed).matches() || !trimmed.substringAfter('@', "").contains('.') ) } val canSubmit: Boolean get() = summary.isNotBlank() && description.isNotBlank() && !isSubmitting && !isEmailInvalid } sealed class ReportBugEvent { data class TypeChanged(val type: ReportType) : ReportBugEvent() data class SummaryChanged(val summary: String) : ReportBugEvent() data class DescriptionChanged(val description: String) : ReportBugEvent() data class ContactEmailChanged(val email: String) : ReportBugEvent() data class IncludeDeviceInfoChanged(val include: Boolean) : ReportBugEvent() data object ToggleDeviceInfo : ReportBugEvent() data object Submit : ReportBugEvent() } sealed class ReportBugEffect { data class ShowSnackbar(val message: String) : ReportBugEffect() data object NavigateBack : ReportBugEffect() data class FallbackToEmail( val recipient: String, val subject: String, val body: String, ) : ReportBugEffect() } class ReportBugViewModel( dependencies: ViewModelDependencies, ) : GatedViewModel(dependencies) { private val sentryWrapper = dependencies.sentryWrapper private val _state = MutableStateFlow(ReportBugState()) val state: StateFlow = _state.asStateFlow() private val _effects = Channel(Channel.BUFFERED) val effects = _effects.receiveAsFlow() private var retryCount = 0 init { _state.update { it.copy( appVersion = BuildConfig.VERSION_NAME, androidVersion = Build.VERSION.RELEASE, locale = Locale.getDefault().toString(), deviceModel = Build.MODEL, ) } viewModelScope.launch { dependencies.preferences.preferences.collect { prefs -> _state.update { it.copy( vaultConfigured = !prefs.vaultUriString.isNullOrEmpty(), crashReportingEnabled = prefs.crashReportingEnabled, ) } } } } fun onEvent(event: ReportBugEvent) { when (event) { is ReportBugEvent.TypeChanged -> _state.update { it.copy(type = event.type) } is ReportBugEvent.SummaryChanged -> _state.update { it.copy(summary = event.summary.take(MAX_SUMMARY_LENGTH)) } is ReportBugEvent.DescriptionChanged -> _state.update { it.copy(description = event.description.take(MAX_DESCRIPTION_LENGTH)) } is ReportBugEvent.ContactEmailChanged -> _state.update { it.copy(contactEmail = event.email) } is ReportBugEvent.IncludeDeviceInfoChanged -> _state.update { it.copy(includeDeviceInfo = event.include) } ReportBugEvent.ToggleDeviceInfo -> _state.update { it.copy(showDeviceInfo = !it.showDeviceInfo) } ReportBugEvent.Submit -> { retryCount = 0 submitReport() } } } private fun submitReport() { if (!state.value.canSubmit) return _state.update { it.copy(isSubmitting = true) } viewModelScope.launch { performSubmission() } } private suspend fun performSubmission() { try { val s = _state.value val comments = "${s.summary}\n\n${s.description}" val email = s.contactEmail.trim().ifBlank { null } val tags = mapOf( "type" to s.type.name.lowercase(), "app_version" to s.appVersion, "android_version" to s.androidVersion, "locale" to s.locale, "device_model" to s.deviceModel, "vault_configured" to s.vaultConfigured.toString(), ) val submissionTags = if (s.includeDeviceInfo) tags else emptyMap() withContext(Dispatchers.IO) { if (sentryWrapper.isEnabled()) { sentryWrapper.captureFeedback(comments, email, submissionTags) } else { // NOTE: bypasses crash-reporting opt-out; user-initiated feedback submission. sentryWrapper.lazyInitAndSendFeedback( app, comments, email, submissionTags, ) } } _state.update { it.copy(isSubmitting = false) } _effects.send(ReportBugEffect.NavigateBack) } catch ( @Suppress("TooGenericExceptionCaught") e: Exception, ) { Log.e("ReportBugViewModel", "Failed to submit report", e) if (retryCount < MAX_RETRY_COUNT) { retryCount++ delay(RETRY_DELAY_MS) performSubmission() } else { _state.update { it.copy(isSubmitting = false) } _effects.send(ReportBugEffect.ShowSnackbar(app.getString(R.string.report_bug_error_failed))) fallbackToEmail() } } } private suspend fun fallbackToEmail() { val s = _state.value val reportType = when (s.type) { ReportType.BUG -> app.getString(R.string.report_bug_type_bug) ReportType.SUGGESTION -> app.getString(R.string.report_bug_type_suggestion) } val subject = "[ServiceBook] $reportType: ${s.summary}" val body = buildString { appendLine(s.description) if (s.contactEmail.isNotBlank()) { appendLine() appendLine("${app.getString(R.string.report_bug_email_contact_label)}: ${s.contactEmail}") } if (s.includeDeviceInfo) { appendLine() appendLine(app.getString(R.string.report_bug_email_device_info)) appendLine("${app.getString(R.string.report_bug_app_version)}: ${s.appVersion}") appendLine("${app.getString(R.string.report_bug_android_version)}: ${s.androidVersion}") appendLine("${app.getString(R.string.report_bug_locale)}: ${s.locale}") appendLine("${app.getString(R.string.report_bug_device_model)}: ${s.deviceModel}") appendLine("${app.getString(R.string.report_bug_vault_configured)}: ${s.vaultConfigured}") } } _effects.send( ReportBugEffect.FallbackToEmail( app.getString(R.string.report_bug_support_email), subject, body, ), ) } companion object { const val MAX_SUMMARY_LENGTH = 120 const val MAX_DESCRIPTION_LENGTH = 2000 private const val MAX_RETRY_COUNT = 2 private const val RETRY_DELAY_MS = 1000L } }