From d1b0593a8c01bceaa13d2947a1a62c3d4696cca7 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 1 Jul 2026 00:03:13 -0500 Subject: [PATCH] feat(reporting): opt-in debug reporting client (capture + review/submit) Implements #32 and #33: a strictly opt-in, F-Droid-safe debug reporting client. Nothing ever leaves the device unless the user taps Submit. #32 capture: - CrashReporter installs a Thread.setDefaultUncaughtExceptionHandler (wired in LibreMailApplication) that persists a structured crash record (stack trace + app/version/device metadata + recent log ring buffer) locally, then delegates to the previous handler. Never auto-sent. - RingLogBuffer + AppLog: a bounded in-memory, non-PII log ring buffer. - DiagnosticsCollector assembles a minimal bundle: app version, Android/device, a fixed non-PII settings allow-list, and the log buffer. - ReportStore persists pending reports as JSON files (not Room, so crash-time saves are robust and independent of the encrypted/migrating DB). - "Report a problem" entry point in Settings creates a report on demand. #33 review & submit: - ReportReviewScreen shows the full payload verbatim (exactly what would be sent), a free-text comment field, and a prominent PII disclaimer, with explicit Submit / Discard and Copy / Save-to-file alternatives. - Submission is user-initiated only: ReportUploadWorker (WorkManager, queue + retry, success/failure surfaced) POSTs to BuildConfig.DEBUG_REPORT_ENDPOINT, which is EMPTY by default (ingest server #34 is out of scope for this repo). - On next launch a saved crash report is offered for review via a dialog. Tests: 23 JVM unit tests covering crash capture + persistence (offered next launch), diagnostic-bundle assembly (minimal, non-PII), JSON round-trip, and the "nothing sent without Submit" invariant. Closes #32 Closes #33 Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 6 + .../org/libremail/LibreMailApplication.kt | 21 ++ .../org/libremail/di/ReportingModule.kt | 22 ++ .../kotlin/org/libremail/reporting/AppLog.kt | 39 +++ .../libremail/reporting/AppVersionProvider.kt | 31 +++ .../org/libremail/reporting/CrashReporter.kt | 53 ++++ .../org/libremail/reporting/DebugReport.kt | 94 +++++++ .../reporting/DiagnosticsCollector.kt | 69 ++++++ .../org/libremail/reporting/ReportStore.kt | 50 ++++ .../libremail/reporting/ReportSubmitter.kt | 38 +++ .../reporting/ReportUploadScheduler.kt | 47 ++++ .../libremail/reporting/ReportUploadWorker.kt | 76 ++++++ .../org/libremail/reporting/RingLogBuffer.kt | 39 +++ .../kotlin/org/libremail/ui/LibreMailApp.kt | 56 ++++- .../org/libremail/ui/navigation/Routes.kt | 5 + .../ui/reporting/ProblemReportsScreen.kt | 122 ++++++++++ .../ui/reporting/ProblemReportsViewModel.kt | 54 ++++ .../ui/reporting/ReportReviewScreen.kt | 230 ++++++++++++++++++ .../ui/reporting/ReportReviewViewModel.kt | 96 ++++++++ .../ui/reporting/StartupReportViewModel.kt | 46 ++++ .../libremail/ui/settings/SettingsScreen.kt | 9 + app/src/main/res/values/strings.xml | 30 +++ .../libremail/reporting/CrashReporterTest.kt | 58 +++++ .../libremail/reporting/DebugReportTest.kt | 72 ++++++ .../reporting/DiagnosticsCollectorTest.kt | 81 ++++++ .../libremail/reporting/ReportStoreTest.kt | 81 ++++++ .../libremail/reporting/RingLogBufferTest.kt | 51 ++++ .../ui/reporting/ReportReviewViewModelTest.kt | 124 ++++++++++ 28 files changed, 1699 insertions(+), 1 deletion(-) create mode 100644 app/src/main/kotlin/org/libremail/di/ReportingModule.kt create mode 100644 app/src/main/kotlin/org/libremail/reporting/AppLog.kt create mode 100644 app/src/main/kotlin/org/libremail/reporting/AppVersionProvider.kt create mode 100644 app/src/main/kotlin/org/libremail/reporting/CrashReporter.kt create mode 100644 app/src/main/kotlin/org/libremail/reporting/DebugReport.kt create mode 100644 app/src/main/kotlin/org/libremail/reporting/DiagnosticsCollector.kt create mode 100644 app/src/main/kotlin/org/libremail/reporting/ReportStore.kt create mode 100644 app/src/main/kotlin/org/libremail/reporting/ReportSubmitter.kt create mode 100644 app/src/main/kotlin/org/libremail/reporting/ReportUploadScheduler.kt create mode 100644 app/src/main/kotlin/org/libremail/reporting/ReportUploadWorker.kt create mode 100644 app/src/main/kotlin/org/libremail/reporting/RingLogBuffer.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsScreen.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsViewModel.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewViewModel.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/reporting/StartupReportViewModel.kt create mode 100644 app/src/test/kotlin/org/libremail/reporting/CrashReporterTest.kt create mode 100644 app/src/test/kotlin/org/libremail/reporting/DebugReportTest.kt create mode 100644 app/src/test/kotlin/org/libremail/reporting/DiagnosticsCollectorTest.kt create mode 100644 app/src/test/kotlin/org/libremail/reporting/ReportStoreTest.kt create mode 100644 app/src/test/kotlin/org/libremail/reporting/RingLogBufferTest.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/reporting/ReportReviewViewModelTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4f0f640..f4e7a93 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -37,6 +37,11 @@ val outlookOAuthClientId: String = secrets.getProperty( "04e4aa5e-ed1f-47f9-b567-b99a0b29b3df", ) +// Debug-report ingest endpoint (issue #34, out of scope for this repo). Empty by default: the debug +// reporting client is strictly opt-in and never sends anything unless the user taps Submit AND an +// endpoint is configured here (overridable via git-ignored secrets.properties). +val debugReportEndpoint: String = secrets.getProperty("DEBUG_REPORT_ENDPOINT", "") + // Optional release signing, configured via git-ignored secrets.properties. When absent, release // builds fall back to the debug key (installable for testing, but not publishable). val releaseStoreFile: String? = secrets.getProperty("RELEASE_STORE_FILE") @@ -59,6 +64,7 @@ android { buildConfigField("String", "GMAIL_OAUTH_REDIRECT_URI", "\"$gmailRedirectScheme:/oauth2redirect\"") buildConfigField("String", "OUTLOOK_OAUTH_CLIENT_ID", "\"$outlookOAuthClientId\"") buildConfigField("String", "OUTLOOK_OAUTH_REDIRECT_URI", "\"org.libremail.outlook://oauth2redirect\"") + buildConfigField("String", "DEBUG_REPORT_ENDPOINT", "\"$debugReportEndpoint\"") // AppAuth captures the OAuth redirect via this custom scheme. manifestPlaceholders["appAuthRedirectScheme"] = gmailRedirectScheme } diff --git a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt index f7e7d4c..8c24366 100644 --- a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt +++ b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt @@ -16,6 +16,10 @@ import org.libremail.data.settings.SettingsRepository import org.libremail.data.sync.SyncScheduler import org.libremail.domain.repository.AccountRepository import org.libremail.push.IdlePushManager +import org.libremail.reporting.AppLog +import org.libremail.reporting.CrashReporter +import org.libremail.reporting.DiagnosticsCollector +import org.libremail.reporting.RingLogBuffer import javax.inject.Inject @HiltAndroidApp @@ -33,6 +37,12 @@ class LibreMailApplication : @Inject lateinit var idlePushManager: IdlePushManager + @Inject lateinit var ringLogBuffer: RingLogBuffer + + @Inject lateinit var crashReporter: CrashReporter + + @Inject lateinit var diagnosticsCollector: DiagnosticsCollector + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) /** Whether the IDLE push service should currently be running (push enabled AND an account exists). */ @@ -46,6 +56,13 @@ class LibreMailApplication : override fun onCreate() { super.onCreate() + // Wire up debug reporting first so crashes during the rest of startup are still captured. + AppLog.install(ringLogBuffer) + crashReporter.install() + AppLog.i(TAG, "Application created") + // Warm the settings cache so a later crash report can include non-PII settings without + // touching DataStore on the crashing thread. + appScope.launch { runCatching { diagnosticsCollector.warmSettingsCache() } } syncScheduler.schedulePeriodicSync() // Run the IMAP IDLE push service only while it has something to do: the push setting is on // AND at least one account exists. This starts it when the first account is added and stops @@ -72,4 +89,8 @@ class LibreMailApplication : fun ensurePushStarted() { if (pushShouldBeActive) idlePushManager.start() } + + private companion object { + const val TAG = "LibreMail" + } } diff --git a/app/src/main/kotlin/org/libremail/di/ReportingModule.kt b/app/src/main/kotlin/org/libremail/di/ReportingModule.kt new file mode 100644 index 0000000..cf79ebd --- /dev/null +++ b/app/src/main/kotlin/org/libremail/di/ReportingModule.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import org.libremail.reporting.ReportStore +import java.io.File +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ReportingModule { + + @Provides + @Singleton + fun provideReportStore(@ApplicationContext context: Context): ReportStore = + ReportStore(File(context.filesDir, "debug_reports")) +} diff --git a/app/src/main/kotlin/org/libremail/reporting/AppLog.kt b/app/src/main/kotlin/org/libremail/reporting/AppLog.kt new file mode 100644 index 0000000..74a2b7f --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/AppLog.kt @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import android.util.Log + +/** + * Logging facade that mirrors Logcat output into the process [RingLogBuffer] so recent activity can + * be attached to a user-reviewed [DebugReport]. Call [install] once at startup. Never pass PII + * (email addresses, message content, credentials) to these methods — the buffer can end up in a + * report the user reviews and may submit. + */ +object AppLog { + @Volatile + private var buffer: RingLogBuffer? = null + + fun install(buffer: RingLogBuffer) { + this.buffer = buffer + } + + fun d(tag: String, message: String) { + Log.d(tag, message) + buffer?.record('D', tag, message) + } + + fun i(tag: String, message: String) { + Log.i(tag, message) + buffer?.record('I', tag, message) + } + + fun w(tag: String, message: String) { + Log.w(tag, message) + buffer?.record('W', tag, message) + } + + fun e(tag: String, message: String, throwable: Throwable? = null) { + Log.e(tag, message, throwable) + buffer?.record('E', tag, message) + } +} diff --git a/app/src/main/kotlin/org/libremail/reporting/AppVersionProvider.kt b/app/src/main/kotlin/org/libremail/reporting/AppVersionProvider.kt new file mode 100644 index 0000000..3d6e7ee --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/AppVersionProvider.kt @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** Resolves the app's version once, tolerating any lookup failure. */ +@Singleton +class AppVersionProvider @Inject constructor(@ApplicationContext context: Context) { + val versionName: String + val versionCode: Long + + init { + var name = UNKNOWN + var code = 0L + runCatching { + @Suppress("DEPRECATION") // getPackageInfo(String, int) is fine for a plain version lookup. + val info = context.packageManager.getPackageInfo(context.packageName, 0) + name = info.versionName ?: UNKNOWN + code = info.longVersionCode + } + versionName = name + versionCode = code + } + + private companion object { + const val UNKNOWN = "unknown" + } +} diff --git a/app/src/main/kotlin/org/libremail/reporting/CrashReporter.kt b/app/src/main/kotlin/org/libremail/reporting/CrashReporter.kt new file mode 100644 index 0000000..d889dd4 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/CrashReporter.kt @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import android.os.Process +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.system.exitProcess + +/** + * Installs a process-wide uncaught-exception handler that persists a crash [DebugReport] locally for + * review on next launch. Reports are NEVER sent automatically — submission is strictly user-initiated + * (see [ReportSubmitter]). The previous handler is always invoked so the system still shows the crash + * and terminates the process. + */ +@Singleton +class CrashReporter @Inject constructor( + private val collector: DiagnosticsCollector, + private val store: ReportStore, + private val logBuffer: RingLogBuffer, +) { + fun install() { + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + persist(throwable) + if (previous != null) { + previous.uncaughtException(thread, throwable) + } else { + terminate() + } + } + } + + /** + * Saves a crash report. Wrapped so a failure here can never mask the original crash. Exposed for + * tests; installing the handler simply routes uncaught exceptions here. + */ + fun persist(throwable: Throwable) { + runCatching { + logBuffer.record('E', TAG, "Uncaught exception: ${throwable.javaClass.name}") + store.save(collector.collectCrash(throwable)) + } + } + + private fun terminate() { + Process.killProcess(Process.myPid()) + exitProcess(EXIT_FAILURE) + } + + private companion object { + const val TAG = "CrashReporter" + const val EXIT_FAILURE = 10 + } +} diff --git a/app/src/main/kotlin/org/libremail/reporting/DebugReport.kt b/app/src/main/kotlin/org/libremail/reporting/DebugReport.kt new file mode 100644 index 0000000..3b6d929 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/DebugReport.kt @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import org.json.JSONArray +import org.json.JSONObject +import java.time.Instant + +/** Whether a report was produced by an uncaught crash or requested manually by the user. */ +enum class ReportKind { CRASH, MANUAL } + +/** + * A locally-stored diagnostic report. It never leaves the device unless the user explicitly submits + * it (see [ReportSubmitter]); [toSubmissionPayload] is the single rendering that is shown for + * review, copied, saved to a file, and POSTed on submit, so what the user reads is exactly what is + * sent. Only the fields assembled by `DiagnosticsCollector` are captured — no message content. + */ +@Suppress("LongParameterList") // A flat diagnostic DTO; grouping fields would only obscure the payload. +data class DebugReport( + val id: String, + val createdAtMillis: Long, + val kind: ReportKind, + val appVersionName: String, + val appVersionCode: Long, + val androidRelease: String, + val androidSdkInt: Int, + val deviceManufacturer: String, + val deviceModel: String, + val stackTrace: String?, + val settings: Map, + val logs: List, + val userComment: String = "", +) { + /** The exact text shown for review, copied, saved to a file, and POSTed on submit. */ + fun toSubmissionPayload(): String = toJson().toString(JSON_INDENT) + + /** Compact form used for on-disk persistence. */ + fun toStorageJson(): String = toJson().toString() + + private fun toJson(): JSONObject { + val app = JSONObject() + .put("versionName", appVersionName) + .put("versionCode", appVersionCode) + val device = JSONObject() + .put("manufacturer", deviceManufacturer) + .put("model", deviceModel) + .put("androidRelease", androidRelease) + .put("sdkInt", androidSdkInt) + val settingsJson = JSONObject() + settings.forEach { (key, value) -> settingsJson.put(key, value) } + val json = JSONObject() + .put("id", id) + .put("createdAt", Instant.ofEpochMilli(createdAtMillis).toString()) + .put("createdAtMillis", createdAtMillis) + .put("kind", kind.name) + .put("app", app) + .put("device", device) + .put("userComment", userComment) + .put("settings", settingsJson) + .put("logs", JSONArray(logs)) + if (stackTrace != null) json.put("stackTrace", stackTrace) + return json + } + + companion object { + private const val JSON_INDENT = 2 + + fun fromStorageJson(raw: String): DebugReport { + val json = JSONObject(raw) + val app = json.getJSONObject("app") + val device = json.getJSONObject("device") + val settingsJson = json.getJSONObject("settings") + val settings = LinkedHashMap() + settingsJson.keys().forEach { key -> settings[key] = settingsJson.getString(key) } + val logsJson = json.getJSONArray("logs") + val logs = ArrayList(logsJson.length()) + for (i in 0 until logsJson.length()) logs.add(logsJson.getString(i)) + return DebugReport( + id = json.getString("id"), + createdAtMillis = json.getLong("createdAtMillis"), + kind = ReportKind.valueOf(json.getString("kind")), + appVersionName = app.getString("versionName"), + appVersionCode = app.getLong("versionCode"), + androidRelease = device.getString("androidRelease"), + androidSdkInt = device.getInt("sdkInt"), + deviceManufacturer = device.getString("manufacturer"), + deviceModel = device.getString("model"), + stackTrace = if (json.has("stackTrace")) json.getString("stackTrace") else null, + settings = settings, + logs = logs, + userComment = json.optString("userComment", ""), + ) + } + } +} diff --git a/app/src/main/kotlin/org/libremail/reporting/DiagnosticsCollector.kt b/app/src/main/kotlin/org/libremail/reporting/DiagnosticsCollector.kt new file mode 100644 index 0000000..6b09218 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/DiagnosticsCollector.kt @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import android.os.Build +import kotlinx.coroutines.flow.first +import org.libremail.data.settings.AppSettings +import org.libremail.data.settings.SettingsRepository +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Assembles a [DebugReport] from app/device metadata, a stack trace (for crashes), a minimal + * non-PII settings summary, and the recent in-app log buffer. Only the fields listed in [summarize] + * are captured — deliberately no account emails, server names, or message content. + */ +@Singleton +class DiagnosticsCollector @Inject constructor( + private val appVersion: AppVersionProvider, + private val settingsRepository: SettingsRepository, + private val logBuffer: RingLogBuffer, +) { + // Cached so a crash report (built synchronously on the crashing thread) can still include + // settings without touching DataStore. Refreshed whenever settings are read for a manual + // report or explicitly warmed at startup. + @Volatile + private var cachedSettings: Map = emptyMap() + + /** Pre-reads settings so a later crash report can include them. Safe to call and ignore. */ + suspend fun warmSettingsCache() { + cachedSettings = summarize(settingsRepository.settings.first()) + } + + /** Builds a report for a user-initiated ("Report a problem") request; includes live settings. */ + suspend fun collectManual(): DebugReport { + val settings = summarize(settingsRepository.settings.first()) + cachedSettings = settings + return build(ReportKind.MANUAL, throwable = null, settings = settings) + } + + /** Builds a crash report synchronously; it must not block or throw on the crashing thread. */ + fun collectCrash(throwable: Throwable): DebugReport = + build(ReportKind.CRASH, throwable = throwable, settings = cachedSettings) + + private fun build(kind: ReportKind, throwable: Throwable?, settings: Map) = DebugReport( + id = UUID.randomUUID().toString(), + createdAtMillis = System.currentTimeMillis(), + kind = kind, + appVersionName = appVersion.versionName, + appVersionCode = appVersion.versionCode, + androidRelease = Build.VERSION.RELEASE ?: "", + androidSdkInt = Build.VERSION.SDK_INT, + deviceManufacturer = Build.MANUFACTURER ?: "", + deviceModel = Build.MODEL ?: "", + stackTrace = throwable?.stackTraceToString(), + settings = settings, + logs = logBuffer.snapshot().map { it.formatted() }, + ) + + private fun summarize(settings: AppSettings): Map = linkedMapOf( + "dynamicColor" to settings.dynamicColor.toString(), + "newMailNotifications" to settings.newMailNotifications.toString(), + "pushIdle" to settings.pushIdle.toString(), + "allowStartTls" to settings.allowStartTls.toString(), + "loadRemoteImages" to settings.loadRemoteImages.toString(), + "encryptCache" to settings.encryptCache.toString(), + "fetchPolicy" to settings.fetchPolicy.name, + ) +} diff --git a/app/src/main/kotlin/org/libremail/reporting/ReportStore.kt b/app/src/main/kotlin/org/libremail/reporting/ReportStore.kt new file mode 100644 index 0000000..247b1ed --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/ReportStore.kt @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File + +/** + * File-backed store of pending [DebugReport]s — one JSON file per report under [directory]. + * Deliberately NOT Room-backed: a crash-time save must be simple and robust, independent of the + * (possibly encrypted, possibly mid-migration) app database. Reports persist until the user submits + * or discards them. [reports] is a snapshot that updates on every [save]/[delete]. + */ +class ReportStore(private val directory: File) { + private val lock = Any() + private val _reports = MutableStateFlow(scan()) + val reports: StateFlow> = _reports.asStateFlow() + + fun save(report: DebugReport) { + synchronized(lock) { + directory.mkdirs() + File(directory, fileName(report.id)).writeText(report.toStorageJson()) + _reports.value = scan() + } + } + + fun find(id: String): DebugReport? = _reports.value.firstOrNull { it.id == id } + + fun delete(id: String) { + synchronized(lock) { + File(directory, fileName(id)).delete() + _reports.value = scan() + } + } + + private fun scan(): List { + val files = directory.listFiles { file -> file.isFile && file.name.endsWith(SUFFIX) } + ?: return emptyList() + return files + .mapNotNull { file -> runCatching { DebugReport.fromStorageJson(file.readText()) }.getOrNull() } + .sortedByDescending { it.createdAtMillis } + } + + private fun fileName(id: String) = "$id$SUFFIX" + + private companion object { + const val SUFFIX = ".json" + } +} diff --git a/app/src/main/kotlin/org/libremail/reporting/ReportSubmitter.kt b/app/src/main/kotlin/org/libremail/reporting/ReportSubmitter.kt new file mode 100644 index 0000000..b116381 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/ReportSubmitter.kt @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import androidx.work.WorkInfo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.libremail.BuildConfig +import javax.inject.Inject +import javax.inject.Singleton + +/** Coarse state of a user-initiated report submission. */ +enum class SubmitStatus { IDLE, SUBMITTING, SUCCEEDED, FAILED } + +/** + * The single seam through which a report can leave the device, and only when the user taps Submit. + * [isEnabled] is false when no ingest endpoint is configured (the default in this repo), so the UI + * can steer the user to Copy/Save instead of a submission that would only fail. + */ +@Singleton +class ReportSubmitter @Inject constructor(private val scheduler: ReportUploadScheduler) { + val isEnabled: Boolean get() = BuildConfig.DEBUG_REPORT_ENDPOINT.isNotBlank() + + fun submit(reportId: String) { + scheduler.enqueue(reportId) + } + + fun status(reportId: String): Flow = scheduler.statusFlow(reportId).map(::toStatus) + + private fun toStatus(infos: List): SubmitStatus { + if (infos.isEmpty()) return SubmitStatus.IDLE + return when { + infos.any { it.state == WorkInfo.State.FAILED || it.state == WorkInfo.State.CANCELLED } -> + SubmitStatus.FAILED + infos.all { it.state == WorkInfo.State.SUCCEEDED } -> SubmitStatus.SUCCEEDED + else -> SubmitStatus.SUBMITTING + } + } +} diff --git a/app/src/main/kotlin/org/libremail/reporting/ReportUploadScheduler.kt b/app/src/main/kotlin/org/libremail/reporting/ReportUploadScheduler.kt new file mode 100644 index 0000000..370354a --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/ReportUploadScheduler.kt @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.workDataOf +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** Enqueues a user-initiated report upload as a retrying WorkManager job and observes its state. */ +@Singleton +class ReportUploadScheduler @Inject constructor(@ApplicationContext private val context: Context) { + fun enqueue(reportId: String) { + val request = OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(), + ) + .setInputData(workDataOf(ReportUploadWorker.KEY_REPORT_ID to reportId)) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS) + .addTag(TAG) + .build() + // REPLACE: a fresh Submit tap starts a clean attempt for this report, overriding any pending + // retry-backoff. Reports are keyed per id, so distinct reports never collide. + WorkManager.getInstance(context) + .enqueueUniqueWork(uniqueName(reportId), ExistingWorkPolicy.REPLACE, request) + } + + fun statusFlow(reportId: String): Flow> = + WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow(uniqueName(reportId)) + + private fun uniqueName(reportId: String) = "$WORK_PREFIX$reportId" + + companion object { + const val TAG = "libremail_report_upload" + private const val WORK_PREFIX = "libremail_report_upload_" + private const val BACKOFF_SECONDS = 30L + } +} diff --git a/app/src/main/kotlin/org/libremail/reporting/ReportUploadWorker.kt b/app/src/main/kotlin/org/libremail/reporting/ReportUploadWorker.kt new file mode 100644 index 0000000..444b3c7 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/ReportUploadWorker.kt @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.libremail.BuildConfig +import java.net.HttpURLConnection +import java.net.URL + +/** + * Posts a single user-submitted report to the ingest endpoint. It runs ONLY when the user tapped + * Submit (enqueued by [ReportUploadScheduler]); nothing here runs automatically. The endpoint is + * [BuildConfig.DEBUG_REPORT_ENDPOINT] — empty by default, because the ingest server (issue #34) is + * out of scope for this repo, so submissions no-op with a clear failure until an endpoint is set. + */ +@HiltWorker +class ReportUploadWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val store: ReportStore, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + val id = inputData.getString(KEY_REPORT_ID) ?: return Result.success() + val report = store.find(id) ?: return Result.success() // discarded before the job ran + val endpoint = BuildConfig.DEBUG_REPORT_ENDPOINT + if (endpoint.isBlank()) return Result.failure() // no ingest server configured in this build + return withContext(Dispatchers.IO) { + runCatching { post(endpoint, report.toSubmissionPayload()) }.fold( + onSuccess = { code -> onResponse(code, id) }, + onFailure = { retryOrFail() }, // network error — retry with backoff + ) + } + } + + private fun onResponse(code: Int, id: String): Result = when { + code in SUCCESS_CODES -> { + store.delete(id) // delivered — drop the local copy + Result.success() + } + code in SERVER_ERROR_CODES -> retryOrFail() // transient server-side failure + else -> Result.failure() // client error (4xx) — retrying won't help + } + + private fun retryOrFail(): Result = if (runAttemptCount >= MAX_ATTEMPTS) Result.failure() else Result.retry() + + private fun post(endpoint: String, body: String): Int { + val connection = (URL(endpoint).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + doOutput = true + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + setRequestProperty("Content-Type", "application/json; charset=utf-8") + } + return try { + connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } + connection.responseCode + } finally { + connection.disconnect() + } + } + + companion object { + const val KEY_REPORT_ID = "report_id" + private val SUCCESS_CODES = 200..299 + private val SERVER_ERROR_CODES = 500..599 + private const val TIMEOUT_MS = 15_000 + private const val MAX_ATTEMPTS = 5 + } +} diff --git a/app/src/main/kotlin/org/libremail/reporting/RingLogBuffer.kt b/app/src/main/kotlin/org/libremail/reporting/RingLogBuffer.kt new file mode 100644 index 0000000..390bbb3 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/RingLogBuffer.kt @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +/** One captured log line. */ +data class LogEntry(val timeMillis: Long, val level: Char, val tag: String, val message: String) { + fun formatted(): String = "${Instant.ofEpochMilli(timeMillis)} $level/$tag: $message" +} + +/** + * A small, thread-safe, in-memory ring buffer of recent app log lines. It is held in memory only + * (never written to disk on its own) and copied into a [DebugReport] on request. Callers must never + * record PII (email addresses, message content, credentials) — see [AppLog]. + */ +@Singleton +class RingLogBuffer @Inject constructor() { + private val lock = Any() + private val entries = ArrayDeque() + + fun record(level: Char, tag: String, message: String) { + synchronized(lock) { + if (entries.size >= CAPACITY) entries.removeFirst() + entries.addLast(LogEntry(System.currentTimeMillis(), level, tag, message)) + } + } + + fun snapshot(): List = synchronized(lock) { entries.toList() } + + fun clear() { + synchronized(lock) { entries.clear() } + } + + private companion object { + const val CAPACITY = 200 + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index 0301b0f..282fe69 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -1,12 +1,18 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavType @@ -14,6 +20,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import org.libremail.R import org.libremail.ui.accountsetup.AccountSetupScreen import org.libremail.ui.accountsetup.ManualSetupScreen import org.libremail.ui.compose.ComposeScreen @@ -22,12 +29,16 @@ import org.libremail.ui.mailbox.MailboxScreen import org.libremail.ui.navigation.Routes import org.libremail.ui.outbox.OutboxScreen import org.libremail.ui.reader.ReaderScreen +import org.libremail.ui.reporting.ProblemReportsScreen +import org.libremail.ui.reporting.ReportReviewScreen +import org.libremail.ui.reporting.StartupReportViewModel import org.libremail.ui.settings.AccountSettingsScreen import org.libremail.ui.settings.SettingsScreen @Composable -fun LibreMailApp() { +fun LibreMailApp(startupViewModel: StartupReportViewModel = hiltViewModel()) { val navController = rememberNavController() + val pendingCrash by startupViewModel.pendingCrash.collectAsStateWithLifecycle() NavHost( navController = navController, @@ -81,8 +92,21 @@ fun LibreMailApp() { onAddAccount = { navController.navigate(Routes.ACCOUNT_SETUP) }, onOpenAccount = { accountId -> navController.navigate(Routes.accountSettings(accountId)) }, onSelectTab = navController::navigateTab, + onReportProblem = { navController.navigate(Routes.PROBLEM_REPORTS) }, ) } + composable(Routes.PROBLEM_REPORTS) { + ProblemReportsScreen( + onBack = navController::popBackStack, + onOpenReport = { reportId -> navController.navigate(Routes.reportReview(reportId)) }, + ) + } + composable( + route = Routes.REPORT_REVIEW_PATTERN, + arguments = listOf(navArgument(Routes.REPORT_REVIEW_ARG_ID) { type = NavType.StringType }), + ) { + ReportReviewScreen(onDone = navController::popBackStack) + } composable( route = Routes.ACCOUNT_SETTINGS_PATTERN, arguments = listOf(navArgument(Routes.ACCOUNT_SETTINGS_ARG_ID) { type = NavType.StringType }), @@ -120,6 +144,36 @@ fun LibreMailApp() { OutboxScreen(onBack = navController::popBackStack) } } + + // On launch, offer any saved crash report for review — never sent without the user's action. + pendingCrash?.let { crash -> + CrashReportDialog( + onReview = { + startupViewModel.dismiss() + navController.navigate(Routes.reportReview(crash.id)) + }, + onLater = startupViewModel::dismiss, + onDiscard = { startupViewModel.discard(crash.id) }, + ) + } +} + +@Composable +private fun CrashReportDialog(onReview: () -> Unit, onLater: () -> Unit, onDiscard: () -> Unit) { + AlertDialog( + onDismissRequest = onLater, + title = { Text(stringResource(R.string.crash_prompt_title)) }, + text = { Text(stringResource(R.string.crash_prompt_message)) }, + confirmButton = { + TextButton(onClick = onReview) { Text(stringResource(R.string.crash_prompt_review)) } + }, + dismissButton = { + Row { + TextButton(onClick = onDiscard) { Text(stringResource(R.string.crash_prompt_discard)) } + TextButton(onClick = onLater) { Text(stringResource(R.string.crash_prompt_later)) } + } + }, + ) } /** Navigate between top-level tabs, preserving each tab's back stack and state. */ diff --git a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt index 5ddb2d5..e03afc0 100644 --- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt @@ -11,6 +11,7 @@ object Routes { const val MANUAL_SETUP = "manual_setup" const val DRAFTS = "drafts" const val OUTBOX = "outbox" + const val PROBLEM_REPORTS = "problem_reports" const val READER_ARG_ID = "messageId" const val READER_PATTERN = "reader/{$READER_ARG_ID}" @@ -20,6 +21,10 @@ object Routes { const val ACCOUNT_SETTINGS_PATTERN = "account_settings/{$ACCOUNT_SETTINGS_ARG_ID}" fun accountSettings(accountId: String) = "account_settings/${Uri.encode(accountId)}" + const val REPORT_REVIEW_ARG_ID = "reportId" + const val REPORT_REVIEW_PATTERN = "report_review/{$REPORT_REVIEW_ARG_ID}" + fun reportReview(reportId: String) = "report_review/${Uri.encode(reportId)}" + const val COMPOSE_ARG_TO = "to" const val COMPOSE_ARG_SUBJECT = "subject" const val COMPOSE_ARG_FROM = "from" diff --git a/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsScreen.kt b/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsScreen.kt new file mode 100644 index 0000000..71367e1 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsScreen.kt @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.reporting + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.libremail.R +import org.libremail.reporting.ReportKind +import java.text.DateFormat +import java.util.Date + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProblemReportsScreen( + onBack: () -> Unit, + onOpenReport: (String) -> Unit, + viewModel: ProblemReportsViewModel = hiltViewModel(), +) { + val reports by viewModel.reports.collectAsStateWithLifecycle() + + // A newly created manual report opens straight into review. + LaunchedEffect(Unit) { + viewModel.created.collect { onOpenReport(it) } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.reports_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.action_back), + ) + } + }, + ) + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding), + ) { + Button( + onClick = viewModel::createManualReport, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text(stringResource(R.string.reports_create)) + } + if (reports.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + stringResource(R.string.reports_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn(Modifier.fillMaxSize()) { + items(reports, key = { it.id }) { report -> + ReportRow(report = report, onClick = { onOpenReport(report.id) }) + HorizontalDivider() + } + } + } + } + } +} + +@Composable +private fun ReportRow(report: ReportSummary, onClick: () -> Unit) { + val kindLabel = when (report.kind) { + ReportKind.CRASH -> stringResource(R.string.report_kind_crash) + ReportKind.MANUAL -> stringResource(R.string.report_kind_manual) + } + val timestamp = remember(report.createdAtMillis) { + DateFormat.getDateTimeInstance().format(Date(report.createdAtMillis)) + } + Column( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text(kindLabel, style = MaterialTheme.typography.bodyLarge) + Text( + timestamp, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsViewModel.kt new file mode 100644 index 0000000..0b5c175 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsViewModel.kt @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.reporting + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.libremail.reporting.DebugReport +import org.libremail.reporting.DiagnosticsCollector +import org.libremail.reporting.ReportKind +import org.libremail.reporting.ReportStore +import javax.inject.Inject + +/** A row in the problem-reports list. */ +data class ReportSummary(val id: String, val kind: ReportKind, val createdAtMillis: Long) + +@HiltViewModel +class ProblemReportsViewModel @Inject constructor( + private val store: ReportStore, + private val collector: DiagnosticsCollector, +) : ViewModel() { + + val reports: StateFlow> = store.reports + .map { list -> list.map { it.toSummary() } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIBE_MS), emptyList()) + + // Emits the id of a freshly created report so the screen can open it for review immediately. + private val _created = MutableSharedFlow(extraBufferCapacity = 1) + val created: SharedFlow = _created + + fun createManualReport() { + viewModelScope.launch { + val report = collector.collectManual() + store.save(report) + _created.tryEmit(report.id) + } + } + + fun discard(id: String) { + viewModelScope.launch { store.delete(id) } + } + + private fun DebugReport.toSummary() = ReportSummary(id, kind, createdAtMillis) + + private companion object { + const val SUBSCRIBE_MS = 5_000L + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt b/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt new file mode 100644 index 0000000..559bf64 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.reporting + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.libremail.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReportReviewScreen(onDone: () -> Unit, viewModel: ReportReviewViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + val savedMessage = stringResource(R.string.report_saved) + val copiedMessage = stringResource(R.string.report_copied) + + val saveLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json"), + ) { uri -> + if (uri != null) { + val text = viewModel.payload() + scope.launch { + withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.openOutputStream(uri)?.use { + it.write(text.toByteArray()) + } + } + } + snackbarHostState.showSnackbar(savedMessage) + } + } + } + + // Once the report has been submitted (deleted by the worker) or discarded, leave the screen. + LaunchedEffect(state.loaded, state.exists) { + if (state.loaded && !state.exists) onDone() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.report_review_title)) }, + navigationIcon = { + IconButton(onClick = onDone) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.action_back), + ) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + PiiDisclaimer() + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = state.comment, + onValueChange = viewModel::updateComment, + label = { Text(stringResource(R.string.report_comment_label)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + ) + Spacer(Modifier.height(16.dp)) + Text( + stringResource(R.string.report_payload_label), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(8.dp)) + PayloadBox(payload = state.payload) + SubmitStatusText(state.submit) + Spacer(Modifier.height(16.dp)) + Button( + onClick = viewModel::submit, + enabled = state.submit != SubmitUiState.SUBMITTING, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.report_submit)) + } + Spacer(Modifier.height(8.dp)) + Row(Modifier.fillMaxWidth()) { + TextButton( + onClick = { + clipboard.setText(AnnotatedString(viewModel.payload())) + scope.launch { snackbarHostState.showSnackbar(copiedMessage) } + }, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.report_copy)) + } + Spacer(Modifier.width(8.dp)) + TextButton( + onClick = { saveLauncher.launch("libremail-report.json") }, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.report_save)) + } + } + Spacer(Modifier.height(8.dp)) + TextButton( + onClick = viewModel::discard, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringResource(R.string.report_discard), + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +@Composable +private fun PiiDisclaimer() { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Row(Modifier.padding(16.dp)) { + Icon(Icons.Filled.Warning, contentDescription = null) + Spacer(Modifier.width(12.dp)) + Column { + Text( + stringResource(R.string.report_pii_disclaimer_title), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.report_pii_disclaimer), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} + +@Composable +private fun PayloadBox(payload: String) { + SelectionContainer { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = payload, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + modifier = Modifier.padding(12.dp), + ) + } + } +} + +@Composable +private fun SubmitStatusText(state: SubmitUiState) { + if (state == SubmitUiState.IDLE) return + val text = when (state) { + SubmitUiState.SUBMITTING -> stringResource(R.string.report_submitting) + SubmitUiState.SUCCEEDED -> stringResource(R.string.report_submitted) + SubmitUiState.FAILED -> stringResource(R.string.report_submit_failed) + SubmitUiState.UNAVAILABLE -> stringResource(R.string.report_submit_unavailable) + SubmitUiState.IDLE -> "" + } + val color = when (state) { + SubmitUiState.SUCCEEDED -> MaterialTheme.colorScheme.primary + SubmitUiState.FAILED, SubmitUiState.UNAVAILABLE -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + Spacer(Modifier.height(8.dp)) + Text(text, color = color, style = MaterialTheme.typography.bodyMedium) +} diff --git a/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewViewModel.kt b/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewViewModel.kt new file mode 100644 index 0000000..e42a8b7 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewViewModel.kt @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.reporting + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.libremail.reporting.ReportStore +import org.libremail.reporting.ReportSubmitter +import org.libremail.reporting.SubmitStatus +import org.libremail.ui.navigation.Routes +import javax.inject.Inject + +/** UI-facing status of a submission attempt. [UNAVAILABLE] means no endpoint is configured. */ +enum class SubmitUiState { IDLE, SUBMITTING, SUCCEEDED, FAILED, UNAVAILABLE } + +data class ReportReviewState( + val loaded: Boolean = false, + val exists: Boolean = false, + val payload: String = "", + val comment: String = "", + val canSubmitOnline: Boolean = false, + val submit: SubmitUiState = SubmitUiState.IDLE, +) + +@HiltViewModel +class ReportReviewViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val store: ReportStore, + private val submitter: ReportSubmitter, +) : ViewModel() { + + private val reportId: String = checkNotNull(savedStateHandle[Routes.REPORT_REVIEW_ARG_ID]) + private val comment = MutableStateFlow(store.find(reportId)?.userComment.orEmpty()) + private val submitState = MutableStateFlow(SubmitUiState.IDLE) + + val state: StateFlow = + combine(store.reports, comment, submitState) { reports, currentComment, submit -> + val report = reports.firstOrNull { it.id == reportId } + ReportReviewState( + loaded = true, + exists = report != null, + // The comment is folded in so the preview is byte-for-byte what a submit would send. + payload = report?.copy(userComment = currentComment)?.toSubmissionPayload().orEmpty(), + comment = currentComment, + canSubmitOnline = submitter.isEnabled, + submit = submit, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIBE_MS), ReportReviewState()) + + fun updateComment(value: String) { + comment.value = value + } + + fun discard() { + viewModelScope.launch { store.delete(reportId) } + } + + /** + * The only path that can send a report off-device, and only from an explicit Submit tap. Persists + * the reviewed comment first so the upload matches exactly what was shown, then enqueues the + * worker (unless no endpoint is configured, in which case it steers the user to Copy/Save). + */ + fun submit() { + viewModelScope.launch { + val report = store.find(reportId) ?: return@launch + store.save(report.copy(userComment = comment.value)) + if (!submitter.isEnabled) { + submitState.value = SubmitUiState.UNAVAILABLE + return@launch + } + submitter.submit(reportId) + submitState.value = SubmitUiState.SUBMITTING + submitter.status(reportId).collect { submitState.value = it.toUi() } + } + } + + /** The exact text shown for review — used for Copy and Save-to-file. */ + fun payload(): String = store.find(reportId)?.copy(userComment = comment.value)?.toSubmissionPayload().orEmpty() + + private fun SubmitStatus.toUi(): SubmitUiState = when (this) { + SubmitStatus.IDLE, SubmitStatus.SUBMITTING -> SubmitUiState.SUBMITTING + SubmitStatus.SUCCEEDED -> SubmitUiState.SUCCEEDED + SubmitStatus.FAILED -> SubmitUiState.FAILED + } + + private companion object { + const val SUBSCRIBE_MS = 5_000L + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/reporting/StartupReportViewModel.kt b/app/src/main/kotlin/org/libremail/ui/reporting/StartupReportViewModel.kt new file mode 100644 index 0000000..89cfdec --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/reporting/StartupReportViewModel.kt @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.reporting + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.libremail.reporting.ReportKind +import org.libremail.reporting.ReportStore +import javax.inject.Inject + +/** Surfaces a pending crash report (if any) so the app can offer it for review on launch. */ +@HiltViewModel +class StartupReportViewModel @Inject constructor(private val store: ReportStore) : ViewModel() { + + private val dismissed = MutableStateFlow(false) + + val pendingCrash: StateFlow = + combine(store.reports, dismissed) { reports, isDismissed -> + if (isDismissed) { + null + } else { + reports.firstOrNull { it.kind == ReportKind.CRASH } + ?.let { ReportSummary(it.id, it.kind, it.createdAtMillis) } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIBE_MS), null) + + /** Hides the prompt for this launch; the report stays saved and is offered again next launch. */ + fun dismiss() { + dismissed.value = true + } + + fun discard(id: String) { + dismissed.value = true + viewModelScope.launch { store.delete(id) } + } + + private companion object { + const val SUBSCRIBE_MS = 5_000L + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt index 32d925b..3b13e91 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt @@ -42,6 +42,7 @@ fun SettingsScreen( onAddAccount: () -> Unit, onOpenAccount: (String) -> Unit, onSelectTab: (TopDest) -> Unit, + onReportProblem: () -> Unit, viewModel: SettingsViewModel = hiltViewModel(), ) { val settings by viewModel.settings.collectAsStateWithLifecycle() @@ -113,6 +114,14 @@ fun SettingsScreen( ) HorizontalDivider() + SectionHeader(stringResource(R.string.settings_diagnostics)) + ClickRow( + title = stringResource(R.string.settings_report_problem), + subtitle = stringResource(R.string.settings_report_problem_summary), + onClick = onReportProblem, + ) + HorizontalDivider() + AdvancedHeader(expanded = advancedExpanded, onToggle = viewModel::toggleAdvanced) AnimatedVisibility(visible = advancedExpanded) { Column { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a4df47f..a29b5d4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -165,4 +165,34 @@ Load remote images by default Encrypt local cache Encrypt cached mail stored on this device, using a key held in the Android Keystore. Applies the next time you open LibreMail. + + + Diagnostics + Report a problem + Capture diagnostics you can review and optionally send + Problem reports + No problem reports yet + Create a report + Crash report + Manual report + Review report + May contain personal information + This report can include email addresses, server names, and other details from your device. Read the whole thing below before sending. Nothing is sent unless you tap Submit. + What went wrong? (optional) + Exactly what will be sent + Submit + Discard report + Copy + Save to file + Submitting… + Report sent. Thank you! + Couldn\'t send — it will be retried. + Online submission isn\'t available in this build. Use Copy or Save to share the report. + Copied to clipboard + Saved + LibreMail closed unexpectedly + A problem report from the last crash is ready for you to review. Nothing is sent automatically. + Review + Not now + Discard diff --git a/app/src/test/kotlin/org/libremail/reporting/CrashReporterTest.kt b/app/src/test/kotlin/org/libremail/reporting/CrashReporterTest.kt new file mode 100644 index 0000000..b09dd3a --- /dev/null +++ b/app/src/test/kotlin/org/libremail/reporting/CrashReporterTest.kt @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import io.mockk.every +import io.mockk.mockk +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.libremail.data.settings.SettingsRepository +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CrashReporterTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private val appVersion = mockk { + every { versionName } returns "0.1.0" + every { versionCode } returns 1L + } + private val settingsRepository = mockk() + + @Test + fun `persisting a forced crash saves a report offered on next launch`() { + val store = ReportStore(tempFolder.root) + val buffer = RingLogBuffer() + val collector = DiagnosticsCollector(appVersion, settingsRepository, buffer) + val reporter = CrashReporter(collector, store, buffer) + + reporter.persist(IllegalStateException("forced crash")) + + // Saved locally, with the stack trace and a crash breadcrumb captured. + val saved = store.reports.value.single() + assertEquals(ReportKind.CRASH, saved.kind) + assertTrue(saved.stackTrace.orEmpty().contains("forced crash")) + assertTrue(saved.logs.any { it.contains("Uncaught exception") }) + + // Still available to a fresh store instance, simulating the next app launch. + val nextLaunch = ReportStore(tempFolder.root) + assertEquals(1, nextLaunch.reports.value.size) + assertEquals(ReportKind.CRASH, nextLaunch.reports.value.single().kind) + } + + @Test + fun `capture only persists — it has no path to transmit`() { + // CrashReporter is constructed without any submitter/scheduler, so a crash can only ever be + // written to the local store. Nothing here can send data off the device. + val store = ReportStore(tempFolder.root) + val buffer = RingLogBuffer() + val collector = DiagnosticsCollector(appVersion, settingsRepository, buffer) + val reporter = CrashReporter(collector, store, buffer) + + reporter.persist(RuntimeException("boom")) + + assertEquals(1, store.reports.value.size) + } +} diff --git a/app/src/test/kotlin/org/libremail/reporting/DebugReportTest.kt b/app/src/test/kotlin/org/libremail/reporting/DebugReportTest.kt new file mode 100644 index 0000000..b582078 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/reporting/DebugReportTest.kt @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DebugReportTest { + + private fun sample( + kind: ReportKind = ReportKind.CRASH, + stackTrace: String? = "java.lang.RuntimeException: boom\n\tat Foo.bar(Foo.kt:1)", + comment: String = "", + ) = DebugReport( + id = "abc-123", + createdAtMillis = 1_700_000_000_000L, + kind = kind, + appVersionName = "0.1.0", + appVersionCode = 7, + androidRelease = "14", + androidSdkInt = 34, + deviceManufacturer = "Google", + deviceModel = "Pixel 8", + stackTrace = stackTrace, + settings = linkedMapOf("pushIdle" to "true", "fetchPolicy" to "ALWAYS"), + logs = listOf("line 1", "line 2"), + userComment = comment, + ) + + @Test + fun `round-trips through storage json`() { + val original = sample(comment = "please fix") + + val restored = DebugReport.fromStorageJson(original.toStorageJson()) + + assertEquals(original, restored) + } + + @Test + fun `round-trips a manual report with no stack trace`() { + val original = sample(kind = ReportKind.MANUAL, stackTrace = null) + + val restored = DebugReport.fromStorageJson(original.toStorageJson()) + + assertEquals(ReportKind.MANUAL, restored.kind) + assertNull(restored.stackTrace) + assertEquals(original, restored) + } + + @Test + fun `submission payload contains the fields the user should see`() { + val payload = sample(comment = "it froze").toSubmissionPayload() + + assertTrue(payload.contains("\"kind\": \"CRASH\""), payload) + assertTrue(payload.contains("0.1.0")) + assertTrue(payload.contains("Pixel 8")) + assertTrue(payload.contains("it froze")) + assertTrue(payload.contains("boom")) + assertTrue(payload.contains("pushIdle")) + assertTrue(payload.contains("line 1")) + } + + @Test + fun `submission payload reflects the edited comment`() { + val base = sample() + + val edited = base.copy(userComment = "edited note").toSubmissionPayload() + + assertTrue(edited.contains("edited note")) + } +} diff --git a/app/src/test/kotlin/org/libremail/reporting/DiagnosticsCollectorTest.kt b/app/src/test/kotlin/org/libremail/reporting/DiagnosticsCollectorTest.kt new file mode 100644 index 0000000..d478790 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/reporting/DiagnosticsCollectorTest.kt @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.libremail.data.settings.AppSettings +import org.libremail.data.settings.FetchPolicy +import org.libremail.data.settings.SettingsRepository +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DiagnosticsCollectorTest { + + private val appVersion = mockk { + every { versionName } returns "1.2.3" + every { versionCode } returns 42L + } + private val settingsRepository = mockk() + private val logBuffer = RingLogBuffer() + private val collector = DiagnosticsCollector(appVersion, settingsRepository, logBuffer) + + @Test + fun `crash report includes stack trace and app version`() = runTest { + val report = collector.collectCrash(RuntimeException("kaboom")) + + assertEquals(ReportKind.CRASH, report.kind) + assertEquals("1.2.3", report.appVersionName) + assertEquals(42L, report.appVersionCode) + assertTrue(report.stackTrace.orEmpty().contains("kaboom")) + } + + @Test + fun `manual report includes a minimal non-PII settings summary and no stack trace`() = runTest { + every { settingsRepository.settings } returns + flowOf(AppSettings(pushIdle = false, fetchPolicy = FetchPolicy.ON_DEMAND)) + + val report = collector.collectManual() + + assertEquals(ReportKind.MANUAL, report.kind) + assertNull(report.stackTrace) + assertEquals("false", report.settings["pushIdle"]) + assertEquals("ON_DEMAND", report.settings["fetchPolicy"]) + // The summary is a fixed allow-list of non-PII flags — no account/server fields. + assertEquals( + setOf( + "dynamicColor", + "newMailNotifications", + "pushIdle", + "allowStartTls", + "loadRemoteImages", + "encryptCache", + "fetchPolicy", + ), + report.settings.keys, + ) + assertTrue(report.settings.values.none { it.contains("@") }) + } + + @Test + fun `crash report includes settings once the cache is warmed`() = runTest { + every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true)) + collector.warmSettingsCache() + + val report = collector.collectCrash(RuntimeException("x")) + + assertEquals("true", report.settings["encryptCache"]) + } + + @Test + fun `crash report captures recent in-app log lines`() = runTest { + logBuffer.record('I', "Startup", "hello-breadcrumb") + + val report = collector.collectCrash(RuntimeException("x")) + + assertTrue(report.logs.any { it.contains("hello-breadcrumb") }) + } +} diff --git a/app/src/test/kotlin/org/libremail/reporting/ReportStoreTest.kt b/app/src/test/kotlin/org/libremail/reporting/ReportStoreTest.kt new file mode 100644 index 0000000..02e7072 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/reporting/ReportStoreTest.kt @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ReportStoreTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private fun report(id: String, createdAt: Long = 1L, kind: ReportKind = ReportKind.MANUAL) = DebugReport( + id = id, + createdAtMillis = createdAt, + kind = kind, + appVersionName = "0.1.0", + appVersionCode = 1, + androidRelease = "14", + androidSdkInt = 34, + deviceManufacturer = "Google", + deviceModel = "Pixel", + stackTrace = null, + settings = emptyMap(), + logs = emptyList(), + ) + + @Test + fun `save then find and list`() { + val store = ReportStore(tempFolder.root) + + store.save(report("a")) + + assertEquals("a", store.find("a")?.id) + assertEquals(listOf("a"), store.reports.value.map { it.id }) + } + + @Test + fun `lists newest first`() { + val store = ReportStore(tempFolder.root) + + store.save(report("old", createdAt = 1L)) + store.save(report("new", createdAt = 2L)) + + assertEquals(listOf("new", "old"), store.reports.value.map { it.id }) + } + + @Test + fun `delete removes the report`() { + val store = ReportStore(tempFolder.root) + store.save(report("a")) + + store.delete("a") + + assertNull(store.find("a")) + assertTrue(store.reports.value.isEmpty()) + } + + @Test + fun `survives a fresh instance over the same directory (next launch)`() { + ReportStore(tempFolder.root).save(report("persisted")) + + val reopened = ReportStore(tempFolder.root) + + assertEquals("persisted", reopened.find("persisted")?.id) + } + + @Test + fun `ignores unparseable files`() { + File(tempFolder.root, "garbage.json").writeText("not json at all") + val store = ReportStore(tempFolder.root) + + store.save(report("valid")) + + assertEquals(listOf("valid"), store.reports.value.map { it.id }) + } +} diff --git a/app/src/test/kotlin/org/libremail/reporting/RingLogBufferTest.kt b/app/src/test/kotlin/org/libremail/reporting/RingLogBufferTest.kt new file mode 100644 index 0000000..f9386e0 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/reporting/RingLogBufferTest.kt @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class RingLogBufferTest { + + @Test + fun `records lines in order`() { + val buffer = RingLogBuffer() + + buffer.record('I', "Tag", "first") + buffer.record('W', "Tag", "second") + + val snapshot = buffer.snapshot() + assertEquals(2, snapshot.size) + assertEquals("first", snapshot[0].message) + assertEquals("second", snapshot[1].message) + assertEquals('W', snapshot[1].level) + } + + @Test + fun `caps capacity and drops the oldest entries`() { + val buffer = RingLogBuffer() + + repeat(TOTAL) { buffer.record('D', "Tag", "msg-$it") } + + val snapshot = buffer.snapshot() + assertEquals(CAPACITY, snapshot.size) + // The most recent entry is retained; the very first was dropped. + assertEquals("msg-${TOTAL - 1}", snapshot.last().message) + assertTrue(snapshot.none { it.message == "msg-0" }) + } + + @Test + fun `clear empties the buffer`() { + val buffer = RingLogBuffer() + buffer.record('I', "Tag", "x") + + buffer.clear() + + assertTrue(buffer.snapshot().isEmpty()) + } + + private companion object { + const val CAPACITY = 200 + const val TOTAL = 250 + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/reporting/ReportReviewViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/reporting/ReportReviewViewModelTest.kt new file mode 100644 index 0000000..79eb032 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/reporting/ReportReviewViewModelTest.kt @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.reporting + +import androidx.lifecycle.SavedStateHandle +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.reporting.DebugReport +import org.libremail.reporting.ReportKind +import org.libremail.reporting.ReportStore +import org.libremail.reporting.ReportSubmitter +import org.libremail.ui.navigation.Routes +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class ReportReviewViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(testDispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + private val report = DebugReport( + id = "rid", + createdAtMillis = 1L, + kind = ReportKind.CRASH, + appVersionName = "0.1.0", + appVersionCode = 1, + androidRelease = "14", + androidSdkInt = 34, + deviceManufacturer = "Google", + deviceModel = "Pixel", + stackTrace = "boom", + settings = emptyMap(), + logs = emptyList(), + ) + + private val store = mockk(relaxed = false) + private val submitter = mockk() + + private fun viewModel(): ReportReviewViewModel { + every { store.reports } returns MutableStateFlow(listOf(report)) + every { store.find("rid") } returns report + return ReportReviewViewModel( + savedStateHandle = SavedStateHandle(mapOf(Routes.REPORT_REVIEW_ARG_ID to "rid")), + store = store, + submitter = submitter, + ) + } + + @Test + fun `editing the comment sends nothing and saves nothing`() = runTest(testDispatcher) { + every { submitter.isEnabled } returns true + val vm = viewModel() + + vm.updateComment("just typing") + + verify(exactly = 0) { submitter.submit(any()) } + verify(exactly = 0) { store.save(any()) } + } + + @Test + fun `payload shown is exactly what would be submitted, including the comment`() = runTest(testDispatcher) { + val vm = viewModel() + + vm.updateComment("my note") + + assertEquals(report.copy(userComment = "my note").toSubmissionPayload(), vm.payload()) + } + + @Test + fun `submit enqueues the upload exactly once and persists the reviewed comment`() = runTest(testDispatcher) { + every { submitter.isEnabled } returns true + every { submitter.submit("rid") } just Runs + every { submitter.status("rid") } returns emptyFlow() + every { store.save(any()) } just Runs + val vm = viewModel() + vm.updateComment("edited before submit") + + vm.submit() + + verify(exactly = 1) { store.save(match { it.userComment == "edited before submit" }) } + verify(exactly = 1) { submitter.submit("rid") } + } + + @Test + fun `submit with no endpoint configured never transmits`() = runTest(testDispatcher) { + every { submitter.isEnabled } returns false + every { store.save(any()) } just Runs + val vm = viewModel() + vm.updateComment("please send") + + vm.submit() + + // The comment is still persisted for Copy/Save, but nothing is enqueued for upload. + verify(exactly = 1) { store.save(match { it.userComment == "please send" }) } + verify(exactly = 0) { submitter.submit(any()) } + } + + @Test + fun `discard deletes the report and does not submit`() = runTest(testDispatcher) { + every { store.delete("rid") } just Runs + val vm = viewModel() + + vm.discard() + + verify(exactly = 1) { store.delete("rid") } + verify(exactly = 0) { submitter.submit(any()) } + } +}