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
60 changes: 60 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import java.util.Properties
import java.util.concurrent.TimeUnit

plugins {
id("com.android.application")
Expand All @@ -11,6 +12,39 @@ if (keystorePropertiesFile.exists()) {
keystoreProperties.load(keystorePropertiesFile.inputStream())
}

// Reads the highest semver tag from `git tag`. Used by debug builds so the
// installed APK reflects the actual latest release (CI strips/injects versionName
// for release builds — debug runs locally without that injection, so we derive
// it from git here). Fails open to the defaultConfig versionName if git is
// unavailable.
fun gitLatestSemverTag(): String? = try {
val proc = ProcessBuilder("git", "tag", "--list", "v*", "--sort=-version:refname")
.directory(rootProject.projectDir)
.redirectErrorStream(true)
.start()
if (proc.waitFor(2, TimeUnit.SECONDS)) {
proc.inputStream.bufferedReader().readLines()
.map { it.trim().removePrefix("v") }
.firstOrNull { it.matches(Regex("\\d+\\.\\d+\\.\\d+")) }
} else {
proc.destroyForcibly(); null
}
} catch (_: Throwable) { null }

// Total commit count — used as debug versionCode so each rebuild after pulling
// new commits gets a higher code than any previously installed debug build.
fun gitCommitCount(): Int = try {
val proc = ProcessBuilder("git", "rev-list", "--count", "HEAD")
.directory(rootProject.projectDir)
.redirectErrorStream(true)
.start()
if (proc.waitFor(2, TimeUnit.SECONDS)) {
proc.inputStream.bufferedReader().readText().trim().toIntOrNull() ?: 0
} else {
proc.destroyForcibly(); 0
}
} catch (_: Throwable) { 0 }

android {
namespace = "com.castla.mirror"
compileSdk = 35
Expand All @@ -36,6 +70,12 @@ android {
}

buildTypes {
debug {
applicationIdSuffix = ".debug"
// versionNameSuffix intentionally not set here — overridden via
// androidComponents.onVariants below so the debug name follows the
// current git tag rather than the stub defaultConfig.versionName.
}
release {
isMinifyEnabled = true
isShrinkResources = true
Expand Down Expand Up @@ -66,6 +106,7 @@ android {

testOptions {
unitTests.isIncludeAndroidResources = true
unitTests.isReturnDefaultValues = true
}

compileOptions {
Expand All @@ -74,6 +115,25 @@ android {
}
}

androidComponents {
onVariants(selector().withBuildType("debug")) { variant ->
val tag = gitLatestSemverTag()
val commitCount = gitCommitCount()
variant.outputs.forEach { output ->
val nameProvider = output.versionName
val codeProvider = output.versionCode
if (tag != null) {
nameProvider.set("$tag-debug")
} else {
nameProvider.set(nameProvider.get() + "-debug")
}
if (commitCount > 0) {
codeProvider.set(commitCount)
}
}
}
}

dependencies {
// NanoHTTPD (HTTP + WebSocket server)
implementation("org.nanohttpd:nanohttpd:2.3.1")
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/com/castla/mirror/CastlaApp.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
package com.castla.mirror

import android.app.Application
import com.castla.mirror.diagnostics.FileLogger

class CastlaApp : Application() {
override fun onCreate() {
super.onCreate()
FileLogger.init(this)
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
FileLogger.e("UEH", "Uncaught on ${thread.name}", throwable)
} catch (_: Throwable) {
}
previous?.uncaughtException(thread, throwable)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.castla.mirror.diagnostics

/**
* Redacts URLs, intent extras, and shell command bodies from log messages
* before they are persisted to disk. Pure object; safe to call concurrently.
*/
object DiagnosticSanitizer {

private const val MAX_LEN = 500
private val URL_REGEX = Regex("https?://[^\\s\"]+")
private val EXTRA_REGEX = Regex("--es\\s+(\\w+)\\s+\\S+")
private val EXECUTING_PREFIX = Regex("(Executing:\\s*).+", RegexOption.DOT_MATCHES_ALL)

/** Returns scheme + host only (no path/query/fragment). Sentinel on parse failure. */
fun redactUrl(url: String): String {
if (url.isBlank()) return "<invalid-url>"
return try {
val u = java.net.URI(url)
val scheme = u.scheme ?: return "<invalid-url>"
val host = u.host ?: return "<invalid-url>"
"$scheme://$host"
} catch (_: Throwable) {
"<invalid-url>"
}
}

/** Strips PII-prone fragments and caps at [MAX_LEN] chars. Safe inputs pass through. */
fun safeMessage(msg: String): String {
if (msg.isEmpty()) return msg
var s = msg
// Order matters: handle "Executing:" first so its trailing command becomes <command-redacted>
// BEFORE we attempt URL/extra redaction inside the now-removed body.
s = EXECUTING_PREFIX.replace(s) { it.groupValues[1] + "<command-redacted>" }
s = EXTRA_REGEX.replace(s) { "--es ${it.groupValues[1]} <redacted>" }
s = URL_REGEX.replace(s) { redactUrl(it.value) }
if (s.length > MAX_LEN) s = s.substring(0, MAX_LEN) + "…"
return s
}
}
136 changes: 136 additions & 0 deletions app/src/main/java/com/castla/mirror/diagnostics/FileLogger.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package com.castla.mirror.diagnostics

import android.content.Context
import android.util.Log
import java.io.File
import java.io.FileWriter
import java.io.PrintWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale

/**
* Persistent on-disk logger with size-bounded rotation. All persisted writes
* are sanitized via [DiagnosticSanitizer] so URLs, intent extras, and shell
* command bodies never hit disk.
*
* Storage: `<filesDir>/logs/mirror.log` (current) + `mirror.log.1` (rotated).
* Cap ~512 KB per file by default → ~1 MB total on disk.
*
* If init fails (e.g. read-only filesystem), the logger silently degrades to
* no-op mode rather than crashing the app.
*/
object FileLogger {

private const val TAG = "FileLogger"
private const val DEFAULT_MAX_FILE_BYTES = 512_000L

private val lock = Any()
@Volatile private var initialized = false
@Volatile private var degraded = false
private var logsDir: File? = null
private var maxFileBytes: Long = DEFAULT_MAX_FILE_BYTES

private val timestampFmt = ThreadLocal.withInitial {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS", Locale.US)
}

/** Production init from [Context]. Idempotent; safe on init failure. */
fun init(context: Context) {
synchronized(lock) {
if (initialized) return
tryInit(File(context.filesDir, "logs"), DEFAULT_MAX_FILE_BYTES)
}
}

/** Test-only init taking an explicit parent directory and cap. */
internal fun initForTest(parent: File, maxFileBytes: Long) {
synchronized(lock) {
if (initialized) return
tryInit(File(parent, "logs"), maxFileBytes)
}
}

/** Test-only: clear initialization state so subsequent init() rebinds. */
internal fun resetForTest() {
synchronized(lock) {
initialized = false
degraded = false
logsDir = null
maxFileBytes = DEFAULT_MAX_FILE_BYTES
}
}

private fun tryInit(dir: File, maxBytes: Long) {
try {
if (!dir.exists() && !dir.mkdirs()) {
Log.w(TAG, "Could not create logs dir: $dir — degraded mode")
degraded = true
initialized = true
return
}
if (!dir.isDirectory) {
Log.w(TAG, "Path exists but is not a directory: $dir — degraded mode")
degraded = true
initialized = true
return
}
this.logsDir = dir
this.maxFileBytes = maxBytes
this.degraded = false
this.initialized = true
} catch (t: Throwable) {
Log.w(TAG, "FileLogger init failed — degraded mode", t)
degraded = true
initialized = true
}
}

fun i(tag: String, msg: String) = write("I", tag, msg, null)
fun w(tag: String, msg: String, t: Throwable? = null) = write("W", tag, msg, t)
fun e(tag: String, msg: String, t: Throwable? = null) = write("E", tag, msg, t)

fun getLogFiles(): List<File> {
synchronized(lock) {
val dir = logsDir ?: return emptyList()
val current = File(dir, "mirror.log")
val rotated = File(dir, "mirror.log.1")
return listOf(current, rotated).filter { it.exists() && it.length() > 0 }
}
}

fun clear() {
synchronized(lock) {
val dir = logsDir ?: return
File(dir, "mirror.log").delete()
File(dir, "mirror.log.1").delete()
}
}

private fun write(level: String, tag: String, msg: String, t: Throwable?) {
if (!initialized || degraded) return
val safe = DiagnosticSanitizer.safeMessage(msg)
val ts = timestampFmt.get()?.format(Date()) ?: ""
val tname = Thread.currentThread().name
val line = "$ts $level $tag: $safe (t=$tname)"
synchronized(lock) {
val dir = logsDir ?: return
try {
val current = File(dir, "mirror.log")
if (current.exists() && current.length() >= maxFileBytes) {
val rotated = File(dir, "mirror.log.1")
if (rotated.exists()) rotated.delete()
current.renameTo(rotated)
}
FileWriter(current, true).use { fw ->
PrintWriter(fw).use { pw ->
pw.println(line)
if (t != null) t.printStackTrace(pw)
}
}
} catch (failure: Throwable) {
Log.w(TAG, "Failed to write log entry", failure)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ enum class DisconnectCause {
UNKNOWN
}

/**
* Specific terminal failure reasons emitted by the service when a known silent-fail
* path is hit. Persisted to disk via [FileLogger] so post-mortem diagnosis is possible.
*/
enum class TerminalReason {
VD_RECREATE_FAILED,
SHIZUKU_REBIND_FAILED,
PIPELINE_REBUILD_EXCEPTION,
BROWSER_ACTIVATION_FAILED,
}

/**
* Pure classifier — given the set of recent diagnostic events that preceded a
* session end, returns the most likely [DisconnectCause].
Expand Down Expand Up @@ -125,6 +136,7 @@ object MirrorDiagnostics {
sessionActive = true
}
Log.i(TAG, "[SESSION_START]")
FileLogger.i(TAG, "[SESSION_START]")
}

/** Record a diagnostic event with optional detail string. */
Expand All @@ -142,6 +154,7 @@ object MirrorDiagnostics {
if (detail != null) append(" $detail")
}
Log.i(TAG, msg)
FileLogger.i(TAG, msg)
}

/**
Expand All @@ -161,8 +174,10 @@ object MirrorDiagnostics {
}
val cause = DisconnectCauseClassifier.classify(events)
log(DiagnosticEvent.SESSION_END, "reason=$cleanupReason cause=${cause.name}")
Log.i(TAG, "[SESSION_SUMMARY] duration=${SystemClock.elapsedRealtime() - sessionStartUptimeMs}ms " +
"events=${events.size} cause=${cause.name} recent=${events.takeLast(5).map { it.name }}")
val summary = "[SESSION_SUMMARY] duration=${SystemClock.elapsedRealtime() - sessionStartUptimeMs}ms " +
"events=${events.size} cause=${cause.name} recent=${events.takeLast(5).map { it.name }}"
Log.i(TAG, summary)
FileLogger.i(TAG, summary)
return cause
}
}
Loading