この記事は【Zoom Video SDK】初心者による導入メモ - iOS/Swift編に続くAndroid/Kotlin編です。
Swift編ではZoom Video SDKが初見だったので初心者すぎて詰まりまくりでしたが、現在はSwiftUI版をもくもく実装中です。複数人のグループ会話を実装する上で、iOSだとシミュレータでカメラが使えなくてテストが捗らないので、Androidのシミュレータで端末数を確保しようと思い、今回はAndroid(Kotlin)でZoom Video SDKを入門したいと思います。
完成イメージ
前回と同様に自分と相手の二人でビデオ会話できるシンプルなアプリです。
ハマりどころは
基本的には公式ドキュメントに沿って進めていけばOKでした。行間が省かれてる箇所があり初心者には戸惑うこともありましたが前回のiOS版での経験値を活かし? 空気を読んで読み替えることで割とサクサク進めることができました。
ということでやっていきたいと思います。
使用環境
- MacBook Air (M1, 2020)
- macOS Monterey 12.4
- Android Studio Chipmunk | 2021.2.2 Patch 1
- zoom-video-sdk-android-1.3.1
Android Studioのプロジェクト設定
- ライブラリーの追加
ダウンロードしたSDKのSample-Libs/mobilertc-android
配下にあるmobilertc
をAndroid Studioのプロジェクトの直下にコピーします。mobilertcにはmobilertc.aarが含まれています。
ライブラリーをコピーしたら、setting.gradle(Project Settings)の末尾にinclude ':mobilertc'
を追加します。
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "ZoomVideo1"
include ':app'
include ':mobilertc'
そして、build.gradle(Module)のdependenciesにimplementation project(':mobilertc')
追加します。
...
dependencies {
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation 'com.google.android.material:material:1.6.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
implementation project(':mobilertc')
}
2. パーミンションを追加
カメラとマイクのパーミンションを設定します。SDKドキュメントには具体的な設定値の記載がありませんでしたが、これで動作しました。
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.example.zoomvideo1">
<!-- ここから -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- ここまで -->
<application
...
3. JWTトークンの取得
これが少々めんどうですが前回のJWTトークンの生成の記事を参考にトークンを作成します。
これで準備完了です。
コードの実装ポイント
レイアウトファイルにViewを設置
activity_main.xmlにビデオ映像を表示するためのViewを置きます。自分の映像と相手の映像の2つ定義しています。
<us.zoom.sdk.ZoomVideoSDKVideoView
android:id="@+id/remoteVideoView"/>
<us.zoom.sdk.ZoomVideoSDKVideoView
android:id="@+id/localVideoView">
パーミンションの確認
hasPermissions()でパーミンションを確認して、パーミンションが無ければrequestPermissions()でパーミンションを要求します。
private fun hasPermissions(context: Context) =
permissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
private fun requestPermissions(activity: MainActivity) {
ActivityCompat.requestPermissions(activity, permissions, PERMISSIONS_REQUEST_CODE)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSIONS_REQUEST_CODE) {
var grantedCount = 0
for (result in grantResults) {
if (result == PackageManager.PERMISSION_GRANTED) {
grantedCount++
}
}
if (grantResults.size == grantedCount) {
// ここに成功時の処理を書く
initZoomSDK()
} else {
Toast.makeText(applicationContext, "アクセスを許可してください", Toast.LENGTH_LONG)
.show()
}
}
}
companion object {
const val PERMISSIONS_REQUEST_CODE = 1
val permissions = arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
}
SDKの初期化とコールバックの実装
SDKの初期化メソッドを呼び出し部とコールバックを実装します。コールバック数が多くて手打ちだと大変なので、Android Studioのコードアシスタントでまとめて作成します。その後、公式ドキュメントから必要なスニペットをコピペします。
自分の環境ではスニペットをペーストするとダブルコートが全角になってしまったので半角に修正する必要がありました。
private fun initZoomSDK() {
val params = ZoomVideoSDKInitParams().apply {
domain = "https://zoom.us" // Required
logFilePrefix = "MyLogPrefix" // Optional for debugging
enableLog = true // Optional for debugging
}
val sdk = ZoomVideoSDK.getInstance()
val initResult = sdk.initialize(this, params)
if (initResult == ZoomVideoSDKErrors.Errors_Success) {
// You have successfully initialized the SDK
Log.d(TAG, "initialize: successfully")
} else {
// Something went wrong, see error code documentation
Log.d(TAG, "initialize: failed")
}
val listener = object : ZoomVideoSDKDelegate {
override fun onSessionJoin() {
Log.d(TAG, "onSessionJoin")
val session = ZoomVideoSDK.getInstance().session
session.mySelf.videoCanvas.subscribe(
localVideoView,
ZoomVideoSDKVideoAspect.ZoomVideoSDKVideoAspect_Original
)
joinButton.setText("Leave")
}
// (略)
}
}
セッションへの参加
セッション名やJWTトークンを設定し、joinSession()を呼び出します。トークン作成時のセッション名・ユーザー名・パスワードが一致していないとonErrorが発生するので注意深く設定します。
private fun join(name: String) {
val audioOptions = ZoomVideoSDKAudioOption().apply {
connect = true // Auto connect to audio upon joining
mute = false // Auto mute audio upon joining
}
val videoOptions = ZoomVideoSDKVideoOption().apply {
localVideoOn = true // Turn on local/self video upon joining
}
val params = ZoomVideoSDKSessionContext().apply {
sessionName = "Session1"
userName = name
sessionPassword = "123"
token = "" // TODO: Pass in your JWT. In a production app, ensure that you do not hard code JWT or any confidential credentials.
audioOption = audioOptions
videoOption = videoOptions
}
val session = ZoomVideoSDK.getInstance().joinSession(params)
if (session != null) {
Log.d(TAG, "joinSession")
Log.d(TAG, " sessionName: $session.sessionName")
Log.d(TAG, " sessionID: $session.sessionID")
val videoHelper = ZoomVideoSDK.getInstance().videoHelper
videoHelper.startVideo()
}
}
private fun leave() {
val shouldEndSession = true
ZoomVideoSDK.getInstance().leaveSession(shouldEndSession)
}
ビデオの開始
セッションが開始されたらsubscribe()でキャンバスに関連付けます。これでビデオ映像が表示されます。
override fun onSessionJoin() {
Log.d(TAG, "onSessionJoin")
val session = ZoomVideoSDK.getInstance().session
session.mySelf.videoCanvas.subscribe(
localVideoView,
ZoomVideoSDKVideoAspect.ZoomVideoSDKVideoAspect_Original
)
joinButton.setText("Leave")
}
ビデオの終了
セッションから抜けるとキャンバスを解放します。
override fun onSessionLeave() {
Log.d(TAG, "onSessionLeave")
val session = ZoomVideoSDK.getInstance().session
session.mySelf.videoCanvas.unSubscribe(localVideoView) // todo: unSubscribeしてもViewがクリアされない
leave()
joinButton.setText("Join")
}
unSubscribe()してもViewに以前の映像がゴミとして残ってしまいました。iOSの場合は自動的に背景色でクリアされたのですが、Androidの場合はクリアしてくれませんでした...。解決方法をご存知の方、教えていただけると助かります。
サンプルコード
今回のサンプルコード全文を掲載します。
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<EditText
android:id="@+id/userNameEditText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="10dp"
android:ems="10"
android:inputType="textPersonName"
android:minHeight="48dp"
android:text="user123"
app:layout_constraintEnd_toStartOf="@+id/joinButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/joinButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginEnd="10dp"
android:text="Join"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/userNameEditText"
app:layout_constraintTop_toTopOf="parent" />
<us.zoom.sdk.ZoomVideoSDKVideoView
android:id="@+id/remoteVideoView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="10dp"
android:layout_marginTop="20dp"
android:layout_marginEnd="10dp"
android:layout_marginBottom="10dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/joinButton" />
<us.zoom.sdk.ZoomVideoSDKVideoView
android:id="@+id/localVideoView"
android:layout_width="123dp"
android:layout_height="142dp"
android:layout_marginStart="10dp"
android:layout_marginTop="10dp"
android:background="#C33939"
android:backgroundTint="#CA4545"
android:backgroundTintMode="add"
app:layout_constraintStart_toStartOf="@+id/remoteVideoView"
app:layout_constraintTop_toTopOf="@+id/remoteVideoView">
</us.zoom.sdk.ZoomVideoSDKVideoView>
</androidx.constraintlayout.widget.ConstraintLayout>
MainActivity.kt
package com.example.zoomvideo1
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.os.Bundle
import android.util.Log
import android.widget.Button
import android.widget.EditText
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import us.zoom.sdk.*
class MainActivity : AppCompatActivity() {
private val TAG = "MyDebug#MainActivity"
private lateinit var joinButton: Button
private lateinit var localVideoView: ZoomVideoSDKVideoView
private lateinit var remoteVideoView: ZoomVideoSDKVideoView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
joinButton = findViewById(R.id.joinButton)
localVideoView = findViewById(R.id.localVideoView)
remoteVideoView = findViewById(R.id.remoteVideoView)
if (!hasPermissions(this)) {
requestPermissions(this)
} else {
initZoomSDK()
}
joinButton.setOnClickListener {
if (ZoomVideoSDK.getInstance().isInSession) {
leave()
} else {
val userNameEditText = findViewById<EditText>(R.id.userNameEditText)
join(userNameEditText.text.toString())
}
}
}
private fun hasPermissions(context: Context) =
permissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
private fun requestPermissions(activity: MainActivity) {
ActivityCompat.requestPermissions(activity, permissions, PERMISSIONS_REQUEST_CODE)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSIONS_REQUEST_CODE) {
var grantedCount = 0
for (result in grantResults) {
if (result == PackageManager.PERMISSION_GRANTED) {
grantedCount++
}
}
if (grantResults.size == grantedCount) {
initZoomSDK()
} else {
Toast.makeText(applicationContext, "アクセスを許可してください", Toast.LENGTH_LONG)
.show()
}
}
}
companion object {
const val PERMISSIONS_REQUEST_CODE = 1
val permissions = arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
}
private fun join(name: String) {
val audioOptions = ZoomVideoSDKAudioOption().apply {
connect = true // Auto connect to audio upon joining
mute = false // Auto mute audio upon joining
}
val videoOptions = ZoomVideoSDKVideoOption().apply {
localVideoOn = true // Turn on local/self video upon joining
}
val params = ZoomVideoSDKSessionContext().apply {
sessionName = "Session1"
userName = name
sessionPassword = "123"
token = "" // TODO: Pass in your JWT. In a production app, ensure that you do not hard code JWT or any confidential credentials.
audioOption = audioOptions
videoOption = videoOptions
}
val session = ZoomVideoSDK.getInstance().joinSession(params)
if (session != null) {
Log.d(TAG, "joinSession")
Log.d(TAG, " sessionName: $session.sessionName")
Log.d(TAG, " sessionID: $session.sessionID")
val videoHelper = ZoomVideoSDK.getInstance().videoHelper
videoHelper.startVideo()
//videoHelper.switchCamera()
}
}
private fun leave() {
val shouldEndSession = true
ZoomVideoSDK.getInstance().leaveSession(shouldEndSession)
}
private fun initZoomSDK() {
val params = ZoomVideoSDKInitParams().apply {
domain = "https://zoom.us" // Required
logFilePrefix = "MyLogPrefix" // Optional for debugging
enableLog = true // Optional for debugging
}
val sdk = ZoomVideoSDK.getInstance()
val initResult = sdk.initialize(this, params)
if (initResult == ZoomVideoSDKErrors.Errors_Success) {
// You have successfully initialized the SDK
Log.d(TAG, "initialize: successfully")
} else {
// Something went wrong, see error code documentation
Log.d(TAG, "initialize: failed")
}
val listener = object : ZoomVideoSDKDelegate {
override fun onSessionJoin() {
Log.d(TAG, "onSessionJoin")
val session = ZoomVideoSDK.getInstance().session
session.mySelf.videoCanvas.subscribe(
localVideoView,
ZoomVideoSDKVideoAspect.ZoomVideoSDKVideoAspect_Original
)
joinButton.setText("Leave")
}
override fun onSessionLeave() {
Log.d(TAG, "onSessionLeave")
val session = ZoomVideoSDK.getInstance().session
session.mySelf.videoCanvas.unSubscribe(localVideoView) // todo: unSubscribeしてもViewがクリアされない
leave()
joinButton.setText("Join")
}
override fun onError(errorCode: Int) {
when (errorCode) {
ZoomVideoSDKErrors.Errors_Session_Join_Failed -> { // 2003
// トークンの期限切れかも
Log.d(TAG, "onError: Errors_Session_Join_Failed($errorCode)")
}
else -> {
Log.d(TAG, "onError: $errorCode")
}
}
}
@SuppressLint("WrongViewCast")
override fun onUserJoin(
userHelper: ZoomVideoSDKUserHelper?,
userList: MutableList<ZoomVideoSDKUser>?
) {
Log.d(TAG, "onUserJoin")
userList?.forEach { user ->
Log.d(TAG, " userName: ${user.userName}")
user.videoCanvas.subscribe(
remoteVideoView,
ZoomVideoSDKVideoAspect.ZoomVideoSDKVideoAspect_Original
)
}
}
override fun onUserLeave(
userHelper: ZoomVideoSDKUserHelper?,
userList: MutableList<ZoomVideoSDKUser>?
) {
Log.d(TAG, "onUserLeave")
userList?.forEach { user ->
Log.d(TAG, " id: " + user.userID)
Log.d(TAG, " name: " + user.userName)
user.videoCanvas.unSubscribe(remoteVideoView) // todo: unSubscribeしてもViewがクリアされない
}
}
override fun onUserVideoStatusChanged(
videoHelper: ZoomVideoSDKVideoHelper?,
userList: MutableList<ZoomVideoSDKUser>?
) {
Log.d(TAG, "onUserVideoStatusChanged")
}
override fun onUserAudioStatusChanged(
audioHelper: ZoomVideoSDKAudioHelper?,
userList: MutableList<ZoomVideoSDKUser>?
) {
Log.d(TAG, "onUserAudioStatusChanged")
}
override fun onUserShareStatusChanged(
shareHelper: ZoomVideoSDKShareHelper?,
userInfo: ZoomVideoSDKUser?,
status: ZoomVideoSDKShareStatus?
) {
Log.d(TAG, "onUserShareStatusChanged")
}
override fun onLiveStreamStatusChanged(
liveStreamHelper: ZoomVideoSDKLiveStreamHelper?,
status: ZoomVideoSDKLiveStreamStatus?
) {
Log.d(TAG, "onLiveStreamStatusChanged")
}
override fun onChatNewMessageNotify(
chatHelper: ZoomVideoSDKChatHelper?,
messageItem: ZoomVideoSDKChatMessage?
) {
Log.d(TAG, "onChatNewMessageNotify")
}
override fun onUserHostChanged(
userHelper: ZoomVideoSDKUserHelper?,
userInfo: ZoomVideoSDKUser?
) {
Log.d(TAG, "onUserHostChanged")
}
override fun onUserManagerChanged(user: ZoomVideoSDKUser?) {
Log.d(TAG, "onUserManagerChanged")
}
override fun onUserNameChanged(user: ZoomVideoSDKUser?) {
Log.d(TAG, "onUserNameChanged")
}
override fun onUserActiveAudioChanged(
audioHelper: ZoomVideoSDKAudioHelper?,
list: MutableList<ZoomVideoSDKUser>?
) {
Log.d(TAG, "onUserActiveAudioChanged")
}
override fun onSessionNeedPassword(handler: ZoomVideoSDKPasswordHandler?) {
Log.d(TAG, "onSessionNeedPassword")
}
override fun onSessionPasswordWrong(handler: ZoomVideoSDKPasswordHandler?) {
Log.d(TAG, "onSessionPasswordWrong")
}
override fun onMixedAudioRawDataReceived(rawData: ZoomVideoSDKAudioRawData?) {
Log.d(TAG, "onMixedAudioRawDataReceived")
}
override fun onOneWayAudioRawDataReceived(
rawData: ZoomVideoSDKAudioRawData?,
user: ZoomVideoSDKUser?
) {
Log.d(TAG, "onOneWayAudioRawDataReceived")
}
override fun onShareAudioRawDataReceived(rawData: ZoomVideoSDKAudioRawData?) {
Log.d(TAG, "onShareAudioRawDataReceived")
}
override fun onCommandReceived(sender: ZoomVideoSDKUser?, strCmd: String?) {
Log.d(TAG, "onCommandReceived")
}
override fun onCommandChannelConnectResult(isSuccess: Boolean) {
Log.d(TAG, "onCommandChannelConnectResult")
}
override fun onCloudRecordingStatus(status: ZoomVideoSDKRecordingStatus?) {
Log.d(TAG, "onCloudRecordingStatus")
}
override fun onHostAskUnmute() {
Log.d(TAG, "onHostAskUnmute")
}
override fun onInviteByPhoneStatus(
status: ZoomVideoSDKPhoneStatus?,
reason: ZoomVideoSDKPhoneFailedReason?
) {
Log.d(TAG, "onInviteByPhoneStatus")
}
override fun onMultiCameraStreamStatusChanged(
status: ZoomVideoSDKMultiCameraStreamStatus?,
user: ZoomVideoSDKUser?,
videoPipe: ZoomVideoSDKRawDataPipe?
) {
Log.d(TAG, "onMultiCameraStreamStatusChanged")
}
override fun onMultiCameraStreamStatusChanged(
status: ZoomVideoSDKMultiCameraStreamStatus?,
user: ZoomVideoSDKUser?,
canvas: ZoomVideoSDKVideoCanvas?
) {
Log.d(TAG, "onMultiCameraStreamStatusChanged")
}
}
ZoomVideoSDK.getInstance().addListener(listener)
}
}
まとめ
iOS用のSDKとAndroid用のSDKでほとんど仕様が同じでAPIもシンプルなため使いやすいなと思いました。MVVMモデルなどでUIとの分離もしやすそうなので、iOSとAndroidのそれぞれで効率良く開発できそうです。今後はAndroid Jetpack Composeによる実装も試してみたいなと思います。