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 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 00:03:13 -05:00
co-authored by Claude Opus 4.8
parent 64fb82ebd9
commit d1b0593a8c
28 changed files with 1699 additions and 1 deletions
+6
View File
@@ -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
}
@@ -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"
}
}
@@ -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"))
}
@@ -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)
}
}
@@ -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"
}
}
@@ -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
}
}
@@ -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<String, String>,
val logs: List<String>,
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<String, String>()
settingsJson.keys().forEach { key -> settings[key] = settingsJson.getString(key) }
val logsJson = json.getJSONArray("logs")
val logs = ArrayList<String>(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", ""),
)
}
}
}
@@ -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<String, String> = 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<String, String>) = 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<String, String> = 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,
)
}
@@ -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<List<DebugReport>> = _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<DebugReport> {
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"
}
}
@@ -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<SubmitStatus> = scheduler.statusFlow(reportId).map(::toStatus)
private fun toStatus(infos: List<WorkInfo>): 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
}
}
}
@@ -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<ReportUploadWorker>()
.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<List<WorkInfo>> =
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
}
}
@@ -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
}
}
@@ -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<LogEntry>()
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<LogEntry> = synchronized(lock) { entries.toList() }
fun clear() {
synchronized(lock) { entries.clear() }
}
private companion object {
const val CAPACITY = 200
}
}
@@ -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. */
@@ -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"
@@ -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,
)
}
}
@@ -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<List<ReportSummary>> = 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<String>(extraBufferCapacity = 1)
val created: SharedFlow<String> = _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
}
}
@@ -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)
}
@@ -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<ReportReviewState> =
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
}
}
@@ -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<ReportSummary?> =
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
}
}
@@ -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 {
+30
View File
@@ -165,4 +165,34 @@
<string name="settings_adv_remote_images">Load remote images by default</string>
<string name="settings_adv_encrypt_cache">Encrypt local cache</string>
<string name="settings_adv_encrypt_cache_summary">Encrypt cached mail stored on this device, using a key held in the Android Keystore. Applies the next time you open LibreMail.</string>
<!-- Diagnostics / debug reporting -->
<string name="settings_diagnostics">Diagnostics</string>
<string name="settings_report_problem">Report a problem</string>
<string name="settings_report_problem_summary">Capture diagnostics you can review and optionally send</string>
<string name="reports_title">Problem reports</string>
<string name="reports_empty">No problem reports yet</string>
<string name="reports_create">Create a report</string>
<string name="report_kind_crash">Crash report</string>
<string name="report_kind_manual">Manual report</string>
<string name="report_review_title">Review report</string>
<string name="report_pii_disclaimer_title">May contain personal information</string>
<string name="report_pii_disclaimer">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.</string>
<string name="report_comment_label">What went wrong? (optional)</string>
<string name="report_payload_label">Exactly what will be sent</string>
<string name="report_submit">Submit</string>
<string name="report_discard">Discard report</string>
<string name="report_copy">Copy</string>
<string name="report_save">Save to file</string>
<string name="report_submitting">Submitting…</string>
<string name="report_submitted">Report sent. Thank you!</string>
<string name="report_submit_failed">Couldn\'t send — it will be retried.</string>
<string name="report_submit_unavailable">Online submission isn\'t available in this build. Use Copy or Save to share the report.</string>
<string name="report_copied">Copied to clipboard</string>
<string name="report_saved">Saved</string>
<string name="crash_prompt_title">LibreMail closed unexpectedly</string>
<string name="crash_prompt_message">A problem report from the last crash is ready for you to review. Nothing is sent automatically.</string>
<string name="crash_prompt_review">Review</string>
<string name="crash_prompt_later">Not now</string>
<string name="crash_prompt_discard">Discard</string>
</resources>
@@ -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<AppVersionProvider> {
every { versionName } returns "0.1.0"
every { versionCode } returns 1L
}
private val settingsRepository = mockk<SettingsRepository>()
@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)
}
}
@@ -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"))
}
}
@@ -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<AppVersionProvider> {
every { versionName } returns "1.2.3"
every { versionCode } returns 42L
}
private val settingsRepository = mockk<SettingsRepository>()
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") })
}
}
@@ -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 })
}
}
@@ -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
}
}
@@ -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<ReportStore>(relaxed = false)
private val submitter = mockk<ReportSubmitter>()
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()) }
}
}