aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java/invalid/lena/scrcpy/Mirror.java
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/invalid/lena/scrcpy/Mirror.java')
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Mirror.java371
1 files changed, 221 insertions, 150 deletions
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;