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

@@ -0,0 +1,36 @@
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) {
directory.mkdirs()
File(directory, "${report.id}.crash").writeText(CrashReportCodec.encode(report), StandardCharsets.UTF_8)
}
@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) {
File(directory, "$id.crash").delete()
}
}

View File

@@ -0,0 +1,9 @@
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)
}
}

View File

@@ -1,6 +1,7 @@
package com.vnidrop.app.logging
import java.io.File
import java.io.RandomAccessFile
import java.nio.charset.StandardCharsets
actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore =
@@ -37,6 +38,32 @@ private class JvmPlatformLogStore(
.map { file -> LogFileInfo(file.name, file.absolutePath, file.length(), file.lastModified()) }
}
@Synchronized
override fun readLatest(maxBytes: Long): String {
if (maxBytes <= 0) return ""
directory.mkdirs()
val files = listOf(activeFile) +
(1..policy.maxFiles).map { File(directory, "app.$it.log") }
val chunks = ArrayList<ByteArray>()
var remaining = maxBytes
for (file in files) {
if (remaining <= 0 || !file.isFile) continue
val slice = readTail(file, remaining)
if (slice.isEmpty()) continue
chunks.add(0, slice)
remaining -= slice.size.toLong()
}
if (chunks.isEmpty()) return ""
val total = chunks.sumOf { it.size }
val out = ByteArray(total)
var offset = 0
for (chunk in chunks) {
chunk.copyInto(out, offset)
offset += chunk.size
}
return String(out, StandardCharsets.UTF_8)
}
private fun rotate() {
if (policy.maxFiles == 0) {
activeFile.delete()
@@ -54,3 +81,23 @@ private class JvmPlatformLogStore(
}
}
}
private fun readTail(file: File, maxBytes: Long): ByteArray {
if (!file.isFile || file.length() == 0L || maxBytes <= 0) return ByteArray(0)
val length = file.length()
val start = (length - maxBytes).coerceAtLeast(0L)
val size = (length - start).toInt()
RandomAccessFile(file, "r").use { raf ->
raf.seek(start)
val bytes = ByteArray(size)
raf.readFully(bytes)
if (start == 0L) return bytes
// Align to the next full line when we start mid-file.
val newline = bytes.indexOf('\n'.code.toByte())
return if (newline in 0 until bytes.lastIndex) {
bytes.copyOfRange(newline + 1, bytes.size)
} else {
bytes
}
}
}