Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application
android:name=".DasherApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
Expand Down
91 changes: 91 additions & 0 deletions app/src/main/java/at/dasher/android/AnalyticsService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,28 @@ object AnalyticsService {
private var appContext: Context? = null
private var initialized = false

/** Engine diagnostic log ring buffer (RFC 0009): appended to a crash report as engine_log_tail. */
private val engineLog = ArrayDeque<String>()
private const val ENGINE_LOG_MAX_LINES = 64
private const val ENGINE_LOG_MAX_BYTES = 8 * 1024
private const val CRASH_FILE = "pending_crash.txt"
private const val CRASH_MAX_AGE_MS = 7L * 24 * 60 * 60 * 1000

@Synchronized
fun appendEngineLog(level: Int, message: String) {
val line = "[${"DIWE".getOrElse(level) { 'X' }}] $message"
engineLog.addLast(line)
while (engineLog.size > ENGINE_LOG_MAX_LINES) engineLog.removeFirst()
// Drop the oldest while over the byte cap.
var bytes = engineLog.sumOf { it.length }
while (bytes > ENGINE_LOG_MAX_BYTES && engineLog.size > 1) {
bytes -= engineLog.removeFirst().length
}
}

@Synchronized
private fun snapshotEngineLog(): String = engineLog.joinToString("\n")

/** Call from Application/Activity startup. Sets up the SDK only if already opted in. */
fun init(context: Context) {
appContext = context.applicationContext
Expand Down Expand Up @@ -88,6 +110,75 @@ object AnalyticsService {
}
}

// ── Crash reporting (RFC 0009) ─────────────────────────────────────────────
// JVM-level capture only in v1: a native SIGSEGV inside libdasher.so is not seen
// by Thread.setDefaultUncaughtExceptionHandler; a signal shim is a follow-up.

/** Install the uncaught-exception handler. Call once from Application.onCreate. */
fun installCrashHandler(context: Context) {
appContext = context.applicationContext
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
writeCrashFile(context, thread.name, throwable)
} catch (_: Throwable) { /* never throw in a crash handler */ }
previous?.uncaughtException(thread, throwable)
}
}

/**
* On launch: if a pending crash file exists, send it (only if opted in — RFC 0009)
* and delete it. Crash files older than 7 days are discarded regardless.
*/
fun flushPendingCrash(context: Context) {
val file = java.io.File(context.filesDir, CRASH_FILE)
if (!file.exists()) return
val age = System.currentTimeMillis() - file.lastModified()
if (age > CRASH_MAX_AGE_MS) { file.delete(); return }
if (optedIn(context)) {
try {
val text = file.readText()
// Minimal envelope: lines "key=value", stack/engine tail after blank line.
val props = mutableMapOf<String, Any>()
val (header, body) = text.split("\n\n", limit = 2)
.let { it.first() to (it.getOrNull(1) ?: "") }
header.split('\n').forEach { ln ->
val idx = ln.indexOf('=')
if (idx > 0) props[ln.substring(0, idx)] = ln.substring(idx + 1)
}
if (body.isNotBlank()) props["stack_trace"] = body
capture("crash", props)
} catch (_: Throwable) { }
}
file.delete()
}

private fun writeCrashFile(context: Context, threadName: String, t: Throwable) {
val sw = java.io.StringWriter()
t.printStackTrace(java.io.PrintWriter(sw))
val stack = scrub(sw.toString()).take(16 * 1024)
val engineTail = scrub(snapshotEngineLog()).take(8 * 1024)
val header = buildString {
append("exception_type=").append(t::class.java.name).append('\n')
append("thread=").append(threadName).append('\n')
append("app_version=").append(appVersion()).append('\n')
append("os_version=Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})\n")
}
// stack_trace carries the JVM stack + the engine log tail (separated) so a
// maintainer can reconstruct what DasherCore was doing when the process died.
val body = if (engineTail.isNotBlank()) "$stack\n--- engine log ---\n$engineTail" else stack
java.io.File(context.filesDir, CRASH_FILE).writeText("$header\n\n$body")
}

/** Scrub home-directory path segments and emails; respect RFC 0001's no-PII promise. */
private fun scrub(s: String): String {
var out = s
out = Regex("""(/Users/|/home/)([^/\\]+)""").replace(out) { "${it.groupValues[1]}<user>" }
out = Regex("""C:\\Users\\([^\\]+)""").replace(out, "C:\\Users\\<user>")
out = Regex("""[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}""").replace(out, "<email>")
return out
}

private fun anonId(context: Context): String =
prefs(context).getString(KEY_ANON_ID, null) ?: UUID.randomUUID().toString().also {
prefs(context).edit().putString(KEY_ANON_ID, it).apply()
Expand Down
25 changes: 25 additions & 0 deletions app/src/main/java/at/dasher/android/DasherApp.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package at.dasher.android

import android.app.Application
import android.content.Context

/**
* Process entry point. Installs the crash handler + flushes any pending crash
* (RFC 0009) and initialises analytics (RFC 0001) as early as possible, before
* any Activity runs. The uncaught-exception handler writes a crash file even
* before the user has opted in; flushPendingCrash only transmits if they have.
*/
class DasherApp : Application() {
override fun onCreate() {
super.onCreate()
AnalyticsService.installCrashHandler(this)
AnalyticsService.flushPendingCrash(this)
AnalyticsService.init(this)
}

// RFC 0003: wrap the base context so the Compose UI (strings.xml) follows the
// user-chosen locale on every API level, independent of the engine's own locale.
override fun attachBaseContext(base: Context) {
super.attachBaseContext(LocaleHelper.wrap(base))
}
}
3 changes: 3 additions & 0 deletions app/src/main/java/at/dasher/android/DasherEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,9 @@ class DasherEngine(
1 -> Log.i("DasherCore", text)
else -> Log.d("DasherCore", text)
}
// Also feed the crash ring buffer (RFC 0009): info+ kept so a crash
// report can carry the engine's last actions as engine_log_tail.
if (level >= 1) AnalyticsService.appendEngineLog(level, text)
}
}
}
Expand Down
37 changes: 37 additions & 0 deletions app/src/main/java/at/dasher/android/LocaleHelper.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package at.dasher.android

import android.content.Context
import android.content.res.Configuration
import java.util.Locale

/**
* RFC 0003: per-app locale for the frontend chrome. The engine's own labels are
* localised via `dasher_set_locale`; this wraps the Application's base context so
* the Compose UI's `strings.xml` resources follow the same user-chosen locale on
* all API levels (no AppCompat dependency needed).
*
* Flow: [setLocale] persists the code and the top Activity is recreated;
* [DasherApp.attachBaseContext] re-wraps on the next process start.
*/
object LocaleHelper {
private const val PREFS = "dasher_locale"
private const val KEY = "app_locale"

fun wrap(base: Context): Context {
val code = base.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY, null)
?: return base
val locale = Locale.forLanguageTag(code)
Locale.setDefault(locale)
val config = Configuration(base.resources.configuration)
config.setLocale(locale)
config.setLayoutDirection(locale)
return base.createConfigurationContext(config)
}

fun setLocale(context: Context, code: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY, code).apply()
}

fun currentLanguageTag(context: Context): String? =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY, null)
}
Loading
Loading