mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +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:
@@ -12,3 +12,9 @@ android.newDsl=false
|
|||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
android.sourceset.disallowProvider=false
|
android.sourceset.disallowProvider=false
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
|
||||||
|
# VniDrop: compile-time diagnostics/telemetry product surface.
|
||||||
|
# false → no Share-diagnostics toggle, no telemetry or crash auto-upload stack.
|
||||||
|
# Bug report UI remains available (user-initiated).
|
||||||
|
# Override per build: ./gradlew … -Pvnidrop.diagnostics.included=false
|
||||||
|
vnidrop.diagnostics.included=true
|
||||||
|
|||||||
@@ -37,6 +37,39 @@ plugins {
|
|||||||
alias(libs.plugins.kotlinAtomicfu)
|
alias(libs.plugins.kotlinAtomicfu)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compile-time switch (gradle.properties or -Pvnidrop.diagnostics.included=false).
|
||||||
|
// false: no Share-diagnostics toggle, no telemetry/crash auto-upload stack.
|
||||||
|
val diagnosticsIncluded: Boolean =
|
||||||
|
(findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: true
|
||||||
|
|
||||||
|
val diagnosticsBuildConfigDir = layout.buildDirectory.dir("generated/diagnostics/commonMain/kotlin")
|
||||||
|
val generateDiagnosticsBuildConfig by tasks.registering {
|
||||||
|
group = "build"
|
||||||
|
description = "Generates DiagnosticsBuildConfig from vnidrop.diagnostics.included"
|
||||||
|
val outputDir = diagnosticsBuildConfigDir
|
||||||
|
val included = diagnosticsIncluded
|
||||||
|
inputs.property("vnidrop.diagnostics.included", included)
|
||||||
|
outputs.dir(outputDir)
|
||||||
|
doLast {
|
||||||
|
val packageDir = outputDir.get().asFile.resolve("com/vnidrop/app/diagnostics")
|
||||||
|
packageDir.mkdirs()
|
||||||
|
packageDir.resolve("DiagnosticsBuildConfig.kt").writeText(
|
||||||
|
"""
|
||||||
|
|package com.vnidrop.app.diagnostics
|
||||||
|
|
|
||||||
|
|/**
|
||||||
|
| * Generated by shared/build.gradle.kts.
|
||||||
|
| * Override with `-Pvnidrop.diagnostics.included=false` or gradle.properties.
|
||||||
|
| */
|
||||||
|
|object DiagnosticsBuildConfig {
|
||||||
|
| const val INCLUDED: Boolean = $included
|
||||||
|
|}
|
||||||
|
|
|
||||||
|
""".trimMargin(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
if (GobleyHost.current.platform == GobleyHost.Platform.MacOS) {
|
if (GobleyHost.current.platform == GobleyHost.Platform.MacOS) {
|
||||||
listOf(
|
listOf(
|
||||||
@@ -59,6 +92,9 @@ kotlin {
|
|||||||
jvm()
|
jvm()
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
|
commonMain {
|
||||||
|
kotlin.srcDir(files(diagnosticsBuildConfigDir).builtBy(generateDiagnosticsBuildConfig))
|
||||||
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.vnidrop.app.diagnostics
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
|
actual fun createPendingCrashStore(appDataDir: String): PendingCrashStore =
|
||||||
|
AndroidPendingCrashStore(appDataDir)
|
||||||
|
|
||||||
|
private class AndroidPendingCrashStore(
|
||||||
|
appDataDir: String,
|
||||||
|
) : PendingCrashStore {
|
||||||
|
private val directory = File(appDataDir, "diagnostics/crashes")
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun write(report: CrashReport) {
|
||||||
|
directory.mkdirs()
|
||||||
|
File(directory, "${report.id}.crash").writeText(CrashReportCodec.encode(report), StandardCharsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun list(): List<CrashReport> {
|
||||||
|
if (!directory.isDirectory) return emptyList()
|
||||||
|
return directory
|
||||||
|
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
|
||||||
|
.orEmpty()
|
||||||
|
.sortedByDescending { it.lastModified() }
|
||||||
|
.mapNotNull { file ->
|
||||||
|
runCatching { CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8)) }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun delete(id: String) {
|
||||||
|
File(directory, "$id.crash").delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.vnidrop.app.diagnostics
|
||||||
|
|
||||||
|
actual fun installPlatformCrashHook(onCrash: (Throwable) -> Unit) {
|
||||||
|
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
|
runCatching { onCrash(throwable) }
|
||||||
|
previous?.uncaughtException(thread, throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.vnidrop.app.logging
|
package com.vnidrop.app.logging
|
||||||
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.RandomAccessFile
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore =
|
actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore =
|
||||||
@@ -37,6 +38,32 @@ private class AndroidPlatformLogStore(
|
|||||||
.map { file -> LogFileInfo(file.name, file.absolutePath, file.length(), file.lastModified()) }
|
.map { file -> LogFileInfo(file.name, file.absolutePath, file.length(), file.lastModified()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun readLatest(maxBytes: Long): String {
|
||||||
|
if (maxBytes <= 0) return ""
|
||||||
|
directory.mkdirs()
|
||||||
|
val files = listOf(activeFile) +
|
||||||
|
(1..policy.maxFiles).map { File(directory, "app.$it.log") }
|
||||||
|
val chunks = ArrayList<ByteArray>()
|
||||||
|
var remaining = maxBytes
|
||||||
|
for (file in files) {
|
||||||
|
if (remaining <= 0 || !file.isFile) continue
|
||||||
|
val slice = readTail(file, remaining)
|
||||||
|
if (slice.isEmpty()) continue
|
||||||
|
chunks.add(0, slice)
|
||||||
|
remaining -= slice.size.toLong()
|
||||||
|
}
|
||||||
|
if (chunks.isEmpty()) return ""
|
||||||
|
val total = chunks.sumOf { it.size }
|
||||||
|
val out = ByteArray(total)
|
||||||
|
var offset = 0
|
||||||
|
for (chunk in chunks) {
|
||||||
|
chunk.copyInto(out, offset)
|
||||||
|
offset += chunk.size
|
||||||
|
}
|
||||||
|
return String(out, StandardCharsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
private fun rotate() {
|
private fun rotate() {
|
||||||
if (policy.maxFiles == 0) {
|
if (policy.maxFiles == 0) {
|
||||||
activeFile.delete()
|
activeFile.delete()
|
||||||
@@ -54,3 +81,22 @@ private class AndroidPlatformLogStore(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun readTail(file: File, maxBytes: Long): ByteArray {
|
||||||
|
if (!file.isFile || file.length() == 0L || maxBytes <= 0) return ByteArray(0)
|
||||||
|
val length = file.length()
|
||||||
|
val start = (length - maxBytes).coerceAtLeast(0L)
|
||||||
|
val size = (length - start).toInt()
|
||||||
|
RandomAccessFile(file, "r").use { raf ->
|
||||||
|
raf.seek(start)
|
||||||
|
val bytes = ByteArray(size)
|
||||||
|
raf.readFully(bytes)
|
||||||
|
if (start == 0L) return bytes
|
||||||
|
val newline = bytes.indexOf('\n'.code.toByte())
|
||||||
|
return if (newline in 0 until bytes.lastIndex) {
|
||||||
|
bytes.copyOfRange(newline + 1, bytes.size)
|
||||||
|
} else {
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -155,6 +155,25 @@
|
|||||||
<string name="about_title">About</string>
|
<string name="about_title">About</string>
|
||||||
<string name="about_privacy">Privacy policy</string>
|
<string name="about_privacy">Privacy policy</string>
|
||||||
<string name="about_bug_report">Report a bug</string>
|
<string name="about_bug_report">Report a bug</string>
|
||||||
|
<string name="diagnostics_title">Share diagnostics</string>
|
||||||
|
<string name="diagnostics_description">Send anonymous crash reports and usage events so we can improve VniDrop. You can turn this off anytime. Tickets, file paths, and transfer contents are never included.</string>
|
||||||
|
<string name="diagnostics_enabled_message">Diagnostics sharing is on.</string>
|
||||||
|
<string name="diagnostics_disabled_message">Diagnostics sharing is off.</string>
|
||||||
|
<string name="bug_report_description">Tell us what went wrong. We attach device info and optional recent logs (with sensitive values redacted).</string>
|
||||||
|
<string name="bug_report_what_label">What happened?</string>
|
||||||
|
<string name="bug_report_expected_label">What did you expect?</string>
|
||||||
|
<string name="bug_report_steps_label">Steps to reproduce (optional)</string>
|
||||||
|
<string name="bug_report_contact_label">Contact email (optional)</string>
|
||||||
|
<string name="bug_report_include_logs">Include recent logs</string>
|
||||||
|
<string name="bug_report_include_logs_description">Helps us diagnose the issue. Sensitive values are redacted before sending.</string>
|
||||||
|
<string name="bug_report_logs_size">Log attachment size</string>
|
||||||
|
<string name="bug_report_device_section">Device information</string>
|
||||||
|
<string name="bug_report_submit">Submit report</string>
|
||||||
|
<string name="bug_report_submitting">Submitting…</string>
|
||||||
|
<string name="bug_report_submitted">Thanks — your bug report was recorded.</string>
|
||||||
|
<string name="bug_report_submit_failed">Could not submit the bug report. Try again later.</string>
|
||||||
|
<string name="bug_report_missing_what">Please describe what happened.</string>
|
||||||
|
<string name="bug_report_missing_expected">Please describe what you expected.</string>
|
||||||
<string name="version_title">App version</string>
|
<string name="version_title">App version</string>
|
||||||
<string name="device_name_title">Device name</string>
|
<string name="device_name_title">Device name</string>
|
||||||
<string name="device_model_title">Device model</string>
|
<string name="device_model_title">Device model</string>
|
||||||
|
|||||||
@@ -57,7 +57,13 @@ fun App(
|
|||||||
val graph = graphHolder.graph
|
val graph = graphHolder.graph
|
||||||
|
|
||||||
val appViewModel = viewModel {
|
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 {
|
val sendViewModel = viewModel {
|
||||||
SendViewModel(
|
SendViewModel(
|
||||||
@@ -79,6 +85,8 @@ fun App(
|
|||||||
graph.preferencesRepository,
|
graph.preferencesRepository,
|
||||||
dependencies.localNotificationService,
|
dependencies.localNotificationService,
|
||||||
graph.messages,
|
graph.messages,
|
||||||
|
graph.diagnostics.bugReports,
|
||||||
|
graph.diagnostics,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val appState by appViewModel.state.collectAsStateWithLifecycle()
|
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.CoreGateway
|
||||||
import com.vnidrop.app.core.CoreRepository
|
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.approvals.ApprovalCoordinator
|
||||||
import com.vnidrop.app.feature.send.AppFilePreviewRepository
|
import com.vnidrop.app.feature.send.AppFilePreviewRepository
|
||||||
import com.vnidrop.app.feature.send.createPlatformPreviewStore
|
import com.vnidrop.app.feature.send.createPlatformPreviewStore
|
||||||
@@ -34,8 +36,17 @@ class AppGraph(
|
|||||||
receiveFolder = dependencies.fileSystemService.defaultReceiveFolder(),
|
receiveFolder = dependencies.fileSystemService.defaultReceiveFolder(),
|
||||||
themeMode = ThemeMode.System,
|
themeMode = ThemeMode.System,
|
||||||
notificationsEnabled = false,
|
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(
|
val approvalCoordinator = ApprovalCoordinator(
|
||||||
repository = coreRepository,
|
repository = coreRepository,
|
||||||
preferencesRepository = preferencesRepository,
|
preferencesRepository = preferencesRepository,
|
||||||
@@ -47,6 +58,7 @@ class AppGraph(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
AppLogger.initialize(dependencies.environment.defaultCoreDataDir)
|
AppLogger.initialize(dependencies.environment.defaultCoreDataDir)
|
||||||
|
diagnostics.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun close() {
|
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.AppDependencies
|
||||||
import com.vnidrop.app.AppGraph
|
import com.vnidrop.app.AppGraph
|
||||||
import com.vnidrop.app.core.CoreGateway
|
import com.vnidrop.app.core.CoreGateway
|
||||||
|
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
|
||||||
import com.vnidrop.app.logging.AppLogger
|
import com.vnidrop.app.logging.AppLogger
|
||||||
import com.vnidrop.app.preferences.PreferencesRepository
|
import com.vnidrop.app.preferences.PreferencesRepository
|
||||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||||
@@ -36,12 +37,14 @@ class AppViewModel(
|
|||||||
private val repository: CoreGateway,
|
private val repository: CoreGateway,
|
||||||
preferencesRepository: PreferencesRepository,
|
preferencesRepository: PreferencesRepository,
|
||||||
private val messages: UiMessageController,
|
private val messages: UiMessageController,
|
||||||
|
private val diagnostics: DiagnosticsCoordinator? = null,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(AppState())
|
private val _state = MutableStateFlow(AppState())
|
||||||
val state: StateFlow<AppState> = _state.asStateFlow()
|
val state: StateFlow<AppState> = _state.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
AppLogger.info("lifecycle", "app started", mapOf("platform" to environment.name))
|
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 {
|
viewModelScope.launch {
|
||||||
repository.initialize(environment.defaultCoreDataDir).onFailure(messages::error)
|
repository.initialize(environment.defaultCoreDataDir).onFailure(messages::error)
|
||||||
}
|
}
|
||||||
@@ -54,5 +57,6 @@ class AppViewModel(
|
|||||||
|
|
||||||
fun selectDestination(destination: AppDestination) {
|
fun selectDestination(destination: AppDestination) {
|
||||||
_state.update { it.copy(destination = destination) }
|
_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.foundation.layout.Column
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import vnidrop.shared.generated.resources.Res
|
import vnidrop.shared.generated.resources.Res
|
||||||
import vnidrop.shared.generated.resources.about_bug_report
|
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.battery_level_title
|
||||||
import vnidrop.shared.generated.resources.device_model_title
|
import vnidrop.shared.generated.resources.device_model_title
|
||||||
import vnidrop.shared.generated.resources.device_name_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.network_title
|
||||||
import vnidrop.shared.generated.resources.os_version_title
|
import vnidrop.shared.generated.resources.os_version_title
|
||||||
import vnidrop.shared.generated.resources.value_unavailable
|
import vnidrop.shared.generated.resources.value_unavailable
|
||||||
import vnidrop.shared.generated.resources.version_title
|
import vnidrop.shared.generated.resources.version_title
|
||||||
|
|
||||||
@Composable
|
@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 unavailable = stringResource(Res.string.value_unavailable)
|
||||||
val info = state.deviceInfo
|
val info = state.deviceInfo
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
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),
|
title = stringResource(Res.string.about_privacy),
|
||||||
iconTone = SettingsIconTone.Neutral,
|
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()
|
SettingsDivider()
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
icon = SettingsIcons.Bug,
|
icon = SettingsIcons.Bug,
|
||||||
title = stringResource(Res.string.about_bug_report),
|
title = stringResource(Res.string.about_bug_report),
|
||||||
iconTone = SettingsIconTone.Neutral,
|
iconTone = SettingsIconTone.Neutral,
|
||||||
|
onClick = onReportBug,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
SettingsGroup {
|
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,
|
onResetFolder = viewModel::resetReceiveFolder,
|
||||||
onNotificationsChanged = viewModel::setNotificationsEnabled,
|
onNotificationsChanged = viewModel::setNotificationsEnabled,
|
||||||
onOpenNotificationSettings = viewModel::openNotificationSettings,
|
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,
|
onResetFolder: () -> Unit,
|
||||||
onNotificationsChanged: (Boolean) -> Unit,
|
onNotificationsChanged: (Boolean) -> Unit,
|
||||||
onOpenNotificationSettings: () -> 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) {
|
if (windowClass == WindowClass.Desktop) {
|
||||||
Row(
|
Row(
|
||||||
@@ -37,12 +44,20 @@ fun SettingsScreen(
|
|||||||
section = state.selectedSection.takeUnless { it == SettingsSection.Overview } ?: SettingsSection.Preferences,
|
section = state.selectedSection.takeUnless { it == SettingsSection.Overview } ?: SettingsSection.Preferences,
|
||||||
onBack = {},
|
onBack = {},
|
||||||
showBack = false,
|
showBack = false,
|
||||||
|
onSectionSelected = onSectionSelected,
|
||||||
onUsernameChanged = onUsernameChanged,
|
onUsernameChanged = onUsernameChanged,
|
||||||
onThemeModeChanged = onThemeModeChanged,
|
onThemeModeChanged = onThemeModeChanged,
|
||||||
onChooseFolder = onChooseFolder,
|
onChooseFolder = onChooseFolder,
|
||||||
onResetFolder = onResetFolder,
|
onResetFolder = onResetFolder,
|
||||||
onNotificationsChanged = onNotificationsChanged,
|
onNotificationsChanged = onNotificationsChanged,
|
||||||
onOpenNotificationSettings = onOpenNotificationSettings,
|
onOpenNotificationSettings = onOpenNotificationSettings,
|
||||||
|
onDiagnosticsChanged = onDiagnosticsChanged,
|
||||||
|
onBugWhatChanged = onBugWhatChanged,
|
||||||
|
onBugExpectedChanged = onBugExpectedChanged,
|
||||||
|
onBugStepsChanged = onBugStepsChanged,
|
||||||
|
onBugContactChanged = onBugContactChanged,
|
||||||
|
onBugIncludeLogsChanged = onBugIncludeLogsChanged,
|
||||||
|
onSubmitBugReport = onSubmitBugReport,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,14 +67,30 @@ fun SettingsScreen(
|
|||||||
else -> SettingsSectionContent(
|
else -> SettingsSectionContent(
|
||||||
state = state,
|
state = state,
|
||||||
section = state.selectedSection,
|
section = state.selectedSection,
|
||||||
onBack = { onSectionSelected(SettingsSection.Overview) },
|
onBack = {
|
||||||
|
onSectionSelected(
|
||||||
|
if (state.selectedSection == SettingsSection.BugReport) {
|
||||||
|
SettingsSection.About
|
||||||
|
} else {
|
||||||
|
SettingsSection.Overview
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
showBack = true,
|
showBack = true,
|
||||||
|
onSectionSelected = onSectionSelected,
|
||||||
onUsernameChanged = onUsernameChanged,
|
onUsernameChanged = onUsernameChanged,
|
||||||
onThemeModeChanged = onThemeModeChanged,
|
onThemeModeChanged = onThemeModeChanged,
|
||||||
onChooseFolder = onChooseFolder,
|
onChooseFolder = onChooseFolder,
|
||||||
onResetFolder = onResetFolder,
|
onResetFolder = onResetFolder,
|
||||||
onNotificationsChanged = onNotificationsChanged,
|
onNotificationsChanged = onNotificationsChanged,
|
||||||
onOpenNotificationSettings = onOpenNotificationSettings,
|
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,
|
section: SettingsSection,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
showBack: Boolean,
|
showBack: Boolean,
|
||||||
|
onSectionSelected: (SettingsSection) -> Unit,
|
||||||
onUsernameChanged: (String) -> Unit,
|
onUsernameChanged: (String) -> Unit,
|
||||||
onThemeModeChanged: (ThemeMode) -> Unit,
|
onThemeModeChanged: (ThemeMode) -> Unit,
|
||||||
onChooseFolder: () -> Unit,
|
onChooseFolder: () -> Unit,
|
||||||
onResetFolder: () -> Unit,
|
onResetFolder: () -> Unit,
|
||||||
onNotificationsChanged: (Boolean) -> Unit,
|
onNotificationsChanged: (Boolean) -> Unit,
|
||||||
onOpenNotificationSettings: () -> 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) {
|
when (section) {
|
||||||
SettingsSection.Overview -> Unit
|
SettingsSection.Overview -> Unit
|
||||||
SettingsSection.Preferences -> PreferencesSettings(state, onUsernameChanged, onChooseFolder, onResetFolder, onBack, showBack)
|
SettingsSection.Preferences -> PreferencesSettings(state, onUsernameChanged, onChooseFolder, onResetFolder, onBack, showBack)
|
||||||
SettingsSection.Appearance -> AppearanceSettings(state.themeMode, onThemeModeChanged, onBack, showBack)
|
SettingsSection.Appearance -> AppearanceSettings(state.themeMode, onThemeModeChanged, onBack, showBack)
|
||||||
SettingsSection.Notifications -> NotificationSettings(state, onNotificationsChanged, onOpenNotificationSettings, 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.FileSystemService
|
||||||
import com.vnidrop.app.core.FolderAccessStatus
|
import com.vnidrop.app.core.FolderAccessStatus
|
||||||
import com.vnidrop.app.core.ReceiveFolder
|
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.LocalNotificationService
|
||||||
import com.vnidrop.app.notifications.NotificationPermission
|
import com.vnidrop.app.notifications.NotificationPermission
|
||||||
import com.vnidrop.app.preferences.PreferencesRepository
|
import com.vnidrop.app.preferences.PreferencesRepository
|
||||||
@@ -27,7 +31,13 @@ import kotlinx.coroutines.flow.receiveAsFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import vnidrop.shared.generated.resources.Res
|
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.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_enabled_message
|
||||||
import vnidrop.shared.generated.resources.notifications_permission_denied
|
import vnidrop.shared.generated.resources.notifications_permission_denied
|
||||||
import vnidrop.shared.generated.resources.notifications_settings_open_failed
|
import vnidrop.shared.generated.resources.notifications_settings_open_failed
|
||||||
@@ -39,6 +49,7 @@ enum class SettingsSection {
|
|||||||
Appearance,
|
Appearance,
|
||||||
Notifications,
|
Notifications,
|
||||||
About,
|
About,
|
||||||
|
BugReport,
|
||||||
}
|
}
|
||||||
|
|
||||||
data class SettingsState(
|
data class SettingsState(
|
||||||
@@ -50,9 +61,17 @@ data class SettingsState(
|
|||||||
val themeMode: ThemeMode = ThemeMode.System,
|
val themeMode: ThemeMode = ThemeMode.System,
|
||||||
val notificationsEnabled: Boolean = false,
|
val notificationsEnabled: Boolean = false,
|
||||||
val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined,
|
val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined,
|
||||||
|
val diagnosticsEnabled: Boolean = false,
|
||||||
val deviceInfo: DeviceInfo? = null,
|
val deviceInfo: DeviceInfo? = null,
|
||||||
val appVersion: String = "",
|
val appVersion: String = "",
|
||||||
val isLoadingDeviceInfo: Boolean = false,
|
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 {
|
sealed interface SettingsEffect {
|
||||||
@@ -66,6 +85,8 @@ class SettingsViewModel(
|
|||||||
private val preferencesRepository: PreferencesRepository,
|
private val preferencesRepository: PreferencesRepository,
|
||||||
private val notifications: LocalNotificationService,
|
private val notifications: LocalNotificationService,
|
||||||
private val messages: UiMessageController,
|
private val messages: UiMessageController,
|
||||||
|
private val bugReports: BugReportService,
|
||||||
|
private val diagnostics: DiagnosticsCoordinator? = null,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(SettingsState(appVersion = environment.appVersion))
|
private val _state = MutableStateFlow(SettingsState(appVersion = environment.appVersion))
|
||||||
val state: StateFlow<SettingsState> = _state.asStateFlow()
|
val state: StateFlow<SettingsState> = _state.asStateFlow()
|
||||||
@@ -88,6 +109,7 @@ class SettingsViewModel(
|
|||||||
receiveFolder = preferences.receiveFolder,
|
receiveFolder = preferences.receiveFolder,
|
||||||
themeMode = preferences.themeMode,
|
themeMode = preferences.themeMode,
|
||||||
notificationsEnabled = preferences.notificationsEnabled,
|
notificationsEnabled = preferences.notificationsEnabled,
|
||||||
|
diagnosticsEnabled = preferences.diagnosticsEnabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (preferences.receiveFolder != previousFolder) {
|
if (preferences.receiveFolder != previousFolder) {
|
||||||
@@ -101,7 +123,13 @@ class SettingsViewModel(
|
|||||||
|
|
||||||
fun selectSection(section: SettingsSection) {
|
fun selectSection(section: SettingsSection) {
|
||||||
_state.update { it.copy(selectedSection = section) }
|
_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) {
|
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() {
|
fun openNotificationSettings() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
enableNotificationsAfterSettings = true
|
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) {
|
private suspend fun validateFolder(folder: ReceiveFolder) {
|
||||||
_state.update { it.copy(isValidatingFolder = true) }
|
_state.update { it.copy(isValidatingFolder = true) }
|
||||||
val status = fileSystemService.validateReceiveFolder(folder)
|
val status = fileSystemService.validateReceiveFolder(folder)
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ interface PlatformLogStore {
|
|||||||
val logDirectory: String
|
val logDirectory: String
|
||||||
fun append(line: String)
|
fun append(line: String)
|
||||||
fun listLogFiles(): List<LogFileInfo>
|
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
|
expect fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore
|
||||||
@@ -72,6 +74,9 @@ object AppLogger {
|
|||||||
fun listLogFiles(): List<LogFileInfo> =
|
fun listLogFiles(): List<LogFileInfo> =
|
||||||
store?.listLogFiles().orEmpty()
|
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>) {
|
private fun write(level: AppLogLevel, scope: String, message: String, fields: Map<String, String>) {
|
||||||
val line = buildString {
|
val line = buildString {
|
||||||
append(platformNowMillis())
|
append(platformNowMillis())
|
||||||
@@ -89,6 +94,8 @@ object AppLogger {
|
|||||||
}
|
}
|
||||||
store?.append(line)
|
store?.append(line)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const val DefaultBugReportLogBytes: Long = 256 * 1024
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.sanitizeLogValue(): String =
|
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.ReceiveFolder
|
||||||
import com.vnidrop.app.core.ReceiveFolderKind
|
import com.vnidrop.app.core.ReceiveFolderKind
|
||||||
import com.vnidrop.app.ui.theme.ThemeMode
|
import com.vnidrop.app.ui.theme.ThemeMode
|
||||||
|
import com.vnidrop.app.util.randomUuidString
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import okio.Path.Companion.toPath
|
import okio.Path.Companion.toPath
|
||||||
|
|
||||||
@@ -20,6 +22,10 @@ data class AppPreferences(
|
|||||||
val receiveFolder: ReceiveFolder,
|
val receiveFolder: ReceiveFolder,
|
||||||
val themeMode: ThemeMode,
|
val themeMode: ThemeMode,
|
||||||
val notificationsEnabled: Boolean,
|
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(
|
class AppPreferencesDefaults(
|
||||||
@@ -27,6 +33,7 @@ class AppPreferencesDefaults(
|
|||||||
val receiveFolder: ReceiveFolder,
|
val receiveFolder: ReceiveFolder,
|
||||||
val themeMode: ThemeMode,
|
val themeMode: ThemeMode,
|
||||||
val notificationsEnabled: Boolean = false,
|
val notificationsEnabled: Boolean = false,
|
||||||
|
val diagnosticsEnabled: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
interface PreferencesRepository {
|
interface PreferencesRepository {
|
||||||
@@ -36,6 +43,9 @@ interface PreferencesRepository {
|
|||||||
suspend fun resetReceiveFolder()
|
suspend fun resetReceiveFolder()
|
||||||
suspend fun setThemeMode(mode: ThemeMode)
|
suspend fun setThemeMode(mode: ThemeMode)
|
||||||
suspend fun setNotificationsEnabled(enabled: Boolean)
|
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(
|
class AppPreferencesRepository(
|
||||||
@@ -50,6 +60,8 @@ class AppPreferencesRepository(
|
|||||||
receiveFolder = resolveReceiveFolder(prefs, defaults.receiveFolder),
|
receiveFolder = resolveReceiveFolder(prefs, defaults.receiveFolder),
|
||||||
themeMode = prefs[PreferenceKeys.ThemeMode]?.let { themeModeOrNull(it) } ?: defaults.themeMode,
|
themeMode = prefs[PreferenceKeys.ThemeMode]?.let { themeModeOrNull(it) } ?: defaults.themeMode,
|
||||||
notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled,
|
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
|
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> =
|
fun createAppPreferencesDataStore(appDataDir: String): DataStore<Preferences> =
|
||||||
@@ -96,6 +126,8 @@ private object PreferenceKeys {
|
|||||||
val ReceiveFolderDisplayName = stringPreferencesKey("receive_folder_display_name")
|
val ReceiveFolderDisplayName = stringPreferencesKey("receive_folder_display_name")
|
||||||
val ThemeMode = stringPreferencesKey("theme_mode")
|
val ThemeMode = stringPreferencesKey("theme_mode")
|
||||||
val NotificationsEnabled = booleanPreferencesKey("notifications_enabled")
|
val NotificationsEnabled = booleanPreferencesKey("notifications_enabled")
|
||||||
|
val DiagnosticsEnabled = booleanPreferencesKey("diagnostics_enabled")
|
||||||
|
val DiagnosticsInstallId = stringPreferencesKey("diagnostics_install_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveReceiveFolder(prefs: Preferences, defaults: ReceiveFolder): ReceiveFolder {
|
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()
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package com.vnidrop.app.diagnostics
|
||||||
|
|
||||||
|
import com.vnidrop.app.DeviceInfo
|
||||||
|
import com.vnidrop.app.preferences.AppPreferences
|
||||||
|
import com.vnidrop.app.core.ReceiveFolder
|
||||||
|
import com.vnidrop.app.core.ReceiveFolderKind
|
||||||
|
import com.vnidrop.app.support.FakePreferencesRepository
|
||||||
|
import com.vnidrop.app.ui.theme.ThemeMode
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class DiagnosticsTest {
|
||||||
|
@Test
|
||||||
|
fun diagnosticsBuildConfigDefaultsToIncluded() {
|
||||||
|
// Production default is true; builds can override with -Pvnidrop.diagnostics.included=false.
|
||||||
|
assertTrue(DiagnosticsBuildConfig.INCLUDED)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun logRedactorScrubsTicketsPathsAndEndpointIds() {
|
||||||
|
val input = """
|
||||||
|
ticket=abcdefghijklmnopqrstuvwxyz012345
|
||||||
|
endpoint_id=peerABCDEFGHIJKLMNOP
|
||||||
|
path=/Users/me/secret/file.bin
|
||||||
|
uri=content://com.android.providers/downloads/1
|
||||||
|
ok=value
|
||||||
|
""".trimIndent()
|
||||||
|
val redacted = LogRedactor.redact(input)
|
||||||
|
assertFalse(redacted.contains("abcdefghijklmnopqrstuvwxyz012345"))
|
||||||
|
assertFalse(redacted.contains("peerABCDEFGHIJKLMNOP"))
|
||||||
|
assertFalse(redacted.contains("/users/me/secret/file.bin", ignoreCase = true))
|
||||||
|
assertFalse(redacted.contains("content://"))
|
||||||
|
assertTrue(redacted.contains("ok=value"))
|
||||||
|
assertTrue(redacted.contains("[redacted-ticket]"))
|
||||||
|
assertTrue(redacted.contains("[redacted-endpoint]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun breadcrumbBufferKeepsOnlyLatestEntries() {
|
||||||
|
val buffer = BreadcrumbBuffer(capacity = 3)
|
||||||
|
buffer.add("a")
|
||||||
|
buffer.add("b")
|
||||||
|
buffer.add("c")
|
||||||
|
buffer.add("d")
|
||||||
|
assertEquals(listOf("b", "c", "d"), buffer.snapshot().map { it.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun telemetryIgnoresEventsWhenDiagnosticsDisabled() = runTest {
|
||||||
|
val preferences = fakePrefs(diagnosticsEnabled = false)
|
||||||
|
val transport = RecordingDiagnosticsTransport()
|
||||||
|
val breadcrumbs = BreadcrumbBuffer()
|
||||||
|
val recorder = TelemetryRecorder(
|
||||||
|
preferencesRepository = preferences,
|
||||||
|
transport = transport,
|
||||||
|
breadcrumbs = breadcrumbs,
|
||||||
|
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||||
|
flushThreshold = 1,
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
recorder.record("app_open")
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(0, transport.events.size)
|
||||||
|
assertEquals(1, breadcrumbs.snapshot().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun telemetryBuffersAndFlushesWhenEnabled() = runTest {
|
||||||
|
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||||
|
val transport = RecordingDiagnosticsTransport()
|
||||||
|
val recorder = TelemetryRecorder(
|
||||||
|
preferencesRepository = preferences,
|
||||||
|
transport = transport,
|
||||||
|
breadcrumbs = BreadcrumbBuffer(),
|
||||||
|
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||||
|
flushThreshold = 2,
|
||||||
|
maxBufferSize = 10,
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
recorder.record("one")
|
||||||
|
recorder.record("two")
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(1, transport.events.size)
|
||||||
|
assertEquals(listOf("one", "two"), transport.events.single().map { it.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun telemetryClearsBufferWhenOptedOut() = runTest {
|
||||||
|
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||||
|
val transport = RecordingDiagnosticsTransport()
|
||||||
|
val recorder = TelemetryRecorder(
|
||||||
|
preferencesRepository = preferences,
|
||||||
|
transport = transport,
|
||||||
|
breadcrumbs = BreadcrumbBuffer(),
|
||||||
|
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||||
|
flushThreshold = 100,
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
recorder.record("queued")
|
||||||
|
assertEquals(1, recorder.pendingCount())
|
||||||
|
preferences.setDiagnosticsEnabled(false)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(0, recorder.pendingCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun crashCodecRoundTrips() {
|
||||||
|
val original = CrashReport(
|
||||||
|
id = "crash-1",
|
||||||
|
timestampMillis = 42L,
|
||||||
|
installId = "install",
|
||||||
|
appVersion = "1.0",
|
||||||
|
platform = "Test",
|
||||||
|
exceptionType = "IllegalStateException",
|
||||||
|
exceptionMessage = "boom\nline",
|
||||||
|
stackTrace = "stack\ntrace",
|
||||||
|
breadcrumbs = listOf(
|
||||||
|
Breadcrumb("open", 1L, mapOf("screen" to "send")),
|
||||||
|
),
|
||||||
|
diagnosticsEnabledAtCapture = true,
|
||||||
|
)
|
||||||
|
val decoded = CrashReportCodec.decode(CrashReportCodec.encode(original))
|
||||||
|
assertEquals(original, decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun crashReporterPersistsAndFlushesOnlyOptedInCrashes() = runTest {
|
||||||
|
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||||
|
val transport = RecordingDiagnosticsTransport()
|
||||||
|
val store = InMemoryPendingCrashStore()
|
||||||
|
val reporter = CrashReporter(
|
||||||
|
store = store,
|
||||||
|
preferencesRepository = preferences,
|
||||||
|
transport = transport,
|
||||||
|
breadcrumbs = BreadcrumbBuffer(),
|
||||||
|
appVersion = "1.0",
|
||||||
|
platform = "Test",
|
||||||
|
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||||
|
)
|
||||||
|
reporter.startObservingPreferences()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val optedIn = reporter.capture(RuntimeException("a"), diagnosticsEnabledOverride = true)
|
||||||
|
reporter.capture(RuntimeException("b"), diagnosticsEnabledOverride = false)
|
||||||
|
assertEquals(2, store.list().size)
|
||||||
|
|
||||||
|
reporter.flushPending()
|
||||||
|
assertEquals(1, transport.crashes.size)
|
||||||
|
assertEquals(optedIn.id, transport.crashes.single().id)
|
||||||
|
assertEquals(1, store.list().size)
|
||||||
|
assertFalse(store.list().single().diagnosticsEnabledAtCapture)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bugReportRequiresWhatAndExpected() = runTest {
|
||||||
|
val preferences = fakePrefs()
|
||||||
|
val service = BugReportService(
|
||||||
|
preferencesRepository = preferences,
|
||||||
|
transport = RecordingDiagnosticsTransport(),
|
||||||
|
breadcrumbs = BreadcrumbBuffer(),
|
||||||
|
appVersion = "1.0",
|
||||||
|
platform = "Test",
|
||||||
|
logReader = { "logs" },
|
||||||
|
)
|
||||||
|
assertTrue(service.submit(BugReportDraft("", "expected"), device()).isFailure)
|
||||||
|
assertTrue(service.submit(BugReportDraft("what", ""), device()).isFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bugReportSubmitsWithRedactedLogsRegardlessOfDiagnostics() = runTest {
|
||||||
|
val preferences = fakePrefs(diagnosticsEnabled = false)
|
||||||
|
val transport = RecordingDiagnosticsTransport()
|
||||||
|
val service = BugReportService(
|
||||||
|
preferencesRepository = preferences,
|
||||||
|
transport = transport,
|
||||||
|
breadcrumbs = BreadcrumbBuffer(),
|
||||||
|
appVersion = "1.0",
|
||||||
|
platform = "Test",
|
||||||
|
logReader = { LogRedactor.redact("ticket=abcdefghijklmnopqrstuvwxyz012345 plain") },
|
||||||
|
)
|
||||||
|
val result = service.submit(
|
||||||
|
BugReportDraft(
|
||||||
|
whatHappened = "crash on receive",
|
||||||
|
expected = "receive succeeds",
|
||||||
|
steps = "open ticket",
|
||||||
|
contact = "user@example.com",
|
||||||
|
includeLogs = true,
|
||||||
|
),
|
||||||
|
device(),
|
||||||
|
)
|
||||||
|
assertTrue(result.isSuccess)
|
||||||
|
assertEquals(1, transport.bugReports.size)
|
||||||
|
val report = transport.bugReports.single()
|
||||||
|
assertEquals("crash on receive", report.whatHappened)
|
||||||
|
assertTrue(report.logs.contains("[redacted-ticket]"))
|
||||||
|
assertFalse(report.logs.contains("abcdefghijklmnopqrstuvwxyz012345"))
|
||||||
|
assertEquals("test-install", report.installId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fakePrefs(diagnosticsEnabled: Boolean = false) = FakePreferencesRepository(
|
||||||
|
AppPreferences(
|
||||||
|
username = "User",
|
||||||
|
receiveFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp"),
|
||||||
|
themeMode = ThemeMode.System,
|
||||||
|
notificationsEnabled = false,
|
||||||
|
diagnosticsEnabled = diagnosticsEnabled,
|
||||||
|
diagnosticsInstallId = "test-install",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun device() = DeviceInfo("Phone", "Pixel", "Android 15", "Wi-Fi", "90%")
|
||||||
|
}
|
||||||
|
|
||||||
|
private class InMemoryPendingCrashStore : PendingCrashStore {
|
||||||
|
private val items = linkedMapOf<String, CrashReport>()
|
||||||
|
override fun write(report: CrashReport) {
|
||||||
|
items[report.id] = report
|
||||||
|
}
|
||||||
|
override fun list(): List<CrashReport> = items.values.sortedByDescending { it.timestampMillis }
|
||||||
|
override fun delete(id: String) {
|
||||||
|
items.remove(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,9 +14,13 @@ import com.vnidrop.app.feature.app.AppViewModel
|
|||||||
import com.vnidrop.app.feature.receive.ReceiveHistoryDeleteTarget
|
import com.vnidrop.app.feature.receive.ReceiveHistoryDeleteTarget
|
||||||
import com.vnidrop.app.feature.receive.ReceiveViewModel
|
import com.vnidrop.app.feature.receive.ReceiveViewModel
|
||||||
import com.vnidrop.app.feature.send.SendViewModel
|
import com.vnidrop.app.feature.send.SendViewModel
|
||||||
|
import com.vnidrop.app.diagnostics.BugReportService
|
||||||
|
import com.vnidrop.app.diagnostics.NoOpDiagnosticsTransport
|
||||||
|
import com.vnidrop.app.diagnostics.BreadcrumbBuffer
|
||||||
import com.vnidrop.app.feature.settings.SettingsViewModel
|
import com.vnidrop.app.feature.settings.SettingsViewModel
|
||||||
import com.vnidrop.app.notifications.NotificationPermission
|
import com.vnidrop.app.notifications.NotificationPermission
|
||||||
import com.vnidrop.app.preferences.AppPreferences
|
import com.vnidrop.app.preferences.AppPreferences
|
||||||
|
import com.vnidrop.app.preferences.PreferencesRepository
|
||||||
import com.vnidrop.app.support.FakeCoreGateway
|
import com.vnidrop.app.support.FakeCoreGateway
|
||||||
import com.vnidrop.app.support.FakeFileSystemService
|
import com.vnidrop.app.support.FakeFileSystemService
|
||||||
import com.vnidrop.app.support.FakeFilePreviewRepository
|
import com.vnidrop.app.support.FakeFilePreviewRepository
|
||||||
@@ -62,14 +66,7 @@ class ViewModelsTest {
|
|||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
val preferences = preferences()
|
val preferences = preferences()
|
||||||
val notifications = FakeNotificationService(NotificationPermission.Granted)
|
val notifications = FakeNotificationService(NotificationPermission.Granted)
|
||||||
val viewModel = SettingsViewModel(
|
val viewModel = settingsViewModel(preferences, notifications)
|
||||||
environment(),
|
|
||||||
{ DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") },
|
|
||||||
FakeFileSystemService(folder),
|
|
||||||
preferences,
|
|
||||||
notifications,
|
|
||||||
UiMessageController(),
|
|
||||||
)
|
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
viewModel.setNotificationsEnabled(true)
|
viewModel.setNotificationsEnabled(true)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
@@ -84,14 +81,7 @@ class ViewModelsTest {
|
|||||||
fun settingsUsernameKeepsSpacesWhileTypingAndPersistsAfterDebounce() = runTest {
|
fun settingsUsernameKeepsSpacesWhileTypingAndPersistsAfterDebounce() = runTest {
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
val preferences = preferences()
|
val preferences = preferences()
|
||||||
val viewModel = SettingsViewModel(
|
val viewModel = settingsViewModel(preferences)
|
||||||
environment(),
|
|
||||||
{ DeviceInfo("Device", null, "OS", null, null) },
|
|
||||||
FakeFileSystemService(folder),
|
|
||||||
preferences,
|
|
||||||
FakeNotificationService(),
|
|
||||||
UiMessageController(),
|
|
||||||
)
|
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
viewModel.setUsername("Ada ")
|
viewModel.setUsername("Ada ")
|
||||||
@@ -109,14 +99,7 @@ class ViewModelsTest {
|
|||||||
fun settingsKeepsNotificationsDisabledWhenPermissionIsDenied() = runTest {
|
fun settingsKeepsNotificationsDisabledWhenPermissionIsDenied() = runTest {
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
val preferences = preferences()
|
val preferences = preferences()
|
||||||
val viewModel = SettingsViewModel(
|
val viewModel = settingsViewModel(preferences, FakeNotificationService(NotificationPermission.Denied))
|
||||||
environment(),
|
|
||||||
{ DeviceInfo("Device", null, "OS", null, null) },
|
|
||||||
FakeFileSystemService(folder),
|
|
||||||
preferences,
|
|
||||||
FakeNotificationService(NotificationPermission.Denied),
|
|
||||||
UiMessageController(),
|
|
||||||
)
|
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
viewModel.setNotificationsEnabled(true)
|
viewModel.setNotificationsEnabled(true)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
@@ -128,14 +111,7 @@ class ViewModelsTest {
|
|||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
val preferences = preferences()
|
val preferences = preferences()
|
||||||
val notifications = FakeNotificationService(NotificationPermission.Denied)
|
val notifications = FakeNotificationService(NotificationPermission.Denied)
|
||||||
val viewModel = SettingsViewModel(
|
val viewModel = settingsViewModel(preferences, notifications)
|
||||||
environment(),
|
|
||||||
{ DeviceInfo("Device", null, "OS", null, null) },
|
|
||||||
FakeFileSystemService(folder),
|
|
||||||
preferences,
|
|
||||||
notifications,
|
|
||||||
UiMessageController(),
|
|
||||||
)
|
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
viewModel.openNotificationSettings()
|
viewModel.openNotificationSettings()
|
||||||
@@ -154,14 +130,7 @@ class ViewModelsTest {
|
|||||||
fun settingsReportsUnsupportedNotificationPlatforms() = runTest {
|
fun settingsReportsUnsupportedNotificationPlatforms() = runTest {
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
val preferences = preferences()
|
val preferences = preferences()
|
||||||
val viewModel = SettingsViewModel(
|
val viewModel = settingsViewModel(preferences, FakeNotificationService(NotificationPermission.Unsupported))
|
||||||
environment(),
|
|
||||||
{ DeviceInfo("Device", null, "OS", null, null) },
|
|
||||||
FakeFileSystemService(folder),
|
|
||||||
preferences,
|
|
||||||
FakeNotificationService(NotificationPermission.Unsupported),
|
|
||||||
UiMessageController(),
|
|
||||||
)
|
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
viewModel.setNotificationsEnabled(true)
|
viewModel.setNotificationsEnabled(true)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
@@ -169,6 +138,33 @@ class ViewModelsTest {
|
|||||||
assertEquals(NotificationPermission.Unsupported, viewModel.state.value.notificationPermission)
|
assertEquals(NotificationPermission.Unsupported, viewModel.state.value.notificationPermission)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsTogglesDiagnosticsPreference() = runTest {
|
||||||
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
val preferences = preferences()
|
||||||
|
val viewModel = settingsViewModel(preferences)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertFalse(viewModel.state.value.diagnosticsEnabled)
|
||||||
|
viewModel.setDiagnosticsEnabled(true)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(preferences.mutablePreferences.value.diagnosticsEnabled)
|
||||||
|
assertTrue(viewModel.state.value.diagnosticsEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsSubmitsBugReportAndClearsForm() = runTest {
|
||||||
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
val preferences = preferences()
|
||||||
|
val viewModel = settingsViewModel(preferences)
|
||||||
|
advanceUntilIdle()
|
||||||
|
viewModel.setBugWhatHappened("Transfer stuck")
|
||||||
|
viewModel.setBugExpected("It should finish")
|
||||||
|
viewModel.submitBugReport()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals("", viewModel.state.value.bugWhatHappened)
|
||||||
|
assertEquals("", viewModel.state.value.bugExpected)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun sendViewModelOwnsSelectedFileState() = runTest {
|
fun sendViewModelOwnsSelectedFileState() = runTest {
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
@@ -501,11 +497,38 @@ class ViewModelsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun preferences() = FakePreferencesRepository(
|
private fun preferences() = FakePreferencesRepository(
|
||||||
AppPreferences("Receiver", folder, ThemeMode.System, notificationsEnabled = false),
|
AppPreferences(
|
||||||
|
username = "Receiver",
|
||||||
|
receiveFolder = folder,
|
||||||
|
themeMode = ThemeMode.System,
|
||||||
|
notificationsEnabled = false,
|
||||||
|
diagnosticsEnabled = false,
|
||||||
|
diagnosticsInstallId = "test-install",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun environment() = PlatformEnvironment("Test", "1.0", "/tmp/vnidrop")
|
private fun environment() = PlatformEnvironment("Test", "1.0", "/tmp/vnidrop")
|
||||||
|
|
||||||
|
private fun settingsViewModel(
|
||||||
|
preferences: PreferencesRepository = preferences(),
|
||||||
|
notifications: FakeNotificationService = FakeNotificationService(),
|
||||||
|
) = SettingsViewModel(
|
||||||
|
environment(),
|
||||||
|
{ DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") },
|
||||||
|
FakeFileSystemService(folder),
|
||||||
|
preferences,
|
||||||
|
notifications,
|
||||||
|
UiMessageController(),
|
||||||
|
BugReportService(
|
||||||
|
preferencesRepository = preferences,
|
||||||
|
transport = NoOpDiagnosticsTransport(),
|
||||||
|
breadcrumbs = BreadcrumbBuffer(),
|
||||||
|
appVersion = "1.0",
|
||||||
|
platform = "Test",
|
||||||
|
logReader = { "sample log line" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
private fun receivedTransfer(id: ULong, status: TransferStatus) = Transfer(
|
private fun receivedTransfer(id: ULong, status: TransferStatus) = Transfer(
|
||||||
localId = "receive-$id",
|
localId = "receive-$id",
|
||||||
transferId = id,
|
transferId = id,
|
||||||
|
|||||||
@@ -197,6 +197,16 @@ class FakePreferencesRepository(
|
|||||||
override suspend fun resetReceiveFolder() = Unit
|
override suspend fun resetReceiveFolder() = Unit
|
||||||
override suspend fun setThemeMode(mode: ThemeMode) { mutablePreferences.value = mutablePreferences.value.copy(themeMode = mode) }
|
override suspend fun setThemeMode(mode: ThemeMode) { mutablePreferences.value = mutablePreferences.value.copy(themeMode = mode) }
|
||||||
override suspend fun setNotificationsEnabled(enabled: Boolean) { mutablePreferences.value = mutablePreferences.value.copy(notificationsEnabled = enabled) }
|
override suspend fun setNotificationsEnabled(enabled: Boolean) { mutablePreferences.value = mutablePreferences.value.copy(notificationsEnabled = enabled) }
|
||||||
|
override suspend fun setDiagnosticsEnabled(enabled: Boolean) {
|
||||||
|
mutablePreferences.value = mutablePreferences.value.copy(diagnosticsEnabled = enabled)
|
||||||
|
}
|
||||||
|
override suspend fun ensureDiagnosticsInstallId(): String {
|
||||||
|
val existing = mutablePreferences.value.diagnosticsInstallId
|
||||||
|
if (existing.isNotBlank()) return existing
|
||||||
|
val created = "test-install-id"
|
||||||
|
mutablePreferences.value = mutablePreferences.value.copy(diagnosticsInstallId = created)
|
||||||
|
return created
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeNotificationService(
|
class FakeNotificationService(
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.vnidrop.app.diagnostics
|
||||||
|
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.addressOf
|
||||||
|
import kotlinx.cinterop.convert
|
||||||
|
import kotlinx.cinterop.usePinned
|
||||||
|
import platform.Foundation.NSData
|
||||||
|
import platform.Foundation.NSFileManager
|
||||||
|
import platform.Foundation.NSString
|
||||||
|
import platform.Foundation.NSUTF8StringEncoding
|
||||||
|
import platform.Foundation.create
|
||||||
|
import platform.Foundation.dataUsingEncoding
|
||||||
|
import platform.Foundation.dataWithContentsOfFile
|
||||||
|
import platform.Foundation.writeToFile
|
||||||
|
import platform.posix.memcpy
|
||||||
|
|
||||||
|
actual fun createPendingCrashStore(appDataDir: String): PendingCrashStore =
|
||||||
|
IosPendingCrashStore(appDataDir)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private class IosPendingCrashStore(
|
||||||
|
appDataDir: String,
|
||||||
|
) : PendingCrashStore {
|
||||||
|
private val fileManager = NSFileManager.defaultManager
|
||||||
|
private val directory = appDataDir.trimEnd('/') + "/diagnostics/crashes"
|
||||||
|
|
||||||
|
override fun write(report: CrashReport) {
|
||||||
|
ensureDirectory()
|
||||||
|
val path = "$directory/${report.id}.crash"
|
||||||
|
val payload = CrashReportCodec.encode(report)
|
||||||
|
val data = (payload as NSString).dataUsingEncoding(NSUTF8StringEncoding) ?: return
|
||||||
|
data.writeToFile(path, atomically = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun list(): List<CrashReport> {
|
||||||
|
ensureDirectory()
|
||||||
|
val names = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty()
|
||||||
|
.filterIsInstance<String>()
|
||||||
|
.filter { it.endsWith(".crash") }
|
||||||
|
return names.mapNotNull { name ->
|
||||||
|
val path = "$directory/$name"
|
||||||
|
val data = NSData.dataWithContentsOfFile(path) ?: return@mapNotNull null
|
||||||
|
val text = data.toUtf8String()
|
||||||
|
CrashReportCodec.decode(text)
|
||||||
|
}.sortedByDescending { it.timestampMillis }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun delete(id: String) {
|
||||||
|
fileManager.removeItemAtPath("$directory/$id.crash", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureDirectory() {
|
||||||
|
fileManager.createDirectoryAtPath(directory, withIntermediateDirectories = true, attributes = null, error = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun NSData.toUtf8String(): String {
|
||||||
|
val size = length.toInt()
|
||||||
|
if (size == 0) return ""
|
||||||
|
val result = ByteArray(size)
|
||||||
|
val source = bytes ?: return ""
|
||||||
|
result.usePinned { pinned ->
|
||||||
|
memcpy(pinned.addressOf(0), source, size.convert())
|
||||||
|
}
|
||||||
|
return result.decodeToString()
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.vnidrop.app.diagnostics
|
||||||
|
|
||||||
|
import kotlin.experimental.ExperimentalNativeApi
|
||||||
|
|
||||||
|
@OptIn(ExperimentalNativeApi::class)
|
||||||
|
actual fun installPlatformCrashHook(onCrash: (Throwable) -> Unit) {
|
||||||
|
val previous = setUnhandledExceptionHook { throwable ->
|
||||||
|
runCatching { onCrash(throwable) }
|
||||||
|
// Terminate like the default hook after capture.
|
||||||
|
terminateWithUnhandledException(throwable)
|
||||||
|
}
|
||||||
|
// Keep a reference so the previous hook is not GC'd unused; we intentionally
|
||||||
|
// replace the default with capture-then-terminate.
|
||||||
|
@Suppress("UNUSED_VARIABLE")
|
||||||
|
val ignored = previous
|
||||||
|
}
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
package com.vnidrop.app.logging
|
package com.vnidrop.app.logging
|
||||||
|
|
||||||
import platform.Foundation.NSData
|
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
import kotlinx.cinterop.addressOf
|
import kotlinx.cinterop.addressOf
|
||||||
import kotlinx.cinterop.convert
|
import kotlinx.cinterop.convert
|
||||||
import kotlinx.cinterop.usePinned
|
import kotlinx.cinterop.usePinned
|
||||||
|
import platform.Foundation.NSData
|
||||||
import platform.Foundation.NSDate
|
import platform.Foundation.NSDate
|
||||||
import platform.Foundation.NSFileManager
|
import platform.Foundation.NSFileManager
|
||||||
import platform.Foundation.NSFileModificationDate
|
import platform.Foundation.NSFileModificationDate
|
||||||
import platform.Foundation.NSFileSize
|
import platform.Foundation.NSFileSize
|
||||||
import platform.Foundation.NSNumber
|
import platform.Foundation.NSNumber
|
||||||
|
import platform.Foundation.dataWithContentsOfFile
|
||||||
import platform.Foundation.timeIntervalSince1970
|
import platform.Foundation.timeIntervalSince1970
|
||||||
import platform.posix.fclose
|
import platform.posix.fclose
|
||||||
import platform.posix.fopen
|
import platform.posix.fopen
|
||||||
import platform.posix.fwrite
|
import platform.posix.fwrite
|
||||||
|
import platform.posix.memcpy
|
||||||
|
|
||||||
actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore =
|
actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore =
|
||||||
IosPlatformLogStore(appDataDir, policy)
|
IosPlatformLogStore(appDataDir, policy)
|
||||||
@@ -61,6 +63,31 @@ private class IosPlatformLogStore(
|
|||||||
.sortedByDescending { it.modifiedAtMillis }
|
.sortedByDescending { it.modifiedAtMillis }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun readLatest(maxBytes: Long): String {
|
||||||
|
if (maxBytes <= 0) return ""
|
||||||
|
ensureDirectory()
|
||||||
|
val paths = listOf(activePath) +
|
||||||
|
(1..policy.maxFiles).map { "$directory/app.$it.log" }
|
||||||
|
val chunks = ArrayList<ByteArray>()
|
||||||
|
var remaining = maxBytes
|
||||||
|
for (path in paths) {
|
||||||
|
if (remaining <= 0 || !fileManager.fileExistsAtPath(path)) continue
|
||||||
|
val slice = readTail(path, remaining)
|
||||||
|
if (slice.isEmpty()) continue
|
||||||
|
chunks.add(0, slice)
|
||||||
|
remaining -= slice.size.toLong()
|
||||||
|
}
|
||||||
|
if (chunks.isEmpty()) return ""
|
||||||
|
val total = chunks.sumOf { it.size }
|
||||||
|
val out = ByteArray(total)
|
||||||
|
var offset = 0
|
||||||
|
for (chunk in chunks) {
|
||||||
|
chunk.copyInto(out, offset)
|
||||||
|
offset += chunk.size
|
||||||
|
}
|
||||||
|
return out.decodeToString()
|
||||||
|
}
|
||||||
|
|
||||||
private fun rotate() {
|
private fun rotate() {
|
||||||
if (policy.maxFiles == 0) {
|
if (policy.maxFiles == 0) {
|
||||||
fileManager.removeItemAtPath(activePath, null)
|
fileManager.removeItemAtPath(activePath, null)
|
||||||
@@ -92,4 +119,31 @@ private class IosPlatformLogStore(
|
|||||||
val date = attributes[NSFileModificationDate] as? NSDate ?: return 0L
|
val date = attributes[NSFileModificationDate] as? NSDate ?: return 0L
|
||||||
return (date.timeIntervalSince1970 * 1000.0).toLong()
|
return (date.timeIntervalSince1970 * 1000.0).toLong()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun readTail(path: String, maxBytes: Long): ByteArray {
|
||||||
|
val data = NSData.dataWithContentsOfFile(path) ?: return ByteArray(0)
|
||||||
|
val all = data.toByteArray()
|
||||||
|
if (all.isEmpty() || maxBytes <= 0) return ByteArray(0)
|
||||||
|
if (all.size.toLong() <= maxBytes) return all
|
||||||
|
val start = all.size - maxBytes.toInt()
|
||||||
|
val slice = all.copyOfRange(start, all.size)
|
||||||
|
val newline = slice.indexOf('\n'.code.toByte())
|
||||||
|
return if (newline in 0 until slice.lastIndex) {
|
||||||
|
slice.copyOfRange(newline + 1, slice.size)
|
||||||
|
} else {
|
||||||
|
slice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun NSData.toByteArray(): ByteArray {
|
||||||
|
val size = length.toInt()
|
||||||
|
if (size == 0) return ByteArray(0)
|
||||||
|
val result = ByteArray(size)
|
||||||
|
val source = bytes ?: return ByteArray(0)
|
||||||
|
result.usePinned { pinned ->
|
||||||
|
memcpy(pinned.addressOf(0), source, size.convert())
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.vnidrop.app.diagnostics
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
|
actual fun createPendingCrashStore(appDataDir: String): PendingCrashStore =
|
||||||
|
JvmPendingCrashStore(appDataDir)
|
||||||
|
|
||||||
|
private class JvmPendingCrashStore(
|
||||||
|
appDataDir: String,
|
||||||
|
) : PendingCrashStore {
|
||||||
|
private val directory = File(appDataDir, "diagnostics/crashes")
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun write(report: CrashReport) {
|
||||||
|
directory.mkdirs()
|
||||||
|
File(directory, "${report.id}.crash").writeText(CrashReportCodec.encode(report), StandardCharsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun list(): List<CrashReport> {
|
||||||
|
if (!directory.isDirectory) return emptyList()
|
||||||
|
return directory
|
||||||
|
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
|
||||||
|
.orEmpty()
|
||||||
|
.sortedByDescending { it.lastModified() }
|
||||||
|
.mapNotNull { file ->
|
||||||
|
runCatching { CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8)) }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun delete(id: String) {
|
||||||
|
File(directory, "$id.crash").delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.vnidrop.app.diagnostics
|
||||||
|
|
||||||
|
actual fun installPlatformCrashHook(onCrash: (Throwable) -> Unit) {
|
||||||
|
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
|
runCatching { onCrash(throwable) }
|
||||||
|
previous?.uncaughtException(thread, throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.vnidrop.app.logging
|
package com.vnidrop.app.logging
|
||||||
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.RandomAccessFile
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore =
|
actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore =
|
||||||
@@ -37,6 +38,32 @@ private class JvmPlatformLogStore(
|
|||||||
.map { file -> LogFileInfo(file.name, file.absolutePath, file.length(), file.lastModified()) }
|
.map { file -> LogFileInfo(file.name, file.absolutePath, file.length(), file.lastModified()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun readLatest(maxBytes: Long): String {
|
||||||
|
if (maxBytes <= 0) return ""
|
||||||
|
directory.mkdirs()
|
||||||
|
val files = listOf(activeFile) +
|
||||||
|
(1..policy.maxFiles).map { File(directory, "app.$it.log") }
|
||||||
|
val chunks = ArrayList<ByteArray>()
|
||||||
|
var remaining = maxBytes
|
||||||
|
for (file in files) {
|
||||||
|
if (remaining <= 0 || !file.isFile) continue
|
||||||
|
val slice = readTail(file, remaining)
|
||||||
|
if (slice.isEmpty()) continue
|
||||||
|
chunks.add(0, slice)
|
||||||
|
remaining -= slice.size.toLong()
|
||||||
|
}
|
||||||
|
if (chunks.isEmpty()) return ""
|
||||||
|
val total = chunks.sumOf { it.size }
|
||||||
|
val out = ByteArray(total)
|
||||||
|
var offset = 0
|
||||||
|
for (chunk in chunks) {
|
||||||
|
chunk.copyInto(out, offset)
|
||||||
|
offset += chunk.size
|
||||||
|
}
|
||||||
|
return String(out, StandardCharsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
private fun rotate() {
|
private fun rotate() {
|
||||||
if (policy.maxFiles == 0) {
|
if (policy.maxFiles == 0) {
|
||||||
activeFile.delete()
|
activeFile.delete()
|
||||||
@@ -54,3 +81,23 @@ private class JvmPlatformLogStore(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun readTail(file: File, maxBytes: Long): ByteArray {
|
||||||
|
if (!file.isFile || file.length() == 0L || maxBytes <= 0) return ByteArray(0)
|
||||||
|
val length = file.length()
|
||||||
|
val start = (length - maxBytes).coerceAtLeast(0L)
|
||||||
|
val size = (length - start).toInt()
|
||||||
|
RandomAccessFile(file, "r").use { raf ->
|
||||||
|
raf.seek(start)
|
||||||
|
val bytes = ByteArray(size)
|
||||||
|
raf.readFully(bytes)
|
||||||
|
if (start == 0L) return bytes
|
||||||
|
// Align to the next full line when we start mid-file.
|
||||||
|
val newline = bytes.indexOf('\n'.code.toByte())
|
||||||
|
return if (newline in 0 until bytes.lastIndex) {
|
||||||
|
bytes.copyOfRange(newline + 1, bytes.size)
|
||||||
|
} else {
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,6 +86,13 @@ class FoundationComposeTest {
|
|||||||
onResetFolder = {},
|
onResetFolder = {},
|
||||||
onNotificationsChanged = {},
|
onNotificationsChanged = {},
|
||||||
onOpenNotificationSettings = {},
|
onOpenNotificationSettings = {},
|
||||||
|
onDiagnosticsChanged = {},
|
||||||
|
onBugWhatChanged = {},
|
||||||
|
onBugExpectedChanged = {},
|
||||||
|
onBugStepsChanged = {},
|
||||||
|
onBugContactChanged = {},
|
||||||
|
onBugIncludeLogsChanged = {},
|
||||||
|
onSubmitBugReport = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,6 +115,13 @@ class FoundationComposeTest {
|
|||||||
onResetFolder = {},
|
onResetFolder = {},
|
||||||
onNotificationsChanged = { enabled = it },
|
onNotificationsChanged = { enabled = it },
|
||||||
onOpenNotificationSettings = {},
|
onOpenNotificationSettings = {},
|
||||||
|
onDiagnosticsChanged = {},
|
||||||
|
onBugWhatChanged = {},
|
||||||
|
onBugExpectedChanged = {},
|
||||||
|
onBugStepsChanged = {},
|
||||||
|
onBugContactChanged = {},
|
||||||
|
onBugIncludeLogsChanged = {},
|
||||||
|
onSubmitBugReport = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,6 +147,13 @@ class FoundationComposeTest {
|
|||||||
onResetFolder = {},
|
onResetFolder = {},
|
||||||
onNotificationsChanged = {},
|
onNotificationsChanged = {},
|
||||||
onOpenNotificationSettings = { opened = true },
|
onOpenNotificationSettings = { opened = true },
|
||||||
|
onDiagnosticsChanged = {},
|
||||||
|
onBugWhatChanged = {},
|
||||||
|
onBugExpectedChanged = {},
|
||||||
|
onBugStepsChanged = {},
|
||||||
|
onBugContactChanged = {},
|
||||||
|
onBugIncludeLogsChanged = {},
|
||||||
|
onSubmitBugReport = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user