From 232fb125d3f2bf0106b88eb1a69f35aeb93df582 Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:54:37 +0200 Subject: [PATCH 1/8] feat(config): shared app.properties for app-wide constants Add a single source of truth (root app.properties) for public app-wide constants, injected at build time on both platforms instead of hardcoding. - Apple: generate-appconfig.sh -> Generated/AppConfig.swift (wired into `make apple-app-config`), consumed as AppConfig.privacyPolicyURL. - KMP: generateAppConfig task -> AppConfig.kt (mirrors DiagnosticsBuildConfig), consumed as AppConfig.PRIVACY_POLICY_URL. Replaces the stale hardcoded privacy-policy URL on both sides with https://vnidrop.sudosy.fr/privacy/. Also fix the Apple release core build: disable release LTO in build-core.sh (Cargo forbids lto in a build-override) to avoid the proc-macro "mis-aligned LINKEDIT string pool" corruption, so release archives are compact instead of shipping the debug core. Update the app icon. Tests: shell test for the generator (escaping, missing/duplicate key), plus XCTest and jvmTest asserting the generated value matches app.properties. --- Makefile | 10 +- app.properties | 4 + apple/Tests/AppConfigTests.swift | 39 ++++++ .../Features/Settings/SettingsSections.swift | 2 +- .../VniDrop/Resources/AppIcon.icon/icon.json | 117 +++++++++--------- apple/scripts/build-core.sh | 7 ++ apple/scripts/generate-appconfig.sh | 44 +++++++ .../scripts/tests/test-generate-appconfig.sh | 59 +++++++++ shared/build.gradle.kts | 50 ++++++++ .../app/feature/settings/AboutSettings.kt | 2 +- .../kotlin/com/vnidrop/app/AppConfigTest.kt | 40 ++++++ 11 files changed, 308 insertions(+), 66 deletions(-) create mode 100644 app.properties create mode 100644 apple/Tests/AppConfigTests.swift create mode 100755 apple/scripts/generate-appconfig.sh create mode 100755 apple/scripts/tests/test-generate-appconfig.sh create mode 100644 shared/src/jvmTest/kotlin/com/vnidrop/app/AppConfigTest.kt diff --git a/Makefile b/Makefile index 5b185ca..39d160a 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ include $(ROOT)/make/release.mk .PHONY: format test check check-rust audit-rust test-rust test-rust-all .PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink .PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop -.PHONY: apple-core apple-version-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple package-apple-core +.PHONY: apple-core apple-version-config apple-app-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple package-apple-core .PHONY: prepare-release check-version check-release check-localization localization localization-migrate .PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics @@ -71,8 +71,9 @@ check-version: ## Validate the canonical version and its platform mappings. cd $(ROOT) && $(GRADLE) verifyVersion $(GRADLE_FLAGS) check-release: ## Validate coordinated release scripts and workflow YAML. - cd $(ROOT) && bash -n apple/scripts/notarize.sh apple/scripts/sign-exported-app.sh apple/scripts/tests/test-notarize.sh apple/scripts/tests/test-sign-exported-app.sh packaging/android/build-release.sh packaging/android/verify-apk-signature.sh packaging/android/tests/test_verify_apk_signature.sh packaging/release/assemble-release.sh packaging/release/test-assemble-release.sh packaging/release/test-release-config.sh + cd $(ROOT) && bash -n apple/scripts/notarize.sh apple/scripts/sign-exported-app.sh apple/scripts/tests/test-notarize.sh apple/scripts/tests/test-sign-exported-app.sh apple/scripts/generate-appconfig.sh apple/scripts/tests/test-generate-appconfig.sh packaging/android/build-release.sh packaging/android/verify-apk-signature.sh packaging/android/tests/test_verify_apk_signature.sh packaging/release/assemble-release.sh packaging/release/test-assemble-release.sh packaging/release/test-release-config.sh cd $(ROOT) && apple/scripts/tests/test-notarize.sh + cd $(ROOT) && apple/scripts/tests/test-generate-appconfig.sh cd $(ROOT) && apple/scripts/tests/test-sign-exported-app.sh cd $(ROOT) && packaging/android/tests/test_verify_apk_signature.sh cd $(ROOT) && packaging/release/test-assemble-release.sh @@ -135,7 +136,10 @@ apple-core: ## Build the Rust XCFramework and generated Swift bindings. apple-version-config: ## Generate derived Store and Direct Apple build settings. cd $(ROOT) && packaging/version/generate-apple-xcconfig.sh all -apple-project: apple-core localization apple-version-config ## Generate the native Apple Xcode project. +apple-app-config: ## Generate AppConfig.swift from the shared app.properties. + cd $(ROOT) && apple/scripts/generate-appconfig.sh + +apple-project: apple-core localization apple-version-config apple-app-config ## Generate the native Apple Xcode project. cd $(ROOT)/apple && $(XCODEGEN) generate open-apple-project: apple-project ## Generate and open the native Apple Xcode project. diff --git a/app.properties b/app.properties new file mode 100644 index 0000000..8e11d09 --- /dev/null +++ b/app.properties @@ -0,0 +1,4 @@ +# Public, app-wide configuration shared by every platform (Apple + KMP). +# Plain KEY=VALUE so it is parsed identically by shell, Gradle, and codegen. +# Injected into the apps at build time — never hardcode these values in app code. +PRIVACY_POLICY_URL=https://vnidrop.sudosy.fr/privacy/ diff --git a/apple/Tests/AppConfigTests.swift b/apple/Tests/AppConfigTests.swift new file mode 100644 index 0000000..93bdcf9 --- /dev/null +++ b/apple/Tests/AppConfigTests.swift @@ -0,0 +1,39 @@ +import XCTest +@testable import VniDrop + +/// Verifies the build-time `AppConfig` (generated from the shared `app.properties`) +/// exposes the expected, well-formed values to the app. +final class AppConfigTests: XCTestCase { + func testPrivacyPolicyURLIsTheExpectedHTTPSEndpoint() { + let url = AppConfig.privacyPolicyURL + XCTAssertEqual(url.scheme, "https", "Privacy policy URL must be https") + XCTAssertEqual(url.absoluteString, "https://vnidrop.sudosy.fr/privacy/") + } + + func testPrivacyPolicyURLMatchesTheSharedConfigFile() throws { + // Cross-check the generated constant against the single source of truth so a + // broken generator (or drift) is caught, not just a hardcoded copy. + let expected = try Self.privacyURLFromAppProperties() + XCTAssertEqual(AppConfig.privacyPolicyURL.absoluteString, expected) + } + + /// Reads `PRIVACY_POLICY_URL` from the repo's `app.properties` by walking up + /// from this source file's location to the repository root. + private static func privacyURLFromAppProperties() throws -> String { + var dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0..<8 { + let candidate = dir.appendingPathComponent("app.properties") + if FileManager.default.fileExists(atPath: candidate.path) { + let contents = try String(contentsOf: candidate, encoding: .utf8) + for line in contents.split(whereSeparator: \.isNewline) { + if line.hasPrefix("PRIVACY_POLICY_URL=") { + return String(line.dropFirst("PRIVACY_POLICY_URL=".count)) + } + } + throw XCTSkip("PRIVACY_POLICY_URL missing in \(candidate.path)") + } + dir.deleteLastPathComponent() + } + throw XCTSkip("app.properties not found from \(#filePath)") + } +} diff --git a/apple/VniDrop/Features/Settings/SettingsSections.swift b/apple/VniDrop/Features/Settings/SettingsSections.swift index 88f21af..e8c5a1a 100644 --- a/apple/VniDrop/Features/Settings/SettingsSections.swift +++ b/apple/VniDrop/Features/Settings/SettingsSections.swift @@ -375,7 +375,7 @@ struct StorageSettings: View { struct AboutSettings: View { @ObservedObject var model: SettingsModel - private static let privacyPolicyURL = URL(string: "https://github.com/vnidrop/vnidrop")! + private static let privacyPolicyURL = AppConfig.privacyPolicyURL var body: some View { Section { diff --git a/apple/VniDrop/Resources/AppIcon.icon/icon.json b/apple/VniDrop/Resources/AppIcon.icon/icon.json index b790233..76af636 100644 --- a/apple/VniDrop/Resources/AppIcon.icon/icon.json +++ b/apple/VniDrop/Resources/AppIcon.icon/icon.json @@ -1,62 +1,57 @@ { - "fill": { - "linear-gradient": [ - "extended-gray:1.00000,1.00000", - "display-p3:0.55433,0.59923,0.92884,1.00000" - ] - }, - "groups": [ - { - "blend-mode": "normal", - "blur-material": null, - "layers": [ - { - "image-name": "Mask.svg", - "name": "Mask" - } - ], - "lighting": "individual", - "refractivity": { - "depth": 0.5, - "enabled": true, - "strength": 0 - }, - "shadow": { - "kind": "neutral", - "opacity": 0.6 - }, - "specular": true, - "translucency": { - "enabled": true, - "value": 0.8 - } - }, - { - "layers": [ - { - "image-name": "Drop.svg", - "name": "Drop" - }, - { - "image-name": "U.svg", - "name": "U" - } - ], - "lighting": "combined", - "shadow": { - "kind": "neutral", - "opacity": 0.6 - }, - "translucency": { - "enabled": true, - "value": 0.4 - } - } - ], - "supported-platforms": { - "circles": [ - "watchOS" - ], - "squares": "shared" - } -} + "fill" : { + "linear-gradient" : [ + "extended-gray:1.00000,1.00000", + "srgb:0.84942,0.81480,0.95401,1.00000" + ] + }, + "groups" : [ + { + "blend-mode" : "normal", + "blur-material" : null, + "layers" : [ + { + "image-name" : "Mask.svg", + "name" : "Mask" + } + ], + "lighting" : "individual", + "shadow" : { + "kind" : "neutral", + "opacity" : 0.6 + }, + "specular" : true, + "translucency" : { + "enabled" : true, + "value" : 0.8 + } + }, + { + "layers" : [ + { + "image-name" : "Drop.svg", + "name" : "Drop" + }, + { + "image-name" : "U.svg", + "name" : "U" + } + ], + "lighting" : "combined", + "shadow" : { + "kind" : "layer-color", + "opacity" : 0.8 + }, + "translucency" : { + "enabled" : true, + "value" : 0.4 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/apple/scripts/build-core.sh b/apple/scripts/build-core.sh index 77f89c6..380d5bb 100755 --- a/apple/scripts/build-core.sh +++ b/apple/scripts/build-core.sh @@ -44,6 +44,13 @@ export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-15.0}" # This never touches the Rust crate — it only changes how the build is invoked. export CARGO_PROFILE_DEV_STRIP=none +# The workspace `[profile.release] lto = "thin"` corrupts host proc-macro / build +# script dylibs when cross-compiling ("mis-aligned LINKEDIT string pool"). Cargo +# forbids overriding `lto` per build-override, so disable thin LTO for the whole +# release build here — the crate is still fully optimized (opt-level 3, debuginfo +# stripped), which is what shrinks the static lib. This never edits the Cargo crate. +export CARGO_PROFILE_RELEASE_LTO=false + IOS_TARGET="aarch64-apple-ios" SIM_ARM_TARGET="aarch64-apple-ios-sim" SIM_X64_TARGET="x86_64-apple-ios" diff --git a/apple/scripts/generate-appconfig.sh b/apple/scripts/generate-appconfig.sh new file mode 100755 index 0000000..9ff9537 --- /dev/null +++ b/apple/scripts/generate-appconfig.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +# Generates apple/VniDrop/Generated/AppConfig.swift from the shared app.properties +# so app-wide constants (privacy policy URL, …) have a single source of truth +# across Apple and KMP. Regenerate instead of editing the output. + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" +config_file="${VNIDROP_APP_PROPERTIES:-$repo_root/app.properties}" +output_dir="${VNIDROP_APPLE_GENERATED_DIR:-$repo_root/apple/VniDrop/Generated}" + +read_property() { + local key=$1 + local value + value="$(sed -n "s/^${key}=//p" "$config_file")" + [[ -n "$value" ]] || { printf 'Missing %s in %s\n' "$key" "$config_file" >&2; exit 1; } + [[ $(printf '%s\n' "$value" | wc -l | tr -d ' ') == 1 ]] || + { printf 'Duplicate %s in %s\n' "$key" "$config_file" >&2; exit 1; } + printf '%s' "$value" +} + +# Escape for a Swift string literal. +swift_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' +} + +privacy_url="$(read_property PRIVACY_POLICY_URL)" + +mkdir -p "$output_dir" +tmp="$(mktemp "$output_dir/.AppConfig.swift.XXXXXX")" +cat > "$tmp" </dev/null 2>&1; then + printf 'Expected command to fail: %s\n' "$*" >&2 + exit 1 + fi +} + +assert_contains() { + local file=$1 needle=$2 + grep -qF "$needle" "$file" || + { printf 'Expected %s to contain: %s\n' "$file" "$needle" >&2; exit 1; } +} + +out="$scratch/out/AppConfig.swift" + +# 1. Nominal value is emitted verbatim as a Swift URL literal. +printf 'PRIVACY_POLICY_URL=%s\n' 'https://example.test/privacy/' > "$scratch/app.properties" +generate +assert_contains "$out" 'URL(string: "https://example.test/privacy/")!' +assert_contains "$out" 'enum AppConfig' + +# 2. Characters special to a Swift string literal are escaped. +printf 'PRIVACY_POLICY_URL=%s\n' 'https://a.test/"q"\z' > "$scratch/app.properties" +generate +assert_contains "$out" 'URL(string: "https://a.test/\"q\"\\z")!' + +# 3. A missing key fails instead of emitting an empty value. +printf 'OTHER_KEY=value\n' > "$scratch/app.properties" +expect_failure generate + +# 4. A duplicated key fails. +printf 'PRIVACY_POLICY_URL=a\nPRIVACY_POLICY_URL=b\n' > "$scratch/app.properties" +expect_failure generate + +# 5. The real committed app.properties produces an https URL. +VNIDROP_APPLE_GENERATED_DIR="$scratch/real" "$generator" +assert_contains "$scratch/real/AppConfig.swift" 'URL(string: "https://' + +printf 'generate-appconfig tests passed.\n' diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index f6baf59..6c66e72 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -110,6 +110,55 @@ val generateDiagnosticsBuildConfig by tasks.registering { } } +// App-wide public constants (privacy policy URL, …) from the shared app.properties, +// so Apple and KMP read one source of truth instead of hardcoding values. +val appProperties = java.util.Properties().apply { + rootProject.file("app.properties").inputStream().use(::load) +} +val privacyPolicyUrl: String = appProperties.getProperty("PRIVACY_POLICY_URL")?.trim().orEmpty() +check(privacyPolicyUrl.isNotEmpty()) { "PRIVACY_POLICY_URL must be set in app.properties" } + +val appConfigDir = layout.buildDirectory.dir("generated/appconfig/commonMain/kotlin") +val generateAppConfig by tasks.registering { + group = "build" + description = "Generates AppConfig from the shared app.properties" + val outputDir = appConfigDir + val privacy = privacyPolicyUrl + inputs.property("PRIVACY_POLICY_URL", privacy) + outputs.dir(outputDir) + doLast { + val packageDir = outputDir.get().asFile.resolve("com/vnidrop/app") + packageDir.mkdirs() + fun esc(value: String): String = buildString { + for (ch in value) { + when (ch) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + '$' -> append("\\$") + else -> append(ch) + } + } + } + packageDir.resolve("AppConfig.kt").writeText( + """ + |package com.vnidrop.app + | + |/** + | * Generated by shared/build.gradle.kts from the shared app.properties. + | * Single source of truth for app-wide public constants (also used by Apple). + | */ + |object AppConfig { + | const val PRIVACY_POLICY_URL: String = "${esc(privacy)}" + |} + | + """.trimMargin(), + ) + } +} + kotlin { androidTarget { compilerOptions { @@ -122,6 +171,7 @@ kotlin { sourceSets { commonMain { kotlin.srcDir(files(diagnosticsBuildConfigDir).builtBy(generateDiagnosticsBuildConfig)) + kotlin.srcDir(files(appConfigDir).builtBy(generateAppConfig)) } androidMain.dependencies { implementation(libs.androidx.activity.compose) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt index 3c4562a..c4067bb 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt @@ -52,7 +52,7 @@ import vnidrop.shared.generated.resources.os_version_title import vnidrop.shared.generated.resources.value_unavailable import vnidrop.shared.generated.resources.version_title -private const val PrivacyPolicyUrl = "https://github.com/vnidrop/vnidrop" +private val PrivacyPolicyUrl = com.vnidrop.app.AppConfig.PRIVACY_POLICY_URL @Composable internal fun AboutSettings( diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/AppConfigTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/AppConfigTest.kt new file mode 100644 index 0000000..01df9fc --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/AppConfigTest.kt @@ -0,0 +1,40 @@ +package com.vnidrop.app + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Verifies the build-time [AppConfig] (generated from the shared `app.properties`) + * exposes the expected, well-formed values to the shared UI. + */ +class AppConfigTest { + @Test + fun privacyPolicyUrlIsTheExpectedHttpsEndpoint() { + assertTrue(AppConfig.PRIVACY_POLICY_URL.startsWith("https://"), "must be https") + assertEquals("https://vnidrop.sudosy.fr/privacy/", AppConfig.PRIVACY_POLICY_URL) + } + + @Test + fun privacyPolicyUrlMatchesTheSharedConfigFile() { + // Cross-check the generated constant against the single source of truth so a + // broken codegen (or drift) is caught, not just a hardcoded copy. + assertEquals(privacyUrlFromAppProperties(), AppConfig.PRIVACY_POLICY_URL) + } + + private fun privacyUrlFromAppProperties(): String { + var dir: File? = File(System.getProperty("user.dir")).absoluteFile + repeat(8) { + val candidate = File(dir, "app.properties") + if (candidate.isFile) { + return candidate.readLines() + .firstOrNull { it.startsWith("PRIVACY_POLICY_URL=") } + ?.substringAfter("PRIVACY_POLICY_URL=") + ?: error("PRIVACY_POLICY_URL missing in ${candidate.path}") + } + dir = dir?.parentFile + } + error("app.properties not found from ${System.getProperty("user.dir")}") + } +} From b8a002a2ade9e72def02cb16cb38cf2e8104e06e Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:02:28 +0200 Subject: [PATCH 2/8] 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. --- gradle.properties | 5 +- localization/strings.json | 56 -- shared/build.gradle.kts | 2 +- .../diagnostics/PendingCrashStore.android.kt | 72 -- .../diagnostics/PlatformCrashHook.android.kt | 9 - .../composeResources/values-de/strings.xml | 4 - .../composeResources/values-es/strings.xml | 4 - .../composeResources/values-fr/strings.xml | 4 - .../composeResources/values-it/strings.xml | 4 - .../composeResources/values-nl/strings.xml | 4 - .../composeResources/values-pl/strings.xml | 4 - .../composeResources/values-pt/strings.xml | 4 - .../composeResources/values-ru/strings.xml | 4 - .../composeResources/values/strings.xml | 4 - .../commonMain/kotlin/com/vnidrop/app/App.kt | 2 - .../kotlin/com/vnidrop/app/AppGraph.kt | 11 - .../vnidrop/app/diagnostics/CrashReporter.kt | 158 ---- .../app/diagnostics/DiagnosticsCoordinator.kt | 44 +- .../app/diagnostics/DiagnosticsJson.kt | 77 -- .../app/diagnostics/DiagnosticsModels.kt | 28 - .../app/diagnostics/DiagnosticsTransport.kt | 31 +- .../diagnostics/HttpDiagnosticsTransport.kt | 27 - .../app/diagnostics/PendingCrashStore.kt | 193 ----- .../app/diagnostics/TelemetryRecorder.kt | 216 ----- .../vnidrop/app/feature/app/AppViewModel.kt | 4 - .../app/feature/settings/AboutSettings.kt | 15 - .../app/feature/settings/SettingsRoute.kt | 1 - .../app/feature/settings/SettingsScreen.kt | 5 - .../app/feature/settings/SettingsViewModel.kt | 28 - .../preferences/AppPreferencesRepository.kt | 14 +- .../app/diagnostics/DiagnosticsTest.kt | 756 +----------------- .../com/vnidrop/app/feature/ViewModelsTest.kt | 30 - .../kotlin/com/vnidrop/app/support/Fakes.kt | 3 - .../app/diagnostics/PendingCrashStore.jvm.kt | 78 -- .../app/diagnostics/PlatformCrashHook.jvm.kt | 9 - .../diagnostics/PendingCrashStoreJvmTest.kt | 59 -- .../vnidrop/app/ui/FoundationComposeTest.kt | 7 - 37 files changed, 55 insertions(+), 1921 deletions(-) delete mode 100644 shared/src/androidMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.android.kt delete mode 100644 shared/src/androidMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.android.kt delete mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/CrashReporter.kt delete mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.kt delete mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/TelemetryRecorder.kt delete mode 100644 shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.jvm.kt delete mode 100644 shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.jvm.kt delete mode 100644 shared/src/jvmTest/kotlin/com/vnidrop/app/diagnostics/PendingCrashStoreJvmTest.kt diff --git a/gradle.properties b/gradle.properties index 035c8de..7ac4041 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,9 +13,8 @@ android.nonTransitiveRClass=true android.sourceset.disallowProvider=false android.useAndroidX=true -# VniDrop: compile-time diagnostics/telemetry product surface. -# false → no Share-diagnostics toggle, no telemetry or crash auto-upload stack. -# Bug report UI remains available (user-initiated). +# VniDrop: compile-time bug-report delivery surface. +# false → user-initiated bug reports fall back to a NoOp transport (never sent). # Enable per build only when endpoint and ingest key are configured: # ./gradlew … -Pvnidrop.diagnostics.included=true vnidrop.diagnostics.included=false diff --git a/localization/strings.json b/localization/strings.json index 2196c4f..49e9fda 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -1178,62 +1178,6 @@ "ru": "Имя устройства" } }, - "diagnostics_description": { - "context": "Settings > Diagnostics: explanation of what anonymous diagnostics collect.", - "translations": { - "en": "Send anonymous crash reports and usage events so we can improve VniDrop. You can turn this off anytime. Invitations, file paths, and transfer contents are never included.", - "fr": "Envoyer des rapports de plantage et des événements d’utilisation anonymes pour nous aider à améliorer VniDrop. Vous pouvez désactiver cela à tout moment. Les invitations, chemins de fichiers et contenus de transfert ne sont jamais inclus.", - "es": "Enviar informes de fallos y eventos de uso anónimos para ayudarnos a mejorar VniDrop. Puede desactivarlo en cualquier momento. Las invitaciones, las rutas de archivos y el contenido de las transferencias nunca se incluyen.", - "it": "Invia report di arresto anomalo ed eventi d’uso anonimi per aiutarci a migliorare VniDrop. Può disattivarlo in qualsiasi momento. Inviti, percorsi dei file e contenuti dei trasferimenti non vengono mai inclusi.", - "de": "Anonyme Absturzberichte und Nutzungsereignisse senden, damit wir VniDrop verbessern können. Sie können dies jederzeit deaktivieren. Einladungen, Dateipfade und Übertragungsinhalte werden niemals einbezogen.", - "pt": "Enviar relatórios de falhas e eventos de utilização anónimos para nos ajudar a melhorar o VniDrop. Pode desativar isto a qualquer momento. Convites, caminhos de ficheiros e conteúdos das transferências nunca são incluídos.", - "pl": "Wysyłaj anonimowe raporty o awariach i zdarzenia użytkowania, aby pomóc nam ulepszać VniDrop. Możesz to wyłączyć w dowolnej chwili. Zaproszenia, ścieżki plików i zawartość transferów nigdy nie są dołączane.", - "nl": "Verstuur anonieme crashrapporten en gebruiksgebeurtenissen zodat we VniDrop kunnen verbeteren. U kunt dit op elk moment uitschakelen. Uitnodigingen, bestandspaden en overdrachtsinhoud worden nooit meegestuurd.", - "ru": "Отправлять анонимные отчёты о сбоях и события использования, чтобы помочь нам улучшать VniDrop. Вы можете отключить это в любой момент. Приглашения, пути к файлам и содержимое передач никогда не включаются." - } - }, - "diagnostics_disabled_message": { - "context": "Settings > Diagnostics: confirmation shown when diagnostics are turned off.", - "translations": { - "en": "Diagnostics sharing is off.", - "fr": "Le partage des diagnostics est désactivé.", - "es": "El uso compartido de diagnósticos está desactivado.", - "it": "La condivisione dei dati diagnostici è disattivata.", - "de": "Die Freigabe von Diagnosedaten ist deaktiviert.", - "pt": "A partilha de diagnósticos está desativada.", - "pl": "Udostępnianie diagnostyki jest wyłączone.", - "nl": "Het delen van diagnostische gegevens is uitgeschakeld.", - "ru": "Передача диагностики отключена." - } - }, - "diagnostics_enabled_message": { - "context": "Settings > Diagnostics: confirmation shown when diagnostics are turned on.", - "translations": { - "en": "Diagnostics sharing is on.", - "fr": "Le partage des diagnostics est activé.", - "es": "El uso compartido de diagnósticos está activado.", - "it": "La condivisione dei dati diagnostici è attivata.", - "de": "Die Freigabe von Diagnosedaten ist aktiviert.", - "pt": "A partilha de diagnósticos está ativada.", - "pl": "Udostępnianie diagnostyki jest włączone.", - "nl": "Het delen van diagnostische gegevens is ingeschakeld.", - "ru": "Передача диагностики включена." - } - }, - "diagnostics_title": { - "context": "Settings > Diagnostics: toggle title.", - "translations": { - "en": "Share diagnostics", - "fr": "Partager les diagnostics", - "es": "Compartir diagnósticos", - "it": "Condividi dati diagnostici", - "de": "Diagnosedaten teilen", - "pt": "Partilhar diagnósticos", - "pl": "Udostępniaj diagnostykę", - "nl": "Diagnostische gegevens delen", - "ru": "Делиться диагностикой" - } - }, "error_camera": { "context": "Error: camera permission is needed to scan a QR code.", "translations": { diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 6c66e72..e240401 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -49,7 +49,7 @@ val desktopRustVariant = providers.gradleProperty("vnidrop.desktop.rustVariant") .orElse(Variant.Debug) // Compile-time switches (gradle.properties or -P…). -// included=false: no Share-diagnostics toggle, no telemetry/crash auto-upload stack. +// included=false: user-initiated bug reports use a NoOp transport (never sent). // endpoint/key both empty: transport is NoOp (safe default until Cloudflare is deployed). val diagnosticsIncluded: Boolean = (findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: false diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.android.kt deleted file mode 100644 index 00673b0..0000000 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.android.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.vnidrop.app.diagnostics - -import java.io.File -import java.nio.charset.StandardCharsets - -actual fun createPendingCrashStore(appDataDir: String): PendingCrashStore = - AndroidPendingCrashStore(appDataDir) - -private class AndroidPendingCrashStore( - appDataDir: String, -) : PendingCrashStore { - private val directory = File(appDataDir, "diagnostics/crashes") - - @Synchronized - override fun write(report: CrashReport) { - if (!isValidDiagnosticId(report.id)) return - directory.mkdirs() - val target = File(directory, "${report.id}.crash") - val temporary = File(directory, ".${report.id}.tmp") - val payload = CrashReportCodec.encode(report) - temporary.writeText(payload, StandardCharsets.UTF_8) - if (!temporary.renameTo(target)) { - target.writeText(payload, StandardCharsets.UTF_8) - temporary.delete() - } - } - - @Synchronized - override fun list(): List { - if (!directory.isDirectory) return emptyList() - return directory - .listFiles { file -> file.isFile && file.name.endsWith(".crash") } - .orEmpty() - .sortedByDescending { it.lastModified() } - .mapNotNull { file -> - runCatching { CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8)) }.getOrNull() - } - } - - @Synchronized - override fun delete(id: String) { - if (!isValidDiagnosticId(id)) return - File(directory, "$id.crash").delete() - } - - @Synchronized - override fun prune(olderThanTimestampMillis: Long, maxCount: Int) { - require(maxCount > 0) { "maxCount must be positive" } - if (!directory.isDirectory) return - directory.listFiles { file -> file.isFile && file.name.endsWith(".tmp") } - .orEmpty() - .forEach(File::delete) - val reports = directory - .listFiles { file -> file.isFile && file.name.endsWith(".crash") } - .orEmpty() - .mapNotNull { file -> - val report = runCatching { - CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8)) - }.getOrNull() - if (report == null) { - file.delete() - null - } else { - file to report - } - } - .sortedByDescending { (_, report) -> report.timestampMillis } - reports.forEachIndexed { index, (file, report) -> - if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) file.delete() - } - } -} diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.android.kt deleted file mode 100644 index d762725..0000000 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.android.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.vnidrop.app.diagnostics - -actual fun installPlatformCrashHook(onCrash: (Throwable) -> Unit) { - val previous = Thread.getDefaultUncaughtExceptionHandler() - Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> - runCatching { onCrash(throwable) } - previous?.uncaughtException(thread, throwable) - } -} diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index 4e18485..8218b6b 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -80,10 +80,6 @@ Auf NFC-Tag schreiben Gerätemodell Gerätename - Anonyme Absturzberichte und Nutzungsereignisse senden, damit wir VniDrop verbessern können. Sie können dies jederzeit deaktivieren. Einladungen, Dateipfade und Übertragungsinhalte werden niemals einbezogen. - Die Freigabe von Diagnosedaten ist deaktiviert. - Die Freigabe von Diagnosedaten ist aktiviert. - Diagnosedaten teilen Für das Scannen eines QR-Codes ist Kamerazugriff erforderlich. Geräteinformationen konnten nicht geladen werden. Am Ziel ist bereits eine Datei mit demselben Namen vorhanden. Wählen Sie einen anderen Ordner oder entfernen Sie die vorhandene Datei. diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index d8112f2..0b06c85 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -80,10 +80,6 @@ Escribir en etiqueta NFC Modelo del dispositivo Nombre del dispositivo - 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. - El uso compartido de diagnósticos está desactivado. - El uso compartido de diagnósticos está activado. - Compartir diagnósticos Se necesita acceso a la cámara para escanear un código QR. No se pudo cargar la información del dispositivo. Ya existe un archivo con el mismo nombre en el destino. Elija otra carpeta o elimine el archivo existente. diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index 871fcc8..4d09439 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -80,10 +80,6 @@ Écrire sur un tag NFC Modèle de l’appareil Nom de l’appareil - 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. - Le partage des diagnostics est désactivé. - Le partage des diagnostics est activé. - Partager les diagnostics L’accès à la caméra est nécessaire pour scanner un QR code. Impossible de charger les informations de l’appareil. Un fichier portant le même nom existe déjà dans la destination. Choisissez un autre dossier ou supprimez le fichier existant. diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index d8aab31..15d643b 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -80,10 +80,6 @@ Scrivi su tag NFC Modello del dispositivo Nome del dispositivo - 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. - La condivisione dei dati diagnostici è disattivata. - La condivisione dei dati diagnostici è attivata. - Condividi dati diagnostici Per scansionare un codice QR è necessario l’accesso alla fotocamera. Impossibile caricare le informazioni sul dispositivo. Nella destinazione esiste già un file con lo stesso nome. Scelga un’altra cartella o rimuova il file esistente. diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index f6940b5..c2c7ff0 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -80,10 +80,6 @@ Naar NFC-tag schrijven Apparaatmodel Apparaatnaam - Verstuur anonieme crashrapporten en gebruiksgebeurtenissen zodat we VniDrop kunnen verbeteren. U kunt dit op elk moment uitschakelen. Uitnodigingen, bestandspaden en overdrachtsinhoud worden nooit meegestuurd. - Het delen van diagnostische gegevens is uitgeschakeld. - Het delen van diagnostische gegevens is ingeschakeld. - Diagnostische gegevens delen Voor het scannen van een QR-code is toegang tot de camera vereist. Apparaatgegevens konden niet worden geladen. Er staat al een bestand met dezelfde naam in de doelmap. Kies een andere map of verwijder het bestaande bestand. diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index 9838356..0ccd2ba 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -80,10 +80,6 @@ Zapisz na tagu NFC Model urządzenia Nazwa urządzenia - 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. - Udostępnianie diagnostyki jest wyłączone. - Udostępnianie diagnostyki jest włączone. - Udostępniaj diagnostykę Do zeskanowania kodu QR wymagany jest dostęp do aparatu. Nie udało się wczytać informacji o urządzeniu. W miejscu docelowym istnieje już plik o tej samej nazwie. Wybierz inny folder lub usuń istniejący plik. diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index 8cab366..267acc3 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -80,10 +80,6 @@ Escrever em etiqueta NFC Modelo do dispositivo Nome do dispositivo - 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. - A partilha de diagnósticos está desativada. - A partilha de diagnósticos está ativada. - Partilhar diagnósticos É necessário acesso à câmara para ler um código QR. Não foi possível carregar as informações do dispositivo. Já existe um ficheiro com o mesmo nome no destino. Escolha outra pasta ou remova o ficheiro existente. diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index 897a67d..79d24db 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -80,10 +80,6 @@ Записать на NFC-метку Модель устройства Имя устройства - Отправлять анонимные отчёты о сбоях и события использования, чтобы помочь нам улучшать VniDrop. Вы можете отключить это в любой момент. Приглашения, пути к файлам и содержимое передач никогда не включаются. - Передача диагностики отключена. - Передача диагностики включена. - Делиться диагностикой Для сканирования QR-кода требуется доступ к камере. Не удалось загрузить сведения об устройстве. В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл. diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 611355c..d9e49b8 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -80,10 +80,6 @@ Write to NFC tag Device model Device name - 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. - Diagnostics sharing is off. - Diagnostics sharing is on. - Share diagnostics Camera access is required to scan a QR code. Could not load device information. A file with the same name already exists in the destination. Choose another folder or remove the existing file. diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index 260a3ce..de8e75a 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -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() diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt index 6f64179..304d917 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt @@ -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() { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/CrashReporter.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/CrashReporter.kt deleted file mode 100644 index ed2d961..0000000 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/CrashReporter.kt +++ /dev/null @@ -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) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsCoordinator.kt index 4b3cd8c..e035f65 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsCoordinator.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsCoordinator.kt @@ -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 = 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, ) } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsJson.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsJson.kt index 39087cd..3476416 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsJson.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsJson.kt @@ -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, - ): 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): 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 "" diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsModels.kt index 76943fc..43392df 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsModels.kt @@ -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 = 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, -) - data class Breadcrumb( val name: String, val timestampMillis: Long, val properties: Map = 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, - /** `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?, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTransport.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTransport.kt index dac525f..cfe01ec 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTransport.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTransport.kt @@ -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 - suspend fun sendCrash(report: CrashReport): Result suspend fun sendBugReport(report: BugReport): Result } 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 = unavailable() - override suspend fun sendCrash(report: CrashReport): Result = unavailable() - override suspend fun sendBugReport(report: BugReport): Result = unavailable() - - private fun unavailable(): Result = Result.failure(DiagnosticsUnavailableException()) + override suspend fun sendBugReport(report: BugReport): Result = + Result.failure(DiagnosticsUnavailableException()) } /** * Test double that records calls and can fail on demand. */ class RecordingDiagnosticsTransport : DiagnosticsTransport { - val eventBatches = mutableListOf() - val events: List> - get() = eventBatches.map(TelemetryBatch::events) - val crashes = mutableListOf() val bugReports = mutableListOf() - var eventsResult: Result = Result.success(Unit) - var crashResult: Result = Result.success(Unit) var bugResult: Result = Result.success(Unit) - override suspend fun sendEvents(batch: TelemetryBatch): Result { - eventBatches += batch - return eventsResult - } - - override suspend fun sendCrash(report: CrashReport): Result { - crashes += report - return crashResult - } - override suspend fun sendBugReport(report: BugReport): Result { bugReports += report return bugResult diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/HttpDiagnosticsTransport.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/HttpDiagnosticsTransport.kt index 14e68e4..a2305eb 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/HttpDiagnosticsTransport.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/HttpDiagnosticsTransport.kt @@ -27,33 +27,6 @@ class HttpDiagnosticsTransport( } } - override suspend fun sendEvents(batch: TelemetryBatch): Result { - 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 { - 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 { val body = DiagnosticsJson.bugBody(report) return postJson("/v1/bugs", body, report.installId, report.id) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.kt deleted file mode 100644 index c009162..0000000 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.kt +++ /dev/null @@ -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 - 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() - 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() - 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, - crumbs: List, - ): 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() -} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/TelemetryRecorder.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/TelemetryRecorder.kt deleted file mode 100644 index 66b0e06..0000000 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/TelemetryRecorder.kt +++ /dev/null @@ -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(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 = 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 { - return bufferMutex.withLock { - var discardedFailure: Throwable? = null - var outcome: Result? = 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): List { - 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 = emptyList(), - val retryBatch: TelemetryBatch? = null, -) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/app/AppViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/app/AppViewModel.kt index a90a163..a34a8f3 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/app/AppViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/app/AppViewModel.kt @@ -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 = _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)) } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt index c4067bb..4d78324 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt @@ -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), diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt index 1f9fd18..e0f5320 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt index b303c13..19f9549 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt index f8ea529..830166d 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt index 6f3090d..bc5c31d 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt @@ -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") diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt index 0c9bf90..0f31727 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt @@ -4,83 +4,25 @@ import com.vnidrop.app.DeviceInfo import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolderKind import com.vnidrop.app.preferences.AppPreferences -import com.vnidrop.app.preferences.PreferencesRepository import com.vnidrop.app.support.FakePreferencesRepository import com.vnidrop.app.ui.theme.ThemeMode import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertFailsWith import kotlin.test.assertIs -import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class DiagnosticsTest { - @Test - fun diagnosticsJsonEscapesAndShapesPayloads() { - val eventsJson = DiagnosticsJson.eventsBody( - batchId = "batch-1", - installId = "inst-1", - appVersion = "1.0", - platform = "Test", - events = listOf( - TelemetryEvent("app_open", 10L, mapOf("a" to "quote\"here")), - ), - ) - assertTrue(eventsJson.contains("\"batchId\":\"batch-1\"")) - assertTrue(eventsJson.contains("\"installId\":\"inst-1\"")) - assertTrue(eventsJson.contains("\"name\":\"app_open\"")) - assertTrue(eventsJson.contains("quote\\\"here")) - - val crashJson = DiagnosticsJson.crashBody( - CrashReport( - id = "c1", - timestampMillis = 1L, - installId = "inst", - appVersion = "1.0", - platform = "Test", - exceptionType = "E", - exceptionMessage = "line\nbreak", - stackTrace = "stack", - breadcrumbs = emptyList(), - diagnosticsEnabledAtCapture = true, - ), - ) - assertTrue(crashJson.contains("line\\nbreak")) - assertTrue(crashJson.contains("\"diagnosticsEnabledAtCapture\":true")) - } - @Test fun diagnosticsJsonKeepsEscapedBugPayloadWithinWorkerLimit() { - val report = BugReport( - id = "b", - timestampMillis = 1L, - installId = "i", - appVersion = "1.0", - platform = "Test", - whatHappened = "w", - expected = "e", - steps = "", - contact = "", - includeLogs = true, + val report = bugReport( logs = "\n".repeat(BugReportService.MaxLogBytes), - device = DeviceSnapshot(null, null, "OS", null, null), - breadcrumbs = emptyList(), + includeLogs = true, ) val body = DiagnosticsJson.bugBody(report) @@ -91,67 +33,24 @@ class DiagnosticsTest { } @Test - fun httpTransportPostsExpectedPaths() = runTest { + fun httpTransportPostsBugReportToExpectedPath() = runTest { val calls = mutableListOf>() - val acknowledgementIds = ArrayDeque(listOf("batch-1", "c", "b")) val transport = HttpDiagnosticsTransport( baseUrl = "https://diag.example", ingestKey = "secret", appVersion = "1.0", platform = "Test", - installIdProvider = { "install-x" }, post = { url, headers, body -> assertEquals("secret", headers["X-VniDrop-Key"]) calls += url to body - PlatformHttpResponse( - 202, - """{"ok":true,"id":"${acknowledgementIds.removeFirst()}","stored":1}""", - ) + PlatformHttpResponse(202, """{"ok":true,"id":"b","stored":1}""") }, ) - val eventResult = transport.sendEvents( - TelemetryBatch("batch-1", listOf(TelemetryEvent("nav", 1L))), - ) - assertTrue(eventResult.isSuccess) - assertEquals("https://diag.example/v1/events", calls[0].first) - assertTrue(calls[0].second.contains("install-x")) - val crashResult = transport.sendCrash( - CrashReport( - id = "c", - timestampMillis = 1L, - installId = "i", - appVersion = "1.0", - platform = "Test", - exceptionType = "E", - exceptionMessage = "m", - stackTrace = "s", - breadcrumbs = emptyList(), - diagnosticsEnabledAtCapture = true, - ), - ) - assertTrue(crashResult.isSuccess) - assertEquals("https://diag.example/v1/crashes", calls[1].first) + val bugResult = transport.sendBugReport(bugReport()) - val bugResult = transport.sendBugReport( - BugReport( - id = "b", - timestampMillis = 1L, - installId = "i", - appVersion = "1.0", - platform = "Test", - whatHappened = "w", - expected = "e", - steps = "", - contact = "", - includeLogs = false, - logs = "", - device = DeviceSnapshot(null, null, "OS", null, null), - breadcrumbs = emptyList(), - ), - ) assertTrue(bugResult.isSuccess) - assertEquals("https://diag.example/v1/bugs", calls[2].first) + assertEquals("https://diag.example/v1/bugs", calls.single().first) } @Test @@ -162,12 +61,12 @@ class DiagnosticsTest { post = { _, _, _ -> PlatformHttpResponse( 202, - """{"\u006f\u006b":true,"id":"batch-\u0031","metadata":{"stored":1,"flags":[true,null]}}""", + """{"ok":true,"id":"b","metadata":{"stored":1,"flags":[true,null]}}""", ) }, ) - assertTrue(transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L)))).isSuccess) + assertTrue(transport.sendBugReport(bugReport()).isSuccess) } @Test @@ -177,9 +76,7 @@ class DiagnosticsTest { ingestKey = "secret", post = { _, _, _ -> PlatformHttpResponse(401, """{"error":"unauthorized"}""") }, ) - assertTrue( - transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L)))).isFailure, - ) + assertTrue(transport.sendBugReport(bugReport()).isFailure) } @Test @@ -191,7 +88,7 @@ class DiagnosticsTest { ) assertFailsWith { - transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1)))) + transport.sendBugReport(bugReport()) } } @@ -203,7 +100,7 @@ class DiagnosticsTest { PlatformHttpResponse(202, """{"ok":false}"""), PlatformHttpResponse(202, """{,"ok":true}"""), PlatformHttpResponse(202, """{"ok":true,"id":"different"}"""), - PlatformHttpResponse(202, """{"ok":true,"id":"batch-4","id":"batch-4"}"""), + PlatformHttpResponse(202, """{"ok":true,"id":"b","id":"b"}"""), ), ) val transport = HttpDiagnosticsTransport( @@ -212,10 +109,8 @@ class DiagnosticsTest { post = { _, _, _ -> responses.removeFirst() }, ) - repeat(5) { index -> - val result = transport.sendEvents( - TelemetryBatch("batch-$index", listOf(TelemetryEvent("x", 1L))), - ) + repeat(5) { + val result = transport.sendBugReport(bugReport()) assertIs(result.exceptionOrNull()) } } @@ -224,11 +119,11 @@ class DiagnosticsTest { fun httpTransportRejectsAmbiguousOrMalformedJsonAcknowledgement() = runTest { val responses = ArrayDeque( listOf( - PlatformHttpResponse(202, """{"ok":true,"\u006f\u006b":true,"id":"batch-0"}"""), + PlatformHttpResponse(202, """{"ok":true,"ok":true,"id":"b"}"""), PlatformHttpResponse(202, """{"ok":true,"id":true}"""), - PlatformHttpResponse(202, """{"ok":true,"id":"batch-2","stored":01}"""), - PlatformHttpResponse(202, """{"ok":true,"id":"batch-3",}"""), - PlatformHttpResponse(202, """{"ok":true,"id":"batch-4"} trailing"""), + PlatformHttpResponse(202, """{"ok":true,"id":"b","stored":01}"""), + PlatformHttpResponse(202, """{"ok":true,"id":"b",}"""), + PlatformHttpResponse(202, """{"ok":true,"id":"b"} trailing"""), ), ) val transport = HttpDiagnosticsTransport( @@ -237,10 +132,8 @@ class DiagnosticsTest { post = { _, _, _ -> responses.removeFirst() }, ) - repeat(5) { index -> - val result = transport.sendEvents( - TelemetryBatch("batch-$index", listOf(TelemetryEvent("x", 1L))), - ) + repeat(5) { + val result = transport.sendBugReport(bugReport()) assertIs(result.exceptionOrNull()) } } @@ -288,9 +181,7 @@ class DiagnosticsTest { @Test fun noOpTransportReportsUnavailableDelivery() = runTest { - val result = NoOpDiagnosticsTransport().sendEvents( - TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L))), - ) + val result = NoOpDiagnosticsTransport().sendBugReport(bugReport()) assertIs(result.exceptionOrNull()) } @@ -323,563 +214,10 @@ class DiagnosticsTest { assertEquals(listOf("b", "c", "d"), buffer.snapshot().map { it.name }) } - @Test - fun telemetryIgnoresEventsWhenDiagnosticsDisabled() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = false) - val transport = RecordingDiagnosticsTransport() - val breadcrumbs = BreadcrumbBuffer() - val recorder = TelemetryRecorder( - preferencesRepository = preferences, - transport = transport, - breadcrumbs = breadcrumbs, - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 1, - ) - advanceUntilIdle() - recorder.record("app_open") - advanceUntilIdle() - assertEquals(0, transport.events.size) - assertEquals(1, breadcrumbs.snapshot().size) - } - - @Test - fun excludedTelemetryDoesNotStartOrRecordEvents() = runTest { - val transport = RecordingDiagnosticsTransport() - val breadcrumbs = BreadcrumbBuffer() - val recorder = TelemetryRecorder( - preferencesRepository = fakePrefs(diagnosticsEnabled = true), - transport = transport, - breadcrumbs = breadcrumbs, - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 1, - included = false, - ) - - recorder.record("app_open") - advanceUntilIdle() - - assertEquals(0, recorder.pendingCount()) - assertTrue(transport.events.isEmpty()) - assertTrue(breadcrumbs.snapshot().isEmpty()) - } - - @Test - fun telemetryRetainsColdStartEventsUntilConsentLoads() = runTest { - val backing = fakePrefs(diagnosticsEnabled = true) - val preferenceGate = CompletableDeferred() - val delayedPreferences = object : PreferencesRepository by backing { - override val preferences = flow { - preferenceGate.await() - emitAll(backing.preferences) - } - } - val transport = RecordingDiagnosticsTransport() - val recorder = TelemetryRecorder( - preferencesRepository = delayedPreferences, - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 20, - ) - runCurrent() - - recorder.record("app_open") - assertEquals(1, recorder.pendingCount()) - preferenceGate.complete(Unit) - runCurrent() - assertTrue(recorder.flush().isSuccess) - - assertEquals(listOf("app_open"), transport.events.single().map { it.name }) - } - - @Test - fun telemetryBuffersAndFlushesWhenEnabled() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = true) - val transport = RecordingDiagnosticsTransport() - val recorder = TelemetryRecorder( - preferencesRepository = preferences, - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 2, - maxBufferSize = 10, - ) - advanceUntilIdle() - recorder.record("one") - recorder.record("two") - advanceUntilIdle() - assertEquals(1, transport.events.size) - assertEquals(listOf("one", "two"), transport.events.single().map { it.name }) - } - - @Test - fun telemetryFlushesSparseEventsAfterTheInterval() = runTest { - val transport = RecordingDiagnosticsTransport() - val recorder = TelemetryRecorder( - preferencesRepository = fakePrefs(diagnosticsEnabled = true), - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 20, - flushIntervalMillis = 1_000, - ) - advanceUntilIdle() - recorder.record("sparse") - - advanceTimeBy(999) - runCurrent() - assertTrue(transport.eventBatches.isEmpty()) - advanceTimeBy(1) - runCurrent() - assertEquals(listOf("sparse"), transport.events.single().map { it.name }) - } - - @Test - fun telemetryCoalescesAutomaticRetriesWithBackoff() = runTest { - val transport = RecordingDiagnosticsTransport().apply { - eventsResult = Result.failure(IllegalStateException("offline")) - } - val recorder = TelemetryRecorder( - preferencesRepository = fakePrefs(diagnosticsEnabled = true), - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 1, - flushIntervalMillis = 10_000, - retryBackoffMillis = 1_000, - automaticRetryCount = 1, - ) - advanceUntilIdle() - recorder.record("retry") - runCurrent() - assertEquals(1, transport.eventBatches.size) - - advanceTimeBy(999) - runCurrent() - assertEquals(1, transport.eventBatches.size) - advanceTimeBy(1) - runCurrent() - assertEquals(2, transport.eventBatches.size) - advanceTimeBy(10_000) - runCurrent() - assertEquals(2, transport.eventBatches.size) - } - - @Test - fun telemetryFlushesAtMostFiftyEventsPerBatch() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = true) - val transport = RecordingDiagnosticsTransport() - val recorder = TelemetryRecorder( - preferencesRepository = preferences, - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 101, - maxBufferSize = 100, - ) - advanceUntilIdle() - repeat(75) { recorder.record("event-$it") } - - assertTrue(recorder.flush().isSuccess) - assertEquals(listOf(50, 25), transport.eventBatches.map { it.events.size }) - assertTrue(transport.eventBatches.all { it.events.size <= TelemetryRecorder.MaxEventsPerBatch }) - } - - @Test - fun telemetrySplitsBatchesByEscapedRequestBytes() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = true) - val requestBodies = mutableListOf() - val transport = HttpDiagnosticsTransport( - baseUrl = "https://diag.example", - ingestKey = "secret", - appVersion = "1.0", - platform = "Test", - installIdProvider = { "test-install" }, - post = { _, _, body -> - requestBodies += body - val id = Regex(""""batchId":"([^"]+)"""").find(body)?.groupValues?.get(1) - PlatformHttpResponse(202, """{"ok":true,"id":"$id"}""") - }, - ) - val recorder = TelemetryRecorder( - preferencesRepository = preferences, - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 101, - maxBufferSize = 100, - ) - advanceUntilIdle() - val properties = LinkedHashMap().apply { - repeat(MaxDiagnosticProperties) { index -> - put("key-$index-${"\u0001".repeat(40)}", "\u0001".repeat(MaxDiagnosticPropertyValueBytes)) - } - } - repeat(50) { index -> - recorder.record("event-$index-${"\u0001".repeat(64)}", properties) - } - - assertTrue(recorder.flush().isSuccess) - assertTrue(requestBodies.size > 1) - assertTrue(requestBodies.all { it.encodeToByteArray().size <= DiagnosticsJson.MaxRequestBytes }) - assertEquals(50, requestBodies.sumOf { body -> "\"schemaVersion\"".toRegex().findAll(body).count() }) - } - - @Test - fun telemetrySanitizesNamesAndPropertiesToServerByteLimits() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = true) - val transport = RecordingDiagnosticsTransport() - val recorder = TelemetryRecorder( - preferencesRepository = preferences, - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 100, - ) - advanceUntilIdle() - val properties = LinkedHashMap().apply { - repeat(20) { index -> put("key-$index-${"🙂".repeat(20)}", "🙂".repeat(100)) } - } - - recorder.record("🙂".repeat(100), properties) - assertTrue(recorder.flush().isSuccess) - - val event = transport.eventBatches.single().events.single() - assertTrue(event.name.encodeToByteArray().size <= MaxDiagnosticNameBytes) - assertEquals(MaxDiagnosticProperties, event.properties.size) - assertTrue(event.properties.keys.all { it.encodeToByteArray().size <= MaxDiagnosticPropertyKeyBytes }) - assertTrue(event.properties.values.all { it.encodeToByteArray().size <= MaxDiagnosticPropertyValueBytes }) - } - - @Test - fun telemetryKeepsConcurrentRecordsWithoutExceedingItsBuffer() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = true) - val transport = RecordingDiagnosticsTransport() - val recorder = TelemetryRecorder( - preferencesRepository = preferences, - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 101, - maxBufferSize = 100, - ) - advanceUntilIdle() - - coroutineScope { - repeat(100) { index -> - launch(Dispatchers.Default) { recorder.record("event-$index") } - } - } - - assertEquals( - 100, - recorder.pendingCount() + transport.eventBatches.sumOf { it.events.size }, - ) - assertTrue(recorder.flush().isSuccess) - assertEquals(100, transport.eventBatches.sumOf { it.events.size }) - } - - @Test - fun telemetryRetryReusesBatchIdAndEvents() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = true) - val transport = RecordingDiagnosticsTransport().apply { - eventsResult = Result.failure(IllegalStateException("offline")) - } - val recorder = TelemetryRecorder( - preferencesRepository = preferences, - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 10, - ) - advanceUntilIdle() - recorder.record("one") - recorder.record("two") - - assertTrue(recorder.flush().isFailure) - val firstAttempt = transport.eventBatches.single() - transport.eventsResult = Result.success(Unit) - assertTrue(recorder.flush().isSuccess) - - assertEquals(listOf(firstAttempt, firstAttempt), transport.eventBatches) - assertEquals(0, recorder.pendingCount()) - } - - @Test - fun telemetryDoesNotRequeuePermanentlyRejectedPayload() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = true) - val transport = RecordingDiagnosticsTransport().apply { - eventsResult = Result.failure(DiagnosticsHttpException(400, "/v1/events")) - } - val recorder = TelemetryRecorder( - preferencesRepository = preferences, - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 10, - ) - advanceUntilIdle() - recorder.record("invalid") - - assertTrue(recorder.flush().isFailure) - assertEquals(0, recorder.pendingCount()) - transport.eventsResult = Result.success(Unit) - assertTrue(recorder.flush().isSuccess) - assertEquals(1, transport.eventBatches.size) - } - - @Test - fun telemetryClearsBufferWhenOptedOut() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = true) - val transport = RecordingDiagnosticsTransport() - val recorder = TelemetryRecorder( - preferencesRepository = preferences, - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - flushThreshold = 100, - ) - advanceUntilIdle() - recorder.record("queued") - assertEquals(1, recorder.pendingCount()) - preferences.setDiagnosticsEnabled(false) - advanceUntilIdle() - assertEquals(0, recorder.pendingCount()) - } - - @Test - fun crashCodecRoundTrips() { - val original = CrashReport( - id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - timestampMillis = 42L, - installId = "install", - appVersion = "1.0", - platform = "Test", - exceptionType = "IllegalStateException", - exceptionMessage = "boom\nline\\nliteral\u001fseparator", - stackTrace = "stack\ntrace\u001erecord", - breadcrumbs = listOf( - Breadcrumb("open|send", 1L, mapOf("screen:key" to "send,value|next")), - ), - diagnosticsEnabledAtCapture = true, - ) - val decoded = CrashReportCodec.decode(CrashReportCodec.encode(original)) - assertEquals(original, decoded) - assertNull(CrashReportCodec.decode(CrashReportCodec.encode(original.copy(id = "../../escape")))) - } - - @Test - fun crashCodecMigratesLegacyV1Envelope() { - val raw = listOf( - "id=bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - "ts=42", - "install=legacy-install", - "app=0.9", - "platform=Desktop", - "type=IllegalStateException", - "message=first\\nsecond", - "stack=frame one\\nframe two", - "diag=1", - "schema=1", - "crumbs=1|opened|screen:send", - ).joinToString("\u001f") - - val report = requireNotNull(CrashReportCodec.decode(raw)) - - assertEquals("first\nsecond", report.exceptionMessage) - assertEquals("frame one\nframe two", report.stackTrace) - assertEquals(true, report.diagnosticsEnabledAtCapture) - assertEquals( - listOf(Breadcrumb("opened", 1, mapOf("screen" to "send"))), - report.breadcrumbs, - ) - } - - @Test - fun crashReporterPersistsAndFlushesOnlyOptedInCrashes() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = true) - val transport = RecordingDiagnosticsTransport() - val store = InMemoryPendingCrashStore() - val reporter = CrashReporter( - store = store, - preferencesRepository = preferences, - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - appVersion = "1.0", - platform = "Test", - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - ) - reporter.startObservingPreferences() - advanceUntilIdle() - - val optedIn = reporter.capture(RuntimeException("a"), diagnosticsEnabledOverride = true) - reporter.capture(RuntimeException("b"), diagnosticsEnabledOverride = false) - assertEquals(1, store.list().size) - - reporter.flushPending() - assertEquals(1, transport.crashes.size) - assertEquals(optedIn.id, transport.crashes.single().id) - assertTrue(store.list().isEmpty()) - } - - @Test - fun crashReporterRetainsCrashWhenDeliveryIsUnavailable() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = true) - val store = InMemoryPendingCrashStore() - val reporter = CrashReporter( - store = store, - preferencesRepository = preferences, - transport = NoOpDiagnosticsTransport(), - breadcrumbs = BreadcrumbBuffer(), - appVersion = "1.0", - platform = "Test", - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - ) - reporter.capture(RuntimeException("boom"), diagnosticsEnabledOverride = true) - - reporter.flushPending() - assertEquals(1, store.list().size) - } - - @Test - fun crashReporterDropsPermanentPayloadFailuresAndStopsAfterTransientFailures() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = true) - val permanentTransport = RecordingDiagnosticsTransport().apply { - crashResult = Result.failure(DiagnosticsHttpException(400, "/v1/crashes")) - } - val permanentStore = InMemoryPendingCrashStore() - val permanentReporter = CrashReporter( - store = permanentStore, - preferencesRepository = preferences, - transport = permanentTransport, - breadcrumbs = BreadcrumbBuffer(), - appVersion = "1.0", - platform = "Test", - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - ) - permanentReporter.capture(RuntimeException("invalid"), diagnosticsEnabledOverride = true) - permanentReporter.flushPending() - assertTrue(permanentStore.list().isEmpty()) - - val transientTransport = RecordingDiagnosticsTransport().apply { - crashResult = Result.failure(IllegalStateException("offline")) - } - val transientStore = InMemoryPendingCrashStore() - val transientReporter = CrashReporter( - store = transientStore, - preferencesRepository = preferences, - transport = transientTransport, - breadcrumbs = BreadcrumbBuffer(), - appVersion = "1.0", - platform = "Test", - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - ) - repeat(2) { - transientReporter.capture(RuntimeException("offline-$it"), diagnosticsEnabledOverride = true) - } - transientReporter.flushPending() - assertEquals(1, transientTransport.crashes.size) - assertEquals(2, transientStore.list().size) - } - - @Test - fun crashReporterResolvesStartupConsentBeforeUploading() = runTest { - val transport = RecordingDiagnosticsTransport() - val store = InMemoryPendingCrashStore() - val reporter = CrashReporter( - store = store, - preferencesRepository = fakePrefs(diagnosticsEnabled = true), - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - appVersion = "1.0", - platform = "Test", - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - ) - val startupCrash = reporter.capture(RuntimeException("startup")) - assertNull(startupCrash.diagnosticsEnabledAtCapture) - - reporter.flushPending() - - assertEquals(true, transport.crashes.single().diagnosticsEnabledAtCapture) - assertEquals("test-install", transport.crashes.single().installId) - assertTrue(store.list().isEmpty()) - } - - @Test - fun crashReporterDoesNotRetroactivelyUploadAnOptedOutStartupCrash() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = false) - val transport = RecordingDiagnosticsTransport() - val store = InMemoryPendingCrashStore() - val reporter = CrashReporter( - store = store, - preferencesRepository = preferences, - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - appVersion = "1.0", - platform = "Test", - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - ) - reporter.capture(RuntimeException("startup")) - - reporter.flushPending() - assertTrue(store.list().isEmpty()) - preferences.setDiagnosticsEnabled(true) - reporter.flushPending() - - assertTrue(transport.crashes.isEmpty()) - assertTrue(store.list().isEmpty()) - } - - @Test - fun crashReporterStopsAFlushWhenTheUserOptsOut() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = true) - val firstSendStarted = CompletableDeferred() - val releaseFirstSend = CompletableDeferred() - val sentIds = mutableListOf() - val transport = object : DiagnosticsTransport { - override suspend fun sendEvents(batch: TelemetryBatch) = Result.success(Unit) - override suspend fun sendBugReport(report: BugReport) = Result.success(Unit) - override suspend fun sendCrash(report: CrashReport): Result { - sentIds += report.id - if (sentIds.size == 1) { - firstSendStarted.complete(Unit) - releaseFirstSend.await() - } - return Result.success(Unit) - } - } - val store = InMemoryPendingCrashStore() - val reporter = CrashReporter( - store = store, - preferencesRepository = preferences, - transport = transport, - breadcrumbs = BreadcrumbBuffer(), - appVersion = "1.0", - platform = "Test", - scope = TestScope(UnconfinedTestDispatcher(testScheduler)), - ) - reporter.startObservingPreferences() - advanceUntilIdle() - repeat(2) { - reporter.capture(RuntimeException("crash-$it"), diagnosticsEnabledOverride = true) - } - - val flush = launch { reporter.flushPending() } - firstSendStarted.await() - preferences.setDiagnosticsEnabled(false) - runCurrent() - releaseFirstSend.complete(Unit) - flush.join() - - assertEquals(1, sentIds.size) - assertTrue(store.list().isEmpty()) - } - @Test fun bugReportRequiresWhatAndExpected() = runTest { - val preferences = fakePrefs() val service = BugReportService( - preferencesRepository = preferences, + preferencesRepository = fakePrefs(), transport = RecordingDiagnosticsTransport(), breadcrumbs = BreadcrumbBuffer(), appVersion = "1.0", @@ -893,8 +231,6 @@ class DiagnosticsTest { @Test fun bugReportConvertsTransportExceptionsToFailure() = runTest { val throwingTransport = object : DiagnosticsTransport { - override suspend fun sendEvents(batch: TelemetryBatch) = Result.success(Unit) - override suspend fun sendCrash(report: CrashReport) = Result.success(Unit) override suspend fun sendBugReport(report: BugReport): Result { throw IllegalStateException("offline") } @@ -914,11 +250,10 @@ class DiagnosticsTest { } @Test - fun bugReportSubmitsWithRedactedLogsRegardlessOfDiagnostics() = runTest { - val preferences = fakePrefs(diagnosticsEnabled = false) + fun bugReportSubmitsWithRedactedLogs() = runTest { val transport = RecordingDiagnosticsTransport() val service = BugReportService( - preferencesRepository = preferences, + preferencesRepository = fakePrefs(), transport = transport, breadcrumbs = BreadcrumbBuffer(), appVersion = "1.0", @@ -981,38 +316,35 @@ class DiagnosticsTest { assertEquals(BugReportService.MaxLogBytes, service.previewLogBytes()) } - private fun fakePrefs(diagnosticsEnabled: Boolean = false) = FakePreferencesRepository( + private fun bugReport( + id: String = "b", + logs: String = "", + includeLogs: Boolean = false, + ) = BugReport( + id = id, + timestampMillis = 1L, + installId = "i", + appVersion = "1.0", + platform = "Test", + whatHappened = "w", + expected = "e", + steps = "", + contact = "", + includeLogs = includeLogs, + logs = logs, + device = DeviceSnapshot(null, null, "OS", null, null), + breadcrumbs = emptyList(), + ) + + private fun fakePrefs() = FakePreferencesRepository( AppPreferences( username = "User", receiveFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp"), themeMode = ThemeMode.System, notificationsEnabled = false, - diagnosticsEnabled = diagnosticsEnabled, diagnosticsInstallId = "test-install", ), ) private fun device() = DeviceInfo("Phone", "Pixel", "Android 15", "Wi-Fi", "90%") } - -private class InMemoryPendingCrashStore : PendingCrashStore { - private val items = linkedMapOf() - override fun write(report: CrashReport) { - items[report.id] = report - } - override fun list(): List = items.values.sortedByDescending { it.timestampMillis } - override fun delete(id: String) { - items.remove(id) - } - override fun prune(olderThanTimestampMillis: Long, maxCount: Int) { - items.values - .sortedByDescending { it.timestampMillis } - .drop(maxCount) - .map(CrashReport::id) - .forEach(items::remove) - items.values - .filter { it.timestampMillis < olderThanTimestampMillis } - .map(CrashReport::id) - .forEach(items::remove) - } -} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index 2cd18a5..7e5c5f3 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -420,33 +420,6 @@ class ViewModelsTest { assertEquals(NotificationPermission.Unsupported, viewModel.state.value.notificationPermission) } - @Test - fun settingsTogglesDiagnosticsPreference() = runTest { - Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - val preferences = preferences() - val viewModel = settingsViewModel(preferences, diagnosticsIncluded = true) - advanceUntilIdle() - assertFalse(viewModel.state.value.diagnosticsEnabled) - viewModel.setDiagnosticsEnabled(true) - advanceUntilIdle() - assertTrue(preferences.mutablePreferences.value.diagnosticsEnabled) - assertTrue(viewModel.state.value.diagnosticsEnabled) - } - - @Test - fun settingsIgnoresDiagnosticsOptInWhenExcluded() = runTest { - Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - val preferences = preferences() - val viewModel = settingsViewModel(preferences) - advanceUntilIdle() - - viewModel.setDiagnosticsEnabled(true) - advanceUntilIdle() - - assertFalse(preferences.mutablePreferences.value.diagnosticsEnabled) - assertFalse(viewModel.state.value.diagnosticsEnabled) - } - @Test fun settingsSubmitsBugReportAndClearsForm() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) @@ -993,7 +966,6 @@ class ViewModelsTest { receiveFolder = folder, themeMode = ThemeMode.System, notificationsEnabled = false, - diagnosticsEnabled = false, diagnosticsInstallId = "test-install", ), ) @@ -1005,7 +977,6 @@ class ViewModelsTest { notifications: FakeNotificationService = FakeNotificationService(), transport: DiagnosticsTransport = RecordingDiagnosticsTransport(), fileSystem: FakeFileSystemService = FakeFileSystemService(folder), - diagnosticsIncluded: Boolean = false, repository: FakeCoreGateway = FakeCoreGateway(), ) = SettingsViewModel( environment(), @@ -1023,7 +994,6 @@ class ViewModelsTest { platform = "Test", logReader = { "sample log line" }, ), - diagnosticsIncluded = diagnosticsIncluded, ) private fun receivedTransfer(id: ULong, status: TransferStatus) = Transfer( diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt index a912d6d..c8f7b55 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -210,9 +210,6 @@ class FakePreferencesRepository( override suspend fun resetReceiveFolder() = Unit override suspend fun setThemeMode(mode: ThemeMode) { mutablePreferences.value = mutablePreferences.value.copy(themeMode = mode) } override suspend fun setNotificationsEnabled(enabled: Boolean) { mutablePreferences.value = mutablePreferences.value.copy(notificationsEnabled = enabled) } - override suspend fun setDiagnosticsEnabled(enabled: Boolean) { - mutablePreferences.value = mutablePreferences.value.copy(diagnosticsEnabled = enabled) - } override suspend fun setRelaySettings(settings: RelaySettings) { mutablePreferences.value = mutablePreferences.value.copy(relaySettings = settings) } diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.jvm.kt deleted file mode 100644 index 3c5c099..0000000 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.jvm.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.vnidrop.app.diagnostics - -import java.io.File -import java.nio.charset.StandardCharsets - -actual fun createPendingCrashStore(appDataDir: String): PendingCrashStore = - JvmPendingCrashStore(appDataDir) - -private class JvmPendingCrashStore( - appDataDir: String, -) : PendingCrashStore { - private val directory = File(appDataDir, "diagnostics/crashes") - - @Synchronized - override fun write(report: CrashReport) { - if (!isValidDiagnosticId(report.id)) return - directory.mkdirs() - val target = File(directory, "${report.id}.crash") - val temporary = File(directory, ".${report.id}.tmp") - val payload = CrashReportCodec.encode(report) - temporary.writeText(payload, StandardCharsets.UTF_8) - if (!temporary.renameTo(target)) { - target.writeText(payload, StandardCharsets.UTF_8) - temporary.delete() - } - } - - @Synchronized - override fun list(): List { - if (!directory.isDirectory) return emptyList() - return directory - .listFiles { file -> file.isFile && file.name.endsWith(".crash") } - .orEmpty() - .mapNotNull { file -> - runCatching { CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8)) }.getOrNull() - } - .sortedWith( - compareByDescending { it.timestampMillis } - .thenBy { it.id }, - ) - } - - @Synchronized - override fun delete(id: String) { - if (!isValidDiagnosticId(id)) return - File(directory, "$id.crash").delete() - } - - @Synchronized - override fun prune(olderThanTimestampMillis: Long, maxCount: Int) { - require(maxCount > 0) { "maxCount must be positive" } - if (!directory.isDirectory) return - directory.listFiles { file -> file.isFile && file.name.endsWith(".tmp") } - .orEmpty() - .forEach(File::delete) - val reports = directory - .listFiles { file -> file.isFile && file.name.endsWith(".crash") } - .orEmpty() - .mapNotNull { file -> - val report = runCatching { - CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8)) - }.getOrNull() - if (report == null) { - file.delete() - null - } else { - file to report - } - } - .sortedWith( - compareByDescending> { (_, report) -> report.timestampMillis } - .thenBy { (_, report) -> report.id }, - ) - reports.forEachIndexed { index, (file, report) -> - if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) file.delete() - } - } -} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.jvm.kt deleted file mode 100644 index d762725..0000000 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.jvm.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.vnidrop.app.diagnostics - -actual fun installPlatformCrashHook(onCrash: (Throwable) -> Unit) { - val previous = Thread.getDefaultUncaughtExceptionHandler() - Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> - runCatching { onCrash(throwable) } - previous?.uncaughtException(thread, throwable) - } -} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/diagnostics/PendingCrashStoreJvmTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/diagnostics/PendingCrashStoreJvmTest.kt deleted file mode 100644 index 1ef7285..0000000 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/diagnostics/PendingCrashStoreJvmTest.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.vnidrop.app.diagnostics - -import java.io.File -import java.nio.file.Files -import java.nio.file.attribute.FileTime -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class PendingCrashStoreJvmTest { - @Test - fun replacesReportsAndPrunesOldCorruptAndTemporaryFiles() { - val root = Files.createTempDirectory("vnidrop-crash-store").toFile() - try { - val store = createPendingCrashStore(root.absolutePath) - val older = report("10000000-0000-4000-8000-000000000001", 1, "older") - val current = report("10000000-0000-4000-8000-000000000002", 2, "current") - store.write(older) - store.write(current) - store.write(current.copy(exceptionMessage = "replaced")) - - val directory = File(root, "diagnostics/crashes") - File(directory, "corrupt.crash").writeText("not a crash envelope") - File(directory, ".orphan.tmp").writeText("partial") - store.write(current.copy(id = "../../escape")) - val escapedPath = File(directory, "../../escape.crash").canonicalFile - Files.setLastModifiedTime(File(directory, "${older.id}.crash").toPath(), FileTime.fromMillis(2_000)) - Files.setLastModifiedTime(File(directory, "${current.id}.crash").toPath(), FileTime.fromMillis(1_000)) - - assertEquals( - listOf("replaced", "older"), - store.list().map(CrashReport::exceptionMessage), - ) - store.prune(olderThanTimestampMillis = 0, maxCount = 1) - - assertEquals(listOf("replaced"), store.list().map(CrashReport::exceptionMessage)) - assertFalse(File(directory, "corrupt.crash").exists()) - assertFalse(File(directory, ".orphan.tmp").exists()) - assertFalse(escapedPath.exists()) - assertTrue(directory.listFiles().orEmpty().all { it.parentFile == directory }) - } finally { - root.deleteRecursively() - } - } - - private fun report(id: String, timestampMillis: Long, message: String) = CrashReport( - id = id, - timestampMillis = timestampMillis, - installId = "install", - appVersion = "1.0", - platform = "Desktop", - exceptionType = "TestError", - exceptionMessage = message, - stackTrace = "stack", - breadcrumbs = emptyList(), - diagnosticsEnabledAtCapture = true, - ) -} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt index bf1d63a..d30a245 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -152,7 +152,6 @@ class FoundationComposeTest { onResetFolder = {}, onNotificationsChanged = {}, onOpenNotificationSettings = {}, - onDiagnosticsChanged = {}, onBugWhatChanged = {}, onBugExpectedChanged = {}, onBugStepsChanged = {}, @@ -182,7 +181,6 @@ class FoundationComposeTest { onResetFolder = {}, onNotificationsChanged = {}, onOpenNotificationSettings = {}, - onDiagnosticsChanged = {}, onBugWhatChanged = {}, onBugExpectedChanged = {}, onBugStepsChanged = {}, @@ -238,7 +236,6 @@ class FoundationComposeTest { onResetFolder = {}, onNotificationsChanged = {}, onOpenNotificationSettings = {}, - onDiagnosticsChanged = {}, onBugWhatChanged = {}, onBugExpectedChanged = {}, onBugStepsChanged = {}, @@ -291,7 +288,6 @@ class FoundationComposeTest { onResetFolder = {}, onNotificationsChanged = {}, onOpenNotificationSettings = {}, - onDiagnosticsChanged = {}, onBugWhatChanged = {}, onBugExpectedChanged = {}, onBugStepsChanged = {}, @@ -322,7 +318,6 @@ class FoundationComposeTest { onResetFolder = {}, onNotificationsChanged = {}, onOpenNotificationSettings = {}, - onDiagnosticsChanged = {}, onBugWhatChanged = {}, onBugExpectedChanged = {}, onBugStepsChanged = {}, @@ -358,7 +353,6 @@ class FoundationComposeTest { onResetFolder = {}, onNotificationsChanged = { enabled = it }, onOpenNotificationSettings = {}, - onDiagnosticsChanged = {}, onBugWhatChanged = {}, onBugExpectedChanged = {}, onBugStepsChanged = {}, @@ -390,7 +384,6 @@ class FoundationComposeTest { onResetFolder = {}, onNotificationsChanged = {}, onOpenNotificationSettings = { opened = true }, - onDiagnosticsChanged = {}, onBugWhatChanged = {}, onBugExpectedChanged = {}, onBugStepsChanged = {}, From b68d338097ef3bbb28cc357ca7cf5b0587a66bd6 Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:02:49 +0200 Subject: [PATCH 3/8] refactor(apple): remove diagnostics opt-in toggle, keep bug reports Drop the Share-diagnostics preference, its Settings toggle and the DiagnosticsBuildConfig stub. Bug reporting (NoopBugReportService) and the diagnostics install id used for bug-report correlation are retained. --- apple/Tests/SettingsModelTests.swift | 3 +-- apple/VniDrop/App/AppGraph.swift | 3 +-- apple/VniDrop/Core/AppPreferences.swift | 10 ---------- .../Features/Settings/BugReportService.swift | 5 ----- .../Features/Settings/SettingsModel.swift | 20 ++----------------- .../Features/Settings/SettingsSections.swift | 11 ---------- 6 files changed, 4 insertions(+), 48 deletions(-) diff --git a/apple/Tests/SettingsModelTests.swift b/apple/Tests/SettingsModelTests.swift index e565762..209fefe 100644 --- a/apple/Tests/SettingsModelTests.swift +++ b/apple/Tests/SettingsModelTests.swift @@ -15,8 +15,7 @@ final class SettingsModelTests: XCTestCase { preferences: preferences, notifications: LocalNotificationService(), messages: UiMessageController(), - bugReports: NoopBugReportService(), - diagnosticsIncluded: false + bugReports: NoopBugReportService() ) } diff --git a/apple/VniDrop/App/AppGraph.swift b/apple/VniDrop/App/AppGraph.swift index 9cd7f97..bc41554 100644 --- a/apple/VniDrop/App/AppGraph.swift +++ b/apple/VniDrop/App/AppGraph.swift @@ -24,8 +24,7 @@ final class AppGraph: ObservableObject { fallback: AppPreferencesDefaults( username: dependencies.environment.defaultUsername, receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(), - themeMode: .system, - diagnosticsEnabled: false + themeMode: .system ) ) self.approvalCoordinator = ApprovalCoordinator( diff --git a/apple/VniDrop/Core/AppPreferences.swift b/apple/VniDrop/Core/AppPreferences.swift index df56a2c..ea62b80 100644 --- a/apple/VniDrop/Core/AppPreferences.swift +++ b/apple/VniDrop/Core/AppPreferences.swift @@ -120,7 +120,6 @@ struct AppPreferences: Equatable { var username: String var receiveFolder: ReceiveFolder var themeMode: ThemeMode - var diagnosticsEnabled: Bool var diagnosticsInstallId: String var relayConfiguration: RelayConfiguration } @@ -129,7 +128,6 @@ struct AppPreferencesDefaults { let username: String let receiveFolder: ReceiveFolder let themeMode: ThemeMode - var diagnosticsEnabled: Bool = false } @MainActor @@ -145,7 +143,6 @@ final class AppPreferencesRepository: ObservableObject { static let receiveFolderValue = "receive_folder_value" static let receiveFolderDisplayName = "receive_folder_display_name" static let themeMode = "theme_mode" - static let diagnosticsEnabled = "diagnostics_enabled" static let diagnosticsInstallId = "diagnostics_install_id" static let relayConfiguration = "relay_configuration" } @@ -160,13 +157,11 @@ final class AppPreferencesRepository: ObservableObject { let username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder) let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode - let diagnostics = defaults.object(forKey: Key.diagnosticsEnabled) as? Bool ?? fallback.diagnosticsEnabled let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? "" return AppPreferences( username: username, receiveFolder: folder, themeMode: themeMode, - diagnosticsEnabled: diagnostics, diagnosticsInstallId: installId, relayConfiguration: resolveRelayConfiguration(defaults) ) @@ -219,11 +214,6 @@ final class AppPreferencesRepository: ObservableObject { reload() } - func setDiagnosticsEnabled(_ enabled: Bool) { - defaults.set(enabled, forKey: Key.diagnosticsEnabled) - reload() - } - func setRelayConfiguration(_ configuration: RelayConfiguration) { guard let encoded = try? JSONEncoder().encode(configuration) else { return } defaults.set(encoded, forKey: Key.relayConfiguration) diff --git a/apple/VniDrop/Features/Settings/BugReportService.swift b/apple/VniDrop/Features/Settings/BugReportService.swift index 17be101..57915e3 100644 --- a/apple/VniDrop/Features/Settings/BugReportService.swift +++ b/apple/VniDrop/Features/Settings/BugReportService.swift @@ -24,8 +24,3 @@ struct NoopBugReportService: BugReportService { } func previewLogBytes() async -> Int { 0 } } - -/// Whether the diagnostics stack is compiled in (mirrors DiagnosticsBuildConfig). -enum DiagnosticsBuildConfig { - static let included = false -} diff --git a/apple/VniDrop/Features/Settings/SettingsModel.swift b/apple/VniDrop/Features/Settings/SettingsModel.swift index 8158926..c3f26d0 100644 --- a/apple/VniDrop/Features/Settings/SettingsModel.swift +++ b/apple/VniDrop/Features/Settings/SettingsModel.swift @@ -44,7 +44,6 @@ struct SettingsState: Equatable { var supportsCustomReceiveFolders = true var themeMode: ThemeMode = .system var notificationPermission: NotificationPermission = .notDetermined - var diagnosticsEnabled = false var relayMode: RelayPreferenceMode = .automatic var relayURLs: [String] = [] var relayValidationError: RelayConfigurationValidationError? @@ -76,7 +75,7 @@ struct SettingsState: Equatable { && lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders && lhs.themeMode == rhs.themeMode && lhs.notificationPermission == rhs.notificationPermission - && lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion + && lhs.appVersion == rhs.appVersion && lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs && lhs.relayValidationError == rhs.relayValidationError && lhs.relayConfigurationIsDirty == rhs.relayConfigurationIsDirty @@ -111,7 +110,6 @@ final class SettingsModel: ObservableObject { private let notifications: LocalNotificationService private let messages: UiMessageController private let bugReports: BugReportService - private let diagnosticsIncluded: Bool private var usernamePersistTask: Task? private var hasLocalUsernameDraft = false @@ -126,8 +124,7 @@ final class SettingsModel: ObservableObject { preferences: AppPreferencesRepository, notifications: LocalNotificationService, messages: UiMessageController, - bugReports: BugReportService, - diagnosticsIncluded: Bool = DiagnosticsBuildConfig.included + bugReports: BugReportService ) { self.environment = environment self.deviceInfoProvider = deviceInfoProvider @@ -137,7 +134,6 @@ final class SettingsModel: ObservableObject { self.notifications = notifications self.messages = messages self.bugReports = bugReports - self.diagnosticsIncluded = diagnosticsIncluded self.state = SettingsState( supportsCustomReceiveFolders: fileSystemService.supportsCustomReceiveFolders, appVersion: environment.appVersion @@ -151,7 +147,6 @@ final class SettingsModel: ObservableObject { self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username self.state.receiveFolder = folder self.state.themeMode = prefs.themeMode - self.state.diagnosticsEnabled = prefs.diagnosticsEnabled if !self.hasRelayConfigurationDraft { self.state.relayMode = prefs.relayConfiguration.mode self.state.relayURLs = prefs.relayConfiguration.relayURLs @@ -230,17 +225,6 @@ final class SettingsModel: ObservableObject { } } - func setDiagnosticsEnabled(_ enabled: Bool) { - if !diagnosticsIncluded { return } - Task { - preferences.setDiagnosticsEnabled(enabled) - messages.show(UiMessage( - text: .resource(enabled ? L10n.Diagnostics.enabledMessage : L10n.Diagnostics.disabledMessage), - tone: .success - )) - } - } - // MARK: - Network func setRelayMode(_ mode: RelayPreferenceMode) { diff --git a/apple/VniDrop/Features/Settings/SettingsSections.swift b/apple/VniDrop/Features/Settings/SettingsSections.swift index e8c5a1a..c14c105 100644 --- a/apple/VniDrop/Features/Settings/SettingsSections.swift +++ b/apple/VniDrop/Features/Settings/SettingsSections.swift @@ -415,17 +415,6 @@ struct AboutSettings: View { Label(String(localized: L10n.About.privacyPolicyLabel), systemSymbol: .handRaised) } } - - if DiagnosticsBuildConfig.included { - Section { - Toggle(isOn: Binding( - get: { model.state.diagnosticsEnabled }, - set: { model.setDiagnosticsEnabled($0) } - )) { - Text(String(localized: L10n.Diagnostics.title)) - } - } - } } } From 3e1837861038e4ded25c3a842e6207bd3dd14218 Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:03:12 +0200 Subject: [PATCH 4/8] refactor(diagnostics-api): drop telemetry and crash ingestion, keep bug reports Remove the /v1/events and /v1/crashes routes, their normalizers and storage paths, and simplify retention to the bugs table. Add a migration dropping the now-unused event_batches and crashes tables, and regenerate worker types. --- services/diagnostics-api/README.md | 47 ++- .../migrations/0003_drop_telemetry_tables.sql | 10 + services/diagnostics-api/src/index.ts | 50 +--- services/diagnostics-api/src/input.ts | 160 ---------- services/diagnostics-api/src/storage.ts | 202 +------------ services/diagnostics-api/test/input.test.ts | 100 ++----- services/diagnostics-api/test/worker.test.ts | 273 +++--------------- .../diagnostics-api/worker-configuration.d.ts | 5 +- services/diagnostics-api/wrangler.jsonc | 5 - 9 files changed, 122 insertions(+), 730 deletions(-) create mode 100644 services/diagnostics-api/migrations/0003_drop_telemetry_tables.sql diff --git a/services/diagnostics-api/README.md b/services/diagnostics-api/README.md index 3f48f08..ac8ab90 100644 --- a/services/diagnostics-api/README.md +++ b/services/diagnostics-api/README.md @@ -1,13 +1,13 @@ # VniDrop diagnostics API -Cloudflare Worker for ingesting batched telemetry, crash reports, and user-submitted -bug reports. D1 stores searchable metadata; R2 stores larger stack traces and logs. +Cloudflare Worker for ingesting user-submitted bug reports. D1 stores searchable +metadata; R2 stores the larger attached logs. The service is designed for modest traffic and low operating cost: -- one D1 row is written per telemetry batch, not per event; -- crash stacks and bug logs are stored in R2 instead of D1; -- request and batch limits reject oversized work before storage writes; +- one D1 row is written per bug report; +- bug logs are stored in R2 instead of D1; +- request limits reject oversized work before storage writes; - an hourly scheduled cleanup and an R2 lifecycle rule enforce retention; - no Queue, Durable Object, or KV resources are required. @@ -35,15 +35,13 @@ X-VniDrop-Install-Id: |--------|------|------| | `GET` | `/live` | process liveness; does not touch storage | | `GET` | `/health` | authenticated readiness; checks required configuration and the D1 schema | -| `POST` | `/v1/events` | `{ batchId, installId, appVersion?, platform?, events: [...] }` | -| `POST` | `/v1/crashes` | app crash payload | | `POST` | `/v1/bugs` | app bug-report payload | -Batch and report IDs are client-generated UUIDs. A client must reuse the same ID -when retrying so D1 can acknowledge the request without storing it twice. +Report IDs are client-generated UUIDs. A client must reuse the same ID when +retrying so D1 can acknowledge the request without storing it twice. -Accepted reports return `202`. Defaults are a 262,144-byte request limit and at -most 50 events per batch. Cloudflare rate-limit bindings allow 30 requests per +Accepted reports return `202`. The default is a 262,144-byte request limit. +Cloudflare rate-limit bindings allow 30 requests per installation and 120 requests per source, per ingest route, per minute. Source limits run before shared-key verification so rejected traffic is bounded too. These counters are eventually consistent and local to a Cloudflare location, so @@ -153,11 +151,11 @@ migrations to the isolated local database assigned to each test file. `RETENTION_DAYS` defaults to 90. The `17 * * * *` cron trigger runs cleanup at 17 minutes past every hour. Cleanup works in bounded batches: it deletes each expired report's referenced R2 object before deleting that exact D1 row. The R2 -lifecycle rule is an independent backstop for stack and log objects, including -objects left behind by a partial ingest failure. Each scheduled run can remove -8,000 event batches and 7,200 rows from each report table while staying below -D1's per-invocation query ceiling. Later hourly runs continue any backlog. -Reaching the cap emits a structured warning with the remaining expired-row counts; +lifecycle rule is an independent backstop for log objects, including objects left +behind by a partial ingest failure. Each scheduled run can remove 7,200 bug rows +while staying below D1's per-invocation query ceiling. Later hourly runs continue +any backlog. +Reaching the cap emits a structured warning with the remaining expired-row count; alert on that warning because retention is necessarily best-effort during sustained distributed abuse. @@ -179,23 +177,20 @@ vnidrop.diagnostics.ingestKey= Both the endpoint and key are required. When both are empty the app uses its offline-safe no-op transport; configuring only one fails the Gradle build. -`vnidrop.diagnostics.included=false` disables -automatic telemetry and crash upload, but a configured endpoint can still accept -an explicit user-submitted bug report. Treat the app-side key as an abuse-control -token with the limitations described above. +`vnidrop.diagnostics.included=false` routes bug reports to that no-op transport +(never sent); a configured endpoint accepts an explicit user-submitted bug report. +Treat the app-side key as an abuse-control token with the limitations described +above. ## Reading reports ```bash -npx wrangler d1 execute vnidrop-diagnostics --remote \ - --command "SELECT id, exception_type, platform, occurred_at FROM crashes ORDER BY occurred_at DESC LIMIT 20" - npx wrangler d1 execute vnidrop-diagnostics --remote \ --command "SELECT id, what_happened, status, occurred_at FROM bugs WHERE status = 'open' ORDER BY occurred_at DESC LIMIT 20" ``` -R2 object keys use `crashes///stack.txt` and -`bugs///logs.txt`. The unique attempt segment prevents a retry -from overwriting an already accepted object before D1 detects the duplicate. +R2 object keys use `bugs///logs.txt`. The unique attempt segment +prevents a retry from overwriting an already accepted object before D1 detects +the duplicate. There is no public administration endpoint; inspect reports through authenticated Cloudflare tools or a future Access-protected dashboard. diff --git a/services/diagnostics-api/migrations/0003_drop_telemetry_tables.sql b/services/diagnostics-api/migrations/0003_drop_telemetry_tables.sql new file mode 100644 index 0000000..946eff8 --- /dev/null +++ b/services/diagnostics-api/migrations/0003_drop_telemetry_tables.sql @@ -0,0 +1,10 @@ +-- Telemetry and crash auto-reporting were removed from the app; only user-initiated +-- bug reports remain. Drop the now-unused ingestion tables and their indexes. +DROP INDEX IF EXISTS idx_event_batches_received; +DROP INDEX IF EXISTS idx_event_batches_install; +DROP TABLE IF EXISTS event_batches; + +DROP INDEX IF EXISTS idx_crashes_received; +DROP INDEX IF EXISTS idx_crashes_fingerprint; +DROP INDEX IF EXISTS idx_crashes_install; +DROP TABLE IF EXISTS crashes; diff --git a/services/diagnostics-api/src/index.ts b/services/diagnostics-api/src/index.ts index f83b5fe..d22c11f 100644 --- a/services/diagnostics-api/src/index.ts +++ b/services/diagnostics-api/src/index.ts @@ -1,20 +1,15 @@ import { normalizeBug, - normalizeCrash, - normalizeEvents, readJsonObject, } from "./input"; import { type DiagnosticsEnv, runRetention, storeBug, - storeCrash, - storeEvents, } from "./storage"; const DEFAULT_MAX_BODY_BYTES = 262_144; const HARD_MAX_BODY_BYTES = 1_048_576; -const DEFAULT_MAX_EVENTS = 50; export default { async fetch(request: Request, env: DiagnosticsEnv, _ctx: ExecutionContext): Promise { @@ -72,41 +67,6 @@ export default { if (!parsed.ok) return json({ error: parsed.error }, parsed.status, requestId); switch (url.pathname) { - case "/v1/events": { - const maxEvents = boundedPositiveInt(env.MAX_EVENTS_PER_BATCH, DEFAULT_MAX_EVENTS, 1, 100); - const normalized = normalizeEvents(parsed.value, maxEvents); - if (!normalized.ok) { - return json({ error: normalized.error }, normalized.status, requestId); - } - const result = await storeEvents(normalized.value, env); - return json( - { - ok: true, - id: result.id, - stored: result.stored, - duplicate: result.duplicate, - }, - 202, - requestId, - ); - } - case "/v1/crashes": { - const normalized = normalizeCrash(parsed.value); - if (!normalized.ok) { - return json({ error: normalized.error }, normalized.status, requestId); - } - const result = await storeCrash(normalized.value, env); - return json( - { - ok: true, - id: result.id, - fingerprint: result.fingerprint, - duplicate: result.duplicate, - }, - 202, - requestId, - ); - } case "/v1/bugs": { const normalized = normalizeBug(parsed.value); if (!normalized.ok) { @@ -159,12 +119,6 @@ async function readiness(env: DiagnosticsEnv, requestId: string): Promise { diff --git a/services/diagnostics-api/src/input.ts b/services/diagnostics-api/src/input.ts index d5c8983..e4ea0b4 100644 --- a/services/diagnostics-api/src/input.ts +++ b/services/diagnostics-api/src/input.ts @@ -10,13 +10,6 @@ export type InputResult = { ok: true; value: T } | InputFailure; export type NormalizedProperties = Record; -export interface NormalizedEvent { - name: string; - timestampMillis: number; - properties: NormalizedProperties; - schemaVersion: 1; -} - export interface NormalizedBreadcrumb { name: string; timestampMillis: number; @@ -31,28 +24,6 @@ export interface NormalizedDevice { batteryLevel: string; } -export interface NormalizedEventsPayload { - batchId: string; - installId: string; - appVersion: string; - platform: string; - events: NormalizedEvent[]; -} - -export interface NormalizedCrashPayload { - id: string; - installId: string; - appVersion: string; - platform: string; - exceptionType: string; - exceptionMessage: string; - stackTrace: string; - occurredAt: number; - diagnosticsEnabledAtCapture: boolean; - breadcrumbs: NormalizedBreadcrumb[]; - schemaVersion: 1; -} - export interface NormalizedBugPayload { id: string; installId: string; @@ -164,119 +135,6 @@ export async function readJsonObject( return success(parsed); } -export function normalizeEvents( - body: JsonObject, - maxEvents = 50, -): InputResult { - if (!isPlainObject(body)) return failure(400, "invalid_body"); - if (!Number.isSafeInteger(maxEvents) || maxEvents <= 0) { - throw new RangeError("maxEvents must be a positive safe integer"); - } - - const batchId = idField(body, ["batchId", "batch_id"], "invalid_batch_id"); - if (!batchId.ok) return batchId; - const installId = installIdField(body); - if (!installId.ok) return installId; - const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version"); - if (!appVersion.ok) return appVersion; - const platform = stringField(body, ["platform"], 40, "invalid_platform"); - if (!platform.ok) return platform; - - const batchSchema = schemaVersion(body); - if (!batchSchema.ok) return batchSchema; - const rawEvents = pick(body, ["events"]); - if (!Array.isArray(rawEvents)) return failure(400, "invalid_events"); - if (rawEvents.length === 0) return failure(400, "empty_batch"); - if (rawEvents.length > maxEvents) return failure(400, "batch_too_large"); - - const events: NormalizedEvent[] = []; - for (const rawEvent of rawEvents) { - const event = normalizeEvent(rawEvent); - if (!event.ok) return event; - events.push(event.value); - } - - return success({ - batchId: batchId.value, - installId: installId.value, - appVersion: appVersion.value, - platform: platform.value, - events, - }); -} - -export function normalizeCrash(body: JsonObject): InputResult { - if (!isPlainObject(body)) return failure(400, "invalid_body"); - - const id = idField(body, ["id"], "invalid_id"); - if (!id.ok) return id; - const installId = installIdField(body); - if (!installId.ok) return installId; - const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version"); - if (!appVersion.ok) return appVersion; - const platform = stringField(body, ["platform"], 40, "invalid_platform"); - if (!platform.ok) return platform; - const exceptionType = stringField( - body, - ["exceptionType", "exception_type"], - 120, - "invalid_exception_type", - true, - true, - ); - if (!exceptionType.ok) return exceptionType; - const exceptionMessage = stringField( - body, - ["exceptionMessage", "exception_message"], - 2_000, - "invalid_exception_message", - true, - ); - if (!exceptionMessage.ok) return exceptionMessage; - const stackTrace = stringField( - body, - ["stackTrace", "stack_trace"], - 32_000, - "invalid_stack_trace", - true, - ); - if (!stackTrace.ok) return stackTrace; - const occurredAt = timestampField( - body, - ["timestampMillis", "timestamp_millis", "occurredAt", "occurred_at"], - ); - if (!occurredAt.ok) return occurredAt; - const diagnosticsEnabled = booleanField( - body, - [ - "diagnosticsEnabledAtCapture", - "diagnostics_enabled_at_capture", - "diagnostics_enabled", - ], - "invalid_diagnostics_enabled", - true, - ); - if (!diagnosticsEnabled.ok) return diagnosticsEnabled; - const version = schemaVersion(body); - if (!version.ok) return version; - const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"])); - if (!breadcrumbs.ok) return breadcrumbs; - - return success({ - id: id.value, - installId: installId.value, - appVersion: appVersion.value, - platform: platform.value, - exceptionType: exceptionType.value, - exceptionMessage: exceptionMessage.value, - stackTrace: stackTrace.value, - occurredAt: occurredAt.value, - diagnosticsEnabledAtCapture: diagnosticsEnabled.value, - breadcrumbs: breadcrumbs.value, - schemaVersion: version.value, - }); -} - export function normalizeBug(body: JsonObject): InputResult { if (!isPlainObject(body)) return failure(400, "invalid_body"); @@ -341,24 +199,6 @@ export function normalizeBug(body: JsonObject): InputResult { - if (!isPlainObject(raw)) return failure(400, "invalid_event"); - const name = stringField(raw, ["name"], 64, "invalid_event", true, true); - if (!name.ok) return name; - const timestamp = timestampField(raw, ["timestampMillis", "timestamp_millis", "ts"]); - if (!timestamp.ok) return failure(400, "invalid_event"); - const properties = normalizeProperties(pick(raw, ["properties", "props"]), "invalid_event"); - if (!properties.ok) return properties; - const version = schemaVersion(raw); - if (!version.ok) return version; - return success({ - name: name.value, - timestampMillis: timestamp.value, - properties: properties.value, - schemaVersion: version.value, - }); -} - function normalizeBreadcrumbs(raw: unknown | typeof MISSING): InputResult { if (raw === MISSING) return success([]); if (!Array.isArray(raw)) return failure(400, "invalid_breadcrumbs"); diff --git a/services/diagnostics-api/src/storage.ts b/services/diagnostics-api/src/storage.ts index 30fd2fe..d5df546 100644 --- a/services/diagnostics-api/src/storage.ts +++ b/services/diagnostics-api/src/storage.ts @@ -1,12 +1,7 @@ -import type { - NormalizedBugPayload, - NormalizedCrashPayload, - NormalizedEventsPayload, -} from "./input"; +import type { NormalizedBugPayload } from "./input"; export type DiagnosticsEnv = Cloudflare.Env & { INGEST_KEY?: string; - AE?: AnalyticsEngineDataset; }; export interface StoreResult { @@ -15,130 +10,6 @@ export interface StoreResult { stored: number; } -export async function storeEvents( - payload: NormalizedEventsPayload, - env: DiagnosticsEnv, -): Promise { - const result = await env.DB.prepare( - `INSERT INTO event_batches (id, received_at, install_id, app_version, platform, event_count, payload_json) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO NOTHING`, - ) - .bind( - payload.batchId, - Date.now(), - payload.installId, - payload.appVersion, - payload.platform, - payload.events.length, - JSON.stringify(payload.events), - ) - .run(); - const duplicate = result.meta.changes === 0; - if (!duplicate && env.AE) { - try { - for (const event of payload.events) { - env.AE.writeDataPoint({ - blobs: [ - event.name, - payload.platform, - payload.appVersion, - payload.installId, - JSON.stringify(event.properties), - payload.batchId, - ], - doubles: [event.timestampMillis, event.schemaVersion], - indexes: [payload.installId], - }); - } - } catch (error) { - // D1 remains the durable source of truth if the optional analytics index is unavailable. - console.error( - JSON.stringify({ - message: "failed to index diagnostics event batch", - batchId: payload.batchId, - error: error instanceof Error ? error.message : String(error), - }), - ); - } - } - return { - id: payload.batchId, - duplicate, - stored: duplicate ? 0 : payload.events.length, - }; -} - -export async function storeCrash( - payload: NormalizedCrashPayload, - env: DiagnosticsEnv, -): Promise { - const database = env.DB.withSession("first-primary"); - const existing = await database - .prepare("SELECT fingerprint FROM crashes WHERE id = ?") - .bind(payload.id) - .first<{ fingerprint: string }>(); - if (existing) { - return { id: payload.id, duplicate: true, stored: 0, fingerprint: existing.fingerprint }; - } - - const fingerprint = await crashFingerprint(payload.exceptionType, payload.stackTrace); - const stackKey = payload.stackTrace - ? `crashes/${payload.id}/${crypto.randomUUID()}/stack.txt` - : null; - - if (stackKey) { - await env.BLOBS.put(stackKey, payload.stackTrace, { - httpMetadata: { contentType: "text/plain; charset=utf-8" }, - customMetadata: { installId: payload.installId, fingerprint }, - }); - } - - try { - const result = await database - .prepare( - `INSERT INTO crashes ( - id, received_at, occurred_at, install_id, app_version, platform, - exception_type, exception_message, fingerprint, diagnostics_enabled, - stack_r2_key, breadcrumbs_json, schema_version - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO NOTHING`, - ) - .bind( - payload.id, - Date.now(), - payload.occurredAt, - payload.installId, - payload.appVersion, - payload.platform, - payload.exceptionType, - payload.exceptionMessage, - fingerprint, - payload.diagnosticsEnabledAtCapture ? 1 : 0, - stackKey, - JSON.stringify(payload.breadcrumbs), - payload.schemaVersion, - ) - .run(); - const duplicate = result.meta.changes === 0; - if (duplicate) { - const stored = await database - .prepare("SELECT fingerprint FROM crashes WHERE id = ?") - .bind(payload.id) - .first<{ fingerprint: string }>(); - if (!stored) throw new Error("duplicate crash row was not readable"); - if (stackKey) await deleteAttemptBlob(env, stackKey); - return { id: payload.id, duplicate: true, stored: 0, fingerprint: stored.fingerprint }; - } - return { id: payload.id, duplicate: false, stored: 1, fingerprint }; - } catch (error) { - if (stackKey) { - await deleteAttemptBlob(env, stackKey); - } - throw error; - } -} - export async function storeBug( payload: NormalizedBugPayload, env: DiagnosticsEnv, @@ -201,26 +72,22 @@ export async function storeBug( export async function runRetention(env: DiagnosticsEnv): Promise { const retentionDays = boundedPositiveInt(env.RETENTION_DAYS, 90, 1, 3_650); const cutoff = Date.now() - retentionDays * 86_400_000; - // Eight full passes plus the backlog check use at most 43 of D1's 50 queries per invocation. + // Eight passes plus the backlog check stay well within D1's 50 queries per invocation. for (let pass = 0; pass < 8; pass += 1) { const hasFullBatch = await runRetentionPass(env, cutoff); if (!hasFullBatch) return; } - const [events, crashes, bugs] = await env.DB.batch<{ count: number }>([ - env.DB.prepare("SELECT COUNT(*) AS count FROM event_batches WHERE received_at < ?").bind( - cutoff, - ), - env.DB.prepare("SELECT COUNT(*) AS count FROM crashes WHERE received_at < ?").bind(cutoff), - env.DB.prepare("SELECT COUNT(*) AS count FROM bugs WHERE received_at < ?").bind(cutoff), - ]); + const bugs = await env.DB.prepare( + "SELECT COUNT(*) AS count FROM bugs WHERE received_at < ?", + ) + .bind(cutoff) + .first<{ count: number }>(); console.warn( JSON.stringify({ message: "diagnostics retention reached its per-run pass limit", cutoff, backlog: { - eventBatches: events.results[0]?.count ?? 0, - crashes: crashes.results[0]?.count ?? 0, - bugs: bugs.results[0]?.count ?? 0, + bugs: bugs?.count ?? 0, }, }), ); @@ -228,28 +95,17 @@ export async function runRetention(env: DiagnosticsEnv): Promise { async function runRetentionPass(env: DiagnosticsEnv, cutoff: number): Promise { const reportBatchSize = 900; - const eventBatchSize = 1_000; - const [crashes, bugs] = await Promise.all([ - expiredBlobRows(env.DB, "crashes", "stack_r2_key", cutoff, reportBatchSize), - expiredBlobRows(env.DB, "bugs", "logs_r2_key", cutoff, reportBatchSize), - ]); + const bugs = await expiredBlobRows(env.DB, "bugs", "logs_r2_key", cutoff, reportBatchSize); - const blobKeys = [...crashes, ...bugs] + const blobKeys = bugs .map((row) => row.blobKey) .filter((key): key is string => key !== null); for (let offset = 0; offset < blobKeys.length; offset += 1_000) { await env.BLOBS.delete(blobKeys.slice(offset, offset + 1_000)); } - const statements = [retentionStatement(env.DB, "event_batches", cutoff, eventBatchSize)]; - if (crashes.length > 0) statements.push(deleteRowsById(env.DB, "crashes", crashes)); - if (bugs.length > 0) statements.push(deleteRowsById(env.DB, "bugs", bugs)); - const [eventsResult] = await env.DB.batch(statements); - return ( - eventsResult.meta.changes === eventBatchSize || - crashes.length === reportBatchSize || - bugs.length === reportBatchSize - ); + if (bugs.length > 0) await deleteRowsById(env.DB, "bugs", bugs).run(); + return bugs.length === reportBatchSize; } interface ExpiredBlobRow { @@ -259,8 +115,8 @@ interface ExpiredBlobRow { async function expiredBlobRows( database: D1Database, - table: "crashes" | "bugs", - column: "stack_r2_key" | "logs_r2_key", + table: "bugs", + column: "logs_r2_key", cutoff: number, batchSize: number, ): Promise { @@ -277,25 +133,9 @@ async function expiredBlobRows( return result.results; } -function retentionStatement( - database: D1Database, - table: "event_batches" | "crashes" | "bugs", - cutoff: number, - batchSize: number, -): D1PreparedStatement { - return database - .prepare( - `DELETE FROM ${table} - WHERE rowid IN ( - SELECT rowid FROM ${table} WHERE received_at < ? ORDER BY received_at LIMIT ? - )`, - ) - .bind(cutoff, batchSize); -} - function deleteRowsById( database: D1Database, - table: "crashes" | "bugs", + table: "bugs", rows: ExpiredBlobRow[], ): D1PreparedStatement { return database @@ -303,18 +143,6 @@ function deleteRowsById( .bind(JSON.stringify(rows.map((row) => row.id))); } -async function crashFingerprint(exceptionType: string, stackTrace: string): Promise { - const topFrames = stackTrace - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .slice(0, 4) - .join("\n"); - const bytes = new TextEncoder().encode(`${exceptionType}\n${topFrames}`); - const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); - return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); -} - async function deleteAttemptBlob(env: DiagnosticsEnv, key: string): Promise { try { await env.BLOBS.delete(key); diff --git a/services/diagnostics-api/test/input.test.ts b/services/diagnostics-api/test/input.test.ts index 6390cfe..5d343f2 100644 --- a/services/diagnostics-api/test/input.test.ts +++ b/services/diagnostics-api/test/input.test.ts @@ -4,8 +4,6 @@ import { MAX_DEVICE_JSON_BYTES, MAX_LOG_BYTES, normalizeBug, - normalizeCrash, - normalizeEvents, readJsonObject, } from "../src/input"; @@ -57,7 +55,7 @@ describe("readJsonObject", () => { }); it("requires application/json with a UTF-8 charset", async () => { - const missing = new Request("https://example.test/v1/events", { + const missing = new Request("https://example.test/v1/bugs", { method: "POST", body: "{}", }); @@ -108,18 +106,6 @@ describe("readJsonObject", () => { describe("normalizers", () => { it("preserves false booleans and rejects their string representation", () => { - const crash = crashPayload(false); - const normalizedCrash = normalizeCrash(crash); - expect(normalizedCrash.ok).toBe(true); - if (normalizedCrash.ok) { - expect(normalizedCrash.value.diagnosticsEnabledAtCapture).toBe(false); - } - expect(normalizeCrash(crashPayload("false"))).toEqual({ - ok: false, - status: 400, - error: "invalid_diagnostics_enabled", - }); - const bug = bugPayload({ include_logs: false, logs: "discard me" }); const normalizedBug = normalizeBug(bug); expect(normalizedBug.ok).toBe(true); @@ -171,42 +157,31 @@ describe("normalizers", () => { }); it("requires stable report IDs and validates supplied IDs and schema versions", () => { - const result = normalizeEvents({ - events: [{ name: "opened", ts: 1, schema_version: 1 }], - }); - expect(result).toEqual({ ok: false, status: 400, error: "invalid_batch_id" }); - const legacyInstall = normalizeEvents({ - batch_id: ID, - install_id: "legacy-test-install", - events: [{ name: "opened", ts: 1 }], - }); - expect(legacyInstall.ok && legacyInstall.value.installId).toBe("legacy-test-install"); - const missingInstall = normalizeEvents({ - batch_id: ID, - events: [{ name: "opened", ts: 1 }], - }); - expect(missingInstall.ok && missingInstall.value.installId).toBe("unknown"); - expect( - normalizeEvents({ - batch_id: ID, - install_id: "bad\u0000install", - events: [{ name: "opened", ts: 1 }], - }), - ).toEqual({ ok: false, status: 400, error: "invalid_install_id" }); + const missingId = normalizeBug(bugPayload({ id: undefined })); + expect(missingId).toEqual({ ok: false, status: 400, error: "invalid_id" }); - expect( - normalizeEvents({ - batch_id: "not-a-uuid", - events: [{ name: "opened", timestamp_millis: 1 }], - }), - ).toEqual({ ok: false, status: 400, error: "invalid_batch_id" }); - expect( - normalizeEvents({ - batch_id: ID, - install_id: INSTALL_ID, - events: [{ name: "opened", timestamp_millis: 1, schema_version: 2 }], - }), - ).toEqual({ ok: false, status: 400, error: "unsupported_schema_version" }); + const legacyInstall = normalizeBug(bugPayload({ install_id: "legacy-test-install" })); + expect(legacyInstall.ok && legacyInstall.value.installId).toBe("legacy-test-install"); + + const missingInstall = normalizeBug(bugPayload({ install_id: undefined })); + expect(missingInstall.ok && missingInstall.value.installId).toBe("unknown"); + + expect(normalizeBug(bugPayload({ install_id: "bad\u0000install" }))).toEqual({ + ok: false, + status: 400, + error: "invalid_install_id", + }); + + expect(normalizeBug(bugPayload({ id: "not-a-uuid" }))).toEqual({ + ok: false, + status: 400, + error: "invalid_id", + }); + expect(normalizeBug(bugPayload({ schema_version: 2 }))).toEqual({ + ok: false, + status: 400, + error: "unsupported_schema_version", + }); }); }); @@ -215,7 +190,7 @@ function chunkedJsonRequest( contentType = "application/json; charset=utf-8", contentLength?: string, ): Request { - return new Request("https://example.test/v1/events", { + return new Request("https://example.test/v1/bugs", { method: "POST", headers: { "content-type": contentType, @@ -230,24 +205,8 @@ function chunkedJsonRequest( }); } -function crashPayload(diagnosticsEnabled: unknown): Record { - return { - id: ID, - install_id: INSTALL_ID, - app_version: "1.0", - platform: "test", - exception_type: "ExampleError", - exception_message: "message", - stack_trace: "stack", - occurred_at: 1, - diagnostics_enabled: diagnosticsEnabled, - schema_version: 1, - breadcrumbs: [], - }; -} - function bugPayload(overrides: Record = {}): Record { - return { + const payload: Record = { id: ID, install_id: INSTALL_ID, app_version: "1.0", @@ -263,4 +222,9 @@ function bugPayload(overrides: Record = {}): Record { expect(unknown.status).toBe(404); const unauthorized = await exports.default.fetch( - jsonRequest("/v1/events", eventPayload(uuid(1)), "wrong-key"), + jsonRequest("/v1/bugs", bugPayload(uuid(1), "logs"), "wrong-key"), ); expect(unauthorized.status).toBe(401); expect(await unauthorized.json()).toEqual({ error: "unauthorized" }); @@ -44,7 +34,7 @@ describe("diagnostics Worker", () => { ); const preflight = await exports.default.fetch( - new Request("https://diagnostics.test/v1/events", { method: "OPTIONS" }), + new Request("https://diagnostics.test/v1/bugs", { method: "OPTIONS" }), ); expect(preflight.status).toBe(204); expect(preflight.headers.get("access-control-allow-origin")).toBeNull(); @@ -68,7 +58,7 @@ describe("diagnostics Worker", () => { const context = createExecutionContext(); const response = await worker.fetch( - jsonRequest("/v1/events", eventPayload(uuid(3)), "wrong-key", "198.51.100.3"), + jsonRequest("/v1/bugs", bugPayload(uuid(3), "logs"), "wrong-key", "198.51.100.3"), limitedEnv, context, ); @@ -80,7 +70,7 @@ describe("diagnostics Worker", () => { }); it("returns structured errors for invalid bodies and asynchronous storage failures", async () => { - const invalid = await exports.default.fetch(jsonRequest("/v1/events", null)); + const invalid = await exports.default.fetch(jsonRequest("/v1/bugs", null)); expect(invalid.status).toBe(400); expect(await invalid.json()).toEqual({ error: "invalid_body" }); @@ -92,6 +82,8 @@ describe("diagnostics Worker", () => { }; const rejectingDatabase = { prepare: () => statement, + batch: async () => Promise.reject(rejection), + withSession: () => ({ prepare: () => statement }), } as unknown as D1Database; const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase }; @@ -106,7 +98,7 @@ describe("diagnostics Worker", () => { const ingestContext = createExecutionContext(); const failedIngest = await worker.fetch( - jsonRequest("/v1/events", eventPayload(uuid(2)), env.INGEST_KEY, "198.51.100.2"), + jsonRequest("/v1/bugs", bugPayload(uuid(2), "logs"), env.INGEST_KEY, "198.51.100.2"), rejectingEnv, ingestContext, ); @@ -114,101 +106,6 @@ describe("diagnostics Worker", () => { expect(await failedIngest.json()).toEqual({ error: "internal" }); }); - it("deduplicates event batches using the client batch ID", async () => { - const id = uuid(10); - const first = await exports.default.fetch(jsonRequest("/v1/events", eventPayload(id))); - const second = await exports.default.fetch(jsonRequest("/v1/events", eventPayload(id))); - - expect(first.status).toBe(202); - expect(await first.json()).toMatchObject({ - ok: true, - id, - stored: 1, - duplicate: false, - }); - expect(second.status).toBe(202); - expect(await second.json()).toMatchObject({ - ok: true, - id, - stored: 0, - duplicate: true, - }); - - const row = await env.DB.prepare( - "SELECT event_count AS eventCount, payload_json AS payloadJson FROM event_batches WHERE id = ?", - ) - .bind(id) - .first<{ eventCount: number; payloadJson: string }>(); - expect(row?.eventCount).toBe(1); - expect(JSON.parse(row?.payloadJson ?? "null")).toEqual([ - { - name: "app_open", - timestampMillis: 1, - properties: { screen: "home" }, - schemaVersion: 1, - }, - ]); - }); - - it("keeps D1 idempotency when the optional analytics index is enabled", async () => { - const points: AnalyticsEngineDataPoint[] = []; - const analytics = { - writeDataPoint: (point: AnalyticsEngineDataPoint) => points.push(point), - } as AnalyticsEngineDataset; - const analyticsEnv: DiagnosticsEnv = { ...env, AE: analytics }; - const payload: NormalizedEventsPayload = { - batchId: uuid(11), - installId: INSTALL_ID, - appVersion: "1.0", - platform: "test", - events: [ - { - name: "indexed", - timestampMillis: 1, - properties: {}, - schemaVersion: 1, - }, - ], - }; - - expect(await storeEvents(payload, analyticsEnv)).toMatchObject({ duplicate: false, stored: 1 }); - expect(await storeEvents(payload, analyticsEnv)).toMatchObject({ duplicate: true, stored: 0 }); - expect(points).toHaveLength(1); - }); - - it("keeps the accepted crash blob when a duplicate request arrives", async () => { - const id = uuid(20); - const first = await exports.default.fetch( - jsonRequest("/v1/crashes", crashPayload(id, "first stack")), - ); - const second = await exports.default.fetch( - jsonRequest("/v1/crashes", crashPayload(id, "second stack")), - ); - - expect(first.status).toBe(202); - const firstBody = await first.json<{ fingerprint: string }>(); - expect(firstBody).toMatchObject({ ok: true, id, duplicate: false }); - expect(second.status).toBe(202); - const secondBody = await second.json<{ fingerprint: string }>(); - expect(secondBody).toMatchObject({ ok: true, id, duplicate: true }); - - const row = await env.DB.prepare( - `SELECT stack_r2_key AS stackKey, breadcrumbs_json AS breadcrumbsJson, - fingerprint - FROM crashes WHERE id = ?`, - ) - .bind(id) - .first<{ stackKey: string; breadcrumbsJson: string; fingerprint: string }>(); - expect(row?.stackKey).toMatch(new RegExp(`^crashes/${id}/[0-9a-f-]+/stack\\.txt$`)); - expect(firstBody.fingerprint).toBe(row?.fingerprint); - expect(secondBody.fingerprint).toBe(row?.fingerprint); - expect(JSON.parse(row?.breadcrumbsJson ?? "null")).toEqual([]); - expect(await (await env.BLOBS.get(row?.stackKey ?? "missing"))?.text()).toBe("first stack"); - - const objects = await env.BLOBS.list({ prefix: `crashes/${id}/` }); - expect(objects.objects.map((object) => object.key)).toEqual([row?.stackKey]); - }); - it("stores bug metadata as JSON and cleans the duplicate upload attempt", async () => { const id = uuid(30); const payload = bugPayload(id, "first logs"); @@ -252,9 +149,7 @@ describe("diagnostics Worker", () => { }); it("acknowledges known report IDs without touching an unavailable blob store", async () => { - const crash = normalizedCrash(uuid(31), "accepted stack"); const bug = normalizedBug(uuid(32), "accepted logs"); - const firstCrash = await storeCrash(crash, env); await storeBug(bug, env); let blobWrites = 0; const unavailableBlobs = { @@ -265,14 +160,6 @@ describe("diagnostics Worker", () => { } as unknown as R2Bucket; const unavailableEnv: DiagnosticsEnv = { ...env, BLOBS: unavailableBlobs }; - await expect( - storeCrash({ ...crash, stackTrace: "retry stack" }, unavailableEnv), - ).resolves.toEqual({ - id: crash.id, - duplicate: true, - stored: 0, - fingerprint: firstCrash.fingerprint, - }); await expect( storeBug({ ...bug, logs: "retry logs" }, unavailableEnv), ).resolves.toEqual({ id: bug.id, duplicate: true, stored: 0 }); @@ -286,59 +173,27 @@ describe("diagnostics Worker", () => { async () => Promise.reject(rejection), ); const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase }; - const crashId = uuid(33); const bugId = uuid(34); - const crashResponse = await worker.fetch( - jsonRequest("/v1/crashes", crashPayload(crashId, "orphan candidate"), env.INGEST_KEY, "198.51.100.33"), - rejectingEnv, - createExecutionContext(), - ); const bugResponse = await worker.fetch( jsonRequest("/v1/bugs", bugPayload(bugId, "orphan candidate"), env.INGEST_KEY, "198.51.100.34"), rejectingEnv, createExecutionContext(), ); - expect(crashResponse.status).toBe(500); - expect(await crashResponse.json()).toEqual({ error: "internal" }); expect(bugResponse.status).toBe(500); expect(await bugResponse.json()).toEqual({ error: "internal" }); - expect((await env.BLOBS.list({ prefix: `crashes/${crashId}/` })).objects).toEqual([]); expect((await env.BLOBS.list({ prefix: `bugs/${bugId}/` })).objects).toEqual([]); }); it("removes expired rows and their exact R2 objects while preserving current data", async () => { - const oldEventId = uuid(40); - const oldCrashId = uuid(41); const oldBugId = uuid(42); - const currentEventId = uuid(43); - const oldCrashKey = `crashes/${oldCrashId}/retention/stack.txt`; + const currentBugId = uuid(43); const oldBugKey = `bugs/${oldBugId}/retention/logs.txt`; const oldReceivedAt = Date.now() - 100 * 86_400_000; - await Promise.all([ - env.BLOBS.put(oldCrashKey, "expired crash"), - env.BLOBS.put(oldBugKey, "expired logs"), - ]); + await env.BLOBS.put(oldBugKey, "expired logs"); await env.DB.batch([ - env.DB.prepare( - `INSERT INTO event_batches - (id, received_at, install_id, app_version, platform, event_count, payload_json) - VALUES (?, ?, ?, '', '', 1, '[]')`, - ).bind(oldEventId, oldReceivedAt, INSTALL_ID), - env.DB.prepare( - `INSERT INTO event_batches - (id, received_at, install_id, app_version, platform, event_count, payload_json) - VALUES (?, ?, ?, '', '', 1, '[]')`, - ).bind(currentEventId, Date.now(), INSTALL_ID), - env.DB.prepare( - `INSERT INTO crashes - (id, received_at, occurred_at, install_id, app_version, platform, - exception_type, exception_message, fingerprint, diagnostics_enabled, - stack_r2_key, breadcrumbs_json, schema_version) - VALUES (?, ?, ?, ?, '', '', 'Error', '', 'fingerprint', 1, ?, '[]', 1)`, - ).bind(oldCrashId, oldReceivedAt, oldReceivedAt, INSTALL_ID, oldCrashKey), env.DB.prepare( `INSERT INTO bugs (id, received_at, occurred_at, install_id, app_version, platform, @@ -346,28 +201,28 @@ describe("diagnostics Worker", () => { device_json, breadcrumbs_json, status, schema_version) VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', ?, '{}', '[]', 'open', 1)`, ).bind(oldBugId, oldReceivedAt, oldReceivedAt, INSTALL_ID, oldBugKey), + env.DB.prepare( + `INSERT INTO bugs + (id, received_at, occurred_at, install_id, app_version, platform, + what_happened, expected, steps, contact, logs_r2_key, + device_json, breadcrumbs_json, status, schema_version) + VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', NULL, '{}', '[]', 'open', 1)`, + ).bind(currentBugId, Date.now(), Date.now(), INSTALL_ID), ]); await runRetention(env); - for (const [table, id] of [ - ["event_batches", oldEventId], - ["crashes", oldCrashId], - ["bugs", oldBugId], - ] as const) { - const row = await env.DB.prepare(`SELECT id FROM ${table} WHERE id = ?`).bind(id).first(); - expect(row).toBeNull(); - } - expect(await env.BLOBS.head(oldCrashKey)).toBeNull(); + expect( + await env.DB.prepare("SELECT id FROM bugs WHERE id = ?").bind(oldBugId).first(), + ).toBeNull(); expect(await env.BLOBS.head(oldBugKey)).toBeNull(); expect( - await env.DB.prepare("SELECT id FROM event_batches WHERE id = ?").bind(currentEventId).first(), + await env.DB.prepare("SELECT id FROM bugs WHERE id = ?").bind(currentBugId).first(), ).not.toBeNull(); }); it("bounds a full retention run below the D1 per-invocation query limit", async () => { let queryCount = 0; - let batchCalls = 0; const blobDeleteBatchSizes: number[] = []; const rows = Array.from({ length: 900 }, (_, index) => ({ id: `expired-${index}`, @@ -381,17 +236,17 @@ describe("diagnostics Worker", () => { queryCount += 1; return d1Result(rows, 0); }, + run: async () => { + queryCount += 1; + return d1Result([], rows.length); + }, + first: async () => { + queryCount += 1; + return { count: rows.length }; + }, }; return statement; }, - batch: async (statements: D1PreparedStatement[]) => { - batchCalls += 1; - queryCount += statements.length; - if (batchCalls === 9) { - return statements.map(() => d1Result([{ count: 1 }], 0)); - } - return statements.map((_, index) => d1Result([], index === 0 ? 1_000 : 900)); - }, } as unknown as D1Database; const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined); const blobs = { @@ -406,9 +261,10 @@ describe("diagnostics Worker", () => { warning.mockRestore(); } - expect(queryCount).toBe(43); - expect(blobDeleteBatchSizes).toHaveLength(16); - expect(Math.max(...blobDeleteBatchSizes)).toBe(1_000); + // Eight passes (one SELECT + one DELETE each) plus the final backlog SELECT. + expect(queryCount).toBe(17); + expect(blobDeleteBatchSizes).toHaveLength(8); + expect(Math.max(...blobDeleteBatchSizes)).toBe(900); }); it("converges an expired report backlog across bounded retention runs", async () => { @@ -424,13 +280,13 @@ describe("diagnostics Worker", () => { CROSS JOIN digits AS ones WHERE thousands.value * 1000 + hundreds.value * 100 + tens.value * 10 + ones.value < 7201 ) - INSERT INTO crashes ( + INSERT INTO bugs ( id, received_at, occurred_at, install_id, app_version, platform, - exception_type, exception_message, fingerprint, diagnostics_enabled, - stack_r2_key, breadcrumbs_json, schema_version + what_happened, expected, steps, contact, logs_r2_key, + device_json, breadcrumbs_json, status, schema_version ) SELECT 'retention-backlog-' || printf('%04d', value), ?, ?, ?, '', '', - 'Error', '', 'fingerprint-' || value, 0, NULL, '[]', 1 + 'failed', 'worked', '', '', NULL, '{}', '[]', 'open', 1 FROM sequence`, ) .bind(oldReceivedAt, oldReceivedAt, INSTALL_ID) @@ -440,13 +296,13 @@ describe("diagnostics Worker", () => { try { await runRetention(env); const afterFirstRun = await env.DB.prepare( - "SELECT COUNT(*) AS count FROM crashes WHERE id LIKE 'retention-backlog-%'", + "SELECT COUNT(*) AS count FROM bugs WHERE id LIKE 'retention-backlog-%'", ).first<{ count: number }>(); expect(afterFirstRun?.count).toBe(1); await runRetention(env); const afterSecondRun = await env.DB.prepare( - "SELECT COUNT(*) AS count FROM crashes WHERE id LIKE 'retention-backlog-%'", + "SELECT COUNT(*) AS count FROM bugs WHERE id LIKE 'retention-backlog-%'", ).first<{ count: number }>(); expect(afterSecondRun?.count).toBe(0); } finally { @@ -482,39 +338,6 @@ function healthRequest(key = env.INGEST_KEY, source = "198.51.100.1"): Request { }); } -function eventPayload(batchId: string): Record { - return { - batchId, - installId: INSTALL_ID, - appVersion: "1.0", - platform: "test", - events: [ - { - name: "app_open", - timestampMillis: 1, - properties: { screen: "home" }, - schemaVersion: 1, - }, - ], - }; -} - -function crashPayload(id: string, stackTrace: string): Record { - return { - id, - installId: INSTALL_ID, - appVersion: "1.0", - platform: "test", - exceptionType: "TestError", - exceptionMessage: "failed", - stackTrace, - timestampMillis: 2, - diagnosticsEnabledAtCapture: true, - breadcrumbs: [], - schemaVersion: 1, - }; -} - function bugPayload(id: string, logs: string): Record { return { id, @@ -540,22 +363,6 @@ function bugPayload(id: string, logs: string): Record { }; } -function normalizedCrash(id: string, stackTrace: string): NormalizedCrashPayload { - return { - id, - installId: INSTALL_ID, - appVersion: "1.0", - platform: "test", - exceptionType: "TestError", - exceptionMessage: "failed", - stackTrace, - occurredAt: 2, - diagnosticsEnabledAtCapture: true, - breadcrumbs: [], - schemaVersion: 1, - }; -} - function normalizedBug(id: string, logs: string): NormalizedBugPayload { return { id, diff --git a/services/diagnostics-api/worker-configuration.d.ts b/services/diagnostics-api/worker-configuration.d.ts index 0481e2f..0d0ca95 100644 --- a/services/diagnostics-api/worker-configuration.d.ts +++ b/services/diagnostics-api/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: e2953336d40e96c4b5125a5de01f7cac) +// Generated by Wrangler by running `wrangler types` (hash: 9c27cfd9219ba0d4efc153db78cad4c3) // Runtime types generated with workerd@1.20260708.1 2026-07-14 nodejs_compat interface __BaseEnv_Env { BLOBS: R2Bucket; @@ -7,7 +7,6 @@ interface __BaseEnv_Env { INSTALL_RATE_LIMITER: RateLimit; SOURCE_RATE_LIMITER: RateLimit; MAX_BODY_BYTES: "262144"; - MAX_EVENTS_PER_BATCH: "50"; RETENTION_DAYS: "90"; } declare namespace Cloudflare { @@ -21,7 +20,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/services/diagnostics-api/wrangler.jsonc b/services/diagnostics-api/wrangler.jsonc index 665477c..1f2a09d 100644 --- a/services/diagnostics-api/wrangler.jsonc +++ b/services/diagnostics-api/wrangler.jsonc @@ -47,13 +47,8 @@ }, }, ], - // Optional: bind Analytics Engine as `AE` when event volume justifies it. - // "analytics_engine_datasets": [ - // { "binding": "AE", "dataset": "vnidrop_events" }, - // ], "vars": { "MAX_BODY_BYTES": "262144", - "MAX_EVENTS_PER_BATCH": "50", "RETENTION_DAYS": "90", }, } From ac6e8375600fc11899404d99f904e9b4371f7424 Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:03:29 +0200 Subject: [PATCH 5/8] docs: describe bug reports instead of telemetry Update the site privacy policy (no telemetry/analytics, bug-report only, v1.2) and the README/apple README to reflect that only user-submitted bug reports remain. --- README.md | 10 +++--- apple/README.md | 6 ++-- docs/app/privacy/page.tsx | 67 ++++++++++++++++----------------------- 3 files changed, 36 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 395adf5..2b33bf9 100644 --- a/README.md +++ b/README.md @@ -127,18 +127,18 @@ people, especially when using **Anyone with this transfer**. - Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android, Windows, and Linux - Strict custom HTTPS relay profiles with safe apply and rollback -- Opt-in diagnostics with transfer contents, invitations, and file paths - excluded +- Optional user-submitted bug reports with transfer contents, invitations, and + file paths excluded ## Privacy by design -- **No hosted transfer copy.** VniDrop does not upload file contents to its - diagnostics service or a VniDrop storage bucket. +- **No hosted transfer copy.** VniDrop does not upload file contents to a bug-report + service or a VniDrop storage bucket. - **Encrypted in transit.** Iroh connections are authenticated and encrypted end to end, including when a relay is needed. - **Local control.** Transfer history and sharing state stay on the device. - **Sensitive invitations.** An invitation can grant access, so it is - deliberately excluded from product logs and diagnostics. + deliberately excluded from product logs and bug reports. - **Explicit access.** Approval is required by default, and stopping a share removes access immediately. diff --git a/apple/README.md b/apple/README.md index 008ece4..6048bb7 100644 --- a/apple/README.md +++ b/apple/README.md @@ -114,7 +114,7 @@ The Rust core (iroh network stack) links `SystemConfiguration`, `Security`, and Screens mirror the Compose UI in `shared/`. Two deliberate simplifications: - Empty-state Lottie animations are rendered as SF Symbols (no `lottie-ios` dependency); swap in `lottie-ios` if exact-parity animation is required. -- The full diagnostics/telemetry stack (`diagnostics/*`) is stubbed behind - `BugReportService` / `DiagnosticsBuildConfig` and lands in a later phase; the UI - hides the diagnostics toggle when not compiled in. +- Bug reporting is stubbed behind `BugReportService` (`NoopBugReportService`) and + a real transport lands in a later phase. There is no telemetry or crash + auto-reporting. ``` diff --git a/docs/app/privacy/page.tsx b/docs/app/privacy/page.tsx index 575983e..944690d 100644 --- a/docs/app/privacy/page.tsx +++ b/docs/app/privacy/page.tsx @@ -3,14 +3,14 @@ import type { Metadata } from "next"; export const metadata: Metadata = { title: "Privacy policy", description: - "How VniDrop handles transfers, local app data, optional diagnostics, bug reports, and website visits.", + "How VniDrop handles transfers, local app data, optional bug reports, and website visits.", }; const sections = [ ["scope", "Scope"], ["transfers", "Transfers"], ["local-data", "Local data"], - ["diagnostics", "Diagnostics"], + ["bug-reports", "Bug reports"], ["website", "Website"], ["permissions", "Permissions"], ["providers", "Service providers"], @@ -29,9 +29,9 @@ export default function PrivacyPage() {

Privacy Policy

This policy explains what moves between devices, what stays local, and what is sent - only when you choose to share diagnostics or a bug report. + only when you choose to submit a bug report.

-

Effective July 16, 2026 · Version 1.1

+

Effective August 2, 2026 · Version 1.2

@@ -56,7 +56,7 @@ export default function PrivacyPage() {

VniDrop has no user accounts and does not upload your transfer to a VniDrop file store. Files travel over an authenticated, end-to-end encrypted connection. - Product diagnostics are opt-in; a bug report is sent only when you submit one. + VniDrop has no telemetry or analytics; a bug report is sent only when you submit one.

@@ -64,7 +64,7 @@ export default function PrivacyPage() {

Scope and who “VniDrop” means

This policy covers the official VniDrop website, the VniDrop applications for - Android, iOS, macOS, Windows, and Linux, and the diagnostics service configured by + Android, iOS, macOS, Windows, and Linux, and the bug-report service configured by the official project. For an official release, VniDrop’s data controller is the individual publisher named in the applicable app-store listing. In this policy, “VniDrop,” “we,” and “us” also include the maintainers acting on that publisher’s @@ -72,7 +72,7 @@ export default function PrivacyPage() {

VniDrop is open-source software. A build distributed or operated by someone else - may use different networking infrastructure, diagnostics settings, or website + may use different networking infrastructure, bug-report settings, or website hosting. That distributor is responsible for explaining its own practices.

@@ -117,9 +117,9 @@ export default function PrivacyPage() {
  • device identity and networking keys used to establish secure connections;
  • active shares, transfer history, receiver requests, progress, and status;
  • -
  • app preferences, including access and diagnostics choices;
  • +
  • app preferences, including access choices;
  • download destinations and locally managed transfer data; and
  • -
  • an anonymous installation identifier used only for diagnostics correlation.
  • +
  • an anonymous installation identifier used only for bug-report correlation.

This information remains until you remove the relevant history, stop or delete a @@ -129,33 +129,27 @@ export default function PrivacyPage() {

-
-

Optional diagnostics and bug reports

-

Automatic product diagnostics

+
+

Optional bug reports

- Official releases indicate in the app settings whether automatic product - diagnostics are included. When included, automatic usage events and crash reports - are disabled until you enable “Share diagnostics.” If enabled, VniDrop may send an - anonymous installation ID, app version, platform, sparse event names and properties, - crash type and message, a redacted stack trace, timestamps, and recent in-app - breadcrumbs. You can turn this off at any time; doing so also removes pending local - crash reports. + VniDrop has no automatic telemetry, usage analytics, or crash auto-reporting. + Nothing is sent to a bug-report service unless you explicitly submit a report.

User-submitted bug reports

- A bug report is separate from the diagnostics toggle and is sent only when you press - submit. It can contain what you say happened, what you expected, reproduction steps, - an optional contact email, app and platform versions, an anonymous installation ID, - device name and model, operating system, network and battery information, recent - breadcrumbs, and optional recent logs. You can exclude logs before submitting. + A bug report is sent only when you press submit. It can contain what you say + happened, what you expected, reproduction steps, an optional contact email, app and + platform versions, an anonymous installation ID, device name and model, operating + system, network and battery information, and optional recent logs. You can exclude + logs before submitting.

Data deliberately excluded

- Automatic diagnostics are designed to exclude transfer contents, invitations, and - file paths. Before diagnostic text or optional logs are sent, VniDrop applies rules - intended to redact invitation tokens, endpoint identifiers, absolute paths, file and - content URIs, and platform document identifiers. No redaction system is perfect, so - review anything you type into a bug report and avoid including secrets. + Bug reports are designed to exclude transfer contents, invitations, and file paths. + Before optional logs are sent, VniDrop applies rules intended to redact invitation + tokens, endpoint identifiers, absolute paths, file and content URIs, and platform + document identifiers. No redaction system is perfect, so review anything you type + into a bug report and avoid including secrets.

@@ -223,7 +217,7 @@ export default function PrivacyPage() {
Cloudflare
Proxies website requests and provides DNS, security, and abuse controls. When - the optional diagnostics service is configured, it uses Cloudflare Workers, D1, + the optional bug-report service is configured, it uses Cloudflare Workers, D1, and R2.
@@ -300,11 +294,7 @@ export default function PrivacyPage() { Until you delete them, clear app data, or uninstall - Pending local crash reports - Up to 30 days and 20 reports; deleted when diagnostics is disabled - - - Server diagnostics and bug reports + Server bug reports The current project configuration is 90 days, with scheduled deletion @@ -317,7 +307,7 @@ export default function PrivacyPage() {

Operational backups, provider logs, and deletion backlogs may persist briefly beyond the stated period where necessary for security, integrity, or legal obligations. If - the production diagnostics retention configuration changes, this policy should be + the production bug-report retention configuration changes, this policy should be updated to match it.

@@ -325,7 +315,6 @@ export default function PrivacyPage() {

Your choices and rights

    -
  • Enable or disable “Share diagnostics” in VniDrop settings.
  • Submit a bug report only when you choose, omit contact information, and exclude logs. @@ -343,7 +332,7 @@ export default function PrivacyPage() {

    Depending on where you live, privacy law may provide rights to access, correct, delete, restrict, or object to processing of personal information. Because VniDrop - has no account and automatic diagnostics use an anonymous installation ID, we may + has no account and bug reports use an anonymous installation ID, we may not be able to connect a server record to you without additional information. Use the contact method below and provide only what is needed to locate your submission.

    @@ -353,7 +342,7 @@ export default function PrivacyPage() {

    Security

    VniDrop uses authenticated end-to-end encrypted connections, content verification, - deny-by-default share access, bounded diagnostics payloads, redaction, and safe file + deny-by-default share access, bounded bug-report payloads, redaction, and safe file publishing that avoids silently replacing an existing file. No system can guarantee absolute security. Keep invitations private, verify receiver names, keep your device updated, and stop sharing when a transfer is finished. From eb8498168d4167accafc96d5f6cd54b9d7b9d342 Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:29:45 +0200 Subject: [PATCH 6/8] fix(shared): avoid java accessor shadowing when reading app.properties In a Gradle Kotlin DSL script `java` resolves to the Java plugin extension accessor, so `java.util.Properties` failed script compilation with "Unresolved reference 'util'", breaking the shared/Linux/Windows KMP jobs. Import java.util.Properties and use it unqualified, matching the root build. --- shared/build.gradle.kts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index e240401..63d9f05 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -14,6 +14,7 @@ import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.jvm.tasks.Jar import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import java.util.Properties abstract class VerifyHostCargoTaskSelection : DefaultTask() { @get:Input @@ -112,7 +113,7 @@ val generateDiagnosticsBuildConfig by tasks.registering { // App-wide public constants (privacy policy URL, …) from the shared app.properties, // so Apple and KMP read one source of truth instead of hardcoding values. -val appProperties = java.util.Properties().apply { +val appProperties = Properties().apply { rootProject.file("app.properties").inputStream().use(::load) } val privacyPolicyUrl: String = appProperties.getProperty("PRIVACY_POLICY_URL")?.trim().orEmpty() From 7cb2270d5671f94b54a32d4820e64399d64f9b91 Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:29:46 +0200 Subject: [PATCH 7/8] ci(apple): add Xcode Cloud post-clone script Xcode Cloud only checks out the repo, so ci_post_clone.sh installs swiftlint, xcodegen and bun, downloads the prebuilt core (vnidrop.xcframework + Vnidrop.swift) from the matching GitHub Release asset, and generates the project via localization, version/app config codegen and xcodegen. Rust is never built on Xcode Cloud. --- ci_scripts/README.md | 40 +++++++++++++++++++++ ci_scripts/ci_post_clone.sh | 72 +++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 ci_scripts/README.md create mode 100755 ci_scripts/ci_post_clone.sh diff --git a/ci_scripts/README.md b/ci_scripts/README.md new file mode 100644 index 0000000..459fb21 --- /dev/null +++ b/ci_scripts/README.md @@ -0,0 +1,40 @@ +# Xcode Cloud CI scripts + +Xcode Cloud runs the scripts in this directory around each build. Only +`ci_post_clone.sh` is used today; add `ci_pre_xcodebuild.sh` / +`ci_post_xcodebuild.sh` here if later steps are needed. + +## What `ci_post_clone.sh` does + +The Xcode project (`apple/VniDrop.xcodeproj`) and its generated inputs are **not** +committed — they are produced by XcodeGen, localization, and the Rust core build. +Since Xcode Cloud only checks out the repository, the post-clone script: + +1. installs `swiftlint`, `xcodegen`, and `bun`; +2. **downloads the prebuilt core** (`vnidrop.xcframework` + `Vnidrop.swift`) from + the matching GitHub Release asset `VnidropCore-.zip` — Xcode Cloud + never builds Rust; +3. runs localization + version/app config codegen and `xcodegen generate` + (equivalent to `make apple-project` without the `apple-core` step). + +The core asset for version `X.Y.Z` must be published on the `vX.Y.Z` release +before an Xcode Cloud build for that version runs (see +`apple/scripts/package-core.sh` and `.github/workflows/apple-release.yml`). + +### Overrides (env vars, optional) + +| Variable | Default | Purpose | +|----------|---------|---------| +| `VNIDROP_CORE_REPO` | `sudosylabs/vnidrop` | Release repository to download the core from | +| `VNIDROP_CORE_TAG` | `v` | Release tag holding the core asset | + +## Workflow configuration (App Store Connect) + +The workflow itself (product, scheme, triggers, actions) is configured in App +Store Connect, not in the repository. Point it at: + +- **Project:** `apple/VniDrop.xcodeproj` (generated by the post-clone script) +- **Scheme:** `VniDrop` (App Store / TestFlight target; shared, see `apple/project.yml`) + +Archive actions use the release Rust profile via the published core asset; build +and test actions reuse the same prebuilt core. diff --git a/ci_scripts/ci_post_clone.sh b/ci_scripts/ci_post_clone.sh new file mode 100755 index 0000000..27f06e8 --- /dev/null +++ b/ci_scripts/ci_post_clone.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# +# Xcode Cloud post-clone step. +# +# The Apple Xcode project is generated (XcodeGen) and gitignored, and it links a +# prebuilt Rust XCFramework plus generated localization/config files. Xcode Cloud +# only checks out the repository, so this script: +# 1. installs the non-Rust build tooling (swiftlint, xcodegen, bun); +# 2. downloads the prebuilt core (vnidrop.xcframework + Vnidrop.swift) from the +# matching GitHub Release asset — we never build Rust here; +# 3. reproduces `make apple-project` minus the Rust `apple-core` step. +# +# Xcode Cloud runs this from the `ci_scripts` directory; CI_PRIMARY_REPOSITORY_PATH +# points at the checked-out repository root. +set -euo pipefail + +REPO_ROOT="${CI_PRIMARY_REPOSITORY_PATH:-$(cd "$(dirname "$0")/.." && pwd)}" +cd "$REPO_ROOT" + +echo "==> Installing build tooling (Homebrew)" +# swiftlint: enforced by a build phase (fails the build if missing). +# xcodegen: generates apple/VniDrop.xcodeproj from apple/project.yml. +brew install swiftlint xcodegen + +echo "==> Installing Bun (localization generator)" +if ! command -v bun >/dev/null 2>&1; then + curl -fsSL https://bun.sh/install | bash +fi +export BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}" +export PATH="$BUN_INSTALL/bin:$PATH" + +# --- Prebuilt core: download instead of building Rust ------------------------- +# The Apple core (xcframework + UniFFI bindings) is published as a release asset +# by apple/scripts/package-core.sh. See docs at the top of that script. +VERSION="$(packaging/version/resolve-version.sh product)" +CORE_REPO="${VNIDROP_CORE_REPO:-sudosylabs/vnidrop}" +CORE_TAG="${VNIDROP_CORE_TAG:-v$VERSION}" +CORE_ZIP="VnidropCore-$VERSION.zip" +CORE_BASE_URL="https://github.com/$CORE_REPO/releases/download/$CORE_TAG" + +PKG_DIR="$REPO_ROOT/apple/VnidropCore" +DOWNLOAD_DIR="$(mktemp -d)" +trap 'rm -rf "$DOWNLOAD_DIR"' EXIT + +echo "==> Downloading prebuilt core $CORE_ZIP from $CORE_REPO@$CORE_TAG" +curl -fsSL "$CORE_BASE_URL/$CORE_ZIP" -o "$DOWNLOAD_DIR/$CORE_ZIP" +curl -fsSL "$CORE_BASE_URL/$CORE_ZIP.sha256" -o "$DOWNLOAD_DIR/$CORE_ZIP.sha256" + +echo "==> Verifying checksum" +( + cd "$DOWNLOAD_DIR" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum --check "$CORE_ZIP.sha256" + else + shasum -a 256 --check "$CORE_ZIP.sha256" + fi +) + +echo "==> Installing core into apple/VnidropCore" +unzip -q -o "$DOWNLOAD_DIR/$CORE_ZIP" -d "$DOWNLOAD_DIR/extracted" +# Zip root holds: vnidrop.xcframework/ and Vnidrop.swift (see package-core.sh). +rm -rf "$PKG_DIR/vnidrop.xcframework" +cp -R "$DOWNLOAD_DIR/extracted/vnidrop.xcframework" "$PKG_DIR/vnidrop.xcframework" +mkdir -p "$PKG_DIR/Sources/VnidropCore" +cp "$DOWNLOAD_DIR/extracted/Vnidrop.swift" "$PKG_DIR/Sources/VnidropCore/Vnidrop.swift" + +# --- Generate the project (everything except the Rust core) ------------------- +echo "==> Generating localization, version/app config, and the Xcode project" +make localization apple-version-config apple-app-config +(cd "$REPO_ROOT/apple" && xcodegen generate) + +echo "==> ci_post_clone complete" From 877083a3edbda5f3313dec0417e6987b64ef82fd Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Sun, 2 Aug 2026 19:18:18 +0200 Subject: [PATCH 8/8] refactor(diagnostics): remove bug report breadcrumbs --- services/diagnostics-api/src/input.ts | 66 ------------------- services/diagnostics-api/src/storage.ts | 13 ++-- services/diagnostics-api/test/input.test.ts | 18 +---- services/diagnostics-api/test/worker.test.ts | 20 ++---- .../app/diagnostics/BreadcrumbBuffer.kt | 47 ------------- .../app/diagnostics/BugReportService.kt | 2 - .../app/diagnostics/DiagnosticsCoordinator.kt | 4 -- .../app/diagnostics/DiagnosticsJson.kt | 42 ------------ .../app/diagnostics/DiagnosticsModels.kt | 7 -- .../app/diagnostics/DiagnosticsSanitizer.kt | 19 ------ .../app/diagnostics/DiagnosticsTest.kt | 16 ----- .../com/vnidrop/app/feature/ViewModelsTest.kt | 2 - 12 files changed, 14 insertions(+), 242 deletions(-) delete mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/BreadcrumbBuffer.kt diff --git a/services/diagnostics-api/src/input.ts b/services/diagnostics-api/src/input.ts index e4ea0b4..49cdd08 100644 --- a/services/diagnostics-api/src/input.ts +++ b/services/diagnostics-api/src/input.ts @@ -8,14 +8,6 @@ export type InputFailure = { export type InputResult = { ok: true; value: T } | InputFailure; -export type NormalizedProperties = Record; - -export interface NormalizedBreadcrumb { - name: string; - timestampMillis: number; - properties: NormalizedProperties; -} - export interface NormalizedDevice { deviceName: string; deviceModel: string; @@ -36,16 +28,12 @@ export interface NormalizedBugPayload { contact: string; logs: string; device: NormalizedDevice; - breadcrumbs: NormalizedBreadcrumb[]; schemaVersion: 1; } export const MAX_LOG_BYTES = 192 * 1024; -export const MAX_BREADCRUMBS_JSON_BYTES = 16_000; export const MAX_DEVICE_JSON_BYTES = 4_000; -const MAX_PROPERTIES = 12; -const MAX_BREADCRUMBS = 40; const MISSING = Symbol("missing"); const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const UTF8_ENCODER = new TextEncoder(); @@ -177,8 +165,6 @@ export function normalizeBug(body: JsonObject): InputResult { - if (raw === MISSING) return success([]); - if (!Array.isArray(raw)) return failure(400, "invalid_breadcrumbs"); - - const breadcrumbs: NormalizedBreadcrumb[] = []; - for (const item of raw.slice(0, MAX_BREADCRUMBS)) { - if (!isPlainObject(item)) return failure(400, "invalid_breadcrumbs"); - const name = stringField(item, ["name"], 64, "invalid_breadcrumbs", true, true); - if (!name.ok) return name; - const timestamp = timestampField(item, ["timestampMillis", "timestamp_millis", "ts"]); - if (!timestamp.ok) return failure(400, "invalid_breadcrumbs"); - const properties = normalizeProperties( - pick(item, ["properties", "props"]), - "invalid_breadcrumbs", - ); - if (!properties.ok) return properties; - - breadcrumbs.push({ - name: name.value, - timestampMillis: timestamp.value, - properties: properties.value, - }); - if (jsonBytes(breadcrumbs) > MAX_BREADCRUMBS_JSON_BYTES) { - breadcrumbs.pop(); - break; - } - } - return success(breadcrumbs); -} - -function normalizeProperties( - raw: unknown | typeof MISSING, - error: string, -): InputResult { - if (raw === MISSING) return success({}); - if (!isPlainObject(raw)) return failure(400, error); - - const entries: Array<[string, string]> = []; - const normalizedKeys = new Set(); - for (const [key, value] of Object.entries(raw).slice(0, MAX_PROPERTIES)) { - if (typeof value !== "string") return failure(400, error); - const normalizedKey = truncateUtf8(key, 40); - if (normalizedKey.length === 0 || normalizedKeys.has(normalizedKey)) { - return failure(400, error); - } - normalizedKeys.add(normalizedKey); - entries.push([normalizedKey, truncateUtf8(value, 128)]); - } - return success(Object.fromEntries(entries)); -} - function normalizeDevice(raw: unknown | typeof MISSING): InputResult { if (raw === MISSING) raw = {}; if (!isPlainObject(raw)) return failure(400, "invalid_device"); diff --git a/services/diagnostics-api/src/storage.ts b/services/diagnostics-api/src/storage.ts index d5df546..19b0c3a 100644 --- a/services/diagnostics-api/src/storage.ts +++ b/services/diagnostics-api/src/storage.ts @@ -32,12 +32,12 @@ export async function storeBug( try { const result = await database .prepare( - `INSERT INTO bugs ( - id, received_at, occurred_at, install_id, app_version, platform, - what_happened, expected, steps, contact, logs_r2_key, - device_json, breadcrumbs_json, status, schema_version - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?) - ON CONFLICT(id) DO NOTHING`, + `INSERT INTO bugs ( + id, received_at, occurred_at, install_id, app_version, platform, + what_happened, expected, steps, contact, logs_r2_key, + device_json, status, schema_version + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?) + ON CONFLICT(id) DO NOTHING`, ) .bind( payload.id, @@ -52,7 +52,6 @@ export async function storeBug( payload.contact, logsKey, JSON.stringify(payload.device), - JSON.stringify(payload.breadcrumbs), payload.schemaVersion, ) .run(); diff --git a/services/diagnostics-api/test/input.test.ts b/services/diagnostics-api/test/input.test.ts index 5d343f2..fb30e08 100644 --- a/services/diagnostics-api/test/input.test.ts +++ b/services/diagnostics-api/test/input.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import { - MAX_BREADCRUMBS_JSON_BYTES, MAX_DEVICE_JSON_BYTES, MAX_LOG_BYTES, normalizeBug, @@ -119,20 +118,11 @@ describe("normalizers", () => { }); }); - it("keeps logs, breadcrumbs, and device JSON within valid byte budgets", () => { - const properties = Object.fromEntries( - Array.from({ length: 12 }, (_, index) => [`key-${index}-${"\u0000".repeat(40)}`, "\u0000".repeat(128)]), - ); - const breadcrumbs = Array.from({ length: 40 }, (_, index) => ({ - name: `crumb-${index}`, - timestamp_millis: index, - properties, - })); + it("keeps logs and device JSON within valid byte budgets", () => { const result = normalizeBug( bugPayload({ include_logs: true, logs: "😀".repeat(60_000), - breadcrumbs, device: { device_name: "\u0000".repeat(200), device_model: "\u0000".repeat(200), @@ -145,14 +135,9 @@ describe("normalizers", () => { expect(result.ok).toBe(true); if (!result.ok) return; - const breadcrumbsJson = JSON.stringify(result.value.breadcrumbs); const deviceJson = JSON.stringify(result.value.device); expect(ENCODER.encode(result.value.logs).byteLength).toBe(MAX_LOG_BYTES); - expect(ENCODER.encode(breadcrumbsJson).byteLength).toBeLessThanOrEqual( - MAX_BREADCRUMBS_JSON_BYTES, - ); expect(ENCODER.encode(deviceJson).byteLength).toBeLessThanOrEqual(MAX_DEVICE_JSON_BYTES); - expect(JSON.parse(breadcrumbsJson)).toEqual(result.value.breadcrumbs); expect(JSON.parse(deviceJson)).toEqual(result.value.device); }); @@ -218,7 +203,6 @@ function bugPayload(overrides: Record = {}): Record { const row = await env.DB.prepare( `SELECT occurred_at AS occurredAt, logs_r2_key AS logsKey, - device_json AS deviceJson, breadcrumbs_json AS breadcrumbsJson + device_json AS deviceJson FROM bugs WHERE id = ?`, ) .bind(id) @@ -129,7 +129,6 @@ describe("diagnostics Worker", () => { occurredAt: number; logsKey: string; deviceJson: string; - breadcrumbsJson: string; }>(); expect(row?.occurredAt).toBe(3); expect(JSON.parse(row?.deviceJson ?? "null")).toEqual({ @@ -139,9 +138,6 @@ describe("diagnostics Worker", () => { network: "offline", batteryLevel: "90%", }); - expect(JSON.parse(row?.breadcrumbsJson ?? "null")).toEqual([ - { name: "opened", timestampMillis: 2, properties: {} }, - ]); expect(await (await env.BLOBS.get(row?.logsKey ?? "missing"))?.text()).toBe("first logs"); const objects = await env.BLOBS.list({ prefix: `bugs/${id}/` }); @@ -198,15 +194,15 @@ describe("diagnostics Worker", () => { `INSERT INTO bugs (id, received_at, occurred_at, install_id, app_version, platform, what_happened, expected, steps, contact, logs_r2_key, - device_json, breadcrumbs_json, status, schema_version) - VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', ?, '{}', '[]', 'open', 1)`, + device_json, status, schema_version) + VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', ?, '{}', 'open', 1)`, ).bind(oldBugId, oldReceivedAt, oldReceivedAt, INSTALL_ID, oldBugKey), env.DB.prepare( `INSERT INTO bugs (id, received_at, occurred_at, install_id, app_version, platform, what_happened, expected, steps, contact, logs_r2_key, - device_json, breadcrumbs_json, status, schema_version) - VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', NULL, '{}', '[]', 'open', 1)`, + device_json, status, schema_version) + VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', NULL, '{}', 'open', 1)`, ).bind(currentBugId, Date.now(), Date.now(), INSTALL_ID), ]); @@ -283,10 +279,10 @@ describe("diagnostics Worker", () => { INSERT INTO bugs ( id, received_at, occurred_at, install_id, app_version, platform, what_happened, expected, steps, contact, logs_r2_key, - device_json, breadcrumbs_json, status, schema_version + device_json, status, schema_version ) SELECT 'retention-backlog-' || printf('%04d', value), ?, ?, ?, '', '', - 'failed', 'worked', '', '', NULL, '{}', '[]', 'open', 1 + 'failed', 'worked', '', '', NULL, '{}', 'open', 1 FROM sequence`, ) .bind(oldReceivedAt, oldReceivedAt, INSTALL_ID) @@ -358,7 +354,6 @@ function bugPayload(id: string, logs: string): Record { network: "offline", batteryLevel: "90%", }, - breadcrumbs: [{ name: "opened", timestampMillis: 2, properties: {} }], schemaVersion: 1, }; } @@ -382,7 +377,6 @@ function normalizedBug(id: string, logs: string): NormalizedBugPayload { network: "offline", batteryLevel: "90%", }, - breadcrumbs: [], schemaVersion: 1, }; } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/BreadcrumbBuffer.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/BreadcrumbBuffer.kt deleted file mode 100644 index 29c2951..0000000 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/BreadcrumbBuffer.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.vnidrop.app.diagnostics - -import com.vnidrop.app.logging.platformNowMillis -import kotlin.concurrent.atomics.AtomicReference -import kotlin.concurrent.atomics.ExperimentalAtomicApi - -/** - * Fixed-size ring of high-level app breadcrumbs for crash / bug context. - * Always in-memory only; never auto-uploaded without policy + transport. - * - * Updates are best-effort under concurrency; losing a breadcrumb is preferable - * to blocking a dying process on a lock. - */ -@OptIn(ExperimentalAtomicApi::class) -class BreadcrumbBuffer( - private val capacity: Int = DefaultCapacity, -) { - init { - require(capacity > 0) { "capacity must be positive" } - } - - private val items = AtomicReference>(emptyList()) - - fun add(name: String, properties: Map = emptyMap(), timestampMillis: Long = platformNowMillis()) { - val sanitizedName = sanitizeDiagnosticName(name) - if (sanitizedName.isBlank()) return - val crumb = Breadcrumb( - name = sanitizedName, - timestampMillis = timestampMillis, - properties = sanitizeDiagnosticProperties(properties), - ) - while (true) { - val current = items.load() - if (items.compareAndSet(current, (current + crumb).takeLast(capacity))) return - } - } - - fun snapshot(): List = items.load() - - fun clear() { - items.store(emptyList()) - } - - companion object { - const val DefaultCapacity = 40 - } -} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/BugReportService.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/BugReportService.kt index a5c2c36..128bde7 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/BugReportService.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/BugReportService.kt @@ -21,7 +21,6 @@ data class BugReportDraft( class BugReportService( private val preferencesRepository: PreferencesRepository, private val transport: DiagnosticsTransport, - private val breadcrumbs: BreadcrumbBuffer, private val appVersion: String, private val platform: String, private val logReader: () -> String = { @@ -53,7 +52,6 @@ class BugReportService( network = deviceInfo?.network?.takeUtf8Bytes(96), batteryLevel = deviceInfo?.batteryLevel?.takeUtf8Bytes(64), ), - breadcrumbs = breadcrumbs.snapshot(), ) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsCoordinator.kt index e035f65..0ae44b1 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsCoordinator.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsCoordinator.kt @@ -13,7 +13,6 @@ import kotlinx.coroutines.launch class DiagnosticsCoordinator( val preferencesRepository: PreferencesRepository, val transport: DiagnosticsTransport, - val breadcrumbs: BreadcrumbBuffer, val bugReports: BugReportService, private val scope: CoroutineScope, ) { @@ -32,18 +31,15 @@ class DiagnosticsCoordinator( scope: CoroutineScope, transport: DiagnosticsTransport = NoOpDiagnosticsTransport(), ): DiagnosticsCoordinator { - val breadcrumbs = BreadcrumbBuffer() val bugReports = BugReportService( preferencesRepository = preferencesRepository, transport = transport, - breadcrumbs = breadcrumbs, appVersion = appVersion, platform = platform, ) return DiagnosticsCoordinator( preferencesRepository = preferencesRepository, transport = transport, - breadcrumbs = breadcrumbs, bugReports = bugReports, scope = scope, ) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsJson.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsJson.kt index 3476416..56dbecc 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsJson.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsJson.kt @@ -8,8 +8,6 @@ internal object DiagnosticsJson { internal const val MaxInstallIdBytes = 80 internal const val MaxAppVersionBytes = 40 internal const val MaxPlatformBytes = 40 - private const val MaxBreadcrumbsJsonBytes = 16_000 - private const val MaxBreadcrumbs = 40 fun bugBody(report: BugReport): String { val logs = if (report.includeLogs) report.logs else "" @@ -72,47 +70,7 @@ internal object DiagnosticsJson { appendJsonField("network", report.device.network.orEmpty()) append(',') appendJsonField("batteryLevel", report.device.batteryLevel.orEmpty()) - append("},") - append("\"breadcrumbs\":") - appendBreadcrumbs(report.breadcrumbs) append('}') - } - - private fun StringBuilder.appendBreadcrumbs(crumbs: List) { - append('[') - var encodedBytes = 2 - var appended = 0 - for (crumb in crumbs) { - if (appended == MaxBreadcrumbs) break - val name = sanitizeDiagnosticName(crumb.name) - if (name.isBlank() || crumb.timestampMillis < 0) continue - val encoded = buildString { - append('{') - appendJsonField("name", name) - append(',') - append("\"timestampMillis\":") - append(crumb.timestampMillis) - append(',') - append("\"properties\":") - appendStringMap(crumb.properties) - append('}') - } - val additionBytes = encoded.encodeToByteArray().size + if (appended == 0) 0 else 1 - if (encodedBytes + additionBytes > MaxBreadcrumbsJsonBytes) break - if (appended > 0) append(',') - append(encoded) - encodedBytes += additionBytes - appended += 1 - } - append(']') - } - - private fun StringBuilder.appendStringMap(map: Map) { - append('{') - sanitizeDiagnosticProperties(map).entries.forEachIndexed { index, (key, value) -> - if (index > 0) append(',') - appendJsonField(key, value) - } append('}') } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsModels.kt index 43392df..7cab917 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsModels.kt @@ -5,12 +5,6 @@ package com.vnidrop.app.diagnostics * intentionally abstracted; nothing here assumes a network backend. */ -data class Breadcrumb( - val name: String, - val timestampMillis: Long, - val properties: Map = emptyMap(), -) - data class DeviceSnapshot( val deviceName: String?, val deviceModel: String?, @@ -32,7 +26,6 @@ data class BugReport( val includeLogs: Boolean, val logs: String, val device: DeviceSnapshot, - val breadcrumbs: List, val schemaVersion: Int = DiagnosticsSchemaVersion, ) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsSanitizer.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsSanitizer.kt index 6cef846..4b64a9e 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsSanitizer.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsSanitizer.kt @@ -1,30 +1,11 @@ package com.vnidrop.app.diagnostics -internal const val MaxDiagnosticProperties = 12 -internal const val MaxDiagnosticPropertyKeyBytes = 40 -internal const val MaxDiagnosticPropertyValueBytes = 128 -internal const val MaxDiagnosticNameBytes = 64 - internal fun sanitizeDiagnosticsInstallId(value: String): String { val trimmed = value.trim() if (trimmed.any { it.code < 0x20 || it.code == 0x7f }) return "" return trimmed.takeUtf8Bytes(DiagnosticsJson.MaxInstallIdBytes) } -internal fun sanitizeDiagnosticName(name: String): String = - name.takeUtf8Bytes(MaxDiagnosticNameBytes) - -internal fun sanitizeDiagnosticProperties(properties: Map): Map { - val sanitized = LinkedHashMap(minOf(properties.size, MaxDiagnosticProperties)) - for ((rawKey, rawValue) in properties) { - val key = rawKey.takeUtf8Bytes(MaxDiagnosticPropertyKeyBytes) - if (key.isEmpty() || key in sanitized) continue - sanitized[key] = LogRedactor.redact(rawValue).takeUtf8Bytes(MaxDiagnosticPropertyValueBytes) - if (sanitized.size == MaxDiagnosticProperties) break - } - return sanitized -} - internal fun String.takeUtf8Bytes(maxBytes: Int): String { require(maxBytes >= 0) { "maxBytes must not be negative" } val encoded = encodeToByteArray() diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt index 0f31727..3fa13a6 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt @@ -204,22 +204,11 @@ class DiagnosticsTest { assertTrue(redacted.contains("[redacted-endpoint]")) } - @Test - fun breadcrumbBufferKeepsOnlyLatestEntries() { - val buffer = BreadcrumbBuffer(capacity = 3) - buffer.add("a") - buffer.add("b") - buffer.add("c") - buffer.add("d") - assertEquals(listOf("b", "c", "d"), buffer.snapshot().map { it.name }) - } - @Test fun bugReportRequiresWhatAndExpected() = runTest { val service = BugReportService( preferencesRepository = fakePrefs(), transport = RecordingDiagnosticsTransport(), - breadcrumbs = BreadcrumbBuffer(), appVersion = "1.0", platform = "Test", logReader = { "logs" }, @@ -238,7 +227,6 @@ class DiagnosticsTest { val service = BugReportService( preferencesRepository = fakePrefs(), transport = throwingTransport, - breadcrumbs = BreadcrumbBuffer(), appVersion = "1.0", platform = "Test", ) @@ -255,7 +243,6 @@ class DiagnosticsTest { val service = BugReportService( preferencesRepository = fakePrefs(), transport = transport, - breadcrumbs = BreadcrumbBuffer(), appVersion = "1.0", platform = "Test", logReader = { LogRedactor.redact("ticket=abcdefghijklmnopqrstuvwxyz012345 plain") }, @@ -286,7 +273,6 @@ class DiagnosticsTest { val service = BugReportService( preferencesRepository = fakePrefs(), transport = RecordingDiagnosticsTransport(), - breadcrumbs = BreadcrumbBuffer(), appVersion = "1.0", platform = "Test", logReader = { rawLogs }, @@ -304,7 +290,6 @@ class DiagnosticsTest { val service = BugReportService( preferencesRepository = fakePrefs(), transport = RecordingDiagnosticsTransport(), - breadcrumbs = BreadcrumbBuffer(), appVersion = "1.0", platform = "Test", logReader = { "🙂".repeat(60_000) }, @@ -333,7 +318,6 @@ class DiagnosticsTest { includeLogs = includeLogs, logs = logs, device = DeviceSnapshot(null, null, "OS", null, null), - breadcrumbs = emptyList(), ) private fun fakePrefs() = FakePreferencesRepository( diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index 7e5c5f3..f95b342 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -18,7 +18,6 @@ import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.Transfer import com.vnidrop.app.core.TransferDirection import com.vnidrop.app.core.TransferStatus -import com.vnidrop.app.diagnostics.BreadcrumbBuffer import com.vnidrop.app.diagnostics.BugReportService import com.vnidrop.app.diagnostics.DiagnosticsTransport import com.vnidrop.app.diagnostics.NoOpDiagnosticsTransport @@ -989,7 +988,6 @@ class ViewModelsTest { BugReportService( preferencesRepository = preferences, transport = transport, - breadcrumbs = BreadcrumbBuffer(), appVersion = "1.0", platform = "Test", logReader = { "sample log line" },