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:
@@ -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 {
|
||||
|
||||
@@ -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()) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user