【Zoom Video SDK】初心者による導入メモ - Android/Kotlin編

https://qiita.com/official-events/86d8555146e1b14fef24

この記事は[【Zoom Video SDK】初心者による導入メモ - iOS/Swift編](https://qiita.com/yuppejp/items/838e1becfc2774bd4e85)に続くAndroid/Kotlin編です。
Swift編ではZoom Video SDKが初見だったので初心者すぎて詰まりまくりでしたが、現在はSwiftUI版をもくもく実装中です。複数人のグループ会話を実装する上で、iOSだとシミュレータでカメラが使えなくてテストが捗らないので、Androidのシミュレータで端末数を確保しようと思い、今回はAndroid(Kotlin)でZoom Video SDKを入門したいと思います。

# 完成イメージ
[前回](https://qiita.com/yuppejp/items/838e1becfc2774bd4e85)と同様に自分と相手の二人でビデオ会話できるシンプルなアプリです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/b44a09ad-90a1-638e-36df-b13d84276ef0.png)

## ハマりどころは
基本的には公式ドキュメントに沿って進めていけば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のプロジェクト設定
1. ライブラリーの追加
ダウンロードしたSDKの```Sample-Libs/mobilertc-android```配下にある```
mobilertc```をAndroid Studioのプロジェクトの直下にコピーします。mobilertcにはmobilertc.aarが含まれています。
　
ライブラリーをコピーしたら、setting.gradle(Project Settings)の末尾に```include ':mobilertc'```を追加します。

```gradle:setting.gradle
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')```追加します。

```gradle:build.gradle(Module)
...

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:AndroidManifest.xml
<?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トークンの生成](https://qiita.com/yuppejp/items/838e1becfc2774bd4e85#jwtトークンの生成)の記事を参考にトークンを作成します。

これで準備完了です。


# コードの実装ポイント

## レイアウトファイルにViewを設置
activity_main.xmlにビデオ映像を表示するためのViewを置きます。自分の映像と相手の映像の2つ定義しています。
```xml:activity_main.xml
    <us.zoom.sdk.ZoomVideoSDKVideoView
        android:id="@+id/remoteVideoView"/>
    <us.zoom.sdk.ZoomVideoSDKVideoView
        android:id="@+id/localVideoView">

```

## パーミンションの確認
hasPermissions()でパーミンションを確認して、パーミンションが無ければrequestPermissions()でパーミンションを要求します。

```kotlin:MainActivity.kt
    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のコードアシスタントでまとめて作成します。その後、公式ドキュメントから必要なスニペットをコピペします。
自分の環境ではスニペットをペーストするとダブルコートが全角になってしまったので半角に修正する必要がありました。

```kotlin
    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が発生するので注意深く設定します。

```kotlin
    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()でキャンバスに関連付けます。これでビデオ映像が表示されます。

```kotlin
          override fun onSessionJoin() {
                Log.d(TAG, "onSessionJoin")

                val session = ZoomVideoSDK.getInstance().session
                session.mySelf.videoCanvas.subscribe(
                    localVideoView,
                    ZoomVideoSDKVideoAspect.ZoomVideoSDKVideoAspect_Original
                )

                joinButton.setText("Leave")
            }
```

## ビデオの終了
セッションから抜けるとキャンバスを解放します。

```kotlin
            override fun onSessionLeave() {
                Log.d(TAG, "onSessionLeave")

                val session = ZoomVideoSDK.getInstance().session
                session.mySelf.videoCanvas.unSubscribe(localVideoView) // todo: unSubscribeしてもViewがクリアされない

                leave()
                joinButton.setText("Join")
            }
```

:::note warn
unSubscribe()してもViewに以前の映像がゴミとして残ってしまいました。iOSの場合は自動的に背景色でクリアされたのですが、Androidの場合はクリアしてくれませんでした...。解決方法をご存知の方、教えていただけると助かります。
:::

# サンプルコード
今回のサンプルコード全文を掲載します。

## activity_main.xml

```xml: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

```kotlin: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による実装も試してみたいなと思います。

# 参考サイト
- [はじめての Zoom Video SDK - 準備編 - Qiita](https://qiita.com/yosuke-sawamura/items/72df3bd91a6319ccc35c)
- [Zoom Video SDK 公式ドキュメント　](https://marketplace.zoom.us/docs/sdk/video/introduction/)
- [ZOOM Developer forum](https://devforum.zoom.us)