mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user