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,67 @@
package com.vnidrop.app.diagnostics
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.convert
import kotlinx.cinterop.usePinned
import platform.Foundation.NSData
import platform.Foundation.NSFileManager
import platform.Foundation.NSString
import platform.Foundation.NSUTF8StringEncoding
import platform.Foundation.create
import platform.Foundation.dataUsingEncoding
import platform.Foundation.dataWithContentsOfFile
import platform.Foundation.writeToFile
import platform.posix.memcpy
actual fun createPendingCrashStore(appDataDir: String): PendingCrashStore =
IosPendingCrashStore(appDataDir)
@OptIn(ExperimentalForeignApi::class)
private class IosPendingCrashStore(
appDataDir: String,
) : PendingCrashStore {
private val fileManager = NSFileManager.defaultManager
private val directory = appDataDir.trimEnd('/') + "/diagnostics/crashes"
override fun write(report: CrashReport) {
ensureDirectory()
val path = "$directory/${report.id}.crash"
val payload = CrashReportCodec.encode(report)
val data = (payload as NSString).dataUsingEncoding(NSUTF8StringEncoding) ?: return
data.writeToFile(path, atomically = true)
}
override fun list(): List<CrashReport> {
ensureDirectory()
val names = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty()
.filterIsInstance<String>()
.filter { it.endsWith(".crash") }
return names.mapNotNull { name ->
val path = "$directory/$name"
val data = NSData.dataWithContentsOfFile(path) ?: return@mapNotNull null
val text = data.toUtf8String()
CrashReportCodec.decode(text)
}.sortedByDescending { it.timestampMillis }
}
override fun delete(id: String) {
fileManager.removeItemAtPath("$directory/$id.crash", null)
}
private fun ensureDirectory() {
fileManager.createDirectoryAtPath(directory, withIntermediateDirectories = true, attributes = null, error = null)
}
}
@OptIn(ExperimentalForeignApi::class)
private fun NSData.toUtf8String(): String {
val size = length.toInt()
if (size == 0) return ""
val result = ByteArray(size)
val source = bytes ?: return ""
result.usePinned { pinned ->
memcpy(pinned.addressOf(0), source, size.convert())
}
return result.decodeToString()
}

View File

@@ -0,0 +1,16 @@
package com.vnidrop.app.diagnostics
import kotlin.experimental.ExperimentalNativeApi
@OptIn(ExperimentalNativeApi::class)
actual fun installPlatformCrashHook(onCrash: (Throwable) -> Unit) {
val previous = setUnhandledExceptionHook { throwable ->
runCatching { onCrash(throwable) }
// Terminate like the default hook after capture.
terminateWithUnhandledException(throwable)
}
// Keep a reference so the previous hook is not GC'd unused; we intentionally
// replace the default with capture-then-terminate.
@Suppress("UNUSED_VARIABLE")
val ignored = previous
}

View File

@@ -1,19 +1,21 @@
package com.vnidrop.app.logging
import platform.Foundation.NSData
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.convert
import kotlinx.cinterop.usePinned
import platform.Foundation.NSData
import platform.Foundation.NSDate
import platform.Foundation.NSFileManager
import platform.Foundation.NSFileModificationDate
import platform.Foundation.NSFileSize
import platform.Foundation.NSNumber
import platform.Foundation.dataWithContentsOfFile
import platform.Foundation.timeIntervalSince1970
import platform.posix.fclose
import platform.posix.fopen
import platform.posix.fwrite
import platform.posix.memcpy
actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore =
IosPlatformLogStore(appDataDir, policy)
@@ -61,6 +63,31 @@ private class IosPlatformLogStore(
.sortedByDescending { it.modifiedAtMillis }
}
override fun readLatest(maxBytes: Long): String {
if (maxBytes <= 0) return ""
ensureDirectory()
val paths = listOf(activePath) +
(1..policy.maxFiles).map { "$directory/app.$it.log" }
val chunks = ArrayList<ByteArray>()
var remaining = maxBytes
for (path in paths) {
if (remaining <= 0 || !fileManager.fileExistsAtPath(path)) continue
val slice = readTail(path, 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 out.decodeToString()
}
private fun rotate() {
if (policy.maxFiles == 0) {
fileManager.removeItemAtPath(activePath, null)
@@ -92,4 +119,31 @@ private class IosPlatformLogStore(
val date = attributes[NSFileModificationDate] as? NSDate ?: return 0L
return (date.timeIntervalSince1970 * 1000.0).toLong()
}
private fun readTail(path: String, maxBytes: Long): ByteArray {
val data = NSData.dataWithContentsOfFile(path) ?: return ByteArray(0)
val all = data.toByteArray()
if (all.isEmpty() || maxBytes <= 0) return ByteArray(0)
if (all.size.toLong() <= maxBytes) return all
val start = all.size - maxBytes.toInt()
val slice = all.copyOfRange(start, all.size)
val newline = slice.indexOf('\n'.code.toByte())
return if (newline in 0 until slice.lastIndex) {
slice.copyOfRange(newline + 1, slice.size)
} else {
slice
}
}
}
@OptIn(ExperimentalForeignApi::class)
private fun NSData.toByteArray(): ByteArray {
val size = length.toInt()
if (size == 0) return ByteArray(0)
val result = ByteArray(size)
val source = bytes ?: return ByteArray(0)
result.usePinned { pinned ->
memcpy(pinned.addressOf(0), source, size.convert())
}
return result
}