From a50d108ffb80b1cf2f90d8b7a7a77c1b1d6903d8 Mon Sep 17 00:00:00 2001 From: Suprhimp Date: Mon, 27 Apr 2026 12:00:29 +0900 Subject: [PATCH] Persistent diagnostics + split resize verification + debug variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add FileLogger with rotation + DiagnosticSanitizer (URL/extras/shell-command redaction); install uncaught exception handler in CastlaApp. - Add markTerminal() (first-writer-wins) at known silent-fail sites in MirrorForegroundService; performCleanup() is sole owner of MirrorDiagnostics.endSession(). - Add SplitMath with min-pane invariants (LEFT >= 360, RIGHT >= 320) and centralized ensureSplitViable() gate at every split entry path; splitTaskBounds()/primaryTaskBounds() now fail-fast via check(). - Add serialized resize verification: per-pane Mutex + cancel-previous, dumpsys bounds re-read with 16px tolerance, up to 4 retry rounds, 800ms shell timeout. - Settings UI: new Diagnostic Logs section with Share/Copy buttons; IO on Dispatchers.IO via rememberCoroutineScope. - Build: applicationIdSuffix=".debug" so debug coexists with release. Debug versionName/versionCode now auto-track latest git tag and commit count via androidComponents.onVariants — debug builds reflect the actual current release (e.g. 1.4.0-debug) instead of the stale defaultConfig stub. Co-Authored-By: Claude Opus 4.7 --- app/build.gradle.kts | 60 +++++ .../main/java/com/castla/mirror/CastlaApp.kt | 10 + .../mirror/diagnostics/DiagnosticSanitizer.kt | 39 +++ .../castla/mirror/diagnostics/FileLogger.kt | 136 +++++++++++ .../mirror/diagnostics/MirrorDiagnostics.kt | 19 +- .../mirror/service/MirrorForegroundService.kt | 222 +++++++++++++++--- .../castla/mirror/service/TaskBoundsParser.kt | 20 ++ .../com/castla/mirror/ui/SettingsScreen.kt | 133 +++++++++++ .../java/com/castla/mirror/utils/SplitMath.kt | 32 +++ app/src/main/res/values-ko/strings.xml | 10 + app/src/main/res/values/strings.xml | 10 + app/src/main/res/xml/file_paths.xml | 1 + .../diagnostics/DiagnosticSanitizerTest.kt | 71 ++++++ .../mirror/diagnostics/FileLoggerTest.kt | 168 +++++++++++++ .../mirror/service/TaskBoundsParserTest.kt | 67 ++++++ .../com/castla/mirror/utils/SplitMathTest.kt | 77 ++++++ 16 files changed, 1037 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/com/castla/mirror/diagnostics/DiagnosticSanitizer.kt create mode 100644 app/src/main/java/com/castla/mirror/diagnostics/FileLogger.kt create mode 100644 app/src/main/java/com/castla/mirror/service/TaskBoundsParser.kt create mode 100644 app/src/main/java/com/castla/mirror/utils/SplitMath.kt create mode 100644 app/src/test/java/com/castla/mirror/diagnostics/DiagnosticSanitizerTest.kt create mode 100644 app/src/test/java/com/castla/mirror/diagnostics/FileLoggerTest.kt create mode 100644 app/src/test/java/com/castla/mirror/service/TaskBoundsParserTest.kt create mode 100644 app/src/test/java/com/castla/mirror/utils/SplitMathTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 83395ec..6034daa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,4 +1,5 @@ import java.util.Properties +import java.util.concurrent.TimeUnit plugins { id("com.android.application") @@ -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 @@ -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 @@ -66,6 +106,7 @@ android { testOptions { unitTests.isIncludeAndroidResources = true + unitTests.isReturnDefaultValues = true } compileOptions { @@ -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") diff --git a/app/src/main/java/com/castla/mirror/CastlaApp.kt b/app/src/main/java/com/castla/mirror/CastlaApp.kt index bdf51d4..40c4ff6 100644 --- a/app/src/main/java/com/castla/mirror/CastlaApp.kt +++ b/app/src/main/java/com/castla/mirror/CastlaApp.kt @@ -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) + } } } diff --git a/app/src/main/java/com/castla/mirror/diagnostics/DiagnosticSanitizer.kt b/app/src/main/java/com/castla/mirror/diagnostics/DiagnosticSanitizer.kt new file mode 100644 index 0000000..d9c81ff --- /dev/null +++ b/app/src/main/java/com/castla/mirror/diagnostics/DiagnosticSanitizer.kt @@ -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 "" + return try { + val u = java.net.URI(url) + val scheme = u.scheme ?: return "" + val host = u.host ?: return "" + "$scheme://$host" + } catch (_: Throwable) { + "" + } + } + + /** 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 + // BEFORE we attempt URL/extra redaction inside the now-removed body. + s = EXECUTING_PREFIX.replace(s) { it.groupValues[1] + "" } + s = EXTRA_REGEX.replace(s) { "--es ${it.groupValues[1]} " } + s = URL_REGEX.replace(s) { redactUrl(it.value) } + if (s.length > MAX_LEN) s = s.substring(0, MAX_LEN) + "…" + return s + } +} diff --git a/app/src/main/java/com/castla/mirror/diagnostics/FileLogger.kt b/app/src/main/java/com/castla/mirror/diagnostics/FileLogger.kt new file mode 100644 index 0000000..f3e5b2d --- /dev/null +++ b/app/src/main/java/com/castla/mirror/diagnostics/FileLogger.kt @@ -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: `/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 { + 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) + } + } + } +} diff --git a/app/src/main/java/com/castla/mirror/diagnostics/MirrorDiagnostics.kt b/app/src/main/java/com/castla/mirror/diagnostics/MirrorDiagnostics.kt index 735788c..d8f81ae 100644 --- a/app/src/main/java/com/castla/mirror/diagnostics/MirrorDiagnostics.kt +++ b/app/src/main/java/com/castla/mirror/diagnostics/MirrorDiagnostics.kt @@ -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]. @@ -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. */ @@ -142,6 +154,7 @@ object MirrorDiagnostics { if (detail != null) append(" $detail") } Log.i(TAG, msg) + FileLogger.i(TAG, msg) } /** @@ -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 } } diff --git a/app/src/main/java/com/castla/mirror/service/MirrorForegroundService.kt b/app/src/main/java/com/castla/mirror/service/MirrorForegroundService.kt index 1acc4a3..e533c06 100644 --- a/app/src/main/java/com/castla/mirror/service/MirrorForegroundService.kt +++ b/app/src/main/java/com/castla/mirror/service/MirrorForegroundService.kt @@ -49,7 +49,10 @@ import com.castla.mirror.policy.ScreenOffAction import com.castla.mirror.policy.ScreenOffPolicy import com.castla.mirror.policy.ScreenOffState import com.castla.mirror.diagnostics.DiagnosticEvent +import com.castla.mirror.diagnostics.FileLogger import com.castla.mirror.diagnostics.MirrorDiagnostics +import com.castla.mirror.diagnostics.TerminalReason +import com.castla.mirror.utils.SplitMath import com.castla.mirror.utils.StreamMath import com.castla.mirror.ui.SplitWebPresentation import kotlinx.coroutines.CoroutineScope @@ -120,6 +123,13 @@ class MirrorForegroundService : Service() { // Grace period constants are now in DisconnectPolicy /** Interval for poking the VD awake while the physical screen is off. */ private const val VD_KEEP_ALIVE_INTERVAL_MS = 30_000L + + // Split resize verification tunables + private const val MAX_LOCATE_ATTEMPTS = 10 + private const val MAX_VERIFY_ROUNDS = 4 + private const val SHELL_TIMEOUT_MS = 800L + private const val VERIFY_BACKOFF_MS = 400L + private const val BOUNDS_TOLERANCE_PX = 16 } /** Binder for local (same-process) binding */ @@ -149,6 +159,12 @@ class MirrorForegroundService : Service() { private var browserConnectionListener: ((Boolean) -> Unit)? = null @Volatile private var stopRequested = false @Volatile private var cleanupCompleted = false + /** + * First-writer-wins terminal failure reason. Set by [markTerminal] at the + * moment a known fatal failure is detected; consumed by [performCleanup] + * when emitting the SESSION_END event so the recorded reason is consistent. + */ + private val terminalReason = java.util.concurrent.atomic.AtomicReference(null) private var serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private var resizeJob: Job? = null private var pendingBrowserDisconnectJob: Job? = null @@ -490,7 +506,7 @@ class MirrorForegroundService : Service() { val displayId = virtualDisplayManager?.getDisplayId() ?: -1 val url = request.url ?: return@collect dismissSplitPresentation(clearState = true) - if (request.splitMode && canLaunchPrimarySplitTask()) { + if (request.splitMode && ensureSplitViable("bus-external-browser")) { launchSplitExternalBrowserTarget(displayId, url, request.sourceAppPackage, request.allowEmbeddedFallback) } else { launchExternalBrowserTarget(displayId, url, request.sourceAppPackage, request.allowEmbeddedFallback) @@ -501,7 +517,7 @@ class MirrorForegroundService : Service() { dismissSplitPresentation(clearState = true) val activityClassName = component.substringAfter('/', "com.castla.mirror.ui.WebBrowserActivity") val url = request.url ?: request.intentExtra ?: return@collect - if (request.splitMode && canLaunchPrimarySplitTask()) { + if (request.splitMode && ensureSplitViable("bus-internal-webview")) { launchSplitWebTarget(activityClassName, displayId, url) } else { launchFullscreenWebTarget(activityClassName, displayId, url) @@ -513,14 +529,14 @@ class MirrorForegroundService : Service() { val displayId = virtualDisplayManager?.getDisplayId() ?: -1 dismissSplitPresentation(clearState = true) val activityClassName = component.substringAfter('/', "com.castla.mirror.ui.WebBrowserActivity") - if (request.splitMode && canLaunchPrimarySplitTask()) { + if (request.splitMode && ensureSplitViable("bus-standard-web")) { launchSplitWebTarget(activityClassName, displayId, request.intentExtra) } else { launchFullscreenWebTarget(activityClassName, displayId, request.intentExtra) } } else { dismissSplitPresentation(clearState = true) - if (request.splitMode && canLaunchPrimarySplitTask()) { + if (request.splitMode && ensureSplitViable("bus-standard-app")) { launchSplitStandardTarget(component) } else { launchFullscreenStandardTarget(component) @@ -764,8 +780,10 @@ class MirrorForegroundService : Service() { return } cleanupCompleted = true // set immediately under lock to prevent reentrant race - Log.i(TAG, "Performing cleanup: $reason") - MirrorDiagnostics.endSession(reason) + val effectiveReason = terminalReason.get()?.let { "terminal:${it.name}" } ?: reason + Log.i(TAG, "Performing cleanup: $effectiveReason") + FileLogger.i(TAG, "Performing cleanup: $effectiveReason") + MirrorDiagnostics.endSession(effectiveReason) isCleanupInProgress = true // Always restore physical display panel on cleanup — safety net @@ -942,6 +960,7 @@ class MirrorForegroundService : Service() { audioEnabled: Boolean ) { try { + terminalReason.set(null) MirrorDiagnostics.onSessionStart() // Only initialize projection and server — defer encoder/capture until browser connects @@ -1535,9 +1554,44 @@ class MirrorForegroundService : Service() { return currentVdApp.isNotBlank() && currentVdApp != "HOME" && currentVdApp != "com.android.settings" } + /** + * Single, centralized split-mode viability gate. Every split entry path must pass + * this before mutating split state or computing pane bounds. Logs once on rejection. + */ + private fun ensureSplitViable(reason: String): Boolean { + if (!canLaunchPrimarySplitTask()) { + FileLogger.w(TAG, "Split rejected ($reason): no primary app (currentVdApp=$currentVdApp)") + return false + } + if (!SplitMath.isSplitViable(currentWidth, currentHeight)) { + FileLogger.w(TAG, "Split rejected ($reason): display too small ${currentWidth}x${currentHeight}") + return false + } + return true + } + + /** + * Records a fatal failure cause (first-writer-wins) and triggers cleanup. + * The actual SESSION_END event is emitted by [performCleanup] using the + * stored reason so there is exactly one terminal log per session. + */ + private fun markTerminal(reason: TerminalReason) { + if (!terminalReason.compareAndSet(null, reason)) return + FileLogger.e(TAG, "Terminal failure: ${reason.name}") + Log.e(TAG, "Terminal failure: ${reason.name}") + try { + requestStopAsync("terminal_${reason.name.lowercase()}") + } catch (e: Exception) { + Log.w(TAG, "requestStopAsync failed after markTerminal", e) + } + } + private fun primaryTaskBounds(): android.graphics.Rect { - val splitBounds = splitTaskBounds() - return android.graphics.Rect(0, 0, splitBounds.left, currentHeight) + check(SplitMath.isSplitViable(currentWidth, currentHeight)) { + "primaryTaskBounds called on non-viable display ${currentWidth}x${currentHeight}; gate with ensureSplitViable() first" + } + val leftWidth = SplitMath.computeLeftPaneWidth(currentWidth, currentHeight) + return android.graphics.Rect(0, 0, leftWidth, currentHeight) } private fun escapeShellArg(value: String): String = "'" + value.replace("'", "'\''") + "'" @@ -1610,6 +1664,7 @@ class MirrorForegroundService : Service() { true } catch (e: Exception) { Log.e(TAG, "Failed to launch $packageOrComponent on display $displayId", e) + FileLogger.e(TAG, "launchTargetOnDisplay failed pkg=$packageOrComponent display=$displayId", e) false } } @@ -1634,8 +1689,8 @@ class MirrorForegroundService : Service() { } private fun launchSplitWebTarget(activityClassName: String, displayId: Int, url: String) { - if (!canLaunchPrimarySplitTask()) { - Log.w(TAG, "Split web launch requested without a primary app; falling back to fullscreen") + if (!ensureSplitViable("split-web")) { + Log.w(TAG, "Split web launch rejected; falling back to fullscreen") launchFullscreenWebTarget(activityClassName, displayId, url) return } @@ -1725,8 +1780,8 @@ class MirrorForegroundService : Service() { * Falls back to internal WebBrowserActivity if no browser is found or launch fails. */ private fun launchSplitExternalBrowserTarget(displayId: Int, url: String, sourceAppPackage: String? = null, allowFallback: Boolean = true) { - if (!canLaunchPrimarySplitTask()) { - Log.w(TAG, "Split external browser launch requested without a primary app; falling back to fullscreen") + if (!ensureSplitViable("split-external-browser")) { + Log.w(TAG, "Split external browser launch rejected; falling back to fullscreen") launchExternalBrowserTarget(displayId, url, sourceAppPackage, allowFallback) return } @@ -1851,8 +1906,8 @@ class MirrorForegroundService : Service() { private fun launchSplitStandardTarget(launchTarget: String) { val displayId = virtualDisplayManager?.getDisplayId() ?: -1 Log.i(TAG, "launchSplitStandardTarget: target=$launchTarget displayId=$displayId currentVdApp=$currentVdApp canSplit=${canLaunchPrimarySplitTask()} currentSize=${currentWidth}x${currentHeight}") - if (displayId < 0 || !canLaunchPrimarySplitTask()) { - Log.w(TAG, "Split app launch requested without a primary app; falling back to fullscreen") + if (displayId < 0 || !ensureSplitViable("split-standard")) { + Log.w(TAG, "Split app launch rejected; falling back to fullscreen") launchFullscreenStandardTarget(launchTarget) return } @@ -1875,7 +1930,7 @@ class MirrorForegroundService : Service() { } private fun relaunchPrimaryTaskForSplit(displayId: Int) { - if (displayId < 0 || !canLaunchPrimarySplitTask()) return + if (displayId < 0 || !ensureSplitViable("relaunch-primary")) return val primaryTarget = normalizeLaunchTarget(currentVdApp) val primaryPkg = primaryTarget.substringBefore('/') val bounds = primaryTaskBounds() @@ -1985,18 +2040,27 @@ class MirrorForegroundService : Service() { } private fun splitTaskBounds(): android.graphics.Rect { - val leftWidth = (currentHeight * 9f / 16f).toInt() - .coerceAtLeast((currentWidth * 0.25f).toInt()) - .coerceAtMost((currentWidth - 320).coerceAtLeast(0)) + check(SplitMath.isSplitViable(currentWidth, currentHeight)) { + "splitTaskBounds called on non-viable display ${currentWidth}x${currentHeight}; gate with ensureSplitViable() first" + } + val leftWidth = SplitMath.computeLeftPaneWidth(currentWidth, currentHeight) return android.graphics.Rect(leftWidth, 0, currentWidth, currentHeight) } + // Per-pane resize jobs so a new resize cancels any superseded one in flight. + private var primaryResizeJob: Job? = null + private var splitResizeJob: Job? = null + private val resizeMutex = kotlinx.coroutines.sync.Mutex() + @Volatile private var boundsParseUnsupportedLogged = false + private fun schedulePrimaryTaskResize(displayId: Int, launchTarget: String) { - scheduleTaskResize(displayId, primaryTaskBounds(), "primary", launchTarget) + try { primaryResizeJob?.cancel() } catch (_: Exception) {} + primaryResizeJob = scheduleTaskResize(displayId, primaryTaskBounds(), "primary", launchTarget) } private fun scheduleSplitTaskResize(displayId: Int, launchTarget: String) { - scheduleTaskResize(displayId, splitTaskBounds(), "split", launchTarget) + try { splitResizeJob?.cancel() } catch (_: Exception) {} + splitResizeJob = scheduleTaskResize(displayId, splitTaskBounds(), "split", launchTarget) } private fun scheduleTaskResize( @@ -2004,22 +2068,103 @@ class MirrorForegroundService : Service() { bounds: android.graphics.Rect, label: String, launchTarget: String + ): Job? { + if (displayId < 0 || currentWidth <= 0 || currentHeight <= 0) return null + return serviceScope.launch(Dispatchers.IO) { + resizeMutex.withLock { + runResizeWithVerification(displayId, bounds, label, launchTarget) + } + } + } + + /** + * Runs the resize and verifies via dumpsys that WMS actually honored the requested bounds. + * If the actual task bounds differ from requested by more than [BOUNDS_TOLERANCE_PX] on + * any side, the resize is reissued. This addresses the race where the task is not yet + * fully in freeform mode when the first resize fires and Android silently drops it. + * + * Total wall-time is bounded by [MAX_LOCATE_ATTEMPTS] * locate-delay + [MAX_VERIFY_ROUNDS] + * * (cmd timeout + verify wait). + */ + private suspend fun runResizeWithVerification( + displayId: Int, + bounds: android.graphics.Rect, + label: String, + launchTarget: String, ) { - if (displayId < 0 || currentWidth <= 0 || currentHeight <= 0) return - serviceScope.launch(Dispatchers.IO) { - repeat(10) { attempt -> - kotlinx.coroutines.delay(if (attempt == 0) 250L else 400L) - val service = virtualDisplayManager?.getPrivilegedService() ?: return@launch - // Use current display ID in case VD was recreated - val currentDisplayId = virtualDisplayManager?.getDisplayId() ?: displayId - val taskId = findTaskId(service, currentDisplayId, launchTarget) ?: return@repeat - service.execCommand("cmd activity task resizeable $taskId 2") - service.execCommand("cmd activity task resize $taskId ${bounds.left} ${bounds.top} ${bounds.right} ${bounds.bottom}") - Log.i(TAG, "Resized $label task $taskId on display $currentDisplayId to ${bounds.flattenToString()}") - return@launch + // Phase 1: locate task (existing retry strategy, lightly bounded by withTimeoutOrNull) + var taskId: Int? = null + var currentDisplayId = displayId + var service: IPrivilegedService? = null + repeat(MAX_LOCATE_ATTEMPTS) { attempt -> + kotlinx.coroutines.delay(if (attempt == 0) 250L else 400L) + service = virtualDisplayManager?.getPrivilegedService() ?: return + currentDisplayId = virtualDisplayManager?.getDisplayId() ?: displayId + taskId = findTaskId(service!!, currentDisplayId, launchTarget) + if (taskId != null) return@repeat + } + val tid = taskId + val svc = service + if (tid == null || svc == null) { + val msg = "Failed to locate $label task on display $currentDisplayId target=$launchTarget" + Log.w(TAG, msg) + FileLogger.w(TAG, msg) + return + } + + // Phase 2: issue resize + verify, retry on mismatch + for (round in 0 until MAX_VERIFY_ROUNDS) { + val cmdSucceeded = kotlinx.coroutines.withTimeoutOrNull(SHELL_TIMEOUT_MS) { + svc.execCommand("cmd activity task resizeable $tid 2") + svc.execCommand("cmd activity task resize $tid ${bounds.left} ${bounds.top} ${bounds.right} ${bounds.bottom}") + true + } ?: false + if (!cmdSucceeded) { + Log.w(TAG, "Resize cmd timed out for $label task=$tid round=$round") + FileLogger.w(TAG, "Resize cmd timed out for $label task=$tid round=$round") + kotlinx.coroutines.delay(VERIFY_BACKOFF_MS) + continue + } + // Verify + kotlinx.coroutines.delay(VERIFY_BACKOFF_MS) + val freshDumpsys = kotlinx.coroutines.withTimeoutOrNull(SHELL_TIMEOUT_MS) { + svc.execCommand("dumpsys activity activities") + } + val taskBlock = freshDumpsys?.let { findTaskBlock(it, currentDisplayId, tid) } + if (taskBlock == null) { + Log.i(TAG, "Resized $label task $tid (no verification — task block missing)") + return + } + val actual = TaskBoundsParser.parseTaskBoundsFromBlock(taskBlock) + if (actual == null) { + if (!boundsParseUnsupportedLogged) { + boundsParseUnsupportedLogged = true + FileLogger.w(TAG, "bounds-parse-unsupported on this Android version; skipping verification") + } + Log.i(TAG, "Resized $label task $tid (verification skipped: parser unsupported)") + return + } + if (boundsMatch(bounds, actual)) { + Log.i(TAG, "Resized $label task $tid to ${bounds.flattenToString()} (verified round=$round)") + FileLogger.i(TAG, "Resized $label task=$tid round=$round") + return } - Log.w(TAG, "Failed to locate $label task on display ${virtualDisplayManager?.getDisplayId() ?: displayId} for resizing") + Log.w(TAG, "Resize verification mismatch $label task=$tid round=$round requested=${bounds.flattenToString()} actual=[${actual.left},${actual.top}][${actual.right},${actual.bottom}]") } + FileLogger.w(TAG, "Resize verification gave up for $label task=$tid after $MAX_VERIFY_ROUNDS rounds") + } + + private fun findTaskBlock(dumpsys: String, displayId: Int, taskId: Int): String? { + val tasks = parseDisplayTasks(dumpsys, displayId) + val match = tasks.firstOrNull { it.taskId == taskId } ?: return null + return match.header + "\n" + match.body + } + + private fun boundsMatch(requested: android.graphics.Rect, actual: TaskBoundsParser.Bounds): Boolean { + return Math.abs(requested.left - actual.left) <= BOUNDS_TOLERANCE_PX && + Math.abs(requested.top - actual.top) <= BOUNDS_TOLERANCE_PX && + Math.abs(requested.right - actual.right) <= BOUNDS_TOLERANCE_PX && + Math.abs(requested.bottom - actual.bottom) <= BOUNDS_TOLERANCE_PX } private fun findTaskId(service: IPrivilegedService, displayId: Int, launchTarget: String): Int? { @@ -2226,7 +2371,7 @@ class MirrorForegroundService : Service() { Log.i(TAG, "Web Launcher: Launching OTT app via external browser: $pkgName -> $webUrl (splitMode=$splitMode)") dismissSplitPresentation(clearState = true) - if (splitMode && canLaunchPrimarySplitTask()) { + if (splitMode && ensureSplitViable("web-launcher-ott")) { launchSplitExternalBrowserTarget(displayId, webUrl, pkgName) } else { launchExternalBrowserTarget(displayId, webUrl, pkgName) @@ -2238,7 +2383,7 @@ class MirrorForegroundService : Service() { val launchTarget = componentName ?: pkgName Log.i(TAG, "Web Launcher: Launching standard app: $pkgName (target=$launchTarget, splitMode=$splitMode, singleVdSplit=$singleVdSplit)") - if (splitMode && canLaunchPrimarySplitTask()) { + if (splitMode && ensureSplitViable("web-launcher-standard")) { launchSplitStandardTarget(launchTarget) } else { launchFullscreenStandardTarget(launchTarget) @@ -2489,7 +2634,8 @@ class MirrorForegroundService : Service() { } } catch (t: Throwable) { Log.e(TAG, "Browser connection activation failed", t) - requestStopAsync("browser_connect_failure") + FileLogger.e(TAG, "Browser connection activation failed", t) + markTerminal(TerminalReason.BROWSER_ACTIVATION_FAILED) } } @@ -2980,6 +3126,7 @@ class MirrorForegroundService : Service() { restoreCurrentVdContent() } else { Log.e(TAG, "VD creation failed after retry — NOT falling back to MediaProjection to prevent raw phone screen leak") + markTerminal(TerminalReason.VD_RECREATE_FAILED) } } } @@ -2992,6 +3139,7 @@ class MirrorForegroundService : Service() { trySetupVirtualDisplay(width, height, surface) { success -> if (!success) { Log.e(TAG, "Shizuku rebind failed — NOT falling back to MediaProjection to prevent raw phone screen leak") + markTerminal(TerminalReason.SHIZUKU_REBIND_FAILED) } else { Log.i(TAG, "Shizuku rebound successfully during rebuild") } @@ -3010,6 +3158,8 @@ class MirrorForegroundService : Service() { } catch (e: Exception) { Log.e(TAG, "Failed to rebuild pipeline", e) + FileLogger.e(TAG, "rebuildPipeline exception", e) + markTerminal(TerminalReason.PIPELINE_REBUILD_EXCEPTION) } finally { screenCapture?.isRebuilding = false } diff --git a/app/src/main/java/com/castla/mirror/service/TaskBoundsParser.kt b/app/src/main/java/com/castla/mirror/service/TaskBoundsParser.kt new file mode 100644 index 0000000..0c1e042 --- /dev/null +++ b/app/src/main/java/com/castla/mirror/service/TaskBoundsParser.kt @@ -0,0 +1,20 @@ +package com.castla.mirror.service + +/** Pure helper for parsing `bounds=[L,T][R,B]` from a dumpsys task block. */ +internal object TaskBoundsParser { + + private val BOUNDS_REGEX = Regex("bounds=\\[(-?\\d+),(-?\\d+)\\]\\[(-?\\d+),(-?\\d+)\\]") + + data class Bounds(val left: Int, val top: Int, val right: Int, val bottom: Int) + + fun parseTaskBoundsFromBlock(body: String): Bounds? { + if (body.isEmpty()) return null + val match = BOUNDS_REGEX.find(body) ?: return null + return Bounds( + left = match.groupValues[1].toInt(), + top = match.groupValues[2].toInt(), + right = match.groupValues[3].toInt(), + bottom = match.groupValues[4].toInt(), + ) + } +} diff --git a/app/src/main/java/com/castla/mirror/ui/SettingsScreen.kt b/app/src/main/java/com/castla/mirror/ui/SettingsScreen.kt index 14339f4..56ea0cb 100644 --- a/app/src/main/java/com/castla/mirror/ui/SettingsScreen.kt +++ b/app/src/main/java/com/castla/mirror/ui/SettingsScreen.kt @@ -23,11 +23,21 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build import android.os.PowerManager +import android.widget.Toast +import androidx.core.content.FileProvider +import com.castla.mirror.BuildConfig import com.castla.mirror.R +import com.castla.mirror.diagnostics.FileLogger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -419,6 +429,69 @@ fun SettingsScreen( } } + Spacer(modifier = Modifier.height(32.dp)) + + // Diagnostic logs + run { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var working by remember { mutableStateOf(false) } + + SettingSection(title = stringResource(R.string.settings_logs_title)) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.settings_logs_description), + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.7f), + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + Button( + onClick = { + if (working) return@Button + working = true + scope.launch { + try { + shareLogs(context) + } finally { + working = false + } + } + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp), + enabled = !working + ) { + Text(stringResource(R.string.settings_share_logs)) + } + Spacer(modifier = Modifier.width(12.dp)) + OutlinedButton( + onClick = { + if (working) return@OutlinedButton + working = true + scope.launch { + try { + copyRecentLogs(context) + } finally { + working = false + } + } + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp), + enabled = !working + ) { + Text(stringResource(R.string.settings_copy_logs)) + } + } + } + } + } + Spacer(modifier = Modifier.height(40.dp)) // Current config summary @@ -465,6 +538,66 @@ fun SettingsScreen( } } +private suspend fun shareLogs(context: Context) { + val files = withContext(Dispatchers.IO) { FileLogger.getLogFiles() } + if (files.isEmpty()) { + withContext(Dispatchers.Main) { + Toast.makeText(context, R.string.settings_logs_empty, Toast.LENGTH_SHORT).show() + } + return + } + try { + val authority = "${BuildConfig.APPLICATION_ID}.fileprovider" + val uris = ArrayList(files.map { FileProvider.getUriForFile(context, authority, it) }) + val intent = if (uris.size == 1) { + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_STREAM, uris[0]) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } else { + Intent(Intent.ACTION_SEND_MULTIPLE).apply { + type = "text/plain" + putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + val title = context.getString(R.string.settings_logs_chooser_title) + val chooser = Intent.createChooser(intent, title).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + withContext(Dispatchers.Main) { + context.startActivity(chooser) + } + } catch (t: Throwable) { + withContext(Dispatchers.Main) { + Toast.makeText(context, R.string.settings_logs_share_failed, Toast.LENGTH_SHORT).show() + } + } +} + +private suspend fun copyRecentLogs(context: Context) { + val tail = withContext(Dispatchers.IO) { + val files = FileLogger.getLogFiles() + if (files.isEmpty()) return@withContext null + val current = files.first() + val maxBytes = 8 * 1024 + val all = current.readBytes() + val start = (all.size - maxBytes).coerceAtLeast(0) + // Decode with replacement so partial UTF-8 codepoints don't crash + String(all, start, all.size - start, Charsets.UTF_8) + } + withContext(Dispatchers.Main) { + if (tail.isNullOrEmpty()) { + Toast.makeText(context, R.string.settings_logs_empty, Toast.LENGTH_SHORT).show() + } else { + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText("castla-logs", tail)) + Toast.makeText(context, R.string.settings_logs_copied, Toast.LENGTH_SHORT).show() + } + } +} + @Composable fun SettingSection(title: String, content: @Composable () -> Unit) { Column(modifier = Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/com/castla/mirror/utils/SplitMath.kt b/app/src/main/java/com/castla/mirror/utils/SplitMath.kt new file mode 100644 index 0000000..ec65d3f --- /dev/null +++ b/app/src/main/java/com/castla/mirror/utils/SplitMath.kt @@ -0,0 +1,32 @@ +package com.castla.mirror.utils + +/** + * Pure utility for split-screen layout math. No Android dependencies so this is + * unit-testable on the JVM. + * + * Defines minimum usable widths for the LEFT (phone-aspect) and RIGHT (web) + * panes; if a display is narrower than the sum, split mode is not viable and + * callers must fall back to fullscreen. + */ +object SplitMath { + const val LEFT_PANE_MIN_PX = 360 + const val RIGHT_PANE_MIN_PX = 320 + const val MIN_TOTAL_PX = LEFT_PANE_MIN_PX + RIGHT_PANE_MIN_PX + + fun isSplitViable(width: Int, height: Int): Boolean = + width >= MIN_TOTAL_PX && height > 0 + + /** + * Returns the LEFT pane width for a viable display. + * Targets a 9:16 phone aspect (height*9/16) clamped to [MIN, width-RIGHT_MIN]. + * @throws IllegalArgumentException when [isSplitViable] would return false. + */ + fun computeLeftPaneWidth(width: Int, height: Int): Int { + require(isSplitViable(width, height)) { + "Split not viable for ${width}x${height}; min total = $MIN_TOTAL_PX" + } + val target = (height * 9f / 16f).toInt() + val maxLeft = width - RIGHT_PANE_MIN_PX + return target.coerceIn(LEFT_PANE_MIN_PX, maxLeft) + } +} diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 8aef342..44a85af 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -102,4 +102,14 @@ Castla는 무료 오픈소스 앱입니다.\n유용하게 사용하셨다면 커피 한 잔 사주세요! Ko-fi로 후원하기 카카오페이로 후원하기 + + + 진단 로그 + 미러링이 실패하면 로그를 공유하거나 복사해 원인 분석에 도움을 받을 수 있어요. URL은 자동 가려집니다. + 로그 공유 + 최근 로그 복사 + 아직 저장된 로그가 없습니다. + 최근 로그를 클립보드에 복사했어요. + 로그를 공유할 수 없습니다. + 진단 로그 공유 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6be9b12..cfd4285 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -98,4 +98,14 @@ Castla is free and open-source.\nIf you find it useful, consider buying me a coffee! Buy me a Coffee on Ko-fi 카카오페이로 후원하기 + + + Diagnostic Logs + If mirroring fails, share or copy these logs to help diagnose the issue. URLs are redacted. + Share Logs + Copy Recent + No logs available yet. + Recent logs copied to clipboard. + Could not share logs. + Share diagnostic logs diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index 340237e..e15de76 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -2,4 +2,5 @@ + diff --git a/app/src/test/java/com/castla/mirror/diagnostics/DiagnosticSanitizerTest.kt b/app/src/test/java/com/castla/mirror/diagnostics/DiagnosticSanitizerTest.kt new file mode 100644 index 0000000..ed93f50 --- /dev/null +++ b/app/src/test/java/com/castla/mirror/diagnostics/DiagnosticSanitizerTest.kt @@ -0,0 +1,71 @@ +package com.castla.mirror.diagnostics + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DiagnosticSanitizerTest { + + @Test + fun `redactUrl strips path query fragment, preserves scheme and host`() { + assertEquals( + "https://www.youtube.com", + DiagnosticSanitizer.redactUrl("https://www.youtube.com/watch?v=abc123#t=10") + ) + assertEquals( + "http://192.168.1.5", + DiagnosticSanitizer.redactUrl("http://192.168.1.5:9090/api/apps?secret=xyz") + ) + } + + @Test + fun `redactUrl returns sentinel for empty or malformed input`() { + assertEquals("", DiagnosticSanitizer.redactUrl("")) + assertEquals("", DiagnosticSanitizer.redactUrl("not a url")) + } + + @Test + fun `safeMessage redacts URL inside message`() { + val msg = "Failed to load https://www.youtube.com/watch?v=secret in browser" + val sanitized = DiagnosticSanitizer.safeMessage(msg) + assertTrue("got: $sanitized", sanitized.contains("https://www.youtube.com")) + assertTrue(!sanitized.contains("secret")) + } + + @Test + fun `safeMessage redacts shell command after Executing prefix`() { + val msg = "Executing: am start -W --display 2 -n com.example/.Activity --es url https://secret.example.com/path" + val sanitized = DiagnosticSanitizer.safeMessage(msg) + assertTrue("got: $sanitized", sanitized.contains("Executing:")) + assertTrue("got: $sanitized", sanitized.contains("")) + assertTrue("got: $sanitized", !sanitized.contains("secret.example.com")) + } + + @Test + fun `safeMessage redacts intent extras keys preserve key, replaces value`() { + val msg = "Launching with --es url https://example.com/path/to/page and --es token abcd1234" + val sanitized = DiagnosticSanitizer.safeMessage(msg) + assertTrue("got: $sanitized", sanitized.contains("--es url ")) + assertTrue("got: $sanitized", sanitized.contains("--es token ")) + assertTrue("got: $sanitized", !sanitized.contains("abcd1234")) + } + + @Test + fun `safeMessage caps very long messages at 500 chars with ellipsis`() { + val longMsg = "A".repeat(2000) + val sanitized = DiagnosticSanitizer.safeMessage(longMsg) + assertTrue("got length=${sanitized.length}", sanitized.length <= 501) + assertTrue(sanitized.endsWith("…")) + } + + @Test + fun `safeMessage passes through safe input unchanged`() { + val msg = "VD recreation failed after retry; taskId=42 displayId=2 size=1280x720" + assertEquals(msg, DiagnosticSanitizer.safeMessage(msg)) + } + + @Test + fun `safeMessage handles empty string`() { + assertEquals("", DiagnosticSanitizer.safeMessage("")) + } +} diff --git a/app/src/test/java/com/castla/mirror/diagnostics/FileLoggerTest.kt b/app/src/test/java/com/castla/mirror/diagnostics/FileLoggerTest.kt new file mode 100644 index 0000000..ee9c888 --- /dev/null +++ b/app/src/test/java/com/castla/mirror/diagnostics/FileLoggerTest.kt @@ -0,0 +1,168 @@ +package com.castla.mirror.diagnostics + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class FileLoggerTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + @Before + fun setUp() { + FileLogger.resetForTest() + } + + @After + fun tearDown() { + FileLogger.resetForTest() + } + + @Test + fun `init creates logs directory under provided dir`() { + FileLogger.initForTest(tempFolder.root, maxFileBytes = 1024) + FileLogger.i("Tag", "first message") + val logsDir = File(tempFolder.root, "logs") + assertTrue(logsDir.isDirectory) + val current = File(logsDir, "mirror.log") + assertTrue(current.exists()) + } + + @Test + fun `writes preserve order and contain tag level message`() { + FileLogger.initForTest(tempFolder.root, maxFileBytes = 4096) + FileLogger.i("Tag1", "info one") + FileLogger.w("Tag2", "warn two") + FileLogger.e("Tag3", "error three") + + val log = File(tempFolder.root, "logs/mirror.log").readText() + val lines = log.lines().filter { it.isNotBlank() } + assertEquals(3, lines.size) + assertTrue("got: ${lines[0]}", lines[0].contains(" I Tag1: info one")) + assertTrue("got: ${lines[1]}", lines[1].contains(" W Tag2: warn two")) + assertTrue("got: ${lines[2]}", lines[2].contains(" E Tag3: error three")) + } + + @Test + fun `error with throwable writes stack trace lines`() { + FileLogger.initForTest(tempFolder.root, maxFileBytes = 8192) + val ex = RuntimeException("boom") + FileLogger.e("Tag", "something failed", ex) + + val log = File(tempFolder.root, "logs/mirror.log").readText() + assertTrue("got: $log", log.contains(" E Tag: something failed")) + assertTrue("got: $log", log.contains("RuntimeException")) + assertTrue("got: $log", log.contains("boom")) + } + + @Test + fun `rotation moves current to log_1 when threshold exceeded`() { + // Use small cap so rotation triggers quickly + FileLogger.initForTest(tempFolder.root, maxFileBytes = 256) + // Each message ~80 bytes; write enough to exceed cap and trigger rotation + repeat(10) { FileLogger.i("Tag", "msg-$it ${"x".repeat(40)}") } + + val current = File(tempFolder.root, "logs/mirror.log") + val rotated = File(tempFolder.root, "logs/mirror.log.1") + assertTrue("current must exist", current.exists()) + assertTrue("rotated must exist after threshold reached", rotated.exists()) + } + + @Test + fun `rotation overwrites existing rotated file (cap stays at 2 files)`() { + FileLogger.initForTest(tempFolder.root, maxFileBytes = 200) + // Trigger first rotation + repeat(8) { FileLogger.i("Tag", "first-batch-$it ${"x".repeat(40)}") } + // Trigger second rotation + repeat(8) { FileLogger.i("Tag", "second-batch-$it ${"y".repeat(40)}") } + + val logsDir = File(tempFolder.root, "logs") + val files = logsDir.listFiles()?.map { it.name }?.sorted() ?: emptyList() + // Should be exactly current + .1; never .2 + assertEquals(setOf("mirror.log", "mirror.log.1"), files.toSet()) + // Second rotation: .log.1 should now contain "first-batch" content (rotated to .1 on second rotation) + // NOTE: The rotation moves CURRENT → .1; so after second rotation, .1 contains the most-recent prior batch + val rotated = File(logsDir, "mirror.log.1").readText() + assertTrue("rotated should reflect a recent batch, got: $rotated", + rotated.contains("first-batch") || rotated.contains("second-batch")) + } + + @Test + fun `concurrent writes from multiple threads do not corrupt output`() { + FileLogger.initForTest(tempFolder.root, maxFileBytes = 1_000_000) + val pool = Executors.newFixedThreadPool(4) + val latch = CountDownLatch(4) + val perThread = 250 + repeat(4) { t -> + pool.submit { + try { + repeat(perThread) { i -> + FileLogger.i("T$t", "msg-$i") + } + } finally { + latch.countDown() + } + } + } + assertTrue(latch.await(15, TimeUnit.SECONDS)) + pool.shutdown() + pool.awaitTermination(2, TimeUnit.SECONDS) + + val combined = FileLogger.getLogFiles().joinToString("\n") { it.readText() } + val totalLines = combined.lines().count { it.isNotBlank() } + assertEquals(4 * perThread, totalLines) + } + + @Test + fun `init is idempotent`() { + FileLogger.initForTest(tempFolder.root, maxFileBytes = 4096) + FileLogger.i("Tag", "before-second-init") + FileLogger.initForTest(tempFolder.root, maxFileBytes = 4096) // second call should be no-op + FileLogger.i("Tag", "after-second-init") + val text = File(tempFolder.root, "logs/mirror.log").readText() + assertTrue(text.contains("before-second-init")) + assertTrue(text.contains("after-second-init")) + } + + @Test + fun `init failure leaves logger in degraded no-op mode`() { + // Use a file path (not a dir) as parent → init should fail safely + val notADir = tempFolder.newFile("collision") + FileLogger.initForTest(notADir, maxFileBytes = 4096) + // Calls should not throw + FileLogger.i("Tag", "should be no-op") + FileLogger.w("Tag", "should be no-op") + FileLogger.e("Tag", "should be no-op") + // No logs dir should be created + assertFalse(File(notADir, "logs").exists()) + assertEquals(0, FileLogger.getLogFiles().size) + } + + @Test + fun `writes are sanitized via DiagnosticSanitizer`() { + FileLogger.initForTest(tempFolder.root, maxFileBytes = 8192) + FileLogger.i("Tag", "Loaded https://www.youtube.com/watch?v=secretvalue") + val text = File(tempFolder.root, "logs/mirror.log").readText() + assertTrue("got: $text", text.contains("https://www.youtube.com")) + assertFalse("got: $text", text.contains("secretvalue")) + } + + @Test + fun `clear removes log files`() { + FileLogger.initForTest(tempFolder.root, maxFileBytes = 200) + repeat(8) { FileLogger.i("Tag", "msg-$it ${"x".repeat(40)}") } + assertTrue(FileLogger.getLogFiles().isNotEmpty()) + FileLogger.clear() + assertEquals(0, FileLogger.getLogFiles().size) + } +} diff --git a/app/src/test/java/com/castla/mirror/service/TaskBoundsParserTest.kt b/app/src/test/java/com/castla/mirror/service/TaskBoundsParserTest.kt new file mode 100644 index 0000000..0e5da7c --- /dev/null +++ b/app/src/test/java/com/castla/mirror/service/TaskBoundsParserTest.kt @@ -0,0 +1,67 @@ +package com.castla.mirror.service + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TaskBoundsParserTest { + + @Test + fun `parses bounds from task body with positive coordinates`() { + val body = """ + mTaskOrganizer=null + bounds=[405,0][1280,720] + mResolvedOverrideConfiguration={1.0 ?mcc?mnc en_US} + """.trimIndent() + val rect = TaskBoundsParser.parseTaskBoundsFromBlock(body) + assertEquals(405, rect?.left) + assertEquals(0, rect?.top) + assertEquals(1280, rect?.right) + assertEquals(720, rect?.bottom) + } + + @Test + fun `returns null when bounds line is absent`() { + val body = """ + mTaskOrganizer=null + mResolvedOverrideConfiguration={1.0 ?mcc?mnc en_US} + mDisplayId=2 + """.trimIndent() + assertNull(TaskBoundsParser.parseTaskBoundsFromBlock(body)) + } + + @Test + fun `parses negative offsets`() { + val body = "bounds=[-50,0][1280,720]" + val rect = TaskBoundsParser.parseTaskBoundsFromBlock(body) + assertEquals(-50, rect?.left) + assertEquals(0, rect?.top) + } + + @Test + fun `parses bounds embedded mid-line with surrounding text`() { + val body = "* Task{1234 type=standard A=10001:com.example.app U=0 visible=true bounds=[0,0][800,600] mode=freeform displayId=2}" + val rect = TaskBoundsParser.parseTaskBoundsFromBlock(body) + assertEquals(0, rect?.left) + assertEquals(800, rect?.right) + assertEquals(600, rect?.bottom) + } + + @Test + fun `picks the first bounds entry when multiple present`() { + // Some dumpsys outputs include both effective and configuration bounds. + // Parser is allowed to pick the first deterministically. + val body = """ + bounds=[0,0][405,720] + mResolvedConfiguration.bounds=[100,100][500,500] + """.trimIndent() + val rect = TaskBoundsParser.parseTaskBoundsFromBlock(body) + assertEquals(0, rect?.left) + assertEquals(405, rect?.right) + } + + @Test + fun `returns null on empty input`() { + assertNull(TaskBoundsParser.parseTaskBoundsFromBlock("")) + } +} diff --git a/app/src/test/java/com/castla/mirror/utils/SplitMathTest.kt b/app/src/test/java/com/castla/mirror/utils/SplitMathTest.kt new file mode 100644 index 0000000..39f69c3 --- /dev/null +++ b/app/src/test/java/com/castla/mirror/utils/SplitMathTest.kt @@ -0,0 +1,77 @@ +package com.castla.mirror.utils + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class SplitMathTest { + + @Test + fun `1280x720 produces 9 to 16 left pane width within bounds`() { + // 720 * 9 / 16 = 405 ; clamps min=360, max=1280-320=960 → 405 + assertEquals(405, SplitMath.computeLeftPaneWidth(1280, 720)) + } + + @Test + fun `1920x1080 clamps within left and right minima`() { + // 1080 * 9 / 16 = 607 ; min=360, max=1920-320=1600 → 607 + assertEquals(607, SplitMath.computeLeftPaneWidth(1920, 1080)) + } + + @Test + fun `narrow display clamps left pane to minimum`() { + // 600 * 9 / 16 = 337 ; min=360 → 360 + assertEquals(360, SplitMath.computeLeftPaneWidth(800, 600)) + } + + @Test + fun `tight right pane respects right minimum`() { + // 400 * 9 / 16 = 225 ; clamped up to min=360 + // width=700, max=700-320=380 → 360 (within [360, 380]) + val left = SplitMath.computeLeftPaneWidth(700, 400) + assertEquals(360, left) + // Right pane width = 700 - 360 = 340 >= RIGHT_PANE_MIN_PX (320) + assertTrue((700 - left) >= SplitMath.RIGHT_PANE_MIN_PX) + } + + @Test + fun `display below minimum total is not viable`() { + // 600 < MIN_TOTAL_PX=680 → not viable + assertFalse(SplitMath.isSplitViable(600, 400)) + } + + @Test + fun `zero or negative dimensions are not viable`() { + assertFalse(SplitMath.isSplitViable(0, 0)) + assertFalse(SplitMath.isSplitViable(1280, 0)) + assertFalse(SplitMath.isSplitViable(-100, 720)) + } + + @Test + fun `at minimum total width split is viable`() { + // width=680, height=480 → viable + assertTrue(SplitMath.isSplitViable(680, 480)) + val left = SplitMath.computeLeftPaneWidth(680, 480) + // 480 * 9 / 16 = 270 ; min=360, max=680-320=360 → exactly 360 + assertEquals(360, left) + } + + @Test + fun `compute left width throws when not viable`() { + assertThrows(IllegalArgumentException::class.java) { + SplitMath.computeLeftPaneWidth(600, 400) + } + assertThrows(IllegalArgumentException::class.java) { + SplitMath.computeLeftPaneWidth(0, 0) + } + } + + @Test + fun `min pane constants are set per plan`() { + assertEquals(360, SplitMath.LEFT_PANE_MIN_PX) + assertEquals(320, SplitMath.RIGHT_PANE_MIN_PX) + assertEquals(680, SplitMath.MIN_TOTAL_PX) + } +}