diff options
Diffstat (limited to 'app')
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)); - } } |