mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
Compare commits
8 Commits
d52ac52cea
...
feat/share
| Author | SHA1 | Date | |
|---|---|---|---|
| 877083a3ed | |||
| 7cb2270d56 | |||
| eb8498168d | |||
| ac6e837560 | |||
| 3e18378610 | |||
| b68d338097 | |||
| b8a002a2ad | |||
| 232fb125d3 |
10
Makefile
10
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.
|
||||
|
||||
10
README.md
10
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.
|
||||
|
||||
|
||||
4
app.properties
Normal file
4
app.properties
Normal file
@@ -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/
|
||||
@@ -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.
|
||||
```
|
||||
|
||||
39
apple/Tests/AppConfigTests.swift
Normal file
39
apple/Tests/AppConfigTests.swift
Normal file
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,7 @@ final class SettingsModelTests: XCTestCase {
|
||||
preferences: preferences,
|
||||
notifications: LocalNotificationService(),
|
||||
messages: UiMessageController(),
|
||||
bugReports: NoopBugReportService(),
|
||||
diagnosticsIncluded: false
|
||||
bugReports: NoopBugReportService()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
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) {
|
||||
|
||||
@@ -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 {
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
44
apple/scripts/generate-appconfig.sh
Executable file
44
apple/scripts/generate-appconfig.sh
Executable file
@@ -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" <<EOF
|
||||
// Generated by apple/scripts/generate-appconfig.sh from app.properties.
|
||||
// Regenerate this file instead of editing it.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// App-wide constants injected at build time from the shared \`app.properties\`.
|
||||
enum AppConfig {
|
||||
static let privacyPolicyURL = URL(string: "$(swift_escape "$privacy_url")")!
|
||||
}
|
||||
EOF
|
||||
mv "$tmp" "$output_dir/AppConfig.swift"
|
||||
59
apple/scripts/tests/test-generate-appconfig.sh
Executable file
59
apple/scripts/tests/test-generate-appconfig.sh
Executable file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Tests apple/scripts/generate-appconfig.sh: the shared app.properties is read
|
||||
# correctly, values are emitted as valid escaped Swift, and a missing key fails.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
generator="$script_dir/../generate-appconfig.sh"
|
||||
repo_root="$(cd "$script_dir/../../.." && pwd)"
|
||||
scratch="$(mktemp -d)"
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
|
||||
# Run the generator against a fixture app.properties, emitting into a temp dir.
|
||||
generate() {
|
||||
VNIDROP_APP_PROPERTIES="$scratch/app.properties" \
|
||||
VNIDROP_APPLE_GENERATED_DIR="$scratch/out" \
|
||||
"$generator"
|
||||
}
|
||||
|
||||
expect_failure() {
|
||||
if "$@" >/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'
|
||||
40
ci_scripts/README.md
Normal file
40
ci_scripts/README.md
Normal file
@@ -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-<version>.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<product-version>` | 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.
|
||||
72
ci_scripts/ci_post_clone.sh
Executable file
72
ci_scripts/ci_post_clone.sh
Executable file
@@ -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"
|
||||
@@ -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() {
|
||||
<h1>Privacy Policy</h1>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p className="privacy-meta">Effective July 16, 2026 · Version 1.1</p>
|
||||
<p className="privacy-meta">Effective August 2, 2026 · Version 1.2</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function PrivacyPage() {
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function PrivacyPage() {
|
||||
<h2>Scope and who “VniDrop” means</h2>
|
||||
<p>
|
||||
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() {
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
@@ -117,9 +117,9 @@ export default function PrivacyPage() {
|
||||
<ul>
|
||||
<li>device identity and networking keys used to establish secure connections;</li>
|
||||
<li>active shares, transfer history, receiver requests, progress, and status;</li>
|
||||
<li>app preferences, including access and diagnostics choices;</li>
|
||||
<li>app preferences, including access choices;</li>
|
||||
<li>download destinations and locally managed transfer data; and</li>
|
||||
<li>an anonymous installation identifier used only for diagnostics correlation.</li>
|
||||
<li>an anonymous installation identifier used only for bug-report correlation.</li>
|
||||
</ul>
|
||||
<p>
|
||||
This information remains until you remove the relevant history, stop or delete a
|
||||
@@ -129,33 +129,27 @@ export default function PrivacyPage() {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="diagnostics" className="policy-section">
|
||||
<h2>Optional diagnostics and bug reports</h2>
|
||||
<h3>Automatic product diagnostics</h3>
|
||||
<section id="bug-reports" className="policy-section">
|
||||
<h2>Optional bug reports</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<h3>User-submitted bug reports</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<h3>Data deliberately excluded</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -223,7 +217,7 @@ export default function PrivacyPage() {
|
||||
<dt>Cloudflare</dt>
|
||||
<dd>
|
||||
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.
|
||||
</dd>
|
||||
</div>
|
||||
@@ -300,11 +294,7 @@ export default function PrivacyPage() {
|
||||
<td>Until you delete them, clear app data, or uninstall</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Pending local crash reports</th>
|
||||
<td>Up to 30 days and 20 reports; deleted when diagnostics is disabled</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Server diagnostics and bug reports</th>
|
||||
<th scope="row">Server bug reports</th>
|
||||
<td>The current project configuration is 90 days, with scheduled deletion</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -317,7 +307,7 @@ export default function PrivacyPage() {
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
@@ -325,7 +315,6 @@ export default function PrivacyPage() {
|
||||
<section id="choices" className="policy-section">
|
||||
<h2>Your choices and rights</h2>
|
||||
<ul>
|
||||
<li>Enable or disable “Share diagnostics” in VniDrop settings.</li>
|
||||
<li>
|
||||
Submit a bug report only when you choose, omit contact information, and exclude
|
||||
logs.
|
||||
@@ -343,7 +332,7 @@ export default function PrivacyPage() {
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
@@ -353,7 +342,7 @@ export default function PrivacyPage() {
|
||||
<h2>Security</h2>
|
||||
<p>
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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: <anonymous install UUID>
|
||||
|--------|------|------|
|
||||
| `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=<same value as INGEST_KEY>
|
||||
|
||||
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/<id>/<attempt-id>/stack.txt` and
|
||||
`bugs/<id>/<attempt-id>/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/<id>/<attempt-id>/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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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<Response> {
|
||||
@@ -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<Respon
|
||||
}
|
||||
try {
|
||||
await env.DB.batch([
|
||||
env.DB.prepare(
|
||||
"SELECT id, received_at, install_id, payload_json FROM event_batches LIMIT 1",
|
||||
),
|
||||
env.DB.prepare(
|
||||
"SELECT id, occurred_at, stack_r2_key, breadcrumbs_json FROM crashes LIMIT 1",
|
||||
),
|
||||
env.DB.prepare(
|
||||
"SELECT id, occurred_at, logs_r2_key, device_json FROM bugs LIMIT 1",
|
||||
),
|
||||
@@ -216,8 +170,8 @@ async function installRateLimited(
|
||||
return !result.success;
|
||||
}
|
||||
|
||||
function isIngestPath(path: string): path is "/v1/events" | "/v1/crashes" | "/v1/bugs" {
|
||||
return path === "/v1/events" || path === "/v1/crashes" || path === "/v1/bugs";
|
||||
function isIngestPath(path: string): path is "/v1/bugs" {
|
||||
return path === "/v1/bugs";
|
||||
}
|
||||
|
||||
async function timingSafeEqual(provided: string, expected: string): Promise<boolean> {
|
||||
|
||||
@@ -8,21 +8,6 @@ export type InputFailure = {
|
||||
|
||||
export type InputResult<T> = { ok: true; value: T } | InputFailure;
|
||||
|
||||
export type NormalizedProperties = Record<string, string>;
|
||||
|
||||
export interface NormalizedEvent {
|
||||
name: string;
|
||||
timestampMillis: number;
|
||||
properties: NormalizedProperties;
|
||||
schemaVersion: 1;
|
||||
}
|
||||
|
||||
export interface NormalizedBreadcrumb {
|
||||
name: string;
|
||||
timestampMillis: number;
|
||||
properties: NormalizedProperties;
|
||||
}
|
||||
|
||||
export interface NormalizedDevice {
|
||||
deviceName: string;
|
||||
deviceModel: string;
|
||||
@@ -31,28 +16,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;
|
||||
@@ -65,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();
|
||||
@@ -164,119 +123,6 @@ export async function readJsonObject(
|
||||
return success(parsed);
|
||||
}
|
||||
|
||||
export function normalizeEvents(
|
||||
body: JsonObject,
|
||||
maxEvents = 50,
|
||||
): InputResult<NormalizedEventsPayload> {
|
||||
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<NormalizedCrashPayload> {
|
||||
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<NormalizedBugPayload> {
|
||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
||||
|
||||
@@ -319,8 +165,6 @@ export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload
|
||||
if (!logs.ok) return logs;
|
||||
const device = normalizeDevice(pick(body, ["device"]));
|
||||
if (!device.ok) return device;
|
||||
const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"]));
|
||||
if (!breadcrumbs.ok) return breadcrumbs;
|
||||
const version = schemaVersion(body);
|
||||
if (!version.ok) return version;
|
||||
|
||||
@@ -336,80 +180,10 @@ export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload
|
||||
contact: contact.value,
|
||||
logs: includeLogs.value === true ? logs.value : "",
|
||||
device: device.value,
|
||||
breadcrumbs: breadcrumbs.value,
|
||||
schemaVersion: version.value,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeEvent(raw: unknown): InputResult<NormalizedEvent> {
|
||||
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<NormalizedBreadcrumb[]> {
|
||||
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<NormalizedProperties> {
|
||||
if (raw === MISSING) return success({});
|
||||
if (!isPlainObject(raw)) return failure(400, error);
|
||||
|
||||
const entries: Array<[string, string]> = [];
|
||||
const normalizedKeys = new Set<string>();
|
||||
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<NormalizedDevice> {
|
||||
if (raw === MISSING) raw = {};
|
||||
if (!isPlainObject(raw)) return failure(400, "invalid_device");
|
||||
|
||||
@@ -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<StoreResult> {
|
||||
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<StoreResult & { fingerprint: string }> {
|
||||
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,
|
||||
@@ -161,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,
|
||||
@@ -181,7 +52,6 @@ export async function storeBug(
|
||||
payload.contact,
|
||||
logsKey,
|
||||
JSON.stringify(payload.device),
|
||||
JSON.stringify(payload.breadcrumbs),
|
||||
payload.schemaVersion,
|
||||
)
|
||||
.run();
|
||||
@@ -201,26 +71,22 @@ export async function storeBug(
|
||||
export async function runRetention(env: DiagnosticsEnv): Promise<void> {
|
||||
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 +94,17 @@ export async function runRetention(env: DiagnosticsEnv): Promise<void> {
|
||||
|
||||
async function runRetentionPass(env: DiagnosticsEnv, cutoff: number): Promise<boolean> {
|
||||
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 +114,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<ExpiredBlobRow[]> {
|
||||
@@ -277,25 +132,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 +142,6 @@ function deleteRowsById(
|
||||
.bind(JSON.stringify(rows.map((row) => row.id)));
|
||||
}
|
||||
|
||||
async function crashFingerprint(exceptionType: string, stackTrace: string): Promise<string> {
|
||||
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<void> {
|
||||
try {
|
||||
await env.BLOBS.delete(key);
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MAX_BREADCRUMBS_JSON_BYTES,
|
||||
MAX_DEVICE_JSON_BYTES,
|
||||
MAX_LOG_BYTES,
|
||||
normalizeBug,
|
||||
normalizeCrash,
|
||||
normalizeEvents,
|
||||
readJsonObject,
|
||||
} from "../src/input";
|
||||
|
||||
@@ -57,7 +54,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 +105,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);
|
||||
@@ -133,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),
|
||||
@@ -159,54 +135,38 @@ 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);
|
||||
});
|
||||
|
||||
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 +175,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 +190,8 @@ function chunkedJsonRequest(
|
||||
});
|
||||
}
|
||||
|
||||
function crashPayload(diagnosticsEnabled: unknown): Record<string, unknown> {
|
||||
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<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
const payload: Record<string, unknown> = {
|
||||
id: ID,
|
||||
install_id: INSTALL_ID,
|
||||
app_version: "1.0",
|
||||
@@ -259,8 +203,12 @@ function bugPayload(overrides: Record<string, unknown> = {}): Record<string, unk
|
||||
contact: "",
|
||||
logs: "",
|
||||
device: {},
|
||||
breadcrumbs: [],
|
||||
schema_version: 1,
|
||||
...overrides,
|
||||
};
|
||||
// An explicit `undefined` override omits the key entirely (simulating a missing field).
|
||||
for (const key of Object.keys(overrides)) {
|
||||
if (overrides[key] === undefined) delete payload[key];
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -2,18 +2,8 @@ import { env, exports } from "cloudflare:workers";
|
||||
import { createExecutionContext } from "cloudflare:test";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import worker from "../src/index";
|
||||
import type {
|
||||
NormalizedBugPayload,
|
||||
NormalizedCrashPayload,
|
||||
NormalizedEventsPayload,
|
||||
} from "../src/input";
|
||||
import {
|
||||
type DiagnosticsEnv,
|
||||
runRetention,
|
||||
storeBug,
|
||||
storeCrash,
|
||||
storeEvents,
|
||||
} from "../src/storage";
|
||||
import type { NormalizedBugPayload } from "../src/input";
|
||||
import { type DiagnosticsEnv, runRetention, storeBug } from "../src/storage";
|
||||
|
||||
const INSTALL_ID = "10000000-0000-4000-8000-000000000000";
|
||||
|
||||
@@ -35,7 +25,7 @@ describe("diagnostics Worker", () => {
|
||||
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");
|
||||
@@ -224,7 +121,7 @@ describe("diagnostics Worker", () => {
|
||||
|
||||
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)
|
||||
@@ -232,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({
|
||||
@@ -242,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}/` });
|
||||
@@ -252,9 +145,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 +156,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,88 +169,56 @@ 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,
|
||||
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, 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 +232,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 +257,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 +276,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, 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 +292,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 +334,6 @@ function healthRequest(key = env.INGEST_KEY, source = "198.51.100.1"): Request {
|
||||
});
|
||||
}
|
||||
|
||||
function eventPayload(batchId: string): Record<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
return {
|
||||
id,
|
||||
@@ -535,23 +354,6 @@ function bugPayload(id: string, logs: string): Record<string, unknown> {
|
||||
network: "offline",
|
||||
batteryLevel: "90%",
|
||||
},
|
||||
breadcrumbs: [{ name: "opened", timestampMillis: 2, properties: {} }],
|
||||
schemaVersion: 1,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -575,7 +377,6 @@ function normalizedBug(id: string, logs: string): NormalizedBugPayload {
|
||||
network: "offline",
|
||||
batteryLevel: "90%",
|
||||
},
|
||||
breadcrumbs: [],
|
||||
schemaVersion: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<EnvType extends Record<string, unknown>> = {
|
||||
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
|
||||
};
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "MAX_BODY_BYTES" | "MAX_EVENTS_PER_BATCH" | "RETENTION_DAYS">> {}
|
||||
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "MAX_BODY_BYTES" | "RETENTION_DAYS">> {}
|
||||
}
|
||||
|
||||
// Begin runtime types
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -49,7 +50,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
|
||||
@@ -110,6 +111,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 = 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 +172,7 @@ kotlin {
|
||||
sourceSets {
|
||||
commonMain {
|
||||
kotlin.srcDir(files(diagnosticsBuildConfigDir).builtBy(generateDiagnosticsBuildConfig))
|
||||
kotlin.srcDir(files(appConfigDir).builtBy(generateAppConfig))
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
actual fun createPendingCrashStore(appDataDir: String): PendingCrashStore =
|
||||
AndroidPendingCrashStore(appDataDir)
|
||||
|
||||
private class AndroidPendingCrashStore(
|
||||
appDataDir: String,
|
||||
) : PendingCrashStore {
|
||||
private val directory = File(appDataDir, "diagnostics/crashes")
|
||||
|
||||
@Synchronized
|
||||
override fun write(report: CrashReport) {
|
||||
if (!isValidDiagnosticId(report.id)) return
|
||||
directory.mkdirs()
|
||||
val target = File(directory, "${report.id}.crash")
|
||||
val temporary = File(directory, ".${report.id}.tmp")
|
||||
val payload = CrashReportCodec.encode(report)
|
||||
temporary.writeText(payload, StandardCharsets.UTF_8)
|
||||
if (!temporary.renameTo(target)) {
|
||||
target.writeText(payload, StandardCharsets.UTF_8)
|
||||
temporary.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun list(): List<CrashReport> {
|
||||
if (!directory.isDirectory) return emptyList()
|
||||
return directory
|
||||
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
|
||||
.orEmpty()
|
||||
.sortedByDescending { it.lastModified() }
|
||||
.mapNotNull { file ->
|
||||
runCatching { CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8)) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun delete(id: String) {
|
||||
if (!isValidDiagnosticId(id)) return
|
||||
File(directory, "$id.crash").delete()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun prune(olderThanTimestampMillis: Long, maxCount: Int) {
|
||||
require(maxCount > 0) { "maxCount must be positive" }
|
||||
if (!directory.isDirectory) return
|
||||
directory.listFiles { file -> file.isFile && file.name.endsWith(".tmp") }
|
||||
.orEmpty()
|
||||
.forEach(File::delete)
|
||||
val reports = directory
|
||||
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
|
||||
.orEmpty()
|
||||
.mapNotNull { file ->
|
||||
val report = runCatching {
|
||||
CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8))
|
||||
}.getOrNull()
|
||||
if (report == null) {
|
||||
file.delete()
|
||||
null
|
||||
} else {
|
||||
file to report
|
||||
}
|
||||
}
|
||||
.sortedByDescending { (_, report) -> report.timestampMillis }
|
||||
reports.forEachIndexed { index, (file, report) ->
|
||||
if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
actual fun installPlatformCrashHook(onCrash: (Throwable) -> Unit) {
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
runCatching { onCrash(throwable) }
|
||||
previous?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
@@ -80,10 +80,6 @@
|
||||
<string name="button_write_nfc">Auf NFC-Tag schreiben</string>
|
||||
<string name="device_model_title">Gerätemodell</string>
|
||||
<string name="device_name_title">Gerätename</string>
|
||||
<string name="diagnostics_description">Anonyme Absturzberichte und Nutzungsereignisse senden, damit wir VniDrop verbessern können. Sie können dies jederzeit deaktivieren. Einladungen, Dateipfade und Übertragungsinhalte werden niemals einbezogen.</string>
|
||||
<string name="diagnostics_disabled_message">Die Freigabe von Diagnosedaten ist deaktiviert.</string>
|
||||
<string name="diagnostics_enabled_message">Die Freigabe von Diagnosedaten ist aktiviert.</string>
|
||||
<string name="diagnostics_title">Diagnosedaten teilen</string>
|
||||
<string name="error_camera">Für das Scannen eines QR-Codes ist Kamerazugriff erforderlich.</string>
|
||||
<string name="error_device_info">Geräteinformationen konnten nicht geladen werden.</string>
|
||||
<string name="error_destination_exists">Am Ziel ist bereits eine Datei mit demselben Namen vorhanden. Wählen Sie einen anderen Ordner oder entfernen Sie die vorhandene Datei.</string>
|
||||
|
||||
@@ -80,10 +80,6 @@
|
||||
<string name="button_write_nfc">Escribir en etiqueta NFC</string>
|
||||
<string name="device_model_title">Modelo del dispositivo</string>
|
||||
<string name="device_name_title">Nombre del dispositivo</string>
|
||||
<string name="diagnostics_description">Enviar informes de fallos y eventos de uso anónimos para ayudarnos a mejorar VniDrop. Puede desactivarlo en cualquier momento. Las invitaciones, las rutas de archivos y el contenido de las transferencias nunca se incluyen.</string>
|
||||
<string name="diagnostics_disabled_message">El uso compartido de diagnósticos está desactivado.</string>
|
||||
<string name="diagnostics_enabled_message">El uso compartido de diagnósticos está activado.</string>
|
||||
<string name="diagnostics_title">Compartir diagnósticos</string>
|
||||
<string name="error_camera">Se necesita acceso a la cámara para escanear un código QR.</string>
|
||||
<string name="error_device_info">No se pudo cargar la información del dispositivo.</string>
|
||||
<string name="error_destination_exists">Ya existe un archivo con el mismo nombre en el destino. Elija otra carpeta o elimine el archivo existente.</string>
|
||||
|
||||
@@ -80,10 +80,6 @@
|
||||
<string name="button_write_nfc">Écrire sur un tag NFC</string>
|
||||
<string name="device_model_title">Modèle de l’appareil</string>
|
||||
<string name="device_name_title">Nom de l’appareil</string>
|
||||
<string name="diagnostics_description">Envoyer des rapports de plantage et des événements d’utilisation anonymes pour nous aider à améliorer VniDrop. Vous pouvez désactiver cela à tout moment. Les invitations, chemins de fichiers et contenus de transfert ne sont jamais inclus.</string>
|
||||
<string name="diagnostics_disabled_message">Le partage des diagnostics est désactivé.</string>
|
||||
<string name="diagnostics_enabled_message">Le partage des diagnostics est activé.</string>
|
||||
<string name="diagnostics_title">Partager les diagnostics</string>
|
||||
<string name="error_camera">L’accès à la caméra est nécessaire pour scanner un QR code.</string>
|
||||
<string name="error_device_info">Impossible de charger les informations de l’appareil.</string>
|
||||
<string name="error_destination_exists">Un fichier portant le même nom existe déjà dans la destination. Choisissez un autre dossier ou supprimez le fichier existant.</string>
|
||||
|
||||
@@ -80,10 +80,6 @@
|
||||
<string name="button_write_nfc">Scrivi su tag NFC</string>
|
||||
<string name="device_model_title">Modello del dispositivo</string>
|
||||
<string name="device_name_title">Nome del dispositivo</string>
|
||||
<string name="diagnostics_description">Invia report di arresto anomalo ed eventi d’uso anonimi per aiutarci a migliorare VniDrop. Può disattivarlo in qualsiasi momento. Inviti, percorsi dei file e contenuti dei trasferimenti non vengono mai inclusi.</string>
|
||||
<string name="diagnostics_disabled_message">La condivisione dei dati diagnostici è disattivata.</string>
|
||||
<string name="diagnostics_enabled_message">La condivisione dei dati diagnostici è attivata.</string>
|
||||
<string name="diagnostics_title">Condividi dati diagnostici</string>
|
||||
<string name="error_camera">Per scansionare un codice QR è necessario l’accesso alla fotocamera.</string>
|
||||
<string name="error_device_info">Impossibile caricare le informazioni sul dispositivo.</string>
|
||||
<string name="error_destination_exists">Nella destinazione esiste già un file con lo stesso nome. Scelga un’altra cartella o rimuova il file esistente.</string>
|
||||
|
||||
@@ -80,10 +80,6 @@
|
||||
<string name="button_write_nfc">Naar NFC-tag schrijven</string>
|
||||
<string name="device_model_title">Apparaatmodel</string>
|
||||
<string name="device_name_title">Apparaatnaam</string>
|
||||
<string name="diagnostics_description">Verstuur anonieme crashrapporten en gebruiksgebeurtenissen zodat we VniDrop kunnen verbeteren. U kunt dit op elk moment uitschakelen. Uitnodigingen, bestandspaden en overdrachtsinhoud worden nooit meegestuurd.</string>
|
||||
<string name="diagnostics_disabled_message">Het delen van diagnostische gegevens is uitgeschakeld.</string>
|
||||
<string name="diagnostics_enabled_message">Het delen van diagnostische gegevens is ingeschakeld.</string>
|
||||
<string name="diagnostics_title">Diagnostische gegevens delen</string>
|
||||
<string name="error_camera">Voor het scannen van een QR-code is toegang tot de camera vereist.</string>
|
||||
<string name="error_device_info">Apparaatgegevens konden niet worden geladen.</string>
|
||||
<string name="error_destination_exists">Er staat al een bestand met dezelfde naam in de doelmap. Kies een andere map of verwijder het bestaande bestand.</string>
|
||||
|
||||
@@ -80,10 +80,6 @@
|
||||
<string name="button_write_nfc">Zapisz na tagu NFC</string>
|
||||
<string name="device_model_title">Model urządzenia</string>
|
||||
<string name="device_name_title">Nazwa urządzenia</string>
|
||||
<string name="diagnostics_description">Wysyłaj anonimowe raporty o awariach i zdarzenia użytkowania, aby pomóc nam ulepszać VniDrop. Możesz to wyłączyć w dowolnej chwili. Zaproszenia, ścieżki plików i zawartość transferów nigdy nie są dołączane.</string>
|
||||
<string name="diagnostics_disabled_message">Udostępnianie diagnostyki jest wyłączone.</string>
|
||||
<string name="diagnostics_enabled_message">Udostępnianie diagnostyki jest włączone.</string>
|
||||
<string name="diagnostics_title">Udostępniaj diagnostykę</string>
|
||||
<string name="error_camera">Do zeskanowania kodu QR wymagany jest dostęp do aparatu.</string>
|
||||
<string name="error_device_info">Nie udało się wczytać informacji o urządzeniu.</string>
|
||||
<string name="error_destination_exists">W miejscu docelowym istnieje już plik o tej samej nazwie. Wybierz inny folder lub usuń istniejący plik.</string>
|
||||
|
||||
@@ -80,10 +80,6 @@
|
||||
<string name="button_write_nfc">Escrever em etiqueta NFC</string>
|
||||
<string name="device_model_title">Modelo do dispositivo</string>
|
||||
<string name="device_name_title">Nome do dispositivo</string>
|
||||
<string name="diagnostics_description">Enviar relatórios de falhas e eventos de utilização anónimos para nos ajudar a melhorar o VniDrop. Pode desativar isto a qualquer momento. Convites, caminhos de ficheiros e conteúdos das transferências nunca são incluídos.</string>
|
||||
<string name="diagnostics_disabled_message">A partilha de diagnósticos está desativada.</string>
|
||||
<string name="diagnostics_enabled_message">A partilha de diagnósticos está ativada.</string>
|
||||
<string name="diagnostics_title">Partilhar diagnósticos</string>
|
||||
<string name="error_camera">É necessário acesso à câmara para ler um código QR.</string>
|
||||
<string name="error_device_info">Não foi possível carregar as informações do dispositivo.</string>
|
||||
<string name="error_destination_exists">Já existe um ficheiro com o mesmo nome no destino. Escolha outra pasta ou remova o ficheiro existente.</string>
|
||||
|
||||
@@ -80,10 +80,6 @@
|
||||
<string name="button_write_nfc">Записать на NFC-метку</string>
|
||||
<string name="device_model_title">Модель устройства</string>
|
||||
<string name="device_name_title">Имя устройства</string>
|
||||
<string name="diagnostics_description">Отправлять анонимные отчёты о сбоях и события использования, чтобы помочь нам улучшать VniDrop. Вы можете отключить это в любой момент. Приглашения, пути к файлам и содержимое передач никогда не включаются.</string>
|
||||
<string name="diagnostics_disabled_message">Передача диагностики отключена.</string>
|
||||
<string name="diagnostics_enabled_message">Передача диагностики включена.</string>
|
||||
<string name="diagnostics_title">Делиться диагностикой</string>
|
||||
<string name="error_camera">Для сканирования QR-кода требуется доступ к камере.</string>
|
||||
<string name="error_device_info">Не удалось загрузить сведения об устройстве.</string>
|
||||
<string name="error_destination_exists">В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл.</string>
|
||||
|
||||
@@ -80,10 +80,6 @@
|
||||
<string name="button_write_nfc">Write to NFC tag</string>
|
||||
<string name="device_model_title">Device model</string>
|
||||
<string name="device_name_title">Device name</string>
|
||||
<string name="diagnostics_description">Send anonymous crash reports and usage events so we can improve VniDrop. You can turn this off anytime. Invitations, file paths, and transfer contents are never included.</string>
|
||||
<string name="diagnostics_disabled_message">Diagnostics sharing is off.</string>
|
||||
<string name="diagnostics_enabled_message">Diagnostics sharing is on.</string>
|
||||
<string name="diagnostics_title">Share diagnostics</string>
|
||||
<string name="error_camera">Camera access is required to scan a QR code.</string>
|
||||
<string name="error_device_info">Could not load device information.</string>
|
||||
<string name="error_destination_exists">A file with the same name already exists in the destination. Choose another folder or remove the existing file.</string>
|
||||
|
||||
@@ -80,7 +80,6 @@ fun App(
|
||||
graph.coreRepository,
|
||||
graph.preferencesRepository,
|
||||
graph.messages,
|
||||
graph.diagnostics,
|
||||
)
|
||||
}
|
||||
val sendViewModel = viewModel {
|
||||
@@ -105,7 +104,6 @@ fun App(
|
||||
dependencies.localNotificationService,
|
||||
graph.messages,
|
||||
graph.diagnostics.bugReports,
|
||||
graph.diagnostics,
|
||||
)
|
||||
}
|
||||
val appState by appViewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -19,9 +19,6 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AppGraph(
|
||||
val dependencies: AppDependencies,
|
||||
@@ -40,11 +37,9 @@ class AppGraph(
|
||||
receiveFolder = dependencies.fileSystemService.defaultReceiveFolder(),
|
||||
themeMode = ThemeMode.System,
|
||||
notificationsEnabled = false,
|
||||
diagnosticsEnabled = false,
|
||||
),
|
||||
)
|
||||
val diagnostics = DiagnosticsCoordinator.create(
|
||||
appDataDir = dependencies.environment.defaultCoreDataDir,
|
||||
appVersion = dependencies.environment.appVersion,
|
||||
platform = dependencies.environment.name,
|
||||
preferencesRepository = preferencesRepository,
|
||||
@@ -75,12 +70,6 @@ class AppGraph(
|
||||
init {
|
||||
AppLogger.initialize(dependencies.environment.defaultCoreDataDir)
|
||||
diagnostics.start()
|
||||
applicationScope.launch {
|
||||
visibility.isForeground
|
||||
.drop(1)
|
||||
.filter { isForeground -> !isForeground }
|
||||
.collect { diagnostics.telemetry.flush() }
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
|
||||
@@ -1,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<List<Breadcrumb>>(emptyList())
|
||||
|
||||
fun add(name: String, properties: Map<String, String> = 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<Breadcrumb> = items.load()
|
||||
|
||||
fun clear() {
|
||||
items.store(emptyList())
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DefaultCapacity = 40
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import com.vnidrop.app.logging.AppLogger
|
||||
import com.vnidrop.app.logging.platformNowMillis
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.util.randomUuidString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.concurrent.atomics.AtomicBoolean
|
||||
import kotlin.concurrent.atomics.AtomicReference
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
|
||||
/**
|
||||
* Captures uncaught exceptions to disk, then uploads on a later launch when
|
||||
* diagnostics is enabled (and when a real [DiagnosticsTransport] is wired).
|
||||
*/
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
class CrashReporter(
|
||||
private val store: PendingCrashStore,
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
private val transport: DiagnosticsTransport,
|
||||
private val breadcrumbs: BreadcrumbBuffer,
|
||||
private val appVersion: String,
|
||||
private val platform: String,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val installed = AtomicBoolean(false)
|
||||
private val observingPreferences = AtomicBoolean(false)
|
||||
private val capturePolicy = AtomicReference(CrashCapturePolicy())
|
||||
|
||||
fun startObservingPreferences() {
|
||||
if (!observingPreferences.compareAndSet(false, true)) return
|
||||
scope.launch {
|
||||
preferencesRepository.preferences.collect { prefs ->
|
||||
capturePolicy.store(
|
||||
CrashCapturePolicy(
|
||||
installId = prefs.diagnosticsInstallId,
|
||||
diagnosticsEnabled = prefs.diagnosticsEnabled,
|
||||
),
|
||||
)
|
||||
if (!prefs.diagnosticsEnabled) {
|
||||
runCatching(::deleteAllPending)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun installUnhandledExceptionHandler() {
|
||||
if (!DiagnosticsBuildConfig.INCLUDED) return
|
||||
if (!installed.compareAndSet(false, true)) return
|
||||
installPlatformCrashHook { throwable ->
|
||||
capture(throwable)
|
||||
}
|
||||
}
|
||||
|
||||
fun capture(throwable: Throwable, diagnosticsEnabledOverride: Boolean? = null): CrashReport {
|
||||
val policy = capturePolicy.load()
|
||||
val report = CrashReport(
|
||||
id = randomUuidString(),
|
||||
timestampMillis = platformNowMillis(),
|
||||
installId = sanitizeDiagnosticsInstallId(policy.installId),
|
||||
appVersion = appVersion.takeUtf8Bytes(DiagnosticsJson.MaxAppVersionBytes),
|
||||
platform = platform.takeUtf8Bytes(DiagnosticsJson.MaxPlatformBytes),
|
||||
exceptionType = throwable::class.simpleName ?: "Throwable",
|
||||
exceptionMessage = LogRedactor.redact(throwable.message.orEmpty()).takeUtf8Bytes(MaxMessageBytes),
|
||||
stackTrace = LogRedactor.redact(throwable.stackTraceToString()).takeUtf8Bytes(MaxStackBytes),
|
||||
breadcrumbs = breadcrumbs.snapshot(),
|
||||
diagnosticsEnabledAtCapture = diagnosticsEnabledOverride ?: policy.diagnosticsEnabled,
|
||||
)
|
||||
if (report.diagnosticsEnabledAtCapture != false) {
|
||||
runCatching { store.write(report) }
|
||||
runCatching {
|
||||
store.prune(
|
||||
olderThanTimestampMillis = platformNowMillis() - LocalRetentionMillis,
|
||||
maxCount = MaxLocalCrashCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
AppLogger.error("crash", "captured crash ${report.id}", throwable)
|
||||
return report
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads pending crashes that were captured with diagnostics enabled.
|
||||
* Local files are deleted after successful delivery or bounded by local retention.
|
||||
*/
|
||||
suspend fun flushPending() {
|
||||
store.prune(
|
||||
olderThanTimestampMillis = platformNowMillis() - LocalRetentionMillis,
|
||||
maxCount = MaxLocalCrashCount,
|
||||
)
|
||||
for (report in store.list()) {
|
||||
val preferences = preferencesRepository.preferences.first()
|
||||
if (!preferences.diagnosticsEnabled || capturePolicy.load().diagnosticsEnabled == false) {
|
||||
deleteAllPending()
|
||||
return
|
||||
}
|
||||
if (report.diagnosticsEnabledAtCapture == false) {
|
||||
store.delete(report.id)
|
||||
continue
|
||||
}
|
||||
val installId = sanitizeDiagnosticsInstallId(
|
||||
preferences.diagnosticsInstallId.ifBlank {
|
||||
preferencesRepository.ensureDiagnosticsInstallId()
|
||||
},
|
||||
)
|
||||
val resolved = report.copy(
|
||||
installId = report.installId.ifBlank { installId },
|
||||
diagnosticsEnabledAtCapture = true,
|
||||
)
|
||||
if (resolved != report) store.write(resolved)
|
||||
if (
|
||||
!preferencesRepository.preferences.first().diagnosticsEnabled ||
|
||||
capturePolicy.load().diagnosticsEnabled == false
|
||||
) {
|
||||
deleteAllPending()
|
||||
return
|
||||
}
|
||||
val result = try {
|
||||
transport.sendCrash(resolved)
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (error: Throwable) {
|
||||
Result.failure(error)
|
||||
}
|
||||
if (result.isSuccess) {
|
||||
store.delete(resolved.id)
|
||||
continue
|
||||
}
|
||||
val error = result.exceptionOrNull()
|
||||
if (error?.isPermanentDiagnosticsPayloadRejection() == true) {
|
||||
store.delete(resolved.id)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteAllPending() {
|
||||
store.list().forEach { report -> store.delete(report.id) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MaxMessageBytes = 2_000
|
||||
private const val MaxStackBytes = 32_000
|
||||
private const val MaxLocalCrashCount = 20
|
||||
private const val LocalRetentionMillis = 30L * 86_400_000L
|
||||
}
|
||||
}
|
||||
|
||||
private data class CrashCapturePolicy(
|
||||
val installId: String = "",
|
||||
val diagnosticsEnabled: Boolean? = null,
|
||||
)
|
||||
|
||||
expect fun installPlatformCrashHook(onCrash: (Throwable) -> Unit)
|
||||
@@ -5,83 +5,43 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Owns diagnostics services for the app process: telemetry, crashes, bug reports.
|
||||
* Owns user-initiated bug reports for the app process.
|
||||
*
|
||||
* When [DiagnosticsBuildConfig.INCLUDED] is false (compile-time), telemetry and
|
||||
* crash auto-reporting are never started; [bugReports] still works for support.
|
||||
* Telemetry and crash auto-reporting were removed; only [bugReports] remains,
|
||||
* and it only sends when the user submits a report from Settings.
|
||||
*/
|
||||
class DiagnosticsCoordinator(
|
||||
val preferencesRepository: PreferencesRepository,
|
||||
val transport: DiagnosticsTransport,
|
||||
val breadcrumbs: BreadcrumbBuffer,
|
||||
val telemetry: TelemetryRecorder,
|
||||
val crashReporter: CrashReporter,
|
||||
val bugReports: BugReportService,
|
||||
private val scope: CoroutineScope,
|
||||
private val included: Boolean = DiagnosticsBuildConfig.INCLUDED,
|
||||
) {
|
||||
fun start() {
|
||||
// Install id is useful for bug-report correlation even without telemetry.
|
||||
// Install id is useful for bug-report correlation.
|
||||
scope.launch {
|
||||
preferencesRepository.ensureDiagnosticsInstallId()
|
||||
}
|
||||
if (!included) return
|
||||
crashReporter.startObservingPreferences()
|
||||
crashReporter.installUnhandledExceptionHandler()
|
||||
scope.launch {
|
||||
crashReporter.flushPending()
|
||||
}
|
||||
}
|
||||
|
||||
fun record(name: String, properties: Map<String, String> = emptyMap()) {
|
||||
if (!included) return
|
||||
telemetry.record(name, properties)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
appDataDir: String,
|
||||
appVersion: String,
|
||||
platform: String,
|
||||
preferencesRepository: PreferencesRepository,
|
||||
scope: CoroutineScope,
|
||||
transport: DiagnosticsTransport = NoOpDiagnosticsTransport(),
|
||||
included: Boolean = DiagnosticsBuildConfig.INCLUDED,
|
||||
): DiagnosticsCoordinator {
|
||||
val breadcrumbs = BreadcrumbBuffer()
|
||||
val crashStore = createPendingCrashStore(appDataDir)
|
||||
val telemetry = TelemetryRecorder(
|
||||
preferencesRepository = preferencesRepository,
|
||||
transport = transport,
|
||||
breadcrumbs = breadcrumbs,
|
||||
scope = scope,
|
||||
included = included,
|
||||
)
|
||||
val crashReporter = CrashReporter(
|
||||
store = crashStore,
|
||||
preferencesRepository = preferencesRepository,
|
||||
transport = transport,
|
||||
breadcrumbs = breadcrumbs,
|
||||
appVersion = appVersion,
|
||||
platform = platform,
|
||||
scope = scope,
|
||||
)
|
||||
val bugReports = BugReportService(
|
||||
preferencesRepository = preferencesRepository,
|
||||
transport = transport,
|
||||
breadcrumbs = breadcrumbs,
|
||||
appVersion = appVersion,
|
||||
platform = platform,
|
||||
)
|
||||
return DiagnosticsCoordinator(
|
||||
preferencesRepository = preferencesRepository,
|
||||
transport = transport,
|
||||
breadcrumbs = breadcrumbs,
|
||||
telemetry = telemetry,
|
||||
crashReporter = crashReporter,
|
||||
bugReports = bugReports,
|
||||
scope = scope,
|
||||
included = included,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,85 +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
|
||||
private const val SizedBatchId = "00000000-0000-4000-8000-000000000000"
|
||||
|
||||
fun eventsBody(
|
||||
batchId: String,
|
||||
installId: String,
|
||||
appVersion: String,
|
||||
platform: String,
|
||||
events: List<TelemetryEvent>,
|
||||
): String = buildString {
|
||||
append('{')
|
||||
appendJsonField("batchId", batchId)
|
||||
append(',')
|
||||
appendJsonField("installId", installId)
|
||||
append(',')
|
||||
appendJsonField("appVersion", appVersion)
|
||||
append(',')
|
||||
appendJsonField("platform", platform)
|
||||
append(',')
|
||||
append("\"events\":[")
|
||||
events.forEachIndexed { index, event ->
|
||||
if (index > 0) append(',')
|
||||
append('{')
|
||||
appendJsonField("name", event.name)
|
||||
append(',')
|
||||
append("\"timestampMillis\":")
|
||||
append(event.timestampMillis)
|
||||
append(',')
|
||||
append("\"schemaVersion\":")
|
||||
append(event.schemaVersion)
|
||||
append(',')
|
||||
append("\"properties\":")
|
||||
appendStringMap(event.properties)
|
||||
append('}')
|
||||
}
|
||||
append("]}")
|
||||
}
|
||||
|
||||
fun eventBatchFitsRequest(events: List<TelemetryEvent>): Boolean =
|
||||
eventsBody(
|
||||
batchId = SizedBatchId,
|
||||
installId = "\u0000".repeat(MaxInstallIdBytes),
|
||||
appVersion = "\u0000".repeat(MaxAppVersionBytes),
|
||||
platform = "\u0000".repeat(MaxPlatformBytes),
|
||||
events = events,
|
||||
).encodeToByteArray().size <= MaxRequestBytes
|
||||
|
||||
fun crashBody(report: CrashReport): String = buildString {
|
||||
append('{')
|
||||
appendJsonField("id", report.id)
|
||||
append(',')
|
||||
append("\"timestampMillis\":")
|
||||
append(report.timestampMillis)
|
||||
append(',')
|
||||
appendJsonField("installId", report.installId)
|
||||
append(',')
|
||||
appendJsonField("appVersion", report.appVersion)
|
||||
append(',')
|
||||
appendJsonField("platform", report.platform)
|
||||
append(',')
|
||||
appendJsonField("exceptionType", report.exceptionType)
|
||||
append(',')
|
||||
appendJsonField("exceptionMessage", report.exceptionMessage)
|
||||
append(',')
|
||||
appendJsonField("stackTrace", report.stackTrace)
|
||||
append(',')
|
||||
append("\"diagnosticsEnabledAtCapture\":")
|
||||
append(requireNotNull(report.diagnosticsEnabledAtCapture) {
|
||||
"crash consent must be resolved before delivery"
|
||||
})
|
||||
append(',')
|
||||
append("\"schemaVersion\":")
|
||||
append(report.schemaVersion)
|
||||
append(',')
|
||||
append("\"breadcrumbs\":")
|
||||
appendBreadcrumbs(report.breadcrumbs)
|
||||
append('}')
|
||||
}
|
||||
|
||||
fun bugBody(report: BugReport): String {
|
||||
val logs = if (report.includeLogs) report.logs else ""
|
||||
@@ -149,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<Breadcrumb>) {
|
||||
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<String, String>) {
|
||||
append('{')
|
||||
sanitizeDiagnosticProperties(map).entries.forEachIndexed { index, (key, value) ->
|
||||
if (index > 0) append(',')
|
||||
appendJsonField(key, value)
|
||||
}
|
||||
append('}')
|
||||
}
|
||||
|
||||
|
||||
@@ -5,40 +5,6 @@ package com.vnidrop.app.diagnostics
|
||||
* intentionally abstracted; nothing here assumes a network backend.
|
||||
*/
|
||||
|
||||
data class TelemetryEvent(
|
||||
val name: String,
|
||||
val timestampMillis: Long,
|
||||
val properties: Map<String, String> = emptyMap(),
|
||||
val schemaVersion: Int = DiagnosticsSchemaVersion,
|
||||
)
|
||||
|
||||
/** One idempotent upload unit; [id] remains stable when delivery is retried. */
|
||||
data class TelemetryBatch(
|
||||
val id: String,
|
||||
val events: List<TelemetryEvent>,
|
||||
)
|
||||
|
||||
data class Breadcrumb(
|
||||
val name: String,
|
||||
val timestampMillis: Long,
|
||||
val properties: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
data class CrashReport(
|
||||
val id: String,
|
||||
val timestampMillis: Long,
|
||||
val installId: String,
|
||||
val appVersion: String,
|
||||
val platform: String,
|
||||
val exceptionType: String,
|
||||
val exceptionMessage: String,
|
||||
val stackTrace: String,
|
||||
val breadcrumbs: List<Breadcrumb>,
|
||||
/** `null` only while a startup crash is waiting for the persisted preference to load. */
|
||||
val diagnosticsEnabledAtCapture: Boolean?,
|
||||
val schemaVersion: Int = DiagnosticsSchemaVersion,
|
||||
)
|
||||
|
||||
data class DeviceSnapshot(
|
||||
val deviceName: String?,
|
||||
val deviceModel: String?,
|
||||
@@ -60,7 +26,6 @@ data class BugReport(
|
||||
val includeLogs: Boolean,
|
||||
val logs: String,
|
||||
val device: DeviceSnapshot,
|
||||
val breadcrumbs: List<Breadcrumb>,
|
||||
val schemaVersion: Int = DiagnosticsSchemaVersion,
|
||||
)
|
||||
|
||||
|
||||
@@ -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<String, String>): Map<String, String> {
|
||||
val sanitized = LinkedHashMap<String, String>(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()
|
||||
|
||||
@@ -1,50 +1,25 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
/** Network boundary for diagnostics; keep batching and validation client-side. */
|
||||
/** Network boundary for bug reports; keep validation client-side. */
|
||||
interface DiagnosticsTransport {
|
||||
suspend fun sendEvents(batch: TelemetryBatch): Result<Unit>
|
||||
suspend fun sendCrash(report: CrashReport): Result<Unit>
|
||||
suspend fun sendBugReport(report: BugReport): Result<Unit>
|
||||
}
|
||||
|
||||
internal class DiagnosticsUnavailableException : IllegalStateException("diagnostics delivery is not configured")
|
||||
|
||||
internal fun Throwable.isPermanentDiagnosticsPayloadRejection(): Boolean =
|
||||
this is DiagnosticsPayloadException ||
|
||||
(this is DiagnosticsHttpException && statusCode in setOf(400, 413, 415, 422))
|
||||
|
||||
/** Fails delivery without leaving the device. Used until a remote endpoint is configured. */
|
||||
class NoOpDiagnosticsTransport : DiagnosticsTransport {
|
||||
override suspend fun sendEvents(batch: TelemetryBatch): Result<Unit> = unavailable()
|
||||
override suspend fun sendCrash(report: CrashReport): Result<Unit> = unavailable()
|
||||
override suspend fun sendBugReport(report: BugReport): Result<Unit> = unavailable()
|
||||
|
||||
private fun unavailable(): Result<Unit> = Result.failure(DiagnosticsUnavailableException())
|
||||
override suspend fun sendBugReport(report: BugReport): Result<Unit> =
|
||||
Result.failure(DiagnosticsUnavailableException())
|
||||
}
|
||||
|
||||
/**
|
||||
* Test double that records calls and can fail on demand.
|
||||
*/
|
||||
class RecordingDiagnosticsTransport : DiagnosticsTransport {
|
||||
val eventBatches = mutableListOf<TelemetryBatch>()
|
||||
val events: List<List<TelemetryEvent>>
|
||||
get() = eventBatches.map(TelemetryBatch::events)
|
||||
val crashes = mutableListOf<CrashReport>()
|
||||
val bugReports = mutableListOf<BugReport>()
|
||||
var eventsResult: Result<Unit> = Result.success(Unit)
|
||||
var crashResult: Result<Unit> = Result.success(Unit)
|
||||
var bugResult: Result<Unit> = Result.success(Unit)
|
||||
|
||||
override suspend fun sendEvents(batch: TelemetryBatch): Result<Unit> {
|
||||
eventBatches += batch
|
||||
return eventsResult
|
||||
}
|
||||
|
||||
override suspend fun sendCrash(report: CrashReport): Result<Unit> {
|
||||
crashes += report
|
||||
return crashResult
|
||||
}
|
||||
|
||||
override suspend fun sendBugReport(report: BugReport): Result<Unit> {
|
||||
bugReports += report
|
||||
return bugResult
|
||||
|
||||
@@ -27,33 +27,6 @@ class HttpDiagnosticsTransport(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendEvents(batch: TelemetryBatch): Result<Unit> {
|
||||
if (batch.events.isEmpty()) return Result.success(Unit)
|
||||
if (batch.events.size > TelemetryRecorder.MaxEventsPerBatch) {
|
||||
return Result.failure(DiagnosticsPayloadException("diagnostics event batch is too large"))
|
||||
}
|
||||
if (batch.events.any { it.name.isBlank() || it.timestampMillis < 0 }) {
|
||||
return Result.failure(DiagnosticsPayloadException("diagnostics event batch is invalid"))
|
||||
}
|
||||
val installId = sanitizeDiagnosticsInstallId(installIdProvider())
|
||||
val body = DiagnosticsJson.eventsBody(
|
||||
batch.id,
|
||||
installId,
|
||||
appVersion.takeUtf8Bytes(DiagnosticsJson.MaxAppVersionBytes),
|
||||
platform.takeUtf8Bytes(DiagnosticsJson.MaxPlatformBytes),
|
||||
batch.events,
|
||||
)
|
||||
return postJson("/v1/events", body, installId, batch.id)
|
||||
}
|
||||
|
||||
override suspend fun sendCrash(report: CrashReport): Result<Unit> {
|
||||
if (report.diagnosticsEnabledAtCapture == null) {
|
||||
return Result.failure(DiagnosticsPayloadException("crash consent is unresolved"))
|
||||
}
|
||||
val body = DiagnosticsJson.crashBody(report)
|
||||
return postJson("/v1/crashes", body, report.installId, report.id)
|
||||
}
|
||||
|
||||
override suspend fun sendBugReport(report: BugReport): Result<Unit> {
|
||||
val body = DiagnosticsJson.bugBody(report)
|
||||
return postJson("/v1/bugs", body, report.installId, report.id)
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
/**
|
||||
* Durable crash envelopes written during process death and read on next launch.
|
||||
* Encoding is a simple line-oriented format (no kotlinx.serialization dependency).
|
||||
*/
|
||||
interface PendingCrashStore {
|
||||
fun write(report: CrashReport)
|
||||
fun list(): List<CrashReport>
|
||||
fun delete(id: String)
|
||||
fun prune(olderThanTimestampMillis: Long, maxCount: Int)
|
||||
}
|
||||
|
||||
expect fun createPendingCrashStore(appDataDir: String): PendingCrashStore
|
||||
|
||||
internal object CrashReportCodec {
|
||||
private const val FieldSep = "\u001f"
|
||||
private const val RecordSep = "\u001e"
|
||||
private const val Version2Prefix = "vnidrop-crash-v2\n"
|
||||
private const val MaxEncodedChars = 512 * 1024
|
||||
|
||||
fun encode(report: CrashReport): String = buildString {
|
||||
fun field(key: String, value: String) {
|
||||
append(key)
|
||||
append('=')
|
||||
append(value.hexEncode())
|
||||
append('\n')
|
||||
}
|
||||
append(Version2Prefix)
|
||||
field("id", report.id)
|
||||
field("ts", report.timestampMillis.toString())
|
||||
field("install", report.installId)
|
||||
field("app", report.appVersion)
|
||||
field("platform", report.platform)
|
||||
field("type", report.exceptionType)
|
||||
field("message", report.exceptionMessage)
|
||||
field("stack", report.stackTrace)
|
||||
field(
|
||||
"diag",
|
||||
when (report.diagnosticsEnabledAtCapture) {
|
||||
true -> "1"
|
||||
false -> "0"
|
||||
null -> "u"
|
||||
},
|
||||
)
|
||||
field("schema", report.schemaVersion.toString())
|
||||
val breadcrumbs = report.breadcrumbs.take(40)
|
||||
field("crumb.count", breadcrumbs.size.toString())
|
||||
breadcrumbs.forEachIndexed { crumbIndex, crumb ->
|
||||
field("crumb.$crumbIndex.ts", crumb.timestampMillis.toString())
|
||||
field("crumb.$crumbIndex.name", crumb.name)
|
||||
val properties = crumb.properties.entries.take(MaxDiagnosticProperties)
|
||||
field("crumb.$crumbIndex.prop.count", properties.size.toString())
|
||||
properties.forEachIndexed { propertyIndex, (key, value) ->
|
||||
field("crumb.$crumbIndex.prop.$propertyIndex.key", key)
|
||||
field("crumb.$crumbIndex.prop.$propertyIndex.value", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun decode(raw: String): CrashReport? {
|
||||
if (raw.isBlank() || raw.length > MaxEncodedChars) return null
|
||||
return if (raw.startsWith(Version2Prefix)) decodeVersion2(raw) else decodeLegacy(raw)
|
||||
}
|
||||
|
||||
private fun decodeVersion2(raw: String): CrashReport? {
|
||||
val map = linkedMapOf<String, String>()
|
||||
for (part in raw.removePrefix(Version2Prefix).lineSequence()) {
|
||||
if (part.isEmpty()) continue
|
||||
val eq = part.indexOf('=')
|
||||
if (eq <= 0) continue
|
||||
val key = part.substring(0, eq)
|
||||
val value = part.substring(eq + 1).hexDecode() ?: return null
|
||||
map[key] = value
|
||||
}
|
||||
val crumbCount = map["crumb.count"]?.toIntOrNull()?.takeIf { it in 0..40 } ?: return null
|
||||
val crumbs = buildList {
|
||||
repeat(crumbCount) { crumbIndex ->
|
||||
val timestamp = map["crumb.$crumbIndex.ts"]
|
||||
?.toLongOrNull()
|
||||
?.takeIf { it >= 0 }
|
||||
?: return null
|
||||
val name = map["crumb.$crumbIndex.name"]?.takeIf { it.isNotBlank() } ?: return null
|
||||
val propertyCount = map["crumb.$crumbIndex.prop.count"]
|
||||
?.toIntOrNull()
|
||||
?.takeIf { it in 0..MaxDiagnosticProperties }
|
||||
?: return null
|
||||
val properties = buildMap {
|
||||
repeat(propertyCount) { propertyIndex ->
|
||||
val key = map["crumb.$crumbIndex.prop.$propertyIndex.key"] ?: return null
|
||||
val value = map["crumb.$crumbIndex.prop.$propertyIndex.value"] ?: return null
|
||||
put(key, value)
|
||||
}
|
||||
}
|
||||
add(Breadcrumb(name = name, timestampMillis = timestamp, properties = properties))
|
||||
}
|
||||
}
|
||||
return reportFromFields(map, crumbs)
|
||||
}
|
||||
|
||||
private fun decodeLegacy(raw: String): CrashReport? {
|
||||
val map = linkedMapOf<String, String>()
|
||||
for (part in raw.split(FieldSep)) {
|
||||
if (part.isEmpty()) continue
|
||||
val eq = part.indexOf('=')
|
||||
if (eq <= 0) continue
|
||||
val key = part.substring(0, eq)
|
||||
val value = part.substring(eq + 1)
|
||||
.replace("\\n", "\n")
|
||||
.replace("\\r", "\r")
|
||||
map[key] = value
|
||||
}
|
||||
val crumbs = map["crumbs"].orEmpty()
|
||||
.split(RecordSep)
|
||||
.filter { it.isNotBlank() }
|
||||
.mapNotNull { entry ->
|
||||
val pieces = entry.split('|', limit = 3)
|
||||
if (pieces.size < 2) return@mapNotNull null
|
||||
val ts = pieces[0].toLongOrNull()?.takeIf { it >= 0 } ?: return@mapNotNull null
|
||||
val name = pieces[1].takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
val props = if (pieces.size > 2 && pieces[2].isNotBlank()) {
|
||||
pieces[2].split(',').mapNotNull { kv ->
|
||||
val colon = kv.indexOf(':')
|
||||
if (colon <= 0) null
|
||||
else kv.substring(0, colon) to kv.substring(colon + 1)
|
||||
}.toMap()
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
Breadcrumb(name = name, timestampMillis = ts, properties = props)
|
||||
}
|
||||
return reportFromFields(map, crumbs)
|
||||
}
|
||||
|
||||
private fun reportFromFields(
|
||||
map: Map<String, String>,
|
||||
crumbs: List<Breadcrumb>,
|
||||
): CrashReport? {
|
||||
val id = map["id"]?.takeIf(::isValidDiagnosticId) ?: return null
|
||||
val timestamp = map["ts"]?.toLongOrNull()?.takeIf { it >= 0 } ?: return null
|
||||
val exceptionType = map["type"]?.takeIf { it.isNotBlank() } ?: return null
|
||||
val schemaVersion = map["schema"]?.toIntOrNull()
|
||||
?.takeIf { it == DiagnosticsSchemaVersion }
|
||||
?: return null
|
||||
val diagnosticsEnabled: Boolean? = when (map["diag"]) {
|
||||
"1" -> true
|
||||
"0" -> false
|
||||
"u" -> null
|
||||
else -> return null
|
||||
}
|
||||
return CrashReport(
|
||||
id = id,
|
||||
timestampMillis = timestamp,
|
||||
installId = map["install"].orEmpty(),
|
||||
appVersion = map["app"].orEmpty(),
|
||||
platform = map["platform"].orEmpty(),
|
||||
exceptionType = exceptionType,
|
||||
exceptionMessage = map["message"].orEmpty(),
|
||||
stackTrace = map["stack"].orEmpty(),
|
||||
breadcrumbs = crumbs,
|
||||
diagnosticsEnabledAtCapture = diagnosticsEnabled,
|
||||
schemaVersion = schemaVersion,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun isValidDiagnosticId(id: String): Boolean =
|
||||
DiagnosticIdPattern.matches(id)
|
||||
|
||||
private val DiagnosticIdPattern =
|
||||
Regex("^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$")
|
||||
|
||||
private fun String.hexEncode(): String {
|
||||
val digits = "0123456789abcdef"
|
||||
return buildString(length * 2) {
|
||||
for (byte in this@hexEncode.encodeToByteArray()) {
|
||||
val value = byte.toInt() and 0xff
|
||||
append(digits[value ushr 4])
|
||||
append(digits[value and 0x0f])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.hexDecode(): String? {
|
||||
if (length % 2 != 0) return null
|
||||
val bytes = ByteArray(length / 2)
|
||||
for (index in bytes.indices) {
|
||||
val high = this[index * 2].digitToIntOrNull(16) ?: return null
|
||||
val low = this[index * 2 + 1].digitToIntOrNull(16) ?: return null
|
||||
bytes[index] = ((high shl 4) or low).toByte()
|
||||
}
|
||||
return runCatching { bytes.decodeToString(throwOnInvalidSequence = true) }.getOrNull()
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import com.vnidrop.app.logging.platformNowMillis
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.util.randomUuidString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.concurrent.atomics.AtomicReference
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
|
||||
/**
|
||||
* Product telemetry: sparse events, gated by diagnostics opt-in.
|
||||
* Events are buffered and flushed in batches when transport is available.
|
||||
*/
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
class TelemetryRecorder(
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
private val transport: DiagnosticsTransport,
|
||||
private val breadcrumbs: BreadcrumbBuffer,
|
||||
private val scope: CoroutineScope,
|
||||
private val maxBufferSize: Int = DefaultMaxBuffer,
|
||||
private val flushThreshold: Int = DefaultFlushThreshold,
|
||||
private val flushIntervalMillis: Long = DefaultFlushIntervalMillis,
|
||||
private val retryBackoffMillis: Long = DefaultRetryBackoffMillis,
|
||||
private val automaticRetryCount: Int = DefaultAutomaticRetryCount,
|
||||
private val included: Boolean = true,
|
||||
) {
|
||||
private val bufferMutex = Mutex()
|
||||
private val state = AtomicReference(TelemetryState())
|
||||
private val flushSignals = Channel<Unit>(Channel.CONFLATED)
|
||||
|
||||
init {
|
||||
require(maxBufferSize > 0) { "maxBufferSize must be positive" }
|
||||
require(flushThreshold > 0) { "flushThreshold must be positive" }
|
||||
require(flushIntervalMillis > 0) { "flushIntervalMillis must be positive" }
|
||||
require(retryBackoffMillis > 0) { "retryBackoffMillis must be positive" }
|
||||
require(automaticRetryCount >= 0) { "automaticRetryCount must not be negative" }
|
||||
if (included) {
|
||||
scope.launch {
|
||||
preferencesRepository.preferences
|
||||
.map { it.diagnosticsEnabled }
|
||||
.distinctUntilChanged()
|
||||
.collect { isEnabled ->
|
||||
updateState { current ->
|
||||
if (isEnabled) current.copy(enabled = true) else TelemetryState(enabled = false)
|
||||
}
|
||||
flushSignals.trySend(Unit)
|
||||
}
|
||||
}
|
||||
scope.launch { runAutomaticFlushes() }
|
||||
}
|
||||
}
|
||||
|
||||
fun record(name: String, properties: Map<String, String> = emptyMap()) {
|
||||
if (!included) return
|
||||
val sanitizedName = sanitizeDiagnosticName(name)
|
||||
if (sanitizedName.isBlank()) return
|
||||
val sanitizedProperties = sanitizeDiagnosticProperties(properties)
|
||||
breadcrumbs.add(sanitizedName, sanitizedProperties)
|
||||
val event = TelemetryEvent(
|
||||
name = sanitizedName,
|
||||
timestampMillis = platformNowMillis(),
|
||||
properties = sanitizedProperties,
|
||||
)
|
||||
while (true) {
|
||||
val current = state.load()
|
||||
if (current.enabled == false) return
|
||||
val remainingCapacity =
|
||||
(maxBufferSize - current.retryBatch?.events.orEmpty().size).coerceAtLeast(0)
|
||||
val nextBuffer = (current.buffer + event).takeLast(remainingCapacity)
|
||||
if (state.compareAndSet(current, current.copy(buffer = nextBuffer))) {
|
||||
flushSignals.trySend(Unit)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun flush(): Result<Unit> {
|
||||
return bufferMutex.withLock {
|
||||
var discardedFailure: Throwable? = null
|
||||
var outcome: Result<Unit>? = null
|
||||
while (outcome == null) {
|
||||
val current = state.load()
|
||||
if (current.enabled != true) return@withLock Result.success(Unit)
|
||||
val pendingRetry = current.retryBatch
|
||||
val events = if (pendingRetry == null) nextBatchEvents(current.buffer) else emptyList()
|
||||
if (pendingRetry == null && events.isEmpty()) {
|
||||
outcome = discardedFailure?.let { Result.failure(it) } ?: Result.success(Unit)
|
||||
continue
|
||||
}
|
||||
val batch: TelemetryBatch
|
||||
if (pendingRetry != null) {
|
||||
batch = pendingRetry
|
||||
} else {
|
||||
val prepared = TelemetryBatch(id = randomUuidString(), events = events)
|
||||
val next = current.copy(
|
||||
buffer = current.buffer.drop(events.size),
|
||||
retryBatch = prepared,
|
||||
)
|
||||
if (!state.compareAndSet(current, next)) continue
|
||||
batch = prepared
|
||||
}
|
||||
if (state.load().retryBatch != batch) continue
|
||||
val result = try {
|
||||
transport.sendEvents(batch)
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (error: Throwable) {
|
||||
Result.failure(error)
|
||||
}
|
||||
if (result.isSuccess) {
|
||||
clearRetryBatch(batch)
|
||||
continue
|
||||
}
|
||||
val error = result.exceptionOrNull() ?: IllegalStateException("diagnostics event delivery failed")
|
||||
if (error.isPermanentDiagnosticsPayloadRejection()) {
|
||||
clearRetryBatch(batch)
|
||||
discardedFailure = discardedFailure ?: error
|
||||
continue
|
||||
}
|
||||
outcome = result
|
||||
}
|
||||
checkNotNull(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
private fun nextBatchEvents(events: List<TelemetryEvent>): List<TelemetryEvent> {
|
||||
if (events.isEmpty()) return emptyList()
|
||||
var minimum = 1
|
||||
var maximum = minOf(events.size, MaxEventsPerBatch)
|
||||
var accepted = 1
|
||||
while (minimum <= maximum) {
|
||||
val candidateSize = minimum + (maximum - minimum) / 2
|
||||
if (DiagnosticsJson.eventBatchFitsRequest(events.take(candidateSize))) {
|
||||
accepted = candidateSize
|
||||
minimum = candidateSize + 1
|
||||
} else {
|
||||
maximum = candidateSize - 1
|
||||
}
|
||||
}
|
||||
return events.take(accepted)
|
||||
}
|
||||
|
||||
fun pendingCount(): Int {
|
||||
val current = state.load()
|
||||
return current.retryBatch?.events.orEmpty().size + current.buffer.size
|
||||
}
|
||||
|
||||
private suspend fun runAutomaticFlushes() {
|
||||
while (true) {
|
||||
flushSignals.receive()
|
||||
var retries = 0
|
||||
while (true) {
|
||||
val current = state.load()
|
||||
if (current.enabled != true || pendingCount() == 0) break
|
||||
if (current.retryBatch == null && pendingCount() < flushThreshold) {
|
||||
val signalled = withTimeoutOrNull(flushIntervalMillis) {
|
||||
flushSignals.receive()
|
||||
true
|
||||
} ?: false
|
||||
if (signalled) continue
|
||||
}
|
||||
|
||||
val result = flush()
|
||||
if (result.isSuccess || pendingCount() == 0) {
|
||||
retries = 0
|
||||
continue
|
||||
}
|
||||
val error = result.exceptionOrNull()
|
||||
if (error?.isPermanentDiagnosticsPayloadRejection() == true || retries >= automaticRetryCount) {
|
||||
break
|
||||
}
|
||||
retries += 1
|
||||
delay(retryBackoffMillis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearRetryBatch(batch: TelemetryBatch) {
|
||||
while (true) {
|
||||
val current = state.load()
|
||||
if (current.retryBatch != batch) return
|
||||
if (state.compareAndSet(current, current.copy(retryBatch = null))) return
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateState(update: (TelemetryState) -> TelemetryState) {
|
||||
while (true) {
|
||||
val current = state.load()
|
||||
if (state.compareAndSet(current, update(current))) return
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DefaultMaxBuffer = 100
|
||||
const val DefaultFlushThreshold = 20
|
||||
const val MaxEventsPerBatch = 50
|
||||
const val DefaultFlushIntervalMillis = 30_000L
|
||||
const val DefaultRetryBackoffMillis = 30_000L
|
||||
const val DefaultAutomaticRetryCount = 3
|
||||
}
|
||||
}
|
||||
|
||||
private data class TelemetryState(
|
||||
val enabled: Boolean? = null,
|
||||
val buffer: List<TelemetryEvent> = emptyList(),
|
||||
val retryBatch: TelemetryBatch? = null,
|
||||
)
|
||||
@@ -6,7 +6,6 @@ import com.vnidrop.app.PlatformEnvironment
|
||||
import com.vnidrop.app.AppDependencies
|
||||
import com.vnidrop.app.AppGraph
|
||||
import com.vnidrop.app.core.CoreGateway
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
|
||||
import com.vnidrop.app.logging.AppLogger
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||
@@ -38,14 +37,12 @@ class AppViewModel(
|
||||
private val repository: CoreGateway,
|
||||
preferencesRepository: PreferencesRepository,
|
||||
private val messages: UiMessageController,
|
||||
private val diagnostics: DiagnosticsCoordinator? = null,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(AppState())
|
||||
val state: StateFlow<AppState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
AppLogger.info("lifecycle", "app started", mapOf("platform" to environment.name))
|
||||
diagnostics?.record("app_open", mapOf("platform" to environment.name, "version" to environment.appVersion))
|
||||
viewModelScope.launch {
|
||||
val relaySettings = preferencesRepository.preferences.first().relaySettings
|
||||
repository.initialize(environment.defaultCoreDataDir, relaySettings).onFailure(messages::error)
|
||||
@@ -59,6 +56,5 @@ class AppViewModel(
|
||||
|
||||
fun selectDestination(destination: AppDestination) {
|
||||
_state.update { it.copy(destination = destination) }
|
||||
diagnostics?.record("nav_select", mapOf("destination" to destination.name))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig
|
||||
import com.vnidrop.app.ui.icons.AppIcon
|
||||
import com.vnidrop.app.ui.icons.PlatformIcon
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
@@ -46,18 +45,15 @@ 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
|
||||
|
||||
private const val PrivacyPolicyUrl = "https://github.com/vnidrop/vnidrop"
|
||||
private val PrivacyPolicyUrl = com.vnidrop.app.AppConfig.PRIVACY_POLICY_URL
|
||||
|
||||
@Composable
|
||||
internal fun AboutSettings(
|
||||
state: SettingsState,
|
||||
onDiagnosticsChanged: (Boolean) -> Unit,
|
||||
onReportBug: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
showBack: Boolean,
|
||||
@@ -130,17 +126,6 @@ internal fun AboutSettings(
|
||||
}
|
||||
|
||||
SettingsGroup {
|
||||
if (DiagnosticsBuildConfig.INCLUDED) {
|
||||
SettingsToggleRow(
|
||||
icon = AppIcon.Info,
|
||||
title = stringResource(Res.string.diagnostics_title),
|
||||
description = stringResource(Res.string.diagnostics_description),
|
||||
checked = state.diagnosticsEnabled,
|
||||
enabled = true,
|
||||
onCheckedChange = onDiagnosticsChanged,
|
||||
)
|
||||
SettingsDivider()
|
||||
}
|
||||
SettingsRow(
|
||||
icon = AppIcon.Bug,
|
||||
title = stringResource(Res.string.about_bug_report),
|
||||
|
||||
@@ -33,7 +33,6 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) {
|
||||
onResetFolder = viewModel::resetReceiveFolder,
|
||||
onNotificationsChanged = viewModel::setNotificationsEnabled,
|
||||
onOpenNotificationSettings = viewModel::openNotificationSettings,
|
||||
onDiagnosticsChanged = viewModel::setDiagnosticsEnabled,
|
||||
onBugWhatChanged = viewModel::setBugWhatHappened,
|
||||
onBugExpectedChanged = viewModel::setBugExpected,
|
||||
onBugStepsChanged = viewModel::setBugSteps,
|
||||
|
||||
@@ -23,7 +23,6 @@ fun SettingsScreen(
|
||||
onResetFolder: () -> Unit,
|
||||
onNotificationsChanged: (Boolean) -> Unit,
|
||||
onOpenNotificationSettings: () -> Unit,
|
||||
onDiagnosticsChanged: (Boolean) -> Unit,
|
||||
onBugWhatChanged: (String) -> Unit,
|
||||
onBugExpectedChanged: (String) -> Unit,
|
||||
onBugStepsChanged: (String) -> Unit,
|
||||
@@ -62,7 +61,6 @@ fun SettingsScreen(
|
||||
onResetFolder = onResetFolder,
|
||||
onNotificationsChanged = onNotificationsChanged,
|
||||
onOpenNotificationSettings = onOpenNotificationSettings,
|
||||
onDiagnosticsChanged = onDiagnosticsChanged,
|
||||
onBugWhatChanged = onBugWhatChanged,
|
||||
onBugExpectedChanged = onBugExpectedChanged,
|
||||
onBugStepsChanged = onBugStepsChanged,
|
||||
@@ -105,7 +103,6 @@ fun SettingsScreen(
|
||||
onResetFolder = onResetFolder,
|
||||
onNotificationsChanged = onNotificationsChanged,
|
||||
onOpenNotificationSettings = onOpenNotificationSettings,
|
||||
onDiagnosticsChanged = onDiagnosticsChanged,
|
||||
onBugWhatChanged = onBugWhatChanged,
|
||||
onBugExpectedChanged = onBugExpectedChanged,
|
||||
onBugStepsChanged = onBugStepsChanged,
|
||||
@@ -140,7 +137,6 @@ private fun SettingsSectionContent(
|
||||
onResetFolder: () -> Unit,
|
||||
onNotificationsChanged: (Boolean) -> Unit,
|
||||
onOpenNotificationSettings: () -> Unit,
|
||||
onDiagnosticsChanged: (Boolean) -> Unit,
|
||||
onBugWhatChanged: (String) -> Unit,
|
||||
onBugExpectedChanged: (String) -> Unit,
|
||||
onBugStepsChanged: (String) -> Unit,
|
||||
@@ -184,7 +180,6 @@ private fun SettingsSectionContent(
|
||||
)
|
||||
SettingsSection.About -> AboutSettings(
|
||||
state = state,
|
||||
onDiagnosticsChanged = onDiagnosticsChanged,
|
||||
onReportBug = { onSectionSelected(SettingsSection.BugReport) },
|
||||
onBack = onBack,
|
||||
showBack = showBack,
|
||||
|
||||
@@ -16,8 +16,6 @@ import com.vnidrop.app.core.TransferStatus
|
||||
import com.vnidrop.app.core.usesCustomRelayUrls
|
||||
import com.vnidrop.app.diagnostics.BugReportDraft
|
||||
import com.vnidrop.app.diagnostics.BugReportService
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
|
||||
import com.vnidrop.app.notifications.LocalNotificationService
|
||||
import com.vnidrop.app.notifications.NotificationPermission
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
@@ -43,8 +41,6 @@ import vnidrop.shared.generated.resources.bug_report_missing_what
|
||||
import vnidrop.shared.generated.resources.bug_report_submit_failed
|
||||
import vnidrop.shared.generated.resources.bug_report_submitted
|
||||
import vnidrop.shared.generated.resources.button_open_settings
|
||||
import vnidrop.shared.generated.resources.diagnostics_disabled_message
|
||||
import vnidrop.shared.generated.resources.diagnostics_enabled_message
|
||||
import vnidrop.shared.generated.resources.notifications_enabled_message
|
||||
import vnidrop.shared.generated.resources.notifications_permission_denied
|
||||
import vnidrop.shared.generated.resources.notifications_settings_open_failed
|
||||
@@ -102,7 +98,6 @@ data class SettingsState(
|
||||
val endpointId: String? = null,
|
||||
val notificationsEnabled: Boolean = false,
|
||||
val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined,
|
||||
val diagnosticsEnabled: Boolean = false,
|
||||
val deviceInfo: DeviceInfo? = null,
|
||||
val appVersion: String = "",
|
||||
val isLoadingDeviceInfo: Boolean = false,
|
||||
@@ -138,8 +133,6 @@ class SettingsViewModel(
|
||||
private val notifications: LocalNotificationService,
|
||||
private val messages: UiMessageController,
|
||||
private val bugReports: BugReportService,
|
||||
private val diagnostics: DiagnosticsCoordinator? = null,
|
||||
private val diagnosticsIncluded: Boolean = DiagnosticsBuildConfig.INCLUDED,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(
|
||||
SettingsState(
|
||||
@@ -167,7 +160,6 @@ class SettingsViewModel(
|
||||
receiveFolder = receiveFolder,
|
||||
themeMode = preferences.themeMode,
|
||||
notificationsEnabled = preferences.notificationsEnabled,
|
||||
diagnosticsEnabled = preferences.diagnosticsEnabled,
|
||||
savedRelaySettings = preferences.relaySettings,
|
||||
relayMode = if (hasLocalRelayDraft) current.relayMode else preferences.relaySettings.mode,
|
||||
relayUrls = if (hasLocalRelayDraft) {
|
||||
@@ -512,25 +504,6 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun setDiagnosticsEnabled(enabled: Boolean) {
|
||||
if (!diagnosticsIncluded) return
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setDiagnosticsEnabled(enabled)
|
||||
diagnostics?.record(
|
||||
if (enabled) "diagnostics_enabled" else "diagnostics_disabled",
|
||||
)
|
||||
messages.show(
|
||||
UiMessage(
|
||||
UiText.Resource(
|
||||
if (enabled) Res.string.diagnostics_enabled_message
|
||||
else Res.string.diagnostics_disabled_message,
|
||||
),
|
||||
UiMessageTone.Success,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setBugWhatHappened(value: String) = _state.update { it.copy(bugWhatHappened = value) }
|
||||
fun setBugExpected(value: String) = _state.update { it.copy(bugExpected = value) }
|
||||
fun setBugSteps(value: String) = _state.update { it.copy(bugSteps = value) }
|
||||
@@ -565,7 +538,6 @@ class SettingsViewModel(
|
||||
)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
diagnostics?.record("bug_report_submitted")
|
||||
_state.update {
|
||||
it.copy(
|
||||
isSubmittingBugReport = false,
|
||||
|
||||
@@ -24,9 +24,7 @@ data class AppPreferences(
|
||||
val receiveFolder: ReceiveFolder,
|
||||
val themeMode: ThemeMode,
|
||||
val notificationsEnabled: Boolean,
|
||||
/** Master opt-in for automatic telemetry + crash upload. Bug reports remain available always. */
|
||||
val diagnosticsEnabled: Boolean = false,
|
||||
/** Stable anonymous install id; never an account or advertising id. */
|
||||
/** Stable anonymous install id for bug-report correlation; never an account or advertising id. */
|
||||
val diagnosticsInstallId: String = "",
|
||||
val relaySettings: RelaySettings = RelaySettings(),
|
||||
)
|
||||
@@ -36,7 +34,6 @@ class AppPreferencesDefaults(
|
||||
val receiveFolder: ReceiveFolder,
|
||||
val themeMode: ThemeMode,
|
||||
val notificationsEnabled: Boolean = false,
|
||||
val diagnosticsEnabled: Boolean = false,
|
||||
)
|
||||
|
||||
interface PreferencesRepository {
|
||||
@@ -46,7 +43,6 @@ interface PreferencesRepository {
|
||||
suspend fun resetReceiveFolder()
|
||||
suspend fun setThemeMode(mode: ThemeMode)
|
||||
suspend fun setNotificationsEnabled(enabled: Boolean)
|
||||
suspend fun setDiagnosticsEnabled(enabled: Boolean)
|
||||
suspend fun setRelaySettings(settings: RelaySettings)
|
||||
/** Ensures a durable install id exists and returns it. */
|
||||
suspend fun ensureDiagnosticsInstallId(): String
|
||||
@@ -83,7 +79,6 @@ class AppPreferencesRepository(
|
||||
receiveFolder = resolveReceiveFolder(prefs, defaults.receiveFolder),
|
||||
themeMode = prefs[PreferenceKeys.ThemeMode]?.let { themeModeOrNull(it) } ?: defaults.themeMode,
|
||||
notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled,
|
||||
diagnosticsEnabled = prefs[PreferenceKeys.DiagnosticsEnabled] ?: defaults.diagnosticsEnabled,
|
||||
diagnosticsInstallId = prefs[PreferenceKeys.DiagnosticsInstallId].orEmpty(),
|
||||
relaySettings = RelaySettings(
|
||||
mode = relayMode,
|
||||
@@ -122,12 +117,6 @@ class AppPreferencesRepository(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setDiagnosticsEnabled(enabled: Boolean) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[PreferenceKeys.DiagnosticsEnabled] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setRelaySettings(settings: RelaySettings) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[PreferenceKeys.RelayMode] = settings.mode.name
|
||||
@@ -160,7 +149,6 @@ private object PreferenceKeys {
|
||||
val ReceiveFolderDisplayName = stringPreferencesKey("receive_folder_display_name")
|
||||
val ThemeMode = stringPreferencesKey("theme_mode")
|
||||
val NotificationsEnabled = booleanPreferencesKey("notifications_enabled")
|
||||
val DiagnosticsEnabled = booleanPreferencesKey("diagnostics_enabled")
|
||||
val DiagnosticsInstallId = stringPreferencesKey("diagnostics_install_id")
|
||||
val RelayMode = stringPreferencesKey("relay_mode")
|
||||
val RelayUrls = stringPreferencesKey("relay_urls")
|
||||
|
||||
@@ -4,83 +4,25 @@ import com.vnidrop.app.DeviceInfo
|
||||
import com.vnidrop.app.core.ReceiveFolder
|
||||
import com.vnidrop.app.core.ReceiveFolderKind
|
||||
import com.vnidrop.app.preferences.AppPreferences
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.support.FakePreferencesRepository
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DiagnosticsTest {
|
||||
@Test
|
||||
fun diagnosticsJsonEscapesAndShapesPayloads() {
|
||||
val eventsJson = DiagnosticsJson.eventsBody(
|
||||
batchId = "batch-1",
|
||||
installId = "inst-1",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
events = listOf(
|
||||
TelemetryEvent("app_open", 10L, mapOf("a" to "quote\"here")),
|
||||
),
|
||||
)
|
||||
assertTrue(eventsJson.contains("\"batchId\":\"batch-1\""))
|
||||
assertTrue(eventsJson.contains("\"installId\":\"inst-1\""))
|
||||
assertTrue(eventsJson.contains("\"name\":\"app_open\""))
|
||||
assertTrue(eventsJson.contains("quote\\\"here"))
|
||||
|
||||
val crashJson = DiagnosticsJson.crashBody(
|
||||
CrashReport(
|
||||
id = "c1",
|
||||
timestampMillis = 1L,
|
||||
installId = "inst",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
exceptionType = "E",
|
||||
exceptionMessage = "line\nbreak",
|
||||
stackTrace = "stack",
|
||||
breadcrumbs = emptyList(),
|
||||
diagnosticsEnabledAtCapture = true,
|
||||
),
|
||||
)
|
||||
assertTrue(crashJson.contains("line\\nbreak"))
|
||||
assertTrue(crashJson.contains("\"diagnosticsEnabledAtCapture\":true"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun diagnosticsJsonKeepsEscapedBugPayloadWithinWorkerLimit() {
|
||||
val report = BugReport(
|
||||
id = "b",
|
||||
timestampMillis = 1L,
|
||||
installId = "i",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
whatHappened = "w",
|
||||
expected = "e",
|
||||
steps = "",
|
||||
contact = "",
|
||||
includeLogs = true,
|
||||
val report = bugReport(
|
||||
logs = "\n".repeat(BugReportService.MaxLogBytes),
|
||||
device = DeviceSnapshot(null, null, "OS", null, null),
|
||||
breadcrumbs = emptyList(),
|
||||
includeLogs = true,
|
||||
)
|
||||
|
||||
val body = DiagnosticsJson.bugBody(report)
|
||||
@@ -91,67 +33,24 @@ class DiagnosticsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun httpTransportPostsExpectedPaths() = runTest {
|
||||
fun httpTransportPostsBugReportToExpectedPath() = runTest {
|
||||
val calls = mutableListOf<Pair<String, String>>()
|
||||
val acknowledgementIds = ArrayDeque(listOf("batch-1", "c", "b"))
|
||||
val transport = HttpDiagnosticsTransport(
|
||||
baseUrl = "https://diag.example",
|
||||
ingestKey = "secret",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
installIdProvider = { "install-x" },
|
||||
post = { url, headers, body ->
|
||||
assertEquals("secret", headers["X-VniDrop-Key"])
|
||||
calls += url to body
|
||||
PlatformHttpResponse(
|
||||
202,
|
||||
"""{"ok":true,"id":"${acknowledgementIds.removeFirst()}","stored":1}""",
|
||||
)
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"b","stored":1}""")
|
||||
},
|
||||
)
|
||||
val eventResult = transport.sendEvents(
|
||||
TelemetryBatch("batch-1", listOf(TelemetryEvent("nav", 1L))),
|
||||
)
|
||||
assertTrue(eventResult.isSuccess)
|
||||
assertEquals("https://diag.example/v1/events", calls[0].first)
|
||||
assertTrue(calls[0].second.contains("install-x"))
|
||||
|
||||
val crashResult = transport.sendCrash(
|
||||
CrashReport(
|
||||
id = "c",
|
||||
timestampMillis = 1L,
|
||||
installId = "i",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
exceptionType = "E",
|
||||
exceptionMessage = "m",
|
||||
stackTrace = "s",
|
||||
breadcrumbs = emptyList(),
|
||||
diagnosticsEnabledAtCapture = true,
|
||||
),
|
||||
)
|
||||
assertTrue(crashResult.isSuccess)
|
||||
assertEquals("https://diag.example/v1/crashes", calls[1].first)
|
||||
val bugResult = transport.sendBugReport(bugReport())
|
||||
|
||||
val bugResult = transport.sendBugReport(
|
||||
BugReport(
|
||||
id = "b",
|
||||
timestampMillis = 1L,
|
||||
installId = "i",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
whatHappened = "w",
|
||||
expected = "e",
|
||||
steps = "",
|
||||
contact = "",
|
||||
includeLogs = false,
|
||||
logs = "",
|
||||
device = DeviceSnapshot(null, null, "OS", null, null),
|
||||
breadcrumbs = emptyList(),
|
||||
),
|
||||
)
|
||||
assertTrue(bugResult.isSuccess)
|
||||
assertEquals("https://diag.example/v1/bugs", calls[2].first)
|
||||
assertEquals("https://diag.example/v1/bugs", calls.single().first)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -162,12 +61,12 @@ class DiagnosticsTest {
|
||||
post = { _, _, _ ->
|
||||
PlatformHttpResponse(
|
||||
202,
|
||||
"""{"\u006f\u006b":true,"id":"batch-\u0031","metadata":{"stored":1,"flags":[true,null]}}""",
|
||||
"""{"ok":true,"id":"b","metadata":{"stored":1,"flags":[true,null]}}""",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assertTrue(transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L)))).isSuccess)
|
||||
assertTrue(transport.sendBugReport(bugReport()).isSuccess)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -177,9 +76,7 @@ class DiagnosticsTest {
|
||||
ingestKey = "secret",
|
||||
post = { _, _, _ -> PlatformHttpResponse(401, """{"error":"unauthorized"}""") },
|
||||
)
|
||||
assertTrue(
|
||||
transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L)))).isFailure,
|
||||
)
|
||||
assertTrue(transport.sendBugReport(bugReport()).isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -191,7 +88,7 @@ class DiagnosticsTest {
|
||||
)
|
||||
|
||||
assertFailsWith<CancellationException> {
|
||||
transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1))))
|
||||
transport.sendBugReport(bugReport())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +100,7 @@ class DiagnosticsTest {
|
||||
PlatformHttpResponse(202, """{"ok":false}"""),
|
||||
PlatformHttpResponse(202, """{,"ok":true}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"different"}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"batch-4","id":"batch-4"}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"b","id":"b"}"""),
|
||||
),
|
||||
)
|
||||
val transport = HttpDiagnosticsTransport(
|
||||
@@ -212,10 +109,8 @@ class DiagnosticsTest {
|
||||
post = { _, _, _ -> responses.removeFirst() },
|
||||
)
|
||||
|
||||
repeat(5) { index ->
|
||||
val result = transport.sendEvents(
|
||||
TelemetryBatch("batch-$index", listOf(TelemetryEvent("x", 1L))),
|
||||
)
|
||||
repeat(5) {
|
||||
val result = transport.sendBugReport(bugReport())
|
||||
assertIs<DiagnosticsProtocolException>(result.exceptionOrNull())
|
||||
}
|
||||
}
|
||||
@@ -224,11 +119,11 @@ class DiagnosticsTest {
|
||||
fun httpTransportRejectsAmbiguousOrMalformedJsonAcknowledgement() = runTest {
|
||||
val responses = ArrayDeque(
|
||||
listOf(
|
||||
PlatformHttpResponse(202, """{"ok":true,"\u006f\u006b":true,"id":"batch-0"}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"ok":true,"id":"b"}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":true}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"batch-2","stored":01}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"batch-3",}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"batch-4"} trailing"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"b","stored":01}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"b",}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"b"} trailing"""),
|
||||
),
|
||||
)
|
||||
val transport = HttpDiagnosticsTransport(
|
||||
@@ -237,10 +132,8 @@ class DiagnosticsTest {
|
||||
post = { _, _, _ -> responses.removeFirst() },
|
||||
)
|
||||
|
||||
repeat(5) { index ->
|
||||
val result = transport.sendEvents(
|
||||
TelemetryBatch("batch-$index", listOf(TelemetryEvent("x", 1L))),
|
||||
)
|
||||
repeat(5) {
|
||||
val result = transport.sendBugReport(bugReport())
|
||||
assertIs<DiagnosticsProtocolException>(result.exceptionOrNull())
|
||||
}
|
||||
}
|
||||
@@ -288,9 +181,7 @@ class DiagnosticsTest {
|
||||
|
||||
@Test
|
||||
fun noOpTransportReportsUnavailableDelivery() = runTest {
|
||||
val result = NoOpDiagnosticsTransport().sendEvents(
|
||||
TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L))),
|
||||
)
|
||||
val result = NoOpDiagnosticsTransport().sendBugReport(bugReport())
|
||||
assertIs<DiagnosticsUnavailableException>(result.exceptionOrNull())
|
||||
}
|
||||
|
||||
@@ -313,575 +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 telemetryIgnoresEventsWhenDiagnosticsDisabled() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = false)
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val breadcrumbs = BreadcrumbBuffer()
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = breadcrumbs,
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 1,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
recorder.record("app_open")
|
||||
advanceUntilIdle()
|
||||
assertEquals(0, transport.events.size)
|
||||
assertEquals(1, breadcrumbs.snapshot().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun excludedTelemetryDoesNotStartOrRecordEvents() = runTest {
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val breadcrumbs = BreadcrumbBuffer()
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = fakePrefs(diagnosticsEnabled = true),
|
||||
transport = transport,
|
||||
breadcrumbs = breadcrumbs,
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 1,
|
||||
included = false,
|
||||
)
|
||||
|
||||
recorder.record("app_open")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(0, recorder.pendingCount())
|
||||
assertTrue(transport.events.isEmpty())
|
||||
assertTrue(breadcrumbs.snapshot().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetryRetainsColdStartEventsUntilConsentLoads() = runTest {
|
||||
val backing = fakePrefs(diagnosticsEnabled = true)
|
||||
val preferenceGate = CompletableDeferred<Unit>()
|
||||
val delayedPreferences = object : PreferencesRepository by backing {
|
||||
override val preferences = flow {
|
||||
preferenceGate.await()
|
||||
emitAll(backing.preferences)
|
||||
}
|
||||
}
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = delayedPreferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 20,
|
||||
)
|
||||
runCurrent()
|
||||
|
||||
recorder.record("app_open")
|
||||
assertEquals(1, recorder.pendingCount())
|
||||
preferenceGate.complete(Unit)
|
||||
runCurrent()
|
||||
assertTrue(recorder.flush().isSuccess)
|
||||
|
||||
assertEquals(listOf("app_open"), transport.events.single().map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetryBuffersAndFlushesWhenEnabled() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 2,
|
||||
maxBufferSize = 10,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
recorder.record("one")
|
||||
recorder.record("two")
|
||||
advanceUntilIdle()
|
||||
assertEquals(1, transport.events.size)
|
||||
assertEquals(listOf("one", "two"), transport.events.single().map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetryFlushesSparseEventsAfterTheInterval() = runTest {
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = fakePrefs(diagnosticsEnabled = true),
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 20,
|
||||
flushIntervalMillis = 1_000,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
recorder.record("sparse")
|
||||
|
||||
advanceTimeBy(999)
|
||||
runCurrent()
|
||||
assertTrue(transport.eventBatches.isEmpty())
|
||||
advanceTimeBy(1)
|
||||
runCurrent()
|
||||
assertEquals(listOf("sparse"), transport.events.single().map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetryCoalescesAutomaticRetriesWithBackoff() = runTest {
|
||||
val transport = RecordingDiagnosticsTransport().apply {
|
||||
eventsResult = Result.failure(IllegalStateException("offline"))
|
||||
}
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = fakePrefs(diagnosticsEnabled = true),
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 1,
|
||||
flushIntervalMillis = 10_000,
|
||||
retryBackoffMillis = 1_000,
|
||||
automaticRetryCount = 1,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
recorder.record("retry")
|
||||
runCurrent()
|
||||
assertEquals(1, transport.eventBatches.size)
|
||||
|
||||
advanceTimeBy(999)
|
||||
runCurrent()
|
||||
assertEquals(1, transport.eventBatches.size)
|
||||
advanceTimeBy(1)
|
||||
runCurrent()
|
||||
assertEquals(2, transport.eventBatches.size)
|
||||
advanceTimeBy(10_000)
|
||||
runCurrent()
|
||||
assertEquals(2, transport.eventBatches.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetryFlushesAtMostFiftyEventsPerBatch() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 101,
|
||||
maxBufferSize = 100,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
repeat(75) { recorder.record("event-$it") }
|
||||
|
||||
assertTrue(recorder.flush().isSuccess)
|
||||
assertEquals(listOf(50, 25), transport.eventBatches.map { it.events.size })
|
||||
assertTrue(transport.eventBatches.all { it.events.size <= TelemetryRecorder.MaxEventsPerBatch })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetrySplitsBatchesByEscapedRequestBytes() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val requestBodies = mutableListOf<String>()
|
||||
val transport = HttpDiagnosticsTransport(
|
||||
baseUrl = "https://diag.example",
|
||||
ingestKey = "secret",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
installIdProvider = { "test-install" },
|
||||
post = { _, _, body ->
|
||||
requestBodies += body
|
||||
val id = Regex(""""batchId":"([^"]+)"""").find(body)?.groupValues?.get(1)
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"$id"}""")
|
||||
},
|
||||
)
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 101,
|
||||
maxBufferSize = 100,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
val properties = LinkedHashMap<String, String>().apply {
|
||||
repeat(MaxDiagnosticProperties) { index ->
|
||||
put("key-$index-${"\u0001".repeat(40)}", "\u0001".repeat(MaxDiagnosticPropertyValueBytes))
|
||||
}
|
||||
}
|
||||
repeat(50) { index ->
|
||||
recorder.record("event-$index-${"\u0001".repeat(64)}", properties)
|
||||
}
|
||||
|
||||
assertTrue(recorder.flush().isSuccess)
|
||||
assertTrue(requestBodies.size > 1)
|
||||
assertTrue(requestBodies.all { it.encodeToByteArray().size <= DiagnosticsJson.MaxRequestBytes })
|
||||
assertEquals(50, requestBodies.sumOf { body -> "\"schemaVersion\"".toRegex().findAll(body).count() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetrySanitizesNamesAndPropertiesToServerByteLimits() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 100,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
val properties = LinkedHashMap<String, String>().apply {
|
||||
repeat(20) { index -> put("key-$index-${"🙂".repeat(20)}", "🙂".repeat(100)) }
|
||||
}
|
||||
|
||||
recorder.record("🙂".repeat(100), properties)
|
||||
assertTrue(recorder.flush().isSuccess)
|
||||
|
||||
val event = transport.eventBatches.single().events.single()
|
||||
assertTrue(event.name.encodeToByteArray().size <= MaxDiagnosticNameBytes)
|
||||
assertEquals(MaxDiagnosticProperties, event.properties.size)
|
||||
assertTrue(event.properties.keys.all { it.encodeToByteArray().size <= MaxDiagnosticPropertyKeyBytes })
|
||||
assertTrue(event.properties.values.all { it.encodeToByteArray().size <= MaxDiagnosticPropertyValueBytes })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetryKeepsConcurrentRecordsWithoutExceedingItsBuffer() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 101,
|
||||
maxBufferSize = 100,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
coroutineScope {
|
||||
repeat(100) { index ->
|
||||
launch(Dispatchers.Default) { recorder.record("event-$index") }
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
100,
|
||||
recorder.pendingCount() + transport.eventBatches.sumOf { it.events.size },
|
||||
)
|
||||
assertTrue(recorder.flush().isSuccess)
|
||||
assertEquals(100, transport.eventBatches.sumOf { it.events.size })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetryRetryReusesBatchIdAndEvents() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val transport = RecordingDiagnosticsTransport().apply {
|
||||
eventsResult = Result.failure(IllegalStateException("offline"))
|
||||
}
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 10,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
recorder.record("one")
|
||||
recorder.record("two")
|
||||
|
||||
assertTrue(recorder.flush().isFailure)
|
||||
val firstAttempt = transport.eventBatches.single()
|
||||
transport.eventsResult = Result.success(Unit)
|
||||
assertTrue(recorder.flush().isSuccess)
|
||||
|
||||
assertEquals(listOf(firstAttempt, firstAttempt), transport.eventBatches)
|
||||
assertEquals(0, recorder.pendingCount())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetryDoesNotRequeuePermanentlyRejectedPayload() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val transport = RecordingDiagnosticsTransport().apply {
|
||||
eventsResult = Result.failure(DiagnosticsHttpException(400, "/v1/events"))
|
||||
}
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 10,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
recorder.record("invalid")
|
||||
|
||||
assertTrue(recorder.flush().isFailure)
|
||||
assertEquals(0, recorder.pendingCount())
|
||||
transport.eventsResult = Result.success(Unit)
|
||||
assertTrue(recorder.flush().isSuccess)
|
||||
assertEquals(1, transport.eventBatches.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetryClearsBufferWhenOptedOut() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 100,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
recorder.record("queued")
|
||||
assertEquals(1, recorder.pendingCount())
|
||||
preferences.setDiagnosticsEnabled(false)
|
||||
advanceUntilIdle()
|
||||
assertEquals(0, recorder.pendingCount())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashCodecRoundTrips() {
|
||||
val original = CrashReport(
|
||||
id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
timestampMillis = 42L,
|
||||
installId = "install",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
exceptionType = "IllegalStateException",
|
||||
exceptionMessage = "boom\nline\\nliteral\u001fseparator",
|
||||
stackTrace = "stack\ntrace\u001erecord",
|
||||
breadcrumbs = listOf(
|
||||
Breadcrumb("open|send", 1L, mapOf("screen:key" to "send,value|next")),
|
||||
),
|
||||
diagnosticsEnabledAtCapture = true,
|
||||
)
|
||||
val decoded = CrashReportCodec.decode(CrashReportCodec.encode(original))
|
||||
assertEquals(original, decoded)
|
||||
assertNull(CrashReportCodec.decode(CrashReportCodec.encode(original.copy(id = "../../escape"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashCodecMigratesLegacyV1Envelope() {
|
||||
val raw = listOf(
|
||||
"id=bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
"ts=42",
|
||||
"install=legacy-install",
|
||||
"app=0.9",
|
||||
"platform=Desktop",
|
||||
"type=IllegalStateException",
|
||||
"message=first\\nsecond",
|
||||
"stack=frame one\\nframe two",
|
||||
"diag=1",
|
||||
"schema=1",
|
||||
"crumbs=1|opened|screen:send",
|
||||
).joinToString("\u001f")
|
||||
|
||||
val report = requireNotNull(CrashReportCodec.decode(raw))
|
||||
|
||||
assertEquals("first\nsecond", report.exceptionMessage)
|
||||
assertEquals("frame one\nframe two", report.stackTrace)
|
||||
assertEquals(true, report.diagnosticsEnabledAtCapture)
|
||||
assertEquals(
|
||||
listOf(Breadcrumb("opened", 1, mapOf("screen" to "send"))),
|
||||
report.breadcrumbs,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashReporterPersistsAndFlushesOnlyOptedInCrashes() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val store = InMemoryPendingCrashStore()
|
||||
val reporter = CrashReporter(
|
||||
store = store,
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
)
|
||||
reporter.startObservingPreferences()
|
||||
advanceUntilIdle()
|
||||
|
||||
val optedIn = reporter.capture(RuntimeException("a"), diagnosticsEnabledOverride = true)
|
||||
reporter.capture(RuntimeException("b"), diagnosticsEnabledOverride = false)
|
||||
assertEquals(1, store.list().size)
|
||||
|
||||
reporter.flushPending()
|
||||
assertEquals(1, transport.crashes.size)
|
||||
assertEquals(optedIn.id, transport.crashes.single().id)
|
||||
assertTrue(store.list().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashReporterRetainsCrashWhenDeliveryIsUnavailable() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val store = InMemoryPendingCrashStore()
|
||||
val reporter = CrashReporter(
|
||||
store = store,
|
||||
preferencesRepository = preferences,
|
||||
transport = NoOpDiagnosticsTransport(),
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
)
|
||||
reporter.capture(RuntimeException("boom"), diagnosticsEnabledOverride = true)
|
||||
|
||||
reporter.flushPending()
|
||||
assertEquals(1, store.list().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashReporterDropsPermanentPayloadFailuresAndStopsAfterTransientFailures() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val permanentTransport = RecordingDiagnosticsTransport().apply {
|
||||
crashResult = Result.failure(DiagnosticsHttpException(400, "/v1/crashes"))
|
||||
}
|
||||
val permanentStore = InMemoryPendingCrashStore()
|
||||
val permanentReporter = CrashReporter(
|
||||
store = permanentStore,
|
||||
preferencesRepository = preferences,
|
||||
transport = permanentTransport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
)
|
||||
permanentReporter.capture(RuntimeException("invalid"), diagnosticsEnabledOverride = true)
|
||||
permanentReporter.flushPending()
|
||||
assertTrue(permanentStore.list().isEmpty())
|
||||
|
||||
val transientTransport = RecordingDiagnosticsTransport().apply {
|
||||
crashResult = Result.failure(IllegalStateException("offline"))
|
||||
}
|
||||
val transientStore = InMemoryPendingCrashStore()
|
||||
val transientReporter = CrashReporter(
|
||||
store = transientStore,
|
||||
preferencesRepository = preferences,
|
||||
transport = transientTransport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
)
|
||||
repeat(2) {
|
||||
transientReporter.capture(RuntimeException("offline-$it"), diagnosticsEnabledOverride = true)
|
||||
}
|
||||
transientReporter.flushPending()
|
||||
assertEquals(1, transientTransport.crashes.size)
|
||||
assertEquals(2, transientStore.list().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashReporterResolvesStartupConsentBeforeUploading() = runTest {
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val store = InMemoryPendingCrashStore()
|
||||
val reporter = CrashReporter(
|
||||
store = store,
|
||||
preferencesRepository = fakePrefs(diagnosticsEnabled = true),
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
)
|
||||
val startupCrash = reporter.capture(RuntimeException("startup"))
|
||||
assertNull(startupCrash.diagnosticsEnabledAtCapture)
|
||||
|
||||
reporter.flushPending()
|
||||
|
||||
assertEquals(true, transport.crashes.single().diagnosticsEnabledAtCapture)
|
||||
assertEquals("test-install", transport.crashes.single().installId)
|
||||
assertTrue(store.list().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashReporterDoesNotRetroactivelyUploadAnOptedOutStartupCrash() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = false)
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val store = InMemoryPendingCrashStore()
|
||||
val reporter = CrashReporter(
|
||||
store = store,
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
)
|
||||
reporter.capture(RuntimeException("startup"))
|
||||
|
||||
reporter.flushPending()
|
||||
assertTrue(store.list().isEmpty())
|
||||
preferences.setDiagnosticsEnabled(true)
|
||||
reporter.flushPending()
|
||||
|
||||
assertTrue(transport.crashes.isEmpty())
|
||||
assertTrue(store.list().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashReporterStopsAFlushWhenTheUserOptsOut() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val firstSendStarted = CompletableDeferred<Unit>()
|
||||
val releaseFirstSend = CompletableDeferred<Unit>()
|
||||
val sentIds = mutableListOf<String>()
|
||||
val transport = object : DiagnosticsTransport {
|
||||
override suspend fun sendEvents(batch: TelemetryBatch) = Result.success(Unit)
|
||||
override suspend fun sendBugReport(report: BugReport) = Result.success(Unit)
|
||||
override suspend fun sendCrash(report: CrashReport): Result<Unit> {
|
||||
sentIds += report.id
|
||||
if (sentIds.size == 1) {
|
||||
firstSendStarted.complete(Unit)
|
||||
releaseFirstSend.await()
|
||||
}
|
||||
return Result.success(Unit)
|
||||
}
|
||||
}
|
||||
val store = InMemoryPendingCrashStore()
|
||||
val reporter = CrashReporter(
|
||||
store = store,
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
)
|
||||
reporter.startObservingPreferences()
|
||||
advanceUntilIdle()
|
||||
repeat(2) {
|
||||
reporter.capture(RuntimeException("crash-$it"), diagnosticsEnabledOverride = true)
|
||||
}
|
||||
|
||||
val flush = launch { reporter.flushPending() }
|
||||
firstSendStarted.await()
|
||||
preferences.setDiagnosticsEnabled(false)
|
||||
runCurrent()
|
||||
releaseFirstSend.complete(Unit)
|
||||
flush.join()
|
||||
|
||||
assertEquals(1, sentIds.size)
|
||||
assertTrue(store.list().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bugReportRequiresWhatAndExpected() = runTest {
|
||||
val preferences = fakePrefs()
|
||||
val service = BugReportService(
|
||||
preferencesRepository = preferences,
|
||||
preferencesRepository = fakePrefs(),
|
||||
transport = RecordingDiagnosticsTransport(),
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
logReader = { "logs" },
|
||||
@@ -893,8 +220,6 @@ class DiagnosticsTest {
|
||||
@Test
|
||||
fun bugReportConvertsTransportExceptionsToFailure() = runTest {
|
||||
val throwingTransport = object : DiagnosticsTransport {
|
||||
override suspend fun sendEvents(batch: TelemetryBatch) = Result.success(Unit)
|
||||
override suspend fun sendCrash(report: CrashReport) = Result.success(Unit)
|
||||
override suspend fun sendBugReport(report: BugReport): Result<Unit> {
|
||||
throw IllegalStateException("offline")
|
||||
}
|
||||
@@ -902,7 +227,6 @@ class DiagnosticsTest {
|
||||
val service = BugReportService(
|
||||
preferencesRepository = fakePrefs(),
|
||||
transport = throwingTransport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
)
|
||||
@@ -914,13 +238,11 @@ 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",
|
||||
platform = "Test",
|
||||
logReader = { LogRedactor.redact("ticket=abcdefghijklmnopqrstuvwxyz012345 plain") },
|
||||
@@ -951,7 +273,6 @@ class DiagnosticsTest {
|
||||
val service = BugReportService(
|
||||
preferencesRepository = fakePrefs(),
|
||||
transport = RecordingDiagnosticsTransport(),
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
logReader = { rawLogs },
|
||||
@@ -969,7 +290,6 @@ class DiagnosticsTest {
|
||||
val service = BugReportService(
|
||||
preferencesRepository = fakePrefs(),
|
||||
transport = RecordingDiagnosticsTransport(),
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
logReader = { "🙂".repeat(60_000) },
|
||||
@@ -981,38 +301,34 @@ 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),
|
||||
)
|
||||
|
||||
private fun fakePrefs() = FakePreferencesRepository(
|
||||
AppPreferences(
|
||||
username = "User",
|
||||
receiveFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp"),
|
||||
themeMode = ThemeMode.System,
|
||||
notificationsEnabled = false,
|
||||
diagnosticsEnabled = diagnosticsEnabled,
|
||||
diagnosticsInstallId = "test-install",
|
||||
),
|
||||
)
|
||||
|
||||
private fun device() = DeviceInfo("Phone", "Pixel", "Android 15", "Wi-Fi", "90%")
|
||||
}
|
||||
|
||||
private class InMemoryPendingCrashStore : PendingCrashStore {
|
||||
private val items = linkedMapOf<String, CrashReport>()
|
||||
override fun write(report: CrashReport) {
|
||||
items[report.id] = report
|
||||
}
|
||||
override fun list(): List<CrashReport> = items.values.sortedByDescending { it.timestampMillis }
|
||||
override fun delete(id: String) {
|
||||
items.remove(id)
|
||||
}
|
||||
override fun prune(olderThanTimestampMillis: Long, maxCount: Int) {
|
||||
items.values
|
||||
.sortedByDescending { it.timestampMillis }
|
||||
.drop(maxCount)
|
||||
.map(CrashReport::id)
|
||||
.forEach(items::remove)
|
||||
items.values
|
||||
.filter { it.timestampMillis < olderThanTimestampMillis }
|
||||
.map(CrashReport::id)
|
||||
.forEach(items::remove)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -420,33 +419,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 +965,6 @@ class ViewModelsTest {
|
||||
receiveFolder = folder,
|
||||
themeMode = ThemeMode.System,
|
||||
notificationsEnabled = false,
|
||||
diagnosticsEnabled = false,
|
||||
diagnosticsInstallId = "test-install",
|
||||
),
|
||||
)
|
||||
@@ -1005,7 +976,6 @@ class ViewModelsTest {
|
||||
notifications: FakeNotificationService = FakeNotificationService(),
|
||||
transport: DiagnosticsTransport = RecordingDiagnosticsTransport(),
|
||||
fileSystem: FakeFileSystemService = FakeFileSystemService(folder),
|
||||
diagnosticsIncluded: Boolean = false,
|
||||
repository: FakeCoreGateway = FakeCoreGateway(),
|
||||
) = SettingsViewModel(
|
||||
environment(),
|
||||
@@ -1018,12 +988,10 @@ class ViewModelsTest {
|
||||
BugReportService(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
logReader = { "sample log line" },
|
||||
),
|
||||
diagnosticsIncluded = diagnosticsIncluded,
|
||||
)
|
||||
|
||||
private fun receivedTransfer(id: ULong, status: TransferStatus) = Transfer(
|
||||
|
||||
@@ -210,9 +210,6 @@ class FakePreferencesRepository(
|
||||
override suspend fun resetReceiveFolder() = Unit
|
||||
override suspend fun setThemeMode(mode: ThemeMode) { mutablePreferences.value = mutablePreferences.value.copy(themeMode = mode) }
|
||||
override suspend fun setNotificationsEnabled(enabled: Boolean) { mutablePreferences.value = mutablePreferences.value.copy(notificationsEnabled = enabled) }
|
||||
override suspend fun setDiagnosticsEnabled(enabled: Boolean) {
|
||||
mutablePreferences.value = mutablePreferences.value.copy(diagnosticsEnabled = enabled)
|
||||
}
|
||||
override suspend fun setRelaySettings(settings: RelaySettings) {
|
||||
mutablePreferences.value = mutablePreferences.value.copy(relaySettings = settings)
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
actual fun createPendingCrashStore(appDataDir: String): PendingCrashStore =
|
||||
JvmPendingCrashStore(appDataDir)
|
||||
|
||||
private class JvmPendingCrashStore(
|
||||
appDataDir: String,
|
||||
) : PendingCrashStore {
|
||||
private val directory = File(appDataDir, "diagnostics/crashes")
|
||||
|
||||
@Synchronized
|
||||
override fun write(report: CrashReport) {
|
||||
if (!isValidDiagnosticId(report.id)) return
|
||||
directory.mkdirs()
|
||||
val target = File(directory, "${report.id}.crash")
|
||||
val temporary = File(directory, ".${report.id}.tmp")
|
||||
val payload = CrashReportCodec.encode(report)
|
||||
temporary.writeText(payload, StandardCharsets.UTF_8)
|
||||
if (!temporary.renameTo(target)) {
|
||||
target.writeText(payload, StandardCharsets.UTF_8)
|
||||
temporary.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun list(): List<CrashReport> {
|
||||
if (!directory.isDirectory) return emptyList()
|
||||
return directory
|
||||
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
|
||||
.orEmpty()
|
||||
.mapNotNull { file ->
|
||||
runCatching { CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8)) }.getOrNull()
|
||||
}
|
||||
.sortedWith(
|
||||
compareByDescending<CrashReport> { it.timestampMillis }
|
||||
.thenBy { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun delete(id: String) {
|
||||
if (!isValidDiagnosticId(id)) return
|
||||
File(directory, "$id.crash").delete()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun prune(olderThanTimestampMillis: Long, maxCount: Int) {
|
||||
require(maxCount > 0) { "maxCount must be positive" }
|
||||
if (!directory.isDirectory) return
|
||||
directory.listFiles { file -> file.isFile && file.name.endsWith(".tmp") }
|
||||
.orEmpty()
|
||||
.forEach(File::delete)
|
||||
val reports = directory
|
||||
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
|
||||
.orEmpty()
|
||||
.mapNotNull { file ->
|
||||
val report = runCatching {
|
||||
CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8))
|
||||
}.getOrNull()
|
||||
if (report == null) {
|
||||
file.delete()
|
||||
null
|
||||
} else {
|
||||
file to report
|
||||
}
|
||||
}
|
||||
.sortedWith(
|
||||
compareByDescending<Pair<File, CrashReport>> { (_, report) -> report.timestampMillis }
|
||||
.thenBy { (_, report) -> report.id },
|
||||
)
|
||||
reports.forEachIndexed { index, (file, report) ->
|
||||
if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
actual fun installPlatformCrashHook(onCrash: (Throwable) -> Unit) {
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
runCatching { onCrash(throwable) }
|
||||
previous?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
40
shared/src/jvmTest/kotlin/com/vnidrop/app/AppConfigTest.kt
Normal file
40
shared/src/jvmTest/kotlin/com/vnidrop/app/AppConfigTest.kt
Normal file
@@ -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")}")
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.attribute.FileTime
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PendingCrashStoreJvmTest {
|
||||
@Test
|
||||
fun replacesReportsAndPrunesOldCorruptAndTemporaryFiles() {
|
||||
val root = Files.createTempDirectory("vnidrop-crash-store").toFile()
|
||||
try {
|
||||
val store = createPendingCrashStore(root.absolutePath)
|
||||
val older = report("10000000-0000-4000-8000-000000000001", 1, "older")
|
||||
val current = report("10000000-0000-4000-8000-000000000002", 2, "current")
|
||||
store.write(older)
|
||||
store.write(current)
|
||||
store.write(current.copy(exceptionMessage = "replaced"))
|
||||
|
||||
val directory = File(root, "diagnostics/crashes")
|
||||
File(directory, "corrupt.crash").writeText("not a crash envelope")
|
||||
File(directory, ".orphan.tmp").writeText("partial")
|
||||
store.write(current.copy(id = "../../escape"))
|
||||
val escapedPath = File(directory, "../../escape.crash").canonicalFile
|
||||
Files.setLastModifiedTime(File(directory, "${older.id}.crash").toPath(), FileTime.fromMillis(2_000))
|
||||
Files.setLastModifiedTime(File(directory, "${current.id}.crash").toPath(), FileTime.fromMillis(1_000))
|
||||
|
||||
assertEquals(
|
||||
listOf("replaced", "older"),
|
||||
store.list().map(CrashReport::exceptionMessage),
|
||||
)
|
||||
store.prune(olderThanTimestampMillis = 0, maxCount = 1)
|
||||
|
||||
assertEquals(listOf("replaced"), store.list().map(CrashReport::exceptionMessage))
|
||||
assertFalse(File(directory, "corrupt.crash").exists())
|
||||
assertFalse(File(directory, ".orphan.tmp").exists())
|
||||
assertFalse(escapedPath.exists())
|
||||
assertTrue(directory.listFiles().orEmpty().all { it.parentFile == directory })
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun report(id: String, timestampMillis: Long, message: String) = CrashReport(
|
||||
id = id,
|
||||
timestampMillis = timestampMillis,
|
||||
installId = "install",
|
||||
appVersion = "1.0",
|
||||
platform = "Desktop",
|
||||
exceptionType = "TestError",
|
||||
exceptionMessage = message,
|
||||
stackTrace = "stack",
|
||||
breadcrumbs = emptyList(),
|
||||
diagnosticsEnabledAtCapture = true,
|
||||
)
|
||||
}
|
||||
@@ -152,7 +152,6 @@ class FoundationComposeTest {
|
||||
onResetFolder = {},
|
||||
onNotificationsChanged = {},
|
||||
onOpenNotificationSettings = {},
|
||||
onDiagnosticsChanged = {},
|
||||
onBugWhatChanged = {},
|
||||
onBugExpectedChanged = {},
|
||||
onBugStepsChanged = {},
|
||||
@@ -182,7 +181,6 @@ class FoundationComposeTest {
|
||||
onResetFolder = {},
|
||||
onNotificationsChanged = {},
|
||||
onOpenNotificationSettings = {},
|
||||
onDiagnosticsChanged = {},
|
||||
onBugWhatChanged = {},
|
||||
onBugExpectedChanged = {},
|
||||
onBugStepsChanged = {},
|
||||
@@ -238,7 +236,6 @@ class FoundationComposeTest {
|
||||
onResetFolder = {},
|
||||
onNotificationsChanged = {},
|
||||
onOpenNotificationSettings = {},
|
||||
onDiagnosticsChanged = {},
|
||||
onBugWhatChanged = {},
|
||||
onBugExpectedChanged = {},
|
||||
onBugStepsChanged = {},
|
||||
@@ -291,7 +288,6 @@ class FoundationComposeTest {
|
||||
onResetFolder = {},
|
||||
onNotificationsChanged = {},
|
||||
onOpenNotificationSettings = {},
|
||||
onDiagnosticsChanged = {},
|
||||
onBugWhatChanged = {},
|
||||
onBugExpectedChanged = {},
|
||||
onBugStepsChanged = {},
|
||||
@@ -322,7 +318,6 @@ class FoundationComposeTest {
|
||||
onResetFolder = {},
|
||||
onNotificationsChanged = {},
|
||||
onOpenNotificationSettings = {},
|
||||
onDiagnosticsChanged = {},
|
||||
onBugWhatChanged = {},
|
||||
onBugExpectedChanged = {},
|
||||
onBugStepsChanged = {},
|
||||
@@ -358,7 +353,6 @@ class FoundationComposeTest {
|
||||
onResetFolder = {},
|
||||
onNotificationsChanged = { enabled = it },
|
||||
onOpenNotificationSettings = {},
|
||||
onDiagnosticsChanged = {},
|
||||
onBugWhatChanged = {},
|
||||
onBugExpectedChanged = {},
|
||||
onBugStepsChanged = {},
|
||||
@@ -390,7 +384,6 @@ class FoundationComposeTest {
|
||||
onResetFolder = {},
|
||||
onNotificationsChanged = {},
|
||||
onOpenNotificationSettings = { opened = true },
|
||||
onDiagnosticsChanged = {},
|
||||
onBugWhatChanged = {},
|
||||
onBugExpectedChanged = {},
|
||||
onBugStepsChanged = {},
|
||||
|
||||
Reference in New Issue
Block a user