mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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 d’utilisation anonymes pour nous aider à améliorer VniDrop. Vous pouvez désactiver cela à tout moment. Les invitations, chemins de fichiers et contenus de transfert ne sont jamais inclus.",
|
|
||||||
"es": "Enviar informes de fallos y eventos de uso anónimos para ayudarnos a mejorar VniDrop. Puede desactivarlo en cualquier momento. Las invitaciones, las rutas de archivos y el contenido de las transferencias nunca se incluyen.",
|
|
||||||
"it": "Invia report di arresto anomalo ed eventi d’uso anonimi per aiutarci a migliorare VniDrop. Può disattivarlo in qualsiasi momento. Inviti, percorsi dei file e contenuti dei trasferimenti non vengono mai inclusi.",
|
|
||||||
"de": "Anonyme Absturzberichte und Nutzungsereignisse senden, damit wir VniDrop verbessern können. Sie können dies jederzeit deaktivieren. Einladungen, Dateipfade und Übertragungsinhalte werden niemals einbezogen.",
|
|
||||||
"pt": "Enviar relatórios de falhas e eventos de utilização anónimos para nos ajudar a melhorar o VniDrop. Pode desativar isto a qualquer momento. Convites, caminhos de ficheiros e conteúdos das transferências nunca são incluídos.",
|
|
||||||
"pl": "Wysyłaj anonimowe raporty o awariach i zdarzenia użytkowania, aby pomóc nam ulepszać VniDrop. Możesz to wyłączyć w dowolnej chwili. Zaproszenia, ścieżki plików i zawartość transferów nigdy nie są dołączane.",
|
|
||||||
"nl": "Verstuur anonieme crashrapporten en gebruiksgebeurtenissen zodat we VniDrop kunnen verbeteren. U kunt dit op elk moment uitschakelen. Uitnodigingen, bestandspaden en overdrachtsinhoud worden nooit meegestuurd.",
|
|
||||||
"ru": "Отправлять анонимные отчёты о сбоях и события использования, чтобы помочь нам улучшать VniDrop. Вы можете отключить это в любой момент. Приглашения, пути к файлам и содержимое передач никогда не включаются."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"diagnostics_disabled_message": {
|
|
||||||
"context": "Settings > Diagnostics: confirmation shown when diagnostics are turned off.",
|
|
||||||
"translations": {
|
|
||||||
"en": "Diagnostics sharing is off.",
|
|
||||||
"fr": "Le partage des diagnostics est désactivé.",
|
|
||||||
"es": "El uso compartido de diagnósticos está desactivado.",
|
|
||||||
"it": "La condivisione dei dati diagnostici è disattivata.",
|
|
||||||
"de": "Die Freigabe von Diagnosedaten ist deaktiviert.",
|
|
||||||
"pt": "A partilha de diagnósticos está desativada.",
|
|
||||||
"pl": "Udostępnianie diagnostyki jest wyłączone.",
|
|
||||||
"nl": "Het delen van diagnostische gegevens is uitgeschakeld.",
|
|
||||||
"ru": "Передача диагностики отключена."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"diagnostics_enabled_message": {
|
|
||||||
"context": "Settings > Diagnostics: confirmation shown when diagnostics are turned on.",
|
|
||||||
"translations": {
|
|
||||||
"en": "Diagnostics sharing is on.",
|
|
||||||
"fr": "Le partage des diagnostics est activé.",
|
|
||||||
"es": "El uso compartido de diagnósticos está activado.",
|
|
||||||
"it": "La condivisione dei dati diagnostici è attivata.",
|
|
||||||
"de": "Die Freigabe von Diagnosedaten ist aktiviert.",
|
|
||||||
"pt": "A partilha de diagnósticos está ativada.",
|
|
||||||
"pl": "Udostępnianie diagnostyki jest włączone.",
|
|
||||||
"nl": "Het delen van diagnostische gegevens is ingeschakeld.",
|
|
||||||
"ru": "Передача диагностики включена."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"diagnostics_title": {
|
|
||||||
"context": "Settings > Diagnostics: toggle title.",
|
|
||||||
"translations": {
|
|
||||||
"en": "Share diagnostics",
|
|
||||||
"fr": "Partager les diagnostics",
|
|
||||||
"es": "Compartir diagnósticos",
|
|
||||||
"it": "Condividi dati diagnostici",
|
|
||||||
"de": "Diagnosedaten teilen",
|
|
||||||
"pt": "Partilhar diagnósticos",
|
|
||||||
"pl": "Udostępniaj diagnostykę",
|
|
||||||
"nl": "Diagnostische gegevens delen",
|
|
||||||
"ru": "Делиться диагностикой"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"error_camera": {
|
"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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 l’appareil</string>
|
<string name="device_model_title">Modèle de l’appareil</string>
|
||||||
<string name="device_name_title">Nom de l’appareil</string>
|
<string name="device_name_title">Nom de l’appareil</string>
|
||||||
<string name="diagnostics_description">Envoyer des rapports de plantage et des événements d’utilisation anonymes pour nous aider à améliorer VniDrop. Vous pouvez désactiver cela à tout moment. Les invitations, chemins de fichiers et contenus de transfert ne sont jamais inclus.</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">L’accès à la caméra est nécessaire pour scanner un QR code.</string>
|
<string name="error_camera">L’accès à la caméra est nécessaire pour scanner un QR code.</string>
|
||||||
<string name="error_device_info">Impossible de charger les informations de l’appareil.</string>
|
<string name="error_device_info">Impossible de charger les informations de l’appareil.</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>
|
||||||
|
|||||||
@@ -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 d’uso anonimi per aiutarci a migliorare VniDrop. Può disattivarlo in qualsiasi momento. Inviti, percorsi dei file e contenuti dei trasferimenti non vengono mai inclusi.</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 l’accesso alla fotocamera.</string>
|
<string name="error_camera">Per scansionare un codice QR è necessario l’accesso 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 un’altra cartella o rimuova il file esistente.</string>
|
<string name="error_destination_exists">Nella destinazione esiste già un file con lo stesso nome. Scelga un’altra cartella o rimuova il file esistente.</string>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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?,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 = {},
|
||||||
|
|||||||
Reference in New Issue
Block a user