aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLena <lena@omega>2026-01-01 00:00:00 +0000
committerLena <lena@omega>2026-06-24 22:14:50 +0300
commiteb0c8951196c637e44daf3c0617b131b997d5d2c (patch)
tree2f83b6a41cee745467e53fc401942b82cea286ea
downloadscrcpy-android-0.1.tar.gz
scrcpy-android: mirror an Android device over wireless ADB0.1
Native Java app for Android 12+ that mirrors another Android device over wireless ADB, forwarding video, audio, touch input, and clipboard. Bundles a pinned scrcpy-server.jar and the vendored libadb-android stack. Supports h264/h265/av1 video and raw/opus audio with in-app codec selection. No NDK, no Kotlin. Includes JVM unit tests and a Docker-based emulator e2e rig.
-rw-r--r--.gitignore18
-rw-r--r--.gitmodules3
-rw-r--r--LICENSE201
-rw-r--r--README147
-rw-r--r--app/build.gradle130
-rw-r--r--app/proguard-rules.pro41
-rw-r--r--app/src/debug/res/layout/mirror.xml93
-rw-r--r--app/src/main/AndroidManifest.xml49
-rw-r--r--app/src/main/assets/scrcpy-server.sha2561
-rw-r--r--app/src/main/assets/scrcpy-server.version1
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Adb.java210
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/App.java15
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/AtomicFiles.java40
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/AudioFrames.java18
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/AudioSink.java215
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/AudioStream.java93
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/ControlMessages.java72
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/ControlStream.java154
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Controller.java173
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Crashlog.java48
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Devices.java126
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Log.java26
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Main.java150
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Mirror.java471
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/MuxRecorder.java225
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Server.java304
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Session.java318
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Sessions.java75
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Settings.java93
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/SettingsActivity.java110
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Sync.java78
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/VideoFrames.java17
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/VideoRecorder.java19
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/VideoSink.java244
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/VideoStream.java156
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Wire.java99
-rw-r--r--app/src/main/res/drawable/bg_card.xml8
-rw-r--r--app/src/main/res/drawable/bg_field.xml18
-rw-r--r--app/src/main/res/drawable/bg_status_pill.xml7
-rw-r--r--app/src/main/res/drawable/btn_primary.xml11
-rw-r--r--app/src/main/res/drawable/btn_secondary.xml12
-rw-r--r--app/src/main/res/drawable/ic_device.xml14
-rw-r--r--app/src/main/res/drawable/ic_launcher_background.xml7
-rw-r--r--app/src/main/res/drawable/ic_launcher_foreground.xml52
-rw-r--r--app/src/main/res/drawable/ic_notification.xml21
-rw-r--r--app/src/main/res/drawable/ic_settings.xml12
-rw-r--r--app/src/main/res/layout/device_row.xml28
-rw-r--r--app/src/main/res/layout/main.xml181
-rw-r--r--app/src/main/res/layout/mirror.xml67
-rw-r--r--app/src/main/res/layout/settings.xml294
-rw-r--r--app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml6
-rw-r--r--app/src/main/res/values/colors.xml16
-rw-r--r--app/src/main/res/values/dimens.xml17
-rw-r--r--app/src/main/res/values/ids.xml10
-rw-r--r--app/src/main/res/values/strings.xml36
-rw-r--r--app/src/main/res/values/styles.xml73
-rw-r--r--app/src/main/res/values/themes.xml32
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/AtomicFilesTest.java54
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/AudioStreamTest.java123
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/ControlMessagesTest.java65
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/ControlStreamTest.java152
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/DevicesTest.java64
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/SyncTest.java137
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/VideoStreamTest.java245
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/WireTest.java94
-rw-r--r--build.gradle4
-rw-r--r--fastlane/metadata/android/en-US/changelogs/1.txt5
-rw-r--r--fastlane/metadata/android/en-US/full_description.txt20
-rw-r--r--fastlane/metadata/android/en-US/images/phoneScreenshots/1.pngbin0 -> 127122 bytes
-rw-r--r--fastlane/metadata/android/en-US/images/phoneScreenshots/2.pngbin0 -> 151143 bytes
-rw-r--r--fastlane/metadata/android/en-US/short_description.txt1
-rw-r--r--fastlane/metadata/android/en-US/title.txt1
-rw-r--r--gradle.properties3
-rw-r--r--gradle/wrapper/gradle-wrapper.jarbin0 -> 43504 bytes
-rw-r--r--gradle/wrapper/gradle-wrapper.jar.sha2561
-rw-r--r--gradle/wrapper/gradle-wrapper.properties8
-rwxr-xr-xgradlew249
-rw-r--r--gradlew.bat92
-rwxr-xr-xscripts/build-apk83
-rwxr-xr-xscripts/check-wrapper24
-rwxr-xr-xscripts/fetch-vendor44
-rwxr-xr-xscripts/update-server64
-rw-r--r--settings.gradle24
-rwxr-xr-xtest90
-rw-r--r--test-rig/Dockerfile43
-rw-r--r--test-rig/Keygen.java92
-rw-r--r--test-rig/Pixhash.java54
-rwxr-xr-xtest-rig/build-apk.sh32
-rwxr-xr-xtest-rig/e2e.sh172
-rwxr-xr-xtest-rig/record.sh178
-rwxr-xr-xtest-rig/run.sh33
-rw-r--r--test-rig/sdk-packages.txt5
-rw-r--r--vendor/libadb-android/COPYING21
-rw-r--r--vendor/libadb-android/LICENSES/Apache-2.0186
-rw-r--r--vendor/libadb-android/LICENSES/BSD-3-Clause36
-rw-r--r--vendor/libadb-android/LICENSES/GPL-3.0-or-later687
-rw-r--r--vendor/libadb-android/LICENSES/MIT33
-rw-r--r--vendor/libadb-android/README.md199
-rw-r--r--vendor/libadb-android/SERVICES.md64
-rw-r--r--vendor/libadb-android/libadb/.gitignore1
-rw-r--r--vendor/libadb-android/libadb/build.gradle59
-rw-r--r--vendor/libadb-android/libadb/src/main/AndroidManifest.xml4
-rw-r--r--vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AbsAdbConnectionManager.java508
-rw-r--r--vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbAuthenticationFailedException.java14
-rw-r--r--vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbConnection.java749
-rw-r--r--vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbInputStream.java43
-rw-r--r--vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbOutputStream.java39
-rw-r--r--vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbPairingRequiredException.java7
-rw-r--r--vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbProtocol.java497
-rw-r--r--vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AdbStream.java300
-rw-r--r--vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/AndroidPubkey.java244
-rw-r--r--vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/ByteArrayNoThrowOutputStream.java24
-rw-r--r--vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/KeyPair.java38
-rw-r--r--vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/LocalServices.java271
-rw-r--r--vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/PRNGFixes.java319
-rw-r--r--vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/PairingAuthCtx.java131
-rw-r--r--vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/PairingConnectionCtx.java384
-rw-r--r--vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/SslUtils.java124
-rw-r--r--vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/StringCompat.java26
-rw-r--r--vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/android/AdbMdns.java193
-rw-r--r--vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/android/AndroidUtils.java58
-rw-r--r--vendor/libadb-android/libadb/src/main/java/io/github/muntashirakon/adb/android/package.html1
-rw-r--r--vendor/libadb-android/libadb/src/test/java/io/github/muntashirakon/adb/AndroidPubkeyTest.java118
m---------vendor/scrcpy0
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
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..288d3c7
--- /dev/null
+++ b/LICENSE
@@ -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.
diff --git a/README b/README
new file mode 100644
index 0000000..a58e244
--- /dev/null
+++ b/README
@@ -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 &gt; Developer options &gt; 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
new file mode 100644
index 0000000..2026b9f
--- /dev/null
+++ b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
Binary files differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
new file mode 100644
index 0000000..fe62024
--- /dev/null
+++ b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
Binary files differ
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
new file mode 100644
index 0000000..2c35211
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.jar
Binary files differ
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
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..97de990
--- /dev/null
+++ b/gradlew
@@ -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')
diff --git a/test b/test
new file mode 100755
index 0000000..d6475d6
--- /dev/null
+++ b/test
@@ -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 &copy; 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>
+ * &lt;pid&gt; "\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 " " &lt;local&gt; " " &lt;remote&gt; "\n"
+ * </pre>
+ * Where &lt;local&gt; is the device-specific endpoint (e.g. {@code tcp:9000}), and &lt;remote&gt; 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