diff --git a/gradle.properties b/gradle.properties index 035c8de..7ac4041 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,9 +13,8 @@ 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). +# VniDrop: compile-time bug-report delivery surface. +# false → user-initiated bug reports fall back to a NoOp transport (never sent). # Enable per build only when endpoint and ingest key are configured: # ./gradlew … -Pvnidrop.diagnostics.included=true vnidrop.diagnostics.included=false diff --git a/localization/strings.json b/localization/strings.json index 2196c4f..49e9fda 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -1178,62 +1178,6 @@ "ru": "Имя устройства" } }, - "diagnostics_description": { - "context": "Settings > Diagnostics: explanation of what anonymous diagnostics collect.", - "translations": { - "en": "Send anonymous crash reports and usage events so we can improve VniDrop. You can turn this off anytime. Invitations, file paths, and transfer contents are never included.", - "fr": "Envoyer des rapports de plantage et des événements d’utilisation anonymes pour nous aider à améliorer VniDrop. Vous pouvez désactiver cela à tout moment. Les invitations, chemins de fichiers et contenus de transfert ne sont jamais inclus.", - "es": "Enviar informes de fallos y eventos de uso anónimos para ayudarnos a mejorar VniDrop. Puede desactivarlo en cualquier momento. Las invitaciones, las rutas de archivos y el contenido de las transferencias nunca se incluyen.", - "it": "Invia report di arresto anomalo ed eventi d’uso anonimi per aiutarci a migliorare VniDrop. Può disattivarlo in qualsiasi momento. Inviti, percorsi dei file e contenuti dei trasferimenti non vengono mai inclusi.", - "de": "Anonyme Absturzberichte und Nutzungsereignisse senden, damit wir VniDrop verbessern können. Sie können dies jederzeit deaktivieren. Einladungen, Dateipfade und Übertragungsinhalte werden niemals einbezogen.", - "pt": "Enviar relatórios de falhas e eventos de utilização anónimos para nos ajudar a melhorar o VniDrop. Pode desativar isto a qualquer momento. Convites, caminhos de ficheiros e conteúdos das transferências nunca são incluídos.", - "pl": "Wysyłaj anonimowe raporty o awariach i zdarzenia użytkowania, aby pomóc nam ulepszać VniDrop. Możesz to wyłączyć w dowolnej chwili. Zaproszenia, ścieżki plików i zawartość transferów nigdy nie są dołączane.", - "nl": "Verstuur anonieme crashrapporten en gebruiksgebeurtenissen zodat we VniDrop kunnen verbeteren. U kunt dit op elk moment uitschakelen. Uitnodigingen, bestandspaden en overdrachtsinhoud worden nooit meegestuurd.", - "ru": "Отправлять анонимные отчёты о сбоях и события использования, чтобы помочь нам улучшать VniDrop. Вы можете отключить это в любой момент. Приглашения, пути к файлам и содержимое передач никогда не включаются." - } - }, - "diagnostics_disabled_message": { - "context": "Settings > Diagnostics: confirmation shown when diagnostics are turned off.", - "translations": { - "en": "Diagnostics sharing is off.", - "fr": "Le partage des diagnostics est désactivé.", - "es": "El uso compartido de diagnósticos está desactivado.", - "it": "La condivisione dei dati diagnostici è disattivata.", - "de": "Die Freigabe von Diagnosedaten ist deaktiviert.", - "pt": "A partilha de diagnósticos está desativada.", - "pl": "Udostępnianie diagnostyki jest wyłączone.", - "nl": "Het delen van diagnostische gegevens is uitgeschakeld.", - "ru": "Передача диагностики отключена." - } - }, - "diagnostics_enabled_message": { - "context": "Settings > Diagnostics: confirmation shown when diagnostics are turned on.", - "translations": { - "en": "Diagnostics sharing is on.", - "fr": "Le partage des diagnostics est activé.", - "es": "El uso compartido de diagnósticos está activado.", - "it": "La condivisione dei dati diagnostici è attivata.", - "de": "Die Freigabe von Diagnosedaten ist aktiviert.", - "pt": "A partilha de diagnósticos está ativada.", - "pl": "Udostępnianie diagnostyki jest włączone.", - "nl": "Het delen van diagnostische gegevens is ingeschakeld.", - "ru": "Передача диагностики включена." - } - }, - "diagnostics_title": { - "context": "Settings > Diagnostics: toggle title.", - "translations": { - "en": "Share diagnostics", - "fr": "Partager les diagnostics", - "es": "Compartir diagnósticos", - "it": "Condividi dati diagnostici", - "de": "Diagnosedaten teilen", - "pt": "Partilhar diagnósticos", - "pl": "Udostępniaj diagnostykę", - "nl": "Diagnostische gegevens delen", - "ru": "Делиться диагностикой" - } - }, "error_camera": { "context": "Error: camera permission is needed to scan a QR code.", "translations": { diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 6c66e72..e240401 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -49,7 +49,7 @@ val desktopRustVariant = providers.gradleProperty("vnidrop.desktop.rustVariant") .orElse(Variant.Debug) // Compile-time switches (gradle.properties or -P…). -// included=false: no Share-diagnostics toggle, no telemetry/crash auto-upload stack. +// included=false: user-initiated bug reports use a NoOp transport (never sent). // endpoint/key both empty: transport is NoOp (safe default until Cloudflare is deployed). val diagnosticsIncluded: Boolean = (findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: false 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 deleted file mode 100644 index 00673b0..0000000 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.android.kt +++ /dev/null @@ -1,72 +0,0 @@ -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) { - if (!isValidDiagnosticId(report.id)) return - directory.mkdirs() - val target = File(directory, "${report.id}.crash") - val temporary = File(directory, ".${report.id}.tmp") - val payload = CrashReportCodec.encode(report) - temporary.writeText(payload, StandardCharsets.UTF_8) - if (!temporary.renameTo(target)) { - target.writeText(payload, StandardCharsets.UTF_8) - temporary.delete() - } - } - - @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) { - if (!isValidDiagnosticId(id)) return - File(directory, "$id.crash").delete() - } - - @Synchronized - override fun prune(olderThanTimestampMillis: Long, maxCount: Int) { - require(maxCount > 0) { "maxCount must be positive" } - if (!directory.isDirectory) return - directory.listFiles { file -> file.isFile && file.name.endsWith(".tmp") } - .orEmpty() - .forEach(File::delete) - val reports = directory - .listFiles { file -> file.isFile && file.name.endsWith(".crash") } - .orEmpty() - .mapNotNull { file -> - val report = runCatching { - CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8)) - }.getOrNull() - if (report == null) { - file.delete() - null - } else { - file to report - } - } - .sortedByDescending { (_, report) -> report.timestampMillis } - reports.forEachIndexed { index, (file, report) -> - if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) file.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 deleted file mode 100644 index d762725..0000000 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.android.kt +++ /dev/null @@ -1,9 +0,0 @@ -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/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index 4e18485..8218b6b 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -80,10 +80,6 @@ Auf NFC-Tag schreiben Gerätemodell Gerätename - Anonyme Absturzberichte und Nutzungsereignisse senden, damit wir VniDrop verbessern können. Sie können dies jederzeit deaktivieren. Einladungen, Dateipfade und Übertragungsinhalte werden niemals einbezogen. - Die Freigabe von Diagnosedaten ist deaktiviert. - Die Freigabe von Diagnosedaten ist aktiviert. - Diagnosedaten teilen Für das Scannen eines QR-Codes ist Kamerazugriff erforderlich. Geräteinformationen konnten nicht geladen werden. Am Ziel ist bereits eine Datei mit demselben Namen vorhanden. Wählen Sie einen anderen Ordner oder entfernen Sie die vorhandene Datei. diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index d8112f2..0b06c85 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -80,10 +80,6 @@ Escribir en etiqueta NFC Modelo del dispositivo Nombre del dispositivo - Enviar informes de fallos y eventos de uso anónimos para ayudarnos a mejorar VniDrop. Puede desactivarlo en cualquier momento. Las invitaciones, las rutas de archivos y el contenido de las transferencias nunca se incluyen. - El uso compartido de diagnósticos está desactivado. - El uso compartido de diagnósticos está activado. - Compartir diagnósticos Se necesita acceso a la cámara para escanear un código QR. No se pudo cargar la información del dispositivo. Ya existe un archivo con el mismo nombre en el destino. Elija otra carpeta o elimine el archivo existente. diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index 871fcc8..4d09439 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -80,10 +80,6 @@ Écrire sur un tag NFC Modèle de l’appareil Nom de l’appareil - Envoyer des rapports de plantage et des événements d’utilisation anonymes pour nous aider à améliorer VniDrop. Vous pouvez désactiver cela à tout moment. Les invitations, chemins de fichiers et contenus de transfert ne sont jamais inclus. - Le partage des diagnostics est désactivé. - Le partage des diagnostics est activé. - Partager les diagnostics L’accès à la caméra est nécessaire pour scanner un QR code. Impossible de charger les informations de l’appareil. Un fichier portant le même nom existe déjà dans la destination. Choisissez un autre dossier ou supprimez le fichier existant. diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index d8aab31..15d643b 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -80,10 +80,6 @@ Scrivi su tag NFC Modello del dispositivo Nome del dispositivo - Invia report di arresto anomalo ed eventi d’uso anonimi per aiutarci a migliorare VniDrop. Può disattivarlo in qualsiasi momento. Inviti, percorsi dei file e contenuti dei trasferimenti non vengono mai inclusi. - La condivisione dei dati diagnostici è disattivata. - La condivisione dei dati diagnostici è attivata. - Condividi dati diagnostici Per scansionare un codice QR è necessario l’accesso alla fotocamera. Impossibile caricare le informazioni sul dispositivo. Nella destinazione esiste già un file con lo stesso nome. Scelga un’altra cartella o rimuova il file esistente. diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index f6940b5..c2c7ff0 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -80,10 +80,6 @@ Naar NFC-tag schrijven Apparaatmodel Apparaatnaam - Verstuur anonieme crashrapporten en gebruiksgebeurtenissen zodat we VniDrop kunnen verbeteren. U kunt dit op elk moment uitschakelen. Uitnodigingen, bestandspaden en overdrachtsinhoud worden nooit meegestuurd. - Het delen van diagnostische gegevens is uitgeschakeld. - Het delen van diagnostische gegevens is ingeschakeld. - Diagnostische gegevens delen Voor het scannen van een QR-code is toegang tot de camera vereist. Apparaatgegevens konden niet worden geladen. Er staat al een bestand met dezelfde naam in de doelmap. Kies een andere map of verwijder het bestaande bestand. diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index 9838356..0ccd2ba 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -80,10 +80,6 @@ Zapisz na tagu NFC Model urządzenia Nazwa urządzenia - Wysyłaj anonimowe raporty o awariach i zdarzenia użytkowania, aby pomóc nam ulepszać VniDrop. Możesz to wyłączyć w dowolnej chwili. Zaproszenia, ścieżki plików i zawartość transferów nigdy nie są dołączane. - Udostępnianie diagnostyki jest wyłączone. - Udostępnianie diagnostyki jest włączone. - Udostępniaj diagnostykę Do zeskanowania kodu QR wymagany jest dostęp do aparatu. Nie udało się wczytać informacji o urządzeniu. W miejscu docelowym istnieje już plik o tej samej nazwie. Wybierz inny folder lub usuń istniejący plik. diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index 8cab366..267acc3 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -80,10 +80,6 @@ Escrever em etiqueta NFC Modelo do dispositivo Nome do dispositivo - Enviar relatórios de falhas e eventos de utilização anónimos para nos ajudar a melhorar o VniDrop. Pode desativar isto a qualquer momento. Convites, caminhos de ficheiros e conteúdos das transferências nunca são incluídos. - A partilha de diagnósticos está desativada. - A partilha de diagnósticos está ativada. - Partilhar diagnósticos É necessário acesso à câmara para ler um código QR. Não foi possível carregar as informações do dispositivo. Já existe um ficheiro com o mesmo nome no destino. Escolha outra pasta ou remova o ficheiro existente. diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index 897a67d..79d24db 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -80,10 +80,6 @@ Записать на NFC-метку Модель устройства Имя устройства - Отправлять анонимные отчёты о сбоях и события использования, чтобы помочь нам улучшать VniDrop. Вы можете отключить это в любой момент. Приглашения, пути к файлам и содержимое передач никогда не включаются. - Передача диагностики отключена. - Передача диагностики включена. - Делиться диагностикой Для сканирования QR-кода требуется доступ к камере. Не удалось загрузить сведения об устройстве. В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл. diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 611355c..d9e49b8 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -80,10 +80,6 @@ Write to NFC tag Device model Device name - Send anonymous crash reports and usage events so we can improve VniDrop. You can turn this off anytime. Invitations, file paths, and transfer contents are never included. - Diagnostics sharing is off. - Diagnostics sharing is on. - Share diagnostics Camera access is required to scan a QR code. Could not load device information. A file with the same name already exists in the destination. Choose another folder or remove the existing file. diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index 260a3ce..de8e75a 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -80,7 +80,6 @@ fun App( graph.coreRepository, graph.preferencesRepository, graph.messages, - graph.diagnostics, ) } val sendViewModel = viewModel { @@ -105,7 +104,6 @@ fun App( 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 6f64179..304d917 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt @@ -19,9 +19,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.launch class AppGraph( val dependencies: AppDependencies, @@ -40,11 +37,9 @@ 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, @@ -75,12 +70,6 @@ class AppGraph( init { AppLogger.initialize(dependencies.environment.defaultCoreDataDir) diagnostics.start() - applicationScope.launch { - visibility.isForeground - .drop(1) - .filter { isForeground -> !isForeground } - .collect { diagnostics.telemetry.flush() } - } } fun close() { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/CrashReporter.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/CrashReporter.kt deleted file mode 100644 index ed2d961..0000000 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/CrashReporter.kt +++ /dev/null @@ -1,158 +0,0 @@ -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.CancellationException -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlin.concurrent.atomics.AtomicBoolean -import kotlin.concurrent.atomics.AtomicReference -import kotlin.concurrent.atomics.ExperimentalAtomicApi - -/** - * Captures uncaught exceptions to disk, then uploads on a later launch when - * diagnostics is enabled (and when a real [DiagnosticsTransport] is wired). - */ -@OptIn(ExperimentalAtomicApi::class) -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, -) { - private val installed = AtomicBoolean(false) - private val observingPreferences = AtomicBoolean(false) - private val capturePolicy = AtomicReference(CrashCapturePolicy()) - - fun startObservingPreferences() { - if (!observingPreferences.compareAndSet(false, true)) return - scope.launch { - preferencesRepository.preferences.collect { prefs -> - capturePolicy.store( - CrashCapturePolicy( - installId = prefs.diagnosticsInstallId, - diagnosticsEnabled = prefs.diagnosticsEnabled, - ), - ) - if (!prefs.diagnosticsEnabled) { - runCatching(::deleteAllPending) - } - } - } - } - - fun installUnhandledExceptionHandler() { - if (!DiagnosticsBuildConfig.INCLUDED) return - if (!installed.compareAndSet(false, true)) return - installPlatformCrashHook { throwable -> - capture(throwable) - } - } - - fun capture(throwable: Throwable, diagnosticsEnabledOverride: Boolean? = null): CrashReport { - val policy = capturePolicy.load() - val report = CrashReport( - id = randomUuidString(), - timestampMillis = platformNowMillis(), - installId = sanitizeDiagnosticsInstallId(policy.installId), - appVersion = appVersion.takeUtf8Bytes(DiagnosticsJson.MaxAppVersionBytes), - platform = platform.takeUtf8Bytes(DiagnosticsJson.MaxPlatformBytes), - exceptionType = throwable::class.simpleName ?: "Throwable", - exceptionMessage = LogRedactor.redact(throwable.message.orEmpty()).takeUtf8Bytes(MaxMessageBytes), - stackTrace = LogRedactor.redact(throwable.stackTraceToString()).takeUtf8Bytes(MaxStackBytes), - breadcrumbs = breadcrumbs.snapshot(), - diagnosticsEnabledAtCapture = diagnosticsEnabledOverride ?: policy.diagnosticsEnabled, - ) - if (report.diagnosticsEnabledAtCapture != false) { - runCatching { store.write(report) } - runCatching { - store.prune( - olderThanTimestampMillis = platformNowMillis() - LocalRetentionMillis, - maxCount = MaxLocalCrashCount, - ) - } - } - AppLogger.error("crash", "captured crash ${report.id}", throwable) - return report - } - - /** - * Uploads pending crashes that were captured with diagnostics enabled. - * Local files are deleted after successful delivery or bounded by local retention. - */ - suspend fun flushPending() { - store.prune( - olderThanTimestampMillis = platformNowMillis() - LocalRetentionMillis, - maxCount = MaxLocalCrashCount, - ) - for (report in store.list()) { - val preferences = preferencesRepository.preferences.first() - if (!preferences.diagnosticsEnabled || capturePolicy.load().diagnosticsEnabled == false) { - deleteAllPending() - return - } - if (report.diagnosticsEnabledAtCapture == false) { - store.delete(report.id) - continue - } - val installId = sanitizeDiagnosticsInstallId( - preferences.diagnosticsInstallId.ifBlank { - preferencesRepository.ensureDiagnosticsInstallId() - }, - ) - val resolved = report.copy( - installId = report.installId.ifBlank { installId }, - diagnosticsEnabledAtCapture = true, - ) - if (resolved != report) store.write(resolved) - if ( - !preferencesRepository.preferences.first().diagnosticsEnabled || - capturePolicy.load().diagnosticsEnabled == false - ) { - deleteAllPending() - return - } - val result = try { - transport.sendCrash(resolved) - } catch (cancelled: CancellationException) { - throw cancelled - } catch (error: Throwable) { - Result.failure(error) - } - if (result.isSuccess) { - store.delete(resolved.id) - continue - } - val error = result.exceptionOrNull() - if (error?.isPermanentDiagnosticsPayloadRejection() == true) { - store.delete(resolved.id) - continue - } - break - } - } - - private fun deleteAllPending() { - store.list().forEach { report -> store.delete(report.id) } - } - - companion object { - private const val MaxMessageBytes = 2_000 - private const val MaxStackBytes = 32_000 - private const val MaxLocalCrashCount = 20 - private const val LocalRetentionMillis = 30L * 86_400_000L - } -} - -private data class CrashCapturePolicy( - val installId: String = "", - val diagnosticsEnabled: Boolean? = null, -) - -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 index 4b3cd8c..e035f65 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsCoordinator.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsCoordinator.kt @@ -5,67 +5,34 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch /** - * Owns diagnostics services for the app process: telemetry, crashes, bug reports. + * Owns user-initiated bug reports for the app process. * - * When [DiagnosticsBuildConfig.INCLUDED] is false (compile-time), telemetry and - * crash auto-reporting are never started; [bugReports] still works for support. + * Telemetry and crash auto-reporting were removed; only [bugReports] remains, + * and it only sends when the user submits a report from Settings. */ class DiagnosticsCoordinator( val preferencesRepository: PreferencesRepository, val transport: DiagnosticsTransport, val breadcrumbs: BreadcrumbBuffer, - val telemetry: TelemetryRecorder, - val crashReporter: CrashReporter, val bugReports: BugReportService, private val scope: CoroutineScope, - private val included: Boolean = DiagnosticsBuildConfig.INCLUDED, ) { fun start() { - // Install id is useful for bug-report correlation even without telemetry. + // Install id is useful for bug-report correlation. scope.launch { preferencesRepository.ensureDiagnosticsInstallId() } - if (!included) return - crashReporter.startObservingPreferences() - crashReporter.installUnhandledExceptionHandler() - scope.launch { - crashReporter.flushPending() - } - } - - fun record(name: String, properties: Map = emptyMap()) { - if (!included) return - telemetry.record(name, properties) } companion object { fun create( - appDataDir: String, appVersion: String, platform: String, preferencesRepository: PreferencesRepository, scope: CoroutineScope, transport: DiagnosticsTransport = NoOpDiagnosticsTransport(), - included: Boolean = DiagnosticsBuildConfig.INCLUDED, ): DiagnosticsCoordinator { val breadcrumbs = BreadcrumbBuffer() - val crashStore = createPendingCrashStore(appDataDir) - val telemetry = TelemetryRecorder( - preferencesRepository = preferencesRepository, - transport = transport, - breadcrumbs = breadcrumbs, - scope = scope, - included = included, - ) - val crashReporter = CrashReporter( - store = crashStore, - preferencesRepository = preferencesRepository, - transport = transport, - breadcrumbs = breadcrumbs, - appVersion = appVersion, - platform = platform, - scope = scope, - ) val bugReports = BugReportService( preferencesRepository = preferencesRepository, transport = transport, @@ -77,11 +44,8 @@ class DiagnosticsCoordinator( preferencesRepository = preferencesRepository, transport = transport, breadcrumbs = breadcrumbs, - telemetry = telemetry, - crashReporter = crashReporter, bugReports = bugReports, scope = scope, - included = included, ) } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsJson.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsJson.kt index 39087cd..3476416 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsJson.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsJson.kt @@ -10,83 +10,6 @@ internal object DiagnosticsJson { internal const val MaxPlatformBytes = 40 private const val MaxBreadcrumbsJsonBytes = 16_000 private const val MaxBreadcrumbs = 40 - private const val SizedBatchId = "00000000-0000-4000-8000-000000000000" - - fun eventsBody( - batchId: String, - installId: String, - appVersion: String, - platform: String, - events: List, - ): String = buildString { - append('{') - appendJsonField("batchId", batchId) - append(',') - appendJsonField("installId", installId) - append(',') - appendJsonField("appVersion", appVersion) - append(',') - appendJsonField("platform", platform) - append(',') - append("\"events\":[") - events.forEachIndexed { index, event -> - if (index > 0) append(',') - append('{') - appendJsonField("name", event.name) - append(',') - append("\"timestampMillis\":") - append(event.timestampMillis) - append(',') - append("\"schemaVersion\":") - append(event.schemaVersion) - append(',') - append("\"properties\":") - appendStringMap(event.properties) - append('}') - } - append("]}") - } - - fun eventBatchFitsRequest(events: List): Boolean = - eventsBody( - batchId = SizedBatchId, - installId = "\u0000".repeat(MaxInstallIdBytes), - appVersion = "\u0000".repeat(MaxAppVersionBytes), - platform = "\u0000".repeat(MaxPlatformBytes), - events = events, - ).encodeToByteArray().size <= MaxRequestBytes - - fun crashBody(report: CrashReport): String = buildString { - append('{') - appendJsonField("id", report.id) - append(',') - append("\"timestampMillis\":") - append(report.timestampMillis) - append(',') - appendJsonField("installId", report.installId) - append(',') - appendJsonField("appVersion", report.appVersion) - append(',') - appendJsonField("platform", report.platform) - append(',') - appendJsonField("exceptionType", report.exceptionType) - append(',') - appendJsonField("exceptionMessage", report.exceptionMessage) - append(',') - appendJsonField("stackTrace", report.stackTrace) - append(',') - append("\"diagnosticsEnabledAtCapture\":") - append(requireNotNull(report.diagnosticsEnabledAtCapture) { - "crash consent must be resolved before delivery" - }) - append(',') - append("\"schemaVersion\":") - append(report.schemaVersion) - append(',') - append("\"breadcrumbs\":") - appendBreadcrumbs(report.breadcrumbs) - append('}') - } fun bugBody(report: BugReport): String { val logs = if (report.includeLogs) report.logs else "" diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsModels.kt index 76943fc..43392df 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsModels.kt @@ -5,40 +5,12 @@ package com.vnidrop.app.diagnostics * 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, -) - -/** One idempotent upload unit; [id] remains stable when delivery is retried. */ -data class TelemetryBatch( - val id: String, - val events: List, -) - 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, - /** `null` only while a startup crash is waiting for the persisted preference to load. */ - val diagnosticsEnabledAtCapture: Boolean?, - val schemaVersion: Int = DiagnosticsSchemaVersion, -) - data class DeviceSnapshot( val deviceName: String?, val deviceModel: String?, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTransport.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTransport.kt index dac525f..cfe01ec 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTransport.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTransport.kt @@ -1,50 +1,25 @@ package com.vnidrop.app.diagnostics -/** Network boundary for diagnostics; keep batching and validation client-side. */ +/** Network boundary for bug reports; keep validation client-side. */ interface DiagnosticsTransport { - suspend fun sendEvents(batch: TelemetryBatch): Result - suspend fun sendCrash(report: CrashReport): Result suspend fun sendBugReport(report: BugReport): Result } internal class DiagnosticsUnavailableException : IllegalStateException("diagnostics delivery is not configured") -internal fun Throwable.isPermanentDiagnosticsPayloadRejection(): Boolean = - this is DiagnosticsPayloadException || - (this is DiagnosticsHttpException && statusCode in setOf(400, 413, 415, 422)) - /** Fails delivery without leaving the device. Used until a remote endpoint is configured. */ class NoOpDiagnosticsTransport : DiagnosticsTransport { - override suspend fun sendEvents(batch: TelemetryBatch): Result = unavailable() - override suspend fun sendCrash(report: CrashReport): Result = unavailable() - override suspend fun sendBugReport(report: BugReport): Result = unavailable() - - private fun unavailable(): Result = Result.failure(DiagnosticsUnavailableException()) + override suspend fun sendBugReport(report: BugReport): Result = + Result.failure(DiagnosticsUnavailableException()) } /** * Test double that records calls and can fail on demand. */ class RecordingDiagnosticsTransport : DiagnosticsTransport { - val eventBatches = mutableListOf() - val events: List> - get() = eventBatches.map(TelemetryBatch::events) - 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(batch: TelemetryBatch): Result { - eventBatches += batch - 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/HttpDiagnosticsTransport.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/HttpDiagnosticsTransport.kt index 14e68e4..a2305eb 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/HttpDiagnosticsTransport.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/HttpDiagnosticsTransport.kt @@ -27,33 +27,6 @@ class HttpDiagnosticsTransport( } } - override suspend fun sendEvents(batch: TelemetryBatch): Result { - if (batch.events.isEmpty()) return Result.success(Unit) - if (batch.events.size > TelemetryRecorder.MaxEventsPerBatch) { - return Result.failure(DiagnosticsPayloadException("diagnostics event batch is too large")) - } - if (batch.events.any { it.name.isBlank() || it.timestampMillis < 0 }) { - return Result.failure(DiagnosticsPayloadException("diagnostics event batch is invalid")) - } - val installId = sanitizeDiagnosticsInstallId(installIdProvider()) - val body = DiagnosticsJson.eventsBody( - batch.id, - installId, - appVersion.takeUtf8Bytes(DiagnosticsJson.MaxAppVersionBytes), - platform.takeUtf8Bytes(DiagnosticsJson.MaxPlatformBytes), - batch.events, - ) - return postJson("/v1/events", body, installId, batch.id) - } - - override suspend fun sendCrash(report: CrashReport): Result { - if (report.diagnosticsEnabledAtCapture == null) { - return Result.failure(DiagnosticsPayloadException("crash consent is unresolved")) - } - val body = DiagnosticsJson.crashBody(report) - return postJson("/v1/crashes", body, report.installId, report.id) - } - override suspend fun sendBugReport(report: BugReport): Result { val body = DiagnosticsJson.bugBody(report) return postJson("/v1/bugs", body, report.installId, report.id) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.kt deleted file mode 100644 index c009162..0000000 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.kt +++ /dev/null @@ -1,193 +0,0 @@ -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 - fun delete(id: String) - fun prune(olderThanTimestampMillis: Long, maxCount: Int) -} - -expect fun createPendingCrashStore(appDataDir: String): PendingCrashStore - -internal object CrashReportCodec { - private const val FieldSep = "\u001f" - private const val RecordSep = "\u001e" - private const val Version2Prefix = "vnidrop-crash-v2\n" - private const val MaxEncodedChars = 512 * 1024 - - fun encode(report: CrashReport): String = buildString { - fun field(key: String, value: String) { - append(key) - append('=') - append(value.hexEncode()) - append('\n') - } - append(Version2Prefix) - 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", - when (report.diagnosticsEnabledAtCapture) { - true -> "1" - false -> "0" - null -> "u" - }, - ) - field("schema", report.schemaVersion.toString()) - val breadcrumbs = report.breadcrumbs.take(40) - field("crumb.count", breadcrumbs.size.toString()) - breadcrumbs.forEachIndexed { crumbIndex, crumb -> - field("crumb.$crumbIndex.ts", crumb.timestampMillis.toString()) - field("crumb.$crumbIndex.name", crumb.name) - val properties = crumb.properties.entries.take(MaxDiagnosticProperties) - field("crumb.$crumbIndex.prop.count", properties.size.toString()) - properties.forEachIndexed { propertyIndex, (key, value) -> - field("crumb.$crumbIndex.prop.$propertyIndex.key", key) - field("crumb.$crumbIndex.prop.$propertyIndex.value", value) - } - } - } - - fun decode(raw: String): CrashReport? { - if (raw.isBlank() || raw.length > MaxEncodedChars) return null - return if (raw.startsWith(Version2Prefix)) decodeVersion2(raw) else decodeLegacy(raw) - } - - private fun decodeVersion2(raw: String): CrashReport? { - val map = linkedMapOf() - for (part in raw.removePrefix(Version2Prefix).lineSequence()) { - if (part.isEmpty()) continue - val eq = part.indexOf('=') - if (eq <= 0) continue - val key = part.substring(0, eq) - val value = part.substring(eq + 1).hexDecode() ?: return null - map[key] = value - } - val crumbCount = map["crumb.count"]?.toIntOrNull()?.takeIf { it in 0..40 } ?: return null - val crumbs = buildList { - repeat(crumbCount) { crumbIndex -> - val timestamp = map["crumb.$crumbIndex.ts"] - ?.toLongOrNull() - ?.takeIf { it >= 0 } - ?: return null - val name = map["crumb.$crumbIndex.name"]?.takeIf { it.isNotBlank() } ?: return null - val propertyCount = map["crumb.$crumbIndex.prop.count"] - ?.toIntOrNull() - ?.takeIf { it in 0..MaxDiagnosticProperties } - ?: return null - val properties = buildMap { - repeat(propertyCount) { propertyIndex -> - val key = map["crumb.$crumbIndex.prop.$propertyIndex.key"] ?: return null - val value = map["crumb.$crumbIndex.prop.$propertyIndex.value"] ?: return null - put(key, value) - } - } - add(Breadcrumb(name = name, timestampMillis = timestamp, properties = properties)) - } - } - return reportFromFields(map, crumbs) - } - - private fun decodeLegacy(raw: String): CrashReport? { - 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 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()?.takeIf { it >= 0 } ?: return@mapNotNull null - val name = pieces[1].takeIf { it.isNotBlank() } ?: return@mapNotNull null - 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 reportFromFields(map, crumbs) - } - - private fun reportFromFields( - map: Map, - crumbs: List, - ): CrashReport? { - val id = map["id"]?.takeIf(::isValidDiagnosticId) ?: return null - val timestamp = map["ts"]?.toLongOrNull()?.takeIf { it >= 0 } ?: return null - val exceptionType = map["type"]?.takeIf { it.isNotBlank() } ?: return null - val schemaVersion = map["schema"]?.toIntOrNull() - ?.takeIf { it == DiagnosticsSchemaVersion } - ?: return null - val diagnosticsEnabled: Boolean? = when (map["diag"]) { - "1" -> true - "0" -> false - "u" -> null - else -> return null - } - return CrashReport( - id = id, - timestampMillis = timestamp, - installId = map["install"].orEmpty(), - appVersion = map["app"].orEmpty(), - platform = map["platform"].orEmpty(), - exceptionType = exceptionType, - exceptionMessage = map["message"].orEmpty(), - stackTrace = map["stack"].orEmpty(), - breadcrumbs = crumbs, - diagnosticsEnabledAtCapture = diagnosticsEnabled, - schemaVersion = schemaVersion, - ) - } -} - -internal fun isValidDiagnosticId(id: String): Boolean = - DiagnosticIdPattern.matches(id) - -private val DiagnosticIdPattern = - Regex("^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$") - -private fun String.hexEncode(): String { - val digits = "0123456789abcdef" - return buildString(length * 2) { - for (byte in this@hexEncode.encodeToByteArray()) { - val value = byte.toInt() and 0xff - append(digits[value ushr 4]) - append(digits[value and 0x0f]) - } - } -} - -private fun String.hexDecode(): String? { - if (length % 2 != 0) return null - val bytes = ByteArray(length / 2) - for (index in bytes.indices) { - val high = this[index * 2].digitToIntOrNull(16) ?: return null - val low = this[index * 2 + 1].digitToIntOrNull(16) ?: return null - bytes[index] = ((high shl 4) or low).toByte() - } - return runCatching { bytes.decodeToString(throwOnInvalidSequence = true) }.getOrNull() -} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/TelemetryRecorder.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/TelemetryRecorder.kt deleted file mode 100644 index 66b0e06..0000000 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/TelemetryRecorder.kt +++ /dev/null @@ -1,216 +0,0 @@ -package com.vnidrop.app.diagnostics - -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.CancellationException -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withTimeoutOrNull -import kotlin.concurrent.atomics.AtomicReference -import kotlin.concurrent.atomics.ExperimentalAtomicApi - -/** - * Product telemetry: sparse events, gated by diagnostics opt-in. - * Events are buffered and flushed in batches when transport is available. - */ -@OptIn(ExperimentalAtomicApi::class) -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 flushIntervalMillis: Long = DefaultFlushIntervalMillis, - private val retryBackoffMillis: Long = DefaultRetryBackoffMillis, - private val automaticRetryCount: Int = DefaultAutomaticRetryCount, - private val included: Boolean = true, -) { - private val bufferMutex = Mutex() - private val state = AtomicReference(TelemetryState()) - private val flushSignals = Channel(Channel.CONFLATED) - - init { - require(maxBufferSize > 0) { "maxBufferSize must be positive" } - require(flushThreshold > 0) { "flushThreshold must be positive" } - require(flushIntervalMillis > 0) { "flushIntervalMillis must be positive" } - require(retryBackoffMillis > 0) { "retryBackoffMillis must be positive" } - require(automaticRetryCount >= 0) { "automaticRetryCount must not be negative" } - if (included) { - scope.launch { - preferencesRepository.preferences - .map { it.diagnosticsEnabled } - .distinctUntilChanged() - .collect { isEnabled -> - updateState { current -> - if (isEnabled) current.copy(enabled = true) else TelemetryState(enabled = false) - } - flushSignals.trySend(Unit) - } - } - scope.launch { runAutomaticFlushes() } - } - } - - fun record(name: String, properties: Map = emptyMap()) { - if (!included) return - val sanitizedName = sanitizeDiagnosticName(name) - if (sanitizedName.isBlank()) return - val sanitizedProperties = sanitizeDiagnosticProperties(properties) - breadcrumbs.add(sanitizedName, sanitizedProperties) - val event = TelemetryEvent( - name = sanitizedName, - timestampMillis = platformNowMillis(), - properties = sanitizedProperties, - ) - while (true) { - val current = state.load() - if (current.enabled == false) return - val remainingCapacity = - (maxBufferSize - current.retryBatch?.events.orEmpty().size).coerceAtLeast(0) - val nextBuffer = (current.buffer + event).takeLast(remainingCapacity) - if (state.compareAndSet(current, current.copy(buffer = nextBuffer))) { - flushSignals.trySend(Unit) - return - } - } - } - - suspend fun flush(): Result { - return bufferMutex.withLock { - var discardedFailure: Throwable? = null - var outcome: Result? = null - while (outcome == null) { - val current = state.load() - if (current.enabled != true) return@withLock Result.success(Unit) - val pendingRetry = current.retryBatch - val events = if (pendingRetry == null) nextBatchEvents(current.buffer) else emptyList() - if (pendingRetry == null && events.isEmpty()) { - outcome = discardedFailure?.let { Result.failure(it) } ?: Result.success(Unit) - continue - } - val batch: TelemetryBatch - if (pendingRetry != null) { - batch = pendingRetry - } else { - val prepared = TelemetryBatch(id = randomUuidString(), events = events) - val next = current.copy( - buffer = current.buffer.drop(events.size), - retryBatch = prepared, - ) - if (!state.compareAndSet(current, next)) continue - batch = prepared - } - if (state.load().retryBatch != batch) continue - val result = try { - transport.sendEvents(batch) - } catch (cancelled: CancellationException) { - throw cancelled - } catch (error: Throwable) { - Result.failure(error) - } - if (result.isSuccess) { - clearRetryBatch(batch) - continue - } - val error = result.exceptionOrNull() ?: IllegalStateException("diagnostics event delivery failed") - if (error.isPermanentDiagnosticsPayloadRejection()) { - clearRetryBatch(batch) - discardedFailure = discardedFailure ?: error - continue - } - outcome = result - } - checkNotNull(outcome) - } - } - - private fun nextBatchEvents(events: List): List { - if (events.isEmpty()) return emptyList() - var minimum = 1 - var maximum = minOf(events.size, MaxEventsPerBatch) - var accepted = 1 - while (minimum <= maximum) { - val candidateSize = minimum + (maximum - minimum) / 2 - if (DiagnosticsJson.eventBatchFitsRequest(events.take(candidateSize))) { - accepted = candidateSize - minimum = candidateSize + 1 - } else { - maximum = candidateSize - 1 - } - } - return events.take(accepted) - } - - fun pendingCount(): Int { - val current = state.load() - return current.retryBatch?.events.orEmpty().size + current.buffer.size - } - - private suspend fun runAutomaticFlushes() { - while (true) { - flushSignals.receive() - var retries = 0 - while (true) { - val current = state.load() - if (current.enabled != true || pendingCount() == 0) break - if (current.retryBatch == null && pendingCount() < flushThreshold) { - val signalled = withTimeoutOrNull(flushIntervalMillis) { - flushSignals.receive() - true - } ?: false - if (signalled) continue - } - - val result = flush() - if (result.isSuccess || pendingCount() == 0) { - retries = 0 - continue - } - val error = result.exceptionOrNull() - if (error?.isPermanentDiagnosticsPayloadRejection() == true || retries >= automaticRetryCount) { - break - } - retries += 1 - delay(retryBackoffMillis) - } - } - } - - private fun clearRetryBatch(batch: TelemetryBatch) { - while (true) { - val current = state.load() - if (current.retryBatch != batch) return - if (state.compareAndSet(current, current.copy(retryBatch = null))) return - } - } - - private fun updateState(update: (TelemetryState) -> TelemetryState) { - while (true) { - val current = state.load() - if (state.compareAndSet(current, update(current))) return - } - } - - companion object { - const val DefaultMaxBuffer = 100 - const val DefaultFlushThreshold = 20 - const val MaxEventsPerBatch = 50 - const val DefaultFlushIntervalMillis = 30_000L - const val DefaultRetryBackoffMillis = 30_000L - const val DefaultAutomaticRetryCount = 3 - } -} - -private data class TelemetryState( - val enabled: Boolean? = null, - val buffer: List = emptyList(), - val retryBatch: TelemetryBatch? = null, -) 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 a90a163..a34a8f3 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,7 +6,6 @@ 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 @@ -38,14 +37,12 @@ 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 { val relaySettings = preferencesRepository.preferences.first().relaySettings repository.initialize(environment.defaultCoreDataDir, relaySettings).onFailure(messages::error) @@ -59,6 +56,5 @@ 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 c4067bb..4d78324 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 @@ -18,7 +18,6 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig import com.vnidrop.app.ui.icons.AppIcon import com.vnidrop.app.ui.icons.PlatformIcon import com.vnidrop.app.ui.theme.LocalVniDropColors @@ -46,8 +45,6 @@ import vnidrop.shared.generated.resources.about_privacy_title import vnidrop.shared.generated.resources.about_tagline import vnidrop.shared.generated.resources.about_title import vnidrop.shared.generated.resources.device_model_title -import vnidrop.shared.generated.resources.diagnostics_description -import vnidrop.shared.generated.resources.diagnostics_title import vnidrop.shared.generated.resources.os_version_title import vnidrop.shared.generated.resources.value_unavailable import vnidrop.shared.generated.resources.version_title @@ -57,7 +54,6 @@ private val PrivacyPolicyUrl = com.vnidrop.app.AppConfig.PRIVACY_POLICY_URL @Composable internal fun AboutSettings( state: SettingsState, - onDiagnosticsChanged: (Boolean) -> Unit, onReportBug: () -> Unit, onBack: () -> Unit, showBack: Boolean, @@ -130,17 +126,6 @@ internal fun AboutSettings( } SettingsGroup { - if (DiagnosticsBuildConfig.INCLUDED) { - SettingsToggleRow( - icon = AppIcon.Info, - title = stringResource(Res.string.diagnostics_title), - description = stringResource(Res.string.diagnostics_description), - checked = state.diagnosticsEnabled, - enabled = true, - onCheckedChange = onDiagnosticsChanged, - ) - SettingsDivider() - } SettingsRow( icon = AppIcon.Bug, title = stringResource(Res.string.about_bug_report), 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 1f9fd18..e0f5320 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 @@ -33,7 +33,6 @@ 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, 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 b303c13..19f9549 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 @@ -23,7 +23,6 @@ fun SettingsScreen( onResetFolder: () -> Unit, onNotificationsChanged: (Boolean) -> Unit, onOpenNotificationSettings: () -> Unit, - onDiagnosticsChanged: (Boolean) -> Unit, onBugWhatChanged: (String) -> Unit, onBugExpectedChanged: (String) -> Unit, onBugStepsChanged: (String) -> Unit, @@ -62,7 +61,6 @@ fun SettingsScreen( onResetFolder = onResetFolder, onNotificationsChanged = onNotificationsChanged, onOpenNotificationSettings = onOpenNotificationSettings, - onDiagnosticsChanged = onDiagnosticsChanged, onBugWhatChanged = onBugWhatChanged, onBugExpectedChanged = onBugExpectedChanged, onBugStepsChanged = onBugStepsChanged, @@ -105,7 +103,6 @@ fun SettingsScreen( onResetFolder = onResetFolder, onNotificationsChanged = onNotificationsChanged, onOpenNotificationSettings = onOpenNotificationSettings, - onDiagnosticsChanged = onDiagnosticsChanged, onBugWhatChanged = onBugWhatChanged, onBugExpectedChanged = onBugExpectedChanged, onBugStepsChanged = onBugStepsChanged, @@ -140,7 +137,6 @@ private fun SettingsSectionContent( onResetFolder: () -> Unit, onNotificationsChanged: (Boolean) -> Unit, onOpenNotificationSettings: () -> Unit, - onDiagnosticsChanged: (Boolean) -> Unit, onBugWhatChanged: (String) -> Unit, onBugExpectedChanged: (String) -> Unit, onBugStepsChanged: (String) -> Unit, @@ -184,7 +180,6 @@ private fun SettingsSectionContent( ) SettingsSection.About -> AboutSettings( state = state, - onDiagnosticsChanged = onDiagnosticsChanged, onReportBug = { onSectionSelected(SettingsSection.BugReport) }, 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 f8ea529..830166d 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 @@ -16,8 +16,6 @@ import com.vnidrop.app.core.TransferStatus import com.vnidrop.app.core.usesCustomRelayUrls 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 @@ -43,8 +41,6 @@ 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 @@ -102,7 +98,6 @@ data class SettingsState( val endpointId: String? = null, val notificationsEnabled: Boolean = false, val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined, - val diagnosticsEnabled: Boolean = false, val deviceInfo: DeviceInfo? = null, val appVersion: String = "", val isLoadingDeviceInfo: Boolean = false, @@ -138,8 +133,6 @@ class SettingsViewModel( private val notifications: LocalNotificationService, private val messages: UiMessageController, private val bugReports: BugReportService, - private val diagnostics: DiagnosticsCoordinator? = null, - private val diagnosticsIncluded: Boolean = DiagnosticsBuildConfig.INCLUDED, ) : ViewModel() { private val _state = MutableStateFlow( SettingsState( @@ -167,7 +160,6 @@ class SettingsViewModel( receiveFolder = receiveFolder, themeMode = preferences.themeMode, notificationsEnabled = preferences.notificationsEnabled, - diagnosticsEnabled = preferences.diagnosticsEnabled, savedRelaySettings = preferences.relaySettings, relayMode = if (hasLocalRelayDraft) current.relayMode else preferences.relaySettings.mode, relayUrls = if (hasLocalRelayDraft) { @@ -512,25 +504,6 @@ class SettingsViewModel( } } - fun setDiagnosticsEnabled(enabled: Boolean) { - if (!diagnosticsIncluded) 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) } @@ -565,7 +538,6 @@ class SettingsViewModel( ) result.fold( onSuccess = { - diagnostics?.record("bug_report_submitted") _state.update { it.copy( isSubmittingBugReport = false, 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 6f3090d..bc5c31d 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt @@ -24,9 +24,7 @@ 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. */ + /** Stable anonymous install id for bug-report correlation; never an account or advertising id. */ val diagnosticsInstallId: String = "", val relaySettings: RelaySettings = RelaySettings(), ) @@ -36,7 +34,6 @@ class AppPreferencesDefaults( val receiveFolder: ReceiveFolder, val themeMode: ThemeMode, val notificationsEnabled: Boolean = false, - val diagnosticsEnabled: Boolean = false, ) interface PreferencesRepository { @@ -46,7 +43,6 @@ interface PreferencesRepository { suspend fun resetReceiveFolder() suspend fun setThemeMode(mode: ThemeMode) suspend fun setNotificationsEnabled(enabled: Boolean) - suspend fun setDiagnosticsEnabled(enabled: Boolean) suspend fun setRelaySettings(settings: RelaySettings) /** Ensures a durable install id exists and returns it. */ suspend fun ensureDiagnosticsInstallId(): String @@ -83,7 +79,6 @@ 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(), relaySettings = RelaySettings( mode = relayMode, @@ -122,12 +117,6 @@ class AppPreferencesRepository( } } - override suspend fun setDiagnosticsEnabled(enabled: Boolean) { - dataStore.edit { prefs -> - prefs[PreferenceKeys.DiagnosticsEnabled] = enabled - } - } - override suspend fun setRelaySettings(settings: RelaySettings) { dataStore.edit { prefs -> prefs[PreferenceKeys.RelayMode] = settings.mode.name @@ -160,7 +149,6 @@ 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") val RelayMode = stringPreferencesKey("relay_mode") val RelayUrls = stringPreferencesKey("relay_urls") diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt index 0c9bf90..0f31727 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt @@ -4,83 +4,25 @@ import com.vnidrop.app.DeviceInfo import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolderKind import com.vnidrop.app.preferences.AppPreferences -import com.vnidrop.app.preferences.PreferencesRepository import com.vnidrop.app.support.FakePreferencesRepository import com.vnidrop.app.ui.theme.ThemeMode import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertFailsWith import kotlin.test.assertIs -import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class DiagnosticsTest { - @Test - fun diagnosticsJsonEscapesAndShapesPayloads() { - val eventsJson = DiagnosticsJson.eventsBody( - batchId = "batch-1", - installId = "inst-1", - appVersion = "1.0", - platform = "Test", - events = listOf( - TelemetryEvent("app_open", 10L, mapOf("a" to "quote\"here")), - ), - ) - assertTrue(eventsJson.contains("\"batchId\":\"batch-1\"")) - assertTrue(eventsJson.contains("\"installId\":\"inst-1\"")) - assertTrue(eventsJson.contains("\"name\":\"app_open\"")) - assertTrue(eventsJson.contains("quote\\\"here")) - - val crashJson = DiagnosticsJson.crashBody( - CrashReport( - id = "c1", - timestampMillis = 1L, - installId = "inst", - appVersion = "1.0", - platform = "Test", - exceptionType = "E", - exceptionMessage = "line\nbreak", - stackTrace = "stack", - breadcrumbs = emptyList(), - diagnosticsEnabledAtCapture = true, - ), - ) - assertTrue(crashJson.contains("line\\nbreak")) - assertTrue(crashJson.contains("\"diagnosticsEnabledAtCapture\":true")) - } - @Test fun diagnosticsJsonKeepsEscapedBugPayloadWithinWorkerLimit() { - val report = BugReport( - id = "b", - timestampMillis = 1L, - installId = "i", - appVersion = "1.0", - platform = "Test", - whatHappened = "w", - expected = "e", - steps = "", - contact = "", - includeLogs = true, + val report = bugReport( logs = "\n".repeat(BugReportService.MaxLogBytes), - device = DeviceSnapshot(null, null, "OS", null, null), - breadcrumbs = emptyList(), + includeLogs = true, ) val body = DiagnosticsJson.bugBody(report) @@ -91,67 +33,24 @@ class DiagnosticsTest { } @Test - fun httpTransportPostsExpectedPaths() = runTest { + fun httpTransportPostsBugReportToExpectedPath() = runTest { val calls = mutableListOf>() - val acknowledgementIds = ArrayDeque(listOf("batch-1", "c", "b")) val transport = HttpDiagnosticsTransport( baseUrl = "https://diag.example", ingestKey = "secret", appVersion = "1.0", platform = "Test", - installIdProvider = { "install-x" }, post = { url, headers, body -> assertEquals("secret", headers["X-VniDrop-Key"]) calls += url to body - PlatformHttpResponse( - 202, - """{"ok":true,"id":"${acknowledgementIds.removeFirst()}","stored":1}""", - ) + PlatformHttpResponse(202, """{"ok":true,"id":"b","stored":1}""") }, ) - val eventResult = transport.sendEvents( - TelemetryBatch("batch-1", listOf(TelemetryEvent("nav", 1L))), - ) - assertTrue(eventResult.isSuccess) - assertEquals("https://diag.example/v1/events", calls[0].first) - assertTrue(calls[0].second.contains("install-x")) - val crashResult = transport.sendCrash( - CrashReport( - id = "c", - timestampMillis = 1L, - installId = "i", - appVersion = "1.0", - platform = "Test", - exceptionType = "E", - exceptionMessage = "m", - stackTrace = "s", - breadcrumbs = emptyList(), - diagnosticsEnabledAtCapture = true, - ), - ) - assertTrue(crashResult.isSuccess) - assertEquals("https://diag.example/v1/crashes", calls[1].first) + val bugResult = transport.sendBugReport(bugReport()) - val bugResult = transport.sendBugReport( - BugReport( - id = "b", - timestampMillis = 1L, - installId = "i", - appVersion = "1.0", - platform = "Test", - whatHappened = "w", - expected = "e", - steps = "", - contact = "", - includeLogs = false, - logs = "", - device = DeviceSnapshot(null, null, "OS", null, null), - breadcrumbs = emptyList(), - ), - ) assertTrue(bugResult.isSuccess) - assertEquals("https://diag.example/v1/bugs", calls[2].first) + assertEquals("https://diag.example/v1/bugs", calls.single().first) } @Test @@ -162,12 +61,12 @@ class DiagnosticsTest { post = { _, _, _ -> PlatformHttpResponse( 202, - """{"\u006f\u006b":true,"id":"batch-\u0031","metadata":{"stored":1,"flags":[true,null]}}""", + """{"ok":true,"id":"b","metadata":{"stored":1,"flags":[true,null]}}""", ) }, ) - assertTrue(transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L)))).isSuccess) + assertTrue(transport.sendBugReport(bugReport()).isSuccess) } @Test @@ -177,9 +76,7 @@ class DiagnosticsTest { ingestKey = "secret", post = { _, _, _ -> PlatformHttpResponse(401, """{"error":"unauthorized"}""") }, ) - assertTrue( - transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L)))).isFailure, - ) + assertTrue(transport.sendBugReport(bugReport()).isFailure) } @Test @@ -191,7 +88,7 @@ class DiagnosticsTest { ) assertFailsWith { - transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1)))) + transport.sendBugReport(bugReport()) } } @@ -203,7 +100,7 @@ class DiagnosticsTest { PlatformHttpResponse(202, """{"ok":false}"""), PlatformHttpResponse(202, """{,"ok":true}"""), PlatformHttpResponse(202, """{"ok":true,"id":"different"}"""), - PlatformHttpResponse(202, """{"ok":true,"id":"batch-4","id":"batch-4"}"""), + PlatformHttpResponse(202, """{"ok":true,"id":"b","id":"b"}"""), ), ) val transport = HttpDiagnosticsTransport( @@ -212,10 +109,8 @@ class DiagnosticsTest { post = { _, _, _ -> responses.removeFirst() }, ) - repeat(5) { index -> - val result = transport.sendEvents( - TelemetryBatch("batch-$index", listOf(TelemetryEvent("x", 1L))), - ) + repeat(5) { + val result = transport.sendBugReport(bugReport()) assertIs(result.exceptionOrNull()) } } @@ -224,11 +119,11 @@ class DiagnosticsTest { fun httpTransportRejectsAmbiguousOrMalformedJsonAcknowledgement() = runTest { val responses = ArrayDeque( listOf( - PlatformHttpResponse(202, """{"ok":true,"\u006f\u006b":true,"id":"batch-0"}"""), + PlatformHttpResponse(202, """{"ok":true,"ok":true,"id":"b"}"""), PlatformHttpResponse(202, """{"ok":true,"id":true}"""), - PlatformHttpResponse(202, """{"ok":true,"id":"batch-2","stored":01}"""), - PlatformHttpResponse(202, """{"ok":true,"id":"batch-3",}"""), - PlatformHttpResponse(202, """{"ok":true,"id":"batch-4"} trailing"""), + PlatformHttpResponse(202, """{"ok":true,"id":"b","stored":01}"""), + PlatformHttpResponse(202, """{"ok":true,"id":"b",}"""), + PlatformHttpResponse(202, """{"ok":true,"id":"b"} trailing"""), ), ) val transport = HttpDiagnosticsTransport( @@ -237,10 +132,8 @@ class DiagnosticsTest { post = { _, _, _ -> responses.removeFirst() }, ) - repeat(5) { index -> - val result = transport.sendEvents( - TelemetryBatch("batch-$index", listOf(TelemetryEvent("x", 1L))), - ) + repeat(5) { + val result = transport.sendBugReport(bugReport()) assertIs(result.exceptionOrNull()) } } @@ -288,9 +181,7 @@ class DiagnosticsTest { @Test fun noOpTransportReportsUnavailableDelivery() = runTest { - val result = NoOpDiagnosticsTransport().sendEvents( - TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L))), - ) + val result = NoOpDiagnosticsTransport().sendBugReport(bugReport()) assertIs(result.exceptionOrNull()) } @@ -323,563 +214,10 @@ class DiagnosticsTest { 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 excludedTelemetryDoesNotStartOrRecordEvents() = runTest { - val transport = RecordingDiagnosticsTransport() - val breadcrumbs = BreadcrumbBuffer() - val recorder = TelemetryRecorder( - preferencesRepository = fakePrefs(diagnosticsEnabled = true), - transport = transport, - breadcrumbs = breadcrumbs, - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 1, - included = false, - ) - - recorder.record("app_open") - advanceUntilIdle() - - assertEquals(0, recorder.pendingCount()) - assertTrue(transport.events.isEmpty()) - assertTrue(breadcrumbs.snapshot().isEmpty()) - } - - @Test - fun telemetryRetainsColdStartEventsUntilConsentLoads() = runTest { - val backing = fakePrefs(diagnosticsEnabled = true) - val preferenceGate = CompletableDeferred() - val delayedPreferences = object : PreferencesRepository by backing { - override val preferences = flow { - preferenceGate.await() - emitAll(backing.preferences) - } - } - val transport = RecordingDiagnosticsTransport() - val recorder = TelemetryRecorder( - preferencesRepository = delayedPreferences, - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 20, - ) - runCurrent() - - recorder.record("app_open") - assertEquals(1, recorder.pendingCount()) - preferenceGate.complete(Unit) - runCurrent() - assertTrue(recorder.flush().isSuccess) - - assertEquals(listOf("app_open"), transport.events.single().map { it.name }) - } - - @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 telemetryFlushesSparseEventsAfterTheInterval() = runTest { - val transport = RecordingDiagnosticsTransport() - val recorder = TelemetryRecorder( - preferencesRepository = fakePrefs(diagnosticsEnabled = true), - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 20, - flushIntervalMillis = 1_000, - ) - advanceUntilIdle() - recorder.record("sparse") - - advanceTimeBy(999) - runCurrent() - assertTrue(transport.eventBatches.isEmpty()) - advanceTimeBy(1) - runCurrent() - assertEquals(listOf("sparse"), transport.events.single().map { it.name }) - } - - @Test - fun telemetryCoalescesAutomaticRetriesWithBackoff() = runTest { - val transport = RecordingDiagnosticsTransport().apply { - eventsResult = Result.failure(IllegalStateException("offline")) - } - val recorder = TelemetryRecorder( - preferencesRepository = fakePrefs(diagnosticsEnabled = true), - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 1, - flushIntervalMillis = 10_000, - retryBackoffMillis = 1_000, - automaticRetryCount = 1, - ) - advanceUntilIdle() - recorder.record("retry") - runCurrent() - assertEquals(1, transport.eventBatches.size) - - advanceTimeBy(999) - runCurrent() - assertEquals(1, transport.eventBatches.size) - advanceTimeBy(1) - runCurrent() - assertEquals(2, transport.eventBatches.size) - advanceTimeBy(10_000) - runCurrent() - assertEquals(2, transport.eventBatches.size) - } - - @Test - fun telemetryFlushesAtMostFiftyEventsPerBatch() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = true) - val transport = RecordingDiagnosticsTransport() - val recorder = TelemetryRecorder( - preferencesRepository = preferences, - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 101, - maxBufferSize = 100, - ) - advanceUntilIdle() - repeat(75) { recorder.record("event-$it") } - - assertTrue(recorder.flush().isSuccess) - assertEquals(listOf(50, 25), transport.eventBatches.map { it.events.size }) - assertTrue(transport.eventBatches.all { it.events.size <= TelemetryRecorder.MaxEventsPerBatch }) - } - - @Test - fun telemetrySplitsBatchesByEscapedRequestBytes() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = true) - val requestBodies = mutableListOf() - val transport = HttpDiagnosticsTransport( - baseUrl = "https://diag.example", - ingestKey = "secret", - appVersion = "1.0", - platform = "Test", - installIdProvider = { "test-install" }, - post = { _, _, body -> - requestBodies += body - val id = Regex(""""batchId":"([^"]+)"""").find(body)?.groupValues?.get(1) - PlatformHttpResponse(202, """{"ok":true,"id":"$id"}""") - }, - ) - val recorder = TelemetryRecorder( - preferencesRepository = preferences, - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 101, - maxBufferSize = 100, - ) - advanceUntilIdle() - val properties = LinkedHashMap().apply { - repeat(MaxDiagnosticProperties) { index -> - put("key-$index-${"\u0001".repeat(40)}", "\u0001".repeat(MaxDiagnosticPropertyValueBytes)) - } - } - repeat(50) { index -> - recorder.record("event-$index-${"\u0001".repeat(64)}", properties) - } - - assertTrue(recorder.flush().isSuccess) - assertTrue(requestBodies.size > 1) - assertTrue(requestBodies.all { it.encodeToByteArray().size <= DiagnosticsJson.MaxRequestBytes }) - assertEquals(50, requestBodies.sumOf { body -> "\"schemaVersion\"".toRegex().findAll(body).count() }) - } - - @Test - fun telemetrySanitizesNamesAndPropertiesToServerByteLimits() = 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() - val properties = LinkedHashMap().apply { - repeat(20) { index -> put("key-$index-${"🙂".repeat(20)}", "🙂".repeat(100)) } - } - - recorder.record("🙂".repeat(100), properties) - assertTrue(recorder.flush().isSuccess) - - val event = transport.eventBatches.single().events.single() - assertTrue(event.name.encodeToByteArray().size <= MaxDiagnosticNameBytes) - assertEquals(MaxDiagnosticProperties, event.properties.size) - assertTrue(event.properties.keys.all { it.encodeToByteArray().size <= MaxDiagnosticPropertyKeyBytes }) - assertTrue(event.properties.values.all { it.encodeToByteArray().size <= MaxDiagnosticPropertyValueBytes }) - } - - @Test - fun telemetryKeepsConcurrentRecordsWithoutExceedingItsBuffer() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = true) - val transport = RecordingDiagnosticsTransport() - val recorder = TelemetryRecorder( - preferencesRepository = preferences, - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 101, - maxBufferSize = 100, - ) - advanceUntilIdle() - - coroutineScope { - repeat(100) { index -> - launch(Dispatchers.Default) { recorder.record("event-$index") } - } - } - - assertEquals( - 100, - recorder.pendingCount() + transport.eventBatches.sumOf { it.events.size }, - ) - assertTrue(recorder.flush().isSuccess) - assertEquals(100, transport.eventBatches.sumOf { it.events.size }) - } - - @Test - fun telemetryRetryReusesBatchIdAndEvents() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = true) - val transport = RecordingDiagnosticsTransport().apply { - eventsResult = Result.failure(IllegalStateException("offline")) - } - val recorder = TelemetryRecorder( - preferencesRepository = preferences, - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 10, - ) - advanceUntilIdle() - recorder.record("one") - recorder.record("two") - - assertTrue(recorder.flush().isFailure) - val firstAttempt = transport.eventBatches.single() - transport.eventsResult = Result.success(Unit) - assertTrue(recorder.flush().isSuccess) - - assertEquals(listOf(firstAttempt, firstAttempt), transport.eventBatches) - assertEquals(0, recorder.pendingCount()) - } - - @Test - fun telemetryDoesNotRequeuePermanentlyRejectedPayload() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = true) - val transport = RecordingDiagnosticsTransport().apply { - eventsResult = Result.failure(DiagnosticsHttpException(400, "/v1/events")) - } - val recorder = TelemetryRecorder( - preferencesRepository = preferences, - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 10, - ) - advanceUntilIdle() - recorder.record("invalid") - - assertTrue(recorder.flush().isFailure) - assertEquals(0, recorder.pendingCount()) - transport.eventsResult = Result.success(Unit) - assertTrue(recorder.flush().isSuccess) - assertEquals(1, transport.eventBatches.size) - } - - @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 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - timestampMillis = 42L, - installId = "install", - appVersion = "1.0", - platform = "Test", - exceptionType = "IllegalStateException", - exceptionMessage = "boom\nline\\nliteral\u001fseparator", - stackTrace = "stack\ntrace\u001erecord", - breadcrumbs = listOf( - Breadcrumb("open|send", 1L, mapOf("screen:key" to "send,value|next")), - ), - diagnosticsEnabledAtCapture = true, - ) - val decoded = CrashReportCodec.decode(CrashReportCodec.encode(original)) - assertEquals(original, decoded) - assertNull(CrashReportCodec.decode(CrashReportCodec.encode(original.copy(id = "../../escape")))) - } - - @Test - fun crashCodecMigratesLegacyV1Envelope() { - val raw = listOf( - "id=bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - "ts=42", - "install=legacy-install", - "app=0.9", - "platform=Desktop", - "type=IllegalStateException", - "message=first\\nsecond", - "stack=frame one\\nframe two", - "diag=1", - "schema=1", - "crumbs=1|opened|screen:send", - ).joinToString("\u001f") - - val report = requireNotNull(CrashReportCodec.decode(raw)) - - assertEquals("first\nsecond", report.exceptionMessage) - assertEquals("frame one\nframe two", report.stackTrace) - assertEquals(true, report.diagnosticsEnabledAtCapture) - assertEquals( - listOf(Breadcrumb("opened", 1, mapOf("screen" to "send"))), - report.breadcrumbs, - ) - } - - @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(1, store.list().size) - - reporter.flushPending() - assertEquals(1, transport.crashes.size) - assertEquals(optedIn.id, transport.crashes.single().id) - assertTrue(store.list().isEmpty()) - } - - @Test - fun crashReporterRetainsCrashWhenDeliveryIsUnavailable() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = true) - val store = InMemoryPendingCrashStore() - val reporter = CrashReporter( - store = store, - preferencesRepository = preferences, - transport = NoOpDiagnosticsTransport(), - breadcrumbs = BreadcrumbBuffer(), - appVersion = "1.0", - platform = "Test", - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - ) - reporter.capture(RuntimeException("boom"), diagnosticsEnabledOverride = true) - - reporter.flushPending() - assertEquals(1, store.list().size) - } - - @Test - fun crashReporterDropsPermanentPayloadFailuresAndStopsAfterTransientFailures() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = true) - val permanentTransport = RecordingDiagnosticsTransport().apply { - crashResult = Result.failure(DiagnosticsHttpException(400, "/v1/crashes")) - } - val permanentStore = InMemoryPendingCrashStore() - val permanentReporter = CrashReporter( - store = permanentStore, - preferencesRepository = preferences, - transport = permanentTransport, - breadcrumbs = BreadcrumbBuffer(), - appVersion = "1.0", - platform = "Test", - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - ) - permanentReporter.capture(RuntimeException("invalid"), diagnosticsEnabledOverride = true) - permanentReporter.flushPending() - assertTrue(permanentStore.list().isEmpty()) - - val transientTransport = RecordingDiagnosticsTransport().apply { - crashResult = Result.failure(IllegalStateException("offline")) - } - val transientStore = InMemoryPendingCrashStore() - val transientReporter = CrashReporter( - store = transientStore, - preferencesRepository = preferences, - transport = transientTransport, - breadcrumbs = BreadcrumbBuffer(), - appVersion = "1.0", - platform = "Test", - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - ) - repeat(2) { - transientReporter.capture(RuntimeException("offline-$it"), diagnosticsEnabledOverride = true) - } - transientReporter.flushPending() - assertEquals(1, transientTransport.crashes.size) - assertEquals(2, transientStore.list().size) - } - - @Test - fun crashReporterResolvesStartupConsentBeforeUploading() = runTest { - val transport = RecordingDiagnosticsTransport() - val store = InMemoryPendingCrashStore() - val reporter = CrashReporter( - store = store, - preferencesRepository = fakePrefs(diagnosticsEnabled = true), - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - appVersion = "1.0", - platform = "Test", - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - ) - val startupCrash = reporter.capture(RuntimeException("startup")) - assertNull(startupCrash.diagnosticsEnabledAtCapture) - - reporter.flushPending() - - assertEquals(true, transport.crashes.single().diagnosticsEnabledAtCapture) - assertEquals("test-install", transport.crashes.single().installId) - assertTrue(store.list().isEmpty()) - } - - @Test - fun crashReporterDoesNotRetroactivelyUploadAnOptedOutStartupCrash() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = false) - 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.capture(RuntimeException("startup")) - - reporter.flushPending() - assertTrue(store.list().isEmpty()) - preferences.setDiagnosticsEnabled(true) - reporter.flushPending() - - assertTrue(transport.crashes.isEmpty()) - assertTrue(store.list().isEmpty()) - } - - @Test - fun crashReporterStopsAFlushWhenTheUserOptsOut() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = true) - val firstSendStarted = CompletableDeferred() - val releaseFirstSend = CompletableDeferred() - val sentIds = mutableListOf() - val transport = object : DiagnosticsTransport { - override suspend fun sendEvents(batch: TelemetryBatch) = Result.success(Unit) - override suspend fun sendBugReport(report: BugReport) = Result.success(Unit) - override suspend fun sendCrash(report: CrashReport): Result { - sentIds += report.id - if (sentIds.size == 1) { - firstSendStarted.complete(Unit) - releaseFirstSend.await() - } - return Result.success(Unit) - } - } - 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() - repeat(2) { - reporter.capture(RuntimeException("crash-$it"), diagnosticsEnabledOverride = true) - } - - val flush = launch { reporter.flushPending() } - firstSendStarted.await() - preferences.setDiagnosticsEnabled(false) - runCurrent() - releaseFirstSend.complete(Unit) - flush.join() - - assertEquals(1, sentIds.size) - assertTrue(store.list().isEmpty()) - } - @Test fun bugReportRequiresWhatAndExpected() = runTest { - val preferences = fakePrefs() val service = BugReportService( - preferencesRepository = preferences, + preferencesRepository = fakePrefs(), transport = RecordingDiagnosticsTransport(), breadcrumbs = BreadcrumbBuffer(), appVersion = "1.0", @@ -893,8 +231,6 @@ class DiagnosticsTest { @Test fun bugReportConvertsTransportExceptionsToFailure() = runTest { val throwingTransport = object : DiagnosticsTransport { - override suspend fun sendEvents(batch: TelemetryBatch) = Result.success(Unit) - override suspend fun sendCrash(report: CrashReport) = Result.success(Unit) override suspend fun sendBugReport(report: BugReport): Result { throw IllegalStateException("offline") } @@ -914,11 +250,10 @@ class DiagnosticsTest { } @Test - fun bugReportSubmitsWithRedactedLogsRegardlessOfDiagnostics() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = false) + fun bugReportSubmitsWithRedactedLogs() = runTest { val transport = RecordingDiagnosticsTransport() val service = BugReportService( - preferencesRepository = preferences, + preferencesRepository = fakePrefs(), transport = transport, breadcrumbs = BreadcrumbBuffer(), appVersion = "1.0", @@ -981,38 +316,35 @@ class DiagnosticsTest { assertEquals(BugReportService.MaxLogBytes, service.previewLogBytes()) } - private fun fakePrefs(diagnosticsEnabled: Boolean = false) = FakePreferencesRepository( + private fun bugReport( + id: String = "b", + logs: String = "", + includeLogs: Boolean = false, + ) = BugReport( + id = id, + timestampMillis = 1L, + installId = "i", + appVersion = "1.0", + platform = "Test", + whatHappened = "w", + expected = "e", + steps = "", + contact = "", + includeLogs = includeLogs, + logs = logs, + device = DeviceSnapshot(null, null, "OS", null, null), + breadcrumbs = emptyList(), + ) + + private fun fakePrefs() = 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) - } - override fun prune(olderThanTimestampMillis: Long, maxCount: Int) { - items.values - .sortedByDescending { it.timestampMillis } - .drop(maxCount) - .map(CrashReport::id) - .forEach(items::remove) - items.values - .filter { it.timestampMillis < olderThanTimestampMillis } - .map(CrashReport::id) - .forEach(items::remove) - } -} 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 2cd18a5..7e5c5f3 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -420,33 +420,6 @@ class ViewModelsTest { assertEquals(NotificationPermission.Unsupported, viewModel.state.value.notificationPermission) } - @Test - fun settingsTogglesDiagnosticsPreference() = runTest { - Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - val preferences = preferences() - val viewModel = settingsViewModel(preferences, diagnosticsIncluded = true) - advanceUntilIdle() - assertFalse(viewModel.state.value.diagnosticsEnabled) - viewModel.setDiagnosticsEnabled(true) - advanceUntilIdle() - assertTrue(preferences.mutablePreferences.value.diagnosticsEnabled) - assertTrue(viewModel.state.value.diagnosticsEnabled) - } - - @Test - fun settingsIgnoresDiagnosticsOptInWhenExcluded() = runTest { - Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - val preferences = preferences() - val viewModel = settingsViewModel(preferences) - advanceUntilIdle() - - viewModel.setDiagnosticsEnabled(true) - advanceUntilIdle() - - assertFalse(preferences.mutablePreferences.value.diagnosticsEnabled) - assertFalse(viewModel.state.value.diagnosticsEnabled) - } - @Test fun settingsSubmitsBugReportAndClearsForm() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) @@ -993,7 +966,6 @@ class ViewModelsTest { receiveFolder = folder, themeMode = ThemeMode.System, notificationsEnabled = false, - diagnosticsEnabled = false, diagnosticsInstallId = "test-install", ), ) @@ -1005,7 +977,6 @@ class ViewModelsTest { notifications: FakeNotificationService = FakeNotificationService(), transport: DiagnosticsTransport = RecordingDiagnosticsTransport(), fileSystem: FakeFileSystemService = FakeFileSystemService(folder), - diagnosticsIncluded: Boolean = false, repository: FakeCoreGateway = FakeCoreGateway(), ) = SettingsViewModel( environment(), @@ -1023,7 +994,6 @@ class ViewModelsTest { platform = "Test", logReader = { "sample log line" }, ), - diagnosticsIncluded = diagnosticsIncluded, ) private fun receivedTransfer(id: ULong, status: TransferStatus) = Transfer( 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 a912d6d..c8f7b55 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -210,9 +210,6 @@ 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 setRelaySettings(settings: RelaySettings) { mutablePreferences.value = mutablePreferences.value.copy(relaySettings = settings) } 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 deleted file mode 100644 index 3c5c099..0000000 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.jvm.kt +++ /dev/null @@ -1,78 +0,0 @@ -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) { - if (!isValidDiagnosticId(report.id)) return - directory.mkdirs() - val target = File(directory, "${report.id}.crash") - val temporary = File(directory, ".${report.id}.tmp") - val payload = CrashReportCodec.encode(report) - temporary.writeText(payload, StandardCharsets.UTF_8) - if (!temporary.renameTo(target)) { - target.writeText(payload, StandardCharsets.UTF_8) - temporary.delete() - } - } - - @Synchronized - override fun list(): List { - if (!directory.isDirectory) return emptyList() - return directory - .listFiles { file -> file.isFile && file.name.endsWith(".crash") } - .orEmpty() - .mapNotNull { file -> - runCatching { CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8)) }.getOrNull() - } - .sortedWith( - compareByDescending { it.timestampMillis } - .thenBy { it.id }, - ) - } - - @Synchronized - override fun delete(id: String) { - if (!isValidDiagnosticId(id)) return - File(directory, "$id.crash").delete() - } - - @Synchronized - override fun prune(olderThanTimestampMillis: Long, maxCount: Int) { - require(maxCount > 0) { "maxCount must be positive" } - if (!directory.isDirectory) return - directory.listFiles { file -> file.isFile && file.name.endsWith(".tmp") } - .orEmpty() - .forEach(File::delete) - val reports = directory - .listFiles { file -> file.isFile && file.name.endsWith(".crash") } - .orEmpty() - .mapNotNull { file -> - val report = runCatching { - CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8)) - }.getOrNull() - if (report == null) { - file.delete() - null - } else { - file to report - } - } - .sortedWith( - compareByDescending> { (_, report) -> report.timestampMillis } - .thenBy { (_, report) -> report.id }, - ) - reports.forEachIndexed { index, (file, report) -> - if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) file.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 deleted file mode 100644 index d762725..0000000 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.jvm.kt +++ /dev/null @@ -1,9 +0,0 @@ -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/jvmTest/kotlin/com/vnidrop/app/diagnostics/PendingCrashStoreJvmTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/diagnostics/PendingCrashStoreJvmTest.kt deleted file mode 100644 index 1ef7285..0000000 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/diagnostics/PendingCrashStoreJvmTest.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.vnidrop.app.diagnostics - -import java.io.File -import java.nio.file.Files -import java.nio.file.attribute.FileTime -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class PendingCrashStoreJvmTest { - @Test - fun replacesReportsAndPrunesOldCorruptAndTemporaryFiles() { - val root = Files.createTempDirectory("vnidrop-crash-store").toFile() - try { - val store = createPendingCrashStore(root.absolutePath) - val older = report("10000000-0000-4000-8000-000000000001", 1, "older") - val current = report("10000000-0000-4000-8000-000000000002", 2, "current") - store.write(older) - store.write(current) - store.write(current.copy(exceptionMessage = "replaced")) - - val directory = File(root, "diagnostics/crashes") - File(directory, "corrupt.crash").writeText("not a crash envelope") - File(directory, ".orphan.tmp").writeText("partial") - store.write(current.copy(id = "../../escape")) - val escapedPath = File(directory, "../../escape.crash").canonicalFile - Files.setLastModifiedTime(File(directory, "${older.id}.crash").toPath(), FileTime.fromMillis(2_000)) - Files.setLastModifiedTime(File(directory, "${current.id}.crash").toPath(), FileTime.fromMillis(1_000)) - - assertEquals( - listOf("replaced", "older"), - store.list().map(CrashReport::exceptionMessage), - ) - store.prune(olderThanTimestampMillis = 0, maxCount = 1) - - assertEquals(listOf("replaced"), store.list().map(CrashReport::exceptionMessage)) - assertFalse(File(directory, "corrupt.crash").exists()) - assertFalse(File(directory, ".orphan.tmp").exists()) - assertFalse(escapedPath.exists()) - assertTrue(directory.listFiles().orEmpty().all { it.parentFile == directory }) - } finally { - root.deleteRecursively() - } - } - - private fun report(id: String, timestampMillis: Long, message: String) = CrashReport( - id = id, - timestampMillis = timestampMillis, - installId = "install", - appVersion = "1.0", - platform = "Desktop", - exceptionType = "TestError", - exceptionMessage = message, - stackTrace = "stack", - breadcrumbs = emptyList(), - diagnosticsEnabledAtCapture = true, - ) -} 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 bf1d63a..d30a245 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -152,7 +152,6 @@ class FoundationComposeTest { onResetFolder = {}, onNotificationsChanged = {}, onOpenNotificationSettings = {}, - onDiagnosticsChanged = {}, onBugWhatChanged = {}, onBugExpectedChanged = {}, onBugStepsChanged = {}, @@ -182,7 +181,6 @@ class FoundationComposeTest { onResetFolder = {}, onNotificationsChanged = {}, onOpenNotificationSettings = {}, - onDiagnosticsChanged = {}, onBugWhatChanged = {}, onBugExpectedChanged = {}, onBugStepsChanged = {}, @@ -238,7 +236,6 @@ class FoundationComposeTest { onResetFolder = {}, onNotificationsChanged = {}, onOpenNotificationSettings = {}, - onDiagnosticsChanged = {}, onBugWhatChanged = {}, onBugExpectedChanged = {}, onBugStepsChanged = {}, @@ -291,7 +288,6 @@ class FoundationComposeTest { onResetFolder = {}, onNotificationsChanged = {}, onOpenNotificationSettings = {}, - onDiagnosticsChanged = {}, onBugWhatChanged = {}, onBugExpectedChanged = {}, onBugStepsChanged = {}, @@ -322,7 +318,6 @@ class FoundationComposeTest { onResetFolder = {}, onNotificationsChanged = {}, onOpenNotificationSettings = {}, - onDiagnosticsChanged = {}, onBugWhatChanged = {}, onBugExpectedChanged = {}, onBugStepsChanged = {}, @@ -358,7 +353,6 @@ class FoundationComposeTest { onResetFolder = {}, onNotificationsChanged = { enabled = it }, onOpenNotificationSettings = {}, - onDiagnosticsChanged = {}, onBugWhatChanged = {}, onBugExpectedChanged = {}, onBugStepsChanged = {}, @@ -390,7 +384,6 @@ class FoundationComposeTest { onResetFolder = {}, onNotificationsChanged = {}, onOpenNotificationSettings = { opened = true }, - onDiagnosticsChanged = {}, onBugWhatChanged = {}, onBugExpectedChanged = {}, onBugStepsChanged = {},