aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java/invalid/lena/scrcpy/Mirror.java
diff options
context:
space:
mode:
authorLena <lena@omega>2026-01-01 00:00:00 +0000
committerLena <lena@omega>2026-06-24 22:14:50 +0300
commiteb0c8951196c637e44daf3c0617b131b997d5d2c (patch)
tree2f83b6a41cee745467e53fc401942b82cea286ea /app/src/main/java/invalid/lena/scrcpy/Mirror.java
downloadscrcpy-android-eb0c8951196c637e44daf3c0617b131b997d5d2c.tar.gz
scrcpy-android: mirror an Android device over wireless ADB0.1
Native Java app for Android 12+ that mirrors another Android device over wireless ADB, forwarding video, audio, touch input, and clipboard. Bundles a pinned scrcpy-server.jar and the vendored libadb-android stack. Supports h264/h265/av1 video and raw/opus audio with in-app codec selection. No NDK, no Kotlin. Includes JVM unit tests and a Docker-based emulator e2e rig.
Diffstat (limited to 'app/src/main/java/invalid/lena/scrcpy/Mirror.java')
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Mirror.java471
1 files changed, 471 insertions, 0 deletions
diff --git a/app/src/main/java/invalid/lena/scrcpy/Mirror.java b/app/src/main/java/invalid/lena/scrcpy/Mirror.java
new file mode 100644
index 0000000..f6e249d
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/Mirror.java
@@ -0,0 +1,471 @@
+package invalid.lena.scrcpy;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.graphics.SurfaceTexture;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+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.WindowInsets;
+import android.view.WindowInsetsController;
+import android.view.WindowManager;
+import android.widget.Button;
+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).
+//
+// Surface lifetime is decoupled from session lifetime: when the surface
+// goes away (rotation, background) we swap the session's video surface
+// to null and let the wire keep draining. When the surface comes back
+// we swap the new one in. Audio and control streams are unaffected.
+//
+// Session state vs activity lifetime: a fatal session error does NOT
+// finish() the activity any more - instead the status bar transitions
+// to DISCONNECTED and exposes a Reconnect button.
+public final class Mirror extends Activity {
+
+ public static final String EXTRA_HOST = "host";
+ public static final String EXTRA_PORT = "port";
+
+ // Arbitrary request code for POST_NOTIFICATIONS - we don't react to
+ // the result; the system caches the choice for next launch.
+ private static final int RQ_POST_NOTIFICATIONS = 1001;
+
+ private enum State { CONNECTING, CONNECTED, DISCONNECTED }
+
+ private volatile Adb adb;
+ private Devices.Device target;
+ private Session session;
+ private Surface currentSurface;
+ private State state = State.CONNECTING;
+ private int connectedW, connectedH;
+
+ // Only one of these is non-null per build variant.
+ private TextureView textureView;
+ private SurfaceView surfaceView;
+
+ // Always present (declared in both layouts).
+ 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) {
+ super.onCreate(saved);
+ setContentView(R.layout.mirror);
+ immersive();
+ // Hold the source screen awake for as long as Mirror is in
+ // front. Cleared automatically when the activity is destroyed.
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+
+ String host = getIntent().getStringExtra(EXTRA_HOST);
+ int port = getIntent().getIntExtra(EXTRA_PORT, -1);
+ if (host == null || port <= 0) {
+ 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");
+ finish();
+ return;
+ }
+
+ statusBar = findViewById(R.id.status_bar);
+ statusText = findViewById(R.id.status_text);
+ reconnectBtn = findViewById(R.id.reconnect);
+ recordBtn = findViewById(R.id.record);
+ reconnectBtn.setOnClickListener(view -> reconnect());
+ recordBtn.setOnClickListener(view -> toggleRecord());
+ 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);
+ }
+
+ requestNotificationsIfNeeded();
+ startForegroundService(new Intent(this, Sessions.class));
+
+ if (!Settings.hintBackShown(this)) {
+ Toast.makeText(this, R.string.hint_long_press_back,
+ Toast.LENGTH_LONG).show();
+ Settings.setHintBackShown(this, true);
+ }
+
+ new Thread(() -> {
+ try {
+ Adb a = Adb.getInstance(this);
+ runOnUiThread(() -> {
+ adb = a;
+ if (session == null && currentSurface != null) {
+ startSession(currentSurface);
+ }
+ });
+ } catch (Exception e) {
+ Log.e(e, "mirror: adb init");
+ runOnUiThread(() -> {
+ Toast.makeText(this, "adb init: " + e.getMessage(),
+ Toast.LENGTH_LONG).show();
+ finish();
+ });
+ }
+ }, "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), 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 ----
+
+ private final SurfaceHolder.Callback holderCallback = new SurfaceHolder.Callback() {
+ @Override
+ public void surfaceCreated(SurfaceHolder holder) {
+ Log.i("mirror: surface created");
+ attachSurface(holder.getSurface(), 0, 0);
+ }
+ @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);
+ }
+ @Override
+ public void surfaceDestroyed(SurfaceHolder holder) {
+ Log.i("mirror: surface destroyed");
+ detachSurface();
+ }
+ };
+
+ // ---- session driver ----
+
+ private void attachSurface(Surface s, int w, int h) {
+ currentSurface = s;
+ if (session != null) {
+ session.swapSurface(s);
+ if (w > 0) session.setViewSize(w, h);
+ return;
+ }
+ if (adb == null) return; // adb-init thread will start the session
+ if (state == State.DISCONNECTED) return; // wait for user to tap reconnect
+ startSession(s);
+ if (w > 0) session.setViewSize(w, h);
+ }
+
+ private void detachSurface() {
+ currentSurface = null;
+ if (session != null) session.swapSurface(null);
+ }
+
+ private void startSession(Surface s) {
+ state = State.CONNECTING;
+ updateStatusBar();
+ session = new Session(this, adb, target, s, new Session.Listener() {
+ @Override public void onConnected(int w, int h) {
+ runOnUiThread(() -> {
+ state = State.CONNECTED;
+ connectedW = w; connectedH = h;
+ updateStatusBar();
+ });
+ }
+ @Override public void onReconnecting() {
+ Log.i("mirror: link lost, reconnecting");
+ runOnUiThread(() -> {
+ state = State.CONNECTING;
+ updateStatusBar();
+ });
+ }
+ @Override public void onError(Throwable t) {
+ runOnUiThread(() -> {
+ Toast.makeText(Mirror.this,
+ "session error: " + t.getMessage(), Toast.LENGTH_LONG).show();
+ });
+ }
+ @Override public void onStopped() {
+ Log.i("mirror: session stopped");
+ runOnUiThread(() -> {
+ state = State.DISCONNECTED;
+ updateStatusBar();
+ });
+ }
+ });
+ session.start();
+ }
+
+ private void toggleRecord() {
+ if (session == null) return;
+ if (session.isRecording()) {
+ session.stopRecording();
+ Toast.makeText(this, "recording saved", 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");
+ Session old = session;
+ session = null;
+ state = State.CONNECTING;
+ updateStatusBar();
+ // 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();
+ runOnUiThread(() -> {
+ if (session != null) return; // another path already started one
+ if (adb == null || currentSurface == null) return;
+ startSession(currentSurface);
+ });
+ }, "session-stop").start();
+ }
+
+ private void updateStatusBar() {
+ if (statusText == null) return;
+ String s;
+ switch (state) {
+ case CONNECTED:
+ s = String.format(Locale.ROOT, "%s:%d %dx%d %s",
+ target.host, target.port, connectedW, connectedH,
+ Settings.videoCodec(this));
+ break;
+ case DISCONNECTED:
+ s = String.format(Locale.ROOT, "%s:%d %s",
+ target.host, target.port, getString(R.string.disconnected));
+ break;
+ default:
+ s = String.format(Locale.ROOT, "%s:%d %s",
+ target.host, target.port, getString(R.string.connecting));
+ }
+ 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
+ // Reconnect button stay reachable.
+ if (statusBar != null) {
+ boolean show = Settings.showStatusBar(this) || state != State.CONNECTED;
+ statusBar.setVisibility(show ? View.VISIBLE : View.GONE);
+ }
+ }
+
+ // ---- 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
+ public boolean onTouchEvent(MotionEvent ev) {
+ if (session != null) {
+ session.onTouch(ev);
+ return true;
+ }
+ return super.onTouchEvent(ev);
+ }
+
+ @Override
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
+ if (keyCode == KeyEvent.KEYCODE_BACK) {
+ event.startTracking();
+ return true;
+ }
+ return super.onKeyDown(keyCode, event);
+ }
+
+ @Override
+ public boolean onKeyLongPress(int keyCode, KeyEvent event) {
+ if (keyCode == KeyEvent.KEYCODE_BACK && session != null) {
+ session.onBack();
+ return true;
+ }
+ return super.onKeyLongPress(keyCode, event);
+ }
+
+ @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;
+ }
+ return super.dispatchKeyEvent(ev);
+ }
+
+ // ---- lifecycle ----
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ ui.removeCallbacksAndMessages(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();
+ }
+ stopService(new Intent(this, Sessions.class));
+ }
+
+ // 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.
+ private void requestNotificationsIfNeeded() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return;
+ if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
+ == PackageManager.PERMISSION_GRANTED) return;
+ requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS},
+ RQ_POST_NOTIFICATIONS);
+ }
+
+ private void immersive() {
+ WindowInsetsController c = getWindow().getInsetsController();
+ if (c != null) {
+ c.hide(WindowInsets.Type.systemBars());
+ c.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
+ }
+ getWindow().setDecorFitsSystemWindows(false);
+ }
+
+ private static boolean shouldForward(KeyEvent ev) {
+ int code = ev.getKeyCode();
+ if (code >= KeyEvent.KEYCODE_DPAD_UP && code <= KeyEvent.KEYCODE_DPAD_CENTER) return true;
+ if (code >= KeyEvent.KEYCODE_0 && code <= KeyEvent.KEYCODE_9) return true;
+ if (code >= KeyEvent.KEYCODE_A && code <= KeyEvent.KEYCODE_Z) return true;
+ switch (code) {
+ case KeyEvent.KEYCODE_SPACE:
+ case KeyEvent.KEYCODE_ENTER:
+ case KeyEvent.KEYCODE_DEL:
+ case KeyEvent.KEYCODE_FORWARD_DEL:
+ case KeyEvent.KEYCODE_TAB:
+ case KeyEvent.KEYCODE_ESCAPE:
+ case KeyEvent.KEYCODE_PAGE_UP:
+ case KeyEvent.KEYCODE_PAGE_DOWN:
+ case KeyEvent.KEYCODE_MOVE_HOME:
+ case KeyEvent.KEYCODE_MOVE_END:
+ case KeyEvent.KEYCODE_INSERT:
+ case KeyEvent.KEYCODE_SHIFT_LEFT:
+ case KeyEvent.KEYCODE_SHIFT_RIGHT:
+ case KeyEvent.KEYCODE_CTRL_LEFT:
+ case KeyEvent.KEYCODE_CTRL_RIGHT:
+ case KeyEvent.KEYCODE_ALT_LEFT:
+ case KeyEvent.KEYCODE_ALT_RIGHT:
+ case KeyEvent.KEYCODE_META_LEFT:
+ case KeyEvent.KEYCODE_META_RIGHT:
+ case KeyEvent.KEYCODE_CAPS_LOCK:
+ case KeyEvent.KEYCODE_NUM_LOCK:
+ case KeyEvent.KEYCODE_SCROLL_LOCK:
+ case KeyEvent.KEYCODE_COMMA:
+ case KeyEvent.KEYCODE_PERIOD:
+ case KeyEvent.KEYCODE_SLASH:
+ case KeyEvent.KEYCODE_BACKSLASH:
+ case KeyEvent.KEYCODE_SEMICOLON:
+ case KeyEvent.KEYCODE_APOSTROPHE:
+ case KeyEvent.KEYCODE_GRAVE:
+ case KeyEvent.KEYCODE_LEFT_BRACKET:
+ case KeyEvent.KEYCODE_RIGHT_BRACKET:
+ case KeyEvent.KEYCODE_MINUS:
+ case KeyEvent.KEYCODE_EQUALS:
+ return true;
+ default:
+ return false;
+ }
+ }
+}