diff options
| author | Lena <lena@omega> | 2026-01-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-06-24 22:14:50 +0300 |
| commit | eb0c8951196c637e44daf3c0617b131b997d5d2c (patch) | |
| tree | 2f83b6a41cee745467e53fc401942b82cea286ea /app/src/main/java/invalid/lena/scrcpy/Mirror.java | |
| download | scrcpy-android-0.1.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.java | 471 |
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; + } + } +} |