From 826263d495f4742f451b7d660b5b69958c8da855 Mon Sep 17 00:00:00 2001 From: Suprhimp Date: Sat, 18 Apr 2026 17:04:33 +0900 Subject: [PATCH 1/5] Harden Shizuku against OEM kills with dual-layer watchdog Addresses repeated Shizuku death on Samsung (FreecessController kills shell-UID processes aggressively, taking both shizuku_server and the single-layer watchdog with it). Users had to re-run wireless-debugging setup every time the hotspot transition killed the server. New resilience stack: - Dual-layer watchdog (outer supervises inner; inner supervises shizuku_server) spawned with nohup+setsid for session detachment. - Heartbeat file written every 5s; 15s staleness threshold triggers reinstall. - Best-effort fortification: doze whitelist, appops RUN_ANY/IN_BG, oom_score_adj = -900 on shizuku_server and the watchdogs. - Staged rollback-safe install: write .new scripts, rename, teardown old processes via PID-file + exact argv-element cmdline match, spawn outer, verify. - ShellDiag marker protocol (__STEP_BEGIN__/__STEP_END__ rc=$?) so stdout-only IPC can recover per-step exit codes and fail fast. - Exact argv-element cmdline validation via tr '\0' '\n' | grep -Fxq (no substring or space-boundary heuristics). - Single synchronized entrypoint ensureShizukuHardened() called from MainActivity and MirrorForegroundService. - Legacy single-script watchdog cleanup via /proc/*/cmdline enumeration. - SHIZUKU_FORTIFIED diagnostic event (informational; classifier ignores). 45 unit tests: 21 new for ShellDiag + ShizukuHealth, 3 regression tests for DisconnectCauseClassifier's treatment of the new event. Reviewed via DUUL (plan + code phases, approved). Co-Authored-By: Claude Opus 4.7 --- .../java/com/castla/mirror/MainActivity.kt | 8 +- .../mirror/diagnostics/MirrorDiagnostics.kt | 5 + .../mirror/service/MirrorForegroundService.kt | 8 +- .../com/castla/mirror/shizuku/ShellDiag.kt | 117 ++++++ .../castla/mirror/shizuku/ShizukuHealth.kt | 26 ++ .../com/castla/mirror/shizuku/ShizukuSetup.kt | 339 ++++++++++++++---- .../DisconnectCauseClassifierTest.kt | 32 ++ .../castla/mirror/shizuku/ShellDiagTest.kt | 189 ++++++++++ .../mirror/shizuku/ShizukuHealthTest.kt | 47 +++ 9 files changed, 690 insertions(+), 81 deletions(-) create mode 100644 app/src/main/java/com/castla/mirror/shizuku/ShellDiag.kt create mode 100644 app/src/main/java/com/castla/mirror/shizuku/ShizukuHealth.kt create mode 100644 app/src/test/java/com/castla/mirror/shizuku/ShellDiagTest.kt create mode 100644 app/src/test/java/com/castla/mirror/shizuku/ShizukuHealthTest.kt diff --git a/app/src/main/java/com/castla/mirror/MainActivity.kt b/app/src/main/java/com/castla/mirror/MainActivity.kt index 60b95ab..bc57ad1 100644 --- a/app/src/main/java/com/castla/mirror/MainActivity.kt +++ b/app/src/main/java/com/castla/mirror/MainActivity.kt @@ -302,12 +302,8 @@ class MainActivity : AppCompatActivity() { isShizukuServiceConnected = connected if (connected) { launch(kotlinx.coroutines.Dispatchers.IO) { - if (!shizukuSetup.isWatchdogRunning()) { - val ok = shizukuSetup.setupShizukuWatchdog() - Log.i(TAG, "Shizuku watchdog setup: $ok") - } else { - Log.d(TAG, "Shizuku watchdog already running") - } + val ok = shizukuSetup.ensureShizukuHardened() + Log.i(TAG, "ensureShizukuHardened (MainActivity): $ok") } } } 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 698e8fd..735788c 100644 --- a/app/src/main/java/com/castla/mirror/diagnostics/MirrorDiagnostics.kt +++ b/app/src/main/java/com/castla/mirror/diagnostics/MirrorDiagnostics.kt @@ -14,6 +14,8 @@ enum class DiagnosticEvent { KEYGUARD_UNLOCKED, SHIZUKU_BINDER_DEAD, SHIZUKU_BINDER_READY, + /** Informational: process fortification applied (doze whitelist, appops, oom_adj). */ + SHIZUKU_FORTIFIED, VD_CREATED, VD_STOPPED, SOCKET_DISCONNECTED, @@ -52,6 +54,9 @@ enum class DisconnectCause { * 3. `SOCKET_DISCONNECTED` or `SOCKET_TIMEOUT` → NETWORK * 4. `SCREEN_OFF` present (with no strong signal) → PROCESS_OR_POWER * 5. fallback → UNKNOWN + * + * Informational events like `SHIZUKU_FORTIFIED` are ignored by the classifier + * and must not affect the outcome. */ object DisconnectCauseClassifier { 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 f4956bb..dc38854 100644 --- a/app/src/main/java/com/castla/mirror/service/MirrorForegroundService.kt +++ b/app/src/main/java/com/castla/mirror/service/MirrorForegroundService.kt @@ -2333,12 +2333,10 @@ class MirrorForegroundService : Service() { touchInjector?.setVirtualDisplayInjector { action, x, y, pointerId -> vdm.injectInput(action, x, y, pointerId) } - // Set up Shizuku watchdog for auto-restart after WiFi off + // Harden Shizuku (fortify + install watchdog if needed) for WiFi-off survival serviceScope.launch(kotlinx.coroutines.Dispatchers.IO) { - if (!setup.isWatchdogRunning()) { - val ok = setup.setupShizukuWatchdog() - Log.i(TAG, "Shizuku watchdog setup from service: $ok") - } + val ok = setup.ensureShizukuHardened() + Log.i(TAG, "ensureShizukuHardened (service): $ok") } safeResult(true) } else { diff --git a/app/src/main/java/com/castla/mirror/shizuku/ShellDiag.kt b/app/src/main/java/com/castla/mirror/shizuku/ShellDiag.kt new file mode 100644 index 0000000..04552f3 --- /dev/null +++ b/app/src/main/java/com/castla/mirror/shizuku/ShellDiag.kt @@ -0,0 +1,117 @@ +package com.castla.mirror.shizuku + +/** + * Result of a single step within a ShellDiag-instrumented shell script. + * + * `rc` is the POSIX exit code captured via `$?` after the step. + * A sentinel `rc = -1` indicates the step's END marker was never emitted + * (e.g. stdout was truncated or the shell process exited mid-step). + */ +data class StepResult( + val name: String, + val rc: Int, + val output: String +) + +/** + * Builds shell scripts annotated with begin/end markers so that each step's + * stdout+stderr and exit code can be recovered from the combined stdout stream. + * + * This is needed because [com.castla.mirror.shizuku.PrivilegedService.execCommand] + * returns only stdout — there's no per-command exit code in the IPC surface. + * + * Marker protocol: + * ``` + * __STEP_BEGIN__ name= + * + * __STEP_END__ name= rc= + * ``` + * + * Steps with missing END markers (truncated output) are surfaced with `rc = -1`. + */ +object ShellDiag { + + private const val BEGIN_PREFIX = "__STEP_BEGIN__ name=" + private const val END_PREFIX = "__STEP_END__ name=" + private const val RC_PREFIX = " rc=" + + /** Build a shell script that runs each (name, command) pair with markers. */ + fun buildScript(steps: List>): String = buildString { + for ((name, cmd) in steps) { + append("echo '").append(BEGIN_PREFIX).append(name).append("'\n") + append("{ ").append(cmd).append("; } 2>&1\n") + append("echo \"").append(END_PREFIX).append(name).append(RC_PREFIX).append("\$?\"\n") + } + } + + /** + * Parse stdout produced by a ShellDiag-instrumented script. + * + * Malformed/truncated input is tolerated — incomplete steps (BEGIN without END) + * get [StepResult.rc] = -1. Lines outside any step are ignored. + */ + fun parse(stdout: String): List { + val results = mutableListOf() + var currentName: String? = null + val buffer = StringBuilder() + + for (rawLine in stdout.lineSequence()) { + val line = rawLine + if (line.startsWith(BEGIN_PREFIX)) { + // If a previous step is still open, close it as incomplete + if (currentName != null) { + results += StepResult( + name = currentName, + rc = -1, + output = buffer.toString().trimEnd('\n') + ) + } + currentName = line.removePrefix(BEGIN_PREFIX) + buffer.setLength(0) + } else if (line.startsWith(END_PREFIX) && currentName != null) { + val rest = line.removePrefix(END_PREFIX) + val rcIdx = rest.indexOf(RC_PREFIX) + val endName: String + val rc: Int + if (rcIdx < 0) { + endName = rest + rc = -1 + } else { + endName = rest.substring(0, rcIdx) + rc = rest.substring(rcIdx + RC_PREFIX.length).trim().toIntOrNull() ?: -1 + } + if (endName == currentName) { + results += StepResult( + name = currentName, + rc = rc, + output = buffer.toString().trimEnd('\n') + ) + currentName = null + buffer.setLength(0) + } else { + // Mismatched END — close current as incomplete, ignore this END + results += StepResult( + name = currentName, + rc = -1, + output = buffer.toString().trimEnd('\n') + ) + currentName = null + buffer.setLength(0) + } + } else if (currentName != null) { + buffer.append(line).append('\n') + } + } + + // Tail: if a step was still open at EOF, surface it as truncated + if (currentName != null) { + results += StepResult( + name = currentName, + rc = -1, + output = buffer.toString().trimEnd('\n') + ) + } + + return results + } +} diff --git a/app/src/main/java/com/castla/mirror/shizuku/ShizukuHealth.kt b/app/src/main/java/com/castla/mirror/shizuku/ShizukuHealth.kt new file mode 100644 index 0000000..15100e1 --- /dev/null +++ b/app/src/main/java/com/castla/mirror/shizuku/ShizukuHealth.kt @@ -0,0 +1,26 @@ +package com.castla.mirror.shizuku + +/** + * Pure classifier for the Shizuku watchdog heartbeat age. + * + * The watchdog writes a unix timestamp to a heartbeat file every ~5s while + * healthy. Callers compute `age = now - heartbeatTimestamp` and pass it here. + * + * Negative age (future timestamp, e.g. from clock skew) or missing heartbeat + * (age < 0) is surfaced as [State.Unknown] — callers should not assume health. + */ +object ShizukuHealth { + sealed class State { + object Healthy : State() + object Warning : State() + object Critical : State() + object Unknown : State() + } + + fun classify(heartbeatAgeSeconds: Long): State = when { + heartbeatAgeSeconds < 0 -> State.Unknown + heartbeatAgeSeconds < 30 -> State.Healthy + heartbeatAgeSeconds < 120 -> State.Warning + else -> State.Critical + } +} diff --git a/app/src/main/java/com/castla/mirror/shizuku/ShizukuSetup.kt b/app/src/main/java/com/castla/mirror/shizuku/ShizukuSetup.kt index 02c1878..77a6d6d 100644 --- a/app/src/main/java/com/castla/mirror/shizuku/ShizukuSetup.kt +++ b/app/src/main/java/com/castla/mirror/shizuku/ShizukuSetup.kt @@ -15,6 +15,16 @@ import rikka.shizuku.Shizuku import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +private const val OUTER_SCRIPT = "/data/local/tmp/shizuku_watchdog_outer.sh" +private const val INNER_SCRIPT = "/data/local/tmp/shizuku_watchdog_inner.sh" +private const val OUTER_PID_FILE = "/data/local/tmp/shizuku_watchdog_outer.pid" +private const val INNER_PID_FILE = "/data/local/tmp/shizuku_watchdog_inner.pid" +private const val HEARTBEAT_FILE = "/data/local/tmp/shizuku_watchdog.heartbeat" +private const val LEGACY_SCRIPT = "/data/local/tmp/shizuku_watchdog.sh" +private const val LIB_DIR_BASE = "/data/local/tmp/shizuku_lib" +private const val HEARTBEAT_MAX_AGE_SECONDS = 15 +private const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api" + sealed class ShizukuState { object NotInstalled : ShizukuState() object NotRunning : ShizukuState() @@ -284,106 +294,295 @@ class ShizukuSetup { return result != null && result.startsWith("OK") } + /** Mutex serializing hardening passes so concurrent callers see consistent results. */ + private val hardenMutex = Any() + /** - * Set up a watchdog script that auto-restarts Shizuku server when it dies. + * Idempotent, thread-safe. Applies best-effort process fortification + * (doze whitelist, appops, OOM-adj) and (re)installs the dual-layer watchdog + * if it is not currently healthy. * - * Uses a dual-layer design for resilience against Samsung FreecessController: - * - Inner loop: monitors shizuku_server PID and restarts via app_process - * - Outer loop: re-spawns the inner watchdog if it gets killed - * Both layers run in the same setsid session, detached from the terminal. + * Concurrent callers block on [hardenMutex]; the second caller re-evaluates + * fortify + watchdog verification freshly so the returned boolean always + * reflects the post-call state. * - * Resets on reboot (user must re-setup Shizuku via wireless debugging anyway). - * Must be called while PrivilegedService is connected. + * Returns true iff watchdog verification is healthy after this call. + * Fortify is advisory and never gates the return value — its per-step + * rc values are logged and attached to the [DiagnosticEvent.SHIZUKU_FORTIFIED] + * event detail for on-device debugging. */ - fun setupShizukuWatchdog(): Boolean { + fun ensureShizukuHardened(): Boolean = synchronized(hardenMutex) { val service = privilegedService if (service == null) { - Log.w(TAG, "setupShizukuWatchdog: service not connected") - return false + Log.w(TAG, "ensureShizukuHardened: service not connected") + return@synchronized false } - try { - // Step 1: Find Shizuku APK path - val pmOutput = service.execCommand("pm path moe.shizuku.privileged.api")?.trim() ?: "" + val fortifyResults = runFortify(service) + val fortifySummary = fortifyResults.joinToString(",") { "${it.name}=${it.rc}" } + Log.i(TAG, "fortify: $fortifySummary") + MirrorDiagnostics.log(DiagnosticEvent.SHIZUKU_FORTIFIED, fortifySummary) + + val verifyReason = verifyWatchdog(service) + val healthy = if (verifyReason == "HEALTHY") { + true + } else { + Log.i(TAG, "watchdog unhealthy ($verifyReason) — reinstalling") + installWatchdog(service) + } + Log.i(TAG, "ensureShizukuHardened: healthy=$healthy") + healthy + } + + /** + * Returns age in seconds of the watchdog heartbeat file, or -1 if missing/unreadable. + * Callers can feed this into [ShizukuHealth.classify] for UI state. + */ + fun getWatchdogHeartbeatAgeSeconds(): Long { + val service = privilegedService ?: return -1L + return try { + val raw = service.execCommand( + "HB=\$(cat $HEARTBEAT_FILE 2>/dev/null); NOW=\$(date +%s); " + + "case \"\$HB\" in ''|*[!0-9]*) echo -1 ;; *) echo \$((NOW - HB)) ;; esac" + )?.trim() ?: return -1L + raw.toLongOrNull() ?: -1L + } catch (_: Exception) { + -1L + } + } + + /** + * Runs fortification steps via the marker protocol; returns all step results. + * Every sub-command is best-effort — the caller treats failures as advisory. + */ + private fun runFortify(service: IPrivilegedService): List { + val steps = listOf( + "doze_whitelist" to "dumpsys deviceidle whitelist +$SHIZUKU_PACKAGE", + "appops_run_any" to "cmd appops set $SHIZUKU_PACKAGE RUN_ANY_IN_BACKGROUND allow", + "appops_run_bg" to "cmd appops set $SHIZUKU_PACKAGE RUN_IN_BACKGROUND allow", + "oom_adj_server" to "for PID in \$(pidof shizuku_server 2>/dev/null); do " + + "echo -900 > /proc/\$PID/oom_score_adj 2>/dev/null; done; echo ok" + ) + val script = ShellDiag.buildScript(steps) + val out = try { + service.execCommand(script) ?: "" + } catch (e: Exception) { + Log.w(TAG, "runFortify: execCommand threw", e) + "" + } + return ShellDiag.parse(out) + } + + /** + * Runs the shell health gate. Returns "HEALTHY" or "UNHEALTHY: ". + * Returns "UNHEALTHY: exec_failed" on IPC failure. + */ + private fun verifyWatchdog(service: IPrivilegedService): String { + val script = """ + fail() { echo "UNHEALTHY: ${'$'}1"; exit 0; } + check_pid_file() { + PF=${'$'}1; SPATH=${'$'}2; LABEL=${'$'}3 + [ -f "${'$'}PF" ] || fail "missing_${'$'}LABEL" + PID=${'$'}(cat "${'$'}PF" 2>/dev/null) + case "${'$'}PID" in + ''|*[!0-9]*) fail "nonnumeric_pid_${'$'}LABEL" ;; + esac + [ -r "/proc/${'$'}PID/cmdline" ] || fail "dead_${'$'}LABEL" + # Exact argv-element match: split NUL-delimited cmdline into lines + # and require one line to equal the full script path. + tr '\0' '\n' < "/proc/${'$'}PID/cmdline" 2>/dev/null | grep -Fxq "${'$'}SPATH" \ + || fail "cmdline_mismatch_${'$'}LABEL" + } + check_pid_file $OUTER_PID_FILE $OUTER_SCRIPT outer + check_pid_file $INNER_PID_FILE $INNER_SCRIPT inner + HB=${'$'}(cat $HEARTBEAT_FILE 2>/dev/null) + case "${'$'}HB" in + ''|*[!0-9]*) fail "heartbeat_malformed" ;; + esac + NOW=${'$'}(date +%s) + AGE=${'$'}((NOW - HB)) + [ "${'$'}AGE" -lt 0 ] && fail "heartbeat_future_${'$'}AGE" + [ "${'$'}AGE" -gt $HEARTBEAT_MAX_AGE_SECONDS ] && fail "heartbeat_stale_${'$'}{AGE}s" + echo "HEALTHY" + """.trimIndent() + return try { + service.execCommand(script)?.trim() ?: "UNHEALTHY: null_output" + } catch (e: Exception) { + Log.w(TAG, "verifyWatchdog: execCommand threw", e) + "UNHEALTHY: exec_failed" + } + } + + /** + * Stages new watchdog scripts, atomically swaps them in, tears down old + * processes, spawns the outer supervisor, and re-runs verification. + * Returns true iff verification reports HEALTHY after install. + */ + private fun installWatchdog(service: IPrivilegedService): Boolean { + return try { + // Resolve Shizuku APK path (required for app_process classpath) + val pmOutput = service.execCommand("pm path $SHIZUKU_PACKAGE")?.trim() ?: "" val shizukuApk = pmOutput.lineSequence() .firstOrNull { it.startsWith("package:") } ?.removePrefix("package:")?.trim() if (shizukuApk.isNullOrEmpty()) { - Log.e(TAG, "setupShizukuWatchdog: could not find Shizuku APK path") + Log.e(TAG, "installWatchdog: could not find Shizuku APK path") return false } - Log.d(TAG, "Shizuku APK: $shizukuApk") - - // Step 2: Determine ABI and extract librish.so from Shizuku APK - val abi = service.execCommand("getprop ro.product.cpu.abi")?.trim() ?: "arm64-v8a" - val libDir = "/data/local/tmp/shizuku_lib/$abi" - service.execCommand("mkdir -p $libDir") - service.execCommand( - "unzip -o $shizukuApk lib/$abi/librish.so -d /data/local/tmp/shizuku_lib_tmp && " + - "cp /data/local/tmp/shizuku_lib_tmp/lib/$abi/librish.so $libDir/ && " + - "rm -rf /data/local/tmp/shizuku_lib_tmp" + val abi = service.execCommand("getprop ro.product.cpu.abi")?.trim()?.ifEmpty { null } + ?: "arm64-v8a" + val libDir = "$LIB_DIR_BASE/$abi" + + val innerScript = buildInnerScript(shizukuApk, libDir) + val outerScript = buildOuterScript() + + // STAGE: write to .new paths (old processes still running untouched) + val stageSteps = listOf( + "ensure_libdir" to "mkdir -p $libDir", + "extract_librish" to + "unzip -o $shizukuApk lib/$abi/librish.so -d /data/local/tmp/shizuku_lib_tmp && " + + "cp /data/local/tmp/shizuku_lib_tmp/lib/$abi/librish.so $libDir/ && " + + "rm -rf /data/local/tmp/shizuku_lib_tmp", + "write_inner" to "cat > ${INNER_SCRIPT}.new <<'__CASTLA_INNER_EOF__'\n$innerScript\n__CASTLA_INNER_EOF__", + "write_outer" to "cat > ${OUTER_SCRIPT}.new <<'__CASTLA_OUTER_EOF__'\n$outerScript\n__CASTLA_OUTER_EOF__", + "chmod_inner" to "chmod 755 ${INNER_SCRIPT}.new", + "chmod_outer" to "chmod 755 ${OUTER_SCRIPT}.new" ) + val stageOut = service.execCommand(ShellDiag.buildScript(stageSteps)) ?: "" + val stageResults = ShellDiag.parse(stageOut) + val stageFailed = stageResults.any { it.rc != 0 } + if (stageFailed) { + Log.e(TAG, "installWatchdog stage failed: ${stageResults.joinToString(",") { "${it.name}=${it.rc}" }}") + return false + } - // Step 3: Kill existing watchdog if any - service.execCommand("pkill -f shizuku_watchdog 2>/dev/null") - Thread.sleep(500) - - // Step 4: Write watchdog script with signal trapping - val script = """ - #!/bin/sh - # shizuku_watchdog: auto-restart Shizuku server when it dies - APK_PATH="$shizukuApk" - LIB_DIR="$libDir" - export LD_LIBRARY_PATH="${'$'}LIB_DIR" - - # Ignore SIGHUP/SIGTERM so Samsung FreecessController can't kill us - trap '' HUP TERM - - while true; do - sleep 5 - if ! pidof shizuku_server > /dev/null 2>&1; then - log -t shizuku_watchdog "Shizuku server not running, restarting..." - app_process -Djava.class.path="${'$'}APK_PATH" /system/bin --nice-name=shizuku_server rikka.shizuku.server.ShizukuService & - sleep 15 + // SWAP + TEARDOWN + SPAWN: each step named so rc values are recovered + val teardownCurrent = """ + for PF in $OUTER_PID_FILE $INNER_PID_FILE; do + [ -f "${'$'}PF" ] || continue + PID=${'$'}(cat "${'$'}PF" 2>/dev/null) + case "${'$'}PID" in + ''|*[!0-9]*) rm -f "${'$'}PF"; continue ;; + esac + # Exact argv-element match via NUL splitting + if tr '\0' '\n' < "/proc/${'$'}PID/cmdline" 2>/dev/null \ + | grep -Fxq -e "$OUTER_SCRIPT" -e "$INNER_SCRIPT"; then + kill "${'$'}PID" 2>/dev/null fi + rm -f "${'$'}PF" done + true """.trimIndent() - service.execCommand("cat > /data/local/tmp/shizuku_watchdog.sh << 'WATCHDOG_EOF'\n$script\nWATCHDOG_EOF") - service.execCommand("chmod 755 /data/local/tmp/shizuku_watchdog.sh") - - // Step 5: Start watchdog with nohup + setsid (fully detached session) - service.execCommand("nohup setsid sh /data/local/tmp/shizuku_watchdog.sh > /dev/null 2>&1 &") - Thread.sleep(1000) + val teardownLegacy = """ + for CMDFILE in /proc/*/cmdline; do + LPID=${'$'}(echo "${'$'}CMDFILE" | sed -n 's#^/proc/\([0-9]*\)/cmdline${'$'}#\1#p') + [ -z "${'$'}LPID" ] && continue + # Split argv elements on NUL for exact matching + ARGS=${'$'}(tr '\0' '\n' < "${'$'}CMDFILE" 2>/dev/null) + [ -z "${'$'}ARGS" ] && continue + # Only act if legacy script path is an exact argv element + echo "${'$'}ARGS" | grep -Fxq "$LEGACY_SCRIPT" || continue + # Skip if this process is one of the new-version watchdogs + if echo "${'$'}ARGS" | grep -Fxq -e "$OUTER_SCRIPT" -e "$INNER_SCRIPT"; then + continue + fi + kill "${'$'}LPID" 2>/dev/null + done + rm -f $LEGACY_SCRIPT + """.trimIndent() - // Step 6: Verify - val pid = service.execCommand("pgrep -f shizuku_watchdog")?.trim() - if (pid.isNullOrEmpty()) { - Log.w(TAG, "setupShizukuWatchdog: process not found after start") + val swapSteps = listOf( + "mv_inner" to "mv -f ${INNER_SCRIPT}.new $INNER_SCRIPT", + "mv_outer" to "mv -f ${OUTER_SCRIPT}.new $OUTER_SCRIPT", + "teardown_current" to teardownCurrent, + "teardown_legacy" to teardownLegacy, + "clear_heartbeat" to "rm -f $HEARTBEAT_FILE", + "spawn_outer" to "nohup setsid sh $OUTER_SCRIPT /dev/null 2>&1 & echo spawned" + ) + val swapOut = service.execCommand(ShellDiag.buildScript(swapSteps)) ?: "" + val swapResults = ShellDiag.parse(swapOut) + val swapSummary = swapResults.joinToString(",") { "${it.name}=${it.rc}" } + val swapFailed = swapResults.any { it.rc != 0 } || swapResults.size != swapSteps.size + if (swapFailed) { + Log.e(TAG, "installWatchdog swap failed: $swapSummary") return false } + Log.i(TAG, "installWatchdog swap ok: $swapSummary") - Log.i(TAG, "Shizuku watchdog started with PIDs: $pid") - return true - } catch (e: Exception) { - Log.e(TAG, "setupShizukuWatchdog failed", e) - return false - } - } + // Let the inner loop write heartbeat + PID files at least once + Thread.sleep(2000L) - /** - * Check if the Shizuku watchdog is currently running. - */ - fun isWatchdogRunning(): Boolean { - val service = privilegedService ?: return false - return try { - val result = service.execCommand("pgrep -f shizuku_watchdog")?.trim() - !result.isNullOrEmpty() + val verify = verifyWatchdog(service) + if (verify != "HEALTHY") { + Log.w(TAG, "installWatchdog: verify reports $verify") + return false + } + Log.i(TAG, "installWatchdog: verify HEALTHY") + true } catch (e: Exception) { + Log.e(TAG, "installWatchdog failed", e) false } } + private fun buildInnerScript(shizukuApk: String, libDir: String): String = """ +#!/bin/sh +trap '' HUP TERM INT QUIT +APK_PATH="$shizukuApk" +LIB_DIR="$libDir" +export LD_LIBRARY_PATH="${'$'}LIB_DIR" +echo ${'$'}${'$'} > $INNER_PID_FILE +# Best-effort: keep our own OOM score low too +echo -900 > /proc/${'$'}${'$'}/oom_score_adj 2>/dev/null + +while true; do + date +%s > $HEARTBEAT_FILE 2>/dev/null + if ! pidof shizuku_server > /dev/null 2>&1; then + log -t shizuku_watchdog "server down, restarting" + setsid nohup app_process -Djava.class.path="${'$'}APK_PATH" /system/bin \ + --nice-name=shizuku_server rikka.shizuku.server.ShizukuService \ + /dev/null 2>&1 & + sleep 3 + for PID in ${'$'}(pidof shizuku_server 2>/dev/null); do + echo -900 > /proc/${'$'}PID/oom_score_adj 2>/dev/null + done + sleep 12 + fi + sleep 5 +done +""".trimIndent() + + private fun buildOuterScript(): String = """ +#!/bin/sh +trap '' HUP TERM INT QUIT +INNER=$INNER_SCRIPT +echo ${'$'}${'$'} > $OUTER_PID_FILE +echo -900 > /proc/${'$'}${'$'}/oom_score_adj 2>/dev/null + +while true; do + ALIVE=0 + if [ -f $INNER_PID_FILE ]; then + IPID=${'$'}(cat $INNER_PID_FILE 2>/dev/null) + case "${'$'}IPID" in + ''|*[!0-9]*) ;; + *) + # Exact argv-element match: split NUL-delimited cmdline into lines + if tr '\0' '\n' < /proc/${'$'}IPID/cmdline 2>/dev/null | grep -Fxq "$INNER_SCRIPT"; then + ALIVE=1 + fi + ;; + esac + fi + if [ ${'$'}ALIVE -eq 0 ]; then + log -t shizuku_watchdog "inner down, respawning" + setsid nohup sh "${'$'}INNER" /dev/null 2>&1 & + fi + # Deterministic jitter per outer PID (0..4 seconds) on top of 5s base + sleep ${'$'}((5 + ${'$'}${'$'} % 5)) +done +""".trimIndent() + fun release() { if (userServiceBound) { try { diff --git a/app/src/test/java/com/castla/mirror/diagnostics/DisconnectCauseClassifierTest.kt b/app/src/test/java/com/castla/mirror/diagnostics/DisconnectCauseClassifierTest.kt index 24db81e..79cbefd 100644 --- a/app/src/test/java/com/castla/mirror/diagnostics/DisconnectCauseClassifierTest.kt +++ b/app/src/test/java/com/castla/mirror/diagnostics/DisconnectCauseClassifierTest.kt @@ -211,4 +211,36 @@ class DisconnectCauseClassifierTest { ) assertEquals(DisconnectCause.NETWORK, DisconnectCauseClassifier.classify(events)) } + + // ── Informational events are ignored ── + + @Test + fun `shizuku fortified alone classifies as UNKNOWN`() { + assertEquals( + DisconnectCause.UNKNOWN, + DisconnectCauseClassifier.classify(listOf(DiagnosticEvent.SHIZUKU_FORTIFIED)) + ) + } + + @Test + fun `shizuku fortified mixed with binder dead still classifies as SHIZUKU`() { + val events = listOf( + DiagnosticEvent.SHIZUKU_FORTIFIED, + DiagnosticEvent.SHIZUKU_BINDER_DEAD, + DiagnosticEvent.SHIZUKU_FORTIFIED + ) + assertEquals(DisconnectCause.SHIZUKU, DisconnectCauseClassifier.classify(events)) + } + + @Test + fun `shizuku fortified does not cancel binder death nor substitute for recovery`() { + val events = listOf( + DiagnosticEvent.SHIZUKU_BINDER_DEAD, + DiagnosticEvent.SHIZUKU_FORTIFIED, + DiagnosticEvent.SHIZUKU_BINDER_READY + ) + // The real recovery (SHIZUKU_BINDER_READY) still cancels the death; + // SHIZUKU_FORTIFIED is informational and irrelevant to classification. + assertEquals(DisconnectCause.UNKNOWN, DisconnectCauseClassifier.classify(events)) + } } diff --git a/app/src/test/java/com/castla/mirror/shizuku/ShellDiagTest.kt b/app/src/test/java/com/castla/mirror/shizuku/ShellDiagTest.kt new file mode 100644 index 0000000..697af8d --- /dev/null +++ b/app/src/test/java/com/castla/mirror/shizuku/ShellDiagTest.kt @@ -0,0 +1,189 @@ +package com.castla.mirror.shizuku + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ShellDiagTest { + + @Test + fun `empty stdout yields empty list`() { + assertTrue(ShellDiag.parse("").isEmpty()) + } + + @Test + fun `parses single step with rc 0`() { + val stdout = """ + __STEP_BEGIN__ name=hello + hi + __STEP_END__ name=hello rc=0 + """.trimIndent() + val result = ShellDiag.parse(stdout) + assertEquals(1, result.size) + assertEquals("hello", result[0].name) + assertEquals(0, result[0].rc) + assertEquals("hi", result[0].output) + } + + @Test + fun `parses multiple steps`() { + val stdout = """ + __STEP_BEGIN__ name=one + first + __STEP_END__ name=one rc=0 + __STEP_BEGIN__ name=two + second + __STEP_END__ name=two rc=0 + """.trimIndent() + val result = ShellDiag.parse(stdout) + assertEquals(2, result.size) + assertEquals("one", result[0].name) + assertEquals("first", result[0].output) + assertEquals("two", result[1].name) + assertEquals("second", result[1].output) + } + + @Test + fun `parses step with nonzero rc`() { + val stdout = """ + __STEP_BEGIN__ name=fail + permission denied + __STEP_END__ name=fail rc=13 + """.trimIndent() + val result = ShellDiag.parse(stdout) + assertEquals(1, result.size) + assertEquals(13, result[0].rc) + assertEquals("permission denied", result[0].output) + } + + @Test + fun `parses step with stderr interleaved via 2 gt 1`() { + // With "{ cmd; } 2>&1", stderr is merged into stdout line-by-line + val stdout = """ + __STEP_BEGIN__ name=mixed + stdout line + stderr line + another stdout + __STEP_END__ name=mixed rc=0 + """.trimIndent() + val result = ShellDiag.parse(stdout) + assertEquals(1, result.size) + val lines = result[0].output.lines() + assertEquals(3, lines.size) + assertEquals("stdout line", lines[0]) + assertEquals("stderr line", lines[1]) + assertEquals("another stdout", lines[2]) + } + + @Test + fun `parses multi-line step output`() { + val stdout = """ + __STEP_BEGIN__ name=multi + line 1 + line 2 + line 3 + __STEP_END__ name=multi rc=0 + """.trimIndent() + val result = ShellDiag.parse(stdout) + assertEquals(1, result.size) + assertEquals("line 1\nline 2\nline 3", result[0].output) + } + + @Test + fun `truncated step without END marker gets rc -1`() { + val stdout = """ + __STEP_BEGIN__ name=truncated + partial output + """.trimIndent() + val result = ShellDiag.parse(stdout) + assertEquals(1, result.size) + assertEquals("truncated", result[0].name) + assertEquals(-1, result[0].rc) + assertEquals("partial output", result[0].output) + } + + @Test + fun `second BEGIN without preceding END closes previous as truncated`() { + val stdout = """ + __STEP_BEGIN__ name=first + never closed + __STEP_BEGIN__ name=second + done + __STEP_END__ name=second rc=0 + """.trimIndent() + val result = ShellDiag.parse(stdout) + assertEquals(2, result.size) + assertEquals("first", result[0].name) + assertEquals(-1, result[0].rc) + assertEquals("never closed", result[0].output) + assertEquals("second", result[1].name) + assertEquals(0, result[1].rc) + } + + @Test + fun `mismatched END name closes current as truncated`() { + val stdout = """ + __STEP_BEGIN__ name=alpha + oops + __STEP_END__ name=beta rc=0 + """.trimIndent() + val result = ShellDiag.parse(stdout) + assertEquals(1, result.size) + assertEquals("alpha", result[0].name) + assertEquals(-1, result[0].rc) + } + + @Test + fun `lines outside any step are ignored`() { + val stdout = """ + garbage before + __STEP_BEGIN__ name=ok + hi + __STEP_END__ name=ok rc=0 + garbage after + """.trimIndent() + val result = ShellDiag.parse(stdout) + assertEquals(1, result.size) + assertEquals("hi", result[0].output) + } + + @Test + fun `buildScript emits correct markers per step`() { + val script = ShellDiag.buildScript( + listOf( + "step_a" to "echo hi", + "step_b" to "false" + ) + ) + assertTrue(script.contains("__STEP_BEGIN__ name=step_a")) + assertTrue(script.contains("{ echo hi; } 2>&1")) + assertTrue(script.contains("__STEP_END__ name=step_a rc=\$?")) + assertTrue(script.contains("__STEP_BEGIN__ name=step_b")) + assertTrue(script.contains("{ false; } 2>&1")) + assertTrue(script.contains("__STEP_END__ name=step_b rc=\$?")) + } + + @Test + fun `buildScript with empty step list yields empty script`() { + val script = ShellDiag.buildScript(emptyList()) + assertTrue(script.isEmpty()) + } + + @Test + fun `roundtrip buildScript and parse recover all step names and rc`() { + // Simulate the shell executing the script and emitting output + val steps = listOf("alpha" to "echo a", "beta" to "echo b") + // buildScript output is shell code; we don't run it here, but we can + // simulate what the shell would print by manually emitting markers: + val simulated = steps.joinToString("\n") { (name, _) -> + "__STEP_BEGIN__ name=$name\nout_$name\n__STEP_END__ name=$name rc=0" + } + val parsed = ShellDiag.parse(simulated) + assertEquals(2, parsed.size) + assertEquals("alpha", parsed[0].name) + assertEquals("out_alpha", parsed[0].output) + assertEquals(0, parsed[0].rc) + assertEquals("beta", parsed[1].name) + assertEquals("out_beta", parsed[1].output) + } +} diff --git a/app/src/test/java/com/castla/mirror/shizuku/ShizukuHealthTest.kt b/app/src/test/java/com/castla/mirror/shizuku/ShizukuHealthTest.kt new file mode 100644 index 0000000..0301127 --- /dev/null +++ b/app/src/test/java/com/castla/mirror/shizuku/ShizukuHealthTest.kt @@ -0,0 +1,47 @@ +package com.castla.mirror.shizuku + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ShizukuHealthTest { + + @Test + fun `age -5 classifies as Unknown`() { + assertEquals(ShizukuHealth.State.Unknown, ShizukuHealth.classify(-5L)) + } + + @Test + fun `age -1 classifies as Unknown`() { + assertEquals(ShizukuHealth.State.Unknown, ShizukuHealth.classify(-1L)) + } + + @Test + fun `age 0 classifies as Healthy`() { + assertEquals(ShizukuHealth.State.Healthy, ShizukuHealth.classify(0L)) + } + + @Test + fun `age 29 classifies as Healthy`() { + assertEquals(ShizukuHealth.State.Healthy, ShizukuHealth.classify(29L)) + } + + @Test + fun `age 30 classifies as Warning`() { + assertEquals(ShizukuHealth.State.Warning, ShizukuHealth.classify(30L)) + } + + @Test + fun `age 119 classifies as Warning`() { + assertEquals(ShizukuHealth.State.Warning, ShizukuHealth.classify(119L)) + } + + @Test + fun `age 120 classifies as Critical`() { + assertEquals(ShizukuHealth.State.Critical, ShizukuHealth.classify(120L)) + } + + @Test + fun `very stale age classifies as Critical`() { + assertEquals(ShizukuHealth.State.Critical, ShizukuHealth.classify(3600L)) + } +} From e46aaa528c75805358f5b742dad91990f38ee0d5 Mon Sep 17 00:00:00 2001 From: Suprhimp Date: Sun, 19 Apr 2026 11:41:29 +0900 Subject: [PATCH 2/5] Hold Shizuku alive across screen-off via WifiLock + loop defense - Acquire WIFI_MODE_FULL_HIGH_PERF lock in ShizukuSetup so wireless ADB does not get torn down by the radio sleeping on screen-off (fixes the AdbDebuggingManager "Network disconnected. Disabling adbwifi" cascade that killed Shizuku within 1-10s of lock). - Add quick-death counter: if the binder dies <15s after being received 3 times in a row, stop auto-relaunching the Shizuku manager and post a high-priority ongoing notification telling the user to plug USB back in or keep the screen on. Avoids an infinite relaunch loop on OEM kill. - Add client-side frame-arrival watchdog so a silently stalled video socket (Shizuku death, encoder stall, VD end) no longer freezes the last frame on screen. - Relocate Home / density / playback-profile controls into a collapsible overlay menu to tidy the on-screen chrome. Co-Authored-By: Claude Opus 4.7 --- app/src/main/assets/web/index.html | 132 ++++--- app/src/main/assets/web/js/main.js | 71 +++- .../java/com/castla/mirror/MainActivity.kt | 13 +- .../mirror/service/MirrorForegroundService.kt | 2 +- .../com/castla/mirror/shizuku/ShizukuSetup.kt | 369 +++++++++++++++++- 5 files changed, 518 insertions(+), 69 deletions(-) diff --git a/app/src/main/assets/web/index.html b/app/src/main/assets/web/index.html index 303036b..62ce329 100644 --- a/app/src/main/assets/web/index.html +++ b/app/src/main/assets/web/index.html @@ -713,39 +713,84 @@ - - - + + +
- - - diff --git a/app/src/main/assets/web/js/main.js b/app/src/main/assets/web/js/main.js index 49b7800..c77d519 100644 --- a/app/src/main/assets/web/js/main.js +++ b/app/src/main/assets/web/js/main.js @@ -75,6 +75,9 @@ document.addEventListener('DOMContentLoaded', async () => { const webLauncher = document.getElementById('web-launcher'); const homeBtn = document.getElementById('home-btn'); + const overlayMenu = document.getElementById('overlay-menu'); + const overlayMenuToggle = document.getElementById('overlay-menu-toggle'); + const overlayMenuPanel = document.getElementById('overlay-menu-panel'); const densityControl = document.getElementById('density-control'); const densityBtn = document.getElementById('density-btn'); const densityLabel = document.getElementById('density-label'); @@ -872,6 +875,7 @@ document.addEventListener('DOMContentLoaded', async () => { const wsUrl = `ws://${host}/ws/video`; if (!isLauncherMode) setStatus('Connecting...', ''); + clearFrameWatchdog(); videoSocket = new WebSocket(wsUrl); videoSocket.binaryType = 'arraybuffer'; @@ -884,6 +888,7 @@ document.addEventListener('DOMContentLoaded', async () => { videoSocket.onmessage = async (event) => { if (event.data instanceof ArrayBuffer) { + armFrameWatchdog(videoSocket); if (!decoder) return; if (codecMode === 'h264') { const v = new Uint8Array(event.data); @@ -897,6 +902,7 @@ document.addEventListener('DOMContentLoaded', async () => { }; videoSocket.onclose = () => { + clearFrameWatchdog(); if (!isLauncherMode) { setStatus('Disconnected', 'error'); showOverlay(); @@ -928,6 +934,37 @@ document.addEventListener('DOMContentLoaded', async () => { let reconnectTimer = null; let isReconnecting = false; let qualityReportInterval = null; + + // Frame-arrival watchdog: if the primary video socket stays open but stops + // delivering frames (Shizuku death, encoder stall, VD end, silent network + // stall), the last frame would otherwise stay frozen on screen. Bind the + // timer to the specific socket instance so a stale fire cannot close a + // freshly reconnected socket. + const FRAME_TIMEOUT_MS = 4000; + let frameWatchdogTimer = null; + + function armFrameWatchdog(socket) { + if (isLauncherMode || !socket) return; + if (socket !== videoSocket) return; + if (frameWatchdogTimer !== null) clearTimeout(frameWatchdogTimer); + frameWatchdogTimer = setTimeout(() => onFrameStalled(socket), FRAME_TIMEOUT_MS); + } + + function clearFrameWatchdog() { + if (frameWatchdogTimer !== null) clearTimeout(frameWatchdogTimer); + frameWatchdogTimer = null; + } + + function onFrameStalled(socket) { + if (socket !== videoSocket) return; + if (!socket || socket.readyState !== WebSocket.OPEN) return; + if (isLauncherMode) return; + console.warn('[Main] Video stream stalled — no frame for', FRAME_TIMEOUT_MS, 'ms. Triggering reconnect.'); + setStatus('Disconnected', 'error'); + showOverlay(); + try { socket.close(); } catch (_) {} + } + function scheduleReconnect() { if (isReconnecting) return; isReconnecting = true; @@ -1280,6 +1317,7 @@ document.addEventListener('DOMContentLoaded', async () => { homeBtn.style.display = 'block'; firstFrameReceived = false; clearLaunchTimeout(); + clearFrameWatchdog(); // Clear the previous app's last frame immediately so it doesn't // flash during the transition to the new app @@ -1330,8 +1368,10 @@ document.addEventListener('DOMContentLoaded', async () => { } function goHome() { + collapseOverlayMenu(); isLauncherMode = true; clearLaunchTimeout(); + clearFrameWatchdog(); closeInputBubble(true); blurKeyboardProxy(); disableBrowserSplit(); @@ -1627,10 +1667,17 @@ document.addEventListener('DOMContentLoaded', async () => { }); } + function collapseOverlayMenu() { + if (overlayMenuPanel) overlayMenuPanel.style.display = 'none'; + if (overlayMenuToggle) overlayMenuToggle.setAttribute('aria-expanded', 'false'); + if (densityPopup) densityPopup.style.display = 'none'; + if (profilePopup) profilePopup.style.display = 'none'; + } + function updateOverlayControlsVisibility() { - const isVisible = homeBtn && homeBtn.style.display !== 'none'; - if (profileControl) profileControl.style.display = isVisible ? 'block' : 'none'; - if (densityControl) densityControl.style.display = isVisible ? 'block' : 'none'; + const active = homeBtn && homeBtn.style.display !== 'none'; + if (overlayMenu) overlayMenu.style.display = active ? 'flex' : 'none'; + if (!active) collapseOverlayMenu(); } // ── Playback Profile UI ── @@ -1777,6 +1824,24 @@ document.addEventListener('DOMContentLoaded', async () => { }); } + // Hamburger toggle: expand/collapse the overlay menu panel on click; + // collapse on clicks outside the entire #overlay-menu wrapper. + if (overlayMenuToggle && overlayMenuPanel && overlayMenu) { + overlayMenuToggle.addEventListener('click', (e) => { + e.stopPropagation(); + const expanded = overlayMenuPanel.style.display === 'flex'; + if (expanded) { + collapseOverlayMenu(); + } else { + overlayMenuPanel.style.display = 'flex'; + overlayMenuToggle.setAttribute('aria-expanded', 'true'); + } + }); + document.addEventListener('click', (e) => { + if (!overlayMenu.contains(e.target)) collapseOverlayMenu(); + }); + } + // Show/hide floating controls with home button const profileObserver = new MutationObserver(() => updateOverlayControlsVisibility()); if (homeBtn) { diff --git a/app/src/main/java/com/castla/mirror/MainActivity.kt b/app/src/main/java/com/castla/mirror/MainActivity.kt index bc57ad1..06868d1 100644 --- a/app/src/main/java/com/castla/mirror/MainActivity.kt +++ b/app/src/main/java/com/castla/mirror/MainActivity.kt @@ -195,7 +195,7 @@ class MainActivity : AppCompatActivity() { shizukuInstalled = isShizukuInstalled() shizukuSetup = ShizukuSetup() if (shizukuInstalled) { - shizukuSetup.init(bindService = false) + shizukuSetup.init(this, bindService = false) } loadAutoDetectState() @@ -530,7 +530,7 @@ class MainActivity : AppCompatActivity() { val wasInstalled = shizukuInstalled shizukuInstalled = isShizukuInstalled() if (shizukuInstalled && !wasInstalled) { - shizukuSetup.init(bindService = false) + shizukuSetup.init(this, bindService = false) } loadAutoDetectState() @@ -538,6 +538,15 @@ class MainActivity : AppCompatActivity() { val intent = Intent(this, MirrorForegroundService::class.java) bindRequested = bindService(intent, serviceConnection, 0) } + + // Recover Shizuku if it died while we were backgrounded (typically from a + // USB-unplug adbd respawn that wiped shell-UID processes). startActivity + // from here is allowed because we're in the foreground — the same call + // from binderDeadListener gets hit by BAL_BLOCK while the screen is + // sleeping, which is why we also rely on this onStart hook. + if (shizukuInstalled) { + shizukuSetup.launchShizukuManagerIfLostSinceBoot() + } } override fun onStop() { 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 dc38854..2d538fb 100644 --- a/app/src/main/java/com/castla/mirror/service/MirrorForegroundService.kt +++ b/app/src/main/java/com/castla/mirror/service/MirrorForegroundService.kt @@ -2271,7 +2271,7 @@ class MirrorForegroundService : Service() { try { releaseShizukuSession("before_virtual_display_setup") val setup = ShizukuSetup() - setup.init(bindService = false) + setup.init(this, bindService = false) if (!setup.isAvailable() || !setup.hasPermission()) { Log.i(TAG, "Shizuku not available/permitted") diff --git a/app/src/main/java/com/castla/mirror/shizuku/ShizukuSetup.kt b/app/src/main/java/com/castla/mirror/shizuku/ShizukuSetup.kt index 77a6d6d..303d581 100644 --- a/app/src/main/java/com/castla/mirror/shizuku/ShizukuSetup.kt +++ b/app/src/main/java/com/castla/mirror/shizuku/ShizukuSetup.kt @@ -1,12 +1,22 @@ package com.castla.mirror.shizuku +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent import android.content.ComponentName +import android.content.Context +import android.content.Intent import android.content.ServiceConnection import android.content.pm.PackageManager +import android.net.wifi.WifiManager import android.os.Handler import android.os.IBinder import android.os.Looper +import android.os.SystemClock +import android.util.Base64 import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import com.castla.mirror.diagnostics.DiagnosticEvent import com.castla.mirror.diagnostics.MirrorDiagnostics import kotlinx.coroutines.flow.MutableStateFlow @@ -24,6 +34,11 @@ private const val LEGACY_SCRIPT = "/data/local/tmp/shizuku_watchdog.sh" private const val LIB_DIR_BASE = "/data/local/tmp/shizuku_lib" private const val HEARTBEAT_MAX_AGE_SECONDS = 15 private const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api" +private const val RESTART_CHANNEL_ID = "shizuku_restart" +private const val RESTART_NOTIFICATION_ID = 0x5A12 +private const val RESTART_PENDING_INTENT_REQUEST = 0x5A12 +private const val GIVE_UP_CHANNEL_ID = "shizuku_unrecoverable" +private const val GIVE_UP_NOTIFICATION_ID = 0x5A13 sealed class ShizukuState { object NotInstalled : ShizukuState() @@ -37,6 +52,18 @@ class ShizukuSetup { private const val TAG = "ShizukuSetup" private const val REQUEST_CODE = 1001 const val USER_SERVICE_VERSION = 107 + /** Cooldown between foreground auto-launches of the Shizuku manager. */ + private const val AUTO_LAUNCH_COOLDOWN_MS = 60_000L + /** + * If Shizuku's binder dies within this window after being received, we + * count the session as a "quick death". Three in a row (with no + * intervening stable session) means WADB is flapping (typically + * screen-off WiFi sleep → Android disables adb-wifi → adbd respawn + * cascade kills shizuku_server). Auto-launch is suppressed past that + * point so we don't spam Shizuku manager restarts at the user. + */ + private const val QUICK_DEATH_WINDOW_MS = 15_000L + private const val MAX_CONSECUTIVE_QUICK_DEATHS = 3 } private val _state = MutableStateFlow(ShizukuState.NotInstalled) @@ -60,6 +87,56 @@ class ShizukuSetup { private var userServiceBound = false private val mainHandler = Handler(Looper.getMainLooper()) + /** applicationContext used by [binderDeadListener] to auto-launch Shizuku manager. */ + private var appContext: Context? = null + + /** + * Set true once Shizuku's binder has been received at least once in this + * process lifetime. Used by [launchShizukuManagerIfLostSinceBoot] so we + * only auto-relaunch after a previously-healthy Shizuku disappears, not + * on first-ever app launch (where Shizuku may legitimately be stopped and + * the user should drive the start flow manually). + */ + @Volatile + private var hasBeenAvailable = false + + /** + * Timestamp of the last auto-launch attempt. Used to throttle + * [launchShizukuManagerIfLostSinceBoot] so we don't re-fire on every + * onStart — otherwise, when the user dismisses the Shizuku manager, our + * MainActivity resumes, fires auto-launch again, and the Shizuku UI + * "keeps coming back" (a loop we saw on Samsung Flip testing 2026-04-18). + */ + @Volatile + private var lastAutoLaunchAtMs = 0L + + /** + * Elapsed-realtime of the most recent [binderReceivedListener] fire. Pair + * with the [binderDeadListener] timestamp to classify a session as a + * "quick death" vs. a normal one — see [consecutiveQuickDeaths]. + */ + @Volatile + private var lastBinderReceivedAtMs = 0L + + /** + * Count of back-to-back sessions where Shizuku's binder died within + * [QUICK_DEATH_WINDOW_MS] of coming up. Reset as soon as one session + * manages to stay alive past that threshold. When this hits + * [MAX_CONSECUTIVE_QUICK_DEATHS] we stop auto-launching the Shizuku + * manager and surface a persistent notification instead. + */ + @Volatile + private var consecutiveQuickDeaths = 0 + + /** + * Holds WiFi awake for the entire lifetime of the setup (≈ app process + * lifetime). Without this, Samsung's screen-off WiFi sleep trips + * `AdbDebuggingManager: Network disconnected. Disabling adbwifi`, which + * restarts adbd — and every shell-UID child (shizuku_server, watchdog) + * dies with it. Held via [acquireWifiLock] / [releaseWifiLock]. + */ + private var wifiLock: WifiManager.WifiLock? = null + private val serviceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { privilegedService = IPrivilegedService.Stub.asInterface(binder) @@ -81,7 +158,19 @@ class ShizukuSetup { private val binderReceivedListener = Shizuku.OnBinderReceivedListener { Log.i(TAG, "Shizuku binder received") MirrorDiagnostics.log(DiagnosticEvent.SHIZUKU_BINDER_READY) + hasBeenAvailable = true + lastAutoLaunchAtMs = 0L + lastBinderReceivedAtMs = SystemClock.elapsedRealtime() updateState() + cancelRestartNotification() + cancelGiveUpNotification() + // Auto-bind PrivilegedService whenever Shizuku becomes available with permission, + // so ensureShizukuHardened (driven by serviceConnected observers) runs at app + // start instead of only when a browser connects. bindPrivilegedService is + // idempotent via _serviceConnected / bindingInProgress guards. + if (hasPermission()) { + bindPrivilegedService() + } } private val binderDeadListener = Shizuku.OnBinderDeadListener { @@ -91,6 +180,24 @@ class ShizukuSetup { _serviceConnected.value = false bindingInProgress = false _state.value = ShizukuState.NotRunning + val aliveMs = if (lastBinderReceivedAtMs == 0L) Long.MAX_VALUE + else SystemClock.elapsedRealtime() - lastBinderReceivedAtMs + if (aliveMs < QUICK_DEATH_WINDOW_MS) { + consecutiveQuickDeaths += 1 + Log.w(TAG, "Quick death #$consecutiveQuickDeaths (alive ${aliveMs}ms)") + } else { + consecutiveQuickDeaths = 0 + } + // Shell-UID watchdog can't recover from adbd restarts (USB unplug on Samsung + // triggers an adbd respawn that cleans out the entire shell UID — both + // shizuku_server and the watchdog die together). Our app UID survives, so + // we post a high-priority notification whose PendingIntent launches the + // Shizuku manager Activity; tapping it triggers shizuku_server restart via + // saved wireless-ADB authorization. We do NOT call startActivity directly + // because when USB is unplugged the screen is typically off (TOP_SLEEPING), + // which Android 14 refuses with BAL_BLOCK regardless of our foreground + // state. The notification is visible on lockscreen and survives screen-off. + requestShizukuRestart() } private val permissionResultListener = Shizuku.OnRequestPermissionResultListener { requestCode, grantResult -> @@ -104,7 +211,9 @@ class ShizukuSetup { } } - fun init(bindService: Boolean = true) { + fun init(context: Context, bindService: Boolean = true) { + appContext = context.applicationContext + acquireWifiLock() Shizuku.addBinderReceivedListenerSticky(binderReceivedListener) Shizuku.addBinderDeadListener(binderDeadListener) Shizuku.addRequestPermissionResultListener(permissionResultListener) @@ -115,6 +224,224 @@ class ShizukuSetup { } } + private fun acquireWifiLock() { + if (wifiLock?.isHeld == true) return + val ctx = appContext ?: return + try { + val wm = ctx.getSystemService(Context.WIFI_SERVICE) as? WifiManager ?: return + @Suppress("DEPRECATION") + val lock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "Castla::ShizukuWifiLock") + lock.setReferenceCounted(false) + lock.acquire() + wifiLock = lock + Log.i(TAG, "ShizukuWifiLock acquired (WiFi stays awake across screen-off)") + } catch (e: Exception) { + Log.w(TAG, "acquireWifiLock failed", e) + } + } + + private fun releaseWifiLock() { + try { + wifiLock?.takeIf { it.isHeld }?.release() + } catch (_: Exception) { + } + wifiLock = null + } + + /** + * Posts a high-priority notification whose tap action launches the Shizuku + * manager Activity. Used from [binderDeadListener] to recover from adbd-restart + * cascades that kill shizuku_server + the shell-UID watchdog together (USB + * unplug on Samsung). Direct startActivity is unreliable here because the + * screen is typically off (TOP_SLEEPING) → Android 14 BAL_BLOCK. + * + * The notification is visible on lockscreen and persists until the user taps + * it or binder is re-received (see [cancelRestartNotification]). Tapping + * routes to Shizuku manager which uses its saved wireless ADB authorization + * to bring shizuku_server back up. + */ + fun requestShizukuRestart() { + val ctx = appContext ?: run { + Log.w(TAG, "requestShizukuRestart: appContext not set — skipping") + return + } + if (!NotificationManagerCompat.from(ctx).areNotificationsEnabled()) { + Log.w(TAG, "requestShizukuRestart: notifications disabled by user") + return + } + ensureRestartChannel(ctx) + val shizukuIntent = ctx.packageManager.getLaunchIntentForPackage(SHIZUKU_PACKAGE) + if (shizukuIntent == null) { + Log.w(TAG, "requestShizukuRestart: no launch intent for $SHIZUKU_PACKAGE") + return + } + shizukuIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + val pending = PendingIntent.getActivity( + ctx, + RESTART_PENDING_INTENT_REQUEST, + shizukuIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + val notification = NotificationCompat.Builder(ctx, RESTART_CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_notify_error) + .setContentTitle("Shizuku stopped") + .setContentText("Tap to restart — mirroring paused until Shizuku is running.") + .setContentIntent(pending) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .build() + try { + NotificationManagerCompat.from(ctx) + .notify(RESTART_NOTIFICATION_ID, notification) + Log.i(TAG, "requestShizukuRestart: notification posted") + } catch (e: SecurityException) { + // POST_NOTIFICATIONS permission not granted on Android 13+. + Log.w(TAG, "requestShizukuRestart: notify threw (missing permission)", e) + } + } + + private fun cancelRestartNotification() { + val ctx = appContext ?: return + try { + NotificationManagerCompat.from(ctx).cancel(RESTART_NOTIFICATION_ID) + } catch (_: Exception) { + // best-effort + } + } + + /** + * If Shizuku's binder has been alive at some point this process but isn't + * now (typical USB-unplug → adbd restart scenario), launch the Shizuku + * manager Activity directly. Intended to be called from an Activity's + * onStart/onResume — i.e. when the caller is in the foreground and Android + * 14 Background-Activity-Launch restrictions don't apply. + * + * No-op on first-ever app launch (when the user may have intentionally left + * Shizuku stopped), while Shizuku is alive, and within a cooldown window + * after a previous attempt (so dismissing the Shizuku UI doesn't loop back + * into a re-launch every time our Activity resumes). + * + * Returns true if a launch was attempted. + */ + fun launchShizukuManagerIfLostSinceBoot(): Boolean { + if (!hasBeenAvailable) return false + if (isAvailable()) return false + if (consecutiveQuickDeaths >= MAX_CONSECUTIVE_QUICK_DEATHS) { + Log.w(TAG, "Auto-launch suppressed: $consecutiveQuickDeaths quick deaths") + postGiveUpNotification() + return false + } + val now = SystemClock.elapsedRealtime() + if (now - lastAutoLaunchAtMs < AUTO_LAUNCH_COOLDOWN_MS) return false + lastAutoLaunchAtMs = now + return launchShizukuManager() + } + + /** + * Unconditional direct launch of the Shizuku manager Activity. Caller is + * responsible for being in the foreground so the OS allows the start. + * Returns true iff startActivity was invoked without throwing. + */ + fun launchShizukuManager(): Boolean { + val ctx = appContext ?: run { + Log.w(TAG, "launchShizukuManager: appContext not set — skipping") + return false + } + val intent = ctx.packageManager.getLaunchIntentForPackage(SHIZUKU_PACKAGE) + if (intent == null) { + Log.w(TAG, "launchShizukuManager: no launch intent for $SHIZUKU_PACKAGE") + return false + } + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + return try { + ctx.startActivity(intent) + Log.i(TAG, "launchShizukuManager: startActivity dispatched") + true + } catch (e: Exception) { + Log.w(TAG, "launchShizukuManager: startActivity threw", e) + false + } + } + + private fun ensureRestartChannel(ctx: Context) { + val mgr = ctx.getSystemService(NotificationManager::class.java) ?: return + if (mgr.getNotificationChannel(RESTART_CHANNEL_ID) != null) return + val channel = NotificationChannel( + RESTART_CHANNEL_ID, + "Shizuku restart", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Alerts when Shizuku stops so you can restart it with one tap." + setShowBadge(true) + } + mgr.createNotificationChannel(channel) + } + + /** + * Posts a persistent notification when we've given up on auto-launching + * Shizuku after repeated quick deaths. The usual cause is Android + * disabling WADB on screen-off WiFi sleep (see [consecutiveQuickDeaths] + * and the `AdbDebuggingManager: Network disconnected` log line); the user + * can recover by plugging USB back in or keeping the screen awake. + */ + private fun postGiveUpNotification() { + val ctx = appContext ?: return + if (!NotificationManagerCompat.from(ctx).areNotificationsEnabled()) return + ensureGiveUpChannel(ctx) + val shizukuIntent = ctx.packageManager.getLaunchIntentForPackage(SHIZUKU_PACKAGE) ?: return + shizukuIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + val pending = PendingIntent.getActivity( + ctx, + GIVE_UP_NOTIFICATION_ID, + shizukuIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + val notification = NotificationCompat.Builder(ctx, GIVE_UP_CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_notify_error) + .setContentTitle("Shizuku keeps disconnecting") + .setContentText("Plug USB back in, or keep the screen on to hold the wireless ADB connection.") + .setStyle(NotificationCompat.BigTextStyle().bigText( + "Shizuku restarted $consecutiveQuickDeaths times and keeps dying within seconds. " + + "Android disables wireless debugging when WiFi sleeps, which kills Shizuku. " + + "Plug USB back in, or keep the screen on until mirroring stabilizes." + )) + .setContentIntent(pending) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .build() + try { + NotificationManagerCompat.from(ctx).notify(GIVE_UP_NOTIFICATION_ID, notification) + } catch (e: SecurityException) { + Log.w(TAG, "postGiveUpNotification: notify threw", e) + } + } + + private fun cancelGiveUpNotification() { + val ctx = appContext ?: return + try { + NotificationManagerCompat.from(ctx).cancel(GIVE_UP_NOTIFICATION_ID) + } catch (_: Exception) { + } + } + + private fun ensureGiveUpChannel(ctx: Context) { + val mgr = ctx.getSystemService(NotificationManager::class.java) ?: return + if (mgr.getNotificationChannel(GIVE_UP_CHANNEL_ID) != null) return + val channel = NotificationChannel( + GIVE_UP_CHANNEL_ID, + "Shizuku unrecoverable", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Surfaces when Shizuku keeps dying and auto-recovery can't help." + setShowBadge(true) + } + mgr.createNotificationChannel(channel) + } + fun attachPrivilegedService(service: IPrivilegedService?) { privilegedService = service _serviceConnected.value = service != null @@ -436,23 +763,34 @@ class ShizukuSetup { val innerScript = buildInnerScript(shizukuApk, libDir) val outerScript = buildOuterScript() - // STAGE: write to .new paths (old processes still running untouched) + // Heredocs cannot be used inside ShellDiag.buildScript steps: the wrapper + // appends `; } 2>&1` to the same line as the step body, so a heredoc close + // marker is never on a line by itself and the heredoc stays open, eating + // the STEP_END marker. We transport script bodies via base64 single-line + // decode instead — the base64 alphabet is quote-safe and newline-free. + val innerB64 = Base64.encodeToString(innerScript.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) + val outerB64 = Base64.encodeToString(outerScript.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) + + // STAGE: write to .new paths (old processes still running untouched). + // preflight_base64 round-trips a known token so a missing/broken `base64 -d` + // surfaces as a clearly-named stage failure rather than silent truncation. val stageSteps = listOf( + "preflight_base64" to "[ \"\$(printf '%s' 'Y2FzdGxh' | base64 -d)\" = 'castla' ]", "ensure_libdir" to "mkdir -p $libDir", "extract_librish" to "unzip -o $shizukuApk lib/$abi/librish.so -d /data/local/tmp/shizuku_lib_tmp && " + "cp /data/local/tmp/shizuku_lib_tmp/lib/$abi/librish.so $libDir/ && " + "rm -rf /data/local/tmp/shizuku_lib_tmp", - "write_inner" to "cat > ${INNER_SCRIPT}.new <<'__CASTLA_INNER_EOF__'\n$innerScript\n__CASTLA_INNER_EOF__", - "write_outer" to "cat > ${OUTER_SCRIPT}.new <<'__CASTLA_OUTER_EOF__'\n$outerScript\n__CASTLA_OUTER_EOF__", + "write_inner" to "printf '%s' '$innerB64' | base64 -d > ${INNER_SCRIPT}.new", + "write_outer" to "printf '%s' '$outerB64' | base64 -d > ${OUTER_SCRIPT}.new", "chmod_inner" to "chmod 755 ${INNER_SCRIPT}.new", "chmod_outer" to "chmod 755 ${OUTER_SCRIPT}.new" ) val stageOut = service.execCommand(ShellDiag.buildScript(stageSteps)) ?: "" val stageResults = ShellDiag.parse(stageOut) - val stageFailed = stageResults.any { it.rc != 0 } + val stageFailed = stageResults.any { it.rc != 0 } || stageResults.size != stageSteps.size if (stageFailed) { - Log.e(TAG, "installWatchdog stage failed: ${stageResults.joinToString(",") { "${it.name}=${it.rc}" }}") + Log.e(TAG, "installWatchdog stage failed: ${summarizeStepFailure(stageResults)}") return false } @@ -505,7 +843,7 @@ class ShizukuSetup { val swapSummary = swapResults.joinToString(",") { "${it.name}=${it.rc}" } val swapFailed = swapResults.any { it.rc != 0 } || swapResults.size != swapSteps.size if (swapFailed) { - Log.e(TAG, "installWatchdog swap failed: $swapSummary") + Log.e(TAG, "installWatchdog swap failed: ${summarizeStepFailure(swapResults)}") return false } Log.i(TAG, "installWatchdog swap ok: $swapSummary") @@ -526,6 +864,22 @@ class ShizukuSetup { } } + /** + * Format a failed batch of ShellDiag steps for logcat: always list all name=rc + * pairs, and append the first failing step's captured output (truncated to 200 + * chars, newlines collapsed) so device-specific failures are directly diagnosable. + */ + private fun summarizeStepFailure(results: List): String { + val summary = results.joinToString(",") { "${it.name}=${it.rc}" } + val firstFailure = results.firstOrNull { it.rc != 0 } ?: return summary + val detail = firstFailure.output + .replace("\r\n", " ") + .replace('\n', ' ') + .replace('\r', ' ') + .take(200) + return "$summary step=${firstFailure.name} output=$detail" + } + private fun buildInnerScript(shizukuApk: String, libDir: String): String = """ #!/bin/sh trap '' HUP TERM INT QUIT @@ -598,5 +952,6 @@ done Shizuku.removeBinderReceivedListener(binderReceivedListener) Shizuku.removeBinderDeadListener(binderDeadListener) Shizuku.removeRequestPermissionResultListener(permissionResultListener) + releaseWifiLock() } } From 5bd03f1086bebacb3851d6830e454389e427fd0f Mon Sep 17 00:00:00 2001 From: Suprhimp Date: Fri, 24 Apr 2026 12:12:46 +0900 Subject: [PATCH 3/5] Advise on Samsung USB config that kills Shizuku on screen lock Samsung's Default USB Configuration = File Transfer (MTP/PTP) causes the OS to restart USB on every screen lock, killing Shizuku. Detect the misconfig at startup and prompt the user to switch to Charging only, with a shortcut to Developer Options. Respects a don't-show-again flag. Co-Authored-By: Claude Opus 4.7 --- .../java/com/castla/mirror/MainActivity.kt | 157 ++++++++++++++++++ .../com/castla/mirror/shizuku/ShizukuSetup.kt | 132 ++++++++++++--- .../castla/mirror/shizuku/UsbConfigChecker.kt | 51 ++++++ app/src/main/res/values-ko/strings.xml | 8 + app/src/main/res/values/strings.xml | 8 + .../mirror/shizuku/UsbConfigCheckerTest.kt | 120 +++++++++++++ 6 files changed, 456 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/castla/mirror/shizuku/UsbConfigChecker.kt create mode 100644 app/src/test/java/com/castla/mirror/shizuku/UsbConfigCheckerTest.kt diff --git a/app/src/main/java/com/castla/mirror/MainActivity.kt b/app/src/main/java/com/castla/mirror/MainActivity.kt index 06868d1..3ddb930 100644 --- a/app/src/main/java/com/castla/mirror/MainActivity.kt +++ b/app/src/main/java/com/castla/mirror/MainActivity.kt @@ -82,6 +82,8 @@ class MainActivity : AppCompatActivity() { private const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api" private const val SHIZUKU_RELEASES_API = "https://api.github.com/repos/RikkaApps/Shizuku/releases/latest" private const val SHIZUKU_APK_FILENAME = "shizuku.apk" + private const val USB_CONFIG_PREFS = "usb_config_advisory" + private const val KEY_SUPPRESS_USB_CONFIG_WARNING = "suppress_warning" } private var isStreaming by mutableStateOf(false) @@ -97,6 +99,7 @@ class MainActivity : AppCompatActivity() { private var isShizukuServiceConnected by mutableStateOf(false) private var showShizukuPermissionDialog by mutableStateOf(false) private var showHotspotOffDialog by mutableStateOf(false) + private var showUsbConfigWarningDialog by mutableStateOf(false) private var teslaAutoDetectEnabled by mutableStateOf(false) private var hotspotEnabledByApp = false private var isHotspotActive by mutableStateOf(false) @@ -304,6 +307,7 @@ class MainActivity : AppCompatActivity() { launch(kotlinx.coroutines.Dispatchers.IO) { val ok = shizukuSetup.ensureShizukuHardened() Log.i(TAG, "ensureShizukuHardened (MainActivity): $ok") + evaluateUsbConfigAdvisory() } } } @@ -455,6 +459,20 @@ class MainActivity : AppCompatActivity() { } } } + + if (showUsbConfigWarningDialog) { + UsbConfigWarningDialog( + onOpenDevOptions = { + showUsbConfigWarningDialog = false + openDeveloperOptions() + }, + onDismiss = { showUsbConfigWarningDialog = false }, + onDontShowAgain = { + suppressUsbConfigWarning() + showUsbConfigWarningDialog = false + } + ) + } } } @@ -498,6 +516,58 @@ class MainActivity : AppCompatActivity() { } } + /** + * Called on the IO thread once the Shizuku privileged service is connected. + * Reads the persistent USB configuration via privileged getprop and, on + * Samsung devices where MTP/PTP is the default, surfaces the advisory + * dialog so the user can swap to "Charging only" in Developer Options. + * No-op if the user previously dismissed via "Don't show again". + */ + private fun evaluateUsbConfigAdvisory() { + if (isUsbConfigWarningSuppressed()) return + val advisory = try { + shizukuSetup.classifyUsbConfig(Build.MANUFACTURER ?: "") + } catch (e: Exception) { + Log.w(TAG, "classifyUsbConfig threw", e) + return + } + Log.i(TAG, "USB config advisory: $advisory") + if (advisory == com.castla.mirror.shizuku.UsbConfigChecker.Advisory.RiskyUsbConfig) { + runOnUiThread { showUsbConfigWarningDialog = true } + } + } + + private fun isUsbConfigWarningSuppressed(): Boolean = + getSharedPreferences(USB_CONFIG_PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_SUPPRESS_USB_CONFIG_WARNING, false) + + private fun suppressUsbConfigWarning() { + getSharedPreferences(USB_CONFIG_PREFS, Context.MODE_PRIVATE) + .edit() + .putBoolean(KEY_SUPPRESS_USB_CONFIG_WARNING, true) + .apply() + } + + private fun openDeveloperOptions() { + val intent = Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + startActivity(intent) + } catch (_: Exception) { + try { + startActivity(Intent(Settings.ACTION_DEVICE_INFO_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } catch (e: Exception) { + Log.w(TAG, "openDeveloperOptions: no matching Settings activity", e) + Toast.makeText( + this, + getString(R.string.toast_dev_options_unavailable), + Toast.LENGTH_LONG + ).show() + } + } + } + override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) if (!awaitingProjectionResult) { @@ -1745,3 +1815,90 @@ fun InfoRow(label: String, value: String) { ) } } + +@Composable +private fun UsbConfigWarningDialog( + onOpenDevOptions: () -> Unit, + onDismiss: () -> Unit, + onDontShowAgain: () -> Unit, +) { + androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(Color(0xFF1A1A2E)) + .border(1.dp, Color.White.copy(alpha = 0.15f), RoundedCornerShape(24.dp)) + .padding(24.dp) + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(id = R.string.dialog_usb_config_title), + style = MaterialTheme.typography.titleLarge, + color = Color.White, + fontWeight = FontWeight.ExtraBold, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(id = R.string.dialog_usb_config_message), + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.75f), + textAlign = TextAlign.Start + ) + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = onOpenDevOptions, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50)) + ) { + Text( + text = stringResource(id = R.string.dialog_usb_config_open_dev_options), + color = Color.White, + fontWeight = FontWeight.Bold + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(14.dp), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) + ) { + Text( + text = stringResource(id = R.string.dialog_usb_config_dismiss), + color = Color.White, + fontWeight = FontWeight.Bold + ) + } + OutlinedButton( + onClick = onDontShowAgain, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(14.dp), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) + ) { + Text( + text = stringResource(id = R.string.dialog_usb_config_dont_show), + color = Color.White.copy(alpha = 0.75f), + fontWeight = FontWeight.Bold + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/castla/mirror/shizuku/ShizukuSetup.kt b/app/src/main/java/com/castla/mirror/shizuku/ShizukuSetup.kt index 303d581..4b63316 100644 --- a/app/src/main/java/com/castla/mirror/shizuku/ShizukuSetup.kt +++ b/app/src/main/java/com/castla/mirror/shizuku/ShizukuSetup.kt @@ -34,11 +34,17 @@ private const val LEGACY_SCRIPT = "/data/local/tmp/shizuku_watchdog.sh" private const val LIB_DIR_BASE = "/data/local/tmp/shizuku_lib" private const val HEARTBEAT_MAX_AGE_SECONDS = 15 private const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api" -private const val RESTART_CHANNEL_ID = "shizuku_restart" +// Channel IDs include a version suffix so IMPORTANCE changes in code take +// effect on upgrade — a pre-existing channel's importance sticks at whatever +// the user set (or the original code created), even after the app replaces +// its declaration. Bumping the ID side-steps that. +private const val RESTART_CHANNEL_ID = "shizuku_restart_v2" private const val RESTART_NOTIFICATION_ID = 0x5A12 private const val RESTART_PENDING_INTENT_REQUEST = 0x5A12 -private const val GIVE_UP_CHANNEL_ID = "shizuku_unrecoverable" +private const val GIVE_UP_CHANNEL_ID = "shizuku_unrecoverable_v2" private const val GIVE_UP_NOTIFICATION_ID = 0x5A13 +private const val LEGACY_RESTART_CHANNEL_ID = "shizuku_restart" +private const val LEGACY_GIVE_UP_CHANNEL_ID = "shizuku_unrecoverable" sealed class ShizukuState { object NotInstalled : ShizukuState() @@ -64,6 +70,16 @@ class ShizukuSetup { */ private const val QUICK_DEATH_WINDOW_MS = 15_000L private const val MAX_CONSECUTIVE_QUICK_DEATHS = 3 + /** + * Delay before posting the "Shizuku stopped" notification after + * [binderDeadListener] fires. Samsung's USB unplug triggers an adbd + * restart that briefly kills the binder even when wireless ADB is + * available and Shizuku manager is about to respawn shizuku_server — + * posting instantly makes every unplug vibrate. If the binder comes + * back within this window, [cancelPendingRestartNotification] skips + * the post entirely. + */ + private const val RESTART_NOTIFICATION_DEBOUNCE_MS = 5_000L } private val _state = MutableStateFlow(ShizukuState.NotInstalled) @@ -137,6 +153,15 @@ class ShizukuSetup { */ private var wifiLock: WifiManager.WifiLock? = null + /** + * Pending (debounced) post of the "Shizuku stopped" notification. Armed + * in [binderDeadListener] and cancelled in [binderReceivedListener] so a + * transient adbd restart that recovers inside [RESTART_NOTIFICATION_DEBOUNCE_MS] + * never alerts the user. + */ + @Volatile + private var pendingRestartNotification: Runnable? = null + private val serviceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { privilegedService = IPrivilegedService.Stub.asInterface(binder) @@ -162,6 +187,7 @@ class ShizukuSetup { lastAutoLaunchAtMs = 0L lastBinderReceivedAtMs = SystemClock.elapsedRealtime() updateState() + cancelPendingRestartNotification() cancelRestartNotification() cancelGiveUpNotification() // Auto-bind PrivilegedService whenever Shizuku becomes available with permission, @@ -191,13 +217,16 @@ class ShizukuSetup { // Shell-UID watchdog can't recover from adbd restarts (USB unplug on Samsung // triggers an adbd respawn that cleans out the entire shell UID — both // shizuku_server and the watchdog die together). Our app UID survives, so - // we post a high-priority notification whose PendingIntent launches the - // Shizuku manager Activity; tapping it triggers shizuku_server restart via - // saved wireless-ADB authorization. We do NOT call startActivity directly + // we post a silent notification whose PendingIntent launches the Shizuku + // manager Activity; tapping it triggers shizuku_server restart via saved + // wireless-ADB authorization. We do NOT call startActivity directly // because when USB is unplugged the screen is typically off (TOP_SLEEPING), // which Android 14 refuses with BAL_BLOCK regardless of our foreground // state. The notification is visible on lockscreen and survives screen-off. - requestShizukuRestart() + // + // Posting is debounced by RESTART_NOTIFICATION_DEBOUNCE_MS so a transient + // adbd respawn that recovers inside the window makes no sound/vibration. + scheduleRestartNotification() } private val permissionResultListener = Shizuku.OnRequestPermissionResultListener { requestCode, grantResult -> @@ -214,6 +243,7 @@ class ShizukuSetup { fun init(context: Context, bindService: Boolean = true) { appContext = context.applicationContext acquireWifiLock() + deleteLegacyNotificationChannels() Shizuku.addBinderReceivedListenerSticky(binderReceivedListener) Shizuku.addBinderDeadListener(binderDeadListener) Shizuku.addRequestPermissionResultListener(permissionResultListener) @@ -224,6 +254,16 @@ class ShizukuSetup { } } + private fun deleteLegacyNotificationChannels() { + val ctx = appContext ?: return + val mgr = ctx.getSystemService(NotificationManager::class.java) ?: return + try { + mgr.deleteNotificationChannel(LEGACY_RESTART_CHANNEL_ID) + mgr.deleteNotificationChannel(LEGACY_GIVE_UP_CHANNEL_ID) + } catch (_: Exception) { + } + } + private fun acquireWifiLock() { if (wifiLock?.isHeld == true) return val ctx = appContext ?: return @@ -249,16 +289,41 @@ class ShizukuSetup { } /** - * Posts a high-priority notification whose tap action launches the Shizuku - * manager Activity. Used from [binderDeadListener] to recover from adbd-restart - * cascades that kill shizuku_server + the shell-UID watchdog together (USB - * unplug on Samsung). Direct startActivity is unreliable here because the - * screen is typically off (TOP_SLEEPING) → Android 14 BAL_BLOCK. + * Arm a debounced post of the restart notification. If the binder comes + * back within [RESTART_NOTIFICATION_DEBOUNCE_MS] the pending runnable is + * cancelled by [binderReceivedListener] and no notification is shown — + * so Samsung's USB-unplug adbd-restart-then-recover case stays silent. + */ + private fun scheduleRestartNotification() { + cancelPendingRestartNotification() + val runnable = Runnable { + pendingRestartNotification = null + requestShizukuRestart() + } + pendingRestartNotification = runnable + mainHandler.postDelayed(runnable, RESTART_NOTIFICATION_DEBOUNCE_MS) + } + + private fun cancelPendingRestartNotification() { + pendingRestartNotification?.let { mainHandler.removeCallbacks(it) } + pendingRestartNotification = null + } + + /** + * Posts a silent notification whose tap action launches the Shizuku + * manager Activity. Used from [binderDeadListener] (via [scheduleRestartNotification]) + * to recover from adbd-restart cascades that kill shizuku_server + the + * shell-UID watchdog together (USB unplug on Samsung). Direct startActivity + * is unreliable here because the screen is typically off (TOP_SLEEPING) + * → Android 14 BAL_BLOCK. + * + * The notification is silent (IMPORTANCE_LOW): visible in the tray and on + * lockscreen, but no sound / no vibration / no heads-up — Shizuku dies + * often enough on Samsung that a high-priority alert is spammy. * - * The notification is visible on lockscreen and persists until the user taps - * it or binder is re-received (see [cancelRestartNotification]). Tapping - * routes to Shizuku manager which uses its saved wireless ADB authorization - * to bring shizuku_server back up. + * Persists until the user taps it or binder is re-received (see + * [cancelRestartNotification]). Tapping routes to Shizuku manager which + * uses its saved wireless ADB authorization to bring shizuku_server back up. */ fun requestShizukuRestart() { val ctx = appContext ?: run { @@ -288,9 +353,11 @@ class ShizukuSetup { .setContentText("Tap to restart — mirroring paused until Shizuku is running.") .setContentIntent(pending) .setAutoCancel(true) - .setPriority(NotificationCompat.PRIORITY_HIGH) + .setPriority(NotificationCompat.PRIORITY_LOW) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setOnlyAlertOnce(true) + .setSilent(true) .build() try { NotificationManagerCompat.from(ctx) @@ -371,10 +438,12 @@ class ShizukuSetup { val channel = NotificationChannel( RESTART_CHANNEL_ID, "Shizuku restart", - NotificationManager.IMPORTANCE_HIGH + NotificationManager.IMPORTANCE_LOW ).apply { - description = "Alerts when Shizuku stops so you can restart it with one tap." + description = "Shown silently in the tray when Shizuku stops and needs a tap to restart." setShowBadge(true) + enableVibration(false) + setSound(null, null) } mgr.createNotificationChannel(channel) } @@ -409,9 +478,10 @@ class ShizukuSetup { )) .setContentIntent(pending) .setOngoing(true) - .setPriority(NotificationCompat.PRIORITY_HIGH) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setOnlyAlertOnce(true) .build() try { NotificationManagerCompat.from(ctx).notify(GIVE_UP_NOTIFICATION_ID, notification) @@ -434,10 +504,11 @@ class ShizukuSetup { val channel = NotificationChannel( GIVE_UP_CHANNEL_ID, "Shizuku unrecoverable", - NotificationManager.IMPORTANCE_HIGH + NotificationManager.IMPORTANCE_DEFAULT ).apply { description = "Surfaces when Shizuku keeps dying and auto-recovery can't help." setShowBadge(true) + enableVibration(false) } mgr.createNotificationChannel(channel) } @@ -550,6 +621,26 @@ class ShizukuSetup { } } + /** + * Classify the device's persistent USB configuration for the Samsung + * OneUI "Default USB Configuration" regression (see [UsbConfigChecker]). + * Requires the privileged service to be connected — returns + * [UsbConfigChecker.Advisory.Unknown] otherwise, which callers should + * interpret as "don't warn, can't tell". + * + * Reads `persist.sys.usb.config` first (the user-configured default) + * and falls back to `sys.usb.config` if the persisted prop is empty. + */ + fun classifyUsbConfig(manufacturer: String): UsbConfigChecker.Advisory { + val persisted = exec("getprop persist.sys.usb.config")?.trim().orEmpty() + val cfg = if (persisted.isNotEmpty()) { + persisted + } else { + exec("getprop sys.usb.config")?.trim().orEmpty() + } + return UsbConfigChecker.classify(manufacturer, cfg) + } + /** * Add IP alias via INetd binder (most reliable method with ADB-level access). */ @@ -938,6 +1029,7 @@ done """.trimIndent() fun release() { + cancelPendingRestartNotification() if (userServiceBound) { try { runOnMainSync { diff --git a/app/src/main/java/com/castla/mirror/shizuku/UsbConfigChecker.kt b/app/src/main/java/com/castla/mirror/shizuku/UsbConfigChecker.kt new file mode 100644 index 0000000..482cccc --- /dev/null +++ b/app/src/main/java/com/castla/mirror/shizuku/UsbConfigChecker.kt @@ -0,0 +1,51 @@ +package com.castla.mirror.shizuku + +/** + * Pure classifier for the persistent USB function configuration (aka Android + * Developer Options → "Default USB Configuration"). + * + * Background: on Samsung OneUI 6.1.1+ (incl. OneUI 8 / Android 16) the OS + * calls `UsbDeviceManager.trySetEnabledFunctions(..., forceRestart=true)` on + * every screen lock and unlock to swap `mScreenUnlockedFunctions` — a + * forceRestart=true there tears adbd down. Every shell-UID process dies with + * it (shizuku_server + our watchdog), so Shizuku dies on each lock cycle. + * + * The toggle only happens when the base function is something other than + * "charging" or "none". "File Transfer" (mtp) and "PTP" (ptp) are the common + * defaults and both trigger the regression. "Charging only" and "Debugging + * only" ("none" / "sec_charging") are stable across screen-lock events. + * + * So the fix we surface to the user is: set Default USB Configuration to + * "Charging only" or "No data transfer" in Developer Options. This classifier + * lets us detect whether the user is in the risky state. + */ +object UsbConfigChecker { + enum class Advisory { + /** Non-Samsung device — the regression does not apply. */ + NotApplicable, + /** Samsung device, config string unreadable or empty — caller should not warn. */ + Unknown, + /** Samsung device, config safe (no mtp/ptp in the function list). */ + Safe, + /** Samsung device, mtp or ptp present in the function list — warn user. */ + RiskyUsbConfig, + } + + /** + * @param manufacturer [android.os.Build.MANUFACTURER], e.g. "samsung". + * @param persistedUsbConfig value of `persist.sys.usb.config` (or + * `sys.usb.config` as a fallback). Typical shapes: "mtp,adb", + * "sec_charging,adb", "ptp,adb", "none". + */ + fun classify(manufacturer: String?, persistedUsbConfig: String?): Advisory { + if (manufacturer.isNullOrBlank() || !manufacturer.equals("samsung", ignoreCase = true)) { + return Advisory.NotApplicable + } + val cfg = persistedUsbConfig?.trim().orEmpty() + if (cfg.isEmpty()) return Advisory.Unknown + val functions = cfg.lowercase().split(',').map { it.trim() }.filter { it.isNotEmpty() } + if (functions.isEmpty()) return Advisory.Unknown + val risky = functions.any { it == "mtp" || it == "ptp" } + return if (risky) Advisory.RiskyUsbConfig else Advisory.Safe + } +} diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 9d7c914..a4a427e 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -87,6 +87,14 @@ 화면 켜기 유지 + + 화면 잠금 시 Shizuku가 꺼질 수 있어요 + 기본 USB 구성이 파일 전송(MTP/PTP)으로 설정되어 있습니다. 최신 Samsung 소프트웨어는 화면을 잠글 때마다 USB를 재시작하며, 이 때문에 Shizuku가 종료됩니다.\n\n안정적인 연결을 위해 개발자 옵션에서 기본 USB 구성을 \"충전만\" 또는 \"데이터 전송 안 함\"으로 변경하세요. + 개발자 옵션 열기 + 나중에 + 다시 보지 않기 + 개발자 옵션을 열 수 없습니다. + 언어 시스템 기본값 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3bf99a..019b429 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -83,6 +83,14 @@ Screen On ⚠️ Device is very hot. Mirroring quality may be reduced. + + Shizuku may drop on screen lock + Your Default USB Configuration is set to File Transfer (MTP/PTP). On recent Samsung software this causes Android to restart USB on every screen lock, which kills Shizuku.\n\nOpen Developer Options and change Default USB Configuration to \"Charging only\" or \"No data transfer\" for a stable connection. + Open Developer Options + Later + Don\'t show again + Could not open Developer Options. + Language System Default diff --git a/app/src/test/java/com/castla/mirror/shizuku/UsbConfigCheckerTest.kt b/app/src/test/java/com/castla/mirror/shizuku/UsbConfigCheckerTest.kt new file mode 100644 index 0000000..be4197c --- /dev/null +++ b/app/src/test/java/com/castla/mirror/shizuku/UsbConfigCheckerTest.kt @@ -0,0 +1,120 @@ +package com.castla.mirror.shizuku + +import org.junit.Assert.assertEquals +import org.junit.Test + +class UsbConfigCheckerTest { + + @Test + fun `non-samsung manufacturer returns NotApplicable`() { + assertEquals( + UsbConfigChecker.Advisory.NotApplicable, + UsbConfigChecker.classify("Google", "mtp,adb") + ) + } + + @Test + fun `null manufacturer returns NotApplicable`() { + assertEquals( + UsbConfigChecker.Advisory.NotApplicable, + UsbConfigChecker.classify(null, "mtp,adb") + ) + } + + @Test + fun `blank manufacturer returns NotApplicable`() { + assertEquals( + UsbConfigChecker.Advisory.NotApplicable, + UsbConfigChecker.classify(" ", "mtp,adb") + ) + } + + @Test + fun `samsung with mtp returns RiskyUsbConfig`() { + assertEquals( + UsbConfigChecker.Advisory.RiskyUsbConfig, + UsbConfigChecker.classify("samsung", "mtp,adb") + ) + } + + @Test + fun `samsung with ptp returns RiskyUsbConfig`() { + assertEquals( + UsbConfigChecker.Advisory.RiskyUsbConfig, + UsbConfigChecker.classify("samsung", "ptp,adb") + ) + } + + @Test + fun `case-insensitive samsung match`() { + assertEquals( + UsbConfigChecker.Advisory.RiskyUsbConfig, + UsbConfigChecker.classify("SAMSUNG", "MTP,adb") + ) + } + + @Test + fun `samsung with sec_charging returns Safe`() { + assertEquals( + UsbConfigChecker.Advisory.Safe, + UsbConfigChecker.classify("samsung", "sec_charging,adb") + ) + } + + @Test + fun `samsung with none returns Safe`() { + assertEquals( + UsbConfigChecker.Advisory.Safe, + UsbConfigChecker.classify("samsung", "none") + ) + } + + @Test + fun `samsung with adb-only returns Safe`() { + assertEquals( + UsbConfigChecker.Advisory.Safe, + UsbConfigChecker.classify("samsung", "adb") + ) + } + + @Test + fun `samsung with null config returns Unknown`() { + assertEquals( + UsbConfigChecker.Advisory.Unknown, + UsbConfigChecker.classify("samsung", null) + ) + } + + @Test + fun `samsung with blank config returns Unknown`() { + assertEquals( + UsbConfigChecker.Advisory.Unknown, + UsbConfigChecker.classify("samsung", " ") + ) + } + + @Test + fun `samsung with empty config returns Unknown`() { + assertEquals( + UsbConfigChecker.Advisory.Unknown, + UsbConfigChecker.classify("samsung", "") + ) + } + + @Test + fun `substring mtpX does not trigger warning`() { + // "midi" alone shouldn't be treated as mtp; only an exact token match. + assertEquals( + UsbConfigChecker.Advisory.Safe, + UsbConfigChecker.classify("samsung", "midi,adb") + ) + } + + @Test + fun `whitespace around tokens is tolerated`() { + assertEquals( + UsbConfigChecker.Advisory.RiskyUsbConfig, + UsbConfigChecker.classify("samsung", " mtp , adb ") + ) + } +} From e0b53297c42225133d460eb73fd09110edd51229 Mon Sep 17 00:00:00 2001 From: Suprhimp Date: Fri, 24 Apr 2026 12:12:59 +0900 Subject: [PATCH 4/5] Mirror phone keyboard visibility strictly in the input bubble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-VD mode: rely on the global IME state (mInputShown / mShowRequested) alone so the bubble only appears when the phone keyboard actually shows. The dumpsys window imeInputTarget fallback is reserved for true Samsung dual-VD where cross-display focus mismatch makes the global state lie. Add a 200ms hide watchdog while the bubble is visible so it disappears as soon as the keyboard dismisses, and a browser→server bubbleClosed signal that suppresses the hasTarget fallback until the user re-engages, so a user-dismissed bubble doesn't immediately re-open. Also move the split ratio controls into the overlay menu panel instead of the separate floating toolbar/tap-zone. Co-Authored-By: Claude Opus 4.7 --- app/src/main/assets/web/index.html | 54 ++---- app/src/main/assets/web/js/main.js | 73 ++++---- .../com/castla/mirror/server/ControlSocket.kt | 3 + .../com/castla/mirror/server/MirrorServer.kt | 11 ++ .../mirror/service/MirrorForegroundService.kt | 163 +++++++++++++++--- .../castla/mirror/utils/ImeTargetParser.kt | 20 +++ .../mirror/utils/ImeTargetParserTest.kt | 57 ++++++ 7 files changed, 274 insertions(+), 107 deletions(-) create mode 100644 app/src/main/java/com/castla/mirror/utils/ImeTargetParser.kt create mode 100644 app/src/test/java/com/castla/mirror/utils/ImeTargetParserTest.kt diff --git a/app/src/main/assets/web/index.html b/app/src/main/assets/web/index.html index 62ce329..5529067 100644 --- a/app/src/main/assets/web/index.html +++ b/app/src/main/assets/web/index.html @@ -546,27 +546,7 @@ background: rgba(255,255,255,0.35); box-shadow: 0 0 0 1px rgba(0,0,0,0.35); } - #split-toolbar-tap-zone { - display: none; - position: absolute; - top: 0; - left: 30%; - width: 40%; - height: 48px; - z-index: 230; - cursor: pointer; - pointer-events: none; - } - #player-shell.browser-split #split-toolbar-tap-zone, - #player-shell.freeform-split #split-toolbar-tap-zone { - display: block; - } #split-pane-toolbar { - position: absolute; - top: 12px; - left: 50%; - transform: translateX(-50%); - z-index: 231; display: none; gap: 5px; align-items: center; @@ -574,17 +554,7 @@ backdrop-filter: blur(10px); border-radius: 999px; padding: 4px 6px; - opacity: 0; - transition: opacity 0.25s ease; - pointer-events: none; - } - #split-pane-toolbar.visible { - opacity: 1; - pointer-events: auto; - } - #player-shell.browser-split #split-pane-toolbar, - #player-shell.freeform-split #split-pane-toolbar { - display: flex; + flex-shrink: 0; } .split-pane-btn { border: none; @@ -687,16 +657,6 @@ -
-
- - - - - -
- -
Connecting...
@@ -730,7 +690,7 @@ -
diff --git a/app/src/main/assets/web/js/main.js b/app/src/main/assets/web/js/main.js index c77d519..8b088c2 100644 --- a/app/src/main/assets/web/js/main.js +++ b/app/src/main/assets/web/js/main.js @@ -312,6 +312,11 @@ document.addEventListener('DOMContentLoaded', async () => { return { primaryWidth, secondaryWidth, shellWidth, shellHeight }; } + function updateSplitToolbarVisibility() { + if (!splitToolbar) return; + splitToolbar.style.display = browserSplitState.active ? 'flex' : 'none'; + } + function setBrowserSplitRatio(nextRatio) { const ratio = Math.max(0.25, Math.min(0.75, nextRatio)); browserSplitState.ratio = ratio; @@ -437,6 +442,7 @@ document.addEventListener('DOMContentLoaded', async () => { // Single canvas shows both apps — add freeform-split class for close button playerShell?.classList.add('freeform-split'); + updateSplitToolbarVisibility(); // Send split app launch request to server if (controlSocket && controlSocket.readyState === WebSocket.OPEN) { const message = { @@ -469,6 +475,7 @@ document.addEventListener('DOMContentLoaded', async () => { b.classList.toggle('active', Math.abs(btnRatio - initialRatio) < 0.05); }); playerShell?.classList.add('browser-split'); + updateSplitToolbarVisibility(); applyActiveFitModes(); await new Promise((resolve) => requestAnimationFrame(() => resolve())); lockBrowserSplitViewports(app); @@ -489,6 +496,7 @@ document.addEventListener('DOMContentLoaded', async () => { const { notifyServer = true } = options; const wasActive = browserSplitState.active; browserSplitState.active = false; + updateSplitToolbarVisibility(); browserSplitState.resizing = false; browserSplitState.app = null; browserSplitState.url = null; @@ -682,6 +690,13 @@ document.addEventListener('DOMContentLoaded', async () => { bubbleCancel.addEventListener('click', (e) => { e.preventDefault(); closeInputBubble(true); + // Tell the server so it can suppress the hasTarget fallback until the + // user re-engages (touch-down on mirror). Otherwise on platforms where + // the phone IME never actually shows, the bubble would re-open on the + // next poll because imeInputTarget persists. + if (controlSocket && controlSocket.readyState === WebSocket.OPEN) { + controlSocket.send(JSON.stringify({ type: 'bubbleClosed' })); + } }); } if (bubbleText) { @@ -1133,11 +1148,22 @@ document.addEventListener('DOMContentLoaded', async () => { : getEffectivePrimaryFitMode(); console.log(`[Main] Server resolution changed pane=${pane} server=${msg.width}x${msg.height} fitMode=${fitMode} locked=${describeViewport(lockedViewport)} split=${browserSplitState.active}`); } else if (msg.type === 'showKeyboard') { + console.log('[IME] showKeyboard received pane=', msg.pane, 'useBubble=', useBubbleInput, 'bubbleEl=', !!inputBubble, 'bubbleVisible=', bubbleVisible); if (useBubbleInput) { - const anchor = secondaryTouchHandler?.lastTap || touchHandler?.lastTap || null; - openInputBubble(anchor); + const pane = msg.pane || 'primary'; + const anchor = pane === 'secondary' + ? (secondaryTouchHandler?.lastTap || null) + : (touchHandler?.lastTap || null); + console.log('[IME] anchor=', anchor); + // Reposition if already visible (user switched pane). + if (bubbleVisible) { + positionInputBubble(anchor); + } else { + openInputBubble(anchor); + } } else focusKeyboardProxy(); } else if (msg.type === 'hideKeyboard') { + console.log('[IME] hideKeyboard received'); if (useBubbleInput) closeInputBubble(true); else blurKeyboardProxy(); } else if (msg.type === 'thermalStatus') { @@ -1433,45 +1459,9 @@ document.addEventListener('DOMContentLoaded', async () => { }, {passive: true}); } - // Split toolbar: show on tap, auto-hide after 3s + // Split toolbar lives inside #overlay-menu-panel; visibility is driven by + // browserSplitState.active so it appears only when a split is actually open. const splitToolbar = document.getElementById('split-pane-toolbar'); - let splitToolbarTimer = null; - function showSplitToolbar() { - if (!splitToolbar || !browserSplitState.active) return; - splitToolbar.classList.add('visible'); - clearTimeout(splitToolbarTimer); - splitToolbarTimer = setTimeout(() => { - splitToolbar.classList.remove('visible'); - }, 3000); - } - function hideSplitToolbar() { - splitToolbar?.classList.remove('visible'); - clearTimeout(splitToolbarTimer); - } - // Show toolbar on double-tap in the top zone — touch passes through to app - let lastToolbarTapTime = 0; - let lastToolbarTapY = 0; - const TOOLBAR_TAP_ZONE_HEIGHT = 48; - playerShell?.addEventListener('pointerup', (e) => { - if (!browserSplitState.active) return; - if (e.target.closest('#split-pane-toolbar')) return; - // Only respond to taps in the top zone - const rect = playerShell.getBoundingClientRect(); - const relY = e.clientY - rect.top; - if (relY > TOOLBAR_TAP_ZONE_HEIGHT) return; - const now = Date.now(); - if (now - lastToolbarTapTime < 400 && Math.abs(relY - lastToolbarTapY) < 30) { - if (splitToolbar?.classList.contains('visible')) { - hideSplitToolbar(); - } else { - showSplitToolbar(); - } - lastToolbarTapTime = 0; - } else { - lastToolbarTapTime = now; - lastToolbarTapY = relY; - } - }); // Split ratio buttons document.querySelectorAll('.split-ratio-btn').forEach(btn => { @@ -1483,13 +1473,11 @@ document.addEventListener('DOMContentLoaded', async () => { setBrowserSplitRatio(ratio); lockBrowserSplitViewports(browserSplitState.app); requestAnimationFrame(() => sendViewportSize()); - showSplitToolbar(); // reset auto-hide timer }); }); if (splitCloseBtn) { splitCloseBtn.addEventListener('click', () => { - hideSplitToolbar(); disableBrowserSplit(); }); } @@ -1848,4 +1836,5 @@ document.addEventListener('DOMContentLoaded', async () => { profileObserver.observe(homeBtn, { attributes: true, attributeFilter: ['style'] }); } updateOverlayControlsVisibility(); + updateSplitToolbarVisibility(); }); diff --git a/app/src/main/java/com/castla/mirror/server/ControlSocket.kt b/app/src/main/java/com/castla/mirror/server/ControlSocket.kt index 5199c3e..bd30a24 100644 --- a/app/src/main/java/com/castla/mirror/server/ControlSocket.kt +++ b/app/src/main/java/com/castla/mirror/server/ControlSocket.kt @@ -118,6 +118,9 @@ class ControlSocket( backlogDrops = json.optInt("backlogDrops", 0) ) } + "bubbleClosed" -> { + server.onBubbleClosed() + } } } catch (e: Exception) { Log.w(TAG, "Failed to parse control message", e) diff --git a/app/src/main/java/com/castla/mirror/server/MirrorServer.kt b/app/src/main/java/com/castla/mirror/server/MirrorServer.kt index 163e326..aa0fa60 100644 --- a/app/src/main/java/com/castla/mirror/server/MirrorServer.kt +++ b/app/src/main/java/com/castla/mirror/server/MirrorServer.kt @@ -42,6 +42,7 @@ class MirrorServer(private val context: Context) : NanoWSD(DEFAULT_PORT) { private var onCloseSplitListener: (() -> Unit)? = null private var onDisplayDensityListener: ((Float) -> Unit)? = null private var onQualityReportListener: ((Int, Double, Int) -> Unit)? = null + private var onBubbleClosedListener: (() -> Unit)? = null // Track active connection status private var isBrowserConnected = false @@ -120,6 +121,10 @@ class MirrorServer(private val context: Context) : NanoWSD(DEFAULT_PORT) { onQualityReportListener = listener } + fun setBubbleClosedListener(listener: () -> Unit) { + onBubbleClosedListener = listener + } + fun isBrowserConnected(): Boolean = isBrowserConnected @@ -294,6 +299,8 @@ class MirrorServer(private val context: Context) : NanoWSD(DEFAULT_PORT) { deadSockets.forEach { unregisterAudioSocket(it) } } + fun controlSocketCount(): Int = controlSockets.size + fun broadcastControlMessage(json: String) { // Cache thermal status so new control sockets receive it immediately if (json.contains("\"thermalStatus\"")) { @@ -359,6 +366,10 @@ class MirrorServer(private val context: Context) : NanoWSD(DEFAULT_PORT) { onQualityReportListener?.invoke(droppedFrames, avgDelayMs, backlogDrops) } + fun onBubbleClosed() { + onBubbleClosedListener?.invoke() + } + override fun openWebSocket(handshake: IHTTPSession): WebSocket { val uri = handshake.uri 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 2d538fb..22c01fd 100644 --- a/app/src/main/java/com/castla/mirror/service/MirrorForegroundService.kt +++ b/app/src/main/java/com/castla/mirror/service/MirrorForegroundService.kt @@ -39,6 +39,7 @@ import com.castla.mirror.shizuku.IPrivilegedService import com.castla.mirror.shizuku.ShizukuSetup import com.castla.mirror.ott.BrowserResolver import com.castla.mirror.ott.OttCatalog +import com.castla.mirror.utils.ImeTargetParser import com.castla.mirror.utils.LaunchMode import com.castla.mirror.policy.AutoScaleDecision import com.castla.mirror.policy.AutoScaleInput @@ -56,6 +57,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -1020,6 +1022,10 @@ class MirrorForegroundService : Service() { } else { touchInjector?.onTouchEvent(event) } + if (event.action == "down") { + // User re-engaging — clear any lingering manual-close suppression. + bubbleClosedByUser = false + } if (event.action == "up") { lastTouchPane = event.pane checkImeAndNotifyBrowser() @@ -1041,6 +1047,14 @@ class MirrorForegroundService : Service() { server.setTextInputListener { text -> injectText(text) } server.setKeyEventListener { keyCode -> injectKeyEvent(keyCode) } server.setCompositionUpdateListener { bs, text -> injectCompositionUpdate(bs, text) } + server.setBubbleClosedListener { + Log.d(TAG, "Browser reported bubbleClosed — resetting IME state") + bubbleClosedByUser = true + lastImeState = false + lastBroadcastPane = null + haveSeenRealImeShow = false + cancelImeHideWatchdog() + } server.setAudioCodecListener { codec -> onAudioCodecRequest(codec) } server.setAudioSocketConnectedListener { audioOrchestrator?.onAudioSocketConnected() } server.setGoHomeListener { @@ -2585,6 +2599,14 @@ class MirrorForegroundService : Service() { Log.i(TAG, "Browser disconnected — suspending pipeline") pendingBrowserDisconnectJob = null browserConnected = false + // Reset IME broadcast state so next reconnect re-emits showKeyboard + // if a text field is still focused — the browser's bubble state + // was cleared by the reload. + lastImeState = false + lastBroadcastPane = null + haveSeenRealImeShow = false + bubbleClosedByUser = false + cancelImeHideWatchdog() dismissSplitPresentation(clearState = false) if (!singleVdSplit) { releaseSecondaryPipeline(clearState = false) @@ -2634,8 +2656,21 @@ class MirrorForegroundService : Service() { private var lastTouchPane = "primary" private var lastImeState = false + private var lastBroadcastPane: String? = null private var lastImeCheckTime = 0L private var imeCheckSuspendUntil = 0L + // Once a real phone IME show (`mInputShown=true` or equivalent) is observed, + // stop trusting the `imeInputTarget` fallback — that signal persists after + // the phone keyboard dismisses, which would otherwise keep the bubble stuck + // open in single-VD mode. Reset whenever we broadcast hide or pane changes. + private var haveSeenRealImeShow = false + // User explicitly dismissed the bubble in the browser. Suppress hasTarget-based + // re-shows until the next touch-down (user intent to re-engage). + private var bubbleClosedByUser = false + // Polls IME visibility at a short interval while the bubble is shown so the + // hide broadcast fires quickly when the user dismisses the phone keyboard + // without tapping the mirror. Auto-exits when lastImeState goes false. + private var imeHideWatchdogJob: Job? = null private fun parseImeVisible(dumpsys: String): Boolean { if (dumpsys.contains("mInputShown=true")) return true @@ -2656,43 +2691,125 @@ class MirrorForegroundService : Service() { return false } + // One IME-visibility poll iteration. Runs dumpsys, updates state, and + // broadcasts show/hide when it changes. Returns the combined-visible + // value, or null on a transport error. Shared by the touch-triggered + // retry loop (show detection) and the hide watchdog (fast dismiss). + private suspend fun imeCheckTick(activePane: String, activeDisplayId: Int, source: String): Boolean? { + val service = virtualDisplayManager?.getPrivilegedService() ?: return null + + val result = try { + service.execCommand("dumpsys input_method | grep -E 'mInputShown|mImeWindowVis|mDecorViewVisible|mWindowVisible|mServedView|mShowRequested|mCurClient'") + } catch (e: android.os.DeadObjectException) { + imeCheckSuspendUntil = System.currentTimeMillis() + 10_000 + return null + } ?: return null + + var imeVisible = parseImeVisible(result) + if (!imeVisible && activeDisplayId > 0) { + imeVisible = parseHasServedInput(result) + } + if (imeVisible) { + haveSeenRealImeShow = true + } + + // Samsung dual-VD only: cross-display focus mismatch makes the global + // IME state lie, so we fall back to per-display imeInputTarget. In + // single-VD (including single-VD split) the global state is truthful, + // and this fallback would fire on mere focus without a keyboard. + val isDualVdMode = secondaryDisplayId >= 0 && !singleVdSplit + var hasTargetOnActive = false + if (isDualVdMode && !imeVisible && activeDisplayId > 0) { + val winOut = try { + service.execCommand("dumpsys window | grep 'imeInputTarget in display'") + } catch (e: android.os.DeadObjectException) { + imeCheckSuspendUntil = System.currentTimeMillis() + 10_000 + return null + } ?: return null + hasTargetOnActive = ImeTargetParser + .displaysWithInputTarget(winOut) + .contains(activeDisplayId) + } + + val useHasTarget = hasTargetOnActive && !haveSeenRealImeShow && !bubbleClosedByUser + val combinedVisible = imeVisible || useHasTarget + Log.d(TAG, "IME $source: pane=$activePane display=$activeDisplayId imeVisible=$imeVisible hasTarget=$hasTargetOnActive seenReal=$haveSeenRealImeShow closedByUser=$bubbleClosedByUser lastState=$lastImeState lastPane=$lastBroadcastPane") + + val stateChanged = combinedVisible != lastImeState + val paneChangedWhileVisible = combinedVisible && lastImeState && + lastBroadcastPane != null && lastBroadcastPane != activePane + if (stateChanged || paneChangedWhileVisible) { + lastImeState = combinedVisible + lastBroadcastPane = if (combinedVisible) activePane else null + if (!combinedVisible || paneChangedWhileVisible) { + haveSeenRealImeShow = false + } + val msg = if (combinedVisible) + """{"type":"showKeyboard","pane":"$activePane"}""" + else + """{"type":"hideKeyboard"}""" + Log.d(TAG, "IME broadcast: $msg (sockets=${mirrorServer?.controlSocketCount()})") + mirrorServer?.broadcastControlMessage(msg) + } + return combinedVisible + } + + private fun startImeHideWatchdog() { + if (imeHideWatchdogJob?.isActive == true) return + imeHideWatchdogJob = serviceScope.launch(Dispatchers.IO) { + try { + while (isActive && lastImeState) { + kotlinx.coroutines.delay(200) + if (!lastImeState) break + val activePane = lastTouchPane + val activeDisplayId = if (activePane == "secondary" && secondaryDisplayId >= 0) { + secondaryDisplayId + } else { + virtualDisplayManager?.getDisplayId() ?: -1 + } + val combined = imeCheckTick(activePane, activeDisplayId, "watchdog") ?: continue + if (!combined) break + } + } catch (e: Exception) { + Log.w(TAG, "IME hide watchdog error", e) + } finally { + imeHideWatchdogJob = null + } + } + } + + private fun cancelImeHideWatchdog() { + imeHideWatchdogJob?.cancel() + imeHideWatchdogJob = null + } + private fun checkImeAndNotifyBrowser() { val now = System.currentTimeMillis() if (now - lastImeCheckTime < 500) return if (now < imeCheckSuspendUntil) return lastImeCheckTime = now + val activePane = lastTouchPane + val activeDisplayId = if (activePane == "secondary" && secondaryDisplayId >= 0) { + secondaryDisplayId + } else { + virtualDisplayManager?.getDisplayId() ?: -1 + } + serviceScope.launch(Dispatchers.IO) { try { val maxRetries = 4 val retryDelays = longArrayOf(300, 400, 500, 600) for (attempt in 0 until maxRetries) { kotlinx.coroutines.delay(retryDelays[attempt]) - val displayId = virtualDisplayManager?.getDisplayId() ?: -1 - val service = virtualDisplayManager?.getPrivilegedService() - if (service == null) return@launch - - val result = try { - service.execCommand("dumpsys input_method | grep -E 'mInputShown|mImeWindowVis|mDecorViewVisible|mWindowVisible|mServedView|mShowRequested|mCurClient'") - } catch (e: android.os.DeadObjectException) { - imeCheckSuspendUntil = System.currentTimeMillis() + 10_000 - return@launch - } - if (result == null) return@launch - - var imeVisible = parseImeVisible(result) - if (!imeVisible && displayId > 0) { - imeVisible = parseHasServedInput(result) - } - - if (imeVisible != lastImeState) { - lastImeState = imeVisible - val msg = if (imeVisible) """{"type":"showKeyboard"}""" else """{"type":"hideKeyboard"}""" - mirrorServer?.broadcastControlMessage(msg) + imeCheckTick(activePane, activeDisplayId, "check") ?: return@launch + // As soon as the bubble is showing, let the hide watchdog + // take over at its tighter cadence. + if (lastImeState) { + startImeHideWatchdog() break } - // IME state unchanged — if already showing or last attempt, stop retrying - if (lastImeState || attempt == maxRetries - 1) break + if (attempt == maxRetries - 1) break } } catch (e: Exception) { imeCheckSuspendUntil = System.currentTimeMillis() + 10_000 diff --git a/app/src/main/java/com/castla/mirror/utils/ImeTargetParser.kt b/app/src/main/java/com/castla/mirror/utils/ImeTargetParser.kt new file mode 100644 index 0000000..d6ba9e4 --- /dev/null +++ b/app/src/main/java/com/castla/mirror/utils/ImeTargetParser.kt @@ -0,0 +1,20 @@ +package com.castla.mirror.utils + +/** + * Parses `imeInputTarget in display# N Window{...}` lines from `dumpsys window` + * output and returns the set of display IDs that currently have a text field + * input target. Used as a Samsung-specific fallback signal when the global IME + * is suppressed due to cross-display focus mismatch, so the global + * `dumpsys input_method` state never reports `mInputShown=true`. + */ +object ImeTargetParser { + private val PATTERN = Regex("""imeInputTarget in display#\s*(\d+)\s+Window\{""") + + fun displaysWithInputTarget(dumpsysWindow: String): Set { + val out = mutableSetOf() + for (m in PATTERN.findAll(dumpsysWindow)) { + m.groupValues[1].toIntOrNull()?.let { out.add(it) } + } + return out + } +} diff --git a/app/src/test/java/com/castla/mirror/utils/ImeTargetParserTest.kt b/app/src/test/java/com/castla/mirror/utils/ImeTargetParserTest.kt new file mode 100644 index 0000000..9c34e7b --- /dev/null +++ b/app/src/test/java/com/castla/mirror/utils/ImeTargetParserTest.kt @@ -0,0 +1,57 @@ +package com.castla.mirror.utils + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ImeTargetParserTest { + + @Test + fun `empty input returns empty set`() { + assertEquals(emptySet(), ImeTargetParser.displaysWithInputTarget("")) + } + + @Test + fun `parses real device snippet with two targets`() { + val snippet = """ + imeLayeringTarget in display# 0 Window{74bf179 u0 com.google.android.youtube/com.google.android.youtube.app.honeycomb.Shell${'$'}HomeActivity} + imeInputTarget in display# 0 Window{74bf179 u0 com.google.android.youtube/com.google.android.youtube.app.honeycomb.Shell${'$'}HomeActivity} + imeControlTarget in display# 0 Window{74bf179 u0 com.google.android.youtube/com.google.android.youtube.app.honeycomb.Shell${'$'}HomeActivity} + imeInputTarget in display# 53 Window{7e42531 u0 com.nhn.android.nmap/com.naver.map.LaunchActivity} + imeControlTarget in display# 53 Window{7e42531 u0 com.nhn.android.nmap/com.naver.map.LaunchActivity} + imeControlTarget in display# 54 Window{e011af6 u0 com.castla.mirror/com.castla.mirror.ui.WebBrowserActivity} + """.trimIndent() + + assertEquals(setOf(0, 53), ImeTargetParser.displaysWithInputTarget(snippet)) + } + + @Test + fun `null Window is excluded`() { + val snippet = "imeInputTarget in display# 53 null" + assertEquals(emptySet(), ImeTargetParser.displaysWithInputTarget(snippet)) + } + + @Test + fun `extra whitespace tolerated`() { + val snippet = "imeInputTarget in display# 53 Window{abc u0 pkg/.Act}" + assertEquals(setOf(53), ImeTargetParser.displaysWithInputTarget(snippet)) + } + + @Test + fun `duplicate lines collapse to one entry`() { + val snippet = """ + imeInputTarget in display# 53 Window{a u0 pkg/.A} + imeInputTarget in display# 53 Window{b u0 pkg/.B} + """.trimIndent() + assertEquals(setOf(53), ImeTargetParser.displaysWithInputTarget(snippet)) + } + + @Test + fun `non imeInputTarget lines are excluded`() { + val snippet = """ + imeLayeringTarget in display# 0 Window{74bf179 u0 pkg/.A} + imeControlTarget in display# 0 Window{74bf179 u0 pkg/.A} + """.trimIndent() + assertTrue(ImeTargetParser.displaysWithInputTarget(snippet).isEmpty()) + } +} From e08f82673f2d58c62c1631a438fe51ee7b950ff0 Mon Sep 17 00:00:00 2001 From: Suprhimp Date: Fri, 24 Apr 2026 12:25:00 +0900 Subject: [PATCH 5/5] Recover Shizuku user-service from token mismatch after reinstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After an install/update (including adb install -r with no version bump), shizuku_server holds a cached user-service record pointing at our previous process's binder. Without intervention, the next bindUserService call either never delivers onServiceConnected or connects then immediately disconnects — the symptom users see as "Shizuku not responding until I restart the manager". Detect reinstall by comparing PackageInfo.lastUpdateTime against a saved SharedPreferences value on init, and when they differ call unbindUserService(args, conn, remove=true) before the first bind so the stale record is wiped. Also watch the user-service ServiceConnection: sessions that end within 3s of connecting trigger one automatic force unbind-rebind (capped at two retries) to cover the case where the stale record slipped past detection. Co-Authored-By: Claude Opus 4.7 --- .../shizuku/ShizukuReinstallDetector.kt | 26 +++++ .../com/castla/mirror/shizuku/ShizukuSetup.kt | 106 +++++++++++++++++- .../shizuku/ShizukuReinstallDetectorTest.kt | 45 ++++++++ 3 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/castla/mirror/shizuku/ShizukuReinstallDetector.kt create mode 100644 app/src/test/java/com/castla/mirror/shizuku/ShizukuReinstallDetectorTest.kt diff --git a/app/src/main/java/com/castla/mirror/shizuku/ShizukuReinstallDetector.kt b/app/src/main/java/com/castla/mirror/shizuku/ShizukuReinstallDetector.kt new file mode 100644 index 0000000..1760178 --- /dev/null +++ b/app/src/main/java/com/castla/mirror/shizuku/ShizukuReinstallDetector.kt @@ -0,0 +1,26 @@ +package com.castla.mirror.shizuku + +/** + * Decides whether the app has been (re)installed since the last time we + * recorded `PackageInfo.lastUpdateTime`. On reinstall, Shizuku's cached + * user-service record points at a dead binder from the previous process — + * we must call `unbindUserService(..., remove=true)` before binding again + * or the next bind silently fails (token mismatch symptom). + * + * `lastUpdateTime` updates on every install/update, including `adb install -r` + * with no version bump, which is the common development case. + */ +object ShizukuReinstallDetector { + sealed class Action { + /** No reinstall detected — proceed with a normal bind. */ + object None : Action() + + /** Reinstall or first-ever launch — unbind with remove=true before binding. */ + object ForceRebind : Action() + } + + fun detect(savedLastUpdateTime: Long, currentLastUpdateTime: Long): Action { + if (currentLastUpdateTime <= 0L) return Action.None + return if (savedLastUpdateTime != currentLastUpdateTime) Action.ForceRebind else Action.None + } +} diff --git a/app/src/main/java/com/castla/mirror/shizuku/ShizukuSetup.kt b/app/src/main/java/com/castla/mirror/shizuku/ShizukuSetup.kt index 4b63316..2666add 100644 --- a/app/src/main/java/com/castla/mirror/shizuku/ShizukuSetup.kt +++ b/app/src/main/java/com/castla/mirror/shizuku/ShizukuSetup.kt @@ -7,6 +7,7 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection +import android.content.SharedPreferences import android.content.pm.PackageManager import android.net.wifi.WifiManager import android.os.Handler @@ -80,6 +81,26 @@ class ShizukuSetup { * the post entirely. */ private const val RESTART_NOTIFICATION_DEBOUNCE_MS = 5_000L + + /** + * Any user-service session that terminates inside this window after + * [ServiceConnection.onServiceConnected] is treated as a probable + * Shizuku token-mismatch symptom (e.g. reinstall without version bump + * left a stale service record in shizuku_server). Triggers one + * [forceUnbindAndRebind] attempt before giving up. + */ + private const val USER_SERVICE_QUICK_DEATH_MS = 3_000L + + /** + * Cap on consecutive user-service quick deaths we try to recover from + * via force-unbind-rebind. Past this, stop retrying and leave the + * service disconnected — the Shizuku-manager restart flow covers the + * remaining cases. + */ + private const val USER_SERVICE_MAX_QUICK_DEATHS = 2 + + private const val PREFS_NAME = "shizuku_setup" + private const val PREF_LAST_UPDATE_TIME = "last_update_time" } private val _state = MutableStateFlow(ShizukuState.NotInstalled) @@ -162,21 +183,67 @@ class ShizukuSetup { @Volatile private var pendingRestartNotification: Runnable? = null + /** + * When true, the next [bindPrivilegedService] will first call + * `Shizuku.unbindUserService(args, conn, remove=true)` to wipe the cached + * (possibly stale) service record in shizuku_server. Set by + * [detectReinstall] on first launch after install/update, or by + * [ServiceConnection.onServiceDisconnected] when a session dies inside + * [USER_SERVICE_QUICK_DEATH_MS] of connect. Cleared after the unbind + * runs so subsequent rebinds stay cheap. + */ + @Volatile + private var pendingForceUnbind = false + + /** + * Elapsed-realtime of the most recent [ServiceConnection.onServiceConnected] + * for the user service. Compared against `onServiceDisconnected` time to + * classify the session as a quick death (likely token mismatch) vs. a + * normal binder termination. + */ + @Volatile + private var userServiceConnectedAtMs = 0L + + /** + * Consecutive user-service quick deaths — see [USER_SERVICE_QUICK_DEATH_MS]. + * Reset when a session stays alive past the threshold. + */ + @Volatile + private var userServiceQuickDeaths = 0 + private val serviceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { privilegedService = IPrivilegedService.Stub.asInterface(binder) _serviceConnected.value = true bindingInProgress = false userServiceBound = true + userServiceConnectedAtMs = SystemClock.elapsedRealtime() Log.i(TAG, "Privileged service connected") } override fun onServiceDisconnected(name: ComponentName?) { + val aliveMs = if (userServiceConnectedAtMs == 0L) Long.MAX_VALUE + else SystemClock.elapsedRealtime() - userServiceConnectedAtMs + userServiceConnectedAtMs = 0L privilegedService = null _serviceConnected.value = false bindingInProgress = false userServiceBound = false - Log.i(TAG, "Privileged service disconnected") + Log.i(TAG, "Privileged service disconnected (alive=${aliveMs}ms)") + if (aliveMs < USER_SERVICE_QUICK_DEATH_MS) { + userServiceQuickDeaths += 1 + if (userServiceQuickDeaths <= USER_SERVICE_MAX_QUICK_DEATHS) { + Log.w(TAG, "User-service quick death #$userServiceQuickDeaths — scheduling force rebind") + pendingForceUnbind = true + if (isAvailable() && hasPermission()) { + mainHandler.post { bindPrivilegedService() } + } + } else { + Log.w(TAG, "User-service quick death #$userServiceQuickDeaths — giving up retries") + } + } else { + userServiceQuickDeaths = 0 + } } } @@ -244,6 +311,7 @@ class ShizukuSetup { appContext = context.applicationContext acquireWifiLock() deleteLegacyNotificationChannels() + detectReinstall() Shizuku.addBinderReceivedListenerSticky(binderReceivedListener) Shizuku.addBinderDeadListener(binderDeadListener) Shizuku.addRequestPermissionResultListener(permissionResultListener) @@ -254,6 +322,32 @@ class ShizukuSetup { } } + /** + * On first launch after an install/update (including `adb install -r` with + * no version bump), flags [pendingForceUnbind] so the next + * [bindPrivilegedService] calls `unbindUserService(remove=true)` before + * binding. Without this, Shizuku returns the cached service record from + * the previous process — whose binder is dead — and onServiceConnected + * either never fires or fires then immediately disconnects (token mismatch). + */ + private fun detectReinstall() { + val ctx = appContext ?: return + val prefs: SharedPreferences = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val saved = prefs.getLong(PREF_LAST_UPDATE_TIME, 0L) + val current = try { + ctx.packageManager.getPackageInfo(ctx.packageName, 0).lastUpdateTime + } catch (e: Exception) { + Log.w(TAG, "detectReinstall: getPackageInfo failed", e) + 0L + } + val action = ShizukuReinstallDetector.detect(saved, current) + if (action == ShizukuReinstallDetector.Action.ForceRebind) { + Log.i(TAG, "Reinstall detected (saved=$saved current=$current) — force unbind before first bind") + pendingForceUnbind = true + prefs.edit().putLong(PREF_LAST_UPDATE_TIME, current).apply() + } + } + private fun deleteLegacyNotificationChannels() { val ctx = appContext ?: return val mgr = ctx.getSystemService(NotificationManager::class.java) ?: return @@ -555,6 +649,16 @@ class ShizukuSetup { try { bindingInProgress = true runOnMainSync { + if (pendingForceUnbind) { + try { + Shizuku.unbindUserService(serviceArgs, serviceConnection, true) + Log.i(TAG, "Force-unbound stale user-service record before rebind") + } catch (e: Exception) { + Log.w(TAG, "Force-unbind threw (treated as best-effort)", e) + } + pendingForceUnbind = false + userServiceBound = false + } Shizuku.bindUserService(serviceArgs, serviceConnection) } userServiceBound = true diff --git a/app/src/test/java/com/castla/mirror/shizuku/ShizukuReinstallDetectorTest.kt b/app/src/test/java/com/castla/mirror/shizuku/ShizukuReinstallDetectorTest.kt new file mode 100644 index 0000000..99eaaca --- /dev/null +++ b/app/src/test/java/com/castla/mirror/shizuku/ShizukuReinstallDetectorTest.kt @@ -0,0 +1,45 @@ +package com.castla.mirror.shizuku + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ShizukuReinstallDetectorTest { + + @Test + fun firstLaunch_whenSavedIsZero_returnsForceRebind() { + val action = ShizukuReinstallDetector.detect( + savedLastUpdateTime = 0L, + currentLastUpdateTime = 1_700_000_000_000L + ) + assertEquals(ShizukuReinstallDetector.Action.ForceRebind, action) + } + + @Test + fun reinstall_whenTimestampsDiffer_returnsForceRebind() { + val action = ShizukuReinstallDetector.detect( + savedLastUpdateTime = 1_700_000_000_000L, + currentLastUpdateTime = 1_700_000_500_000L + ) + assertEquals(ShizukuReinstallDetector.Action.ForceRebind, action) + } + + @Test + fun normalRestart_whenTimestampsMatch_returnsNone() { + val action = ShizukuReinstallDetector.detect( + savedLastUpdateTime = 1_700_000_000_000L, + currentLastUpdateTime = 1_700_000_000_000L + ) + assertEquals(ShizukuReinstallDetector.Action.None, action) + } + + @Test + fun zeroCurrent_whenPackageInfoUnavailable_returnsNone() { + // Defensive: if currentLastUpdateTime is 0 (packageManager lookup failed), + // don't spuriously force a rebind on every launch. + val action = ShizukuReinstallDetector.detect( + savedLastUpdateTime = 0L, + currentLastUpdateTime = 0L + ) + assertEquals(ShizukuReinstallDetector.Action.None, action) + } +}