こんにちは。AI事業本部 DX本部 小売セクター ミライネージで開発をしている2020年新卒入社の大田です。
リモートワークの中、Zoom等の画面共有でscrcpyを使ってAndroid実機のデモをして開発メンバーに共有する用途や、画面を持たないAndroid端末(STBなど)での開発でモニタを1台占領せずに開発マシンにモニタを使えるなど、scrcpyに非常に支えられています。
今では当たり前になったscrcpyも、初めて見たときは「こんなことが可能なのか」とすごく驚きました。特にADBの接続のみでAndroid側に追加でアプリケーションを入れずに実現している仕組みについてずっと技術的関心がありました。そこで本稿では、どのように”ADBだけ”で画面操作を実現しているのかを追いかけます。
scrcpyとは
scrcpyはAndroidを端末のGUIでの画面操作を可能にするオープンソースのソフトウェアです。
https://github.com/Genymobile/scrcpy
Macの場合はbrewで導入できます。
brew install scrcpy
そして、scrcpy
コマンドを実行するだけで画面操作が可能です。
scrcpyの素晴らしさは、ADBでの接続があれば利用可能であることだと思います。つまりAndroidをUSBデバッグで接続できていれば画面の操作が可能です。そのためAndroid端末側にあらかじめ何かをインストールする必要がなく実現できる点が最大の魅力だと感じています。
scrcpyの構成
scrcpy@1fb7957を元にしています。大体 scrcpy v1.17 に対応します。
scrcpyは大きく分けて以下の2つのソフトウェアで構成されています。
scrcpy
: 操作する側のマシンで動く(主にC言語)scrcpy-server.jar
: scrcpyによってAndroid側に送り込まれてapp_process
によってAndroid内で起動する (主にJava)
ビルド方法とビルド後の成果物
挙動を追いかけるにあたり、ソース変更したものをビルドする必要がありました。開発環境はMacです。
ビルド方法は公式のドキュメントに書かれている方法を参考にしています。(macOS向け、各OSの共通手順)
ビルドするための依存関係はbrewでインストールできます。
brew install sdl2 ffmpeg
brew install pkg-config meson
そして、meson
コマンドを実行します。(これはおそらくconfigureをしているもので、一度実行すれば良いものでした。二度目実行すると「”Directory already configured.”」と言われます。)
meson x --buildtype release --strip -Db_lto=true
そして、ninja
コマンドでビルドできます。
export ANDROID_SDK_ROOT=~/Library/Android/sdk
ninja -Cx
以後、ソースコードを変更した場合はこのninja -Cx
のみで変更が反映されました。
成果物
./run x
: 操作する側マシンでscrcpyを実行することに相当x/server/scrcpy-server
: scrcpy-server.jarに相当
つまり、開発者上で./run x
を実行するとscrcpy
コマンドを実行することと同じことになります。
x/server/scrcpy-server
は自動でAndroid側に送り込まれます。こういったことも含めて、挙動を追った結果を紹介していきます。
本題:scrcpyがどうやってADBだけで画面操作までたどり着くか
ここからが本題のADBだけで画面操作までたどり着く流れです。
まずscrcpy
コマンドのエントリーポイント(main)はここです。
1. adb push で Android側に /data/local/tmp/scrcpy-server.jar を置く。
実際に実行されるコマンドは以下のとおりで、先ほどの成果物であるx/server/scrcpy-server
が送り込まれます。
adb push x/server/scrcpy-server /data/local/tmp/scrcpy-server.jar
2. 「adb reverse localabstract:scrcpy tcp:27183 」が実行され、操作する側のマシンがlistenしている27183ポートがAndroid側のUnixドメインソケット @scrcpy にポートフォワーディングされる。
adb reverse localabstract:scrcpy tcp:27183
より正確には操作する側のポートはデフォルトでは27183〜27199 の範囲で利用可能なものが選ばれるはずです。
3. 先ほどpushされた scrcpy-server.jar が adb shell を用いて app_process コマンド によって起動され、Android側はUnixドメインソケット @scrcpy にconnect()する。この @scrcpy は 操作する側の27183ポートに繋がっており、画面やマウスでのGUI操作を通信し合う。
adb shell CLASSPATH=/data/local/tmp/scrcpy-server.jar app_process / com.genymobile.scrcpy.Server 1.17 info 0 8000000 0 -1 false - true true 0 false false - -
上記のコマンドの引数部分は調査端末での値です。これらの引数はscrcpy
コマンドに渡すオプション(画面サイズやビットレート指定など)などによっても変化します。
scrcpy-server.jar
側のエントリーポイント(main)は以下の通りです。
https://github.com/Genymobile/scrcpy/blob/1fb79575250460e159161bbe5ca55335409ffec5/server/src/main/java/com/genymobile/scrcpy/Server.java#L239-L248
オプションの対応関係は以下のコードの通りです。
https://github.com/Genymobile/scrcpy/blob/1fb79575250460e159161bbe5ca55335409ffec5/server/src/main/java/com/genymobile/scrcpy/Server.java#L127-L189
上記のadb shell CLASSPATH=... app_process
コマンドによりscrcpy-server.jar
が起動し、Unixドメインソケット@scrcpy
に接続する時のコードは以下のとおりです。
private static final String SOCKET_NAME = "scrcpy";
...
public static DesktopConnection open(Device device, boolean tunnelForward) throws IOException {
...
videoSocket = connect(SOCKET_NAME);
try {
controlSocket = connect(SOCKET_NAME);
...
また上記のコードから分かるように映像と制御のソケットは接続の順番で判別しているようです。ただし今回の最大の関心ごとは”ADBだけ”でのどのように実現しているのかでした。そのため、このあとどのような通信をして映像を送っているのか制御の信号を送るのかに関しては深堀ません。その代わり、映像と制御に関してはこの公式のドキュメントで触れられています。 「Introducing scrcpy · ~rom1v/blog」にもレイテンシを下げて映像を送る話題について触れらています。
より正確にはこの流れは forceadbforward = falseの場合であり、adb reverse
成功し、adb forward
にフォールバックしなかった場合のフローです。adb forward
する場合は 自然に考えると、操作する側の 27183ポートがlistenする代わりにconnectして、Android側でlistenするようになっているはずです。
Unixドメインソケット @scrcpy
Unixドメインソケット@scrcpy
というのが個人的にはポイントでUnixドメインソケットは/var/run/docker.sock
のようにパス名のようになるものばかりと思っていましたが@scrcpy
というものも存在するようです。localabstractのscrcpyがOS上でどのような表現になるかを知るためには、cat /proc/net/unix
を利用しました。
$ cat /proc/net/unix | grep scrcpy
0000000000000000: 00000002 00000000 00010000 0001 01 390295 @scrcpy
scrcpyはshell権限が必要
scrcpy
は確かにroot権限不要で、ADBだけあればAndroid端末の画面操作が可能です。
ただしscrcpy-server.jar
を実行するにはshell権限で動かす必要があります。adb shell
でコマンドを実行するということはそのコマンドをshell権限で実行されるということを意味しているようです。もしscrcpy-server.jar
をshell権限を持たない他のアプリなどから起動すると以下のように例外が発生します。
[server] INFO: Device: ...... (Android ...)
not tunnel forward
java.lang.AssertionError: java.lang.reflect.InvocationTargetException
at com.genymobile.scrcpy.wrappers.SurfaceControl.setDisplaySurface(SurfaceControl.java:75)
at com.genymobile.scrcpy.ScreenEncoder.setDisplaySurface(ScreenEncoder.java:243)
at com.genymobile.scrcpy.ScreenEncoder.internalStreamScreen(ScreenEncoder.java:91)
at com.genymobile.scrcpy.ScreenEncoder.streamScreen(ScreenEncoder.java:60)
at com.genymobile.scrcpy.Server.scrcpy(Server.java:80)
at com.genymobile.scrcpy.Server.main(Server.java:253)
at com.android.internal.os.RuntimeInit.nativeFinishInit(Native Method)
at com.android.internal.os.RuntimeInit.main(RuntimeInit.java:251)
Caused by: java.lang.reflect.InvocationTargetException
at java.lang.reflect.Method.invoke(Native Method)
at com.genymobile.scrcpy.wrappers.SurfaceControl.setDisplaySurface(SurfaceControl.java:73)
... 7 more
Caused by: java.lang.IllegalArgumentException: displayToken must not be null
at android.view.SurfaceControl.setDisplaySurface(SurfaceControl.java:601)
... 9 more
[server] ERROR: Exception on thread Thread[main,5,main]
java.lang.AssertionError: java.lang.reflect.InvocationTargetException
at com.genymobile.scrcpy.wrappers.SurfaceControl.setDisplaySurface(SurfaceControl.java:75)
at com.genymobile.scrcpy.ScreenEncoder.setDisplaySurface(ScreenEncoder.java:243)
at com.genymobile.scrcpy.ScreenEncoder.internalStreamScreen(ScreenEncoder.java:91)
at com.genymobile.scrcpy.ScreenEncoder.streamScreen(ScreenEncoder.java:60)
at com.genymobile.scrcpy.Server.scrcpy(Server.java:80)
at com.genymobile.scrcpy.Server.main(Server.java:253)
at com.android.internal.os.RuntimeInit.nativeFinishInit(Native Method)
at com.android.internal.os.RuntimeInit.main(RuntimeInit.java:251)
Caused by: java.lang.reflect.InvocationTargetException
at java.lang.reflect.Method.invoke(Native Method)
at com.genymobile.scrcpy.wrappers.SurfaceControl.setDisplaySurface(SurfaceControl.java:73)
... 7 more
Caused by: java.lang.IllegalArgumentException: displayToken must not be null
at android.view.SurfaceControl.setDisplaySurface(SurfaceControl.java:601)
... 9 more
調査後に気がついたことで、この公式ドキュメント(https://github.com/Genymobile/scrcpy/blob/1fb79575250460e159161bbe5ca55335409ffec5/DEVELOP.md#privileges)にこのshell権限が必要なことは触れられていました。
リフレクションの例外が発生するのはscrcpyがAndroidの非公開のAPIにリフレクションでアクセスしていることに影響しているはずです。例えば今回のSurfaceControl
はここ(https://github.com/Genymobile/scrcpy/blob/1fb79575250460e159161bbe5ca55335409ffec5/server/src/main/java/com/genymobile/scrcpy/wrappers/SurfaceControl.java)にあります。
この「SurfaceControl needs system apps permission · Issue #1083 · Genymobile/scrcpy」issueでもこの件が取り上げられています。そしてshell権限を使わないなら「MediaProjection | Android デベロッパー | Android Developers」という選択肢が提示されています。ただマウスなどの操作を注入するためにはsystem権限が必要などと書かれいます。
つまりscrcpyをADBを使わず”脱ADB scrcpy”を実現するにはshell権限の障壁を突破する必要があります。上記のissueからsystem権限があれば実現可能かもしれないので、/system/priv-app
置いたアプリからscrcpy-server.jar
を起動できるのであればshell権限なし脱ADBも可能かもしれません。
最後に:この調査に使った方法
大雑把にどんなadbコマンドが実行されるのかの流れを追うことがまず重要でした。
そのため簡単にadbコマンドを実行する大元の関数を見つけてそこにprintfを仕掛けただけです。
process_t
adb_execute(const char *serial, const char *const adb_cmd[], size_t len) {
printf(">>>> adb_execute: "); // 追加
for (unsigned long i = 0; i < len; i++) { // 追加
printf("%s ", adb_cmd[i]); // 追加
} // 追加
puts(""); // 追加
...
この状態でビルドして実行すると以下のような出力を得ることができ何が実行されているか容易に把握できました。
>>>> adb_execute: push x/server/scrcpy-server /data/local/tmp/scrcpy-server.jar
>>>> adb_execute: reverse localabstract:scrcpy tcp:27183
>>>> adb_execute: shell CLASSPATH=/data/local/tmp/scrcpy-server.jar app_process / com.genymobile.scrcpy.Server 1.17 info 0 8000000 0 -1 false - true true 0 false false - -
>>>> adb_execute: reverse --remove localabstract:scrcpy
(>>>>の行だけを抽出しています)