mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
feat(shared): add opt-in diagnostics client with compile-time kill switch
Ship client-side telemetry, crash capture, and bug-report assembly behind a user opt-in, with NoOp transport until Cloudflare is wired. Builds can set vnidrop.diagnostics.included=false to omit the Share-diagnostics UI and disable the telemetry/crash auto-upload stack while keeping bug reports.
This commit is contained in:
@@ -57,7 +57,13 @@ fun App(
|
||||
val graph = graphHolder.graph
|
||||
|
||||
val appViewModel = viewModel {
|
||||
AppViewModel(dependencies.environment, graph.coreRepository, graph.preferencesRepository, graph.messages)
|
||||
AppViewModel(
|
||||
dependencies.environment,
|
||||
graph.coreRepository,
|
||||
graph.preferencesRepository,
|
||||
graph.messages,
|
||||
graph.diagnostics,
|
||||
)
|
||||
}
|
||||
val sendViewModel = viewModel {
|
||||
SendViewModel(
|
||||
@@ -79,6 +85,8 @@ fun App(
|
||||
graph.preferencesRepository,
|
||||
dependencies.localNotificationService,
|
||||
graph.messages,
|
||||
graph.diagnostics.bugReports,
|
||||
graph.diagnostics,
|
||||
)
|
||||
}
|
||||
val appState by appViewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.vnidrop.app
|
||||
|
||||
import com.vnidrop.app.core.CoreGateway
|
||||
import com.vnidrop.app.core.CoreRepository
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
|
||||
import com.vnidrop.app.diagnostics.NoOpDiagnosticsTransport
|
||||
import com.vnidrop.app.feature.approvals.ApprovalCoordinator
|
||||
import com.vnidrop.app.feature.send.AppFilePreviewRepository
|
||||
import com.vnidrop.app.feature.send.createPlatformPreviewStore
|
||||
@@ -34,8 +36,17 @@ class AppGraph(
|
||||
receiveFolder = dependencies.fileSystemService.defaultReceiveFolder(),
|
||||
themeMode = ThemeMode.System,
|
||||
notificationsEnabled = false,
|
||||
diagnosticsEnabled = false,
|
||||
),
|
||||
)
|
||||
val diagnostics = DiagnosticsCoordinator.create(
|
||||
appDataDir = dependencies.environment.defaultCoreDataDir,
|
||||
appVersion = dependencies.environment.appVersion,
|
||||
platform = dependencies.environment.name,
|
||||
preferencesRepository = preferencesRepository,
|
||||
scope = applicationScope,
|
||||
transport = NoOpDiagnosticsTransport(),
|
||||
)
|
||||
val approvalCoordinator = ApprovalCoordinator(
|
||||
repository = coreRepository,
|
||||
preferencesRepository = preferencesRepository,
|
||||
@@ -47,6 +58,7 @@ class AppGraph(
|
||||
|
||||
init {
|
||||
AppLogger.initialize(dependencies.environment.defaultCoreDataDir)
|
||||
diagnostics.start()
|
||||
}
|
||||
|
||||
fun close() {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import com.vnidrop.app.logging.platformNowMillis
|
||||
|
||||
/**
|
||||
* Fixed-size ring of high-level app breadcrumbs for crash / bug context.
|
||||
* Always in-memory only; never auto-uploaded without policy + transport.
|
||||
*
|
||||
* Updates are best-effort under concurrency; losing a breadcrumb is preferable
|
||||
* to blocking a dying process on a lock.
|
||||
*/
|
||||
class BreadcrumbBuffer(
|
||||
private val capacity: Int = DefaultCapacity,
|
||||
) {
|
||||
init {
|
||||
require(capacity > 0) { "capacity must be positive" }
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var items: List<Breadcrumb> = emptyList()
|
||||
|
||||
fun add(name: String, properties: Map<String, String> = emptyMap(), timestampMillis: Long = platformNowMillis()) {
|
||||
val crumb = Breadcrumb(
|
||||
name = name.take(MaxNameLength),
|
||||
timestampMillis = timestampMillis,
|
||||
properties = LogRedactor.redactMap(properties).mapValues { it.value.take(MaxPropertyValueLength) },
|
||||
)
|
||||
val current = items
|
||||
items = (current + crumb).takeLast(capacity)
|
||||
}
|
||||
|
||||
fun snapshot(): List<Breadcrumb> = items
|
||||
|
||||
fun clear() {
|
||||
items = emptyList()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DefaultCapacity = 40
|
||||
private const val MaxNameLength = 64
|
||||
private const val MaxPropertyValueLength = 128
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import com.vnidrop.app.DeviceInfo
|
||||
import com.vnidrop.app.logging.AppLogger
|
||||
import com.vnidrop.app.logging.platformNowMillis
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.util.randomUuidString
|
||||
|
||||
data class BugReportDraft(
|
||||
val whatHappened: String,
|
||||
val expected: String,
|
||||
val steps: String = "",
|
||||
val contact: String = "",
|
||||
val includeLogs: Boolean = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* User-initiated bug reports. Always allowed regardless of diagnostics opt-in.
|
||||
*/
|
||||
class BugReportService(
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
private val transport: DiagnosticsTransport,
|
||||
private val breadcrumbs: BreadcrumbBuffer,
|
||||
private val appVersion: String,
|
||||
private val platform: String,
|
||||
private val logReader: () -> String = {
|
||||
LogRedactor.redact(AppLogger.readLatestLogs(AppLogger.DefaultBugReportLogBytes))
|
||||
},
|
||||
) {
|
||||
fun assemble(
|
||||
draft: BugReportDraft,
|
||||
deviceInfo: DeviceInfo?,
|
||||
installId: String,
|
||||
): BugReport {
|
||||
val logs = if (draft.includeLogs) logReader() else ""
|
||||
return BugReport(
|
||||
id = randomUuidString(),
|
||||
timestampMillis = platformNowMillis(),
|
||||
installId = installId,
|
||||
appVersion = appVersion,
|
||||
platform = platform,
|
||||
whatHappened = draft.whatHappened.trim().take(MaxFieldLength),
|
||||
expected = draft.expected.trim().take(MaxFieldLength),
|
||||
steps = draft.steps.trim().take(MaxFieldLength),
|
||||
contact = draft.contact.trim().take(MaxContactLength),
|
||||
includeLogs = draft.includeLogs,
|
||||
logs = logs,
|
||||
device = DeviceSnapshot(
|
||||
deviceName = deviceInfo?.deviceName,
|
||||
deviceModel = deviceInfo?.deviceModel,
|
||||
operatingSystem = deviceInfo?.operatingSystem ?: platform,
|
||||
network = deviceInfo?.network,
|
||||
batteryLevel = deviceInfo?.batteryLevel,
|
||||
),
|
||||
breadcrumbs = breadcrumbs.snapshot(),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun submit(draft: BugReportDraft, deviceInfo: DeviceInfo?): Result<BugReport> {
|
||||
val what = draft.whatHappened.trim()
|
||||
val expected = draft.expected.trim()
|
||||
if (what.isEmpty()) {
|
||||
return Result.failure(IllegalArgumentException("Describe what happened."))
|
||||
}
|
||||
if (expected.isEmpty()) {
|
||||
return Result.failure(IllegalArgumentException("Describe what you expected."))
|
||||
}
|
||||
val installId = preferencesRepository.ensureDiagnosticsInstallId()
|
||||
val report = assemble(draft, deviceInfo, installId)
|
||||
val send = transport.sendBugReport(report)
|
||||
return send.fold(
|
||||
onSuccess = {
|
||||
AppLogger.info("bug_report", "submitted", mapOf("id" to report.id))
|
||||
Result.success(report)
|
||||
},
|
||||
onFailure = { error ->
|
||||
AppLogger.error("bug_report", "submit failed", error)
|
||||
Result.failure(error)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun previewLogBytes(): Int = logReader().encodeToByteArray().size
|
||||
|
||||
companion object {
|
||||
private const val MaxFieldLength = 4_000
|
||||
private const val MaxContactLength = 320
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import com.vnidrop.app.logging.AppLogger
|
||||
import com.vnidrop.app.logging.platformNowMillis
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.util.randomUuidString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Captures uncaught exceptions to disk, then uploads on a later launch when
|
||||
* diagnostics is enabled (and when a real [DiagnosticsTransport] is wired).
|
||||
*/
|
||||
class CrashReporter(
|
||||
private val store: PendingCrashStore,
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
private val transport: DiagnosticsTransport,
|
||||
private val breadcrumbs: BreadcrumbBuffer,
|
||||
private val appVersion: String,
|
||||
private val platform: String,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
@Volatile private var installed = false
|
||||
@Volatile private var lastInstallId: String = ""
|
||||
@Volatile private var lastDiagnosticsEnabled: Boolean = false
|
||||
|
||||
fun startObservingPreferences() {
|
||||
scope.launch {
|
||||
preferencesRepository.preferences.collect { prefs ->
|
||||
lastInstallId = prefs.diagnosticsInstallId
|
||||
lastDiagnosticsEnabled = prefs.diagnosticsEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun installUnhandledExceptionHandler() {
|
||||
if (!DiagnosticsBuildConfig.INCLUDED) return
|
||||
if (installed) return
|
||||
installed = true
|
||||
installPlatformCrashHook { throwable ->
|
||||
capture(throwable)
|
||||
}
|
||||
}
|
||||
|
||||
fun capture(throwable: Throwable, diagnosticsEnabledOverride: Boolean? = null): CrashReport {
|
||||
val report = CrashReport(
|
||||
id = randomUuidString(),
|
||||
timestampMillis = platformNowMillis(),
|
||||
installId = lastInstallId,
|
||||
appVersion = appVersion,
|
||||
platform = platform,
|
||||
exceptionType = throwable::class.simpleName ?: "Throwable",
|
||||
exceptionMessage = LogRedactor.redact(throwable.message.orEmpty()).take(MaxMessageLength),
|
||||
stackTrace = LogRedactor.redact(throwable.stackTraceToString()).take(MaxStackLength),
|
||||
breadcrumbs = breadcrumbs.snapshot(),
|
||||
diagnosticsEnabledAtCapture = diagnosticsEnabledOverride ?: lastDiagnosticsEnabled,
|
||||
)
|
||||
runCatching { store.write(report) }
|
||||
AppLogger.error("crash", "captured crash ${report.id}", throwable)
|
||||
return report
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads pending crashes that were captured with diagnostics enabled.
|
||||
* Local files are always kept for bug-report attachment until deleted after successful send.
|
||||
*/
|
||||
suspend fun flushPending() {
|
||||
val diagnosticsEnabled = preferencesRepository.preferences.first().diagnosticsEnabled
|
||||
if (!diagnosticsEnabled) return
|
||||
for (report in store.list()) {
|
||||
// Only auto-upload crashes captured while diagnostics was on.
|
||||
if (!report.diagnosticsEnabledAtCapture) continue
|
||||
val result = transport.sendCrash(report)
|
||||
if (result.isSuccess) {
|
||||
store.delete(report.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun latestLocalCrash(): CrashReport? = store.list().maxByOrNull { it.timestampMillis }
|
||||
|
||||
companion object {
|
||||
private const val MaxMessageLength = 2_000
|
||||
private const val MaxStackLength = 32_000
|
||||
}
|
||||
}
|
||||
|
||||
expect fun installPlatformCrashHook(onCrash: (Throwable) -> Unit)
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Owns diagnostics services for the app process: telemetry, crashes, bug reports.
|
||||
*
|
||||
* When [DiagnosticsBuildConfig.INCLUDED] is false (compile-time), telemetry and
|
||||
* crash auto-reporting are never started; [bugReports] still works for support.
|
||||
*/
|
||||
class DiagnosticsCoordinator(
|
||||
val preferencesRepository: PreferencesRepository,
|
||||
val transport: DiagnosticsTransport,
|
||||
val breadcrumbs: BreadcrumbBuffer,
|
||||
val telemetry: TelemetryRecorder,
|
||||
val crashReporter: CrashReporter,
|
||||
val bugReports: BugReportService,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
fun start() {
|
||||
// Install id is useful for bug-report correlation even without telemetry.
|
||||
scope.launch {
|
||||
preferencesRepository.ensureDiagnosticsInstallId()
|
||||
}
|
||||
if (!DiagnosticsBuildConfig.INCLUDED) return
|
||||
crashReporter.startObservingPreferences()
|
||||
crashReporter.installUnhandledExceptionHandler()
|
||||
scope.launch {
|
||||
crashReporter.flushPending()
|
||||
}
|
||||
}
|
||||
|
||||
fun record(name: String, properties: Map<String, String> = emptyMap()) {
|
||||
if (!DiagnosticsBuildConfig.INCLUDED) return
|
||||
telemetry.record(name, properties)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
appDataDir: String,
|
||||
appVersion: String,
|
||||
platform: String,
|
||||
preferencesRepository: PreferencesRepository,
|
||||
scope: CoroutineScope,
|
||||
transport: DiagnosticsTransport = NoOpDiagnosticsTransport(),
|
||||
): DiagnosticsCoordinator {
|
||||
val breadcrumbs = BreadcrumbBuffer()
|
||||
val crashStore = createPendingCrashStore(appDataDir)
|
||||
val telemetry = TelemetryRecorder(
|
||||
preferencesRepository = preferencesRepository,
|
||||
transport = transport,
|
||||
breadcrumbs = breadcrumbs,
|
||||
scope = scope,
|
||||
)
|
||||
val crashReporter = CrashReporter(
|
||||
store = crashStore,
|
||||
preferencesRepository = preferencesRepository,
|
||||
transport = transport,
|
||||
breadcrumbs = breadcrumbs,
|
||||
appVersion = appVersion,
|
||||
platform = platform,
|
||||
scope = scope,
|
||||
)
|
||||
val bugReports = BugReportService(
|
||||
preferencesRepository = preferencesRepository,
|
||||
transport = transport,
|
||||
breadcrumbs = breadcrumbs,
|
||||
appVersion = appVersion,
|
||||
platform = platform,
|
||||
)
|
||||
return DiagnosticsCoordinator(
|
||||
preferencesRepository = preferencesRepository,
|
||||
transport = transport,
|
||||
breadcrumbs = breadcrumbs,
|
||||
telemetry = telemetry,
|
||||
crashReporter = crashReporter,
|
||||
bugReports = bugReports,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
/**
|
||||
* Client-side diagnostics payloads. Transport to Cloudflare (or elsewhere) is
|
||||
* intentionally abstracted; nothing here assumes a network backend.
|
||||
*/
|
||||
|
||||
data class TelemetryEvent(
|
||||
val name: String,
|
||||
val timestampMillis: Long,
|
||||
val properties: Map<String, String> = emptyMap(),
|
||||
val schemaVersion: Int = DiagnosticsSchemaVersion,
|
||||
)
|
||||
|
||||
data class Breadcrumb(
|
||||
val name: String,
|
||||
val timestampMillis: Long,
|
||||
val properties: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
data class CrashReport(
|
||||
val id: String,
|
||||
val timestampMillis: Long,
|
||||
val installId: String,
|
||||
val appVersion: String,
|
||||
val platform: String,
|
||||
val exceptionType: String,
|
||||
val exceptionMessage: String,
|
||||
val stackTrace: String,
|
||||
val breadcrumbs: List<Breadcrumb>,
|
||||
/** Whether diagnostics was opted in when the crash was captured. */
|
||||
val diagnosticsEnabledAtCapture: Boolean,
|
||||
val schemaVersion: Int = DiagnosticsSchemaVersion,
|
||||
)
|
||||
|
||||
data class DeviceSnapshot(
|
||||
val deviceName: String?,
|
||||
val deviceModel: String?,
|
||||
val operatingSystem: String,
|
||||
val network: String?,
|
||||
val batteryLevel: String?,
|
||||
)
|
||||
|
||||
data class BugReport(
|
||||
val id: String,
|
||||
val timestampMillis: Long,
|
||||
val installId: String,
|
||||
val appVersion: String,
|
||||
val platform: String,
|
||||
val whatHappened: String,
|
||||
val expected: String,
|
||||
val steps: String,
|
||||
val contact: String,
|
||||
val includeLogs: Boolean,
|
||||
val logs: String,
|
||||
val device: DeviceSnapshot,
|
||||
val breadcrumbs: List<Breadcrumb>,
|
||||
val schemaVersion: Int = DiagnosticsSchemaVersion,
|
||||
)
|
||||
|
||||
const val DiagnosticsSchemaVersion: Int = 1
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
/**
|
||||
* Network boundary for diagnostics. Production will swap [NoOpDiagnosticsTransport]
|
||||
* for a Cloudflare Worker client; keep batching/validation client-side.
|
||||
*/
|
||||
interface DiagnosticsTransport {
|
||||
suspend fun sendEvents(events: List<TelemetryEvent>): Result<Unit>
|
||||
suspend fun sendCrash(report: CrashReport): Result<Unit>
|
||||
suspend fun sendBugReport(report: BugReport): Result<Unit>
|
||||
}
|
||||
|
||||
/** Accepts payloads without leaving the device. Used until Cloudflare is wired. */
|
||||
class NoOpDiagnosticsTransport : DiagnosticsTransport {
|
||||
override suspend fun sendEvents(events: List<TelemetryEvent>): Result<Unit> = Result.success(Unit)
|
||||
override suspend fun sendCrash(report: CrashReport): Result<Unit> = Result.success(Unit)
|
||||
override suspend fun sendBugReport(report: BugReport): Result<Unit> = Result.success(Unit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test double that records calls and can fail on demand.
|
||||
*/
|
||||
class RecordingDiagnosticsTransport : DiagnosticsTransport {
|
||||
val events = mutableListOf<List<TelemetryEvent>>()
|
||||
val crashes = mutableListOf<CrashReport>()
|
||||
val bugReports = mutableListOf<BugReport>()
|
||||
var eventsResult: Result<Unit> = Result.success(Unit)
|
||||
var crashResult: Result<Unit> = Result.success(Unit)
|
||||
var bugResult: Result<Unit> = Result.success(Unit)
|
||||
|
||||
override suspend fun sendEvents(events: List<TelemetryEvent>): Result<Unit> {
|
||||
this.events += events
|
||||
return eventsResult
|
||||
}
|
||||
|
||||
override suspend fun sendCrash(report: CrashReport): Result<Unit> {
|
||||
crashes += report
|
||||
return crashResult
|
||||
}
|
||||
|
||||
override suspend fun sendBugReport(report: BugReport): Result<Unit> {
|
||||
bugReports += report
|
||||
return bugResult
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
/**
|
||||
* Scrubs known-sensitive patterns from free-form diagnostics text before upload
|
||||
* or attachment. Defense in depth for tickets, endpoint-like ids, and paths.
|
||||
*/
|
||||
object LogRedactor {
|
||||
fun redact(input: String): String {
|
||||
if (input.isEmpty()) return input
|
||||
var result = input
|
||||
for (rule in Rules) {
|
||||
result = rule.regex.replace(result, rule.replacement)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun redactMap(fields: Map<String, String>): Map<String, String> =
|
||||
fields.mapValues { (_, value) -> redact(value) }
|
||||
|
||||
private data class Rule(val regex: Regex, val replacement: String)
|
||||
|
||||
private val Rules = listOf(
|
||||
// Blob tickets / long base64-ish tokens often appear near "ticket=".
|
||||
Rule(
|
||||
Regex("""(?i)(ticket\s*[=:]\s*)([A-Za-z0-9+/=_\-]{24,})"""),
|
||||
"$1[redacted-ticket]",
|
||||
),
|
||||
// iroh-style node/endpoint ids: long hex or base32-ish.
|
||||
Rule(
|
||||
Regex("""(?i)(endpoint[_-]?id\s*[=:]\s*)([A-Za-z0-9+/=_\-]{16,})"""),
|
||||
"$1[redacted-endpoint]",
|
||||
),
|
||||
Rule(
|
||||
Regex("""\b[0-9a-fA-F]{48,}\b"""),
|
||||
"[redacted-hex]",
|
||||
),
|
||||
// Absolute filesystem paths (Unix + Windows drive).
|
||||
Rule(
|
||||
Regex("""(?<![A-Za-z0-9_])(/[^\s:]+|[A-Za-z]:\\[^\s]+)"""),
|
||||
"[redacted-path]",
|
||||
),
|
||||
// content:// and file:// URIs.
|
||||
Rule(
|
||||
Regex("""(?i)\b((?:content|file|http|https)://[^\s]+)"""),
|
||||
"[redacted-uri]",
|
||||
),
|
||||
// SAF tree/document ids.
|
||||
Rule(
|
||||
Regex("""(?i)(document[_-]?id|tree[_-]?uri)\s*[=:]\s*\S+"""),
|
||||
"$1=[redacted]",
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
/**
|
||||
* Durable crash envelopes written during process death and read on next launch.
|
||||
* Encoding is a simple line-oriented format (no kotlinx.serialization dependency).
|
||||
*/
|
||||
interface PendingCrashStore {
|
||||
fun write(report: CrashReport)
|
||||
fun list(): List<CrashReport>
|
||||
fun delete(id: String)
|
||||
}
|
||||
|
||||
expect fun createPendingCrashStore(appDataDir: String): PendingCrashStore
|
||||
|
||||
internal object CrashReportCodec {
|
||||
private const val FieldSep = "\u001f"
|
||||
private const val RecordSep = "\u001e"
|
||||
|
||||
fun encode(report: CrashReport): String = buildString {
|
||||
fun field(key: String, value: String) {
|
||||
append(key)
|
||||
append('=')
|
||||
append(value.replace("\n", "\\n").replace("\r", "\\r"))
|
||||
append(FieldSep)
|
||||
}
|
||||
field("id", report.id)
|
||||
field("ts", report.timestampMillis.toString())
|
||||
field("install", report.installId)
|
||||
field("app", report.appVersion)
|
||||
field("platform", report.platform)
|
||||
field("type", report.exceptionType)
|
||||
field("message", report.exceptionMessage)
|
||||
field("stack", report.stackTrace)
|
||||
field("diag", if (report.diagnosticsEnabledAtCapture) "1" else "0")
|
||||
field("schema", report.schemaVersion.toString())
|
||||
field(
|
||||
"crumbs",
|
||||
report.breadcrumbs.joinToString(RecordSep) { crumb ->
|
||||
listOf(
|
||||
crumb.timestampMillis.toString(),
|
||||
crumb.name,
|
||||
crumb.properties.entries.joinToString(",") { "${it.key}:${it.value}" },
|
||||
).joinToString("|")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun decode(raw: String): CrashReport? {
|
||||
if (raw.isBlank()) return null
|
||||
val map = linkedMapOf<String, String>()
|
||||
for (part in raw.split(FieldSep)) {
|
||||
if (part.isEmpty()) continue
|
||||
val eq = part.indexOf('=')
|
||||
if (eq <= 0) continue
|
||||
val key = part.substring(0, eq)
|
||||
val value = part.substring(eq + 1)
|
||||
.replace("\\n", "\n")
|
||||
.replace("\\r", "\r")
|
||||
map[key] = value
|
||||
}
|
||||
val id = map["id"] ?: return null
|
||||
val crumbs = map["crumbs"].orEmpty()
|
||||
.split(RecordSep)
|
||||
.filter { it.isNotBlank() }
|
||||
.mapNotNull { entry ->
|
||||
val pieces = entry.split('|', limit = 3)
|
||||
if (pieces.size < 2) return@mapNotNull null
|
||||
val ts = pieces[0].toLongOrNull() ?: return@mapNotNull null
|
||||
val name = pieces[1]
|
||||
val props = if (pieces.size > 2 && pieces[2].isNotBlank()) {
|
||||
pieces[2].split(',').mapNotNull { kv ->
|
||||
val colon = kv.indexOf(':')
|
||||
if (colon <= 0) null
|
||||
else kv.substring(0, colon) to kv.substring(colon + 1)
|
||||
}.toMap()
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
Breadcrumb(name = name, timestampMillis = ts, properties = props)
|
||||
}
|
||||
return CrashReport(
|
||||
id = id,
|
||||
timestampMillis = map["ts"]?.toLongOrNull() ?: 0L,
|
||||
installId = map["install"].orEmpty(),
|
||||
appVersion = map["app"].orEmpty(),
|
||||
platform = map["platform"].orEmpty(),
|
||||
exceptionType = map["type"].orEmpty(),
|
||||
exceptionMessage = map["message"].orEmpty(),
|
||||
stackTrace = map["stack"].orEmpty(),
|
||||
breadcrumbs = crumbs,
|
||||
diagnosticsEnabledAtCapture = map["diag"] == "1",
|
||||
schemaVersion = map["schema"]?.toIntOrNull() ?: DiagnosticsSchemaVersion,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import com.vnidrop.app.logging.platformNowMillis
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Product telemetry: sparse events, gated by diagnostics opt-in.
|
||||
* Events are buffered and flushed in batches when transport is available.
|
||||
*/
|
||||
class TelemetryRecorder(
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
private val transport: DiagnosticsTransport,
|
||||
private val breadcrumbs: BreadcrumbBuffer,
|
||||
private val scope: CoroutineScope,
|
||||
private val maxBufferSize: Int = DefaultMaxBuffer,
|
||||
private val flushThreshold: Int = DefaultFlushThreshold,
|
||||
) {
|
||||
private val bufferMutex = Mutex()
|
||||
@Volatile private var buffer: List<TelemetryEvent> = emptyList()
|
||||
@Volatile private var enabled: Boolean = false
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
preferencesRepository.preferences
|
||||
.map { it.diagnosticsEnabled }
|
||||
.distinctUntilChanged()
|
||||
.collect { isEnabled ->
|
||||
enabled = isEnabled
|
||||
if (!isEnabled) {
|
||||
buffer = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun record(name: String, properties: Map<String, String> = emptyMap()) {
|
||||
if (!DiagnosticsBuildConfig.INCLUDED) return
|
||||
val redacted = LogRedactor.redactMap(properties)
|
||||
breadcrumbs.add(name, redacted)
|
||||
if (!enabled) return
|
||||
val event = TelemetryEvent(
|
||||
name = name.take(MaxNameLength),
|
||||
timestampMillis = platformNowMillis(),
|
||||
properties = redacted.mapValues { it.value.take(MaxPropertyValueLength) },
|
||||
)
|
||||
val next = (buffer + event).takeLast(maxBufferSize)
|
||||
buffer = next
|
||||
if (next.size >= flushThreshold) {
|
||||
scope.launch { flush() }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun flush(): Result<Unit> = bufferMutex.withLock {
|
||||
if (!enabled) {
|
||||
buffer = emptyList()
|
||||
return Result.success(Unit)
|
||||
}
|
||||
val batch = buffer
|
||||
if (batch.isEmpty()) return Result.success(Unit)
|
||||
buffer = emptyList()
|
||||
val result = transport.sendEvents(batch)
|
||||
if (result.isFailure) {
|
||||
// Re-queue on failure so a future transport can retry once online.
|
||||
buffer = (batch + buffer).takeLast(maxBufferSize)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun pendingCount(): Int = buffer.size
|
||||
|
||||
companion object {
|
||||
const val DefaultMaxBuffer = 100
|
||||
const val DefaultFlushThreshold = 20
|
||||
private const val MaxNameLength = 64
|
||||
private const val MaxPropertyValueLength = 128
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.vnidrop.app.PlatformEnvironment
|
||||
import com.vnidrop.app.AppDependencies
|
||||
import com.vnidrop.app.AppGraph
|
||||
import com.vnidrop.app.core.CoreGateway
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
|
||||
import com.vnidrop.app.logging.AppLogger
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||
@@ -36,12 +37,14 @@ class AppViewModel(
|
||||
private val repository: CoreGateway,
|
||||
preferencesRepository: PreferencesRepository,
|
||||
private val messages: UiMessageController,
|
||||
private val diagnostics: DiagnosticsCoordinator? = null,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(AppState())
|
||||
val state: StateFlow<AppState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
AppLogger.info("lifecycle", "app started", mapOf("platform" to environment.name))
|
||||
diagnostics?.record("app_open", mapOf("platform" to environment.name, "version" to environment.appVersion))
|
||||
viewModelScope.launch {
|
||||
repository.initialize(environment.defaultCoreDataDir).onFailure(messages::error)
|
||||
}
|
||||
@@ -54,5 +57,6 @@ class AppViewModel(
|
||||
|
||||
fun selectDestination(destination: AppDestination) {
|
||||
_state.update { it.copy(destination = destination) }
|
||||
diagnostics?.record("nav_select", mapOf("destination" to destination.name))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.about_bug_report
|
||||
@@ -12,13 +13,21 @@ import vnidrop.shared.generated.resources.about_title
|
||||
import vnidrop.shared.generated.resources.battery_level_title
|
||||
import vnidrop.shared.generated.resources.device_model_title
|
||||
import vnidrop.shared.generated.resources.device_name_title
|
||||
import vnidrop.shared.generated.resources.diagnostics_description
|
||||
import vnidrop.shared.generated.resources.diagnostics_title
|
||||
import vnidrop.shared.generated.resources.network_title
|
||||
import vnidrop.shared.generated.resources.os_version_title
|
||||
import vnidrop.shared.generated.resources.value_unavailable
|
||||
import vnidrop.shared.generated.resources.version_title
|
||||
|
||||
@Composable
|
||||
internal fun AboutSettings(state: SettingsState, onBack: () -> Unit, showBack: Boolean) {
|
||||
internal fun AboutSettings(
|
||||
state: SettingsState,
|
||||
onDiagnosticsChanged: (Boolean) -> Unit,
|
||||
onReportBug: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
showBack: Boolean,
|
||||
) {
|
||||
val unavailable = stringResource(Res.string.value_unavailable)
|
||||
val info = state.deviceInfo
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
@@ -29,11 +38,23 @@ internal fun AboutSettings(state: SettingsState, onBack: () -> Unit, showBack: B
|
||||
title = stringResource(Res.string.about_privacy),
|
||||
iconTone = SettingsIconTone.Neutral,
|
||||
)
|
||||
if (DiagnosticsBuildConfig.INCLUDED) {
|
||||
SettingsDivider()
|
||||
SettingsToggleRow(
|
||||
icon = SettingsIcons.Info,
|
||||
title = stringResource(Res.string.diagnostics_title),
|
||||
description = stringResource(Res.string.diagnostics_description),
|
||||
checked = state.diagnosticsEnabled,
|
||||
enabled = true,
|
||||
onCheckedChange = onDiagnosticsChanged,
|
||||
)
|
||||
}
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
icon = SettingsIcons.Bug,
|
||||
title = stringResource(Res.string.about_bug_report),
|
||||
iconTone = SettingsIconTone.Neutral,
|
||||
onClick = onReportBug,
|
||||
)
|
||||
}
|
||||
SettingsGroup {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.vnidrop.app.feature.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.ui.components.Field
|
||||
import com.vnidrop.app.ui.components.PrimaryButton
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.about_bug_report
|
||||
import vnidrop.shared.generated.resources.bug_report_contact_label
|
||||
import vnidrop.shared.generated.resources.bug_report_description
|
||||
import vnidrop.shared.generated.resources.bug_report_device_section
|
||||
import vnidrop.shared.generated.resources.bug_report_expected_label
|
||||
import vnidrop.shared.generated.resources.bug_report_include_logs
|
||||
import vnidrop.shared.generated.resources.bug_report_include_logs_description
|
||||
import vnidrop.shared.generated.resources.bug_report_logs_size
|
||||
import vnidrop.shared.generated.resources.bug_report_steps_label
|
||||
import vnidrop.shared.generated.resources.bug_report_submit
|
||||
import vnidrop.shared.generated.resources.bug_report_submitting
|
||||
import vnidrop.shared.generated.resources.bug_report_what_label
|
||||
import vnidrop.shared.generated.resources.device_model_title
|
||||
import vnidrop.shared.generated.resources.device_name_title
|
||||
import vnidrop.shared.generated.resources.os_version_title
|
||||
import vnidrop.shared.generated.resources.value_unavailable
|
||||
import vnidrop.shared.generated.resources.version_title
|
||||
|
||||
@Composable
|
||||
internal fun BugReportSettings(
|
||||
state: SettingsState,
|
||||
onWhatChanged: (String) -> Unit,
|
||||
onExpectedChanged: (String) -> Unit,
|
||||
onStepsChanged: (String) -> Unit,
|
||||
onContactChanged: (String) -> Unit,
|
||||
onIncludeLogsChanged: (Boolean) -> Unit,
|
||||
onSubmit: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
showBack: Boolean,
|
||||
) {
|
||||
val unavailable = stringResource(Res.string.value_unavailable)
|
||||
val info = state.deviceInfo
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
SettingsTopBar(stringResource(Res.string.about_bug_report), onBack, showBack)
|
||||
Text(
|
||||
stringResource(Res.string.bug_report_description),
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Field(
|
||||
value = state.bugWhatHappened,
|
||||
onValueChange = onWhatChanged,
|
||||
label = stringResource(Res.string.bug_report_what_label),
|
||||
minLines = 3,
|
||||
enabled = !state.isSubmittingBugReport,
|
||||
)
|
||||
Field(
|
||||
value = state.bugExpected,
|
||||
onValueChange = onExpectedChanged,
|
||||
label = stringResource(Res.string.bug_report_expected_label),
|
||||
minLines = 2,
|
||||
enabled = !state.isSubmittingBugReport,
|
||||
)
|
||||
Field(
|
||||
value = state.bugSteps,
|
||||
onValueChange = onStepsChanged,
|
||||
label = stringResource(Res.string.bug_report_steps_label),
|
||||
minLines = 2,
|
||||
enabled = !state.isSubmittingBugReport,
|
||||
)
|
||||
Field(
|
||||
value = state.bugContact,
|
||||
onValueChange = onContactChanged,
|
||||
label = stringResource(Res.string.bug_report_contact_label),
|
||||
enabled = !state.isSubmittingBugReport,
|
||||
)
|
||||
SettingsGroup {
|
||||
SettingsToggleRow(
|
||||
icon = SettingsIcons.Document,
|
||||
title = stringResource(Res.string.bug_report_include_logs),
|
||||
description = stringResource(Res.string.bug_report_include_logs_description),
|
||||
checked = state.bugIncludeLogs,
|
||||
enabled = !state.isSubmittingBugReport,
|
||||
onCheckedChange = onIncludeLogsChanged,
|
||||
)
|
||||
if (state.bugIncludeLogs && state.bugLogPreviewBytes > 0) {
|
||||
SettingsDivider()
|
||||
InfoItem(
|
||||
stringResource(Res.string.bug_report_logs_size),
|
||||
formatByteSize(state.bugLogPreviewBytes),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(Res.string.bug_report_device_section),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
SettingsGroup {
|
||||
InfoItem(stringResource(Res.string.version_title), state.appVersion)
|
||||
SettingsDivider(startPadding = 16.dp)
|
||||
InfoItem(stringResource(Res.string.device_name_title), info?.deviceName.orUnavailable(unavailable))
|
||||
SettingsDivider(startPadding = 16.dp)
|
||||
InfoItem(stringResource(Res.string.device_model_title), info?.deviceModel.orUnavailable(unavailable))
|
||||
SettingsDivider(startPadding = 16.dp)
|
||||
InfoItem(stringResource(Res.string.os_version_title), info?.operatingSystem ?: unavailable)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
PrimaryButton(
|
||||
text = if (state.isSubmittingBugReport) {
|
||||
stringResource(Res.string.bug_report_submitting)
|
||||
} else {
|
||||
stringResource(Res.string.bug_report_submit)
|
||||
},
|
||||
onClick = onSubmit,
|
||||
enabled = !state.isSubmittingBugReport,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String?.orUnavailable(fallback: String): String =
|
||||
this?.takeIf(String::isNotBlank) ?: fallback
|
||||
|
||||
private fun formatByteSize(bytes: Int): String = when {
|
||||
bytes < 1024 -> "$bytes B"
|
||||
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
||||
else -> "${bytes / (1024 * 1024)} MB"
|
||||
}
|
||||
@@ -28,5 +28,12 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) {
|
||||
onResetFolder = viewModel::resetReceiveFolder,
|
||||
onNotificationsChanged = viewModel::setNotificationsEnabled,
|
||||
onOpenNotificationSettings = viewModel::openNotificationSettings,
|
||||
onDiagnosticsChanged = viewModel::setDiagnosticsEnabled,
|
||||
onBugWhatChanged = viewModel::setBugWhatHappened,
|
||||
onBugExpectedChanged = viewModel::setBugExpected,
|
||||
onBugStepsChanged = viewModel::setBugSteps,
|
||||
onBugContactChanged = viewModel::setBugContact,
|
||||
onBugIncludeLogsChanged = viewModel::setBugIncludeLogs,
|
||||
onSubmitBugReport = viewModel::submitBugReport,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ fun SettingsScreen(
|
||||
onResetFolder: () -> Unit,
|
||||
onNotificationsChanged: (Boolean) -> Unit,
|
||||
onOpenNotificationSettings: () -> Unit,
|
||||
onDiagnosticsChanged: (Boolean) -> Unit,
|
||||
onBugWhatChanged: (String) -> Unit,
|
||||
onBugExpectedChanged: (String) -> Unit,
|
||||
onBugStepsChanged: (String) -> Unit,
|
||||
onBugContactChanged: (String) -> Unit,
|
||||
onBugIncludeLogsChanged: (Boolean) -> Unit,
|
||||
onSubmitBugReport: () -> Unit,
|
||||
) {
|
||||
if (windowClass == WindowClass.Desktop) {
|
||||
Row(
|
||||
@@ -37,12 +44,20 @@ fun SettingsScreen(
|
||||
section = state.selectedSection.takeUnless { it == SettingsSection.Overview } ?: SettingsSection.Preferences,
|
||||
onBack = {},
|
||||
showBack = false,
|
||||
onSectionSelected = onSectionSelected,
|
||||
onUsernameChanged = onUsernameChanged,
|
||||
onThemeModeChanged = onThemeModeChanged,
|
||||
onChooseFolder = onChooseFolder,
|
||||
onResetFolder = onResetFolder,
|
||||
onNotificationsChanged = onNotificationsChanged,
|
||||
onOpenNotificationSettings = onOpenNotificationSettings,
|
||||
onDiagnosticsChanged = onDiagnosticsChanged,
|
||||
onBugWhatChanged = onBugWhatChanged,
|
||||
onBugExpectedChanged = onBugExpectedChanged,
|
||||
onBugStepsChanged = onBugStepsChanged,
|
||||
onBugContactChanged = onBugContactChanged,
|
||||
onBugIncludeLogsChanged = onBugIncludeLogsChanged,
|
||||
onSubmitBugReport = onSubmitBugReport,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -52,14 +67,30 @@ fun SettingsScreen(
|
||||
else -> SettingsSectionContent(
|
||||
state = state,
|
||||
section = state.selectedSection,
|
||||
onBack = { onSectionSelected(SettingsSection.Overview) },
|
||||
onBack = {
|
||||
onSectionSelected(
|
||||
if (state.selectedSection == SettingsSection.BugReport) {
|
||||
SettingsSection.About
|
||||
} else {
|
||||
SettingsSection.Overview
|
||||
},
|
||||
)
|
||||
},
|
||||
showBack = true,
|
||||
onSectionSelected = onSectionSelected,
|
||||
onUsernameChanged = onUsernameChanged,
|
||||
onThemeModeChanged = onThemeModeChanged,
|
||||
onChooseFolder = onChooseFolder,
|
||||
onResetFolder = onResetFolder,
|
||||
onNotificationsChanged = onNotificationsChanged,
|
||||
onOpenNotificationSettings = onOpenNotificationSettings,
|
||||
onDiagnosticsChanged = onDiagnosticsChanged,
|
||||
onBugWhatChanged = onBugWhatChanged,
|
||||
onBugExpectedChanged = onBugExpectedChanged,
|
||||
onBugStepsChanged = onBugStepsChanged,
|
||||
onBugContactChanged = onBugContactChanged,
|
||||
onBugIncludeLogsChanged = onBugIncludeLogsChanged,
|
||||
onSubmitBugReport = onSubmitBugReport,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -71,18 +102,43 @@ private fun SettingsSectionContent(
|
||||
section: SettingsSection,
|
||||
onBack: () -> Unit,
|
||||
showBack: Boolean,
|
||||
onSectionSelected: (SettingsSection) -> Unit,
|
||||
onUsernameChanged: (String) -> Unit,
|
||||
onThemeModeChanged: (ThemeMode) -> Unit,
|
||||
onChooseFolder: () -> Unit,
|
||||
onResetFolder: () -> Unit,
|
||||
onNotificationsChanged: (Boolean) -> Unit,
|
||||
onOpenNotificationSettings: () -> Unit,
|
||||
onDiagnosticsChanged: (Boolean) -> Unit,
|
||||
onBugWhatChanged: (String) -> Unit,
|
||||
onBugExpectedChanged: (String) -> Unit,
|
||||
onBugStepsChanged: (String) -> Unit,
|
||||
onBugContactChanged: (String) -> Unit,
|
||||
onBugIncludeLogsChanged: (Boolean) -> Unit,
|
||||
onSubmitBugReport: () -> Unit,
|
||||
) {
|
||||
when (section) {
|
||||
SettingsSection.Overview -> Unit
|
||||
SettingsSection.Preferences -> PreferencesSettings(state, onUsernameChanged, onChooseFolder, onResetFolder, onBack, showBack)
|
||||
SettingsSection.Appearance -> AppearanceSettings(state.themeMode, onThemeModeChanged, onBack, showBack)
|
||||
SettingsSection.Notifications -> NotificationSettings(state, onNotificationsChanged, onOpenNotificationSettings, onBack, showBack)
|
||||
SettingsSection.About -> AboutSettings(state, onBack, showBack)
|
||||
SettingsSection.About -> AboutSettings(
|
||||
state = state,
|
||||
onDiagnosticsChanged = onDiagnosticsChanged,
|
||||
onReportBug = { onSectionSelected(SettingsSection.BugReport) },
|
||||
onBack = onBack,
|
||||
showBack = showBack,
|
||||
)
|
||||
SettingsSection.BugReport -> BugReportSettings(
|
||||
state = state,
|
||||
onWhatChanged = onBugWhatChanged,
|
||||
onExpectedChanged = onBugExpectedChanged,
|
||||
onStepsChanged = onBugStepsChanged,
|
||||
onContactChanged = onBugContactChanged,
|
||||
onIncludeLogsChanged = onBugIncludeLogsChanged,
|
||||
onSubmit = onSubmitBugReport,
|
||||
onBack = onBack,
|
||||
showBack = showBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ import com.vnidrop.app.PlatformEnvironment
|
||||
import com.vnidrop.app.core.FileSystemService
|
||||
import com.vnidrop.app.core.FolderAccessStatus
|
||||
import com.vnidrop.app.core.ReceiveFolder
|
||||
import com.vnidrop.app.diagnostics.BugReportDraft
|
||||
import com.vnidrop.app.diagnostics.BugReportService
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
|
||||
import com.vnidrop.app.notifications.LocalNotificationService
|
||||
import com.vnidrop.app.notifications.NotificationPermission
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
@@ -27,7 +31,13 @@ import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.bug_report_missing_expected
|
||||
import vnidrop.shared.generated.resources.bug_report_missing_what
|
||||
import vnidrop.shared.generated.resources.bug_report_submit_failed
|
||||
import vnidrop.shared.generated.resources.bug_report_submitted
|
||||
import vnidrop.shared.generated.resources.button_open_settings
|
||||
import vnidrop.shared.generated.resources.diagnostics_disabled_message
|
||||
import vnidrop.shared.generated.resources.diagnostics_enabled_message
|
||||
import vnidrop.shared.generated.resources.notifications_enabled_message
|
||||
import vnidrop.shared.generated.resources.notifications_permission_denied
|
||||
import vnidrop.shared.generated.resources.notifications_settings_open_failed
|
||||
@@ -39,6 +49,7 @@ enum class SettingsSection {
|
||||
Appearance,
|
||||
Notifications,
|
||||
About,
|
||||
BugReport,
|
||||
}
|
||||
|
||||
data class SettingsState(
|
||||
@@ -50,9 +61,17 @@ data class SettingsState(
|
||||
val themeMode: ThemeMode = ThemeMode.System,
|
||||
val notificationsEnabled: Boolean = false,
|
||||
val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined,
|
||||
val diagnosticsEnabled: Boolean = false,
|
||||
val deviceInfo: DeviceInfo? = null,
|
||||
val appVersion: String = "",
|
||||
val isLoadingDeviceInfo: Boolean = false,
|
||||
val bugWhatHappened: String = "",
|
||||
val bugExpected: String = "",
|
||||
val bugSteps: String = "",
|
||||
val bugContact: String = "",
|
||||
val bugIncludeLogs: Boolean = true,
|
||||
val isSubmittingBugReport: Boolean = false,
|
||||
val bugLogPreviewBytes: Int = 0,
|
||||
)
|
||||
|
||||
sealed interface SettingsEffect {
|
||||
@@ -66,6 +85,8 @@ class SettingsViewModel(
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
private val notifications: LocalNotificationService,
|
||||
private val messages: UiMessageController,
|
||||
private val bugReports: BugReportService,
|
||||
private val diagnostics: DiagnosticsCoordinator? = null,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(SettingsState(appVersion = environment.appVersion))
|
||||
val state: StateFlow<SettingsState> = _state.asStateFlow()
|
||||
@@ -88,6 +109,7 @@ class SettingsViewModel(
|
||||
receiveFolder = preferences.receiveFolder,
|
||||
themeMode = preferences.themeMode,
|
||||
notificationsEnabled = preferences.notificationsEnabled,
|
||||
diagnosticsEnabled = preferences.diagnosticsEnabled,
|
||||
)
|
||||
}
|
||||
if (preferences.receiveFolder != previousFolder) {
|
||||
@@ -101,7 +123,13 @@ class SettingsViewModel(
|
||||
|
||||
fun selectSection(section: SettingsSection) {
|
||||
_state.update { it.copy(selectedSection = section) }
|
||||
if (section == SettingsSection.About) loadDeviceInfo()
|
||||
when (section) {
|
||||
SettingsSection.About, SettingsSection.BugReport -> {
|
||||
loadDeviceInfo()
|
||||
if (section == SettingsSection.BugReport) refreshBugLogPreview()
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun setUsername(value: String) {
|
||||
@@ -164,6 +192,90 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun setDiagnosticsEnabled(enabled: Boolean) {
|
||||
if (!DiagnosticsBuildConfig.INCLUDED) return
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setDiagnosticsEnabled(enabled)
|
||||
diagnostics?.record(
|
||||
if (enabled) "diagnostics_enabled" else "diagnostics_disabled",
|
||||
)
|
||||
messages.show(
|
||||
UiMessage(
|
||||
UiText.Resource(
|
||||
if (enabled) Res.string.diagnostics_enabled_message
|
||||
else Res.string.diagnostics_disabled_message,
|
||||
),
|
||||
UiMessageTone.Success,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setBugWhatHappened(value: String) = _state.update { it.copy(bugWhatHappened = value) }
|
||||
fun setBugExpected(value: String) = _state.update { it.copy(bugExpected = value) }
|
||||
fun setBugSteps(value: String) = _state.update { it.copy(bugSteps = value) }
|
||||
fun setBugContact(value: String) = _state.update { it.copy(bugContact = value) }
|
||||
fun setBugIncludeLogs(value: Boolean) = _state.update { it.copy(bugIncludeLogs = value) }
|
||||
|
||||
fun submitBugReport() {
|
||||
if (_state.value.isSubmittingBugReport) return
|
||||
viewModelScope.launch {
|
||||
val snapshot = _state.value
|
||||
val what = snapshot.bugWhatHappened.trim()
|
||||
val expected = snapshot.bugExpected.trim()
|
||||
if (what.isEmpty()) {
|
||||
messages.show(UiMessage(UiText.Resource(Res.string.bug_report_missing_what), UiMessageTone.Warning))
|
||||
return@launch
|
||||
}
|
||||
if (expected.isEmpty()) {
|
||||
messages.show(UiMessage(UiText.Resource(Res.string.bug_report_missing_expected), UiMessageTone.Warning))
|
||||
return@launch
|
||||
}
|
||||
_state.update { it.copy(isSubmittingBugReport = true) }
|
||||
try {
|
||||
val result = bugReports.submit(
|
||||
BugReportDraft(
|
||||
whatHappened = what,
|
||||
expected = expected,
|
||||
steps = snapshot.bugSteps,
|
||||
contact = snapshot.bugContact,
|
||||
includeLogs = snapshot.bugIncludeLogs,
|
||||
),
|
||||
deviceInfo = snapshot.deviceInfo,
|
||||
)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
diagnostics?.record("bug_report_submitted")
|
||||
_state.update {
|
||||
it.copy(
|
||||
isSubmittingBugReport = false,
|
||||
bugWhatHappened = "",
|
||||
bugExpected = "",
|
||||
bugSteps = "",
|
||||
bugContact = "",
|
||||
bugIncludeLogs = true,
|
||||
)
|
||||
}
|
||||
messages.show(
|
||||
UiMessage(UiText.Resource(Res.string.bug_report_submitted), UiMessageTone.Success),
|
||||
)
|
||||
selectSection(SettingsSection.About)
|
||||
},
|
||||
onFailure = {
|
||||
_state.update { it.copy(isSubmittingBugReport = false) }
|
||||
messages.show(
|
||||
UiMessage(UiText.Resource(Res.string.bug_report_submit_failed), UiMessageTone.Error),
|
||||
)
|
||||
},
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
if (error is CancellationException) throw error
|
||||
_state.update { it.copy(isSubmittingBugReport = false) }
|
||||
messages.show(UiMessage(UiText.Resource(Res.string.bug_report_submit_failed), UiMessageTone.Error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openNotificationSettings() {
|
||||
viewModelScope.launch {
|
||||
enableNotificationsAfterSettings = true
|
||||
@@ -212,6 +324,13 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshBugLogPreview() {
|
||||
viewModelScope.launch {
|
||||
val bytes = runCatching { bugReports.previewLogBytes() }.getOrDefault(0)
|
||||
_state.update { it.copy(bugLogPreviewBytes = bytes) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun validateFolder(folder: ReceiveFolder) {
|
||||
_state.update { it.copy(isValidatingFolder = true) }
|
||||
val status = fileSystemService.validateReceiveFolder(folder)
|
||||
|
||||
@@ -31,6 +31,8 @@ interface PlatformLogStore {
|
||||
val logDirectory: String
|
||||
fun append(line: String)
|
||||
fun listLogFiles(): List<LogFileInfo>
|
||||
/** Newest log content, up to [maxBytes], from the active file then rotated tails if needed. */
|
||||
fun readLatest(maxBytes: Long): String
|
||||
}
|
||||
|
||||
expect fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore
|
||||
@@ -72,6 +74,9 @@ object AppLogger {
|
||||
fun listLogFiles(): List<LogFileInfo> =
|
||||
store?.listLogFiles().orEmpty()
|
||||
|
||||
fun readLatestLogs(maxBytes: Long = DefaultBugReportLogBytes): String =
|
||||
store?.readLatest(maxBytes).orEmpty()
|
||||
|
||||
private fun write(level: AppLogLevel, scope: String, message: String, fields: Map<String, String>) {
|
||||
val line = buildString {
|
||||
append(platformNowMillis())
|
||||
@@ -89,6 +94,8 @@ object AppLogger {
|
||||
}
|
||||
store?.append(line)
|
||||
}
|
||||
|
||||
const val DefaultBugReportLogBytes: Long = 256 * 1024
|
||||
}
|
||||
|
||||
private fun String.sanitizeLogValue(): String =
|
||||
|
||||
@@ -10,8 +10,10 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import com.vnidrop.app.core.ReceiveFolder
|
||||
import com.vnidrop.app.core.ReceiveFolderKind
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import com.vnidrop.app.util.randomUuidString
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import okio.Path.Companion.toPath
|
||||
|
||||
@@ -20,6 +22,10 @@ data class AppPreferences(
|
||||
val receiveFolder: ReceiveFolder,
|
||||
val themeMode: ThemeMode,
|
||||
val notificationsEnabled: Boolean,
|
||||
/** Master opt-in for automatic telemetry + crash upload. Bug reports remain available always. */
|
||||
val diagnosticsEnabled: Boolean = false,
|
||||
/** Stable anonymous install id; never an account or advertising id. */
|
||||
val diagnosticsInstallId: String = "",
|
||||
)
|
||||
|
||||
class AppPreferencesDefaults(
|
||||
@@ -27,6 +33,7 @@ class AppPreferencesDefaults(
|
||||
val receiveFolder: ReceiveFolder,
|
||||
val themeMode: ThemeMode,
|
||||
val notificationsEnabled: Boolean = false,
|
||||
val diagnosticsEnabled: Boolean = false,
|
||||
)
|
||||
|
||||
interface PreferencesRepository {
|
||||
@@ -36,6 +43,9 @@ interface PreferencesRepository {
|
||||
suspend fun resetReceiveFolder()
|
||||
suspend fun setThemeMode(mode: ThemeMode)
|
||||
suspend fun setNotificationsEnabled(enabled: Boolean)
|
||||
suspend fun setDiagnosticsEnabled(enabled: Boolean)
|
||||
/** Ensures a durable install id exists and returns it. */
|
||||
suspend fun ensureDiagnosticsInstallId(): String
|
||||
}
|
||||
|
||||
class AppPreferencesRepository(
|
||||
@@ -50,6 +60,8 @@ class AppPreferencesRepository(
|
||||
receiveFolder = resolveReceiveFolder(prefs, defaults.receiveFolder),
|
||||
themeMode = prefs[PreferenceKeys.ThemeMode]?.let { themeModeOrNull(it) } ?: defaults.themeMode,
|
||||
notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled,
|
||||
diagnosticsEnabled = prefs[PreferenceKeys.DiagnosticsEnabled] ?: defaults.diagnosticsEnabled,
|
||||
diagnosticsInstallId = prefs[PreferenceKeys.DiagnosticsInstallId].orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -82,6 +94,24 @@ class AppPreferencesRepository(
|
||||
prefs[PreferenceKeys.NotificationsEnabled] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setDiagnosticsEnabled(enabled: Boolean) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[PreferenceKeys.DiagnosticsEnabled] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun ensureDiagnosticsInstallId(): String {
|
||||
val existing = preferences.first().diagnosticsInstallId
|
||||
if (existing.isNotBlank()) return existing
|
||||
val created = randomUuidString()
|
||||
dataStore.edit { prefs ->
|
||||
if (prefs[PreferenceKeys.DiagnosticsInstallId].isNullOrBlank()) {
|
||||
prefs[PreferenceKeys.DiagnosticsInstallId] = created
|
||||
}
|
||||
}
|
||||
return preferences.first().diagnosticsInstallId.ifBlank { created }
|
||||
}
|
||||
}
|
||||
|
||||
fun createAppPreferencesDataStore(appDataDir: String): DataStore<Preferences> =
|
||||
@@ -96,6 +126,8 @@ private object PreferenceKeys {
|
||||
val ReceiveFolderDisplayName = stringPreferencesKey("receive_folder_display_name")
|
||||
val ThemeMode = stringPreferencesKey("theme_mode")
|
||||
val NotificationsEnabled = booleanPreferencesKey("notifications_enabled")
|
||||
val DiagnosticsEnabled = booleanPreferencesKey("diagnostics_enabled")
|
||||
val DiagnosticsInstallId = stringPreferencesKey("diagnostics_install_id")
|
||||
}
|
||||
|
||||
private fun resolveReceiveFolder(prefs: Preferences, defaults: ReceiveFolder): ReceiveFolder {
|
||||
|
||||
20
shared/src/commonMain/kotlin/com/vnidrop/app/util/Uuid.kt
Normal file
20
shared/src/commonMain/kotlin/com/vnidrop/app/util/Uuid.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.vnidrop.app.util
|
||||
|
||||
import kotlin.random.Random
|
||||
|
||||
/** RFC 4122 version-4 UUID string without depending on java.util.UUID in commonMain. */
|
||||
fun randomUuidString(random: Random = Random.Default): String {
|
||||
val bytes = ByteArray(16)
|
||||
random.nextBytes(bytes)
|
||||
bytes[6] = ((bytes[6].toInt() and 0x0f) or 0x40).toByte()
|
||||
bytes[8] = ((bytes[8].toInt() and 0x3f) or 0x80).toByte()
|
||||
return buildString(36) {
|
||||
bytes.forEachIndexed { index, byte ->
|
||||
if (index == 4 || index == 6 || index == 8 || index == 10) append('-')
|
||||
append(HEX[(byte.toInt() ushr 4) and 0x0f])
|
||||
append(HEX[byte.toInt() and 0x0f])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val HEX = "0123456789abcdef".toCharArray()
|
||||
Reference in New Issue
Block a user