アプリケーション開発とテストの領域において、Androidエミュレーションは不可欠なツールとしてその地位を確立しています。物理的なデバイスを多数用意することなく、多様なAPIレベル、画面解像度、ハードウェア構成をシミュレートできる能力は、開発サイクルの効率化に大きく貢献してきました。従来、このエミュレーションは開発者のローカルマシン上で、Android Studioに統合されたAndroid Virtual Device (AVD) Managerを通じて実行されるのが一般的でした。しかし、このアプローチには、マシンスペックへの高い依存、複数人での環境共有の難しさ、そしてCI/CDパイプラインへの統合における制約といった課題が常に存在していました。
近年、クラウドコンピューティングとコンテナ技術の成熟は、これらの課題に対する強力な解決策を提示しています。それが「クラウドベースAndroidエミュレーション」です。これは、エミュレータのインスタンスをローカルマシンではなく、クラウドサーバー上のコンテナとして実行し、Webブラウザを通じてストリーミング技術でアクセス・操作するというアプローチです。この方式は、リソースのスケーラビリティ、場所を選ばないアクセシビリティ、そして自動化されたテスト環境との親和性といった、従来のローカルエミュレーションにはない数多くの利点を提供します。
本稿では、この先進的なクラウドAndroidエミュレーション環境を支える中核技術とアーキテクチャについて、詳細に解説していきます。特に、Googleが提供するオープンソースプロジェクト群を基盤とし、リアルタイム通信を実現するWebRTC、コンテナ化を担うDocker、そしてサービス間の通信を制御するEnvoyプロキシといった要素が、どのように連携して一つの堅牢なシステムを形成するのかを明らかにします。単なるツールの紹介に留まらず、各コンポーネントが果たす役割、その背後にある技術的原理、そしてそれらを組み合わせることで生まれる相乗効果に焦点を当て、次世代のモバイルテスト環境を自ら構築するための知識を提供することを目的とします。
クラウドAndroidエミュレーションのアーキテクチャ概要
クラウドベースのAndroidエミュレーションシステムは、複数の独立したコンポーネントが協調して動作する分散システムとして構築されます。このアーキテクチャを理解することは、システム全体の動作原理を把握する上で極めて重要です。主要な構成要素は以下の通りです。
- Androidエミュレータコンテナ: Android OSとアプリケーションが実行される心臓部。Dockerコンテナとしてパッケージ化されており、必要に応じて動的に生成・破棄されます。
- WebRTCブリッジ (Goldfish-WebRTC): エミュレータの画面描画と音声出力をキャプチャし、WebRTCプロトコルを介してビデオ・オーディオストリームとしてクライアント(Webブラウザ)に送信します。同時に、ブラウザからのマウスやキーボード入力を受け取り、エミュレータのイベントとして注入する役割も担います。
- Webクライアント: ユーザーが操作するインターフェース。HTML, CSS, JavaScriptで構築されたWebアプリケーションであり、ビデオストリームを受信して表示し、ユーザー入力をWebRTCデータチャネル経由でサーバーに送信します。
- シグナリングサーバー: WebRTC接続を確立するために不可欠な仲介役。クライアントとエミュレータ(WebRTCブリッジ)が互いのネットワーク情報(IPアドレス、ポート、サポートするコーデックなど)を交換するのを助けます。このプロセスは「オファー/アンサー」モデルとして知られています。
- STUN/TURNサーバー: NAT(Network Address Translation)やファイアウォール越しの通信を可能にするための補助サーバー。特に、P2P(ピア・ツー・ピア)での直接接続が困難な場合に、通信を中継(リレー)するTURNサーバーの役割が重要となります。
- 認証・認可サービス (JWT): システムへのアクセスを制御し、セキュリティを確保します。ユーザーを認証し、リソースへのアクセス権限を証明するトークン(JSON Web Token)を発行します。
- エッジプロキシ (Envoy): システム全体のエントリポイント。リクエストのルーティング、TLS終端、負荷分散、そしてJWTの検証など、インフラストラクチャに関わる横断的な関心事を一手に引き受けます。
これらのコンポーネントが連携する一連の流れは以下のようになります。
- ユーザーがWebブラウザからシステムにアクセスし、認証サービスでログインします。
- 認証に成功すると、サービスはJWTを発行し、Webクライアントに渡します。
- Webクライアントは、エミュレータの起動を要求します。このリクエストにはJWTが付与されます。
- バックエンドのオーケストレーションシステム(例: Kubernetes)が、リクエストに応じてAndroidエミュレータとWebRTCブリッジを含むコンテナを起動します。
- Webクライアントは、シグナリングサーバーに接続し、WebRTC接続の確立を開始します。この際も通信はJWTで保護されます。
- シグナリングサーバーを介して、WebクライアントとWebRTCブリッジがSDP(Session Description Protocol)を交換し、NAT越えのためにSTUN/TURNサーバーを利用して接続経路を確立します。
- WebRTCのピア接続が確立されると、エミュレータの画面と音声がリアルタイムでブラウザにストリーミングされ、ユーザーは遅延の少ない操作感でリモートのエミュレータを制御できるようになります。
このアーキテクチャにより、スケーラブルでセキュア、かつ場所に依存しないAndroidエミュレーション環境が実現されるのです。以降の章では、これらの各コンポーネント、特にWebRTCブリッジ、JWT、TURNサーバー、そしてEnvoyプロキシについて、さらに深く掘り下げていきます。
Goldfish-WebRTCブリッジ:エミュレータと世界を繋ぐ架け橋
クラウドAndroidエミュレーションの実現において、最も革新的なコンポーネントの一つがGoldfish-WebRTCブリッジです。このブリッジがなければ、コンテナ内で動作するエミュレータは単なる閉じた箱であり、外部からリアルタイムに操作することはできません。その役割と内部的な仕組みを理解することは、システム全体のパフォーマンスと可能性を評価する上で欠かせません。
「Goldfish」とは何か
まず、「Goldfish」という名称について触れておく必要があります。これは、Androidエミュレータが内部でシミュレートしている仮想ハードウェアプラットフォームのコードネームです。ARMアーキテクチャベースのCPU、グラフィックス、オーディオ、ネットワークインターフェースなど、Android OSが動作するために必要な一連の仮想デバイスが含まれています。Goldfish-WebRTCブリッジは、このGoldfish仮想ハードウェア層に直接アクセスし、描画や音声のデータを横取り(インターセプト)することで機能します。
WebRTCによるリアルタイム通信の実現
WebRTC (Web Real-Time Communication) は、プラグインを必要とせずにWebブラウザ間でリアルタイムの音声、映像、データ通信を可能にするオープンなフレームワークです。Goldfish-WebRTCブリッジは、このWebRTCの技術を巧みに利用しています。
ブリッジの主な機能は以下の2つです。
- エミュレータからの出力ストリーミング:
- 画面キャプチャ: ブリッジは、エミュレータのフレームバッファ(画面に表示されるピクセルデータが格納されるメモリ領域)を常に監視します。画面が更新されるたびに、その差分または全フレームをキャプチャします。
- ビデオエンコーディング: キャプチャされた生のピクセルデータは、ネットワーク経由で効率的に送信するために、H.264やVP8/VP9といったビデオコーデックを用いて圧縮・エンコードされます。これにより、帯域幅の消費が大幅に削減されます。
- ストリーム送信: エンコードされたビデオフレームは、確立されたWebRTCの
RTCPeerConnection
を通じて、RTP (Real-time Transport Protocol) パケットとしてクライアント(Webブラウザ)に送信されます。音声についても同様のプロセス(キャプチャ→オーパスエンコード→RTP送信)が行われます。
- クライアントからの入力ハンドリング:
- データチャネル: WebRTCは、メディアストリームとは別に、任意のバイナリデータを送受信するための
RTCDataChannel
という仕組みを提供します。Webクライアント側で発生したマウスのクリック、移動、キーボードの押下、タッチスクリーン操作(ピンチ、スワイプなど)といったイベントは、JSON形式のメッセージとしてシリアライズされ、このデータチャネルを通じてブリッジに送信されます。 - イベント注入: ブリッジはデータチャネル経由で受信したJSONメッセージをパースし、それをGoldfish仮想ハードウェアが理解できる低レベルな入力イベントに変換します。そして、そのイベントをエミュレータの仮想入力デバイス(/dev/input/event*)に直接書き込みます。これにより、エミュレータはあたかも物理的なマウスやキーボードが接続されているかのように、リモートからの操作を受け付けます。
- データチャネル: WebRTCは、メディアストリームとは別に、任意のバイナリデータを送受信するための
この双方向の通信メカニズムにより、ユーザーはあたかも目の前にあるデバイスを操作しているかのような、非常に低い遅延でのインタラクションが可能になります。Googleが提供するandroid-emulator-webrtcプロジェクトは、このGoldfish-WebRTCブリッジの実装と、それを内包するDockerイメージを構築するためのスクリプト群を提供しており、クラウドエミュレーション環境構築の出発点となります。
実践:android-emulator-webrtcスクリプトによる環境構築
理論を理解したところで、次はその実践です。Googleのandroid-emulator-webrtc
プロジェクトは、クラウドエミュレーションのコンセプトを実際に動かすための具体的な手段を提供します。このセクションでは、スクリプトを使用してDockerコンテナベースのAndroidエミュレータを起動し、Webブラウザからアクセスするまでの基本的な手順を解説します。
前提条件
作業を開始する前に、以下のツールがシステムにインストールされている必要があります。
- Docker: コンテナをビルド・実行するためのプラットフォーム。
- Git: プロジェクトのソースコードをリポジトリからクローンするために使用します。
- KVM (Linuxの場合): パフォーマンス向上のため、ハードウェア仮想化支援機能(Intel VT-xまたはAMD-V)が有効になっていること、およびKVMが利用可能であることが強く推奨されます。
ステップ1: プロジェクトのクローン
まず、ターミナルを開き、GitHubからプロジェクトをクローンします。
git clone https://github.com/google/android-emulator-webrtc.git
cd android-emulator-webrtc
ステップ2: Dockerイメージのビルド
プロジェクトのルートディレクトリにはDockerfile
が存在します。このファイルには、ベースとなるOSイメージにAndroidエミュレータ、SDK、そしてWebRTCブリッジをインストールするための一連の命令が記述されています。以下のコマンドでDockerイメージをビルドします。ビルドには時間がかかることがあります。
docker build -t android-webrtc .
このコマンドは、現在のディレクトリにあるDockerfile
を基に、android-webrtc
という名前(タグ)のイメージを構築します。
ステップ3: Dockerコンテナの実行
イメージのビルドが完了したら、次はこのイメージからコンテナを起動します。run.sh
スクリプトが提供されていますが、直接docker run
コマンドを使用することで、より詳細な制御が可能です。
docker run -d --privileged -p 8080:8080 -p 5554:5554 -p 5555:5555 \
-e "EMULATOR_ARGS=--skin 1080x1920" \
--name my-android-emulator \
android-webrtc
このコマンドの各オプションの意味は以下の通りです。
-d
: コンテナをバックグラウンド(デタッチモード)で実行します。--privileged
: コンテナにホストシステムの広範な権限を与えます。エミュレータがKVMなどのハードウェア機能を利用するために必要です。-p 8080:8080
: ホストマシンのポート8080をコンテナのポート8080にマッピングします。WebクライアントのUIはこのポートで提供されます。-p 5554:5554 -p 5555:5555
: Android Debug Bridge (ADB) が使用するポートをマッピングします。これにより、ホストマシンからコンテナ内のエミュレータにADBで接続できます。-e "EMULATOR_ARGS=--skin 1080x1920"
: 環境変数EMULATOR_ARGS
を通じて、エミュレータの起動引数を指定します。ここでは画面解像度を設定しています。--name my-android-emulator
: コンテナにmy-android-emulator
という名前を付けます。android-webrtc
: 起動するイメージの名前です。
ステップ4: ブラウザからのアクセス
コンテナが正常に起動したら、Webブラウザを開き、以下のURLにアクセスします。
http://localhost:8080
Webページが表示され、しばらくするとAndroidエミュレータの画面がストリーミングされてくるはずです。マウスやキーボードで操作し、その入力がリアルタイムにエミュレータに反映されることを確認できます。
この基本的な手順は、クラウドエミュレーションの概念実証(PoC)や個人的な開発環境としては十分ですが、これを複数ユーザーが利用する本番サービスとして展開するには、セキュリティ、スケーラビリティ、ネットワークの複雑性といった課題に対処する必要があります。次の章で解説するJWT、TURN、Envoyといった技術が、まさにそのための解決策となります。
JWTサービス:セキュアなアクセス制御の要
単一のコンテナをローカルで実行するだけなら、認証は不要かもしれません。しかし、クラウド上で複数のユーザーがアクセスするサービスを構築する場合、誰が、いつ、どのリソースにアクセスできるのかを厳密に管理するメカニズムが不可欠になります。ここで重要な役割を果たすのが、JSON Web Token(JWT)を用いた認証・認可サービスです。
なぜJWTが必要か?
クラウドエミュレーション環境では、以下の理由から堅牢なアクセス制御が求められます。
- リソース保護: エミュレータインスタンスはCPUやメモリといった計算リソースを消費します。不正な利用を防ぎ、正当なユーザーのみがリソースを利用できるようにする必要があります。
- マルチテナンシー: 複数のユーザーやチームが同じインフラを共有する場合、各テナントのデータやセッションが互いに干渉しないように隔離する必要があります。
- 利用状況の追跡: 誰がどのくらいの時間サービスを利用したかを記録することで、課金や利用分析が可能になります。
JWTは、これらの要求を満たすためのコンパクトで自己完結した方法を提供します。ステートレスな性質を持つため、サーバー側でセッション情報を保持する必要がなく、マイクロサービスアーキテクチャとの親和性が高いという利点もあります。
JWTの構造と検証フロー
JWTは、ピリオド(.
)で区切られた3つの部分から構成される文字列です。
[ヘッダー].[ペイロード].[署名]
- ヘッダー (Header): トークンの種類(JWT)と、使用されている署名アルゴリズム(例: HMAC SHA256やRSA)をエンコードしたものです。
- ペイロード (Payload): クレーム(Claim)と呼ばれる、エンティティ(通常はユーザー)や追加のデータに関する情報を含みます。クレームには、発行者(
iss
)、有効期限(exp
)、ユーザーID(sub
)などの標準的なものと、アプリケーション固有のカスタムクレーム(例: ユーザーのロール、アクセス可能なエミュレータイメージの種類など)があります。 - 署名 (Signature): ヘッダーとペイロードを基に、秘密鍵(HMACの場合)または秘密鍵/公開鍵ペア(RSAの場合)を使って生成されます。この署名により、トークンが途中で改ざんされていないこと、そして信頼できる発行者によって発行されたことを検証できます。
クラウドエミュレーションシステムにおけるJWTの典型的な利用フローは以下のようになります。
- ユーザーがIDとパスワードでWebポータルの認証サーバーにログインします。
- 認証サーバーは認証情報を検証し、成功すればユーザーID、ロール、有効期限などの情報を含むペイロードを持つJWTを生成し、秘密鍵で署名します。
- 生成されたJWTがWebクライアント(ブラウザ)に返され、クライアントはそれをローカルストレージなどに安全に保管します。
- 以降、WebクライアントがシグナリングサーバーやAPIサーバーなど、保護されたリソースにアクセスする際は、HTTPリクエストの
Authorization
ヘッダーにBearer [JWT]
の形式でトークンを添付します。 - リクエストを受け取ったサーバー(またはその手前のEnvoyプロキシ)は、まず署名を検証します。公開鍵(または共有秘密鍵)を使って署名を再計算し、トークン内の署名と一致するかを確認します。
- 署名が有効であれば、次にペイロード内のクレームを検証します。特に、有効期限(
exp
)が切れていないかを確認します。 - すべての検証をパスした場合のみ、リクエストの処理を許可します。ペイロード内のユーザーIDやロール情報に基づき、さらに詳細なアクセス制御(例: 特定のユーザーは高解像度エミュレータを起動できない、など)を行うことも可能です。
このように、JWTを導入することで、ステートレスでありながらもセキュアでスケーラブルなアクセス制御システムを構築できるのです。
TURNサーバー:あらゆるネットワーク環境を乗り越えるために
WebRTCの大きな魅力は、サーバーを介さずにブラウザ間で直接データをやり取りするP2P(ピア・ツー・ピア)通信にあります。これにより、低遅延で高スループットな通信が期待できます。しかし、現実のインターネット環境は、NAT(Network Address Translation)やファイアウォールといった障壁に満ちており、P2P接続の確立を困難にしています。
クラウドエミュレーションの文脈では、エミュレータコンテナはクラウドプロバイダーのVPC(Virtual Private Cloud)内でプライベートIPアドレスを持ち、一方のユーザーは自宅やオフィスのローカルネットワーク内にあることがほとんどです。両者が直接通信経路を確立することは、多くの場合不可能です。この問題を解決し、あらゆるネットワーク環境下で安定した通信を保証するのが、TURNサーバーの役割です。
ICEフレームワーク:最適な通信経路の探索
WebRTCは、接続を試みる際にICE (Interactive Connectivity Establishment) というフレームワークを利用します。ICEは、利用可能なあらゆる手段を試して、2つのピア間で通信可能な経路を見つけ出そうとします。
- ホスト候補: まず、ピアは自身のローカルIPアドレスを通信候補として収集します。
- STUNによるサーバーリフレクシブ候補: 次に、ピアはインターネット上にあるSTUN (Session Traversal Utilities for NAT) サーバーにリクエストを送信します。STUNサーバーは、リクエストを送信してきたピアのパブリックIPアドレスとポート番号をそのまま応答として返します。これにより、ピアはNATの背後から見た自身の「外側の顔」を知ることができます。これがサーバーリフレクシブ候補です。多くの場合、この情報を使えばP2P接続が成功します。
- TURNによるリレー候補: しかし、一部の厳しいNAT(特にシンメトリックNAT)環境下では、STUNで得たアドレスを使っても直接通信ができません。このような場合の最後の砦がTURN (Traversal Using Relays around NAT) サーバーです。
TURNサーバーの役割:通信のリレー
TURNサーバーは、その名の通り通信を「リレー(中継)」するサーバーです。ICEプロセスが直接のP2P接続を確立できないと判断した場合、両方のピアはTURNサーバーにデータを送信し、TURNサーバーがそのデータを相手方のピアに転送します。
その動作は以下の通りです。
- 両ピアはTURNサーバーとの間に接続を確立します。
- ピアAがピアBにデータを送りたい場合、データをTURNサーバーに送信します。
- TURNサーバーはそのデータを受け取り、ピアBへの接続を通じて転送します。
- ピアBからピアAへの通信も同様に、TURNサーバーを経由して行われます。
この方式では、すべてのメディアトラフィックがTURNサーバーを通過することになります。そのため、以下のようなトレードオフが生じます。
- 利点(信頼性): どんなに複雑なネットワーク環境であっても、両ピアがTURNサーバーにさえ到達できれば、通信を確立できます。これにより、サービスの接続成功率が劇的に向上します。
- 欠点(コストと遅延):
- 帯域幅コスト: すべてのトラフィックを中継するため、TURNサーバーは非常に多くの帯域幅を消費します。これはサーバーの運用コストに直結します。
- 遅延の増加: P2P接続に比べて、サーバーを経由する分だけ通信遅延(レイテンシー)が増加します。サーバーの地理的な位置がユーザーとエミュレータから離れていると、遅延はさらに悪化します。
したがって、堅牢なクラウドエミュレーションサービスを構築するためには、オープンソースのcoturn
プロジェクトなどを用いて、ユーザーとエミュレータが実行されるリージョンの両方に近い場所に、スケーラブルなTURNサーバーを設置することが不可欠な戦略となります。
Envoyプロキシ:マイクロサービスアーキテクチャの司令塔
これまでの章で解説してきた各コンポーネント(エミュレータ、WebRTCブリッジ、シグナリングサーバー、認証サーバー)を組み合わせることで、クラウドエミュレーションの基本的な機能は実現できます。しかし、これらを本番環境で安定して運用し、スケールさせていくためには、各サービス間の複雑な通信を管理し、セキュリティや可観測性といった横断的な要件を一元的に扱う仕組みが必要になります。ここで登場するのが、Envoyのようなサービスプロキシです。
Envoyは、元々Lyft社で開発され、現在はCloud Native Computing Foundation (CNCF) の卒業プロジェクトとなっている、高性能なオープンソースのエッジ/サービスプロキシです。マイクロサービスアーキテクチャにおいて、サービス間のすべてのネットワークトラフィックを仲介する「サービスメッシュ」の中核として広く利用されています。
クラウドエミュレーションにおけるEnvoyの役割
我々のアーキテクチャでは、Envoyはシステム全体のエントリポイント、つまり「フロントプロキシ」として配置されます。これにより、以下のような多くの重要な責務をアプリケーションコードから分離し、インフラ層で集約的に管理できます。
- リクエストルーティング:
外部からのすべてのリクエスト(例:
https://emu.example.com/
)は、まずEnvoyに到達します。Envoyはリクエストのパスやホスト名に基づいて、それを適切なバックエンドサービスに転送します。例えば、/auth
へのリクエストは認証サーバーへ、/ws
(WebSocket)へのリクエストはシグナリングサーバーへ、それ以外はWebクライアントの静的ファイルを提供するサーバーへ、といった具合に柔軟なルーティングが可能です。 - TLS終端:
ユーザーとの通信を暗号化するためのHTTPS(TLS)は、現代のWebサービスにおいて必須です。EnvoyにTLS証明書を配置し、TLS接続の確立と解除(終端)を担わせることで、個々のバックエンドサービスは暗号化を意識する必要がなくなり、非暗号化のHTTP通信に専念できます。これにより、証明書の管理が一元化され、開発が簡素化されます。
- JWT検証:
Envoyは、強力な認証・認可フィルタを備えています。特に
jwt_authn
フィルタを使用すると、バックエンドサービスにリクエストを転送する前に、リクエストヘッダーに含まれるJWTを自動的に検証できます。署名の正当性、発行者の確認、有効期限のチェックなどをEnvoyが行うため、各サービスはJWTの検証ロジックを実装する必要がなくなります。検証に失敗したリクエストは、バックエンドに到達する前にEnvoyが弾いてくれるため、セキュリティが向上します。 - 負荷分散:
サービスの可用性とスケーラビリティを高めるために、シグナリングサーバーや認証サーバーを複数インスタンスで実行することがあります。Envoyは、これらのインスタンス群へのリクエストを、ラウンドロビンや最小接続数といった様々なアルゴリズムに基づいて賢く分散(ロードバランシング)します。あるインスタンスがダウンした際には自動的に検知し、正常なインスタンスにのみリクエストを転送するヘルスチェック機能も備えています。
- 可観測性 (Observability):
Envoyは、自身を通過するすべてのリクエストに関する詳細な統計情報(メトリクス)を生成します。リクエスト数、レイテンシー、エラーレートなどをPrometheusのような監視システムと連携させることで、システムの健全性をリアルタイムで把握できます。また、詳細なアクセスログや、分散トレーシングのためのヘッダー伝播機能も提供し、問題発生時の原因究明を容易にします。
設定例(概念)
以下は、Envoyの設定ファイル(YAML形式)の簡略化された概念例です。JWT検証とパスベースのルーティングを行う様子を示しています。
static_resources:
listeners:
- address:
socket_address: { address: 0.0.0.0, port_value: 443 }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
route_config:
virtual_hosts:
- name: backend
domains: ["*"]
routes:
- match: { prefix: "/ws" } # WebSocket for signaling
route: { cluster: signaling_service }
- match: { prefix: "/auth" }
route: { cluster: auth_service }
- match: { prefix: "/" }
route: { cluster: web_ui_service }
http_filters:
- name: envoy.filters.http.jwt_authn
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
providers:
my_auth_provider:
issuer: "https://auth.example.com"
remote_jwks:
http_uri:
uri: "https://auth.example.com/.well-known/jwks.json"
cluster: auth_service
rules:
- match: { prefix: "/ws" } # Protect the signaling service
requires: { provider_name: "my_auth_provider" }
- name: envoy.filters.http.router
clusters:
- name: signaling_service
# ... シグナリングサーバーのエンドポイント定義
- name: auth_service
# ... 認証サーバーのエンドポイント定義
- name: web_ui_service
# ... Web UIサーバーのエンドポイント定義
このようにEnvoyを導入することで、個々のサービスは自身のビジネスロジックに集中でき、インフラに関わる複雑な処理はEnvoyに委任できます。これにより、開発効率、セキュリティ、運用性のすべてが向上し、堅牢でスケーラブルなクラウドAndroidエミュレーション基盤が完成するのです。
0 개의 댓글:
Post a Comment