AndroidでWebRTCを使った電話アプリの実装方法を解説します。
WebRTCは、リアルタイムでビデオチャットやボイスチャット、テキストなどのデータ送信ができるものです。
今回は、ボイスチャットを実装していきます。ビデオは取り扱いません。
WebRTCを1から実装しようとすると大変なので、今回は、SkyWayというサービスとそのSDKを利用します。
このSDKを使うと、AndroidのみならずiOSやweb(javascript)でWebRTCを簡単に利用するアプリを作ることができます。
また、初心者向けを意識しているので、外部ライブラリは、SkyWaySDK以外は導入せずに実装していきます。
「動いた、すげぇ」を重視しているので、エラーハンドリングやonPause
などのライフサイクルでの適切な処理は省いています。
実装したソースコードは、こちらにあります。
はじめに
念のため今回開発を行った環境を。
- macOS : 10.11.6 (El Capitan)
- Android Studio : 2.3.3
- 使ったエミュレータ : API24
多少違っても問題ないです。
1. アプリのひな形を作る
わかってる人は次の下準備まで飛ばしてください。
-
Start a new Android Studio project
をクリック
2. アプリ名と、ドメイン(それぞれ適当でいいです)を入力してNext
3. MinimumSDKは適当に決めてNext
(今回は、API version19にしてみました)
4. [Empty Activity]が選択されていることを確認して、Next
5. Activity Name はデフォルトのMainActivity
のままで、Finish
これで、プロジェクトが作成されます。
2. 下準備(SkyWayの導入)
- まず、プロジェクト一覧画面を変更します。画像の矢印のボタンをクリックして、「Project」をクリックしてください。
2. デフォルトの「Android」のナビゲーション画面はわかりにくいので、以降「Project」のナビゲーション画面を使います。これは、実際のファイル・フォルダ構造と同じように表示されるのでわかりやすいです。画像のターゲットアイコンを押すと、右側のエディタで開いているファイルの場所を表示してくれます。
3. SkyWayのAndroidのSDKをダウンロードし(Source code (zip)をクリック)、解凍して、SkyWay.aar
をプロジェクトのapp/libs
ディレクトリにコピーしてください。
4. SkyWay.aar
をプロジェクトに取り込むために、app/build.gradle
を編集します。追記が終わったらSync Now
を押してください。aarファイルが読み込まれます。
dependencies {
compile(name: 'SkyWay', ext: 'aar') // <- この行
compile fileTree(dir: 'libs', include: ['*.jar'])
androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
exclude group: 'com.android.support', module: 'support-annotations'
})
compile 'com.android.support:appcompat-v7:24.2.1'
compile 'com.android.support.constraint:constraint-layout:1.0.2'
testCompile 'junit:junit:4.12'
}
// この下の行も
repositories {
flatDir {
dirs 'libs'
}
}
5. 次に、SkyWayに会員登録してください。利用するドメインは、ひとまずは、localhost
としてください。
6. この後表示される「APIキー」と「利用可能ドメイン」はこの後利用するので注意しておいてください
7. app
ディレクトリを右クリックして、「New」->「File」で、gradle.properties
ファイルを作成して、先ほど表示された、「APIキー」と「利用可能なドメイン」を入力してください。
skywayApiKey=xxxxxxxxxxxxxxxxxxxxxxxxxxxx
skywayHost=localhost
8. app/.gitignore
にgradle.properties
を追記してください。これにより、ソースコードをgithubに公開した時に、app/gradle.properties
は公開されないため、APIキーを他者に知られずにソースコードを公開できます。
9. app/build.gradle
に以下の追記をします。これにより、javaのコードから、BuildConfig. SKYWAY_API_KEY
と BuildConfig. SKYWAY_HOST
でアクセスできるようになります。追記が終わったら、Sync Now
を押してください。
buildTypes {
debug {
buildConfigField "String", "SKYWAY_API_KEY", "\"${project.property("skywayApiKey")}\""
buildConfigField "String", "SKYWAY_HOST", "\"${project.property("skywayHost")}\""
}
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
buildConfigField "String", "SKYWAY_API_KEY", "\"${project.property("skywayApiKey")}\""
buildConfigField "String", "SKYWAY_HOST", "\"${project.property("skywayHost")}\""
}
}
10. AndroidManifest.xml
を編集します。これは、Androidアプリが実行されるときに必要な権限を宣言するものです。
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
3. 自分のpeerIDを表示する
まず、自分のPeerId
を表示する部分の実装を進めていきます。SkyWayによって自動で割り当てられるPeerId
を確認することができるようになります。
ここでは、
-
Peer
クラスインスタンスの作成 -
Log
でログを出す - TextViewに動的に値をセットする
を行っていきます。
- まず、
Peer
クラスのインスタンスを作成します。これが作成されることで通信ができるようになります。以下のコードを入力してください。「Select Classes to Import」が出てきたらOK
を押してください。
// MainActivityのすぐ下
private Peer peer;
// onCreate の中
PeerOption options = new PeerOption();
options.key = BuildConfig.SKYWAY_API_KEY;
options.domain = BuildConfig.SKYWAY_HOST;
peer = new Peer(this, options);
Navigator.initialize(peer);
2. 次に、自分のPeerId
を取得して、Logに出力してみましょう。
// MainActivityの下
private String TAG = getClass().getSimpleName();
private String currentId;
// onCreateの中
showCurrentPeerId();
// onCreateの下
private void showCurrentPeerId() {
peer.on(Peer.PeerEventEnum.OPEN, new OnCallback() {
@Override
public void onCallback(Object o) {
if (o instanceof String) {
currentId = (String) o;
Log.d(TAG, "currentId: " + currentId);
}
}
});
}
3. この状態で実際にアプリを起動してみましょう。画面上部の緑色の三角ボタンが実行ボタンです。動かす機体を選んで実行しましょう。ソースコードのコンパイルやアプリのインストールに時間がかかりますが、画面下に実行状況が表示されるので待ちましょう。
4. 起動されたら、ログを確認しましょう。画面下の「Android Monitor」をクリックするとログ画面が開きます。開かない場合は、「logcat」を選択するとログが流れてきます。すると、確かに、ログで自分の、PeerId
が表示されているのが確認できます。
5. 次に、TextViewをjavaから操作するために、TextViewにidをつけます。idの追加などは、GUI画面で設定もできますが、今後のことを考えて、CUI画面で設定していきます。画面下のText
タブをクリックすると、CUI画面に切り替えることができます。Hello World!
とあるデフォルトのTextViewにidを追記してください。
android:id="@+id/id_textview"
6. 次に、MainActivityから、PeerId
を表示します。ここまで出来たらもう一度アプリを実行してみてください。Idが表示されているはずです。
// MainActivity の下に
private TextView idTextView;
// onCreate の中に
idTextView = (TextView) findViewById(R.id.id_textview);
// showCurrentPeerId の中に
runOnUiThread(new Runnable() {
@Override
public void run() {
idTextView.setText("ID: " + currentId);
}
});
4. 通話できるユーザーの一覧を表示する
通話が出来るユーザーの一覧を表示していきます。
ここでは、
- ListViewでの表示
を行っていきます。
- まず、ListViewのダミーデータを画面に表示するところから始めていきます。
activity_main.xml
にLinearLayout
とListView
を追加し、TextView
を、LinearLayout
の中に移動させます。
<LinearLayout
android:id="@+id/head_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/id_textview"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!" />
</LinearLayout>
<ListView
android:id="@+id/listview"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@+id/head_layout"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
2. 次に、ListViewの中の1行1行のViewを作成します。app/src/res/layout
の中に、Layout resource file
としてlist_item.xml
を作成してください。root
は、LinearLayout
で大丈夫です。そして、TextView
を作成してください。
<TextView
android:id="@+id/item_textview"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="20dp"/>
3. 次に、MainActivityにListViewのロジックを作成していきます。以下を追記して、実行するとListViewが表示されていると思います。書く順番としては、内部クラスであるMyAdapter
を最初に書くとエラーが出ずにスムースに書けます。
// MainActivity の最終行の一つ上に
private class MyAdapter extends ArrayAdapter<String> {
private LayoutInflater inflater;
MyAdapter(@NonNull Context context, @LayoutRes int resource, @NonNull List<String> objects) {
super(context, resource, objects);
inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
}
@NonNull
@Override
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
View view = inflater.inflate(R.layout.list_item, null, false);
TextView textView = (TextView) view.findViewById(R.id.item_textview);
String name = getItem(position);
textView.setText(name);
return view;
}
}
// MainActivityの下に
private ListView listView;
private MyAdapter adapter;
private List<String> idList = new ArrayList<String>();
// onCreateの中に
listView = (ListView) findViewById(R.id.listview);
adapter = new MyAdapter(this, 0, idList);
listView.setAdapter(adapter);
idList.clear();
idList.add("Hello1");
idList.add("Hello2");
idList.add("Hello3");
idList.add("Hello4");
adapter.notifyDataSetChanged();
4. 最後にダミーデータの代わりに、peerIdを表示させます。実行してみると、自分のidだけがListViewに表示されます。もし、複数端末で同時に起動すると複数のidが表示されます。
// onCreate内の以下を削除
idList.clear();
idList.add("Hello1");
idList.add("Hello2");
idList.add("Hello3");
idList.add("Hello4");
adapter.notifyDataSetChanged();
// showCurrentPeerId の下に追記
private void refreshPeerList() {
Log.d(TAG, "Refreshing");
peer.listAllPeers(new OnCallback() {
@Override
public void onCallback(Object o) {
if (o instanceof JSONArray) {
JSONArray array = (JSONArray) o;
idList.clear();
for (int i = 0; i < array.length(); i++) {
try {
String id = array.getString(i);
idList.add(id);
Log.d(TAG, "Fetched PeerId: " + id);
} catch (JSONException e) {
Log.e(TAG, "Parse ListAllPeer", e);
}
}
runOnUiThread(new Runnable() {
@Override
public void run() {
adapter.notifyDataSetChanged();
}
});
}
}
});
}
// onCreateに追記
refreshPeerList();
5. 最後に「refresh」ボタンを押すとListView
が更新されるように変更します。activity_main.xml
とMainActivity.java
にそれぞれ追記します。
<Button
android:id="@+id/refresh_btn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Refresh" />
// onCreateの中に追記
Button refreshBtn = (Button) findViewById(R.id.refresh_btn);
refreshBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
refreshPeerList();
}
});
5. 通話のための権限を揃える
Androidで使う一部の機能はアプリを利用するユーザーから権限を認められないと利用することができません。
AndroidManifest.xml
に記載することで権限は利用できるようになるのですが、一部の機能は、Dangerous Permissions
として、AndroidManifest.xml
に記載した上でさらに利用するときにダイアログを出して許可してもらう必要があります。
今回のアプリでは、RECORD_AUDIO
がDangerous Permissions
に指定されているので、アプリを起動したときに、権限を要求するダイアログを出す処理を記述します。
なお、Dangerous Permissions
の概念は、Android6.0(API23)以降に導入されたもので、それ以前のAndroid端末を対象とする場合は不要です。
これらのコードは、実行時のパーミッション リクエストを参考に作成しました。コメントは必要ないので入力する必要はありません。
// MainActivity の下に
private static final int RECORD_AUDIO_REQUEST_ID = 1;
// onCreate の下に
private void checkAudioPermission() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED) {
Log.d(TAG, "Manifest.permission.RECORD_AUDIO is not GRANTED");
// Should we show an explanation?
if (ActivityCompat.shouldShowRequestPermissionRationale(this,
Manifest.permission.RECORD_AUDIO)) {
Log.d(TAG, "shouldShowRequestPermissionRationale = false");
// Show an expanation to the user *asynchronously* -- don't block
// this thread waiting for the user's response! After the user
// sees the explanation, try again to request the permission.
} else {
// No explanation needed, we can request the permission.
Log.d(TAG, "request Permission RECORD_AUDIO");
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.RECORD_AUDIO},
RECORD_AUDIO_REQUEST_ID);
// MY_PERMISSIONS_REQUEST_READ_CONTACTS is an
// app-defined int constant. The callback method gets the
// result of the request.
}
}
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
switch (requestCode) {
case RECORD_AUDIO_REQUEST_ID: {
// If request is cancelled, the result arrays are empty.
if (grantResults.length > 0
&& grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Log.d(TAG, "request Permission RECORD_AUDIO GRANTED!");
// permission was granted, yay! Do the
// contacts-related task you need to do.
} else {
Log.d(TAG, "request Permission RECORD_AUDIO DENIED!");
// permission denied, boo! Disable the
// functionality that depends on this permission.
}
return;
}
// other 'case' lines to check for other
// permissions this app might request
}
}
// onCreate の中に
checkAudioPermission();
これらのコードを書いた上で、実行すると以下のようなダイアログが表示されます。許可
を押してください。
また、本来であれば、許可されなかった時に、許可を再申請する処理などのエラー処理が必要になります。
今回はサンプルアプリなのでその辺りの細かいエラー処理は省いて実装しています。
6. 通話(発信)を実装する
ここから、peerを使った発信の処理を行っていきます。
ListViewでクリックされたPeerIdに対して発信をする処理を書きます。
まずは、ListViewのクリックを検知する処理を書きます。
Logを確認すると、クリックが検出できていることがわかります。
// onCreate の中に
listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) {
String selectedPeerId = idList.get(i);
if (selectedPeerId == null) {
Log.d(TAG, "Selected PeerId == null");
return;
}
Log.d(TAG, "SelectedPeerId: " + selectedPeerId);
}
});
次に、実際に発信する処理を実装します。getMediaStream
の中で、audioFlag
のみをtrue
にしているので、ボイスチャットのみの利用に制限することができます。
// MainActivity の下に
private MediaConnection connection;
// refreshPeerList の下に
private void call(String peerId) {
Log.d(TAG, "Calling to id:" + peerId);
if (peer == null) {
Log.i(TAG, "Call but peer is null");
return;
}
if (peer.isDestroyed || peer.isDisconnected) {
Log.i(TAG, "Call but peer is not active");
return;
}
if (connection != null) {
Log.d(TAG, "Call but connection is already created");
return;
}
MediaStream stream = getMediaStream();
if (stream == null) {
Log.d(TAG, "Call but media stream is null");
return;
}
CallOption option = new CallOption();
option.metadata = "test"; // TODO: metadata
MediaConnection connection = peer.call(peerId, stream, option);
if (connection == null) {
Log.d(TAG, "Call but MediaConnection is null");
return;
}
this.connection = connection;
Log.d(TAG, "connection started!");
}
private MediaStream getMediaStream() {
MediaConstraints constraints = new MediaConstraints();
constraints.videoFlag = false;
constraints.audioFlag = true;
return Navigator.getUserMedia(constraints);
}
// OnItemClickListener の中に
call(selectedPeerId);
ここまでで、発信をすることができるようになりました。
7. 終了処理を実装する
次に、発信した通話を切る処理を実装します。Button
を追加し、クリックされたら、connection
をclose()
します。
<Button
android:id="@+id/close_btn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="CLOSE"/>
// getMediaStream の下に
private void closeConnection() {
if (connection != null) {
connection.close();
MainActivity.this.connection = null;
Log.d(TAG, "Connection is Closed");
}
}
// onCreate の中に
Button closeBtn = (Button) findViewById(R.id.close_btn);
closeBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
closeConnection();
}
});
これで、こちらから通話を切ることができるようになりました。しかし、相手から通話が切られた時に、こちらのconnection
はつなぎっぱなしになってしまい、次の相手に発信することができません。
connection
が切れた時には、CLOSE
イベントが発生するので、それを探知して、connection
をclose()
する処理を実装します。
// closeConnection の上に
private void setConnectionCallback(MediaConnection connection) {
connection.on(MediaConnection.MediaEventEnum.CLOSE, new OnCallback() {
@Override
public void onCallback(Object o) {
Log.d(TAG, "Close Event is Received");
closeConnection();
}
});
}
// call の中に
setConnectionCallback(connection);
最後に、アプリが終了した時にも、通話を終了しPeer
を終了しなくてはなりません。
// onCreate の下に
@Override
protected void onDestroy() {
super.onDestroy();
if (connection != null) {
closeConnection();
}
if (peer != null && !peer.isDestroyed) {
peer.destroy();
peer = null;
}
}
8. 通話(着信)を実装する
ここまでで、発信については実装が完了しました。
次に、着信の時の処理を実装していきます。
着信があった時、peer
インスタンスにCALL
イベントが発生します。そのイベントをキャッチして、通話を開始します。
peer.on(Peer.PeerEventEnum.CALL, new OnCallback() {
@Override
public void onCallback(Object o) {
Log.d(TAG, "CALL Event is Received");
if (o instanceof MediaConnection) {
MediaConnection connection = (MediaConnection) o;
if (MainActivity.this.connection != null) {
Log.d(TAG, "connection is already created");
connection.close();
return;
}
// TODO: show dialog
MediaStream stream = MainActivity.this.getMediaStream();
connection.answer(stream);
setConnectionCallback(connection);
MainActivity.this.connection = connection;
Log.d(TAG, "CALL Event is Received and Set");
}
}
});
これで、着信を処理することができるようになりました。
9. エラーを抑える
実際に通話をしてログを見てみると以下のような、warningが出ていることに気づきました。
W/AudioDeviceTemplate: The application should use MODE_IN_COMMUNICATION audio mode!
これを解消するために、以下のコードを付け加えます。
// onCreate の中に
Context context = getApplicationContext();
AudioManager am = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
am.setMode(AudioManager.MODE_IN_COMMUNICATION);
これで、解消されます。
最後に
TODO: 着信のダイアログを出したい