refactor(shared): remove telemetry and crash reporting, keep bug reports

Delete the TelemetryRecorder, CrashReporter, PendingCrashStore and platform
crash hooks along with their models, JSON encoders and the diagnostics opt-in
preference. The DiagnosticsTransport interface is narrowed to sendBugReport, and
DiagnosticsCoordinator now only wires the bug-report service and install id.

Bug reporting, the breadcrumb buffer, log redaction and the diagnostics endpoint
config are kept. Regenerate localization after dropping the diagnostics_* keys.
This commit is contained in:
2026-08-02 10:02:28 +02:00
parent 232fb125d3
commit b8a002a2ad
37 changed files with 55 additions and 1921 deletions

View File

@@ -13,9 +13,8 @@ android.nonTransitiveRClass=true
android.sourceset.disallowProvider=false android.sourceset.disallowProvider=false
android.useAndroidX=true android.useAndroidX=true
# VniDrop: compile-time diagnostics/telemetry product surface. # VniDrop: compile-time bug-report delivery surface.
# false → no Share-diagnostics toggle, no telemetry or crash auto-upload stack. # false → user-initiated bug reports fall back to a NoOp transport (never sent).
# Bug report UI remains available (user-initiated).
# Enable per build only when endpoint and ingest key are configured: # Enable per build only when endpoint and ingest key are configured:
# ./gradlew … -Pvnidrop.diagnostics.included=true # ./gradlew … -Pvnidrop.diagnostics.included=true
vnidrop.diagnostics.included=false vnidrop.diagnostics.included=false

View File

@@ -1178,62 +1178,6 @@
"ru": "Имя устройства" "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 dutilisation 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 duso 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": { "error_camera": {
"context": "Error: camera permission is needed to scan a QR code.", "context": "Error: camera permission is needed to scan a QR code.",
"translations": { "translations": {

View File

@@ -49,7 +49,7 @@ val desktopRustVariant = providers.gradleProperty("vnidrop.desktop.rustVariant")
.orElse(Variant.Debug) .orElse(Variant.Debug)
// Compile-time switches (gradle.properties or -P…). // 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). // endpoint/key both empty: transport is NoOp (safe default until Cloudflare is deployed).
val diagnosticsIncluded: Boolean = val diagnosticsIncluded: Boolean =
(findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: false (findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: false

View File

@@ -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<CrashReport> {
if (!directory.isDirectory) return emptyList()
return directory
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
.orEmpty()
.sortedByDescending { it.lastModified() }
.mapNotNull { file ->
runCatching { CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8)) }.getOrNull()
}
}
@Synchronized
override fun delete(id: String) {
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()
}
}
}

View File

@@ -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)
}
}

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Auf NFC-Tag schreiben</string> <string name="button_write_nfc">Auf NFC-Tag schreiben</string>
<string name="device_model_title">Gerätemodell</string> <string name="device_model_title">Gerätemodell</string>
<string name="device_name_title">Gerätename</string> <string name="device_name_title">Gerätename</string>
<string name="diagnostics_description">Anonyme Absturzberichte und Nutzungsereignisse senden, damit wir VniDrop verbessern können. Sie können dies jederzeit deaktivieren. Einladungen, Dateipfade und Übertragungsinhalte werden niemals einbezogen.</string>
<string name="diagnostics_disabled_message">Die Freigabe von Diagnosedaten ist deaktiviert.</string>
<string name="diagnostics_enabled_message">Die Freigabe von Diagnosedaten ist aktiviert.</string>
<string name="diagnostics_title">Diagnosedaten teilen</string>
<string name="error_camera">Für das Scannen eines QR-Codes ist Kamerazugriff erforderlich.</string> <string name="error_camera">Für das Scannen eines QR-Codes ist Kamerazugriff erforderlich.</string>
<string name="error_device_info">Geräteinformationen konnten nicht geladen werden.</string> <string name="error_device_info">Geräteinformationen konnten nicht geladen werden.</string>
<string name="error_destination_exists">Am Ziel ist bereits eine Datei mit demselben Namen vorhanden. Wählen Sie einen anderen Ordner oder entfernen Sie die vorhandene Datei.</string> <string name="error_destination_exists">Am Ziel ist bereits eine Datei mit demselben Namen vorhanden. Wählen Sie einen anderen Ordner oder entfernen Sie die vorhandene Datei.</string>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Escribir en etiqueta NFC</string> <string name="button_write_nfc">Escribir en etiqueta NFC</string>
<string name="device_model_title">Modelo del dispositivo</string> <string name="device_model_title">Modelo del dispositivo</string>
<string name="device_name_title">Nombre del dispositivo</string> <string name="device_name_title">Nombre del dispositivo</string>
<string name="diagnostics_description">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.</string>
<string name="diagnostics_disabled_message">El uso compartido de diagnósticos está desactivado.</string>
<string name="diagnostics_enabled_message">El uso compartido de diagnósticos está activado.</string>
<string name="diagnostics_title">Compartir diagnósticos</string>
<string name="error_camera">Se necesita acceso a la cámara para escanear un código QR.</string> <string name="error_camera">Se necesita acceso a la cámara para escanear un código QR.</string>
<string name="error_device_info">No se pudo cargar la información del dispositivo.</string> <string name="error_device_info">No se pudo cargar la información del dispositivo.</string>
<string name="error_destination_exists">Ya existe un archivo con el mismo nombre en el destino. Elija otra carpeta o elimine el archivo existente.</string> <string name="error_destination_exists">Ya existe un archivo con el mismo nombre en el destino. Elija otra carpeta o elimine el archivo existente.</string>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Écrire sur un tag NFC</string> <string name="button_write_nfc">Écrire sur un tag NFC</string>
<string name="device_model_title">Modèle de lappareil</string> <string name="device_model_title">Modèle de lappareil</string>
<string name="device_name_title">Nom de lappareil</string> <string name="device_name_title">Nom de lappareil</string>
<string name="diagnostics_description">Envoyer des rapports de plantage et des événements dutilisation 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.</string>
<string name="diagnostics_disabled_message">Le partage des diagnostics est désactivé.</string>
<string name="diagnostics_enabled_message">Le partage des diagnostics est activé.</string>
<string name="diagnostics_title">Partager les diagnostics</string>
<string name="error_camera">Laccès à la caméra est nécessaire pour scanner un QR code.</string> <string name="error_camera">Laccès à la caméra est nécessaire pour scanner un QR code.</string>
<string name="error_device_info">Impossible de charger les informations de lappareil.</string> <string name="error_device_info">Impossible de charger les informations de lappareil.</string>
<string name="error_destination_exists">Un fichier portant le même nom existe déjà dans la destination. Choisissez un autre dossier ou supprimez le fichier existant.</string> <string name="error_destination_exists">Un fichier portant le même nom existe déjà dans la destination. Choisissez un autre dossier ou supprimez le fichier existant.</string>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Scrivi su tag NFC</string> <string name="button_write_nfc">Scrivi su tag NFC</string>
<string name="device_model_title">Modello del dispositivo</string> <string name="device_model_title">Modello del dispositivo</string>
<string name="device_name_title">Nome del dispositivo</string> <string name="device_name_title">Nome del dispositivo</string>
<string name="diagnostics_description">Invia report di arresto anomalo ed eventi duso anonimi per aiutarci a migliorare VniDrop. Può disattivarlo in qualsiasi momento. Inviti, percorsi dei file e contenuti dei trasferimenti non vengono mai inclusi.</string>
<string name="diagnostics_disabled_message">La condivisione dei dati diagnostici è disattivata.</string>
<string name="diagnostics_enabled_message">La condivisione dei dati diagnostici è attivata.</string>
<string name="diagnostics_title">Condividi dati diagnostici</string>
<string name="error_camera">Per scansionare un codice QR è necessario laccesso alla fotocamera.</string> <string name="error_camera">Per scansionare un codice QR è necessario laccesso alla fotocamera.</string>
<string name="error_device_info">Impossibile caricare le informazioni sul dispositivo.</string> <string name="error_device_info">Impossibile caricare le informazioni sul dispositivo.</string>
<string name="error_destination_exists">Nella destinazione esiste già un file con lo stesso nome. Scelga unaltra cartella o rimuova il file esistente.</string> <string name="error_destination_exists">Nella destinazione esiste già un file con lo stesso nome. Scelga unaltra cartella o rimuova il file esistente.</string>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Naar NFC-tag schrijven</string> <string name="button_write_nfc">Naar NFC-tag schrijven</string>
<string name="device_model_title">Apparaatmodel</string> <string name="device_model_title">Apparaatmodel</string>
<string name="device_name_title">Apparaatnaam</string> <string name="device_name_title">Apparaatnaam</string>
<string name="diagnostics_description">Verstuur anonieme crashrapporten en gebruiksgebeurtenissen zodat we VniDrop kunnen verbeteren. U kunt dit op elk moment uitschakelen. Uitnodigingen, bestandspaden en overdrachtsinhoud worden nooit meegestuurd.</string>
<string name="diagnostics_disabled_message">Het delen van diagnostische gegevens is uitgeschakeld.</string>
<string name="diagnostics_enabled_message">Het delen van diagnostische gegevens is ingeschakeld.</string>
<string name="diagnostics_title">Diagnostische gegevens delen</string>
<string name="error_camera">Voor het scannen van een QR-code is toegang tot de camera vereist.</string> <string name="error_camera">Voor het scannen van een QR-code is toegang tot de camera vereist.</string>
<string name="error_device_info">Apparaatgegevens konden niet worden geladen.</string> <string name="error_device_info">Apparaatgegevens konden niet worden geladen.</string>
<string name="error_destination_exists">Er staat al een bestand met dezelfde naam in de doelmap. Kies een andere map of verwijder het bestaande bestand.</string> <string name="error_destination_exists">Er staat al een bestand met dezelfde naam in de doelmap. Kies een andere map of verwijder het bestaande bestand.</string>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Zapisz na tagu NFC</string> <string name="button_write_nfc">Zapisz na tagu NFC</string>
<string name="device_model_title">Model urządzenia</string> <string name="device_model_title">Model urządzenia</string>
<string name="device_name_title">Nazwa urządzenia</string> <string name="device_name_title">Nazwa urządzenia</string>
<string name="diagnostics_description">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.</string>
<string name="diagnostics_disabled_message">Udostępnianie diagnostyki jest wyłączone.</string>
<string name="diagnostics_enabled_message">Udostępnianie diagnostyki jest włączone.</string>
<string name="diagnostics_title">Udostępniaj diagnostykę</string>
<string name="error_camera">Do zeskanowania kodu QR wymagany jest dostęp do aparatu.</string> <string name="error_camera">Do zeskanowania kodu QR wymagany jest dostęp do aparatu.</string>
<string name="error_device_info">Nie udało się wczytać informacji o urządzeniu.</string> <string name="error_device_info">Nie udało się wczytać informacji o urządzeniu.</string>
<string name="error_destination_exists">W miejscu docelowym istnieje już plik o tej samej nazwie. Wybierz inny folder lub usuń istniejący plik.</string> <string name="error_destination_exists">W miejscu docelowym istnieje już plik o tej samej nazwie. Wybierz inny folder lub usuń istniejący plik.</string>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Escrever em etiqueta NFC</string> <string name="button_write_nfc">Escrever em etiqueta NFC</string>
<string name="device_model_title">Modelo do dispositivo</string> <string name="device_model_title">Modelo do dispositivo</string>
<string name="device_name_title">Nome do dispositivo</string> <string name="device_name_title">Nome do dispositivo</string>
<string name="diagnostics_description">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.</string>
<string name="diagnostics_disabled_message">A partilha de diagnósticos está desativada.</string>
<string name="diagnostics_enabled_message">A partilha de diagnósticos está ativada.</string>
<string name="diagnostics_title">Partilhar diagnósticos</string>
<string name="error_camera">É necessário acesso à câmara para ler um código QR.</string> <string name="error_camera">É necessário acesso à câmara para ler um código QR.</string>
<string name="error_device_info">Não foi possível carregar as informações do dispositivo.</string> <string name="error_device_info">Não foi possível carregar as informações do dispositivo.</string>
<string name="error_destination_exists">Já existe um ficheiro com o mesmo nome no destino. Escolha outra pasta ou remova o ficheiro existente.</string> <string name="error_destination_exists">Já existe um ficheiro com o mesmo nome no destino. Escolha outra pasta ou remova o ficheiro existente.</string>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Записать на NFC-метку</string> <string name="button_write_nfc">Записать на NFC-метку</string>
<string name="device_model_title">Модель устройства</string> <string name="device_model_title">Модель устройства</string>
<string name="device_name_title">Имя устройства</string> <string name="device_name_title">Имя устройства</string>
<string name="diagnostics_description">Отправлять анонимные отчёты о сбоях и события использования, чтобы помочь нам улучшать VniDrop. Вы можете отключить это в любой момент. Приглашения, пути к файлам и содержимое передач никогда не включаются.</string>
<string name="diagnostics_disabled_message">Передача диагностики отключена.</string>
<string name="diagnostics_enabled_message">Передача диагностики включена.</string>
<string name="diagnostics_title">Делиться диагностикой</string>
<string name="error_camera">Для сканирования QR-кода требуется доступ к камере.</string> <string name="error_camera">Для сканирования QR-кода требуется доступ к камере.</string>
<string name="error_device_info">Не удалось загрузить сведения об устройстве.</string> <string name="error_device_info">Не удалось загрузить сведения об устройстве.</string>
<string name="error_destination_exists">В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл.</string> <string name="error_destination_exists">В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл.</string>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Write to NFC tag</string> <string name="button_write_nfc">Write to NFC tag</string>
<string name="device_model_title">Device model</string> <string name="device_model_title">Device model</string>
<string name="device_name_title">Device name</string> <string name="device_name_title">Device name</string>
<string name="diagnostics_description">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.</string>
<string name="diagnostics_disabled_message">Diagnostics sharing is off.</string>
<string name="diagnostics_enabled_message">Diagnostics sharing is on.</string>
<string name="diagnostics_title">Share diagnostics</string>
<string name="error_camera">Camera access is required to scan a QR code.</string> <string name="error_camera">Camera access is required to scan a QR code.</string>
<string name="error_device_info">Could not load device information.</string> <string name="error_device_info">Could not load device information.</string>
<string name="error_destination_exists">A file with the same name already exists in the destination. Choose another folder or remove the existing file.</string> <string name="error_destination_exists">A file with the same name already exists in the destination. Choose another folder or remove the existing file.</string>

View File

@@ -80,7 +80,6 @@ fun App(
graph.coreRepository, graph.coreRepository,
graph.preferencesRepository, graph.preferencesRepository,
graph.messages, graph.messages,
graph.diagnostics,
) )
} }
val sendViewModel = viewModel { val sendViewModel = viewModel {
@@ -105,7 +104,6 @@ fun App(
dependencies.localNotificationService, dependencies.localNotificationService,
graph.messages, graph.messages,
graph.diagnostics.bugReports, graph.diagnostics.bugReports,
graph.diagnostics,
) )
} }
val appState by appViewModel.state.collectAsStateWithLifecycle() val appState by appViewModel.state.collectAsStateWithLifecycle()

View File

@@ -19,9 +19,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
class AppGraph( class AppGraph(
val dependencies: AppDependencies, val dependencies: AppDependencies,
@@ -40,11 +37,9 @@ class AppGraph(
receiveFolder = dependencies.fileSystemService.defaultReceiveFolder(), receiveFolder = dependencies.fileSystemService.defaultReceiveFolder(),
themeMode = ThemeMode.System, themeMode = ThemeMode.System,
notificationsEnabled = false, notificationsEnabled = false,
diagnosticsEnabled = false,
), ),
) )
val diagnostics = DiagnosticsCoordinator.create( val diagnostics = DiagnosticsCoordinator.create(
appDataDir = dependencies.environment.defaultCoreDataDir,
appVersion = dependencies.environment.appVersion, appVersion = dependencies.environment.appVersion,
platform = dependencies.environment.name, platform = dependencies.environment.name,
preferencesRepository = preferencesRepository, preferencesRepository = preferencesRepository,
@@ -75,12 +70,6 @@ class AppGraph(
init { init {
AppLogger.initialize(dependencies.environment.defaultCoreDataDir) AppLogger.initialize(dependencies.environment.defaultCoreDataDir)
diagnostics.start() diagnostics.start()
applicationScope.launch {
visibility.isForeground
.drop(1)
.filter { isForeground -> !isForeground }
.collect { diagnostics.telemetry.flush() }
}
} }
fun close() { fun close() {

View File

@@ -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)

View File

@@ -5,67 +5,34 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch 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 * Telemetry and crash auto-reporting were removed; only [bugReports] remains,
* crash auto-reporting are never started; [bugReports] still works for support. * and it only sends when the user submits a report from Settings.
*/ */
class DiagnosticsCoordinator( class DiagnosticsCoordinator(
val preferencesRepository: PreferencesRepository, val preferencesRepository: PreferencesRepository,
val transport: DiagnosticsTransport, val transport: DiagnosticsTransport,
val breadcrumbs: BreadcrumbBuffer, val breadcrumbs: BreadcrumbBuffer,
val telemetry: TelemetryRecorder,
val crashReporter: CrashReporter,
val bugReports: BugReportService, val bugReports: BugReportService,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val included: Boolean = DiagnosticsBuildConfig.INCLUDED,
) { ) {
fun start() { fun start() {
// Install id is useful for bug-report correlation even without telemetry. // Install id is useful for bug-report correlation.
scope.launch { scope.launch {
preferencesRepository.ensureDiagnosticsInstallId() preferencesRepository.ensureDiagnosticsInstallId()
} }
if (!included) return
crashReporter.startObservingPreferences()
crashReporter.installUnhandledExceptionHandler()
scope.launch {
crashReporter.flushPending()
}
}
fun record(name: String, properties: Map<String, String> = emptyMap()) {
if (!included) return
telemetry.record(name, properties)
} }
companion object { companion object {
fun create( fun create(
appDataDir: String,
appVersion: String, appVersion: String,
platform: String, platform: String,
preferencesRepository: PreferencesRepository, preferencesRepository: PreferencesRepository,
scope: CoroutineScope, scope: CoroutineScope,
transport: DiagnosticsTransport = NoOpDiagnosticsTransport(), transport: DiagnosticsTransport = NoOpDiagnosticsTransport(),
included: Boolean = DiagnosticsBuildConfig.INCLUDED,
): DiagnosticsCoordinator { ): DiagnosticsCoordinator {
val breadcrumbs = BreadcrumbBuffer() 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( val bugReports = BugReportService(
preferencesRepository = preferencesRepository, preferencesRepository = preferencesRepository,
transport = transport, transport = transport,
@@ -77,11 +44,8 @@ class DiagnosticsCoordinator(
preferencesRepository = preferencesRepository, preferencesRepository = preferencesRepository,
transport = transport, transport = transport,
breadcrumbs = breadcrumbs, breadcrumbs = breadcrumbs,
telemetry = telemetry,
crashReporter = crashReporter,
bugReports = bugReports, bugReports = bugReports,
scope = scope, scope = scope,
included = included,
) )
} }
} }

View File

@@ -10,83 +10,6 @@ internal object DiagnosticsJson {
internal const val MaxPlatformBytes = 40 internal const val MaxPlatformBytes = 40
private const val MaxBreadcrumbsJsonBytes = 16_000 private const val MaxBreadcrumbsJsonBytes = 16_000
private const val MaxBreadcrumbs = 40 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<TelemetryEvent>,
): 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<TelemetryEvent>): 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 { fun bugBody(report: BugReport): String {
val logs = if (report.includeLogs) report.logs else "" val logs = if (report.includeLogs) report.logs else ""

View File

@@ -5,40 +5,12 @@ package com.vnidrop.app.diagnostics
* intentionally abstracted; nothing here assumes a network backend. * intentionally abstracted; nothing here assumes a network backend.
*/ */
data class TelemetryEvent(
val name: String,
val timestampMillis: Long,
val properties: Map<String, String> = emptyMap(),
val schemaVersion: Int = DiagnosticsSchemaVersion,
)
/** One idempotent upload unit; [id] remains stable when delivery is retried. */
data class TelemetryBatch(
val id: String,
val events: List<TelemetryEvent>,
)
data class Breadcrumb( data class Breadcrumb(
val name: String, val name: String,
val timestampMillis: Long, val timestampMillis: Long,
val properties: Map<String, String> = emptyMap(), val properties: Map<String, String> = emptyMap(),
) )
data class CrashReport(
val id: String,
val timestampMillis: Long,
val installId: String,
val appVersion: String,
val platform: String,
val exceptionType: String,
val exceptionMessage: String,
val stackTrace: String,
val breadcrumbs: List<Breadcrumb>,
/** `null` only while a startup crash is waiting for the persisted preference to load. */
val diagnosticsEnabledAtCapture: Boolean?,
val schemaVersion: Int = DiagnosticsSchemaVersion,
)
data class DeviceSnapshot( data class DeviceSnapshot(
val deviceName: String?, val deviceName: String?,
val deviceModel: String?, val deviceModel: String?,

View File

@@ -1,50 +1,25 @@
package com.vnidrop.app.diagnostics 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 { interface DiagnosticsTransport {
suspend fun sendEvents(batch: TelemetryBatch): Result<Unit>
suspend fun sendCrash(report: CrashReport): Result<Unit>
suspend fun sendBugReport(report: BugReport): Result<Unit> suspend fun sendBugReport(report: BugReport): Result<Unit>
} }
internal class DiagnosticsUnavailableException : IllegalStateException("diagnostics delivery is not configured") 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. */ /** Fails delivery without leaving the device. Used until a remote endpoint is configured. */
class NoOpDiagnosticsTransport : DiagnosticsTransport { class NoOpDiagnosticsTransport : DiagnosticsTransport {
override suspend fun sendEvents(batch: TelemetryBatch): Result<Unit> = unavailable() override suspend fun sendBugReport(report: BugReport): Result<Unit> =
override suspend fun sendCrash(report: CrashReport): Result<Unit> = unavailable() Result.failure(DiagnosticsUnavailableException())
override suspend fun sendBugReport(report: BugReport): Result<Unit> = unavailable()
private fun unavailable(): Result<Unit> = Result.failure(DiagnosticsUnavailableException())
} }
/** /**
* Test double that records calls and can fail on demand. * Test double that records calls and can fail on demand.
*/ */
class RecordingDiagnosticsTransport : DiagnosticsTransport { class RecordingDiagnosticsTransport : DiagnosticsTransport {
val eventBatches = mutableListOf<TelemetryBatch>()
val events: List<List<TelemetryEvent>>
get() = eventBatches.map(TelemetryBatch::events)
val crashes = mutableListOf<CrashReport>()
val bugReports = mutableListOf<BugReport>() val bugReports = mutableListOf<BugReport>()
var eventsResult: Result<Unit> = Result.success(Unit)
var crashResult: Result<Unit> = Result.success(Unit)
var bugResult: Result<Unit> = Result.success(Unit) var bugResult: Result<Unit> = Result.success(Unit)
override suspend fun sendEvents(batch: TelemetryBatch): Result<Unit> {
eventBatches += batch
return eventsResult
}
override suspend fun sendCrash(report: CrashReport): Result<Unit> {
crashes += report
return crashResult
}
override suspend fun sendBugReport(report: BugReport): Result<Unit> { override suspend fun sendBugReport(report: BugReport): Result<Unit> {
bugReports += report bugReports += report
return bugResult return bugResult

View File

@@ -27,33 +27,6 @@ class HttpDiagnosticsTransport(
} }
} }
override suspend fun sendEvents(batch: TelemetryBatch): Result<Unit> {
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<Unit> {
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<Unit> { override suspend fun sendBugReport(report: BugReport): Result<Unit> {
val body = DiagnosticsJson.bugBody(report) val body = DiagnosticsJson.bugBody(report)
return postJson("/v1/bugs", body, report.installId, report.id) return postJson("/v1/bugs", body, report.installId, report.id)

View File

@@ -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<CrashReport>
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<String, String>()
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<String, String>()
for (part in raw.split(FieldSep)) {
if (part.isEmpty()) continue
val eq = part.indexOf('=')
if (eq <= 0) continue
val key = part.substring(0, eq)
val value = part.substring(eq + 1)
.replace("\\n", "\n")
.replace("\\r", "\r")
map[key] = value
}
val 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<String, String>,
crumbs: List<Breadcrumb>,
): 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()
}

View File

@@ -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<Unit>(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<String, String> = 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<Unit> {
return bufferMutex.withLock {
var discardedFailure: Throwable? = null
var outcome: Result<Unit>? = 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<TelemetryEvent>): List<TelemetryEvent> {
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<TelemetryEvent> = emptyList(),
val retryBatch: TelemetryBatch? = null,
)

View File

@@ -6,7 +6,6 @@ import com.vnidrop.app.PlatformEnvironment
import com.vnidrop.app.AppDependencies import com.vnidrop.app.AppDependencies
import com.vnidrop.app.AppGraph import com.vnidrop.app.AppGraph
import com.vnidrop.app.core.CoreGateway import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
import com.vnidrop.app.logging.AppLogger import com.vnidrop.app.logging.AppLogger
import com.vnidrop.app.preferences.PreferencesRepository import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessageController import com.vnidrop.app.ui.feedback.UiMessageController
@@ -38,14 +37,12 @@ class AppViewModel(
private val repository: CoreGateway, private val repository: CoreGateway,
preferencesRepository: PreferencesRepository, preferencesRepository: PreferencesRepository,
private val messages: UiMessageController, private val messages: UiMessageController,
private val diagnostics: DiagnosticsCoordinator? = null,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(AppState()) private val _state = MutableStateFlow(AppState())
val state: StateFlow<AppState> = _state.asStateFlow() val state: StateFlow<AppState> = _state.asStateFlow()
init { init {
AppLogger.info("lifecycle", "app started", mapOf("platform" to environment.name)) AppLogger.info("lifecycle", "app started", mapOf("platform" to environment.name))
diagnostics?.record("app_open", mapOf("platform" to environment.name, "version" to environment.appVersion))
viewModelScope.launch { viewModelScope.launch {
val relaySettings = preferencesRepository.preferences.first().relaySettings val relaySettings = preferencesRepository.preferences.first().relaySettings
repository.initialize(environment.defaultCoreDataDir, relaySettings).onFailure(messages::error) repository.initialize(environment.defaultCoreDataDir, relaySettings).onFailure(messages::error)
@@ -59,6 +56,5 @@ class AppViewModel(
fun selectDestination(destination: AppDestination) { fun selectDestination(destination: AppDestination) {
_state.update { it.copy(destination = destination) } _state.update { it.copy(destination = destination) }
diagnostics?.record("nav_select", mapOf("destination" to destination.name))
} }
} }

View File

@@ -18,7 +18,6 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp 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.AppIcon
import com.vnidrop.app.ui.icons.PlatformIcon import com.vnidrop.app.ui.icons.PlatformIcon
import com.vnidrop.app.ui.theme.LocalVniDropColors 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_tagline
import vnidrop.shared.generated.resources.about_title import vnidrop.shared.generated.resources.about_title
import vnidrop.shared.generated.resources.device_model_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.os_version_title
import vnidrop.shared.generated.resources.value_unavailable import vnidrop.shared.generated.resources.value_unavailable
import vnidrop.shared.generated.resources.version_title import vnidrop.shared.generated.resources.version_title
@@ -57,7 +54,6 @@ private val PrivacyPolicyUrl = com.vnidrop.app.AppConfig.PRIVACY_POLICY_URL
@Composable @Composable
internal fun AboutSettings( internal fun AboutSettings(
state: SettingsState, state: SettingsState,
onDiagnosticsChanged: (Boolean) -> Unit,
onReportBug: () -> Unit, onReportBug: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
showBack: Boolean, showBack: Boolean,
@@ -130,17 +126,6 @@ internal fun AboutSettings(
} }
SettingsGroup { 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( SettingsRow(
icon = AppIcon.Bug, icon = AppIcon.Bug,
title = stringResource(Res.string.about_bug_report), title = stringResource(Res.string.about_bug_report),

View File

@@ -33,7 +33,6 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) {
onResetFolder = viewModel::resetReceiveFolder, onResetFolder = viewModel::resetReceiveFolder,
onNotificationsChanged = viewModel::setNotificationsEnabled, onNotificationsChanged = viewModel::setNotificationsEnabled,
onOpenNotificationSettings = viewModel::openNotificationSettings, onOpenNotificationSettings = viewModel::openNotificationSettings,
onDiagnosticsChanged = viewModel::setDiagnosticsEnabled,
onBugWhatChanged = viewModel::setBugWhatHappened, onBugWhatChanged = viewModel::setBugWhatHappened,
onBugExpectedChanged = viewModel::setBugExpected, onBugExpectedChanged = viewModel::setBugExpected,
onBugStepsChanged = viewModel::setBugSteps, onBugStepsChanged = viewModel::setBugSteps,

View File

@@ -23,7 +23,6 @@ fun SettingsScreen(
onResetFolder: () -> Unit, onResetFolder: () -> Unit,
onNotificationsChanged: (Boolean) -> Unit, onNotificationsChanged: (Boolean) -> Unit,
onOpenNotificationSettings: () -> Unit, onOpenNotificationSettings: () -> Unit,
onDiagnosticsChanged: (Boolean) -> Unit,
onBugWhatChanged: (String) -> Unit, onBugWhatChanged: (String) -> Unit,
onBugExpectedChanged: (String) -> Unit, onBugExpectedChanged: (String) -> Unit,
onBugStepsChanged: (String) -> Unit, onBugStepsChanged: (String) -> Unit,
@@ -62,7 +61,6 @@ fun SettingsScreen(
onResetFolder = onResetFolder, onResetFolder = onResetFolder,
onNotificationsChanged = onNotificationsChanged, onNotificationsChanged = onNotificationsChanged,
onOpenNotificationSettings = onOpenNotificationSettings, onOpenNotificationSettings = onOpenNotificationSettings,
onDiagnosticsChanged = onDiagnosticsChanged,
onBugWhatChanged = onBugWhatChanged, onBugWhatChanged = onBugWhatChanged,
onBugExpectedChanged = onBugExpectedChanged, onBugExpectedChanged = onBugExpectedChanged,
onBugStepsChanged = onBugStepsChanged, onBugStepsChanged = onBugStepsChanged,
@@ -105,7 +103,6 @@ fun SettingsScreen(
onResetFolder = onResetFolder, onResetFolder = onResetFolder,
onNotificationsChanged = onNotificationsChanged, onNotificationsChanged = onNotificationsChanged,
onOpenNotificationSettings = onOpenNotificationSettings, onOpenNotificationSettings = onOpenNotificationSettings,
onDiagnosticsChanged = onDiagnosticsChanged,
onBugWhatChanged = onBugWhatChanged, onBugWhatChanged = onBugWhatChanged,
onBugExpectedChanged = onBugExpectedChanged, onBugExpectedChanged = onBugExpectedChanged,
onBugStepsChanged = onBugStepsChanged, onBugStepsChanged = onBugStepsChanged,
@@ -140,7 +137,6 @@ private fun SettingsSectionContent(
onResetFolder: () -> Unit, onResetFolder: () -> Unit,
onNotificationsChanged: (Boolean) -> Unit, onNotificationsChanged: (Boolean) -> Unit,
onOpenNotificationSettings: () -> Unit, onOpenNotificationSettings: () -> Unit,
onDiagnosticsChanged: (Boolean) -> Unit,
onBugWhatChanged: (String) -> Unit, onBugWhatChanged: (String) -> Unit,
onBugExpectedChanged: (String) -> Unit, onBugExpectedChanged: (String) -> Unit,
onBugStepsChanged: (String) -> Unit, onBugStepsChanged: (String) -> Unit,
@@ -184,7 +180,6 @@ private fun SettingsSectionContent(
) )
SettingsSection.About -> AboutSettings( SettingsSection.About -> AboutSettings(
state = state, state = state,
onDiagnosticsChanged = onDiagnosticsChanged,
onReportBug = { onSectionSelected(SettingsSection.BugReport) }, onReportBug = { onSectionSelected(SettingsSection.BugReport) },
onBack = onBack, onBack = onBack,
showBack = showBack, showBack = showBack,

View File

@@ -16,8 +16,6 @@ import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.core.usesCustomRelayUrls import com.vnidrop.app.core.usesCustomRelayUrls
import com.vnidrop.app.diagnostics.BugReportDraft import com.vnidrop.app.diagnostics.BugReportDraft
import com.vnidrop.app.diagnostics.BugReportService import com.vnidrop.app.diagnostics.BugReportService
import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
import com.vnidrop.app.notifications.LocalNotificationService import com.vnidrop.app.notifications.LocalNotificationService
import com.vnidrop.app.notifications.NotificationPermission import com.vnidrop.app.notifications.NotificationPermission
import com.vnidrop.app.preferences.PreferencesRepository import com.vnidrop.app.preferences.PreferencesRepository
@@ -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_submit_failed
import vnidrop.shared.generated.resources.bug_report_submitted import vnidrop.shared.generated.resources.bug_report_submitted
import vnidrop.shared.generated.resources.button_open_settings import vnidrop.shared.generated.resources.button_open_settings
import vnidrop.shared.generated.resources.diagnostics_disabled_message
import vnidrop.shared.generated.resources.diagnostics_enabled_message
import vnidrop.shared.generated.resources.notifications_enabled_message import vnidrop.shared.generated.resources.notifications_enabled_message
import vnidrop.shared.generated.resources.notifications_permission_denied import vnidrop.shared.generated.resources.notifications_permission_denied
import vnidrop.shared.generated.resources.notifications_settings_open_failed import vnidrop.shared.generated.resources.notifications_settings_open_failed
@@ -102,7 +98,6 @@ data class SettingsState(
val endpointId: String? = null, val endpointId: String? = null,
val notificationsEnabled: Boolean = false, val notificationsEnabled: Boolean = false,
val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined, val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined,
val diagnosticsEnabled: Boolean = false,
val deviceInfo: DeviceInfo? = null, val deviceInfo: DeviceInfo? = null,
val appVersion: String = "", val appVersion: String = "",
val isLoadingDeviceInfo: Boolean = false, val isLoadingDeviceInfo: Boolean = false,
@@ -138,8 +133,6 @@ class SettingsViewModel(
private val notifications: LocalNotificationService, private val notifications: LocalNotificationService,
private val messages: UiMessageController, private val messages: UiMessageController,
private val bugReports: BugReportService, private val bugReports: BugReportService,
private val diagnostics: DiagnosticsCoordinator? = null,
private val diagnosticsIncluded: Boolean = DiagnosticsBuildConfig.INCLUDED,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow( private val _state = MutableStateFlow(
SettingsState( SettingsState(
@@ -167,7 +160,6 @@ class SettingsViewModel(
receiveFolder = receiveFolder, receiveFolder = receiveFolder,
themeMode = preferences.themeMode, themeMode = preferences.themeMode,
notificationsEnabled = preferences.notificationsEnabled, notificationsEnabled = preferences.notificationsEnabled,
diagnosticsEnabled = preferences.diagnosticsEnabled,
savedRelaySettings = preferences.relaySettings, savedRelaySettings = preferences.relaySettings,
relayMode = if (hasLocalRelayDraft) current.relayMode else preferences.relaySettings.mode, relayMode = if (hasLocalRelayDraft) current.relayMode else preferences.relaySettings.mode,
relayUrls = if (hasLocalRelayDraft) { 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 setBugWhatHappened(value: String) = _state.update { it.copy(bugWhatHappened = value) }
fun setBugExpected(value: String) = _state.update { it.copy(bugExpected = value) } fun setBugExpected(value: String) = _state.update { it.copy(bugExpected = value) }
fun setBugSteps(value: String) = _state.update { it.copy(bugSteps = value) } fun setBugSteps(value: String) = _state.update { it.copy(bugSteps = value) }
@@ -565,7 +538,6 @@ class SettingsViewModel(
) )
result.fold( result.fold(
onSuccess = { onSuccess = {
diagnostics?.record("bug_report_submitted")
_state.update { _state.update {
it.copy( it.copy(
isSubmittingBugReport = false, isSubmittingBugReport = false,

View File

@@ -24,9 +24,7 @@ data class AppPreferences(
val receiveFolder: ReceiveFolder, val receiveFolder: ReceiveFolder,
val themeMode: ThemeMode, val themeMode: ThemeMode,
val notificationsEnabled: Boolean, val notificationsEnabled: Boolean,
/** Master opt-in for automatic telemetry + crash upload. Bug reports remain available always. */ /** Stable anonymous install id for bug-report correlation; never an account or advertising id. */
val diagnosticsEnabled: Boolean = false,
/** Stable anonymous install id; never an account or advertising id. */
val diagnosticsInstallId: String = "", val diagnosticsInstallId: String = "",
val relaySettings: RelaySettings = RelaySettings(), val relaySettings: RelaySettings = RelaySettings(),
) )
@@ -36,7 +34,6 @@ class AppPreferencesDefaults(
val receiveFolder: ReceiveFolder, val receiveFolder: ReceiveFolder,
val themeMode: ThemeMode, val themeMode: ThemeMode,
val notificationsEnabled: Boolean = false, val notificationsEnabled: Boolean = false,
val diagnosticsEnabled: Boolean = false,
) )
interface PreferencesRepository { interface PreferencesRepository {
@@ -46,7 +43,6 @@ interface PreferencesRepository {
suspend fun resetReceiveFolder() suspend fun resetReceiveFolder()
suspend fun setThemeMode(mode: ThemeMode) suspend fun setThemeMode(mode: ThemeMode)
suspend fun setNotificationsEnabled(enabled: Boolean) suspend fun setNotificationsEnabled(enabled: Boolean)
suspend fun setDiagnosticsEnabled(enabled: Boolean)
suspend fun setRelaySettings(settings: RelaySettings) suspend fun setRelaySettings(settings: RelaySettings)
/** Ensures a durable install id exists and returns it. */ /** Ensures a durable install id exists and returns it. */
suspend fun ensureDiagnosticsInstallId(): String suspend fun ensureDiagnosticsInstallId(): String
@@ -83,7 +79,6 @@ class AppPreferencesRepository(
receiveFolder = resolveReceiveFolder(prefs, defaults.receiveFolder), receiveFolder = resolveReceiveFolder(prefs, defaults.receiveFolder),
themeMode = prefs[PreferenceKeys.ThemeMode]?.let { themeModeOrNull(it) } ?: defaults.themeMode, themeMode = prefs[PreferenceKeys.ThemeMode]?.let { themeModeOrNull(it) } ?: defaults.themeMode,
notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled, notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled,
diagnosticsEnabled = prefs[PreferenceKeys.DiagnosticsEnabled] ?: defaults.diagnosticsEnabled,
diagnosticsInstallId = prefs[PreferenceKeys.DiagnosticsInstallId].orEmpty(), diagnosticsInstallId = prefs[PreferenceKeys.DiagnosticsInstallId].orEmpty(),
relaySettings = RelaySettings( relaySettings = RelaySettings(
mode = relayMode, 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) { override suspend fun setRelaySettings(settings: RelaySettings) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[PreferenceKeys.RelayMode] = settings.mode.name prefs[PreferenceKeys.RelayMode] = settings.mode.name
@@ -160,7 +149,6 @@ private object PreferenceKeys {
val ReceiveFolderDisplayName = stringPreferencesKey("receive_folder_display_name") val ReceiveFolderDisplayName = stringPreferencesKey("receive_folder_display_name")
val ThemeMode = stringPreferencesKey("theme_mode") val ThemeMode = stringPreferencesKey("theme_mode")
val NotificationsEnabled = booleanPreferencesKey("notifications_enabled") val NotificationsEnabled = booleanPreferencesKey("notifications_enabled")
val DiagnosticsEnabled = booleanPreferencesKey("diagnostics_enabled")
val DiagnosticsInstallId = stringPreferencesKey("diagnostics_install_id") val DiagnosticsInstallId = stringPreferencesKey("diagnostics_install_id")
val RelayMode = stringPreferencesKey("relay_mode") val RelayMode = stringPreferencesKey("relay_mode")
val RelayUrls = stringPreferencesKey("relay_urls") val RelayUrls = stringPreferencesKey("relay_urls")

View File

@@ -4,83 +4,25 @@ import com.vnidrop.app.DeviceInfo
import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.preferences.AppPreferences import com.vnidrop.app.preferences.AppPreferences
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.support.FakePreferencesRepository import com.vnidrop.app.support.FakePreferencesRepository
import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi 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 kotlinx.coroutines.test.runTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertFailsWith import kotlin.test.assertFailsWith
import kotlin.test.assertIs import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class DiagnosticsTest { 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 @Test
fun diagnosticsJsonKeepsEscapedBugPayloadWithinWorkerLimit() { fun diagnosticsJsonKeepsEscapedBugPayloadWithinWorkerLimit() {
val report = BugReport( val report = bugReport(
id = "b",
timestampMillis = 1L,
installId = "i",
appVersion = "1.0",
platform = "Test",
whatHappened = "w",
expected = "e",
steps = "",
contact = "",
includeLogs = true,
logs = "\n".repeat(BugReportService.MaxLogBytes), logs = "\n".repeat(BugReportService.MaxLogBytes),
device = DeviceSnapshot(null, null, "OS", null, null), includeLogs = true,
breadcrumbs = emptyList(),
) )
val body = DiagnosticsJson.bugBody(report) val body = DiagnosticsJson.bugBody(report)
@@ -91,67 +33,24 @@ class DiagnosticsTest {
} }
@Test @Test
fun httpTransportPostsExpectedPaths() = runTest { fun httpTransportPostsBugReportToExpectedPath() = runTest {
val calls = mutableListOf<Pair<String, String>>() val calls = mutableListOf<Pair<String, String>>()
val acknowledgementIds = ArrayDeque(listOf("batch-1", "c", "b"))
val transport = HttpDiagnosticsTransport( val transport = HttpDiagnosticsTransport(
baseUrl = "https://diag.example", baseUrl = "https://diag.example",
ingestKey = "secret", ingestKey = "secret",
appVersion = "1.0", appVersion = "1.0",
platform = "Test", platform = "Test",
installIdProvider = { "install-x" },
post = { url, headers, body -> post = { url, headers, body ->
assertEquals("secret", headers["X-VniDrop-Key"]) assertEquals("secret", headers["X-VniDrop-Key"])
calls += url to body calls += url to body
PlatformHttpResponse( PlatformHttpResponse(202, """{"ok":true,"id":"b","stored":1}""")
202,
"""{"ok":true,"id":"${acknowledgementIds.removeFirst()}","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( val bugResult = transport.sendBugReport(bugReport())
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(
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) assertTrue(bugResult.isSuccess)
assertEquals("https://diag.example/v1/bugs", calls[2].first) assertEquals("https://diag.example/v1/bugs", calls.single().first)
} }
@Test @Test
@@ -162,12 +61,12 @@ class DiagnosticsTest {
post = { _, _, _ -> post = { _, _, _ ->
PlatformHttpResponse( PlatformHttpResponse(
202, 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 @Test
@@ -177,9 +76,7 @@ class DiagnosticsTest {
ingestKey = "secret", ingestKey = "secret",
post = { _, _, _ -> PlatformHttpResponse(401, """{"error":"unauthorized"}""") }, post = { _, _, _ -> PlatformHttpResponse(401, """{"error":"unauthorized"}""") },
) )
assertTrue( assertTrue(transport.sendBugReport(bugReport()).isFailure)
transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L)))).isFailure,
)
} }
@Test @Test
@@ -191,7 +88,7 @@ class DiagnosticsTest {
) )
assertFailsWith<CancellationException> { assertFailsWith<CancellationException> {
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":false}"""),
PlatformHttpResponse(202, """{,"ok":true}"""), PlatformHttpResponse(202, """{,"ok":true}"""),
PlatformHttpResponse(202, """{"ok":true,"id":"different"}"""), 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( val transport = HttpDiagnosticsTransport(
@@ -212,10 +109,8 @@ class DiagnosticsTest {
post = { _, _, _ -> responses.removeFirst() }, post = { _, _, _ -> responses.removeFirst() },
) )
repeat(5) { index -> repeat(5) {
val result = transport.sendEvents( val result = transport.sendBugReport(bugReport())
TelemetryBatch("batch-$index", listOf(TelemetryEvent("x", 1L))),
)
assertIs<DiagnosticsProtocolException>(result.exceptionOrNull()) assertIs<DiagnosticsProtocolException>(result.exceptionOrNull())
} }
} }
@@ -224,11 +119,11 @@ class DiagnosticsTest {
fun httpTransportRejectsAmbiguousOrMalformedJsonAcknowledgement() = runTest { fun httpTransportRejectsAmbiguousOrMalformedJsonAcknowledgement() = runTest {
val responses = ArrayDeque( val responses = ArrayDeque(
listOf( 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":true}"""),
PlatformHttpResponse(202, """{"ok":true,"id":"batch-2","stored":01}"""), PlatformHttpResponse(202, """{"ok":true,"id":"b","stored":01}"""),
PlatformHttpResponse(202, """{"ok":true,"id":"batch-3",}"""), PlatformHttpResponse(202, """{"ok":true,"id":"b",}"""),
PlatformHttpResponse(202, """{"ok":true,"id":"batch-4"} trailing"""), PlatformHttpResponse(202, """{"ok":true,"id":"b"} trailing"""),
), ),
) )
val transport = HttpDiagnosticsTransport( val transport = HttpDiagnosticsTransport(
@@ -237,10 +132,8 @@ class DiagnosticsTest {
post = { _, _, _ -> responses.removeFirst() }, post = { _, _, _ -> responses.removeFirst() },
) )
repeat(5) { index -> repeat(5) {
val result = transport.sendEvents( val result = transport.sendBugReport(bugReport())
TelemetryBatch("batch-$index", listOf(TelemetryEvent("x", 1L))),
)
assertIs<DiagnosticsProtocolException>(result.exceptionOrNull()) assertIs<DiagnosticsProtocolException>(result.exceptionOrNull())
} }
} }
@@ -288,9 +181,7 @@ class DiagnosticsTest {
@Test @Test
fun noOpTransportReportsUnavailableDelivery() = runTest { fun noOpTransportReportsUnavailableDelivery() = runTest {
val result = NoOpDiagnosticsTransport().sendEvents( val result = NoOpDiagnosticsTransport().sendBugReport(bugReport())
TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L))),
)
assertIs<DiagnosticsUnavailableException>(result.exceptionOrNull()) assertIs<DiagnosticsUnavailableException>(result.exceptionOrNull())
} }
@@ -323,563 +214,10 @@ class DiagnosticsTest {
assertEquals(listOf("b", "c", "d"), buffer.snapshot().map { it.name }) 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<Unit>()
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<String>()
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<String, String>().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<String, String>().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<Unit>()
val releaseFirstSend = CompletableDeferred<Unit>()
val sentIds = mutableListOf<String>()
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<Unit> {
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 @Test
fun bugReportRequiresWhatAndExpected() = runTest { fun bugReportRequiresWhatAndExpected() = runTest {
val preferences = fakePrefs()
val service = BugReportService( val service = BugReportService(
preferencesRepository = preferences, preferencesRepository = fakePrefs(),
transport = RecordingDiagnosticsTransport(), transport = RecordingDiagnosticsTransport(),
breadcrumbs = BreadcrumbBuffer(), breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0", appVersion = "1.0",
@@ -893,8 +231,6 @@ class DiagnosticsTest {
@Test @Test
fun bugReportConvertsTransportExceptionsToFailure() = runTest { fun bugReportConvertsTransportExceptionsToFailure() = runTest {
val throwingTransport = object : DiagnosticsTransport { 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<Unit> { override suspend fun sendBugReport(report: BugReport): Result<Unit> {
throw IllegalStateException("offline") throw IllegalStateException("offline")
} }
@@ -914,11 +250,10 @@ class DiagnosticsTest {
} }
@Test @Test
fun bugReportSubmitsWithRedactedLogsRegardlessOfDiagnostics() = runTest { fun bugReportSubmitsWithRedactedLogs() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = false)
val transport = RecordingDiagnosticsTransport() val transport = RecordingDiagnosticsTransport()
val service = BugReportService( val service = BugReportService(
preferencesRepository = preferences, preferencesRepository = fakePrefs(),
transport = transport, transport = transport,
breadcrumbs = BreadcrumbBuffer(), breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0", appVersion = "1.0",
@@ -981,38 +316,35 @@ class DiagnosticsTest {
assertEquals(BugReportService.MaxLogBytes, service.previewLogBytes()) 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( AppPreferences(
username = "User", username = "User",
receiveFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp"), receiveFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp"),
themeMode = ThemeMode.System, themeMode = ThemeMode.System,
notificationsEnabled = false, notificationsEnabled = false,
diagnosticsEnabled = diagnosticsEnabled,
diagnosticsInstallId = "test-install", diagnosticsInstallId = "test-install",
), ),
) )
private fun device() = DeviceInfo("Phone", "Pixel", "Android 15", "Wi-Fi", "90%") private fun device() = DeviceInfo("Phone", "Pixel", "Android 15", "Wi-Fi", "90%")
} }
private class InMemoryPendingCrashStore : PendingCrashStore {
private val items = linkedMapOf<String, CrashReport>()
override fun write(report: CrashReport) {
items[report.id] = report
}
override fun list(): List<CrashReport> = items.values.sortedByDescending { it.timestampMillis }
override fun delete(id: String) {
items.remove(id)
}
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)
}
}

View File

@@ -420,33 +420,6 @@ class ViewModelsTest {
assertEquals(NotificationPermission.Unsupported, viewModel.state.value.notificationPermission) assertEquals(NotificationPermission.Unsupported, viewModel.state.value.notificationPermission)
} }
@Test
fun settingsTogglesDiagnosticsPreference() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val preferences = preferences()
val viewModel = settingsViewModel(preferences, 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 @Test
fun settingsSubmitsBugReportAndClearsForm() = runTest { fun settingsSubmitsBugReportAndClearsForm() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
@@ -993,7 +966,6 @@ class ViewModelsTest {
receiveFolder = folder, receiveFolder = folder,
themeMode = ThemeMode.System, themeMode = ThemeMode.System,
notificationsEnabled = false, notificationsEnabled = false,
diagnosticsEnabled = false,
diagnosticsInstallId = "test-install", diagnosticsInstallId = "test-install",
), ),
) )
@@ -1005,7 +977,6 @@ class ViewModelsTest {
notifications: FakeNotificationService = FakeNotificationService(), notifications: FakeNotificationService = FakeNotificationService(),
transport: DiagnosticsTransport = RecordingDiagnosticsTransport(), transport: DiagnosticsTransport = RecordingDiagnosticsTransport(),
fileSystem: FakeFileSystemService = FakeFileSystemService(folder), fileSystem: FakeFileSystemService = FakeFileSystemService(folder),
diagnosticsIncluded: Boolean = false,
repository: FakeCoreGateway = FakeCoreGateway(), repository: FakeCoreGateway = FakeCoreGateway(),
) = SettingsViewModel( ) = SettingsViewModel(
environment(), environment(),
@@ -1023,7 +994,6 @@ class ViewModelsTest {
platform = "Test", platform = "Test",
logReader = { "sample log line" }, logReader = { "sample log line" },
), ),
diagnosticsIncluded = diagnosticsIncluded,
) )
private fun receivedTransfer(id: ULong, status: TransferStatus) = Transfer( private fun receivedTransfer(id: ULong, status: TransferStatus) = Transfer(

View File

@@ -210,9 +210,6 @@ class FakePreferencesRepository(
override suspend fun resetReceiveFolder() = Unit override suspend fun resetReceiveFolder() = Unit
override suspend fun setThemeMode(mode: ThemeMode) { mutablePreferences.value = mutablePreferences.value.copy(themeMode = mode) } override suspend fun setThemeMode(mode: ThemeMode) { mutablePreferences.value = mutablePreferences.value.copy(themeMode = mode) }
override suspend fun setNotificationsEnabled(enabled: Boolean) { mutablePreferences.value = mutablePreferences.value.copy(notificationsEnabled = enabled) } override suspend fun setNotificationsEnabled(enabled: Boolean) { mutablePreferences.value = mutablePreferences.value.copy(notificationsEnabled = enabled) }
override suspend fun setDiagnosticsEnabled(enabled: Boolean) {
mutablePreferences.value = mutablePreferences.value.copy(diagnosticsEnabled = enabled)
}
override suspend fun setRelaySettings(settings: RelaySettings) { override suspend fun setRelaySettings(settings: RelaySettings) {
mutablePreferences.value = mutablePreferences.value.copy(relaySettings = settings) mutablePreferences.value = mutablePreferences.value.copy(relaySettings = settings)
} }

View File

@@ -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<CrashReport> {
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<CrashReport> { 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<Pair<File, CrashReport>> { (_, report) -> report.timestampMillis }
.thenBy { (_, report) -> report.id },
)
reports.forEachIndexed { index, (file, report) ->
if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) file.delete()
}
}
}

View File

@@ -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)
}
}

View File

@@ -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,
)
}

View File

@@ -152,7 +152,6 @@ class FoundationComposeTest {
onResetFolder = {}, onResetFolder = {},
onNotificationsChanged = {}, onNotificationsChanged = {},
onOpenNotificationSettings = {}, onOpenNotificationSettings = {},
onDiagnosticsChanged = {},
onBugWhatChanged = {}, onBugWhatChanged = {},
onBugExpectedChanged = {}, onBugExpectedChanged = {},
onBugStepsChanged = {}, onBugStepsChanged = {},
@@ -182,7 +181,6 @@ class FoundationComposeTest {
onResetFolder = {}, onResetFolder = {},
onNotificationsChanged = {}, onNotificationsChanged = {},
onOpenNotificationSettings = {}, onOpenNotificationSettings = {},
onDiagnosticsChanged = {},
onBugWhatChanged = {}, onBugWhatChanged = {},
onBugExpectedChanged = {}, onBugExpectedChanged = {},
onBugStepsChanged = {}, onBugStepsChanged = {},
@@ -238,7 +236,6 @@ class FoundationComposeTest {
onResetFolder = {}, onResetFolder = {},
onNotificationsChanged = {}, onNotificationsChanged = {},
onOpenNotificationSettings = {}, onOpenNotificationSettings = {},
onDiagnosticsChanged = {},
onBugWhatChanged = {}, onBugWhatChanged = {},
onBugExpectedChanged = {}, onBugExpectedChanged = {},
onBugStepsChanged = {}, onBugStepsChanged = {},
@@ -291,7 +288,6 @@ class FoundationComposeTest {
onResetFolder = {}, onResetFolder = {},
onNotificationsChanged = {}, onNotificationsChanged = {},
onOpenNotificationSettings = {}, onOpenNotificationSettings = {},
onDiagnosticsChanged = {},
onBugWhatChanged = {}, onBugWhatChanged = {},
onBugExpectedChanged = {}, onBugExpectedChanged = {},
onBugStepsChanged = {}, onBugStepsChanged = {},
@@ -322,7 +318,6 @@ class FoundationComposeTest {
onResetFolder = {}, onResetFolder = {},
onNotificationsChanged = {}, onNotificationsChanged = {},
onOpenNotificationSettings = {}, onOpenNotificationSettings = {},
onDiagnosticsChanged = {},
onBugWhatChanged = {}, onBugWhatChanged = {},
onBugExpectedChanged = {}, onBugExpectedChanged = {},
onBugStepsChanged = {}, onBugStepsChanged = {},
@@ -358,7 +353,6 @@ class FoundationComposeTest {
onResetFolder = {}, onResetFolder = {},
onNotificationsChanged = { enabled = it }, onNotificationsChanged = { enabled = it },
onOpenNotificationSettings = {}, onOpenNotificationSettings = {},
onDiagnosticsChanged = {},
onBugWhatChanged = {}, onBugWhatChanged = {},
onBugExpectedChanged = {}, onBugExpectedChanged = {},
onBugStepsChanged = {}, onBugStepsChanged = {},
@@ -390,7 +384,6 @@ class FoundationComposeTest {
onResetFolder = {}, onResetFolder = {},
onNotificationsChanged = {}, onNotificationsChanged = {},
onOpenNotificationSettings = { opened = true }, onOpenNotificationSettings = { opened = true },
onDiagnosticsChanged = {},
onBugWhatChanged = {}, onBugWhatChanged = {},
onBugExpectedChanged = {}, onBugExpectedChanged = {},
onBugStepsChanged = {}, onBugStepsChanged = {},