diff options
| author | Lena <lena@omega> | 2026-01-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-06-24 22:14:50 +0300 |
| commit | eb0c8951196c637e44daf3c0617b131b997d5d2c (patch) | |
| tree | 2f83b6a41cee745467e53fc401942b82cea286ea | |
| download | scrcpy-android-eb0c8951196c637e44daf3c0617b131b997d5d2c.tar.gz | |
scrcpy-android: mirror an Android device over wireless ADB0.1
Native Java app for Android 12+ that mirrors another Android
device over wireless ADB, forwarding video, audio, touch input,
and clipboard. Bundles a pinned scrcpy-server.jar and the
vendored libadb-android stack. Supports h264/h265/av1 video and
raw/opus audio with in-app codec selection. No NDK, no Kotlin.
Includes JVM unit tests and a Docker-based emulator e2e rig.
124 files changed, 12789 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6c7953 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.gradle/ +.tools/ +build/ +local.properties +*.iml +.idea/ +.DS_Store +app/release/ + +# vendored binary, regenerated by scripts/update-server +app/src/main/assets/scrcpy-server.jar + +# capture artefact from ./test record +output.mp4 + +# adb keypair lives in app's filesDir at runtime; never check it in +adbkey +adbkey.pub diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..580d485 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "vendor/scrcpy"] + path = vendor/scrcpy + url = https://github.com/Genymobile/scrcpy.git @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, excluding + those notices that do not pertain to any part of the Derivative + Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and do + not modify the License. You may add Your own attribution notices + within Derivative Works that You distribute, alongside or as an + addendum to the NOTICE text from the Work, provided that such + additional attribution notices cannot be construed as modifying + the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions for + use, reproduction, or distribution of Your modifications, or for any + such Derivative Works as a whole, provided Your use, reproduction, + and distribution of the Work otherwise complies with the conditions + stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Lena + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. @@ -0,0 +1,147 @@ +scrcpy-android +============== + + +What it does +------------ +Native Android app that mirrors a second Android device over Wireless ADB, +the same way scrcpy(1) does. Source device runs this app; target is any +Android 11+ device with Wireless Debugging enabled. Video, audio, touch, +key, and clipboard are forwarded. No root required on either side. + +Java only, no Kotlin, no NDK. ADB stack vendored from +MuntashirAkon/libadb-android. scrcpy-server.jar is a pinned upstream binary +in `app/src/main/assets/`. + + +How to run it +------------- +Prerequisites: Android Studio (Iguana or newer) or JDK 17 + Android SDK with +platform-tools and platform 35. + + ./scripts/fetch-vendor # pull libadb-android into vendor/ + ./scripts/update-server # download + verify pinned scrcpy-server.jar + ./gradlew :app:assembleDebug # build debug APK + adb install app/build/outputs/apk/debug/app-debug.apk + +For a signed release APK, see "How to build a release APK" below. + +On the target device: Settings -> Developer options -> Wireless debugging -> +Pair device with pairing code. Note the displayed ip:port and 6-digit code. + +In the app on the source device: enter ip:port and the pairing code, tap +Pair. Then tap the saved device row to connect and mirror. + + +How to test it +-------------- +A `./test` entrypoint runs tests inside a project-local Docker image +(JDK 17, Android cmdline-tools, platform 35, build-tools 35, x86_64 +emulator). The image is built on first run and cached. + + ./test # JVM unit tests (Wire, Sync, ControlMessages, + # Devices, plus piped-stream parser tests for + # VideoStream, AudioStream, ControlStream). + # ~30 s on warm cache. No KVM needed. + + ./test e2e # Single-emulator self-mirror end-to-end. + # Boots the API-35 AOSP emulator, roots it, + # installs the debug APK, drops the app's public + # key into /data/misc/adb/adb_keys, launches + # Mirror against 127.0.0.1:5555, then asserts + # 'video frame n=1' lands in logcat and the + # screencap is non-uniform. Then kills the server + # on the target and asserts the session detects the + # dropped link and auto-reconnects (a fresh + # 'video frame n=1'). Needs /dev/kvm readable by the + # invoking uid. + + ./test all # unit tests followed by e2e. Fails fast: if the + # unit tier fails, the e2e tier is skipped. + # Same /dev/kvm requirement as `./test e2e`. + + ./test record # Boots the emulator, runs a Mirror session, injects + # taps/keys, and stitches a screen recording into + # ./output.mp4. Needs /dev/kvm. + + ./test apk # Smoke build: generates a throwaway PKCS12 keystore + # under .tools/, runs scripts/build-apk against it, + # verifies the resulting APK is signed. No KVM. + +If /dev/kvm is not rw for your uid the script prints the one-line fix +and exits - it does not auto-chmod. + + +How to build a release APK +-------------------------- +Set four environment variables and run the release script: + + export KEYSTORE_PATH=/abs/path/to/release.jks + export KEYSTORE_PASS=... + export KEY_ALIAS=... + export KEY_PASS=... + ./scripts/build-apk + +The signed APK lands at +`app/build/outputs/apk/release/app-release.apk` and its sha256 is +printed at the end. If `apksigner` is reachable (either on $PATH or +under $ANDROID_SDK_ROOT/build-tools/) the script also runs a +post-build `apksigner verify`. + +Generate a release keystore once with: + + keytool -genkey -v -keystore release.jks -storetype PKCS12 \ + -alias scrcpy-android -keyalg RSA -keysize 2048 -validity 10000 + +Keep it OUTSIDE the repo. The .gitignore does not protect arbitrary +filenames; the keystore is your secret. + + +How to debug it +--------------- +Everything is logged with tag `scrcpy-android`: + + adb logcat -s scrcpy-android + +Expected log sequence on a successful connect (pair ok appears once, +at pairing time): + + pair ok host=... pair_port=... + adb connect ok + push /data/local/tmp/scrcpy-server.jar bytes=... + spawn server ver=<v> scid=<hex> + openAbstract scrcpy_<scid> ok (attempt <n>) (x3) + video meta codec=h264 + audio meta codec=raw (or opus if selected in Settings) + video session meta <w>x<h> client_resize=false + video frame n=1 + +Server stdout/stderr is pumped into the same log stream prefixed with +`server: ` so target-side errors are visible without an extra adb shell. + + +Layout +------ + app/ Android module (Java) + vendor/libadb-android/ vendored libadb module, pinned tag, plus licenses + scripts/ POSIX sh, set -eu + gradle/, gradlew* Gradle 8.9 wrapper + LICENSE Apache-2.0 + + +Constraints +----------- +- minSdk 31 (source device needs Android 12+); compileSdk = targetSdk = + 35 (built against and targeting Android 15) +- Wireless ADB only; no USB-OTG host mode +- Multi-touch, soft keys, clipboard sync, and hardware Back (long-press + the device Back key while Mirror is foreground) are forwarded. +- Video codec (h264 / h265 / av1) and audio codec (raw / opus) are + selectable from the in-app Settings screen; defaults are h264 + raw. + + +License +------- +Apache-2.0; see LICENSE. Bundles libadb-android (dual GPL-3.0-or-later OR +Apache-2.0, used here under Apache-2.0) and a pinned, checksum-verified +scrcpy-server.jar from Genymobile/scrcpy (Apache-2.0). diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..d16a8d5 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,130 @@ +plugins { + id 'com.android.application' +} + +android { + namespace 'invalid.lena.scrcpy' + compileSdk 35 + // Pin build-tools explicitly so the aapt2/zipalign/d8 toolchain is + // fixed and builds stay reproducible, not whatever AGP defaults to. + // 35.0.0 is the compileSdk's. + buildToolsVersion '35.0.0' + + defaultConfig { + // The published package id. Immutable across releases. + applicationId 'invalid.lena.scrcpy' + minSdk 31 + targetSdk 35 + versionCode 1 + versionName '0.1' + } + + // AGP otherwise embeds a Google-signed dependency-metadata blob in the + // APK that is not byte-reproducible. Strip it so the build stays + // deterministic. This is the single most common reproducible-build + // breaker on AGP. + dependenciesInfo { + includeInApk false + includeInBundle false + } + + // Release signing pulls credentials from the environment. Set all + // four of KEYSTORE_PATH / KEYSTORE_PASS / KEY_ALIAS / KEY_PASS and + // assembleRelease produces a signed APK. With any of them missing + // the release build is unsigned (Gradle's default). + signingConfigs { + release { + def ksPath = System.getenv('KEYSTORE_PATH') + def ksPass = System.getenv('KEYSTORE_PASS') + def kAlias = System.getenv('KEY_ALIAS') + def kPass = System.getenv('KEY_PASS') + if (ksPath && ksPass && kAlias && kPass) { + storeFile file(ksPath) + storePassword ksPass + keyAlias kAlias + keyPassword kPass + } + } + } + + // Native ABI policy: release ships arm64-v8a only. Every targeted + // source device is 64-bit ARM in practice; 32-bit-only devices are + // not supported. The test rig uses an x86_64 emulator, so debug keeps + // x86_64 (and arm64) so ./test e2e still works. + buildTypes { + debug { + applicationIdSuffix '.debug' + versionNameSuffix '-debug' + ndk { + abiFilters 'arm64-v8a', 'x86_64' + } + } + release { + ndk { + abiFilters 'arm64-v8a' + } + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + if (System.getenv('KEYSTORE_PATH')) { + signingConfig signingConfigs.release + } + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + packaging { + // bouncycastle (transitive via :adb) is the dominant source of + // dead weight in the APK. Each entry below is dead code/data for + // our use case (we only call BC's asn1, crypto, util.encoders): + // - picnic post-quantum lookup tables: ~1.2 MB of .properties + // - cert-path-reviewer i18n messages: ~92 KB, en+de only + // - kotlin metadata: bleed from spake2-android (Kotlin lib); + // our code is pure Java and doesn't reflect on .kotlin_builtins + // - androidx annotation LICENSE.txt: 10 KB blob, no runtime use + // - duplicate META-INF licenses/notices: ordinary AGP cleanup + resources.excludes += [ + 'META-INF/LICENSE*', + 'META-INF/NOTICE*', + 'META-INF/versions/9/OSGI-INF/MANIFEST.MF', + 'META-INF/androidx/**', + 'META-INF/kotlin-stdlib*', + 'org/bouncycastle/pqc/crypto/picnic/**', + 'org/bouncycastle/x509/CertPathReviewerMessages*.properties', + 'kotlin/**', + ] + } + + testOptions { + // android.util.Log and friends are stubs on the JVM unit-test + // classpath; let them return defaults instead of throwing. + unitTests.returnDefaultValues = true + } +} + +dependencies { + implementation project(':adb') + // bcprov is already pulled in transitively by :adb at runtime; we + // need it on the compile classpath too for Adb.java's ASN.1 + // cert-builder. :adb keeps it 'implementation'-scoped upstream so + // we declare it explicitly here rather than patch the vendor tree. + implementation 'org.bouncycastle:bcprov-jdk15to18:1.81' + + // Bundled Conscrypt. libadb-android's TLS pairing exports keying + // material via Conscrypt. Without a bundled copy it reflects into the + // platform's hidden com.android.org.conscrypt.Conscrypt, which under + // targetSdk 35 is invisible to reflection (getMethod throws + // NoSuchMethodException and pairing fails on real devices). Bundling a + // standalone Conscrypt flips libadb onto the public + // org.conscrypt.Conscrypt API, which is not restricted. Ships a native + // .so (filtered to arm64-v8a in release). + implementation 'org.conscrypt:conscrypt-android:2.5.2' + + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.json:json:20240303' +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..a2c74bf --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,41 @@ +# R8 keep rules. The goal is to let R8 strip everything the project +# doesn't use while keeping the runtime paths the vendored libraries +# rely on. We use bouncycastle directly (asn1/crypto/util.encoders) +# and via :adb (pairing crypto), so its dead weight - pqc, jcajce, +# pkix, cert, cms, tls, openpgp - is fair game for R8. Packaging +# excludes drop the matching resources alongside. + +# ---- bouncycastle ---- +# asn1 has heavy static OID-to-class lookup; keep the full surface. +-keep class org.bouncycastle.asn1.** { *; } +# crypto primitives we call directly + via :adb (SHA256Digest, +# AESEngine, GCMBlockCipher, HKDF, AEADParameters, KeyParameter). +-keep class org.bouncycastle.crypto.** { *; } +# util.encoders.Base64; rest of util/* is also referenced from asn1. +-keep class org.bouncycastle.util.** { *; } +-dontwarn org.bouncycastle.** + +# ---- libadb-android (vendored as :adb) ---- +# AbsAdbConnectionManager + AdbStream + LocalServices are the public +# surface we touch. Pairing internals reach into Conscrypt/Provider +# reflectively; keep the package wholesale, it's ~150 KB. +-keep class io.github.muntashirakon.adb.** { *; } +-dontwarn io.github.muntashirakon.adb.** + +# Conscrypt is now bundled (org.conscrypt:conscrypt-android) so TLS +# pairing uses its public exportKeyingMaterial instead of the platform's +# hidden one. libadb reaches it reflectively (Class.forName + +# getDeclaredConstructor + getMethod), and Conscrypt self-references via +# JNI, so keep the package wholesale. The platform conscrypt name still +# appears in a dead else-branch, so silence that one. +-keep class org.conscrypt.** { *; } +-dontwarn org.conscrypt.** +-dontwarn com.android.org.conscrypt.** +# Legacy Apache Harmony JSSE; unreachable on minSdk 35 but +# referenced in libadb's PRNGFixes. +-dontwarn org.apache.harmony.** + +# spake2-android (Kotlin) - small native bridge for SPAKE2 pairing. +# Native methods are looked up by JNI signature; keep the names. +-keep class com.muntashirakon.crypto.spake2.** { *; } +-dontwarn com.muntashirakon.crypto.spake2.** diff --git a/app/src/debug/res/layout/mirror.xml b/app/src/debug/res/layout/mirror.xml new file mode 100644 index 0000000..5d0e845 --- /dev/null +++ b/app/src/debug/res/layout/mirror.xml @@ -0,0 +1,93 @@ +<?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 new file mode 100644 index 0000000..3e1e4f3 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + + <uses-permission android:name="android.permission.INTERNET"/> + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/> + <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> + + <application + android:name=".App" + android:label="@string/app_name" + android:icon="@mipmap/ic_launcher" + android:roundIcon="@mipmap/ic_launcher" + android:theme="@style/Theme.Scrcpy" + android:allowBackup="false" + android:supportsRtl="false"> + + <activity + android:name=".Main" + android:exported="true" + android:theme="@style/Theme.Scrcpy.NoActionBar"> + <intent-filter> + <action android:name="android.intent.action.MAIN"/> + <category android:name="android.intent.category.LAUNCHER"/> + </intent-filter> + </activity> + + <activity + android:name=".Mirror" + android:exported="false" + android:configChanges="orientation|screenSize|keyboardHidden" + android:screenOrientation="unspecified" + android:hardwareAccelerated="true" + android:theme="@style/Theme.Scrcpy.Fullscreen"/> + + <activity + android:name=".SettingsActivity" + android:exported="false" + android:label="@string/settings"/> + + <service + android:name=".Sessions" + android:exported="false" + android:foregroundServiceType="dataSync"/> + + </application> + +</manifest> diff --git a/app/src/main/assets/scrcpy-server.sha256 b/app/src/main/assets/scrcpy-server.sha256 new file mode 100644 index 0000000..376a42f --- /dev/null +++ b/app/src/main/assets/scrcpy-server.sha256 @@ -0,0 +1 @@ +84924bd564a1eb6089c872c7521f968058977f91f5ff02514a8c74aff3210f3a diff --git a/app/src/main/assets/scrcpy-server.version b/app/src/main/assets/scrcpy-server.version new file mode 100644 index 0000000..5186d07 --- /dev/null +++ b/app/src/main/assets/scrcpy-server.version @@ -0,0 +1 @@ +4.0 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)); + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/App.java b/app/src/main/java/invalid/lena/scrcpy/App.java new file mode 100644 index 0000000..f28ec9d --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/App.java @@ -0,0 +1,15 @@ +package invalid.lena.scrcpy; + +import android.app.Application; + +// Process-wide bootstrap. Only job today is to install the crash +// logger before any of our code runs. Add other process-scoped init +// here if it appears, but resist filling this with statics. +public final class App extends Application { + + @Override + public void onCreate() { + super.onCreate(); + Crashlog.install(this); + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/AtomicFiles.java b/app/src/main/java/invalid/lena/scrcpy/AtomicFiles.java new file mode 100644 index 0000000..6f755b6 --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/AtomicFiles.java @@ -0,0 +1,40 @@ +package invalid.lena.scrcpy; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; + +// Crash-atomic whole-file write: stage the bytes into a sibling temp file, +// 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. +// +// Deliberately no fsync of the parent directory: the rename itself may be +// lost on power failure (the old content survives intact). Callers store +// re-creatable state, so atomicity matters here and durability does not. +// +// android-free (java.io/java.nio only) so it is unit-testable on the JVM. +final class AtomicFiles { + + private AtomicFiles() {} + + static 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(); + } + try { + Files.move(tmp.toPath(), dest.toPath(), + StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + tmp.delete(); + throw e; + } + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/AudioFrames.java b/app/src/main/java/invalid/lena/scrcpy/AudioFrames.java new file mode 100644 index 0000000..da0bb0a --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/AudioFrames.java @@ -0,0 +1,18 @@ +package invalid.lena.scrcpy; + +// Sink for parsed audio frames. AudioSink is the only production +// implementation; tests use a recording stub. Kept android-free. +// +// start(fourcc) tells the sink which wire codec it should expect. +// For raw payloads are interleaved s16le PCM. For opus the first +// frame has isConfig=true and payload is the OpusHead (the server +// pre-strips the AOPUSHDR/AOPUSDLY/AOPUSPRL container); subsequent +// frames are opus packets. +public interface AudioFrames { + + void start(int fourcc); + + void feed(byte[] data, int off, int len, boolean isConfig); + + void release(); +} diff --git a/app/src/main/java/invalid/lena/scrcpy/AudioSink.java b/app/src/main/java/invalid/lena/scrcpy/AudioSink.java new file mode 100644 index 0000000..2b3e32d --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/AudioSink.java @@ -0,0 +1,215 @@ +package invalid.lena.scrcpy; + +import android.media.AudioAttributes; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioTrack; +import android.media.MediaCodec; +import android.media.MediaFormat; +import android.os.Handler; +import android.os.HandlerThread; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +// Audio output: AudioTrack writing 48 kHz stereo 16-bit PCM. The +// upstream feed is either raw PCM (passthrough) or Opus packets +// (decoded through MediaCodec into PCM first). +public final class AudioSink implements AudioFrames { + + private static final int SAMPLE_RATE = 48_000; + private static final int CHANNEL_OUT = AudioFormat.CHANNEL_OUT_STEREO; + private static final int ENCODING = AudioFormat.ENCODING_PCM_16BIT; + + // Defaults documented at <https://developer.android.com/reference/android/media/MediaCodec#CSD>. + private static final long DEFAULT_PRE_ROLL_NS = 80_000_000L; + + private AudioTrack track; + private MediaCodec opusCodec; + private HandlerThread opusThread; + private Handler opusHandler; + private boolean opusConfigured; + private int fourcc; + + private volatile boolean released; + public volatile long frames; // public read for the status overlay + private long droppedBytes; + private long lastDropLogMs; + + @Override + public void start(int fourcc) { + this.fourcc = fourcc; + + int minBuf = AudioTrack.getMinBufferSize(SAMPLE_RATE, CHANNEL_OUT, ENCODING); + if (minBuf <= 0) throw new IllegalStateException("AudioTrack.getMinBufferSize=" + minBuf); + int bufSize = Math.max(minBuf, 32 * 1024); + + AudioAttributes attrs = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build(); + AudioFormat fmt = new AudioFormat.Builder() + .setSampleRate(SAMPLE_RATE) + .setChannelMask(CHANNEL_OUT) + .setEncoding(ENCODING) + .build(); + + track = new AudioTrack( + attrs, fmt, bufSize, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE); + track.play(); + Log.i("audio sink: AudioTrack started sr=%d ch=2 buf=%d", SAMPLE_RATE, bufSize); + + if (fourcc == Wire.CODEC_OPUS) { + opusThread = new HandlerThread("audio-mc"); + opusThread.start(); + opusHandler = new Handler(opusThread.getLooper()); + // Codec is created here, but only configured once the first + // FLAG_CONFIG frame arrives with the OpusHead bytes. + try { + opusCodec = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS); + } catch (IOException e) { + throw new IllegalStateException("audio sink: no opus decoder", e); + } + opusCodec.setCallback(new MediaCodec.Callback() { + @Override public void onInputBufferAvailable(MediaCodec mc, int idx) { + // Frames are queued synchronously from feed(); we + // don't pull on this callback. This handler exists + // so MediaCodec's async machinery is wired up. + } + @Override public void onOutputBufferAvailable(MediaCodec mc, int idx, + MediaCodec.BufferInfo info) { + try { + ByteBuffer out = mc.getOutputBuffer(idx); + if (out != null && info.size > 0 && !released) { + byte[] pcm = new byte[info.size]; + out.position(info.offset); + out.limit(info.offset + info.size); + out.get(pcm); + writePcm(pcm, 0, pcm.length); + } + } catch (IllegalStateException ignored) { + } 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"); + } + @Override public void onOutputFormatChanged(MediaCodec mc, MediaFormat f) { + Log.i("audio sink: opus output format %s", f); + } + }, opusHandler); + } + } + + @Override + public void feed(byte[] data, int off, int len, boolean isConfig) { + if (released || len <= 0) return; + if (fourcc == Wire.CODEC_OPUS) { + feedOpus(data, off, len, isConfig); + } else { + // Raw PCM: passthrough. + frames++; + writePcm(data, off, len); + } + } + + private void feedOpus(byte[] data, int off, int len, boolean isConfig) { + if (isConfig) { + if (opusConfigured) return; // already configured + // 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); + return; + } + try { + byte[] head = new byte[len]; + System.arraycopy(data, off, head, 0, len); + int preSkipSamples = ((head[10] & 0xff) | ((head[11] & 0xff) << 8)); + long preSkipNs = preSkipSamples * 1_000_000_000L / SAMPLE_RATE; + + MediaFormat fmt = MediaFormat.createAudioFormat( + MediaFormat.MIMETYPE_AUDIO_OPUS, SAMPLE_RATE, 2); + fmt.setByteBuffer("csd-0", ByteBuffer.wrap(head)); + fmt.setByteBuffer("csd-1", longLeBytes(preSkipNs)); + fmt.setByteBuffer("csd-2", longLeBytes(DEFAULT_PRE_ROLL_NS)); + opusCodec.configure(fmt, null, null, 0); + opusCodec.start(); + opusConfigured = true; + Log.i("audio sink: opus configured, pre_skip=%d ns", preSkipNs); + } catch (Exception e) { + Log.e(e, "audio sink: opus configure"); + } + return; + } + if (!opusConfigured) return; + // Encoded packet → MediaCodec input. + int idx; + try { idx = opusCodec.dequeueInputBuffer(0); } + catch (IllegalStateException e) { return; } + if (idx < 0) { + // No input buffer right now; drop the packet. Opus is forgiving + // about gaps for short stalls. + return; + } + try { + ByteBuffer in = opusCodec.getInputBuffer(idx); + if (in == null) return; + in.clear(); + in.put(data, off, len); + opusCodec.queueInputBuffer(idx, 0, len, 0, 0); + frames++; + } catch (IllegalStateException e) { + Log.w("audio sink: opus queueInputBuffer: %s", e); + } + } + + // Encode a long little-endian for MediaFormat csd-1 / csd-2. + private static ByteBuffer longLeBytes(long v) { + ByteBuffer b = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(v); + b.flip(); + return b; + } + + private void writePcm(byte[] data, int off, int len) { + AudioTrack t = track; + 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); + return; + } + if (written < len) { + droppedBytes += (len - written); + long now = System.currentTimeMillis(); + if (now - lastDropLogMs > 1000L) { + lastDropLogMs = now; + Log.w("audio sink: ring full, dropped %d bytes so far", droppedBytes); + } + } + } + + @Override + public void release() { + released = true; + MediaCodec c = opusCodec; + opusCodec = null; + HandlerThread ht = opusThread; + opusThread = null; + opusHandler = null; + if (c != null) { + try { c.stop(); } catch (Exception ignored) {} + try { c.release(); } catch (Exception ignored) {} + } + if (ht != null) ht.quitSafely(); + + AudioTrack t = track; + track = null; + if (t == null) return; + try { t.pause(); t.flush(); t.stop(); } catch (Exception ignored) {} + try { t.release(); } catch (Exception ignored) {} + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/AudioStream.java b/app/src/main/java/invalid/lena/scrcpy/AudioStream.java new file mode 100644 index 0000000..39b184d --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/AudioStream.java @@ -0,0 +1,93 @@ +package invalid.lena.scrcpy; + +import java.io.IOException; +import java.io.InputStream; + +// Reads the scrcpy audio socket and drives an AudioFrames sink. +// +// Wire format (big-endian, scrcpy 3.x/4.x): +// 1) Stream meta: uint32 fourcc +// special values 0 = disabled, 1 = error +// 2) Loop: 12-byte frame header (uint64 ptsAndFlags | uint32 size) +// followed by `size` bytes of payload. +// +// We honour FLAG_CONFIG on the per-frame header: for opus the first +// frame is the OpusHead (sent with FLAG_CONFIG set), subsequent are +// opus packets. For raw PCM the server never sets FLAG_CONFIG. +// +// Takes a plain InputStream; caller owns stream lifecycle. +public final class AudioStream { + + private static final long FLAG_CONFIG = 1L << 62; + + // 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 final InputStream source; + private final AudioFrames sink; + private Thread thread; + private volatile boolean stop; + + public AudioStream(InputStream source, AudioFrames sink) { + this.source = source; + this.sink = sink; + } + + public void start() { + thread = new Thread(this::run, "audio-reader"); + thread.start(); + } + + public void stop() { + stop = true; + if (thread != null) thread.interrupt(); + } + + public void run() { + try { + byte[] four = new byte[4]; + Wire.readFully(source, four); + int fourcc = Wire.readBe32(four, 0); + if (fourcc == 0) { + Log.w("audio: server reports stream disabled (target cannot capture)"); + return; + } + if (fourcc == 1) { + Log.e("audio: server reports configuration error"); + return; + } + if (fourcc != Wire.CODEC_RAW && fourcc != Wire.CODEC_OPUS) { + Log.w("audio: unexpected codec %s - keeping silent", + Wire.fourccName(fourcc)); + return; + } + Log.i("audio meta codec=%s", Wire.fourccName(fourcc)); + sink.start(fourcc); + + byte[] hdr = new byte[12]; + byte[] payload = new byte[16 * 1024]; + long frames = 0; + while (!stop) { + Wire.readFully(source, hdr); + long ptsAndFlags = Wire.readBe64(hdr, 0); + int size = Wire.readBe32(hdr, 8); + boolean cfg = (ptsAndFlags & FLAG_CONFIG) != 0; + if (size <= 0 || size > MAX_FRAME_SIZE) { + throw new IOException("audio frame size out of range: " + size); + } + if (size > payload.length) payload = new byte[size]; + Wire.readFully(source, payload, 0, size); + 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"); + } catch (Exception e) { + Log.e(e, "audio reader unexpected"); + } finally { + Log.i("audio reader: end"); + } + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/ControlMessages.java b/app/src/main/java/invalid/lena/scrcpy/ControlMessages.java new file mode 100644 index 0000000..bd983ed --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/ControlMessages.java @@ -0,0 +1,72 @@ +package invalid.lena.scrcpy; + +import java.nio.charset.StandardCharsets; + +// Pure-java encoders for scrcpy control messages. Layouts mirror +// com.genymobile.scrcpy.control.ControlMessageReader in the scrcpy +// server source. Extracted from Controller so the wire format can be +// unit-tested without android.* on the classpath. +public final class ControlMessages { + + 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; + + // 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; + + 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) { + byte[] m = new byte[TOUCH_MSG_LEN]; + m[0] = TYPE_INJECT_TOUCH_EVENT; + m[1] = (byte) action; + Wire.writeBe64(m, 2, pointerId); + Wire.writeBe32(m, 10, x); + Wire.writeBe32(m, 14, y); + m[18] = (byte)(targetW >>> 8); m[19] = (byte) targetW; + m[20] = (byte)(targetH >>> 8); m[21] = (byte) targetH; + m[22] = (byte)(pressureU16 >>> 8); m[23] = (byte) pressureU16; + Wire.writeBe32(m, 24, actionButton); + Wire.writeBe32(m, 28, buttons); + return m; + } + + public static byte[] keycode(int action, int keycode, int repeat, int metaState) { + byte[] m = new byte[KEY_MSG_LEN]; + m[0] = TYPE_INJECT_KEYCODE; + m[1] = (byte) action; + Wire.writeBe32(m, 2, keycode); + Wire.writeBe32(m, 6, repeat); + Wire.writeBe32(m, 10, metaState); + return m; + } + + // Single button-press event. scrcpy server interprets this as Back + // when the screen is on, or screen-on when it's off. We send a + // DOWN+UP pair to make a tap; this helper builds one byte pair. + public static byte[] backOrScreenOn(int action) { + return new byte[]{(byte) TYPE_BACK_OR_SCREEN_ON, (byte) action}; + } + + public static byte[] setClipboard(long sequence, boolean paste, String text) { + byte[] data = text.getBytes(StandardCharsets.UTF_8); + byte[] m = new byte[1 + 8 + 1 + 4 + data.length]; + m[0] = TYPE_SET_CLIPBOARD; + Wire.writeBe64(m, 1, sequence); + m[9] = (byte)(paste ? 1 : 0); + Wire.writeBe32(m, 10, data.length); + System.arraycopy(data, 0, m, 14, data.length); + return m; + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/ControlStream.java b/app/src/main/java/invalid/lena/scrcpy/ControlStream.java new file mode 100644 index 0000000..eea6c7d --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/ControlStream.java @@ -0,0 +1,154 @@ +package invalid.lena.scrcpy; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.LinkedBlockingDeque; + +// Bidirectional bridge to the scrcpy control socket. +// +// Outbound: Controller hands us pre-encoded ControlMessage byte arrays +// via send(); the writer thread drains them onto the OutputStream. +// Queue is bounded; on overflow, the *oldest* intermediate +// INJECT_TOUCH_EVENT with action=MOVE is dropped. Touch-down/up and key +// events are never dropped, otherwise pointers get orphaned on the target. +// +// Inbound: the reader thread parses DeviceMessage frames from the +// InputStream. v1 only cares about TYPE_CLIPBOARD; ACK_CLIPBOARD is +// logged, UHID_OUTPUT is consumed and ignored. +// +// Constructor takes plain streams so this class is android-free. +public final class ControlStream { + + public interface InboundSink { + void onRemoteClipboard(String text); + } + + private static final int TYPE_INJECT_TOUCH_EVENT = 2; + private static final int ACTION_MOVE = 2; + + private static final int DEV_TYPE_CLIPBOARD = 0; + private static final int DEV_TYPE_ACK_CLIPBOARD = 1; + private static final int DEV_TYPE_UHID_OUTPUT = 2; + + private static final int MAX_QUEUED = 256; + + private final InputStream in; + private final OutputStream out; + private final LinkedBlockingDeque<byte[]> outbox = new LinkedBlockingDeque<>(MAX_QUEUED); + + private volatile InboundSink sink; + private Thread writer; + private Thread reader; + private volatile boolean stop; + + public ControlStream(InputStream in, OutputStream out) { + this.in = in; + this.out = out; + } + + // Wired after construction: the Controller that consumes inbound + // messages needs this stream's send() to exist first. + public void setInboundSink(InboundSink sink) { + this.sink = sink; + } + + public void start() { + writer = new Thread(this::runWriter, "control-writer"); + reader = new Thread(this::runReader, "control-reader"); + writer.start(); + reader.start(); + } + + public void stop() { + stop = true; + outbox.clear(); + if (writer != null) writer.interrupt(); + if (reader != null) reader.interrupt(); + } + + public void send(byte[] msg) { + if (stop) return; + if (outbox.offerLast(msg)) return; + + // Full: try to drop an intermediate touch-MOVE to make room. + // Touch-down/up and non-touch messages stay. + for (byte[] b : outbox) { + if (b.length >= 2 && b[0] == TYPE_INJECT_TOUCH_EVENT && (b[1] & 0xff) == ACTION_MOVE) { + if (outbox.remove(b)) break; + } + } + if (!outbox.offerLast(msg)) { + Log.w("control: outbox full, dropping msg type=%d", msg.length > 0 ? msg[0] & 0xff : -1); + } + } + + // ---- writer ---- + + public void runWriter() { + try { + while (!stop) { + byte[] msg = outbox.takeFirst(); + out.write(msg); + out.flush(); + } + } catch (InterruptedException ignored) { + } catch (IOException e) { + if (!stop) Log.e(e, "control writer"); + } finally { + Log.i("control writer: end"); + } + } + + // ---- reader ---- + + public void runReader() { + try { + byte[] tmp = new byte[12]; + while (!stop) { + Wire.readFully(in, tmp, 0, 1); + int type = tmp[0] & 0xff; + switch (type) { + case DEV_TYPE_CLIPBOARD: { + Wire.readFully(in, tmp, 0, 4); + int len = Wire.readBe32(tmp, 0); + if (len < 0 || len > 1 << 20) { + 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); + InboundSink s = sink; + if (s != null) s.onRemoteClipboard(text); + break; + } + case DEV_TYPE_ACK_CLIPBOARD: { + Wire.readFully(in, tmp, 0, 8); + long seq = (long) Wire.readBe32(tmp, 0) << 32 + | (Wire.readBe32(tmp, 4) & 0xffffffffL); + Log.i("control: ack clipboard seq=%d", seq); + break; + } + case DEV_TYPE_UHID_OUTPUT: { + Wire.readFully(in, tmp, 0, 4); + int len = ((tmp[2] & 0xff) << 8) | (tmp[3] & 0xff); + if (len > 0) { + byte[] skip = new byte[len]; + Wire.readFully(in, skip); + } + break; + } + default: + throw new IOException("unknown DeviceMessage type=" + type); + } + } + } catch (IOException e) { + if (!stop) Log.e(e, "control reader"); + } catch (Exception e) { + Log.e(e, "control reader unexpected"); + } finally { + Log.i("control reader: end"); + } + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/Controller.java b/app/src/main/java/invalid/lena/scrcpy/Controller.java new file mode 100644 index 0000000..88b9442 --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/Controller.java @@ -0,0 +1,173 @@ +package invalid.lena.scrcpy; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.view.KeyEvent; +import android.view.MotionEvent; + +import java.util.function.Consumer; + +// Encodes UI events into scrcpy ControlMessage byte arrays and pushes +// them at ControlStream. Also mirrors the remote clipboard locally and +// echoes local clipboard changes the other way. +// +// Forwards multi-pointer touch and AOSP keycodes. Mouse buttons and +// scroll wheels are not forwarded yet. +public final class Controller implements ControlStream.InboundSink { + + private final Consumer<byte[]> sender; + private final ClipboardManager clipboard; + public volatile String lastEvent = "(idle)"; + // 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; + + // Suppress one local clipboard change after we set it from a remote update. + private volatile boolean suppressNextClipChange; + + public Controller(Context ctx, Consumer<byte[]> sender) { + this.sender = sender; + this.clipboard = (ClipboardManager) ctx.getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboard != null) { + clipboard.addPrimaryClipChangedListener(clipListener); + } + } + + public void release() { + if (clipboard != null) { + try { clipboard.removePrimaryClipChangedListener(clipListener); } + catch (Exception ignored) {} + } + } + + public void setTargetSize(int w, int h) { + targetW = w; targetH = h; + Log.i("controller: target %dx%d", w, h); + } + + public void setViewSize(int w, int h) { + viewW = w; viewH = h; + } + + // ---- inbound ---- + + @Override + public void onRemoteClipboard(String text) { + Log.i("clipboard from target: %d chars", text.length()); + if (clipboard == null) return; + suppressNextClipChange = true; + try { + clipboard.setPrimaryClip(ClipData.newPlainText("scrcpy-android", text)); + } catch (Exception e) { + Log.w("clipboard set local failed: %s", e); + suppressNextClipChange = false; + } + } + + 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); + if (cs == null) return; + sendSetClipboard(cs.toString(), false); + Log.i("clipboard to target: %d chars", cs.length()); + } + + // ---- 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; + + // scrcpy's wire protocol uses ACTION_DOWN/UP/MOVE/CANCEL with a + // pointerId per message. The server tracks which pointers are + // currently down. So we translate Android's masked actions: + // 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 + 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); + break; + case MotionEvent.ACTION_POINTER_DOWN: + sendPointer(ev, idx, MotionEvent.ACTION_DOWN, tw, th, vw, vh); + break; + case MotionEvent.ACTION_UP: + sendPointer(ev, 0, MotionEvent.ACTION_UP, tw, th, vw, vh); + break; + case MotionEvent.ACTION_POINTER_UP: + sendPointer(ev, idx, MotionEvent.ACTION_UP, tw, th, vw, vh); + break; + case MotionEvent.ACTION_MOVE: + for (int i = 0; i < n; i++) sendPointer(ev, i, MotionEvent.ACTION_MOVE, tw, th, vw, vh); + break; + case MotionEvent.ACTION_CANCEL: + for (int i = 0; i < n; i++) sendPointer(ev, i, MotionEvent.ACTION_CANCEL, tw, th, vw, vh); + 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) { + 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 pressure = (action == MotionEvent.ACTION_UP) ? 0 + : (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 + ")"; + } + + public void onKey(KeyEvent ev) { + int tw = targetW, th = targetH; + if (tw == 0 || th == 0) 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()); + } + + // Fire a tap of the target's Back. scrcpy server treats the + // BACK_OR_SCREEN_ON message as Back when the target is on, screen- + // on when it's off - useful for waking a locked target too. + public void onBack() { + sender.accept(ControlMessages.backOrScreenOn(ControlMessages.ACTION_DOWN)); + sender.accept(ControlMessages.backOrScreenOn(ControlMessages.ACTION_UP)); + lastEvent = "back"; + } + + // ---- 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) { + sender.accept(ControlMessages.setClipboard(/* sequence */ 0L, paste, text)); + lastEvent = "clip " + text.length() + " chars"; + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/Crashlog.java b/app/src/main/java/invalid/lena/scrcpy/Crashlog.java new file mode 100644 index 0000000..00bd3d8 --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/Crashlog.java @@ -0,0 +1,48 @@ +package invalid.lena.scrcpy; + +import android.content.Context; + +import java.io.File; +import java.io.FileWriter; +import java.io.PrintWriter; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +// Catches uncaught exceptions on any thread, writes them as plain +// text to <externalFilesDir>/crash-<timestamp>.log, then chains to +// the system default handler so the OS crash dialog still appears. +// Testers can share the file from the app's storage instead of +// fishing for it in adb logcat. +public final class Crashlog { + + private Crashlog() {} + + public static void install(Context ctx) { + File dir = ctx.getExternalFilesDir(null); + if (dir == null) { + Log.w("crashlog: no external storage, skipping install"); + return; + } + Thread.UncaughtExceptionHandler prev = + Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler((t, e) -> { + try { + String ts = new SimpleDateFormat("yyyyMMdd-HHmmss", + Locale.ROOT).format(new Date()); + File out = new File(dir, "crash-" + ts + ".log"); + try (PrintWriter pw = new PrintWriter(new FileWriter(out))) { + pw.println("# " + new Date()); + pw.println("# thread=" + t.getName()); + pw.println(); + e.printStackTrace(pw); + } + Log.e(e, "crashlog: wrote %s", out.getAbsolutePath()); + } catch (Throwable ignored) { + // best effort - do not mask the original crash + } + if (prev != null) prev.uncaughtException(t, e); + }); + Log.i("crashlog: installed -> %s", dir.getAbsolutePath()); + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/Devices.java b/app/src/main/java/invalid/lena/scrcpy/Devices.java new file mode 100644 index 0000000..2883025 --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/Devices.java @@ -0,0 +1,126 @@ +package invalid.lena.scrcpy; + +import android.content.Context; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; + +// Tiny JSON-backed list of paired targets, stored at filesDir/devices.json. +// No SQLite, no Room. Read on demand, written whole-file on add/remove. +// +// parse() / serialize() are pure-java and android-free for unit tests. +// load() / save() add the Context-rooted file I/O. +public final class Devices { + + private static final String FILE = "devices.json"; + + public static final class Device { + public final String host; + public final int port; + + public Device(String host, int port) { + this.host = host; + this.port = port; + } + + @Override + public String toString() { + return host + ":" + port; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Device d)) return false; + return port == d.port && host.equals(d.host); + } + + @Override + public int hashCode() { + return host.hashCode() * 31 + port; + } + } + + private Devices() {} + + // Pure-java parse: returns whatever rows are well-formed; logs and + // skips anything malformed instead of nuking the list. + public static List<Device> parse(String json) { + List<Device> out = new ArrayList<>(); + if (json == null) return out; + 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; } + + for (int i = 0; i < arr.length(); i++) { + try { + JSONObject o = arr.getJSONObject(i); + out.add(new Device(o.getString("host"), o.getInt("port"))); + } catch (Exception e) { + Log.w("devices: skipping malformed row %d: %s", i, e); + } + } + return out; + } + + public static String serialize(List<Device> devices) { + try { + JSONArray arr = new JSONArray(); + for (Device d : devices) { + JSONObject o = new JSONObject(); + o.put("host", d.host); + o.put("port", d.port); + arr.put(o); + } + return arr.toString(2); + } catch (Exception e) { + Log.e(e, "devices: serialize failed"); + return "[]"; + } + } + + public static List<Device> load(Context ctx) { + 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)); + } catch (Exception e) { + Log.e(e, "devices: load failed"); + return new ArrayList<>(); + } + } + + public static void save(Context ctx, List<Device> devices) { + File f = new File(ctx.getFilesDir(), FILE); + try { + AtomicFiles.write(f, serialize(devices).getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + Log.e(e, "devices: save failed"); + } + } + + // Add or replace by host+port. Context-rooted; the in-place helper + // is the test seam. + public static List<Device> upsert(Context ctx, Device d) { + List<Device> list = load(ctx); + list.removeIf(d::equals); + list.add(d); + save(ctx, list); + return list; + } + + // Remove the matching device (by host+port). Returns the updated list. + public static List<Device> remove(Context ctx, Device d) { + List<Device> list = load(ctx); + list.removeIf(d::equals); + save(ctx, list); + return list; + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/Log.java b/app/src/main/java/invalid/lena/scrcpy/Log.java new file mode 100644 index 0000000..f2c7e87 --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/Log.java @@ -0,0 +1,26 @@ +package invalid.lena.scrcpy; + +import java.util.Locale; + +// Single static wrapper around android.util.Log so every line in this app +// uses the same tag. Filter the whole app with `adb logcat -s scrcpy-android`. +// +// String.format always uses Locale.ROOT so logs are unaffected by device +// locales that use comma decimals or other surprises. +public final class Log { + public static final String TAG = "scrcpy-android"; + + private Log() {} + + public static void i(String fmt, Object... a) { android.util.Log.i(TAG, fmt(fmt, a)); } + public static void w(String fmt, Object... a) { android.util.Log.w(TAG, fmt(fmt, a)); } + public static void e(String fmt, Object... a) { android.util.Log.e(TAG, fmt(fmt, a)); } + + public static void e(Throwable t, String fmt, Object... a) { + android.util.Log.e(TAG, fmt(fmt, a), t); + } + + private static String fmt(String fmt, Object... a) { + return a.length == 0 ? fmt : String.format(Locale.ROOT, fmt, a); + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/Main.java b/app/src/main/java/invalid/lena/scrcpy/Main.java new file mode 100644 index 0000000..104f57f --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/Main.java @@ -0,0 +1,150 @@ +package invalid.lena.scrcpy; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Intent; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.View; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ListView; +import android.widget.TextView; +import android.widget.Toast; + +import java.util.List; + +// Pairing form + saved-device list. +// +// Layout: host, pair-port, pair-code, connect-port + a single "Pair and save" +// button. After a successful pair() against the daemon, the row is appended +// to devices.json with the *connect* port (different from the pair port on +// Android 11+). 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. +public final class Main extends Activity { + + private volatile Adb adb; + private ArrayAdapter<Devices.Device> adapter; + private Button pairButton; + + @Override + protected void onCreate(Bundle saved) { + super.onCreate(saved); + setContentView(R.layout.main); + + EditText host = findViewById(R.id.host); + EditText pairPort = findViewById(R.id.pair_port); + EditText pairCode = findViewById(R.id.pair_code); + EditText connectPort = findViewById(R.id.connect_port); + pairButton = findViewById(R.id.pair); + View settingsBtn = findViewById(R.id.settings); + ListView devices = findViewById(R.id.devices); + TextView 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); + + // 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); + runOnUiThread(() -> { + adb = a; + pairButton.setEnabled(true); + }); + } catch (Exception e) { + Log.e(e, "adb init failed"); + runOnUiThread(() -> { + Toast.makeText(this, "adb init failed: " + e.getMessage(), + Toast.LENGTH_LONG).show(); + finish(); + }); + } + }, "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.host + ":" + d.port) + .setPositiveButton(android.R.string.ok, (dlg, w) -> { + Log.i("forget device: %s", d); + List<Devices.Device> updated = Devices.remove(this, d); + adapter.clear(); + adapter.addAll(updated); + adapter.notifyDataSetChanged(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + return true; + }); + + pairButton.setOnClickListener(v -> { + if (adb == null) return; // still initialising + String h = host.getText().toString().trim(); + String pp = pairPort.getText().toString().trim(); + String pc = pairCode.getText().toString().trim(); + String cp = connectPort.getText().toString().trim(); + if (TextUtils.isEmpty(h) || TextUtils.isEmpty(pp) + || TextUtils.isEmpty(pc) || TextUtils.isEmpty(cp)) { + Toast.makeText(this, "fill all four fields", Toast.LENGTH_SHORT).show(); + return; + } + int pairP, connP; + try { + pairP = Integer.parseInt(pp); + connP = Integer.parseInt(cp); + } catch (NumberFormatException e) { + Toast.makeText(this, "ports must be numeric", Toast.LENGTH_SHORT).show(); + return; + } + if (pairP < 1 || pairP > 65535 || connP < 1 || connP > 65535) { + Toast.makeText(this, "ports must be 1-65535", Toast.LENGTH_SHORT).show(); + return; + } + pairButton.setEnabled(false); + Toast.makeText(this, R.string.pairing, Toast.LENGTH_SHORT).show(); + new Thread(() -> pairAndSave(h, pairP, pc, connP, pairButton), "pair").start(); + }); + } + + private void pairAndSave(String host, int pairPort, String code, int connPort, Button btn) { + try { + Log.i("pair: %s:%d", host, pairPort); + boolean ok = adb.pair(host, pairPort, code); + if (!ok) throw new IllegalStateException("pair returned false"); + Log.i("pair ok host=%s pair_port=%d", host, pairPort); + Devices.Device d = new Devices.Device(host, connPort); + List<Devices.Device> updated = Devices.upsert(this, d); + runOnUiThread(() -> { + adapter.clear(); + adapter.addAll(updated); + adapter.notifyDataSetChanged(); + Toast.makeText(this, "paired and saved", Toast.LENGTH_SHORT).show(); + btn.setEnabled(true); + }); + } catch (Exception e) { + Log.e(e, "pair failed"); + runOnUiThread(() -> { + 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 new file mode 100644 index 0000000..f6e249d --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/Mirror.java @@ -0,0 +1,471 @@ +package invalid.lena.scrcpy; + +import android.Manifest; +import android.app.Activity; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.graphics.SurfaceTexture; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.Surface; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.TextureView; +import android.view.View; +import android.view.WindowInsets; +import android.view.WindowInsetsController; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.TextView; +import android.widget.Toast; + +import java.io.File; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +// Full-screen mirror activity. Pulls target host/port from intent +// extras, starts a Sessions foreground service to keep the process +// alive during brief backgrounding, and owns the Session itself. +// +// Two layouts ship: src/main/res/layout/mirror.xml (release: SurfaceView +// + status bar) and src/debug/res/layout/mirror.xml (debug: TextureView +// + status bar + bottom stats overlay used by the e2e screen capture +// because emulators don't composite SurfaceView into screencap). +// +// Surface lifetime is decoupled from session lifetime: when the surface +// goes away (rotation, background) we swap the session's video surface +// to null and let the wire keep draining. When the surface comes back +// we swap the new one in. Audio and control streams are unaffected. +// +// Session state vs activity lifetime: a fatal session error does NOT +// finish() the activity any more - instead the status bar transitions +// to DISCONNECTED and exposes a Reconnect button. +public final class Mirror extends Activity { + + public static final String EXTRA_HOST = "host"; + public static final String EXTRA_PORT = "port"; + + // Arbitrary request code for POST_NOTIFICATIONS - we don't react to + // the result; the system caches the choice for next launch. + private static final int RQ_POST_NOTIFICATIONS = 1001; + + private enum State { CONNECTING, CONNECTED, DISCONNECTED } + + private volatile Adb adb; + private Devices.Device target; + private Session session; + private Surface currentSurface; + private State state = State.CONNECTING; + private int connectedW, connectedH; + + // Only one of these is non-null per build variant. + private TextureView textureView; + private SurfaceView surfaceView; + + // Always present (declared in both layouts). + private View statusBar; + private TextView statusText; + private Button reconnectBtn; + private Button recordBtn; + + // Overlay TextViews - only present in the debug layout. null in release. + private TextView overlayTarget, overlayStats, overlayEvent; + private final Handler ui = new Handler(Looper.getMainLooper()); + private int textureUpdates; + + @Override + protected void onCreate(Bundle saved) { + super.onCreate(saved); + setContentView(R.layout.mirror); + immersive(); + // Hold the source screen awake for as long as Mirror is in + // front. Cleared automatically when the activity is destroyed. + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + + String host = getIntent().getStringExtra(EXTRA_HOST); + int port = getIntent().getIntExtra(EXTRA_PORT, -1); + if (host == null || port <= 0) { + Log.e("mirror: bad extras host=%s port=%d", host, port); + finish(); + return; + } + target = new Devices.Device(host, port); + + View v = findViewById(R.id.surface); + if (v instanceof TextureView) { + textureView = (TextureView) v; + textureView.setSurfaceTextureListener(textureListener); + } else if (v instanceof SurfaceView) { + surfaceView = (SurfaceView) v; + surfaceView.getHolder().addCallback(holderCallback); + } else { + Log.e("mirror: layout has no SurfaceView or TextureView at R.id.surface"); + finish(); + return; + } + + statusBar = findViewById(R.id.status_bar); + statusText = findViewById(R.id.status_text); + reconnectBtn = findViewById(R.id.reconnect); + recordBtn = findViewById(R.id.record); + reconnectBtn.setOnClickListener(view -> reconnect()); + recordBtn.setOnClickListener(view -> toggleRecord()); + updateStatusBar(); + + overlayTarget = findViewById(R.id.overlay_target); + overlayStats = findViewById(R.id.overlay_stats); + overlayEvent = findViewById(R.id.overlay_event); + if (overlayTarget != null) { + overlayTarget.setText("target: " + host + ":" + port); + ui.postDelayed(this::pollOverlay, 200); + } + + requestNotificationsIfNeeded(); + startForegroundService(new Intent(this, Sessions.class)); + + if (!Settings.hintBackShown(this)) { + Toast.makeText(this, R.string.hint_long_press_back, + Toast.LENGTH_LONG).show(); + Settings.setHintBackShown(this, true); + } + + new Thread(() -> { + try { + Adb a = Adb.getInstance(this); + runOnUiThread(() -> { + adb = a; + if (session == null && currentSurface != null) { + startSession(currentSurface); + } + }); + } catch (Exception e) { + Log.e(e, "mirror: adb init"); + runOnUiThread(() -> { + Toast.makeText(this, "adb init: " + e.getMessage(), + Toast.LENGTH_LONG).show(); + finish(); + }); + } + }, "adb-init").start(); + } + + // ---- surface lifecycle: TextureView path ---- + + private final TextureView.SurfaceTextureListener textureListener = + new TextureView.SurfaceTextureListener() { + @Override + public void onSurfaceTextureAvailable(SurfaceTexture st, int w, int h) { + Log.i("mirror: texture available %dx%d", w, h); + attachSurface(new Surface(st), w, h); + } + @Override + public void onSurfaceTextureSizeChanged(SurfaceTexture st, int w, int h) { + Log.i("mirror: texture resized %dx%d", w, h); + if (session != null) session.setViewSize(w, h); + } + @Override + public boolean onSurfaceTextureDestroyed(SurfaceTexture st) { + Log.i("mirror: texture destroyed"); + detachSurface(); + return true; + } + @Override + public void onSurfaceTextureUpdated(SurfaceTexture st) { + if (++textureUpdates == 1 || textureUpdates % 30 == 0) { + Log.i("mirror: texture updates=%d", textureUpdates); + } + } + }; + + // ---- surface lifecycle: SurfaceView path ---- + + private final SurfaceHolder.Callback holderCallback = new SurfaceHolder.Callback() { + @Override + public void surfaceCreated(SurfaceHolder holder) { + Log.i("mirror: surface created"); + attachSurface(holder.getSurface(), 0, 0); + } + @Override + public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) { + Log.i("mirror: surface changed %dx%d", w, h); + if (session != null) session.setViewSize(w, h); + } + @Override + public void surfaceDestroyed(SurfaceHolder holder) { + Log.i("mirror: surface destroyed"); + detachSurface(); + } + }; + + // ---- session driver ---- + + private void attachSurface(Surface s, int w, int h) { + currentSurface = s; + if (session != null) { + session.swapSurface(s); + if (w > 0) session.setViewSize(w, h); + return; + } + if (adb == null) return; // adb-init thread will start the session + if (state == State.DISCONNECTED) return; // wait for user to tap reconnect + startSession(s); + if (w > 0) session.setViewSize(w, h); + } + + private void detachSurface() { + currentSurface = null; + if (session != null) session.swapSurface(null); + } + + private void startSession(Surface s) { + state = State.CONNECTING; + updateStatusBar(); + session = new Session(this, adb, target, s, new Session.Listener() { + @Override public void onConnected(int w, int h) { + runOnUiThread(() -> { + state = State.CONNECTED; + connectedW = w; connectedH = h; + updateStatusBar(); + }); + } + @Override public void onReconnecting() { + Log.i("mirror: link lost, reconnecting"); + runOnUiThread(() -> { + state = State.CONNECTING; + updateStatusBar(); + }); + } + @Override public void onError(Throwable t) { + runOnUiThread(() -> { + Toast.makeText(Mirror.this, + "session error: " + t.getMessage(), Toast.LENGTH_LONG).show(); + }); + } + @Override public void onStopped() { + Log.i("mirror: session stopped"); + runOnUiThread(() -> { + state = State.DISCONNECTED; + updateStatusBar(); + }); + } + }); + session.start(); + } + + private void toggleRecord() { + if (session == null) return; + if (session.isRecording()) { + session.stopRecording(); + Toast.makeText(this, "recording saved", Toast.LENGTH_SHORT).show(); + } else { + File dir = getExternalFilesDir(null); + if (dir == null) { + Toast.makeText(this, "no external storage", Toast.LENGTH_LONG).show(); + return; + } + String ts = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.ROOT).format(new Date()); + File out = new File(dir, "scrcpy-" + ts + ".mp4"); + session.armRecording(out); + Toast.makeText(this, "recording -> " + out.getName(), Toast.LENGTH_SHORT).show(); + } + updateRecordButton(); + } + + private void updateRecordButton() { + boolean on = session != null && session.isRecording(); + recordBtn.setText(on ? R.string.record_on : R.string.record); + } + + private void reconnect() { + Log.i("mirror: reconnect tapped"); + Session old = session; + session = null; + state = State.CONNECTING; + updateStatusBar(); + // Stop the old session off the UI thread (teardown closes + // sockets and joins the server's log pump), THEN start the new + // one. Sequencing matters: both sessions share the singleton + // Adb, so the old teardown's disconnect must finish before the + // new bring-up connects. + new Thread(() -> { + if (old != null) old.stop(); + runOnUiThread(() -> { + if (session != null) return; // another path already started one + if (adb == null || currentSurface == null) return; + startSession(currentSurface); + }); + }, "session-stop").start(); + } + + private void updateStatusBar() { + if (statusText == null) return; + String s; + switch (state) { + case CONNECTED: + s = String.format(Locale.ROOT, "%s:%d %dx%d %s", + target.host, target.port, connectedW, connectedH, + Settings.videoCodec(this)); + break; + case DISCONNECTED: + s = String.format(Locale.ROOT, "%s:%d %s", + target.host, target.port, getString(R.string.disconnected)); + break; + default: + s = String.format(Locale.ROOT, "%s:%d %s", + target.host, target.port, getString(R.string.connecting)); + } + statusText.setText(s); + reconnectBtn.setVisibility(state == State.DISCONNECTED ? View.VISIBLE : View.GONE); + recordBtn.setEnabled(state == State.CONNECTED); + updateRecordButton(); + + // Hide the whole bar while actively mirroring if the user opted + // out; keep it up whenever not CONNECTED so the status and the + // Reconnect button stay reachable. + if (statusBar != null) { + boolean show = Settings.showStatusBar(this) || state != State.CONNECTED; + statusBar.setVisibility(show ? View.VISIBLE : View.GONE); + } + } + + // ---- overlay (debug only - fields are null in release) ---- + + private void pollOverlay() { + if (overlayStats == null) return; + if (session != null) { + overlayStats.setText(String.format(Locale.ROOT, + "v=%-4d a=%-4d tex=%-4d", + session.videoFrames(), session.audioFrames(), textureUpdates)); + overlayEvent.setText("event: " + session.lastEvent()); + } + ui.postDelayed(this::pollOverlay, 200); + } + + // ---- input ---- + + @Override + public boolean onTouchEvent(MotionEvent ev) { + if (session != null) { + session.onTouch(ev); + return true; + } + return super.onTouchEvent(ev); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_BACK) { + event.startTracking(); + return true; + } + return super.onKeyDown(keyCode, event); + } + + @Override + public boolean onKeyLongPress(int keyCode, KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_BACK && session != null) { + session.onBack(); + return true; + } + return super.onKeyLongPress(keyCode, event); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent ev) { + if (ev.getKeyCode() == KeyEvent.KEYCODE_BACK) { + return super.dispatchKeyEvent(ev); + } + if (session != null && shouldForward(ev)) { + session.onKey(ev); + return true; + } + return super.dispatchKeyEvent(ev); + } + + // ---- lifecycle ---- + + @Override + protected void onDestroy() { + super.onDestroy(); + ui.removeCallbacksAndMessages(null); + Session s = session; + session = null; + if (s != null) { + // Teardown blocks on socket closes and a thread join; keep + // it off the UI thread. + new Thread(s::stop, "session-stop").start(); + } + stopService(new Intent(this, Sessions.class)); + } + + // Android 13+ requires runtime grant for POST_NOTIFICATIONS. The + // foreground service notification is silently suppressed if the + // user never sees the dialog, and on Android 14+ a notification- + // less FGS can be killed at any time. + private void requestNotificationsIfNeeded() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return; + if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED) return; + requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, + RQ_POST_NOTIFICATIONS); + } + + private void immersive() { + WindowInsetsController c = getWindow().getInsetsController(); + if (c != null) { + c.hide(WindowInsets.Type.systemBars()); + c.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); + } + getWindow().setDecorFitsSystemWindows(false); + } + + private static boolean shouldForward(KeyEvent ev) { + int code = ev.getKeyCode(); + if (code >= KeyEvent.KEYCODE_DPAD_UP && code <= KeyEvent.KEYCODE_DPAD_CENTER) return true; + if (code >= KeyEvent.KEYCODE_0 && code <= KeyEvent.KEYCODE_9) return true; + if (code >= KeyEvent.KEYCODE_A && code <= KeyEvent.KEYCODE_Z) return true; + switch (code) { + case KeyEvent.KEYCODE_SPACE: + case KeyEvent.KEYCODE_ENTER: + case KeyEvent.KEYCODE_DEL: + case KeyEvent.KEYCODE_FORWARD_DEL: + case KeyEvent.KEYCODE_TAB: + case KeyEvent.KEYCODE_ESCAPE: + case KeyEvent.KEYCODE_PAGE_UP: + case KeyEvent.KEYCODE_PAGE_DOWN: + case KeyEvent.KEYCODE_MOVE_HOME: + case KeyEvent.KEYCODE_MOVE_END: + case KeyEvent.KEYCODE_INSERT: + case KeyEvent.KEYCODE_SHIFT_LEFT: + case KeyEvent.KEYCODE_SHIFT_RIGHT: + case KeyEvent.KEYCODE_CTRL_LEFT: + case KeyEvent.KEYCODE_CTRL_RIGHT: + case KeyEvent.KEYCODE_ALT_LEFT: + case KeyEvent.KEYCODE_ALT_RIGHT: + case KeyEvent.KEYCODE_META_LEFT: + case KeyEvent.KEYCODE_META_RIGHT: + case KeyEvent.KEYCODE_CAPS_LOCK: + case KeyEvent.KEYCODE_NUM_LOCK: + case KeyEvent.KEYCODE_SCROLL_LOCK: + case KeyEvent.KEYCODE_COMMA: + case KeyEvent.KEYCODE_PERIOD: + case KeyEvent.KEYCODE_SLASH: + case KeyEvent.KEYCODE_BACKSLASH: + case KeyEvent.KEYCODE_SEMICOLON: + case KeyEvent.KEYCODE_APOSTROPHE: + case KeyEvent.KEYCODE_GRAVE: + case KeyEvent.KEYCODE_LEFT_BRACKET: + case KeyEvent.KEYCODE_RIGHT_BRACKET: + case KeyEvent.KEYCODE_MINUS: + case KeyEvent.KEYCODE_EQUALS: + return true; + default: + return false; + } + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/MuxRecorder.java b/app/src/main/java/invalid/lena/scrcpy/MuxRecorder.java new file mode 100644 index 0000000..457272e --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/MuxRecorder.java @@ -0,0 +1,225 @@ +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 35 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 { + + public interface Listener { + // Fires on the recorder thread; main use is updating the UI's + // record button label once writing actually begins (we may be + // ARMED for many frames before a keyframe lands). + default void onStarted(File out) {} + default void onStopped(File out, long bytes) {} + default void onError(Throwable t) {} + } + + private enum State { IDLE, ARMED, RECORDING, ERROR } + + private final Object lock = new Object(); + private final Listener listener; + + 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; + + public MuxRecorder() { this(null); } + + public MuxRecorder(Listener listener) { + this.listener = listener != null ? listener : new Listener() {}; + } + + // 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); + } + + public void stop() { + File out; + long bytes; + boolean reportStopped; + synchronized (lock) { + if (state == State.IDLE) return; + reportStopped = (state == State.RECORDING); + closeMuxerLocked(); + out = outFile; + bytes = bytesWritten; + state = State.IDLE; + outFile = null; + } + Log.i("rec: stopped (%d bytes)", bytes); + if (reportStopped) listener.onStopped(out, bytes); + } + + 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, surface as an error so + // the UI can re-arm. addTrack after start is illegal; we + // don't try to splice tracks together. + if (state == State.RECORDING && (fcc != fourcc || width != w || height != h)) { + Log.w("rec: resize/codec change during recording - closing"); + File out = outFile; + long bytes = bytesWritten; + closeMuxerLocked(); + state = State.IDLE; + outFile = null; + listener.onStopped(out, bytes); + } + 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: + case ERROR: + return; + case ARMED: + if (!isKeyframe || csd == null) return; + if (!startMuxerLocked()) { + state = State.ERROR; + return; + } + writeSampleLocked(data, ptsUs, true); + state = State.RECORDING; + listener.onStarted(outFile); + return; + case RECORDING: + writeSampleLocked(data, ptsUs, isKeyframe); + } + } + } + + @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; + listener.onError(e); + return false; + } + } + + private void writeSampleLocked(byte[] data, long ptsUs, boolean isKeyframe) { + if (muxer == null) return; + // 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; + } catch (IllegalStateException e) { + Log.w("rec: writeSampleData: %s", e); + } + } + + private void closeMuxerLocked() { + MediaMuxer m = muxer; + muxer = null; + trackIdx = -1; + if (m == null) return; + try { m.stop(); } catch (Exception ignored) {} + try { m.release(); } catch (Exception ignored) {} + } + + 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 new file mode 100644 index 0000000..cc16b44 --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/Server.java @@ -0,0 +1,304 @@ +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.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import io.github.muntashirakon.adb.AdbStream; + +// Bring the scrcpy server up on the target: +// 1. Push assets/scrcpy-server.jar to /data/local/tmp/scrcpy-server.jar +// via the adb sync protocol. +// 2. Spawn `app_process / com.genymobile.scrcpy.Server <ver> key=value...` +// via an adb shell stream and hold it open. cleanup=true means the +// server exits when we close that stream. +// 3. Open three localabstract:scrcpy_<scid> streams in order +// (video, audio, control). With tunnel_forward=true the server is +// the listener, so we just dial. +// 4. Drain 1 probe byte + 64-byte device name from the FIRST stream +// that the server accepts (video, if requested). +// +// Returns a Streams record with everything Session needs: typed +// InputStream/OutputStream wrappers (opened ONCE per AdbStream so the +// reader's offset is unambiguous), plus the underlying AdbStream +// handles so close() can release them. +public final class Server { + + private static final String REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar"; + private static final String ASSET_JAR = "scrcpy-server.jar"; + private static final String ASSET_VERSION = "scrcpy-server.version"; + private static final int FILE_MODE = 0100644; // regular file, 0644 + // Server forks a CleanUp helper before opening its abstract sockets; + // on slow emulators that takes several seconds, so the budget needs + // to be generous. Wall-clock bounded: even if every attempt wedges + // for the full OPEN_ATTEMPT_TIMEOUT_MS, the total stays near + // OPEN_DEADLINE_MS - strictly below the e2e test deadline + // (test-rig/e2e.sh's E2E_DEADLINE, default 60 s), or the test + // would fail the wrong way. + private static final long OPEN_DEADLINE_MS = 20_000; + private static final int OPEN_BACKOFF_MS = 100; + // Per-attempt timeout for adb.openAbstract. libadb-android's + // AdbConnection.open() blocks on a naked stream.wait() with no + // loop or timeout - a missed notification (response arrives before + // we park) wedges the call forever. Wrap each attempt with a + // timeout + interrupt so the loop can move on. + private static final long OPEN_ATTEMPT_TIMEOUT_MS = 800; + + public static final class Streams { + 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) { + 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; + } + } + + private final Context ctx; + private final Adb adb; + private AdbStream shell; + private Thread shellPump; + private Streams streams; + private volatile boolean serverEof; + + public Server(Context ctx, Adb adb) { + this.ctx = ctx; + this.adb = adb; + } + + public Streams bringUp() throws Exception { + String version = readVersion(); + long pushed = push(); + Log.i("push %s bytes=%d", REMOTE_PATH, pushed); + + String scid = newScid(); + String cmd = buildCmdline(version, scid); + Log.i("spawn server ver=%s scid=%s", version, scid); + Log.i("cmdline: %s", cmd); + + shell = adb.openShell(cmd); + shellPump = new Thread(() -> pump(shell.openInputStream()), "server-stdout"); + shellPump.setDaemon(true); + shellPump.start(); + + AdbStream va = openAbstract(scid); + AdbStream aa = openAbstract(scid); + AdbStream ca = openAbstract(scid); + + InputStream vi = va.openInputStream(); + InputStream ai = aa.openInputStream(); + 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); + return streams; + } + + // Idempotent. Closes the three media/control streams first (lets + // the wire drain), then the shell (which makes the scrcpy server + // exit via cleanup=true), then gives the cleanup helper up to + // CLOSE_GRACE_MS to restore device state - display power, screen + // timeout, brightness, etc. - before we kill the adb connection + // out from under it. Best-effort: cleanup is server-side and we + // can't synchronously confirm it. + public void close() { + if (streams != null) { + closeQuietly(streams.videoAds); + closeQuietly(streams.audioAds); + closeQuietly(streams.controlAds); + streams = null; + } + Thread t = shellPump; + AdbStream s = shell; + shell = null; + shellPump = null; + closeQuietly(s); + if (t != null) { + try { t.join(CLOSE_GRACE_MS); } + catch (InterruptedException ignored) {} + if (t.isAlive()) t.interrupt(); + } + } + + private static final long CLOSE_GRACE_MS = 500; + + private static void closeQuietly(AdbStream s) { + if (s == null) return; + try { s.close(); } catch (IOException ignored) {} + } + + // ---- helpers ---- + + 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"); + } + return v; + } + } + + 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); + } + + private String buildCmdline(String version, String scid) { + String videoCodec = Settings.videoCodec(ctx); + String audioCodec = Settings.audioCodec(ctx); + int maxSize = Settings.maxSize(ctx); + int videoBitR = Settings.videoBitRate(ctx); + int maxFps = Settings.maxFps(ctx); + List<String> args = new ArrayList<>(); + args.add("CLASSPATH=" + REMOTE_PATH); + args.add("app_process"); + args.add("/"); + args.add("com.genymobile.scrcpy.Server"); + args.add(version); + args.add("scid=" + scid); + args.add("log_level=info"); + args.add("video=true"); + args.add("audio=true"); + args.add("control=true"); + args.add("video_codec=" + videoCodec); + args.add("audio_codec=" + audioCodec); + args.add("max_size=" + maxSize); + args.add("video_bit_rate=" + videoBitR); + if (maxFps > 0) args.add("max_fps=" + maxFps); + args.add("clipboard_autosync=true"); + args.add("tunnel_forward=true"); + args.add("cleanup=true"); + args.add("power_on=true"); + return String.join(" ", args); + } + + private AdbStream openAbstract(String scid) throws Exception { + String name = "scrcpy_" + scid; + long deadline = System.currentTimeMillis() + OPEN_DEADLINE_MS; + Throwable last = null; + for (int attempt = 1; System.currentTimeMillis() < deadline; attempt++) { + // If the shell stream closed (server died early - usually with + // an error printed to stderr that our pump captured), bail + // immediately. Otherwise we'd spin out the deadline on a + // dead target. + if (serverEof) { + throw new IOException("server exited before opening " + + name + " (see 'server:' logs)", + last instanceof Exception ? (Exception) last : null); + } + try { + AdbStream s = openAbstractOnce(name, OPEN_ATTEMPT_TIMEOUT_MS); + Log.i("openAbstract %s ok (attempt %d)", name, attempt); + return s; + } catch (Throwable t) { + last = t; + Thread.sleep(OPEN_BACKOFF_MS); + } + } + throw new IOException("openAbstract " + name + " failed after " + + (OPEN_DEADLINE_MS / 1000) + " s", + last instanceof Exception ? (Exception) last : null); + } + + // Wraps a single adb.openAbstract call with a hard timeout. The + // upstream call can wedge forever on a missed notify in its naked + // stream.wait(); we run it on a daemon thread and join with timeout, + // interrupt on overrun, and let the caller retry. If the orphaned + // call succeeds after we gave up, the stream must be closed: + // keeping it would silently consume one of the scrcpy server's + // three accepts and shift every later dial off by one. + private AdbStream openAbstractOnce(String name, long timeoutMs) throws Exception { + AtomicReference<AdbStream> result = new AtomicReference<>(); + AtomicReference<Throwable> err = new AtomicReference<>(); + AtomicBoolean abandoned = new AtomicBoolean(); + Thread t = new Thread(() -> { + try { + AdbStream s = adb.openAbstract(name); + result.set(s); + if (abandoned.get()) closeQuietly(s); + } catch (Throwable ex) { + err.set(ex); + } + }, "openAbstract-" + name); + t.setDaemon(true); + t.start(); + t.join(timeoutMs); + if (t.isAlive()) { + abandoned.set(true); + t.interrupt(); + // One side of the publish/abandon race closes the stream; + // closeQuietly tolerates both doing it. + closeQuietly(result.get()); + throw new IOException("openAbstract " + name + " timed out"); + } + Throwable ex = err.get(); + if (ex instanceof Exception) throw (Exception) ex; + if (ex != null) throw new RuntimeException(ex); + return result.get(); + } + + private static String readDeviceMeta(InputStream in) throws IOException { + byte[] probe = new byte[1]; + Wire.readFully(in, probe); + 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); + } + + 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); + } + } catch (IOException e) { + if (!Thread.currentThread().isInterrupted()) Log.w("server-stdout closed: %s", e); + } finally { + serverEof = true; + Log.i("server: stream end"); + } + } + + // ---- adb sync push ---- + + private long push() throws Exception { + AdbStream sync = adb.openSync(); + // sync.openInput/OutputStream() return wrapper streams whose close() + // is a no-op; the AdbStream itself owns the channel. Close it once + // in finally. + try (InputStream src = ctx.getAssets().open(ASSET_JAR)) { + int mtime = (int)(System.currentTimeMillis() / 1000L); + return Sync.push(src, sync.openOutputStream(), sync.openInputStream(), + REMOTE_PATH, FILE_MODE, mtime); + } finally { + try { sync.close(); } catch (IOException ignored) {} + } + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/Session.java b/app/src/main/java/invalid/lena/scrcpy/Session.java new file mode 100644 index 0000000..019ce80 --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/Session.java @@ -0,0 +1,318 @@ +package invalid.lena.scrcpy; + +import android.content.Context; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.Surface; + +import java.io.File; +import java.io.IOException; +import java.util.concurrent.CountDownLatch; +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. +// +// 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. +// +// 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. +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) {} + // 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() {} + void onError(Throwable t); + 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}; + + private final Context ctx; + private final Adb adb; + private final Devices.Device target; + 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 AudioStream audioStream; + private AudioSink audioSink; + private ControlStream controlStream; + private Controller controller; + private MuxRecorder recorder; + private Thread runner; + private volatile boolean stopped; + private volatile int pendingViewW, pendingViewH; + + // Counted down by the video reader when its loop exits; the session + // thread parks on it for the live duration of a connection. Swapped + // for a fresh latch on each reconnect cycle. + private volatile CountDownLatch endSignal = new CountDownLatch(1); + + public Session(Context ctx, Adb adb, Devices.Device target, Surface surface, Listener listener) { + this.ctx = ctx; + this.adb = adb; + this.target = target; + this.surface = surface; + this.listener = listener; + } + + public synchronized void start() { + if (runner != null) return; + runner = new Thread(this::run, "session"); + runner.start(); + } + + public synchronized void stop() { + if (stopped) return; + stopped = true; + Log.i("session: stop"); + tearDownInstalled(); + if (listener != null) listener.onStopped(); + } + + // ---- input forwarding (Controller stays internal) ---- + + public void onTouch(MotionEvent ev) { + Controller c = controller; + if (c != null) c.onTouch(ev); + } + + public void onKey(KeyEvent ev) { + Controller c = controller; + if (c != null) c.onKey(ev); + } + + public void onBack() { + Controller c = controller; + if (c != null) c.onBack(); + } + + public void setViewSize(int w, int h) { + pendingViewW = w; pendingViewH = h; + Controller c = controller; + if (c != null) c.setViewSize(w, h); + } + + // ---- 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 stopRecording() { + MuxRecorder r = recorder; + if (r != null) r.stop(); + } + + public boolean isRecording() { + MuxRecorder r = recorder; + return r != null && r.isActive(); + } + + // Swap the surface the video pipeline draws to. The audio and control + // sides keep streaming, so audio + clipboard still work while the + // activity is backgrounded. Pass null to detach; pass a new Surface + // (from a recreated SurfaceView) to resume rendering. + public void swapSurface(Surface s) { + surface = s; + VideoSink vk = videoSink; + if (vk != null) vk.setOutputSurface(s); + } + + // ---- internals ---- + + // Supervisor loop: connect, park until the live pipeline dies, and + // reconnect if the death wasn't a user stop(). Runs on one thread for + // the whole session lifetime. + private void run() { + while (!stopped) { + if (!connect()) return; // gave up: onError + onStopped fired + try { + endSignal.await(); // park until the pipeline dies or stop() + } catch (InterruptedException ie) { + return; + } + // 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. + 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(); + } + } + } + + // Run the bring-up retry ladder once. Returns true when a session is + // live (read threads started); false if the budget was exhausted, in + // which case onError() + onStopped() have already fired. + private boolean connect() { + Throwable 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", + attempt + 1, BACKOFF_MS.length, BACKOFF_MS[attempt]); + try { Thread.sleep(BACKOFF_MS[attempt]); } + catch (InterruptedException ie) { return false; } + if (stopped) return false; + } + try { + bringUp(); + return true; // success; the read threads own the live session + } catch (InterruptedException ie) { + return false; + } catch (Throwable t) { + Log.w("session: bring-up attempt %d/%d failed: %s", + attempt + 1, BACKOFF_MS.length, t); + lastErr = t; + // Discard any partial state from this attempt before retrying. + tearDownInstalled(); + } + } + if (stopped) return false; + Log.e(lastErr, "session: gave up after %d attempts", BACKOFF_MS.length); + Throwable err = lastErr; + synchronized (this) { + if (stopped) return false; + stopped = true; + tearDownInstalled(); + } + if (listener != null) { + listener.onError(err); + listener.onStopped(); + } + return false; + } + + // Build everything into locals first. Then install + start under the + // 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 { + Log.i("session: connect %s:%d", target.host, target.port); + adb.disconnect(); + if (!adb.connect(target.host, target.port)) { + throw new IOException("adb connect returned false"); + } + Log.i("adb connect ok"); + + Server srv = new Server(ctx, adb); + Server.Streams s = srv.bringUp(); + + ControlStream cs = new ControlStream(s.controlIn, s.controlOut); + Controller ctrl = new Controller(ctx, cs::send); + cs.setInboundSink(ctrl); + + AudioSink ak = new AudioSink(); + AudioStream as = new AudioStream(s.audioIn, ak); + + VideoSink vk = new VideoSink(surface); + final Controller ctrlRef = ctrl; // capture for SizeListener + AtomicBoolean reported = new AtomicBoolean(); + VideoStream vs = new VideoStream(s.videoIn, vk, (w, h) -> { + ctrlRef.setTargetSize(w, h); + if (reported.compareAndSet(false, true) && listener != null) { + listener.onConnected(w, h); + } + }); + vs.setOnEnd(this::onVideoEnded); + MuxRecorder rec = new MuxRecorder(); + vs.setRecorder(rec); + + synchronized (this) { + if (stopped) { + tearDownLocals(srv, cs, ctrl, ak, as, vk, vs, rec); + throw new IOException("session: stopped during bring-up"); + } + server = srv; + controlStream = cs; + controller = ctrl; + audioSink = ak; + audioStream = as; + videoSink = vk; + videoStream = vs; + recorder = rec; + if (pendingViewW > 0) ctrl.setViewSize(pendingViewW, pendingViewH); + } + + cs.start(); + as.start(); + vs.start(); + } + + // 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(); + } + + private synchronized void tearDownInstalled() { + tearDownLocals(server, controlStream, controller, + audioSink, audioStream, videoSink, videoStream, recorder); + server = null; + controlStream = null; + controller = null; + audioSink = null; + audioStream = null; + videoSink = null; + videoStream = null; + recorder = null; + try { adb.disconnect(); } catch (Exception ignored) {} + } + + 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(); + if (vs != null) vs.stop(); + if (vk != null) vk.release(); + if (as != null) as.stop(); + if (ak != null) ak.release(); + if (cs != null) cs.stop(); + if (ctrl != null) ctrl.release(); + if (srv != null) srv.close(); + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/Sessions.java b/app/src/main/java/invalid/lena/scrcpy/Sessions.java new file mode 100644 index 0000000..95c0a1f --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/Sessions.java @@ -0,0 +1,75 @@ +package invalid.lena.scrcpy; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.content.pm.ServiceInfo; +import android.os.IBinder; + +// Foreground service whose only job is to keep the app process alive +// while the user is mirroring. Mirror starts it on entry and stops it +// in onDestroy; the running foreground service (with its notification) +// is what keeps the OOM killer away, so the activity can survive being +// briefly backgrounded (rotation, IME, swipe-to-home) without the +// scrcpy server tearing down. +// +// Type is FOREGROUND_SERVICE_DATA_SYNC: we are pulling a continuous +// data stream (encoded video + raw PCM) from another device. +// +// No binder API - the service does not own Session. Mirror owns it. +public final class Sessions extends Service { + + private static final String CHANNEL_ID = "scrcpy-android-session"; + private static final int NOTIF_ID = 1; + + @Override + public void onCreate() { + super.onCreate(); + Log.i("sessions: onCreate"); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + 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()) + .setOngoing(true) + .build(); + startForeground(NOTIF_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); + return START_NOT_STICKY; + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onDestroy() { + Log.i("sessions: onDestroy"); + super.onDestroy(); + } + + private void ensureChannel() { + NotificationManager nm = getSystemService(NotificationManager.class); + if (nm.getNotificationChannel(CHANNEL_ID) != null) return; + NotificationChannel ch = new NotificationChannel( + CHANNEL_ID, getString(R.string.app_name), + NotificationManager.IMPORTANCE_LOW); + ch.setDescription(getString(R.string.notif_session_active)); + 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); + 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 new file mode 100644 index 0000000..c4bb83b --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/Settings.java @@ -0,0 +1,93 @@ +package invalid.lena.scrcpy; + +import android.content.Context; +import android.content.SharedPreferences; + +// Thin wrapper around SharedPreferences for the small set of user +// choices the app exposes. No reactivity, no DataStore - the values +// are read once when a Session starts and applied to the scrcpy +// server command line. +public final class Settings { + + private static final String FILE = "scrcpy-android"; + + public static final String VIDEO_CODEC = "video_codec"; + public static final String AUDIO_CODEC = "audio_codec"; + public static final String MAX_SIZE = "max_size"; // px, long edge; 0 = no cap + public static final String VIDEO_BIT_RATE = "video_bit_rate"; // bits/sec + 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 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 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; + + private Settings() {} + + public static SharedPreferences prefs(Context ctx) { + return ctx.getApplicationContext().getSharedPreferences(FILE, Context.MODE_PRIVATE); + } + + public static String videoCodec(Context ctx) { + return prefs(ctx).getString(VIDEO_CODEC, DEFAULT_VIDEO_CODEC); + } + + public static String audioCodec(Context ctx) { + return prefs(ctx).getString(AUDIO_CODEC, DEFAULT_AUDIO_CODEC); + } + + public static int maxSize(Context ctx) { + return prefs(ctx).getInt(MAX_SIZE, DEFAULT_MAX_SIZE); + } + + public static int videoBitRate(Context ctx) { + return prefs(ctx).getInt(VIDEO_BIT_RATE, DEFAULT_VIDEO_BIT_RATE); + } + + public static int maxFps(Context ctx) { + return prefs(ctx).getInt(MAX_FPS, DEFAULT_MAX_FPS); + } + + public static void setVideoCodec(Context ctx, String v) { + prefs(ctx).edit().putString(VIDEO_CODEC, v).apply(); + } + + public static void setAudioCodec(Context ctx, String a) { + prefs(ctx).edit().putString(AUDIO_CODEC, a).apply(); + } + + public static void setMaxSize(Context ctx, int v) { + prefs(ctx).edit().putInt(MAX_SIZE, v).apply(); + } + + public static void setVideoBitRate(Context ctx, int v) { + prefs(ctx).edit().putInt(VIDEO_BIT_RATE, v).apply(); + } + + public static void setMaxFps(Context ctx, int v) { + prefs(ctx).edit().putInt(MAX_FPS, v).apply(); + } + + public static boolean hintBackShown(Context ctx) { + return prefs(ctx).getBoolean(HINT_BACK_SHOWN, false); + } + + public static void setHintBackShown(Context ctx, boolean v) { + prefs(ctx).edit().putBoolean(HINT_BACK_SHOWN, v).apply(); + } + + public static boolean showStatusBar(Context ctx) { + return prefs(ctx).getBoolean(STATUS_BAR, DEFAULT_STATUS_BAR); + } + + public static void setShowStatusBar(Context ctx, boolean v) { + prefs(ctx).edit().putBoolean(STATUS_BAR, v).apply(); + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/SettingsActivity.java b/app/src/main/java/invalid/lena/scrcpy/SettingsActivity.java new file mode 100644 index 0000000..d95ecba --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/SettingsActivity.java @@ -0,0 +1,110 @@ +package invalid.lena.scrcpy; + +import android.app.Activity; +import android.os.Bundle; +import android.widget.CheckBox; +import android.widget.RadioButton; +import android.widget.RadioGroup; + +// Codec choice + streaming knobs. All persisted to SharedPreferences +// via Settings on click; the next session bringup reads them and +// applies to the scrcpy server cmdline. +public final class SettingsActivity extends Activity { + + @Override + protected void onCreate(Bundle saved) { + super.onCreate(saved); + setContentView(R.layout.settings); + setTitle(R.string.settings); + + RadioGroup videoGroup = findViewById(R.id.video_codec); + RadioGroup audioGroup = findViewById(R.id.audio_codec); + RadioGroup maxSizeGroup = findViewById(R.id.max_size); + RadioGroup bitRateGroup = findViewById(R.id.video_bit_rate); + RadioGroup maxFpsGroup = findViewById(R.id.max_fps); + CheckBox statusBarBox = findViewById(R.id.show_status_bar); + + switch (Settings.videoCodec(this)) { + case "h265": ((RadioButton) findViewById(R.id.video_h265)).setChecked(true); break; + case "av1": ((RadioButton) findViewById(R.id.video_av1)).setChecked(true); break; + default: ((RadioButton) findViewById(R.id.video_h264)).setChecked(true); + } + switch (Settings.audioCodec(this)) { + case "opus": ((RadioButton) findViewById(R.id.audio_opus)).setChecked(true); break; + default: ((RadioButton) findViewById(R.id.audio_raw)).setChecked(true); + } + switch (Settings.maxSize(this)) { + case 480: ((RadioButton) findViewById(R.id.max_size_480)).setChecked(true); break; + case 720: ((RadioButton) findViewById(R.id.max_size_720)).setChecked(true); break; + case 1080: ((RadioButton) findViewById(R.id.max_size_1080)).setChecked(true); break; + case 1440: ((RadioButton) findViewById(R.id.max_size_1440)).setChecked(true); break; + case 2160: ((RadioButton) findViewById(R.id.max_size_2160)).setChecked(true); break; + default: ((RadioButton) findViewById(R.id.max_size_0)).setChecked(true); + } + 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 16_000_000: ((RadioButton) findViewById(R.id.bit_rate_16m)).setChecked(true); break; + default: ((RadioButton) findViewById(R.id.bit_rate_8m)).setChecked(true); + } + switch (Settings.maxFps(this)) { + case 30: ((RadioButton) findViewById(R.id.max_fps_30)).setChecked(true); break; + case 60: ((RadioButton) findViewById(R.id.max_fps_60)).setChecked(true); break; + case 90: ((RadioButton) findViewById(R.id.max_fps_90)).setChecked(true); break; + case 120: ((RadioButton) findViewById(R.id.max_fps_120)).setChecked(true); break; + default: ((RadioButton) findViewById(R.id.max_fps_0)).setChecked(true); + } + + videoGroup.setOnCheckedChangeListener((g, id) -> { + String v = "h264"; + if (id == R.id.video_h265) v = "h265"; + else if (id == R.id.video_av1) v = "av1"; + Settings.setVideoCodec(this, v); + Log.i("settings: video_codec=%s", v); + }); + + audioGroup.setOnCheckedChangeListener((g, id) -> { + String a = id == R.id.audio_opus ? "opus" : "raw"; + Settings.setAudioCodec(this, a); + Log.i("settings: audio_codec=%s", a); + }); + + maxSizeGroup.setOnCheckedChangeListener((g, id) -> { + int v = 0; + if (id == R.id.max_size_480) v = 480; + else if (id == R.id.max_size_720) v = 720; + else if (id == R.id.max_size_1080) v = 1080; + else if (id == R.id.max_size_1440) v = 1440; + else if (id == R.id.max_size_2160) v = 2160; + Settings.setMaxSize(this, v); + Log.i("settings: max_size=%d", v); + }); + + bitRateGroup.setOnCheckedChangeListener((g, id) -> { + int v = Settings.DEFAULT_VIDEO_BIT_RATE; + 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_16m) v = 16_000_000; + Settings.setVideoBitRate(this, v); + Log.i("settings: video_bit_rate=%d", v); + }); + + maxFpsGroup.setOnCheckedChangeListener((g, id) -> { + int v = 0; + if (id == R.id.max_fps_30) v = 30; + else if (id == R.id.max_fps_60) v = 60; + else if (id == R.id.max_fps_90) v = 90; + else if (id == R.id.max_fps_120) v = 120; + Settings.setMaxFps(this, v); + Log.i("settings: max_fps=%d", v); + }); + + statusBarBox.setChecked(Settings.showStatusBar(this)); + statusBarBox.setOnCheckedChangeListener((b, checked) -> { + Settings.setShowStatusBar(this, checked); + Log.i("settings: status_bar=%b", checked); + }); + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/Sync.java b/app/src/main/java/invalid/lena/scrcpy/Sync.java new file mode 100644 index 0000000..6e37523 --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/Sync.java @@ -0,0 +1,78 @@ +package invalid.lena.scrcpy; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; + +// Pure-java adb sync v1 SEND framing. Extracted from Server.push() so +// it can be unit-tested without an AdbStream or any android coupling. +// +// Layout on the wire (little-endian uint32 lengths): +// "SEND" | u32(len(path,mode)) | path,mode +// "DATA" | u32(chunk_size) | chunk_bytes (repeats, chunk <= 64K) +// "DONE" | u32(mtime_seconds) +// ----> response: +// "OKAY" | u32(0) +// or +// "FAIL" | u32(msg_len) | msg +public final class Sync { + + public static final int CHUNK = 64 * 1024; + + // 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 Sync() {} + + // Push `src` to `remotePath` with the given mode and mtime, using the + // 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) + throws IOException { + String header = remotePath + "," + mode; + byte[] hb = header.getBytes(StandardCharsets.UTF_8); + + byte[] tag = new byte[8]; + putTag(tag, 0, "SEND"); + Wire.writeLe32(tag, 4, hb.length); + out.write(tag); + out.write(hb); + + byte[] chunk = new byte[CHUNK]; + byte[] dataHdr = new byte[8]; + putTag(dataHdr, 0, "DATA"); + long total = 0; + int n; + while ((n = src.read(chunk)) > 0) { + Wire.writeLe32(dataHdr, 4, n); + out.write(dataHdr); + out.write(chunk, 0, n); + total += n; + } + + byte[] done = new byte[8]; + putTag(done, 0, "DONE"); + Wire.writeLe32(done, 4, mtimeSec); + out.write(done); + out.flush(); + + byte[] resp = new byte[8]; + 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; + + byte[] msg = new byte[Math.min(Math.max(0, len), MAX_FAIL_MSG)]; + if (msg.length > 0) Wire.readFully(in, msg); + throw new IOException("sync " + code + ": " + + new String(msg, StandardCharsets.UTF_8)); + } + + private static void putTag(byte[] dst, int off, String tag) { + byte[] b = tag.getBytes(StandardCharsets.US_ASCII); + System.arraycopy(b, 0, dst, off, 4); + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/VideoFrames.java b/app/src/main/java/invalid/lena/scrcpy/VideoFrames.java new file mode 100644 index 0000000..cfffe90 --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/VideoFrames.java @@ -0,0 +1,17 @@ +package invalid.lena.scrcpy; + +import java.io.IOException; + +// Sink for parsed video frames. VideoSink is the only production +// implementation; tests use a recording stub. Kept android-free so +// it can be referenced from JVM-only test code. +public interface VideoFrames { + + void configure(int codecFourcc, int width, int height) throws IOException; + + void reconfigure(int codecFourcc, int width, int height) throws IOException; + + void feed(byte[] data, long ptsUs, boolean isConfig); + + void release(); +} diff --git a/app/src/main/java/invalid/lena/scrcpy/VideoRecorder.java b/app/src/main/java/invalid/lena/scrcpy/VideoRecorder.java new file mode 100644 index 0000000..98a5ca9 --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/VideoRecorder.java @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..fa8248b --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/VideoSink.java @@ -0,0 +1,244 @@ +package invalid.lena.scrcpy; + +import android.media.MediaCodec; +import android.media.MediaFormat; +import android.os.Handler; +import android.os.HandlerThread; +import android.view.Surface; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Iterator; + +// MediaCodec async-mode video decoder writing to a Surface. +// +// MediaCodec hands us input buffer indices on its callback handler; +// VideoStream pushes encoded frames at us synchronously. The two ends +// meet through a small queue of pending frames waiting for input buffers, +// plus a corresponding pool of free input buffer indices. +// +// Back-pressure policy: when both queues fill, drop the *next* incoming +// frame unless it is a config or keyframe (we have no way to know its +// type from outside). For v1 we simply drop oldest pending non-keyframes +// if the pending queue grows past a small bound. +// +// 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. +public final class VideoSink implements VideoFrames { + + private static final int MAX_PENDING = 8; + + private volatile Surface surface; + public volatile long frames; // public read for the status overlay + private 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 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; } + } + + public VideoSink(Surface surface) { + this.surface = surface; + } + + // 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. + public void setOutputSurface(Surface newSurface) { + 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 + 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); + } + // 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; + } + } + + @Override + 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)); + Log.i("video sink: configure mime=%s %dx%d", mime, width, height); + + handlerThread = new HandlerThread("video-mc"); + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()); + + codec = MediaCodec.createDecoderByType(mime); + codec.setCallback(new MediaCodec.Callback() { + @Override public void onInputBufferAvailable(MediaCodec mc, int idx) { + onFreeInput(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. + 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) {} + } + @Override public void onError(MediaCodec mc, MediaCodec.CodecException e) { + Log.e(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(); + } + + // Called by VideoStream for every encoded frame, in order. + @Override + public void feed(byte[] data, long ptsUs, boolean isConfig) { + if (!isConfig) frames++; + synchronized (lock) { + if (released) return; + // Try to drain immediately if there's a free input. + while (!pending.isEmpty() && !freeInputs.isEmpty()) { + submit(pending.pollFirst(), freeInputs.pollFirst()); + } + if (!freeInputs.isEmpty()) { + submit(new Frame(data, ptsUs, isConfig), freeInputs.pollFirst()); + return; + } + // Queue, with bounded drop policy on non-config frames. + if (pending.size() >= MAX_PENDING && !isConfig) { + // Drop the oldest non-config frame to avoid stalling + // forever. Config frames must survive: the decoder + // cannot start without its CSD. + for (Iterator<Frame> it = pending.iterator(); it.hasNext(); ) { + if (!it.next().isConfig) { it.remove(); break; } + } + } + pending.offerLast(new Frame(data, ptsUs, isConfig)); + } + } + + @Override + public void release() { + synchronized (lock) { + if (released) return; + released = true; + } + teardownCodec(); + } + + // 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. + @Override + public void reconfigure(int codecFourcc, int width, int height) throws IOException { + synchronized (lock) { + if (released) return; + } + teardownCodec(); + synchronized (lock) { + freeInputs.clear(); + pending.clear(); + ptsAnchored = false; // re-anchor on the first frame of the new run + } + 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) {} + } + if (ht != null) ht.quitSafely(); + } + + // Internal - runs on the MediaCodec callback thread. + private void onFreeInput(int idx) { + synchronized (lock) { + if (released) return; + if (!pending.isEmpty()) submit(pending.pollFirst(), idx); + else freeInputs.offerLast(idx); + } + } + + // Must be called with `lock` held. + private void submit(Frame f, int idx) { + try { + ByteBuffer buf = codec.getInputBuffer(idx); + if (buf == null) return; + buf.clear(); + buf.put(f.data); + int flags = f.isConfig ? MediaCodec.BUFFER_FLAG_CODEC_CONFIG : 0; + codec.queueInputBuffer(idx, 0, f.data.length, f.ptsUs, flags); + } catch (IllegalStateException e) { + Log.w("video sink: queueInputBuffer: %s", e); + } + } + + // 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 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/VideoStream.java b/app/src/main/java/invalid/lena/scrcpy/VideoStream.java new file mode 100644 index 0000000..4eb62d3 --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/VideoStream.java @@ -0,0 +1,156 @@ +package invalid.lena.scrcpy; + +import java.io.IOException; +import java.io.InputStream; + +// Reads the scrcpy video socket and drives a VideoFrames sink. +// +// Wire format (big-endian, scrcpy 3.x/4.x): +// 1) Stream meta: uint32 fourcc +// special values 0 = disabled, 1 = error +// 2) Then a stream of variable packets. Each packet starts with 4 bytes +// that disambiguate the type: +// a) Session meta (12 bytes total): uint32 flags(bit31=1) | uint32 w | uint32 h +// flags bit0 = client-resize. May recur on rotation. +// b) Frame header (12 bytes total): uint64 ptsAndFlags | uint32 size +// ptsAndFlags top bits: +// bit 63 = SESSION (never set in frame headers) +// bit 62 = CONFIG (CSD, e.g. SPS/PPS for h264) +// bit 61 = KEYFRAME +// rest = pts microseconds +// followed by `size` bytes of encoded payload. +// +// The constructor takes a plain InputStream so this class is +// android-free and unit-testable. The caller owns stream lifecycle +// (close it to unblock reads on stop()). +public final class VideoStream { + + public interface SizeListener { void onSize(int w, int h); } + + private static final long FLAG_SESSION = 1L << 63; + private static final long FLAG_CONFIG = 1L << 62; + private static final long FLAG_KEYFRAME = 1L << 61; + private static final long PTS_MASK = ~(FLAG_SESSION | FLAG_CONFIG | FLAG_KEYFRAME); + private static final int FLAG_SESSION_INT_BIT = 0x80000000; + + private static final int MAX_FRAME_SIZE = 8 * 1024 * 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; + + // Parser state held across packets in run(). + private int fourcc; + private boolean configured; + private int curW, curH; + + public VideoStream(InputStream source, VideoFrames sink, SizeListener sizeListener) { + this.source = source; + this.sink = sink; + this.sizeListener = sizeListener; + } + + public void start() { + thread = new Thread(this::run, "video-reader"); + thread.start(); + } + + public void stop() { + stop = true; + if (thread != null) thread.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 + // this to surface a disconnect to the UI instead of freezing. + public void setOnEnd(Runnable r) { + this.onEnd = r; + } + + // Visible for tests: parse the same way the thread does, on the caller's thread. + public void run() { + try { + byte[] four = new byte[4]; + Wire.readFully(source, four); + 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"); + Log.i("video meta codec=%s", Wire.fourccName(fourcc)); + + byte[] tail8 = new byte[8]; + long frames = 0; + + while (!stop) { + Wire.readFully(source, four); + int hi = Wire.readBe32(four, 0); + + if ((hi & FLAG_SESSION_INT_BIT) != 0) { + parseSessionMeta(hi, tail8); + } else { + parseFrame(hi, tail8); + if (++frames == 1) Log.i("video frame n=1"); + } + } + } catch (IOException e) { + if (!stop) Log.e(e, "video reader"); + } catch (Exception e) { + Log.e(e, "video reader unexpected"); + } finally { + Log.i("video reader: end"); + Runnable r = onEnd; + if (r != null) r.run(); + } + } + + private void parseSessionMeta(int hi, byte[] tail8) throws IOException { + Wire.readFully(source, tail8); + int newW = Wire.readBe32(tail8, 0); + int newH = Wire.readBe32(tail8, 4); + boolean clientResize = (hi & 1) != 0; + Log.i("video session meta %dx%d client_resize=%s", newW, newH, clientResize); + + if (!configured) { + curW = newW; curH = newH; + sink.configure(fourcc, curW, curH); + configured = true; + } else if (newW != curW || newH != curH) { + Log.i("video resize %dx%d -> %dx%d", curW, curH, newW, newH); + curW = newW; curH = newH; + 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; + long ptsUs = pts & PTS_MASK; + + if (size <= 0 || size > MAX_FRAME_SIZE) { + throw new IOException("video frame size out of range: " + size); + } + // Validate ordering BEFORE allocating the payload buffer - a + // malformed early frame could otherwise OOM on small devices. + if (!configured) throw new IOException("video frame before session meta"); + + 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); + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/Wire.java b/app/src/main/java/invalid/lena/scrcpy/Wire.java new file mode 100644 index 0000000..7ed6c6a --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/Wire.java @@ -0,0 +1,99 @@ +package invalid.lena.scrcpy; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; + +// Byte-order helpers used everywhere we touch raw streams. +// +// scrcpy server framing is big-endian (java standard). +// adb sync framing is little-endian (legacy). +// +// FOURCCs are read as a big-endian uint32, which matches the literal +// 4-char ASCII tag, so '"h264"' becomes 0x68323634. +public final class Wire { + + private Wire() {} + + // ---- big-endian (scrcpy) ---- + + public static int readBe32(byte[] b, int off) { + return ((b[off] & 0xff) << 24) + | ((b[off + 1] & 0xff) << 16) + | ((b[off + 2] & 0xff) << 8) + | (b[off + 3] & 0xff); + } + + public static long readBe64(byte[] b, int off) { + return ((long)(readBe32(b, off)) << 32) | (readBe32(b, off + 4) & 0xffffffffL); + } + + public static void writeBe32(byte[] b, int off, int v) { + b[off] = (byte)(v >>> 24); + b[off + 1] = (byte)(v >>> 16); + b[off + 2] = (byte)(v >>> 8); + b[off + 3] = (byte) v; + } + + public static void writeBe64(byte[] b, int off, long v) { + writeBe32(b, off, (int)(v >>> 32)); + writeBe32(b, off + 4, (int) v); + } + + // ---- little-endian (adb sync) ---- + + public static int readLe32(byte[] b, int off) { + return (b[off] & 0xff) + | ((b[off + 1] & 0xff) << 8) + | ((b[off + 2] & 0xff) << 16) + | ((b[off + 3] & 0xff) << 24); + } + + public static void writeLe32(byte[] b, int off, int v) { + b[off] = (byte) v; + b[off + 1] = (byte)(v >>> 8); + b[off + 2] = (byte)(v >>> 16); + b[off + 3] = (byte)(v >>> 24); + } + + // ---- I/O helpers ---- + + public static void readFully(InputStream in, byte[] buf, int off, int len) throws IOException { + 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); + got += n; + } + } + + public static void readFully(InputStream in, byte[] buf) throws IOException { + 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. + // 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_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 from = 0; + while (from < cs.length && cs[from] == 0) from++; + return new String(cs, from, cs.length - from); + } +} diff --git a/app/src/main/res/drawable/bg_card.xml b/app/src/main/res/drawable/bg_card.xml new file mode 100644 index 0000000..17a0cfa --- /dev/null +++ b/app/src/main/res/drawable/bg_card.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Rounded surface panel used to group related controls. --> +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <solid android:color="@color/surface"/> + <stroke android:width="1dp" android:color="@color/stroke"/> + <corners android:radius="@dimen/card_radius"/> +</shape> diff --git a/app/src/main/res/drawable/bg_field.xml b/app/src/main/res/drawable/bg_field.xml new file mode 100644 index 0000000..e656ad4 --- /dev/null +++ b/app/src/main/res/drawable/bg_field.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Text-field box. Accent border when focused, neutral otherwise. --> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_focused="true"> + <shape android:shape="rectangle"> + <solid android:color="@color/surface_alt"/> + <stroke android:width="2dp" android:color="@color/accent"/> + <corners android:radius="@dimen/field_radius"/> + </shape> + </item> + <item> + <shape android:shape="rectangle"> + <solid android:color="@color/surface_alt"/> + <stroke android:width="1dp" android:color="@color/stroke"/> + <corners android:radius="@dimen/field_radius"/> + </shape> + </item> +</selector> diff --git a/app/src/main/res/drawable/bg_status_pill.xml b/app/src/main/res/drawable/bg_status_pill.xml new file mode 100644 index 0000000..ae91094 --- /dev/null +++ b/app/src/main/res/drawable/bg_status_pill.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Floating scrim pill behind the Mirror status text and buttons. --> +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <solid android:color="@color/status_scrim"/> + <corners android:radius="@dimen/button_radius"/> +</shape> diff --git a/app/src/main/res/drawable/btn_primary.xml b/app/src/main/res/drawable/btn_primary.xml new file mode 100644 index 0000000..75aab01 --- /dev/null +++ b/app/src/main/res/drawable/btn_primary.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Filled accent button with the platform press ripple on top. --> +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="?android:attr/colorControlHighlight"> + <item> + <shape android:shape="rectangle"> + <solid android:color="@color/accent"/> + <corners android:radius="@dimen/button_radius"/> + </shape> + </item> +</ripple> diff --git a/app/src/main/res/drawable/btn_secondary.xml b/app/src/main/res/drawable/btn_secondary.xml new file mode 100644 index 0000000..2c3a618 --- /dev/null +++ b/app/src/main/res/drawable/btn_secondary.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Outlined accent button (transparent fill, accent border). --> +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="?android:attr/colorControlHighlight"> + <item> + <shape android:shape="rectangle"> + <solid android:color="@android:color/transparent"/> + <stroke android:width="1dp" android:color="@color/accent"/> + <corners android:radius="@dimen/button_radius"/> + </shape> + </item> +</ripple> diff --git a/app/src/main/res/drawable/ic_device.xml b/app/src/main/res/drawable/ic_device.xml new file mode 100644 index 0000000..f12a226 --- /dev/null +++ b/app/src/main/res/drawable/ic_device.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Small phone outline used as the leading glyph on saved-device rows. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:strokeColor="@color/accent" + android:strokeWidth="2" + android:strokeLineJoin="round" + android:fillColor="#00000000" + android:pathData="M8,3 H16 A2,2 0 0 1 18,5 V19 A2,2 0 0 1 16,21 H8 A2,2 0 0 1 6,19 V5 A2,2 0 0 1 8,3 Z"/> +</vector> diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..e26af1f --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Dark CRT-terminal blue. Plain solid colour so the adaptive icon + reads cleanly under any launcher crop (circle, squircle, square). --> +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <solid android:color="#0E1E28"/> +</shape> diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..e5b7400 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,52 @@ +<?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. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + + <!-- Phone outlines (source 20x34, target 28x50), stroke only. --> + <path + android:strokeColor="#E8E8E8" + android:strokeWidth="3" + android:strokeLineJoin="round" + android:fillColor="#00000000" + android:pathData="M57,29 H75 A5,5 0 0 1 80,34 V74 A5,5 0 0 1 75,79 H57 A5,5 0 0 1 52,74 V34 A5,5 0 0 1 57,29 Z + M30,47 H42 A4,4 0 0 1 46,51 V77 A4,4 0 0 1 42,81 H30 A4,4 0 0 1 26,77 V51 A4,4 0 0 1 30,47 Z"/> + + <!-- Speaker slits and home indicators on both phones. --> + <path + android:strokeColor="#E8E8E8" + android:strokeWidth="2" + android:strokeLineCap="round" + android:fillColor="#00000000" + android:pathData="M62,35 H70 M61,74 H71 M32,52 H40 M32,76 H40"/> + + <!-- Play triangle, filled, inside the large phone screen. --> + <path + android:fillColor="#E8E8E8" + android:pathData="M61,46 L61,62 L74,54 Z"/> + + <!-- Wireless arcs: three concentric quarter-arcs centred at (40,48), + sweeping from north to east so they fan up-right from the small + phone toward the large one. --> + <path + android:strokeColor="#3FD0D6" + android:strokeWidth="2.5" + android:strokeLineCap="round" + android:fillColor="#00000000" + android:pathData="M40,43 A5,5 0 0 1 45,48 M40,39.5 A8.5,8.5 0 0 1 48.5,48 M40,36 A12,12 0 0 1 52,48"/> + +</vector> diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..f592b9d --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Status-bar notification icon: white-on-transparent silhouette (the + launcher mipmap is the wrong shape and colour for a small icon). The + system tints it. Phone outline with a play triangle, matching the + app's visual identity. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24" + android:tint="#FFFFFF"> + <path + android:strokeColor="#FFFFFF" + android:strokeWidth="1.8" + android:strokeLineJoin="round" + android:fillColor="#00000000" + android:pathData="M7,2 H17 A2,2 0 0 1 19,4 V20 A2,2 0 0 1 17,22 H7 A2,2 0 0 1 5,20 V4 A2,2 0 0 1 7,2 Z"/> + <path + android:fillColor="#FFFFFF" + android:pathData="M10,8 L10,16 L16,12 Z"/> +</vector> diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..696f6c2 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Gear 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" + android:viewportWidth="24" + android:viewportHeight="24" + android:tint="@color/on_surface_muted"> + <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"/> +</vector> diff --git a/app/src/main/res/layout/device_row.xml b/app/src/main/res/layout/device_row.xml new file mode 100644 index 0000000..f06ab20 --- /dev/null +++ b/app/src/main/res/layout/device_row.xml @@ -0,0 +1,28 @@ +<?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" + 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> diff --git a/app/src/main/res/layout/main.xml b/app/src/main/res/layout/main.xml new file mode 100644 index 0000000..acd4a3c --- /dev/null +++ b/app/src/main/res/layout/main.xml @@ -0,0 +1,181 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical" + 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"> + + <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" + 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. Fields are grouped and labelled by where their + value comes from on the target's Wireless debugging screen. --> + <LinearLayout + style="@style/Card" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <TextView + style="@style/SectionHeader" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/pair_a_device"/> + + <TextView + style="@style/Caption" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/pair_help"/> + + <TextView + style="@style/FieldLabel" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/label_host"/> + + <EditText + android:id="@+id/host" + style="@style/Field" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:hint="@string/host_hint" + android:inputType="text" + android:autofillHints="postalAddress"/> + + <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/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:autofillHints="off"/> + + <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:autofillHints="off"/> + + </LinearLayout> + + <TextView + style="@style/FieldLabel" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/label_connect"/> + + <TextView + style="@style/Caption" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/sub_connect"/> + + <EditText + android:id="@+id/connect_port" + style="@style/Field" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/space_xs" + android:hint="@string/connect_port_hint" + android:inputType="number" + android:autofillHints="off"/> + + <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> + + <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 + android:id="@+id/devices" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:divider="@color/stroke" + android:dividerHeight="1dp"/> + + <TextView + android:id="@+id/devices_empty" + style="@style/Note" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center_horizontal" + android:paddingTop="@dimen/space_xl" + android:text="@string/no_devices_yet"/> + + </FrameLayout> + +</LinearLayout> diff --git a/app/src/main/res/layout/mirror.xml b/app/src/main/res/layout/mirror.xml new file mode 100644 index 0000000..cf24e11 --- /dev/null +++ b/app/src/main/res/layout/mirror.xml @@ -0,0 +1,67 @@ +<?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. --> +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="#000000"> + + <SurfaceView + 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:layout_margin="@dimen/space_sm" + android:orientation="horizontal" + android:gravity="center_vertical" + android:paddingHorizontal="@dimen/space_md" + android:paddingVertical="@dimen/space_sm" + android:background="@drawable/bg_status_pill"> + + <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" + style="@style/Button.Secondary" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/space_sm" + android:minHeight="0dp" + android:paddingHorizontal="@dimen/space_md" + android:paddingVertical="@dimen/space_xs" + android:text="@string/record"/> + + <Button + android:id="@+id/reconnect" + style="@style/Button.Secondary" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/space_sm" + android:minHeight="0dp" + android:paddingHorizontal="@dimen/space_md" + android:paddingVertical="@dimen/space_xs" + android:text="@string/reconnect" + android:visibility="gone"/> + + </LinearLayout> + +</FrameLayout> diff --git a/app/src/main/res/layout/settings.xml b/app/src/main/res/layout/settings.xml new file mode 100644 index 0000000..5d4e450 --- /dev/null +++ b/app/src/main/res/layout/settings.xml @@ -0,0 +1,294 @@ +<?xml version="1.0" encoding="utf-8"?> +<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:padding="@dimen/space_lg"> + + <TextView + style="@style/Note" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingBottom="@dimen/space_lg" + android:text="@string/settings_apply_on_reconnect"/> + + <LinearLayout + style="@style/Card" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <TextView + style="@style/SectionHeader" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/video_codec"/> + + <RadioGroup + android:id="@+id/video_codec" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + + <RadioButton + android:id="@+id/video_h264" + style="@style/RadioOption" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="H.264 (default)"/> + + <RadioButton + android:id="@+id/video_h265" + style="@style/RadioOption" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="H.265"/> + + <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+)"/> + + </RadioGroup> + + </LinearLayout> + + <LinearLayout + style="@style/Card" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <TextView + style="@style/SectionHeader" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/audio_codec"/> + + <RadioGroup + android:id="@+id/audio_codec" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + + <RadioButton + android:id="@+id/audio_raw" + style="@style/RadioOption" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Raw PCM (1.5 Mbps, default)"/> + + <RadioButton + android:id="@+id/audio_opus" + style="@style/RadioOption" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Opus (~128 kbps)"/> + + </RadioGroup> + + </LinearLayout> + + <LinearLayout + style="@style/Card" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <TextView + style="@style/SectionHeader" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/max_size"/> + + <RadioGroup + android:id="@+id/max_size" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + + <RadioButton + android:id="@+id/max_size_0" + style="@style/RadioOption" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Original (default)"/> + + <RadioButton + android:id="@+id/max_size_480" + style="@style/RadioOption" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="480"/> + + <RadioButton + android:id="@+id/max_size_720" + style="@style/RadioOption" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="720"/> + + <RadioButton + android:id="@+id/max_size_1080" + style="@style/RadioOption" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="1080"/> + + <RadioButton + android:id="@+id/max_size_1440" + style="@style/RadioOption" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="1440"/> + + <RadioButton + android:id="@+id/max_size_2160" + style="@style/RadioOption" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="2160"/> + + </RadioGroup> + + </LinearLayout> + + <LinearLayout + style="@style/Card" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <TextView + style="@style/SectionHeader" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/video_bit_rate"/> + + <RadioGroup + android:id="@+id/video_bit_rate" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + + <RadioButton + android:id="@+id/bit_rate_1m" + style="@style/RadioOption" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="1 Mbps"/> + + <RadioButton + android:id="@+id/bit_rate_2m" + style="@style/RadioOption" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="2 Mbps"/> + + <RadioButton + android:id="@+id/bit_rate_4m" + style="@style/RadioOption" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="4 Mbps"/> + + <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)"/> + + <RadioButton + android:id="@+id/bit_rate_16m" + style="@style/RadioOption" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="16 Mbps"/> + + </RadioGroup> + + </LinearLayout> + + <LinearLayout + style="@style/Card" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <TextView + style="@style/SectionHeader" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/max_fps"/> + + <RadioGroup + android:id="@+id/max_fps" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + + <RadioButton + android:id="@+id/max_fps_0" + style="@style/RadioOption" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Unlimited (default)"/> + + <RadioButton + android:id="@+id/max_fps_30" + style="@style/RadioOption" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="30"/> + + <RadioButton + android:id="@+id/max_fps_60" + style="@style/RadioOption" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="60"/> + + <RadioButton + android:id="@+id/max_fps_90" + style="@style/RadioOption" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="90"/> + + <RadioButton + android:id="@+id/max_fps_120" + style="@style/RadioOption" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="120"/> + + </RadioGroup> + + </LinearLayout> + + <LinearLayout + style="@style/Card" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="0dp"> + + <TextView + style="@style/SectionHeader" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/display"/> + + <CheckBox + android:id="@+id/show_status_bar" + style="@style/RadioOption" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/show_status_bar"/> + + </LinearLayout> + + </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 new file mode 100644 index 0000000..93542a7 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@drawable/ic_launcher_background"/> + <foreground android:drawable="@drawable/ic_launcher_foreground"/> + <monochrome android:drawable="@drawable/ic_launcher_foreground"/> +</adaptive-icon> diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..7e9fa9b --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Dark palette built around the launcher icon's CRT-blue. The light + foreground (#E8E8E8) matches the icon; the cyan/teal accent tints + interactive controls. Hex values live here only; layouts reference + them by name. --> +<resources> + <color name="bg">#0B1620</color> + <color name="surface">#14242E</color> + <color name="surface_alt">#0E1E28</color> + <color name="stroke">#283D49</color> + <color name="accent">#3FD0D6</color> + <color name="on_surface">#E8E8E8</color> + <color name="on_surface_muted">#8AA0AB</color> + <color name="on_accent">#04222B</color> + <color name="status_scrim">#B3000000</color> +</resources> diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..0ec4c88 --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Single spacing scale and corner radii, referenced everywhere so the + layout rhythm stays consistent. --> +<resources> + <dimen name="space_xs">4dp</dimen> + <dimen name="space_sm">8dp</dimen> + <dimen name="space_md">12dp</dimen> + <dimen name="space_lg">16dp</dimen> + <dimen name="space_xl">24dp</dimen> + + <dimen name="card_radius">14dp</dimen> + <dimen name="field_radius">8dp</dimen> + <dimen name="button_radius">10dp</dimen> + + <dimen name="card_padding">16dp</dimen> + <dimen name="touch_min">48dp</dimen> +</resources> diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml new file mode 100644 index 0000000..f551102 --- /dev/null +++ b/app/src/main/res/values/ids.xml @@ -0,0 +1,10 @@ +<?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 new file mode 100644 index 0000000..a9a552c --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="app_name">scrcpy</string> + <string name="host_hint">192.168.1.42</string> + <string name="pair_port_hint">37123</string> + <string name="pair_code_hint">6 digits</string> + <string name="connect_port_hint">41234</string> + <string name="pair_help">On the target: Settings > Developer options > Wireless debugging</string> + <string name="label_host">Host / IP address</string> + <string name="label_pairing">Pairing port and code</string> + <string name="sub_pairing">From the “Pair device with pairing code” dialog</string> + <string name="label_connect">Connection port</string> + <string name="sub_connect">Shown on the Wireless debugging screen</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="video_codec">Video codec</string> + <string name="audio_codec">Audio codec</string> + <string name="forget_device">Forget device?</string> + <string name="max_size">Max size (long edge, px)</string> + <string name="video_bit_rate">Video bit rate</string> + <string name="max_fps">Max fps</string> + <string name="reconnect">Reconnect</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="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="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 new file mode 100644 index 0000000..d0b07ac --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,73 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Reusable widget styles. Applied per-widget via style="@style/..." so + behaviour stays explicit and visible at the call site. --> +<resources> + <style name="Card"> + <item name="android:orientation">vertical</item> + <item name="android:background">@drawable/bg_card</item> + <item name="android:padding">@dimen/card_padding</item> + <item name="android:layout_marginBottom">@dimen/space_lg</item> + </style> + + <style name="SectionHeader"> + <item name="android:textColor">@color/on_surface</item> + <item name="android:textStyle">bold</item> + <item name="android:textSize">16sp</item> + <item name="android:paddingBottom">@dimen/space_sm</item> + </style> + + <style name="Field" parent="@android:style/Widget.Material.EditText"> + <item name="android:background">@drawable/bg_field</item> + <item name="android:textColor">@color/on_surface</item> + <item name="android:textColorHint">@color/on_surface_muted</item> + <item name="android:paddingHorizontal">@dimen/space_md</item> + <item name="android:paddingVertical">@dimen/space_md</item> + <item name="android:minHeight">@dimen/touch_min</item> + </style> + + <style name="Button.Primary" parent="@android:style/Widget.Material.Button"> + <item name="android:background">@drawable/btn_primary</item> + <item name="android:textColor">@color/on_accent</item> + <item name="android:textStyle">bold</item> + <item name="android:textAllCaps">false</item> + <item name="android:minHeight">@dimen/touch_min</item> + </style> + + <style name="Button.Secondary" parent="@android:style/Widget.Material.Button"> + <item name="android:background">@drawable/btn_secondary</item> + <item name="android:textColor">@color/accent</item> + <item name="android:textAllCaps">false</item> + <item name="android:minWidth">0dp</item> + </style> + + <style name="RadioOption"> + <item name="android:textColor">@color/on_surface</item> + <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> + </style> + + <style name="Note"> + <item name="android:textStyle">italic</item> + <item name="android:textColor">@color/on_surface_muted</item> + </style> + + <style name="FieldLabel"> + <item name="android:textColor">@color/on_surface</item> + <item name="android:textSize">13sp</item> + <item name="android:textStyle">bold</item> + <item name="android:paddingTop">@dimen/space_md</item> + </style> + + <style name="Caption"> + <item name="android:textColor">@color/on_surface_muted</item> + <item name="android:textSize">12sp</item> + </style> + + <style name="DeviceLabel"> + <item name="android:textColor">@color/on_surface</item> + <item name="android:textSize">16sp</item> + <item name="android:fontFamily">monospace</item> + </style> +</resources> diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..3d702c6 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Custom theme on the platform's built-in Material theme (ships with + Android, not a dependency). Setting the colour attributes makes the + framework auto-tint EditText focus, radio buttons, checkboxes, the + action bar, and the cursor. NoActionBar / Fullscreen inherit the + same colours via dotted-name inheritance. --> +<resources> + <style name="Theme.Scrcpy" parent="@android:style/Theme.Material"> + <item name="android:colorBackground">@color/bg</item> + <item name="android:windowBackground">@color/bg</item> + <item name="android:colorPrimary">@color/surface</item> + <item name="android:colorPrimaryDark">@color/bg</item> + <item name="android:colorAccent">@color/accent</item> + <item name="android:textColorPrimary">@color/on_surface</item> + <item name="android:textColorSecondary">@color/on_surface_muted</item> + <item name="android:textColorHint">@color/on_surface_muted</item> + <item name="android:statusBarColor">@color/bg</item> + <item name="android:navigationBarColor">@color/bg</item> + </style> + + <style name="Theme.Scrcpy.NoActionBar"> + <item name="android:windowActionBar">false</item> + <item name="android:windowNoTitle">true</item> + </style> + + <style name="Theme.Scrcpy.Fullscreen"> + <item name="android:windowActionBar">false</item> + <item name="android:windowNoTitle">true</item> + <item name="android:windowFullscreen">true</item> + <item name="android:windowBackground">@android:color/black</item> + </style> +</resources> diff --git a/app/src/test/java/invalid/lena/scrcpy/AtomicFilesTest.java b/app/src/test/java/invalid/lena/scrcpy/AtomicFilesTest.java new file mode 100644 index 0000000..7124fd2 --- /dev/null +++ b/app/src/test/java/invalid/lena/scrcpy/AtomicFilesTest.java @@ -0,0 +1,54 @@ +package invalid.lena.scrcpy; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.file.Files; + +public class AtomicFilesTest { + + @Rule + public TemporaryFolder dir = new TemporaryFolder(); + + @Test + public void writeCreatesFile() throws Exception { + File f = new File(dir.getRoot(), "x"); + AtomicFiles.write(f, "hello".getBytes()); + assertArrayEquals("hello".getBytes(), Files.readAllBytes(f.toPath())); + } + + @Test + public void writeReplacesExistingWhole() throws Exception { + File f = new File(dir.getRoot(), "x"); + AtomicFiles.write(f, "old".getBytes()); + AtomicFiles.write(f, "new-and-longer".getBytes()); + assertArrayEquals("new-and-longer".getBytes(), Files.readAllBytes(f.toPath())); + } + + @Test + public void writeLeavesNoTempBehind() throws Exception { + File f = new File(dir.getRoot(), "x"); + AtomicFiles.write(f, "data".getBytes()); + assertFalse(new File(dir.getRoot(), "x.tmp").exists()); + } + + @Test + public void staleTempDoesNotCorruptDestination() throws Exception { + // A crash after staging but before the rename leaves a partial ".tmp". + // The destination must still hold the previous good content, and the + // next successful write must overwrite the stale temp cleanly. + File f = new File(dir.getRoot(), "x"); + AtomicFiles.write(f, "good".getBytes()); + Files.write(new File(dir.getRoot(), "x.tmp").toPath(), "partial".getBytes()); + assertArrayEquals("good".getBytes(), Files.readAllBytes(f.toPath())); + + AtomicFiles.write(f, "good2".getBytes()); + assertArrayEquals("good2".getBytes(), Files.readAllBytes(f.toPath())); + assertFalse(new File(dir.getRoot(), "x.tmp").exists()); + } +} diff --git a/app/src/test/java/invalid/lena/scrcpy/AudioStreamTest.java b/app/src/test/java/invalid/lena/scrcpy/AudioStreamTest.java new file mode 100644 index 0000000..076d27c --- /dev/null +++ b/app/src/test/java/invalid/lena/scrcpy/AudioStreamTest.java @@ -0,0 +1,123 @@ +package invalid.lena.scrcpy; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class AudioStreamTest { + + private static final long FLAG_CONFIG = 1L << 62; + + private static final class RecordingFrames implements AudioFrames { + int starts = 0, releases = 0, startFourcc = 0; + final List<byte[]> feeds = new ArrayList<>(); + final List<Boolean> cfgs = new ArrayList<>(); + @Override public void start(int fourcc) { starts++; startFourcc = fourcc; } + @Override public void feed(byte[] data, int off, int len, boolean isConfig) { + byte[] cp = new byte[len]; + System.arraycopy(data, off, cp, 0, len); + feeds.add(cp); + cfgs.add(isConfig); + } + @Override public void release() { releases++; } + } + + private static byte[] fourcc(int v) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + new DataOutputStream(bos).writeInt(v); + return bos.toByteArray(); + } + + private static byte[] frame(long ptsAndFlags, byte[] payload) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bos); + out.writeLong(ptsAndFlags); + out.writeInt(payload.length); + out.write(payload); + return bos.toByteArray(); + } + + private static byte[] cat(byte[]... parts) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + for (byte[] p : parts) bos.write(p); + return bos.toByteArray(); + } + + @Test + public void rawCodecFeedsFrames() throws Exception { + byte[] pcm1 = new byte[]{1, 2, 3, 4}; + byte[] pcm2 = new byte[]{5, 6, 7, 8, 9, 10}; + byte[] bytes = cat( + fourcc(Wire.CODEC_RAW), + frame(1_000_000L, pcm1), + frame(2_000_000L, pcm2)); + + RecordingFrames sink = new RecordingFrames(); + new AudioStream(new ByteArrayInputStream(bytes), sink).run(); + + assertEquals(1, sink.starts); + assertEquals(Wire.CODEC_RAW, sink.startFourcc); + assertEquals(2, sink.feeds.size()); + assertEquals(pcm1.length, sink.feeds.get(0).length); + assertEquals(pcm2.length, sink.feeds.get(1).length); + for (int i = 0; i < pcm1.length; i++) assertEquals(pcm1[i], sink.feeds.get(0)[i]); + for (int i = 0; i < pcm2.length; i++) assertEquals(pcm2[i], sink.feeds.get(1)[i]); + assertEquals(false, sink.cfgs.get(0)); + assertEquals(false, sink.cfgs.get(1)); + } + + @Test + public void opusConfigFlagIsExposed() throws Exception { + byte[] head = new byte[]{ + 'O','p','u','s','H','e','a','d', // magic + 1, // version + 2, // channel count + (byte)0x38, 0x01, // pre-skip = 312 LE + (byte)0x80, (byte)0xBB, 0, 0, // sample rate 48000 LE + 0, 0, // output gain + 0 // channel mapping family + }; + byte[] pkt = new byte[]{0x10, 0x20, 0x30}; + byte[] bytes = cat( + fourcc(Wire.CODEC_OPUS), + frame(FLAG_CONFIG, head), + frame(0L, pkt)); + + RecordingFrames sink = new RecordingFrames(); + new AudioStream(new ByteArrayInputStream(bytes), sink).run(); + + assertEquals(1, sink.starts); + assertEquals(Wire.CODEC_OPUS, sink.startFourcc); + assertEquals(2, sink.feeds.size()); + assertTrue("first frame must carry FLAG_CONFIG", sink.cfgs.get(0)); + assertTrue("second frame must not carry FLAG_CONFIG", !sink.cfgs.get(1)); + assertEquals(head.length, sink.feeds.get(0).length); + assertEquals(pkt.length, sink.feeds.get(1).length); + } + + @Test + public void disabledCodecDoesNotStart() throws Exception { + byte[] bytes = fourcc(0); + RecordingFrames sink = new RecordingFrames(); + new AudioStream(new ByteArrayInputStream(bytes), sink).run(); + assertEquals(0, sink.starts); + assertEquals(0, sink.feeds.size()); + } + + @Test + public void errorCodecDoesNotStart() throws Exception { + byte[] bytes = fourcc(1); + RecordingFrames sink = new RecordingFrames(); + new AudioStream(new ByteArrayInputStream(bytes), sink).run(); + assertEquals(0, sink.starts); + assertEquals(0, sink.feeds.size()); + } +} diff --git a/app/src/test/java/invalid/lena/scrcpy/ControlMessagesTest.java b/app/src/test/java/invalid/lena/scrcpy/ControlMessagesTest.java new file mode 100644 index 0000000..a84e18e --- /dev/null +++ b/app/src/test/java/invalid/lena/scrcpy/ControlMessagesTest.java @@ -0,0 +1,65 @@ +package invalid.lena.scrcpy; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; + +public class ControlMessagesTest { + + @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, + /* x */ 100, /* y */ 200, /* tw */ 1080, /* th */ 2400, + /* pressure */ 0xffff, /* actionButton */ 0, /* buttons */ 0); + assertEquals(ControlMessages.TOUCH_MSG_LEN, m.length); + assertEquals(ControlMessages.TYPE_INJECT_TOUCH_EVENT, m[0]); + assertEquals(0, m[1]); // action + assertEquals(0L, Wire.readBe64(m, 2)); // pointer id + assertEquals(100, Wire.readBe32(m, 10)); // x + assertEquals(200, Wire.readBe32(m, 14)); // y + assertEquals(1080, ((m[18] & 0xff) << 8) | (m[19] & 0xff)); // tw + assertEquals(2400, ((m[20] & 0xff) << 8) | (m[21] & 0xff)); // th + assertEquals(0xffff, ((m[22] & 0xff) << 8) | (m[23] & 0xff)); // pressure + assertEquals(0, Wire.readBe32(m, 24)); // actionButton + assertEquals(0, Wire.readBe32(m, 28)); // buttons + } + + @Test + public void keycodeByteLayout() { + // ACTION_DOWN=0, KEYCODE_A=29, repeat=0, metaState=0 + byte[] m = ControlMessages.keycode(0, 29, 0, 0); + assertEquals(ControlMessages.KEY_MSG_LEN, m.length); + assertEquals(ControlMessages.TYPE_INJECT_KEYCODE, m[0]); + assertEquals(0, m[1]); + assertEquals(29, Wire.readBe32(m, 2)); + assertEquals(0, Wire.readBe32(m, 6)); + assertEquals(0, Wire.readBe32(m, 10)); + } + + @Test + public void setClipboardWithUtf8Payload() { + String text = "héllo 🎉"; // 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); + assertEquals(ControlMessages.TYPE_SET_CLIPBOARD, m[0]); + assertEquals(0xdeadbeefcafeL, Wire.readBe64(m, 1)); + assertEquals(1, m[9]); // paste + assertEquals(expected.length, Wire.readBe32(m, 10)); + byte[] tail = new byte[expected.length]; + System.arraycopy(m, 14, tail, 0, expected.length); + assertArrayEquals(expected, tail); + } + + @Test + public void setClipboardNoPaste() { + byte[] m = ControlMessages.setClipboard(0L, false, ""); + assertEquals(0, m[9]); + assertEquals(0, Wire.readBe32(m, 10)); + assertEquals(14, m.length); + } +} diff --git a/app/src/test/java/invalid/lena/scrcpy/ControlStreamTest.java b/app/src/test/java/invalid/lena/scrcpy/ControlStreamTest.java new file mode 100644 index 0000000..cafff81 --- /dev/null +++ b/app/src/test/java/invalid/lena/scrcpy/ControlStreamTest.java @@ -0,0 +1,152 @@ +package invalid.lena.scrcpy; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; + +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +public class ControlStreamTest { + + private static final class RecordingSink implements ControlStream.InboundSink { + final List<String> clipboards = new ArrayList<>(); + @Override public void onRemoteClipboard(String text) { clipboards.add(text); } + } + + // ---- inbound (DeviceMessage) parsing ---- + + @Test + public void clipboardMessageDispatched() throws Exception { + String text = "héllo world"; + byte[] payload = text.getBytes(StandardCharsets.UTF_8); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bos); + out.writeByte(0); // TYPE_CLIPBOARD + out.writeInt(payload.length); + out.write(payload); + + RecordingSink sink = new RecordingSink(); + ControlStream cs = new ControlStream( + new ByteArrayInputStream(bos.toByteArray()), + new ByteArrayOutputStream()); + cs.setInboundSink(sink); + cs.runReader(); // synchronous; returns on EOF + + assertEquals(1, sink.clipboards.size()); + assertEquals(text, sink.clipboards.get(0)); + } + + @Test + public void ackClipboardConsumedSilently() throws Exception { + // type=1, then a long sequence number. No dispatch should occur. + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bos); + out.writeByte(1); + out.writeLong(0x1234567890abcdefL); + + RecordingSink sink = new RecordingSink(); + ControlStream cs = new ControlStream( + new ByteArrayInputStream(bos.toByteArray()), + new ByteArrayOutputStream()); + cs.setInboundSink(sink); + cs.runReader(); + + assertEquals(0, sink.clipboards.size()); + } + + @Test + public void unknownTypeAborts() throws Exception { + // type=42 - runReader should log and return. + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + bos.write(42); + // a few trailing bytes so the reader doesn't EOF before processing + bos.write(new byte[]{0, 0, 0, 0}); + + RecordingSink sink = new RecordingSink(); + ControlStream cs = new ControlStream( + new ByteArrayInputStream(bos.toByteArray()), + new ByteArrayOutputStream()); + cs.setInboundSink(sink); + cs.runReader(); + assertEquals(0, sink.clipboards.size()); + } + + // ---- outbound (send + writer) ---- + + @Test + public void writerDrainsInOrder() throws Exception { + // We submit three messages, then run the writer until the + // outbox is empty AND we've signalled stop. + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ControlStream cs = new ControlStream( + new ByteArrayInputStream(new byte[0]), out); + + byte[] a = new byte[]{1, 2, 3}; + byte[] b = new byte[]{4, 5}; + byte[] c = new byte[]{6}; + cs.send(a); + cs.send(b); + cs.send(c); + + Thread writer = new Thread(cs::runWriter, "writer-under-test"); + writer.start(); + + // give the writer a moment to drain + for (int i = 0; i < 50 && out.size() < 6; i++) Thread.sleep(10); + cs.stop(); + writer.join(1_000); + + byte[] got = out.toByteArray(); + assertEquals(6, got.length); + assertEquals(1, got[0]); assertEquals(2, got[1]); assertEquals(3, got[2]); + assertEquals(4, got[3]); assertEquals(5, got[4]); + assertEquals(6, got[5]); + } + + @Test + public void keyEventLandsAfterOutboxOverflow() throws Exception { + // Fill the bounded outbox with touch-MOVE messages, then enqueue + // one key event. The MOVE must be evicted to make room; the key + // 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); + + for (int i = 0; i < 256; i++) { + cs.send(ControlMessages.touch(/* MOVE */ 2, 0L, i, i, 1080, 2400, 0xffff, 0, 0)); + } + byte[] key = ControlMessages.keycode(0, 29, 0, 0); + cs.send(key); + + Thread w = new Thread(cs::runWriter, "drain"); + w.start(); + + // Drain to quiescence (size stable for 100ms). + long deadline = System.currentTimeMillis() + 2_000; + int last = -1, stable = 0; + while (System.currentTimeMillis() < deadline) { + int now = out.size(); + if (now == last) { stable++; if (stable > 5) break; } + else { stable = 0; last = now; } + Thread.sleep(20); + } + cs.stop(); + w.join(1_000); + + byte[] bytes = out.toByteArray(); + assertNotEquals(0, bytes.length); + + // The key event should be the final 14 bytes of the drained stream + // (queued last, FIFO order, only touch-MOVEs evicted on overflow). + byte[] tail = new byte[14]; + System.arraycopy(bytes, bytes.length - 14, tail, 0, 14); + for (int i = 0; i < 14; i++) assertEquals(key[i], tail[i]); + } +} diff --git a/app/src/test/java/invalid/lena/scrcpy/DevicesTest.java b/app/src/test/java/invalid/lena/scrcpy/DevicesTest.java new file mode 100644 index 0000000..d1e4f02 --- /dev/null +++ b/app/src/test/java/invalid/lena/scrcpy/DevicesTest.java @@ -0,0 +1,64 @@ +package invalid.lena.scrcpy; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class DevicesTest { + + @Test + public void parseEmptyOrNull() { + assertTrue(Devices.parse(null).isEmpty()); + assertTrue(Devices.parse("").isEmpty()); + assertTrue(Devices.parse(" ").isEmpty()); + } + + @Test + public void parseNonArrayReturnsEmpty() { + assertTrue(Devices.parse("{not json").isEmpty()); + assertTrue(Devices.parse("{\"host\":\"x\"}").isEmpty()); + } + + @Test + public void parseSkipsMalformedRowsKeepsValid() { + // Middle row missing 'port' - must NOT nuke the other two. + String json = "[" + + "{\"host\":\"a\",\"port\":1}," + + "{\"host\":\"bad\"}," + + "{\"host\":\"c\",\"port\":3}" + + "]"; + List<Devices.Device> got = Devices.parse(json); + assertEquals(2, got.size()); + assertEquals("a", got.get(0).host); + assertEquals("c", got.get(1).host); + } + + @Test + public void roundTrip() { + List<Devices.Device> in = new ArrayList<>(Arrays.asList( + new Devices.Device("192.168.1.10", 5555), + new Devices.Device("10.0.0.2", 43210))); + String json = Devices.serialize(in); + List<Devices.Device> out = Devices.parse(json); + assertEquals(2, out.size()); + assertEquals("192.168.1.10", out.get(0).host); + assertEquals(5555, out.get(0).port); + assertEquals("10.0.0.2", out.get(1).host); + assertEquals(43210, out.get(1).port); + } + + @Test + public void deviceEqualsByHostAndPort() { + Devices.Device a = new Devices.Device("1.1.1.1", 5555); + Devices.Device b = new Devices.Device("1.1.1.1", 5555); + Devices.Device c = new Devices.Device("1.1.1.1", 5556); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + assertEquals(false, a.equals(c)); + } +} diff --git a/app/src/test/java/invalid/lena/scrcpy/SyncTest.java b/app/src/test/java/invalid/lena/scrcpy/SyncTest.java new file mode 100644 index 0000000..57493ec --- /dev/null +++ b/app/src/test/java/invalid/lena/scrcpy/SyncTest.java @@ -0,0 +1,137 @@ +package invalid.lena.scrcpy; + +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.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +public class SyncTest { + + private static final String PATH = "/data/local/tmp/scrcpy-server.jar"; + private static final int MODE = 0100644; + private static final int MTIME = 0x12345678; + + @Test + public void emptyPayloadFraming() throws Exception { + byte[] payload = new byte[0]; + ByteArrayOutputStream framed = new ByteArrayOutputStream(); + ByteArrayInputStream resp = okayResponse(); + + long total = Sync.push(new ByteArrayInputStream(payload), framed, resp, PATH, MODE, MTIME); + assertEquals(0L, total); + + byte[] bytes = framed.toByteArray(); + int pos = 0; + + // SEND + le32(headerLen) + header + String header = PATH + "," + MODE; + byte[] hb = header.getBytes(StandardCharsets.UTF_8); + pos = assertTag(bytes, pos, "SEND"); + assertEquals(hb.length, Wire.readLe32(bytes, pos)); pos += 4; + for (int i = 0; i < hb.length; i++, pos++) assertEquals(hb[i], bytes[pos]); + + // No DATA chunks for empty payload. + // DONE + le32(mtime) + pos = assertTag(bytes, pos, "DONE"); + assertEquals(MTIME, Wire.readLe32(bytes, pos)); pos += 4; + + assertEquals(bytes.length, pos); + } + + @Test + public void singleByteIsOneDataChunk() throws Exception { + ByteArrayOutputStream framed = new ByteArrayOutputStream(); + long total = Sync.push(new ByteArrayInputStream(new byte[]{0x42}), + framed, okayResponse(), PATH, MODE, MTIME); + assertEquals(1L, total); + + byte[] bytes = framed.toByteArray(); + int pos = skipSendHeader(bytes); + pos = assertTag(bytes, pos, "DATA"); + assertEquals(1, Wire.readLe32(bytes, pos)); pos += 4; + assertEquals(0x42, bytes[pos]); pos += 1; + pos = assertTag(bytes, pos, "DONE"); + assertEquals(MTIME, Wire.readLe32(bytes, pos)); pos += 4; + assertEquals(bytes.length, pos); + } + + @Test + public void payloadLargerThanChunkSplits() throws Exception { + int n = Sync.CHUNK + 1; + byte[] payload = new byte[n]; + for (int i = 0; i < n; i++) payload[i] = (byte) i; + + ByteArrayOutputStream framed = new ByteArrayOutputStream(); + long total = Sync.push(new ByteArrayInputStream(payload), + framed, okayResponse(), PATH, MODE, MTIME); + assertEquals(n, total); + + byte[] bytes = framed.toByteArray(); + int pos = skipSendHeader(bytes); + + // First DATA = full chunk + pos = assertTag(bytes, pos, "DATA"); + assertEquals(Sync.CHUNK, Wire.readLe32(bytes, pos)); pos += 4; + pos += Sync.CHUNK; + + // Second DATA = the remaining one byte + pos = assertTag(bytes, pos, "DATA"); + assertEquals(1, Wire.readLe32(bytes, pos)); pos += 4; + pos += 1; + + pos = assertTag(bytes, pos, "DONE"); + assertEquals(MTIME, Wire.readLe32(bytes, pos)); pos += 4; + assertEquals(bytes.length, pos); + } + + @Test + public void failResponseRaises() { + ByteArrayOutputStream framed = new ByteArrayOutputStream(); + ByteArrayInputStream resp = failResponse("disk full"); + try { + Sync.push(new ByteArrayInputStream(new byte[]{1, 2, 3}), framed, resp, + PATH, MODE, MTIME); + fail("expected IOException"); + } catch (IOException e) { + assertTrue(e.getMessage(), e.getMessage().contains("disk full")); + assertTrue(e.getMessage(), e.getMessage().startsWith("sync FAIL")); + } + } + + private static ByteArrayInputStream okayResponse() { + byte[] r = new byte[8]; + r[0] = 'O'; r[1] = 'K'; r[2] = 'A'; r[3] = 'Y'; + // length 0 + return new ByteArrayInputStream(r); + } + + private static ByteArrayInputStream failResponse(String msg) { + byte[] mb = msg.getBytes(StandardCharsets.UTF_8); + byte[] r = new byte[8 + mb.length]; + r[0] = 'F'; r[1] = 'A'; r[2] = 'I'; r[3] = 'L'; + Wire.writeLe32(r, 4, mb.length); + System.arraycopy(mb, 0, r, 8, mb.length); + return new ByteArrayInputStream(r); + } + + private static int assertTag(byte[] bytes, int pos, String tag) { + for (int i = 0; i < 4; i++) { + assertEquals("tag pos=" + pos + " idx=" + i, + (byte) tag.charAt(i), bytes[pos + i]); + } + return pos + 4; + } + + private static int skipSendHeader(byte[] bytes) { + // SEND + le32(len) + header + int pos = 4; + int len = Wire.readLe32(bytes, pos); + return pos + 4 + len; + } +} diff --git a/app/src/test/java/invalid/lena/scrcpy/VideoStreamTest.java b/app/src/test/java/invalid/lena/scrcpy/VideoStreamTest.java new file mode 100644 index 0000000..9d132f6 --- /dev/null +++ b/app/src/test/java/invalid/lena/scrcpy/VideoStreamTest.java @@ -0,0 +1,245 @@ +package invalid.lena.scrcpy; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class VideoStreamTest { + + // ---- recording sink ---- + + 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;} } + + final List<Cfg> configs = new ArrayList<>(); + final List<Cfg> reconfigs = new ArrayList<>(); + final List<Feed> feeds = new ArrayList<>(); + int releases = 0; + + @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 release() { releases++; } + } + + // ---- byte builders ---- + + private static byte[] fourcc(int v) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + new DataOutputStream(bos).writeInt(v); + return bos.toByteArray(); + } + + private static byte[] sessionMeta(int w, int h, boolean clientResize) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bos); + out.writeInt(0x80000000 | (clientResize ? 1 : 0)); + out.writeInt(w); + out.writeInt(h); + return bos.toByteArray(); + } + + private static byte[] frame(long ptsAndFlags, byte[] payload) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bos); + out.writeLong(ptsAndFlags); + out.writeInt(payload.length); + out.write(payload); + return bos.toByteArray(); + } + + private static byte[] cat(byte[]... parts) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + for (byte[] p : parts) bos.write(p); + return bos.toByteArray(); + } + + private static void runUntilEof(VideoStream s) { + s.run(); // returns when the InputStream hits EOF (IOException) + } + + // ---- tests ---- + + @Test + public void happyPath_configureCsdKeyframeDelta() throws Exception { + byte[] csd = new byte[]{0x67, 0x42, (byte)0xe0, 0x1e}; // bogus SPS-ish + byte[] keyframe = new byte[]{1, 2, 3, 4, 5}; + byte[] delta = new byte[]{6, 7, 8}; + + long PTS_CFG = 1L << 62; // CONFIG + long PTS_KEY = (1L << 61) | 1_000_000L; // KEYFRAME, pts=1s + long PTS_DELT = 2_000_000L; // pts=2s + + byte[] bytes = cat( + fourcc(Wire.CODEC_H264), + sessionMeta(1080, 2400, false), + frame(PTS_CFG, csd), + frame(PTS_KEY, keyframe), + frame(PTS_DELT, delta)); + + RecordingFrames sink = new RecordingFrames(); + VideoStream s = new VideoStream(new ByteArrayInputStream(bytes), sink, + (w, h) -> {}); + runUntilEof(s); + + assertEquals(1, sink.configs.size()); + assertEquals(Wire.CODEC_H264, sink.configs.get(0).fourcc); + assertEquals(1080, sink.configs.get(0).w); + assertEquals(2400, sink.configs.get(0).h); + + assertEquals(3, sink.feeds.size()); + assertTrue("first is config", sink.feeds.get(0).isCfg); + assertEquals(0L, sink.feeds.get(0).ptsUs); // CONFIG strips pts to 0 + assertEquals(4, sink.feeds.get(0).data.length); + + // keyframe: keyframe flag is stripped from ptsUs + assertEquals(1_000_000L, sink.feeds.get(1).ptsUs); + assertEquals(5, sink.feeds.get(1).data.length); + + assertEquals(2_000_000L, sink.feeds.get(2).ptsUs); + } + + @Test + public void sessionMetaResizeTriggersReconfigure() throws Exception { + byte[] bytes = cat( + fourcc(Wire.CODEC_H264), + sessionMeta(1080, 2400, false), + frame(1L << 62, new byte[]{1}), // config + sessionMeta(720, 1600, true), // resize + frame(1L << 61, new byte[]{2})); // keyframe at new size + + RecordingFrames sink = new RecordingFrames(); + new VideoStream(new ByteArrayInputStream(bytes), sink, null).run(); + + assertEquals(1, sink.configs.size()); + assertEquals(1, sink.reconfigs.size()); + assertEquals(720, sink.reconfigs.get(0).w); + assertEquals(1600, sink.reconfigs.get(0).h); + } + + @Test + public void sizeListenerFiresOnEachSessionMeta() throws Exception { + byte[] bytes = cat( + fourcc(Wire.CODEC_H264), + sessionMeta(1080, 2400, false), + frame(1L << 62, new byte[]{1}), + sessionMeta(720, 1600, false), + frame(1L << 61, new byte[]{2})); + + List<int[]> sizes = new ArrayList<>(); + RecordingFrames sink = new RecordingFrames(); + new VideoStream(new ByteArrayInputStream(bytes), sink, + (w, h) -> sizes.add(new int[]{w, h})).run(); + + assertEquals(2, sizes.size()); + assertEquals(1080, sizes.get(0)[0]); + assertEquals(720, sizes.get(1)[0]); + } + + @Test + public void disabledCodecExits() throws Exception { + byte[] bytes = fourcc(0); + RecordingFrames sink = new RecordingFrames(); + new VideoStream(new ByteArrayInputStream(bytes), sink, null).run(); + assertEquals(0, sink.configs.size()); + assertEquals(0, sink.feeds.size()); + } + + @Test + public void frameBeforeSessionMetaIsRejected() throws Exception { + // A frame header arriving without a preceding session-meta should + // raise - the stream is unconfigured. + byte[] bytes = cat( + fourcc(Wire.CODEC_H264), + frame(1L << 62, new byte[]{1, 2, 3})); // config-flag set, but no meta first + RecordingFrames sink = new RecordingFrames(); + new VideoStream(new ByteArrayInputStream(bytes), sink, null).run(); + // The loop should have aborted without feeding anything. + assertEquals(0, sink.feeds.size()); + assertEquals(0, sink.configs.size()); + } + + @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); + byte[] bytes = cat( + fourcc(Wire.CODEC_H264), + sessionMeta(640, 480, false), + frame(1L << 62, payload)); + + RecordingFrames sink = new RecordingFrames(); + new VideoStream(new ByteArrayInputStream(bytes), sink, null).run(); + assertNotNull(sink.feeds.isEmpty() ? null : sink.feeds.get(0)); + byte[] got = sink.feeds.get(0).data; + assertEquals(payload.length, got.length); + for (int i = 0; i < payload.length; i++) { + assertEquals("byte " + i, payload[i], got[i]); + } + } +} diff --git a/app/src/test/java/invalid/lena/scrcpy/WireTest.java b/app/src/test/java/invalid/lena/scrcpy/WireTest.java new file mode 100644 index 0000000..9cee6c5 --- /dev/null +++ b/app/src/test/java/invalid/lena/scrcpy/WireTest.java @@ -0,0 +1,94 @@ +package invalid.lena.scrcpy; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.EOFException; + +public class WireTest { + + @Test + public void be32RoundTrip() { + byte[] b = new byte[4]; + int[] cases = {0, 1, -1, 0x7fffffff, 0x80000000, 0x12345678}; + for (int v : cases) { + Wire.writeBe32(b, 0, v); + assertEquals(v, Wire.readBe32(b, 0)); + } + } + + @Test + public void be32Layout() { + byte[] b = new byte[4]; + Wire.writeBe32(b, 0, 0x12345678); + assertArrayEquals(new byte[]{0x12, 0x34, 0x56, 0x78}, b); + } + + @Test + public void be64RoundTrip() { + byte[] b = new byte[8]; + long[] cases = {0L, 1L, -1L, Long.MAX_VALUE, Long.MIN_VALUE, 0x0123456789abcdefL}; + for (long v : cases) { + Wire.writeBe64(b, 0, v); + assertEquals(v, Wire.readBe64(b, 0)); + } + } + + @Test + public void le32RoundTrip() { + byte[] b = new byte[4]; + int[] cases = {0, 1, -1, 0x7fffffff, 0x80000000, 0x12345678}; + for (int v : cases) { + Wire.writeLe32(b, 0, v); + assertEquals(v, Wire.readLe32(b, 0)); + } + } + + @Test + public void le32Layout() { + byte[] b = new byte[4]; + Wire.writeLe32(b, 0, 0x12345678); + assertArrayEquals(new byte[]{0x78, 0x56, 0x34, 0x12}, b); + } + + @Test + public void readFullyShortStreamThrows() throws Exception { + ByteArrayInputStream in = new ByteArrayInputStream(new byte[3]); + byte[] buf = new byte[8]; + try { + Wire.readFully(in, buf); + fail("expected EOFException"); + } catch (EOFException ignored) { + // expected + } + } + + @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" + } + + @Test + public void fourccName() { + assertEquals("h264", Wire.fourccName(Wire.CODEC_H264)); + assertEquals("opus", Wire.fourccName(Wire.CODEC_OPUS)); + } + + @Test + public void fourccNameTrimsLeadingNuls() { + assertEquals("raw", Wire.fourccName(Wire.CODEC_RAW)); + assertEquals("aac", Wire.fourccName(Wire.CODEC_AAC)); + } +} diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..9da7691 --- /dev/null +++ b/build.gradle @@ -0,0 +1,4 @@ +plugins { + id 'com.android.application' version '8.7.3' apply false + id 'com.android.library' version '8.7.3' apply false +} diff --git a/fastlane/metadata/android/en-US/changelogs/1.txt b/fastlane/metadata/android/en-US/changelogs/1.txt new file mode 100644 index 0000000..ecef3e4 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1.txt @@ -0,0 +1,5 @@ +First release. + +Mirror a second Android device over Wireless ADB: video, audio, touch, keys, +and clipboard. Selectable video and audio codecs, screen recording, and +automatic reconnection. diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..9ca1ea9 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,20 @@ +This app mirrors a second Android device on your phone or tablet over Wireless +ADB, the same way the desktop scrcpy tool does. + +The device running this app is the source; the target is any Android 12+ device +with Wireless Debugging enabled. Video, audio, touch, key input, and clipboard +are forwarded between them. No root is required on either side. + +Features: + +* Wireless pairing and connection using the target's Wireless Debugging code +* Live video mirroring with selectable codec (H.264, H.265, or AV1) +* Audio forwarding with selectable codec (raw or Opus) +* Multi-touch, soft keys, hardware Back, and two-way clipboard sync +* Adjustable max size, bit rate, and frame rate +* Screen recording of the mirrored session +* Automatic reconnection when the link drops + +The app pushes the scrcpy server, built from Genymobile's scrcpy sources, to the +target device and speaks the scrcpy protocol directly. It is written in plain +Java with no proprietary dependencies. diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png Binary files differnew file mode 100644 index 0000000..2026b9f --- /dev/null +++ b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png Binary files differnew file mode 100644 index 0000000..fe62024 --- /dev/null +++ b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..f161f30 --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Mirror a second Android device over wireless ADB. No root required. diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000..49299a1 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +scrcpy diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..18825f9 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar Binary files differnew file mode 100644 index 0000000..2c35211 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.jar diff --git a/gradle/wrapper/gradle-wrapper.jar.sha256 b/gradle/wrapper/gradle-wrapper.jar.sha256 new file mode 100644 index 0000000..d824e33 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.jar.sha256 @@ -0,0 +1 @@ +498495120a03b9a6ab5d155f5de3c8f0d986a449153702fb80fc80e134484f17 gradle-wrapper.jar diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..77f5d0b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..16e26a1 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/scripts/build-apk b/scripts/build-apk new file mode 100755 index 0000000..9fd1409 --- /dev/null +++ b/scripts/build-apk @@ -0,0 +1,83 @@ +#!/bin/sh +# Build a signed release APK. +# +# Required environment: +# KEYSTORE_PATH absolute path to a JKS / PKCS12 keystore +# KEYSTORE_PASS password for the keystore +# KEY_ALIAS alias of the signing key inside the keystore +# KEY_PASS password for that key (often the same as KEYSTORE_PASS) +# +# Optional: +# ANDROID_SDK_ROOT if set, apksigner is located via this; otherwise +# the script trusts gradle's output and skips the +# post-build verification step. +# +# Usage: +# scripts/build-apk +# +# Output: prints the absolute path of the signed APK and its sha256. +# The signed APK lives at app/build/outputs/apk/release/app-release.apk. +# +# This script does NOT generate a keystore. Generate one once with: +# keytool -genkey -v -keystore release.jks -alias scrcpy-android \ +# -keyalg RSA -keysize 2048 -validity 10000 +# and keep it outside the repo (it must never be committed). + +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +APK="$ROOT/app/build/outputs/apk/release/app-release.apk" +JAR="$ROOT/app/src/main/assets/scrcpy-server.jar" + +require() { + name="$1" + eval "val=\${$name:-}" + if [ -z "$val" ]; then + echo "build-apk: \$$name is not set" >&2 + echo " see the header of $0 for the required environment" >&2 + exit 1 + fi +} + +require KEYSTORE_PATH +require KEYSTORE_PASS +require KEY_ALIAS +require KEY_PASS + +if [ ! -f "$KEYSTORE_PATH" ]; then + echo "build-apk: keystore not found at $KEYSTORE_PATH" >&2 + exit 1 +fi + +if [ ! -f "$JAR" ]; then + echo "build-apk: $JAR is missing" >&2 + echo " run scripts/update-server first" >&2 + exit 1 +fi + +echo "build-apk: gradle :app:assembleRelease" +cd "$ROOT" +./gradlew --no-daemon :app:assembleRelease + +if [ ! -f "$APK" ]; then + echo "build-apk: gradle finished but $APK does not exist" >&2 + exit 1 +fi + +SUM=$(sha256sum "$APK" | awk '{print $1}') + +# Best-effort: if apksigner is reachable, confirm the signature. +APKSIGNER="" +if [ -n "${ANDROID_SDK_ROOT:-}" ]; then + APKSIGNER=$(ls "$ANDROID_SDK_ROOT"/build-tools/*/apksigner 2>/dev/null | sort | tail -n 1 || true) +fi +if [ -z "$APKSIGNER" ] && command -v apksigner >/dev/null 2>&1; then + APKSIGNER=$(command -v apksigner) +fi +if [ -n "$APKSIGNER" ]; then + echo "build-apk: apksigner verify" + "$APKSIGNER" verify --verbose "$APK" | sed 's/^/ /' +fi + +echo "build-apk: $APK" +echo "build-apk: sha256 $SUM" diff --git a/scripts/check-wrapper b/scripts/check-wrapper new file mode 100755 index 0000000..31531b6 --- /dev/null +++ b/scripts/check-wrapper @@ -0,0 +1,24 @@ +#!/bin/sh +# Verify the checked-in gradle-wrapper.jar matches the expected SHA-256. +# Catches accidental swaps or supply-chain mischief. + +set -eu + +cd "$(git rev-parse --show-toplevel)" + +WRAPPER='gradle/wrapper/gradle-wrapper.jar' +EXPECTED_FILE="$WRAPPER.sha256" + +if [ ! -f "$WRAPPER" ]; then + echo "check-wrapper: $WRAPPER is missing" >&2 + exit 1 +fi +if [ ! -f "$EXPECTED_FILE" ]; then + echo "check-wrapper: $EXPECTED_FILE is missing" >&2 + exit 1 +fi + +# sha256sum reads its first column from the .sha256 sidecar; the second +# column ('gradle-wrapper.jar') is informational. +( cd "$(dirname "$WRAPPER")" && sha256sum -c "$(basename "$EXPECTED_FILE")" >/dev/null ) +echo "check-wrapper: ok" diff --git a/scripts/fetch-vendor b/scripts/fetch-vendor new file mode 100755 index 0000000..4c73381 --- /dev/null +++ b/scripts/fetch-vendor @@ -0,0 +1,44 @@ +#!/bin/sh +# Pull pinned vendor sources via git subtree. Idempotent. +# +# Usage: +# scripts/fetch-vendor # add or update to pinned tag, verify SHA +# +# Bump: +# 1. Pick a new tag from https://github.com/MuntashirAkon/libadb-android +# 2. git ls-remote https://github.com/MuntashirAkon/libadb-android.git \ +# refs/tags/<tag> +# → copy the commit hex. +# 3. Update LIBADB_TAG and LIBADB_SHA below. +# 4. Run this script. +# 5. Re-prune. This repo keeps only the libadb module plus license files +# (vendor/libadb-android/{libadb,LICENSES,COPYING,README.md, +# SERVICES.md}); the rest of upstream - sample app, gradle wrapper, +# jitpack.yml - is unused. A subtree pull re-adds it; delete it again. + +set -eu + +LIBADB_REPO='https://github.com/MuntashirAkon/libadb-android.git' +LIBADB_TAG='3.1.1' +LIBADB_SHA='c849886ebc6d48e7b46d967e78a6bb65c90c3b74' +LIBADB_PREFIX='vendor/libadb-android' + +cd "$(git rev-parse --show-toplevel)" + +# Tags can be re-pointed upstream; cross-check against the pinned commit. +remote_sha=$(git ls-remote "$LIBADB_REPO" "refs/tags/$LIBADB_TAG" | awk '{print $1}') +if [ "$remote_sha" != "$LIBADB_SHA" ]; then + echo "fetch-vendor: refs/tags/$LIBADB_TAG points at $remote_sha," >&2 + echo " expected $LIBADB_SHA. Refusing to pull." >&2 + exit 1 +fi + +if [ ! -d "$LIBADB_PREFIX" ]; then + echo "fetch-vendor: adding $LIBADB_PREFIX @ $LIBADB_TAG ($LIBADB_SHA)" + git subtree add --prefix="$LIBADB_PREFIX" "$LIBADB_REPO" "$LIBADB_TAG" --squash +else + echo "fetch-vendor: pulling $LIBADB_PREFIX @ $LIBADB_TAG ($LIBADB_SHA)" + git subtree pull --prefix="$LIBADB_PREFIX" "$LIBADB_REPO" "$LIBADB_TAG" --squash +fi + +echo "fetch-vendor: ok" diff --git a/scripts/update-server b/scripts/update-server new file mode 100755 index 0000000..df12e3a --- /dev/null +++ b/scripts/update-server @@ -0,0 +1,64 @@ +#!/bin/sh +# Download a pinned scrcpy-server.jar release, verify SHA-256 against an +# in-script allow-list, and install it into app assets. Idempotent. +# +# Usage: +# scripts/update-server # use DEFAULT_VERSION below +# scripts/update-server 4.0 # use explicit version +# +# Bumping to a new release: +# 1. Run with the new version. The script will print the observed SHA-256 +# and exit non-zero if it is not in the allow-list. +# 2. Paste the printed line into the `case` below. +# 3. Update DEFAULT_VERSION. +# 4. Re-run. Commit the assets. + +set -eu + +DEFAULT_VERSION='4.0' +VERSION="${1:-$DEFAULT_VERSION}" + +ROOT="$(git rev-parse --show-toplevel)" +ASSETS="$ROOT/app/src/main/assets" +URL="https://github.com/Genymobile/scrcpy/releases/download/v$VERSION/scrcpy-server-v$VERSION" + +# Known-good SHA-256 sums. Keep one line per blessed version. +EXPECTED=$(cat <<EOF +4.0 84924bd564a1eb6089c872c7521f968058977f91f5ff02514a8c74aff3210f3a +EOF +) + +want=$(printf '%s\n' "$EXPECTED" | awk -v v="$VERSION" '$1==v {print $2}') +if [ -z "$want" ]; then + echo "update-server: version $VERSION is not in the allow-list" >&2 + echo " to bless it, add this line to the EXPECTED block in this script:" >&2 + echo " $VERSION <sha256-of-the-downloaded-binary>" >&2 +fi + +mkdir -p "$ASSETS" +tmp=$(mktemp) +trap 'rm -f "$tmp"' EXIT + +echo "update-server: GET $URL" +curl -fsSL -o "$tmp" "$URL" + +got=$(sha256sum "$tmp" | awk '{print $1}') +echo "update-server: sha256 $got" + +if [ -z "$want" ]; then + echo "update-server: refusing to install unverified jar" >&2 + exit 1 +fi + +if [ "$got" != "$want" ]; then + echo "update-server: sha256 mismatch" >&2 + echo " want: $want" >&2 + echo " got: $got" >&2 + exit 1 +fi + +install -m 0644 "$tmp" "$ASSETS/scrcpy-server.jar" +printf '%s\n' "$VERSION" >"$ASSETS/scrcpy-server.version" +printf '%s\n' "$got" >"$ASSETS/scrcpy-server.sha256" + +echo "update-server: installed $ASSETS/scrcpy-server.jar v$VERSION" diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..78b1a9c --- /dev/null +++ b/settings.gradle @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + // JitPack is required for spake2-android, pulled in by :adb (libadb-android). + // Kept at settings level so the :adb module stays untouched upstream. + maven { url 'https://jitpack.io' } + } +} + +rootProject.name = 'scrcpy-android' + +include ':app' +include ':adb' +project(':adb').projectDir = new File(rootDir, 'vendor/libadb-android/libadb') @@ -0,0 +1,90 @@ +#!/bin/sh +# Run scrcpy-android tests inside the project-local Docker image. +# +# ./test unit tests (JVM only, no KVM) +# ./test unit same +# ./test e2e single-emulator self-mirror E2E (needs /dev/kvm) +# ./test all unit + e2e +# ./test record boot emulator, run a Mirror session, inject events, +# capture screen recording to ./output.mp4 (needs /dev/kvm) +# ./test apk build a signed release APK with a throwaway keystore +# (exercises scripts/build-apk end-to-end, no KVM) +# +# The image is built on first run and cached as $IMG. +# /dev/kvm must be readable by the invoking user for `./test e2e` and `record`. + +set -eu + +ROOT="$(cd "$(dirname "$0")" && pwd)" +# Bump the tag suffix whenever test-rig/Dockerfile or sdk-packages.txt +# changes; otherwise a stale cached image keeps being used. +IMG="scrcpy-android-test:5" + +cmd="${1:-unit}" +case "$cmd" in + unit|e2e|all|record|apk) ;; + -h|--help) + sed -n '2,14p' "$0" >&2 + exit 0 ;; + *) + echo "usage: $0 [unit|e2e|all|record|apk]" >&2 + exit 2 ;; +esac + +# Cheap supply-chain check: the gradle wrapper jar is a small binary +# we check into the tree; verify its sha before letting it run. +"$ROOT/scripts/check-wrapper" + +# Build the image if needed. --network=host so RUN apt/curl can reach +# the outside on hosts where the default docker bridge has no DNS. +if ! docker image inspect "$IMG" >/dev/null 2>&1; then + echo "test: building $IMG (first run, ~10 min)" >&2 + # Cheap pre-flight: warn the user up-front if the host can't resolve + # the docker registry, so they don't watch an apt step time out. + if ! getent hosts registry-1.docker.io >/dev/null 2>&1; then + echo "test: WARNING - registry-1.docker.io did not resolve via /etc/resolv.conf." >&2 + echo " Build will likely fail. Fix the host DNS (e.g." >&2 + echo " append 'nameserver 1.1.1.1' to /etc/resolv.conf) and retry." >&2 + fi + docker build --network=host -t "$IMG" "$ROOT/test-rig" +fi + +# KVM check for tiers that boot the emulator. +needs_kvm= +case "$cmd" in + e2e|all|record) needs_kvm=1 ;; +esac + +kvm_args= +if [ -n "$needs_kvm" ]; then + if [ ! -e /dev/kvm ]; then + echo "test: /dev/kvm not present on this host - e2e cannot run." >&2 + exit 3 + fi + if [ ! -r /dev/kvm ] || [ ! -w /dev/kvm ]; then + echo "test: /dev/kvm exists but is not rw for uid $(id -u)." >&2 + echo " one-time fix:" >&2 + echo " sudo chmod 666 /dev/kvm" >&2 + echo " or add yourself to the kvm group and re-login:" >&2 + echo " sudo usermod -aG kvm $(id -un)" >&2 + exit 3 + fi + # Pass through the host's kvm gid so the container user (mapped to + # host uid:gid) can actually open /dev/kvm. + kvm_gid=$(getent group kvm | awk -F: '{print $3}') + kvm_args="--device /dev/kvm --group-add ${kvm_gid:-kvm}" +fi + +# Forward Ctrl-C cleanly, run as the host user so files in .tools/ stay +# owned by the invoker (avoids root-owned gradle cache on the host). +# --network=host so the gradle wrapper can fetch the distribution and +# maven deps in environments where the docker bridge DNS misbehaves. +exec docker run --rm -i \ + --network=host \ + $kvm_args \ + -u "$(id -u):$(id -g)" \ + -v "$ROOT":/work \ + -w /work \ + -e HOME=/work/.tools/home \ + "$IMG" \ + /work/test-rig/run.sh "$cmd" diff --git a/test-rig/Dockerfile b/test-rig/Dockerfile new file mode 100644 index 0000000..759a09d --- /dev/null +++ b/test-rig/Dockerfile @@ -0,0 +1,43 @@ +# scrcpy-android test image. +# +# Bundles JDK 17, Android cmdline-tools, platform 35, build-tools 35, +# emulator, and an x86_64 default API-35 system image (for e2e). +# Project is mounted at /work; runs in --rm mode. + +FROM debian:bookworm-slim + +ARG SDK_VER=11076708 +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + openjdk-17-jdk-headless \ + curl unzip ca-certificates \ + libpulse0 libxkbcommon0 libgl1 libnss3 libasound2 libdbus-1-3 \ + qemu-system-x86 qemu-utils \ + procps \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +ENV ANDROID_SDK_ROOT=/opt/android-sdk +ENV ANDROID_HOME=$ANDROID_SDK_ROOT + +RUN mkdir -p $ANDROID_SDK_ROOT/cmdline-tools \ + && curl -fsSL "https://dl.google.com/android/repository/commandlinetools-linux-${SDK_VER}_latest.zip" \ + -o /tmp/clt.zip \ + && unzip -q /tmp/clt.zip -d $ANDROID_SDK_ROOT/cmdline-tools \ + && mv $ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools $ANDROID_SDK_ROOT/cmdline-tools/latest \ + && rm /tmp/clt.zip + +ENV PATH=$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools:$ANDROID_SDK_ROOT/emulator:$PATH + +COPY sdk-packages.txt /tmp/sdk-packages.txt +RUN yes | sdkmanager --licenses >/dev/null \ + && sdkmanager $(tr '\n' ' ' < /tmp/sdk-packages.txt) >/dev/null \ + && chmod -R a+rwX "$ANDROID_SDK_ROOT" + +# Pre-create the default AVD location and let gradle put its cache here. +ENV GRADLE_USER_HOME=/work/.tools/gradle-home +ENV ANDROID_AVD_HOME=/work/.tools/avd + +WORKDIR /work diff --git a/test-rig/Keygen.java b/test-rig/Keygen.java new file mode 100644 index 0000000..8cb90a6 --- /dev/null +++ b/test-rig/Keygen.java @@ -0,0 +1,92 @@ +// Read a DER-encoded PKCS#8 RSA private key, derive the public key, +// emit the Android adb_keys line on stdout. +// +// java test-rig/Keygen.java <path/to/adbkey.der> +// +// The Android pubkey blob format (from system/core/libcrypto_utils): +// uint32_t modulus_size_words; // = 64 for RSA-2048 +// uint32_t n0inv; // -1 / N[0] mod 2^32 +// uint8_t modulus[256]; // little-endian +// uint8_t rr[256]; // (2^2048)^2 mod N, little-endian +// uint32_t exponent; // typically 65537 +// = 524 bytes, then base64-encoded, then " scrcpy-android@test". + +import java.io.IOException; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.interfaces.RSAPrivateCrtKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; + +public final class Keygen { + + private static final int MODULUS_SIZE = 256; // bytes (2048 / 8) + private static final int MODULUS_SIZE_WORDS = MODULUS_SIZE / 4; + private static final int ENCODED_SIZE = 3 * 4 + 2 * MODULUS_SIZE; + + public static void main(String[] args) throws Exception { + if (args.length != 1) { + System.err.println("usage: java Keygen.java <der private key path>"); + System.exit(2); + } + byte[] der = Files.readAllBytes(Path.of(args[0])); + PrivateKey priv = KeyFactory.getInstance("RSA") + .generatePrivate(new PKCS8EncodedKeySpec(der)); + RSAPublicKey pub = derivePublic(priv); + + byte[] blob = encode(pub); + String b64 = Base64.getEncoder().encodeToString(blob); + System.out.print(b64); + System.out.println(" scrcpy-android@test"); + } + + private static RSAPublicKey derivePublic(PrivateKey priv) throws Exception { + if (priv instanceof RSAPrivateCrtKey crt) { + RSAPublicKeySpec spec = new RSAPublicKeySpec(crt.getModulus(), crt.getPublicExponent()); + return (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(spec); + } + throw new IOException("private key is not an RSAPrivateCrtKey"); + } + + private static byte[] encode(RSAPublicKey pub) { + ByteBuffer buf = ByteBuffer.allocate(ENCODED_SIZE).order(ByteOrder.LITTLE_ENDIAN); + BigInteger n = pub.getModulus(); + + buf.putInt(MODULUS_SIZE_WORDS); + + // n0inv = (2^32) - (N[0] mod 2^32)^-1 mod 2^32 + BigInteger r32 = BigInteger.ZERO.setBit(32); + BigInteger n0inv = n.mod(r32).modInverse(r32); + n0inv = r32.subtract(n0inv); + buf.putInt(n0inv.intValue()); + + buf.put(beToLe(MODULUS_SIZE, n)); + + // rr = (2^(MODULUS_SIZE*8))^2 mod N + BigInteger rr = BigInteger.ZERO.setBit(MODULUS_SIZE * 8).modPow(BigInteger.TWO, n); + buf.put(beToLe(MODULUS_SIZE, rr)); + + buf.putInt(pub.getPublicExponent().intValue()); + return buf.array(); + } + + private static byte[] beToLe(int len, BigInteger v) { + byte[] be = v.toByteArray(); + // strip leading sign byte if present + int start = 0; + if (be.length > len) start = be.length - len; + byte[] out = new byte[len]; + for (int i = 0; i < len; i++) { + int srcIdx = be.length - 1 - i; + if (srcIdx >= start) out[i] = be[srcIdx]; + } + return out; + } +} diff --git a/test-rig/Pixhash.java b/test-rig/Pixhash.java new file mode 100644 index 0000000..47ab8b8 --- /dev/null +++ b/test-rig/Pixhash.java @@ -0,0 +1,54 @@ +// Verify that a screencap PNG is non-uniform - i.e., MediaCodec +// actually painted something. Exits 0 if the image has at least +// `--min` percent of pixels that differ from pixel (0,0); 1 otherwise. +// +// java test-rig/Pixhash.java <png> # default min=5% +// java test-rig/Pixhash.java <png> --min 1 +// +// We avoid AWT's BufferedImage because some headless JDKs ship without +// it; ImageIO is in java.desktop which IS present in the openjdk-17-jdk +// package in our image, so it's fine. + +import java.io.File; +import java.io.IOException; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; + +public final class Pixhash { + + public static void main(String[] args) throws IOException { + if (args.length < 1) { + System.err.println("usage: java Pixhash.java <png> [--min N]"); + System.exit(2); + } + double minPercent = 5.0; + for (int i = 1; i < args.length; i++) { + if ("--min".equals(args[i]) && i + 1 < args.length) { + minPercent = Double.parseDouble(args[++i]); + } + } + + BufferedImage img = ImageIO.read(new File(args[0])); + if (img == null) { + System.err.println("pixhash: could not read " + args[0]); + System.exit(2); + } + + int w = img.getWidth(), h = img.getHeight(); + int ref = img.getRGB(0, 0); + long total = (long) w * h; + long diff = 0; + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + if (img.getRGB(x, y) != ref) diff++; + } + } + double pct = 100.0 * diff / total; + System.out.printf("pixhash: %d/%d differ from (0,0)=%08x => %.2f%%%n", + diff, total, ref, pct); + if (pct < minPercent) { + System.err.printf("pixhash: only %.2f%% differ, need %.2f%%%n", pct, minPercent); + System.exit(1); + } + } +} diff --git a/test-rig/build-apk.sh b/test-rig/build-apk.sh new file mode 100755 index 0000000..18b3969 --- /dev/null +++ b/test-rig/build-apk.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Smoke test: build a signed release APK from inside the rig using a +# throwaway keystore. Validates that scripts/build-apk + the gradle +# signingConfigs wiring still produces a valid signed apk. +# +# Output: /work/app/build/outputs/apk/release/app-release.apk +# (and the keystore in /work/.tools/release-smoke.jks) + +set -eu + +KS="/work/.tools/release-smoke.p12" +KS_PASS="changeit" +KEY_ALIAS="scrcpy-android-smoke" + +mkdir -p /work/.tools + +if [ ! -f "$KS" ]; then + echo "build-apk.sh: generating throwaway keystore at $KS" + keytool -genkey -noprompt \ + -keystore "$KS" -storetype PKCS12 \ + -storepass "$KS_PASS" -keypass "$KS_PASS" \ + -alias "$KEY_ALIAS" -keyalg RSA -keysize 2048 -validity 365 \ + -dname "CN=scrcpy-android smoke, OU=test, O=local, C=US" \ + >/dev/null +fi + +export KEYSTORE_PATH="$KS" +export KEYSTORE_PASS="$KS_PASS" +export KEY_ALIAS="$KEY_ALIAS" +export KEY_PASS="$KS_PASS" + +exec /work/scripts/build-apk diff --git a/test-rig/e2e.sh b/test-rig/e2e.sh new file mode 100755 index 0000000..ae2a1a8 --- /dev/null +++ b/test-rig/e2e.sh @@ -0,0 +1,172 @@ +#!/bin/sh +# Single-emulator self-mirror E2E. +# +# Boot an AOSP API-35 emulator, root it, install the app, let it +# generate its keypair, copy that pubkey into /data/misc/adb/adb_keys +# so the app can connect to its own host's adbd over TCP, launch +# Mirror against 127.0.0.1:5555, wait for the first video frame in +# logcat, screencap, assert the frame is non-uniform. +# +# Runs inside the test image; orchestrated by ../test. + +set -eu + +ROOT=/work +# Debug applicationId (note the .debug suffix). Activity classes live in +# the invalid.lena.scrcpy namespace, which carries no suffix. +PKG=invalid.lena.scrcpy.debug +AVD=scrcpy-test +SERIAL=emulator-5554 +APK=$ROOT/app/build/outputs/apk/debug/app-debug.apk +TMPDIR=$ROOT/.tools/e2e +mkdir -p "$TMPDIR" + +log() { printf 'e2e: %s\n' "$*" >&2; } + +# ---- 0. server jar ---- +[ -f "$ROOT/app/src/main/assets/scrcpy-server.jar" ] || "$ROOT/scripts/update-server" + +# ---- 1. build APK ---- +log 'gradle :app:assembleDebug' +"$ROOT/gradlew" --no-daemon -q :app:assembleDebug + +# ---- 2. AVD + boot ---- +if ! avdmanager list avd | grep -q "Name: $AVD$"; then + log "creating avd $AVD" + echo no | avdmanager create avd -n "$AVD" -k 'system-images;android-35;default;x86_64' -d pixel >/dev/null +fi + +# Clean stale lock files from a previously aborted run; otherwise the +# emulator refuses to start ("Running multiple emulators with the same AVD"). +AVD_DIR="$ANDROID_AVD_HOME/$AVD.avd" +rm -f "$AVD_DIR"/multiinstance.lock "$AVD_DIR"/hardware-qemu.ini.lock 2>/dev/null || true +rm -rf "$HOME/.android/avd/running" 2>/dev/null || true + +if ! pgrep -f "emulator.*-avd $AVD" >/dev/null; then + log 'booting emulator (no window, no snapshot)' + emulator -avd "$AVD" -no-window -no-audio -no-snapshot -gpu swiftshader_indirect \ + -no-boot-anim -accel on >"$TMPDIR/emulator.log" 2>&1 & +fi + +log 'wait-for-device' +adb -s "$SERIAL" wait-for-device +log 'wait for boot' +until [ "$(adb -s "$SERIAL" shell getprop sys.boot_completed | tr -d '\r')" = 1 ]; do + sleep 2 +done + +# ---- 3. root (emulator adbd already listens on TCP 5555) ---- +log 'adb root' +adb -s "$SERIAL" root +adb -s "$SERIAL" wait-for-device + +# ---- 4. install ---- +log "install $APK" +adb -s "$SERIAL" install -r "$APK" >/dev/null + +# ---- 5. let the app generate its keypair ---- +log "launching Main once so Adb.java writes filesDir/adbkey" +adb -s "$SERIAL" shell am start -W -n "$PKG/invalid.lena.scrcpy.Main" >/dev/null +# Adb.java writes the keypair inside its constructor, before setContentView +sleep 1 + +# ---- 6. extract pubkey and authorise ---- +log 'reading filesDir/adbkey via run-as' +adb -s "$SERIAL" shell "run-as $PKG cat files/adbkey" > "$TMPDIR/adbkey.der" +if [ ! -s "$TMPDIR/adbkey.der" ]; then + log 'adbkey was empty - did Adb.java run?' + adb -s "$SERIAL" logcat -d -t 200 -s scrcpy-android >&2 || true + exit 1 +fi + +log 'computing android pubkey blob' +java "$ROOT/test-rig/Keygen.java" "$TMPDIR/adbkey.der" > "$TMPDIR/adb_keys.line" + +log 'appending to /data/misc/adb/adb_keys' +adb -s "$SERIAL" push "$TMPDIR/adb_keys.line" /sdcard/adb_keys.line >/dev/null +adb -s "$SERIAL" shell "cat /sdcard/adb_keys.line >> /data/misc/adb/adb_keys \ + && chown system:shell /data/misc/adb/adb_keys \ + && chmod 640 /data/misc/adb/adb_keys \ + && rm /sdcard/adb_keys.line" + +# adbd rereads /data/misc/adb/adb_keys on every AUTH challenge, +# so no daemon restart is necessary. Restarting it would put the +# host's adb client into 'offline' state and force a reconnect dance. + +# ---- 7. trigger Mirror ---- +log 'starting Mirror -> 127.0.0.1:5555' +adb -s "$SERIAL" logcat -c +adb -s "$SERIAL" shell am start -n "$PKG/invalid.lena.scrcpy.Mirror" \ + --es host 127.0.0.1 --ei port 5555 >/dev/null + +# ---- 8. wait for first frame, fail on session give-up ---- +# Server's openAbstract budget (Server.OPEN_DEADLINE_MS) must stay +# shorter than this deadline; otherwise our retries finish before +# we've given the wire a chance. +deadline=$(( $(date +%s) + ${E2E_DEADLINE:-60} )) +ok= +while [ "$(date +%s)" -lt "$deadline" ]; do + line=$(adb -s "$SERIAL" logcat -d -s scrcpy-android | tail -200) + if printf '%s' "$line" | grep -q 'video frame n=1'; then + ok=1; break + fi + if printf '%s' "$line" | grep -q 'session: gave up'; then + log 'session gave up; logcat:' + printf '%s\n' "$line" | tail -50 >&2 + exit 1 + fi + sleep 1 +done +if [ -z "$ok" ]; then + log 'timed out waiting for video frame n=1' + log "full app log: $TMPDIR/logcat.scrcpy" + adb -s "$SERIAL" logcat -d -s scrcpy-android > "$TMPDIR/logcat.scrcpy" 2>&1 || true + log "full system log: $TMPDIR/logcat.full" + adb -s "$SERIAL" logcat -d > "$TMPDIR/logcat.full" 2>&1 || true + log 'last 80 scrcpy-android lines:' + tail -80 "$TMPDIR/logcat.scrcpy" >&2 || true + exit 1 +fi +log 'video frame n=1 - proceeding to screencap' + +# Give the decoder a moment to actually render to the surface. +sleep 1 + +# ---- 9. screencap + pixhash ---- +adb -s "$SERIAL" exec-out screencap -p > "$TMPDIR/shot.png" +log "screencap $(wc -c <"$TMPDIR/shot.png") bytes" +java "$ROOT/test-rig/Pixhash.java" "$TMPDIR/shot.png" --min 5 + +# ---- 9b. mid-session disconnect -> auto-reconnect ---- +# Kill the scrcpy server on the target out from under the app. The video +# socket dies; Session.run() must detect it, rerun the bring-up ladder, +# and a fresh VideoStream must log 'video frame n=1' again. Clear logcat +# first so the only frame we can match is the post-reconnect one. +log 'forcing a mid-session disconnect (pkill scrcpy server on target)' +adb -s "$SERIAL" logcat -c +adb -s "$SERIAL" shell 'pkill -f com.genymobile.scrcpy.Server' || true + +rdeadline=$(( $(date +%s) + ${E2E_RECONNECT_DEADLINE:-60} )) +relink= +while [ "$(date +%s)" -lt "$rdeadline" ]; do + if adb -s "$SERIAL" logcat -d -s scrcpy-android | grep -q 'video frame n=1'; then + relink=1; break + fi + sleep 1 +done +if [ -z "$relink" ]; then + log 'timed out waiting for auto-reconnect (no fresh video frame n=1)' + adb -s "$SERIAL" logcat -d -s scrcpy-android > "$TMPDIR/logcat.reconnect" 2>&1 || true + log "full app log: $TMPDIR/logcat.reconnect" + tail -80 "$TMPDIR/logcat.reconnect" >&2 || true + exit 1 +fi +log 'auto-reconnect ok - fresh video frame n=1 after link loss' + +# ---- 10. cleanup ---- +log "full app log: $TMPDIR/logcat.scrcpy" +adb -s "$SERIAL" logcat -d -s scrcpy-android > "$TMPDIR/logcat.scrcpy" 2>&1 || true +log 'adb emu kill' +adb -s "$SERIAL" emu kill || true + +log 'e2e: pass' diff --git a/test-rig/record.sh b/test-rig/record.sh new file mode 100755 index 0000000..82e31dc --- /dev/null +++ b/test-rig/record.sh @@ -0,0 +1,178 @@ +#!/bin/sh +# Boot the emulator + Mirror, then capture frames via `screencap` and +# inject taps/keys through the app. Frames are PNGs in $TMPDIR/frames; +# the host side stitches them into /work/output.mp4 with ffmpeg. +# +# Why not `screenrecord`? On the AOSP x86_64 system image with a +# software GPU, screenrecord composes the system layer but misses +# SurfaceView's hardware layer - the mirror's decoded surface ends +# up as a black rectangle in the recording. screencap goes through +# a different capture path that does include the SurfaceView, as +# the pixhash check in ./test e2e already verifies. +# +# Source == target: every injected tap/key fires both locally and +# over the wire - visible in the nested mirror, which is the demo. + +set -eu + +ROOT=/work +# Debug applicationId (note the .debug suffix). Activity classes live in +# the invalid.lena.scrcpy namespace, which carries no suffix. +PKG=invalid.lena.scrcpy.debug +AVD=scrcpy-test +SERIAL=emulator-5554 +APK=$ROOT/app/build/outputs/apk/debug/app-debug.apk +TMPDIR=$ROOT/.tools/e2e +FRAMES=$TMPDIR/frames +OUT=$ROOT/output.mp4 +FPS=8 +mkdir -p "$TMPDIR" "$FRAMES" +rm -f "$FRAMES"/*.png 2>/dev/null || true + +log() { printf 'record: %s\n' "$*" >&2; } + +# ---- 0. server jar ---- +[ -f "$ROOT/app/src/main/assets/scrcpy-server.jar" ] || "$ROOT/scripts/update-server" + +# ---- 1. build APK ---- +log 'gradle :app:assembleDebug' +"$ROOT/gradlew" --no-daemon -q :app:assembleDebug + +# ---- 2. AVD + boot ---- +if ! avdmanager list avd | grep -q "Name: $AVD$"; then + log "creating avd $AVD" + echo no | avdmanager create avd -n "$AVD" -k 'system-images;android-35;default;x86_64' -d pixel >/dev/null +fi +AVD_DIR="$ANDROID_AVD_HOME/$AVD.avd" +rm -f "$AVD_DIR"/multiinstance.lock "$AVD_DIR"/hardware-qemu.ini.lock \ + "$AVD_DIR"/snapshot.lock.lock "$AVD_DIR"/read-snapshot.txt 2>/dev/null || true +rm -rf "$HOME/.android/avd/running" 2>/dev/null || true + +if ! pgrep -f "emulator.*-avd $AVD" >/dev/null; then + log 'booting emulator' + emulator -avd "$AVD" -no-window -no-audio -no-snapshot -gpu swiftshader_indirect \ + -no-boot-anim -accel on >"$TMPDIR/emulator.log" 2>&1 & +fi +adb -s "$SERIAL" wait-for-device +until [ "$(adb -s "$SERIAL" shell getprop sys.boot_completed | tr -d '\r')" = 1 ]; do + sleep 2 +done + +# ---- 3. root + install ---- +log 'adb root' +adb -s "$SERIAL" root +adb -s "$SERIAL" wait-for-device +log "install $APK" +adb -s "$SERIAL" install -r "$APK" >/dev/null + +# ---- 4. bootstrap adb_keys via the app's own keypair ---- +adb -s "$SERIAL" shell am start -W -n "$PKG/invalid.lena.scrcpy.Main" >/dev/null +sleep 1 +adb -s "$SERIAL" shell "run-as $PKG cat files/adbkey" > "$TMPDIR/adbkey.der" +[ -s "$TMPDIR/adbkey.der" ] || { log 'adbkey was empty'; exit 1; } +java "$ROOT/test-rig/Keygen.java" "$TMPDIR/adbkey.der" > "$TMPDIR/adb_keys.line" +adb -s "$SERIAL" push "$TMPDIR/adb_keys.line" /sdcard/adb_keys.line >/dev/null +adb -s "$SERIAL" shell "cat /sdcard/adb_keys.line >> /data/misc/adb/adb_keys \ + && chown system:shell /data/misc/adb/adb_keys \ + && chmod 640 /data/misc/adb/adb_keys \ + && rm /sdcard/adb_keys.line" + +# Dismiss the "Viewing full screen" system toast that pops up the +# first time an activity goes immersive - otherwise it covers the top +# strip of the mirror in our captures. +adb -s "$SERIAL" shell settings put global policy_control \ + 'immersive.full=*' >/dev/null 2>&1 || true + +# ---- 5. launch Mirror ---- +log 'launching Mirror against 127.0.0.1:5555' +adb -s "$SERIAL" logcat -c +adb -s "$SERIAL" shell am start -n "$PKG/invalid.lena.scrcpy.Mirror" \ + --es host 127.0.0.1 --ei port 5555 >/dev/null + +# ---- 6. wait for first frame ---- +deadline=$(( $(date +%s) + ${E2E_DEADLINE:-120} )) +while [ "$(date +%s)" -lt "$deadline" ]; do + if adb -s "$SERIAL" logcat -d -s scrcpy-android | tail -500 | grep -q 'video frame n=1'; then + ok=1; break + fi + sleep 1 +done +if [ "${ok:-}" != 1 ]; then + log 'timed out waiting for first frame; full app log:' + adb -s "$SERIAL" logcat -d -s scrcpy-android > "$TMPDIR/logcat.record.scrcpy" 2>&1 || true + tail -60 "$TMPDIR/logcat.record.scrcpy" >&2 || true + exit 1 +fi +log 'first frame ok - settling 5 s for the immersive toast to fade' +# The first immersive activity on a fresh AVD draws a top-of-screen +# "Viewing full screen" toast that fades after ~4 s. We deliberately +# do NOT try to dismiss it by injecting touch - anything at y ~= 0 +# pulls the notification shade, anything near the bottom hits the nav +# bar, and either ends Mirror's foreground status. Wait it out instead. +sleep 5 + +# ---- 7. screencap loop in background while we inject events ---- +# Mirror's overlay TextViews (target / frame counts / last event) +# live in the regular view hierarchy, so screencap captures them +# fine - the actual decoded video underneath stays black to capture +# but the overlay is the in-test proof. +log "capturing frames @ ${FPS} fps via screencap" +touch "$TMPDIR/recording" +( + i=0 + while [ -f "$TMPDIR/recording" ]; do + n=$(printf '%05d' "$i") + adb -s "$SERIAL" exec-out screencap -p > "$FRAMES/f_$n.png" 2>/dev/null || true + i=$((i + 1)) + done +) & +CAP_PID=$! +sleep 1 + +log 'taps' +adb -s "$SERIAL" shell input tap 540 960 +sleep 0.6 +adb -s "$SERIAL" shell input tap 270 480 +sleep 0.6 +adb -s "$SERIAL" shell input tap 810 1440 +sleep 0.6 + +log 'swipe' +adb -s "$SERIAL" shell input swipe 200 1000 880 1000 250 +sleep 0.6 + +log 'dpad keys' +for k in KEYCODE_DPAD_RIGHT KEYCODE_DPAD_LEFT KEYCODE_DPAD_DOWN KEYCODE_DPAD_UP; do + adb -s "$SERIAL" shell input keyevent "$k" + sleep 0.3 +done + +log 'alphanumerics' +for k in KEYCODE_S KEYCODE_C KEYCODE_R KEYCODE_C KEYCODE_P KEYCODE_Y; do + adb -s "$SERIAL" shell input keyevent "$k" + sleep 0.25 +done + +sleep 1 +adb -s "$SERIAL" shell input tap 540 960 +sleep 2 + +# ---- 8. stop capture, stitch ---- +rm -f "$TMPDIR/recording" +wait "$CAP_PID" 2>/dev/null || true + +count=$(ls "$FRAMES"/*.png 2>/dev/null | wc -l) +log "captured $count frames" +[ "$count" -gt 0 ] || { log 'no frames captured'; exit 1; } + +log "stitching $FRAMES/*.png -> $OUT" +ffmpeg -y -framerate "$FPS" -pattern_type glob -i "$FRAMES/f_*.png" \ + -c:v libx264 -pix_fmt yuv420p -movflags +faststart "$OUT" \ + >/dev/null 2>&1 + +# ---- 9. cleanup ---- +adb -s "$SERIAL" logcat -d -s scrcpy-android > "$TMPDIR/logcat.record.scrcpy" 2>&1 || true +log "app log: $TMPDIR/logcat.record.scrcpy" +adb -s "$SERIAL" emu kill || true +log "wrote $(wc -c <"$OUT") bytes to $OUT" +log 'done' diff --git a/test-rig/run.sh b/test-rig/run.sh new file mode 100755 index 0000000..ff63c21 --- /dev/null +++ b/test-rig/run.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# In-container orchestrator. Called by ./test from outside. +# /work/test-rig/run.sh unit|e2e|all +# +# Expects to run inside the scrcpy-android-test docker image with +# /work mounted at the project root. + +set -eu + +cmd="${1:-unit}" + +mkdir -p /work/.tools/home /work/.tools/gradle-home /work/.tools/avd +export HOME="/work/.tools/home" +export GRADLE_USER_HOME="/work/.tools/gradle-home" +export ANDROID_AVD_HOME="/work/.tools/avd" + +unit() { + echo "test: gradle :app:test" + /work/gradlew --no-daemon -q :app:test +} + +e2e() { + /work/test-rig/e2e.sh +} + +case "$cmd" in + unit) unit ;; + e2e) e2e ;; + all) unit && e2e ;; + record) /work/test-rig/record.sh ;; + apk) /work/test-rig/build-apk.sh ;; + *) echo "run.sh: bad cmd $cmd" >&2; exit 2 ;; +esac diff --git a/test-rig/sdk-packages.txt b/test-rig/sdk-packages.txt new file mode 100644 index 0000000..97ed4a6 --- /dev/null +++ b/test-rig/sdk-packages.txt @@ -0,0 +1,5 @@ +platform-tools +platforms;android-35 +build-tools;35.0.0 +emulator +system-images;android-35;default;x86_64 diff --git a/vendor/libadb-android/COPYING b/vendor/libadb-android/COPYING new file mode 100644 index 0000000..b6a0e66 --- /dev/null +++ b/vendor/libadb-android/COPYING @@ -0,0 +1,21 @@ +LibADB Android is provided under: + + SPDX-License-Identifier: GPL-3.0-or-later or Apache-2.0 + +Being under the terms of the GNU General Public License version 3 or +later, according with: + + LICENSES/GPL-3.0-or-later + +or Apache License, Version 2.0, according with: + + LICENSES/Apache-2.0 + +In addition, other licenses may also apply. Please navigate to: + + LICENSES/ + +to see all the licenses used in this project. + +All contributions to the LibADB Android are subject to this COPYING +file. diff --git a/vendor/libadb-android/LICENSES/Apache-2.0 b/vendor/libadb-android/LICENSES/Apache-2.0 new file mode 100644 index 0000000..325c59c --- /dev/null +++ b/vendor/libadb-android/LICENSES/Apache-2.0 @@ -0,0 +1,186 @@ +Valid-License-Identifier: Apache-2.0 +SPDX-URL: https://spdx.org/licenses/Apache-2.0.html +Usage-Guide: + To use the Apache License version 2.0 put the following SPDX tag/value + pair into a comment according to the placement guidelines in the + licensing rules documentation: + SPDX-License-Identifier: Apache-2.0 + Do NOT use this license unless the files are copied from another work + under the same license. In such cases, use "AND GPL-3.0-or-later" so + that your contributions are under GPL-3.0+ license. +License-Text: + +Apache License + +Version 2.0, January 2004 + +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the +copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other +entities that control, are controlled by, or are under common control with +that entity. For the purposes of this definition, "control" means (i) the +power, direct or indirect, to cause the direction or management of such +entity, whether by contract or otherwise, or (ii) ownership of fifty +percent (50%) or more of the outstanding shares, or (iii) beneficial +ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation source, +and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation +or translation of a Source form, including but not limited to compiled +object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, +made available under the License, as indicated by a copyright notice that +is included in or attached to the work (an example is provided in the +Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, +that is based on (or derived from) the Work and for which the editorial +revisions, annotations, elaborations, or other modifications represent, as +a whole, an original work of authorship. For the purposes of this License, +Derivative Works shall not include works that remain separable from, or +merely link (or bind by name) to the interfaces of, the Work and Derivative +Works thereof. + +"Contribution" shall mean any work of authorship, including the original +version of the Work and any modifications or additions to that Work or +Derivative Works thereof, that is intentionally submitted to Licensor for +inclusion in the Work by the copyright owner or by an individual or Legal +Entity authorized to submit on behalf of the copyright owner. For the +purposes of this definition, "submitted" means any form of electronic, +verbal, or written communication sent to the Licensor or its +representatives, including but not limited to communication on electronic +mailing lists, source code control systems, and issue tracking systems that +are managed by, or on behalf of, the Licensor for the purpose of discussing +and improving the Work, but excluding communication that is conspicuously +marked or otherwise designated in writing by the copyright owner as "Not a +Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on +behalf of whom a Contribution has been received by Licensor and +subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this + License, each Contributor hereby grants to You a perpetual, worldwide, + non-exclusive, no-charge, royalty-free, irrevocable copyright license to + reproduce, prepare Derivative Works of, publicly display, publicly + perform, sublicense, and distribute the Work and such Derivative Works + in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this + License, each Contributor hereby grants to You a perpetual, worldwide, + non-exclusive, no-charge, royalty-free, irrevocable (except as stated in + this section) patent license to make, have made, use, offer to sell, + sell, import, and otherwise transfer the Work, where such license + applies only to those patent claims licensable by such Contributor that + are necessarily infringed by their Contribution(s) alone or by + combination of their Contribution(s) with the Work to which such + Contribution(s) was submitted. If You institute patent litigation + against any entity (including a cross-claim or counterclaim in a + lawsuit) alleging that the Work or a Contribution incorporated within + the Work constitutes direct or contributory patent infringement, then + any patent licenses granted to You under this License for that Work + shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or + Derivative Works thereof in any medium, with or without modifications, + and in Source or Object form, provided that You meet the following + conditions: + + a. You must give any other recipients of the Work or Derivative Works a + copy of this License; and + + b. You must cause any modified files to carry prominent notices stating + that You changed the files; and + + c. You must retain, in the Source form of any Derivative Works that You + distribute, all copyright, patent, trademark, and attribution notices + from the Source form of the Work, excluding those notices that do not + pertain to any part of the Derivative Works; and + + d. If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained within + such NOTICE file, excluding those notices that do not pertain to any + part of the Derivative Works, in at least one of the following + places: within a NOTICE text file distributed as part of the + Derivative Works; within the Source form or documentation, if + provided along with the Derivative Works; or, within a display + generated by the Derivative Works, if and wherever such third-party + notices normally appear. The contents of the NOTICE file are for + informational purposes only and do not modify the License. You may + add Your own attribution notices within Derivative Works that You + distribute, alongside or as an addendum to the NOTICE text from the + Work, provided that such additional attribution notices cannot be + construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may + provide additional or different license terms and conditions for use, + reproduction, or distribution of Your modifications, or for any such + Derivative Works as a whole, provided Your use, reproduction, and + distribution of the Work otherwise complies with the conditions stated + in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any + Contribution intentionally submitted for inclusion in the Work by You to + the Licensor shall be under the terms and conditions of this License, + without any additional terms or conditions. Notwithstanding the above, + nothing herein shall supersede or modify the terms of any separate + license agreement you may have executed with Licensor regarding such + Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to + in writing, Licensor provides the Work (and each Contributor provides + its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS + OF ANY KIND, either express or implied, including, without limitation, + any warranties or conditions of TITLE, NON-INFRINGEMENT, + MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely + responsible for determining the appropriateness of using or + redistributing the Work and assume any risks associated with Your + exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether + in tort (including negligence), contract, or otherwise, unless required + by applicable law (such as deliberate and grossly negligent acts) or + agreed to in writing, shall any Contributor be liable to You for + damages, including any direct, indirect, special, incidental, or + consequential damages of any character arising as a result of this + License or out of the use or inability to use the Work (including but + not limited to damages for loss of goodwill, work stoppage, computer + failure or malfunction, or any and all other commercial damages or + losses), even if such Contributor has been advised of the possibility of + such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the + Work or Derivative Works thereof, You may choose to offer, and charge a + fee for, acceptance of support, warranty, indemnity, or other liability + obligations and/or rights consistent with this License. However, in + accepting such obligations, You may act only on Your own behalf and on + Your sole responsibility, not on behalf of any other Contributor, and + only if You agree to indemnify, defend, and hold each Contributor + harmless for any liability incurred by, or claims asserted against, such + Contributor by reason of your accepting any such warranty or additional + liability. + +END OF TERMS AND CONDITIONS
\ No newline at end of file diff --git a/vendor/libadb-android/LICENSES/BSD-3-Clause b/vendor/libadb-android/LICENSES/BSD-3-Clause new file mode 100644 index 0000000..3789c3e --- /dev/null +++ b/vendor/libadb-android/LICENSES/BSD-3-Clause @@ -0,0 +1,36 @@ +Valid-License-Identifier: BSD-3-Clause +SPDX-URL: https://spdx.org/licenses/BSD-3-Clause.html +Usage-Guide: + To use the BSD 3-clause "New" or "Revised" License put the following SPDX + tag/value pair into a comment according to the placement guidelines in + the licensing rules documentation: + SPDX-License-Identifier: BSD-3-Clause +License-Text: + +Copyright (c) <year> <owner> . All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE.
\ No newline at end of file diff --git a/vendor/libadb-android/LICENSES/GPL-3.0-or-later b/vendor/libadb-android/LICENSES/GPL-3.0-or-later new file mode 100644 index 0000000..b7b2bd9 --- /dev/null +++ b/vendor/libadb-android/LICENSES/GPL-3.0-or-later @@ -0,0 +1,687 @@ +Valid-License-Identifier: GPL-3.0+ +Valid-License-Identifier: GPL-3.0-or-later +SPDX-URL: https://spdx.org/licenses/GPL-3.0-or-later.html +Usage-Guide: + To use this license in source code, put one of the following SPDX + tag/value pairs into a comment according to the placement + guidelines in the licensing rules documentation. + For 'GNU General Public License (GPL) version 3 or any later version' use: + SPDX-License-Identifier: GPL-3.0+ + or + SPDX-License-Identifier: GPL-3.0-or-later +License-Text: + + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + <program> Copyright (C) <year> <name of author> + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +<https://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +<https://www.gnu.org/licenses/why-not-lgpl.html>.
\ No newline at end of file diff --git a/vendor/libadb-android/LICENSES/MIT b/vendor/libadb-android/LICENSES/MIT new file mode 100644 index 0000000..2827cab --- /dev/null +++ b/vendor/libadb-android/LICENSES/MIT @@ -0,0 +1,33 @@ +Valid-License-Identifier: MIT +SPDX-URL: https://spdx.org/licenses/MIT.html +Usage-Guide: + To use the MIT License put the following SPDX tag/value pair into a + comment according to the placement guidelines in the licensing rules + documentation: + SPDX-License-Identifier: MIT + Do NOT use this license unless the files are copied from another work + under the same license. In such cases, use "AND GPL-3.0-or-later" so + that your contributions are under GPL-3.0+ license. +License-Text: + +MIT License + +Copyright (c) <year> <copyright holders> + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE.
\ No newline at end of file diff --git a/vendor/libadb-android/README.md b/vendor/libadb-android/README.md new file mode 100644 index 0000000..815cf46 --- /dev/null +++ b/vendor/libadb-android/README.md @@ -0,0 +1,199 @@ +# LibADB Android + +ADB library for Android. It enables an app to connect to the ADB daemon (`adbd` process) belonging to the same or a +different device and execute arbitrary services or commands (via `shell:` service). + +**Disclaimer:** This library has never gone through a security audit. Please, proceed with caution if security is +crucial for your app. Avoid using the APIs for reasons other than connecting or using ADB. For the safety of your app +and its users, open a remote service instead of using ADB and ask the user to disconnect Wireless debugging. + +## Getting Started +### Adding Dependencies +LibADB Android is available via JitPack. + +```groovy +// Top level build file +repositories { + mavenCentral() + maven { url "https://jitpack.io" } +} + +// Add to dependencies section +dependencies { + // Add this library + implementation 'com.github.MuntashirAkon:libadb-android:3.1.1' + + // Library to generate X509Certificate. You can also use BouncyCastle for + // this. See example for use-case. + // implementation 'com.github.MuntashirAkon:sun-security-android:1.1' + + // Bypass hidden API if you want to use the Android default Conscrypt in + // Android 9 (Pie) or later. It also requires additional steps. See + // https://github.com/LSPosed/AndroidHiddenApiBypass to find out more about + // this. + // implementation 'org.lsposed.hiddenapibypass:hiddenapibypass:6.1' + + // Use custom Conscrypt library. If you want to connect to a remote ADB + // daemon instead of the device the app is currently running or do not want + // to bypass hidden API, this is the recommended choice. + implementation 'org.conscrypt:conscrypt-android:2.5.3' +} +``` + +If you're using the custom Conscrypt library in order to connect to a remote ADB daemon and the app targets Android +version below 4.4, you have to extend `android.app.Application` to apply fixes for the random number generation: +```java +public class MyAwesomeApp extends Application { + @Override + public void onCreate() { + super.onCreate(); + // Fix random number generation in Android versions below 4.4. + PRNGFixes.apply(); + } + + @Override + protected void attachBaseContext(Context base) { + super.attachBaseContext(base); + // Uncomment the following line if you want to bypass hidden API as + // described above. + // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + // HiddenApiBypass.addHiddenApiExemptions("L"); + // } + } +} +``` + +**Notice:** Conscrypt supports only API 9 (Gingerbread) or later, meaning you cannot use ADB pairing or any TLSv1.3 +features in API less than 9. The corresponding methods are already annotated properly. So, you don't have to worry about +compatibility issues that may arise when your app's minimum SDK is set to one of the unsupported versions. + +### Configuring ADB +Instead of doing everything manually, you can create a concrete implementation of the `AbsAdbConnectionManager` class. +Example: + +```java +public class AdbConnectionManager extends AbsAdbConnectionManager { + private static AbsAdbConnectionManager INSTANCE; + + public static AbsAdbConnectionManager getInstance() throws Exception { + if (INSTANCE == null) { + INSTANCE = new AdbConnectionManager(); + } + return INSTANCE; + } + + private PrivateKey mPrivateKey; + private Certificate mCertificate; + + private AdbConnectionManager() throws Exception { + // Set the API version whose `adbd` is running + setApi(Build.VERSION.SDK_INT); + // TODO: Load private key and certificate (along with public key) from + // some place such as KeyStore or file system. + mPrivateKey = ...; + mCertificate = ...; + if (mPrivateKey == null) { + // Generate a new key pair + int keySize = 2048; + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(keySize, SecureRandom.getInstance("SHA1PRNG")); + KeyPair generateKeyPair = keyPairGenerator.generateKeyPair(); + PublicKey publicKey = generateKeyPair.getPublic(); + mPrivateKey = generateKeyPair.getPrivate(); + // Generate a certificate + // On Android, it requires sun.security-android library as mentioned + // above. + String subject = "CN=My Awesome App"; + String algorithmName = "SHA512withRSA"; + long expiryDate = System.currentTimeMillis() + 86400000; + CertificateExtensions certificateExtensions = new CertificateExtensions(); + certificateExtensions.set("SubjectKeyIdentifier", new SubjectKeyIdentifierExtension( + new KeyIdentifier(publicKey).getIdentifier())); + X500Name x500Name = new X500Name(subject); + Date notBefore = new Date(); + Date notAfter = new Date(expiryDate); + certificateExtensions.set("PrivateKeyUsage", new PrivateKeyUsageExtension(notBefore, notAfter)); + CertificateValidity certificateValidity = new CertificateValidity(notBefore, notAfter); + X509CertInfo x509CertInfo = new X509CertInfo(); + x509CertInfo.set("version", new CertificateVersion(2)); + x509CertInfo.set("serialNumber", new CertificateSerialNumber(new Random().nextInt() & Integer.MAX_VALUE)); + x509CertInfo.set("algorithmID", new CertificateAlgorithmId(AlgorithmId.get(algorithmName))); + x509CertInfo.set("subject", new CertificateSubjectName(x500Name)); + x509CertInfo.set("key", new CertificateX509Key(publicKey)); + x509CertInfo.set("validity", certificateValidity); + x509CertInfo.set("issuer", new CertificateIssuerName(x500Name)); + x509CertInfo.set("extensions", certificateExtensions); + X509CertImpl x509CertImpl = new X509CertImpl(x509CertInfo); + x509CertImpl.sign(mPrivateKey, algorithmName); + mCertificate = x509CertImpl; + // TODO: Store the key pair to some place else. + } + } + + @NonNull + @Override + protected PrivateKey getPrivateKey() { + return mPrivateKey; + } + + @NonNull + @Override + protected Certificate getCertificate() { + return mCertificate; + } + + @NonNull + @Override + protected String getDeviceName() { + return "MyAwesomeApp"; + } +} +``` + +### Connecting to ADB + +You can connect to ADB in several ways from the `AbsAdbConnectionManager`: + +| Method | Description | +|---------------------------------|---------------------------------------------------------------------------------------------------------------| +| `connect(host, port)` | Connect using a host address and a port number | +| `connect(port)` | Connect using a host address set by `setHostAddress()` and a port number | +| `connectTcp(Context, timeout)` | (SDK 16+) Discover host address and port number automatically for ADB over TCP and connect to it | +| `connectTls(Context, timeout)` | (SDK 16+) Discover host address and port number automatically for TLS (from Android 9) and connect to it | +| `autoConnect(Context, timeout)` | (SDK 16+) Discover host address and port number automatically for both ADB over TCP and TLS and connect to it | + +### Wireless Debugging +Internally, ADB over TCP and Wireless Debugging are very similar except Wireless Debugging requires an extra step of +_pairing_ the device. In order to pair a new device, you can simply invoke `AdbConnectionManager.getInstance().pair(host, port, pairingCode)`. +After the pairing, you can connect to ADB via the usual `connect()` methods without any additional steps. + +### Opening ADB Shell for Executing Arbitrary Commands +Simply use `AdbConnectionManager.getInstance().openStream("shell:")`. This will return an `AdbStream` which can be used +to read/write to the ADB shell via `AdbStream#openInputStream()` and `AdbStream#openOutputStream()` methods +respectively like a normal Java `Process`. While it is possible to read/write in the same thread (first write and then +read), this is not recommended because the shell might be stuck indefinitely for commands such as `top`. + +**NOTE:** If you want to create a full-featured terminal emulator, this approach isn't recommended. Instead, you should +create a remote service via `app_process` or start an SSH server and connect to it. + +### Other services +You can also use other services via the `AdbConnectionManager#openStream()` methods. See [SERVICES.md](./SERVICES.md) +for more information. + +## For Java (non-Android) Projects +It is possible to modify this library to work on non-Android project. But it isn't supported because Spake2-Java only +provides stable releases for Android. However, you can incorporate this library in your project by manually compiling +Spake2 library for your platforms. + +## Contributing +By contributing to this project, you permit your work to be released under the terms of GNU General Public License, +Version 3 or later **or** Apache License, Version 2.0. + +## License +Copyright 2021 © Muntashir Al-Islam + +Dual licensed under the terms of [GPL-3.0-or-later](https://www.gnu.org/licenses/gpl-3.0.html) or +[Apache-2.0 license](https://www.apache.org/licenses/LICENSE-2.0.html). Use whatever license you need for your project. + +_Note regarding the Apache-2.0 license, this library has an LGPL dependency which may go against the policy of some +organizations such as ASF._ diff --git a/vendor/libadb-android/SERVICES.md b/vendor/libadb-android/SERVICES.md new file mode 100644 index 0000000..dfe9b2b --- /dev/null +++ b/vendor/libadb-android/SERVICES.md @@ -0,0 +1,64 @@ +# Services + +LibADB only supports local services which can be requested through `AbsAdbConnectionManager#openStream(String)` or +`AdbConnection#open(String)`. + +- `shell:command arg1 arg2 ...` + + Run `command arg1 arg2 ...` in a shell on the device, and return its output and error streams. Note that arguments + must be separated by spaces. If an argument contains a space, it must be quoted with double-quotes. Arguments cannot + contain double quotes or things will go very wrong. + +- `shell:` + + Start an interactive shell session on the device. Redirect stdin/stdout/stderr as appropriate. + +- `dev:<path>` + + Opens a device file and connects the client directly to it for read/write purposes. Useful for debugging, but may + require special privileges and thus may not run on all devices. `<path>` is a full path from the root of the + filesystem. + +- `tcp:<port>` + + Tries to connect to tcp port `<port>` on localhost. + +- `tcp:<port>:<server-name>` + + Tries to connect to tcp port `<port>` on machine `<server-name>` from the device. This can be useful to debug some + networking/proxy issues that can only be revealed on the device itself. + +- `local:<path>` + + Tries to connect to a Unix domain socket `<path>` on the device. + +- `localreserved:<path>`/ + `localabstract:<path>`/ + `localfilesystem:<path>` + + Variants of `local:<path>` that are used to access other Android socket namespaces. + +- `sync:` + + This starts the file synchronization service, used to implement "adb push" and "adb pull". Since this service is + pretty complex, it will be detailed in a companion document named SYNC.TXT + +- `reverse:<forward-command>` + + This implements the 'adb reverse' feature, i.e. the ability to reverse socket connections from a device to the host. + `<forward-command>` is one of the forwarding commands that are described above, as in: + + list-forward + forward:<local>;<remote> + forward:norebind:<local>;<remote> + killforward-all + killforward:<local> + + Note that in this case, <local> corresponds to the socket on the device + and <remote> corresponds to the socket on the host. + + The output of reverse:list-forward is the same as host:list-forward + except that <serial> will be just 'host'. + +## Reference +- [SERVICES.TXT](https://android.googlesource.com/platform/packages/modules/adb/+/6a85258511fb13ebbbedba4e36616db4c6e970fb/SERVICES.TXT) diff --git a/vendor/libadb-android/libadb/.gitignore b/vendor/libadb-android/libadb/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/vendor/libadb-android/libadb/.gitignore @@ -0,0 +1 @@ +/build diff --git a/vendor/libadb-android/libadb/build.gradle b/vendor/libadb-android/libadb/build.gradle new file mode 100644 index 0000000..c5bee94 --- /dev/null +++ b/vendor/libadb-android/libadb/build.gradle @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-or-later OR Apache-2.0 + +plugins { + id 'com.android.library' + id 'maven-publish' +} + +group = 'io.github.muntashirakon' +version = '3.1.1' + +android { + compileSdk 35 + namespace "io.github.muntashirakon.adb" + + defaultConfig { + minSdk 1 + targetSdk 35 + aarMetadata { + minCompileSdk = 1 + } + } + + buildTypes { + release { + minifyEnabled false + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + publishing { + singleVariant("release") { + withSourcesJar() + withJavadocJar() + } + } +} + +publishing { + publications { + release(MavenPublication) { + artifactId = 'libadb' + afterEvaluate { + from components.release + } + } + } +} + +dependencies { + implementation "androidx.annotation:annotation:1.9.1" + implementation 'org.bouncycastle:bcprov-jdk15to18:1.81' + implementation 'com.github.MuntashirAkon.spake2-java:spake2-android:2.2.1' + + testImplementation 'junit:junit:4.13.2' +} diff --git a/vendor/libadb-android/libadb/src/main/AndroidManifest.xml b/vendor/libadb-android/libadb/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c9d05a3 --- /dev/null +++ b/vendor/libadb-android/libadb/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ +<!-- SPDX-License-Identifier: GPL-3.0-or-later OR Apache-2.0 --> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + <uses-permission android:name="android.permission.INTERNET" /> +</manifest> diff --git a/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AbsAdbConnectionManager.java b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AbsAdbConnectionManager.java new file mode 100644 index 0000000..967c47a --- /dev/null +++ b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AbsAdbConnectionManager.java @@ -0,0 +1,508 @@ +// SPDX-License-Identifier: GPL-3.0-or-later OR Apache-2.0 + +package io.github.muntashirakon.adb; + +import android.content.Context; +import android.os.Build; + +import androidx.annotation.CallSuper; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.WorkerThread; + +import java.io.Closeable; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import javax.security.auth.DestroyFailedException; + +import io.github.muntashirakon.adb.android.AdbMdns; + +@SuppressWarnings("unused") +public abstract class AbsAdbConnectionManager implements Closeable { + private final Object mLock = new Object(); + @Nullable + private AdbConnection mAdbConnection; + private String mHostAddress = "127.0.0.1"; + private int mApi = Build.VERSION_CODES.BASE; + private long mTimeout = Long.MAX_VALUE; + private TimeUnit mTimeoutUnit = TimeUnit.MILLISECONDS; + private boolean mThrowOnUnauthorised = false; + + /** + * Return generated/stored private key. + */ + @NonNull + protected abstract PrivateKey getPrivateKey(); + + /** + * Return public key wrapped around a certificate. + */ + @NonNull + protected abstract Certificate getCertificate(); + + /** + * Return a name for the device. This can be the app label, hostname or user@hostname. + */ + @NonNull + protected abstract String getDeviceName(); + + /** + * Set host address for this connection. On the same device, this should be {@code 127.0.0.1}. + */ + @CallSuper + public void setHostAddress(@NonNull String hostAddress) { + mHostAddress = Objects.requireNonNull(hostAddress); + } + + /** + * Get host address for this connection. Default value is {@code 127.0.0.1}. + */ + @NonNull + public String getHostAddress() { + return mHostAddress; + } + + /** + * Set Android API (i.e. SDK) version for this connection. If the daemon and the client are located in the same + * directory, the value should be {@link Build.VERSION#SDK_INT} in order to improve performance as well as security. + * + * @param api The API version, default is {@link Build.VERSION_CODES#BASE}. + */ + public void setApi(int api) { + this.mApi = api; + } + + /** + * Get Android API (i.e. SDK) version for this connection. Default value is {@link Build.VERSION_CODES#BASE}. + */ + public int getApi() { + return mApi; + } + + /** + * Set time to wait for the connection to be made. + * + * @param timeout Timeout value + * @param unit Timeout unit + */ + @CallSuper + public void setTimeout(long timeout, TimeUnit unit) { + mTimeout = timeout; + mTimeoutUnit = unit; + } + + /** + * Get time to wait for the connection to be made. If not set using {@link #setTimeout(long, TimeUnit)}, the default + * timeout is {@link Long#MAX_VALUE} milliseconds. + * + * @return Timeout in milliseconds + */ + public long getTimeout() { + return mTimeoutUnit.toMillis(mTimeout); + } + + /** + * Get the unit for the timeout. If not set using {@link #setTimeout(long, TimeUnit)}, the default timeout unit is + * {@link TimeUnit#MILLISECONDS}. + */ + @NonNull + public TimeUnit getTimeoutUnit() { + return mTimeoutUnit; + } + + /** + * Set whether to throw {@link AdbAuthenticationFailedException} if the daemon rejects the first authentication + * attempt. + * + * @param throwOnUnauthorised {@code true} to throw {@link AdbAuthenticationFailedException} or {@code false} + * otherwise. + */ + @CallSuper + public void setThrowOnUnauthorised(boolean throwOnUnauthorised) { + mThrowOnUnauthorised = throwOnUnauthorised; + } + + /** + * Get whether to throw {@link AdbAuthenticationFailedException} if the daemon rejects the first authentication + * attempt. + * + * @return {@code true} if the system is configured to throw {@link AdbAuthenticationFailedException} or + * {@code false} otherwise. The default value is {@code false}. + */ + public boolean isThrowOnUnauthorised() { + return mThrowOnUnauthorised; + } + + /** + * Get the {@link AdbConnection} backed by this object. + * + * @return Underlying {@link AdbConnection}, or {@code null} if the connection hasn't been made yet. + */ + @CallSuper + @Nullable + public AdbConnection getAdbConnection() { + synchronized (mLock) { + return mAdbConnection; + } + } + + /** + * Check if it is connected to an ADB daemon. + * + * @return {@code true} if connected, {@code false} otherwise. + */ + public boolean isConnected() { + synchronized (mLock) { + return mAdbConnection != null && mAdbConnection.isConnected() && mAdbConnection.isConnectionEstablished(); + } + } + + /** + * Attempt to connect to ADB by performing an automatic network discovery of TLS host and port. Host address set by + * {@link #setHostAddress(String)} is ignored. + * + * @param context Application context + * @param timeoutMillis Amount of time spent in searching for a host and a port. + * @return {@code true} if and only if the connection is successful. It returns {@code false} if the connection + * attempt is unsuccessful, or it has already been made. + * @throws IOException If the socket connection could not be made. + * @throws InterruptedException If timeout has reached. + * @throws AdbAuthenticationFailedException If {@link #isThrowOnUnauthorised()} is set to {@code true}, and the ADB + * daemon has rejected the first authentication attempt, which indicates + * that the daemon has not saved the public key from a previous connection. + * @throws AdbPairingRequiredException If ADB lacks pairing + */ + @WorkerThread + @RequiresApi(Build.VERSION_CODES.JELLY_BEAN) + public boolean connectTls(@NonNull Context context, long timeoutMillis) + throws IOException, InterruptedException, AdbPairingRequiredException { + return autoConnect(context, AdbMdns.SERVICE_TYPE_TLS_CONNECT, timeoutMillis); + } + + /** + * Attempt to connect to ADB by performing an automatic network discovery of TCP host and port. Host address set by + * {@link #setHostAddress(String)} is ignored. + * + * @param context Application context + * @param timeoutMillis Amount of time spent in searching for a host and a port. + * @return {@code true} if and only if the connection is successful. It returns {@code false} if the connection + * attempt is unsuccessful, or it has already been made. + * @throws IOException If the socket connection could not be made. + * @throws InterruptedException If timeout has reached. + * @throws AdbAuthenticationFailedException If {@link #isThrowOnUnauthorised()} is set to {@code true}, and the ADB + * daemon has rejected the first authentication attempt, which indicates + * that the daemon has not saved the public key from a previous connection. + * @throws AdbPairingRequiredException If ADB lacks pairing + */ + @WorkerThread + @RequiresApi(Build.VERSION_CODES.JELLY_BEAN) + public boolean connectTcp(@NonNull Context context, long timeoutMillis) + throws IOException, InterruptedException, AdbPairingRequiredException { + return autoConnect(context, AdbMdns.SERVICE_TYPE_ADB, timeoutMillis); + } + + /** + * Attempt to connect to ADB by performing an automatic network discovery of host and port. Host address set by + * {@link #setHostAddress(String)} is ignored. + * + * @param context Application context + * @param timeoutMillis Amount of time spent in searching for a host and a port. + * @return {@code true} if and only if the connection is successful. It returns {@code false} if the connection + * attempt is unsuccessful, or it has already been made. + * @throws IOException If the socket connection could not be made. + * @throws InterruptedException If timeout has reached. + * @throws AdbAuthenticationFailedException If {@link #isThrowOnUnauthorised()} is set to {@code true}, and the ADB + * daemon has rejected the first authentication attempt, which indicates + * that the daemon has not saved the public key from a previous connection. + * @throws AdbPairingRequiredException If ADB lacks pairing + */ + @WorkerThread + @RequiresApi(Build.VERSION_CODES.JELLY_BEAN) + public boolean autoConnect(@NonNull Context context, long timeoutMillis) + throws IOException, InterruptedException, AdbPairingRequiredException { + synchronized (mLock) { + AtomicInteger atomicPort = new AtomicInteger(-1); + AtomicReference<String> atomicHostAddress = new AtomicReference<>(null); + CountDownLatch resolveHostAndPort = new CountDownLatch(1); + + AdbMdns adbMdnsTcp = new AdbMdns(context, AdbMdns.SERVICE_TYPE_ADB, (hostAddress, port) -> { + if (hostAddress != null) { + atomicHostAddress.set(hostAddress.getHostAddress()); + atomicPort.set(port); + } + resolveHostAndPort.countDown(); + }); + adbMdnsTcp.start(); + + AdbMdns adbMdnsTls = new AdbMdns(context, AdbMdns.SERVICE_TYPE_TLS_CONNECT, (hostAddress, port) -> { + if (hostAddress != null) { + atomicHostAddress.set(hostAddress.getHostAddress()); + atomicPort.set(port); + } + resolveHostAndPort.countDown(); + }); + adbMdnsTls.start(); + + try { + if (!resolveHostAndPort.await(timeoutMillis, TimeUnit.MILLISECONDS)) { + throw new InterruptedException("Timed out while trying to find a valid host address and port"); + } + } finally { + adbMdnsTcp.stop(); + adbMdnsTls.stop(); + } + + String host = atomicHostAddress.get(); + int port = atomicPort.get(); + + if (host == null || port == -1) { + throw new IOException("Could not find any valid host address or port"); + } + + mHostAddress = host; + mAdbConnection = new AdbConnection.Builder(host, port) + .setApi(mApi) + .setKeyPair(getAdbKeyPair()) + .setDeviceName(Objects.requireNonNull(getDeviceName())) + .build(); + return mAdbConnection.connect(mTimeout, mTimeoutUnit, mThrowOnUnauthorised); + } + } + + @WorkerThread + @RequiresApi(Build.VERSION_CODES.JELLY_BEAN) + private boolean autoConnect(@NonNull Context context, @AdbMdns.ServiceType @NonNull String serviceType, long timeoutMillis) + throws IOException, InterruptedException, AdbPairingRequiredException { + synchronized (mLock) { + AtomicInteger atomicPort = new AtomicInteger(-1); + AtomicReference<String> atomicHostAddress = new AtomicReference<>(null); + CountDownLatch resolveHostAndPort = new CountDownLatch(1); + + AdbMdns adbMdns = new AdbMdns(context, serviceType, (hostAddress, port) -> { + if (hostAddress != null) { + atomicHostAddress.set(hostAddress.getHostAddress()); + atomicPort.set(port); + } + resolveHostAndPort.countDown(); + }); + adbMdns.start(); + + try { + if (!resolveHostAndPort.await(timeoutMillis, TimeUnit.MILLISECONDS)) { + throw new InterruptedException("Timed out while trying to find a valid host address and port"); + } + } finally { + adbMdns.stop(); + } + + String host = atomicHostAddress.get(); + int port = atomicPort.get(); + + if (host == null || port == -1) { + throw new IOException("Could not find any valid host address or port"); + } + + mHostAddress = host; + mAdbConnection = new AdbConnection.Builder(host, port) + .setApi(mApi) + .setKeyPair(getAdbKeyPair()) + .setDeviceName(Objects.requireNonNull(getDeviceName())) + .build(); + return mAdbConnection.connect(mTimeout, mTimeoutUnit, mThrowOnUnauthorised); + } + } + + /** + * Attempt to connect to ADB given a port number. Host address is set via {@link #setHostAddress(String)}. + * + * @param port Port number + * @return {@code true} if and only if the connection is successful. It returns {@code false} if the connection + * attempt is unsuccessful, or it has already been made. + * @throws IOException If the socket connection could not be made. + * @throws InterruptedException If timeout has reached. + * @throws AdbAuthenticationFailedException If {@link #isThrowOnUnauthorised()} is set to {@code true}, and the ADB + * daemon has rejected the first authentication attempt, which indicates + * that the daemon has not saved the public key from a previous connection. + * @throws AdbPairingRequiredException If ADB lacks pairing + */ + @WorkerThread + public boolean connect(int port) throws IOException, InterruptedException, AdbPairingRequiredException { + synchronized (mLock) { + if (isConnected()) { + return false; + } + mAdbConnection = new AdbConnection.Builder(mHostAddress, port) + .setApi(mApi) + .setKeyPair(getAdbKeyPair()) + .setDeviceName(Objects.requireNonNull(getDeviceName())) + .build(); + return mAdbConnection.connect(mTimeout, mTimeoutUnit, mThrowOnUnauthorised); + } + } + + /** + * Attempt to connect to ADB via a host address and a port number. + * + * @param host Host address to use instead of taking it from the {@link #getHostAddress()} + * @param port Port number + * @return {@code true} if and only if the connection is successful. It returns {@code false} if the connection + * attempt is unsuccessful, or it has already been made. + * @throws IOException If the socket connection could not be made. + * @throws InterruptedException If timeout has reached. + * @throws AdbAuthenticationFailedException If {@link #isThrowOnUnauthorised()} is set to {@code true}, and the + * ADB daemon has rejected the first authentication attempt, which + * indicates that the daemon has not saved the public key from a previous + * connection. + * @throws AdbPairingRequiredException If ADB lacks pairing + */ + @WorkerThread + public boolean connect(@NonNull String host, int port) + throws IOException, InterruptedException, AdbPairingRequiredException { + synchronized (mLock) { + if (isConnected()) { + return false; + } + mHostAddress = host; + mAdbConnection = new AdbConnection.Builder(host, port) + .setApi(mApi) + .setKeyPair(getAdbKeyPair()) + .setDeviceName(Objects.requireNonNull(getDeviceName())) + .build(); + return mAdbConnection.connect(mTimeout, mTimeoutUnit, mThrowOnUnauthorised); + } + } + + /** + * Disconnect the underlying {@link AdbConnection}. + * + * @throws IOException If the underlying socket fails to close + */ + public void disconnect() throws IOException { + synchronized (mLock) { + if (mAdbConnection != null) { + mAdbConnection.close(); + mAdbConnection = null; + } + } + } + + /** + * Opens an {@link AdbStream} object corresponding to the specified destination. + * This routine will block until the connection completes. + * + * @param destination The destination to open on the target + * @return {@link AdbStream} object corresponding to the specified destination + * @throws IOException If the steam fails or no connection has been made + * @throws InterruptedException If the stream fails while sending the packet + * @throws UnsupportedEncodingException If the destination cannot be encoded to UTF-8. + */ + @WorkerThread + @NonNull + public AdbStream openStream(String destination) throws IOException, InterruptedException { + synchronized (mLock) { + if (mAdbConnection != null && mAdbConnection.isConnected()) { + try { + return mAdbConnection.open(destination); + } catch (AdbPairingRequiredException e) { + throw new IllegalStateException(e); + } + } + throw new IOException("Not connected to ADB."); + } + } + + /** + * Opens an {@link AdbStream} object corresponding to the specified destination. + * This routine will block until the connection completes. + * + * @param service The service to open. One of the services under {@link LocalServices.Services}. + * @param args Additional arguments supported by the service (see the corresponding constant to learn more). + * @return AdbStream object corresponding to the specified destination + * @throws UnsupportedEncodingException If the destination cannot be encoded to UTF-8 + * @throws IOException If the stream fails while sending the packet + * @throws InterruptedException If we are unable to wait for the connection to finish + */ + @NonNull + public AdbStream openStream(@LocalServices.Services int service, @NonNull String... args) + throws IOException, InterruptedException { + synchronized (mLock) { + if (mAdbConnection != null && mAdbConnection.isConnected()) { + try { + return mAdbConnection.open(service, args); + } catch (AdbPairingRequiredException e) { + throw new IllegalStateException(e); + } + } + throw new IOException("Not connected to ADB."); + } + } + + /** + * Pair with an ADB daemon given port number and pairing code. + * + * @param port Port number + * @param pairingCode The six-digit pairing code as string + * @return {@code true} if the pairing is successful and {@code false} otherwise. + * @throws Exception If pairing failed for some reason. + */ + @WorkerThread + @RequiresApi(Build.VERSION_CODES.GINGERBREAD) + public boolean pair(int port, @NonNull String pairingCode) throws Exception { + return pair(mHostAddress, port, pairingCode); + } + + /** + * Pair with an ADB daemon given host address, port number and pairing code. + * + * @param host Host address to use instead of taking it from the {@link #getHostAddress()} + * @param port Port number + * @param pairingCode The six-digit pairing code as string + * @return {@code true} if the pairing is successful and {@code false} otherwise. + * @throws Exception If pairing failed for some reason. + */ + @WorkerThread + @RequiresApi(Build.VERSION_CODES.GINGERBREAD) + public boolean pair(@NonNull String host, int port, @NonNull String pairingCode) throws Exception { + synchronized (mLock) { + KeyPair keyPair = getAdbKeyPair(); + try (PairingConnectionCtx pairingClient = new PairingConnectionCtx(Objects.requireNonNull(host), port, + StringCompat.getBytes(Objects.requireNonNull(pairingCode), "UTF-8"), keyPair, getDeviceName())) { + // TODO: 5/12/21 Return true/false instead of only exceptions + pairingClient.start(); + } + return true; + } + } + + /** + * Close the underlying {@link AdbConnection} and destroy the private key. + * + * @throws IOException If socket fails to close. + */ + @Override + public void close() throws IOException { + try { + getPrivateKey().destroy(); + } catch (DestroyFailedException | NoSuchMethodError e) { + e.printStackTrace(); + } + if (mAdbConnection != null) { + mAdbConnection.close(); + mAdbConnection = null; + } + } + + @NonNull + private KeyPair getAdbKeyPair() { + return new KeyPair(Objects.requireNonNull(getPrivateKey()), Objects.requireNonNull(getCertificate())); + } +} diff --git a/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbAuthenticationFailedException.java b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbAuthenticationFailedException.java new file mode 100644 index 0000000..bf50eeb --- /dev/null +++ b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbAuthenticationFailedException.java @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: BSD-3-Clause AND (GPL-3.0-or-later OR Apache-2.0) + +package io.github.muntashirakon.adb; + +/** + * Thrown when the ADB daemon rejects our initial authentication attempt, which typically means that the peer has not + * previously saved our public key. + */ +// Copyright 2020 Sam Palmer +public class AdbAuthenticationFailedException extends RuntimeException { + public AdbAuthenticationFailedException() { + super("Initial authentication attempt rejected by peer."); + } +} diff --git a/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbConnection.java b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbConnection.java new file mode 100644 index 0000000..a21cb66 --- /dev/null +++ b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbConnection.java @@ -0,0 +1,749 @@ +// SPDX-License-Identifier: BSD-3-Clause AND (GPL-3.0-or-later OR Apache-2.0) + +package io.github.muntashirakon.adb; + +import android.os.Build; +import android.util.Log; + +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.ConnectException; +import java.net.Socket; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.interfaces.RSAPublicKey; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import javax.security.auth.DestroyFailedException; + +/** + * This class represents an ADB connection. + */ +// Copyright 2013 Cameron Gutman +public class AdbConnection implements Closeable { + public static final String TAG = AdbConnection.class.getSimpleName(); + + /** + * The underlying socket that this class uses to communicate with the target device. + */ + @NonNull + private final Socket mSocket; + + @NonNull + private final String mHost; + + private final int mPort; + + private final int mApi; + + /** + * The last allocated local stream ID. The ID chosen for the next stream will be this value + 1. + */ + private int mLastLocalId; + + /** + * The input stream that this class uses to read from the socket. + */ + @GuardedBy("lock") + @NonNull + private final InputStream mPlainInputStream; + + /** + * The output stream that this class uses to read from the socket. + */ + @GuardedBy("lock") + @NonNull + private final OutputStream mPlainOutputStream; + + /** + * The input stream that this class uses to read from the TLS socket. + */ + @GuardedBy("lock") + @Nullable + private volatile InputStream mTlsInputStream; + + /** + * The output stream that this class uses to read from the TLS socket. + */ + @GuardedBy("lock") + @Nullable + private volatile OutputStream mTlsOutputStream; + + /** + * The backend thread that handles responding to ADB packets. + */ + @NonNull + private final Thread mConnectionThread; + + /** + * Specifies whether a CNXN has been attempted. + */ + private volatile boolean mConnectAttempted; + + /** + * Whether the connection thread should give up if the first authentication attempt fails. + */ + private volatile boolean mAbortOnUnauthorised; + + /** + * Whether the first authentication attempt failed and {@link #mAbortOnUnauthorised} was {@code true}. + */ + private volatile boolean mAuthorisationFailed; + + /** + * Specifies whether a CNXN packet has been received from the peer. + */ + private volatile boolean mConnectionEstablished; + + /** + * Exceptions that occur in {@link #createConnectionThread()}. + */ + @Nullable + private volatile Exception mConnectionException; + + /** + * Specifies the maximum amount data that can be sent to the remote peer. + * This is only valid after connect() returns successfully. + */ + private volatile int mMaxData; + + private volatile int mProtocolVersion; + + @NonNull + private final KeyPair mKeyPair; + + @NonNull + private volatile String mDeviceName = "Unknown Device"; + + /** + * Specifies whether this connection has already sent a signed token. + */ + private volatile boolean mSentSignature; + + /** + * A hash map of our opened streams indexed by local ID. + */ + @NonNull + private final ConcurrentHashMap<Integer, AdbStream> mOpenedStreams; + + private volatile boolean mIsTls = false; + + @GuardedBy("lock") + @NonNull + private final Object mLock = new Object(); + + /** + * Creates a AdbConnection object associated with the socket and crypto object specified. + * + * @return A new AdbConnection object. + * @throws IOException If there is a socket error + */ + @WorkerThread + @NonNull + public static AdbConnection create(@NonNull String host, int port, @NonNull PrivateKey privateKey, + @NonNull Certificate certificate) + throws IOException { + return create(host, port, privateKey, certificate, Build.VERSION_CODES.BASE); + } + + /** + * Creates a AdbConnection object associated with the socket and crypto object specified. + * + * @return A new AdbConnection object. + * @throws IOException If there is a socket error + */ + @WorkerThread + @NonNull + public static AdbConnection create(@NonNull String host, int port, @NonNull PrivateKey privateKey, + @NonNull Certificate certificate, int api) + throws IOException { + return create(host, port, new KeyPair(Objects.requireNonNull(privateKey), Objects.requireNonNull(certificate)), + api); + } + + /** + * Creates a AdbConnection object associated with the socket and crypto object specified. + * + * @return A new AdbConnection object. + * @throws IOException If there is a socket error + */ + @WorkerThread + @NonNull + static AdbConnection create(@NonNull String host, int port, @NonNull KeyPair keyPair, int api) throws IOException { + return new AdbConnection(host, port, keyPair, api); + } + + /** + * Internal constructor to initialize some internal state + */ + @WorkerThread + private AdbConnection(@NonNull String host, int port, @NonNull KeyPair keyPair, int api) throws IOException { + this.mHost = Objects.requireNonNull(host); + this.mPort = port; + this.mApi = api; + this.mProtocolVersion = AdbProtocol.getProtocolVersion(mApi); + this.mMaxData = AdbProtocol.getMaxData(api); + this.mKeyPair = Objects.requireNonNull(keyPair); + try { + this.mSocket = new Socket(host, port); + } catch (Throwable th) { + //noinspection UnnecessaryInitCause + throw (IOException) new IOException().initCause(th); + } + this.mPlainInputStream = mSocket.getInputStream(); + this.mPlainOutputStream = mSocket.getOutputStream(); + + // Disable Nagle because we're sending tiny packets + mSocket.setTcpNoDelay(true); + + this.mOpenedStreams = new ConcurrentHashMap<>(); + this.mLastLocalId = 0; + this.mConnectionThread = createConnectionThread(); + } + + @GuardedBy("lock") + @NonNull + private InputStream getInputStream() { + return mIsTls ? Objects.requireNonNull(mTlsInputStream) : mPlainInputStream; + } + + @GuardedBy("lock") + @NonNull + private OutputStream getOutputStream() { + return mIsTls ? Objects.requireNonNull(mTlsOutputStream) : mPlainOutputStream; + } + + /** + * Creates a new connection thread. + * + * @return A new connection thread. + */ + @NonNull + private Thread createConnectionThread() { + return new Thread(() -> { + loop: + while (!mConnectionThread.isInterrupted()) { + try { + // Read and parse a message off the socket's input stream + AdbProtocol.Message msg = AdbProtocol.Message.parse(getInputStream(), mProtocolVersion, mMaxData); + + switch (msg.command) { + // Stream-oriented commands + case AdbProtocol.A_OKAY: + case AdbProtocol.A_WRTE: + case AdbProtocol.A_CLSE: { + // Ignore all packets when not connected + if (!mConnectionEstablished) { + continue; + } + + // Get the stream object corresponding to the packet + AdbStream waitingStream = mOpenedStreams.get(msg.arg1); + if (waitingStream == null) { + continue; + } + + synchronized (waitingStream) { + if (msg.command == AdbProtocol.A_OKAY) { + // We're ready for writes + waitingStream.updateRemoteId(msg.arg0); + waitingStream.readyForWrite(); + + // Notify an open/write + waitingStream.notify(); + } else if (msg.command == AdbProtocol.A_WRTE) { + // Got some data from our partner + waitingStream.addPayload(msg.payload); + + // Tell it we're ready for more + waitingStream.sendReady(); + } else { // if (msg.command == AdbProtocol.A_CLSE) { + mOpenedStreams.remove(msg.arg1); + // Notify readers and writers + waitingStream.notifyClose(true); + } + } + break; + } + case AdbProtocol.A_STLS: { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD) { + sendPacket(AdbProtocol.generateStls()); + + SSLContext sslContext = SslUtils.getSslContext(mKeyPair); + SSLSocket tlsSocket = (SSLSocket) sslContext.getSocketFactory() + .createSocket(mSocket, mHost, mPort, true); + tlsSocket.startHandshake(); + Log.d(TAG, "Handshake succeeded."); + + synchronized (AdbConnection.this) { + mTlsInputStream = tlsSocket.getInputStream(); + mTlsOutputStream = tlsSocket.getOutputStream(); + mIsTls = true; + } + } + break; + } + case AdbProtocol.A_AUTH: { + if (mIsTls) { + break; + } + if (msg.arg0 != AdbProtocol.ADB_AUTH_TOKEN) { + break; + } + byte[] packet; + // This is an authentication challenge + if (mSentSignature) { + if (mAbortOnUnauthorised) { + mAuthorisationFailed = true; + break loop; + } + + // We've already tried our signature, so send our public key + packet = AdbProtocol.generateAuth(AdbProtocol.ADB_AUTH_RSAPUBLICKEY, AndroidPubkey + .encodeWithName((RSAPublicKey) mKeyPair.getPublicKey(), mDeviceName)); + } else { + // Sign the token + packet = AdbProtocol.generateAuth(AdbProtocol.ADB_AUTH_SIGNATURE, AndroidPubkey + .adbAuthSign(mKeyPair.getPrivateKey(), msg.payload)); + mSentSignature = true; + } + + // Write the AUTH reply + sendPacket(packet); + break; + } + case AdbProtocol.A_CNXN: { + synchronized (AdbConnection.this) { + mProtocolVersion = msg.arg0; + mMaxData = msg.arg1; + mConnectionEstablished = true; + AdbConnection.this.notifyAll(); + } + break; + } + case AdbProtocol.A_OPEN: + case AdbProtocol.A_SYNC: + default: + Log.e(TAG, String.format("Unrecognized command = 0x%x", msg.command)); + // Unrecognized packet, just drop it + break; + } + } catch (Exception e) { + mConnectionException = e; + e.printStackTrace(); + // The cleanup is taken care of by a combination of this thread and close() + break; + } + } + + // This thread takes care of cleaning up pending streams + synchronized (AdbConnection.this) { + cleanupStreams(); + AdbConnection.this.notifyAll(); + mConnectionEstablished = false; + mConnectAttempted = false; + } + }); + } + + /** + * Set a name for the device. Default is “Unknown Device”. + * + * @param deviceName Name of the device, could be the app label, hostname or user@hostname. + */ + public void setDeviceName(@NonNull String deviceName) { + this.mDeviceName = Objects.requireNonNull(deviceName); + } + + /** + * Get the version of the ADB protocol supported by the ADB daemon. The result may depend on the API version + * specified and whether the connection has been established. In API 29 (Android 9) or later, the daemon returns + * {@link AdbProtocol#A_VERSION_SKIP_CHECKSUM} regardless of the protocol used to create the connection. So, if + * {@link #mApi} is set to API 28 or earlier but the OS version is Android 9 or later, before establishing the + * connection, it returns {@link AdbProtocol#A_VERSION_MIN}, and after establishing the connection, it returns + * {@link AdbProtocol#A_VERSION_SKIP_CHECKSUM}. In other cases, it always returns {@link AdbProtocol#A_VERSION_MIN}. + * + * @see #isConnectionEstablished() + */ + public int getProtocolVersion() { + return mProtocolVersion; + } + + /** + * Get the max data size supported by the ADB daemon. A connection have to be attempted before calling this method + * and shall be blocked if the connection is in progress. + * + * @return The maximum data size indicated in the CONNECT packet. + * @throws InterruptedException If a connection cannot be waited on. + * @throws IOException if the connection fails. + * @throws AdbPairingRequiredException If ADB lacks pairing + */ + public int getMaxData() throws InterruptedException, IOException, AdbPairingRequiredException { + if (!mConnectAttempted) { + throw new IllegalStateException("connect() must be called first"); + } + + waitForConnection(Long.MAX_VALUE, TimeUnit.MILLISECONDS); + + return mMaxData; + } + + /** + * Whether a connection has been established. A connection has been established if a CONNECT request has been + * received from the ADB daemon. + */ + public boolean isConnectionEstablished() { + return mConnectionEstablished; + } + + /** + * Whether the underlying socket is connected to an ADB daemon and is not in a closed state. + */ + public boolean isConnected() { + return !mSocket.isClosed() && mSocket.isConnected(); + } + + /** + * Same as {@link #connect(long, TimeUnit, boolean)} without throwing anything if the first authentication attempt + * fails. + * + * @return {@code true} if the connection was established, or {@code false} if the connection timed out + * @throws IOException If the socket fails while connecting + * @throws InterruptedException If timeout has reached + * @throws AdbPairingRequiredException If ADB lacks pairing + */ + public boolean connect() throws IOException, InterruptedException, AdbPairingRequiredException { + return connect(Long.MAX_VALUE, TimeUnit.MILLISECONDS, false); + } + + /** + * Connects to the remote device. This routine will block until the connection completes or the timeout elapses. + * + * @param timeout the time to wait for the lock + * @param unit the time unit of the timeout argument + * @param throwOnUnauthorised Whether to throw an {@link AdbAuthenticationFailedException} + * if the peer rejects out first authentication attempt + * @return {@code true} if the connection was established, or {@code false} if the connection timed out + * @throws IOException If the socket fails while connecting + * @throws InterruptedException If timeout has reached + * @throws AdbAuthenticationFailedException If {@code throwOnUnauthorised} is {@code true} and the peer rejects the + * first authentication attempt, which indicates that the peer has not + * saved the public key from a previous connection + * @throws AdbPairingRequiredException If ADB lacks pairing + */ + public boolean connect(long timeout, @NonNull TimeUnit unit, boolean throwOnUnauthorised) + throws IOException, InterruptedException, AdbAuthenticationFailedException, AdbPairingRequiredException { + if (mConnectionEstablished) { + throw new IllegalStateException("Already connected"); + } + + // Send CONNECT + sendPacket(AdbProtocol.generateConnect(mApi)); + + // Start the connection thread to respond to the peer + mConnectAttempted = true; + mAbortOnUnauthorised = throwOnUnauthorised; + mAuthorisationFailed = false; + mConnectionThread.start(); + + return waitForConnection(timeout, Objects.requireNonNull(unit)); + } + + /** + * Opens an {@link AdbStream} object corresponding to the specified destination. + * This routine will block until the connection completes. + * + * @param service The service to open. One of the services under {@link LocalServices.Services}. + * @param args Additional arguments supported by the service (see the corresponding constant to learn more). + * @return AdbStream object corresponding to the specified destination + * @throws UnsupportedEncodingException If the destination cannot be encoded to UTF-8 + * @throws IOException If the stream fails while sending the packet + * @throws InterruptedException If we are unable to wait for the connection to finish + * @throws AdbPairingRequiredException If ADB lacks pairing + */ + @NonNull + public AdbStream open(@LocalServices.Services int service, @NonNull String... args) + throws IOException, InterruptedException, AdbPairingRequiredException { + if (service < LocalServices.SERVICE_FIRST || service > LocalServices.SERVICE_LAST) { + throw new IllegalArgumentException("Invalid service: " + service); + } + return open(LocalServices.getDestination(service, args)); + } + + /** + * Opens an AdbStream object corresponding to the specified destination. + * This routine will block until the connection completes. + * + * @param destination The destination to open on the target + * @return AdbStream object corresponding to the specified destination + * @throws UnsupportedEncodingException If the destination cannot be encoded to UTF-8 + * @throws IOException If the stream fails while sending the packet + * @throws InterruptedException If we are unable to wait for the connection to finish + * @throws AdbPairingRequiredException If ADB lacks pairing + */ + @NonNull + public AdbStream open(@NonNull String destination) + throws IOException, InterruptedException, AdbPairingRequiredException { + int localId = ++mLastLocalId; + + if (!mConnectAttempted) { + throw new IllegalStateException("connect() must be called first"); + } + + waitForConnection(Long.MAX_VALUE, TimeUnit.MILLISECONDS); + + // Add this stream to this list of half-open streams + AdbStream stream = new AdbStream(this, localId); + mOpenedStreams.put(localId, stream); + + // Send OPEN + sendPacket(AdbProtocol.generateOpen(localId, Objects.requireNonNull(destination))); + + // Wait for the connection thread to receive the OKAY + synchronized (stream) { + stream.wait(); + } + + // Check if the OPEN request was rejected + if (stream.isClosed()) { + mOpenedStreams.remove(localId); + throw new ConnectException("Stream open actively rejected by remote peer."); + } + + return stream; + } + + private boolean waitForConnection(long timeout, @NonNull TimeUnit unit) + throws InterruptedException, IOException, AdbPairingRequiredException { + synchronized (this) { + // Block if a connection is pending, but not yet complete + long timeoutEndMillis = System.currentTimeMillis() + Objects.requireNonNull(unit).toMillis(timeout); + while (!mConnectionEstablished && mConnectAttempted && timeoutEndMillis - System.currentTimeMillis() > 0) { + wait(timeoutEndMillis - System.currentTimeMillis()); + } + + if (!mConnectionEstablished) { + if (mConnectAttempted) { + return false; + } else if (mAuthorisationFailed) { + // The peer may not have saved the public key in the past connections, or they've been removed. + throw new AdbAuthenticationFailedException(); + } else { + Exception connectionException = mConnectionException; + if (connectionException != null) { + if (connectionException instanceof javax.net.ssl.SSLProtocolException) { + String message = connectionException.getMessage(); + if (message != null && message.contains("protocol error")) { + throw (AdbPairingRequiredException) (new AdbPairingRequiredException("ADB pairing is required.").initCause(connectionException)); + } + } + } + throw new IOException("Connection failed"); + } + } + } + + return true; + } + + /** + * This function terminates all I/O on streams associated with this ADB connection + */ + private void cleanupStreams() { + // Close all streams on this connection + for (AdbStream s : mOpenedStreams.values()) { + try { + s.close(); + } catch (IOException ignored) { + } + } + mOpenedStreams.clear(); + } + + /** + * This routine closes the Adb connection and underlying socket + * + * @throws IOException if the socket fails to close + */ + @Override + public void close() throws IOException { + // Closing the socket will kick the connection thread + mSocket.close(); + + // Wait for the connection thread to die + mConnectionThread.interrupt(); + try { + mConnectionThread.join(); + } catch (InterruptedException ignored) { + } + + // Destroy keypair + try { + mKeyPair.destroy(); + } catch (DestroyFailedException ignore) { + } + } + + void sendPacket(byte[] packet) throws IOException { + synchronized (mLock) { + OutputStream os = getOutputStream(); + os.write(packet); + os.flush(); + } + } + + void flushPacket() throws IOException { + synchronized (mLock) { + getOutputStream().flush(); + } + } + + public static class Builder { + private String mHost = "127.0.0.1"; + private int mPort = 5555; + private int mApi = Build.VERSION_CODES.BASE; + private PrivateKey mPrivateKey; + private Certificate mCertificate; + private KeyPair mKeyPair; + private String mDeviceName; + + public Builder() { + } + + public Builder(String host, int port) { + mHost = host; + mPort = port; + } + + /** + * Set host address. Default is 127.0.0.1 + */ + public Builder setHost(String host) { + this.mHost = host; + return this; + } + + /** + * Set port number. Default is 5555. + */ + public Builder setPort(int port) { + this.mPort = port; + return this; + } + + /** + * Set a name for the device. Default is “Unknown Device”. + * + * @param deviceName Name of the device, could be the app label, hostname or user@hostname. + */ + public Builder setDeviceName(String deviceName) { + this.mDeviceName = deviceName; + return this; + } + + /** + * Set Android API (i.e. SDK) version for this connection. If the ADB daemon and the client are located in the + * same device, the value should be {@link Build.VERSION#SDK_INT} in order to improve performance as well as + * security. + * + * @param api The API version, default is {@link Build.VERSION_CODES#BASE}. + */ + public Builder setApi(int api) { + this.mApi = api; + return this; + } + + /** + * Set generated/stored private key. + */ + public Builder setPrivateKey(PrivateKey privateKey) { + this.mPrivateKey = privateKey; + return this; + } + + /** + * Set public key wrapped around a certificate + */ + public Builder setCertificate(Certificate certificate) { + this.mCertificate = certificate; + return this; + } + + Builder setKeyPair(KeyPair keyPair) { + this.mKeyPair = keyPair; + return this; + } + + /** + * Creates a new {@link AdbConnection} associated with the socket and crypto object specified. + * + * @throws IOException If there was an error while establishing a socket connection + */ + public AdbConnection build() throws IOException { + if (mKeyPair == null) { + if (mPrivateKey == null || mCertificate == null) { + throw new UnsupportedOperationException("Private key and certificate must be set."); + } + mKeyPair = new KeyPair(mPrivateKey, mCertificate); + } + AdbConnection adbConnection = create(mHost, mPort, mKeyPair, mApi); + if (mDeviceName != null) { + adbConnection.setDeviceName(mDeviceName); + } + return adbConnection; + } + + /** + * Same as {@link #connect(long, TimeUnit, boolean)} without throwing anything if the first authentication + * attempt fails. + * + * @return The underlying {@link AdbConnection} + * @throws IOException If the socket fails while connecting + * @throws InterruptedException If timeout has reached + * @throws AdbPairingRequiredException If ADB lacks pairing + */ + public AdbConnection connect() throws IOException, InterruptedException, AdbPairingRequiredException { + AdbConnection adbConnection = build(); + if (adbConnection.connect()) { + throw new IOException("Unable to establish a new connection."); + } + return adbConnection; + } + + /** + * Connects to the remote device. This routine will block until the connection completes or the timeout elapses. + * + * @param timeout the time to wait for the lock + * @param unit the time unit of the timeout argument + * @param throwOnUnauthorised Whether to throw an {@link AdbAuthenticationFailedException} + * if the peer rejects out first authentication attempt + * @return {@code true} if the connection was established, or {@code false} if the connection timed out + * @throws IOException If the socket fails while connecting + * @throws InterruptedException If timeout has reached + * @throws AdbAuthenticationFailedException If {@code throwOnUnauthorised} is {@code true} and the peer rejects + * the first authentication attempt, which indicates that the peer has + * not saved the public key from a previous connection + * @throws AdbPairingRequiredException If ADB lacks pairing + */ + public AdbConnection connect(long timeout, @NonNull TimeUnit unit, boolean throwOnUnauthorised) + throws IOException, InterruptedException, AdbPairingRequiredException { + AdbConnection adbConnection = build(); + if (adbConnection.connect(timeout, unit, throwOnUnauthorised)) { + throw new IOException("Unable to establish a new connection."); + } + return adbConnection; + } + } +} diff --git a/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbInputStream.java b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbInputStream.java new file mode 100644 index 0000000..6d0676f --- /dev/null +++ b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbInputStream.java @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-or-later OR Apache-2.0 + +package io.github.muntashirakon.adb; + +import java.io.IOException; +import java.io.InputStream; + +public class AdbInputStream extends InputStream { + public AdbStream mAdbStream; + + public AdbInputStream(AdbStream adbStream) { + this.mAdbStream = adbStream; + } + + @Override + public int read() throws IOException { + byte[] bytes = new byte[1]; + if (read(bytes) == -1) { + return -1; + } + return bytes[0]; + } + + @Override + public int read(byte[] b) throws IOException { + return read(b, 0, b.length); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + if (mAdbStream.isClosed()) return -1; + return mAdbStream.read(b, off, len); + } + + @Override + public void close() { + } + + @Override + public int available() throws IOException { + return mAdbStream.available(); + } +} diff --git a/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbOutputStream.java b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbOutputStream.java new file mode 100644 index 0000000..1894735 --- /dev/null +++ b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbOutputStream.java @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later OR Apache-2.0 + +package io.github.muntashirakon.adb; + +import java.io.IOException; +import java.io.OutputStream; + +public class AdbOutputStream extends OutputStream { + private final AdbStream mAdbStream; + + public AdbOutputStream(AdbStream adbStream) { + this.mAdbStream = adbStream; + } + + @Override + public void write(int b) throws IOException { + write(new byte[]{(byte) (b & 0xFF)}); + } + + @Override + public void write(byte[] b) throws IOException { + write(b, 0, b.length); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + mAdbStream.write(b, off, len); + } + + @Override + public void flush() throws IOException { + mAdbStream.flush(); + } + + @Override + public void close() throws IOException { + flush(); + } +} diff --git a/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbPairingRequiredException.java b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbPairingRequiredException.java new file mode 100644 index 0000000..f324cb0 --- /dev/null +++ b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbPairingRequiredException.java @@ -0,0 +1,7 @@ +package io.github.muntashirakon.adb; + +public class AdbPairingRequiredException extends Exception { + public AdbPairingRequiredException(String message) { + super(message); + } +} diff --git a/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbProtocol.java b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbProtocol.java new file mode 100644 index 0000000..4c58206 --- /dev/null +++ b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbProtocol.java @@ -0,0 +1,497 @@ +// SPDX-License-Identifier: BSD-3-Clause AND (GPL-3.0-or-later OR Apache-2.0) + +package io.github.muntashirakon.adb; + +import android.os.Build; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.io.StreamCorruptedException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; + +/** + * This class provides useful functions and fields for ADB protocol details. + */ +// Copyright 2013 Cameron Gutman +final class AdbProtocol { + /** + * The length of the ADB message header + */ + public static final int ADB_HEADER_LENGTH = 24; + + /** + * SYNC(online, sequence, "") + * + * @deprecated Obsolete, no longer used. Never used on the client side. + */ + public static final int A_SYNC = 0x434e5953; + + /** + * CNXN is the connect message. No messages (except AUTH) are valid before this message is received. + */ + public static final int A_CNXN = 0x4e584e43; + + /** + * The payload sent with the CONNECT message. + */ + public static final byte[] SYSTEM_IDENTITY_STRING_HOST = StringCompat.getBytes("host::\0", "UTF-8"); + + /** + * AUTH is the authentication message. It is part of the RSA public key authentication added in Android 4.2.2 + * ({@link Build.VERSION_CODES#JELLY_BEAN_MR1}). + */ + public static final int A_AUTH = 0x48545541; + + /** + * OPEN is the open stream message. It is sent to open a new stream on the target device. + */ + public static final int A_OPEN = 0x4e45504f; + + /** + * OKAY is a success message. It is sent when a write is processed successfully. + */ + public static final int A_OKAY = 0x59414b4f; + + /** + * CLSE is the close stream message. It is sent to close an existing stream on the target device. + */ + public static final int A_CLSE = 0x45534c43; + + /** + * WRTE is the write stream message. It is sent with a payload that is the data to write to the stream. + */ + public static final int A_WRTE = 0x45545257; + + /** + * STLS is the Stream-based TLS1.3 authentication method, added in Android 9 ({@link Build.VERSION_CODES#P}). + */ + public static final int A_STLS = 0x534c5453; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({A_SYNC, A_CNXN, A_OPEN, A_OKAY, A_CLSE, A_WRTE, A_AUTH, A_STLS}) + private @interface Command { + } + + /** + * Original payload size + */ + public static final int MAX_PAYLOAD_V1 = 4 * 1024; + /** + * Supported payload size since Android 7 (N) + */ + public static final int MAX_PAYLOAD_V2 = 256 * 1024; + /** + * Supported payload size since Android 9 (P) + */ + public static final int MAX_PAYLOAD_V3 = 1024 * 1024; + /** + * Maximum supported payload size is set to the original to support all APIs + */ + public static final int MAX_PAYLOAD = MAX_PAYLOAD_V1; + + /** + * The original version of the ADB protocol + */ + public static final int A_VERSION_MIN = 0x01000000; + /** + * The new version of the ADB protocol introduced in Android 9 (P) with the introduction of TLS + */ + public static final int A_VERSION_SKIP_CHECKSUM = 0x01000001; + public static final int A_VERSION = A_VERSION_MIN; + + /** + * The current version of the Stream-based TLS + */ + public static final int A_STLS_VERSION_MIN = 0x01000000; + public static final int A_STLS_VERSION = A_STLS_VERSION_MIN; + + /** + * This authentication type represents a SHA1 hash to sign. + */ + public static final int ADB_AUTH_TOKEN = 1; + + /** + * This authentication type represents the signed SHA1 hash. + */ + public static final int ADB_AUTH_SIGNATURE = 2; + + /** + * This authentication type represents an RSA public key. + */ + public static final int ADB_AUTH_RSAPUBLICKEY = 3; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ADB_AUTH_TOKEN, ADB_AUTH_SIGNATURE, ADB_AUTH_RSAPUBLICKEY}) + private @interface AuthType { + } + + public static int getMaxData(int api) { + if (api >= Build.VERSION_CODES.P) { + return MAX_PAYLOAD_V3; + } + if (api >= Build.VERSION_CODES.N) { + return MAX_PAYLOAD_V2; + } + return MAX_PAYLOAD_V1; + } + + public static int getProtocolVersion(int api) { + if (api >= Build.VERSION_CODES.P) { + return A_VERSION_SKIP_CHECKSUM; + } + return A_VERSION_MIN; + } + + /** + * This function performs a checksum on the ADB payload data. + * + * @param data The data + * @return The checksum of the data + */ + private static int getPayloadChecksum(@NonNull byte[] data) { + return getPayloadChecksum(data, 0, data.length); + } + + /** + * This function performs a checksum on the ADB payload data. + * + * @param data The data + * @param offset The start offset in the data + * @param length The number of bytes to take from the data + * @return The checksum of the data + */ + private static int getPayloadChecksum(@NonNull byte[] data, int offset, int length) { + int checksum = 0; + for (int i = offset; i < offset + length; ++i) { + checksum += data[i] & 0xFF; + } + return checksum; + } + + /** + * This function generates an ADB message given the fields. + * + * @param command Command identifier constant + * @param arg0 First argument + * @param arg1 Second argument + * @param data The data + * @return Byte array containing the message + */ + @NonNull + public static byte[] generateMessage(@Command int command, int arg0, int arg1, @Nullable byte[] data) { + return generateMessage(command, arg0, arg1, data, 0, data == null ? 0 : data.length); + } + + /** + * This function generates an ADB message given the fields. + * + * @param command Command identifier constant + * @param arg0 First argument + * @param arg1 Second argument + * @param data The data + * @param offset The start offset in the data + * @param length The number of bytes to take from the data + * @return Byte array containing the message + */ + @NonNull + public static byte[] generateMessage(@Command int command, int arg0, int arg1, @Nullable byte[] data, int offset, int length) { + // Protocol as defined at https://github.com/aosp-mirror/platform_system_core/blob/6072de17cd812daf238092695f26a552d3122f8c/adb/protocol.txt + // struct message { + // unsigned command; // command identifier constant + // unsigned arg0; // first argument + // unsigned arg1; // second argument + // unsigned data_length; // length of payload (0 is allowed) + // unsigned data_check; // checksum of data payload + // unsigned magic; // command ^ 0xffffffff + // }; + + ByteBuffer message; + + if (data != null) { + message = ByteBuffer.allocate(ADB_HEADER_LENGTH + length).order(ByteOrder.LITTLE_ENDIAN); + } else { + message = ByteBuffer.allocate(ADB_HEADER_LENGTH).order(ByteOrder.LITTLE_ENDIAN); + } + + message.putInt(command); + message.putInt(arg0); + message.putInt(arg1); + + if (data != null) { + message.putInt(length); + message.putInt(getPayloadChecksum(data, offset, length)); + } else { + message.putInt(0); + message.putInt(0); + } + + message.putInt(~command); + + if (data != null) { + message.put(data, offset, length); + } + + return message.array(); + } + + /** + * Generates a CONNECT message for a given API. + * <p> + * CONNECT(version, maxdata, "system-identity-string") + * + * @param api API version + * @return Byte array containing the message + */ + @NonNull + public static byte[] generateConnect(int api) { + return generateMessage(A_CNXN, getProtocolVersion(api), getMaxData(api), SYSTEM_IDENTITY_STRING_HOST); + } + + /** + * Generates an AUTH message with the specified type and payload. + * <p> + * AUTH(type, 0, "data") + * + * @param type Authentication type (see ADB_AUTH_* constants) + * @param data The data + * @return Byte array containing the message + */ + @NonNull + public static byte[] generateAuth(@AuthType int type, byte[] data) { + return generateMessage(A_AUTH, type, 0, data); + } + + /** + * Generates an STLS message with default parameters. + * <p> + * STLS(version, 0, "") + * + * @return Byte array containing the message + */ + @NonNull + public static byte[] generateStls() { + return generateMessage(A_STLS, A_STLS_VERSION, 0, null); + } + + /** + * Generates an OPEN stream message with the specified local ID and destination. + * <p> + * OPEN(local-id, 0, "destination") + * + * @param localId A unique local ID identifying the stream + * @param destination The destination of the stream on the target + * @return Byte array containing the message + */ + @NonNull + public static byte[] generateOpen(int localId, @NonNull String destination) { + ByteBuffer bbuf = ByteBuffer.allocate(destination.length() + 1); + bbuf.put(StringCompat.getBytes(destination, "UTF-8")); + bbuf.put((byte) 0); + return generateMessage(A_OPEN, localId, 0, bbuf.array()); + } + + /** + * Generates a WRITE stream message with the specified IDs and payload. + * <p> + * WRITE(local-id, remote-id, "data") + * + * @param localId The unique local ID of the stream + * @param remoteId The unique remote ID of the stream + * @param data The data + * @param offset The start offset in the data + * @param length The number of bytes to take from the data + * @return Byte array containing the message + */ + @NonNull + public static byte[] generateWrite(int localId, int remoteId, byte[] data, int offset, int length) { + return generateMessage(A_WRTE, localId, remoteId, data, offset, length); + } + + /** + * Generates a CLOSE stream message with the specified IDs. + * <p> + * CLOSE(local-id, remote-id, "") + * + * @param localId The unique local ID of the stream + * @param remoteId The unique remote ID of the stream + * @return Byte array containing the message + */ + @NonNull + public static byte[] generateClose(int localId, int remoteId) { + return generateMessage(A_CLSE, localId, remoteId, null); + } + + /** + * Generates an OKAY/READY message with the specified IDs. + * <p> + * READY(local-id, remote-id, "") + * + * @param localId The unique local ID of the stream + * @param remoteId The unique remote ID of the stream + * @return Byte array containing the message + */ + @NonNull + public static byte[] generateReady(int localId, int remoteId) { + return generateMessage(A_OKAY, localId, remoteId, null); + } + + /** + * This class provides an abstraction for the ADB message format. + */ + static final class Message { + /** + * The command field of the message + */ + @Command + public final int command; + /** + * The arg0 field of the message + */ + public final int arg0; + /** + * The arg1 field of the message + */ + public final int arg1; + /** + * The payload length field of the message + */ + public final int dataLength; + /** + * The checksum field of the message + */ + public final int dataCheck; + /** + * The magic field of the message + */ + public final int magic; + /** + * The payload of the message + */ + public byte[] payload; + + /** + * Read and parse an ADB message from the supplied input stream. + * <p> + * <b>Note:</b> If data is corrupted, the connection has to be closed immediately to avoid inconsistencies. + * + * @param in InputStream object to read data from + * @return An AdbMessage object represented the message read + * @throws IOException If the stream fails while reading. + * @throws StreamCorruptedException If data is corrupted. + */ + @NonNull + public static Message parse(@NonNull InputStream in, int protocolVersion, int maxData) throws IOException { + ByteBuffer header = ByteBuffer.allocate(ADB_HEADER_LENGTH).order(ByteOrder.LITTLE_ENDIAN); + + // Read header + int dataRead = 0; + do { + int bytesRead = in.read(header.array(), dataRead, ADB_HEADER_LENGTH - dataRead); + if (bytesRead < 0) { + throw new IOException("Stream closed"); + } else dataRead += bytesRead; + } while (dataRead < ADB_HEADER_LENGTH); + + Message msg = new Message(header); + + // Validate header + if (msg.command != (~msg.magic)) { // magic = cmd ^ 0xFFFFFFFF + throw new StreamCorruptedException(String.format("Invalid header: Invalid magic 0x%x.", msg.magic)); + } + if (msg.command != A_SYNC && msg.command != A_CNXN && msg.command != A_OPEN && msg.command != A_OKAY + && msg.command != A_CLSE && msg.command != A_WRTE && msg.command != A_AUTH + && msg.command != A_STLS) { + throw new StreamCorruptedException(String.format("Invalid header: Invalid command 0x%x.", msg.command)); + } + if (msg.dataLength < 0 || msg.dataLength > maxData) { + throw new StreamCorruptedException(String.format("Invalid header: Invalid data length %d", msg.dataLength)); + } + + if (msg.dataLength == 0) { + // No payload supplied, return immediately + return msg; + } + + // Read payload + msg.payload = new byte[msg.dataLength]; + dataRead = 0; + do { + int bytesRead = in.read(msg.payload, dataRead, msg.dataLength - dataRead); + if (bytesRead < 0) { + throw new IOException("Stream closed"); + } else dataRead += bytesRead; + } while (dataRead < msg.dataLength); + + // Verify payload + if ((protocolVersion <= A_VERSION_MIN || (msg.command == A_CNXN && msg.arg0 <= A_VERSION_MIN)) + && getPayloadChecksum(msg.payload) != msg.dataCheck) { + // Checksum verification failed + throw new StreamCorruptedException("Invalid header: Checksum mismatched."); + } + + return msg; + } + + private Message(@NonNull ByteBuffer header) { + command = header.getInt(); + arg0 = header.getInt(); + arg1 = header.getInt(); + dataLength = header.getInt(); + dataCheck = header.getInt(); + magic = header.getInt(); + } + + @NonNull + @Override + public String toString() { + String tag; + switch (command) { + case A_SYNC: + tag = "SYNC"; + break; + case A_CNXN: + tag = "CNXN"; + break; + case A_OPEN: + tag = "OPEN"; + break; + case A_OKAY: + tag = "OKAY"; + break; + case A_CLSE: + tag = "CLSE"; + break; + case A_WRTE: + tag = "WRTE"; + break; + case A_AUTH: + tag = "AUTH"; + break; + case A_STLS: + tag = "STLS"; + break; + default: + tag = "????"; + break; + } + return "Message{" + + "command=" + tag + + ", arg0=0x" + Integer.toHexString(arg0) + + ", arg1=0x" + Integer.toHexString(arg1) + + ", payloadLength=" + dataLength + + ", checksum=" + dataCheck + + ", magic=0x" + Integer.toHexString(magic) + + ", payload=" + Arrays.toString(payload) + + '}'; + } + } + +} diff --git a/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbStream.java b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbStream.java new file mode 100644 index 0000000..6d24c06 --- /dev/null +++ b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbStream.java @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: BSD-3-Clause AND (GPL-3.0-or-later OR Apache-2.0) + +package io.github.muntashirakon.adb; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * This class abstracts the underlying ADB streams + */ +// Copyright 2013 Cameron Gutman +public class AdbStream implements Closeable { + + /** + * The AdbConnection object that the stream communicates over + */ + private final AdbConnection mAdbConnection; + + /** + * The local ID of the stream + */ + private final int mLocalId; + + /** + * The remote ID of the stream + */ + private volatile int mRemoteId; + + /** + * Indicates whether WRTE is currently allowed + */ + private final AtomicBoolean mWriteReady; + + /** + * A queue of data from the target's WRTE packets + */ + private final Queue<byte[]> mReadQueue; + + /** + * Store data received from the first WRTE packet in order to support buffering. + */ + private final ByteBuffer mReadBuffer; + + /** + * Indicates whether the connection is closed already + */ + private volatile boolean mIsClosed; + + /** + * Whether the remote peer has closed but we still have unread data in the queue + */ + private volatile boolean mPendingClose; + + /** + * Creates a new AdbStream object on the specified AdbConnection + * with the given local ID. + * + * @param adbConnection AdbConnection that this stream is running on + * @param localId Local ID of the stream + */ + AdbStream(AdbConnection adbConnection, int localId) + throws IOException, InterruptedException, AdbPairingRequiredException { + this.mAdbConnection = adbConnection; + this.mLocalId = localId; + this.mReadQueue = new ConcurrentLinkedQueue<>(); + this.mReadBuffer = (ByteBuffer) ByteBuffer.allocate(adbConnection.getMaxData()).flip(); + this.mWriteReady = new AtomicBoolean(false); + this.mIsClosed = false; + } + + public AdbInputStream openInputStream() { + return new AdbInputStream(this); + } + + public AdbOutputStream openOutputStream() { + return new AdbOutputStream(this); + } + + /** + * Called by the connection thread to indicate newly received data. + * + * @param payload Data inside the WRTE message + */ + void addPayload(byte[] payload) { + synchronized (mReadQueue) { + mReadQueue.add(payload); + mReadQueue.notifyAll(); + } + } + + /** + * Called by the connection thread to send an OKAY packet, allowing the + * other side to continue transmission. + * + * @throws IOException If the connection fails while sending the packet + */ + void sendReady() throws IOException { + // Generate and send a OKAY packet + mAdbConnection.sendPacket(AdbProtocol.generateReady(mLocalId, mRemoteId)); + } + + /** + * Called by the connection thread to update the remote ID for this stream + * + * @param remoteId New remote ID + */ + void updateRemoteId(int remoteId) { + this.mRemoteId = remoteId; + } + + /** + * Called by the connection thread to indicate the stream is okay to send data. + */ + void readyForWrite() { + mWriteReady.set(true); + } + + /** + * Called by the connection thread to notify that the stream was closed by the peer. + */ + void notifyClose(boolean closedByPeer) { + // We don't call close() because it sends another CLSE + if (closedByPeer && !mReadQueue.isEmpty()) { + // The remote peer closed the stream, but we haven't finished reading the remaining data + mPendingClose = true; + } else { + mIsClosed = true; + } + + // Notify readers and writers + synchronized (this) { + notifyAll(); + } + synchronized (mReadQueue) { + mReadQueue.notifyAll(); + } + } + + /** + * Read bytes from the ADB daemon. + * + * @return the next byte of data, or {@code -1} if the end of the stream is reached. + * @throws IOException If the stream fails while waiting + */ + public int read(byte[] bytes, int offset, int length) throws IOException { + if (mReadBuffer.hasRemaining()) { + return readBuffer(bytes, offset, length); + } + // Buffer has no data, grab from the queue + synchronized (mReadQueue) { + byte[] data; + // Wait for the connection to close or data to be received + while ((data = mReadQueue.poll()) == null && !mIsClosed) { + try { + mReadQueue.wait(); + } catch (InterruptedException e) { + //noinspection UnnecessaryInitCause + throw (IOException) new IOException().initCause(e); + } + } + // Add data to the buffer + if (data != null) { + mReadBuffer.clear(); + mReadBuffer.put(data); + mReadBuffer.flip(); + if (mReadBuffer.hasRemaining()) { + return readBuffer(bytes, offset, length); + } + } + + if (mIsClosed) { + throw new IOException("Stream closed."); + } + + if (mPendingClose && mReadQueue.isEmpty()) { + // The peer closed the stream, and we've finished reading the stream data, so this stream is finished + mIsClosed = true; + } + } + + return -1; + } + + private int readBuffer(byte[] bytes, int offset, int length) { + int count = 0; + for (int i = offset; i < offset + length; ++i) { + if (mReadBuffer.hasRemaining()) { + bytes[i] = mReadBuffer.get(); + ++count; + } + } + return count; + } + + /** + * Sends a WRTE packet with a given byte array payload. It does not flush the stream. + * + * @param bytes Payload in the form of a byte array + * @throws IOException If the stream fails while sending data + */ + public void write(byte[] bytes, int offset, int length) throws IOException { + synchronized (this) { + // Make sure we're ready for a WRTE + while (!mIsClosed && !mWriteReady.compareAndSet(true, false)) { + try { + wait(); + } catch (InterruptedException e) { + //noinspection UnnecessaryInitCause + throw (IOException) new IOException().initCause(e); + } + } + + if (mIsClosed) { + throw new IOException("Stream closed"); + } + } + // Split and send data as WRTE packet + // TODO: A WRITE message may not be sent until a READY message is received. + // Once a WRITE message is sent, an additional WRITE message may not be + // sent until another READY message has been received. Recipients of + // a WRITE message that is in violation of this requirement will CLOSE + // the connection. + int maxData; + try { + maxData = mAdbConnection.getMaxData(); + } catch (InterruptedException | AdbPairingRequiredException e) { + //noinspection UnnecessaryInitCause + throw (IOException) new IOException().initCause(e); + } + while (length != 0) { + if (length <= maxData) { + mAdbConnection.sendPacket(AdbProtocol.generateWrite(mLocalId, mRemoteId, bytes, offset, length)); + offset = offset + length; + length = 0; + } else { // if (length > maxData) { + mAdbConnection.sendPacket(AdbProtocol.generateWrite(mLocalId, mRemoteId, bytes, offset, maxData)); + offset = offset + maxData; + length = length - maxData; + } + } + } + + public void flush() throws IOException { + if (mIsClosed) { + throw new IOException("Stream closed"); + } + mAdbConnection.flushPacket(); + } + + /** + * Closes the stream. This sends a close message to the peer. + * + * @throws IOException If the stream fails while sending the close message. + */ + @Override + public void close() throws IOException { + synchronized (this) { + // This may already be closed by the remote host + if (mIsClosed) + return; + + // Notify readers/writers that we've closed + notifyClose(false); + } + + mAdbConnection.sendPacket(AdbProtocol.generateClose(mLocalId, mRemoteId)); + } + + /** + * Returns whether the stream is closed or not + * + * @return True if the stream is close, false if not + */ + public boolean isClosed() { + return mIsClosed; + } + + /** + * Returns an estimate of available data. + * + * @return an estimate of the number of bytes that can be read from this stream without blocking. + * @throws IOException if the stream is close. + */ + public int available() throws IOException { + synchronized (this) { + if (mIsClosed) { + throw new IOException("Stream closed."); + } + if (mReadBuffer.hasRemaining()) { + return mReadBuffer.remaining(); + } + byte[] data = mReadQueue.peek(); + return data == null ? 0 : data.length; + } + } +} diff --git a/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AndroidPubkey.java b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AndroidPubkey.java new file mode 100644 index 0000000..6aa12aa --- /dev/null +++ b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AndroidPubkey.java @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: GPL-3.0-or-later OR Apache-2.0 + +package io.github.muntashirakon.adb; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import org.bouncycastle.util.encoders.Base64; + +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.GeneralSecurityException; +import java.security.InvalidKeyException; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.RSAPublicKeySpec; +import java.util.Objects; + +import javax.crypto.Cipher; + +final class AndroidPubkey { + /** + * Size of an RSA modulus such as an encrypted block or a signature. + */ + public static final int ANDROID_PUBKEY_MODULUS_SIZE = 2048 / 8; + + /** + * Size of an encoded RSA key. + */ + public static final int ANDROID_PUBKEY_ENCODED_SIZE = 3 * 4 + 2 * ANDROID_PUBKEY_MODULUS_SIZE; + + /** + * Size of the RSA modulus in words. + */ + public static final int ANDROID_PUBKEY_MODULUS_SIZE_WORDS = ANDROID_PUBKEY_MODULUS_SIZE / 4; + + /** + * The RSA signature padding as an int array. + */ + private static final int[] SIGNATURE_PADDING_AS_INT = new int[]{ + 0x00, 0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, + 0x30, 0x21, 0x30, 0x09, 0x06, 0x05, 0x2b, 0x0e, 0x03, 0x02, 0x1a, 0x05, 0x00, + 0x04, 0x14 + }; + + /** + * The RSA signature padding as a byte array + */ + private static final byte[] RSA_SHA_PKCS1_SIGNATURE_PADDING; + + static { + RSA_SHA_PKCS1_SIGNATURE_PADDING = new byte[SIGNATURE_PADDING_AS_INT.length]; + + for (int i = 0; i < RSA_SHA_PKCS1_SIGNATURE_PADDING.length; i++) + RSA_SHA_PKCS1_SIGNATURE_PADDING[i] = (byte) SIGNATURE_PADDING_AS_INT[i]; + } + + /** + * Signs the ADB SHA1 payload with the private key of this object. + * + * @param privateKey Private key to sign with + * @param payload SHA1 payload to sign + * @return Signed SHA1 payload + * @throws GeneralSecurityException If signing fails + */ + // Taken from adb_auth_sign + @NonNull + public static byte[] adbAuthSign(@NonNull PrivateKey privateKey, byte[] payload) + throws GeneralSecurityException { + Cipher c = Cipher.getInstance("RSA/ECB/NoPadding"); + c.init(Cipher.ENCRYPT_MODE, privateKey); + c.update(RSA_SHA_PKCS1_SIGNATURE_PADDING); + return c.doFinal(payload); + } + + /** + * Converts a standard RSAPublicKey object to the special ADB format. Available since 4.2.2. + * + * @param publicKey RSAPublicKey object to convert + * @param name Name without null terminator + * @return Byte array containing the converted RSAPublicKey object + */ + @NonNull + public static byte[] encodeWithName(@NonNull RSAPublicKey publicKey, @NonNull String name) + throws InvalidKeyException { + int pkeySize = 4 * (int) Math.ceil(ANDROID_PUBKEY_ENCODED_SIZE / 3.0); + try (ByteArrayNoThrowOutputStream bos = new ByteArrayNoThrowOutputStream(pkeySize + name.length() + 2)) { + bos.write(Base64.encode(encode(publicKey))); + bos.write(getUserInfo(name)); + return bos.toByteArray(); + } + } + + // Taken from get_user_info except that a custom name is used instead of host@user + @VisibleForTesting + @NonNull + static byte[] getUserInfo(@NonNull String name) { + return StringCompat.getBytes(String.format(" %s\u0000", name), "UTF-8"); + } + + // https://android.googlesource.com/platform/system/core/+/e797a5c75afc17024d0f0f488c130128fcd704e2/libcrypto_utils/android_pubkey.cpp + // typedef struct RSAPublicKey { + // uint32_t modulus_size_words; // Modulus length. This must be ANDROID_PUBKEY_MODULUS_SIZE. + // uint32_t n0inv; // Precomputed montgomery parameter: -1 / n[0] mod 2^32 + // uint8_t modulus[ANDROID_PUBKEY_MODULUS_SIZE]; // RSA modulus as a little-endian array. + // uint8_t rr[ANDROID_PUBKEY_MODULUS_SIZE]; // Montgomery parameter R^2 as a little-endian array. + // uint32_t exponent; // RSA modulus: 3 or 65537 + // } RSAPublicKey; + + /** + * Allocates a new {@link RSAPublicKey} object, decodes a public RSA key stored in Android's custom binary format, + * and sets the key parameters. The resulting key can be used with the standard Java cryptography API to perform + * public operations. + * + * @param androidPubkey Public RSA key in Android's custom binary format. The size of the key must be at least + * {@link #ANDROID_PUBKEY_ENCODED_SIZE} + * @return {@link RSAPublicKey} object + */ + @NonNull + public static RSAPublicKey decode(@NonNull byte[] androidPubkey) + throws InvalidKeyException, NoSuchAlgorithmException, InvalidKeySpecException { + BigInteger n; + BigInteger e; + + // Check size is large enough and the modulus size is correct. + if (androidPubkey.length < ANDROID_PUBKEY_ENCODED_SIZE) { + throw new InvalidKeyException("Invalid key length"); + } + ByteBuffer keyStruct = ByteBuffer.wrap(androidPubkey).order(ByteOrder.LITTLE_ENDIAN); + int modulusSize = keyStruct.getInt(); + if (modulusSize != ANDROID_PUBKEY_MODULUS_SIZE_WORDS) { + throw new InvalidKeyException("Invalid modulus length."); + } + + // Convert the modulus to big-endian byte order as expected by BN_bin2bn. + byte[] modulus = new byte[ANDROID_PUBKEY_MODULUS_SIZE]; + keyStruct.position(8); + keyStruct.get(modulus); + n = new BigInteger(1, swapEndianness(modulus)); + + // Read the exponent. + keyStruct.position(520); + e = BigInteger.valueOf(keyStruct.getInt()); + + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e); + return (RSAPublicKey) keyFactory.generatePublic(publicKeySpec); + } + + /** + * Encodes the given key in the Android RSA public key binary format. + * + * @return Public RSA key in Android's custom binary format. The size of the key should be at least + * {@link #ANDROID_PUBKEY_ENCODED_SIZE} + */ + @NonNull + public static byte[] encode(@NonNull RSAPublicKey publicKey) throws InvalidKeyException { + BigInteger r32; + BigInteger n0inv; + BigInteger rr; + + if (publicKey.getModulus().toByteArray().length < ANDROID_PUBKEY_MODULUS_SIZE) { + throw new InvalidKeyException("Invalid key length " + publicKey.getModulus().toByteArray().length); + } + + ByteBuffer keyStruct = ByteBuffer.allocate(ANDROID_PUBKEY_ENCODED_SIZE).order(ByteOrder.LITTLE_ENDIAN); + // Store the modulus size. + keyStruct.putInt(ANDROID_PUBKEY_MODULUS_SIZE_WORDS); // modulus_size_words + + // Compute and store n0inv = -1 / N[0] mod 2^32. + r32 = BigInteger.ZERO.setBit(32); // r32 = 2^32 + n0inv = publicKey.getModulus().mod(r32); // n0inv = N[0] mod 2^32 + n0inv = n0inv.modInverse(r32); // n0inv = 1/n0inv mod 2^32 + n0inv = r32.subtract(n0inv); // n0inv = 2^32 - n0inv + keyStruct.putInt(n0inv.intValue()); // n0inv + + // Store the modulus. + keyStruct.put(Objects.requireNonNull(BigEndianToLittleEndianPadded(ANDROID_PUBKEY_MODULUS_SIZE, publicKey.getModulus()))); + + // Compute and store rr = (2^(rsa_size)) ^ 2 mod N. + rr = BigInteger.ZERO.setBit(ANDROID_PUBKEY_MODULUS_SIZE * 8); // rr = 2^(rsa_size) + rr = rr.modPow(BigInteger.valueOf(2), publicKey.getModulus()); // rr = rr^2 mod N + keyStruct.put(Objects.requireNonNull(BigEndianToLittleEndianPadded(ANDROID_PUBKEY_MODULUS_SIZE, rr))); + + // Store the exponent. + keyStruct.putInt(publicKey.getPublicExponent().intValue()); // exponent + + return keyStruct.array(); + } + + @Nullable + private static byte[] BigEndianToLittleEndianPadded(int len, @NonNull BigInteger in) { + byte[] out = new byte[len]; + byte[] bytes = swapEndianness(in.toByteArray()); // Convert big endian -> little endian + int num_bytes = bytes.length; + if (len < num_bytes) { + if (!fitsInBytes(bytes, num_bytes, len)) { + return null; + } + num_bytes = len; + } + System.arraycopy(bytes, 0, out, 0, num_bytes); + return out; + } + + static boolean fitsInBytes(@NonNull byte[] bytes, int num_bytes, int len) { + byte mask = 0; + for (int i = len; i < num_bytes; i++) { + mask |= bytes[i]; + } + return mask == 0; + } + + @NonNull + private static byte[] swapEndianness(@NonNull byte[] bytes) { + int len = bytes.length; + byte[] out = new byte[len]; + for (int i = 0; i < len; ++i) { + out[i] = bytes[len - i - 1]; + } + return out; + } +} diff --git a/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/ByteArrayNoThrowOutputStream.java b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/ByteArrayNoThrowOutputStream.java new file mode 100644 index 0000000..55cc1bf --- /dev/null +++ b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/ByteArrayNoThrowOutputStream.java @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-or-later OR Apache-2.0 + +package io.github.muntashirakon.adb; + +import java.io.ByteArrayOutputStream; + +class ByteArrayNoThrowOutputStream extends ByteArrayOutputStream { + public ByteArrayNoThrowOutputStream() { + super(); + } + + public ByteArrayNoThrowOutputStream(int size) { + super(size); + } + + @Override + public void write(byte[] b) { + write(b, 0, b.length); + } + + @Override + public void close() { + } +} diff --git a/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/KeyPair.java b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/KeyPair.java new file mode 100644 index 0000000..4574d8b --- /dev/null +++ b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/KeyPair.java @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-3.0-or-later OR Apache-2.0 + +package io.github.muntashirakon.adb; + +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.cert.Certificate; + +import javax.security.auth.DestroyFailedException; + +final class KeyPair { + private final PrivateKey mPrivateKey; + private final Certificate mCertificate; + + public KeyPair(PrivateKey privateKey, Certificate certificate) { + mPrivateKey = privateKey; + mCertificate = certificate; + } + + public PrivateKey getPrivateKey() { + return mPrivateKey; + } + + public PublicKey getPublicKey() { + return mCertificate.getPublicKey(); + } + + public Certificate getCertificate() { + return mCertificate; + } + + public void destroy() throws DestroyFailedException { + try { + mPrivateKey.destroy(); + } catch (NoSuchMethodError ignore) { + } + } +} diff --git a/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/LocalServices.java b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/LocalServices.java new file mode 100644 index 0000000..523bce3 --- /dev/null +++ b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/LocalServices.java @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: GPL-3.0-or-later OR Apache-2.0 + +package io.github.muntashirakon.adb; + +import android.text.TextUtils; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Objects; + + +/** + * Local services extracted from the <a href="https://cs.android.com/android/platform/superproject/+/master:packages/modules/adb/client/commandline.cpp">ADB client</a> + * for easy access. + */ +public class LocalServices { + static final int SERVICE_FIRST = 1; + + public static final int SHELL = 1; + /** + * Remount the device's filesystem in read-write mode, instead of read-only. This is usually necessary before + * performing an {@link #SYNC} request. This request may not succeed on certain builds which do not allow that. + * <p> + * This essentially executes {@code /system/bin/remount} command. Additional arguments such as {@code -R} can be + * passed too. + */ + public static final int REMOUNT = 2; + public static final int FILE = 3; + public static final int TCP_CONNECT = 4; + public static final int LOCAL_UNIX_SOCKET = 5; + public static final int LOCAL_UNIX_SOCKET_RESERVED = 6; + public static final int LOCAL_UNIX_SOCKET_ABSTRACT = 7; + public static final int LOCAL_UNIX_SOCKET_FILE_SYSTEM = 8; + /** + * Receive snapshots of the framebuffer. It requires sufficient privileges (or the connection is closed immediately) + * but works as follows: + * <p> + * After an {@link AdbStream} is opened, ADB daemon sends a 16-byte binary structure containing the following fields + * (little-endian format): + * <pre> + * uint32_t depth; // framebuffer depth = 16 + * uint32_t size; // framebuffer size in bytes = 2 * width * height + * uint32_t width; // framebuffer width in pixels + * uint32_t height; // framebuffer height in pixels + * </pre> + * After that, each time a snapshot is wanted, one byte should be sent through the channel, which will trigger the + * daemon to send {@code size} bytes of framebuffer data. + */ + public static final int FRAMEBUFFER = 9; + /** + * Connects to the JDWP thread running in the VM of process PID (specified as an argument). + */ + public static final int CONNECT_JDWP = 10; + /** + * Receive the list of JDWP PIDs periodically. The format of the returned data is the following (in order): + * <ol> + * <li> {@code hex4}: The length of all content as a 4-char hexadecimal string i.e. {@code %04zx}. + * <li> {@code content}: A series of ASCII lines of the following format: + * <pre> + * <pid> "\n" + * </pre> + * </ol> + * This service is used by DDMS to know which debuggable processes are running on the device/emulator. + * <p> + * Note that there is no single-shot service to retrieve the list only once. + */ + public static final int TRACK_JDWP = 11; + public static final int SYNC = 12; + /** + * Reverse socket connections from the device running ADB daemon to this client. This should not be used if both + * the ADB daemon and the client are in the same device. + * <p> + * It takes an additional argument called {@code forward-command}. It can be one of the following: + * <ul> + * <li> {@code list-forward}: List all forwarded connections from the device + * This returns something that looks like the following: + * <ol> + * <li> {@code hex4}: The length of the payload, as 4 hexadecimal chars i.e. {@code %04zx}. + * <li> {@code payload}: A series of lines of the following format: + * <pre> + * host " " <local> " " <remote> "\n" + * </pre> + * Where <local> is the device-specific endpoint (e.g. {@code tcp:9000}), and <remote> is the + * client-specific endpoint. + * </ol> + * <li> forward:<local>;<remote> + * <li> forward:norebind:<local>;<remote> + * <li> killforward-all + * <li> killforward:<local> + * </ul> + */ + public static final int REVERSE = 13; + /** + * Backup some or all packages installed in the device. For this to work, {@code allowBackup=true} must be present + * in the application section of the AndroidManifest.xml of the app. + * <p> + * It takes additional arguments which can be one of the following: + * <ul> + * <li>List of packages (as array) + * <li>{@code -all} + * <li>{@code -shared} + * </ul> + * Output is a stream which is in zlib format with 24 bytes at the front (if unencrypted). + */ + public static final int BACKUP = 14; + /** + * Restore a backup. Input is a stream which is in zlib format with 24 bytes at the front (if unencrypted). + */ + public static final int RESTORE = 15; + + static final int SERVICE_LAST = 15; + + @IntDef({ + SHELL, + REMOUNT, + FILE, + TCP_CONNECT, + LOCAL_UNIX_SOCKET, + LOCAL_UNIX_SOCKET_RESERVED, + LOCAL_UNIX_SOCKET_ABSTRACT, + LOCAL_UNIX_SOCKET_FILE_SYSTEM, + FRAMEBUFFER, + CONNECT_JDWP, + TRACK_JDWP, + SYNC, + REVERSE, + BACKUP, + RESTORE, + }) + @Retention(RetentionPolicy.SOURCE) + public @interface Services { + } + + @NonNull + static String getServiceName(@Services int service) { + switch (service) { + case SHELL: + return "shell:"; + case CONNECT_JDWP: + return "jdwp:"; + case FILE: + return "dev:"; + case FRAMEBUFFER: + return "framebuffer:"; + case LOCAL_UNIX_SOCKET: + return "local:"; + case LOCAL_UNIX_SOCKET_ABSTRACT: + return "localabstract:"; + case LOCAL_UNIX_SOCKET_FILE_SYSTEM: + return "localfilesystem:"; + case LOCAL_UNIX_SOCKET_RESERVED: + return "localreserved:"; + case REMOUNT: + return "remount:"; + case REVERSE: + return "reverse:"; + case SYNC: + return "sync:"; + case TCP_CONNECT: + return "tcp:"; + case TRACK_JDWP: + return "track-jdwp"; + case BACKUP: + return "backup:"; + case RESTORE: + return "restore:"; + default: + throw new IllegalArgumentException("Invalid service: " + service); + } + } + + @NonNull + static String getDestination(@Services int service, @NonNull String... args) { + String serviceName = getServiceName(service); + StringBuilder destination = new StringBuilder(serviceName); + switch (service) { + case SHELL: + for (String arg : args) { + if (arg.contains("\"")) { + throw new IllegalArgumentException("Arguments for inline shell cannot contain double" + + " quotations."); + } + if (arg.contains(" ")) { + destination.append("\"").append(Objects.requireNonNull(arg)).append("\""); + } else destination.append(Objects.requireNonNull(arg)); + } + break; + case FILE: + if (args.length == 0) { + throw new IllegalArgumentException("File name must be specified."); + } else if (args.length != 1) { + throw new IllegalArgumentException("Service expects exactly one argument, " + args.length + + " supplied."); + } + destination.append(Objects.requireNonNull(args[0])); + break; + case TCP_CONNECT: + if (args.length == 0) { + throw new IllegalArgumentException("Port number must be specified."); + } else if (args.length == 1) { + destination.append(args[0]); + } else if (args.length == 2) { + destination.append(Objects.requireNonNull(args[0])) + .append(':') + .append(Objects.requireNonNull(args[1])); + } else { + throw new IllegalArgumentException("Invalid number of arguments supplied."); + } + break; + case LOCAL_UNIX_SOCKET: + case LOCAL_UNIX_SOCKET_ABSTRACT: + case LOCAL_UNIX_SOCKET_FILE_SYSTEM: + case LOCAL_UNIX_SOCKET_RESERVED: + if (args.length == 0) { + throw new IllegalArgumentException("Path must be specified."); + } else if (args.length != 1) { + throw new IllegalArgumentException("Service expects exactly one argument, " + args.length + + " supplied."); + } + destination.append(Objects.requireNonNull(args[0])); + break; + case CONNECT_JDWP: + if (args.length == 0) { + throw new IllegalArgumentException("PID must be specified."); + } else if (args.length != 1) { + throw new IllegalArgumentException("Service expects exactly one argument, " + args.length + + " supplied."); + } + destination.append(Objects.requireNonNull(args[0])); + break; + case REVERSE: + if (args.length == 0) { + throw new IllegalArgumentException("Forward command must be specified."); + } else if (args.length != 1) { + throw new IllegalArgumentException("Service expects exactly one argument, " + args.length + + " supplied."); + } + if (args[0] == null) { + throw new IllegalArgumentException("Forward command is empty"); + } + if ("list-forward".equals(args[0]) || "killforward-all".equals(args[0])) { + destination.append(args[0]); + } else if (args[0].startsWith("forward:") || args[0].startsWith("killforward:")) { + destination.append(args[0]); + } else { + throw new IllegalArgumentException("Invalid forward command."); + } + break; + case BACKUP: + if (args.length == 0) { + throw new IllegalArgumentException("At least one package must be specified or use -shared/-all."); + } + case REMOUNT: + // Additional arguments for the commands + destination.append(TextUtils.join(" ", args)); + break; + case RESTORE: + case FRAMEBUFFER: + case SYNC: + case TRACK_JDWP: + if (args.length != 0) { + throw new IllegalArgumentException("Service expects no arguments."); + } + break; + } + return destination.toString(); + } +} diff --git a/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/PRNGFixes.java b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/PRNGFixes.java new file mode 100644 index 0000000..fe6343f --- /dev/null +++ b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/PRNGFixes.java @@ -0,0 +1,319 @@ +// SPDX-License-Identifier: MIT AND (GPL-3.0-or-later OR Apache-2.0) + +package io.github.muntashirakon.adb; + +import android.os.Build; +import android.os.Process; +import android.util.Log; + +import androidx.annotation.GuardedBy; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.security.NoSuchAlgorithmException; +import java.security.Provider; +import java.security.SecureRandom; +import java.security.SecureRandomSpi; +import java.security.Security; + +/** + * Fixes for the output of the default PRNG having low entropy. + * <p> + * The fixes need to be applied via {@link #apply()} before any use of Java + * Cryptography Architecture primitives. A good place to invoke them is in the + * application's {@code onCreate}. + */ +// Copyright 2013 Google Inc. +public final class PRNGFixes { + private static final byte[] BUILD_FINGERPRINT_AND_DEVICE_SERIAL = getBuildFingerprintAndDeviceSerial(); + + /** + * Hidden constructor to prevent instantiation. + */ + private PRNGFixes() { + } + + /** + * Applies all fixes. + * + * @throws SecurityException if a fix is needed but could not be applied. + */ + public static void apply() { + applyOpenSSLFix(); + installLinuxPRNGSecureRandom(); + } + + /** + * Applies the fix for OpenSSL PRNG having low entropy. Does nothing if the + * fix is not needed. + * + * @throws SecurityException if the fix is needed but could not be applied. + */ + private static void applyOpenSSLFix() throws SecurityException { + if ((Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) + || (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2)) { + // No need to apply the fix + return; + } + + try { + // Mix in the device- and invocation-specific seed. + Class.forName("org.apache.harmony.xnet.provider.jsse.NativeCrypto") + .getMethod("RAND_seed", byte[].class) + .invoke(null, generateSeed()); + + // Mix output of Linux PRNG into OpenSSL's PRNG + int bytesRead = (Integer) Class.forName("org.apache.harmony.xnet.provider.jsse.NativeCrypto") + .getMethod("RAND_load_file", String.class, long.class) + .invoke(null, "/dev/urandom", 1024); + if (bytesRead != 1024) { + throw new IOException("Unexpected number of bytes read from Linux PRNG: " + bytesRead); + } + } catch (Exception e) { + throw new SecurityException("Failed to seed OpenSSL PRNG", e); + } + } + + /** + * Installs a Linux PRNG-backed {@code SecureRandom} implementation as the + * default. Does nothing if the implementation is already the default or if + * there is not need to install the implementation. + * + * @throws SecurityException if the fix is needed but could not be applied. + */ + private static void installLinuxPRNGSecureRandom() + throws SecurityException { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2) { + // No need to apply the fix + return; + } + + // Install a Linux PRNG-based SecureRandom implementation as the + // default, if not yet installed. + Provider[] secureRandomProviders = Security.getProviders("SecureRandom.SHA1PRNG"); + if ((secureRandomProviders == null) + || (secureRandomProviders.length < 1) + || (!LinuxPRNGSecureRandomProvider.class.equals(secureRandomProviders[0].getClass()))) { + Security.insertProviderAt(new LinuxPRNGSecureRandomProvider(), 1); + } + + // Assert that new SecureRandom() and + // SecureRandom.getInstance("SHA1PRNG") return a SecureRandom backed + // by the Linux PRNG-based SecureRandom implementation. + SecureRandom rng1 = new SecureRandom(); + if (!LinuxPRNGSecureRandomProvider.class.equals(rng1.getProvider().getClass())) { + throw new SecurityException("new SecureRandom() backed by wrong Provider: " + + rng1.getProvider().getClass()); + } + + SecureRandom rng2; + try { + rng2 = SecureRandom.getInstance("SHA1PRNG"); + } catch (NoSuchAlgorithmException e) { + throw new SecurityException("SHA1PRNG not available", e); + } + if (!LinuxPRNGSecureRandomProvider.class.equals( + rng2.getProvider().getClass())) { + throw new SecurityException("SecureRandom.getInstance(\"SHA1PRNG\") backed by wrong provider: " + + rng2.getProvider().getClass()); + } + } + + /** + * {@code Provider} of {@code SecureRandom} engines which pass through + * all requests to the Linux PRNG. + */ + private static class LinuxPRNGSecureRandomProvider extends Provider { + + public LinuxPRNGSecureRandomProvider() { + super("LinuxPRNG", 1.0, "A Linux-specific random number provider that uses /dev/urandom"); + // Although /dev/urandom is not a SHA-1 PRNG, some apps + // explicitly request a SHA1PRNG SecureRandom and we thus need to + // prevent them from getting the default implementation whose output + // may have low entropy. + put("SecureRandom.SHA1PRNG", LinuxPRNGSecureRandom.class.getName()); + put("SecureRandom.SHA1PRNG ImplementedIn", "Software"); + } + } + + /** + * {@link SecureRandomSpi} which passes all requests to the Linux PRNG + * ({@code /dev/urandom}). + */ + public static class LinuxPRNGSecureRandom extends SecureRandomSpi { + + /* + * IMPLEMENTATION NOTE: Requests to generate bytes and to mix in a seed + * are passed through to the Linux PRNG (/dev/urandom). Instances of + * this class seed themselves by mixing in the current time, PID, UID, + * build fingerprint, and hardware serial number (where available) into + * Linux PRNG. + * + * Concurrency: Read requests to the underlying Linux PRNG are + * serialized (on sLock) to ensure that multiple threads do not get + * duplicated PRNG output. + */ + + private static final File URANDOM_FILE = new File("/dev/urandom"); + + private static final Object sLock = new Object(); + + /** + * Input stream for reading from Linux PRNG or {@code null} if not yet + * opened. + */ + @GuardedBy("sLock") + private static DataInputStream sUrandomIn; + + /** + * Output stream for writing to Linux PRNG or {@code null} if not yet + * opened. + */ + @GuardedBy("sLock") + private static OutputStream sUrandomOut; + + /** + * Whether this engine instance has been seeded. This is needed because + * each instance needs to seed itself if the client does not explicitly + * seed it. + */ + private boolean mSeeded; + + @Override + protected void engineSetSeed(byte[] bytes) { + try { + OutputStream out; + synchronized (sLock) { + out = getUrandomOutputStream(); + } + out.write(bytes); + out.flush(); + } catch (IOException e) { + // On a small fraction of devices /dev/urandom is not writable. + // Log and ignore. + Log.w(PRNGFixes.class.getSimpleName(), + "Failed to mix seed into " + URANDOM_FILE); + } finally { + mSeeded = true; + } + } + + @Override + protected void engineNextBytes(byte[] bytes) { + if (!mSeeded) { + // Mix in the device- and invocation-specific seed. + engineSetSeed(generateSeed()); + } + + try { + DataInputStream in; + synchronized (sLock) { + in = getUrandomInputStream(); + } + synchronized (in) { + in.readFully(bytes); + } + } catch (IOException e) { + throw new SecurityException( + "Failed to read from " + URANDOM_FILE, e); + } + } + + @Override + protected byte[] engineGenerateSeed(int size) { + byte[] seed = new byte[size]; + engineNextBytes(seed); + return seed; + } + + private DataInputStream getUrandomInputStream() { + synchronized (sLock) { + if (sUrandomIn == null) { + // NOTE: Consider inserting a BufferedInputStream between + // DataInputStream and FileInputStream if you need higher + // PRNG output performance and can live with future PRNG + // output being pulled into this process prematurely. + try { + sUrandomIn = new DataInputStream( + new FileInputStream(URANDOM_FILE)); + } catch (IOException e) { + throw new SecurityException("Failed to open " + + URANDOM_FILE + " for reading", e); + } + } + return sUrandomIn; + } + } + + private OutputStream getUrandomOutputStream() throws IOException { + synchronized (sLock) { + if (sUrandomOut == null) { + sUrandomOut = new FileOutputStream(URANDOM_FILE); + } + return sUrandomOut; + } + } + } + + /** + * Generates a device- and invocation-specific seed to be mixed into the + * Linux PRNG. + */ + private static byte[] generateSeed() { + try { + ByteArrayOutputStream seedBuffer = new ByteArrayOutputStream(); + DataOutputStream seedBufferOut = + new DataOutputStream(seedBuffer); + seedBufferOut.writeLong(System.currentTimeMillis()); + seedBufferOut.writeLong(System.nanoTime()); + seedBufferOut.writeInt(Process.myPid()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BASE_1_1) { + seedBufferOut.writeInt(Process.myUid()); + } + seedBufferOut.write(BUILD_FINGERPRINT_AND_DEVICE_SERIAL); + seedBufferOut.close(); + return seedBuffer.toByteArray(); + } catch (IOException e) { + throw new SecurityException("Failed to generate seed", e); + } + } + + /** + * Gets the hardware serial number of this device. + * + * @return serial number or {@code null} if not available. + */ + private static String getDeviceSerialNumber() { + // We're using the Reflection API because Build.SERIAL is only available + // since API Level 9 (Gingerbread, Android 2.3). + try { + return (String) Build.class.getField("SERIAL").get(null); + } catch (Exception ignored) { + return null; + } + } + + private static byte[] getBuildFingerprintAndDeviceSerial() { + StringBuilder result = new StringBuilder(); + String fingerprint = Build.FINGERPRINT; + if (fingerprint != null) { + result.append(fingerprint); + } + String serial = getDeviceSerialNumber(); + if (serial != null) { + result.append(serial); + } + try { + return result.toString().getBytes("UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("UTF-8 encoding not supported"); + } + } +}
\ No newline at end of file diff --git a/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/PairingAuthCtx.java b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/PairingAuthCtx.java new file mode 100644 index 0000000..a329c29 --- /dev/null +++ b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/PairingAuthCtx.java @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: GPL-3.0-or-later OR Apache-2.0 + +package io.github.muntashirakon.adb; + +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.VisibleForTesting; + +import org.bouncycastle.crypto.InvalidCipherTextException; +import org.bouncycastle.crypto.digests.SHA256Digest; +import org.bouncycastle.crypto.engines.AESEngine; +import org.bouncycastle.crypto.generators.HKDFBytesGenerator; +import org.bouncycastle.crypto.modes.GCMBlockCipher; +import org.bouncycastle.crypto.modes.GCMModeCipher; +import org.bouncycastle.crypto.params.AEADParameters; +import org.bouncycastle.crypto.params.HKDFParameters; +import org.bouncycastle.crypto.params.KeyParameter; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; + +import javax.security.auth.Destroyable; + +import io.github.muntashirakon.crypto.spake2.Spake2Context; +import io.github.muntashirakon.crypto.spake2.Spake2Role; + +@RequiresApi(Build.VERSION_CODES.GINGERBREAD) +class PairingAuthCtx implements Destroyable { + // The following values are taken from the following source and are subjected to change + // https://github.com/aosp-mirror/platform_system_core/blob/android-11.0.0_r1/adb/pairing_auth/pairing_auth.cpp + private static final byte[] CLIENT_NAME = StringCompat.getBytes("adb pair client\u0000", "UTF-8"); + private static final byte[] SERVER_NAME = StringCompat.getBytes("adb pair server\u0000", "UTF-8"); + + // The following values are taken from the following source and are subjected to change + // https://github.com/aosp-mirror/platform_system_core/blob/android-11.0.0_r1/adb/pairing_auth/aes_128_gcm.cpp + private static final byte[] INFO = StringCompat.getBytes("adb pairing_auth aes-128-gcm key", "UTF-8"); + private static final int HKDF_KEY_LENGTH = 128 / 8; + public static final int GCM_IV_LENGTH = 12; // in bytes + + private final byte[] mMsg; + private final Spake2Context mSpake2Ctx; + private final byte[] mSecretKey = new byte[HKDF_KEY_LENGTH]; + private long mDecIv = 0; + private long mEncIv = 0; + private boolean mIsDestroyed = false; + + @Nullable + public static PairingAuthCtx createAlice(byte[] password) { + Spake2Context spake25519 = new Spake2Context(Spake2Role.Alice, CLIENT_NAME, SERVER_NAME); + try { + return new PairingAuthCtx(spake25519, password); + } catch (IllegalArgumentException | IllegalStateException e) { + return null; + } + } + + @VisibleForTesting + @Nullable + public static PairingAuthCtx createBob(byte[] password) { + Spake2Context spake25519 = new Spake2Context(Spake2Role.Bob, SERVER_NAME, CLIENT_NAME); + try { + return new PairingAuthCtx(spake25519, password); + } catch (IllegalArgumentException | IllegalStateException e) { + return null; + } + } + + private PairingAuthCtx(Spake2Context spake25519, byte[] password) + throws IllegalArgumentException, IllegalStateException { + mSpake2Ctx = spake25519; + mMsg = mSpake2Ctx.generateMessage(password); + } + + public byte[] getMsg() { + return mMsg; + } + + public boolean initCipher(byte[] theirMsg) throws IllegalArgumentException, IllegalStateException { + if (mIsDestroyed) return false; + byte[] keyMaterial = mSpake2Ctx.processMessage(theirMsg); + if (keyMaterial == null) return false; + HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA256Digest()); + hkdf.init(new HKDFParameters(keyMaterial, null, INFO)); + hkdf.generateBytes(mSecretKey, 0, mSecretKey.length); + return true; + } + + @Nullable + public byte[] encrypt(@NonNull byte[] in) { + return encryptDecrypt(true, in, ByteBuffer.allocate(GCM_IV_LENGTH) + .order(ByteOrder.LITTLE_ENDIAN).putLong(mEncIv++).array()); + } + + @Nullable + public byte[] decrypt(@NonNull byte[] in) { + return encryptDecrypt(false, in, ByteBuffer.allocate(GCM_IV_LENGTH) + .order(ByteOrder.LITTLE_ENDIAN).putLong(mDecIv++).array()); + } + + @Override + public boolean isDestroyed() { + return mIsDestroyed; + } + + @Override + public void destroy() { + mIsDestroyed = true; + Arrays.fill(mSecretKey, (byte) 0); + mSpake2Ctx.destroy(); + } + + @Nullable + private byte[] encryptDecrypt(boolean forEncryption, @NonNull byte[] in, @NonNull byte[] iv) { + if (mIsDestroyed) return null; + AEADParameters spec = new AEADParameters(new KeyParameter(mSecretKey), mSecretKey.length * 8, iv); + GCMModeCipher cipher = GCMBlockCipher.newInstance(AESEngine.newInstance()); + cipher.init(forEncryption, spec); + byte[] out = new byte[cipher.getOutputSize(in.length)]; + int newOffset = cipher.processBytes(in, 0, in.length, out, 0); + try { + cipher.doFinal(out, newOffset); + } catch (InvalidCipherTextException e) { + return null; + } + return out; + } +} diff --git a/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/PairingConnectionCtx.java b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/PairingConnectionCtx.java new file mode 100644 index 0000000..732a08e --- /dev/null +++ b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/PairingConnectionCtx.java @@ -0,0 +1,384 @@ +// SPDX-License-Identifier: GPL-3.0-or-later OR Apache-2.0 + +package io.github.muntashirakon.adb; + +import android.annotation.SuppressLint; +import android.os.Build; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import java.io.Closeable; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.lang.reflect.Method; +import java.net.Socket; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.InvalidKeyException; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.interfaces.RSAPublicKey; +import java.util.Arrays; +import java.util.Objects; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLSocket; + +// https://github.com/aosp-mirror/platform_system_core/blob/android-11.0.0_r1/adb/pairing_connection/pairing_connection.cpp +// Also based on Shizuku's implementation +@RequiresApi(Build.VERSION_CODES.GINGERBREAD) +public final class PairingConnectionCtx implements Closeable { + public static final String TAG = PairingConnectionCtx.class.getSimpleName(); + + public static final String EXPORTED_KEY_LABEL = "adb-label\u0000"; + public static final int EXPORT_KEY_SIZE = 64; + + private enum State { + Ready, + ExchangingMsgs, + ExchangingPeerInfo, + Stopped + } + + enum Role { + Client, + Server, + } + + private final String mHost; + private final int mPort; + private final byte[] mPswd; + private final PeerInfo mPeerInfo; + private final SSLContext mSslContext; + private final Role mRole = Role.Client; + + private DataInputStream mInputStream; + private DataOutputStream mOutputStream; + private PairingAuthCtx mPairingAuthCtx; + private State mState = State.Ready; + + public PairingConnectionCtx(@NonNull String host, int port, @NonNull byte[] pswd, @NonNull KeyPair keyPair, + @NonNull String deviceName) + throws NoSuchAlgorithmException, KeyManagementException, InvalidKeyException { + this.mHost = Objects.requireNonNull(host); + this.mPort = port; + this.mPswd = Objects.requireNonNull(pswd); + this.mPeerInfo = new PeerInfo(PeerInfo.ADB_RSA_PUB_KEY, AndroidPubkey.encodeWithName((RSAPublicKey) + keyPair.getPublicKey(), Objects.requireNonNull(deviceName))); + this.mSslContext = SslUtils.getSslContext(keyPair); + } + + public PairingConnectionCtx(@NonNull String host, int port, @NonNull byte[] pswd, @NonNull PrivateKey privateKey, + @NonNull Certificate certificate, @NonNull String deviceName) + throws NoSuchAlgorithmException, KeyManagementException, InvalidKeyException { + this(host, port, pswd, new KeyPair(Objects.requireNonNull(privateKey), Objects.requireNonNull(certificate)), + deviceName); + } + + public void start() throws IOException { + if (mState != State.Ready) { + throw new IOException("Connection is not ready yet."); + } + + mState = State.ExchangingMsgs; + + // Start worker + setupTlsConnection(); + + for (; ; ) { + switch (mState) { + case ExchangingMsgs: + if (!doExchangeMsgs()) { + notifyResult(); + throw new IOException("Exchanging message wasn't successful."); + } + mState = State.ExchangingPeerInfo; + break; + case ExchangingPeerInfo: + if (!doExchangePeerInfo()) { + notifyResult(); + throw new IOException("Could not exchange peer info."); + } + notifyResult(); + return; + case Ready: + case Stopped: + throw new IOException("Connection closed with errors."); + } + } + } + + private void notifyResult() { + mState = State.Stopped; + } + + private void setupTlsConnection() throws IOException { + Socket socket; + if (mRole == Role.Server) { + SSLServerSocket sslServerSocket = (SSLServerSocket) mSslContext.getServerSocketFactory().createServerSocket(mPort); + socket = sslServerSocket.accept(); + // TODO: Write automated test scripts after removing Conscrypt dependency. + } else { // role == Role.Client + socket = new Socket(mHost, mPort); + } + socket.setTcpNoDelay(true); + + // We use custom SSLContext to allow any SSL certificates + SSLSocket sslSocket = (SSLSocket) mSslContext.getSocketFactory().createSocket(socket, mHost, mPort, true); + sslSocket.startHandshake(); + Log.d(TAG, "Handshake succeeded."); + + mInputStream = new DataInputStream(sslSocket.getInputStream()); + mOutputStream = new DataOutputStream(sslSocket.getOutputStream()); + + // To ensure the connection is not stolen while we do the PAKE, append the exported key material from the + // tls connection to the password. + byte[] keyMaterial = exportKeyingMaterial(sslSocket, EXPORT_KEY_SIZE); + byte[] passwordBytes = new byte[mPswd.length + keyMaterial.length]; + System.arraycopy(mPswd, 0, passwordBytes, 0, mPswd.length); + System.arraycopy(keyMaterial, 0, passwordBytes, mPswd.length, keyMaterial.length); + + PairingAuthCtx pairingAuthCtx = PairingAuthCtx.createAlice(passwordBytes); + if (pairingAuthCtx == null) { + throw new IOException("Unable to create PairingAuthCtx."); + } + this.mPairingAuthCtx = pairingAuthCtx; + } + + @SuppressLint("PrivateApi") // Conscrypt is a stable private API + private byte[] exportKeyingMaterial(SSLSocket sslSocket, int length) throws SSLException { + // Conscrypt#exportKeyingMaterial(SSLSocket socket, String label, byte[] context, int length): byte[] + // throws SSLException + try { + Class<?> conscryptClass; + if (SslUtils.isCustomConscrypt()) { + conscryptClass = Class.forName("org.conscrypt.Conscrypt"); + } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + // Although support for conscrypt has been added in Android 5.0 (Lollipop), + // TLS1.3 isn't supported until Android 9 (Pie). + throw new SSLException("TLSv1.3 isn't supported on your platform. Use custom Conscrypt library instead."); + } else { + conscryptClass = Class.forName("com.android.org.conscrypt.Conscrypt"); + } + Method exportKeyingMaterial = conscryptClass.getMethod("exportKeyingMaterial", SSLSocket.class, + String.class, byte[].class, int.class); + return (byte[]) exportKeyingMaterial.invoke(null, sslSocket, EXPORTED_KEY_LABEL, null, length); + } catch (SSLException e) { + throw e; + } catch (Throwable th) { + throw new SSLException(th); + } + } + + private void writeHeader(@NonNull PairingPacketHeader header, @NonNull byte[] payload) throws IOException { + ByteBuffer buffer = ByteBuffer.allocate(PairingPacketHeader.PAIRING_PACKET_HEADER_SIZE) + .order(ByteOrder.BIG_ENDIAN); + header.writeTo(buffer); + + mOutputStream.write(buffer.array()); + mOutputStream.write(payload); + } + + @Nullable + private PairingPacketHeader readHeader() throws IOException { + byte[] bytes = new byte[PairingPacketHeader.PAIRING_PACKET_HEADER_SIZE]; + mInputStream.readFully(bytes); + ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN); + return PairingPacketHeader.readFrom(buffer); + } + + @NonNull + private PairingPacketHeader createHeader(byte type, int payloadSize) { + return new PairingPacketHeader(PairingPacketHeader.CURRENT_KEY_HEADER_VERSION, type, payloadSize); + } + + private boolean checkHeaderType(byte expected, byte actual) { + if (expected != actual) { + Log.e(TAG, "Unexpected header type (expected=" + expected + " actual=" + actual + ")"); + return false; + } + return true; + } + + private boolean doExchangeMsgs() throws IOException { + byte[] msg = mPairingAuthCtx.getMsg(); + + PairingPacketHeader ourHeader = createHeader(PairingPacketHeader.SPAKE2_MSG, msg.length); + // Write our SPAKE2 msg + writeHeader(ourHeader, msg); + + // Read the peer's SPAKE2 msg header + PairingPacketHeader theirHeader = readHeader(); + if (theirHeader == null || !checkHeaderType(PairingPacketHeader.SPAKE2_MSG, theirHeader.type)) return false; + + // Read the SPAKE2 msg payload and initialize the cipher for encrypting the PeerInfo and certificate. + byte[] theirMsg = new byte[theirHeader.payloadSize]; + mInputStream.readFully(theirMsg); + + try { + return mPairingAuthCtx.initCipher(theirMsg); + } catch (Exception e) { + Log.e(TAG, "Unable to initialize pairing cipher"); + //noinspection UnnecessaryInitCause + throw (IOException) new IOException().initCause(e); + } + } + + private boolean doExchangePeerInfo() throws IOException { + // Encrypt PeerInfo + ByteBuffer buffer = ByteBuffer.allocate(PeerInfo.MAX_PEER_INFO_SIZE).order(ByteOrder.BIG_ENDIAN); + mPeerInfo.writeTo(buffer); + byte[] outBuffer = mPairingAuthCtx.encrypt(buffer.array()); + if (outBuffer == null) { + Log.e(TAG, "Failed to encrypt peer info"); + return false; + } + + // Write out the packet header + PairingPacketHeader ourHeader = createHeader(PairingPacketHeader.PEER_INFO, outBuffer.length); + // Write out the encrypted payload + writeHeader(ourHeader, outBuffer); + + // Read in the peer's packet header + PairingPacketHeader theirHeader = readHeader(); + if (theirHeader == null || !checkHeaderType(PairingPacketHeader.PEER_INFO, theirHeader.type)) return false; + + // Read in the encrypted peer certificate + byte[] theirMsg = new byte[theirHeader.payloadSize]; + mInputStream.readFully(theirMsg); + + // Try to decrypt the certificate + byte[] decryptedMsg = mPairingAuthCtx.decrypt(theirMsg); + if (decryptedMsg == null) { + Log.e(TAG, "Unsupported payload while decrypting peer info."); + return false; + } + + // The decrypted message should contain the PeerInfo. + if (decryptedMsg.length != PeerInfo.MAX_PEER_INFO_SIZE) { + Log.e(TAG, "Got size=" + decryptedMsg.length + " PeerInfo.size=" + PeerInfo.MAX_PEER_INFO_SIZE); + return false; + } + + PeerInfo theirPeerInfo = PeerInfo.readFrom(ByteBuffer.wrap(decryptedMsg)); + Log.d(TAG, theirPeerInfo.toString()); + return true; + } + + @Override + public void close() { + Arrays.fill(mPswd, (byte) 0); + try { + mInputStream.close(); + } catch (IOException ignore) { + } + try { + mOutputStream.close(); + } catch (IOException ignore) { + } + if (mState != State.Ready) { + mPairingAuthCtx.destroy(); + } + } + + private static class PeerInfo { + public static final int MAX_PEER_INFO_SIZE = 1 << 13; + + public static final byte ADB_RSA_PUB_KEY = 0; + public static final byte ADB_DEVICE_GUID = 0; + + @NonNull + public static PeerInfo readFrom(@NonNull ByteBuffer buffer) { + byte type = buffer.get(); + byte[] data = new byte[MAX_PEER_INFO_SIZE - 1]; + buffer.get(data); + return new PeerInfo(type, data); + } + + private final byte type; + private final byte[] data = new byte[MAX_PEER_INFO_SIZE - 1]; + + public PeerInfo(byte type, byte[] data) { + this.type = type; + System.arraycopy(data, 0, this.data, 0, Math.min(data.length, MAX_PEER_INFO_SIZE - 1)); + } + + public void writeTo(@NonNull ByteBuffer buffer) { + buffer.put(type).put(data); + } + + @NonNull + @Override + public String toString() { + return "PeerInfo{" + + "type=" + type + + ", data=" + Arrays.toString(data) + + '}'; + } + } + + private static class PairingPacketHeader { + public static final byte CURRENT_KEY_HEADER_VERSION = 1; + public static final byte MIN_SUPPORTED_KEY_HEADER_VERSION = 1; + public static final byte MAX_SUPPORTED_KEY_HEADER_VERSION = 1; + + public static final int MAX_PAYLOAD_SIZE = 2 * PeerInfo.MAX_PEER_INFO_SIZE; + public static final byte PAIRING_PACKET_HEADER_SIZE = 6; + + public static final byte SPAKE2_MSG = 0; + public static final byte PEER_INFO = 1; + + @Nullable + public static PairingPacketHeader readFrom(@NonNull ByteBuffer buffer) { + byte version = buffer.get(); + byte type = buffer.get(); + int payload = buffer.getInt(); + if (version < MIN_SUPPORTED_KEY_HEADER_VERSION || version > MAX_SUPPORTED_KEY_HEADER_VERSION) { + Log.e(TAG, "PairingPacketHeader version mismatch (us=" + CURRENT_KEY_HEADER_VERSION + + " them=" + version + ")"); + return null; + } + if (type != SPAKE2_MSG && type != PEER_INFO) { + Log.e(TAG, "Unknown PairingPacket type " + type); + return null; + } + if (payload <= 0 || payload > MAX_PAYLOAD_SIZE) { + Log.e(TAG, "Header payload not within a safe payload size (size=" + payload + ")"); + return null; + } + return new PairingPacketHeader(version, type, payload); + } + + private final byte version; + private final byte type; + private final int payloadSize; + + public PairingPacketHeader(byte version, byte type, int payloadSize) { + this.version = version; + this.type = type; + this.payloadSize = payloadSize; + } + + public void writeTo(@NonNull ByteBuffer buffer) { + buffer.put(version).put(type).putInt(payloadSize); + } + + @NonNull + @Override + public String toString() { + return "PairingPacketHeader{" + + "version=" + version + + ", type=" + type + + ", payloadSize=" + payloadSize + + '}'; + } + } +} diff --git a/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/SslUtils.java b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/SslUtils.java new file mode 100644 index 0000000..aea4e6f --- /dev/null +++ b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/SslUtils.java @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: GPL-3.0-or-later OR Apache-2.0 + +package io.github.muntashirakon.adb; + +import android.annotation.SuppressLint; +import android.os.Build; + +import androidx.annotation.NonNull; + +import java.net.Socket; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.Provider; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.X509ExtendedKeyManager; +import javax.net.ssl.X509TrustManager; + +final class SslUtils { + private static boolean customConscrypt = false; + private static SSLContext sslContext; + + public static boolean isCustomConscrypt() { + return customConscrypt; + } + + @SuppressLint("TrulyRandom") // The users are already instructed to fix this issue + @NonNull + public static SSLContext getSslContext(KeyPair keyPair) throws NoSuchAlgorithmException, KeyManagementException { + if (sslContext != null) { + return sslContext; + } + try { + Class<?> providerClass = Class.forName("org.conscrypt.OpenSSLProvider"); + Provider openSslProvder = (Provider) providerClass.getDeclaredConstructor().newInstance(); + sslContext = SSLContext.getInstance("TLSv1.3", openSslProvder); + customConscrypt = true; + } catch (NoSuchAlgorithmException e) { + throw e; + } catch (Throwable e) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + // Custom error message to inform user that they should use custom Conscrypt library. + throw new NoSuchAlgorithmException("TLSv1.3 isn't supported on your platform. Use custom Conscrypt library instead."); + } + sslContext = SSLContext.getInstance("TLSv1.3"); + customConscrypt = false; + } + System.out.println("Using " + (customConscrypt ? "custom" : "default") + " TLSv1.3 provider..."); + sslContext.init(new KeyManager[]{getKeyManager(keyPair)}, + new X509TrustManager[]{getAllAcceptingTrustManager()}, + new SecureRandom()); + return sslContext; + } + + @NonNull + private static KeyManager getKeyManager(KeyPair keyPair) { + return new X509ExtendedKeyManager() { + private final String mAlias = "key"; + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + return null; + } + + @Override + public String chooseClientAlias(String[] keyTypes, Principal[] issuers, Socket socket) { + for (String keyType : keyTypes) { + if (keyType.equals("RSA")) return mAlias; + } + return null; + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + return null; + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { + return null; + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + if (this.mAlias.equals(alias)) { + return new X509Certificate[]{(X509Certificate) keyPair.getCertificate()}; + } + return null; + } + + @Override + public PrivateKey getPrivateKey(String alias) { + if (this.mAlias.equals(alias)) { + return keyPair.getPrivateKey(); + } + return null; + } + }; + } + + @SuppressLint("TrustAllX509TrustManager") // Accept all certificates + @NonNull + private static X509TrustManager getAllAcceptingTrustManager() { + return new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }; + } +} diff --git a/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/StringCompat.java b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/StringCompat.java new file mode 100644 index 0000000..1e05eee --- /dev/null +++ b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/StringCompat.java @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-or-later OR Apache-2.0 + +package io.github.muntashirakon.adb; + +import android.os.Build; + +import androidx.annotation.NonNull; + +import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; +import java.nio.charset.IllegalCharsetNameException; + +final class StringCompat { + @NonNull + public static byte[] getBytes(@NonNull String text, @NonNull String charsetName) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD) { + return text.getBytes(Charset.forName(charsetName)); + } + try { + return text.getBytes(charsetName); + } catch (UnsupportedEncodingException e) { + throw (IllegalCharsetNameException) new IllegalCharsetNameException("Illegal charset " + charsetName) + .initCause(e); + } + } +} diff --git a/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/android/AdbMdns.java b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/android/AdbMdns.java new file mode 100644 index 0000000..6b424cb --- /dev/null +++ b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/android/AdbMdns.java @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: GPL-3.0-or-later OR Apache-2.0 + +package io.github.muntashirakon.adb.android; + +import android.content.Context; +import android.net.nsd.NsdManager; +import android.net.nsd.NsdServiceInfo; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.StringDef; + +import java.io.IOException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.NetworkInterface; +import java.net.ServerSocket; +import java.net.SocketException; +import java.util.Collections; +import java.util.Objects; + +/** + * Automatic discovery of ADB daemons. + */ +// Copyright 2020 南宫雪珊 +// Copyright 2022 Muntashir Al-Islam +// Based on https://android.googlesource.com/platform/packages/modules/adb/+/eddd2d3a386a83f5d1e14f87a318adef4c2f1a9d/adb_mdns.cpp +@RequiresApi(Build.VERSION_CODES.JELLY_BEAN) +public class AdbMdns { + public static final String SERVICE_TYPE_ADB = "adb"; + public static final String SERVICE_TYPE_TLS_PAIRING = "adb-tls-pairing"; + public static final String SERVICE_TYPE_TLS_CONNECT = "adb-tls-connect"; + + @StringDef({ + SERVICE_TYPE_ADB, + SERVICE_TYPE_TLS_PAIRING, + SERVICE_TYPE_TLS_CONNECT, + }) + @Retention(RetentionPolicy.SOURCE) + public @interface ServiceType { + } + + public interface OnAdbDaemonDiscoveredListener { + void onPortChanged(@Nullable InetAddress hostAddress, int port); + } + + @NonNull + private final Context mContext; + @NonNull + private final String mServiceType; + @NonNull + private final OnAdbDaemonDiscoveredListener mAdbDaemonDiscoveredListener; + private final NsdManager.DiscoveryListener mDiscoveryListener; + private final NsdManager mNsdManager; + + private boolean mRegistered; + private boolean mRunning; + @Nullable + private String mServiceName; + + public AdbMdns(@NonNull Context context, @ServiceType @NonNull String serviceType, + @NonNull OnAdbDaemonDiscoveredListener portChangeListener) { + mContext = Objects.requireNonNull(context); + mServiceType = String.format("_%s._tcp", Objects.requireNonNull(serviceType)); + mAdbDaemonDiscoveredListener = Objects.requireNonNull(portChangeListener); + mNsdManager = (NsdManager) context.getSystemService(Context.NSD_SERVICE); + mDiscoveryListener = new DiscoveryListener(this); + } + + public void start() { + if (mRunning) return; + mRunning = true; + if (!mRegistered) { + mNsdManager.discoverServices(mServiceType, NsdManager.PROTOCOL_DNS_SD, mDiscoveryListener); + } + } + + public void stop() { + if (!mRunning) return; + mRunning = false; + if (mRegistered) { + mNsdManager.stopServiceDiscovery(mDiscoveryListener); + } + } + + public boolean isRunning() { + return mRunning; + } + + private void onDiscoveryStart() { + mRegistered = true; + } + + private void onDiscoverStop() { + mRegistered = false; + } + + private void onServiceFound(NsdServiceInfo serviceInfo) { + mNsdManager.resolveService(serviceInfo, new ResolveListener(this)); + } + + private void onServiceLost(NsdServiceInfo serviceInfo) { + if (mServiceName != null && mServiceName.equals(serviceInfo.getServiceName())) { + mAdbDaemonDiscoveredListener.onPortChanged(serviceInfo.getHost(), -1); + } + } + + private void onServiceResolved(NsdServiceInfo serviceInfo) { + if (!mRunning) return; + try { + for (NetworkInterface networkInterface : Collections.list(NetworkInterface.getNetworkInterfaces())) { + for (InetAddress inetAddress : Collections.list(networkInterface.getInetAddresses())) { + String inetHost = inetAddress.getHostAddress(); + if (Objects.equals(inetHost, serviceInfo.getHost().getHostAddress()) + && isPortAvailable(serviceInfo.getPort())) { + mServiceName = serviceInfo.getServiceName(); + mAdbDaemonDiscoveredListener.onPortChanged(serviceInfo.getHost(), serviceInfo.getPort()); + } + } + } + } catch (SocketException e) { + e.printStackTrace(); + } + } + + private boolean isPortAvailable(int port) { + try (ServerSocket socket = new ServerSocket()) { + socket.bind(new InetSocketAddress(AndroidUtils.getHostIpAddress(mContext), port), 1); + return false; + } catch (IOException e) { + return true; + } + } + + private static class DiscoveryListener implements NsdManager.DiscoveryListener { + @NonNull + private final AdbMdns mAdbMdns; + + private DiscoveryListener(@NonNull AdbMdns adbMdns) { + mAdbMdns = adbMdns; + } + + @Override + public void onDiscoveryStarted(String serviceType) { + mAdbMdns.onDiscoveryStart(); + } + + @Override + public void onStartDiscoveryFailed(String serviceType, int errorCode) { + } + + @Override + public void onDiscoveryStopped(String serviceType) { + mAdbMdns.onDiscoverStop(); + } + + @Override + public void onStopDiscoveryFailed(String serviceType, int errorCode) { + } + + @Override + public void onServiceFound(NsdServiceInfo serviceInfo) { + mAdbMdns.onServiceFound(serviceInfo); + } + + @Override + public void onServiceLost(NsdServiceInfo serviceInfo) { + mAdbMdns.onServiceLost(serviceInfo); + } + } + + private static class ResolveListener implements NsdManager.ResolveListener { + @NonNull + private final AdbMdns mAdbMdns; + + private ResolveListener(@NonNull AdbMdns adbMdns) { + mAdbMdns = adbMdns; + } + + @Override + public void onResolveFailed(NsdServiceInfo serviceInfo, int errorCode) { + } + + @Override + public void onServiceResolved(NsdServiceInfo serviceInfo) { + mAdbMdns.onServiceResolved(serviceInfo); + } + } +} diff --git a/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/android/AndroidUtils.java b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/android/AndroidUtils.java new file mode 100644 index 0000000..bb679d2 --- /dev/null +++ b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/android/AndroidUtils.java @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later OR Apache-2.0 + +package io.github.muntashirakon.adb.android; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Build; +import android.provider.Settings; + +import androidx.annotation.NonNull; + +import java.net.InetAddress; +import java.net.UnknownHostException; + +public class AndroidUtils { + // https://github.com/firebase/firebase-android-sdk/blob/7d86138304a6573cbe2c61b66b247e930fa05767/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CommonUtils.java#L402 + private static final String GOLDFISH = "goldfish"; + private static final String RANCHU = "ranchu"; + private static final String SDK = "sdk"; + + public static boolean isEmulator(@NonNull Context context) { + if (Build.PRODUCT.contains(SDK)) { + return true; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.FROYO + && (Build.HARDWARE.contains(GOLDFISH) || Build.HARDWARE.contains(RANCHU))) { + return true; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CUPCAKE) { + @SuppressLint("HardwareIds") + String androidId = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID); + return androidId == null; + } + return false; + } + + + @NonNull + public static String getHostIpAddress(@NonNull Context context) { + if (AndroidUtils.isEmulator(context)) { + return "10.0.2.2"; + } + String ipAddress; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + ipAddress = InetAddress.getLoopbackAddress().getHostAddress(); + } else { + try { + ipAddress = InetAddress.getLocalHost().getHostAddress(); + } catch (UnknownHostException e) { + ipAddress = null; + } + } + if (ipAddress == null || ipAddress.equals("::1")) { + return "127.0.0.1"; + } + return ipAddress; + } +} diff --git a/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/android/package.html b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/android/package.html new file mode 100644 index 0000000..704dd26 --- /dev/null +++ b/vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/android/package.html @@ -0,0 +1 @@ +<p>All Android dependencies are kept under this package for easy reference.</p>
\ No newline at end of file diff --git a/vendor/libadb-android/libadb/src/test/java/io/github/muntashirakon/adb/AndroidPubkeyTest.java b/vendor/libadb-android/libadb/src/test/java/io/github/muntashirakon/adb/AndroidPubkeyTest.java new file mode 100644 index 0000000..261edac --- /dev/null +++ b/vendor/libadb-android/libadb/src/test/java/io/github/muntashirakon/adb/AndroidPubkeyTest.java @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: GPL-3.0-or-later OR Apache-2.0 + +package io.github.muntashirakon.adb; + +import org.bouncycastle.util.encoders.Base64; +import org.junit.Before; +import org.junit.Test; + +import java.math.BigInteger; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.Signature; +import java.security.SignatureException; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.InvalidKeySpecException; + +import static io.github.muntashirakon.adb.AndroidPubkey.ANDROID_PUBKEY_ENCODED_SIZE; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class AndroidPubkeyTest { + private static final byte[] DIGEST = { + 0x31, 0x5f, 0x5b, (byte) 0xdb, 0x76, (byte) 0xd0, 0x78, (byte) 0xc4, 0x3b, (byte) 0x8a, (byte) 0xc0, + 0x06, 0x4e, 0x4a, 0x01, 0x64, 0x61, 0x2b, 0x1f, (byte) 0xce, 0x77, (byte) 0xc8, + 0x69, 0x34, 0x5b, (byte) 0xfc, (byte) 0x94, (byte) 0xc7, 0x58, (byte) 0x94, (byte) 0xed, (byte) 0xd3, + }; + + private static final String[] KEYS = new String[] { + "20686253007643618731996444194404343867753606615063450617882673005393433941259733754887313620913867473991126076227684529071148116710971688307399269161393680066708223500416626973582453156074796463551655506880263757530692111705709492670974173865966567229986684076018218836054376260921024258822748029571012424062161309155451659011612585496480130049086533401220131725127467783734498206350584978609270260042864036755332689164701948323567009422782353582299103998793983781904622199786127904484293079447215050401933233201140660611995982950449318766441491470794461010754367235997277784582260919909111021889463573881293340673638258140979906632549357292907467007755298711750754693102503358563113321739768558800007075371722687735888311697725543005911629713945900921982291726988822299885412290333597947505237030964811359845572308590371290828162825800693816284267659154250460946638141531109051703958714241913036543711553816745407369784077289292046547482798618404863636910359464490602947766408857942988353632212055739706153132169632236839182513092231542800002960492871433639429992495815686493836203332828415821398124085709593551180637824827628263278195555177924931666478926276783820224389307415162160245683448074970045831874158357509710640316611667245663639463489328681291088128", + "20686253017157961681070131581867072824582833493615686581285202812978323130952408474782337908483014193592959801158288937338861941384391935424181886115887706116180211910326203195827988548568848400075560545658694754072288281934799100824960003665469676627306218542649151650279594335310005774745074102233566034401681058875954456976451246992673780060830565203755535989743862421541016487655599803066890290569271073410408457924901854658523004566584730402488976324736395344746149490673499453625232592526755899773745566599094231998380788633185151420348626744906833795805930985207848484533950617753433631247755579325235753032891647748010767177734536686383017861046451119520379951845678816861944386294035286574657104145896837994175074910881605806540878194352563352369751154950231748036893872855863146059482454816118277619882879924798566063675418426269661488345544005535393697052314311247263890598514707311723166476167962362723441242864747039873917888833291982018201334656703016344691467362831812856230943332947764107530472082685276225170871545829843748520918630697076657160036883930216256677113853170428981812392602195530517909206428574912517211805856768824444514391062511206137932417723915871095033856634183547083377240616484404928845477796622124210865489291636384376815872", + "20686253012016661596297187165295953810124840916410787780259125355974730724901079696472630368937851048257090320232652157100991308376039941481829068380105246658346925724672501337521327139011807932361821638555679532028068537874220234967686261347630150684757119393835764732734869517200772690364990583127671131537583714931243019173197423018607993303448083354399470192423616983206536107095715775647406269008431508946794838483788727203899942491271993309634327951609153908662621681729016801610515674359693917283475097384354342498810325041161909134028476434295385254073752420814778998269287749533881374904934126049438895801381045118590479396113271334390254276279559056023087328573647647215037607856427456918131285395255693120064580259556735556512377323876432429678739578757363573131334912599024688462135510569451655007983978712803921393726583392694890019375462303417604382964474778937531805444153682154861176782819373335110870826625829171651646773939431655533573703527937698849234129612444879578476798107659804571742743573317338460943463599693739087545180845307099319818906850017269705411337110447078163740825844263091960377332261204721538817750302929778362322626050195563553439929517852576974112801805532465084720065825179978157459297137449086759181900619029928059339008", + "20686253007164298102187579022292645782622950267376457918101697669358007301488761837295142744126519186355305488389221986154518328646591249427359811715893827299671450881507028164629286041250862175310771677831707558127385367785197803483575096787182004608184320944234595728063837868041235349956855438493602107933402900375680275580658634153323631657275297128293064166637994755918951415154912946597772260559048829851194391800720841779417294685741008056484437199267285946970880090812268047882017049497063496655486104722944355404468824860822958344153152666873208144779124031791189998000506056940151806004196000409215856732235270100083588540707679527939391873638758465419555753702157561970233144942307459852882991188472466621152506161477788274973160814417916904823923993324434775898693916605927490225550209649971721585529738473244554435841443463085919856039187050589485138795602302083255665055214846777613999233572727226441658259782563053572457091354281339853515214717324207788284184709742610276702868576013972404040386528578798398612655692848123956484300353770097737367273673338660759524265042605762204830694090505973135478745543215676846804149964034093456987246386704478034104616628251724608616498482846759154973645939865112578396682953063481438609050519636632406065408", + "20686253013631672077140860925430431681294767795873158523291037328668344494856968639956583809600800609243074268151952075506986744112173705954028708402476984870394191960873402354883924141967550191866699568842907604201375691789447875717904606519911990426542474202257873447575301056799275687300162520647747107269726474224871972583841770132600021392824170364479997596039740110453703092023353509635466916728572484548240377314945387893669640571134322168573530937553272534344026352868086607470719702834083444892288110107409203387500645304783564563250097472203694663921776055854765509655453678476671839476460047809572066383800073578102412448677588127686456580476332728179819886722353651757881477563060082645588924556499391555273484226830089405923826482032061727033691299171028963180078332147031372667760359558227049050997025606644301016548127476540817805335115255877601761512137427173611440130583277311855464175578764391247410560993954608789331880719073041849580910172771756479685430766451208229539612899065008167414362054168182076054905614418672686595489698195205424496943265168367449853961685536803353605862503830213266961051166928463688349071936538327772591282503905358730343548547940428875061176999789174879128250317383032697059739647594262479435287070553934024147200", + "20686253009137100677580536481772220019834185053674835675104805567705108100012120793382980473459675176967509777404926949610065001902175161695962003614967789069678586166752499518706455413722715678873250863436364658098942388406798677103189295795606470644008251449037179794502035845361840650187691172794383123040477163218916023150334807750836446866590977919532470017535348573419966872388934361829146368421225272657444717715086585757479506344335650280743058062849751597138313964046571213056448713724664410661632559255622391230727347897637119451531510420187884140761879388980580251574156491409434892603700316490405451728370661830847139541500680542314851537865497120978838526245028564455048339534705369938676962653936571813488824888264746297425445127522661324094850525422265769467214262342247384649233154604849204645241734493281272153516726661106475381716751539505876924878328383153759965309983960685978480476384651288642156631803739479287061925347068044278079778523599362177880625669615321469494541417207707795112792614703988712865352607326208856280054996813476487679520479619834119561518751799862129372840860158171276154958342280155058027979175914132253409673754984859885834350221155842157851019202903208837729538330893332631827655821675906374426442436139049029730560", + "20686253016517652578576226218037280304512998878967782081643030383762252648179296997011341926638803523834137938978407145354361840964277764429298580433833879753046327333287664442011756674514375099551151016760768951640623954608522240638785781825523830834849040469461889966736539399995075932019080426966535734677817177333374424187501199788948653236985818361084089703379091583995023155469499841274582203201561305334645372332402890018547968670807123139871175453983341212876440749594027233646858105401856148507349000386220939016569850622488928617382330345394648656509732599042249901506581310905883901959534984505897371121911753593182717796270161120916944128299041339386928147022131549318082428289605796149115464012497661997457808599992494790605129095762340900378198216634471902525669353909170959057894014738736417606036662468401786737193290238560507341770145447091177773612273526207707414967862580865925752359713241893890750323872352660127060159105979208005350694984418621693785977972962175850201555015110758212910334258690225997862373112062500145196188829278217998167244761059788293227986694796302184050693617864339940026112434075085598165880397454033611127221858601971082649842623914964304450321042940327685124173187185970331014916661615200988009485722532416914981120", + "20686253011995955629076325009649943801969349915799396834999938713706577453135666219827378331078791305568238767012603523685969479718725348248703275237034007216573140943056580311779171211523596323125369774697691841627573832408640321451759775024971845341190607371902257208390902094844221754727824487661732047560640579666147972731827393052406954025855636608759486135987631984582660021446965995410633211966029298815507401281475655895406689670177716825267171265721675030636564325187593177345954853929509007143136312590872799900706331444637303395615294877562776780224309357978460277807368192971699424316405430599226137562559166681593644350393134128404993203172540565405337112770214130741644001329050834501676799331704650166273747915134031416224513404401863940821611218604578118152404764705070475345051218888969396184751106112136786410948127273335981631264270134095884196287017029694382607779915472082121808328197672918175667411956374486872625493883365083931491511971634291156868483178497893682614629335075497305499292286022408321989969349011048118695523910315223143950295729792988112393289748804875597785632720969020601590483488192942403773245914283442681067607851920760864623779136461182039085569209069946423436337464957827499602113031939666405499335195577103935340800", + "20686253012905249420753123641394125581338490431246330974155061264735030723090537231131634982701543430151111947340098713400919665484501369074291449678715950324404084971416405468246859755401939519930479946881325066315343108805200689731279192503717023845106229875664749782703938485867918135872531314893451199777947488701264763722901060781624134818284452130770584902522697944415430677834037635796007590708428660357263597678671576847673995935030932886476827700205482688638424266106169588048237006500162050678096674641748475495896688241776715995439349517453160862577278522303148756730047750797909055062592477399585648804967165274624689461786686029846025735530763855449050156214748263389735761523970386426501083016336782069534800132132814441821194458883018197602204145921510758387553720734875367542620886529149295030141428678136073816645318546217907182935719212846985527885268490931042846941843895577575986868990275246964000930910996610476509616118972208655613370459666905798364207165929375677455372211850892032163428761255574831943581170173798962498310302619079810560252634395602186653677496588976269539691991643655645015285991996519815871201426765673211289585535433601874045698363351158020555486412212492888981581014428858434326634365544072244470887968780251830419712", + "20686253012482713944170510486006503495965426418624979215207888186336584311563125668176618892341461495958989530109497058836208707044168245279695940788984032479278397649608713044920405685548592937000089068732569152566103178304391968262275366634982743672579525342222333315935193825742608586653438687027252465742150192128141744663537027469859051411958033527779975309458836521627355592244019941914250716050720601976679339560457780808022948385738031590113665471354096086486875697653599495164469297979201195396357751415927806268197841979271997133417306549038027016271542831330284708665839765584145061136794149016409901583479427347126229617073748763355410944442711689578134558428523469322867841307165512505150561440558923516413487943512575228004609949233825559134736566257971598139983362643764003946061810478982678081113673133692828469537444314163454166205388851192666274080898281014667651235171324569407150427688362370268502748192144621240298284168580601438138025554803091157078261255439130748192811252524386962992249383432688422311292315445533782932932345446806388697895661377268890467142415746788623768632474596533761037877631922147784592888033960567149861018268485072235834034618474495971824025999049529331906554158125086921199432771280915863695896037886622831739136", + "20686253017596800673636777315187534270021266420333274774530680374903816600749071306262945801692120725976064051984138994058746162072937121793853747964627815552811045393684927849560459595356986505364881801969476935753890194193700810599429133930398705250755254836009280177917842707354484199908531777573893797187673907712955690758349411755815598103902935655017090298516565740392457105436565547185001255070561133895788176000782782281412093088710504721088727045238362229707513145743387163755472103144359822049806563915934557475036319622688517527471037575232699010534869068383647539493972360025529780544812641848222048512592585298352948703198597172454291713942776468719578698387846171669051363353131159533380505301214164797646016372174811126560901763198906646299690775898549445445030438862050154367175654798908548309949046431701706518418835907611144671153892500148829267345643289528684512615681494813120785604119244868024564867462454807660897301092541915645564379781995052629376347977326432306238090292830317108743463398855319724461757906202501054760315093634564844367373695116166888912389471388175972952318344253589126281155102370809690828946212895190035082791906579557462335096539646297577735157137081490920968937162188359746613039900117481527765337456961996600639744", + "20686253021670819484641397455235988893189004357659986764689257803151409177614461364649970433006714126825319428176308950177181495096102292376641455488005607469636772210940591752734984815566464618433768320892209283008301650256134897180639126904431683668620836360770522622987413924299753298563911962706925772038987129402614215060442406183258855362002322270760435388491304782642695492897104954057313819686015892539037035024067606433686326406317580126032026561653107203711784714174553258867988922391584823277517348441610017887616725654033103467290522569906901516453939475371008093949281078143911461551090436073001946185740446013553585657161583955882953841177944465026376300855580949947151004864741065528954219420584830087663819106583270144309975996802314686009200802567965571029611087066652726123514794266804514760076981917095632831537957124002094177446121777714425501240501887464206036422959211832442482836841510124546833303416051006026030814775613976153585582885196096288806254708297796542938960077290086802302255719602193211623734787514863004972159363553648873516823809184020531994302661726715338441496144201492168521781315979852201195520642908900413827363866480500192561642524090584879553530477247358859671792789145550860764422409857398848511879706827894470738176", + "20686253007779418538146435611960103480111665071547886109640735401007273574064776615540701179896428377142052442567006099697162043246765708980976145988576633992791951978052644497415923662944617922884643981724695008383893280530637173581248946990739081903596261263913018197718895124329861431222284653233340024925156526473018621659579602661924631346612848404894851502528305401866261493226279256928046285934986381048052034850172444367408714525087992488005859355731486073285673373650699722735519641713322832229543168311432490222848569998028342925773025050691460124263527225809542829858215777298289860007795297848906118975152353977610454216097635614989263760656961019437297514661609993804271689676470502860376666591936653476564447284287948444996394814992534945211106658510902269472032893555816145339327183535542766040947125922850444024392962311389749647694036921114853739217072675694979896955780825486065012334086560739968331771161859717998929049806691359069902247801751447279973168812586166245092210343145733018596567430818598988422806423359671179738425327829924055793651209866185908863272645671672053161275134558190614726280574493595200071091284480123481958235975875823917688354274132904898857900963754501168982796149653431776796588769427548375743948019813104125739264", + "20686253007604876142864868042280524165402934179882610914697720762337073476216917389996124773136098970057364791099676488858171958566606397819353213332750920419342798801931043028447913975575839783079948872757605203684602892225855510703027538051098416020818203339920511893350539925823111193247570595010592164929661686244708062422421354240082899804498188803419141823510080225562148441961167143787065210061597742895645200944179873692681009532549500390826790962525777523907581122863044028963918723919775810059776782603566022001398041618219382359180430108215466147420894596600846280899658544993640061836655948799058416531930107797645597300743718230009291894571536654250713613225292199432580280761478601571747214473410393967212703089811962847476443829426705969655282817199293842112005075777464459419904700085996691611909093195963267420749267548377551222829489604133422219862110546274841495019087571956520965526972054781444561101065107965888687457231748284726446965643182772550915093919251096627310403665749159263278352617835127791887411059400056120654704697281677759462412423949563123799300922337337031434011248992699167653294572541741943792138821657961461481276538506021735660790599721725446180641231442745067805783335704101764512016456627263309212202141050291403882752", + "20686253007021608241689663659963106759118723298198840826493322751986448442693666027764988836450224647331743582291670090917210382327270531482163927227988223092974790331550687752773643667187876554181907548400902488924327949585152208488928230350565913895512207957249179245514039152326127203620938403901730645311641228704710362766137452100607502345181504058992226565546374405238584328644321921093037858527565125084661975400406032983072371091348426803460064043823708388520703550652014817583643133020229845020242944346824617584906011027684828450454972028242671768211013258545366598182329727396593002246615440504964904310838157610000834126195478113929502258505249404659889702740225008795785252745301900749237920017295319107372211580703483426556992675462580169514689033492702354908848878614127325367288349925054680142050382109615731173807465659057525671650993994596156034190434249834629081700288210478883667431390577000482807339023985920513954187158118910393481098591489866417654826052352494975210865965568210016302565250763960252121003586393653990260068738875254130452064019745948999144292814505789232823453267156783611177723108336077508126670001743428554516943943698961609321446420452826416665860184743437533100494614025619487465731926709595422582461864066896099279104", + "20686253017576328604397757628696916976575522581262455658158084545267679494101813744271361066833895727275999990362314163487589781714736086134535643149499659121815284171966664818069832388563106430729424843320170501399681992962816815405466327181883993596336326588913980995040153015039842498603793624311027080239766082382138185011127721145696561736552179519972972189860686192212873871541534333569298609110022068676249161087325016973188219381193106238748350352370017704856280849939084182123474167745313719296726857149643911426423892259144920430332447233445727394410596908871121943691683251347594543016832133353836309946329620336278195398229250583694331105065483574747460477844028959296763798522682650225035915279057821815082477316716472233093622430236186011224736996806782238285457943841178908716320629716681607442109914452439698030106201073955996654282803838012221802786122722463668921800632723654622027772616422218197375832565654227089919843040086396119306096757556190008760084017430688032081654953292207865443621116020116320243741976393955557450724420382106551748948637361361237424102598662130622446951212397620681130020351934387640468968673784832844072192051121879959906157803839174272822858710610565971349380572855080734703266039110827437002755727257645879394560", + "20686253015632194446964549752865306095675796992984280836459827244594988985728341929467828908174136317388413601759640625700697567744571888535192920357933899911995149737015137803918541787810386447480938150007979984323709223630514858926018913313903533635089468154621298339064447275675686499058206570127524798025633713627136893628055375633970109073821185460898039856680258335615963791682491988980277498832573429849348764105832237085843498643498195695163785310161310344120423648634988353729865581801558283805742288660458122510719188397548085580202887930607910533534516693933657267260780859925051022338611693920561790642016487656354781128170103744324893680564297782645461578140938361264816397758641997289612268599215669012143301913510494792757326575404821915282598318350394530566425173158364070432671579795603645149354325381596532039075427958043639242360621114659751231872073036120732181043105972973109788769983560711082730917065413254697951492111991932795350395291042777547404358191506284743875086602012076691203993957228415814341341317076567346863011063363884150995846166969589047317632929392743149362575140604429955129918452363340274074095953595228692113309367060073180152866610193934981631436082786314283160713802768267996895973676591655886961137949790395651064064", + "20686253008280076434063627792976585679258240901117771774848183680897991358808598372294247945349776781930725891159660642772324594460473591428092423907922733507827637130679002417108815594381100952950780741331843640801790721759935458380251498677194095462459535511544103447989319232932465020496572704531324890159727609395394440487675667733293698176844674240132373049476798001543842889891227651621801389443373039936389118507727414268889974019400734622787942381724850321036295849698333470872363731655908573604683885345765197680079122655638597424255830729241057473120238971524652052886581049260033714044170704671920548231649930751117900234527322744641536960791319040850129139290431448454658633022000433479483039128614980515014378005152986215764207972187867609137628051392176711451953239313092106278507175222152806081191880959633861865125667318317791167731714976299042173744637249086661016974783212613211649879521639713884566600223256092655930849081112415596892313717463722708665626233145587935660772625224236074541613317671527221358656944545879413948646453563296121659821042385880073296612404056486812912561838422746761742255638925665021058593318024264673057295116442867443437729038595456425369615349800800527803917805871133175616209136030135179809038417225106990301440", + "20686253024951658247963784256461033211161677366121981865435929241662537577939141826066423582382620272467025896961525533649529834951928815732696192031254242001310976196395313722735262686979806326419012885189967369774411212706004362512392149609842235706677304433133443952583032520610040362070455319152450494660815670160633796512952042902222316108338953442388922875611033294149441219453308994445197831263244239682760264737410367007220572112879442727200430192143800723337291968248880476611034380579121992553429999072019510264034072224367169076681647830266796198698184376443826044285160611787998026826563875013778342721603031807058331671932037301518976354062079082150728323092697138014541325857426045339923252246197903880065058351701199295574478678901159403090260453086693563410504667697340773698567005624143430980337698805702319491020156284314353992648718466728823214596123324888480179957675142649621354384527020551369805874882218433548180164260225089194029633825464003674135793863996433213944915953458051073341886199898474985986664620533716615542750212008869522238677744797202727258018045172140382145668069884754503189079041670422324774317816739968675502203964561699314302235308312748370659797007514871638586017968916729509284382607208921962626655481606857704866048", + "20686253015329051263633614301062841926780457793244333362445652976039258394085904409997569212036513569627020122416105184089156035648212037623378445311136404144169674742143343217136229368645245696403751711017904206141255246181738620208604563131352355142398183885029711993169895631312556797474335190203415666424731815815318567886625912492670817003495147968334343459502296653971351530810954197377406607923523701655523747303747572405902504136614192793569878303382172717914572317866962252666818690300625318696007782616025654101394182820792992039050797265658839910245551706198215350312385971886655288470117569599164586651540666084220530837687983855363703457438523945771377188384096611403701921732343378131772105605616111230060395767250296130321274230574922212950953525510434760474902956273375775869424140206127230345610638214846167903417131896656272090379493970136500706006844054279981738876197283239177788834113814444218385342970502089028750415245685729607184037765166339498069290010361131234888359345071611741250271620624165209355771772144843622884733754033577735187746521804131583913214093166309888724137377435858133185966519694264750031605141214041348529307409532348278522558737337691108243193690034235284716283236278857093221201219489501714217369972614111881593088", + }; + private static final String[] SIGNATURES = new String[] { + "8528503594670204436052357015928862792506358113369106777406225289021120755325204284915095461436534593726527514965930392069276752206673637292335104066658988404928981760593418142871524780306395774215697719603009673950459832507052700686367128506073190142373812763000304886902929199810111204681208295515597746727406521222365667866848729045254159801613868059972975579907523041955922782192361370263034597048555422592874100353115430271270939317456318510451811646267157137577724074073241955677595103004166831493027134464898153615411682508571271524917051485420113620183698439910489332188676948813054298376589190870205376721722", + "12714752653171379071902223518900086455469254778099176783756546057501252740388760646633111438554988457804110827172122185741643374376377476845818226306550070549540100924375173033053872725592206270543865057909506204686416217217175205975936864769675359443835464932866652953564668335772839380415278907842078521565813907913565738769303530371937587632865012931316702492771417680964122472313546679654313803229834342261250191743408149738051095079442654981806457495648490488615580878652623030639431127849097089635678230782461813874847730298433045763073635235796063186457060470168072867337516295219377274643254214166187741928195", + "13908999250565370485651189816621604309406838390888107327583648160922258563174890896666525571590573441122409145906761904814530942061844184451657972503856070661891327123507140696829535452282425270195643203742925435202847440053652045520143275171734902378485911971935113470515174453988792439225620093679659059409128896537294846575246782278559049832955440237015381818190544157681329515828756452798679892374749946754033759443639475313334060671371142984239868008118691626803951984815668325843801784709432199331447858105577423245817671538352849819889855177911611607915665983585833151320422174088007859931381348016319265461694", + "10663928803538142495874798753524371044109713444112476166920999738661408452717517594386747916849812868865845184227592286809206405151706316864404978033443369238163211298053129657897912511475878426734168668138448142684774180483848489762892194047712675443661602073836529221861198253894032646440612405989149292046072809162239075060183622012102135247005904791258725387249405763973030121585327387996523577075440922630211242753391888083653575027601995970547566022378966563679332453413606097684906558136475009659092263370997631008298982911150670427099551097448345541506637646597809408731612314781030427962298783217248914223806", + "2306715670154317872337078201496706067018016875073166641656805679181208518844313408613041429049277142712575268846333720788389889629069536193483907615434194600534988985015385608627085443890672124721486043133411014103460576453162713180941943637321699804181949641374502608547423908202551134802437475178385172142729079080049166263881079442027408091314096230581266354997604485294706409288693050419576182967429750806152579311940537301588263217818952406077159490924792302551188620989397020490518323111334752644091498632583425115828895844183618266417719447627326662269293970039782216019092059851746977085656284304341508057993", + "4659358238164596158948789743224240069711265838365302036573085292613329291682190238495190399519475812546652724862729777463345410534406299261146698284440785385833664987236353362536409224868061055771434659441519724477807214986173373107393432602854768393738777892796122503107069552589289874263521713955968508746406485296523435090470754765084421945615052354670619502660960366860227870952975168583254800217898305238191480303688181715819411899720342242163096384819366364486118817498794842558871973328704472113837172120537734616429530518033340109825721160272151409573547421252939751876457633571421692240302001467345028442601", + "4049477946498817875644423736582614129429104652925293903578491350187944109357140281079215461385310310227699148230226103813511093858314731577806298771933039828435988536582683795573155489962134767330360695808904080698959547160137535666545619466761042007784679323603927971926202759931895569401425552243257170909460081819203771507281795697451004296311404365704082156689073796657538886169509765942854790434677625126588993604623270447590077611239790400219558927531394967281599304514894414627660359455930701315462448554588404533268609540765516347343066315049932609011242206592126920115645749055638930724732121135122116141166", + "-15821074705595424775489985353461364659530670829708431022534131332700730014024858879661015439321392526031983716218847190089100850526695320564737549648506802838787532008475561270345163817098663400387502475397954072183343244145179711236049401253605959260401466676172293703977395461139502392520912345905534309764600115973777897708515531741002585035454835142666002006897210376288419070871163424306698312361191916347081600355010527694492360779722717717750770230108246572032092508371533877677011198486557314243866055301211143049322569030292978261520962920278653892612567676627225563117772597001334618978059073836056716669225", + "-3253944899977321876942783783337240903554123129096642235321635334925673112914508303442241100990114967311556077951307727781710343006400897352395067739623245112414217140757020483043902863652615234151122932808390646136142553799710067271445726854624819815642073592109754781288339958163810012173119634614599349819358363736874502076277367766337257137053470157924537552941473944009380652976471624978609651165801832875705879218057300556705292310854083054205343202526672473020368237867322973861758935442876895613369159646304355164238984667221571500917386264593984463607981687284420899772355044207890979293151892151717827509819", + "9499141240577043451589632252552399646757731315654129758760015349045571672430739028949896539759619921365912271793699639488523910752170974284878504952562109143512853537668435378131893436415070630973505762192641967189753052893201272349296728232365714025670987726850356205941217266924338273790152610590219162996666427392130736201968771225650743588893284231594006554226911151399044815787836234434162548055950690340366254107040568369155820242765188526434944719636880243193487042089094702078107054425645211148742327810444676596010608038011230808463633129576609253919361989917628880939682855954355300546341301909315104641292", + "2785829464159962785792464154214301170737801888846376572507071703868621757492888529934734721293299181626548638776917276845515937348930186241646686214395195802058604838439131193816292088810714374344194956806787815938869239553710725569223765884297868657637234129941827347968009169909865231403471928705479925843478070964175560548021424092972184851431376822977172730546215170545593072337665216944481311725273881895390870402865177526671062318915225337990250246626982564561668482752340309344623795724163702528068799345034816267947632414651344549931495044609876171529597903856776762161922406306755255424075670945233876468235", + "9693399573277126168117955145498856804911026370939734084616110928720966781810687706982467213763886301391323622148228097849029578280382356919106089289850794524605638208088414420886934380985144626389853251740087295754111363849778298354982244500232225340060817412736334732343104887783650252691586118114592726131559322014895783483438914376642606356658629244848851203222766693264106049037206515886724418160132270092812177670695280590828717742154134306911500638045109639882998686085462128237114003344000744142984004964727666767343836058943694299268149858387245532882552313792450703351859102940672866025492125862734810702868", + "2588026480762077299880435211630462130829042494172803139844752929165151314366576349692479708707902818325002447901174618910914735813021836209789642281992964413385413651230009333804422618970344994918374367306597128918353293939655079898804834272495466247472976936750289217893184038643984663442084787174472727556425006629985118671735361758704886890235923409705820370265894413685399080693702003337025366946813158285546498272869112876242928634530360165484359119404508056677189703714418493887546407516490045931543871651300601164453872612008839158278878642833640818155397129455955015836831802614094386547616288007668519396656", + "-11728748857916220938525563370642851174888323460551443012033711709118634455694860641893644284241538905301760022334170567702665954472646365780973999849934512640749241332019089471035867766088117990425231025030842560049003944730933840471815845647939175446669519176892871100667327144122288934729242175451319185176733723593770503584081246166626636645030357166311144861050296775574567995193508629237812544699293985433002148216129743645932049717574867996068064127541167278296952065930035012382421475825376778590937794554225647092055932998039404832926821724386093261449972691735490787917635072888523885575813644811005358833688", + "-15229937207665399077858745779056928142076482322535266981438711930746572914030457988461614013826308364893535928521195966837848031188929394769614489759198148009387852082467397161392193074580370561838070595801238055150547001898037272379177792186097373425590642040064504319592980875488946638406365056236476661594999049247103250079448049760186508271091630332833596279458697068078832218087075544964591498372812863039399793891647438843170406180128852474289724184601066049404074247449524559845321683147746932532594721096776453826585995068663187778354136614612407086729935627102933428001169544176494702260120088616177094122596", + "4423817485097350932424418489755694412699593088843423207118652642017753124828670168023683986663483348010821563740973349339039999652593572489226634061594354003970858509410018682617414793335864951696251946617385611984536476771260728219342233573060916777310319872368264904534560004743365113463062419519791519083814132572592001715626074081892878836026456243524878087931315541387440248693791684485700635565529131429893772243402800191002799200914843481323690852313588145236316661063827783308810378121509659713394573216583671878018935266378492137948180346291679018739284608191825337179228499660670255782455554732811032893595", + "14060727610797171065873730743344628826300688857981195853338897134682600312555998942680998373000530961057721631672534399458386502204866782136713687245588626842312961478612189645195715241803501047584717823837224418642078891142575255896720379626691373477223808040839453370799715426143237974402342331020941533125564932963228074818891707580296516262584451816695873169230235668081091425131622898726217356123668519420820803676318446901933688924722455581079574809688082011559628335681866023133655136379898615214209349346203435853293689318189116037465645474669519318589567809649024218325830547412938703120625760137723658167545", + "12143975642996547734993210479666904095397741261764866078090510262645460132290235986630376713004350672851651807054875332939956731775733959997351324143497076406076776154977723396094902338839439098161231984686885314849947129332117197645344151755344324038625202859604817402251985217489847067013287339738384296915967594310421751908244841803407747710371415190598155158739898568686373111717605879278384135851318969079281050507414909011506576169586220120054116056221990949404820925350166961128163192137909212020962545745326872002538950594091355414304183527270036601878504771221000529804769425263948649859429914831451793830447", + "7681905128991420738358979923777240433337546546660192093231163456029821861424188702648102462301544733499592591906609649350535414419477664467070025593525931603028515171360109575816154776046183332815807349628739221532165348992025144025203444717113795284728750784856862721905401143841702874481392901879089726940312479501647028937736816673686877284445430463537947530572199915087117895344271025474693120828230653492777168767540782277375513209223619007329449471451811372004116502977451480549159579358749540564515729677785198516083957467543681269622455949740687260144765977931043642705413831493101417185777391942989245676973", + "6324957736802947762698105017681228051594271495029008384509236777170014306498658302206164665097448703570931287007659305566350360036085316522653055137878512074480755437541173568670880476898182300821763522340043814781148109583375526242284492181946863678861291620814183269643728258345959383134128289995080908583694306703558113066837628145217885578776624299254179033312744171665733436458179725336656848290970572987456090589484353899143202322971998318634185334126489034866595781074592194167476225131764951162637607536439161513547138214421713349130949142742184752353531752780426054707814473818221353155710305120310324535073", + }; + + private final RSAPublicKey[] mPublicKeys = new RSAPublicKey[20]; + + @Before + public void setUp() throws NoSuchAlgorithmException, InvalidKeySpecException, InvalidKeyException { + for (int i = 0; i < 20; ++i) { + mPublicKeys[i] = AndroidPubkey.decode(new BigInteger(KEYS[i]).toByteArray()); + } + } + + @Test + public void decodeTest() throws NoSuchAlgorithmException, InvalidKeyException, SignatureException { + for (int i = 0; i < 20; ++i) { + Signature signature = Signature.getInstance("SHA256withRSA"); + signature.initVerify(mPublicKeys[i]); + signature.update(DIGEST); + assertTrue(signature.verify(new BigInteger(SIGNATURES[i]).toByteArray())); + } + } + + @Test + public void encodeTest() throws InvalidKeyException { + for (int i = 0; i < 20; ++i) { + byte[] pubKey = AndroidPubkey.encode(mPublicKeys[i]); + assertEquals(ANDROID_PUBKEY_ENCODED_SIZE, pubKey.length); + assertArrayEquals(new BigInteger(KEYS[i]).toByteArray(), pubKey); + } + } + + @Test + public void encodeWithNameTest() throws InvalidKeyException { + String name = "MyAwesomeApp"; + byte[] nameEncodedBytes = AndroidPubkey.getUserInfo(name); + int pkeyB64Size = 4 * (int) Math.ceil(ANDROID_PUBKEY_ENCODED_SIZE / 3.0); + int expectedLength = pkeyB64Size + nameEncodedBytes.length; + for (int i = 0; i < 20; ++i) { + byte[] expectedEncodedBytes = new byte[expectedLength]; + System.arraycopy(Base64.encode(new BigInteger(KEYS[i]).toByteArray()), 0, expectedEncodedBytes, 0, pkeyB64Size); + System.arraycopy(nameEncodedBytes, 0, expectedEncodedBytes, pkeyB64Size, nameEncodedBytes.length); + byte[] actualEncodedBytes = AndroidPubkey.encodeWithName(mPublicKeys[i], name); + assertEquals(expectedLength, actualEncodedBytes.length); + assertArrayEquals(expectedEncodedBytes, actualEncodedBytes); + } + } + +} diff --git a/vendor/scrcpy b/vendor/scrcpy new file mode 160000 +Subproject 2322868e9e256eb5fce0b3d659ab2a409f29bae |