From eb0c8951196c637e44daf3c0617b131b997d5d2c Mon Sep 17 00:00:00 2001 From: Lena Date: Thu, 1 Jan 2026 00:00:00 +0000 Subject: scrcpy-android: mirror an Android device over wireless ADB Native Java app for Android 12+ that mirrors another Android device over wireless ADB, forwarding video, audio, touch input, and clipboard. Bundles a pinned scrcpy-server.jar and the vendored libadb-android stack. Supports h264/h265/av1 video and raw/opus audio with in-app codec selection. No NDK, no Kotlin. Includes JVM unit tests and a Docker-based emulator e2e rig. --- app/src/main/java/invalid/lena/scrcpy/Adb.java | 210 +++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 app/src/main/java/invalid/lena/scrcpy/Adb.java (limited to 'app/src/main/java/invalid/lena/scrcpy/Adb.java') diff --git a/app/src/main/java/invalid/lena/scrcpy/Adb.java b/app/src/main/java/invalid/lena/scrcpy/Adb.java new file mode 100644 index 0000000..2909156 --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/Adb.java @@ -0,0 +1,210 @@ +package invalid.lena.scrcpy; + +import android.content.Context; + +import org.bouncycastle.asn1.ASN1EncodableVector; +import org.bouncycastle.asn1.ASN1Encoding; +import org.bouncycastle.asn1.ASN1Integer; +import org.bouncycastle.asn1.DERBitString; +import org.bouncycastle.asn1.DERSequence; +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.AlgorithmIdentifier; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.asn1.x509.TBSCertificate; +import org.bouncycastle.asn1.x509.Time; +import org.bouncycastle.asn1.x509.V3TBSCertificateGenerator; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.nio.file.Files; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.Signature; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import io.github.muntashirakon.adb.AbsAdbConnectionManager; +import io.github.muntashirakon.adb.AdbStream; +import io.github.muntashirakon.adb.LocalServices; + +// Thin facade over libadb-android. One instance per process: pairs once, +// connects per session, then exposes typed openers for the three streams +// the rest of the app cares about (shell, sync, localabstract). +// +// The keypair lives in filesDir/{adbkey,adbcert}: PKCS#8 DER for the key, +// X.509 DER for the cert. Software-backed RSA - AndroidKeyStore is unusable +// here because libadb-android's auth path uses raw RSA/ECB/NoPadding which +// hardware-backed keys refuse. +public final class Adb extends AbsAdbConnectionManager { + + private static final String DEVICE_NAME = "scrcpy-android"; + private static final String KEY_FILE = "adbkey"; + private static final String CERT_FILE = "adbcert"; + + // Per-operation timeout for adb protocol calls (pair/connect/openStream). + // libadb-android defaults to Long.MAX_VALUE; a hung target would block + // forever otherwise. + private static final long OP_TIMEOUT_MS = 15_000L; + + // libadb-android's mApi is documented as "the target's API for protocol + // negotiation". We don't know the target's API at construction time; + // pinning to the current Android max means the protocol stays at its + // latest version, which is what wireless-debugging Android-11+ targets + // expect. Bump when newer protocol versions ship. + private static final int TARGET_API_HINT = android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE; + + // Singleton: Main and Mirror both want an Adb; building two would + // race on the on-disk keypair and waste a RSA generation. Lazily + // initialised on the FIRST caller, which should be off the UI thread. + private static volatile Adb instance; + + public static Adb getInstance(Context ctx) throws Exception { + Adb local = instance; + if (local != null) return local; + synchronized (Adb.class) { + if (instance == null) instance = new Adb(ctx.getApplicationContext()); + return instance; + } + } + + private final PrivateKey privateKey; + private final Certificate certificate; + + private Adb(Context ctx) throws Exception { + setApi(TARGET_API_HINT); + setTimeout(OP_TIMEOUT_MS, TimeUnit.MILLISECONDS); + File keyFile = new File(ctx.getFilesDir(), KEY_FILE); + File certFile = new File(ctx.getFilesDir(), CERT_FILE); + if (keyFile.exists() && certFile.exists()) { + privateKey = loadKey(keyFile); + certificate = loadCert(certFile); + Log.i("adb: loaded keypair from %s", keyFile); + } else { + KeyPair kp = generateRsa(); + privateKey = kp.getPrivate(); + certificate = selfSignedCert(kp); + saveKey(keyFile, privateKey); + saveCert(certFile, certificate); + Log.i("adb: generated new keypair at %s", keyFile); + } + } + + @Override protected PrivateKey getPrivateKey() { return privateKey; } + @Override protected Certificate getCertificate() { return certificate; } + @Override protected String getDeviceName() { return DEVICE_NAME; } + + // ---- typed openers ---- + + public AdbStream openAbstract(String name) throws IOException, InterruptedException { + return openStream(LocalServices.LOCAL_UNIX_SOCKET_ABSTRACT, name); + } + + public AdbStream openShell(String cmd) throws IOException, InterruptedException { + // Bypass LocalServices.getDestination(SHELL, args) - it wraps any + // arg containing a space in literal double quotes, and adbd then + // tries to exec `"the whole quoted thing"` as a single filename. + // Build the destination ourselves so the cmd reaches sh -c + // unmolested. + return openStream("shell:" + cmd); + } + + public AdbStream openSync() throws IOException, InterruptedException { + return openStream(LocalServices.SYNC); + } + + // ---- key + cert I/O ---- + + private static KeyPair generateRsa() throws Exception { + KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); + gen.initialize(2048, new SecureRandom()); + return gen.generateKeyPair(); + } + + private static PrivateKey loadKey(File f) throws Exception { + byte[] data = Files.readAllBytes(f.toPath()); + return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(data)); + } + + private static Certificate loadCert(File f) throws Exception { + try (InputStream in = new FileInputStream(f)) { + return CertificateFactory.getInstance("X.509").generateCertificate(in); + } + } + + private static void saveKey(File f, PrivateKey k) throws IOException { + AtomicFiles.write(f, k.getEncoded()); + } + + private static void saveCert(File f, Certificate c) throws Exception { + AtomicFiles.write(f, c.getEncoded()); + } + + // Build a minimal self-signed X.509 certificate over the keypair. + // + // ASN.1 layout (RFC 5280 sec. 4.1): + // + // Certificate ::= SEQUENCE { + // tbsCertificate TBSCertificate, + // signatureAlgorithm AlgorithmIdentifier, -- sha256WithRSAEnc + // signature BIT STRING -- RSA over tbs + // } + // + // TBSCertificate ::= SEQUENCE { + // version [0] EXPLICIT v3, + // serialNumber INTEGER 1, + // signatureAlgorithm AlgorithmIdentifier, + // issuer = subject Name (CN=scrcpy-android), + // validity { notBefore, notAfter }, + // subjectPublicKeyInfo SubjectPublicKeyInfo + // } + // + // ADB validates the peer by the raw RSA public-key fingerprint in the + // SPKI bits, not by the DN or signature, so the surrounding cert is + // cosmetic - but a valid X.509 wrapper is still required for the TLS + // handshake that wireless-debugging uses post-pairing. + private static Certificate selfSignedCert(KeyPair kp) throws Exception { + long now = System.currentTimeMillis(); + Date notBefore = new Date(now - 60_000L); + Date notAfter = new Date(now + 50L * 365 * 24 * 3600 * 1000L); + X500Name dn = new X500Name("CN=" + DEVICE_NAME); + AlgorithmIdentifier sigAlg = + new AlgorithmIdentifier(PKCSObjectIdentifiers.sha256WithRSAEncryption); + + V3TBSCertificateGenerator g = new V3TBSCertificateGenerator(); + g.setSerialNumber(new ASN1Integer(BigInteger.ONE)); + g.setSignature(sigAlg); + g.setIssuer(dn); + g.setSubject(dn); + g.setStartDate(new Time(notBefore)); + g.setEndDate(new Time(notAfter)); + g.setSubjectPublicKeyInfo( + SubjectPublicKeyInfo.getInstance(kp.getPublic().getEncoded())); + + TBSCertificate tbs = g.generateTBSCertificate(); + + Signature s = Signature.getInstance("SHA256withRSA"); + s.initSign(kp.getPrivate()); + s.update(tbs.getEncoded(ASN1Encoding.DER)); + byte[] sig = s.sign(); + + ASN1EncodableVector v = new ASN1EncodableVector(); + v.add(tbs); + v.add(sigAlg); + v.add(new DERBitString(sig)); + byte[] certDer = new DERSequence(v).getEncoded(ASN1Encoding.DER); + + return CertificateFactory.getInstance("X.509") + .generateCertificate(new ByteArrayInputStream(certDer)); + } +} -- cgit v1.2.3