AgoraSDKを使ってAndroidのビデオ通話アプリを作ってみる

# Agoraとは
リアルタイムの音声・ビデオ通話やライブストリーミング機能を提供するライブラリです。
Agora SDKをアプリに組み込むことで簡単に音声と映像の送受信が可能となります。

https://www.agora.io/en/

# 今回作るアプリ
AgoraSDKを使って簡単なビデオ通話アプリを作ります。
アプリ起動後に音声と映像の送受信ができるシンプルな作りです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3979045/cc70c31c-e126-4667-94bc-c03f08493fe3.png" width=30%>

# ビデオ通話ができるようになるまでの流れ
1. Agora Project作成
1. Agora SDKの依存関係を追加
1. マイクやカメラ等の必要なPermissionの許可設定をする
1. Rtc Engineを生成
1. Rtc Channelに参加
1. 音声と映像の送受信が可能となる

# 事前準備
### 1. Agora ConsoleでProject作成
①アカウント作成
②Create Project

https://console.agora.io/projects

![スクリーンショット 2025-02-12 22.17.11.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3979045/c0f8e47f-e837-47ec-a61b-f4deaa208716.jpeg)

### 2. 依存関係を追加
SDKは2025/2月時点で最新の4.5.0を使用

https://central.sonatype.com/artifact/io.agora.rtc/full-sdk/4.5.0/dependencies

```libs.versions.toml
[versions]
agora = "4.5.0"

[libraries]
agora = { module = "io.agora.rtc:full-sdk", version.ref = "agora" }
```
```build.gradle
implementation(libs.agora)
```

### 3. 必要なPermissionを追加
下記をマニフェストファイルに追加します。(ドキュメントからの引用です)
```Xml
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!--Required permissions-->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<!--Optional permissions-->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.BLUETOOTH"/>
<!-- For devices running Android 12 (API level 32) or higher and integrating Agora Video SDK version v4.1.0 or lower, you also need to add the following permissions -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<!-- For Android 12.0 or higher, the following permissions are also required -->
<uses-permission android:name="android.permission.READ_PHONE_STATE"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
```

# ビデオ通話機能を実装する
`App Id`と`RTC Token`が必要になるのでAgora ConsoleのProjectページで確認する。

```Kotlin
/**
 * Agora Projectの情報
 */
object AgoraConfig {
    const val APP_ID = "xxx" // Agora Consoleで作成したProjectのApp Idを使用する
    const val RTC_TOKEN = "xxx" // Agora Consoleで作成したProjectのRTC Tokenを使用する
    const val CHANNEL_NAME = "DemoChannel"
}
```
Agoraを利用したビデオ通話のロジックを実装するInterface。
```Kotlin
import android.content.Context
import android.util.Log
import io.agora.rtc2.ChannelMediaOptions
import io.agora.rtc2.Constants
import io.agora.rtc2.IRtcEngineEventHandler
import io.agora.rtc2.RtcEngine
import io.agora.rtc2.RtcEngineConfig
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

/**
 * Agora のAPIクライアント
 */
interface AgoraService {
    var mRtcEngine: RtcEngine?
    var remoteUserId: StateFlow<Int?>
    var localUserId: StateFlow<Int?>

    fun initializeRtcEngine(context: Context)
    fun joinChannel()
    fun leaveChannel()
}

class  AgoraServiceImpl: AgoraService {
    companion object {
        private const val TAG = "AgoraServiceImpl"
    }

    private var _remoteUserId = MutableStateFlow<Int?>(null)
    private var _localUserId = MutableStateFlow<Int?>(null)

    override var remoteUserId: StateFlow<Int?> = _remoteUserId
    override var localUserId: StateFlow<Int?> = _localUserId

    override var mRtcEngine: RtcEngine? = null

    /**
     * Rtc Engine を生成
     */
    override fun initializeRtcEngine(context: Context) {
        // 古いRtcEngineの影響を受けないようにするため初期化前に解放する
        RtcEngine.destroy()

        // RtcEngineConfigを生成
        val rtcConfig = RtcEngineConfig().apply {
            mAppId = AgoraConfig.APP_ID
            mContext = context
            mEventHandler = object : IRtcEngineEventHandler() {
                /**
                 * Local UserがChannelに参加成功した契機で通知されるコールバック
                 */
                override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
                    super.onJoinChannelSuccess(channel, uid, elapsed)
                    Log.d(TAG, "onJoinChannelSuccess channel: $channel uid: $uid")
                    _localUserId.value = uid
                }

                /**
                 * Remote UserがChannelに参加成功した契機で通知されるコールバック
                 */
                override fun onUserJoined(uid: Int, elapsed: Int) {
                    super.onUserJoined(uid, elapsed)
                    Log.d(TAG, "onUserJoined uid: $uid")
                    _remoteUserId.value = uid
                }
            }
        }

        // RtcEngineを生成
        try {
            mRtcEngine = RtcEngine.create(rtcConfig)
        } catch (e: Exception) {
            Log.e(TAG, "creating rtc engine is fail.")
        }
    }

    /**
     * Rtc Channelに参加
     */
    override fun joinChannel() {
        if (mRtcEngine == null) {
            Log.e(TAG, "joinChannel, mRtcEngine is null")
            return
        }

        // ChannelMediaOptionsを生成
        val channelMediaOptions = ChannelMediaOptions().apply {
            clientRoleType = Constants.CLIENT_ROLE_BROADCASTER
            channelProfile = Constants.CHANNEL_PROFILE_COMMUNICATION
            publishMicrophoneTrack = true
            publishCameraTrack = true
        }

        // ビデオ機能を有効化
        enableVideo()

        // Channelに参加
        mRtcEngine?.joinChannel(
            AgoraConfig.TOKEN,
            AgoraConfig.CHANNEL_NAME,
            0,
            channelMediaOptions
        )
    }

    /**
     * Channelから退出
     * RtcEngineインスタンスを解放
     */
    override fun leaveChannel() {
        mRtcEngine?.leaveChannel()
        mRtcEngine = null
    }

    /**
     * ビデオ機能を有効化
     */
    private fun enableVideo() {
        mRtcEngine?.apply {
            enableVideo()
            startPreview()
        }
    }
}
```
自分と通話相手のビデオ画面。
AgoraはJetpack Composeに対応しているAPIは存在しないので、Android View Composableを使用して組み込む形になります。

```Kotlin
import android.view.SurfaceView
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import io.agora.rtc2.RtcEngine
import io.agora.rtc2.video.VideoCanvas

/**
 * 通話相手のビデオ画面
 */
@Composable
fun RemoteVideoScreen(rtcEngine: RtcEngine, uid: Int) {
    Box(modifier = Modifier.fillMaxSize()) {
        AndroidView(
            factory = { context ->
                SurfaceView(context).apply {
                    rtcEngine.setupRemoteVideo(
                        // uidはIRtcEngineEventHandler#onUserJoined()で通知されるuidを使用する
                        VideoCanvas(this, VideoCanvas.RENDER_MODE_FIT, uid)
                    )
                }
            }
        )
    }
}
```
```Kotlin
import android.view.SurfaceView
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import io.agora.rtc2.RtcEngine
import io.agora.rtc2.video.VideoCanvas

/**
 * 自分のビデオ画面
 */
@Composable
fun LocalVideoScreen(rtcEngine: RtcEngine, uid: Int) {
    Box(modifier = Modifier.width(200.dp).height(400.dp)) {
        AndroidView(
            factory = { context ->
                SurfaceView(context).apply {
                    setZOrderMediaOverlay(true)
                    rtcEngine.setupLocalVideo(
                        // uidはIRtcEngineEventHandler#onJoinChannelSuccess()で通知されるuidを使用する
                        VideoCanvas(this, VideoCanvas.RENDER_MODE_FIT, uid)
                    )
                }
            }
        )
    }
}
```
`LocalVideoScreen`と`RemoteVideoScreen`をまとめて表示する`VideoCallingScreen`。
`RemoteVideoScreen`の上に`LocalVideoScreen`を配置しています。
`AgoraService`の`remoteUserId`と`localUserId`を監視する。
```Kotlin
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import io.agora.rtc2.RtcEngine

@Composable
fun VideoCallingScreen(agoraService: AgoraService) {
    val remoteUserId by agoraService.remoteUserId.collectAsState()
    val localUserId by agoraService.localUserId.collectAsState()

    agoraService.mRtcEngine?.let { rtcEngine ->
        if (remoteUserId != null && localUserId != null) {
            VideoCallingScreenContent(rtcEngine, remoteUserId!!, localUserId!!)
        }
    }
}

@Composable
fun VideoCallingScreenContent(rtcEngine: RtcEngine, remoteUserId: Int, localUserId: Int) {
    Box(modifier = Modifier.fillMaxSize()) {
        RemoteVideoScreen(rtcEngine, remoteUserId)
        LocalVideoScreen(rtcEngine, localUserId)
    }
}
```
MainActivityでは以下の処理を実装。
* ビデオ通話に必要な端末の許可設定
* `AgoraService`のAPIを呼び出す
* `VideoCallingScreen`を表示する
```Kotlin
import android.Manifest.permission.BLUETOOTH_CONNECT
import android.Manifest.permission.CAMERA
import android.Manifest.permission.READ_PHONE_STATE
import android.Manifest.permission.RECORD_AUDIO
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.eotw95.agorademo.ui.theme.AgoraDemoTheme
import io.agora.rtc2.RtcEngine

class MainActivity : ComponentActivity() {
    companion object {
        private const val TAG = "MainActivity"
    }

    private val agoraService = AgoraServiceImpl()

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

        if (isGrantedSelfPermission(getRequiredPermissions())) {
            startVideoCalling()

            enableEdgeToEdge()
            setContent {
                AgoraDemoTheme {
                    VideoCallingScreen(agoraService)
                }
            }
        } else {
            ActivityCompat.requestPermissions(this, getRequiredPermissions(), 22)
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        agoraService.leaveChannel()
        RtcEngine.destroy()
    }

    /**
     * ActivityCompat.requestPermissions()が呼ばれ後に通知されるコールバック
     * Permissionの許可設定を再度チェックする
     */
    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray,
        deviceId: Int
    ) {
        Log.d(TAG, "onRequestPermissionsResult")
        super.onRequestPermissionsResult(requestCode, permissions, grantResults, deviceId)

        if (isGrantedSelfPermission(getRequiredPermissions())) {
            startVoiceCalling()
        }
    }

    /**
     * ビデオ通話を開始
     */
    private fun startVideoCalling() {
        Log.d(TAG, "startVideoCalling")

        agoraService.initializeRtcEngine(applicationContext)
        agoraService.joinChannel()
    }

    /**
     * ユーザーの許可設定が必要なPermission
     */
    private fun getRequiredPermissions(): Array<String> {
        Log.d(TAG, "getRequirePermission")

        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            arrayOf(
                CAMERA,
                RECORD_AUDIO,
                BLUETOOTH_CONNECT,
                READ_PHONE_STATE
            )
        } else {
            arrayOf(
                CAMERA,
                RECORD_AUDIO
            )
        }
    }

    /**
     * Permissionが許可されているかどうかチェック
     */
    private fun isGrantedSelfPermission(permissions: Array<String>): Boolean {
        Log.d(TAG, "isGrantedSelfPermission")

        permissions.forEach { permission ->
            val granted = ContextCompat.checkSelfPermission(application, permission)
            if (granted != PackageManager.PERMISSION_GRANTED) return false
        }

        return true
    }
}
```

# 実際に通話の動作確認をする方法
1. Android端末を2台用意する。
1. 上記のソースコードを実装したAPKを2台の端末にインストールしてビルドする。
1. アプリ起動後にPermissionの許可を促すダイアログが表示される場合は許可する。
1. ビデオ通話ができる状態となる。