aboutsummaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-01 00:00:00 +0000
committerLena <lena@omega>2026-08-01 00:00:00 +0000
commit852a8a00273c9128efeed0217a8e5d3fcd8bf780 (patch)
treec72f959731fa3c7c809cf1a0222363b6c9c9b03b /app/src
parentf379b93d52bf0dbd3816ec3f64d165314771d2a6 (diff)
downloadscrcpy-android-852a8a00273c9128efeed0217a8e5d3fcd8bf780.tar.gz
app: harden mirroring lifecycle and state
Diffstat (limited to 'app/src')
-rw-r--r--app/src/debug/AndroidManifest.xml9
-rw-r--r--app/src/debug/java/invalid/lena/scrcpy/Pattern.java115
-rw-r--r--app/src/debug/res/layout/mirror.xml93
-rw-r--r--app/src/main/AndroidManifest.xml8
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/AtomicFiles.java30
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/AudioSink.java55
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/AudioStream.java45
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/ControlMessages.java37
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/ControlStream.java11
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Controller.java141
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Crashlog.java29
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Devices.java86
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Licenses.java48
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Main.java117
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Mirror.java371
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/MuxRecorder.java226
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Server.java96
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Session.java377
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Sessions.java24
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Settings.java119
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/SettingsActivity.java22
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Sync.java54
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/TouchGeometry.java46
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/TouchMap.java34
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Ui.java2
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/VideoFrames.java2
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/VideoQueue.java94
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/VideoRecorder.java19
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/VideoSink.java277
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/VideoStream.java26
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Wire.java71
-rw-r--r--app/src/main/res/drawable/ic_launcher_foreground.xml13
-rw-r--r--app/src/main/res/drawable/ic_settings.xml10
-rw-r--r--app/src/main/res/layout/device_row.xml28
-rw-r--r--app/src/main/res/layout/licenses.xml20
-rw-r--r--app/src/main/res/layout/main.xml264
-rw-r--r--app/src/main/res/layout/mirror.xml46
-rw-r--r--app/src/main/res/layout/settings.xml60
-rw-r--r--app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml5
-rw-r--r--app/src/main/res/values/ids.xml10
-rw-r--r--app/src/main/res/values/strings.xml40
-rw-r--r--app/src/main/res/values/styles.xml2
-rw-r--r--app/src/main/res/xml/data_extraction_rules.xml14
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/AtomicFilesTest.java12
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/AudioStreamTest.java13
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/ControlMessagesTest.java29
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/ControlStreamTest.java10
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/CrashlogTest.java36
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/DevicesTest.java16
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/TouchGeometryTest.java48
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/TouchMapTest.java85
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/VideoQueueTest.java53
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/VideoStreamTest.java61
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/WireTest.java58
54 files changed, 2400 insertions, 1217 deletions
diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..8e04c86
--- /dev/null
+++ b/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+ <application>
+ <activity
+ android:name=".Pattern"
+ android:exported="true"
+ android:theme="@style/Theme.Scrcpy.Fullscreen"/>
+ </application>
+</manifest>
diff --git a/app/src/debug/java/invalid/lena/scrcpy/Pattern.java b/app/src/debug/java/invalid/lena/scrcpy/Pattern.java
new file mode 100644
index 0000000..39a5d42
--- /dev/null
+++ b/app/src/debug/java/invalid/lena/scrcpy/Pattern.java
@@ -0,0 +1,115 @@
+package invalid.lena.scrcpy;
+
+import android.app.Activity;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.os.Bundle;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.WindowInsets;
+import android.view.WindowInsetsController;
+
+// Deterministic moving target for the two-emulator E2E test. Debug-only,
+// exported only in the debug manifest, and absent from release artifacts.
+public final class Pattern extends Activity {
+
+ private static final String TARGET_CLIPBOARD_TEXT = "scrcpy-e2e";
+ private static final String SOURCE_CLIPBOARD_TEXT = "source-e2e";
+ private static final String EXTRA_CLIPBOARD_TEXT = "clipboard_text";
+
+ private ClipboardManager clipboard;
+ private String clipboardText;
+ private final ClipboardManager.OnPrimaryClipChangedListener clipboardListener =
+ this::onClipboardChanged;
+
+ @Override
+ @SuppressWarnings("deprecation")
+ protected void onCreate(Bundle state) {
+ super.onCreate(state);
+ String requested = getIntent().getStringExtra(EXTRA_CLIPBOARD_TEXT);
+ clipboardText = SOURCE_CLIPBOARD_TEXT.equals(requested)
+ ? SOURCE_CLIPBOARD_TEXT : TARGET_CLIPBOARD_TEXT;
+ setContentView(new PatternView());
+ clipboard = getSystemService(ClipboardManager.class);
+ if (clipboard != null) clipboard.addPrimaryClipChangedListener(clipboardListener);
+ getWindow().setDecorFitsSystemWindows(false);
+ WindowInsetsController insets = getWindow().getInsetsController();
+ if (insets != null) insets.hide(WindowInsets.Type.systemBars());
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (clipboard != null) clipboard.removePrimaryClipChangedListener(clipboardListener);
+ super.onDestroy();
+ }
+
+ private void onClipboardChanged() {
+ ClipData data = clipboard.getPrimaryClip();
+ if (data == null || data.getItemCount() == 0) return;
+ CharSequence text = data.getItemAt(0).getText();
+ if (SOURCE_CLIPBOARD_TEXT.contentEquals(text)) {
+ Log.i("pattern: clipboard from source");
+ }
+ }
+
+ @Override
+ public boolean dispatchKeyEvent(KeyEvent event) {
+ if (event.getAction() == KeyEvent.ACTION_UP) {
+ Log.i("pattern: key up code=%d", event.getKeyCode());
+ }
+ return super.dispatchKeyEvent(event);
+ }
+
+ private final class PatternView extends View {
+ private final Paint paint = new Paint();
+ private int step;
+
+ PatternView() {
+ super(Pattern.this);
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ if (event.getActionMasked() == MotionEvent.ACTION_UP) {
+ Log.i("pattern: touch up x=%d y=%d", (int) event.getX(), (int) event.getY());
+ performClick();
+ }
+ return true;
+ }
+
+ @Override
+ public boolean performClick() {
+ super.performClick();
+ if (clipboard != null) {
+ clipboard.setPrimaryClip(ClipData.newPlainText("e2e", clipboardText));
+ Log.i("pattern: clipboard set");
+ }
+ return true;
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ int w = getWidth();
+ int h = getHeight();
+ int mx = w / 2;
+ int my = h / 2;
+ paint.setColor(Color.rgb(224, 32, 32));
+ canvas.drawRect(0, 0, mx, my, paint);
+ paint.setColor(Color.rgb(32, 192, 48));
+ canvas.drawRect(mx, 0, w, my, paint);
+ paint.setColor(Color.rgb(32, 64, 224));
+ canvas.drawRect(0, my, mx, h, paint);
+ paint.setColor(Color.rgb(224, 192, 32));
+ canvas.drawRect(mx, my, w, h, paint);
+
+ paint.setColor(Color.WHITE);
+ int x = step++ % Math.max(1, w);
+ canvas.drawRect(x, 0, Math.min(w, x + 12), h, paint);
+ postInvalidateDelayed(33);
+ }
+ }
+}
diff --git a/app/src/debug/res/layout/mirror.xml b/app/src/debug/res/layout/mirror.xml
deleted file mode 100644
index 5d0e845..0000000
--- a/app/src/debug/res/layout/mirror.xml
+++ /dev/null
@@ -1,93 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!-- Debug Mirror layout: TextureView, the same top status bar as
- production, plus a bottom-left stats overlay that screencap can
- capture (target host, video/audio/texture-update counts, last
- forwarded event). The TextureView path is used because emulators
- don't composite SurfaceView's hardware layer into screencap. -->
-<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:background="#000000">
-
- <TextureView
- android:id="@+id/surface"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:layout_gravity="center"/>
-
- <LinearLayout
- android:id="@+id/status_bar"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_gravity="top"
- android:orientation="horizontal"
- android:gravity="center_vertical"
- android:padding="8dp"
- android:background="#a0000000">
-
- <TextView
- android:id="@+id/status_text"
- android:layout_width="0dp"
- android:layout_weight="1"
- android:layout_height="wrap_content"
- android:textColor="#ffffff"
- android:textSize="14sp"
- android:fontFamily="monospace"
- android:singleLine="true"
- android:ellipsize="end"
- android:text="@string/connecting"/>
-
- <Button
- android:id="@+id/record"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginEnd="8dp"
- android:text="@string/record"/>
-
- <Button
- android:id="@+id/reconnect"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/reconnect"
- android:visibility="gone"/>
-
- </LinearLayout>
-
- <LinearLayout
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_gravity="bottom|start"
- android:orientation="vertical"
- android:padding="16dp"
- android:background="#a0000000">
-
- <TextView
- android:id="@+id/overlay_target"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:textColor="#ffffff"
- android:textSize="16sp"
- android:fontFamily="monospace"
- android:text="target: ..."/>
-
- <TextView
- android:id="@+id/overlay_stats"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:textColor="#aef0a0"
- android:textSize="16sp"
- android:fontFamily="monospace"
- android:text="v=0 a=0"/>
-
- <TextView
- android:id="@+id/overlay_event"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:textColor="#f0c060"
- android:textSize="16sp"
- android:fontFamily="monospace"
- android:text="event: (idle)"/>
-
- </LinearLayout>
-
-</FrameLayout>
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7e8e599..a6f6154 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -30,6 +30,7 @@
android:roundIcon="@mipmap/ic_launcher"
android:theme="@style/Theme.Scrcpy"
android:allowBackup="false"
+ android:dataExtractionRules="@xml/data_extraction_rules"
android:supportsRtl="false">
<activity
@@ -47,7 +48,6 @@
android:exported="false"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|keyboardHidden"
- android:screenOrientation="unspecified"
android:hardwareAccelerated="true"
android:theme="@style/Theme.Scrcpy.Fullscreen"/>
@@ -57,6 +57,12 @@
android:label="@string/settings"
android:theme="@style/Theme.Scrcpy.NoActionBar"/>
+ <activity
+ android:name=".Licenses"
+ android:exported="false"
+ android:label="@string/licenses"
+ android:theme="@style/Theme.Scrcpy.NoActionBar"/>
+
<service
android:name=".Sessions"
android:exported="false"
diff --git a/app/src/main/java/invalid/lena/scrcpy/AtomicFiles.java b/app/src/main/java/invalid/lena/scrcpy/AtomicFiles.java
index 6f755b6..77235f6 100644
--- a/app/src/main/java/invalid/lena/scrcpy/AtomicFiles.java
+++ b/app/src/main/java/invalid/lena/scrcpy/AtomicFiles.java
@@ -10,7 +10,8 @@ import java.nio.file.StandardCopyOption;
// fsync it, then rename it over the destination. The rename is the only
// mutation a concurrent reader can observe, so a reader sees either the old
// file or the new file in full, never a truncated mix. A crash mid-write
-// leaves at most a stale ".tmp", never a damaged destination.
+// may leave a stale staging file, never a damaged destination. The next
+// write removes stale staging files before creating its own.
//
// Deliberately no fsync of the parent directory: the rename itself may be
// lost on power failure (the old content survives intact). Callers store
@@ -21,20 +22,29 @@ final class AtomicFiles {
private AtomicFiles() {}
- static void write(File dest, byte[] data) throws IOException {
+ static synchronized void write(File dest, byte[] data) throws IOException {
File parent = dest.getAbsoluteFile().getParentFile();
- File tmp = new File(parent, dest.getName() + ".tmp");
- try (FileOutputStream os = new FileOutputStream(tmp)) {
- os.write(data);
- os.flush();
- os.getFD().sync();
+ if (parent == null || !parent.isDirectory()) {
+ throw new IOException("destination parent is not a directory: " + parent);
}
+ String prefix = dest.getName() + ".tmp";
+ File[] stale = parent.listFiles((dir, name) ->
+ name.equals(prefix) || name.startsWith(prefix + "-"));
+ if (stale == null) throw new IOException("cannot list destination parent: " + parent);
+ for (File file : stale) Files.deleteIfExists(file.toPath());
+ File tmp = Files.createTempFile(parent.toPath(), dest.getName() + ".tmp-", null).toFile();
+ boolean moved = false;
try {
+ try (FileOutputStream os = new FileOutputStream(tmp)) {
+ os.write(data);
+ os.flush();
+ os.getFD().sync();
+ }
Files.move(tmp.toPath(), dest.toPath(),
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
- } catch (IOException e) {
- tmp.delete();
- throw e;
+ moved = true;
+ } finally {
+ if (!moved) Files.deleteIfExists(tmp.toPath());
}
}
}
diff --git a/app/src/main/java/invalid/lena/scrcpy/AudioSink.java b/app/src/main/java/invalid/lena/scrcpy/AudioSink.java
index 7f09620..5507c5c 100644
--- a/app/src/main/java/invalid/lena/scrcpy/AudioSink.java
+++ b/app/src/main/java/invalid/lena/scrcpy/AudioSink.java
@@ -14,6 +14,7 @@ import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayDeque;
import java.util.Deque;
+import java.util.concurrent.atomic.AtomicBoolean;
// Audio output: AudioTrack writing 48 kHz stereo 16-bit PCM. The
// upstream feed is either raw PCM (passthrough) or Opus packets
@@ -24,6 +25,7 @@ public final class AudioSink implements AudioFrames {
private static final int CHANNEL_OUT = AudioFormat.CHANNEL_OUT_STEREO;
private static final int ENCODING = AudioFormat.ENCODING_PCM_16BIT;
private static final int MAX_PENDING_OPUS = 16;
+ private static final int MAX_PACKET_BYTES = 256 * 1024;
// Defaults documented at <https://developer.android.com/reference/android/media/MediaCodec#CSD>.
private static final long DEFAULT_PRE_ROLL_NS = 80_000_000L;
@@ -32,16 +34,17 @@ public final class AudioSink implements AudioFrames {
private volatile MediaCodec opusCodec;
private HandlerThread opusThread;
private Handler opusHandler;
- private boolean opusConfigured;
+ private volatile boolean opusConfigured;
private int fourcc;
private final Object lock = new Object();
private final Runnable onFatalError;
+ private final AtomicBoolean fatalReported = new AtomicBoolean();
+ private final AtomicBoolean playbackReported = new AtomicBoolean();
private final Deque<Integer> freeOpusInputs = new ArrayDeque<>();
private final Deque<byte[]> pendingOpus = new ArrayDeque<>(MAX_PENDING_OPUS);
private volatile boolean released;
- public volatile long frames; // public read for the status overlay
private long droppedBytes;
private long lastDropLogMs;
@@ -69,7 +72,17 @@ public final class AudioSink implements AudioFrames {
track = new AudioTrack(
attrs, fmt, bufSize, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE);
+ if (track.getState() != AudioTrack.STATE_INITIALIZED) {
+ track.release();
+ track = null;
+ throw new IllegalStateException("audio sink: AudioTrack failed to initialize");
+ }
track.play();
+ if (track.getPlayState() != AudioTrack.PLAYSTATE_PLAYING) {
+ track.release();
+ track = null;
+ throw new IllegalStateException("audio sink: AudioTrack failed to start");
+ }
Log.i("audio sink: AudioTrack started sr=%d ch=2 buf=%d", SAMPLE_RATE, bufSize);
if (fourcc == Wire.CODEC_OPUS) {
@@ -103,14 +116,15 @@ public final class AudioSink implements AudioFrames {
out.get(pcm);
writePcm(pcm, 0, pcm.length);
}
- } catch (IllegalStateException ignored) {
+ } catch (IllegalStateException e) {
+ reportFatal(e, "audio sink: opus output");
} finally {
try { mc.releaseOutputBuffer(idx, false); }
catch (IllegalStateException ignored) {}
}
}
@Override public void onError(MediaCodec mc, MediaCodec.CodecException e) {
- Log.e(e, "audio sink: opus codec error");
+ reportFatal(e, "audio sink: opus codec error");
}
@Override public void onOutputFormatChanged(MediaCodec mc, MediaFormat f) {
Log.i("audio sink: opus output format %s", f);
@@ -121,12 +135,18 @@ public final class AudioSink implements AudioFrames {
@Override
public void feed(byte[] data, int off, int len, boolean isConfig) {
- if (released || len <= 0) return;
+ if (data == null || off < 0 || len < 0 || off > data.length - len) {
+ throw new IndexOutOfBoundsException("invalid audio frame range");
+ }
+ if (len > MAX_PACKET_BYTES) {
+ reportFatal(null, "audio sink: packet too large (" + len + " bytes)");
+ return;
+ }
+ if (released || len == 0) return;
if (fourcc == Wire.CODEC_OPUS) {
feedOpus(data, off, len, isConfig);
} else {
// Raw PCM: passthrough.
- frames++;
writePcm(data, off, len);
}
}
@@ -137,7 +157,7 @@ public final class AudioSink implements AudioFrames {
// OpusHead is 19 bytes; pre_skip lives at bytes [10..11]. Reject
// anything shorter before indexing into it.
if (len < 19) {
- Log.e("audio sink: opus head too short (%d bytes)", len);
+ reportFatal(null, "audio sink: opus head too short (" + len + " bytes)");
return;
}
try {
@@ -156,7 +176,7 @@ public final class AudioSink implements AudioFrames {
opusConfigured = true;
Log.i("audio sink: opus configured, pre_skip=%d ns", preSkipNs);
} catch (Exception e) {
- Log.e(e, "audio sink: opus configure");
+ reportFatal(e, "audio sink: opus configure");
}
return;
}
@@ -181,16 +201,15 @@ public final class AudioSink implements AudioFrames {
try {
ByteBuffer in = codec.getInputBuffer(idx);
if (in == null || packet.length > in.capacity()) {
- Log.e("audio sink: opus packet exceeds codec input (%d bytes)", packet.length);
- if (onFatalError != null) onFatalError.run();
+ reportFatal(null,
+ "audio sink: opus packet exceeds codec input (" + packet.length + " bytes)");
return;
}
in.clear();
in.put(packet);
codec.queueInputBuffer(idx, 0, packet.length, 0, 0);
- frames++;
} catch (IllegalStateException e) {
- Log.w("audio sink: opus queueInputBuffer: %s", e);
+ reportFatal(e, "audio sink: opus queueInputBuffer");
}
}
@@ -206,9 +225,12 @@ public final class AudioSink implements AudioFrames {
if (released || t == null || len <= 0) return;
int written = t.write(data, off, len, AudioTrack.WRITE_NON_BLOCKING);
if (written < 0) {
- Log.w("audio sink: write rc=%d", written);
+ reportFatal(null, "audio sink: AudioTrack write rc=" + written);
return;
}
+ if (written > 0 && playbackReported.compareAndSet(false, true)) {
+ Log.i("audio sink: playback started");
+ }
if (written < len) {
droppedBytes += (len - written);
long now = System.currentTimeMillis();
@@ -219,6 +241,13 @@ public final class AudioSink implements AudioFrames {
}
}
+ private void reportFatal(Exception error, String message) {
+ if (!fatalReported.compareAndSet(false, true)) return;
+ if (error == null) Log.e("%s", message);
+ else Log.e(error, "%s", message);
+ if (onFatalError != null) onFatalError.run();
+ }
+
@Override
public void release() {
MediaCodec c;
diff --git a/app/src/main/java/invalid/lena/scrcpy/AudioStream.java b/app/src/main/java/invalid/lena/scrcpy/AudioStream.java
index 7fe33f0..13b39f5 100644
--- a/app/src/main/java/invalid/lena/scrcpy/AudioStream.java
+++ b/app/src/main/java/invalid/lena/scrcpy/AudioStream.java
@@ -2,6 +2,7 @@ package invalid.lena.scrcpy;
import java.io.IOException;
import java.io.InputStream;
+import java.util.concurrent.TimeUnit;
// Reads the scrcpy audio socket and drives an AudioFrames sink.
//
@@ -23,16 +24,34 @@ public final class AudioStream {
// Generous upper bound for one audio packet (a raw PCM block or an
// opus packet is a few KB). A corrupt or hostile length field must
// not drive the allocation below.
- private static final int MAX_FRAME_SIZE = 1024 * 1024;
+ private static final int MAX_FRAME_SIZE = 256 * 1024;
private final InputStream source;
private final AudioFrames sink;
+ private final Runnable onFatalError;
private Thread thread;
private volatile boolean stop;
+ // Liveness signal for Session's stall watchdog. The audio socket is
+ // the only one that is reliably continuous: the server captures PCM
+ // at 48 kHz whether or not anything on the target's screen moves, so
+ // silence here means the wire is gone. Video is useless for this - a
+ // static screen queues nothing to the encoder and legitimately
+ // produces no frames for minutes.
+ private volatile boolean started;
+ private volatile long lastPacketAtMs;
+
+ public boolean isStarted() { return started; }
+ public long lastPacketAtMs() { return lastPacketAtMs; }
+
public AudioStream(InputStream source, AudioFrames sink) {
+ this(source, sink, null);
+ }
+
+ public AudioStream(InputStream source, AudioFrames sink, Runnable onFatalError) {
this.source = source;
this.sink = sink;
+ this.onFatalError = onFatalError;
}
public void start() {
@@ -60,16 +79,15 @@ public final class AudioStream {
return;
}
if (fourcc == 1) {
- Log.e("audio: server reports configuration error");
- return;
+ throw new IOException("audio: server reports configuration error");
}
if (fourcc != Wire.CODEC_RAW && fourcc != Wire.CODEC_OPUS) {
- Log.w("audio: unexpected codec %s - keeping silent",
- Wire.fourccName(fourcc));
- return;
+ throw new IOException("audio: unexpected codec " + Wire.fourccName(fourcc));
}
Log.i("audio meta codec=%s", Wire.fourccName(fourcc));
sink.start(fourcc);
+ lastPacketAtMs = monotonicMs();
+ started = true;
byte[] hdr = new byte[12];
byte[] payload = new byte[16 * 1024];
@@ -84,15 +102,28 @@ public final class AudioStream {
}
if (size > payload.length) payload = new byte[size];
Wire.readFully(source, payload, 0, size);
+ lastPacketAtMs = monotonicMs();
sink.feed(payload, 0, size, cfg);
if (++frames == 1) Log.i("audio frame n=1 size=%d cfg=%s", size, cfg);
}
} catch (IOException e) {
- if (!stop) Log.e(e, "audio reader");
+ if (!stop) {
+ Log.e(e, "audio reader");
+ reportFatal();
+ }
} catch (Exception e) {
Log.e(e, "audio reader unexpected");
+ if (!stop) reportFatal();
} finally {
Log.i("audio reader: end");
}
}
+
+ private void reportFatal() {
+ if (onFatalError != null) onFatalError.run();
+ }
+
+ private static long monotonicMs() {
+ return TimeUnit.NANOSECONDS.toMillis(System.nanoTime());
+ }
}
diff --git a/app/src/main/java/invalid/lena/scrcpy/ControlMessages.java b/app/src/main/java/invalid/lena/scrcpy/ControlMessages.java
index aba2ca8..6455a65 100644
--- a/app/src/main/java/invalid/lena/scrcpy/ControlMessages.java
+++ b/app/src/main/java/invalid/lena/scrcpy/ControlMessages.java
@@ -8,28 +8,52 @@ import java.nio.charset.StandardCharsets;
// unit-tested without android.* on the classpath.
public final class ControlMessages {
- static final int MAX_CLIPBOARD_BYTES = 1 << 20;
+ // scrcpy caps a whole control message at MESSAGE_MAX_SIZE = 256 KiB.
+ // For SET_CLIPBOARD that leaves 256 KiB minus the 14-byte header
+ // (type 1, sequence 8, paste flag 1, length 4); see the server's
+ // ControlMessageReader.CLIPBOARD_TEXT_MAX_LENGTH. Going over makes
+ // the server raise ControlProtocolException and drop the control
+ // connection, taking the session with it, so refuse locally instead.
+ static final int MAX_CLIPBOARD_BYTES = (1 << 18) - 14;
+
+ // The reverse direction, from the server's DeviceMessageWriter:
+ // 256 KiB minus its 5-byte header (type 1, length 4). Used to bound
+ // what we are willing to read off the control socket.
+ static final int MAX_DEVICE_CLIPBOARD_BYTES = (1 << 18) - 5;
public static final int TYPE_INJECT_KEYCODE = 0;
- public static final int TYPE_INJECT_TEXT = 1;
public static final int TYPE_INJECT_TOUCH_EVENT = 2;
public static final int TYPE_BACK_OR_SCREEN_ON = 4;
public static final int TYPE_SET_CLIPBOARD = 9;
+ public static final int TYPE_RESET_VIDEO = 17;
// KeyEvent.ACTION_DOWN / ACTION_UP. Mirror the int values rather
// than depend on android.view.KeyEvent so this stays android-free.
public static final int ACTION_DOWN = 0;
public static final int ACTION_UP = 1;
+ // AOSP keycodes, mirrored rather than imported so this stays
+ // android-free. KeyEvent.KEYCODE_HOME / KEYCODE_APP_SWITCH.
+ public static final int KEYCODE_HOME = 3;
+ public static final int KEYCODE_APP_SWITCH = 187;
+
public static final int TOUCH_MSG_LEN = 32; // 1 + 1 + 8 + 4 + 4 + 2 + 2 + 2 + 4 + 4
public static final int KEY_MSG_LEN = 14; // 1 + 1 + 4 + 4 + 4
- public static final int BACK_MSG_LEN = 2; // 1 + 1
private ControlMessages() {}
public static byte[] touch(int action, long pointerId,
int x, int y, int targetW, int targetH,
int pressureU16, int actionButton, int buttons) {
+ if (targetW < 1 || targetW > 0xffff || targetH < 1 || targetH > 0xffff) {
+ throw new IllegalArgumentException("touch target size is out of range");
+ }
+ if (x < 0 || x >= targetW || y < 0 || y >= targetH) {
+ throw new IllegalArgumentException("touch position is out of range");
+ }
+ if (pressureU16 < 0 || pressureU16 > 0xffff) {
+ throw new IllegalArgumentException("touch pressure is out of range");
+ }
byte[] m = new byte[TOUCH_MSG_LEN];
m[0] = TYPE_INJECT_TOUCH_EVENT;
m[1] = (byte) action;
@@ -61,7 +85,14 @@ public final class ControlMessages {
return new byte[]{(byte) TYPE_BACK_OR_SCREEN_ON, (byte) action};
}
+ public static byte[] resetVideo() {
+ return new byte[]{(byte) TYPE_RESET_VIDEO};
+ }
+
public static byte[] setClipboard(long sequence, boolean paste, String text) {
+ // UTF-8 is at least one byte per char, so this rejects the
+ // hopeless cases without encoding a huge string first. The byte
+ // count below is the check that actually matters.
if (text.length() > MAX_CLIPBOARD_BYTES) {
throw new IllegalArgumentException("clipboard text is too large");
}
diff --git a/app/src/main/java/invalid/lena/scrcpy/ControlStream.java b/app/src/main/java/invalid/lena/scrcpy/ControlStream.java
index a231b92..2ea2883 100644
--- a/app/src/main/java/invalid/lena/scrcpy/ControlStream.java
+++ b/app/src/main/java/invalid/lena/scrcpy/ControlStream.java
@@ -3,7 +3,7 @@ package invalid.lena.scrcpy;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
-import java.nio.charset.StandardCharsets;
+import java.util.Objects;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -46,10 +46,6 @@ public final class ControlStream {
private Thread reader;
private volatile boolean stop;
- public ControlStream(InputStream in, OutputStream out) {
- this(in, out, null);
- }
-
public ControlStream(InputStream in, OutputStream out, Runnable onFatalError) {
this.in = in;
this.out = out;
@@ -77,6 +73,7 @@ public final class ControlStream {
}
public void send(byte[] msg) {
+ Objects.requireNonNull(msg);
if (stop) return;
if (outbox.offerLast(msg)) return;
@@ -135,12 +132,12 @@ public final class ControlStream {
case DEV_TYPE_CLIPBOARD: {
Wire.readFully(in, tmp, 0, 4);
int len = Wire.readBe32(tmp, 0);
- if (len < 0 || len > 1 << 20) {
+ if (len < 0 || len > ControlMessages.MAX_DEVICE_CLIPBOARD_BYTES) {
throw new IOException("clipboard len out of range: " + len);
}
byte[] data = new byte[len];
Wire.readFully(in, data);
- String text = new String(data, StandardCharsets.UTF_8);
+ String text = Wire.decodeUtf8(data);
InboundSink s = sink;
if (s != null) s.onRemoteClipboard(text);
break;
diff --git a/app/src/main/java/invalid/lena/scrcpy/Controller.java b/app/src/main/java/invalid/lena/scrcpy/Controller.java
index 242d6a5..3cc321c 100644
--- a/app/src/main/java/invalid/lena/scrcpy/Controller.java
+++ b/app/src/main/java/invalid/lena/scrcpy/Controller.java
@@ -7,6 +7,7 @@ import android.view.KeyEvent;
import android.view.MotionEvent;
import java.util.function.Consumer;
+import java.util.concurrent.atomic.AtomicReference;
// Encodes UI events into scrcpy ControlMessage byte arrays and pushes
// them at ControlStream. Also mirrors the remote clipboard locally and
@@ -17,22 +18,30 @@ import java.util.function.Consumer;
public final class Controller implements ControlStream.InboundSink {
private final Consumer<byte[]> sender;
+ // Null when the user has turned clipboard sync off, which disables
+ // both directions: nothing is read from this device and nothing the
+ // target sends is written to it.
private final ClipboardManager clipboard;
- public volatile String lastEvent = "(idle)";
+ private final boolean clipboardSync;
// Held once so add/removePrimaryClipChangedListener see the same
// listener reference. Method references generate fresh lambdas
// each call site and the remove silently no-ops otherwise.
private final ClipboardManager.OnPrimaryClipChangedListener clipListener =
this::onLocalClipboardChanged;
- private volatile int targetW, targetH;
- private volatile int viewW, viewH;
+ private final TouchGeometry geometry = new TouchGeometry();
- // Suppress one local clipboard change after we set it from a remote update.
- private volatile boolean suppressNextClipChange;
+ // The latest value applied from the target. Keeping the value, rather
+ // than a one-shot boolean, cannot consume an unrelated user clipboard
+ // change when Android delays or omits our own callback.
+ private final AtomicReference<String> remoteClipboardText = new AtomicReference<>();
public Controller(Context ctx, Consumer<byte[]> sender) {
this.sender = sender;
- this.clipboard = (ClipboardManager) ctx.getSystemService(Context.CLIPBOARD_SERVICE);
+ this.clipboardSync = Settings.clipboardSync(ctx);
+ Context app = ctx.getApplicationContext();
+ this.clipboard = clipboardSync
+ ? (ClipboardManager) app.getSystemService(Context.CLIPBOARD_SERVICE)
+ : null;
if (clipboard != null) {
clipboard.addPrimaryClipChangedListener(clipListener);
}
@@ -45,51 +54,68 @@ public final class Controller implements ControlStream.InboundSink {
}
}
- public void setTargetSize(int w, int h) {
- targetW = w; targetH = h;
+ public void setTargetSize(long version, int w, int h) {
+ geometry.setTargetSize(version, w, h);
Log.i("controller: target %dx%d", w, h);
}
- public void setViewSize(int w, int h) {
- viewW = w; viewH = h;
+ // The rectangle the video occupies in the activity window, which is
+ // the coordinate space MotionEvents arrive in.
+ public void setViewport(long version, int x, int y, int w, int h) {
+ geometry.setViewport(version, x, y, w, h);
}
// ---- inbound ----
@Override
public void onRemoteClipboard(String text) {
+ if (!clipboardSync || clipboard == null) {
+ Log.i("clipboard from target ignored: sync is off");
+ return;
+ }
Log.i("clipboard from target: %d chars", text.length());
- if (clipboard == null) return;
- suppressNextClipChange = true;
+ remoteClipboardText.set(text);
try {
clipboard.setPrimaryClip(ClipData.newPlainText("scrcpy-android", text));
} catch (Exception e) {
Log.w("clipboard set local failed: %s", e);
- suppressNextClipChange = false;
+ remoteClipboardText.compareAndSet(text, null);
}
}
private void onLocalClipboardChanged() {
- if (suppressNextClipChange) {
- suppressNextClipChange = false;
- return;
- }
if (clipboard == null) return;
ClipData data;
try { data = clipboard.getPrimaryClip(); }
catch (Exception e) { Log.w("clipboard get local failed: %s", e); return; }
if (data == null || data.getItemCount() == 0) return;
- CharSequence cs = data.getItemAt(0).coerceToText(null);
+ // The scrcpy control protocol carries UTF-8 text, not URI or Intent
+ // clipboard items. Do not coerce URI items: that invokes an arbitrary
+ // local ContentProvider and may materialize unbounded content before
+ // our wire-size check. Explicit text fails closed.
+ CharSequence cs = data.getItemAt(0).getText();
if (cs == null) return;
- sendSetClipboard(cs.toString(), false);
- Log.i("clipboard to target: %d chars", cs.length());
+ String text = cs.toString();
+ if (text.equals(remoteClipboardText.get())) return;
+ remoteClipboardText.set(null);
+ if (sendSetClipboard(text, false)) {
+ Log.i("clipboard to target: %d chars", cs.length());
+ }
+ }
+
+ // Android 10+ denies clipboard reads while an app is not focused. A copy
+ // made in another app therefore cannot be forwarded by the listener at
+ // copy time. Mirror calls this after regaining focus so that ordinary
+ // copy, return-to-mirror is reliable.
+ public void syncLocalClipboard() {
+ onLocalClipboardChanged();
}
// ---- outbound ----
public void onTouch(MotionEvent ev) {
- int tw = targetW, th = targetH, vw = viewW, vh = viewH;
- if (tw == 0 || th == 0 || vw == 0 || vh == 0) return;
+ TouchGeometry.Snapshot g = geometry.snapshot();
+ if (g == null) return;
// scrcpy's wire protocol uses ACTION_DOWN/UP/MOVE/CANCEL with a
// pointerId per message. The server tracks which pointers are
@@ -97,57 +123,59 @@ public final class Controller implements ControlStream.InboundSink {
// ACTION_POINTER_DOWN[i] -> ACTION_DOWN (this pointer joins)
// ACTION_POINTER_UP[i] -> ACTION_UP (this pointer leaves)
// ACTION_MOVE -> ACTION_MOVE for every current pointer
- // ACTION_CANCEL -> ACTION_CANCEL for every current pointer
+ // ACTION_CANCEL -> ACTION_UP for every current pointer
int action = ev.getActionMasked();
int idx = ev.getActionIndex();
int n = ev.getPointerCount();
switch (action) {
case MotionEvent.ACTION_DOWN:
- sendPointer(ev, 0, MotionEvent.ACTION_DOWN, tw, th, vw, vh);
+ sendPointer(ev, 0, MotionEvent.ACTION_DOWN, g);
break;
case MotionEvent.ACTION_POINTER_DOWN:
- sendPointer(ev, idx, MotionEvent.ACTION_DOWN, tw, th, vw, vh);
+ sendPointer(ev, idx, MotionEvent.ACTION_DOWN, g);
break;
case MotionEvent.ACTION_UP:
- sendPointer(ev, 0, MotionEvent.ACTION_UP, tw, th, vw, vh);
+ sendPointer(ev, 0, MotionEvent.ACTION_UP, g);
break;
case MotionEvent.ACTION_POINTER_UP:
- sendPointer(ev, idx, MotionEvent.ACTION_UP, tw, th, vw, vh);
+ sendPointer(ev, idx, MotionEvent.ACTION_UP, g);
break;
case MotionEvent.ACTION_MOVE:
- for (int i = 0; i < n; i++) sendPointer(ev, i, MotionEvent.ACTION_MOVE, tw, th, vw, vh);
+ for (int i = 0; i < n; i++) sendPointer(ev, i, MotionEvent.ACTION_MOVE, g);
break;
case MotionEvent.ACTION_CANCEL:
- for (int i = 0; i < n; i++) sendPointer(ev, i, MotionEvent.ACTION_CANCEL, tw, th, vw, vh);
+ // Sent as UP, not CANCEL. The server releases a pointer
+ // only on ACTION_UP (Controller.injectTouch calls
+ // pointer.setUp(action == ACTION_UP)), so a forwarded
+ // CANCEL leaves it down in PointersState for the rest of
+ // the session and every later touch behaves as an extra
+ // finger. Cancels are routine on the source device: an
+ // edge swipe or the notification shade stealing the
+ // gesture produces one.
+ for (int i = 0; i < n; i++) sendPointer(ev, i, MotionEvent.ACTION_UP, g);
break;
default:
return;
}
}
- // Sizes come from onTouch's snapshot of the volatile fields, so a
- // concurrent resize cannot zero a divisor between check and use.
private void sendPointer(MotionEvent ev, int index, int action,
- int tw, int th, int vw, int vh) {
+ TouchGeometry.Snapshot g) {
long pointerId = ev.getPointerId(index);
- int x = (int) ev.getX(index);
- int y = (int) ev.getY(index);
- int tx = (int) ((long) x * tw / vw);
- int ty = (int) ((long) y * th / vh);
+ int tx = TouchMap.map((int) ev.getX(index), g.x, g.w, g.targetW);
+ int ty = TouchMap.map((int) ev.getY(index), g.y, g.h, g.targetH);
// getPressure is calibrated around 1.0 but may exceed it on some
// digitizers; clamp instead of masking so hard presses don't wrap
// around to a light touch.
int pressure = (action == MotionEvent.ACTION_UP) ? 0
- : Math.min((int)(ev.getPressure(index) * 0xffff), 0xffff);
- sender.accept(ControlMessages.touch(action, pointerId, tx, ty, tw, th,
- pressure, /* actionButton */ 0, /* buttons */ 0));
- lastEvent = "touch a=" + action + " (" + tx + "," + ty + ")";
+ : Math.max(0, Math.min((int)(ev.getPressure(index) * 0xffff), 0xffff));
+ sender.accept(ControlMessages.touch(action, pointerId, tx, ty,
+ g.targetW, g.targetH, pressure, /* actionButton */ 0, /* buttons */ 0));
}
public void onKey(KeyEvent ev) {
- int tw = targetW, th = targetH;
- if (tw == 0 || th == 0) return;
+ if (geometry.snapshot() == null) return;
int action = ev.getAction(); // ACTION_DOWN=0, ACTION_UP=1
if (action != KeyEvent.ACTION_DOWN && action != KeyEvent.ACTION_UP) return;
sendKeycode(action, ev.getKeyCode(), ev.getRepeatCount(), ev.getMetaState());
@@ -159,22 +187,43 @@ public final class Controller implements ControlStream.InboundSink {
public void onBack() {
sender.accept(ControlMessages.backOrScreenOn(ControlMessages.ACTION_DOWN));
sender.accept(ControlMessages.backOrScreenOn(ControlMessages.ACTION_UP));
- lastEvent = "back";
+ }
+
+ // Home and Recents go as keycodes, not as gestures. On a
+ // gesture-navigation source a swipe from the bottom edge is claimed
+ // by the source's own gesture detector and never reaches this app,
+ // and unlike the side edges that area cannot be released with
+ // setSystemGestureExclusionRects - the system always keeps it. So
+ // the target's navigation is unreachable by forwarding touches, on
+ // any modern source device, and has to be driven explicitly.
+ public void onHome() {
+ sendKeycode(ControlMessages.ACTION_DOWN, ControlMessages.KEYCODE_HOME, 0, 0);
+ sendKeycode(ControlMessages.ACTION_UP, ControlMessages.KEYCODE_HOME, 0, 0);
+ }
+
+ public void onRecents() {
+ sendKeycode(ControlMessages.ACTION_DOWN, ControlMessages.KEYCODE_APP_SWITCH, 0, 0);
+ sendKeycode(ControlMessages.ACTION_UP, ControlMessages.KEYCODE_APP_SWITCH, 0, 0);
+ }
+
+ public void resetVideo() {
+ sender.accept(ControlMessages.resetVideo());
+ Log.i("controller: reset video");
}
// ---- encoders (delegate to pure-java ControlMessages) ----
private void sendKeycode(int action, int keycode, int repeat, int metaState) {
sender.accept(ControlMessages.keycode(action, keycode, repeat, metaState));
- lastEvent = "key a=" + action + " kc=" + keycode;
}
- private void sendSetClipboard(String text, boolean paste) {
+ private boolean sendSetClipboard(String text, boolean paste) {
try {
sender.accept(ControlMessages.setClipboard(/* sequence */ 0L, paste, text));
- lastEvent = "clip " + text.length() + " chars";
+ return true;
} catch (IllegalArgumentException e) {
Log.w("clipboard not sent: %s", e.getMessage());
+ return false;
}
}
}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Crashlog.java b/app/src/main/java/invalid/lena/scrcpy/Crashlog.java
index 00bd3d8..705d191 100644
--- a/app/src/main/java/invalid/lena/scrcpy/Crashlog.java
+++ b/app/src/main/java/invalid/lena/scrcpy/Crashlog.java
@@ -4,8 +4,11 @@ import android.content.Context;
import java.io.File;
import java.io.FileWriter;
+import java.io.IOException;
import java.io.PrintWriter;
+import java.nio.file.Files;
import java.text.SimpleDateFormat;
+import java.util.Arrays;
import java.util.Date;
import java.util.Locale;
@@ -16,6 +19,8 @@ import java.util.Locale;
// fishing for it in adb logcat.
public final class Crashlog {
+ private static final int MAX_LOGS = 5;
+
private Crashlog() {}
public static void install(Context ctx) {
@@ -24,19 +29,25 @@ public final class Crashlog {
Log.w("crashlog: no external storage, skipping install");
return;
}
+ try {
+ prune(dir, MAX_LOGS);
+ } catch (IOException e) {
+ Log.w("crashlog: prune failed: %s", e);
+ }
Thread.UncaughtExceptionHandler prev =
Thread.getDefaultUncaughtExceptionHandler();
Thread.setDefaultUncaughtExceptionHandler((t, e) -> {
try {
- String ts = new SimpleDateFormat("yyyyMMdd-HHmmss",
+ String ts = new SimpleDateFormat("yyyyMMdd-HHmmss-SSS",
Locale.ROOT).format(new Date());
- File out = new File(dir, "crash-" + ts + ".log");
+ File out = File.createTempFile("crash-" + ts + "-", ".log", dir);
try (PrintWriter pw = new PrintWriter(new FileWriter(out))) {
pw.println("# " + new Date());
pw.println("# thread=" + t.getName());
pw.println();
e.printStackTrace(pw);
}
+ prune(dir, MAX_LOGS);
Log.e(e, "crashlog: wrote %s", out.getAbsolutePath());
} catch (Throwable ignored) {
// best effort - do not mask the original crash
@@ -45,4 +56,18 @@ public final class Crashlog {
});
Log.i("crashlog: installed -> %s", dir.getAbsolutePath());
}
+
+ static void prune(File dir, int keep) throws IOException {
+ if (keep < 0) throw new IllegalArgumentException("negative retention");
+ File[] logs = dir.listFiles((parent, name) ->
+ name.startsWith("crash-") && name.endsWith(".log"));
+ if (logs == null) throw new IOException("cannot list crashlog directory: " + dir);
+ Arrays.sort(logs, (left, right) -> {
+ int modified = Long.compare(right.lastModified(), left.lastModified());
+ return modified != 0 ? modified : right.getName().compareTo(left.getName());
+ });
+ for (int i = keep; i < logs.length; i++) {
+ Files.deleteIfExists(logs[i].toPath());
+ }
+ }
}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Devices.java b/app/src/main/java/invalid/lena/scrcpy/Devices.java
index 0ba61a3..9ec308c 100644
--- a/app/src/main/java/invalid/lena/scrcpy/Devices.java
+++ b/app/src/main/java/invalid/lena/scrcpy/Devices.java
@@ -5,10 +5,12 @@ import android.content.Context;
import org.json.JSONArray;
import org.json.JSONObject;
+import java.io.ByteArrayOutputStream;
import java.io.File;
+import java.io.FileInputStream;
import java.io.IOException;
+import java.io.InputStream;
import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
@@ -20,6 +22,8 @@ import java.util.List;
public final class Devices {
private static final String FILE = "devices.json";
+ private static final long MAX_FILE_BYTES = 1024 * 1024;
+ private static final int MAX_HOST_CHARS = 255;
public static final class Device {
public final String host;
@@ -74,7 +78,7 @@ public final class Devices {
port = s.substring(colon + 1);
}
int p = parsePort(port);
- if (host.isEmpty() || p < 0) return null;
+ if (!validHost(host) || p < 0) return null;
return new Device(host, p);
}
@@ -99,13 +103,29 @@ public final class Devices {
json = json.trim();
if (json.isEmpty()) return out;
JSONArray arr;
- try { arr = new JSONArray(json); }
- catch (Exception e) { Log.e(e, "devices: not a json array"); return out; }
+ try {
+ arr = new JSONArray(json);
+ } catch (Exception e) {
+ throw new IllegalArgumentException("devices: expected a JSON array", e);
+ }
for (int i = 0; i < arr.length(); i++) {
try {
JSONObject o = arr.getJSONObject(i);
- out.add(new Device(o.getString("host"), o.getInt("port")));
+ String host = o.getString("host");
+ Object portValue = o.get("port");
+ if (!(portValue instanceof Integer) && !(portValue instanceof Long)) {
+ throw new IllegalArgumentException("port is not an integer");
+ }
+ long portLong = ((Number) portValue).longValue();
+ if (portLong < 1 || portLong > 65535) {
+ throw new IllegalArgumentException("port is invalid");
+ }
+ int port = (int) portLong;
+ if (!validHost(host) || port < 1 || port > 65535) {
+ throw new IllegalArgumentException("invalid device fields");
+ }
+ out.add(new Device(host, port));
} catch (Exception e) {
Log.w("devices: skipping malformed row %d: %s", i, e);
}
@@ -117,6 +137,9 @@ public final class Devices {
try {
JSONArray arr = new JSONArray();
for (Device d : devices) {
+ if (!validHost(d.host) || d.port < 1 || d.port > 65535) {
+ throw new IllegalArgumentException("invalid saved device");
+ }
JSONObject o = new JSONObject();
o.put("host", d.host);
o.put("port", d.port);
@@ -124,23 +147,35 @@ public final class Devices {
}
return arr.toString(2);
} catch (Exception e) {
- Log.e(e, "devices: serialize failed");
- return "[]";
+ throw new IllegalStateException("devices: serialize failed", e);
}
}
- public static List<Device> load(Context ctx) {
+ public static List<Device> load(Context ctx) throws IOException {
File f = new File(ctx.getFilesDir(), FILE);
if (!f.exists()) return new ArrayList<>();
try {
- return parse(new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8));
+ if (f.length() > MAX_FILE_BYTES) {
+ throw new IOException("device list is too large: " + f.length());
+ }
+ byte[] data = readLimited(f);
+ if (data.length == 0) throw new IOException("device list is empty");
+ return parse(Wire.decodeUtf8(data));
} catch (Exception e) {
- Log.e(e, "devices: load failed");
- return new ArrayList<>();
+ if (e instanceof IOException) throw (IOException) e;
+ throw new IOException("devices: load failed", e);
}
}
- public static void save(Context ctx, List<Device> devices) throws IOException {
+ // The saved row for an address, or null if there is none.
+ public static Device find(Context ctx, String host, int port) throws IOException {
+ for (Device d : load(ctx)) {
+ if (d.port == port && d.host.equals(host)) return d;
+ }
+ return null;
+ }
+
+ private static void save(Context ctx, List<Device> devices) throws IOException {
File f = new File(ctx.getFilesDir(), FILE);
AtomicFiles.write(f, serialize(devices).getBytes(StandardCharsets.UTF_8));
}
@@ -162,4 +197,31 @@ public final class Devices {
save(ctx, list);
return list;
}
+
+ private static boolean validHost(String host) {
+ if (host == null || host.isEmpty() || host.length() > MAX_HOST_CHARS) return false;
+ for (int i = 0; i < host.length(); i++) {
+ char c = host.charAt(i);
+ if (Character.isWhitespace(c) || Character.isISOControl(c) || c == '[' || c == ']') {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static byte[] readLimited(File file) throws IOException {
+ try (InputStream in = new FileInputStream(file);
+ ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+ byte[] buf = new byte[8192];
+ long total = 0;
+ for (;;) {
+ int n = in.read(buf);
+ if (n < 0) return out.toByteArray();
+ if (n == 0) throw new IOException("device list read made no progress");
+ total += n;
+ if (total > MAX_FILE_BYTES) throw new IOException("device list is too large");
+ out.write(buf, 0, n);
+ }
+ }
+ }
}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Licenses.java b/app/src/main/java/invalid/lena/scrcpy/Licenses.java
new file mode 100644
index 0000000..8e66912
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/Licenses.java
@@ -0,0 +1,48 @@
+package invalid.lena.scrcpy;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.view.WindowInsets;
+import android.widget.TextView;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+// Shows the bundled third-party notices.
+//
+// This is a legal requirement, not a courtesy. The APK links
+// libspake2.so, which is LGPL-3.0: section 4 lets an LGPL library be
+// combined into a differently-licensed work only if the combined work
+// gives prominent notice that the library is used and is covered by the
+// LGPL, and points at the source needed to relink it. Shipping that text
+// as an asset no code reads is not notice. This screen is what makes it
+// notice.
+public final class Licenses extends Activity {
+
+ private static final String ASSET = "THIRD_PARTY_NOTICES";
+
+ @Override
+ protected void onCreate(Bundle saved) {
+ super.onCreate(saved);
+ setContentView(R.layout.licenses);
+ Ui.padForInsets(findViewById(R.id.root), WindowInsets.Type.systemBars());
+ ((TextView) findViewById(R.id.notices)).setText(read());
+ }
+
+ private String read() {
+ try (InputStream in = getAssets().open(ASSET)) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buf = new byte[8192];
+ for (int n; (n = in.read(buf)) >= 0; ) {
+ if (n == 0) throw new IOException("license asset read made no progress");
+ out.write(buf, 0, n);
+ }
+ return out.toString(StandardCharsets.UTF_8.name());
+ } catch (IOException e) {
+ Log.e(e, "licenses: cannot read %s", ASSET);
+ return getString(R.string.licenses_unavailable);
+ }
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Main.java b/app/src/main/java/invalid/lena/scrcpy/Main.java
index c069850..24126e9 100644
--- a/app/src/main/java/invalid/lena/scrcpy/Main.java
+++ b/app/src/main/java/invalid/lena/scrcpy/Main.java
@@ -7,13 +7,13 @@ import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.view.WindowInsets;
-import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.EditText;
-import android.widget.ListView;
+import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
+import java.io.IOException;
import java.util.List;
// Pairing form + saved-device list.
@@ -23,14 +23,13 @@ import java.util.List;
// the target's Wireless debugging screen; pairing happens on a different,
// short-lived port from the pairing dialog, against the same host. After a
// successful pair() against the daemon, the row is appended to devices.json
-// with the *connect* port. Tapping a saved row launches the Mirror activity
-// with the target host/port; Mirror owns its own Adb instance loaded from
-// the same on-disk keypair.
+// with the *connect* port. Tapping a saved row launches the Mirror activity.
public final class Main extends Activity {
private volatile Adb adb;
- private ArrayAdapter<Devices.Device> adapter;
- private Button pairButton;
+ private LinearLayout deviceList;
+ private TextView devicesEmpty;
+ private Button pairButton;
@Override
protected void onCreate(Bundle saved) {
@@ -38,8 +37,9 @@ public final class Main extends Activity {
setContentView(R.layout.main);
// The IME inset is in the mask too: the window is edge-to-edge, so
- // nothing resizes it when the keyboard opens. Padding the root
- // shrinks the weighted device list and keeps the form reachable.
+ // nothing resizes it when the keyboard opens. The page is one
+ // ScrollView, so padding the root keeps the form reachable behind
+ // the keyboard.
Ui.padForInsets(findViewById(R.id.root),
WindowInsets.Type.systemBars() | WindowInsets.Type.ime());
@@ -48,28 +48,34 @@ public final class Main extends Activity {
EditText pairCode = findViewById(R.id.pair_code);
pairButton = findViewById(R.id.pair);
View settingsBtn = findViewById(R.id.settings);
- ListView devices = findViewById(R.id.devices);
- TextView devicesEmpty = findViewById(R.id.devices_empty);
+ deviceList = findViewById(R.id.devices);
+ devicesEmpty = findViewById(R.id.devices_empty);
settingsBtn.setOnClickListener(v -> startActivity(
new Intent(this, SettingsActivity.class)));
- adapter = new ArrayAdapter<>(this, R.layout.device_row, R.id.device_label, Devices.load(this));
- devices.setAdapter(adapter);
- devices.setEmptyView(devicesEmpty);
+ try {
+ showDevices(Devices.load(this));
+ } catch (IOException e) {
+ Log.e(e, "devices: load failed");
+ showDevices(java.util.Collections.emptyList());
+ Toast.makeText(this, R.string.device_list_unreadable, Toast.LENGTH_LONG).show();
+ }
// RSA keygen on first launch can take 1-3 s; never on the UI thread.
pairButton.setEnabled(false);
new Thread(() -> {
try {
- Adb a = Adb.getInstance(this);
+ Adb a = Adb.getInstance(getApplicationContext());
runOnUiThread(() -> {
+ if (isFinishing() || isDestroyed()) return;
adb = a;
pairButton.setEnabled(true);
});
} catch (Exception e) {
Log.e(e, "adb init failed");
runOnUiThread(() -> {
+ if (isFinishing() || isDestroyed()) return;
Toast.makeText(this, "adb init failed: " + e.getMessage(),
Toast.LENGTH_LONG).show();
finish();
@@ -77,38 +83,6 @@ public final class Main extends Activity {
}
}, "adb-init").start();
- devices.setOnItemClickListener((parent, view, pos, id) -> {
- Devices.Device d = adapter.getItem(pos);
- Log.i("connect tap: %s", d);
- Intent i = new Intent(this, Mirror.class);
- i.putExtra(Mirror.EXTRA_HOST, d.host);
- i.putExtra(Mirror.EXTRA_PORT, d.port);
- startActivity(i);
- });
-
- devices.setOnItemLongClickListener((parent, view, pos, id) -> {
- Devices.Device d = adapter.getItem(pos);
- new AlertDialog.Builder(this)
- .setTitle(R.string.forget_device)
- .setMessage(d.toString())
- .setPositiveButton(android.R.string.ok, (dlg, w) -> {
- Log.i("forget device: %s", d);
- try {
- List<Devices.Device> updated = Devices.remove(this, d);
- adapter.clear();
- adapter.addAll(updated);
- adapter.notifyDataSetChanged();
- } catch (Exception e) {
- Log.e(e, "forget device: save failed");
- Toast.makeText(this, "could not save device list",
- Toast.LENGTH_LONG).show();
- }
- })
- .setNegativeButton(android.R.string.cancel, null)
- .show();
- return true;
- });
-
pairButton.setOnClickListener(v -> {
if (adb == null) return; // still initialising
// The saved endpoint is the address field verbatim; pairing
@@ -124,7 +98,7 @@ public final class Main extends Activity {
return;
}
String pc = pairCode.getText().toString().trim();
- if (TextUtils.isEmpty(pc)) {
+ if (TextUtils.isEmpty(pc) || !pc.matches("[0-9]{6}")) {
Toast.makeText(this, R.string.bad_pair_code, Toast.LENGTH_LONG).show();
return;
}
@@ -134,22 +108,61 @@ public final class Main extends Activity {
});
}
+ // Rebuild the saved-device rows. There is no adapter: the whole page
+ // is one ScrollView, a ListView cannot measure itself inside one, and
+ // a handful of rows does not need recycling.
+ private void showDevices(List<Devices.Device> devices) {
+ deviceList.removeAllViews();
+ devicesEmpty.setVisibility(devices.isEmpty() ? View.VISIBLE : View.GONE);
+ for (Devices.Device d : devices) {
+ View row = getLayoutInflater().inflate(R.layout.device_row, deviceList, false);
+ ((TextView) row.findViewById(R.id.device_label)).setText(d.toString());
+ row.setOnClickListener(v -> {
+ Log.i("connect tap: %s", d);
+ Intent i = new Intent(this, Mirror.class);
+ i.putExtra(Mirror.EXTRA_HOST, d.host);
+ i.putExtra(Mirror.EXTRA_PORT, d.port);
+ startActivity(i);
+ });
+ row.setOnLongClickListener(v -> { confirmForget(d); return true; });
+ deviceList.addView(row);
+ }
+ }
+
+ private void confirmForget(Devices.Device d) {
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.forget_device)
+ .setMessage(d.toString())
+ .setPositiveButton(android.R.string.ok, (dlg, w) -> {
+ Log.i("forget device: %s", d);
+ try {
+ showDevices(Devices.remove(this, d));
+ } catch (Exception e) {
+ Log.e(e, "forget device: save failed");
+ Toast.makeText(this, "could not save device list",
+ Toast.LENGTH_LONG).show();
+ }
+ })
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+ }
+
private void pairAndSave(Devices.Device target, int pairPort, String code, Button btn) {
try {
Log.i("pair: %s:%d", target.host, pairPort);
adb.pairDevice(target.host, pairPort, code);
Log.i("pair ok host=%s pair_port=%d", target.host, pairPort);
- List<Devices.Device> updated = Devices.upsert(this, target);
+ List<Devices.Device> updated = Devices.upsert(getApplicationContext(), target);
runOnUiThread(() -> {
- adapter.clear();
- adapter.addAll(updated);
- adapter.notifyDataSetChanged();
+ if (isFinishing() || isDestroyed()) return;
+ showDevices(updated);
Toast.makeText(this, "paired and saved", Toast.LENGTH_SHORT).show();
btn.setEnabled(true);
});
} catch (Exception e) {
Log.e(e, "pair failed");
runOnUiThread(() -> {
+ if (isFinishing() || isDestroyed()) return;
Toast.makeText(this, "pair failed: " + e.getMessage(), Toast.LENGTH_LONG).show();
btn.setEnabled(true);
});
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;
diff --git a/app/src/main/java/invalid/lena/scrcpy/MuxRecorder.java b/app/src/main/java/invalid/lena/scrcpy/MuxRecorder.java
deleted file mode 100644
index 6631f66..0000000
--- a/app/src/main/java/invalid/lena/scrcpy/MuxRecorder.java
+++ /dev/null
@@ -1,226 +0,0 @@
-package invalid.lena.scrcpy;
-
-import android.media.MediaCodec;
-import android.media.MediaFormat;
-import android.media.MediaMuxer;
-
-import java.io.File;
-import java.io.IOException;
-import java.nio.ByteBuffer;
-
-// MP4 muxer that taps the H.264/H.265/AV1 elementary stream coming
-// straight from the scrcpy server - no re-encode, the bytes go onto
-// disk verbatim. AV1 muxing into MP4 needs Android 11+ (MediaMuxer
-// learned the codec there); we are minSdk 31 so that's fine.
-//
-// State machine: IDLE → (arm) → ARMED → (next keyframe) → RECORDING
-// → (stop) → IDLE
-//
-// Ordering:
-// onMeta(fcc, w, h) from VideoStream session-meta packets.
-// onFrame(...) every frame, in wire order. Config frames
-// carry SPS/PPS; we cache the most recent one
-// as csd-0 for MediaMuxer.addTrack.
-//
-// Resize during recording closes the file (addTrack-after-start is
-// illegal). User can re-arm; a fresh output gets the new dimensions.
-public final class MuxRecorder implements VideoRecorder {
-
- private enum State { IDLE, ARMED, RECORDING }
-
- private final Object lock = new Object();
-
- private State state = State.IDLE;
- private int fourcc, w, h;
- private byte[] csd;
-
- private File outFile;
- private MediaMuxer muxer;
- private int trackIdx = -1;
- private long bytesWritten;
- private long firstPtsUs = -1;
- private long lastPtsUs = -1;
-
- // Externally-driven controls.
-
- public void arm(File out) {
- synchronized (lock) {
- if (state != State.IDLE) {
- Log.w("rec: arm ignored in state %s", state);
- return;
- }
- outFile = out;
- state = State.ARMED;
- firstPtsUs = -1;
- lastPtsUs = -1;
- bytesWritten = 0;
- }
- Log.i("rec: armed -> %s", out);
- }
-
- // Returns true if a recording was actually written (a keyframe landed
- // and the muxer ran); false if we were still ARMED, so the caller can
- // tell the user the truth instead of claiming a file was saved.
- public boolean stop() {
- long bytes;
- boolean wrote;
- synchronized (lock) {
- if (state == State.IDLE) return false;
- wrote = state == State.RECORDING && closeMuxerLocked();
- if (!wrote) deleteOutputLocked();
- bytes = bytesWritten;
- state = State.IDLE;
- outFile = null;
- }
- Log.i("rec: stopped (%d bytes)", bytes);
- return wrote;
- }
-
- public boolean isActive() {
- synchronized (lock) {
- return state == State.ARMED || state == State.RECORDING;
- }
- }
-
- // VideoRecorder callbacks.
-
- @Override
- public void onMeta(int fcc, int width, int height) {
- synchronized (lock) {
- // Resize during recording → close the file. addTrack after
- // start is illegal; we don't try to splice tracks together.
- // The user can re-arm; a fresh output gets the new dimensions.
- if (state == State.RECORDING && (fcc != fourcc || width != w || height != h)) {
- Log.w("rec: resize/codec change during recording - closing (%d bytes)",
- bytesWritten);
- closeMuxerLocked();
- state = State.IDLE;
- outFile = null;
- }
- fourcc = fcc; w = width; h = height;
- }
- }
-
- @Override
- public void onFrame(byte[] data, long ptsUs, boolean isConfig, boolean isKeyframe) {
- if (isConfig) {
- // Cache the latest CSD (SPS/PPS for h264 - vendor packs as
- // Annex-B NAL units, exactly what MediaMuxer wants in csd-0
- // for AVC/HEVC/AV1).
- synchronized (lock) {
- csd = data.clone();
- }
- return;
- }
- synchronized (lock) {
- switch (state) {
- case IDLE:
- return;
- case ARMED:
- if (!isKeyframe || csd == null) return;
- if (!startMuxerLocked()) {
- failLocked("start");
- return;
- }
- if (!writeSampleLocked(data, ptsUs, true)) {
- failLocked("write");
- return;
- }
- state = State.RECORDING;
- return;
- case RECORDING:
- if (!writeSampleLocked(data, ptsUs, isKeyframe)) failLocked("write");
- }
- }
- }
-
- @Override
- public void close() {
- stop();
- }
-
- // --- internals; all called with `lock` held ---
-
- private boolean startMuxerLocked() {
- String mime = mimeFor(fourcc);
- if (mime == null) {
- Log.e("rec: unsupported codec %s", Wire.fourccName(fourcc));
- return false;
- }
- try {
- muxer = new MediaMuxer(outFile.getAbsolutePath(),
- MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
- MediaFormat fmt = MediaFormat.createVideoFormat(mime, w, h);
- fmt.setByteBuffer("csd-0", ByteBuffer.wrap(csd));
- trackIdx = muxer.addTrack(fmt);
- muxer.start();
- Log.i("rec: muxer start %s %dx%d -> %s", mime, w, h, outFile);
- return true;
- } catch (IOException | IllegalStateException e) {
- Log.e(e, "rec: muxer start failed");
- try { if (muxer != null) muxer.release(); } catch (Exception ignored) {}
- muxer = null;
- trackIdx = -1;
- return false;
- }
- }
-
- private boolean writeSampleLocked(byte[] data, long ptsUs, boolean isKeyframe) {
- if (muxer == null) return false;
- // PTS is rebased to zero so the file is self-contained; some
- // players choke on absolute PTS that doesn't start near zero.
- if (firstPtsUs < 0) firstPtsUs = ptsUs;
- long rebased = ptsUs - firstPtsUs;
- if (rebased < 0) rebased = 0;
- // Monotonic guard - MediaMuxer fails the write if pts goes back.
- if (rebased <= lastPtsUs) rebased = lastPtsUs + 1;
- lastPtsUs = rebased;
-
- try {
- ByteBuffer buf = ByteBuffer.wrap(data);
- MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
- info.set(0, data.length, rebased,
- isKeyframe ? MediaCodec.BUFFER_FLAG_KEY_FRAME : 0);
- muxer.writeSampleData(trackIdx, buf, info);
- bytesWritten += data.length;
- return true;
- } catch (IllegalStateException e) {
- Log.w("rec: writeSampleData: %s", e);
- return false;
- }
- }
-
- private boolean closeMuxerLocked() {
- MediaMuxer m = muxer;
- muxer = null;
- trackIdx = -1;
- if (m == null) return false;
- boolean ok = true;
- try { m.stop(); } catch (Exception e) { ok = false; Log.w("rec: muxer stop: %s", e); }
- try { m.release(); } catch (Exception ignored) {}
- return ok;
- }
-
- private void failLocked(String operation) {
- Log.e("rec: %s failed, discarding output", operation);
- closeMuxerLocked();
- deleteOutputLocked();
- state = State.IDLE;
- outFile = null;
- }
-
- private void deleteOutputLocked() {
- if (outFile != null && outFile.exists() && !outFile.delete()) {
- Log.w("rec: could not delete incomplete output %s", outFile);
- }
- }
-
- private static String mimeFor(int fourcc) {
- switch (fourcc) {
- case Wire.CODEC_H264: return MediaFormat.MIMETYPE_VIDEO_AVC;
- case Wire.CODEC_H265: return MediaFormat.MIMETYPE_VIDEO_HEVC;
- case Wire.CODEC_AV1: return MediaFormat.MIMETYPE_VIDEO_AV1;
- default: return null;
- }
- }
-}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Server.java b/app/src/main/java/invalid/lena/scrcpy/Server.java
index 284dd6c..dbc6738 100644
--- a/app/src/main/java/invalid/lena/scrcpy/Server.java
+++ b/app/src/main/java/invalid/lena/scrcpy/Server.java
@@ -2,16 +2,15 @@ package invalid.lena.scrcpy;
import android.content.Context;
-import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
-import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.ConnectException;
-import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
import io.github.muntashirakon.adb.AdbStream;
@@ -43,17 +42,12 @@ public final class Server {
public final AdbStream videoAds, audioAds, controlAds;
public final InputStream videoIn, audioIn, controlIn;
public final OutputStream controlOut;
- public final String deviceName;
- public final String scid;
- public final String version;
Streams(AdbStream va, AdbStream aa, AdbStream ca,
- InputStream vi, InputStream ai, InputStream ci, OutputStream co,
- String name, String scid, String version) {
+ InputStream vi, InputStream ai, InputStream ci, OutputStream co) {
this.videoAds = va; this.audioAds = aa; this.controlAds = ca;
this.videoIn = vi; this.audioIn = ai; this.controlIn = ci;
this.controlOut = co;
- this.deviceName = name; this.scid = scid; this.version = version;
}
}
@@ -101,9 +95,8 @@ public final class Server {
InputStream ci = ca.openInputStream();
OutputStream co = ca.openOutputStream();
- String name = readDeviceMeta(vi);
- Log.i("device name=%s", name);
- streams = new Streams(va, aa, ca, vi, ai, ci, co, name, scid, version);
+ Log.i("device name=%s", readDeviceMeta(vi));
+ streams = new Streams(va, aa, ca, vi, ai, ci, co);
committed = true;
return streams;
} finally {
@@ -140,8 +133,11 @@ public final class Server {
shellPump = null;
closeQuietly(s);
if (t != null) {
- try { t.join(CLOSE_GRACE_MS); }
- catch (InterruptedException ignored) {}
+ try {
+ t.join(CLOSE_GRACE_MS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
if (t.isAlive()) t.interrupt();
}
}
@@ -158,11 +154,19 @@ public final class Server {
private String readVersion() throws IOException {
try (InputStream in = ctx.getAssets().open(ASSET_VERSION)) {
byte[] buf = new byte[64];
- int n = 0, r;
- while ((r = in.read(buf, n, buf.length - n)) > 0) n += r;
- String v = new String(buf, 0, n, StandardCharsets.UTF_8).trim();
- if (v.isEmpty()) {
- throw new IOException("scrcpy-server.version is empty");
+ int n = 0;
+ while (n < buf.length) {
+ int r = in.read(buf, n, buf.length - n);
+ if (r < 0) break;
+ if (r == 0) throw new IOException("scrcpy-server.version read made no progress");
+ n += r;
+ }
+ if (n == buf.length && in.read() >= 0) {
+ throw new IOException("scrcpy-server.version is too long");
+ }
+ String v = Wire.decodeUtf8(buf, 0, n).trim();
+ if (!v.matches("[0-9]+(\\.[0-9]+)*")) {
+ throw new IOException("scrcpy-server.version is invalid");
}
return v;
}
@@ -171,7 +175,7 @@ public final class Server {
private static String newScid() {
// 31-bit random, 8 lowercase hex chars - matches scrcpy upstream client.
int v = new SecureRandom().nextInt() & 0x7fffffff;
- return String.format("%08x", v);
+ return String.format(Locale.ROOT, "%08x", v);
}
private String buildCmdline(String version, String scid) {
@@ -196,7 +200,9 @@ public final class Server {
args.add("max_size=" + maxSize);
args.add("video_bit_rate=" + videoBitR);
if (maxFps > 0) args.add("max_fps=" + maxFps);
- args.add("clipboard_autosync=true");
+ // Off means off at the source: the server never sends the
+ // target's clipboard, rather than us receiving and discarding it.
+ args.add("clipboard_autosync=" + Settings.clipboardSync(ctx));
args.add("tunnel_forward=true");
args.add("cleanup=true");
args.add("power_on=true");
@@ -205,9 +211,9 @@ public final class Server {
private AdbStream openAbstract(String scid) throws Exception {
String name = "scrcpy_" + scid;
- long deadline = System.currentTimeMillis() + LISTENER_DEADLINE_MS;
+ long deadline = monotonicMs() + LISTENER_DEADLINE_MS;
ConnectException last = null;
- while (System.currentTimeMillis() < deadline) {
+ while (monotonicMs() < deadline) {
if (serverEof) {
throw new IOException("server exited before opening " + name, last);
}
@@ -230,18 +236,27 @@ public final class Server {
private static String readDeviceMeta(InputStream in) throws IOException {
byte[] probe = new byte[1];
Wire.readFully(in, probe);
+ if (probe[0] != 0) throw new IOException("invalid scrcpy probe byte");
byte[] name = new byte[64];
Wire.readFully(in, name);
int n = 0;
while (n < name.length && name[n] != 0) n++;
- return new String(name, 0, n, StandardCharsets.UTF_8);
+ for (int i = n; i < name.length; i++) {
+ if (name[i] != 0) throw new IOException("invalid device-name padding");
+ }
+ return safeLogText(Wire.decodeUtf8(name, 0, n));
}
private void pump(InputStream in) {
- try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
- String line;
- while ((line = r.readLine()) != null) {
- Log.i("server: %s", line);
+ // Do not use BufferedReader.readLine(): a hostile target can emit an
+ // unterminated line of arbitrary size and make it allocate until OOM.
+ byte[] bytes = new byte[2048];
+ try (InputStream source = in) {
+ int n;
+ while ((n = source.read(bytes)) >= 0) {
+ if (n == 0) throw new IOException("server stdout made no progress");
+ String chunk = safeLogBytes(bytes, n).trim();
+ if (!chunk.isEmpty()) Log.i("server: %s", chunk);
}
} catch (IOException e) {
if (!Thread.currentThread().isInterrupted()) Log.w("server-stdout closed: %s", e);
@@ -266,4 +281,29 @@ public final class Server {
try { sync.close(); } catch (IOException ignored) {}
}
}
+
+ private static String safeLogBytes(byte[] data, int len) {
+ StringBuilder out = new StringBuilder(len);
+ for (int i = 0; i < len; i++) {
+ int b = data[i] & 0xff;
+ if (b == '\n' || b == '\r' || b == '\t') out.append(' ');
+ else if (b >= 0x20 && b <= 0x7e) out.append((char) b);
+ else out.append('.');
+ }
+ return out.toString();
+ }
+
+ private static String safeLogText(String text) {
+ StringBuilder out = new StringBuilder(text.length());
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ int type = Character.getType(c);
+ out.append(Character.isISOControl(c) || type == Character.FORMAT ? '?' : c);
+ }
+ return out.toString();
+ }
+
+ private static long monotonicMs() {
+ return TimeUnit.NANOSECONDS.toMillis(System.nanoTime());
+ }
}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Session.java b/app/src/main/java/invalid/lena/scrcpy/Session.java
index a427d63..294b27d 100644
--- a/app/src/main/java/invalid/lena/scrcpy/Session.java
+++ b/app/src/main/java/invalid/lena/scrcpy/Session.java
@@ -5,53 +5,86 @@ import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.Surface;
-import java.io.File;
import java.io.IOException;
+import java.net.SocketTimeoutException;
import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
// One mirroring session: owns the Adb connection, the spawned scrcpy
// server, the three streams, sinks, and the controller. start() and
// stop() are idempotent and may be called from any thread.
//
-// Listener contract:
-// onConnected(w,h) - wire open, frames flowing. Fires on the first
-// connect and again after each successful auto-reconnect.
-// onReconnecting() - a live link dropped; the bring-up ladder is rerun.
-// Followed by onConnected (recovered) or onError (gave up).
-// onError(t) - fatal: retries exhausted, or non-retriable failure.
-// Always followed by onStopped().
-// onStopped() - final state. Fires exactly once per session, whether
-// user stop() or retries-exhausted.
+// The Listener contract is stated once, on the interface below.
//
-// Auto-reconnect: after a successful connect the video socket is watched;
-// if it ends mid-session (target sleep, Wi-Fi blip, server crash) the
-// session reruns the bring-up ladder instead of freezing. Only when that
-// ladder is exhausted does it give up via onError + onStopped.
+// Two retry budgets, because there are two different failures.
+// BACKOFF_MS covers bring-up, where the target is not answering yet.
+// RECONNECT_BACKOFF_MS covers a link that came up and then died (target
+// sleep, Wi-Fi blip, server crash) - and also the case where it dies
+// every time for the same permanent reason, which is why that budget is
+// finite. Either budget running out ends the session via onError +
+// onStopped rather than retrying forever.
//
// Surface-readiness race: Mirror.surfaceCreated builds the Session and
-// calls start(), but Mirror.surfaceChanged (which carries the view
-// dimensions) can fire before run() has finished constructing Controller.
-// setViewSize() therefore stashes the value and applies it as soon as
-// the Controller is available.
+// calls start(), but the viewport can be measured before run() has
+// finished constructing Controller. setViewport() therefore stashes the
+// value and applies it as soon as the Controller is available.
public final class Session {
public interface Listener {
// Fires the first time the server reports a session-meta packet
// (wire open, frames about to flow), and again after each
// successful auto-reconnect. Useful for the activity's status bar.
- default void onConnected(int w, int h) {}
+ default void onConnected(long geometryVersion, int w, int h) {}
// A previously-live link dropped and we are bringing it back up.
// Followed by onConnected (recovered), onError (gave up), or
// onStopped alone (user stop() raced the reconnect).
default void onReconnecting() {}
+ // Fatal: a retry budget ran out, or the failure was not
+ // retriable. Always followed by onStopped().
void onError(Throwable t);
+ // Final state. Fires exactly once per session, whether from
+ // stop() or from a budget running out.
void onStopped();
}
- // Bring-up retry budget. Total worst-case wait ~ sum of these
- // delays + per-attempt bring-up time. Keep below e2e.sh's deadline.
- private static final long[] BACKOFF_MS = {0L, 1_500L, 5_000L};
+ // Bring-up retry budget, for a target that is not answering yet.
+ // Total worst-case wait ~ sum of these delays + per-attempt bring-up
+ // time.
+ private static final long[] BACKOFF_MS =
+ {0L, 1_500L, 5_000L, 10_000L, 20_000L};
+
+ // Reconnect budget, for a link that dropped AFTER it came up. This is
+ // a different failure from bring-up: something that worked has
+ // stopped working, and it may be permanent (target has no decoder for
+ // the selected codec, frame larger than the decoder's input buffer).
+ // Without a budget those spin here forever at zero delay, re-pushing
+ // the server jar and respawning app_process on the target on every
+ // pass, with nothing shown to the user.
+ //
+ // A connection that stays up for HEALTHY_MS resets the budget, so a
+ // long session that blips occasionally never exhausts it, while a
+ // deterministic failure walks the ladder and then gives up.
+ private static final long[] RECONNECT_BACKOFF_MS =
+ {1_000L, 2_000L, 5_000L, 10_000L, 15_000L};
+ // Comfortably longer than STALL_MS + STALL_POLL_MS. If it were not,
+ // a stall-detected drop would always look "healthy" (a stall is only
+ // declared after STALL_MS of silence, so the connection is at least
+ // that old by then), the budget would reset on every stall and the
+ // ladder would never be reached for the one failure it exists for.
+ private static final long HEALTHY_MS = 120_000L;
+
+ // Stall watchdog. adb streams have no read timeout - AdbStream.read()
+ // waits on its queue until data arrives or the stream is closed - so a
+ // target that disappears without closing the socket (Wi-Fi dropping
+ // mid-frame, NAT rebinding, a carrier idle timeout on a VPN link)
+ // leaves the readers parked and the session frozen forever with no
+ // reconnect. Poll for silence on the audio socket instead; see
+ // AudioStream.isStarted() for why audio and not video.
+ private static final long STALL_MS = 30_000L;
+ private static final long STALL_POLL_MS = 5_000L;
+ private static final long BRING_UP_DEADLINE_MS = 90_000L;
+ private static final long STOP_JOIN_MS = 10_000L;
private final Context ctx;
private final Adb adb;
@@ -59,23 +92,31 @@ public final class Session {
private volatile Surface surface;
private final Listener listener;
- // Lightweight counters for the in-app status overlay; readers reach
- // into the sinks/controller directly.
- public long videoFrames() { VideoSink v = videoSink; return v == null ? 0 : v.frames; }
- public long audioFrames() { AudioSink a = audioSink; return a == null ? 0 : a.frames; }
- public String lastEvent() { Controller c = controller; return c == null ? "(idle)" : c.lastEvent; }
-
private Server server;
private VideoStream videoStream;
- private VideoSink videoSink;
+ private volatile VideoSink videoSink;
private AudioStream audioStream;
private AudioSink audioSink;
private ControlStream controlStream;
- private Controller controller;
- private MuxRecorder recorder;
+ private volatile Controller controller;
private Thread runner;
private volatile boolean stopped;
- private volatile int pendingViewW, pendingViewH;
+ private boolean stoppedNotified;
+ private long geometryVersion;
+ private volatile Viewport pendingViewport;
+
+ private static final class Viewport {
+ final long version;
+ final int x, y, w, h;
+
+ Viewport(long version, int x, int y, int w, int h) {
+ this.version = version;
+ this.x = x;
+ this.y = y;
+ this.w = w;
+ this.h = h;
+ }
+ }
// Counted down by the video reader when its loop exits; the session
// thread parks on it for the live duration of a connection. Swapped
@@ -91,17 +132,48 @@ public final class Session {
}
public synchronized void start() {
- if (runner != null) return;
+ if (runner != null || stopped) return;
runner = new Thread(this::run, "session");
runner.start();
}
- public synchronized void stop() {
- if (stopped) return;
- stopped = true;
- Log.i("session: stop");
+ public boolean stop() {
+ Thread r;
+ boolean notify;
+ synchronized (this) {
+ if (!stopped) {
+ stopped = true;
+ Log.i("session: stop");
+ }
+ notify = !stoppedNotified;
+ stoppedNotified = true;
+ r = runner;
+ }
+ if (r != null && r != Thread.currentThread()) r.interrupt();
+ adb.abort();
tearDownInstalled();
- if (listener != null) listener.onStopped();
+ endSignal.countDown();
+ boolean joined = true;
+ if (r != null && r != Thread.currentThread()) {
+ boolean interrupted = false;
+ long deadline = monotonicMs() + STOP_JOIN_MS;
+ while (r.isAlive()) {
+ long remaining = deadline - monotonicMs();
+ if (remaining <= 0) {
+ joined = false;
+ break;
+ }
+ try {
+ r.join(remaining);
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ if (interrupted) Thread.currentThread().interrupt();
+ }
+ if (!joined) Log.e("session: runner did not stop within %d ms", STOP_JOIN_MS);
+ if (notify && listener != null) listener.onStopped();
+ return joined;
}
// ---- input forwarding (Controller stays internal) ----
@@ -121,32 +193,26 @@ public final class Session {
if (c != null) c.onBack();
}
- public void setViewSize(int w, int h) {
- pendingViewW = w; pendingViewH = h;
+ public void onHome() {
Controller c = controller;
- if (c != null) c.setViewSize(w, h);
+ if (c != null) c.onHome();
}
- // ---- recording (delegates to MuxRecorder) ----
-
- public void armRecording(File out) {
- MuxRecorder r = recorder;
- if (r == null) {
- Log.w("session: armRecording before bring-up - ignored");
- return;
- }
- r.arm(out);
+ public void onRecents() {
+ Controller c = controller;
+ if (c != null) c.onRecents();
}
- // Returns true if a file was actually written (see MuxRecorder.stop).
- public boolean stopRecording() {
- MuxRecorder r = recorder;
- return r != null && r.stop();
+ public void syncClipboard() {
+ Controller c = controller;
+ if (c != null) c.syncLocalClipboard();
}
- public boolean isRecording() {
- MuxRecorder r = recorder;
- return r != null && r.isActive();
+ public void setViewport(long version, int x, int y, int w, int h) {
+ Viewport viewport = new Viewport(version, x, y, w, h);
+ pendingViewport = viewport;
+ Controller c = controller;
+ if (c != null) c.setViewport(version, x, y, w, h);
}
// Swap the surface the video pipeline draws to. The audio and control
@@ -154,9 +220,14 @@ public final class Session {
// activity is backgrounded. Pass null to detach; pass a new Surface
// (from a recreated SurfaceView) to resume rendering.
public void swapSurface(Surface s) {
+ Surface previous = surface;
surface = s;
VideoSink vk = videoSink;
if (vk != null) vk.setOutputSurface(s);
+ if (s != null && previous == null) {
+ Controller c = controller;
+ if (c != null) c.resetVideo();
+ }
}
// ---- internals ----
@@ -165,23 +236,75 @@ public final class Session {
// reconnect if the death wasn't a user stop(). Runs on one thread for
// the whole session lifetime.
private void run() {
- while (!stopped) {
+ int drops = 0; // consecutive short-lived connections
+ while (true) {
+ if (stopped) return;
if (!connect()) return; // gave up: onError + onStopped fired
+ long upAt = monotonicMs();
+ CountDownLatch latch;
+ AudioStream as;
+ synchronized (this) {
+ if (stopped) return;
+ latch = endSignal;
+ as = audioStream;
+ }
try {
- endSignal.await(); // park until the pipeline dies or stop()
+ awaitEndOrStall(latch, as);
} catch (InterruptedException ie) {
return;
}
+ long lived = monotonicMs() - upAt;
+
// Decide teardown-and-retry under the monitor so a concurrent
// stop() can't interleave: without this, onReconnecting()
// could fire after stop()'s onStopped(), breaking the
// listener contract.
+ boolean exhausted;
synchronized (this) {
if (stopped) return; // user stop()
- Log.i("session: video stream ended - link lost, reconnecting");
- tearDownInstalled();
- endSignal = new CountDownLatch(1);
- if (listener != null) listener.onReconnecting();
+ if (lived >= HEALTHY_MS) drops = 0;
+ exhausted = drops >= RECONNECT_BACKOFF_MS.length;
+ if (!exhausted) {
+ Log.i("session: link lost after %d ms - reconnecting (%d/%d)",
+ lived, drops + 1, RECONNECT_BACKOFF_MS.length);
+ tearDownInstalled();
+ endSignal = new CountDownLatch(1);
+ if (listener != null) listener.onReconnecting();
+ }
+ }
+ if (exhausted) {
+ Log.e("session: gave up after %d reconnects without a healthy link",
+ RECONNECT_BACKOFF_MS.length);
+ giveUp(new IOException("link kept dropping; gave up after "
+ + RECONNECT_BACKOFF_MS.length + " reconnects"));
+ return;
+ }
+
+ long delay = RECONNECT_BACKOFF_MS[drops++];
+ try { Thread.sleep(delay); }
+ catch (InterruptedException ie) { return; }
+ }
+ }
+
+ // Park until the pipeline dies, stop() fires, or the audio socket has
+ // been silent long enough that the link must be gone. Returning
+ // without the latch firing leaves the readers parked; the caller's
+ // tearDownInstalled() closes their streams, which unblocks them.
+ //
+ // The watchdog only arms once audio is actually flowing. If the
+ // target cannot capture audio the server reports the stream disabled,
+ // AudioStream returns immediately, and there is no reliable idle
+ // signal left - in that case fall back to waiting indefinitely rather
+ // than inventing one from the video socket, which is legitimately
+ // silent whenever the target's screen is static.
+ private void awaitEndOrStall(CountDownLatch latch, AudioStream as)
+ throws InterruptedException {
+ while (!latch.await(STALL_POLL_MS, TimeUnit.MILLISECONDS)) {
+ if (stopped || as == null || !as.isStarted()) continue;
+ long idle = monotonicMs() - as.lastPacketAtMs();
+ if (idle >= STALL_MS) {
+ Log.w("session: no audio for %d ms - link presumed dead", idle);
+ return;
}
}
}
@@ -190,7 +313,7 @@ public final class Session {
// live (read threads started); false if the budget was exhausted, in
// which case onError() + onStopped() have already fired.
private boolean connect() {
- Throwable lastErr = null;
+ Exception lastErr = null;
for (int attempt = 0; attempt < BACKOFF_MS.length && !stopped; attempt++) {
if (BACKOFF_MS[attempt] > 0) {
Log.i("session: retry %d/%d after %d ms",
@@ -204,7 +327,7 @@ public final class Session {
return true; // success; the read threads own the live session
} catch (InterruptedException ie) {
return false;
- } catch (Throwable t) {
+ } catch (Exception t) {
Log.w("session: bring-up attempt %d/%d failed: %s",
attempt + 1, BACKOFF_MS.length, t);
lastErr = t;
@@ -214,12 +337,22 @@ public final class Session {
}
if (stopped) return false;
Log.e(lastErr, "session: gave up after %d attempts", BACKOFF_MS.length);
- Throwable err = lastErr;
+ return giveUp(lastErr);
+ }
+
+ // Terminal failure. Marks the session stopped, tears down, and fires
+ // the final listener pair exactly once. Must not be called while
+ // holding the monitor: the listener runs on the caller's thread.
+ // Always returns false so callers can `return giveUp(err)`.
+ private boolean giveUp(Throwable err) {
synchronized (this) {
if (stopped) return false;
stopped = true;
- tearDownInstalled();
+ stoppedNotified = true;
}
+ adb.abort();
+ tearDownInstalled();
+ endSignal.countDown();
if (listener != null) {
listener.onError(err);
listener.onStopped();
@@ -231,6 +364,41 @@ public final class Session {
// monitor - but only if stop() hasn't already fired, in which case
// we tear down the locals we just built so nothing leaks.
private void bringUp() throws Exception {
+ CountDownLatch finished = new CountDownLatch(1);
+ AtomicBoolean timedOut = new AtomicBoolean();
+ Thread deadline = new Thread(() -> {
+ try {
+ if (!finished.await(BRING_UP_DEADLINE_MS, TimeUnit.MILLISECONDS)) {
+ timedOut.set(true);
+ Log.e("session: bring-up exceeded %d ms", BRING_UP_DEADLINE_MS);
+ adb.abort();
+ }
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }, "bring-up-deadline");
+ deadline.setDaemon(true);
+ deadline.start();
+
+ Exception failure = null;
+ try {
+ bringUpAttempt();
+ } catch (Exception e) {
+ failure = e;
+ } finally {
+ finished.countDown();
+ deadline.interrupt();
+ }
+ if (timedOut.get()) {
+ SocketTimeoutException timeout = new SocketTimeoutException(
+ "session bring-up exceeded " + BRING_UP_DEADLINE_MS + " ms");
+ if (failure != null) timeout.initCause(failure);
+ throw timeout;
+ }
+ if (failure != null) throw failure;
+ }
+
+ private void bringUpAttempt() throws Exception {
Log.i("session: connect %s:%d", target.host, target.port);
adb.disconnect();
adb.connect(target.host, target.port);
@@ -243,27 +411,32 @@ public final class Session {
AudioStream as = null;
VideoSink vk = null;
VideoStream vs = null;
- MuxRecorder rec = null;
boolean installed = false;
try {
srv = new Server(ctx, adb);
Server.Streams s = srv.bringUp();
- cs = new ControlStream(s.controlIn, s.controlOut, this::onVideoEnded);
+ // Bind the end callbacks to THIS generation's latch, not to
+ // the mutable field. Teardown joins the readers with a 1 s
+ // timeout, so on a black-holed link a reader can outlive its
+ // generation; reading the field at fire time would let it
+ // count down the next generation's latch and fake an
+ // immediate drop on a connection that was fine.
+ final CountDownLatch mine = endSignal;
+ Runnable ended = mine::countDown;
+
+ cs = new ControlStream(s.controlIn, s.controlOut, ended);
ctrl = new Controller(ctx, cs::send);
cs.setInboundSink(ctrl);
- ak = new AudioSink(this::onVideoEnded);
- as = new AudioStream(s.audioIn, ak);
+ ak = new AudioSink(ended);
+ as = new AudioStream(s.audioIn, ak, ended);
- vk = new VideoSink(surface, this::onVideoEnded);
+ vk = new VideoSink(surface, ended);
Controller ctrlRef = ctrl;
- AtomicBoolean reported = new AtomicBoolean();
vs = new VideoStream(s.videoIn, vk,
- (w, h) -> reportConnected(ctrlRef, reported, w, h));
- vs.setOnEnd(this::onVideoEnded);
- rec = new MuxRecorder();
- vs.setRecorder(rec);
+ (w, h) -> reportConnected(ctrlRef, w, h));
+ vs.setOnEnd(ended);
// Start locals before publishing them. stop() either tears down
// a previously installed generation or marks this generation for
@@ -281,35 +454,35 @@ public final class Session {
audioStream = as;
videoSink = vk;
videoStream = vs;
- recorder = rec;
- if (pendingViewW > 0) ctrl.setViewSize(pendingViewW, pendingViewH);
+ Viewport viewport = pendingViewport;
+ if (viewport != null && viewport.w > 0 && viewport.h > 0) {
+ ctrl.setViewport(viewport.version, viewport.x, viewport.y,
+ viewport.w, viewport.h);
+ }
installed = true;
}
} finally {
- if (!installed) tearDownLocals(srv, cs, ctrl, ak, as, vk, vs, rec);
+ if (!installed) tearDownLocals(srv, cs, ctrl, ak, as, vk, vs);
}
}
- private synchronized void reportConnected(Controller ctrl, AtomicBoolean reported,
- int w, int h) {
+ // Fires for every session-meta packet, not just the first. The server
+ // sends a fresh one whenever the target rotates or resizes, and the
+ // listener needs it every time: it carries the geometry the view is
+ // sized to and the touch viewport is derived from. Reporting only the
+ // first left a rotated target drawn into a view shaped for its old
+ // orientation, with touches mapped through the stale rectangle.
+ // Repeats are harmless - the listener's handling is idempotent.
+ private synchronized void reportConnected(Controller ctrl, int w, int h) {
if (stopped) return;
- ctrl.setTargetSize(w, h);
- if (reported.compareAndSet(false, true) && listener != null) {
- listener.onConnected(w, h);
- }
- }
-
- // Fired on the video-reader thread when its read loop exits (EOF,
- // error, or stop()). The video socket is the authoritative stream;
- // its end wakes the supervisor, which either unwinds (stop) or
- // reconnects. A no-op countdown after stop() is harmless.
- private void onVideoEnded() {
- endSignal.countDown();
+ long version = ++geometryVersion;
+ ctrl.setTargetSize(version, w, h);
+ if (listener != null) listener.onConnected(version, w, h);
}
private synchronized void tearDownInstalled() {
tearDownLocals(server, controlStream, controller,
- audioSink, audioStream, videoSink, videoStream, recorder);
+ audioSink, audioStream, videoSink, videoStream);
server = null;
controlStream = null;
controller = null;
@@ -317,14 +490,16 @@ public final class Session {
audioStream = null;
videoSink = null;
videoStream = null;
- recorder = null;
- try { adb.disconnect(); } catch (Exception ignored) {}
+ try {
+ adb.disconnect();
+ } catch (IOException e) {
+ Log.w("session: adb disconnect failed: %s", e);
+ }
}
private static void tearDownLocals(Server srv, ControlStream cs, Controller ctrl,
AudioSink ak, AudioStream as,
- VideoSink vk, VideoStream vs, MuxRecorder rec) {
- if (rec != null) rec.close();
+ VideoSink vk, VideoStream vs) {
// Closing the owning ADB streams first unblocks readers. Join them
// before releasing their sinks so no callback can recreate resources
// after teardown.
@@ -336,4 +511,8 @@ public final class Session {
if (ak != null) ak.release();
if (ctrl != null) ctrl.release();
}
+
+ private static long monotonicMs() {
+ return TimeUnit.NANOSECONDS.toMillis(System.nanoTime());
+ }
}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Sessions.java b/app/src/main/java/invalid/lena/scrcpy/Sessions.java
index 91144be..fbf19d7 100644
--- a/app/src/main/java/invalid/lena/scrcpy/Sessions.java
+++ b/app/src/main/java/invalid/lena/scrcpy/Sessions.java
@@ -33,12 +33,18 @@ public final class Sessions extends Service {
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
+ // Mirror passes its target so the notification can lead back to
+ // the session it describes. Restarting the service with a new
+ // target just rebuilds the notification.
+ String host = intent == null ? null : intent.getStringExtra(Mirror.EXTRA_HOST);
+ int port = intent == null ? -1 : intent.getIntExtra(Mirror.EXTRA_PORT, -1);
+
ensureChannel();
Notification n = new Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.notif_session_active))
- .setContentIntent(reopenIntent())
+ .setContentIntent(reopenIntent(host, port))
.setOngoing(true)
.build();
startForeground(NOTIF_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
@@ -66,9 +72,19 @@ public final class Sessions extends Service {
nm.createNotificationChannel(ch);
}
- private PendingIntent reopenIntent() {
- Intent i = new Intent(this, Main.class);
- i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ // Tapping an ongoing "mirroring active" notification must return to
+ // the mirror. It used to point at Main with FLAG_ACTIVITY_CLEAR_TOP,
+ // which finished Mirror on the way - the notification destroyed the
+ // session it was advertising. Mirror is singleTask, so a plain
+ // NEW_TASK launch brings the existing instance forward, and carrying
+ // the target means a launch after the activity died still works.
+ private PendingIntent reopenIntent(String host, int port) {
+ Intent i = new Intent(this, Mirror.class);
+ i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ if (host != null) {
+ i.putExtra(Mirror.EXTRA_HOST, host);
+ i.putExtra(Mirror.EXTRA_PORT, port);
+ }
return PendingIntent.getActivity(this, 0, i,
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Settings.java b/app/src/main/java/invalid/lena/scrcpy/Settings.java
index c4bb83b..fab6ed1 100644
--- a/app/src/main/java/invalid/lena/scrcpy/Settings.java
+++ b/app/src/main/java/invalid/lena/scrcpy/Settings.java
@@ -18,16 +18,32 @@ public final class Settings {
public static final String MAX_FPS = "max_fps"; // fps; 0 = unlimited
public static final String HINT_BACK_SHOWN = "hint_back_shown"; // first-run UI hint
public static final String STATUS_BAR = "status_bar"; // show status bar while mirroring
+ public static final String CLIPBOARD = "clipboard"; // two-way clipboard sync
public static final String DEFAULT_VIDEO_CODEC = "h264";
- public static final String DEFAULT_AUDIO_CODEC = "raw";
- public static final int DEFAULT_MAX_SIZE = 0;
- public static final int DEFAULT_VIDEO_BIT_RATE = 8_000_000;
+ public static final String DEFAULT_AUDIO_CODEC = "opus";
+ // Defaults sized for the worst link this is meant to work over, not
+ // the best. The constraint is the target's UPLINK: an uncapped
+ // stream at 8 Mbit/s is fine on a LAN and unusable over a VPN or a
+ // home broadband uplink, which is a supported way to reach a target.
+ // 1080p at 4 Mbit/s looks fine on a phone and fits an ordinary
+ // uplink; raise both in Settings when both devices are on the LAN.
+ public static final int DEFAULT_MAX_SIZE = 1080;
+ public static final int DEFAULT_VIDEO_BIT_RATE = 4_000_000;
public static final int DEFAULT_MAX_FPS = 0;
- // Hidden while mirroring by default: an always-on bar over the video
- // is intrusive. It still auto-shows when not CONNECTED so Reconnect
- // stays reachable. Record lives in the bar, so enable this to keep it.
- public static final boolean DEFAULT_STATUS_BAR = false;
+ // Shown by default. It carries Back, Home and Recents, which are the
+ // only way to reach the target's navigation: a swipe from the
+ // source's bottom edge is taken by the source's own gesture
+ // detector and never reaches us. Hiding it is a deliberate choice
+ // for an unobstructed picture, not the default, because a control
+ // the user cannot find is a control that does not exist.
+ public static final boolean DEFAULT_STATUS_BAR = true;
+ // On by default: it is a headline feature and the target is one the
+ // user deliberately paired with. It is a setting because that trust
+ // is not absolute - a compromised target
+ // can read whatever is copied on this device and write anything it
+ // likes back - and because there was previously no way to decline.
+ public static final boolean DEFAULT_CLIPBOARD = true;
private Settings() {}
@@ -36,58 +52,133 @@ public final class Settings {
}
public static String videoCodec(Context ctx) {
- return prefs(ctx).getString(VIDEO_CODEC, DEFAULT_VIDEO_CODEC);
+ String value = string(ctx, VIDEO_CODEC, DEFAULT_VIDEO_CODEC);
+ return "h264".equals(value) || "h265".equals(value) || "av1".equals(value)
+ ? value : DEFAULT_VIDEO_CODEC;
}
public static String audioCodec(Context ctx) {
- return prefs(ctx).getString(AUDIO_CODEC, DEFAULT_AUDIO_CODEC);
+ String value = string(ctx, AUDIO_CODEC, DEFAULT_AUDIO_CODEC);
+ return "opus".equals(value) || "raw".equals(value)
+ ? value : DEFAULT_AUDIO_CODEC;
}
public static int maxSize(Context ctx) {
- return prefs(ctx).getInt(MAX_SIZE, DEFAULT_MAX_SIZE);
+ int value = integer(ctx, MAX_SIZE, DEFAULT_MAX_SIZE);
+ switch (value) {
+ case 0: case 480: case 720: case 1080: case 1440: case 2160:
+ return value;
+ default:
+ return DEFAULT_MAX_SIZE;
+ }
}
public static int videoBitRate(Context ctx) {
- return prefs(ctx).getInt(VIDEO_BIT_RATE, DEFAULT_VIDEO_BIT_RATE);
+ int value = integer(ctx, VIDEO_BIT_RATE, DEFAULT_VIDEO_BIT_RATE);
+ switch (value) {
+ case 1_000_000: case 2_000_000: case 4_000_000:
+ case 8_000_000: case 16_000_000:
+ return value;
+ default:
+ return DEFAULT_VIDEO_BIT_RATE;
+ }
}
public static int maxFps(Context ctx) {
- return prefs(ctx).getInt(MAX_FPS, DEFAULT_MAX_FPS);
+ int value = integer(ctx, MAX_FPS, DEFAULT_MAX_FPS);
+ switch (value) {
+ case 0: case 30: case 60: case 90: case 120:
+ return value;
+ default:
+ return DEFAULT_MAX_FPS;
+ }
}
public static void setVideoCodec(Context ctx, String v) {
+ if (!"h264".equals(v) && !"h265".equals(v) && !"av1".equals(v)) {
+ throw new IllegalArgumentException("invalid video codec");
+ }
prefs(ctx).edit().putString(VIDEO_CODEC, v).apply();
}
public static void setAudioCodec(Context ctx, String a) {
+ if (!"opus".equals(a) && !"raw".equals(a)) {
+ throw new IllegalArgumentException("invalid audio codec");
+ }
prefs(ctx).edit().putString(AUDIO_CODEC, a).apply();
}
public static void setMaxSize(Context ctx, int v) {
+ if (v != 0 && v != 480 && v != 720 && v != 1080 && v != 1440 && v != 2160) {
+ throw new IllegalArgumentException("invalid maximum size");
+ }
prefs(ctx).edit().putInt(MAX_SIZE, v).apply();
}
public static void setVideoBitRate(Context ctx, int v) {
+ if (v != 1_000_000 && v != 2_000_000 && v != 4_000_000
+ && v != 8_000_000 && v != 16_000_000) {
+ throw new IllegalArgumentException("invalid video bit rate");
+ }
prefs(ctx).edit().putInt(VIDEO_BIT_RATE, v).apply();
}
public static void setMaxFps(Context ctx, int v) {
+ if (v != 0 && v != 30 && v != 60 && v != 90 && v != 120) {
+ throw new IllegalArgumentException("invalid maximum frame rate");
+ }
prefs(ctx).edit().putInt(MAX_FPS, v).apply();
}
public static boolean hintBackShown(Context ctx) {
- return prefs(ctx).getBoolean(HINT_BACK_SHOWN, false);
+ return bool(ctx, HINT_BACK_SHOWN, false);
}
public static void setHintBackShown(Context ctx, boolean v) {
prefs(ctx).edit().putBoolean(HINT_BACK_SHOWN, v).apply();
}
+ public static boolean clipboardSync(Context ctx) {
+ return bool(ctx, CLIPBOARD, DEFAULT_CLIPBOARD);
+ }
+
+ public static void setClipboardSync(Context ctx, boolean v) {
+ prefs(ctx).edit().putBoolean(CLIPBOARD, v).apply();
+ }
+
public static boolean showStatusBar(Context ctx) {
- return prefs(ctx).getBoolean(STATUS_BAR, DEFAULT_STATUS_BAR);
+ return bool(ctx, STATUS_BAR, DEFAULT_STATUS_BAR);
}
public static void setShowStatusBar(Context ctx, boolean v) {
prefs(ctx).edit().putBoolean(STATUS_BAR, v).apply();
}
+
+ private static String string(Context ctx, String key, String fallback) {
+ try {
+ String value = prefs(ctx).getString(key, fallback);
+ return value == null ? fallback : value;
+ } catch (ClassCastException e) {
+ Log.w("settings: %s has the wrong type", key);
+ return fallback;
+ }
+ }
+
+ private static int integer(Context ctx, String key, int fallback) {
+ try {
+ return prefs(ctx).getInt(key, fallback);
+ } catch (ClassCastException e) {
+ Log.w("settings: %s has the wrong type", key);
+ return fallback;
+ }
+ }
+
+ private static boolean bool(Context ctx, String key, boolean fallback) {
+ try {
+ return prefs(ctx).getBoolean(key, fallback);
+ } catch (ClassCastException e) {
+ Log.w("settings: %s has the wrong type", key);
+ return fallback;
+ }
+ }
}
diff --git a/app/src/main/java/invalid/lena/scrcpy/SettingsActivity.java b/app/src/main/java/invalid/lena/scrcpy/SettingsActivity.java
index 43b0954..b650bc4 100644
--- a/app/src/main/java/invalid/lena/scrcpy/SettingsActivity.java
+++ b/app/src/main/java/invalid/lena/scrcpy/SettingsActivity.java
@@ -45,9 +45,9 @@ public final class SettingsActivity extends Activity {
switch (Settings.videoBitRate(this)) {
case 1_000_000: ((RadioButton) findViewById(R.id.bit_rate_1m)).setChecked(true); break;
case 2_000_000: ((RadioButton) findViewById(R.id.bit_rate_2m)).setChecked(true); break;
- case 4_000_000: ((RadioButton) findViewById(R.id.bit_rate_4m)).setChecked(true); break;
+ case 8_000_000: ((RadioButton) findViewById(R.id.bit_rate_8m)).setChecked(true); break;
case 16_000_000: ((RadioButton) findViewById(R.id.bit_rate_16m)).setChecked(true); break;
- default: ((RadioButton) findViewById(R.id.bit_rate_8m)).setChecked(true);
+ default: ((RadioButton) findViewById(R.id.bit_rate_4m)).setChecked(true);
}
switch (Settings.maxFps(this)) {
case 30: ((RadioButton) findViewById(R.id.max_fps_30)).setChecked(true); break;
@@ -83,11 +83,17 @@ public final class SettingsActivity extends Activity {
});
bitRateGroup.setOnCheckedChangeListener((g, id) -> {
- int v = Settings.DEFAULT_VIDEO_BIT_RATE;
+ // Every button maps explicitly. Falling back to
+ // DEFAULT_VIDEO_BIT_RATE for the unmatched one silently tied
+ // whichever button that was to the default's current value,
+ // so changing the default changed what that button stored.
+ int v;
if (id == R.id.bit_rate_1m) v = 1_000_000;
else if (id == R.id.bit_rate_2m) v = 2_000_000;
else if (id == R.id.bit_rate_4m) v = 4_000_000;
+ else if (id == R.id.bit_rate_8m) v = 8_000_000;
else if (id == R.id.bit_rate_16m) v = 16_000_000;
+ else return;
Settings.setVideoBitRate(this, v);
Log.i("settings: video_bit_rate=%d", v);
});
@@ -102,6 +108,16 @@ public final class SettingsActivity extends Activity {
Log.i("settings: max_fps=%d", v);
});
+ findViewById(R.id.licenses).setOnClickListener(v ->
+ startActivity(new android.content.Intent(this, Licenses.class)));
+
+ CheckBox clipboardBox = findViewById(R.id.clipboard_sync);
+ clipboardBox.setChecked(Settings.clipboardSync(this));
+ clipboardBox.setOnCheckedChangeListener((b, checked) -> {
+ Settings.setClipboardSync(this, checked);
+ Log.i("settings: clipboard=%b", checked);
+ });
+
statusBarBox.setChecked(Settings.showStatusBar(this));
statusBarBox.setOnCheckedChangeListener((b, checked) -> {
Settings.setShowStatusBar(this, checked);
diff --git a/app/src/main/java/invalid/lena/scrcpy/Sync.java b/app/src/main/java/invalid/lena/scrcpy/Sync.java
index 6e37523..b7bb4b5 100644
--- a/app/src/main/java/invalid/lena/scrcpy/Sync.java
+++ b/app/src/main/java/invalid/lena/scrcpy/Sync.java
@@ -4,6 +4,7 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
+import java.util.Objects;
// Pure-java adb sync v1 SEND framing. Extracted from Server.push() so
// it can be unit-tested without an AdbStream or any android coupling.
@@ -23,6 +24,7 @@ public final class Sync {
// FAIL responses carry a short human-readable message; cap what a
// corrupt or hostile length field can make us allocate.
private static final int MAX_FAIL_MSG = 4 * 1024;
+ private static final int MAX_PATH_BYTES = 1024;
private Sync() {}
@@ -30,10 +32,22 @@ public final class Sync {
// already-open `out` and `in` of an adb sync stream. Returns the
// total payload byte count.
public static long push(InputStream src, OutputStream out, InputStream in,
- String remotePath, int mode, int mtimeSec)
+ String remotePath, int mode, int mtimeSec)
throws IOException {
+ Objects.requireNonNull(src);
+ Objects.requireNonNull(out);
+ Objects.requireNonNull(in);
+ Objects.requireNonNull(remotePath);
+ if (remotePath.isEmpty() || remotePath.indexOf(',') >= 0
+ || remotePath.indexOf('\0') >= 0) {
+ throw new IllegalArgumentException("invalid sync path");
+ }
+ if (mode < 0) throw new IllegalArgumentException("invalid sync mode");
String header = remotePath + "," + mode;
byte[] hb = header.getBytes(StandardCharsets.UTF_8);
+ if (hb.length > MAX_PATH_BYTES) {
+ throw new IllegalArgumentException("sync path and mode are too long");
+ }
byte[] tag = new byte[8];
putTag(tag, 0, "SEND");
@@ -46,7 +60,8 @@ public final class Sync {
putTag(dataHdr, 0, "DATA");
long total = 0;
int n;
- while ((n = src.read(chunk)) > 0) {
+ while ((n = src.read(chunk)) >= 0) {
+ if (n == 0) throw new IOException("sync source made no progress");
Wire.writeLe32(dataHdr, 4, n);
out.write(dataHdr);
out.write(chunk, 0, n);
@@ -63,16 +78,43 @@ public final class Sync {
Wire.readFully(in, resp);
String code = new String(resp, 0, 4, StandardCharsets.US_ASCII);
int len = Wire.readLe32(resp, 4);
- if ("OKAY".equals(code)) return total;
+ if ("OKAY".equals(code)) {
+ if (len != 0) throw new IOException("sync OKAY length is not zero: " + len);
+ return total;
+ }
+ if (!"FAIL".equals(code)) {
+ throw new IOException("unknown sync response: " + safeCode(code));
+ }
- byte[] msg = new byte[Math.min(Math.max(0, len), MAX_FAIL_MSG)];
+ if (len < 0 || len > MAX_FAIL_MSG) {
+ throw new IOException("sync " + code + " response length out of range: " + len);
+ }
+ byte[] msg = new byte[len];
if (msg.length > 0) Wire.readFully(in, msg);
- throw new IOException("sync " + code + ": "
- + new String(msg, StandardCharsets.UTF_8));
+ throw new IOException("sync FAIL: " + safeMessage(Wire.decodeUtf8(msg)));
}
private static void putTag(byte[] dst, int off, String tag) {
byte[] b = tag.getBytes(StandardCharsets.US_ASCII);
System.arraycopy(b, 0, dst, off, 4);
}
+
+ private static String safeCode(String code) {
+ StringBuilder out = new StringBuilder(code.length());
+ for (int i = 0; i < code.length(); i++) {
+ char c = code.charAt(i);
+ out.append(c >= 0x20 && c <= 0x7e ? c : '?');
+ }
+ return out.toString();
+ }
+
+ private static String safeMessage(String message) {
+ StringBuilder out = new StringBuilder(message.length());
+ for (int i = 0; i < message.length(); i++) {
+ char c = message.charAt(i);
+ int type = Character.getType(c);
+ out.append(Character.isISOControl(c) || type == Character.FORMAT ? '?' : c);
+ }
+ return out.toString();
+ }
}
diff --git a/app/src/main/java/invalid/lena/scrcpy/TouchGeometry.java b/app/src/main/java/invalid/lena/scrcpy/TouchGeometry.java
new file mode 100644
index 0000000..5ee80d4
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/TouchGeometry.java
@@ -0,0 +1,46 @@
+package invalid.lena.scrcpy;
+
+// Publishes a target size and its matching on-screen viewport as one
+// immutable value. A target resize invalidates the old viewport until the UI
+// lays out the new generation.
+final class TouchGeometry {
+
+ static final class Snapshot {
+ final int targetW, targetH;
+ final int x, y, w, h;
+
+ Snapshot(int targetW, int targetH, int x, int y, int w, int h) {
+ this.targetW = targetW;
+ this.targetH = targetH;
+ this.x = x;
+ this.y = y;
+ this.w = w;
+ this.h = h;
+ }
+ }
+
+ private volatile Snapshot snapshot;
+ private long version;
+ private int targetW;
+ private int targetH;
+
+ synchronized void setTargetSize(long nextVersion, int w, int h) {
+ if (nextVersion <= version) throw new IllegalArgumentException("stale target version");
+ if (w <= 0 || h <= 0) throw new IllegalArgumentException("invalid target size");
+ version = nextVersion;
+ targetW = w;
+ targetH = h;
+ snapshot = null;
+ }
+
+ synchronized void setViewport(long expectedVersion, int x, int y, int w, int h) {
+ if (expectedVersion != version) return;
+ snapshot = version > 0 && targetW > 0 && targetH > 0 && w > 0 && h > 0
+ ? new Snapshot(targetW, targetH, x, y, w, h)
+ : null;
+ }
+
+ Snapshot snapshot() {
+ return snapshot;
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/TouchMap.java b/app/src/main/java/invalid/lena/scrcpy/TouchMap.java
new file mode 100644
index 0000000..d1289e0
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/TouchMap.java
@@ -0,0 +1,34 @@
+package invalid.lena.scrcpy;
+
+// Maps a coordinate in the activity window onto the target's pixels.
+//
+// Android-free for the same reason Wire and ControlMessages are: this is
+// the arithmetic that decides where a tap lands, and it is worth testing
+// without an emulator. Controller cannot be tested directly because it
+// takes MotionEvent.
+//
+// The video does not fill the window. It is letterboxed to the target's
+// aspect ratio and centred, so a coordinate has to have the bar
+// subtracted before it is scaled.
+public final class TouchMap {
+
+ private TouchMap() {}
+
+ // coord - along one axis, in window coordinates
+ // viewOrigin - where the video rectangle starts on that axis
+ // viewSpan - how long the video rectangle is on that axis
+ // targetSpan - the target's size on that axis
+ //
+ // Clamped into the rectangle rather than rejected: a drag that wanders
+ // onto a letterbox bar and is released there must still deliver its
+ // UP, or the target keeps the pointer down for the rest of the
+ // session. Returns 0..targetSpan-1.
+ public static int map(int coord, int viewOrigin, int viewSpan, int targetSpan) {
+ if (viewSpan <= 0 || targetSpan <= 0) return 0;
+ long v = (long) coord - viewOrigin;
+ if (v < 0) v = 0;
+ else if (v > viewSpan - 1) v = viewSpan - 1;
+ // 64-bit intermediate: a 4K target times a 4K span overflows int.
+ return (int) (v * targetSpan / viewSpan);
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Ui.java b/app/src/main/java/invalid/lena/scrcpy/Ui.java
index 91b8b66..d750219 100644
--- a/app/src/main/java/invalid/lena/scrcpy/Ui.java
+++ b/app/src/main/java/invalid/lena/scrcpy/Ui.java
@@ -5,7 +5,7 @@ import android.view.View;
// Window inset plumbing for the non-fullscreen activities.
//
-// targetSdk 35 makes every window edge-to-edge on Android 15: the system
+// targetSdk 35 and later make every window edge-to-edge on Android 15: the system
// no longer insets the content view, so an unhandled layout draws under
// the status and navigation bars. Mirror wants that and opts in itself;
// Main and Settings do not, so they pad their root by the bar sizes.
diff --git a/app/src/main/java/invalid/lena/scrcpy/VideoFrames.java b/app/src/main/java/invalid/lena/scrcpy/VideoFrames.java
index cfffe90..ec930ac 100644
--- a/app/src/main/java/invalid/lena/scrcpy/VideoFrames.java
+++ b/app/src/main/java/invalid/lena/scrcpy/VideoFrames.java
@@ -11,7 +11,7 @@ public interface VideoFrames {
void reconfigure(int codecFourcc, int width, int height) throws IOException;
- void feed(byte[] data, long ptsUs, boolean isConfig);
+ void feed(byte[] data, long ptsUs, boolean isConfig, boolean isKeyframe);
void release();
}
diff --git a/app/src/main/java/invalid/lena/scrcpy/VideoQueue.java b/app/src/main/java/invalid/lena/scrcpy/VideoQueue.java
new file mode 100644
index 0000000..a3c308f
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/VideoQueue.java
@@ -0,0 +1,94 @@
+package invalid.lena.scrcpy;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.Iterator;
+
+// Bounded encoded-video queue. It never leaves delta frames queued without
+// the keyframe that starts their decoder generation.
+final class VideoQueue {
+
+ static final class Frame {
+ final byte[] data;
+ final long ptsUs;
+ final boolean config;
+ final boolean keyframe;
+
+ Frame(byte[] data, long ptsUs, boolean config, boolean keyframe) {
+ this.data = data;
+ this.ptsUs = ptsUs;
+ this.config = config;
+ this.keyframe = keyframe;
+ }
+ }
+
+ private final int maxFrames;
+ private final int maxBytes;
+ private final Deque<Frame> frames;
+ private int bytes;
+ private boolean needsKeyframe = true;
+
+ VideoQueue(int maxFrames, int maxBytes) {
+ if (maxFrames < 1 || maxBytes < 1) throw new IllegalArgumentException();
+ this.maxFrames = maxFrames;
+ this.maxBytes = maxBytes;
+ frames = new ArrayDeque<>(maxFrames);
+ }
+
+ boolean offer(Frame frame) {
+ if (frame.config) {
+ clear();
+ return append(frame);
+ }
+ if (frame.keyframe) {
+ removeMediaFrames();
+ needsKeyframe = false;
+ if (append(frame)) return true;
+ needsKeyframe = true;
+ return false;
+ }
+ if (needsKeyframe) return false;
+ if (append(frame)) return true;
+ needsKeyframe = true;
+ return false;
+ }
+
+ Frame poll() {
+ Frame frame = frames.pollFirst();
+ if (frame != null) bytes -= frame.data.length;
+ return frame;
+ }
+
+ boolean isEmpty() {
+ return frames.isEmpty();
+ }
+
+ boolean needsKeyframe() {
+ return needsKeyframe;
+ }
+
+ void clear() {
+ frames.clear();
+ bytes = 0;
+ needsKeyframe = true;
+ }
+
+ private boolean append(Frame frame) {
+ if (frames.size() >= maxFrames || frame.data.length > maxBytes - bytes) {
+ return false;
+ }
+ frames.offerLast(frame);
+ bytes += frame.data.length;
+ return true;
+ }
+
+ private void removeMediaFrames() {
+ for (Iterator<Frame> it = frames.iterator(); it.hasNext(); ) {
+ Frame frame = it.next();
+ if (!frame.config) {
+ it.remove();
+ bytes -= frame.data.length;
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/VideoRecorder.java b/app/src/main/java/invalid/lena/scrcpy/VideoRecorder.java
deleted file mode 100644
index 98a5ca9..0000000
--- a/app/src/main/java/invalid/lena/scrcpy/VideoRecorder.java
+++ /dev/null
@@ -1,19 +0,0 @@
-package invalid.lena.scrcpy;
-
-// Optional tap on VideoStream. Receives the same frames the decoder
-// gets - same bytes, same order - plus the keyframe bit so a downstream
-// muxer can mark random-access samples. Android-free so VideoStream
-// stays unit-testable; MuxRecorder is the concrete Android impl.
-//
-// Wire order:
-// onMeta(fourcc, w, h) - once per session, again on resize
-// onFrame(data, pts, cfg, key) - for every frame
-// close() - when the stream tears down
-public interface VideoRecorder {
-
- void onMeta(int codecFourcc, int width, int height);
-
- void onFrame(byte[] data, long ptsUs, boolean isConfig, boolean isKeyframe);
-
- void close();
-}
diff --git a/app/src/main/java/invalid/lena/scrcpy/VideoSink.java b/app/src/main/java/invalid/lena/scrcpy/VideoSink.java
index ff8f449..8c69b31 100644
--- a/app/src/main/java/invalid/lena/scrcpy/VideoSink.java
+++ b/app/src/main/java/invalid/lena/scrcpy/VideoSink.java
@@ -10,7 +10,7 @@ import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayDeque;
import java.util.Deque;
-import java.util.Iterator;
+import java.util.concurrent.atomic.AtomicBoolean;
// MediaCodec async-mode video decoder writing to a Surface.
//
@@ -22,67 +22,75 @@ import java.util.Iterator;
// Back-pressure policy: when no input buffer is free and the pending
// queue is full, replace the oldest frame of the same kind, or the oldest
// frame overall. The queue remains bounded even if a peer floods CSD.
-// Keyframes are not distinguishable here (the flag stays in VideoStream)
-// so they drop like any delta frame; the picture heals at the next one.
+// After a decoder rebuild, delta frames are dropped until the next keyframe;
+// feeding them first can leave MediaCodec waiting forever for missing refs.
//
-// Output timing: releaseOutputBuffer is called with an absolute nano
-// timestamp on the System.nanoTime clock, derived from the wire PTS so
-// the surface composer interpolates frames against vsync instead of
-// rendering them as fast as they decode. Anchor is set on the first
-// frame: wallClockNs0 - ptsUs0 * 1000 = constant offset, then for each
-// subsequent buffer renderAt = ptsUs * 1000 + offset.
+// Output buffers render when decoded. The Surface compositor already
+// synchronizes presentation to vsync; translating a hostile remote PTS
+// into an absolute local clock can instead queue frames arbitrarily far
+// into the future.
public final class VideoSink implements VideoFrames {
private static final int MAX_PENDING = 8;
+ private static final int MAX_FRAME_BYTES = 8 * 1024 * 1024;
+ private static final int MAX_PENDING_BYTES = 16 * 1024 * 1024;
private volatile Surface surface;
private final Runnable onFatalError;
- public volatile long frames; // public read for the status overlay
+ private final AtomicBoolean fatalReported = new AtomicBoolean();
+ private long renderedFrames;
private volatile MediaCodec codec;
private HandlerThread handlerThread;
private Handler handler;
private final Object lock = new Object();
private final Deque<Integer> freeInputs = new ArrayDeque<>(16);
- private final Deque<Frame> pending = new ArrayDeque<>(MAX_PENDING);
+ private final VideoQueue pending = new VideoQueue(MAX_PENDING, MAX_PENDING_BYTES);
private boolean released;
- private long ptsOffsetNs; // wall-ns = ptsUs * 1000 + ptsOffsetNs
- private boolean ptsAnchored;
- private static final class Frame {
- final byte[] data;
- final long ptsUs;
- final boolean isConfig;
- Frame(byte[] d, long pts, boolean cfg) { data = d; ptsUs = pts; isConfig = cfg; }
- }
+ // The format the server last announced, held so the decoder can be
+ // built later if there was no output surface when it arrived.
+ private boolean haveFormat;
+ private int fmtFourcc, fmtW, fmtH;
+
+ // Most recent CSD (SPS/PPS). A decoder built late, or rebuilt on
+ // resize, needs it before it can decode anything, and the server only
+ // sends it once per run. VideoStream hands us a fresh array per frame,
+ // so holding the reference is enough.
+ private byte[] lastConfig;
public VideoSink(Surface surface, Runnable onFatalError) {
this.surface = surface;
this.onFatalError = onFatalError;
}
- // Swap the output Surface without rebuilding MediaCodec. Passing null
- // detaches output: the codec keeps decoding but discards frames so the
- // wire stays drained while the activity is backgrounded. Passing a new
- // Surface re-attaches and rendering resumes from the next decoded frame.
+ // Swap the output Surface. A destroyed Surface cannot remain attached to
+ // MediaCodec, so null tears the decoder down but retains its format and
+ // codec config. The reader keeps draining the wire; a new Surface rebuilds
+ // the decoder and replays the cached config.
public void setOutputSurface(Surface newSurface) {
+ if (newSurface == null) {
+ synchronized (lock) {
+ if (released) return;
+ surface = null;
+ }
+ teardownCodec(false);
+ return;
+ }
synchronized (lock) {
if (released) return;
this.surface = newSurface;
MediaCodec c = codec;
- if (c == null) return; // not yet configured; new surface will be used at configure time
+ if (c == null) {
+ if (newSurface != null && haveFormat) startDeferredLocked();
+ return;
+ }
try {
- if (newSurface != null) c.setOutputSurface(newSurface);
- // Calling setOutputSurface(null) is unsupported on some
- // codecs; instead, ignore output buffers in the callback
- // when surface is null (see onOutputBufferAvailable).
- } catch (IllegalStateException e) {
- Log.w("video sink: setOutputSurface: %s", e);
+ c.setOutputSurface(newSurface);
+ } catch (RuntimeException e) {
+ reportFatal(e, "video sink: setOutputSurface");
}
- // Re-anchor PTS so frames decoded against the old surface clock
- // don't drag the new surface's render time into the past.
- ptsAnchored = false;
}
}
@@ -90,78 +98,131 @@ public final class VideoSink implements VideoFrames {
public void configure(int codecFourcc, int width, int height) throws IOException {
String mime = mimeFor(codecFourcc);
if (mime == null) throw new IOException("unsupported video codec " + Wire.fourccName(codecFourcc));
+ synchronized (lock) {
+ if (released) return;
+ haveFormat = true;
+ fmtFourcc = codecFourcc; fmtW = width; fmtH = height;
+ if (surface == null) {
+ // No output attached yet: the activity was backgrounded
+ // during bring-up, or this is a reconnect that completed
+ // while backgrounded. MediaCodec cannot be moved from
+ // ByteBuffer mode to Surface mode afterwards, so
+ // configuring with a null surface here would black the
+ // session out permanently and setOutputSurface() would
+ // throw for the rest of the session. Wait instead; the
+ // wire keeps draining and frames are dropped until a
+ // surface arrives.
+ Log.i("video sink: no output surface, deferring decoder (%s %dx%d)",
+ mime, width, height);
+ return;
+ }
+ startCodecLocked(mime, width, height);
+ }
+ }
+
+ // Build the deferred decoder once a surface finally shows up. Failure
+ // here is fatal to the session: without a decoder there is no picture
+ // and no way to ask for one again.
+ private void startDeferredLocked() {
+ try {
+ startCodecLocked(mimeFor(fmtFourcc), fmtW, fmtH);
+ } catch (Exception e) {
+ reportFatal(e, "video sink: deferred configure failed");
+ }
+ }
+
+ // Must be called with `lock` held and `surface` non-null.
+ private void startCodecLocked(String mime, int width, int height) throws IOException {
Log.i("video sink: configure mime=%s %dx%d", mime, width, height);
+ renderedFrames = 0;
+ freeInputs.clear();
+ pending.clear();
+
+ // Created before the HandlerThread: createDecoderByType throws
+ // IOException, which the RuntimeException cleanup below does not
+ // cover, and an orphaned thread would survive until release().
+ MediaCodec c = MediaCodec.createDecoderByType(mime);
handlerThread = new HandlerThread("video-mc");
handlerThread.start();
handler = new Handler(handlerThread.getLooper());
- codec = MediaCodec.createDecoderByType(mime);
- codec.setCallback(new MediaCodec.Callback() {
+ MediaCodec.Callback callback = new MediaCodec.Callback() {
@Override public void onInputBufferAvailable(MediaCodec mc, int idx) {
onFreeInput(mc, idx);
}
@Override public void onOutputBufferAvailable(MediaCodec mc, int idx, MediaCodec.BufferInfo info) {
try {
- if (surface == null) {
- // Activity backgrounded - discard output instead
- // of rendering to a dead surface.
+ if (mc != codec) {
mc.releaseOutputBuffer(idx, false);
return;
}
- // PTS-honoured render: SurfaceFlinger queues the buffer
- // for renderTimestampNs and interpolates against vsync,
- // so bursty arrivals smooth out instead of judder.
- // Config frames (pts=0) fall through to render-immediately.
- long renderAtNs = renderTimeNs(info.presentationTimeUs);
- if (renderAtNs == 0L) mc.releaseOutputBuffer(idx, true);
- else mc.releaseOutputBuffer(idx, renderAtNs);
- } catch (IllegalStateException ignored) {}
+ mc.releaseOutputBuffer(idx, true);
+ if (++renderedFrames == 1) {
+ Log.i("video sink: rendered frame n=1");
+ }
+ } catch (IllegalStateException e) {
+ if (mc == codec) reportFatal(e, "video sink: releaseOutputBuffer");
+ }
}
@Override public void onError(MediaCodec mc, MediaCodec.CodecException e) {
- Log.e(e, "video sink: codec error");
- if (mc == codec && onFatalError != null) onFatalError.run();
+ if (mc == codec) reportFatal(e, "video sink: codec error");
}
@Override public void onOutputFormatChanged(MediaCodec mc, MediaFormat fmt) {
Log.i("video sink: output format %s", fmt);
}
- }, handler);
+ };
- MediaFormat fmt = MediaFormat.createVideoFormat(mime, width, height);
- codec.configure(fmt, surface, null, 0);
- codec.start();
+ try {
+ c.setCallback(callback, handler);
+ MediaFormat fmt = MediaFormat.createVideoFormat(mime, width, height);
+ c.configure(fmt, surface, null, 0);
+ // Publish before start() so the identity checks in the
+ // callbacks match from the very first buffer.
+ codec = c;
+ c.start();
+ } catch (RuntimeException e) {
+ codec = null;
+ try { c.release(); } catch (Exception ignored) {}
+ HandlerThread ht = handlerThread;
+ handlerThread = null;
+ handler = null;
+ if (ht != null) ht.quitSafely();
+ throw e;
+ }
+
+ // A decoder built after the stream started - deferred for a
+ // missing surface, or rebuilt on resize - has missed the CSD the
+ // server only sends once. Replay it ahead of everything else.
+ if (lastConfig != null) {
+ pending.offer(new VideoQueue.Frame(lastConfig, 0L, true, false));
+ }
}
// Called by VideoStream for every encoded frame, in order.
@Override
- public void feed(byte[] data, long ptsUs, boolean isConfig) {
- if (!isConfig) frames++;
+ public void feed(byte[] data, long ptsUs, boolean isConfig, boolean isKeyframe) {
synchronized (lock) {
if (released) return;
+ if (data == null || data.length == 0 || data.length > MAX_FRAME_BYTES) {
+ reportFatal(null, "video sink: invalid frame size");
+ return;
+ }
+ if (isConfig) lastConfig = data;
// Try to drain immediately if there's a free input.
while (!pending.isEmpty() && !freeInputs.isEmpty()) {
- submit(codec, pending.pollFirst(), freeInputs.pollFirst());
+ submit(codec, pending.poll(), freeInputs.pollFirst());
}
- if (!freeInputs.isEmpty()) {
- submit(codec, new Frame(data, ptsUs, isConfig), freeInputs.pollFirst());
+ boolean waiting = pending.needsKeyframe();
+ if (!pending.offer(new VideoQueue.Frame(data, ptsUs, isConfig, isKeyframe))) {
return;
}
- // Keep the queue strictly bounded. A newer config frame replaces
- // an older one; retaining every config packet lets a hostile peer
- // turn the queue into an unbounded allocation sink.
- if (pending.size() >= MAX_PENDING) {
- boolean removed = false;
- for (Iterator<Frame> it = pending.iterator(); it.hasNext(); ) {
- Frame f = it.next();
- if (f.isConfig == isConfig) {
- it.remove();
- removed = true;
- break;
- }
- }
- if (!removed) pending.pollFirst();
+ if (waiting && isKeyframe && !isConfig) {
+ Log.i("video sink: accepted keyframe after configure or overflow");
+ }
+ while (!pending.isEmpty() && !freeInputs.isEmpty()) {
+ submit(codec, pending.poll(), freeInputs.pollFirst());
}
- pending.offerLast(new Frame(data, ptsUs, isConfig));
}
}
@@ -171,32 +232,43 @@ public final class VideoSink implements VideoFrames {
if (released) return;
released = true;
}
- teardownCodec();
+ teardownCodec(true);
}
- // Tear down the current decoder and reconfigure with new dimensions.
- // The next CSD frame on the wire (server resets on resize) will prime
- // the new codec instance.
+ // Tear down the current decoder and rebuild it at the new dimensions.
+ // startCodecLocked replays the cached CSD into the new instance.
@Override
public void reconfigure(int codecFourcc, int width, int height) throws IOException {
synchronized (lock) {
if (released) return;
+ // Disarm the deferred-start path for the window in which we
+ // hold no codec: a setOutputSurface() landing between the
+ // teardown and the configure below must not build a second
+ // decoder behind our back.
+ haveFormat = false;
}
- teardownCodec();
+ teardownCodec(false);
+ configure(codecFourcc, width, height);
+ }
+
+ // Claim the codec under `lock` so setOutputSurface() can never touch
+ // an instance that is being released, and two callers cannot both
+ // stop the same one. The stop/release themselves run unlocked: they
+ // can take a while, and they do not wait on the callback looper, so
+ // there is nothing to gain by holding the lock across them.
+ private void teardownCodec(boolean clearConfig) {
+ MediaCodec c;
+ HandlerThread ht;
synchronized (lock) {
+ c = codec;
+ codec = null;
+ ht = handlerThread;
+ handlerThread = null;
+ handler = null;
freeInputs.clear();
pending.clear();
- ptsAnchored = false; // re-anchor on the first frame of the new run
+ if (clearConfig) lastConfig = null;
}
- configure(codecFourcc, width, height);
- }
-
- private void teardownCodec() {
- MediaCodec c = codec;
- codec = null;
- HandlerThread ht = handlerThread;
- handlerThread = null;
- handler = null;
if (c != null) {
try { c.stop(); } catch (Exception ignored) {}
try { c.release(); } catch (Exception ignored) {}
@@ -208,7 +280,7 @@ public final class VideoSink implements VideoFrames {
private void onFreeInput(MediaCodec mc, int idx) {
synchronized (lock) {
if (released || mc != codec) return;
- if (!pending.isEmpty()) submit(mc, pending.pollFirst(), idx);
+ if (!pending.isEmpty()) submit(mc, pending.poll(), idx);
else freeInputs.offerLast(idx);
}
}
@@ -216,38 +288,29 @@ public final class VideoSink implements VideoFrames {
// Must be called with `lock` held. codec can be null mid-reconfigure
// (teardownCodec runs unlocked); the frame is dropped like any other
// back-pressure casualty.
- private void submit(MediaCodec mc, Frame f, int idx) {
+ private void submit(MediaCodec mc, VideoQueue.Frame f, int idx) {
if (mc == null || mc != codec) return;
try {
ByteBuffer buf = mc.getInputBuffer(idx);
if (buf == null || f.data.length > buf.capacity()) {
- Log.e("video sink: frame exceeds codec input (%d bytes)", f.data.length);
- if (onFatalError != null) onFatalError.run();
+ reportFatal(null, "video sink: frame exceeds codec input ("
+ + f.data.length + " bytes)");
return;
}
buf.clear();
buf.put(f.data);
- int flags = f.isConfig ? MediaCodec.BUFFER_FLAG_CODEC_CONFIG : 0;
+ int flags = f.config ? MediaCodec.BUFFER_FLAG_CODEC_CONFIG : 0;
mc.queueInputBuffer(idx, 0, f.data.length, f.ptsUs, flags);
} catch (IllegalStateException e) {
- Log.w("video sink: queueInputBuffer: %s", e);
+ reportFatal(e, "video sink: queueInputBuffer");
}
}
- // Convert a wire PTS (microseconds since some scrcpy epoch) into a
- // System.nanoTime value the surface composer should render at.
- // First call anchors the offset to "now" so latency stays whatever
- // the wire produced. PTS=0 (config frames) and unanchored state
- // both return 0 → caller falls back to render-immediately.
- private long renderTimeNs(long ptsUs) {
- if (ptsUs <= 0L) return 0L;
- synchronized (lock) {
- if (!ptsAnchored) {
- ptsOffsetNs = System.nanoTime() - ptsUs * 1000L;
- ptsAnchored = true;
- }
- return ptsUs * 1000L + ptsOffsetNs;
- }
+ private void reportFatal(Exception error, String message) {
+ if (!fatalReported.compareAndSet(false, true)) return;
+ if (error == null) Log.e("%s", message);
+ else Log.e(error, "%s", message);
+ if (onFatalError != null) onFatalError.run();
}
private static String mimeFor(int fourcc) {
diff --git a/app/src/main/java/invalid/lena/scrcpy/VideoStream.java b/app/src/main/java/invalid/lena/scrcpy/VideoStream.java
index 7ecb3f1..9d870e5 100644
--- a/app/src/main/java/invalid/lena/scrcpy/VideoStream.java
+++ b/app/src/main/java/invalid/lena/scrcpy/VideoStream.java
@@ -34,11 +34,11 @@ public final class VideoStream {
private static final int FLAG_SESSION_INT_BIT = 0x80000000;
private static final int MAX_FRAME_SIZE = 8 * 1024 * 1024;
+ private static final int MAX_DIMENSION = 16 * 1024;
private final InputStream source;
private final VideoFrames sink;
private final SizeListener sizeListener;
- private volatile VideoRecorder recorder; // optional tap
private volatile Runnable onEnd; // fired once when run() exits
private Thread thread;
private volatile boolean stop;
@@ -69,10 +69,6 @@ public final class VideoStream {
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
- public void setRecorder(VideoRecorder r) {
- this.recorder = r;
- }
-
// Fired exactly once, on the reader thread, when run() exits - whether
// by EOF, error, or stop(). The video socket is the authoritative
// stream: when it ends mid-session the link is gone, so Session uses
@@ -89,6 +85,10 @@ public final class VideoStream {
fourcc = Wire.readBe32(four, 0);
if (fourcc == 0) throw new IOException("video: server reports stream disabled");
if (fourcc == 1) throw new IOException("video: server reports configuration error");
+ if (fourcc != Wire.CODEC_H264 && fourcc != Wire.CODEC_H265
+ && fourcc != Wire.CODEC_AV1) {
+ throw new IOException("video: unexpected codec " + Wire.fourccName(fourcc));
+ }
Log.i("video meta codec=%s", Wire.fourccName(fourcc));
byte[] tail8 = new byte[8];
@@ -117,9 +117,15 @@ public final class VideoStream {
}
private void parseSessionMeta(int hi, byte[] tail8) throws IOException {
+ if ((hi & ~0x80000001) != 0) {
+ throw new IOException("video session meta has unknown flags");
+ }
Wire.readFully(source, tail8);
int newW = Wire.readBe32(tail8, 0);
int newH = Wire.readBe32(tail8, 4);
+ if (newW < 1 || newW > MAX_DIMENSION || newH < 1 || newH > MAX_DIMENSION) {
+ throw new IOException("video size out of range: " + newW + "x" + newH);
+ }
boolean clientResize = (hi & 1) != 0;
Log.i("video session meta %dx%d client_resize=%s", newW, newH, clientResize);
@@ -133,16 +139,14 @@ public final class VideoStream {
sink.reconfigure(fourcc, curW, curH);
}
if (sizeListener != null) sizeListener.onSize(curW, curH);
- VideoRecorder r = recorder;
- if (r != null) r.onMeta(fourcc, curW, curH);
}
private void parseFrame(int hi, byte[] tail8) throws IOException {
Wire.readFully(source, tail8);
long pts = ((long) hi << 32) | (Wire.readBe32(tail8, 0) & 0xffffffffL);
int size = Wire.readBe32(tail8, 4);
- boolean cfg = (pts & FLAG_CONFIG) != 0;
- boolean key = (pts & FLAG_KEYFRAME) != 0;
+ boolean cfg = (pts & FLAG_CONFIG) != 0;
+ boolean keyframe = (pts & FLAG_KEYFRAME) != 0;
long ptsUs = pts & PTS_MASK;
if (size <= 0 || size > MAX_FRAME_SIZE) {
@@ -154,8 +158,6 @@ public final class VideoStream {
byte[] payload = new byte[size];
Wire.readFully(source, payload);
- sink.feed(payload, ptsUs, cfg);
- VideoRecorder r = recorder;
- if (r != null) r.onFrame(payload, ptsUs, cfg, key);
+ sink.feed(payload, ptsUs, cfg, keyframe);
}
}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Wire.java b/app/src/main/java/invalid/lena/scrcpy/Wire.java
index 7ed6c6a..3d78c9b 100644
--- a/app/src/main/java/invalid/lena/scrcpy/Wire.java
+++ b/app/src/main/java/invalid/lena/scrcpy/Wire.java
@@ -3,6 +3,10 @@ package invalid.lena.scrcpy;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
// Byte-order helpers used everywhere we touch raw streams.
//
@@ -18,6 +22,7 @@ public final class Wire {
// ---- big-endian (scrcpy) ----
public static int readBe32(byte[] b, int off) {
+ checkRange(b, off, 4);
return ((b[off] & 0xff) << 24)
| ((b[off + 1] & 0xff) << 16)
| ((b[off + 2] & 0xff) << 8)
@@ -25,10 +30,12 @@ public final class Wire {
}
public static long readBe64(byte[] b, int off) {
+ checkRange(b, off, 8);
return ((long)(readBe32(b, off)) << 32) | (readBe32(b, off + 4) & 0xffffffffL);
}
public static void writeBe32(byte[] b, int off, int v) {
+ checkRange(b, off, 4);
b[off] = (byte)(v >>> 24);
b[off + 1] = (byte)(v >>> 16);
b[off + 2] = (byte)(v >>> 8);
@@ -36,6 +43,7 @@ public final class Wire {
}
public static void writeBe64(byte[] b, int off, long v) {
+ checkRange(b, off, 8);
writeBe32(b, off, (int)(v >>> 32));
writeBe32(b, off + 4, (int) v);
}
@@ -43,6 +51,7 @@ public final class Wire {
// ---- little-endian (adb sync) ----
public static int readLe32(byte[] b, int off) {
+ checkRange(b, off, 4);
return (b[off] & 0xff)
| ((b[off + 1] & 0xff) << 8)
| ((b[off + 2] & 0xff) << 16)
@@ -50,6 +59,7 @@ public final class Wire {
}
public static void writeLe32(byte[] b, int off, int v) {
+ checkRange(b, off, 4);
b[off] = (byte) v;
b[off + 1] = (byte)(v >>> 8);
b[off + 2] = (byte)(v >>> 16);
@@ -59,11 +69,17 @@ public final class Wire {
// ---- I/O helpers ----
public static void readFully(InputStream in, byte[] buf, int off, int len) throws IOException {
+ if (in == null || buf == null) throw new NullPointerException();
+ if (off < 0 || len < 0 || off > buf.length || len > buf.length - off) {
+ throw new IndexOutOfBoundsException();
+ }
int got = 0;
while (got < len) {
int n = in.read(buf, off + got, len - got);
if (n < 0) throw new EOFException(
"short read: wanted " + len + " got " + got);
+ if (n == 0) throw new IOException(
+ "input made no progress: wanted " + len + " got " + got);
got += n;
}
}
@@ -72,28 +88,59 @@ public final class Wire {
readFully(in, buf, 0, buf.length);
}
- // scrcpy codec identifiers, mirrored from com.genymobile.scrcpy.{video,audio}.*Codec.
- // Short names (raw, aac) are NUL-padded on the left, not space-padded on the right.
+ public static String decodeUtf8(byte[] data, int off, int len)
+ throws CharacterCodingException {
+ checkRange(data, off, len);
+ return StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ .decode(ByteBuffer.wrap(data, off, len)).toString();
+ }
+
+ public static String decodeUtf8(byte[] data) throws CharacterCodingException {
+ return decodeUtf8(data, 0, data.length);
+ }
+
+ // scrcpy codec identifiers, mirrored from
+ // vendor/scrcpy/server/src/main/java/com/genymobile/scrcpy/video/VideoCodec.java
+ // and .../audio/AudioCodec.java, which are the authoritative source.
+ // Every id is the 4-char ASCII name NUL-padded on the LEFT for names
+ // shorter than four characters (av1, raw, aac), never space-padded on
+ // the right. WireTest asserts that rule rather than copying the
+ // literals, so a mis-transcribed id fails the build.
// Declared as int-literal constants so they can drive switch-case labels.
public static final int CODEC_H264 = 0x68_32_36_34; // 'h264'
public static final int CODEC_H265 = 0x68_32_36_35; // 'h265'
- public static final int CODEC_AV1 = 0x61_76_30_31; // 'av01'
+ public static final int CODEC_AV1 = 0x00_61_76_31; // '\0av1'
public static final int CODEC_OPUS = 0x6f_70_75_73; // 'opus'
- public static final int CODEC_FLAC = 0x66_6c_61_63; // 'flac'
- public static final int CODEC_AAC = 0x00_61_61_63; // '\0aac'
public static final int CODEC_RAW = 0x00_72_61_77; // '\0raw'
public static String fourccName(int v) {
// Skip any leading NUL bytes - scrcpy left-pads short names like
// 'raw' and 'aac' with \0, which would otherwise render as
// non-printable characters in logs.
- char[] cs = new char[]{
- (char)((v >>> 24) & 0xff),
- (char)((v >>> 16) & 0xff),
- (char)((v >>> 8) & 0xff),
- (char)( v & 0xff)};
+ int[] bytes = {(v >>> 24) & 0xff, (v >>> 16) & 0xff,
+ (v >>> 8) & 0xff, v & 0xff};
int from = 0;
- while (from < cs.length && cs[from] == 0) from++;
- return new String(cs, from, cs.length - from);
+ while (from < bytes.length && bytes[from] == 0) from++;
+ StringBuilder out = new StringBuilder(4);
+ char[] hex = "0123456789abcdef".toCharArray();
+ for (int i = from; i < bytes.length; i++) {
+ int b = bytes[i];
+ if (b >= 0x20 && b <= 0x7e) {
+ out.append((char) b);
+ } else {
+ out.append("\\x").append(hex[b >>> 4]).append(hex[b & 0xf]);
+ }
+ }
+ return out.toString();
+ }
+
+ private static void checkRange(byte[] data, int off, int len) {
+ if (data == null) throw new NullPointerException("data");
+ if (off < 0 || len < 0 || off > data.length - len) {
+ throw new IndexOutOfBoundsException("off=" + off + " len=" + len
+ + " size=" + data.length);
+ }
}
}
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
index e5b7400..ba62f39 100644
--- a/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -1,16 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
-<!-- Foreground: a fresh take on wireless screen mirroring.
- 1. A small phone (source) and a larger phone (target display).
- 2. Cyan wireless arcs rising from the small phone toward the large
- one - the wireless ADB link / casting direction.
- 3. A play triangle on the large screen - references scrcpy's
- upstream identity ("playing what the device is showing").
- Geometry mirrors the AI-generated reference 1:1. The arcs carry the
- one accent colour; everything else is the light foreground tone.
- For the Android 13+ themed (monochrome) icon the same drawable is
- reused: the system tints by coverage, so the colours here are
- irrelevant in that mode. Adaptive viewport is 108x108; all content
- stays inside the inner safe zone. -->
+<!-- Wireless screen-mirroring glyph for the adaptive launcher icon. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml
index 696f6c2..9e50804 100644
--- a/app/src/main/res/drawable/ic_settings.xml
+++ b/app/src/main/res/drawable/ic_settings.xml
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
-<!-- Gear glyph for the Settings action in the Main header. -->
+<!-- Sliders glyph for the Settings action in the Main header. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
@@ -7,6 +7,12 @@
android:viewportHeight="24"
android:tint="@color/on_surface_muted">
<path
+ android:fillColor="@android:color/transparent"
+ android:strokeColor="#FFFFFF"
+ android:strokeLineCap="round"
+ android:strokeWidth="2"
+ android:pathData="M4,7h16M4,12h16M4,17h16"/>
+ <path
android:fillColor="#FFFFFF"
- android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94l-0.36,-2.54c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41l-0.36,2.54c-0.59,0.24 -1.13,0.57 -1.62,0.94l-2.39,-0.96c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.82,11.69 4.82,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
+ android:pathData="M8,5a2,2 0,1 0,0 4a2,2 0,1 0,0 -4M16,10a2,2 0,1 0,0 4a2,2 0,1 0,0 -4M10,15a2,2 0,1 0,0 4a2,2 0,1 0,0 -4"/>
</vector>
diff --git a/app/src/main/res/layout/device_row.xml b/app/src/main/res/layout/device_row.xml
index f06ab20..0471639 100644
--- a/app/src/main/res/layout/device_row.xml
+++ b/app/src/main/res/layout/device_row.xml
@@ -1,28 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
-<!-- Saved-device row: phone glyph + "host:port" label, with the platform
- touch ripple. Bound by an ArrayAdapter via R.id.device_label. -->
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<!-- Saved-device row with a platform touch ripple. -->
+<TextView xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/device_label"
+ style="@style/DeviceLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:orientation="horizontal"
android:gravity="center_vertical"
android:minHeight="56dp"
android:paddingHorizontal="@dimen/space_sm"
android:paddingVertical="@dimen/space_md"
- android:background="?android:attr/selectableItemBackground">
-
- <ImageView
- android:layout_width="24dp"
- android:layout_height="24dp"
- android:src="@drawable/ic_device"
- android:contentDescription="@null"/>
-
- <TextView
- android:id="@+id/device_label"
- style="@style/DeviceLabel"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_weight="1"
- android:layout_marginStart="@dimen/space_md"/>
-
-</LinearLayout>
+ android:drawableStart="@drawable/ic_device"
+ android:drawablePadding="@dimen/space_md"
+ android:foreground="?android:attr/selectableItemBackground"/>
diff --git a/app/src/main/res/layout/licenses.xml b/app/src/main/res/layout/licenses.xml
new file mode 100644
index 0000000..6ca02ab
--- /dev/null
+++ b/app/src/main/res/layout/licenses.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Plain scrolling text. The notices are wrapped at 78 columns in the
+ asset, so they are shown in a monospace face at a size that does not
+ re-wrap them into nonsense. -->
+<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:padding="@dimen/space_lg">
+
+ <TextView
+ android:id="@+id/notices"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textColor="@color/on_surface"
+ android:textSize="12sp"
+ android:fontFamily="monospace"
+ android:textIsSelectable="true"/>
+
+</ScrollView>
diff --git a/app/src/main/res/layout/main.xml b/app/src/main/res/layout/main.xml
index 0b1273b..15b1c90 100644
--- a/app/src/main/res/layout/main.xml
+++ b/app/src/main/res/layout/main.xml
@@ -1,161 +1,177 @@
<?xml version="1.0" encoding="utf-8"?>
-<!-- Root padding is the base; Ui.padForInsets() adds the system bar and
+<!-- One scroll for the whole page. The saved-device rows are built into
+ the LinearLayout below rather than a ListView, because a ListView
+ inside a ScrollView cannot measure itself and splitting the screen
+ into two independently scrolling halves left one of them unusable at
+ some sizes. The list is a handful of rows; it does not need
+ recycling.
+
+ Root padding is the base; Ui.padForInsets() adds the system bar and
IME insets to it at runtime (the window is edge-to-edge). -->
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
- android:orientation="vertical"
+ android:fillViewport="true"
android:padding="@dimen/space_lg">
- <!-- Header: app glyph + name, Settings on the right. -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:orientation="horizontal"
- android:gravity="center_vertical"
- android:layout_marginBottom="@dimen/space_lg">
+ android:orientation="vertical">
- <ImageView
- android:layout_width="40dp"
- android:layout_height="40dp"
- android:src="@drawable/ic_launcher_foreground"
- android:scaleType="fitCenter"
- android:contentDescription="@null"/>
-
- <TextView
- android:layout_width="0dp"
+ <!-- Header: app glyph + name, Settings on the right. -->
+ <LinearLayout
+ android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_weight="1"
- android:layout_marginStart="@dimen/space_sm"
- android:text="@string/app_name"
- android:textColor="@color/on_surface"
- android:textSize="22sp"
- android:textStyle="bold"/>
-
- <ImageButton
- android:id="@+id/settings"
- android:layout_width="48dp"
- android:layout_height="48dp"
- android:src="@drawable/ic_settings"
- android:scaleType="centerInside"
- android:background="?android:attr/selectableItemBackgroundBorderless"
- android:contentDescription="@string/settings"/>
-
- </LinearLayout>
-
- <!-- Pairing form. Each field is one value the target already prints,
- copied verbatim: the Wireless debugging screen shows the device
- address as a single "ip:port" string, and the pairing dialog adds
- its own port plus the code. -->
- <LinearLayout
- style="@style/Card"
- android:layout_width="match_parent"
- android:layout_height="wrap_content">
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginBottom="@dimen/space_lg">
- <TextView
- style="@style/SectionHeader"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/pair_a_device"/>
+ <ImageView
+ android:layout_width="40dp"
+ android:layout_height="40dp"
+ android:src="@drawable/ic_launcher_foreground"
+ android:scaleType="fitCenter"
+ android:contentDescription="@null"/>
- <TextView
- style="@style/Caption"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:text="@string/pair_help"/>
+ <TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:layout_marginStart="@dimen/space_sm"
+ android:text="@string/app_name"
+ android:textColor="@color/on_surface"
+ android:textSize="22sp"
+ android:textStyle="bold"/>
+
+ <ImageButton
+ android:id="@+id/settings"
+ android:layout_width="48dp"
+ android:layout_height="48dp"
+ android:src="@drawable/ic_settings"
+ android:scaleType="centerInside"
+ android:background="?android:attr/selectableItemBackgroundBorderless"
+ android:contentDescription="@string/settings"/>
- <TextView
- style="@style/FieldLabel"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/label_address"/>
+ </LinearLayout>
- <TextView
- style="@style/Caption"
+ <!-- Pairing form. Each field is one value the target already
+ prints, copied verbatim: the Wireless debugging screen shows
+ the device address as a single "ip:port" string, and the
+ pairing dialog adds its own port plus the code. -->
+ <LinearLayout
+ style="@style/Card"
android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:text="@string/sub_address"/>
+ android:layout_height="wrap_content">
- <EditText
- android:id="@+id/device_address"
- style="@style/Field"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginTop="@dimen/space_xs"
- android:hint="@string/address_hint"
- android:inputType="textUri"
- android:autofillHints="postalAddress"/>
+ <TextView
+ style="@style/SectionHeader"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/pair_a_device"/>
- <TextView
- style="@style/FieldLabel"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/label_pairing"/>
+ <TextView
+ style="@style/Caption"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/pair_help"/>
- <TextView
- style="@style/Caption"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:text="@string/sub_pairing"/>
+ <TextView
+ style="@style/FieldLabel"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/label_address"/>
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:orientation="horizontal"
- android:layout_marginTop="@dimen/space_xs">
+ <TextView
+ style="@style/Caption"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/sub_address"/>
<EditText
- android:id="@+id/pair_port"
+ android:id="@+id/device_address"
style="@style/Field"
- android:layout_width="0dp"
+ android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_weight="1"
- android:layout_marginEnd="@dimen/space_sm"
- android:hint="@string/pair_port_hint"
- android:inputType="number"
- android:autofillHints="off"/>
+ android:layout_marginTop="@dimen/space_xs"
+ android:hint="@string/address_hint"
+ android:inputType="textUri"
+ android:importantForAutofill="no"/>
+
+ <TextView
+ style="@style/FieldLabel"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/label_pairing"/>
- <EditText
- android:id="@+id/pair_code"
- style="@style/Field"
- android:layout_width="0dp"
+ <TextView
+ style="@style/Caption"
+ android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_weight="1"
- android:hint="@string/pair_code_hint"
- android:inputType="numberPassword"
- android:maxLength="6"
- android:autofillHints="off"/>
+ android:text="@string/sub_pairing"/>
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:layout_marginTop="@dimen/space_xs">
+
+ <EditText
+ android:id="@+id/pair_port"
+ style="@style/Field"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:layout_marginEnd="@dimen/space_sm"
+ android:hint="@string/pair_port_hint"
+ android:inputType="number"
+ android:importantForAutofill="no"/>
+
+ <EditText
+ android:id="@+id/pair_code"
+ style="@style/Field"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:hint="@string/pair_code_hint"
+ android:inputType="numberPassword"
+ android:maxLength="6"
+ android:importantForAutofill="no"/>
+
+ </LinearLayout>
+
+ <TextView
+ style="@style/Caption"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="@dimen/space_md"
+ android:text="@string/network_trust"/>
+
+ <Button
+ android:id="@+id/pair"
+ style="@style/Button.Primary"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="@dimen/space_lg"
+ android:text="@string/pair_and_save"/>
</LinearLayout>
- <Button
- android:id="@+id/pair"
- style="@style/Button.Primary"
+ <TextView
+ style="@style/SectionHeader"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginTop="@dimen/space_lg"
- android:text="@string/pair_and_save"/>
-
- </LinearLayout>
+ android:text="@string/saved_devices"/>
- <TextView
- style="@style/SectionHeader"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:text="@string/saved_devices"/>
-
- <FrameLayout
- android:layout_width="match_parent"
- android:layout_height="0dp"
- android:layout_weight="1">
-
- <ListView
+ <LinearLayout
android:id="@+id/devices"
android:layout_width="match_parent"
- android:layout_height="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
android:divider="@color/stroke"
- android:dividerHeight="1dp"/>
+ android:showDividers="middle"
+ android:dividerPadding="0dp"/>
<TextView
android:id="@+id/devices_empty"
@@ -166,6 +182,6 @@
android:paddingTop="@dimen/space_xl"
android:text="@string/no_devices_yet"/>
- </FrameLayout>
+ </LinearLayout>
-</LinearLayout>
+</ScrollView>
diff --git a/app/src/main/res/layout/mirror.xml b/app/src/main/res/layout/mirror.xml
index cf24e11..d2355a8 100644
--- a/app/src/main/res/layout/mirror.xml
+++ b/app/src/main/res/layout/mirror.xml
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
-<!-- Release Mirror layout: SurfaceView for the video pipeline plus a
- floating top status pill with Record and a Reconnect button (visible
- only when the session is in DISCONNECTED state). The debug layout
- under src/debug/res/layout/mirror.xml shadows this with a TextureView
- and extra stats overlay used by the e2e screen capture. -->
+<!-- Video surface with optional target navigation and reconnect controls. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
- android:background="#000000">
+ tools:ignore="MergeRootFrame">
+ <!-- Sized at runtime to the target's aspect ratio and centred, so the
+ black FrameLayout behind it shows as letterbox bars. -->
<SurfaceView
android:id="@+id/surface"
android:layout_width="match_parent"
@@ -39,16 +39,42 @@
android:ellipsize="end"
android:text="@string/connecting"/>
+
+ <Button
+ android:id="@+id/nav_back"
+ style="@style/Button.Secondary"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="@dimen/space_sm"
+ android:minWidth="0dp"
+ android:minHeight="@dimen/touch_min"
+ android:paddingHorizontal="@dimen/space_md"
+ android:paddingVertical="@dimen/space_xs"
+ android:text="@string/nav_back"/>
+
+ <Button
+ android:id="@+id/nav_home"
+ style="@style/Button.Secondary"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="@dimen/space_sm"
+ android:minWidth="0dp"
+ android:minHeight="@dimen/touch_min"
+ android:paddingHorizontal="@dimen/space_md"
+ android:paddingVertical="@dimen/space_xs"
+ android:text="@string/nav_home"/>
+
<Button
- android:id="@+id/record"
+ android:id="@+id/nav_recents"
style="@style/Button.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/space_sm"
- android:minHeight="0dp"
+ android:minWidth="0dp"
+ android:minHeight="@dimen/touch_min"
android:paddingHorizontal="@dimen/space_md"
android:paddingVertical="@dimen/space_xs"
- android:text="@string/record"/>
+ android:text="@string/nav_recents"/>
<Button
android:id="@+id/reconnect"
@@ -56,7 +82,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/space_sm"
- android:minHeight="0dp"
+ android:minHeight="@dimen/touch_min"
android:paddingHorizontal="@dimen/space_md"
android:paddingVertical="@dimen/space_xs"
android:text="@string/reconnect"
diff --git a/app/src/main/res/layout/settings.xml b/app/src/main/res/layout/settings.xml
index 76f706b..aa2f117 100644
--- a/app/src/main/res/layout/settings.xml
+++ b/app/src/main/res/layout/settings.xml
@@ -51,21 +51,21 @@
style="@style/RadioOption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="H.264 (default)"/>
+ android:text="@string/video_h264"/>
<RadioButton
android:id="@+id/video_h265"
style="@style/RadioOption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="H.265"/>
+ android:text="@string/video_h265"/>
<RadioButton
android:id="@+id/video_av1"
style="@style/RadioOption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="AV1 (requires target Android 14+)"/>
+ android:text="@string/video_av1"/>
</RadioGroup>
@@ -93,14 +93,14 @@
style="@style/RadioOption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="Raw PCM (1.5 Mbps, default)"/>
+ android:text="@string/audio_raw"/>
<RadioButton
android:id="@+id/audio_opus"
style="@style/RadioOption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="Opus (~128 kbps)"/>
+ android:text="@string/audio_opus"/>
</RadioGroup>
@@ -128,42 +128,42 @@
style="@style/RadioOption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="Original (default)"/>
+ android:text="@string/max_size_original"/>
<RadioButton
android:id="@+id/max_size_480"
style="@style/RadioOption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="480"/>
+ android:text="@string/max_size_480"/>
<RadioButton
android:id="@+id/max_size_720"
style="@style/RadioOption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="720"/>
+ android:text="@string/max_size_720"/>
<RadioButton
android:id="@+id/max_size_1080"
style="@style/RadioOption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="1080"/>
+ android:text="@string/max_size_1080"/>
<RadioButton
android:id="@+id/max_size_1440"
style="@style/RadioOption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="1440"/>
+ android:text="@string/max_size_1440"/>
<RadioButton
android:id="@+id/max_size_2160"
style="@style/RadioOption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="2160"/>
+ android:text="@string/max_size_2160"/>
</RadioGroup>
@@ -191,35 +191,35 @@
style="@style/RadioOption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="1 Mbps"/>
+ android:text="@string/bit_rate_1m"/>
<RadioButton
android:id="@+id/bit_rate_2m"
style="@style/RadioOption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="2 Mbps"/>
+ android:text="@string/bit_rate_2m"/>
<RadioButton
android:id="@+id/bit_rate_4m"
style="@style/RadioOption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="4 Mbps"/>
+ android:text="@string/bit_rate_4m"/>
<RadioButton
android:id="@+id/bit_rate_8m"
style="@style/RadioOption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="8 Mbps (default)"/>
+ android:text="@string/bit_rate_8m"/>
<RadioButton
android:id="@+id/bit_rate_16m"
style="@style/RadioOption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="16 Mbps"/>
+ android:text="@string/bit_rate_16m"/>
</RadioGroup>
@@ -247,35 +247,35 @@
style="@style/RadioOption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="Unlimited (default)"/>
+ android:text="@string/max_fps_unlimited"/>
<RadioButton
android:id="@+id/max_fps_30"
style="@style/RadioOption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="30"/>
+ android:text="@string/max_fps_30"/>
<RadioButton
android:id="@+id/max_fps_60"
style="@style/RadioOption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="60"/>
+ android:text="@string/max_fps_60"/>
<RadioButton
android:id="@+id/max_fps_90"
style="@style/RadioOption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="90"/>
+ android:text="@string/max_fps_90"/>
<RadioButton
android:id="@+id/max_fps_120"
style="@style/RadioOption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="120"/>
+ android:text="@string/max_fps_120"/>
</RadioGroup>
@@ -294,6 +294,13 @@
android:text="@string/display"/>
<CheckBox
+ android:id="@+id/clipboard_sync"
+ style="@style/RadioOption"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/clipboard_sync"/>
+
+ <CheckBox
android:id="@+id/show_status_bar"
style="@style/RadioOption"
android:layout_width="match_parent"
@@ -302,6 +309,17 @@
</LinearLayout>
+ <!-- Required, not decorative: the bundled SPAKE2 library is
+ LGPL-3.0 and the combined work has to give the user prominent
+ notice of that and of where to get the source. -->
+ <Button
+ android:id="@+id/licenses"
+ style="@style/Button.Secondary"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="@dimen/space_lg"
+ android:text="@string/licenses"/>
+
</LinearLayout>
</ScrollView>
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index 93542a7..9378d4c 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
-<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+<!-- Adaptive icons require a version-qualified resource directory. -->
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ tools:ignore="ObsoleteSdkInt">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml
deleted file mode 100644
index f551102..0000000
--- a/app/src/main/res/values/ids.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!-- IDs referenced from Mirror.java that live only in the debug
- layout (src/debug/res/layout/mirror.xml). Declaring them here
- means R.id.* is stable across both build types; findViewById
- returns null in release where the views are absent. -->
-<resources>
- <item name="overlay_target" type="id"/>
- <item name="overlay_stats" type="id"/>
- <item name="overlay_event" type="id"/>
-</resources>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d7c45ac..9b7e03b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
-<resources>
+<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name">scrcpy</string>
<string name="address_hint">192.168.1.42:41234</string>
<string name="pair_port_hint">37123</string>
@@ -12,26 +12,56 @@
<string name="bad_address">Enter the device address as 192.168.1.42:41234</string>
<string name="bad_pair_port">Pairing port must be a number from 1 to 65535</string>
<string name="bad_pair_code">Enter the 6-digit pairing code</string>
+ <string name="device_not_paired">This target is not paired. Pair it again from the device list.</string>
+ <string name="device_list_unreadable">The paired-device list could not be read; it was left unchanged.</string>
+ <string name="network_trust">Use only on a network you trust, or over a VPN. ADB cannot verify the target\'s identity, so anything answering this address could receive what you type.</string>
<string name="pair_and_save">Pair and save</string>
<string name="pair_a_device">Pair a device</string>
<string name="saved_devices">Saved devices</string>
<string name="notif_session_active">Mirroring active</string>
<string name="settings">Settings</string>
+ <string name="licenses">Open-source licenses</string>
+ <string name="licenses_unavailable">Notices unavailable.</string>
<string name="video_codec">Video codec</string>
<string name="audio_codec">Audio codec</string>
+ <string name="video_h264">H.264 (default)</string>
+ <string name="video_h265">H.265</string>
+ <string name="video_av1" tools:ignore="Typos">AV1 (target Android 14+)</string>
+ <string name="audio_raw">Raw PCM (1.5 Mbps)</string>
+ <string name="audio_opus">Opus (~128 kbps, default)</string>
<string name="forget_device">Forget device?</string>
<string name="max_size">Max size (long edge, px)</string>
+ <string name="max_size_original">Original</string>
+ <string name="max_size_480">480</string>
+ <string name="max_size_720">720</string>
+ <string name="max_size_1080">1080 (default)</string>
+ <string name="max_size_1440">1440</string>
+ <string name="max_size_2160">2160</string>
<string name="video_bit_rate">Video bit rate</string>
+ <string name="bit_rate_1m">1 Mbps</string>
+ <string name="bit_rate_2m">2 Mbps</string>
+ <string name="bit_rate_4m">4 Mbps (default)</string>
+ <string name="bit_rate_8m">8 Mbps</string>
+ <string name="bit_rate_16m">16 Mbps</string>
<string name="max_fps">Max fps</string>
+ <string name="max_fps_unlimited">Unlimited (default)</string>
+ <string name="max_fps_30">30</string>
+ <string name="max_fps_60">60</string>
+ <string name="max_fps_90">90</string>
+ <string name="max_fps_120">120</string>
<string name="reconnect">Reconnect</string>
+ <string name="nav_back">Back</string>
+ <string name="nav_home">Home</string>
+ <string name="nav_recents">Recents</string>
<string name="connecting">connecting…</string>
<string name="disconnected">disconnected</string>
- <string name="record">Record</string>
- <string name="record_on">Stop recording</string>
- <string name="hint_long_press_back">Long-press Back to send it to the target device.</string>
+ <string name="session_error">Session error: %1$s</string>
+ <string name="session_stop_timeout">The old session did not stop. Reconnect was cancelled.</string>
+ <string name="hint_back">Back goes to the target. Press Back twice to leave. Home and Recents are in the bar above.</string>
<string name="pairing">Pairing…</string>
<string name="settings_apply_on_reconnect">Changes apply on the next connection.</string>
<string name="display">Display</string>
- <string name="show_status_bar">Show status bar (IP, resolution, Record) while mirroring</string>
+ <string name="clipboard_sync">Sync clipboard with the target</string>
+ <string name="show_status_bar">Show status bar (IP and resolution) while mirroring</string>
<string name="no_devices_yet">No devices paired yet - fill the fields above and tap Pair.</string>
</resources>
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index d0b07ac..6e81bf1 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -45,7 +45,7 @@
<item name="android:textSize">15sp</item>
<item name="android:paddingTop">@dimen/space_sm</item>
<item name="android:paddingBottom">@dimen/space_sm</item>
- <item name="android:minHeight">44dp</item>
+ <item name="android:minHeight">@dimen/touch_min</item>
</style>
<style name="Note">
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..54630d1
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- The app's private files hold the ADB private key and the list of
+ paired targets. Neither may leave this device: allowBackup="false"
+ already blocks cloud backup, but Android 12's device-to-device
+ transfer is a separate path and would otherwise clone an identity
+ the target has authorised onto another phone. -->
+<data-extraction-rules>
+ <cloud-backup>
+ <exclude domain="root"/>
+ </cloud-backup>
+ <device-transfer>
+ <exclude domain="root"/>
+ </device-transfer>
+</data-extraction-rules>
diff --git a/app/src/test/java/invalid/lena/scrcpy/AtomicFilesTest.java b/app/src/test/java/invalid/lena/scrcpy/AtomicFilesTest.java
index 7124fd2..1cafb0c 100644
--- a/app/src/test/java/invalid/lena/scrcpy/AtomicFilesTest.java
+++ b/app/src/test/java/invalid/lena/scrcpy/AtomicFilesTest.java
@@ -51,4 +51,16 @@ public class AtomicFilesTest {
assertArrayEquals("good2".getBytes(), Files.readAllBytes(f.toPath()));
assertFalse(new File(dir.getRoot(), "x.tmp").exists());
}
+
+ @Test
+ public void writeRemovesEveryStaleTemp() throws Exception {
+ File f = new File(dir.getRoot(), "x");
+ Files.write(new File(dir.getRoot(), "x.tmp-old").toPath(), new byte[]{1});
+ Files.write(new File(dir.getRoot(), "x.tmp-new").toPath(), new byte[]{2});
+
+ AtomicFiles.write(f, "good".getBytes());
+
+ assertFalse(new File(dir.getRoot(), "x.tmp-old").exists());
+ assertFalse(new File(dir.getRoot(), "x.tmp-new").exists());
+ }
}
diff --git a/app/src/test/java/invalid/lena/scrcpy/AudioStreamTest.java b/app/src/test/java/invalid/lena/scrcpy/AudioStreamTest.java
index 076d27c..005a2e3 100644
--- a/app/src/test/java/invalid/lena/scrcpy/AudioStreamTest.java
+++ b/app/src/test/java/invalid/lena/scrcpy/AudioStreamTest.java
@@ -11,6 +11,7 @@ import java.io.DataOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
public class AudioStreamTest {
@@ -116,8 +117,18 @@ public class AudioStreamTest {
public void errorCodecDoesNotStart() throws Exception {
byte[] bytes = fourcc(1);
RecordingFrames sink = new RecordingFrames();
- new AudioStream(new ByteArrayInputStream(bytes), sink).run();
+ AtomicInteger fatal = new AtomicInteger();
+ new AudioStream(new ByteArrayInputStream(bytes), sink, fatal::incrementAndGet).run();
assertEquals(0, sink.starts);
assertEquals(0, sink.feeds.size());
+ assertEquals(1, fatal.get());
+ }
+
+ @Test
+ public void disabledCodecIsNotFatal() throws Exception {
+ AtomicInteger fatal = new AtomicInteger();
+ new AudioStream(new ByteArrayInputStream(fourcc(0)),
+ new RecordingFrames(), fatal::incrementAndGet).run();
+ assertEquals(0, fatal.get());
}
}
diff --git a/app/src/test/java/invalid/lena/scrcpy/ControlMessagesTest.java b/app/src/test/java/invalid/lena/scrcpy/ControlMessagesTest.java
index 1890860..142bb34 100644
--- a/app/src/test/java/invalid/lena/scrcpy/ControlMessagesTest.java
+++ b/app/src/test/java/invalid/lena/scrcpy/ControlMessagesTest.java
@@ -16,6 +16,28 @@ public class ControlMessagesTest {
}
@Test
+ public void clipboardLimitsMatchScrcpyMessageSize() {
+ // The server caps a whole control message at MESSAGE_MAX_SIZE and
+ // derives each direction's text limit by subtracting that
+ // direction's header. Derive it the same way rather than copying
+ // the numbers, so a server bump shows up here as a failure.
+ final int messageMax = 1 << 18; // 256 KiB
+ assertEquals(messageMax - (1 + 8 + 1 + 4), // SET_CLIPBOARD
+ ControlMessages.MAX_CLIPBOARD_BYTES);
+ assertEquals(messageMax - (1 + 4), // DeviceMessage
+ ControlMessages.MAX_DEVICE_CLIPBOARD_BYTES);
+ }
+
+ @Test
+ public void clipboardAcceptsExactlyTheLimit() {
+ char[] chars = new char[ControlMessages.MAX_CLIPBOARD_BYTES];
+ java.util.Arrays.fill(chars, 'a'); // 1 byte per char in UTF-8
+ byte[] m = ControlMessages.setClipboard(0, false, new String(chars));
+ assertEquals(14 + ControlMessages.MAX_CLIPBOARD_BYTES, m.length);
+ assertEquals(ControlMessages.MAX_CLIPBOARD_BYTES, Wire.readBe32(m, 10));
+ }
+
+ @Test
public void touchByteLayout() {
// ACTION_DOWN=0, single finger, target 1080x2400, x=100, y=200, full pressure
byte[] m = ControlMessages.touch(/* action */ 0, /* pointerId */ 0L,
@@ -47,8 +69,13 @@ public class ControlMessagesTest {
}
@Test
+ public void resetVideoByteLayout() {
+ assertArrayEquals(new byte[]{17}, ControlMessages.resetVideo());
+ }
+
+ @Test
public void setClipboardWithUtf8Payload() {
- String text = "héllo 🎉"; // includes a 4-byte surrogate pair
+ String text = "héllo \ud83c\udf89"; // includes a 4-byte surrogate pair
byte[] expected = text.getBytes(StandardCharsets.UTF_8);
byte[] m = ControlMessages.setClipboard(0xdeadbeefcafeL, /* paste */ true, text);
assertEquals(1 + 8 + 1 + 4 + expected.length, m.length);
diff --git a/app/src/test/java/invalid/lena/scrcpy/ControlStreamTest.java b/app/src/test/java/invalid/lena/scrcpy/ControlStreamTest.java
index 8573d3b..f3359e4 100644
--- a/app/src/test/java/invalid/lena/scrcpy/ControlStreamTest.java
+++ b/app/src/test/java/invalid/lena/scrcpy/ControlStreamTest.java
@@ -36,7 +36,7 @@ public class ControlStreamTest {
RecordingSink sink = new RecordingSink();
ControlStream cs = new ControlStream(
new ByteArrayInputStream(bos.toByteArray()),
- new ByteArrayOutputStream());
+ new ByteArrayOutputStream(), null);
cs.setInboundSink(sink);
cs.runReader(); // synchronous; returns on EOF
@@ -55,7 +55,7 @@ public class ControlStreamTest {
RecordingSink sink = new RecordingSink();
ControlStream cs = new ControlStream(
new ByteArrayInputStream(bos.toByteArray()),
- new ByteArrayOutputStream());
+ new ByteArrayOutputStream(), null);
cs.setInboundSink(sink);
cs.runReader();
@@ -73,7 +73,7 @@ public class ControlStreamTest {
RecordingSink sink = new RecordingSink();
ControlStream cs = new ControlStream(
new ByteArrayInputStream(bos.toByteArray()),
- new ByteArrayOutputStream());
+ new ByteArrayOutputStream(), null);
cs.setInboundSink(sink);
cs.runReader();
assertEquals(0, sink.clipboards.size());
@@ -87,7 +87,7 @@ public class ControlStreamTest {
// outbox is empty AND we've signalled stop.
ByteArrayOutputStream out = new ByteArrayOutputStream();
ControlStream cs = new ControlStream(
- new ByteArrayInputStream(new byte[0]), out);
+ new ByteArrayInputStream(new byte[0]), out, null);
byte[] a = new byte[]{1, 2, 3};
byte[] b = new byte[]{4, 5};
@@ -118,7 +118,7 @@ public class ControlStreamTest {
// event must be preserved and end up last in the drain.
ByteArrayOutputStream out = new ByteArrayOutputStream();
ControlStream cs = new ControlStream(
- new ByteArrayInputStream(new byte[0]), out);
+ new ByteArrayInputStream(new byte[0]), out, null);
for (int i = 0; i < 256; i++) {
cs.send(ControlMessages.touch(/* MOVE */ 2, 0L, i, i, 1080, 2400, 0xffff, 0, 0));
diff --git a/app/src/test/java/invalid/lena/scrcpy/CrashlogTest.java b/app/src/test/java/invalid/lena/scrcpy/CrashlogTest.java
new file mode 100644
index 0000000..41c05a1
--- /dev/null
+++ b/app/src/test/java/invalid/lena/scrcpy/CrashlogTest.java
@@ -0,0 +1,36 @@
+package invalid.lena.scrcpy;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+import java.nio.file.Files;
+
+public class CrashlogTest {
+
+ @Rule
+ public TemporaryFolder dir = new TemporaryFolder();
+
+ @Test
+ public void pruneKeepsNewestCrashlogsOnly() throws Exception {
+ for (int i = 0; i < 7; i++) {
+ File file = new File(dir.getRoot(), "crash-" + i + ".log");
+ Files.write(file.toPath(), new byte[]{(byte) i});
+ assertTrue(file.setLastModified(1_000L + i));
+ }
+ File unrelated = dir.newFile("notes.txt");
+
+ Crashlog.prune(dir.getRoot(), 5);
+
+ File[] logs = dir.getRoot().listFiles((parent, name) -> name.endsWith(".log"));
+ assertEquals(5, logs.length);
+ assertFalse(new File(dir.getRoot(), "crash-0.log").exists());
+ assertFalse(new File(dir.getRoot(), "crash-1.log").exists());
+ assertTrue(unrelated.exists());
+ }
+}
diff --git a/app/src/test/java/invalid/lena/scrcpy/DevicesTest.java b/app/src/test/java/invalid/lena/scrcpy/DevicesTest.java
index dcd04ca..bde8af8 100644
--- a/app/src/test/java/invalid/lena/scrcpy/DevicesTest.java
+++ b/app/src/test/java/invalid/lena/scrcpy/DevicesTest.java
@@ -2,6 +2,7 @@ package invalid.lena.scrcpy;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
@@ -20,9 +21,10 @@ public class DevicesTest {
}
@Test
- public void parseNonArrayReturnsEmpty() {
- assertTrue(Devices.parse("{not json").isEmpty());
- assertTrue(Devices.parse("{\"host\":\"x\"}").isEmpty());
+ public void parseNonArrayFailsLoudly() {
+ assertThrows(IllegalArgumentException.class, () -> Devices.parse("{not json"));
+ assertThrows(IllegalArgumentException.class,
+ () -> Devices.parse("{\"host\":\"x\"}"));
}
@Test
@@ -54,6 +56,14 @@ public class DevicesTest {
}
@Test
+ public void serializeRejectsInvalidRows() {
+ assertThrows(IllegalStateException.class, () -> Devices.serialize(Arrays.asList(
+ new Devices.Device("bad host", 5555))));
+ assertThrows(IllegalStateException.class, () -> Devices.serialize(Arrays.asList(
+ new Devices.Device("target", 0))));
+ }
+
+ @Test
public void parseAddressSplitsHostAndPort() {
Devices.Device d = Devices.parseAddress(" 192.168.1.42:41234 ");
assertEquals("192.168.1.42", d.host);
diff --git a/app/src/test/java/invalid/lena/scrcpy/TouchGeometryTest.java b/app/src/test/java/invalid/lena/scrcpy/TouchGeometryTest.java
new file mode 100644
index 0000000..5db6d32
--- /dev/null
+++ b/app/src/test/java/invalid/lena/scrcpy/TouchGeometryTest.java
@@ -0,0 +1,48 @@
+package invalid.lena.scrcpy;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import org.junit.Test;
+
+public class TouchGeometryTest {
+
+ @Test
+ public void viewportBeforeTargetDoesNotEnableInput() {
+ TouchGeometry geometry = new TouchGeometry();
+ geometry.setViewport(0, 0, 0, 1080, 608);
+ assertNull(geometry.snapshot());
+ }
+
+ @Test
+ public void targetResizeInvalidatesViewport() {
+ TouchGeometry geometry = new TouchGeometry();
+ long first = 7;
+ geometry.setTargetSize(first, 1920, 1080);
+ geometry.setViewport(first, 0, 100, 1080, 608);
+
+ long second = 8;
+ geometry.setTargetSize(second, 1080, 1920);
+
+ assertNull(geometry.snapshot());
+ geometry.setViewport(first, 100, 0, 608, 1080);
+ assertNull(geometry.snapshot());
+
+ geometry.setViewport(second, 100, 0, 608, 1080);
+ TouchGeometry.Snapshot snapshot = geometry.snapshot();
+ assertEquals(1080, snapshot.targetW);
+ assertEquals(1920, snapshot.targetH);
+ assertEquals(100, snapshot.x);
+ assertEquals(608, snapshot.w);
+ }
+
+ @Test
+ public void emptyViewportDisablesInput() {
+ TouchGeometry geometry = new TouchGeometry();
+ long version = 1;
+ geometry.setTargetSize(version, 1920, 1080);
+ geometry.setViewport(version, 0, 0, 1080, 608);
+ geometry.setViewport(version, 0, 0, 0, 0);
+ assertNull(geometry.snapshot());
+ }
+}
diff --git a/app/src/test/java/invalid/lena/scrcpy/TouchMapTest.java b/app/src/test/java/invalid/lena/scrcpy/TouchMapTest.java
new file mode 100644
index 0000000..becacc1
--- /dev/null
+++ b/app/src/test/java/invalid/lena/scrcpy/TouchMapTest.java
@@ -0,0 +1,85 @@
+package invalid.lena.scrcpy;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+// The arithmetic that decides where a tap lands. Until this existed the
+// mapping was verified by nothing: the only coverage was an emulator
+// self-mirror, where the target and the view are the same size, so the
+// transform is the identity and every scaling or offset bug passes.
+public class TouchMapTest {
+
+ // A 1080x2400 phone showing a 1080x1920 target: fitted to 864x1920,
+ // leaving 108px bars left and right.
+ private static final int VIEW_X = 108, VIEW_W = 864, TARGET_W = 1080;
+
+ @Test
+ public void leftEdgeOfTheVideoIsTheLeftEdgeOfTheTarget() {
+ assertEquals(0, TouchMap.map(VIEW_X, VIEW_X, VIEW_W, TARGET_W));
+ }
+
+ // The view has fewer pixels than the target, so the last column the
+ // mapping can produce is floor((span-1) * target / span) - 1078 here,
+ // not 1079. That is granularity, not an off-by-one: a 864px-wide view
+ // cannot address all 1080 target columns. What matters is that the
+ // right edge lands on the last addressable column and stays inside.
+ @Test
+ public void rightEdgeIsTheLastAddressableColumn() {
+ int got = TouchMap.map(VIEW_X + VIEW_W - 1, VIEW_X, VIEW_W, TARGET_W);
+ assertEquals((VIEW_W - 1) * TARGET_W / VIEW_W, got);
+ assertTrue("must stay inside the target", got < TARGET_W);
+ assertTrue("must be within one step of the far edge",
+ got >= TARGET_W - (TARGET_W / VIEW_W) - 1);
+ }
+
+ @Test
+ public void centreMapsToCentre() {
+ assertEquals(TARGET_W / 2, TouchMap.map(VIEW_X + VIEW_W / 2, VIEW_X, VIEW_W, TARGET_W));
+ }
+
+ @Test
+ public void theLetterboxBarIsClampedNotWrapped() {
+ // Touches on the bars must land on the near edge. Subtracting the
+ // origin without clamping would make the left bar negative and the
+ // right bar overflow past the target's width.
+ assertEquals(0, TouchMap.map(0, VIEW_X, VIEW_W, TARGET_W));
+ assertEquals(0, TouchMap.map(VIEW_X - 50, VIEW_X, VIEW_W, TARGET_W));
+ int far = (VIEW_W - 1) * TARGET_W / VIEW_W; // last addressable column
+ assertEquals(far, TouchMap.map(VIEW_X + VIEW_W + 50, VIEW_X, VIEW_W, TARGET_W));
+ assertEquals(far, TouchMap.map(2000, VIEW_X, VIEW_W, TARGET_W));
+ }
+
+ @Test
+ public void neverEscapesTheTargetForAnyCoordinate() {
+ for (int c = -500; c < 3000; c++) {
+ int t = TouchMap.map(c, VIEW_X, VIEW_W, TARGET_W);
+ if (t < 0 || t >= TARGET_W) {
+ throw new AssertionError("coord " + c + " mapped outside the target: " + t);
+ }
+ }
+ }
+
+ @Test
+ public void noOverflowAtFourK() {
+ // 3840 * 2160 exceeds int when multiplied in 32 bits; the mapping
+ // has to widen before it scales.
+ assertEquals(2159, TouchMap.map(3839, 0, 3840, 2160));
+ assertEquals(0, TouchMap.map(0, 0, 3840, 2160));
+ }
+
+ @Test
+ public void identityWhenTheViewMatchesTheTarget() {
+ // The self-mirror case the e2e exercises: nothing should move.
+ for (int c : new int[]{0, 1, 539, 1079}) {
+ assertEquals(c, TouchMap.map(c, 0, 1080, 1080));
+ }
+ }
+
+ @Test
+ public void degenerateGeometryIsRefusedRatherThanDividingByZero() {
+ assertEquals(0, TouchMap.map(100, 0, 0, 1080));
+ assertEquals(0, TouchMap.map(100, 0, 1080, 0));
+ }
+}
diff --git a/app/src/test/java/invalid/lena/scrcpy/VideoQueueTest.java b/app/src/test/java/invalid/lena/scrcpy/VideoQueueTest.java
new file mode 100644
index 0000000..00b19a5
--- /dev/null
+++ b/app/src/test/java/invalid/lena/scrcpy/VideoQueueTest.java
@@ -0,0 +1,53 @@
+package invalid.lena.scrcpy;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+public class VideoQueueTest {
+
+ private static VideoQueue.Frame frame(int id, boolean config, boolean keyframe) {
+ return new VideoQueue.Frame(new byte[]{(byte) id}, id, config, keyframe);
+ }
+
+ @Test
+ public void rejectsDeltaUntilKeyframe() {
+ VideoQueue queue = new VideoQueue(4, 4);
+ assertFalse(queue.offer(frame(1, false, false)));
+ assertTrue(queue.offer(frame(2, false, true)));
+ assertTrue(queue.offer(frame(3, false, false)));
+ }
+
+ @Test
+ public void overflowKeepsQueuedDecoderGeneration() {
+ VideoQueue queue = new VideoQueue(3, 3);
+ VideoQueue.Frame keyframe = frame(1, false, true);
+ VideoQueue.Frame delta = frame(2, false, false);
+ assertTrue(queue.offer(keyframe));
+ assertTrue(queue.offer(delta));
+ assertTrue(queue.offer(frame(3, false, false)));
+ assertFalse(queue.offer(frame(4, false, false)));
+ assertTrue(queue.needsKeyframe());
+ assertFalse(queue.offer(frame(5, false, false)));
+ assertSame(keyframe, queue.poll());
+ assertSame(delta, queue.poll());
+ }
+
+ @Test
+ public void newKeyframeReplacesOldMediaButKeepsConfig() {
+ VideoQueue queue = new VideoQueue(4, 4);
+ VideoQueue.Frame config = frame(1, true, false);
+ VideoQueue.Frame nextKeyframe = frame(4, false, true);
+ assertTrue(queue.offer(config));
+ assertTrue(queue.offer(frame(2, false, true)));
+ assertTrue(queue.offer(frame(3, false, false)));
+
+ assertTrue(queue.offer(nextKeyframe));
+
+ assertSame(config, queue.poll());
+ assertSame(nextKeyframe, queue.poll());
+ assertTrue(queue.isEmpty());
+ }
+}
diff --git a/app/src/test/java/invalid/lena/scrcpy/VideoStreamTest.java b/app/src/test/java/invalid/lena/scrcpy/VideoStreamTest.java
index 9d132f6..38d7416 100644
--- a/app/src/test/java/invalid/lena/scrcpy/VideoStreamTest.java
+++ b/app/src/test/java/invalid/lena/scrcpy/VideoStreamTest.java
@@ -19,8 +19,8 @@ public class VideoStreamTest {
private static final class RecordingFrames implements VideoFrames {
static final class Cfg { final int fourcc, w, h; Cfg(int f,int w,int h){this.fourcc=f;this.w=w;this.h=h;} }
- static final class Feed { final byte[] data; final long ptsUs; final boolean isCfg;
- Feed(byte[] d,long p,boolean c){data=d;ptsUs=p;isCfg=c;} }
+ static final class Feed { final byte[] data; final long ptsUs; final boolean isCfg, isKey;
+ Feed(byte[] d,long p,boolean c,boolean k){data=d;ptsUs=p;isCfg=c;isKey=k;} }
final List<Cfg> configs = new ArrayList<>();
final List<Cfg> reconfigs = new ArrayList<>();
@@ -29,7 +29,7 @@ public class VideoStreamTest {
@Override public void configure(int f, int w, int h) { configs.add(new Cfg(f, w, h)); }
@Override public void reconfigure(int f, int w, int h) { reconfigs.add(new Cfg(f, w, h)); }
- @Override public void feed(byte[] d, long pts, boolean c){ feeds.add(new Feed(d, pts, c)); }
+ @Override public void feed(byte[] d, long pts, boolean c, boolean k){ feeds.add(new Feed(d, pts, c, k)); }
@Override public void release() { releases++; }
}
@@ -106,6 +106,7 @@ public class VideoStreamTest {
// keyframe: keyframe flag is stripped from ptsUs
assertEquals(1_000_000L, sink.feeds.get(1).ptsUs);
assertEquals(5, sink.feeds.get(1).data.length);
+ assertTrue("second is keyframe", sink.feeds.get(1).isKey);
assertEquals(2_000_000L, sink.feeds.get(2).ptsUs);
}
@@ -171,60 +172,6 @@ public class VideoStreamTest {
}
@Test
- public void recorderTapSeesMetaAndFramesWithFlags() throws Exception {
- byte[] csd = new byte[]{0x67, 0x42, (byte)0xe0, 0x1e};
- byte[] keyframe = new byte[]{10, 11, 12};
- byte[] delta = new byte[]{20, 21};
- long PTS_CFG = 1L << 62;
- long PTS_KEY = (1L << 61) | 1_000_000L;
- long PTS_DELT = 2_000_000L;
-
- byte[] bytes = cat(
- fourcc(Wire.CODEC_H264),
- sessionMeta(800, 600, false),
- frame(PTS_CFG, csd),
- frame(PTS_KEY, keyframe),
- frame(PTS_DELT, delta));
-
- RecordingFrames sink = new RecordingFrames();
- RecordingRecorder rec = new RecordingRecorder();
- VideoStream s = new VideoStream(new ByteArrayInputStream(bytes), sink, null);
- s.setRecorder(rec);
- s.run();
-
- assertEquals(1, rec.metas.size());
- assertEquals(800, rec.metas.get(0)[1]);
- assertEquals(600, rec.metas.get(0)[2]);
-
- assertEquals(3, rec.frames.size());
- assertTrue("cfg frame", rec.frames.get(0).isConfig);
- assertTrue("key frame", rec.frames.get(1).isKeyframe);
- assertTrue("delta is not config or key",
- !rec.frames.get(2).isConfig && !rec.frames.get(2).isKeyframe);
- assertEquals(csd.length, rec.frames.get(0).data.length);
- assertEquals(keyframe.length, rec.frames.get(1).data.length);
- assertEquals(delta.length, rec.frames.get(2).data.length);
- }
-
- private static final class RecordingRecorder implements VideoRecorder {
- static final class FrameRec {
- final byte[] data; final long ptsUs;
- final boolean isConfig, isKeyframe;
- FrameRec(byte[] d, long p, boolean c, boolean k) {
- data = d; ptsUs = p; isConfig = c; isKeyframe = k;
- }
- }
- final List<int[]> metas = new ArrayList<>();
- final List<FrameRec> frames = new ArrayList<>();
- int closes = 0;
- @Override public void onMeta(int f, int w, int h) { metas.add(new int[]{f, w, h}); }
- @Override public void onFrame(byte[] d, long p, boolean c, boolean k) {
- frames.add(new FrameRec(d, p, c, k));
- }
- @Override public void close() { closes++; }
- }
-
- @Test
public void payloadIntegrity() throws Exception {
byte[] payload = new byte[1024];
for (int i = 0; i < payload.length; i++) payload[i] = (byte) (i & 0xff);
diff --git a/app/src/test/java/invalid/lena/scrcpy/WireTest.java b/app/src/test/java/invalid/lena/scrcpy/WireTest.java
index 9cee6c5..e4ada33 100644
--- a/app/src/test/java/invalid/lena/scrcpy/WireTest.java
+++ b/app/src/test/java/invalid/lena/scrcpy/WireTest.java
@@ -2,16 +2,33 @@ package invalid.lena.scrcpy;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import org.junit.Test;
import java.io.ByteArrayInputStream;
import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
public class WireTest {
@Test
+ public void readFullyRejectsZeroProgress() throws Exception {
+ InputStream in = new InputStream() {
+ @Override public int read() { return 0; }
+ @Override public int read(byte[] b, int off, int len) { return 0; }
+ };
+ try {
+ Wire.readFully(in, new byte[1]);
+ fail("zero-progress input accepted");
+ } catch (IOException expected) {
+ assertTrue(expected.getMessage().contains("no progress"));
+ }
+ }
+
+ @Test
public void be32RoundTrip() {
byte[] b = new byte[4];
int[] cases = {0, 1, -1, 0x7fffffff, 0x80000000, 0x12345678};
@@ -67,28 +84,39 @@ public class WireTest {
}
}
+ // Derive the id the way scrcpy's Codec enums do: the ASCII name, right
+ // aligned in a big-endian uint32, NUL-padded on the left. Deriving it
+ // from the name is the whole point - the previous version of this test
+ // asserted the constants against hand-copied literals, so it passed
+ // while CODEC_AV1 held 'av01' instead of '\0av1' and AV1 could never
+ // negotiate.
+ private static int idOf(String name) {
+ if (name.length() > 4) throw new IllegalArgumentException(name);
+ int v = 0;
+ for (int i = 0; i < 4 - name.length(); i++) v <<= 8;
+ for (int i = 0; i < name.length(); i++) v = (v << 8) | name.charAt(i);
+ return v;
+ }
+
@Test
public void fourccConstantsMatchScrcpy() {
- // Big-endian uint32 of the 4-char ASCII tag, NUL-padded on the LEFT
- // for short names (raw, aac) - mirrors AudioCodec / VideoCodec.
- assertEquals(0x68_32_36_34, Wire.CODEC_H264); // "h264"
- assertEquals(0x68_32_36_35, Wire.CODEC_H265); // "h265"
- assertEquals(0x61_76_30_31, Wire.CODEC_AV1); // "av01"
- assertEquals(0x6f_70_75_73, Wire.CODEC_OPUS); // "opus"
- assertEquals(0x66_6c_61_63, Wire.CODEC_FLAC); // "flac"
- assertEquals(0x00_61_61_63, Wire.CODEC_AAC); // "\0aac"
- assertEquals(0x00_72_61_77, Wire.CODEC_RAW); // "\0raw"
+ // Names as spelled in scrcpy's VideoCodec / AudioCodec enums.
+ assertEquals(idOf("h264"), Wire.CODEC_H264);
+ assertEquals(idOf("h265"), Wire.CODEC_H265);
+ assertEquals(idOf("av1"), Wire.CODEC_AV1);
+ assertEquals(idOf("opus"), Wire.CODEC_OPUS);
+ assertEquals(idOf("raw"), Wire.CODEC_RAW);
}
@Test
- public void fourccName() {
+ public void fourccNameRoundTripsEveryCodec() {
assertEquals("h264", Wire.fourccName(Wire.CODEC_H264));
+ assertEquals("h265", Wire.fourccName(Wire.CODEC_H265));
+ assertEquals("av1", Wire.fourccName(Wire.CODEC_AV1));
assertEquals("opus", Wire.fourccName(Wire.CODEC_OPUS));
+ assertEquals("raw", Wire.fourccName(Wire.CODEC_RAW));
+ // Left-NUL padding is the rule, not a special case for raw.
+ assertEquals("aac", Wire.fourccName(0x00_61_61_63));
}
- @Test
- public void fourccNameTrimsLeadingNuls() {
- assertEquals("raw", Wire.fourccName(Wire.CODEC_RAW));
- assertEquals("aac", Wire.fourccName(Wire.CODEC_AAC));
- }
}