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

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

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

View File

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

View File

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

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Écrire sur un tag NFC</string>
<string name="device_model_title">Modèle de lappareil</string>
<string name="device_name_title">Nom de lappareil</string>
<string name="diagnostics_description">Envoyer des rapports de plantage et des événements dutilisation anonymes pour nous aider à améliorer VniDrop. Vous pouvez désactiver cela à tout moment. Les invitations, chemins de fichiers et contenus de transfert ne sont jamais inclus.</string>
<string name="diagnostics_disabled_message">Le partage des diagnostics est désactivé.</string>
<string name="diagnostics_enabled_message">Le partage des diagnostics est activé.</string>
<string name="diagnostics_title">Partager les diagnostics</string>
<string name="error_camera">Laccès à la caméra est nécessaire pour scanner un QR code.</string>
<string name="error_device_info">Impossible de charger les informations de lappareil.</string>
<string name="error_destination_exists">Un fichier portant le même nom existe déjà dans la destination. Choisissez un autre dossier ou supprimez le fichier existant.</string>

View File

@@ -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 duso anonimi per aiutarci a migliorare VniDrop. Può disattivarlo in qualsiasi momento. Inviti, percorsi dei file e contenuti dei trasferimenti non vengono mai inclusi.</string>
<string name="diagnostics_disabled_message">La condivisione dei dati diagnostici è disattivata.</string>
<string name="diagnostics_enabled_message">La condivisione dei dati diagnostici è attivata.</string>
<string name="diagnostics_title">Condividi dati diagnostici</string>
<string name="error_camera">Per scansionare un codice QR è necessario laccesso alla fotocamera.</string>
<string name="error_device_info">Impossibile caricare le informazioni sul dispositivo.</string>
<string name="error_destination_exists">Nella destinazione esiste già un file con lo stesso nome. Scelga unaltra cartella o rimuova il file esistente.</string>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,158 +0,0 @@
package com.vnidrop.app.diagnostics
import com.vnidrop.app.logging.AppLogger
import com.vnidrop.app.logging.platformNowMillis
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.util.randomUuidString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
/**
* Captures uncaught exceptions to disk, then uploads on a later launch when
* diagnostics is enabled (and when a real [DiagnosticsTransport] is wired).
*/
@OptIn(ExperimentalAtomicApi::class)
class CrashReporter(
private val store: PendingCrashStore,
private val preferencesRepository: PreferencesRepository,
private val transport: DiagnosticsTransport,
private val breadcrumbs: BreadcrumbBuffer,
private val appVersion: String,
private val platform: String,
private val scope: CoroutineScope,
) {
private val installed = AtomicBoolean(false)
private val observingPreferences = AtomicBoolean(false)
private val capturePolicy = AtomicReference(CrashCapturePolicy())
fun startObservingPreferences() {
if (!observingPreferences.compareAndSet(false, true)) return
scope.launch {
preferencesRepository.preferences.collect { prefs ->
capturePolicy.store(
CrashCapturePolicy(
installId = prefs.diagnosticsInstallId,
diagnosticsEnabled = prefs.diagnosticsEnabled,
),
)
if (!prefs.diagnosticsEnabled) {
runCatching(::deleteAllPending)
}
}
}
}
fun installUnhandledExceptionHandler() {
if (!DiagnosticsBuildConfig.INCLUDED) return
if (!installed.compareAndSet(false, true)) return
installPlatformCrashHook { throwable ->
capture(throwable)
}
}
fun capture(throwable: Throwable, diagnosticsEnabledOverride: Boolean? = null): CrashReport {
val policy = capturePolicy.load()
val report = CrashReport(
id = randomUuidString(),
timestampMillis = platformNowMillis(),
installId = sanitizeDiagnosticsInstallId(policy.installId),
appVersion = appVersion.takeUtf8Bytes(DiagnosticsJson.MaxAppVersionBytes),
platform = platform.takeUtf8Bytes(DiagnosticsJson.MaxPlatformBytes),
exceptionType = throwable::class.simpleName ?: "Throwable",
exceptionMessage = LogRedactor.redact(throwable.message.orEmpty()).takeUtf8Bytes(MaxMessageBytes),
stackTrace = LogRedactor.redact(throwable.stackTraceToString()).takeUtf8Bytes(MaxStackBytes),
breadcrumbs = breadcrumbs.snapshot(),
diagnosticsEnabledAtCapture = diagnosticsEnabledOverride ?: policy.diagnosticsEnabled,
)
if (report.diagnosticsEnabledAtCapture != false) {
runCatching { store.write(report) }
runCatching {
store.prune(
olderThanTimestampMillis = platformNowMillis() - LocalRetentionMillis,
maxCount = MaxLocalCrashCount,
)
}
}
AppLogger.error("crash", "captured crash ${report.id}", throwable)
return report
}
/**
* Uploads pending crashes that were captured with diagnostics enabled.
* Local files are deleted after successful delivery or bounded by local retention.
*/
suspend fun flushPending() {
store.prune(
olderThanTimestampMillis = platformNowMillis() - LocalRetentionMillis,
maxCount = MaxLocalCrashCount,
)
for (report in store.list()) {
val preferences = preferencesRepository.preferences.first()
if (!preferences.diagnosticsEnabled || capturePolicy.load().diagnosticsEnabled == false) {
deleteAllPending()
return
}
if (report.diagnosticsEnabledAtCapture == false) {
store.delete(report.id)
continue
}
val installId = sanitizeDiagnosticsInstallId(
preferences.diagnosticsInstallId.ifBlank {
preferencesRepository.ensureDiagnosticsInstallId()
},
)
val resolved = report.copy(
installId = report.installId.ifBlank { installId },
diagnosticsEnabledAtCapture = true,
)
if (resolved != report) store.write(resolved)
if (
!preferencesRepository.preferences.first().diagnosticsEnabled ||
capturePolicy.load().diagnosticsEnabled == false
) {
deleteAllPending()
return
}
val result = try {
transport.sendCrash(resolved)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
Result.failure(error)
}
if (result.isSuccess) {
store.delete(resolved.id)
continue
}
val error = result.exceptionOrNull()
if (error?.isPermanentDiagnosticsPayloadRejection() == true) {
store.delete(resolved.id)
continue
}
break
}
}
private fun deleteAllPending() {
store.list().forEach { report -> store.delete(report.id) }
}
companion object {
private const val MaxMessageBytes = 2_000
private const val MaxStackBytes = 32_000
private const val MaxLocalCrashCount = 20
private const val LocalRetentionMillis = 30L * 86_400_000L
}
}
private data class CrashCapturePolicy(
val installId: String = "",
val diagnosticsEnabled: Boolean? = null,
)
expect fun installPlatformCrashHook(onCrash: (Throwable) -> Unit)

View File

@@ -5,67 +5,34 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* 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,
)
}
}

View File

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

View File

@@ -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?,

View File

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

View File

@@ -27,33 +27,6 @@ class HttpDiagnosticsTransport(
}
}
override suspend fun sendEvents(batch: TelemetryBatch): Result<Unit> {
if (batch.events.isEmpty()) return Result.success(Unit)
if (batch.events.size > TelemetryRecorder.MaxEventsPerBatch) {
return Result.failure(DiagnosticsPayloadException("diagnostics event batch is too large"))
}
if (batch.events.any { it.name.isBlank() || it.timestampMillis < 0 }) {
return Result.failure(DiagnosticsPayloadException("diagnostics event batch is invalid"))
}
val installId = sanitizeDiagnosticsInstallId(installIdProvider())
val body = DiagnosticsJson.eventsBody(
batch.id,
installId,
appVersion.takeUtf8Bytes(DiagnosticsJson.MaxAppVersionBytes),
platform.takeUtf8Bytes(DiagnosticsJson.MaxPlatformBytes),
batch.events,
)
return postJson("/v1/events", body, installId, batch.id)
}
override suspend fun sendCrash(report: CrashReport): Result<Unit> {
if (report.diagnosticsEnabledAtCapture == null) {
return Result.failure(DiagnosticsPayloadException("crash consent is unresolved"))
}
val body = DiagnosticsJson.crashBody(report)
return postJson("/v1/crashes", body, report.installId, report.id)
}
override suspend fun sendBugReport(report: BugReport): Result<Unit> {
val body = DiagnosticsJson.bugBody(report)
return postJson("/v1/bugs", body, report.installId, report.id)

View File

@@ -1,193 +0,0 @@
package com.vnidrop.app.diagnostics
/**
* Durable crash envelopes written during process death and read on next launch.
* Encoding is a simple line-oriented format (no kotlinx.serialization dependency).
*/
interface PendingCrashStore {
fun write(report: CrashReport)
fun list(): List<CrashReport>
fun delete(id: String)
fun prune(olderThanTimestampMillis: Long, maxCount: Int)
}
expect fun createPendingCrashStore(appDataDir: String): PendingCrashStore
internal object CrashReportCodec {
private const val FieldSep = "\u001f"
private const val RecordSep = "\u001e"
private const val Version2Prefix = "vnidrop-crash-v2\n"
private const val MaxEncodedChars = 512 * 1024
fun encode(report: CrashReport): String = buildString {
fun field(key: String, value: String) {
append(key)
append('=')
append(value.hexEncode())
append('\n')
}
append(Version2Prefix)
field("id", report.id)
field("ts", report.timestampMillis.toString())
field("install", report.installId)
field("app", report.appVersion)
field("platform", report.platform)
field("type", report.exceptionType)
field("message", report.exceptionMessage)
field("stack", report.stackTrace)
field(
"diag",
when (report.diagnosticsEnabledAtCapture) {
true -> "1"
false -> "0"
null -> "u"
},
)
field("schema", report.schemaVersion.toString())
val breadcrumbs = report.breadcrumbs.take(40)
field("crumb.count", breadcrumbs.size.toString())
breadcrumbs.forEachIndexed { crumbIndex, crumb ->
field("crumb.$crumbIndex.ts", crumb.timestampMillis.toString())
field("crumb.$crumbIndex.name", crumb.name)
val properties = crumb.properties.entries.take(MaxDiagnosticProperties)
field("crumb.$crumbIndex.prop.count", properties.size.toString())
properties.forEachIndexed { propertyIndex, (key, value) ->
field("crumb.$crumbIndex.prop.$propertyIndex.key", key)
field("crumb.$crumbIndex.prop.$propertyIndex.value", value)
}
}
}
fun decode(raw: String): CrashReport? {
if (raw.isBlank() || raw.length > MaxEncodedChars) return null
return if (raw.startsWith(Version2Prefix)) decodeVersion2(raw) else decodeLegacy(raw)
}
private fun decodeVersion2(raw: String): CrashReport? {
val map = linkedMapOf<String, String>()
for (part in raw.removePrefix(Version2Prefix).lineSequence()) {
if (part.isEmpty()) continue
val eq = part.indexOf('=')
if (eq <= 0) continue
val key = part.substring(0, eq)
val value = part.substring(eq + 1).hexDecode() ?: return null
map[key] = value
}
val crumbCount = map["crumb.count"]?.toIntOrNull()?.takeIf { it in 0..40 } ?: return null
val crumbs = buildList {
repeat(crumbCount) { crumbIndex ->
val timestamp = map["crumb.$crumbIndex.ts"]
?.toLongOrNull()
?.takeIf { it >= 0 }
?: return null
val name = map["crumb.$crumbIndex.name"]?.takeIf { it.isNotBlank() } ?: return null
val propertyCount = map["crumb.$crumbIndex.prop.count"]
?.toIntOrNull()
?.takeIf { it in 0..MaxDiagnosticProperties }
?: return null
val properties = buildMap {
repeat(propertyCount) { propertyIndex ->
val key = map["crumb.$crumbIndex.prop.$propertyIndex.key"] ?: return null
val value = map["crumb.$crumbIndex.prop.$propertyIndex.value"] ?: return null
put(key, value)
}
}
add(Breadcrumb(name = name, timestampMillis = timestamp, properties = properties))
}
}
return reportFromFields(map, crumbs)
}
private fun decodeLegacy(raw: String): CrashReport? {
val map = linkedMapOf<String, String>()
for (part in raw.split(FieldSep)) {
if (part.isEmpty()) continue
val eq = part.indexOf('=')
if (eq <= 0) continue
val key = part.substring(0, eq)
val value = part.substring(eq + 1)
.replace("\\n", "\n")
.replace("\\r", "\r")
map[key] = value
}
val crumbs = map["crumbs"].orEmpty()
.split(RecordSep)
.filter { it.isNotBlank() }
.mapNotNull { entry ->
val pieces = entry.split('|', limit = 3)
if (pieces.size < 2) return@mapNotNull null
val ts = pieces[0].toLongOrNull()?.takeIf { it >= 0 } ?: return@mapNotNull null
val name = pieces[1].takeIf { it.isNotBlank() } ?: return@mapNotNull null
val props = if (pieces.size > 2 && pieces[2].isNotBlank()) {
pieces[2].split(',').mapNotNull { kv ->
val colon = kv.indexOf(':')
if (colon <= 0) null
else kv.substring(0, colon) to kv.substring(colon + 1)
}.toMap()
} else {
emptyMap()
}
Breadcrumb(name = name, timestampMillis = ts, properties = props)
}
return reportFromFields(map, crumbs)
}
private fun reportFromFields(
map: Map<String, String>,
crumbs: List<Breadcrumb>,
): CrashReport? {
val id = map["id"]?.takeIf(::isValidDiagnosticId) ?: return null
val timestamp = map["ts"]?.toLongOrNull()?.takeIf { it >= 0 } ?: return null
val exceptionType = map["type"]?.takeIf { it.isNotBlank() } ?: return null
val schemaVersion = map["schema"]?.toIntOrNull()
?.takeIf { it == DiagnosticsSchemaVersion }
?: return null
val diagnosticsEnabled: Boolean? = when (map["diag"]) {
"1" -> true
"0" -> false
"u" -> null
else -> return null
}
return CrashReport(
id = id,
timestampMillis = timestamp,
installId = map["install"].orEmpty(),
appVersion = map["app"].orEmpty(),
platform = map["platform"].orEmpty(),
exceptionType = exceptionType,
exceptionMessage = map["message"].orEmpty(),
stackTrace = map["stack"].orEmpty(),
breadcrumbs = crumbs,
diagnosticsEnabledAtCapture = diagnosticsEnabled,
schemaVersion = schemaVersion,
)
}
}
internal fun isValidDiagnosticId(id: String): Boolean =
DiagnosticIdPattern.matches(id)
private val DiagnosticIdPattern =
Regex("^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$")
private fun String.hexEncode(): String {
val digits = "0123456789abcdef"
return buildString(length * 2) {
for (byte in this@hexEncode.encodeToByteArray()) {
val value = byte.toInt() and 0xff
append(digits[value ushr 4])
append(digits[value and 0x0f])
}
}
}
private fun String.hexDecode(): String? {
if (length % 2 != 0) return null
val bytes = ByteArray(length / 2)
for (index in bytes.indices) {
val high = this[index * 2].digitToIntOrNull(16) ?: return null
val low = this[index * 2 + 1].digitToIntOrNull(16) ?: return null
bytes[index] = ((high shl 4) or low).toByte()
}
return runCatching { bytes.decodeToString(throwOnInvalidSequence = true) }.getOrNull()
}

View File

@@ -1,216 +0,0 @@
package com.vnidrop.app.diagnostics
import com.vnidrop.app.logging.platformNowMillis
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.util.randomUuidString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
/**
* Product telemetry: sparse events, gated by diagnostics opt-in.
* Events are buffered and flushed in batches when transport is available.
*/
@OptIn(ExperimentalAtomicApi::class)
class TelemetryRecorder(
private val preferencesRepository: PreferencesRepository,
private val transport: DiagnosticsTransport,
private val breadcrumbs: BreadcrumbBuffer,
private val scope: CoroutineScope,
private val maxBufferSize: Int = DefaultMaxBuffer,
private val flushThreshold: Int = DefaultFlushThreshold,
private val flushIntervalMillis: Long = DefaultFlushIntervalMillis,
private val retryBackoffMillis: Long = DefaultRetryBackoffMillis,
private val automaticRetryCount: Int = DefaultAutomaticRetryCount,
private val included: Boolean = true,
) {
private val bufferMutex = Mutex()
private val state = AtomicReference(TelemetryState())
private val flushSignals = Channel<Unit>(Channel.CONFLATED)
init {
require(maxBufferSize > 0) { "maxBufferSize must be positive" }
require(flushThreshold > 0) { "flushThreshold must be positive" }
require(flushIntervalMillis > 0) { "flushIntervalMillis must be positive" }
require(retryBackoffMillis > 0) { "retryBackoffMillis must be positive" }
require(automaticRetryCount >= 0) { "automaticRetryCount must not be negative" }
if (included) {
scope.launch {
preferencesRepository.preferences
.map { it.diagnosticsEnabled }
.distinctUntilChanged()
.collect { isEnabled ->
updateState { current ->
if (isEnabled) current.copy(enabled = true) else TelemetryState(enabled = false)
}
flushSignals.trySend(Unit)
}
}
scope.launch { runAutomaticFlushes() }
}
}
fun record(name: String, properties: Map<String, String> = emptyMap()) {
if (!included) return
val sanitizedName = sanitizeDiagnosticName(name)
if (sanitizedName.isBlank()) return
val sanitizedProperties = sanitizeDiagnosticProperties(properties)
breadcrumbs.add(sanitizedName, sanitizedProperties)
val event = TelemetryEvent(
name = sanitizedName,
timestampMillis = platformNowMillis(),
properties = sanitizedProperties,
)
while (true) {
val current = state.load()
if (current.enabled == false) return
val remainingCapacity =
(maxBufferSize - current.retryBatch?.events.orEmpty().size).coerceAtLeast(0)
val nextBuffer = (current.buffer + event).takeLast(remainingCapacity)
if (state.compareAndSet(current, current.copy(buffer = nextBuffer))) {
flushSignals.trySend(Unit)
return
}
}
}
suspend fun flush(): Result<Unit> {
return bufferMutex.withLock {
var discardedFailure: Throwable? = null
var outcome: Result<Unit>? = null
while (outcome == null) {
val current = state.load()
if (current.enabled != true) return@withLock Result.success(Unit)
val pendingRetry = current.retryBatch
val events = if (pendingRetry == null) nextBatchEvents(current.buffer) else emptyList()
if (pendingRetry == null && events.isEmpty()) {
outcome = discardedFailure?.let { Result.failure(it) } ?: Result.success(Unit)
continue
}
val batch: TelemetryBatch
if (pendingRetry != null) {
batch = pendingRetry
} else {
val prepared = TelemetryBatch(id = randomUuidString(), events = events)
val next = current.copy(
buffer = current.buffer.drop(events.size),
retryBatch = prepared,
)
if (!state.compareAndSet(current, next)) continue
batch = prepared
}
if (state.load().retryBatch != batch) continue
val result = try {
transport.sendEvents(batch)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
Result.failure(error)
}
if (result.isSuccess) {
clearRetryBatch(batch)
continue
}
val error = result.exceptionOrNull() ?: IllegalStateException("diagnostics event delivery failed")
if (error.isPermanentDiagnosticsPayloadRejection()) {
clearRetryBatch(batch)
discardedFailure = discardedFailure ?: error
continue
}
outcome = result
}
checkNotNull(outcome)
}
}
private fun nextBatchEvents(events: List<TelemetryEvent>): List<TelemetryEvent> {
if (events.isEmpty()) return emptyList()
var minimum = 1
var maximum = minOf(events.size, MaxEventsPerBatch)
var accepted = 1
while (minimum <= maximum) {
val candidateSize = minimum + (maximum - minimum) / 2
if (DiagnosticsJson.eventBatchFitsRequest(events.take(candidateSize))) {
accepted = candidateSize
minimum = candidateSize + 1
} else {
maximum = candidateSize - 1
}
}
return events.take(accepted)
}
fun pendingCount(): Int {
val current = state.load()
return current.retryBatch?.events.orEmpty().size + current.buffer.size
}
private suspend fun runAutomaticFlushes() {
while (true) {
flushSignals.receive()
var retries = 0
while (true) {
val current = state.load()
if (current.enabled != true || pendingCount() == 0) break
if (current.retryBatch == null && pendingCount() < flushThreshold) {
val signalled = withTimeoutOrNull(flushIntervalMillis) {
flushSignals.receive()
true
} ?: false
if (signalled) continue
}
val result = flush()
if (result.isSuccess || pendingCount() == 0) {
retries = 0
continue
}
val error = result.exceptionOrNull()
if (error?.isPermanentDiagnosticsPayloadRejection() == true || retries >= automaticRetryCount) {
break
}
retries += 1
delay(retryBackoffMillis)
}
}
}
private fun clearRetryBatch(batch: TelemetryBatch) {
while (true) {
val current = state.load()
if (current.retryBatch != batch) return
if (state.compareAndSet(current, current.copy(retryBatch = null))) return
}
}
private fun updateState(update: (TelemetryState) -> TelemetryState) {
while (true) {
val current = state.load()
if (state.compareAndSet(current, update(current))) return
}
}
companion object {
const val DefaultMaxBuffer = 100
const val DefaultFlushThreshold = 20
const val MaxEventsPerBatch = 50
const val DefaultFlushIntervalMillis = 30_000L
const val DefaultRetryBackoffMillis = 30_000L
const val DefaultAutomaticRetryCount = 3
}
}
private data class TelemetryState(
val enabled: Boolean? = null,
val buffer: List<TelemetryEvent> = emptyList(),
val retryBatch: TelemetryBatch? = null,
)

View File

@@ -6,7 +6,6 @@ import com.vnidrop.app.PlatformEnvironment
import com.vnidrop.app.AppDependencies
import com.vnidrop.app.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))
}
}

View File

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

View File

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

View File

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

View File

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

View File

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