From 298aeff1d56aaccff1ce79f40c6d748c9b81b58e Mon Sep 17 00:00:00 2001 From: Suprhimp Date: Sat, 16 May 2026 09:37:43 +0900 Subject: [PATCH] Fix Shizuku double-bind cascade that orphaned virtual displays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VirtualDisplayManager and ShizukuSetup were both calling Shizuku.bindUserService, and VDM treated every onServiceConnected callback after the first as a binder death — so duplicate connects spawned 6+ orphan VDs per session and apps launched from the web launcher targeted a different display than the one being encoded. Consolidate bind ownership in ShizukuSetup; VDM becomes a passive helper that mirrors the privileged service via attachPrivilegedService. Add BinderConnectionTracker (pure logic, unit-tested) so reconnect is only triggered after a real disconnect event. Co-Authored-By: Claude Opus 4.7 --- .../mirror/capture/VirtualDisplayManager.kt | 181 ++------- .../mirror/service/MirrorForegroundService.kt | 342 ++++++++++++------ .../mirror/shizuku/BinderConnectionTracker.kt | 52 +++ .../com/castla/mirror/shizuku/ShizukuSetup.kt | 6 - .../shizuku/BinderConnectionTrackerTest.kt | 90 +++++ 5 files changed, 415 insertions(+), 256 deletions(-) create mode 100644 app/src/main/java/com/castla/mirror/shizuku/BinderConnectionTracker.kt create mode 100644 app/src/test/java/com/castla/mirror/shizuku/BinderConnectionTrackerTest.kt diff --git a/app/src/main/java/com/castla/mirror/capture/VirtualDisplayManager.kt b/app/src/main/java/com/castla/mirror/capture/VirtualDisplayManager.kt index db77ab0..f4009da 100644 --- a/app/src/main/java/com/castla/mirror/capture/VirtualDisplayManager.kt +++ b/app/src/main/java/com/castla/mirror/capture/VirtualDisplayManager.kt @@ -1,26 +1,23 @@ package com.castla.mirror.capture -import android.content.ComponentName -import android.content.ServiceConnection import android.hardware.display.VirtualDisplay -import android.os.Handler -import android.os.IBinder -import android.os.Looper import android.util.Log import android.view.Surface import com.castla.mirror.diagnostics.DiagnosticEvent import com.castla.mirror.diagnostics.MirrorDiagnostics import com.castla.mirror.shizuku.IPrivilegedService -import com.castla.mirror.shizuku.PrivilegedService -import com.castla.mirror.shizuku.ShizukuSetup -import rikka.shizuku.Shizuku -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit /** - * Creates a virtual display using Shizuku for independent phone + Tesla operation. - * When a virtual display is active, the phone screen operates independently while - * Tesla shows content on the virtual display. + * Owns the per-session virtual display lifecycle and exposes VD-scoped privileged + * operations (input injection, app launch, surface attachment). + * + * Binder ownership: this class no longer binds the Shizuku user-service. The + * `IPrivilegedService` reference is passed in via [attachPrivilegedService] by + * the foreground service, which keeps a single long-lived [com.castla.mirror.shizuku.ShizukuSetup] + * as the sole bind owner. Previously this class held its own ServiceConnection, + * which produced two concurrent binds per session and a cascade of orphan VDs + * when duplicate `onServiceConnected` callbacks were misclassified as binder + * deaths. */ class VirtualDisplayManager { @@ -32,119 +29,29 @@ class VirtualDisplayManager { private var privilegedService: IPrivilegedService? = null @Volatile private var displayId: Int = -1 @Volatile private var isBound = false - private var bindingInProgress = false - private val mainHandler = Handler(Looper.getMainLooper()) - private var serviceConnection: ServiceConnection? = null - private var userServiceArgs: Shizuku.UserServiceArgs? = null - private fun runOnMainSync(block: () -> Unit) { - if (Looper.myLooper() == Looper.getMainLooper()) { - block() - return - } - val latch = CountDownLatch(1) - var error: Throwable? = null - mainHandler.post { - try { - block() - } catch (t: Throwable) { - error = t - } finally { - latch.countDown() - } + /** + * Mirror the latest [IPrivilegedService] reference owned by `ShizukuSetup`. + * Called on first connect and on every reconnect after a binder death. + * Passing `null` invalidates the local VD state — the caller must follow up + * with [createVirtualDisplay] before issuing any VD-scoped operations again. + */ + fun attachPrivilegedService(svc: IPrivilegedService?) { + privilegedService = svc + isBound = svc != null + if (svc == null) { + virtualDisplay = null + displayId = -1 } - latch.await(2, TimeUnit.SECONDS) - error?.let { throw it } } - /** Called when Shizuku service reconnects after a death — caller should recreate VD + launch home */ - var reconnectListener: (() -> Unit)? = null - /** Expose the privileged service for IME checks (avoids separate binder connection) */ fun getPrivilegedService(): IPrivilegedService? = privilegedService /** - * Bind to the Shizuku privileged service. - * Must be called before createVirtualDisplay when using Shizuku mode. - * Returns true if binding was initiated. - */ - fun bindShizukuService(callback: (Boolean) -> Unit): Boolean { - if (isBound && privilegedService != null) { - callback(true) - return true - } - if (bindingInProgress) { - Log.d(TAG, "bindShizukuService skipped: bind already in progress") - return true - } - - return try { - bindingInProgress = true - val args = Shizuku.UserServiceArgs( - ComponentName( - com.castla.mirror.BuildConfig.APPLICATION_ID, - PrivilegedService::class.java.name - ) - ) - .daemon(false) - .processNameSuffix("privileged") - .debuggable(true) - .version(ShizukuSetup.USER_SERVICE_VERSION) - userServiceArgs = args - - var callbackFired = false - val connection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { - privilegedService = IPrivilegedService.Stub.asInterface(binder) - bindingInProgress = false - - try { - privilegedService?.registerDeathToken(android.os.Binder()) - } catch (e: Exception) { - Log.w(TAG, "Failed to register death token", e) - } - - isBound = true - Log.i(TAG, "Shizuku privileged service connected") - MirrorDiagnostics.log(DiagnosticEvent.SHIZUKU_BINDER_READY, "via VirtualDisplayManager") - if (!callbackFired) { - callbackFired = true - callback(true) - } else { - // Reconnect after service death — any previous displayId is now stale because - // PrivilegedService keeps the VirtualDisplay map in-process. - virtualDisplay = null - displayId = -1 - Log.i(TAG, "Shizuku service reconnected (was dead), notifying listener") - reconnectListener?.invoke() - } - } - - override fun onServiceDisconnected(name: ComponentName?) { - privilegedService = null - isBound = false - bindingInProgress = false - displayId = -1 - Log.i(TAG, "Shizuku privileged service disconnected") - MirrorDiagnostics.log(DiagnosticEvent.SHIZUKU_BINDER_DEAD, "via VirtualDisplayManager") - } - } - serviceConnection = connection - - runOnMainSync { Shizuku.bindUserService(args, connection) } - true - } catch (e: Exception) { - bindingInProgress = false - Log.w(TAG, "Failed to bind Shizuku service", e) - callback(false) - false - } - } - - /** - * Create a virtual display via Shizuku's elevated privileges. - * If Shizuku is not available, returns null — caller should fall back - * to MediaProjection's built-in VirtualDisplay. + * Create a virtual display via Shizuku's elevated privileges. Returns null + * because the underlying privileged service tracks the [VirtualDisplay] in + * its own process; locally we only retain the assigned [displayId]. */ fun createVirtualDisplay( width: Int, @@ -189,12 +96,12 @@ class VirtualDisplayManager { null } } - + /** Creates an additional virtual display for dual-screen scenarios */ fun createSecondaryVirtualDisplay(width: Int, height: Int, dpi: Int, surface: Surface): Int { val service = privilegedService if (service == null) return -1 - + return try { val id = service.createVirtualDisplay(width, height, dpi, "Castla_Sec") if (id >= 0) { @@ -205,13 +112,13 @@ class VirtualDisplayManager { -1 } } - + fun releaseSecondaryVirtualDisplay(id: Int) { try { privilegedService?.releaseVirtualDisplay(id) } catch (e: Exception) {} } - + fun launchAppOnSpecificDisplay(targetDisplayId: Int, packageName: String) { try { privilegedService?.launchAppOnDisplay(targetDisplayId, packageName) @@ -264,7 +171,7 @@ class VirtualDisplayManager { } } - /** Returns true if the Shizuku service is bound (even if no VD is active). */ + /** Returns true if the privileged service mirror is set. */ fun isBound(): Boolean = isBound && privilegedService != null /** Display ID of the Shizuku-created virtual display, or -1. */ @@ -325,11 +232,11 @@ class VirtualDisplayManager { Log.i(TAG, "Launched $packageName on virtual display $id") true } catch (e: SecurityException) { - Log.e(TAG, "Display $id is stale (SecurityException), invalidating", e) + Log.e(TAG, "Failed to launch $packageName on display $id (display not found?)", e) displayId = -1 false } catch (e: Exception) { - Log.e(TAG, "Failed to launch $packageName on virtual display $id", e) + Log.e(TAG, "Failed to launch $packageName on display $id", e) false } } @@ -343,17 +250,17 @@ class VirtualDisplayManager { Log.i(TAG, "Launched $packageName with extra on virtual display $id") true } catch (e: SecurityException) { - Log.e(TAG, "Display $id is stale (SecurityException), invalidating", e) + Log.e(TAG, "Failed to launch $packageName with extra on display $id (display not found?)", e) displayId = -1 false } catch (e: Exception) { - Log.e(TAG, "Failed to launch $packageName on virtual display $id", e) + Log.e(TAG, "Failed to launch $packageName on display $id", e) false } } /** - * Release just the virtual display, keeping the Shizuku service bound. + * Release just the virtual display, keeping the privileged service mirror. * Use this when rebuilding the pipeline with new dimensions. */ fun releaseVirtualDisplay() { @@ -371,6 +278,11 @@ class VirtualDisplayManager { displayId = -1 } + /** + * Local-state release. Does NOT unbind the Shizuku user service — that is + * owned by `ShizukuSetup` for the foreground service lifetime. Releases the + * privileged-service-side VD first when one is held. + */ fun release() { val releasedId = displayId if (releasedId >= 0) { @@ -381,21 +293,8 @@ class VirtualDisplayManager { } MirrorDiagnostics.log(DiagnosticEvent.VD_STOPPED, "id=$releasedId (full release)") } - try { - privilegedService?.destroy() - } catch (_: Exception) {} - - val args = userServiceArgs - val conn = serviceConnection - if (args != null && conn != null) { - try { runOnMainSync { Shizuku.unbindUserService(args, conn, true) } } catch (_: Exception) {} - } - privilegedService = null isBound = false - bindingInProgress = false - serviceConnection = null - userServiceArgs = null virtualDisplay?.release() virtualDisplay = null displayId = -1 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 e533c06..e65baef 100644 --- a/app/src/main/java/com/castla/mirror/service/MirrorForegroundService.kt +++ b/app/src/main/java/com/castla/mirror/service/MirrorForegroundService.kt @@ -34,6 +34,7 @@ import com.castla.mirror.capture.VideoEncoder import com.castla.mirror.capture.VirtualDisplayManager import com.castla.mirror.input.TouchInjector import com.castla.mirror.server.MirrorServer +import com.castla.mirror.shizuku.BinderConnectionTracker import com.castla.mirror.shizuku.IPrivilegedService import com.castla.mirror.shizuku.ShizukuSetup import com.castla.mirror.ott.BrowserResolver @@ -63,9 +64,11 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.isActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull import org.json.JSONObject class MirrorForegroundService : Service() { @@ -205,6 +208,20 @@ class MirrorForegroundService : Service() { @Volatile private var shizukuSetupInProgress = false private var shizukuBindRetryCount = 0 private val SHIZUKU_MAX_RETRIES = 2 + private val BIND_WAIT_BUDGET_MS = 8_000L + + /** + * Singleton coroutine that observes [ShizukuSetup.serviceConnected] for the + * service lifetime. Translates flow emissions into discrete transitions via + * [BinderConnectionTracker] so duplicate connects (a frequent symptom on + * Samsung when the user-service is briefly killed and respawned) do not + * spawn a new VirtualDisplay per spurious callback. + * + * Created lazily inside [ensureShizukuSetup]; cancelled in [performCleanup] + * BEFORE [ShizukuSetup.release] so a queued reconnect dispatch can never + * race against listener teardown. + */ + private var reconnectJob: Job? = null // Auto mode: dynamically adjusts resolution/fps based on conditions. // When true, the service starts conservatively (720p/30fps) and steps up @@ -826,6 +843,11 @@ class MirrorForegroundService : Service() { try { removeAllVdTasks() } catch (e: Exception) { Log.w(TAG, "Failed to remove VD tasks", e) } try { pendingBrowserDisconnectJob?.cancel() } catch (_: Exception) {} pendingBrowserDisconnectJob = null + // Cancel reconnect collector BEFORE releasing ShizukuSetup so an + // in-flight Reconnect dispatch can never re-enter after listeners are + // unregistered and the binder is unbound. + try { reconnectJob?.cancel() } catch (_: Exception) {} + reconnectJob = null try { virtualDisplayManager?.release() } catch (e: Exception) { Log.w(TAG, "Failed to release virtual display manager", e) } try { shizukuSetup?.release() } catch (e: Exception) { Log.w(TAG, "Failed to release shizuku setup", e) } try { screenCapture?.release() } catch (e: Exception) { Log.w(TAG, "Failed to release screen capture", e) } @@ -1714,6 +1736,20 @@ class MirrorForegroundService : Service() { clearSplitState() val previousApp = currentVdApp + if (displayId < 0) { + Log.w(TAG, "External browser launch refused: invalid displayId=$displayId for $url") + if (allowFallback) { + launchFullscreenWebTarget("com.castla.mirror.ui.WebBrowserActivity", displayId, url) + activeSession = ActiveLaunchSession( + mode = SessionMode.INTERNAL_WEBVIEW, + launchTarget = internalComponentName("com.castla.mirror.ui.WebBrowserActivity"), + url = url, + sourceAppPackage = sourceAppPackage + ) + } + return + } + val browser = BrowserResolver.resolve(this, url) if (browser != null) { val command = buildExternalBrowserCommand(displayId, url, browser.componentFlat, freeform = false) @@ -1780,6 +1816,19 @@ class MirrorForegroundService : Service() { * Falls back to internal WebBrowserActivity if no browser is found or launch fails. */ private fun launchSplitExternalBrowserTarget(displayId: Int, url: String, sourceAppPackage: String? = null, allowFallback: Boolean = true) { + if (displayId < 0) { + Log.w(TAG, "Split external browser launch refused: invalid displayId=$displayId for $url") + if (allowFallback) { + launchSplitWebTarget("com.castla.mirror.ui.WebBrowserActivity", displayId, url) + activeSplitSession = ActiveLaunchSession( + mode = SessionMode.INTERNAL_WEBVIEW, + launchTarget = internalComponentName("com.castla.mirror.ui.WebBrowserActivity"), + url = url, + sourceAppPackage = sourceAppPackage + ) + } + return + } if (!ensureSplitViable("split-external-browser")) { Log.w(TAG, "Split external browser launch rejected; falling back to fullscreen") launchExternalBrowserTarget(displayId, url, sourceAppPackage, allowFallback) @@ -2402,12 +2451,102 @@ class MirrorForegroundService : Service() { } } + /** + * Returns the long-lived [ShizukuSetup], creating it lazily on first + * browser-connect activation. Registers Shizuku binder listeners and + * acquires the WiFi lock exactly once for the foreground service lifetime, + * preventing the listener/wifi-lock pile-up that fed the prior bind cascade. + * + * Single-instance contract: subsequent calls reuse the existing instance. + * A reconnect-observation collector is started exactly once — guarded + * inside [startReconnectObserver]. + */ + private fun ensureShizukuSetup(): ShizukuSetup? { + shizukuSetup?.let { + Log.i(TAG, "ensureShizukuSetup: reusing existing instance") + return it + } + val setup = ShizukuSetup() + setup.init(this, bindService = true) + Log.i(TAG, "ensureShizukuSetup: created new instance lazily on browser-connect path") + shizukuSetup = setup + startReconnectObserver(setup) + return setup + } + + /** + * Launch the singleton [reconnectJob]. No-op if already started. + * + * The collector classifies each [ShizukuSetup.serviceConnected] emission + * via [BinderConnectionTracker]: + * - FirstConnect / Idempotent → no-op (initial VD creation is owned + * exclusively by [trySetupVirtualDisplay]; duplicate connects from + * listener pile-ups are inert). + * - Disconnect → detach VDM (mark VD stale), preserve surface for the + * upcoming reconnect. + * - Reconnect → recreate the VD on the same encoder surface and restore + * the previously-launched app via [restoreCurrentVdContent]. + */ + private fun startReconnectObserver(setup: ShizukuSetup) { + if (reconnectJob != null) return + reconnectJob = serviceScope.launch { + val tracker = BinderConnectionTracker() + setup.serviceConnected.collect { connected -> + val transition = if (connected) tracker.onConnected() else tracker.onDisconnected() + Log.i(TAG, "Shizuku connection transition=$transition connected=$connected") + when (transition) { + BinderConnectionTracker.Transition.FirstConnect, + BinderConnectionTracker.Transition.Idempotent -> { /* trySetupVirtualDisplay owns first-connect; idempotent ignored */ } + BinderConnectionTracker.Transition.Disconnect -> handleShizukuDisconnect() + BinderConnectionTracker.Transition.Reconnect -> handleShizukuReconnect(setup) + } + } + } + } + + private fun handleShizukuDisconnect() { + val vdm = virtualDisplayManager ?: return + vdm.attachPrivilegedService(null) + } + + private fun handleShizukuReconnect(setup: ShizukuSetup) { + if (!browserConnected) { + Log.w(TAG, "Ignoring stale Shizuku reconnect after browser disconnect") + return + } + val vdm = virtualDisplayManager ?: return + val surf = currentEncoderSurface ?: return + if (currentWidth <= 0 || currentHeight <= 0) { + Log.w(TAG, "Reconnect skipped: invalid dims ${currentWidth}x${currentHeight}") + return + } + val svc = setup.privilegedService ?: return + vdm.attachPrivilegedService(svc) + vdm.createVirtualDisplay(currentWidth, currentHeight, computeVirtualDisplayDpi(currentWidth, currentHeight), surf) + if (vdm.hasVirtualDisplay()) { + touchInjector?.setVirtualDisplayInjector { action, x, y, pointerId -> + vdm.injectInput(action, x, y, pointerId) + } + restoreCurrentVdContent() + } + } + private fun trySetupVirtualDisplay( width: Int, height: Int, surface: android.view.Surface, onResult: (Boolean) -> Unit ) { + // Atomic guard: rebuildPipeline races (e.g. concurrent codec-change + + // browser-reconnect on a closed-flip → wifi-drop recovery) used to + // launch trySetupVirtualDisplay twice, each creating its own VDM and + // VD. The shizukuSetupInProgress guard in rebuildPipeline catches most + // cases but not all — gate at the entry as well. + if (shizukuSetupInProgress) { + Log.i(TAG, "trySetupVirtualDisplay skipped: setup already in progress") + onResult(false) + return + } shizukuSetupInProgress = true var resultDelivered = false val safeResult = { success: Boolean -> @@ -2415,125 +2554,106 @@ class MirrorForegroundService : Service() { resultDelivered = true shizukuSetupInProgress = false if (!success) { - releaseShizukuSession("virtual_display_setup_failed") + tearDownVdSession("virtual_display_setup_failed") } onResult(success) } } - try { - releaseShizukuSession("before_virtual_display_setup") - val setup = ShizukuSetup() - setup.init(this, bindService = false) - - if (!setup.isAvailable() || !setup.hasPermission()) { - Log.i(TAG, "Shizuku not available/permitted") - setup.release() - safeResult(false) - return - } + val setup = ensureShizukuSetup() ?: run { + Log.w(TAG, "trySetupVirtualDisplay: ensureShizukuSetup returned null") + safeResult(false) + return + } + if (!setup.isAvailable() || !setup.hasPermission()) { + Log.i(TAG, "Shizuku not available/permitted") + safeResult(false) + return + } - shizukuSetup = setup - val vdm = VirtualDisplayManager() - virtualDisplayManager = vdm + // bindPrivilegedService is idempotent; safe even if already bound. + setup.bindPrivilegedService() - vdm.reconnectListener = reconnect@ { - if (!browserConnected) { - Log.w(TAG, "Ignoring stale Shizuku reconnect after browser disconnect") - return@reconnect - } - val surf = currentEncoderSurface - if (surf != null) { - vdm.createVirtualDisplay(currentWidth, currentHeight, 160, surf) - if (vdm.hasVirtualDisplay()) { - // Refresh the binder so audio/text/input paths use the new connection - setup.attachPrivilegedService(vdm.getPrivilegedService()) - touchInjector?.setVirtualDisplayInjector { action, x, y, pointerId -> - vdm.injectInput(action, x, y, pointerId) + serviceScope.launch { + val connected = withTimeoutOrNull(BIND_WAIT_BUDGET_MS) { + setup.serviceConnected.first { it } + } != null + + if (!connected) { + shizukuBindRetryCount++ + FileLogger.w(TAG, "trySetupVirtualDisplay: serviceConnected timeout (attempt $shizukuBindRetryCount/$SHIZUKU_MAX_RETRIES)") + if (shizukuBindRetryCount <= SHIZUKU_MAX_RETRIES) { + Log.w(TAG, "Shizuku binding timed out (attempt $shizukuBindRetryCount/$SHIZUKU_MAX_RETRIES) — retrying") + shizukuSetupInProgress = false + tearDownVdSession("binding_timeout") + kotlinx.coroutines.delay(2_000) + if (browserConnected) { + val surf = currentEncoderSurface + if (surf != null) { + Log.i(TAG, "Retrying Shizuku setup after timeout (attempt ${shizukuBindRetryCount + 1})") + trySetupVirtualDisplay(currentWidth, currentHeight, surf, onResult) + return@launch } - restoreCurrentVdContent() } + // Browser/surface gone before we could retry — final failure via + // single-delivery guard, otherwise the caller's MediaProjection + // fallback could race with a stale resultDelivered=false state. + safeResult(false) + } else { + Log.e(TAG, "Shizuku binding failed after $SHIZUKU_MAX_RETRIES retries — Shizuku server may need restart") + safeResult(false) } + return@launch } - vdm.bindShizukuService { bound -> - try { - if (!browserConnected) { - Log.w(TAG, "Ignoring stale virtual display bind callback after browser disconnect") - safeResult(false) - return@bindShizukuService - } - if (bound) { - shizukuBindRetryCount = 0 - // Enable freeform windowing support for split mode - try { - vdm.getPrivilegedService()?.let { svc -> - svc.execCommand("settings put global enable_freeform_support 1") - svc.execCommand("settings put global force_resizable_activities 1") - Log.i(TAG, "Enabled freeform windowing support") - } - } catch (e: Exception) { - Log.w(TAG, "Failed to enable freeform support (non-fatal)", e) - } - // Use latest dimensions/surface in case viewport changed during async bind - val actualWidth = if (currentWidth > 0) currentWidth else width - val actualHeight = if (currentHeight > 0) currentHeight else height - val actualSurface = currentEncoderSurface ?: surface - val actualDpi = computeVirtualDisplayDpi(actualWidth, actualHeight) - vdm.createVirtualDisplay(actualWidth, actualHeight, actualDpi, actualSurface) - if (vdm.hasVirtualDisplay()) { - setup.attachPrivilegedService(vdm.getPrivilegedService()) - touchInjector?.setVirtualDisplayInjector { action, x, y, pointerId -> - vdm.injectInput(action, x, y, pointerId) - } - // Harden Shizuku (fortify + install watchdog if needed) for WiFi-off survival - serviceScope.launch(kotlinx.coroutines.Dispatchers.IO) { - val ok = setup.ensureShizukuHardened() - Log.i(TAG, "ensureShizukuHardened (service): $ok") - } - safeResult(true) - } else { - safeResult(false) - } - } else { - safeResult(false) - } - } catch (e: Exception) { - safeResult(false) - } + shizukuBindRetryCount = 0 + + // Late-event guard: browser may have disconnected while we awaited. + if (!browserConnected) { + Log.w(TAG, "trySetupVirtualDisplay: browser disconnected during bind wait — abort") + safeResult(false) + return@launch } - // Timeout: if binding callback never fires (e.g. service process killed by Shizuku), - // reset flags and retry up to SHIZUKU_MAX_RETRIES times. - mainHandler.postDelayed({ - if (!resultDelivered) { - shizukuBindRetryCount++ - if (shizukuBindRetryCount <= SHIZUKU_MAX_RETRIES) { - Log.w(TAG, "Shizuku binding timed out (attempt $shizukuBindRetryCount/$SHIZUKU_MAX_RETRIES) — retrying") - resultDelivered = true - shizukuSetupInProgress = false - releaseShizukuSession("binding_timeout") - mainHandler.postDelayed({ - if (browserConnected) { - val surf = currentEncoderSurface - if (surf != null) { - Log.i(TAG, "Retrying Shizuku setup after timeout (attempt ${shizukuBindRetryCount + 1})") - trySetupVirtualDisplay(currentWidth, currentHeight, surf, onResult) - } else { - onResult(false) - } - } else { - onResult(false) - } - }, 2_000) - } else { - Log.e(TAG, "Shizuku binding failed after $SHIZUKU_MAX_RETRIES retries — Shizuku server may need restart") - safeResult(false) - } + val svc = setup.privilegedService + if (svc == null) { + Log.w(TAG, "trySetupVirtualDisplay: serviceConnected=true but privilegedService null") + safeResult(false) + return@launch + } + + // Enable freeform windowing support for split mode + try { + svc.execCommand("settings put global enable_freeform_support 1") + svc.execCommand("settings put global force_resizable_activities 1") + Log.i(TAG, "Enabled freeform windowing support") + } catch (e: Exception) { + Log.w(TAG, "Failed to enable freeform support (non-fatal)", e) + } + + val vdm = VirtualDisplayManager().also { virtualDisplayManager = it } + vdm.attachPrivilegedService(svc) + + // Use latest dimensions/surface in case viewport changed during async wait + val actualWidth = if (currentWidth > 0) currentWidth else width + val actualHeight = if (currentHeight > 0) currentHeight else height + val actualSurface = currentEncoderSurface ?: surface + val actualDpi = computeVirtualDisplayDpi(actualWidth, actualHeight) + vdm.createVirtualDisplay(actualWidth, actualHeight, actualDpi, actualSurface) + + if (vdm.hasVirtualDisplay()) { + touchInjector?.setVirtualDisplayInjector { action, x, y, pointerId -> + vdm.injectInput(action, x, y, pointerId) } - }, 8_000) - } catch (e: Exception) { - safeResult(false) + // Harden Shizuku (fortify + install watchdog if needed) for WiFi-off survival + serviceScope.launch(kotlinx.coroutines.Dispatchers.IO) { + val ok = setup.ensureShizukuHardened() + Log.i(TAG, "ensureShizukuHardened (service): $ok") + } + safeResult(true) + } else { + safeResult(false) + } } } @@ -2639,13 +2759,17 @@ class MirrorForegroundService : Service() { } } - private fun releaseShizukuSession(reason: String) { - Log.i(TAG, "Releasing Shizuku session: $reason") + /** + * Tear down the per-session VD/encoder wiring. Does NOT release + * [shizukuSetup] or its listeners — that lives for the foreground service + * lifetime so the singleton bind contract holds across browser + * disconnect/reconnect and pipeline rebuilds. + */ + private fun tearDownVdSession(reason: String) { + Log.i(TAG, "Tearing down VD session: $reason") try { touchInjector?.setVirtualDisplayInjector(null) } catch (_: Exception) {} try { virtualDisplayManager?.release() } catch (e: Exception) { Log.w(TAG, "Failed to release virtual display manager", e) } - try { shizukuSetup?.release() } catch (e: Exception) { Log.w(TAG, "Failed to release shizuku setup", e) } virtualDisplayManager = null - shizukuSetup = null } private fun ensureAudioCaptureState(codecOverride: String? = null) { @@ -2751,7 +2875,7 @@ class MirrorForegroundService : Service() { releaseSecondaryPipeline(clearState = false) } try { removeAllVdTasks() } catch (e: Exception) { Log.w(TAG, "Failed to remove VD tasks on disconnect", e) } - releaseShizukuSession("browser_disconnected") + tearDownVdSession("browser_disconnected") screenCapture?.stopCapture() videoEncoder?.release() diff --git a/app/src/main/java/com/castla/mirror/shizuku/BinderConnectionTracker.kt b/app/src/main/java/com/castla/mirror/shizuku/BinderConnectionTracker.kt new file mode 100644 index 0000000..7209fa8 --- /dev/null +++ b/app/src/main/java/com/castla/mirror/shizuku/BinderConnectionTracker.kt @@ -0,0 +1,52 @@ +package com.castla.mirror.shizuku + +/** + * State machine that distinguishes a fresh connect from a reconnect after a real + * disconnect event. Pure logic; no Android dependency so it can be unit-tested. + * + * Why this exists: prior to this fix, [VirtualDisplayManager] treated every + * [android.content.ServiceConnection.onServiceConnected] callback after the + * first as `service died, recreate VD`. Concurrent Shizuku binds (one from + * `VirtualDisplayManager`, one from [ShizukuSetup]) produced multiple + * onServiceConnected callbacks back-to-back, and the recreate path spawned a + * new VirtualDisplay per spurious callback — yielding 6+ orphan VDs per + * session and breaking app launching from the web launcher. + * + * The tracker classifies callbacks against a real [State.Disconnected] event + * so duplicate connects are inert. + */ +class BinderConnectionTracker { + enum class Transition { FirstConnect, Reconnect, Idempotent, Disconnect } + private enum class State { New, Connected, Disconnected } + private var state = State.New + + fun onConnected(): Transition = when (state) { + State.New -> { + state = State.Connected + Transition.FirstConnect + } + State.Disconnected -> { + state = State.Connected + Transition.Reconnect + } + State.Connected -> Transition.Idempotent + } + + fun onDisconnected(): Transition = when (state) { + State.Connected -> { + state = State.Disconnected + Transition.Disconnect + } + // Disconnect from New (e.g. StateFlow's initial `false` emission to a + // fresh collector) must NOT advance to Disconnected, otherwise the next + // real connect would be misclassified as Reconnect and trigger the VD + // recreate path. + State.New, State.Disconnected -> Transition.Idempotent + } + + fun isConnected(): Boolean = state == State.Connected + + fun reset() { + state = State.New + } +} 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 2666add..21d523d 100644 --- a/app/src/main/java/com/castla/mirror/shizuku/ShizukuSetup.kt +++ b/app/src/main/java/com/castla/mirror/shizuku/ShizukuSetup.kt @@ -607,12 +607,6 @@ class ShizukuSetup { mgr.createNotificationChannel(channel) } - fun attachPrivilegedService(service: IPrivilegedService?) { - privilegedService = service - _serviceConnected.value = service != null - bindingInProgress = false - } - fun requestPermission() { if (Shizuku.isPreV11()) { Log.w(TAG, "Shizuku pre-v11 not supported") diff --git a/app/src/test/java/com/castla/mirror/shizuku/BinderConnectionTrackerTest.kt b/app/src/test/java/com/castla/mirror/shizuku/BinderConnectionTrackerTest.kt new file mode 100644 index 0000000..d3678b4 --- /dev/null +++ b/app/src/test/java/com/castla/mirror/shizuku/BinderConnectionTrackerTest.kt @@ -0,0 +1,90 @@ +package com.castla.mirror.shizuku + +import com.castla.mirror.shizuku.BinderConnectionTracker.Transition +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BinderConnectionTrackerTest { + + @Test + fun firstConnect_returnsFirstConnect() { + val tracker = BinderConnectionTracker() + assertEquals(Transition.FirstConnect, tracker.onConnected()) + assertTrue(tracker.isConnected()) + } + + @Test + fun secondConnect_withoutDisconnect_returnsIdempotent() { + // Reproduces the cascade source: VirtualDisplayManager used to treat + // every onServiceConnected after the first as a death/reconnect, which + // spawned a new VD per spurious callback. Tracker must classify these + // as no-ops so the reconnect handler does not run. + val tracker = BinderConnectionTracker() + tracker.onConnected() + assertEquals(Transition.Idempotent, tracker.onConnected()) + assertEquals(Transition.Idempotent, tracker.onConnected()) + } + + @Test + fun connectAfterDisconnect_returnsReconnect() { + val tracker = BinderConnectionTracker() + tracker.onConnected() + tracker.onDisconnected() + assertEquals(Transition.Reconnect, tracker.onConnected()) + } + + @Test + fun disconnectFromNew_returnsIdempotent() { + val tracker = BinderConnectionTracker() + assertEquals(Transition.Idempotent, tracker.onDisconnected()) + } + + @Test + fun disconnectFromNew_keepsStateAtNew_soNextConnectIsFirstConnect() { + // Reproduces the bug where MutableStateFlow's initial `false` emission + // to a fresh collector advanced the tracker to Disconnected, causing + // the next real connect to be classified as Reconnect. + val tracker = BinderConnectionTracker() + tracker.onDisconnected() + assertEquals(Transition.FirstConnect, tracker.onConnected()) + } + + @Test + fun multipleDisconnects_withoutInterveningConnect_emitDisconnectOnceThenIdempotent() { + val tracker = BinderConnectionTracker() + tracker.onConnected() + assertEquals(Transition.Disconnect, tracker.onDisconnected()) + assertEquals(Transition.Idempotent, tracker.onDisconnected()) + } + + @Test + fun reset_returnsToNew_soNextConnectIsFirstConnect() { + val tracker = BinderConnectionTracker() + tracker.onConnected() + tracker.onDisconnected() + tracker.reset() + assertFalse(tracker.isConnected()) + assertEquals(Transition.FirstConnect, tracker.onConnected()) + } + + @Test + fun fullSequence_connectDisconnectConnectConnect_classifiesEachStep() { + val tracker = BinderConnectionTracker() + assertEquals(Transition.FirstConnect, tracker.onConnected()) + assertEquals(Transition.Disconnect, tracker.onDisconnected()) + assertEquals(Transition.Reconnect, tracker.onConnected()) + assertEquals(Transition.Idempotent, tracker.onConnected()) + } + + @Test + fun isConnected_reflectsCurrentState() { + val tracker = BinderConnectionTracker() + assertFalse(tracker.isConnected()) + tracker.onConnected() + assertTrue(tracker.isConnected()) + tracker.onDisconnected() + assertFalse(tracker.isConnected()) + } +}