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


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

今回の記事は、[前回](https://qiita.com/yuppejp/items/a99ad6a8e2c4b0788868)iOS/SwiftUIで実装したビデオチャットアプリをAndroid Jetpack Composeを使って再実装してみました。SwiftUIと同様の宣言的UIフレームであるJetpack Composeを使って同じように実装できたでしょうか？

# 使用環境
- MacBook Air (M1, 2020) メモリ16GB
- Android Studio Chipmunk | 2021.2.2 Patch 1
- zoom-video-sdk-android-1.3.1

## アプリの構造
前回のSwiftUI版とほぼ同等のレイアウトとモデルの実装でいけました。

レイアウトはiOS版と同様に円形部分がマイクミュートで参加しているメンバーで、四角のマス部分がミュートを解除し会話に参加しているメンバーです。画面の下部にテキストチャットを配置しています。

ViewModelから派生させたZoomViewModelクラスに、ZoomVideoSDKのリスナーを実装しています。このViewModelのインスタンスを各Viewの引数で渡すことで、Zoomセッションの状態の変化に連動してViewを自動的に更新します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/f1043503-e600-829b-ccf4-a2fa63aee938.png)

# 実装のポイント

## Androidプロジェクトの設定
前回の[プロジェクト設定](https://qiita.com/yuppejp/items/46e659aa3d2cb6646923)を参考にプロジェクトを作成します。
今回はViewModelを使用するための参照を追加します。また、マテリアルアイコンを使用するための参照も定義しておきます。

```gradle:build.gradle
...
dependencies {

    implementation 'androidx.core:core-ktx:1.7.0'
    implementation "androidx.compose.ui:ui:$compose_version"
    implementation "androidx.compose.material:material:$compose_version"
    implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
    implementation 'androidx.activity:activity-compose:1.3.1'

    // ViewModel
    implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.4.0"
    implementation "androidx.compose.runtime:runtime-livedata:1.0.5"

    // composeアイコン
    implementation "androidx.compose.material:material-icons-extended:1.0.0"

    // ZOOM VIDEO SDK
    implementation project(':mobilertc')

    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
    androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
    debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
    debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"

}
```

## ビデオキャンバスをCommpose化
ZoomVideoSDKVideoCanvasをViewで使用できるようにAndroidViewでラップしたComposeを作成します。この辺りはSwiftUIのUIViewControllerRepresentableと同じような間隔です。

```kotlin:ZoomVideoView.kt(抜粋)
@Composable
fun ZoomVideoView(canvas: ZoomVideoSDKVideoCanvas, modifier: Modifier = Modifier) {
    AndroidView(
        factory = ::ZoomVideoSDKVideoView,
        update = { zoomVideoSDKVideoView ->
            canvas.subscribe(
                zoomVideoSDKVideoView,
                ZoomVideoSDKVideoAspect.ZoomVideoSDKVideoAspect_PanAndScan
            )
        },
        modifier = modifier
    )
}
```

## ArrayListの変更をComposeに検知させる
Zoomの参加メンバーやテキストチャットのメッセージをMutableLiveDataのArrayListに格納したところ、ArrayListの要素が変わってもCompose側で検知してくれない問題に遭遇しました。一旦新しいArrayListに詰め直すことで変更を検知してくれる場合もありましが、たまに機嫌を損ねることがあるので確実に検知されるように、ArrayListの要素数を別のプロパティに出して対処しました。

- ViewModel側
```kotlin:ZoomViewModel.kt(抜粋)
    val messages = MutableLiveData<ArrayList<ChatMessage>>()
    val messageCount = MutableLiveData(0) // 要素数を外出し

    fun addMessage(userName: String, text: String) {
        val msg = ChatMessage(userName, text)
        messages.value?.add(msg)

        // 変更通知
        messageCount.value = messages.value?.size
    }
```

- View側
```kotlin:ChatView.kt(抜粋)
@Composable
fun ChatView(viewModel: ZoomViewModel, modifier: Modifier = Modifier) {
    val messages = viewModel.messages.observeAsState()
    val messageCount = viewModel.messageCount.observeAsState()
 
    Column(modifier = modifier) {
        LazyColumn {
            if (messageCount.value!! >= 0) { // messageCountを監視
                items(messages) { message ->
                    MessageItemView(message)
                }
            }
        }
    }
}

```

あとは特につまずくことなく素直に実装できました。


# サンプルコード

## MainActivity.kt
```kotlin:MainActivity.kt
package com.example.zoomvideocompose

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.example.zoomvideocompose.ui.theme.ZoomVideoComposeTheme

class MainActivity : ComponentActivity() {
    private val viewModel by viewModels<ZoomViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        if (!hasPermissions(this)) {
            requestPermissions(this)
        } else {
            viewModel.initZoomSDK(this)
        }

        setContent {
            ZoomVideoComposeTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background
                ) {
                    MainView(viewModel)
                }
            }
        }
    }

    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) {
                //　ここに成功時の処理を書く
                viewModel.initZoomSDK(this)
            } 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
        )
    }
}

@Composable
fun MainView(viewModel: ZoomViewModel, modifier: Modifier = Modifier) {
    Column(modifier = modifier) {
        HeaderView(viewModel)

        Row(modifier = Modifier.weight(0.7f)) {
            LocalVideoView(
                viewModel,
                modifier = Modifier
                    .padding(start = 4.dp, end = 4.dp)
                    .aspectRatio(1f)
            )

            MutedVideoView(viewModel)
        }

        SpeakerVideoView(
            viewModel,
            modifier = Modifier
                .weight(4f)
        )

        ChatView(
            viewModel,
            modifier = Modifier
                .fillMaxSize()
                .weight(3f)
        )
    }
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    ZoomVideoComposeTheme {
        val viewModel = ZoomViewModel()
        MainView(viewModel)
    }
}
```

## ZoomVideoView.kt
```kotlin:ZoomVideoView.kt
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import us.zoom.sdk.ZoomVideoSDKVideoAspect
import us.zoom.sdk.ZoomVideoSDKVideoCanvas
import us.zoom.sdk.ZoomVideoSDKVideoView

@Composable
fun ZoomVideoView(canvas: ZoomVideoSDKVideoCanvas, modifier: Modifier = Modifier) {
    AndroidView(
        factory = ::ZoomVideoSDKVideoView,
        update = { zoomVideoSDKVideoView ->
            canvas.subscribe(
                zoomVideoSDKVideoView,
                ZoomVideoSDKVideoAspect.ZoomVideoSDKVideoAspect_PanAndScan
            )
        },
        modifier = modifier
    )
}
```

## UserViewModel.kt
```kotlin:UserViewModel.kt
package com.example.zoomvideocompose

import android.annotation.SuppressLint
import android.app.Activity
import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import us.zoom.sdk.*
import java.text.SimpleDateFormat
import java.util.*

class UserViewModel(user: ZoomVideoSDKUser) : ViewModel() {
    var isMuted = MutableLiveData(true)
    var isVideoOn = MutableLiveData(true)
    var isTalking = MutableLiveData(false)
    var zoomUser: ZoomVideoSDKUser

    init {
        zoomUser = user
    }

    fun getName(): String {
        return zoomUser.userName
    }

    fun mute() {
        ZoomVideoSDK.getInstance().audioHelper?.muteAudio(zoomUser)
    }

    fun unMute() {
        val mySelf = ZoomVideoSDK.getInstance().session.mySelf
        if (zoomUser == mySelf) {
            // 自分自身の場合、オーディオが開始していない場合は開始する
            ZoomVideoSDK.getInstance().audioHelper?.startAudio()
        }

        ZoomVideoSDK.getInstance().audioHelper?.unMuteAudio(zoomUser)
    }
}

class ChatMessage(userName: String, text: String) {
    var userName: String
    var text: String
    var date: String

    init {
        this.userName = userName
        this.text = text
        this.date = SimpleDateFormat("HH:mm").format(Date())
    }
}

class ZoomViewModel : ViewModel() {
    var isJoined = MutableLiveData(false)
    var sessionName = MutableLiveData("(未参加)")
    var mySelf = MutableLiveData<UserViewModel>()
    val users = MutableLiveData<ArrayList<UserViewModel>>()
    val mutedUsersCount = MutableLiveData(0)
    val speakerUsersCount = MutableLiveData(0)
    val messages = MutableLiveData<ArrayList<ChatMessage>>()
    val messageCount = MutableLiveData(0)
    private val myName = makeRandomString(6) // ランダムな名前を生成
    private val tag = "MyDebug#ZoomModel"

    init {
        users.value = ArrayList<UserViewModel>() // 空のリスト
        messages.value = ArrayList<ChatMessage>() // 空のリスト
    }

    fun addMessage(userName: String, text: String) {
        val msg = ChatMessage(userName, text)
        messages.value?.add(msg)

        // 変更通知
        messageCount.value = messages.value?.size
    }

    fun sendMessage(text: String) {
        val chatHelper = ZoomVideoSDK.getInstance().chatHelper
        chatHelper?.sendChatToAll(text)
    }

    fun join() {
        val audioOptions = ZoomVideoSDKAudioOption().apply {
            connect = true // Auto connect to audio upon joining
            mute = true // 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 = myName
            sessionPassword = "123"
            token = "Your jwt"
            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()
        }
    }

    fun leave() {
        val shouldEndSession = false
        ZoomVideoSDK.getInstance().leaveSession(shouldEndSession)
    }

    fun startVideo() {
        val videoHelper = ZoomVideoSDK.getInstance().videoHelper
        videoHelper?.startVideo()
    }

    fun stopVideo() {
        val videoHelper = ZoomVideoSDK.getInstance().videoHelper
        videoHelper?.stopVideo()
    }

    fun updateUsersCount() {
        var muters = 0
        var speakers = 0
        for (user in users.value!!) {
            if (user.isMuted.value!!) {
                muters += 1
            } else {
                speakers += 1
            }
        }
        mutedUsersCount.postValue(muters)
        speakerUsersCount.postValue(speakers)
    }

    fun initZoomSDK(activity: Activity) {
        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(activity, 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")

                isJoined.postValue(true)

                val session = ZoomVideoSDK.getInstance().session
                val user = UserViewModel(session.mySelf)
                sessionName.postValue(session.sessionName)
                mySelf.postValue(user)
            }

            override fun onSessionLeave() {
                Log.d(tag, "onSessionLeave")

                isJoined.postValue(false)
                sessionName.postValue("")
                users.value?.clear()
            }

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

//                // UI更新用に詰め直す
//                val newUsers = ArrayList<UserViewModel>()
//                if (users.value != null) {
//                    newUsers.addAll(users.value!!)
//                }

                userList?.forEach { user ->
                    Log.d(tag, "  userName: ${user.userName}")

                    val userModel = UserViewModel(user)
                    users.value?.add(userModel)
                }

//                users.value = newUsers
                updateUsersCount()
            }

            override fun onUserLeave(
                userHelper: ZoomVideoSDKUserHelper?,
                userList: MutableList<ZoomVideoSDKUser>?
            ) {
                Log.d(tag, "onUserLeave")

//                // UI更新用に詰め直す
//                val newUsers = ArrayList<UserViewModel>()
//                if (users.value != null) {
//                    newUsers.addAll(users.value!!)
//                }

                userList?.forEach { user ->
                    Log.d(tag, "  id: " + user.userID)
                    Log.d(tag, "  name: " + user.userName)

                    for (userModel in users.value!!) {
                        if (userModel.zoomUser == user) {
                            users.value?.remove(userModel)
                        }
                    }
                }

//                users.postValue(newUsers)
                updateUsersCount()
            }

            override fun onUserVideoStatusChanged(
                videoHelper: ZoomVideoSDKVideoHelper?,
                userList: MutableList<ZoomVideoSDKUser>?
            ) {
                Log.d(tag, "onUserVideoStatusChanged")

                if (userList != null) {
                    for (zoomUser in userList) {
                        val isOn = zoomUser.videoCanvas.videoStatus.isOn

                        for (user in users.value!!) {
                            if (user.zoomUser == zoomUser) {
                                user.isVideoOn.value = isOn
                            }
                        }

                        if (mySelf.value?.zoomUser == zoomUser) {
                            mySelf.value?.isVideoOn?.value = isOn
                            //mySelf.value?.isVideoOn?.postValue(isOn)
                        }
                        Log.d(tag, "  isOn: $isOn")
                    }
                }
            }

            override fun onUserAudioStatusChanged(
                audioHelper: ZoomVideoSDKAudioHelper?,
                userList: MutableList<ZoomVideoSDKUser>?
            ) {
                Log.d(tag, "onUserAudioStatusChanged")

                if (userList != null) {
                    for (zoomUser in userList) {
                        val isMuted = zoomUser.audioStatus.isMuted
                        Log.d(tag, "  isMuted: $isMuted")

                        for (user in users.value!!) {
                            if (user.zoomUser == zoomUser) {
                                user.isMuted.value = isMuted
                            }
                        }

                        if (mySelf.value?.zoomUser == zoomUser) {
                            mySelf.value?.isMuted?.postValue(isMuted)
                        }
                    }
                }

                updateUsersCount()
            }

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

                if (messageItem != null) {
                    addMessage(
                        messageItem.senderUser.userName,
                        messageItem.content)
                }
            }

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

                if (list != null) {
                    for (zoomUser in list) {
                        val userName = zoomUser.userName
                        val isTalking = zoomUser.audioStatus.isTalking
                        Log.d(tag, "  user: ${userName}, isTalking: $isTalking")

                        for (user in users.value!!) {
                            if (user.zoomUser == zoomUser) {
                                user.isTalking.value = true
                            }
                        }

                        if (mySelf.value?.zoomUser == zoomUser) {
                            mySelf.value?.isTalking?.postValue(isTalking)
                        }
                    }
                }
            }

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

    private fun makeRandomString(length: Int): String {
        val charset = "ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz0123456789"
        return (1..length)
            .map { charset.random() }
            .joinToString("")
    }
}
```

# 動作イメージ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/7c27fd2d-2631-6c40-45f2-a4b6c087d5f7.png)



# まとめ
SwiftUIとほぼ同様の実装でJetpack Composeで実装することができました。Zoomのようなオンラインアプリではネットワーク越しの非同期な状態変化にUI側を同期して反映させることが重要ですが、MVVMモデルとの親和性が良いので、SwiftUIやJetpack Composeのような宣言的UIフレームワークをスムーズに利用することができました。

これまではZoomは一般のユーザーとして使う側の立場でしかありませんでしたが、Zoom Video SDKによって自分のアプリにZoom機能を組み込めるようになりました。Zoom SDKの初心者としては最初の[取っ付きにくさ](https://qiita.com/yuppejp/items/838e1becfc2774bd4e85#ハマりどころ)がハードルでしたが...一度慣れてしまえば設計に一貫性があり、iOS版とAndroid版で仕様差がほとんどなく、SwiftUIやJetpack Composeとの相性もバッチリで使いやすいSDKでした。

皆さまも良いZoom SDKライフを！

## 関連記事
- [【Zoom Video SDK】初心者による導入メモ - iOS/Swift編](https://qiita.com/yuppejp/items/838e1becfc2774bd4e85)
- [【Zoom Video SDK】初心者による導入メモ - iOS/SwiftUI編](https://qiita.com/yuppejp/items/a99ad6a8e2c4b0788868)
- [【Zoom Video SDK】初心者による導入メモ - Android/Kotlin編](https://qiita.com/yuppejp/items/46e659aa3d2cb6646923)
- 【Zoom Video SDK】初心者による導入メモ - Android/Jetpack Compose編 (この記事)

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