feat(shared): add opt-in diagnostics client with compile-time kill switch

Ship client-side telemetry, crash capture, and bug-report assembly behind a
user opt-in, with NoOp transport until Cloudflare is wired. Builds can set
vnidrop.diagnostics.included=false to omit the Share-diagnostics UI and
disable the telemetry/crash auto-upload stack while keeping bug reports.
This commit is contained in:
2026-07-14 08:27:42 +02:00
parent 6dd867e43d
commit ead4e09a60
36 changed files with 1776 additions and 47 deletions

View File

@@ -57,7 +57,13 @@ fun App(
val graph = graphHolder.graph
val appViewModel = viewModel {
AppViewModel(dependencies.environment, graph.coreRepository, graph.preferencesRepository, graph.messages)
AppViewModel(
dependencies.environment,
graph.coreRepository,
graph.preferencesRepository,
graph.messages,
graph.diagnostics,
)
}
val sendViewModel = viewModel {
SendViewModel(
@@ -79,6 +85,8 @@ fun App(
graph.preferencesRepository,
dependencies.localNotificationService,
graph.messages,
graph.diagnostics.bugReports,
graph.diagnostics,
)
}
val appState by appViewModel.state.collectAsStateWithLifecycle()

View File

@@ -2,6 +2,8 @@ package com.vnidrop.app
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreRepository
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
import com.vnidrop.app.diagnostics.NoOpDiagnosticsTransport
import com.vnidrop.app.feature.approvals.ApprovalCoordinator
import com.vnidrop.app.feature.send.AppFilePreviewRepository
import com.vnidrop.app.feature.send.createPlatformPreviewStore
@@ -34,8 +36,17 @@ 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,
scope = applicationScope,
transport = NoOpDiagnosticsTransport(),
)
val approvalCoordinator = ApprovalCoordinator(
repository = coreRepository,
preferencesRepository = preferencesRepository,
@@ -47,6 +58,7 @@ class AppGraph(
init {
AppLogger.initialize(dependencies.environment.defaultCoreDataDir)
diagnostics.start()
}
fun close() {

View File

@@ -0,0 +1,43 @@
package com.vnidrop.app.diagnostics
import com.vnidrop.app.logging.platformNowMillis
/**
* 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.
*/
class BreadcrumbBuffer(
private val capacity: Int = DefaultCapacity,
) {
init {
require(capacity > 0) { "capacity must be positive" }
}
@Volatile
private var items: List<Breadcrumb> = emptyList()
fun add(name: String, properties: Map<String, String> = emptyMap(), timestampMillis: Long = platformNowMillis()) {
val crumb = Breadcrumb(
name = name.take(MaxNameLength),
timestampMillis = timestampMillis,
properties = LogRedactor.redactMap(properties).mapValues { it.value.take(MaxPropertyValueLength) },
)
val current = items
items = (current + crumb).takeLast(capacity)
}
fun snapshot(): List<Breadcrumb> = items
fun clear() {
items = emptyList()
}
companion object {
const val DefaultCapacity = 40
private const val MaxNameLength = 64
private const val MaxPropertyValueLength = 128
}
}

View File

@@ -0,0 +1,89 @@
package com.vnidrop.app.diagnostics
import com.vnidrop.app.DeviceInfo
import com.vnidrop.app.logging.AppLogger
import com.vnidrop.app.logging.platformNowMillis
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.util.randomUuidString
data class BugReportDraft(
val whatHappened: String,
val expected: String,
val steps: String = "",
val contact: String = "",
val includeLogs: Boolean = true,
)
/**
* User-initiated bug reports. Always allowed regardless of diagnostics opt-in.
*/
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 = {
LogRedactor.redact(AppLogger.readLatestLogs(AppLogger.DefaultBugReportLogBytes))
},
) {
fun assemble(
draft: BugReportDraft,
deviceInfo: DeviceInfo?,
installId: String,
): BugReport {
val logs = if (draft.includeLogs) logReader() else ""
return BugReport(
id = randomUuidString(),
timestampMillis = platformNowMillis(),
installId = installId,
appVersion = appVersion,
platform = platform,
whatHappened = draft.whatHappened.trim().take(MaxFieldLength),
expected = draft.expected.trim().take(MaxFieldLength),
steps = draft.steps.trim().take(MaxFieldLength),
contact = draft.contact.trim().take(MaxContactLength),
includeLogs = draft.includeLogs,
logs = logs,
device = DeviceSnapshot(
deviceName = deviceInfo?.deviceName,
deviceModel = deviceInfo?.deviceModel,
operatingSystem = deviceInfo?.operatingSystem ?: platform,
network = deviceInfo?.network,
batteryLevel = deviceInfo?.batteryLevel,
),
breadcrumbs = breadcrumbs.snapshot(),
)
}
suspend fun submit(draft: BugReportDraft, deviceInfo: DeviceInfo?): Result<BugReport> {
val what = draft.whatHappened.trim()
val expected = draft.expected.trim()
if (what.isEmpty()) {
return Result.failure(IllegalArgumentException("Describe what happened."))
}
if (expected.isEmpty()) {
return Result.failure(IllegalArgumentException("Describe what you expected."))
}
val installId = preferencesRepository.ensureDiagnosticsInstallId()
val report = assemble(draft, deviceInfo, installId)
val send = transport.sendBugReport(report)
return send.fold(
onSuccess = {
AppLogger.info("bug_report", "submitted", mapOf("id" to report.id))
Result.success(report)
},
onFailure = { error ->
AppLogger.error("bug_report", "submit failed", error)
Result.failure(error)
},
)
}
fun previewLogBytes(): Int = logReader().encodeToByteArray().size
companion object {
private const val MaxFieldLength = 4_000
private const val MaxContactLength = 320
}
}

View File

@@ -0,0 +1,89 @@
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.flow.first
import kotlinx.coroutines.launch
/**
* Captures uncaught exceptions to disk, then uploads on a later launch when
* diagnostics is enabled (and when a real [DiagnosticsTransport] is wired).
*/
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,
) {
@Volatile private var installed = false
@Volatile private var lastInstallId: String = ""
@Volatile private var lastDiagnosticsEnabled: Boolean = false
fun startObservingPreferences() {
scope.launch {
preferencesRepository.preferences.collect { prefs ->
lastInstallId = prefs.diagnosticsInstallId
lastDiagnosticsEnabled = prefs.diagnosticsEnabled
}
}
}
fun installUnhandledExceptionHandler() {
if (!DiagnosticsBuildConfig.INCLUDED) return
if (installed) return
installed = true
installPlatformCrashHook { throwable ->
capture(throwable)
}
}
fun capture(throwable: Throwable, diagnosticsEnabledOverride: Boolean? = null): CrashReport {
val report = CrashReport(
id = randomUuidString(),
timestampMillis = platformNowMillis(),
installId = lastInstallId,
appVersion = appVersion,
platform = platform,
exceptionType = throwable::class.simpleName ?: "Throwable",
exceptionMessage = LogRedactor.redact(throwable.message.orEmpty()).take(MaxMessageLength),
stackTrace = LogRedactor.redact(throwable.stackTraceToString()).take(MaxStackLength),
breadcrumbs = breadcrumbs.snapshot(),
diagnosticsEnabledAtCapture = diagnosticsEnabledOverride ?: lastDiagnosticsEnabled,
)
runCatching { store.write(report) }
AppLogger.error("crash", "captured crash ${report.id}", throwable)
return report
}
/**
* Uploads pending crashes that were captured with diagnostics enabled.
* Local files are always kept for bug-report attachment until deleted after successful send.
*/
suspend fun flushPending() {
val diagnosticsEnabled = preferencesRepository.preferences.first().diagnosticsEnabled
if (!diagnosticsEnabled) return
for (report in store.list()) {
// Only auto-upload crashes captured while diagnostics was on.
if (!report.diagnosticsEnabledAtCapture) continue
val result = transport.sendCrash(report)
if (result.isSuccess) {
store.delete(report.id)
}
}
}
fun latestLocalCrash(): CrashReport? = store.list().maxByOrNull { it.timestampMillis }
companion object {
private const val MaxMessageLength = 2_000
private const val MaxStackLength = 32_000
}
}
expect fun installPlatformCrashHook(onCrash: (Throwable) -> Unit)

View File

@@ -0,0 +1,84 @@
package com.vnidrop.app.diagnostics
import com.vnidrop.app.preferences.PreferencesRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Owns diagnostics services for the app process: telemetry, crashes, bug reports.
*
* When [DiagnosticsBuildConfig.INCLUDED] is false (compile-time), telemetry and
* crash auto-reporting are never started; [bugReports] still works for support.
*/
class DiagnosticsCoordinator(
val preferencesRepository: PreferencesRepository,
val transport: DiagnosticsTransport,
val breadcrumbs: BreadcrumbBuffer,
val telemetry: TelemetryRecorder,
val crashReporter: CrashReporter,
val bugReports: BugReportService,
private val scope: CoroutineScope,
) {
fun start() {
// Install id is useful for bug-report correlation even without telemetry.
scope.launch {
preferencesRepository.ensureDiagnosticsInstallId()
}
if (!DiagnosticsBuildConfig.INCLUDED) return
crashReporter.startObservingPreferences()
crashReporter.installUnhandledExceptionHandler()
scope.launch {
crashReporter.flushPending()
}
}
fun record(name: String, properties: Map<String, String> = emptyMap()) {
if (!DiagnosticsBuildConfig.INCLUDED) return
telemetry.record(name, properties)
}
companion object {
fun create(
appDataDir: String,
appVersion: String,
platform: String,
preferencesRepository: PreferencesRepository,
scope: CoroutineScope,
transport: DiagnosticsTransport = NoOpDiagnosticsTransport(),
): DiagnosticsCoordinator {
val breadcrumbs = BreadcrumbBuffer()
val crashStore = createPendingCrashStore(appDataDir)
val telemetry = TelemetryRecorder(
preferencesRepository = preferencesRepository,
transport = transport,
breadcrumbs = breadcrumbs,
scope = scope,
)
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,
)
}
}
}

View File

@@ -0,0 +1,61 @@
package com.vnidrop.app.diagnostics
/**
* Client-side diagnostics payloads. Transport to Cloudflare (or elsewhere) is
* 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,
)
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>,
/** Whether diagnostics was opted in when the crash was captured. */
val diagnosticsEnabledAtCapture: Boolean,
val schemaVersion: Int = DiagnosticsSchemaVersion,
)
data class DeviceSnapshot(
val deviceName: String?,
val deviceModel: String?,
val operatingSystem: String,
val network: String?,
val batteryLevel: String?,
)
data class BugReport(
val id: String,
val timestampMillis: Long,
val installId: String,
val appVersion: String,
val platform: String,
val whatHappened: String,
val expected: String,
val steps: String,
val contact: String,
val includeLogs: Boolean,
val logs: String,
val device: DeviceSnapshot,
val breadcrumbs: List<Breadcrumb>,
val schemaVersion: Int = DiagnosticsSchemaVersion,
)
const val DiagnosticsSchemaVersion: Int = 1

View File

@@ -0,0 +1,45 @@
package com.vnidrop.app.diagnostics
/**
* Network boundary for diagnostics. Production will swap [NoOpDiagnosticsTransport]
* for a Cloudflare Worker client; keep batching/validation client-side.
*/
interface DiagnosticsTransport {
suspend fun sendEvents(events: List<TelemetryEvent>): Result<Unit>
suspend fun sendCrash(report: CrashReport): Result<Unit>
suspend fun sendBugReport(report: BugReport): Result<Unit>
}
/** Accepts payloads without leaving the device. Used until Cloudflare is wired. */
class NoOpDiagnosticsTransport : DiagnosticsTransport {
override suspend fun sendEvents(events: List<TelemetryEvent>): Result<Unit> = Result.success(Unit)
override suspend fun sendCrash(report: CrashReport): Result<Unit> = Result.success(Unit)
override suspend fun sendBugReport(report: BugReport): Result<Unit> = Result.success(Unit)
}
/**
* Test double that records calls and can fail on demand.
*/
class RecordingDiagnosticsTransport : DiagnosticsTransport {
val events = mutableListOf<List<TelemetryEvent>>()
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(events: List<TelemetryEvent>): Result<Unit> {
this.events += events
return eventsResult
}
override suspend fun sendCrash(report: CrashReport): Result<Unit> {
crashes += report
return crashResult
}
override suspend fun sendBugReport(report: BugReport): Result<Unit> {
bugReports += report
return bugResult
}
}

View File

@@ -0,0 +1,53 @@
package com.vnidrop.app.diagnostics
/**
* Scrubs known-sensitive patterns from free-form diagnostics text before upload
* or attachment. Defense in depth for tickets, endpoint-like ids, and paths.
*/
object LogRedactor {
fun redact(input: String): String {
if (input.isEmpty()) return input
var result = input
for (rule in Rules) {
result = rule.regex.replace(result, rule.replacement)
}
return result
}
fun redactMap(fields: Map<String, String>): Map<String, String> =
fields.mapValues { (_, value) -> redact(value) }
private data class Rule(val regex: Regex, val replacement: String)
private val Rules = listOf(
// Blob tickets / long base64-ish tokens often appear near "ticket=".
Rule(
Regex("""(?i)(ticket\s*[=:]\s*)([A-Za-z0-9+/=_\-]{24,})"""),
"$1[redacted-ticket]",
),
// iroh-style node/endpoint ids: long hex or base32-ish.
Rule(
Regex("""(?i)(endpoint[_-]?id\s*[=:]\s*)([A-Za-z0-9+/=_\-]{16,})"""),
"$1[redacted-endpoint]",
),
Rule(
Regex("""\b[0-9a-fA-F]{48,}\b"""),
"[redacted-hex]",
),
// Absolute filesystem paths (Unix + Windows drive).
Rule(
Regex("""(?<![A-Za-z0-9_])(/[^\s:]+|[A-Za-z]:\\[^\s]+)"""),
"[redacted-path]",
),
// content:// and file:// URIs.
Rule(
Regex("""(?i)\b((?:content|file|http|https)://[^\s]+)"""),
"[redacted-uri]",
),
// SAF tree/document ids.
Rule(
Regex("""(?i)(document[_-]?id|tree[_-]?uri)\s*[=:]\s*\S+"""),
"$1=[redacted]",
),
)
}

View File

@@ -0,0 +1,95 @@
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)
}
expect fun createPendingCrashStore(appDataDir: String): PendingCrashStore
internal object CrashReportCodec {
private const val FieldSep = "\u001f"
private const val RecordSep = "\u001e"
fun encode(report: CrashReport): String = buildString {
fun field(key: String, value: String) {
append(key)
append('=')
append(value.replace("\n", "\\n").replace("\r", "\\r"))
append(FieldSep)
}
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", if (report.diagnosticsEnabledAtCapture) "1" else "0")
field("schema", report.schemaVersion.toString())
field(
"crumbs",
report.breadcrumbs.joinToString(RecordSep) { crumb ->
listOf(
crumb.timestampMillis.toString(),
crumb.name,
crumb.properties.entries.joinToString(",") { "${it.key}:${it.value}" },
).joinToString("|")
},
)
}
fun decode(raw: String): CrashReport? {
if (raw.isBlank()) return null
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 id = map["id"] ?: return null
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() ?: return@mapNotNull null
val name = pieces[1]
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 CrashReport(
id = id,
timestampMillis = map["ts"]?.toLongOrNull() ?: 0L,
installId = map["install"].orEmpty(),
appVersion = map["app"].orEmpty(),
platform = map["platform"].orEmpty(),
exceptionType = map["type"].orEmpty(),
exceptionMessage = map["message"].orEmpty(),
stackTrace = map["stack"].orEmpty(),
breadcrumbs = crumbs,
diagnosticsEnabledAtCapture = map["diag"] == "1",
schemaVersion = map["schema"]?.toIntOrNull() ?: DiagnosticsSchemaVersion,
)
}
}

View File

@@ -0,0 +1,83 @@
package com.vnidrop.app.diagnostics
import com.vnidrop.app.logging.platformNowMillis
import com.vnidrop.app.preferences.PreferencesRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Product telemetry: sparse events, gated by diagnostics opt-in.
* Events are buffered and flushed in batches when transport is available.
*/
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 bufferMutex = Mutex()
@Volatile private var buffer: List<TelemetryEvent> = emptyList()
@Volatile private var enabled: Boolean = false
init {
scope.launch {
preferencesRepository.preferences
.map { it.diagnosticsEnabled }
.distinctUntilChanged()
.collect { isEnabled ->
enabled = isEnabled
if (!isEnabled) {
buffer = emptyList()
}
}
}
}
fun record(name: String, properties: Map<String, String> = emptyMap()) {
if (!DiagnosticsBuildConfig.INCLUDED) return
val redacted = LogRedactor.redactMap(properties)
breadcrumbs.add(name, redacted)
if (!enabled) return
val event = TelemetryEvent(
name = name.take(MaxNameLength),
timestampMillis = platformNowMillis(),
properties = redacted.mapValues { it.value.take(MaxPropertyValueLength) },
)
val next = (buffer + event).takeLast(maxBufferSize)
buffer = next
if (next.size >= flushThreshold) {
scope.launch { flush() }
}
}
suspend fun flush(): Result<Unit> = bufferMutex.withLock {
if (!enabled) {
buffer = emptyList()
return Result.success(Unit)
}
val batch = buffer
if (batch.isEmpty()) return Result.success(Unit)
buffer = emptyList()
val result = transport.sendEvents(batch)
if (result.isFailure) {
// Re-queue on failure so a future transport can retry once online.
buffer = (batch + buffer).takeLast(maxBufferSize)
}
return result
}
fun pendingCount(): Int = buffer.size
companion object {
const val DefaultMaxBuffer = 100
const val DefaultFlushThreshold = 20
private const val MaxNameLength = 64
private const val MaxPropertyValueLength = 128
}
}

View File

@@ -6,6 +6,7 @@ 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
@@ -36,12 +37,14 @@ 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 {
repository.initialize(environment.defaultCoreDataDir).onFailure(messages::error)
}
@@ -54,5 +57,6 @@ class AppViewModel(
fun selectDestination(destination: AppDestination) {
_state.update { it.copy(destination = destination) }
diagnostics?.record("nav_select", mapOf("destination" to destination.name))
}
}

View File

@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.about_bug_report
@@ -12,13 +13,21 @@ import vnidrop.shared.generated.resources.about_title
import vnidrop.shared.generated.resources.battery_level_title
import vnidrop.shared.generated.resources.device_model_title
import vnidrop.shared.generated.resources.device_name_title
import vnidrop.shared.generated.resources.diagnostics_description
import vnidrop.shared.generated.resources.diagnostics_title
import vnidrop.shared.generated.resources.network_title
import vnidrop.shared.generated.resources.os_version_title
import vnidrop.shared.generated.resources.value_unavailable
import vnidrop.shared.generated.resources.version_title
@Composable
internal fun AboutSettings(state: SettingsState, onBack: () -> Unit, showBack: Boolean) {
internal fun AboutSettings(
state: SettingsState,
onDiagnosticsChanged: (Boolean) -> Unit,
onReportBug: () -> Unit,
onBack: () -> Unit,
showBack: Boolean,
) {
val unavailable = stringResource(Res.string.value_unavailable)
val info = state.deviceInfo
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
@@ -29,11 +38,23 @@ internal fun AboutSettings(state: SettingsState, onBack: () -> Unit, showBack: B
title = stringResource(Res.string.about_privacy),
iconTone = SettingsIconTone.Neutral,
)
if (DiagnosticsBuildConfig.INCLUDED) {
SettingsDivider()
SettingsToggleRow(
icon = SettingsIcons.Info,
title = stringResource(Res.string.diagnostics_title),
description = stringResource(Res.string.diagnostics_description),
checked = state.diagnosticsEnabled,
enabled = true,
onCheckedChange = onDiagnosticsChanged,
)
}
SettingsDivider()
SettingsRow(
icon = SettingsIcons.Bug,
title = stringResource(Res.string.about_bug_report),
iconTone = SettingsIconTone.Neutral,
onClick = onReportBug,
)
}
SettingsGroup {

View File

@@ -0,0 +1,135 @@
package com.vnidrop.app.feature.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.components.Field
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.about_bug_report
import vnidrop.shared.generated.resources.bug_report_contact_label
import vnidrop.shared.generated.resources.bug_report_description
import vnidrop.shared.generated.resources.bug_report_device_section
import vnidrop.shared.generated.resources.bug_report_expected_label
import vnidrop.shared.generated.resources.bug_report_include_logs
import vnidrop.shared.generated.resources.bug_report_include_logs_description
import vnidrop.shared.generated.resources.bug_report_logs_size
import vnidrop.shared.generated.resources.bug_report_steps_label
import vnidrop.shared.generated.resources.bug_report_submit
import vnidrop.shared.generated.resources.bug_report_submitting
import vnidrop.shared.generated.resources.bug_report_what_label
import vnidrop.shared.generated.resources.device_model_title
import vnidrop.shared.generated.resources.device_name_title
import vnidrop.shared.generated.resources.os_version_title
import vnidrop.shared.generated.resources.value_unavailable
import vnidrop.shared.generated.resources.version_title
@Composable
internal fun BugReportSettings(
state: SettingsState,
onWhatChanged: (String) -> Unit,
onExpectedChanged: (String) -> Unit,
onStepsChanged: (String) -> Unit,
onContactChanged: (String) -> Unit,
onIncludeLogsChanged: (Boolean) -> Unit,
onSubmit: () -> Unit,
onBack: () -> Unit,
showBack: Boolean,
) {
val unavailable = stringResource(Res.string.value_unavailable)
val info = state.deviceInfo
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SettingsTopBar(stringResource(Res.string.about_bug_report), onBack, showBack)
Text(
stringResource(Res.string.bug_report_description),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodyMedium,
)
Field(
value = state.bugWhatHappened,
onValueChange = onWhatChanged,
label = stringResource(Res.string.bug_report_what_label),
minLines = 3,
enabled = !state.isSubmittingBugReport,
)
Field(
value = state.bugExpected,
onValueChange = onExpectedChanged,
label = stringResource(Res.string.bug_report_expected_label),
minLines = 2,
enabled = !state.isSubmittingBugReport,
)
Field(
value = state.bugSteps,
onValueChange = onStepsChanged,
label = stringResource(Res.string.bug_report_steps_label),
minLines = 2,
enabled = !state.isSubmittingBugReport,
)
Field(
value = state.bugContact,
onValueChange = onContactChanged,
label = stringResource(Res.string.bug_report_contact_label),
enabled = !state.isSubmittingBugReport,
)
SettingsGroup {
SettingsToggleRow(
icon = SettingsIcons.Document,
title = stringResource(Res.string.bug_report_include_logs),
description = stringResource(Res.string.bug_report_include_logs_description),
checked = state.bugIncludeLogs,
enabled = !state.isSubmittingBugReport,
onCheckedChange = onIncludeLogsChanged,
)
if (state.bugIncludeLogs && state.bugLogPreviewBytes > 0) {
SettingsDivider()
InfoItem(
stringResource(Res.string.bug_report_logs_size),
formatByteSize(state.bugLogPreviewBytes),
)
}
}
Text(
stringResource(Res.string.bug_report_device_section),
style = MaterialTheme.typography.titleSmall,
)
SettingsGroup {
InfoItem(stringResource(Res.string.version_title), state.appVersion)
SettingsDivider(startPadding = 16.dp)
InfoItem(stringResource(Res.string.device_name_title), info?.deviceName.orUnavailable(unavailable))
SettingsDivider(startPadding = 16.dp)
InfoItem(stringResource(Res.string.device_model_title), info?.deviceModel.orUnavailable(unavailable))
SettingsDivider(startPadding = 16.dp)
InfoItem(stringResource(Res.string.os_version_title), info?.operatingSystem ?: unavailable)
}
Row(modifier = Modifier.fillMaxWidth()) {
PrimaryButton(
text = if (state.isSubmittingBugReport) {
stringResource(Res.string.bug_report_submitting)
} else {
stringResource(Res.string.bug_report_submit)
},
onClick = onSubmit,
enabled = !state.isSubmittingBugReport,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
private fun String?.orUnavailable(fallback: String): String =
this?.takeIf(String::isNotBlank) ?: fallback
private fun formatByteSize(bytes: Int): String = when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
else -> "${bytes / (1024 * 1024)} MB"
}

View File

@@ -28,5 +28,12 @@ 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,
onBugContactChanged = viewModel::setBugContact,
onBugIncludeLogsChanged = viewModel::setBugIncludeLogs,
onSubmitBugReport = viewModel::submitBugReport,
)
}

View File

@@ -22,6 +22,13 @@ fun SettingsScreen(
onResetFolder: () -> Unit,
onNotificationsChanged: (Boolean) -> Unit,
onOpenNotificationSettings: () -> Unit,
onDiagnosticsChanged: (Boolean) -> Unit,
onBugWhatChanged: (String) -> Unit,
onBugExpectedChanged: (String) -> Unit,
onBugStepsChanged: (String) -> Unit,
onBugContactChanged: (String) -> Unit,
onBugIncludeLogsChanged: (Boolean) -> Unit,
onSubmitBugReport: () -> Unit,
) {
if (windowClass == WindowClass.Desktop) {
Row(
@@ -37,12 +44,20 @@ fun SettingsScreen(
section = state.selectedSection.takeUnless { it == SettingsSection.Overview } ?: SettingsSection.Preferences,
onBack = {},
showBack = false,
onSectionSelected = onSectionSelected,
onUsernameChanged = onUsernameChanged,
onThemeModeChanged = onThemeModeChanged,
onChooseFolder = onChooseFolder,
onResetFolder = onResetFolder,
onNotificationsChanged = onNotificationsChanged,
onOpenNotificationSettings = onOpenNotificationSettings,
onDiagnosticsChanged = onDiagnosticsChanged,
onBugWhatChanged = onBugWhatChanged,
onBugExpectedChanged = onBugExpectedChanged,
onBugStepsChanged = onBugStepsChanged,
onBugContactChanged = onBugContactChanged,
onBugIncludeLogsChanged = onBugIncludeLogsChanged,
onSubmitBugReport = onSubmitBugReport,
)
}
}
@@ -52,14 +67,30 @@ fun SettingsScreen(
else -> SettingsSectionContent(
state = state,
section = state.selectedSection,
onBack = { onSectionSelected(SettingsSection.Overview) },
onBack = {
onSectionSelected(
if (state.selectedSection == SettingsSection.BugReport) {
SettingsSection.About
} else {
SettingsSection.Overview
},
)
},
showBack = true,
onSectionSelected = onSectionSelected,
onUsernameChanged = onUsernameChanged,
onThemeModeChanged = onThemeModeChanged,
onChooseFolder = onChooseFolder,
onResetFolder = onResetFolder,
onNotificationsChanged = onNotificationsChanged,
onOpenNotificationSettings = onOpenNotificationSettings,
onDiagnosticsChanged = onDiagnosticsChanged,
onBugWhatChanged = onBugWhatChanged,
onBugExpectedChanged = onBugExpectedChanged,
onBugStepsChanged = onBugStepsChanged,
onBugContactChanged = onBugContactChanged,
onBugIncludeLogsChanged = onBugIncludeLogsChanged,
onSubmitBugReport = onSubmitBugReport,
)
}
}
@@ -71,18 +102,43 @@ private fun SettingsSectionContent(
section: SettingsSection,
onBack: () -> Unit,
showBack: Boolean,
onSectionSelected: (SettingsSection) -> Unit,
onUsernameChanged: (String) -> Unit,
onThemeModeChanged: (ThemeMode) -> Unit,
onChooseFolder: () -> Unit,
onResetFolder: () -> Unit,
onNotificationsChanged: (Boolean) -> Unit,
onOpenNotificationSettings: () -> Unit,
onDiagnosticsChanged: (Boolean) -> Unit,
onBugWhatChanged: (String) -> Unit,
onBugExpectedChanged: (String) -> Unit,
onBugStepsChanged: (String) -> Unit,
onBugContactChanged: (String) -> Unit,
onBugIncludeLogsChanged: (Boolean) -> Unit,
onSubmitBugReport: () -> Unit,
) {
when (section) {
SettingsSection.Overview -> Unit
SettingsSection.Preferences -> PreferencesSettings(state, onUsernameChanged, onChooseFolder, onResetFolder, onBack, showBack)
SettingsSection.Appearance -> AppearanceSettings(state.themeMode, onThemeModeChanged, onBack, showBack)
SettingsSection.Notifications -> NotificationSettings(state, onNotificationsChanged, onOpenNotificationSettings, onBack, showBack)
SettingsSection.About -> AboutSettings(state, onBack, showBack)
SettingsSection.About -> AboutSettings(
state = state,
onDiagnosticsChanged = onDiagnosticsChanged,
onReportBug = { onSectionSelected(SettingsSection.BugReport) },
onBack = onBack,
showBack = showBack,
)
SettingsSection.BugReport -> BugReportSettings(
state = state,
onWhatChanged = onBugWhatChanged,
onExpectedChanged = onBugExpectedChanged,
onStepsChanged = onBugStepsChanged,
onContactChanged = onBugContactChanged,
onIncludeLogsChanged = onBugIncludeLogsChanged,
onSubmit = onSubmitBugReport,
onBack = onBack,
showBack = showBack,
)
}
}

View File

@@ -8,6 +8,10 @@ import com.vnidrop.app.PlatformEnvironment
import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.core.ReceiveFolder
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
@@ -27,7 +31,13 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.bug_report_missing_expected
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
@@ -39,6 +49,7 @@ enum class SettingsSection {
Appearance,
Notifications,
About,
BugReport,
}
data class SettingsState(
@@ -50,9 +61,17 @@ data class SettingsState(
val themeMode: ThemeMode = ThemeMode.System,
val notificationsEnabled: Boolean = false,
val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined,
val diagnosticsEnabled: Boolean = false,
val deviceInfo: DeviceInfo? = null,
val appVersion: String = "",
val isLoadingDeviceInfo: Boolean = false,
val bugWhatHappened: String = "",
val bugExpected: String = "",
val bugSteps: String = "",
val bugContact: String = "",
val bugIncludeLogs: Boolean = true,
val isSubmittingBugReport: Boolean = false,
val bugLogPreviewBytes: Int = 0,
)
sealed interface SettingsEffect {
@@ -66,6 +85,8 @@ class SettingsViewModel(
private val preferencesRepository: PreferencesRepository,
private val notifications: LocalNotificationService,
private val messages: UiMessageController,
private val bugReports: BugReportService,
private val diagnostics: DiagnosticsCoordinator? = null,
) : ViewModel() {
private val _state = MutableStateFlow(SettingsState(appVersion = environment.appVersion))
val state: StateFlow<SettingsState> = _state.asStateFlow()
@@ -88,6 +109,7 @@ class SettingsViewModel(
receiveFolder = preferences.receiveFolder,
themeMode = preferences.themeMode,
notificationsEnabled = preferences.notificationsEnabled,
diagnosticsEnabled = preferences.diagnosticsEnabled,
)
}
if (preferences.receiveFolder != previousFolder) {
@@ -101,7 +123,13 @@ class SettingsViewModel(
fun selectSection(section: SettingsSection) {
_state.update { it.copy(selectedSection = section) }
if (section == SettingsSection.About) loadDeviceInfo()
when (section) {
SettingsSection.About, SettingsSection.BugReport -> {
loadDeviceInfo()
if (section == SettingsSection.BugReport) refreshBugLogPreview()
}
else -> Unit
}
}
fun setUsername(value: String) {
@@ -164,6 +192,90 @@ class SettingsViewModel(
}
}
fun setDiagnosticsEnabled(enabled: Boolean) {
if (!DiagnosticsBuildConfig.INCLUDED) 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) }
fun setBugContact(value: String) = _state.update { it.copy(bugContact = value) }
fun setBugIncludeLogs(value: Boolean) = _state.update { it.copy(bugIncludeLogs = value) }
fun submitBugReport() {
if (_state.value.isSubmittingBugReport) return
viewModelScope.launch {
val snapshot = _state.value
val what = snapshot.bugWhatHappened.trim()
val expected = snapshot.bugExpected.trim()
if (what.isEmpty()) {
messages.show(UiMessage(UiText.Resource(Res.string.bug_report_missing_what), UiMessageTone.Warning))
return@launch
}
if (expected.isEmpty()) {
messages.show(UiMessage(UiText.Resource(Res.string.bug_report_missing_expected), UiMessageTone.Warning))
return@launch
}
_state.update { it.copy(isSubmittingBugReport = true) }
try {
val result = bugReports.submit(
BugReportDraft(
whatHappened = what,
expected = expected,
steps = snapshot.bugSteps,
contact = snapshot.bugContact,
includeLogs = snapshot.bugIncludeLogs,
),
deviceInfo = snapshot.deviceInfo,
)
result.fold(
onSuccess = {
diagnostics?.record("bug_report_submitted")
_state.update {
it.copy(
isSubmittingBugReport = false,
bugWhatHappened = "",
bugExpected = "",
bugSteps = "",
bugContact = "",
bugIncludeLogs = true,
)
}
messages.show(
UiMessage(UiText.Resource(Res.string.bug_report_submitted), UiMessageTone.Success),
)
selectSection(SettingsSection.About)
},
onFailure = {
_state.update { it.copy(isSubmittingBugReport = false) }
messages.show(
UiMessage(UiText.Resource(Res.string.bug_report_submit_failed), UiMessageTone.Error),
)
},
)
} catch (error: Throwable) {
if (error is CancellationException) throw error
_state.update { it.copy(isSubmittingBugReport = false) }
messages.show(UiMessage(UiText.Resource(Res.string.bug_report_submit_failed), UiMessageTone.Error))
}
}
}
fun openNotificationSettings() {
viewModelScope.launch {
enableNotificationsAfterSettings = true
@@ -212,6 +324,13 @@ class SettingsViewModel(
}
}
private fun refreshBugLogPreview() {
viewModelScope.launch {
val bytes = runCatching { bugReports.previewLogBytes() }.getOrDefault(0)
_state.update { it.copy(bugLogPreviewBytes = bytes) }
}
}
private suspend fun validateFolder(folder: ReceiveFolder) {
_state.update { it.copy(isValidatingFolder = true) }
val status = fileSystemService.validateReceiveFolder(folder)

View File

@@ -31,6 +31,8 @@ interface PlatformLogStore {
val logDirectory: String
fun append(line: String)
fun listLogFiles(): List<LogFileInfo>
/** Newest log content, up to [maxBytes], from the active file then rotated tails if needed. */
fun readLatest(maxBytes: Long): String
}
expect fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore
@@ -72,6 +74,9 @@ object AppLogger {
fun listLogFiles(): List<LogFileInfo> =
store?.listLogFiles().orEmpty()
fun readLatestLogs(maxBytes: Long = DefaultBugReportLogBytes): String =
store?.readLatest(maxBytes).orEmpty()
private fun write(level: AppLogLevel, scope: String, message: String, fields: Map<String, String>) {
val line = buildString {
append(platformNowMillis())
@@ -89,6 +94,8 @@ object AppLogger {
}
store?.append(line)
}
const val DefaultBugReportLogBytes: Long = 256 * 1024
}
private fun String.sanitizeLogValue(): String =

View File

@@ -10,8 +10,10 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.ui.theme.ThemeMode
import com.vnidrop.app.util.randomUuidString
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import okio.Path.Companion.toPath
@@ -20,6 +22,10 @@ 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. */
val diagnosticsInstallId: String = "",
)
class AppPreferencesDefaults(
@@ -27,6 +33,7 @@ class AppPreferencesDefaults(
val receiveFolder: ReceiveFolder,
val themeMode: ThemeMode,
val notificationsEnabled: Boolean = false,
val diagnosticsEnabled: Boolean = false,
)
interface PreferencesRepository {
@@ -36,6 +43,9 @@ interface PreferencesRepository {
suspend fun resetReceiveFolder()
suspend fun setThemeMode(mode: ThemeMode)
suspend fun setNotificationsEnabled(enabled: Boolean)
suspend fun setDiagnosticsEnabled(enabled: Boolean)
/** Ensures a durable install id exists and returns it. */
suspend fun ensureDiagnosticsInstallId(): String
}
class AppPreferencesRepository(
@@ -50,6 +60,8 @@ 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(),
)
}
@@ -82,6 +94,24 @@ class AppPreferencesRepository(
prefs[PreferenceKeys.NotificationsEnabled] = enabled
}
}
override suspend fun setDiagnosticsEnabled(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[PreferenceKeys.DiagnosticsEnabled] = enabled
}
}
override suspend fun ensureDiagnosticsInstallId(): String {
val existing = preferences.first().diagnosticsInstallId
if (existing.isNotBlank()) return existing
val created = randomUuidString()
dataStore.edit { prefs ->
if (prefs[PreferenceKeys.DiagnosticsInstallId].isNullOrBlank()) {
prefs[PreferenceKeys.DiagnosticsInstallId] = created
}
}
return preferences.first().diagnosticsInstallId.ifBlank { created }
}
}
fun createAppPreferencesDataStore(appDataDir: String): DataStore<Preferences> =
@@ -96,6 +126,8 @@ 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")
}
private fun resolveReceiveFolder(prefs: Preferences, defaults: ReceiveFolder): ReceiveFolder {

View File

@@ -0,0 +1,20 @@
package com.vnidrop.app.util
import kotlin.random.Random
/** RFC 4122 version-4 UUID string without depending on java.util.UUID in commonMain. */
fun randomUuidString(random: Random = Random.Default): String {
val bytes = ByteArray(16)
random.nextBytes(bytes)
bytes[6] = ((bytes[6].toInt() and 0x0f) or 0x40).toByte()
bytes[8] = ((bytes[8].toInt() and 0x3f) or 0x80).toByte()
return buildString(36) {
bytes.forEachIndexed { index, byte ->
if (index == 4 || index == 6 || index == 8 || index == 10) append('-')
append(HEX[(byte.toInt() ushr 4) and 0x0f])
append(HEX[byte.toInt() and 0x0f])
}
}
}
private val HEX = "0123456789abcdef".toCharArray()