こんにちは。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デモ

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 を置く。

https://github.com/Genymobile/scrcpy/blob/1fb79575250460e159161bbe5ca55335409ffec5/app/src/server.c#L426

実際に実行されるコマンドは以下のとおりで、先ほどの成果物である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 にポートフォワーディングされる。

https://github.com/Genymobile/scrcpy/blob/1fb79575250460e159161bbe5ca55335409ffec5/app/src/server.c#L430

adb reverse localabstract:scrcpy tcp:27183

より正確には操作する側のポートはデフォルトでは27183〜27199 の範囲で利用可能なものが選ばれるはずです。

3. 先ほどpushされた scrcpy-server.jar が adb shell を用いて app_process コマンド によって起動され、Android側はUnixドメインソケット @scrcpy にconnect()する。この @scrcpy は 操作する側の27183ポートに繋がっており、画面やマウスでのGUI操作を通信し合う。

https://github.com/Genymobile/scrcpy/blob/1fb79575250460e159161bbe5ca55335409ffec5/app/src/server.c#L267-L310

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);
  ...

https://github.com/Genymobile/scrcpy/blob/1fb79575250460e159161bbe5ca55335409ffec5/server/src/main/java/com/genymobile/scrcpy/DesktopConnection.java#L63-L65

また上記のコードから分かるように映像と制御のソケットは接続の順番で判別しているようです。ただし今回の最大の関心ごとは”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("");                                  // 追加
    ...

https://github.com/Genymobile/scrcpy/blob/1fb79575250460e159161bbe5ca55335409ffec5/app/src/command.c#L106

この状態でビルドして実行すると以下のような出力を得ることができ何が実行されているか容易に把握できました。

>>>> 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

(>>>>の行だけを抽出しています)


【採用強化中】サイバーエージェントでは、140兆円の小売業界をデジタルで再発明するため、覚悟ある仲間を募集しています。
140兆円市場を、再発明する。