From ead4e09a6012f663598b8feecd492d75a95dcaf5 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Tue, 14 Jul 2026 08:27:42 +0200 Subject: [PATCH] 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. --- gradle.properties | 6 + shared/build.gradle.kts | 36 +++ .../diagnostics/PendingCrashStore.android.kt | 36 +++ .../diagnostics/PlatformCrashHook.android.kt | 9 + .../app/logging/PlatformLogStore.android.kt | 46 ++++ .../composeResources/values/strings.xml | 19 ++ .../commonMain/kotlin/com/vnidrop/app/App.kt | 10 +- .../kotlin/com/vnidrop/app/AppGraph.kt | 12 + .../app/diagnostics/BreadcrumbBuffer.kt | 43 ++++ .../app/diagnostics/BugReportService.kt | 89 +++++++ .../vnidrop/app/diagnostics/CrashReporter.kt | 89 +++++++ .../app/diagnostics/DiagnosticsCoordinator.kt | 84 +++++++ .../app/diagnostics/DiagnosticsModels.kt | 61 +++++ .../app/diagnostics/DiagnosticsTransport.kt | 45 ++++ .../vnidrop/app/diagnostics/LogRedactor.kt | 53 ++++ .../app/diagnostics/PendingCrashStore.kt | 95 +++++++ .../app/diagnostics/TelemetryRecorder.kt | 83 +++++++ .../vnidrop/app/feature/app/AppViewModel.kt | 4 + .../app/feature/settings/AboutSettings.kt | 23 +- .../app/feature/settings/BugReportSettings.kt | 135 ++++++++++ .../app/feature/settings/SettingsRoute.kt | 7 + .../app/feature/settings/SettingsScreen.kt | 60 ++++- .../app/feature/settings/SettingsViewModel.kt | 121 ++++++++- .../com/vnidrop/app/logging/AppLogger.kt | 7 + .../preferences/AppPreferencesRepository.kt | 32 +++ .../kotlin/com/vnidrop/app/util/Uuid.kt | 20 ++ .../app/diagnostics/DiagnosticsTest.kt | 231 ++++++++++++++++++ .../com/vnidrop/app/feature/ViewModelsTest.kt | 105 ++++---- .../kotlin/com/vnidrop/app/support/Fakes.kt | 10 + .../app/diagnostics/PendingCrashStore.ios.kt | 67 +++++ .../app/diagnostics/PlatformCrashHook.ios.kt | 16 ++ .../app/logging/PlatformLogStore.ios.kt | 56 ++++- .../app/diagnostics/PendingCrashStore.jvm.kt | 36 +++ .../app/diagnostics/PlatformCrashHook.jvm.kt | 9 + .../app/logging/PlatformLogStore.jvm.kt | 47 ++++ .../vnidrop/app/ui/FoundationComposeTest.kt | 21 ++ 36 files changed, 1776 insertions(+), 47 deletions(-) create mode 100644 shared/src/androidMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.android.kt create mode 100644 shared/src/androidMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.android.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/BreadcrumbBuffer.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/BugReportService.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/CrashReporter.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsCoordinator.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsModels.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTransport.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/LogRedactor.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/TelemetryRecorder.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/BugReportSettings.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/util/Uuid.kt create mode 100644 shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt create mode 100644 shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.ios.kt create mode 100644 shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.ios.kt create mode 100644 shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.jvm.kt create mode 100644 shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.jvm.kt diff --git a/gradle.properties b/gradle.properties index 61ca66c..995273c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,3 +12,9 @@ android.newDsl=false android.nonTransitiveRClass=true android.sourceset.disallowProvider=false 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 diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 5a7b285..35f4abf 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -37,6 +37,39 @@ plugins { 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 { if (GobleyHost.current.platform == GobleyHost.Platform.MacOS) { listOf( @@ -59,6 +92,9 @@ kotlin { jvm() sourceSets { + commonMain { + kotlin.srcDir(files(diagnosticsBuildConfigDir).builtBy(generateDiagnosticsBuildConfig)) + } androidMain.dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.core.ktx) diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.android.kt new file mode 100644 index 0000000..4005563 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.android.kt @@ -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 { + 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() + } +} diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.android.kt new file mode 100644 index 0000000..d762725 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.android.kt @@ -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) + } +} diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.android.kt index 59134ef..541cd1c 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.android.kt @@ -1,6 +1,7 @@ package com.vnidrop.app.logging import java.io.File +import java.io.RandomAccessFile import java.nio.charset.StandardCharsets 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()) } } + @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() + 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() { if (policy.maxFiles == 0) { 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 + } + } +} diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 04aa81a..707d235 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -155,6 +155,25 @@ About Privacy policy Report a bug + Share diagnostics + 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. + Diagnostics sharing is on. + Diagnostics sharing is off. + Tell us what went wrong. We attach device info and optional recent logs (with sensitive values redacted). + What happened? + What did you expect? + Steps to reproduce (optional) + Contact email (optional) + Include recent logs + Helps us diagnose the issue. Sensitive values are redacted before sending. + Log attachment size + Device information + Submit report + Submitting… + Thanks — your bug report was recorded. + Could not submit the bug report. Try again later. + Please describe what happened. + Please describe what you expected. App version Device name Device model diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index f1c9198..1bc72cf 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -57,7 +57,13 @@ fun App( val graph = graphHolder.graph val appViewModel = viewModel { - AppViewModel(dependencies.environment, graph.coreRepository, graph.preferencesRepository, graph.messages) + AppViewModel( + dependencies.environment, + graph.coreRepository, + graph.preferencesRepository, + graph.messages, + graph.diagnostics, + ) } val sendViewModel = viewModel { SendViewModel( @@ -79,6 +85,8 @@ fun App( graph.preferencesRepository, dependencies.localNotificationService, graph.messages, + graph.diagnostics.bugReports, + graph.diagnostics, ) } val appState by appViewModel.state.collectAsStateWithLifecycle() diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt index 1d3d6e6..f255576 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt @@ -2,6 +2,8 @@ package com.vnidrop.app import com.vnidrop.app.core.CoreGateway import com.vnidrop.app.core.CoreRepository +import com.vnidrop.app.diagnostics.DiagnosticsCoordinator +import com.vnidrop.app.diagnostics.NoOpDiagnosticsTransport import com.vnidrop.app.feature.approvals.ApprovalCoordinator import com.vnidrop.app.feature.send.AppFilePreviewRepository import com.vnidrop.app.feature.send.createPlatformPreviewStore @@ -34,8 +36,17 @@ class AppGraph( receiveFolder = dependencies.fileSystemService.defaultReceiveFolder(), themeMode = ThemeMode.System, notificationsEnabled = false, + diagnosticsEnabled = false, ), ) + val diagnostics = DiagnosticsCoordinator.create( + appDataDir = dependencies.environment.defaultCoreDataDir, + appVersion = dependencies.environment.appVersion, + platform = dependencies.environment.name, + preferencesRepository = preferencesRepository, + scope = applicationScope, + transport = NoOpDiagnosticsTransport(), + ) val approvalCoordinator = ApprovalCoordinator( repository = coreRepository, preferencesRepository = preferencesRepository, @@ -47,6 +58,7 @@ class AppGraph( init { AppLogger.initialize(dependencies.environment.defaultCoreDataDir) + diagnostics.start() } fun close() { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/BreadcrumbBuffer.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/BreadcrumbBuffer.kt new file mode 100644 index 0000000..695475c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/BreadcrumbBuffer.kt @@ -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 = emptyList() + + fun add(name: String, properties: Map = 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 = items + + fun clear() { + items = emptyList() + } + + companion object { + const val DefaultCapacity = 40 + private const val MaxNameLength = 64 + private const val MaxPropertyValueLength = 128 + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/BugReportService.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/BugReportService.kt new file mode 100644 index 0000000..c5bf263 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/BugReportService.kt @@ -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 { + 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 + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/CrashReporter.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/CrashReporter.kt new file mode 100644 index 0000000..7d99e36 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/CrashReporter.kt @@ -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) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsCoordinator.kt new file mode 100644 index 0000000..0173f63 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsCoordinator.kt @@ -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 = 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, + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsModels.kt new file mode 100644 index 0000000..376c83b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsModels.kt @@ -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 = emptyMap(), + val schemaVersion: Int = DiagnosticsSchemaVersion, +) + +data class Breadcrumb( + val name: String, + val timestampMillis: Long, + val properties: Map = 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, + /** 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, + val schemaVersion: Int = DiagnosticsSchemaVersion, +) + +const val DiagnosticsSchemaVersion: Int = 1 diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTransport.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTransport.kt new file mode 100644 index 0000000..0244ea3 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTransport.kt @@ -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): Result + suspend fun sendCrash(report: CrashReport): Result + suspend fun sendBugReport(report: BugReport): Result +} + +/** Accepts payloads without leaving the device. Used until Cloudflare is wired. */ +class NoOpDiagnosticsTransport : DiagnosticsTransport { + override suspend fun sendEvents(events: List): Result = Result.success(Unit) + override suspend fun sendCrash(report: CrashReport): Result = Result.success(Unit) + override suspend fun sendBugReport(report: BugReport): Result = Result.success(Unit) +} + +/** + * Test double that records calls and can fail on demand. + */ +class RecordingDiagnosticsTransport : DiagnosticsTransport { + val events = mutableListOf>() + val crashes = mutableListOf() + val bugReports = mutableListOf() + var eventsResult: Result = Result.success(Unit) + var crashResult: Result = Result.success(Unit) + var bugResult: Result = Result.success(Unit) + + override suspend fun sendEvents(events: List): Result { + this.events += events + return eventsResult + } + + override suspend fun sendCrash(report: CrashReport): Result { + crashes += report + return crashResult + } + + override suspend fun sendBugReport(report: BugReport): Result { + bugReports += report + return bugResult + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/LogRedactor.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/LogRedactor.kt new file mode 100644 index 0000000..67222ae --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/LogRedactor.kt @@ -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): Map = + 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("""(? + 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() + 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, + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/TelemetryRecorder.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/TelemetryRecorder.kt new file mode 100644 index 0000000..ecc32e7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/TelemetryRecorder.kt @@ -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 = 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 = 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 = 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 + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/app/AppViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/app/AppViewModel.kt index 2e0f2dc..810a6dc 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/app/AppViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/app/AppViewModel.kt @@ -6,6 +6,7 @@ import com.vnidrop.app.PlatformEnvironment import com.vnidrop.app.AppDependencies import com.vnidrop.app.AppGraph import com.vnidrop.app.core.CoreGateway +import com.vnidrop.app.diagnostics.DiagnosticsCoordinator import com.vnidrop.app.logging.AppLogger import com.vnidrop.app.preferences.PreferencesRepository import com.vnidrop.app.ui.feedback.UiMessageController @@ -36,12 +37,14 @@ class AppViewModel( private val repository: CoreGateway, preferencesRepository: PreferencesRepository, private val messages: UiMessageController, + private val diagnostics: DiagnosticsCoordinator? = null, ) : ViewModel() { private val _state = MutableStateFlow(AppState()) val state: StateFlow = _state.asStateFlow() init { AppLogger.info("lifecycle", "app started", mapOf("platform" to environment.name)) + diagnostics?.record("app_open", mapOf("platform" to environment.name, "version" to environment.appVersion)) viewModelScope.launch { repository.initialize(environment.defaultCoreDataDir).onFailure(messages::error) } @@ -54,5 +57,6 @@ class AppViewModel( fun selectDestination(destination: AppDestination) { _state.update { it.copy(destination = destination) } + diagnostics?.record("nav_select", mapOf("destination" to destination.name)) } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt index ee7dabd..ede24c5 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp +import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig import org.jetbrains.compose.resources.stringResource import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.about_bug_report @@ -12,13 +13,21 @@ import vnidrop.shared.generated.resources.about_title import vnidrop.shared.generated.resources.battery_level_title import vnidrop.shared.generated.resources.device_model_title import vnidrop.shared.generated.resources.device_name_title +import vnidrop.shared.generated.resources.diagnostics_description +import vnidrop.shared.generated.resources.diagnostics_title import vnidrop.shared.generated.resources.network_title import vnidrop.shared.generated.resources.os_version_title import vnidrop.shared.generated.resources.value_unavailable import vnidrop.shared.generated.resources.version_title @Composable -internal fun AboutSettings(state: SettingsState, onBack: () -> Unit, showBack: Boolean) { +internal fun AboutSettings( + state: SettingsState, + onDiagnosticsChanged: (Boolean) -> Unit, + onReportBug: () -> Unit, + onBack: () -> Unit, + showBack: Boolean, +) { val unavailable = stringResource(Res.string.value_unavailable) val info = state.deviceInfo Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { @@ -29,11 +38,23 @@ internal fun AboutSettings(state: SettingsState, onBack: () -> Unit, showBack: B title = stringResource(Res.string.about_privacy), iconTone = SettingsIconTone.Neutral, ) + if (DiagnosticsBuildConfig.INCLUDED) { + SettingsDivider() + SettingsToggleRow( + icon = SettingsIcons.Info, + title = stringResource(Res.string.diagnostics_title), + description = stringResource(Res.string.diagnostics_description), + checked = state.diagnosticsEnabled, + enabled = true, + onCheckedChange = onDiagnosticsChanged, + ) + } SettingsDivider() SettingsRow( icon = SettingsIcons.Bug, title = stringResource(Res.string.about_bug_report), iconTone = SettingsIconTone.Neutral, + onClick = onReportBug, ) } SettingsGroup { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/BugReportSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/BugReportSettings.kt new file mode 100644 index 0000000..0e50b72 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/BugReportSettings.kt @@ -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" +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt index 7701ed5..20dff4b 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt @@ -28,5 +28,12 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) { onResetFolder = viewModel::resetReceiveFolder, onNotificationsChanged = viewModel::setNotificationsEnabled, onOpenNotificationSettings = viewModel::openNotificationSettings, + onDiagnosticsChanged = viewModel::setDiagnosticsEnabled, + onBugWhatChanged = viewModel::setBugWhatHappened, + onBugExpectedChanged = viewModel::setBugExpected, + onBugStepsChanged = viewModel::setBugSteps, + onBugContactChanged = viewModel::setBugContact, + onBugIncludeLogsChanged = viewModel::setBugIncludeLogs, + onSubmitBugReport = viewModel::submitBugReport, ) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt index faf0a3f..786042d 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt @@ -22,6 +22,13 @@ fun SettingsScreen( onResetFolder: () -> Unit, onNotificationsChanged: (Boolean) -> Unit, onOpenNotificationSettings: () -> Unit, + onDiagnosticsChanged: (Boolean) -> Unit, + onBugWhatChanged: (String) -> Unit, + onBugExpectedChanged: (String) -> Unit, + onBugStepsChanged: (String) -> Unit, + onBugContactChanged: (String) -> Unit, + onBugIncludeLogsChanged: (Boolean) -> Unit, + onSubmitBugReport: () -> Unit, ) { if (windowClass == WindowClass.Desktop) { Row( @@ -37,12 +44,20 @@ fun SettingsScreen( section = state.selectedSection.takeUnless { it == SettingsSection.Overview } ?: SettingsSection.Preferences, onBack = {}, showBack = false, + onSectionSelected = onSectionSelected, onUsernameChanged = onUsernameChanged, onThemeModeChanged = onThemeModeChanged, onChooseFolder = onChooseFolder, onResetFolder = onResetFolder, onNotificationsChanged = onNotificationsChanged, onOpenNotificationSettings = onOpenNotificationSettings, + onDiagnosticsChanged = onDiagnosticsChanged, + onBugWhatChanged = onBugWhatChanged, + onBugExpectedChanged = onBugExpectedChanged, + onBugStepsChanged = onBugStepsChanged, + onBugContactChanged = onBugContactChanged, + onBugIncludeLogsChanged = onBugIncludeLogsChanged, + onSubmitBugReport = onSubmitBugReport, ) } } @@ -52,14 +67,30 @@ fun SettingsScreen( else -> SettingsSectionContent( state = state, section = state.selectedSection, - onBack = { onSectionSelected(SettingsSection.Overview) }, + onBack = { + onSectionSelected( + if (state.selectedSection == SettingsSection.BugReport) { + SettingsSection.About + } else { + SettingsSection.Overview + }, + ) + }, showBack = true, + onSectionSelected = onSectionSelected, onUsernameChanged = onUsernameChanged, onThemeModeChanged = onThemeModeChanged, onChooseFolder = onChooseFolder, onResetFolder = onResetFolder, onNotificationsChanged = onNotificationsChanged, onOpenNotificationSettings = onOpenNotificationSettings, + onDiagnosticsChanged = onDiagnosticsChanged, + onBugWhatChanged = onBugWhatChanged, + onBugExpectedChanged = onBugExpectedChanged, + onBugStepsChanged = onBugStepsChanged, + onBugContactChanged = onBugContactChanged, + onBugIncludeLogsChanged = onBugIncludeLogsChanged, + onSubmitBugReport = onSubmitBugReport, ) } } @@ -71,18 +102,43 @@ private fun SettingsSectionContent( section: SettingsSection, onBack: () -> Unit, showBack: Boolean, + onSectionSelected: (SettingsSection) -> Unit, onUsernameChanged: (String) -> Unit, onThemeModeChanged: (ThemeMode) -> Unit, onChooseFolder: () -> Unit, onResetFolder: () -> Unit, onNotificationsChanged: (Boolean) -> Unit, onOpenNotificationSettings: () -> Unit, + onDiagnosticsChanged: (Boolean) -> Unit, + onBugWhatChanged: (String) -> Unit, + onBugExpectedChanged: (String) -> Unit, + onBugStepsChanged: (String) -> Unit, + onBugContactChanged: (String) -> Unit, + onBugIncludeLogsChanged: (Boolean) -> Unit, + onSubmitBugReport: () -> Unit, ) { when (section) { SettingsSection.Overview -> Unit SettingsSection.Preferences -> PreferencesSettings(state, onUsernameChanged, onChooseFolder, onResetFolder, onBack, showBack) SettingsSection.Appearance -> AppearanceSettings(state.themeMode, onThemeModeChanged, onBack, showBack) SettingsSection.Notifications -> NotificationSettings(state, onNotificationsChanged, onOpenNotificationSettings, onBack, showBack) - SettingsSection.About -> AboutSettings(state, onBack, showBack) + SettingsSection.About -> AboutSettings( + state = state, + onDiagnosticsChanged = onDiagnosticsChanged, + onReportBug = { onSectionSelected(SettingsSection.BugReport) }, + onBack = onBack, + showBack = showBack, + ) + SettingsSection.BugReport -> BugReportSettings( + state = state, + onWhatChanged = onBugWhatChanged, + onExpectedChanged = onBugExpectedChanged, + onStepsChanged = onBugStepsChanged, + onContactChanged = onBugContactChanged, + onIncludeLogsChanged = onBugIncludeLogsChanged, + onSubmit = onSubmitBugReport, + onBack = onBack, + showBack = showBack, + ) } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt index 377763c..c9ad186 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt @@ -8,6 +8,10 @@ import com.vnidrop.app.PlatformEnvironment import com.vnidrop.app.core.FileSystemService import com.vnidrop.app.core.FolderAccessStatus import com.vnidrop.app.core.ReceiveFolder +import com.vnidrop.app.diagnostics.BugReportDraft +import com.vnidrop.app.diagnostics.BugReportService +import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig +import com.vnidrop.app.diagnostics.DiagnosticsCoordinator import com.vnidrop.app.notifications.LocalNotificationService import com.vnidrop.app.notifications.NotificationPermission import com.vnidrop.app.preferences.PreferencesRepository @@ -27,7 +31,13 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.bug_report_missing_expected +import vnidrop.shared.generated.resources.bug_report_missing_what +import vnidrop.shared.generated.resources.bug_report_submit_failed +import vnidrop.shared.generated.resources.bug_report_submitted import vnidrop.shared.generated.resources.button_open_settings +import vnidrop.shared.generated.resources.diagnostics_disabled_message +import vnidrop.shared.generated.resources.diagnostics_enabled_message import vnidrop.shared.generated.resources.notifications_enabled_message import vnidrop.shared.generated.resources.notifications_permission_denied import vnidrop.shared.generated.resources.notifications_settings_open_failed @@ -39,6 +49,7 @@ enum class SettingsSection { Appearance, Notifications, About, + BugReport, } data class SettingsState( @@ -50,9 +61,17 @@ data class SettingsState( val themeMode: ThemeMode = ThemeMode.System, val notificationsEnabled: Boolean = false, val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined, + val diagnosticsEnabled: Boolean = false, val deviceInfo: DeviceInfo? = null, val appVersion: String = "", val isLoadingDeviceInfo: Boolean = false, + val bugWhatHappened: String = "", + val bugExpected: String = "", + val bugSteps: String = "", + val bugContact: String = "", + val bugIncludeLogs: Boolean = true, + val isSubmittingBugReport: Boolean = false, + val bugLogPreviewBytes: Int = 0, ) sealed interface SettingsEffect { @@ -66,6 +85,8 @@ class SettingsViewModel( private val preferencesRepository: PreferencesRepository, private val notifications: LocalNotificationService, private val messages: UiMessageController, + private val bugReports: BugReportService, + private val diagnostics: DiagnosticsCoordinator? = null, ) : ViewModel() { private val _state = MutableStateFlow(SettingsState(appVersion = environment.appVersion)) val state: StateFlow = _state.asStateFlow() @@ -88,6 +109,7 @@ class SettingsViewModel( receiveFolder = preferences.receiveFolder, themeMode = preferences.themeMode, notificationsEnabled = preferences.notificationsEnabled, + diagnosticsEnabled = preferences.diagnosticsEnabled, ) } if (preferences.receiveFolder != previousFolder) { @@ -101,7 +123,13 @@ class SettingsViewModel( fun selectSection(section: SettingsSection) { _state.update { it.copy(selectedSection = section) } - if (section == SettingsSection.About) loadDeviceInfo() + when (section) { + SettingsSection.About, SettingsSection.BugReport -> { + loadDeviceInfo() + if (section == SettingsSection.BugReport) refreshBugLogPreview() + } + else -> Unit + } } fun setUsername(value: String) { @@ -164,6 +192,90 @@ class SettingsViewModel( } } + fun setDiagnosticsEnabled(enabled: Boolean) { + if (!DiagnosticsBuildConfig.INCLUDED) return + viewModelScope.launch { + preferencesRepository.setDiagnosticsEnabled(enabled) + diagnostics?.record( + if (enabled) "diagnostics_enabled" else "diagnostics_disabled", + ) + messages.show( + UiMessage( + UiText.Resource( + if (enabled) Res.string.diagnostics_enabled_message + else Res.string.diagnostics_disabled_message, + ), + UiMessageTone.Success, + ), + ) + } + } + + fun setBugWhatHappened(value: String) = _state.update { it.copy(bugWhatHappened = value) } + fun setBugExpected(value: String) = _state.update { it.copy(bugExpected = value) } + fun setBugSteps(value: String) = _state.update { it.copy(bugSteps = value) } + fun setBugContact(value: String) = _state.update { it.copy(bugContact = value) } + fun setBugIncludeLogs(value: Boolean) = _state.update { it.copy(bugIncludeLogs = value) } + + fun submitBugReport() { + if (_state.value.isSubmittingBugReport) return + viewModelScope.launch { + val snapshot = _state.value + val what = snapshot.bugWhatHappened.trim() + val expected = snapshot.bugExpected.trim() + if (what.isEmpty()) { + messages.show(UiMessage(UiText.Resource(Res.string.bug_report_missing_what), UiMessageTone.Warning)) + return@launch + } + if (expected.isEmpty()) { + messages.show(UiMessage(UiText.Resource(Res.string.bug_report_missing_expected), UiMessageTone.Warning)) + return@launch + } + _state.update { it.copy(isSubmittingBugReport = true) } + try { + val result = bugReports.submit( + BugReportDraft( + whatHappened = what, + expected = expected, + steps = snapshot.bugSteps, + contact = snapshot.bugContact, + includeLogs = snapshot.bugIncludeLogs, + ), + deviceInfo = snapshot.deviceInfo, + ) + result.fold( + onSuccess = { + diagnostics?.record("bug_report_submitted") + _state.update { + it.copy( + isSubmittingBugReport = false, + bugWhatHappened = "", + bugExpected = "", + bugSteps = "", + bugContact = "", + bugIncludeLogs = true, + ) + } + messages.show( + UiMessage(UiText.Resource(Res.string.bug_report_submitted), UiMessageTone.Success), + ) + selectSection(SettingsSection.About) + }, + onFailure = { + _state.update { it.copy(isSubmittingBugReport = false) } + messages.show( + UiMessage(UiText.Resource(Res.string.bug_report_submit_failed), UiMessageTone.Error), + ) + }, + ) + } catch (error: Throwable) { + if (error is CancellationException) throw error + _state.update { it.copy(isSubmittingBugReport = false) } + messages.show(UiMessage(UiText.Resource(Res.string.bug_report_submit_failed), UiMessageTone.Error)) + } + } + } + fun openNotificationSettings() { viewModelScope.launch { enableNotificationsAfterSettings = true @@ -212,6 +324,13 @@ class SettingsViewModel( } } + private fun refreshBugLogPreview() { + viewModelScope.launch { + val bytes = runCatching { bugReports.previewLogBytes() }.getOrDefault(0) + _state.update { it.copy(bugLogPreviewBytes = bytes) } + } + } + private suspend fun validateFolder(folder: ReceiveFolder) { _state.update { it.copy(isValidatingFolder = true) } val status = fileSystemService.validateReceiveFolder(folder) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/logging/AppLogger.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/logging/AppLogger.kt index b372dba..944ffaf 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/logging/AppLogger.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/logging/AppLogger.kt @@ -31,6 +31,8 @@ interface PlatformLogStore { val logDirectory: String fun append(line: String) fun listLogFiles(): List + /** Newest log content, up to [maxBytes], from the active file then rotated tails if needed. */ + fun readLatest(maxBytes: Long): String } expect fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore @@ -72,6 +74,9 @@ object AppLogger { fun listLogFiles(): List = store?.listLogFiles().orEmpty() + fun readLatestLogs(maxBytes: Long = DefaultBugReportLogBytes): String = + store?.readLatest(maxBytes).orEmpty() + private fun write(level: AppLogLevel, scope: String, message: String, fields: Map) { val line = buildString { append(platformNowMillis()) @@ -89,6 +94,8 @@ object AppLogger { } store?.append(line) } + + const val DefaultBugReportLogBytes: Long = 256 * 1024 } private fun String.sanitizeLogValue(): String = diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt index 0c64aa3..d630af9 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt @@ -10,8 +10,10 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolderKind import com.vnidrop.app.ui.theme.ThemeMode +import com.vnidrop.app.util.randomUuidString import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import okio.Path.Companion.toPath @@ -20,6 +22,10 @@ data class AppPreferences( val receiveFolder: ReceiveFolder, val themeMode: ThemeMode, val notificationsEnabled: Boolean, + /** Master opt-in for automatic telemetry + crash upload. Bug reports remain available always. */ + val diagnosticsEnabled: Boolean = false, + /** Stable anonymous install id; never an account or advertising id. */ + val diagnosticsInstallId: String = "", ) class AppPreferencesDefaults( @@ -27,6 +33,7 @@ class AppPreferencesDefaults( val receiveFolder: ReceiveFolder, val themeMode: ThemeMode, val notificationsEnabled: Boolean = false, + val diagnosticsEnabled: Boolean = false, ) interface PreferencesRepository { @@ -36,6 +43,9 @@ interface PreferencesRepository { suspend fun resetReceiveFolder() suspend fun setThemeMode(mode: ThemeMode) suspend fun setNotificationsEnabled(enabled: Boolean) + suspend fun setDiagnosticsEnabled(enabled: Boolean) + /** Ensures a durable install id exists and returns it. */ + suspend fun ensureDiagnosticsInstallId(): String } class AppPreferencesRepository( @@ -50,6 +60,8 @@ class AppPreferencesRepository( receiveFolder = resolveReceiveFolder(prefs, defaults.receiveFolder), themeMode = prefs[PreferenceKeys.ThemeMode]?.let { themeModeOrNull(it) } ?: defaults.themeMode, notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled, + diagnosticsEnabled = prefs[PreferenceKeys.DiagnosticsEnabled] ?: defaults.diagnosticsEnabled, + diagnosticsInstallId = prefs[PreferenceKeys.DiagnosticsInstallId].orEmpty(), ) } @@ -82,6 +94,24 @@ class AppPreferencesRepository( prefs[PreferenceKeys.NotificationsEnabled] = enabled } } + + override suspend fun setDiagnosticsEnabled(enabled: Boolean) { + dataStore.edit { prefs -> + prefs[PreferenceKeys.DiagnosticsEnabled] = enabled + } + } + + override suspend fun ensureDiagnosticsInstallId(): String { + val existing = preferences.first().diagnosticsInstallId + if (existing.isNotBlank()) return existing + val created = randomUuidString() + dataStore.edit { prefs -> + if (prefs[PreferenceKeys.DiagnosticsInstallId].isNullOrBlank()) { + prefs[PreferenceKeys.DiagnosticsInstallId] = created + } + } + return preferences.first().diagnosticsInstallId.ifBlank { created } + } } fun createAppPreferencesDataStore(appDataDir: String): DataStore = @@ -96,6 +126,8 @@ private object PreferenceKeys { val ReceiveFolderDisplayName = stringPreferencesKey("receive_folder_display_name") val ThemeMode = stringPreferencesKey("theme_mode") val NotificationsEnabled = booleanPreferencesKey("notifications_enabled") + val DiagnosticsEnabled = booleanPreferencesKey("diagnostics_enabled") + val DiagnosticsInstallId = stringPreferencesKey("diagnostics_install_id") } private fun resolveReceiveFolder(prefs: Preferences, defaults: ReceiveFolder): ReceiveFolder { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/util/Uuid.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/util/Uuid.kt new file mode 100644 index 0000000..5f1de13 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/util/Uuid.kt @@ -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() diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt new file mode 100644 index 0000000..e52c85f --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt @@ -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() + override fun write(report: CrashReport) { + items[report.id] = report + } + override fun list(): List = items.values.sortedByDescending { it.timestampMillis } + override fun delete(id: String) { + items.remove(id) + } +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index 2953f55..6fe61e5 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -14,9 +14,13 @@ import com.vnidrop.app.feature.app.AppViewModel import com.vnidrop.app.feature.receive.ReceiveHistoryDeleteTarget import com.vnidrop.app.feature.receive.ReceiveViewModel 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.notifications.NotificationPermission import com.vnidrop.app.preferences.AppPreferences +import com.vnidrop.app.preferences.PreferencesRepository import com.vnidrop.app.support.FakeCoreGateway import com.vnidrop.app.support.FakeFileSystemService import com.vnidrop.app.support.FakeFilePreviewRepository @@ -62,14 +66,7 @@ class ViewModelsTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) val preferences = preferences() val notifications = FakeNotificationService(NotificationPermission.Granted) - val viewModel = SettingsViewModel( - environment(), - { DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") }, - FakeFileSystemService(folder), - preferences, - notifications, - UiMessageController(), - ) + val viewModel = settingsViewModel(preferences, notifications) advanceUntilIdle() viewModel.setNotificationsEnabled(true) advanceUntilIdle() @@ -84,14 +81,7 @@ class ViewModelsTest { fun settingsUsernameKeepsSpacesWhileTypingAndPersistsAfterDebounce() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) val preferences = preferences() - val viewModel = SettingsViewModel( - environment(), - { DeviceInfo("Device", null, "OS", null, null) }, - FakeFileSystemService(folder), - preferences, - FakeNotificationService(), - UiMessageController(), - ) + val viewModel = settingsViewModel(preferences) advanceUntilIdle() viewModel.setUsername("Ada ") @@ -109,14 +99,7 @@ class ViewModelsTest { fun settingsKeepsNotificationsDisabledWhenPermissionIsDenied() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) val preferences = preferences() - val viewModel = SettingsViewModel( - environment(), - { DeviceInfo("Device", null, "OS", null, null) }, - FakeFileSystemService(folder), - preferences, - FakeNotificationService(NotificationPermission.Denied), - UiMessageController(), - ) + val viewModel = settingsViewModel(preferences, FakeNotificationService(NotificationPermission.Denied)) advanceUntilIdle() viewModel.setNotificationsEnabled(true) advanceUntilIdle() @@ -128,14 +111,7 @@ class ViewModelsTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) val preferences = preferences() val notifications = FakeNotificationService(NotificationPermission.Denied) - val viewModel = SettingsViewModel( - environment(), - { DeviceInfo("Device", null, "OS", null, null) }, - FakeFileSystemService(folder), - preferences, - notifications, - UiMessageController(), - ) + val viewModel = settingsViewModel(preferences, notifications) advanceUntilIdle() viewModel.openNotificationSettings() @@ -154,14 +130,7 @@ class ViewModelsTest { fun settingsReportsUnsupportedNotificationPlatforms() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) val preferences = preferences() - val viewModel = SettingsViewModel( - environment(), - { DeviceInfo("Device", null, "OS", null, null) }, - FakeFileSystemService(folder), - preferences, - FakeNotificationService(NotificationPermission.Unsupported), - UiMessageController(), - ) + val viewModel = settingsViewModel(preferences, FakeNotificationService(NotificationPermission.Unsupported)) advanceUntilIdle() viewModel.setNotificationsEnabled(true) advanceUntilIdle() @@ -169,6 +138,33 @@ class ViewModelsTest { 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 fun sendViewModelOwnsSelectedFileState() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) @@ -501,11 +497,38 @@ class ViewModelsTest { } 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 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( localId = "receive-$id", transferId = id, diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt index b6b3b16..5f61ab5 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -197,6 +197,16 @@ class FakePreferencesRepository( override suspend fun resetReceiveFolder() = Unit 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 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( diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.ios.kt new file mode 100644 index 0000000..9ec4294 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.ios.kt @@ -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 { + ensureDirectory() + val names = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty() + .filterIsInstance() + .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() +} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.ios.kt new file mode 100644 index 0000000..49d4127 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.ios.kt @@ -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 +} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.ios.kt index c91d1a0..d7a8aaf 100644 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.ios.kt +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.ios.kt @@ -1,19 +1,21 @@ package com.vnidrop.app.logging -import platform.Foundation.NSData import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.addressOf import kotlinx.cinterop.convert import kotlinx.cinterop.usePinned +import platform.Foundation.NSData import platform.Foundation.NSDate import platform.Foundation.NSFileManager import platform.Foundation.NSFileModificationDate import platform.Foundation.NSFileSize import platform.Foundation.NSNumber +import platform.Foundation.dataWithContentsOfFile import platform.Foundation.timeIntervalSince1970 import platform.posix.fclose import platform.posix.fopen import platform.posix.fwrite +import platform.posix.memcpy actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore = IosPlatformLogStore(appDataDir, policy) @@ -61,6 +63,31 @@ private class IosPlatformLogStore( .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() + 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() { if (policy.maxFiles == 0) { fileManager.removeItemAtPath(activePath, null) @@ -92,4 +119,31 @@ private class IosPlatformLogStore( val date = attributes[NSFileModificationDate] as? NSDate ?: return 0L 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 } diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.jvm.kt new file mode 100644 index 0000000..79fb77a --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.jvm.kt @@ -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 { + 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() + } +} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.jvm.kt new file mode 100644 index 0000000..d762725 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.jvm.kt @@ -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) + } +} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.jvm.kt index 6abf999..ab65244 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.jvm.kt @@ -1,6 +1,7 @@ package com.vnidrop.app.logging import java.io.File +import java.io.RandomAccessFile import java.nio.charset.StandardCharsets 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()) } } + @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() + 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() { if (policy.maxFiles == 0) { 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 + } + } +} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt index e38f947..0f2e2e0 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -86,6 +86,13 @@ class FoundationComposeTest { onResetFolder = {}, onNotificationsChanged = {}, onOpenNotificationSettings = {}, + onDiagnosticsChanged = {}, + onBugWhatChanged = {}, + onBugExpectedChanged = {}, + onBugStepsChanged = {}, + onBugContactChanged = {}, + onBugIncludeLogsChanged = {}, + onSubmitBugReport = {}, ) } } @@ -108,6 +115,13 @@ class FoundationComposeTest { onResetFolder = {}, onNotificationsChanged = { enabled = it }, onOpenNotificationSettings = {}, + onDiagnosticsChanged = {}, + onBugWhatChanged = {}, + onBugExpectedChanged = {}, + onBugStepsChanged = {}, + onBugContactChanged = {}, + onBugIncludeLogsChanged = {}, + onSubmitBugReport = {}, ) } } @@ -133,6 +147,13 @@ class FoundationComposeTest { onResetFolder = {}, onNotificationsChanged = {}, onOpenNotificationSettings = { opened = true }, + onDiagnosticsChanged = {}, + onBugWhatChanged = {}, + onBugExpectedChanged = {}, + onBugStepsChanged = {}, + onBugContactChanged = {}, + onBugIncludeLogsChanged = {}, + onSubmitBugReport = {}, ) } }