From 852a8a00273c9128efeed0217a8e5d3fcd8bf780 Mon Sep 17 00:00:00 2001 From: Lena Date: Sat, 1 Aug 2026 00:00:00 +0000 Subject: app: harden mirroring lifecycle and state --- app/src/main/java/invalid/lena/scrcpy/Mirror.java | 371 +++++++++++++--------- 1 file changed, 221 insertions(+), 150 deletions(-) (limited to 'app/src/main/java/invalid/lena/scrcpy/Mirror.java') diff --git a/app/src/main/java/invalid/lena/scrcpy/Mirror.java b/app/src/main/java/invalid/lena/scrcpy/Mirror.java index 2a9d390..bc9db34 100644 --- a/app/src/main/java/invalid/lena/scrcpy/Mirror.java +++ b/app/src/main/java/invalid/lena/scrcpy/Mirror.java @@ -1,41 +1,40 @@ package invalid.lena.scrcpy; import android.Manifest; +import android.annotation.SuppressLint; import android.app.Activity; import android.content.Intent; import android.content.pm.PackageManager; -import android.graphics.SurfaceTexture; +import android.graphics.Insets; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.os.SystemClock; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.Surface; import android.view.SurfaceHolder; import android.view.SurfaceView; -import android.view.TextureView; import android.view.View; +import android.view.ViewGroup; import android.view.WindowInsets; import android.view.WindowInsetsController; import android.view.WindowManager; +import android.window.OnBackInvokedDispatcher; import android.widget.Button; +import android.widget.FrameLayout; import android.widget.TextView; import android.widget.Toast; -import java.io.File; -import java.text.SimpleDateFormat; -import java.util.Date; import java.util.Locale; // Full-screen mirror activity. Pulls target host/port from intent // extras, starts a Sessions foreground service to keep the process // alive during brief backgrounding, and owns the Session itself. // -// Two layouts ship: src/main/res/layout/mirror.xml (release: SurfaceView -// + status bar) and src/debug/res/layout/mirror.xml (debug: TextureView -// + status bar + bottom stats overlay used by the e2e screen capture -// because emulators don't composite SurfaceView into screencap). +// Every build uses the same SurfaceView layout. Tests must exercise the +// renderer users receive, not a debug-only TextureView substitute. // // Surface lifetime is decoupled from session lifetime: when the surface // goes away (rotation, background) we swap the session's video surface @@ -60,27 +59,22 @@ public final class Mirror extends Activity { private Devices.Device target; private Session session; private Surface currentSurface; - private boolean ownsCurrentSurface; private volatile boolean destroyed; private long sessionGeneration; private boolean stoppingSession; private State state = State.CONNECTING; private int connectedW, connectedH; + private long connectedGeometryVersion; - // Only one of these is non-null per build variant. - private TextureView textureView; private SurfaceView surfaceView; // Always present (declared in both layouts). + private View root; private View statusBar; private TextView statusText; private Button reconnectBtn; - private Button recordBtn; - // Overlay TextViews - only present in the debug layout. null in release. - private TextView overlayTarget, overlayStats, overlayEvent; private final Handler ui = new Handler(Looper.getMainLooper()); - private int textureUpdates; @Override protected void onCreate(Bundle saved) { @@ -93,54 +87,71 @@ public final class Mirror extends Activity { String host = getIntent().getStringExtra(EXTRA_HOST); int port = getIntent().getIntExtra(EXTRA_PORT, -1); - if (host == null || port <= 0) { + if (host == null || port <= 0 || port > 65535) { Log.e("mirror: bad extras host=%s port=%d", host, port); finish(); return; } - target = new Devices.Device(host, port); - - View v = findViewById(R.id.surface); - if (v instanceof TextureView) { - textureView = (TextureView) v; - textureView.setSurfaceTextureListener(textureListener); - } else if (v instanceof SurfaceView) { - surfaceView = (SurfaceView) v; - surfaceView.getHolder().addCallback(holderCallback); - } else { - Log.e("mirror: layout has no SurfaceView or TextureView at R.id.surface"); + target = resolveTarget(host, port); + if (target == null) { + Log.e("mirror: refusing unsaved target %s:%d", host, port); + Toast.makeText(this, R.string.device_not_paired, Toast.LENGTH_LONG).show(); + finish(); + return; + } + + View video = findViewById(R.id.surface); + if (!(video instanceof SurfaceView)) { + Log.e("mirror: layout has no SurfaceView at R.id.surface"); finish(); return; } + surfaceView = (SurfaceView) video; + surfaceView.getHolder().addCallback(holderCallback); + + root = findViewById(R.id.root); + // Rotation and insets change the container without touching the + // video surface, so re-fit from here as well. + root.addOnLayoutChangeListener( + (view, l, t, r, b, ol, ot, or, ob) -> applyLetterbox()); statusBar = findViewById(R.id.status_bar); statusText = findViewById(R.id.status_text); reconnectBtn = findViewById(R.id.reconnect); - recordBtn = findViewById(R.id.record); + insetStatusBar(); reconnectBtn.setOnClickListener(view -> reconnect()); - recordBtn.setOnClickListener(view -> toggleRecord()); + // Back, Home and Recents as explicit controls. On a + // gesture-navigation source the target's own navigation cannot be + // reached by forwarding touches: the source claims the bottom + // edge swipe for itself and, unlike the side edges, that region + // cannot be released with setSystemGestureExclusionRects. + findViewById(R.id.nav_back).setOnClickListener(view -> { + if (session != null) session.onBack(); + }); + findViewById(R.id.nav_home).setOnClickListener(view -> { + if (session != null) session.onHome(); + }); + findViewById(R.id.nav_recents).setOnClickListener(view -> { + if (session != null) session.onRecents(); + }); updateStatusBar(); - overlayTarget = findViewById(R.id.overlay_target); - overlayStats = findViewById(R.id.overlay_stats); - overlayEvent = findViewById(R.id.overlay_event); - if (overlayTarget != null) { - overlayTarget.setText("target: " + host + ":" + port); - ui.postDelayed(this::pollOverlay, 200); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getOnBackInvokedDispatcher().registerOnBackInvokedCallback( + OnBackInvokedDispatcher.PRIORITY_DEFAULT, this::onBackRequested); } requestNotificationsIfNeeded(); - startForegroundService(new Intent(this, Sessions.class)); + startKeepalive(); if (!Settings.hintBackShown(this)) { - Toast.makeText(this, R.string.hint_long_press_back, - Toast.LENGTH_LONG).show(); + Toast.makeText(this, R.string.hint_back, Toast.LENGTH_LONG).show(); Settings.setHintBackShown(this, true); } new Thread(() -> { try { - Adb a = Adb.getInstance(this); + Adb a = Adb.getInstance(getApplicationContext()); runOnUiThread(() -> { if (destroyed) return; adb = a; @@ -160,46 +171,18 @@ public final class Mirror extends Activity { }, "adb-init").start(); } - // ---- surface lifecycle: TextureView path ---- - - private final TextureView.SurfaceTextureListener textureListener = - new TextureView.SurfaceTextureListener() { - @Override - public void onSurfaceTextureAvailable(SurfaceTexture st, int w, int h) { - Log.i("mirror: texture available %dx%d", w, h); - attachSurface(new Surface(st), true, w, h); - } - @Override - public void onSurfaceTextureSizeChanged(SurfaceTexture st, int w, int h) { - Log.i("mirror: texture resized %dx%d", w, h); - if (session != null) session.setViewSize(w, h); - } - @Override - public boolean onSurfaceTextureDestroyed(SurfaceTexture st) { - Log.i("mirror: texture destroyed"); - detachSurface(); - return true; - } - @Override - public void onSurfaceTextureUpdated(SurfaceTexture st) { - if (++textureUpdates == 1 || textureUpdates % 30 == 0) { - Log.i("mirror: texture updates=%d", textureUpdates); - } - } - }; - - // ---- surface lifecycle: SurfaceView path ---- + // ---- surface lifecycle ---- private final SurfaceHolder.Callback holderCallback = new SurfaceHolder.Callback() { @Override public void surfaceCreated(SurfaceHolder holder) { Log.i("mirror: surface created"); - attachSurface(holder.getSurface(), false, 0, 0); + attachSurface(holder.getSurface()); } @Override public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) { Log.i("mirror: surface changed %dx%d", w, h); - if (session != null) session.setViewSize(w, h); + applyLetterbox(); } @Override public void surfaceDestroyed(SurfaceHolder holder) { @@ -210,46 +193,82 @@ public final class Mirror extends Activity { // ---- session driver ---- - private void attachSurface(Surface s, boolean owned, int w, int h) { + private void attachSurface(Surface s) { if (session != null && currentSurface != null) session.swapSurface(null); - releaseOwnedSurface(); currentSurface = s; - ownsCurrentSurface = owned; if (session != null) { session.swapSurface(s); - if (w > 0) session.setViewSize(w, h); + applyLetterbox(); return; } if (adb == null) return; // adb-init thread will start the session if (stoppingSession) return; // stop worker starts the latest target if (state == State.DISCONNECTED) return; // wait for user to tap reconnect startSession(s); - if (w > 0) session.setViewSize(w, h); + applyLetterbox(); + } + + // Size the video view to the target's aspect ratio inside the root + // frame and centre it, so the black root shows through as letterbox + // bars instead of the picture being stretched to the source's screen + // shape. Also tells the session where the picture ended up, because + // touches arrive in window coordinates and must be offset by the bars. + // + // No-ops until both the container and the target geometry are known; + // every caller is a point where one of them may have just changed. + private void applyLetterbox() { + View v = surfaceView; + if (v == null || root == null || session == null) return; + int cw = root.getWidth(), ch = root.getHeight(); + int tw = connectedW, th = connectedH; + if (cw <= 0 || ch <= 0 || tw <= 0 || th <= 0) return; + + float scale = Math.min(cw / (float) tw, ch / (float) th); + int w = Math.min(cw, Math.max(1, Math.round(tw * scale))); + int h = Math.min(ch, Math.max(1, Math.round(th * scale))); + + ViewGroup.LayoutParams lp = v.getLayoutParams(); + if (lp.width != w || lp.height != h) { + lp.width = w; + lp.height = h; + v.setLayoutParams(lp); // re-layout re-enters here, then converges + Log.i("mirror: letterbox %dx%d -> %dx%d in %dx%d", tw, th, w, h, cw, ch); + } + session.setViewport(connectedGeometryVersion, + (cw - w) / 2, (ch - h) / 2, w, h); } private void detachSurface() { if (session != null) session.swapSurface(null); - releaseOwnedSurface(); currentSurface = null; } - private void releaseOwnedSurface() { - if (ownsCurrentSurface && currentSurface != null) currentSurface.release(); - ownsCurrentSurface = false; - } - private void startSession(Surface s) { if (destroyed) return; + // Re-read the row so forgetting a device invalidates stale tasks and + // notifications before they open a new session. + Devices.Device current = target; + Devices.Device resolved = resolveTarget(current.host, current.port); + if (resolved == null) { + state = State.DISCONNECTED; + updateStatusBar(); + Toast.makeText(this, R.string.device_not_paired, Toast.LENGTH_LONG).show(); + return; + } + target = resolved; state = State.CONNECTING; updateStatusBar(); long generation = ++sessionGeneration; session = new Session(this, adb, target, s, new Session.Listener() { - @Override public void onConnected(int w, int h) { + @Override public void onConnected(long geometryVersion, int w, int h) { runOnUiThread(() -> { if (destroyed || generation != sessionGeneration) return; state = State.CONNECTED; + connectedGeometryVersion = geometryVersion; connectedW = w; connectedH = h; updateStatusBar(); + applyLetterbox(); + if (session != null) session.syncClipboard(); }); } @Override public void onReconnecting() { @@ -263,8 +282,7 @@ public final class Mirror extends Activity { @Override public void onError(Throwable t) { runOnUiThread(() -> { if (destroyed || generation != sessionGeneration) return; - Toast.makeText(Mirror.this, - "session error: " + t.getMessage(), Toast.LENGTH_LONG).show(); + Toast.makeText(Mirror.this, describe(t), Toast.LENGTH_LONG).show(); }); } @Override public void onStopped() { @@ -279,33 +297,6 @@ public final class Mirror extends Activity { session.start(); } - private void toggleRecord() { - if (session == null) return; - if (session.isRecording()) { - // stopRecording is false when no keyframe ever landed (still - // ARMED) - no file was written, so don't claim one was saved. - boolean saved = session.stopRecording(); - Toast.makeText(this, saved ? "recording saved" : "recording discarded (no video)", - Toast.LENGTH_SHORT).show(); - } else { - File dir = getExternalFilesDir(null); - if (dir == null) { - Toast.makeText(this, "no external storage", Toast.LENGTH_LONG).show(); - return; - } - String ts = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.ROOT).format(new Date()); - File out = new File(dir, "scrcpy-" + ts + ".mp4"); - session.armRecording(out); - Toast.makeText(this, "recording -> " + out.getName(), Toast.LENGTH_SHORT).show(); - } - updateRecordButton(); - } - - private void updateRecordButton() { - boolean on = session != null && session.isRecording(); - recordBtn.setText(on ? R.string.record_on : R.string.record); - } - private void reconnect() { Log.i("mirror: reconnect tapped"); state = State.CONNECTING; @@ -315,16 +306,25 @@ public final class Mirror extends Activity { Session old = session; session = null; sessionGeneration++; + connectedW = connectedH = 0; + connectedGeometryVersion = 0; // Stop the old session off the UI thread (teardown closes // sockets and joins the server's log pump), THEN start the new // one. Sequencing matters: both sessions share the singleton // Adb, so the old teardown's disconnect must finish before the // new bring-up connects. new Thread(() -> { - if (old != null) old.stop(); + boolean stopped = old == null || old.stop(); runOnUiThread(() -> { if (destroyed) return; stoppingSession = false; + if (!stopped) { + state = State.DISCONNECTED; + updateStatusBar(); + Toast.makeText(this, R.string.session_stop_timeout, + Toast.LENGTH_LONG).show(); + return; + } if (session != null) return; // another path already started one if (adb == null || currentSurface == null) return; startSession(currentSurface); @@ -343,10 +343,17 @@ public final class Mirror extends Activity { } if (target.host.equals(host) && target.port == port) return; + Devices.Device replacement = resolveTarget(host, port); + if (replacement == null) { + Log.w("mirror: refusing unsaved replacement target %s:%d", host, port); + Toast.makeText(this, R.string.device_not_paired, Toast.LENGTH_LONG).show(); + return; + } setIntent(intent); - target = new Devices.Device(host, port); + target = replacement; connectedW = connectedH = 0; - if (overlayTarget != null) overlayTarget.setText("target: " + target); + connectedGeometryVersion = 0; + startKeepalive(); // repoint the notification at the new target reconnect(); } @@ -369,8 +376,6 @@ public final class Mirror extends Activity { } statusText.setText(s); reconnectBtn.setVisibility(state == State.DISCONNECTED ? View.VISIBLE : View.GONE); - recordBtn.setEnabled(state == State.CONNECTED); - updateRecordButton(); // Hide the whole bar while actively mirroring if the user opted // out; keep it up whenever not CONNECTED so the status and the @@ -381,19 +386,6 @@ public final class Mirror extends Activity { } } - // ---- overlay (debug only - fields are null in release) ---- - - private void pollOverlay() { - if (overlayStats == null) return; - if (session != null) { - overlayStats.setText(String.format(Locale.ROOT, - "v=%-4d a=%-4d tex=%-4d", - session.videoFrames(), session.audioFrames(), textureUpdates)); - overlayEvent.setText("event: " + session.lastEvent()); - } - ui.postDelayed(this::pollOverlay, 200); - } - // ---- input ---- @Override @@ -405,29 +397,41 @@ public final class Mirror extends Activity { return super.onTouchEvent(ev); } - @Override - public boolean onKeyDown(int keyCode, KeyEvent event) { - if (keyCode == KeyEvent.KEYCODE_BACK) { - event.startTracking(); - return true; + // Back goes to the target; Back twice in quick succession leaves the + // mirror. + // + // It used to be long-press-Back to reach the target and short-press + // to do nothing. That stopped working twice over: gesture navigation + // has no Back key to hold, and at targetSdk 35 and later predictive back is on + // by default, so the framework routes Back through + // OnBackInvokedDispatcher and onKeyDown/onKeyLongPress are never + // called for it at all. The advertised feature was unreachable on + // every current device. + private static final long DOUBLE_BACK_MS = 600L; + private long lastBackAtMs; + + private void onBackRequested() { + long now = SystemClock.elapsedRealtime(); + if (now - lastBackAtMs < DOUBLE_BACK_MS) { + finish(); + return; } - return super.onKeyDown(keyCode, event); + lastBackAtMs = now; + if (session != null) session.onBack(); } + // Pre-33 devices (minSdk is 31) still deliver Back this way. API 33+ + // uses the OnBackInvokedDispatcher callback registered in onCreate; + // lint does not follow that version split. @Override - public boolean onKeyLongPress(int keyCode, KeyEvent event) { - if (keyCode == KeyEvent.KEYCODE_BACK && session != null) { - session.onBack(); - return true; - } - return super.onKeyLongPress(keyCode, event); + @SuppressLint("GestureBackNavigation") + @SuppressWarnings("deprecation") + public void onBackPressed() { + onBackRequested(); } @Override public boolean dispatchKeyEvent(KeyEvent ev) { - if (ev.getKeyCode() == KeyEvent.KEYCODE_BACK) { - return super.dispatchKeyEvent(ev); - } if (session != null && shouldForward(ev)) { session.onKey(ev); return true; @@ -442,23 +446,27 @@ public final class Mirror extends Activity { destroyed = true; sessionGeneration++; ui.removeCallbacksAndMessages(null); - releaseOwnedSurface(); currentSurface = null; Session s = session; session = null; if (s != null) { // Teardown blocks on socket closes and a thread join; keep // it off the UI thread. - new Thread(s::stop, "session-stop").start(); + new Thread(() -> s.stop(), "session-stop").start(); } stopService(new Intent(this, Sessions.class)); super.onDestroy(); } + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (hasFocus && session != null) session.syncClipboard(); + } + // Android 13+ requires runtime grant for POST_NOTIFICATIONS. The - // foreground service notification is silently suppressed if the - // user never sees the dialog, and on Android 14+ a notification- - // less FGS can be killed at any time. + // foreground service still starts without the grant, but granting it + // keeps the active session visible in the notification drawer. private void requestNotificationsIfNeeded() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return; if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) @@ -467,7 +475,51 @@ public final class Mirror extends Activity { RQ_POST_NOTIFICATIONS); } + // Most of what reaches here has a null message - a bare IOException + // from the socket, an SSLHandshakeException - and "session error: + // null" is what the user was being shown for the commonest failure + // there is. + private String describe(Throwable t) { + for (Throwable c = t; c != null; c = c.getCause()) { + String m = c.getMessage(); + if (m != null && !m.isEmpty()) return getString(R.string.session_error, m); + } + return getString(R.string.session_error, + t == null ? "unknown" : t.getClass().getSimpleName()); + } + + // Missing rows fail closed. A stale notification, restored task, or + // malformed internal intent must not create a session for an unsaved row. + private Devices.Device resolveTarget(String host, int port) { + try { + return Devices.find(this, host, port); + } catch (java.io.IOException e) { + Log.e(e, "mirror: cannot read paired devices"); + return null; + } + } + + // The foreground service exists only to keep this process alive while + // mirroring. It carries the target so its notification can lead back + // here rather than somewhere that would tear the session down. + private void startKeepalive() { + Intent i = new Intent(this, Sessions.class); + i.putExtra(EXTRA_HOST, target.host); + i.putExtra(EXTRA_PORT, target.port); + startForegroundService(i); + } + + @SuppressWarnings("deprecation") private void immersive() { + // Without this the window stops at the cutout's safe area and the + // system letterboxes it, which shows up as black bands down the + // sides of what is supposed to be a full-screen mirror. Only the + // overlay controls are moved around a display cutout. + WindowManager.LayoutParams lp = getWindow().getAttributes(); + lp.layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS; + getWindow().setAttributes(lp); + WindowInsetsController c = getWindow().getInsetsController(); if (c != null) { c.hide(WindowInsets.Type.systemBars()); @@ -476,6 +528,25 @@ public final class Mirror extends Activity { getWindow().setDecorFitsSystemWindows(false); } + private void insetStatusBar() { + int base = getResources().getDimensionPixelSize(R.dimen.space_sm); + statusBar.setOnApplyWindowInsetsListener((view, windowInsets) -> { + Insets cutout = windowInsets.getInsetsIgnoringVisibility( + WindowInsets.Type.displayCutout()); + FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) view.getLayoutParams(); + int left = base + cutout.left; + int top = base + cutout.top; + int right = base + cutout.right; + if (lp.leftMargin != left || lp.topMargin != top + || lp.rightMargin != right || lp.bottomMargin != base) { + lp.setMargins(left, top, right, base); + view.setLayoutParams(lp); + } + return windowInsets; + }); + statusBar.requestApplyInsets(); + } + private static boolean shouldForward(KeyEvent ev) { int code = ev.getKeyCode(); if (code >= KeyEvent.KEYCODE_DPAD_UP && code <= KeyEvent.KEYCODE_DPAD_CENTER) return true; -- cgit v1.2.3