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:
@@ -49,7 +49,7 @@ val desktopRustVariant = providers.gradleProperty("vnidrop.desktop.rustVariant")
|
||||
.orElse(Variant.Debug)
|
||||
|
||||
// Compile-time switches (gradle.properties or -P…).
|
||||
// included=false: no Share-diagnostics toggle, no telemetry/crash auto-upload stack.
|
||||
// included=false: user-initiated bug reports use a NoOp transport (never sent).
|
||||
// endpoint/key both empty: transport is NoOp (safe default until Cloudflare is deployed).
|
||||
val diagnosticsIncluded: Boolean =
|
||||
(findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: false
|
||||
|
||||
@@ -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="device_model_title">Gerätemodell</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_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>
|
||||
|
||||
@@ -80,10 +80,6 @@
|
||||
<string name="button_write_nfc">Escribir en etiqueta NFC</string>
|
||||
<string name="device_model_title">Modelo 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_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>
|
||||
|
||||
@@ -80,10 +80,6 @@
|
||||
<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_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_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>
|
||||
|
||||
@@ -80,10 +80,6 @@
|
||||
<string name="button_write_nfc">Scrivi su tag NFC</string>
|
||||
<string name="device_model_title">Modello 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_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>
|
||||
|
||||
@@ -80,10 +80,6 @@
|
||||
<string name="button_write_nfc">Naar NFC-tag schrijven</string>
|
||||
<string name="device_model_title">Apparaatmodel</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_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>
|
||||
|
||||
@@ -80,10 +80,6 @@
|
||||
<string name="button_write_nfc">Zapisz na tagu NFC</string>
|
||||
<string name="device_model_title">Model 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_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>
|
||||
|
||||
@@ -80,10 +80,6 @@
|
||||
<string name="button_write_nfc">Escrever em etiqueta NFC</string>
|
||||
<string name="device_model_title">Modelo 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_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>
|
||||
|
||||
@@ -80,10 +80,6 @@
|
||||
<string name="button_write_nfc">Записать на NFC-метку</string>
|
||||
<string name="device_model_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_device_info">Не удалось загрузить сведения об устройстве.</string>
|
||||
<string name="error_destination_exists">В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл.</string>
|
||||
|
||||
@@ -80,10 +80,6 @@
|
||||
<string name="button_write_nfc">Write to NFC tag</string>
|
||||
<string name="device_model_title">Device model</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_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>
|
||||
|
||||
@@ -80,7 +80,6 @@ fun App(
|
||||
graph.coreRepository,
|
||||
graph.preferencesRepository,
|
||||
graph.messages,
|
||||
graph.diagnostics,
|
||||
)
|
||||
}
|
||||
val sendViewModel = viewModel {
|
||||
@@ -105,7 +104,6 @@ fun App(
|
||||
dependencies.localNotificationService,
|
||||
graph.messages,
|
||||
graph.diagnostics.bugReports,
|
||||
graph.diagnostics,
|
||||
)
|
||||
}
|
||||
val appState by appViewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -19,9 +19,6 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AppGraph(
|
||||
val dependencies: AppDependencies,
|
||||
@@ -40,11 +37,9 @@ class AppGraph(
|
||||
receiveFolder = dependencies.fileSystemService.defaultReceiveFolder(),
|
||||
themeMode = ThemeMode.System,
|
||||
notificationsEnabled = false,
|
||||
diagnosticsEnabled = false,
|
||||
),
|
||||
)
|
||||
val diagnostics = DiagnosticsCoordinator.create(
|
||||
appDataDir = dependencies.environment.defaultCoreDataDir,
|
||||
appVersion = dependencies.environment.appVersion,
|
||||
platform = dependencies.environment.name,
|
||||
preferencesRepository = preferencesRepository,
|
||||
@@ -75,12 +70,6 @@ class AppGraph(
|
||||
init {
|
||||
AppLogger.initialize(dependencies.environment.defaultCoreDataDir)
|
||||
diagnostics.start()
|
||||
applicationScope.launch {
|
||||
visibility.isForeground
|
||||
.drop(1)
|
||||
.filter { isForeground -> !isForeground }
|
||||
.collect { diagnostics.telemetry.flush() }
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
* Owns diagnostics services for the app process: telemetry, crashes, bug reports.
|
||||
* Owns user-initiated bug reports for the app process.
|
||||
*
|
||||
* When [DiagnosticsBuildConfig.INCLUDED] is false (compile-time), telemetry and
|
||||
* crash auto-reporting are never started; [bugReports] still works for support.
|
||||
* Telemetry and crash auto-reporting were removed; only [bugReports] remains,
|
||||
* and it only sends when the user submits a report from Settings.
|
||||
*/
|
||||
class DiagnosticsCoordinator(
|
||||
val preferencesRepository: PreferencesRepository,
|
||||
val transport: DiagnosticsTransport,
|
||||
val breadcrumbs: BreadcrumbBuffer,
|
||||
val telemetry: TelemetryRecorder,
|
||||
val crashReporter: CrashReporter,
|
||||
val bugReports: BugReportService,
|
||||
private val scope: CoroutineScope,
|
||||
private val included: Boolean = DiagnosticsBuildConfig.INCLUDED,
|
||||
) {
|
||||
fun start() {
|
||||
// Install id is useful for bug-report correlation even without telemetry.
|
||||
// Install id is useful for bug-report correlation.
|
||||
scope.launch {
|
||||
preferencesRepository.ensureDiagnosticsInstallId()
|
||||
}
|
||||
if (!included) return
|
||||
crashReporter.startObservingPreferences()
|
||||
crashReporter.installUnhandledExceptionHandler()
|
||||
scope.launch {
|
||||
crashReporter.flushPending()
|
||||
}
|
||||
}
|
||||
|
||||
fun record(name: String, properties: Map<String, String> = emptyMap()) {
|
||||
if (!included) return
|
||||
telemetry.record(name, properties)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
appDataDir: String,
|
||||
appVersion: String,
|
||||
platform: String,
|
||||
preferencesRepository: PreferencesRepository,
|
||||
scope: CoroutineScope,
|
||||
transport: DiagnosticsTransport = NoOpDiagnosticsTransport(),
|
||||
included: Boolean = DiagnosticsBuildConfig.INCLUDED,
|
||||
): DiagnosticsCoordinator {
|
||||
val breadcrumbs = BreadcrumbBuffer()
|
||||
val crashStore = createPendingCrashStore(appDataDir)
|
||||
val telemetry = TelemetryRecorder(
|
||||
preferencesRepository = preferencesRepository,
|
||||
transport = transport,
|
||||
breadcrumbs = breadcrumbs,
|
||||
scope = scope,
|
||||
included = included,
|
||||
)
|
||||
val crashReporter = CrashReporter(
|
||||
store = crashStore,
|
||||
preferencesRepository = preferencesRepository,
|
||||
transport = transport,
|
||||
breadcrumbs = breadcrumbs,
|
||||
appVersion = appVersion,
|
||||
platform = platform,
|
||||
scope = scope,
|
||||
)
|
||||
val bugReports = BugReportService(
|
||||
preferencesRepository = preferencesRepository,
|
||||
transport = transport,
|
||||
@@ -77,11 +44,8 @@ class DiagnosticsCoordinator(
|
||||
preferencesRepository = preferencesRepository,
|
||||
transport = transport,
|
||||
breadcrumbs = breadcrumbs,
|
||||
telemetry = telemetry,
|
||||
crashReporter = crashReporter,
|
||||
bugReports = bugReports,
|
||||
scope = scope,
|
||||
included = included,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,83 +10,6 @@ internal object DiagnosticsJson {
|
||||
internal const val MaxPlatformBytes = 40
|
||||
private const val MaxBreadcrumbsJsonBytes = 16_000
|
||||
private const val MaxBreadcrumbs = 40
|
||||
private const val SizedBatchId = "00000000-0000-4000-8000-000000000000"
|
||||
|
||||
fun eventsBody(
|
||||
batchId: String,
|
||||
installId: String,
|
||||
appVersion: String,
|
||||
platform: String,
|
||||
events: List<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 {
|
||||
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.
|
||||
*/
|
||||
|
||||
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(
|
||||
val name: String,
|
||||
val timestampMillis: Long,
|
||||
val properties: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
data class CrashReport(
|
||||
val id: String,
|
||||
val timestampMillis: Long,
|
||||
val installId: String,
|
||||
val appVersion: String,
|
||||
val platform: String,
|
||||
val exceptionType: String,
|
||||
val exceptionMessage: String,
|
||||
val stackTrace: String,
|
||||
val breadcrumbs: List<Breadcrumb>,
|
||||
/** `null` only while a startup crash is waiting for the persisted preference to load. */
|
||||
val diagnosticsEnabledAtCapture: Boolean?,
|
||||
val schemaVersion: Int = DiagnosticsSchemaVersion,
|
||||
)
|
||||
|
||||
data class DeviceSnapshot(
|
||||
val deviceName: String?,
|
||||
val deviceModel: String?,
|
||||
|
||||
@@ -1,50 +1,25 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
/** Network boundary for diagnostics; keep batching and validation client-side. */
|
||||
/** Network boundary for bug reports; keep validation client-side. */
|
||||
interface DiagnosticsTransport {
|
||||
suspend fun sendEvents(batch: TelemetryBatch): Result<Unit>
|
||||
suspend fun sendCrash(report: CrashReport): Result<Unit>
|
||||
suspend fun sendBugReport(report: BugReport): Result<Unit>
|
||||
}
|
||||
|
||||
internal class DiagnosticsUnavailableException : IllegalStateException("diagnostics delivery is not configured")
|
||||
|
||||
internal fun Throwable.isPermanentDiagnosticsPayloadRejection(): Boolean =
|
||||
this is DiagnosticsPayloadException ||
|
||||
(this is DiagnosticsHttpException && statusCode in setOf(400, 413, 415, 422))
|
||||
|
||||
/** Fails delivery without leaving the device. Used until a remote endpoint is configured. */
|
||||
class NoOpDiagnosticsTransport : DiagnosticsTransport {
|
||||
override suspend fun sendEvents(batch: TelemetryBatch): Result<Unit> = unavailable()
|
||||
override suspend fun sendCrash(report: CrashReport): Result<Unit> = unavailable()
|
||||
override suspend fun sendBugReport(report: BugReport): Result<Unit> = unavailable()
|
||||
|
||||
private fun unavailable(): Result<Unit> = Result.failure(DiagnosticsUnavailableException())
|
||||
override suspend fun sendBugReport(report: BugReport): Result<Unit> =
|
||||
Result.failure(DiagnosticsUnavailableException())
|
||||
}
|
||||
|
||||
/**
|
||||
* Test double that records calls and can fail on demand.
|
||||
*/
|
||||
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>()
|
||||
var eventsResult: Result<Unit> = Result.success(Unit)
|
||||
var crashResult: Result<Unit> = Result.success(Unit)
|
||||
var bugResult: Result<Unit> = Result.success(Unit)
|
||||
|
||||
override suspend fun sendEvents(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> {
|
||||
bugReports += report
|
||||
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> {
|
||||
val body = DiagnosticsJson.bugBody(report)
|
||||
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.AppGraph
|
||||
import com.vnidrop.app.core.CoreGateway
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
|
||||
import com.vnidrop.app.logging.AppLogger
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||
@@ -38,14 +37,12 @@ class AppViewModel(
|
||||
private val repository: CoreGateway,
|
||||
preferencesRepository: PreferencesRepository,
|
||||
private val messages: UiMessageController,
|
||||
private val diagnostics: DiagnosticsCoordinator? = null,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(AppState())
|
||||
val state: StateFlow<AppState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
AppLogger.info("lifecycle", "app started", mapOf("platform" to environment.name))
|
||||
diagnostics?.record("app_open", mapOf("platform" to environment.name, "version" to environment.appVersion))
|
||||
viewModelScope.launch {
|
||||
val relaySettings = preferencesRepository.preferences.first().relaySettings
|
||||
repository.initialize(environment.defaultCoreDataDir, relaySettings).onFailure(messages::error)
|
||||
@@ -59,6 +56,5 @@ class AppViewModel(
|
||||
|
||||
fun selectDestination(destination: AppDestination) {
|
||||
_state.update { it.copy(destination = destination) }
|
||||
diagnostics?.record("nav_select", mapOf("destination" to destination.name))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig
|
||||
import com.vnidrop.app.ui.icons.AppIcon
|
||||
import com.vnidrop.app.ui.icons.PlatformIcon
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
@@ -46,8 +45,6 @@ import vnidrop.shared.generated.resources.about_privacy_title
|
||||
import vnidrop.shared.generated.resources.about_tagline
|
||||
import vnidrop.shared.generated.resources.about_title
|
||||
import vnidrop.shared.generated.resources.device_model_title
|
||||
import vnidrop.shared.generated.resources.diagnostics_description
|
||||
import vnidrop.shared.generated.resources.diagnostics_title
|
||||
import vnidrop.shared.generated.resources.os_version_title
|
||||
import vnidrop.shared.generated.resources.value_unavailable
|
||||
import vnidrop.shared.generated.resources.version_title
|
||||
@@ -57,7 +54,6 @@ private val PrivacyPolicyUrl = com.vnidrop.app.AppConfig.PRIVACY_POLICY_URL
|
||||
@Composable
|
||||
internal fun AboutSettings(
|
||||
state: SettingsState,
|
||||
onDiagnosticsChanged: (Boolean) -> Unit,
|
||||
onReportBug: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
showBack: Boolean,
|
||||
@@ -130,17 +126,6 @@ internal fun AboutSettings(
|
||||
}
|
||||
|
||||
SettingsGroup {
|
||||
if (DiagnosticsBuildConfig.INCLUDED) {
|
||||
SettingsToggleRow(
|
||||
icon = AppIcon.Info,
|
||||
title = stringResource(Res.string.diagnostics_title),
|
||||
description = stringResource(Res.string.diagnostics_description),
|
||||
checked = state.diagnosticsEnabled,
|
||||
enabled = true,
|
||||
onCheckedChange = onDiagnosticsChanged,
|
||||
)
|
||||
SettingsDivider()
|
||||
}
|
||||
SettingsRow(
|
||||
icon = AppIcon.Bug,
|
||||
title = stringResource(Res.string.about_bug_report),
|
||||
|
||||
@@ -33,7 +33,6 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) {
|
||||
onResetFolder = viewModel::resetReceiveFolder,
|
||||
onNotificationsChanged = viewModel::setNotificationsEnabled,
|
||||
onOpenNotificationSettings = viewModel::openNotificationSettings,
|
||||
onDiagnosticsChanged = viewModel::setDiagnosticsEnabled,
|
||||
onBugWhatChanged = viewModel::setBugWhatHappened,
|
||||
onBugExpectedChanged = viewModel::setBugExpected,
|
||||
onBugStepsChanged = viewModel::setBugSteps,
|
||||
|
||||
@@ -23,7 +23,6 @@ fun SettingsScreen(
|
||||
onResetFolder: () -> Unit,
|
||||
onNotificationsChanged: (Boolean) -> Unit,
|
||||
onOpenNotificationSettings: () -> Unit,
|
||||
onDiagnosticsChanged: (Boolean) -> Unit,
|
||||
onBugWhatChanged: (String) -> Unit,
|
||||
onBugExpectedChanged: (String) -> Unit,
|
||||
onBugStepsChanged: (String) -> Unit,
|
||||
@@ -62,7 +61,6 @@ fun SettingsScreen(
|
||||
onResetFolder = onResetFolder,
|
||||
onNotificationsChanged = onNotificationsChanged,
|
||||
onOpenNotificationSettings = onOpenNotificationSettings,
|
||||
onDiagnosticsChanged = onDiagnosticsChanged,
|
||||
onBugWhatChanged = onBugWhatChanged,
|
||||
onBugExpectedChanged = onBugExpectedChanged,
|
||||
onBugStepsChanged = onBugStepsChanged,
|
||||
@@ -105,7 +103,6 @@ fun SettingsScreen(
|
||||
onResetFolder = onResetFolder,
|
||||
onNotificationsChanged = onNotificationsChanged,
|
||||
onOpenNotificationSettings = onOpenNotificationSettings,
|
||||
onDiagnosticsChanged = onDiagnosticsChanged,
|
||||
onBugWhatChanged = onBugWhatChanged,
|
||||
onBugExpectedChanged = onBugExpectedChanged,
|
||||
onBugStepsChanged = onBugStepsChanged,
|
||||
@@ -140,7 +137,6 @@ private fun SettingsSectionContent(
|
||||
onResetFolder: () -> Unit,
|
||||
onNotificationsChanged: (Boolean) -> Unit,
|
||||
onOpenNotificationSettings: () -> Unit,
|
||||
onDiagnosticsChanged: (Boolean) -> Unit,
|
||||
onBugWhatChanged: (String) -> Unit,
|
||||
onBugExpectedChanged: (String) -> Unit,
|
||||
onBugStepsChanged: (String) -> Unit,
|
||||
@@ -184,7 +180,6 @@ private fun SettingsSectionContent(
|
||||
)
|
||||
SettingsSection.About -> AboutSettings(
|
||||
state = state,
|
||||
onDiagnosticsChanged = onDiagnosticsChanged,
|
||||
onReportBug = { onSectionSelected(SettingsSection.BugReport) },
|
||||
onBack = onBack,
|
||||
showBack = showBack,
|
||||
|
||||
@@ -16,8 +16,6 @@ import com.vnidrop.app.core.TransferStatus
|
||||
import com.vnidrop.app.core.usesCustomRelayUrls
|
||||
import com.vnidrop.app.diagnostics.BugReportDraft
|
||||
import com.vnidrop.app.diagnostics.BugReportService
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
|
||||
import com.vnidrop.app.notifications.LocalNotificationService
|
||||
import com.vnidrop.app.notifications.NotificationPermission
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
@@ -43,8 +41,6 @@ import vnidrop.shared.generated.resources.bug_report_missing_what
|
||||
import vnidrop.shared.generated.resources.bug_report_submit_failed
|
||||
import vnidrop.shared.generated.resources.bug_report_submitted
|
||||
import vnidrop.shared.generated.resources.button_open_settings
|
||||
import vnidrop.shared.generated.resources.diagnostics_disabled_message
|
||||
import vnidrop.shared.generated.resources.diagnostics_enabled_message
|
||||
import vnidrop.shared.generated.resources.notifications_enabled_message
|
||||
import vnidrop.shared.generated.resources.notifications_permission_denied
|
||||
import vnidrop.shared.generated.resources.notifications_settings_open_failed
|
||||
@@ -102,7 +98,6 @@ data class SettingsState(
|
||||
val endpointId: String? = null,
|
||||
val notificationsEnabled: Boolean = false,
|
||||
val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined,
|
||||
val diagnosticsEnabled: Boolean = false,
|
||||
val deviceInfo: DeviceInfo? = null,
|
||||
val appVersion: String = "",
|
||||
val isLoadingDeviceInfo: Boolean = false,
|
||||
@@ -138,8 +133,6 @@ class SettingsViewModel(
|
||||
private val notifications: LocalNotificationService,
|
||||
private val messages: UiMessageController,
|
||||
private val bugReports: BugReportService,
|
||||
private val diagnostics: DiagnosticsCoordinator? = null,
|
||||
private val diagnosticsIncluded: Boolean = DiagnosticsBuildConfig.INCLUDED,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(
|
||||
SettingsState(
|
||||
@@ -167,7 +160,6 @@ class SettingsViewModel(
|
||||
receiveFolder = receiveFolder,
|
||||
themeMode = preferences.themeMode,
|
||||
notificationsEnabled = preferences.notificationsEnabled,
|
||||
diagnosticsEnabled = preferences.diagnosticsEnabled,
|
||||
savedRelaySettings = preferences.relaySettings,
|
||||
relayMode = if (hasLocalRelayDraft) current.relayMode else preferences.relaySettings.mode,
|
||||
relayUrls = if (hasLocalRelayDraft) {
|
||||
@@ -512,25 +504,6 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun setDiagnosticsEnabled(enabled: Boolean) {
|
||||
if (!diagnosticsIncluded) return
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setDiagnosticsEnabled(enabled)
|
||||
diagnostics?.record(
|
||||
if (enabled) "diagnostics_enabled" else "diagnostics_disabled",
|
||||
)
|
||||
messages.show(
|
||||
UiMessage(
|
||||
UiText.Resource(
|
||||
if (enabled) Res.string.diagnostics_enabled_message
|
||||
else Res.string.diagnostics_disabled_message,
|
||||
),
|
||||
UiMessageTone.Success,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setBugWhatHappened(value: String) = _state.update { it.copy(bugWhatHappened = value) }
|
||||
fun setBugExpected(value: String) = _state.update { it.copy(bugExpected = value) }
|
||||
fun setBugSteps(value: String) = _state.update { it.copy(bugSteps = value) }
|
||||
@@ -565,7 +538,6 @@ class SettingsViewModel(
|
||||
)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
diagnostics?.record("bug_report_submitted")
|
||||
_state.update {
|
||||
it.copy(
|
||||
isSubmittingBugReport = false,
|
||||
|
||||
@@ -24,9 +24,7 @@ data class AppPreferences(
|
||||
val receiveFolder: ReceiveFolder,
|
||||
val themeMode: ThemeMode,
|
||||
val notificationsEnabled: Boolean,
|
||||
/** Master opt-in for automatic telemetry + crash upload. Bug reports remain available always. */
|
||||
val diagnosticsEnabled: Boolean = false,
|
||||
/** Stable anonymous install id; never an account or advertising id. */
|
||||
/** Stable anonymous install id for bug-report correlation; never an account or advertising id. */
|
||||
val diagnosticsInstallId: String = "",
|
||||
val relaySettings: RelaySettings = RelaySettings(),
|
||||
)
|
||||
@@ -36,7 +34,6 @@ class AppPreferencesDefaults(
|
||||
val receiveFolder: ReceiveFolder,
|
||||
val themeMode: ThemeMode,
|
||||
val notificationsEnabled: Boolean = false,
|
||||
val diagnosticsEnabled: Boolean = false,
|
||||
)
|
||||
|
||||
interface PreferencesRepository {
|
||||
@@ -46,7 +43,6 @@ interface PreferencesRepository {
|
||||
suspend fun resetReceiveFolder()
|
||||
suspend fun setThemeMode(mode: ThemeMode)
|
||||
suspend fun setNotificationsEnabled(enabled: Boolean)
|
||||
suspend fun setDiagnosticsEnabled(enabled: Boolean)
|
||||
suspend fun setRelaySettings(settings: RelaySettings)
|
||||
/** Ensures a durable install id exists and returns it. */
|
||||
suspend fun ensureDiagnosticsInstallId(): String
|
||||
@@ -83,7 +79,6 @@ class AppPreferencesRepository(
|
||||
receiveFolder = resolveReceiveFolder(prefs, defaults.receiveFolder),
|
||||
themeMode = prefs[PreferenceKeys.ThemeMode]?.let { themeModeOrNull(it) } ?: defaults.themeMode,
|
||||
notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled,
|
||||
diagnosticsEnabled = prefs[PreferenceKeys.DiagnosticsEnabled] ?: defaults.diagnosticsEnabled,
|
||||
diagnosticsInstallId = prefs[PreferenceKeys.DiagnosticsInstallId].orEmpty(),
|
||||
relaySettings = RelaySettings(
|
||||
mode = relayMode,
|
||||
@@ -122,12 +117,6 @@ class AppPreferencesRepository(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setDiagnosticsEnabled(enabled: Boolean) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[PreferenceKeys.DiagnosticsEnabled] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setRelaySettings(settings: RelaySettings) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[PreferenceKeys.RelayMode] = settings.mode.name
|
||||
@@ -160,7 +149,6 @@ private object PreferenceKeys {
|
||||
val ReceiveFolderDisplayName = stringPreferencesKey("receive_folder_display_name")
|
||||
val ThemeMode = stringPreferencesKey("theme_mode")
|
||||
val NotificationsEnabled = booleanPreferencesKey("notifications_enabled")
|
||||
val DiagnosticsEnabled = booleanPreferencesKey("diagnostics_enabled")
|
||||
val DiagnosticsInstallId = stringPreferencesKey("diagnostics_install_id")
|
||||
val RelayMode = stringPreferencesKey("relay_mode")
|
||||
val RelayUrls = stringPreferencesKey("relay_urls")
|
||||
|
||||
@@ -4,83 +4,25 @@ import com.vnidrop.app.DeviceInfo
|
||||
import com.vnidrop.app.core.ReceiveFolder
|
||||
import com.vnidrop.app.core.ReceiveFolderKind
|
||||
import com.vnidrop.app.preferences.AppPreferences
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.support.FakePreferencesRepository
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DiagnosticsTest {
|
||||
@Test
|
||||
fun diagnosticsJsonEscapesAndShapesPayloads() {
|
||||
val eventsJson = DiagnosticsJson.eventsBody(
|
||||
batchId = "batch-1",
|
||||
installId = "inst-1",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
events = listOf(
|
||||
TelemetryEvent("app_open", 10L, mapOf("a" to "quote\"here")),
|
||||
),
|
||||
)
|
||||
assertTrue(eventsJson.contains("\"batchId\":\"batch-1\""))
|
||||
assertTrue(eventsJson.contains("\"installId\":\"inst-1\""))
|
||||
assertTrue(eventsJson.contains("\"name\":\"app_open\""))
|
||||
assertTrue(eventsJson.contains("quote\\\"here"))
|
||||
|
||||
val crashJson = DiagnosticsJson.crashBody(
|
||||
CrashReport(
|
||||
id = "c1",
|
||||
timestampMillis = 1L,
|
||||
installId = "inst",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
exceptionType = "E",
|
||||
exceptionMessage = "line\nbreak",
|
||||
stackTrace = "stack",
|
||||
breadcrumbs = emptyList(),
|
||||
diagnosticsEnabledAtCapture = true,
|
||||
),
|
||||
)
|
||||
assertTrue(crashJson.contains("line\\nbreak"))
|
||||
assertTrue(crashJson.contains("\"diagnosticsEnabledAtCapture\":true"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun diagnosticsJsonKeepsEscapedBugPayloadWithinWorkerLimit() {
|
||||
val report = BugReport(
|
||||
id = "b",
|
||||
timestampMillis = 1L,
|
||||
installId = "i",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
whatHappened = "w",
|
||||
expected = "e",
|
||||
steps = "",
|
||||
contact = "",
|
||||
includeLogs = true,
|
||||
val report = bugReport(
|
||||
logs = "\n".repeat(BugReportService.MaxLogBytes),
|
||||
device = DeviceSnapshot(null, null, "OS", null, null),
|
||||
breadcrumbs = emptyList(),
|
||||
includeLogs = true,
|
||||
)
|
||||
|
||||
val body = DiagnosticsJson.bugBody(report)
|
||||
@@ -91,67 +33,24 @@ class DiagnosticsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun httpTransportPostsExpectedPaths() = runTest {
|
||||
fun httpTransportPostsBugReportToExpectedPath() = runTest {
|
||||
val calls = mutableListOf<Pair<String, String>>()
|
||||
val acknowledgementIds = ArrayDeque(listOf("batch-1", "c", "b"))
|
||||
val transport = HttpDiagnosticsTransport(
|
||||
baseUrl = "https://diag.example",
|
||||
ingestKey = "secret",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
installIdProvider = { "install-x" },
|
||||
post = { url, headers, body ->
|
||||
assertEquals("secret", headers["X-VniDrop-Key"])
|
||||
calls += url to body
|
||||
PlatformHttpResponse(
|
||||
202,
|
||||
"""{"ok":true,"id":"${acknowledgementIds.removeFirst()}","stored":1}""",
|
||||
)
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"b","stored":1}""")
|
||||
},
|
||||
)
|
||||
val eventResult = transport.sendEvents(
|
||||
TelemetryBatch("batch-1", listOf(TelemetryEvent("nav", 1L))),
|
||||
)
|
||||
assertTrue(eventResult.isSuccess)
|
||||
assertEquals("https://diag.example/v1/events", calls[0].first)
|
||||
assertTrue(calls[0].second.contains("install-x"))
|
||||
|
||||
val crashResult = transport.sendCrash(
|
||||
CrashReport(
|
||||
id = "c",
|
||||
timestampMillis = 1L,
|
||||
installId = "i",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
exceptionType = "E",
|
||||
exceptionMessage = "m",
|
||||
stackTrace = "s",
|
||||
breadcrumbs = emptyList(),
|
||||
diagnosticsEnabledAtCapture = true,
|
||||
),
|
||||
)
|
||||
assertTrue(crashResult.isSuccess)
|
||||
assertEquals("https://diag.example/v1/crashes", calls[1].first)
|
||||
val bugResult = transport.sendBugReport(bugReport())
|
||||
|
||||
val bugResult = transport.sendBugReport(
|
||||
BugReport(
|
||||
id = "b",
|
||||
timestampMillis = 1L,
|
||||
installId = "i",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
whatHappened = "w",
|
||||
expected = "e",
|
||||
steps = "",
|
||||
contact = "",
|
||||
includeLogs = false,
|
||||
logs = "",
|
||||
device = DeviceSnapshot(null, null, "OS", null, null),
|
||||
breadcrumbs = emptyList(),
|
||||
),
|
||||
)
|
||||
assertTrue(bugResult.isSuccess)
|
||||
assertEquals("https://diag.example/v1/bugs", calls[2].first)
|
||||
assertEquals("https://diag.example/v1/bugs", calls.single().first)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -162,12 +61,12 @@ class DiagnosticsTest {
|
||||
post = { _, _, _ ->
|
||||
PlatformHttpResponse(
|
||||
202,
|
||||
"""{"\u006f\u006b":true,"id":"batch-\u0031","metadata":{"stored":1,"flags":[true,null]}}""",
|
||||
"""{"ok":true,"id":"b","metadata":{"stored":1,"flags":[true,null]}}""",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assertTrue(transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L)))).isSuccess)
|
||||
assertTrue(transport.sendBugReport(bugReport()).isSuccess)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -177,9 +76,7 @@ class DiagnosticsTest {
|
||||
ingestKey = "secret",
|
||||
post = { _, _, _ -> PlatformHttpResponse(401, """{"error":"unauthorized"}""") },
|
||||
)
|
||||
assertTrue(
|
||||
transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L)))).isFailure,
|
||||
)
|
||||
assertTrue(transport.sendBugReport(bugReport()).isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -191,7 +88,7 @@ class DiagnosticsTest {
|
||||
)
|
||||
|
||||
assertFailsWith<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":true}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"different"}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"batch-4","id":"batch-4"}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"b","id":"b"}"""),
|
||||
),
|
||||
)
|
||||
val transport = HttpDiagnosticsTransport(
|
||||
@@ -212,10 +109,8 @@ class DiagnosticsTest {
|
||||
post = { _, _, _ -> responses.removeFirst() },
|
||||
)
|
||||
|
||||
repeat(5) { index ->
|
||||
val result = transport.sendEvents(
|
||||
TelemetryBatch("batch-$index", listOf(TelemetryEvent("x", 1L))),
|
||||
)
|
||||
repeat(5) {
|
||||
val result = transport.sendBugReport(bugReport())
|
||||
assertIs<DiagnosticsProtocolException>(result.exceptionOrNull())
|
||||
}
|
||||
}
|
||||
@@ -224,11 +119,11 @@ class DiagnosticsTest {
|
||||
fun httpTransportRejectsAmbiguousOrMalformedJsonAcknowledgement() = runTest {
|
||||
val responses = ArrayDeque(
|
||||
listOf(
|
||||
PlatformHttpResponse(202, """{"ok":true,"\u006f\u006b":true,"id":"batch-0"}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"ok":true,"id":"b"}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":true}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"batch-2","stored":01}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"batch-3",}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"batch-4"} trailing"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"b","stored":01}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"b",}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"b"} trailing"""),
|
||||
),
|
||||
)
|
||||
val transport = HttpDiagnosticsTransport(
|
||||
@@ -237,10 +132,8 @@ class DiagnosticsTest {
|
||||
post = { _, _, _ -> responses.removeFirst() },
|
||||
)
|
||||
|
||||
repeat(5) { index ->
|
||||
val result = transport.sendEvents(
|
||||
TelemetryBatch("batch-$index", listOf(TelemetryEvent("x", 1L))),
|
||||
)
|
||||
repeat(5) {
|
||||
val result = transport.sendBugReport(bugReport())
|
||||
assertIs<DiagnosticsProtocolException>(result.exceptionOrNull())
|
||||
}
|
||||
}
|
||||
@@ -288,9 +181,7 @@ class DiagnosticsTest {
|
||||
|
||||
@Test
|
||||
fun noOpTransportReportsUnavailableDelivery() = runTest {
|
||||
val result = NoOpDiagnosticsTransport().sendEvents(
|
||||
TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L))),
|
||||
)
|
||||
val result = NoOpDiagnosticsTransport().sendBugReport(bugReport())
|
||||
assertIs<DiagnosticsUnavailableException>(result.exceptionOrNull())
|
||||
}
|
||||
|
||||
@@ -323,563 +214,10 @@ class DiagnosticsTest {
|
||||
assertEquals(listOf("b", "c", "d"), buffer.snapshot().map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetryIgnoresEventsWhenDiagnosticsDisabled() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = false)
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val breadcrumbs = BreadcrumbBuffer()
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = breadcrumbs,
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 1,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
recorder.record("app_open")
|
||||
advanceUntilIdle()
|
||||
assertEquals(0, transport.events.size)
|
||||
assertEquals(1, breadcrumbs.snapshot().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun excludedTelemetryDoesNotStartOrRecordEvents() = runTest {
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val breadcrumbs = BreadcrumbBuffer()
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = fakePrefs(diagnosticsEnabled = true),
|
||||
transport = transport,
|
||||
breadcrumbs = breadcrumbs,
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 1,
|
||||
included = false,
|
||||
)
|
||||
|
||||
recorder.record("app_open")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(0, recorder.pendingCount())
|
||||
assertTrue(transport.events.isEmpty())
|
||||
assertTrue(breadcrumbs.snapshot().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetryRetainsColdStartEventsUntilConsentLoads() = runTest {
|
||||
val backing = fakePrefs(diagnosticsEnabled = true)
|
||||
val preferenceGate = CompletableDeferred<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
|
||||
fun bugReportRequiresWhatAndExpected() = runTest {
|
||||
val preferences = fakePrefs()
|
||||
val service = BugReportService(
|
||||
preferencesRepository = preferences,
|
||||
preferencesRepository = fakePrefs(),
|
||||
transport = RecordingDiagnosticsTransport(),
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
@@ -893,8 +231,6 @@ class DiagnosticsTest {
|
||||
@Test
|
||||
fun bugReportConvertsTransportExceptionsToFailure() = runTest {
|
||||
val throwingTransport = object : DiagnosticsTransport {
|
||||
override suspend fun sendEvents(batch: TelemetryBatch) = Result.success(Unit)
|
||||
override suspend fun sendCrash(report: CrashReport) = Result.success(Unit)
|
||||
override suspend fun sendBugReport(report: BugReport): Result<Unit> {
|
||||
throw IllegalStateException("offline")
|
||||
}
|
||||
@@ -914,11 +250,10 @@ class DiagnosticsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bugReportSubmitsWithRedactedLogsRegardlessOfDiagnostics() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = false)
|
||||
fun bugReportSubmitsWithRedactedLogs() = runTest {
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val service = BugReportService(
|
||||
preferencesRepository = preferences,
|
||||
preferencesRepository = fakePrefs(),
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
@@ -981,38 +316,35 @@ class DiagnosticsTest {
|
||||
assertEquals(BugReportService.MaxLogBytes, service.previewLogBytes())
|
||||
}
|
||||
|
||||
private fun fakePrefs(diagnosticsEnabled: Boolean = false) = FakePreferencesRepository(
|
||||
private fun bugReport(
|
||||
id: String = "b",
|
||||
logs: String = "",
|
||||
includeLogs: Boolean = false,
|
||||
) = BugReport(
|
||||
id = id,
|
||||
timestampMillis = 1L,
|
||||
installId = "i",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
whatHappened = "w",
|
||||
expected = "e",
|
||||
steps = "",
|
||||
contact = "",
|
||||
includeLogs = includeLogs,
|
||||
logs = logs,
|
||||
device = DeviceSnapshot(null, null, "OS", null, null),
|
||||
breadcrumbs = emptyList(),
|
||||
)
|
||||
|
||||
private fun fakePrefs() = FakePreferencesRepository(
|
||||
AppPreferences(
|
||||
username = "User",
|
||||
receiveFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp"),
|
||||
themeMode = ThemeMode.System,
|
||||
notificationsEnabled = false,
|
||||
diagnosticsEnabled = diagnosticsEnabled,
|
||||
diagnosticsInstallId = "test-install",
|
||||
),
|
||||
)
|
||||
|
||||
private fun device() = DeviceInfo("Phone", "Pixel", "Android 15", "Wi-Fi", "90%")
|
||||
}
|
||||
|
||||
private class InMemoryPendingCrashStore : PendingCrashStore {
|
||||
private val items = linkedMapOf<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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsTogglesDiagnosticsPreference() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val preferences = preferences()
|
||||
val viewModel = settingsViewModel(preferences, diagnosticsIncluded = true)
|
||||
advanceUntilIdle()
|
||||
assertFalse(viewModel.state.value.diagnosticsEnabled)
|
||||
viewModel.setDiagnosticsEnabled(true)
|
||||
advanceUntilIdle()
|
||||
assertTrue(preferences.mutablePreferences.value.diagnosticsEnabled)
|
||||
assertTrue(viewModel.state.value.diagnosticsEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsIgnoresDiagnosticsOptInWhenExcluded() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val preferences = preferences()
|
||||
val viewModel = settingsViewModel(preferences)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.setDiagnosticsEnabled(true)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse(preferences.mutablePreferences.value.diagnosticsEnabled)
|
||||
assertFalse(viewModel.state.value.diagnosticsEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsSubmitsBugReportAndClearsForm() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
@@ -993,7 +966,6 @@ class ViewModelsTest {
|
||||
receiveFolder = folder,
|
||||
themeMode = ThemeMode.System,
|
||||
notificationsEnabled = false,
|
||||
diagnosticsEnabled = false,
|
||||
diagnosticsInstallId = "test-install",
|
||||
),
|
||||
)
|
||||
@@ -1005,7 +977,6 @@ class ViewModelsTest {
|
||||
notifications: FakeNotificationService = FakeNotificationService(),
|
||||
transport: DiagnosticsTransport = RecordingDiagnosticsTransport(),
|
||||
fileSystem: FakeFileSystemService = FakeFileSystemService(folder),
|
||||
diagnosticsIncluded: Boolean = false,
|
||||
repository: FakeCoreGateway = FakeCoreGateway(),
|
||||
) = SettingsViewModel(
|
||||
environment(),
|
||||
@@ -1023,7 +994,6 @@ class ViewModelsTest {
|
||||
platform = "Test",
|
||||
logReader = { "sample log line" },
|
||||
),
|
||||
diagnosticsIncluded = diagnosticsIncluded,
|
||||
)
|
||||
|
||||
private fun receivedTransfer(id: ULong, status: TransferStatus) = Transfer(
|
||||
|
||||
@@ -210,9 +210,6 @@ class FakePreferencesRepository(
|
||||
override suspend fun resetReceiveFolder() = Unit
|
||||
override suspend fun setThemeMode(mode: ThemeMode) { mutablePreferences.value = mutablePreferences.value.copy(themeMode = mode) }
|
||||
override suspend fun setNotificationsEnabled(enabled: Boolean) { mutablePreferences.value = mutablePreferences.value.copy(notificationsEnabled = enabled) }
|
||||
override suspend fun setDiagnosticsEnabled(enabled: Boolean) {
|
||||
mutablePreferences.value = mutablePreferences.value.copy(diagnosticsEnabled = enabled)
|
||||
}
|
||||
override suspend fun setRelaySettings(settings: RelaySettings) {
|
||||
mutablePreferences.value = mutablePreferences.value.copy(relaySettings = settings)
|
||||
}
|
||||
|
||||
@@ -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 = {},
|
||||
onNotificationsChanged = {},
|
||||
onOpenNotificationSettings = {},
|
||||
onDiagnosticsChanged = {},
|
||||
onBugWhatChanged = {},
|
||||
onBugExpectedChanged = {},
|
||||
onBugStepsChanged = {},
|
||||
@@ -182,7 +181,6 @@ class FoundationComposeTest {
|
||||
onResetFolder = {},
|
||||
onNotificationsChanged = {},
|
||||
onOpenNotificationSettings = {},
|
||||
onDiagnosticsChanged = {},
|
||||
onBugWhatChanged = {},
|
||||
onBugExpectedChanged = {},
|
||||
onBugStepsChanged = {},
|
||||
@@ -238,7 +236,6 @@ class FoundationComposeTest {
|
||||
onResetFolder = {},
|
||||
onNotificationsChanged = {},
|
||||
onOpenNotificationSettings = {},
|
||||
onDiagnosticsChanged = {},
|
||||
onBugWhatChanged = {},
|
||||
onBugExpectedChanged = {},
|
||||
onBugStepsChanged = {},
|
||||
@@ -291,7 +288,6 @@ class FoundationComposeTest {
|
||||
onResetFolder = {},
|
||||
onNotificationsChanged = {},
|
||||
onOpenNotificationSettings = {},
|
||||
onDiagnosticsChanged = {},
|
||||
onBugWhatChanged = {},
|
||||
onBugExpectedChanged = {},
|
||||
onBugStepsChanged = {},
|
||||
@@ -322,7 +318,6 @@ class FoundationComposeTest {
|
||||
onResetFolder = {},
|
||||
onNotificationsChanged = {},
|
||||
onOpenNotificationSettings = {},
|
||||
onDiagnosticsChanged = {},
|
||||
onBugWhatChanged = {},
|
||||
onBugExpectedChanged = {},
|
||||
onBugStepsChanged = {},
|
||||
@@ -358,7 +353,6 @@ class FoundationComposeTest {
|
||||
onResetFolder = {},
|
||||
onNotificationsChanged = { enabled = it },
|
||||
onOpenNotificationSettings = {},
|
||||
onDiagnosticsChanged = {},
|
||||
onBugWhatChanged = {},
|
||||
onBugExpectedChanged = {},
|
||||
onBugStepsChanged = {},
|
||||
@@ -390,7 +384,6 @@ class FoundationComposeTest {
|
||||
onResetFolder = {},
|
||||
onNotificationsChanged = {},
|
||||
onOpenNotificationSettings = { opened = true },
|
||||
onDiagnosticsChanged = {},
|
||||
onBugWhatChanged = {},
|
||||
onBugExpectedChanged = {},
|
||||
onBugStepsChanged = {},
|
||||
|
||||
Reference in New Issue
Block a user