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()) { // 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"); 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; } } }