fix(app): standardize branding and disable telemetry

This commit is contained in:
2026-07-15 23:30:13 +02:00
parent d2d3c45180
commit e40a4bfee2
12 changed files with 92 additions and 30 deletions

View File

@@ -1,3 +1,3 @@
<resources> <resources>
<string name="app_name">vnidrop</string> <string name="app_name">VniDrop</string>
</resources> </resources>

View File

@@ -77,7 +77,7 @@ fun main(args: Array<String>) {
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
state = windowState, state = windowState,
title = "vnidrop", title = "VniDrop",
// Compose keeps edge resizers active for this client-decorated Linux window. // Compose keeps edge resizers active for this client-decorated Linux window.
undecorated = linux, undecorated = linux,
) { ) {
@@ -166,7 +166,7 @@ private fun WindowScope.MacOsTitleBar() {
) { ) {
Box(modifier = Modifier.fillMaxSize().padding(end = MacOsTrafficLightsWidth)) { Box(modifier = Modifier.fillMaxSize().padding(end = MacOsTrafficLightsWidth)) {
BasicText( BasicText(
text = "vnidrop", text = "VniDrop",
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
style = TextStyle( style = TextStyle(
color = colors.foregroundDefault, color = colors.foregroundDefault,
@@ -195,7 +195,7 @@ private fun WindowScope.LinuxTitleBar(
.background(colors.backgroundSurface200), .background(colors.backgroundSurface200),
) { ) {
BasicText( BasicText(
text = "vnidrop", text = "VniDrop",
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
style = TextStyle( style = TextStyle(
color = colors.foregroundDefault, color = colors.foregroundDefault,

View File

@@ -16,8 +16,9 @@ android.useAndroidX=true
# VniDrop: compile-time diagnostics/telemetry product surface. # VniDrop: compile-time diagnostics/telemetry product surface.
# false → no Share-diagnostics toggle, no telemetry or crash auto-upload stack. # false → no Share-diagnostics toggle, no telemetry or crash auto-upload stack.
# Bug report UI remains available (user-initiated). # Bug report UI remains available (user-initiated).
# Override per build: ./gradlew … -Pvnidrop.diagnostics.included=false # Enable per build only when endpoint and ingest key are configured:
vnidrop.diagnostics.included=true # ./gradlew … -Pvnidrop.diagnostics.included=true
vnidrop.diagnostics.included=false
# Cloudflare Worker base URL (no trailing slash). Both endpoint/key empty → NoOp transport. # Cloudflare Worker base URL (no trailing slash). Both endpoint/key empty → NoOp transport.
# Example: https://vnidrop-diagnostics.<subdomain>.workers.dev # Example: https://vnidrop-diagnostics.<subdomain>.workers.dev
vnidrop.diagnostics.endpoint= vnidrop.diagnostics.endpoint=

View File

@@ -1,6 +1,6 @@
TEAM_ID= TEAM_ID=
PRODUCT_NAME=vnidrop PRODUCT_NAME=VniDrop
PRODUCT_BUNDLE_IDENTIFIER=com.vnidrop.app.vnidrop$(TEAM_ID) PRODUCT_BUNDLE_IDENTIFIER=com.vnidrop.app.vnidrop$(TEAM_ID)
CURRENT_PROJECT_VERSION=1 CURRENT_PROJECT_VERSION=1

View File

@@ -7,7 +7,7 @@
objects = { objects = {
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
FA325F1B4E7D8FFDF19A5C4A /* vnidrop.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = vnidrop.app; sourceTree = BUILT_PRODUCTS_DIR; }; FA325F1B4E7D8FFDF19A5C4A /* VniDrop.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VniDrop.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -59,7 +59,7 @@
C13A067056BA9F38FD87A539 /* Products */ = { C13A067056BA9F38FD87A539 /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
FA325F1B4E7D8FFDF19A5C4A /* vnidrop.app */, FA325F1B4E7D8FFDF19A5C4A /* VniDrop.app */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -87,7 +87,7 @@
packageProductDependencies = ( packageProductDependencies = (
); );
productName = iosApp; productName = iosApp;
productReference = FA325F1B4E7D8FFDF19A5C4A /* vnidrop.app */; productReference = FA325F1B4E7D8FFDF19A5C4A /* VniDrop.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */

View File

@@ -41,12 +41,12 @@ plugins {
// included=false: no Share-diagnostics toggle, no telemetry/crash auto-upload stack. // included=false: no Share-diagnostics toggle, no telemetry/crash auto-upload stack.
// endpoint/key both empty: transport is NoOp (safe default until Cloudflare is deployed). // endpoint/key both empty: transport is NoOp (safe default until Cloudflare is deployed).
val diagnosticsIncluded: Boolean = val diagnosticsIncluded: Boolean =
(findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: true (findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: false
val diagnosticsEndpoint: String = val diagnosticsEndpoint: String =
(findProperty("vnidrop.diagnostics.endpoint") as String?)?.trim().orEmpty() (findProperty("vnidrop.diagnostics.endpoint") as String?)?.trim().orEmpty()
val diagnosticsIngestKey: String = val diagnosticsIngestKey: String =
(findProperty("vnidrop.diagnostics.ingestKey") as String?)?.trim().orEmpty() (findProperty("vnidrop.diagnostics.ingestKey") as String?)?.trim().orEmpty()
check(diagnosticsEndpoint.isEmpty() == diagnosticsIngestKey.isEmpty()) { check(!diagnosticsIncluded || diagnosticsEndpoint.isEmpty() == diagnosticsIngestKey.isEmpty()) {
"vnidrop.diagnostics.endpoint and vnidrop.diagnostics.ingestKey must be configured together" "vnidrop.diagnostics.endpoint and vnidrop.diagnostics.ingestKey must be configured together"
} }
@@ -56,8 +56,8 @@ val generateDiagnosticsBuildConfig by tasks.registering {
description = "Generates DiagnosticsBuildConfig from vnidrop.diagnostics.* properties" description = "Generates DiagnosticsBuildConfig from vnidrop.diagnostics.* properties"
val outputDir = diagnosticsBuildConfigDir val outputDir = diagnosticsBuildConfigDir
val included = diagnosticsIncluded val included = diagnosticsIncluded
val endpoint = diagnosticsEndpoint val endpoint = if (included) diagnosticsEndpoint else ""
val ingestKey = diagnosticsIngestKey val ingestKey = if (included) diagnosticsIngestKey else ""
inputs.property("vnidrop.diagnostics.included", included) inputs.property("vnidrop.diagnostics.included", included)
inputs.property("vnidrop.diagnostics.endpoint", endpoint) inputs.property("vnidrop.diagnostics.endpoint", endpoint)
inputs.property("vnidrop.diagnostics.ingestKey", ingestKey) inputs.property("vnidrop.diagnostics.ingestKey", ingestKey)

View File

@@ -18,13 +18,14 @@ class DiagnosticsCoordinator(
val crashReporter: CrashReporter, val crashReporter: CrashReporter,
val bugReports: BugReportService, val bugReports: BugReportService,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val included: Boolean = DiagnosticsBuildConfig.INCLUDED,
) { ) {
fun start() { fun start() {
// Install id is useful for bug-report correlation even without telemetry. // Install id is useful for bug-report correlation even without telemetry.
scope.launch { scope.launch {
preferencesRepository.ensureDiagnosticsInstallId() preferencesRepository.ensureDiagnosticsInstallId()
} }
if (!DiagnosticsBuildConfig.INCLUDED) return if (!included) return
crashReporter.startObservingPreferences() crashReporter.startObservingPreferences()
crashReporter.installUnhandledExceptionHandler() crashReporter.installUnhandledExceptionHandler()
scope.launch { scope.launch {
@@ -33,7 +34,7 @@ class DiagnosticsCoordinator(
} }
fun record(name: String, properties: Map<String, String> = emptyMap()) { fun record(name: String, properties: Map<String, String> = emptyMap()) {
if (!DiagnosticsBuildConfig.INCLUDED) return if (!included) return
telemetry.record(name, properties) telemetry.record(name, properties)
} }
@@ -45,6 +46,7 @@ class DiagnosticsCoordinator(
preferencesRepository: PreferencesRepository, preferencesRepository: PreferencesRepository,
scope: CoroutineScope, scope: CoroutineScope,
transport: DiagnosticsTransport = NoOpDiagnosticsTransport(), transport: DiagnosticsTransport = NoOpDiagnosticsTransport(),
included: Boolean = DiagnosticsBuildConfig.INCLUDED,
): DiagnosticsCoordinator { ): DiagnosticsCoordinator {
val breadcrumbs = BreadcrumbBuffer() val breadcrumbs = BreadcrumbBuffer()
val crashStore = createPendingCrashStore(appDataDir) val crashStore = createPendingCrashStore(appDataDir)
@@ -53,6 +55,7 @@ class DiagnosticsCoordinator(
transport = transport, transport = transport,
breadcrumbs = breadcrumbs, breadcrumbs = breadcrumbs,
scope = scope, scope = scope,
included = included,
) )
val crashReporter = CrashReporter( val crashReporter = CrashReporter(
store = crashStore, store = crashStore,
@@ -78,6 +81,7 @@ class DiagnosticsCoordinator(
crashReporter = crashReporter, crashReporter = crashReporter,
bugReports = bugReports, bugReports = bugReports,
scope = scope, scope = scope,
included = included,
) )
} }
} }

View File

@@ -119,6 +119,7 @@ fun createDiagnosticsTransport(
platform: String, platform: String,
installIdProvider: suspend () -> String, installIdProvider: suspend () -> String,
): DiagnosticsTransport = buildDiagnosticsTransport( ): DiagnosticsTransport = buildDiagnosticsTransport(
included = DiagnosticsBuildConfig.INCLUDED,
endpoint = DiagnosticsBuildConfig.ENDPOINT, endpoint = DiagnosticsBuildConfig.ENDPOINT,
ingestKey = DiagnosticsBuildConfig.INGEST_KEY, ingestKey = DiagnosticsBuildConfig.INGEST_KEY,
appVersion = appVersion, appVersion = appVersion,
@@ -127,12 +128,14 @@ fun createDiagnosticsTransport(
) )
internal fun buildDiagnosticsTransport( internal fun buildDiagnosticsTransport(
included: Boolean = true,
endpoint: String, endpoint: String,
ingestKey: String, ingestKey: String,
appVersion: String, appVersion: String,
platform: String, platform: String,
installIdProvider: suspend () -> String, installIdProvider: suspend () -> String,
): DiagnosticsTransport { ): DiagnosticsTransport {
if (!included) return NoOpDiagnosticsTransport()
val normalizedEndpoint = endpoint.trim() val normalizedEndpoint = endpoint.trim()
val normalizedIngestKey = ingestKey.trim() val normalizedIngestKey = ingestKey.trim()
if (normalizedEndpoint.isEmpty() && normalizedIngestKey.isEmpty()) return NoOpDiagnosticsTransport() if (normalizedEndpoint.isEmpty() && normalizedIngestKey.isEmpty()) return NoOpDiagnosticsTransport()

View File

@@ -31,6 +31,7 @@ class TelemetryRecorder(
private val flushIntervalMillis: Long = DefaultFlushIntervalMillis, private val flushIntervalMillis: Long = DefaultFlushIntervalMillis,
private val retryBackoffMillis: Long = DefaultRetryBackoffMillis, private val retryBackoffMillis: Long = DefaultRetryBackoffMillis,
private val automaticRetryCount: Int = DefaultAutomaticRetryCount, private val automaticRetryCount: Int = DefaultAutomaticRetryCount,
private val included: Boolean = true,
) { ) {
private val bufferMutex = Mutex() private val bufferMutex = Mutex()
private val state = AtomicReference(TelemetryState()) private val state = AtomicReference(TelemetryState())
@@ -42,6 +43,7 @@ class TelemetryRecorder(
require(flushIntervalMillis > 0) { "flushIntervalMillis must be positive" } require(flushIntervalMillis > 0) { "flushIntervalMillis must be positive" }
require(retryBackoffMillis > 0) { "retryBackoffMillis must be positive" } require(retryBackoffMillis > 0) { "retryBackoffMillis must be positive" }
require(automaticRetryCount >= 0) { "automaticRetryCount must not be negative" } require(automaticRetryCount >= 0) { "automaticRetryCount must not be negative" }
if (included) {
scope.launch { scope.launch {
preferencesRepository.preferences preferencesRepository.preferences
.map { it.diagnosticsEnabled } .map { it.diagnosticsEnabled }
@@ -55,9 +57,10 @@ class TelemetryRecorder(
} }
scope.launch { runAutomaticFlushes() } scope.launch { runAutomaticFlushes() }
} }
}
fun record(name: String, properties: Map<String, String> = emptyMap()) { fun record(name: String, properties: Map<String, String> = emptyMap()) {
if (!DiagnosticsBuildConfig.INCLUDED) return if (!included) return
val sanitizedName = sanitizeDiagnosticName(name) val sanitizedName = sanitizeDiagnosticName(name)
if (sanitizedName.isBlank()) return if (sanitizedName.isBlank()) return
val sanitizedProperties = sanitizeDiagnosticProperties(properties) val sanitizedProperties = sanitizeDiagnosticProperties(properties)

View File

@@ -88,6 +88,7 @@ class SettingsViewModel(
private val messages: UiMessageController, private val messages: UiMessageController,
private val bugReports: BugReportService, private val bugReports: BugReportService,
private val diagnostics: DiagnosticsCoordinator? = null, private val diagnostics: DiagnosticsCoordinator? = null,
private val diagnosticsIncluded: Boolean = DiagnosticsBuildConfig.INCLUDED,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow( private val _state = MutableStateFlow(
SettingsState( SettingsState(
@@ -200,7 +201,7 @@ class SettingsViewModel(
} }
fun setDiagnosticsEnabled(enabled: Boolean) { fun setDiagnosticsEnabled(enabled: Boolean) {
if (!DiagnosticsBuildConfig.INCLUDED) return if (!diagnosticsIncluded) return
viewModelScope.launch { viewModelScope.launch {
preferencesRepository.setDiagnosticsEnabled(enabled) preferencesRepository.setDiagnosticsEnabled(enabled)
diagnostics?.record( diagnostics?.record(

View File

@@ -273,6 +273,19 @@ class DiagnosticsTest {
assertIs<NoOpDiagnosticsTransport>(transport) assertIs<NoOpDiagnosticsTransport>(transport)
} }
@Test
fun excludedTransportIgnoresIncompleteConfiguration() {
val transport = buildDiagnosticsTransport(
included = false,
endpoint = "https://diag.example",
ingestKey = "",
appVersion = "1.0",
platform = "Test",
installIdProvider = { "id" },
)
assertIs<NoOpDiagnosticsTransport>(transport)
}
@Test @Test
fun noOpTransportReportsUnavailableDelivery() = runTest { fun noOpTransportReportsUnavailableDelivery() = runTest {
val result = NoOpDiagnosticsTransport().sendEvents( val result = NoOpDiagnosticsTransport().sendEvents(
@@ -329,6 +342,27 @@ class DiagnosticsTest {
assertEquals(1, breadcrumbs.snapshot().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 @Test
fun telemetryRetainsColdStartEventsUntilConsentLoads() = runTest { fun telemetryRetainsColdStartEventsUntilConsentLoads() = runTest {
val backing = fakePrefs(diagnosticsEnabled = true) val backing = fakePrefs(diagnosticsEnabled = true)

View File

@@ -158,7 +158,7 @@ class ViewModelsTest {
fun settingsTogglesDiagnosticsPreference() = runTest { fun settingsTogglesDiagnosticsPreference() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val preferences = preferences() val preferences = preferences()
val viewModel = settingsViewModel(preferences) val viewModel = settingsViewModel(preferences, diagnosticsIncluded = true)
advanceUntilIdle() advanceUntilIdle()
assertFalse(viewModel.state.value.diagnosticsEnabled) assertFalse(viewModel.state.value.diagnosticsEnabled)
viewModel.setDiagnosticsEnabled(true) viewModel.setDiagnosticsEnabled(true)
@@ -167,6 +167,20 @@ class ViewModelsTest {
assertTrue(viewModel.state.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 @Test
fun settingsSubmitsBugReportAndClearsForm() = runTest { fun settingsSubmitsBugReportAndClearsForm() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
@@ -647,6 +661,7 @@ class ViewModelsTest {
notifications: FakeNotificationService = FakeNotificationService(), notifications: FakeNotificationService = FakeNotificationService(),
transport: DiagnosticsTransport = RecordingDiagnosticsTransport(), transport: DiagnosticsTransport = RecordingDiagnosticsTransport(),
fileSystem: FakeFileSystemService = FakeFileSystemService(folder), fileSystem: FakeFileSystemService = FakeFileSystemService(folder),
diagnosticsIncluded: Boolean = false,
) = SettingsViewModel( ) = SettingsViewModel(
environment(), environment(),
{ DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") }, { DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") },
@@ -662,6 +677,7 @@ class ViewModelsTest {
platform = "Test", platform = "Test",
logReader = { "sample log line" }, logReader = { "sample log line" },
), ),
diagnosticsIncluded = diagnosticsIncluded,
) )
private fun receivedTransfer(id: ULong, status: TransferStatus) = Transfer( private fun receivedTransfer(id: ULong, status: TransferStatus) = Transfer(