Android Developer

Android Zoom Meeting SDK : 2. Integrate SDK

졸려질려 2022. 6. 30. 21:02
반응형

공식 문서 URL : https://marketplace.zoom.us/docs/sdk/native-sdks/introduction

 지난 글에서는 Zoom SDK 에 대해 간단히 알아보고, 데모 앱 중 하나인 "sample" 앱을 실행시켜보았다. 이제는 Zoom Meeting SDK 를 응용하여 내게 맞게 커스텀이 가능한지 공식 문서를 따라하면서 글을 정리해본다.

 

Zoom Meeting SDK - Android 적용기 1

공식 문서 URL : https://marketplace.zoom.us/docs/sdk/native-sdks/introduction 1. Zoom Meeting SDK  Zoom Meeting SDK 는 Zoom Client App 에서 사용 가능한 기능들을 손쉽게 개발 중인 애플리케이션으로..

choboit.tistory.com


1. Locate the libraries

 이제 SDK 의 AAR 파일을 개발하려는 프로젝트에 넣어서, 나만의 Zoom 앱을 만들어보도록 하자. AAR 파일은 데모앱에서 볼 수 있었던 commonlib, mobilertc 폴더에 있다.


2. Add modules and Configure dependencies

 이제 AAR 파일이 있는 모듈 폴더를 import 해주도록 한다.

 [File] -> [New] -> [Import Module] 클릭

 [Browse] 버튼을 클릭하여 파일 탐색기 창을 열기

 AAR 파일이 있는 모듈 폴더 자체를 선택하여 [Open]

 폴더 자체를 선택했다면, 폴더의 이름으로 Module name 이 설정되어 있다. 지금까지의 방식으로 [mobilertc] 모듈 뿐만 아니라, [commonlib] 모듈까지 Import 해주도록 한다.

 그런데, 위와 같이 모듈이 import 된 것처럼 보여도 완전히 import 된 것은 아니다. 제대로 import 되었는지 확인하기 위해서 "build.gradle(Module: app)" 파일에서 implementaion 이 추가되었는지 확인한다. 만약 implementation 이 없다면, 아래와 같이 추가해준다. (Project Structure 에서 Dependency 를 추가하는 방법도 있다.)

dependencies {
    implementation project(path: ':mobilertc')
    implementation project(path: ':commonlib')
}

* Flexbox Error

 commonlib 와 mobilertc 모듈을 추가하고, Dependency 를 추가한 후 실행시켜보면 다음과 같은 에러가 나타날 수 있다. Android Studio Chipmunk 를 사용하고 있는 상황이다.

 "com.google.android:flexbox:2.0.1" 을 찾을 수 없다는 에러이다. 해당 에러는 mobilertc 모듈에서 발생한다. mobilertc 모듈의 build.gradle 파일을 살펴보면, flextbox 에 대한 dependency 가 있는 것을 확인할 수 있다.

// mobilertc/build.gradle
dependencies.add("default","com.google.android:flexbox:2.0.1")

 위 부분을 다음과 같이 수정해주면 해결된다.

// mobilertc/build.gradle
dependencies.add("default","com.google.android.flexbox:flexbox:3.0.0")

 수정 전의 flexbox 도메인은 jCenter 를 repository 로 지정했을 때 사용하던 도메인이다. jCenter 가 Android Project 에서 Deprecated 되었고, jCenter 를 repository 로 지정하지 않아서 생긴 에러였다. 따라서, flexbox 에 대한 도메인을 수정하여 문제를 해결하였다. (참고 링크 : https://github.com/Dhaval2404/ColorPicker/issues/21 )


3. Initialize the SDK

 import 한 SDK 를 코드로 사용하기 위해서는 초기화를 먼저 해줘야한다. ZoomSDK 객체를 인스턴스화 시켜준다.

val sdk = ZoomSDK.getInstance()

 이제 ZoomSDK 객체에 있는 initialize() 메소드를 호출해야한다. 그러나 initialize() 메소드를 호출하기 위해 필요한 인자값들이 존재한다.

initialize(Context, ZoomSDKInitializeListener, ZoomSDKInitParams)

 위와 같이 Context, ZoomSDKInitializeListener, ZoomSDKInitParams 타입의 파라미터가 필요하다. 현재 Activity 안에서 코드를 구현하고 있으므로, Context 는 this 를 통해 넣어줄 수 있다. ZoomSDKInitParams 는 다음과 같이 선언 및 초기화해줄 수 있다.

val params = ZoomSDKInitParams().apply { 
    appKey = "[MY_APP_KEY]"
    appSecret = "[MY_APP_SECRET]"
    domain = "zoom.us"
    enableLog = true // Optional: enable logging for debugging
}

 appKey 와 appSecret 은 지난글에서 JWT 를 발급 받으며 사용했던 SDK key 와 SDK secret 을 사용하면 된다. domain 은 "zoom.us" 로 고정하고, enableLog 는 false 를 해도 상관없다.

 ZoomSDKInitParams 를 위와 같이 초기화한 후, 남은 ZoomSDKInitializeListener 를 초기화한다. 타입명에서 추측할 수 있듯이, ZoomSDK 의 초기화와 관련된 Listener 인 것으로 보인다. Interface 이기 때문에, object 문법을 사용해서 익명 클래스 구현으로 인스턴스화 시켜주도록 한다.

val listener = object: ZoomSDKInitializeListener {
    override fun onZoomSDKInitializeResult(errorCode: Int, internalErrorCode: Int) {
        Log.d(TAG, "onZoomSDKInitializeResult: $errorCode / $internalErrorCode")
    }

    override fun onZoomAuthIdentityExpired() {
        Log.d(TAG, "onZoomAuthIdentityExpired: ")
    }
}

 ZoomSDKInitializeListener 를 구현하며 이상한점이 있다면, 결과에 관한 콜백함수가 있으나, errorCode 변수만 준다는 것이다. 즉, errorCode 안에 성공에 관한 코드넘버도 포함되어있다는 것을 알 수 있다. ZoomSDKInitialize 가 성공적으로 이루어질 때는, errorCode = 0 로 콜백될 것이다. 이미 상수로 만들어진 변수가 있으니, 그것을 사용하는 것도 좋다. 변수명은 ZoomError.ZOOM_ERROR_SUCCESS 이다.

 ZoomSDKInitParams 와 ZoomSDKInitializeListener 를 모두 구현했으니, ZoomSDK.Initialize() 메소드의 파라미터로 넘겨주도록 한다.

sdk.initialize(context, listener, params)

 위 일련의 과정을 하나의 메소드로 묶자면, 다음과 같다.

private fun initializeSdk(context: Context) {
    val sdk = ZoomSDK.getInstance()

    val params = ZoomSDKInitParams().apply {
        appKey = "[MY_SDK_KEY]"
        appSecret = "[MY_SDK_SECRET]"
        domain = "zoom.us"
        enableLog = true // Optional: enable logging for debugging
    }

    val listener = object: ZoomSDKInitializeListener {
        override fun onZoomSDKInitializeResult(errorCode: Int, internalErrorCode: Int) {
            Log.d(TAG, "onZoomSDKInitializeResult: $errorCode / $internalErrorCode")
        }

        override fun onZoomAuthIdentityExpired() {
            Log.d(TAG, "onZoomAuthIdentityExpired: ")
        }
    }

    sdk.initialize(context, listener, params)
}

 이제 실행을 시켜서 Log 를 통해 성공적으로 SDK 가 초기화되는지 확인한다.

 errorCode 0 는 Success 를 뜻하므로, 성공적으로 SDK 가 초기화 되었음을 확인했다.


4. Build a Zoom Test App 

 이제 필수적인 요소들을 가지고 간단한 Zoom 앱을 만들어본다. Test App 에서는 회의를 참석하는 기능과 회의를 시작하는 기능만 가능하도록 구현한다. 우선, Join Meeting 과 Start Meeting 버튼 2개를 만들어준다.

// activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/join_meeting"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/join_meeting" />

    <Button
        android:id="@+id/start_meeting"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/start_meeting" />
</LinearLayout>
// MainActivity.kt
class MainActivity : AppCompatActivity(), View.OnClickListener {
    private val TAG = MainActivity::class.java.simpleName

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        initializeSdk(this)

        val joinButton = findViewById<Button>(R.id.join_meeting)
        joinButton.setOnClickListener(this)
        val startButton = findViewById<Button>(R.id.start_meeting)
        startButton.setOnClickListener(this)
    }
    
    ...

    override fun onClick(view: View?) {
        when (view?.id) {
            R.id.join_meeting -> {

            }
            R.id.start_meeting -> {

            }
            else -> {
                Toast.makeText(this, "onClicked: ${view?.id}", Toast.LENGTH_SHORT).show()
            }
        }
    }
}

 Join Meeting 과 Start Meeting 기능에 대한 분기점을 만들었다. 두 기능 중에서 [Join Meeting] 기능은 이미 생성된 회의 룸에 참여할 수 있도록 해주는 기능이다. 이전 글에서 데모앱을 살펴보면서, 데스크탑의 Zoom 앱에서 생성한 회의실에 데모앱으로 입장했다. 그 때, 참여하고자 하는 회의실의 Meeting Number 와 Meeting Password 를 입력해야했다. 따라서, [Join Meeting] 버튼을 클릭했을 때, Meeting Number 와 Meeting Password 를 입력받는 EditText를 추가로 구현한다.

// activity_main.xml
...
    <EditText
        android:id="@+id/meeting_number_edittext"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:hint="@string/hint_meeting_number" />

    <EditText
        android:id="@+id/meeting_password_edittext"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:hint="@string/hint_meeting_password" />
....

 [Join Meeting] 버튼을 클릭했을 때, 두 EditText 에 값이 들어가있는지 확인하는 Verification 을 구현하는 것이 좋으나, 지금은 Test 앱을 해보는 것이니 생략한다. 배포를 염두에 둔다면 Verification 을 추가 해야할 것이다.


 먼저, [Join Meeting] 기능을 추가한다. 모든 기능은 위에서 구현한 SDK 초기화 이후에 실행되도록 코드를 짜야한다. [Join Meeting] 은 다른 유저가 생성한 회의실로 입장하는 기능이다. 로그인 유무와 상관 없이 회의실의 번호와 암호만 있으면 된다. 따라서, 코드에서도 Meeting Number 와 Meeting Password 를 EditText 로부터 받아서 Join Meeting 할 때 파라미터로 넘겨준다. 코드는 다음과 같다.

// MainActivity.kt
...
    private fun joinMeeting(context: Context, meetingNumber: String, meetingPassword: String) {
        val meetingService = ZoomSDK.getInstance().meetingService
        val options = JoinMeetingOptions()
        val params = JoinMeetingParams().apply {
            displayName = "TEST101"
            meetingNo = meetingNumber
            password = meetingPassword
        }

        meetingService.joinMeetingWithParams(context, params, options)
    }

    override fun onClick(view: View?) {
        when (view?.id) {
            R.id.join_meeting -> {
                val meetingNumber = findViewById<EditText>(R.id.meeting_number_edittext).text.toString()
                val meetingPassword = findViewById<EditText>(R.id.meeting_password_edittext).text.toString()
                joinMeeting(this@MainActivity, meetingNumber, meetingPassword)
            }
            R.id.start_meeting -> {

            }
            else -> {
                Toast.makeText(this, "onClicked: ${view?.id}", Toast.LENGTH_SHORT).show()
            }
        }
    }
...

 displayName 은 하드코딩이 되어있다. 필요에 따라 새로운 EditText 를 추가하여 사용자가 원하는 DisplayName 을 설정할 수 있도록 추가 구현할 수 있다. joinMeeting() 는 회의에 참가하는 메소드이며, EditText 로부터 Meeting Number 와 Meeting Password 를 받아서 Join Meeting 할때 파라미터로 사용한다. 데스크탑에서 Zoom 회의실을 생성하고, 위 코드로 입장하면 다음과 같다.

 회의실에 같이 있는 것을 확인할 수 있고, 다른 Zoom 기능들도 정상적으로 동작한다. 이번에는 반대로 Android 앱에서 회의실을 만들고, 데스크탑에서 회의를 참석하는 구조로 구현해본다. Android 시점에서 Join 과 Start 의 차이점은 어떤 유저 정보가 필요하느냐 일것이다. Join 의 경우에는 로그인을 하지 않아도 회의실의 번호와 암호만 있어도 참석이 가능했다. 하지만, 회의실을 만들때는 Zoom Authorization Key (ZAK) 가 필요하다. 지금까지 구현한 Android 앱에는 ZAK 에 대한 부분이 전혀 없으므로, 이 점을 추가하고 ZAK 를 사용해서 회의실을 만드는 것이 목표라 볼 수 있다.


5. PKCE Auth & Start Meeting

 Proof Key for Code Exchange(PKCE) 가 있는 인증 코드는 Zoom 에서 새롭게 지원하는 OAuth flow 이다. 표준 인증 코드 흐름(Standard Authorization Code flow) 과 유사하지만, 인증 토큰을 얻기 위해 서버를 구축할 필요가 없다는 것이 차별점이다. 그 대신, Zoom OAuth 서버로부터 직접 토큰을 받게 된다. 아래에 Zoom OAuth flow 는 OAuth 2.0 flow 와 거의 똑같기 때문에, OAuth 2.0 에 대해 사전 지식이 있다면 쉽게 접근할 수 있을 것이다 (필자는 사전 지식이 없어서 삽질을 해버렸다).

 PKCE Auth 를 Android 에서 구현하기 위한 환경 조건이 있다.

- Zoom Meeting SDK v5.9.0 or newer
- Zoom Meeting SDK & OAuth credentials
- An install URL from your SDK app

 현재 사용 중인 Zoom Meeting SDK 는 5.10 버전이고, SDK & OAuth credentials 은 SDK 를 다운받으면서 발급 받았다. 발급 받는 방법은 본 글의 최상단에 첨부한 이전 글을 살펴보면 된다.

5-1) Add Dependency

 Zoom SDK 를 새로 만든 Android Project 에 Import 하는 것은 위 섹션들에서 다뤘다. 그 외에 Zoom OAuth 를 사용하기 위해 추가로 Dependency 가 필요하다. Zoom 공식문서에서는 OkHttp 를 사용해서 OAuth 2.0 flow 를 구현했다. 하지만, 필자가 직접 따라해보니 원활히 되지 않는 부분이 많았다. 며칠간의 삽질로 고쳐내서 정리한 내용으로 공식문서와 살짝 다르게 기술한다.

// build.gradle (App Level)

// Zoom SDK
implementation project(path: ':mobilertc')
implementation project(path: ':commonlib')

// Serialization (Kotlin Only)
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2")

// OKHttp
implementation("com.squareup.okhttp3:okhttp:4.9.3")
    
// Retrofit
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'

// Coroutines (Kotlin Only)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2")

 이전 과정에서 추가한 Zoom SDK 외에, Serializtion, OkHttp, Retrofit, Coroutine 에 대한 Dependency 를 추가한다.

5-2) Generate the code verifier and challenge

 먼저, 아래 코드와 같이 Code Verifier 를 생성한다. 그리고 verifier 를 Hash 하여 Code Challenge 를 만들 것이다. 이 과정을 CodeChallengeHelper 클래스에서 담당한다.

// CodeChallengeHelper.kt
class CodeChallengeHelper {
    companion object {
        var verifier: String? = null
    }
}

 CodeChallengeHelper 클래스의 companion object 안에 있는 verifier 변수는 verifier 코드를 저장하기 위해 사용된다. Access Token 을 요청할 때 필요한 Code Challenge 를 생성하는 verifier 는 동일해야하기 때문이다. 

// CodeChallengeHelper.kt
...
    companion object {
        ...
        fun createCodeVerifier() {
            val secureRandom = SecureRandom()
            val code = ByteArray(32)
            secureRandom.nextBytes(code)
            verifier = Base64.encodeToString(code, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
        }

        fun createCodeChallenge(verifier: String): String {
            val bytes: ByteArray = verifier.toByteArray(Charsets.US_ASCII)
            val md: MessageDigest = MessageDigest.getInstance("SHA-256")
            md.update(bytes, 0, bytes.size)
            val digest: ByteArray = md.digest()
            return Base64.encodeToString(digest, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
        }
        ...
    }
...

 companion object 안에 두 개의 메소드도 추가한다. 새로운 verifier 를 생성하는 createCodeVerifier() 메소드와 verifier 를 사용하여 Code Challenge 를 생성하는 getCodeChallenge() 메소드이다. 이제 verifier 에 접근하거나, Code Challenge 를 얻고 싶다면 필수적으로 createCodeVerifier() 메소드를 사전에 호출해줘야한다.

5-3) Get Authorization code

 CodeChallengeHelper 에서 생성한 verifier 를 사용하여 Zoom 의 Auth Server 로 요청을 전송한다. 쉽게 생각하면, Zoom 에 로그인하는 것이라 보면 된다. 실제로 다음 과정을 거치면 Zoom 에 로그인하는 과정처럼 느껴진다. 현재 Zoom SDK 를 테스트 중인 ZoomTest 앱의 MainActivity 에서 Request 를 보내서 AuthActivity 에서 OAuth Redirection 을 받는 과정을 구현한다.

 먼저, OAuth Redirection 을 받을 AuthActivity 를 생성한다. 해당 Activity 는 OAuth Redirection URL 을 통해 접근되며, OAuth Response 로 받게되는 "code" URL 파라미터의 값을 받기 위해 사용된다.

// AuthActivity.kt
class AuthActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_auth)

        val textView = findViewById<TextView>(R.id.auth_textview)
    }
}
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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"
    android:gravity="center"
    tools:context=".AuthActivity">

    <TextView
        android:id="@+id/auth_textview"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />
</LinearLayout>

 code 값을 받기 전에 한가지 해야될 일이 있기 때문에 AuthActivity 를 생성만 한다. 후에 TextView 로 code 값을 띄울 것이기 때문에 TextView 에 대한 바인딩 코드만 추가했다.

 이제 "AndroidManifest.xml" 파일로 가서 Custom URL Scheme 을 설정한다. Custom URL Scheme 을 설정하면, 웹에서 링크로 접속했을 때 해당 Activity 가 열리도록 할 수 있다.

// AndroidManifest.xml
...
<activity android:name=".AuthActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />

        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />

        <data
            android:host="auth"
            android:scheme="com.example.zoomtest" />
    </intent-filter>
</activity>
...

 위와 같이 <intent-filter> 를 설정하게 되면, "com.example.zoomtest://auth" 링크로 접속했을 때 AuthActivity 가 열리게 된다. 이제 Zoom SDK 페이지로 가서 "com.example.zoomtest://auth" 링크를 OAuth Redirection URL 로 설정한다.

 아래에 OAuth allow list 에도 링크를 추가해줘야한다. 그렇지 않으면, "잘못된 리디렉션" 페이지가 뜨면서 Custom URL Scheme 이 동작하지 않는다. OAuth Redirection URL 추가를 완료한 후에, Zoom SDK 페이지에서 [SDK Activation] 메뉴로 들어간다. 그리고 그 안에 OAuth URL 링크를 복사한다.

 다시 MainActivity 로 돌아와서, [Start Meeting] 버튼을 눌렀을 때 방금 복사한 링크로 code 요청을 보내도록 구현한다. 위 링크로 요청을 보낼 때, CodeChallengeHelper 클래스에서 verifier 를 생성하고 Code Challenge 까지 생성하여 파라미터 값으로 보낼 것이다.

// MainActivity.kt
...
    private fun startMeeting() {
        CodeChallengeHelper.createCodeVerifier()
        
        val OAUTH_URL = "[COPY_URL]"
        val uri = Uri.parse(OAUTH_URL)
            .buildUpon()
            .appendQueryParameter("code_challenge", CodeChallengeHelper.createCodeChallenge(CodeChallengeHelper.verifier!!))
            .appendQueryParameter("code_challenge_method", "S256")
            .build()

        val intent = Intent(Intent.ACTION_VIEW, uri)
        startActivity(intent)
    }
...

 요청을 보낼 때 verifier 가 필요하기 때문에, 가장 먼저 CodeChallengeHelper.createCodeVerifier() 메소드를 호출한다. 그 후에 Zoom SDK 에서 최종적으로 복사한 URL 을 기반으로 "code_challenge" 와 "code_challenge_method" 파라미터를 추가하여 링크를 호출한다. [Start Meeting] 버튼을 클릭했을 때, 위 startMeeting() 메소드가 호출되도록 한다.

// MainActivity.kt
...
    private fun startMeeting() {
	    ...
    }

    override fun onClick(view: View?) {
        when (view?.id) {
            R.id.join_meeting -> {
                val meetingNumber = findViewById<EditText>(R.id.meeting_number_edittext).text.toString()
                val meetingPassword = findViewById<EditText>(R.id.meeting_password_edittext).text.toString()
                joinMeeting(this@MainActivity, meetingNumber, meetingPassword)
            }
            R.id.start_meeting -> {
                startMeeting()
            }
            else -> {
                Toast.makeText(this, "onClicked: ${view?.id}", Toast.LENGTH_SHORT).show()
            }
        }
    }
...

 이제 AuthActivity 에서 OAuth Redirection 과 함께 전송되는 code 값을 출력하는 것을 구현한다.

class AuthActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_auth)

        val textView = findViewById<TextView>(R.id.auth_textview)

        val action: String? = intent?.action
        val data: Uri? = intent?.data

        if (action == Intent.ACTION_VIEW) {
            val code = data?.getQueryParameter("code")
            textView.text = code
        }
    }
}

 OAuth Redirection 에서 AuthActivity 가 열리면서, URL 안에 code 파라미터의 값이 출력되는 것을 확인할 수 있다. 이제 code 를 가지고 Zoom 의 Access Token 과 ZAK 까지 발급받는 과정을 기술하도록 한다.

5-4) Get AccessToken

 위에 발급받은 code 를 사용하여 AccessToken 을 발급받는다. 위에서 발급 받은 code 를 이제부터 "Authorization code" 라 부른다. Authorization code 로 "zoom.us/oauth/token" 서버로부터 AccessToken 을 발급 받을 것이다. 이 부분부터 공식문서에서는 OkHttpClient 를 사용해서 설명을 한다. 그러나, 직접 해보니 Retrofit 을 사용하는 것이 더 편한 것 같아서 Retrofit 으로 AccessToken 발급 방법에 대해 기술한다. 

 먼저, AccessToken 을 발급 받을 수 있는 API 로 연결하는 Retrofit 의 interface 를 추가한다. 그와 동시에, 발급 받을 AccessToken 에 대한 Data class 도 추가해준다.

// AccessToken.kt
data class AccessToken(
    @SerializedName("access_token") val access_token: String,
    @SerializedName("token_type") val token_type: String,
    @SerializedName("refresh_token") val refresh_token: String,
    @SerializedName("expires_in") val expires_in: Long,
    @SerializedName("scope") val scope: String
)
// RetrofitService.kt
interface RetrofitService {
    companion object {
        const val BASE_URL = "https://zoom.us/"
    }

    @Headers("Content-type: application/x-www-form-urlencoded")
    @POST("oauth/token")
    fun getAccessToken(
        @Header("Authorization") basicAuth: String,
        @Query("grant_type") grantType: String = "authorization_code",
        @Query("code") code: String,
        @Query("redirect_uri") redirectUri: String,
        @Query("code_verifier") codeVerifier: String
    ): Call<AccessToken>
}

 RetrofitService 코드를 보면, "https://zoom.us/oauth/token" 주소로 POST 요청을 보낸다는 것을 알 수 있다. Header 에는 "Authorization" 과 "Content-type" 을 설정하며, Query 파라미터를 추가하여 Request URL 을 완성한다. Response 의 Body 는 같이 구현한 AccessToken 로 받을 수 있도록 구현했다. 이제 위 인터페이스를 실제로 호출하여 사용할 부분을 구현한다.

 이전에 Authorization code 는 AuthActivity 에서 Intent 를 통해 받아서, URL Query 의 값을 추출하여 얻었다. 이제 그 값을 getAccessToken() 메소드에서 "code" 부분에 입력하여 API 호출을 할 것이다. 코드는 다음과 같다.

// AuthActivity.kt
...
    private fun requestAccessToken(code: String) {
        val encoded = Base64.encodeToString(
            "$CLIENT_ID:$CLIENT_SECRET".toByteArray(),
            Base64.URL_SAFE or Base64.NO_WRAP
        )
        
        val retrofit = Retrofit.Builder()
            .addConverterFactory(GsonConverterFactory.create())
            .baseUrl(RetrofitService.BASE_URL)
            .build()

        val service = retrofit.create(RetrofitService::class.java)
        service.getAccessToken(
            basicAuth = "Basic $encoded",
            code = code,
            redirectUri = "com.example.zoomtest://auth",
            codeVerifier = CodeChallengeHelper.verifier!!
        ).enqueue(object : Callback<AccessToken> {
            override fun onResponse(call: Call<AccessToken>, response: Response<AccessToken>) {
                if (response.body() != null) {
                    val receivedData = response.body()
                    Log.d(TAG, "onAccessTokenResponse: ${response.body()}")
                    accessToken = receivedData!!.access_token
                    getZak()
                }
            }

            override fun onFailure(call: Call<AccessToken>, t: Throwable) {
                Log.d(TAG, "onFailure: $t")
            }

        })
    }
....

 Zoom SDK 페이지에서 얻을 수 있는 "Client ID" 와 "Client Secret" 을 ":(콜론)" 으로 조합한 후, Base64 로 인코딩해준다.

val encoded = Base64.encodeToString(
    "$CLIENT_ID:$CLIENT_SECRET".toByteArray(),
    Base64.URL_SAFE or Base64.NO_WRAP
)

 그 외에 필요한 값들은 모두 가지고 있으니, 바로 API 통신 부분을 구현한다. Retrofit 객체를 생성하면서, AccessToekn 모델에 Response 를 파싱할 수 있도록 "GsonConverterFactory" 도 설정해주도록 한다. 그 후에, RetrofitService 의 getAccessToken() 메소드를 호출한다.

1) 첫번째 인자값인 "basicAuth" 는 헤더에 넣어줄 부분이다. 방금 Client ID 와 Client Secret 을  Base64 로 인코딩한 값의 앞에 "Basic " 문자열을 붙여서 넣어준다.

2) 두번째 인자값은 메소드 정의상, "grant_type" 이다. 하지만, 메소드를 정의하면서 "authorization_code" 라는 값으로 고정했기 때문에, 호출 할때 생략이 가능하다.

3) 세번째 인자값은 "code" 이며, Zoom 로그인을 하면서 발급 받은 Authrization code 를 넣어준다.

4) 네번째 인자값은 "redirectUri" 이다. Authorization code 를 발급 받은 AuthActivity 로 리디렉션 되도록 똑같은 주소를 사용했다.

5) 다섯번째 인자값은 "codeVerifier" 이다. Authorization code 를 발급 받으면서 CodeChallengeHelper 로 생성한 verifier 값을 넣는다. AccessToken 을 발급하는 서버에서, Authorization code 를 요청할 때 받은 Code Challenge 값과 AccessToekn 을 요청할 때 받게 될 Code Verifier 값을 서로 매칭시켜서 알맞을 경우에만 AccessToken 을 발급한다. 따라서, Authorization Code 를 발급 받을 때 생성했던 verifier 값이 동일하게 필요하다.

val retrofit = Retrofit.Builder()
    .addConverterFactory(GsonConverterFactory.create())
    .baseUrl(RetrofitService.BASE_URL)
    .build()

val service = retrofit.create(RetrofitService::class.java)
service.getAccessToken(
    basicAuth = "Basic $encoded",
    code = code,
    redirectUri = "com.example.zoomtest://auth",
    codeVerifier = CodeChallengeHelper.verifier!!
).enqueue(object : Callback<AccessToken> {
    override fun onResponse(call: Call<AccessToken>, response: Response<AccessToken>) {
        if (response.body() != null) {
            val receivedData = response.body()
            Log.d(TAG, "onAccessTokenResponse: ${response.body()}")
            accessToken = receivedData!!.access_token
            getZak()
        }
    }

    override fun onFailure(call: Call<AccessToken>, t: Throwable) {
        Log.d(TAG, "onFailure: $t")
    }
})

 이제 로그를 통해 AccessToken 모델의 형식으로 Response 가 넘어오는 것을 확인할 수 있다. 만약 AccessToken 이 넘어오지 않는다면, Zoom SDK 페이지에서 Permission 부분을 확인해볼 필요가 있다.

 Zoom SDK 에 대한 Permission 은 [Scopes] 메뉴에서 설정할 수 있다. 우측에 [+ Add Scopes] 버튼을 클릭하여, [User] 메뉴에 있는 권한들과 [Meeting] 메뉴에 있는 권한들을 모두 체크해준다. 필요한 권한이 무엇인지까지 정확히 몰라서 모든 권한을 체크하는 것으로 해결했다. 아직은 Zoom SDK 를 테스트 해보는 상황이라 가능한 것이고, 추후에 배포까지 하게 된다면 상세히 알아두는 것이 좋아보인다.

[Scopes] -> [Meeting]
[Scopes] -> [User]

5-5) Get ZAK (Zoom Access Key)

 AccessToken 을 획득하는데 성공했다면, 이제 회의실을 호스팅하기 위해 ZAK 발급만 받으면 된다. 위 "Get AccessToken" 파트의 코드 블럭 중에 "getZAK()" 메소드가 호출되는 부분이 있다. 발급 받은 AccessToken 을 바로 ZAK 발급해야 하기 때문에, AccessToken 이 정상적인 응답으로 들어온 후에 ZAK 받는 메소드를 호출하도록 구현했다.

 AccessToken 을 요청할 때, 돌아오는 응답의 형태는 Data class 를 통해 알 수 있었다. 그에 반해, ZAK 는 "token" 값 하나만 응답으로 돌아온다.

 key 가 "token" 하나 뿐이기 때문에, ZAK 는 Data class 를 따로 두지 않고 JSON 파싱을 사용해서 얻었다. ZAK 요청 코드는 다음과 같다.

// RetrofitService.kt
interface RetrofitService {
    companion object {
        ....
        const val BASE_ZOOM_API_URL = "https://api.zoom.us/"
    }

    ....
    
    @GET("v2/users/me/token")
    fun getZak(
        @Header("authorization") bearerAuth: String,
        @Query("type") type: String = "zak"
    ): Call<ResponseBody>
}
// AuthActivity.kt
private fun getZak() {
    val retrofit = Retrofit.Builder()
        .addConverterFactory(GsonConverterFactory.create())
        .addConverterFactory(ScalarsConverterFactory.create())
        .baseUrl(RetrofitService.BASE_ZOOM_API_URL)
        .build()

    val service = retrofit.create(RetrofitService::class.java)
    service.getZak("Bearer $accessToken").enqueue(object : Callback<ResponseBody> {
        override fun onResponse(call: Call<ResponseBody>, response: Response<ResponseBody>) {
            if (response.isSuccessful && response.body() != null) {
                val json = JSONObject(response.body()!!.string())
                zak = json.getString("token")

                startMeeting()
            }
        }

        override fun onFailure(call: Call<ResponseBody>, t: Throwable) {
            Log.d(TAG, "onFailure: $t")
        }
    })
}

 ZAK 는 AccessToken 을 요청하는 URL 과 완전히 다르다. ZAK 는 "https://api.zoom.us" 서버로 요청을 보낸다. 그래서 Retrofit 객체도 baseUrl 을 다르게 설정해서 새로 생성해야한다. 그리고, ZAK 요청의 헤더값은 Bearer 인증값으로 넣어줘야한다. 그래서 "Bearer [ACCESS_TOKEN]" 형식으로 문자열을 구성하여 헤더값으로 넣어준다. 그리고 호출을 하면 ZAK 가 정상적으로 돌아온다. JSON 파싱은 다양한 방법이 존재하니, 상세히 설명하는 것은 생략한다.

5-6) Start Meeting

 이제 본 섹션의 목표였던 [Start Meeting] 을 실질적으로 구현하는 단계에 도착했다. 최종적으로 발급받은 ZAK 를 사용해서 회의실을 생성할 것이다. 추가적으로 ZAK 외에 설정해줘야 하는 MeetingParams 가 존재한다. 우선 코드는 다음과 같다.

// AuthActivity.kt
private fun startMeeting() {
    val meetingService = ZoomSDK.getInstance().meetingService
    val startParams = StartMeetingParamsWithoutLogin().apply {
        zoomAccessToken = zak
        meetingNo = ""
        displayName = "test202"
        userId = "dodri0605@naver.com"
        userType = MeetingService.USER_TYPE_UNKNOWN
    }

    meetingService.addListener(object : MeetingServiceListener {
        override fun onMeetingStatusChanged(p0: MeetingStatus?, p1: Int, p2: Int) {
            Log.d(TAG, "onMeetingStatusChanged: $p0 / $p1 / $p2")
        }

        override fun onMeetingParameterNotification(p0: MeetingParameter?) {
            Log.d(TAG, "onMeetingParameterNotification: $p0")
        }

    })

    val result = meetingService.startMeetingWithParams(
        this@AuthActivity,
        startParams,
        StartMeetingOptions()
    )
    Log.d(TAG, "ZOOM START Result: $result")
}

 위 코드에서 중요한 부분은 StartMeetingParamsWithoutLogin() 에서 프로퍼티를 설정해주는 부분이다. meetingService.addListener() 코드는 옵션 같은 느낌이지만, 위에 Params 를 초기화하는 코드는 잘못 값을 넣어서는 회의실이 생성되지 않는다.

val startParams = StartMeetingParamsWithoutLogin().apply {
    zoomAccessToken = zak
    meetingNo = ""
    displayName = "test202"
    userId = "dodri0605@naver.com"
    userType = MeetingService.USER_TYPE_UNKNOWN
}

 "zoomAccessToken" 프로퍼티에는 방금 마지막으로 발급 받은 ZAK 값을 넣어준다. 그리고 meetingNo 는 "" 빈 문자열로 설정한다. 빈 문자열로 설정하면, 회의실이 생성되면서 랜덤 회의실 넘버가 설정된다. displayName 은 [Join Meeting] 기능을 구현했을 때처럼 회의실에서 보여주고 싶은 이름을 넣어준다.

 "userId" 는 JavaDocs 에 따르면 API 에서 반환 받은 UserId 값이라 정의되어 있다. 하지만, 위와 같이 필자의 이메일을 넣었더니 정상적으로 동작했다. 상세한 내용을 더 알아보고 싶었지만, Authorization code -> AccessToken -> ZAK 과정에서 삽질을 너무 많이 해서 보류하기로 했다.

 마지막으로 "userType" 은 "UNKNOWN" 으로 설정했다. "userType" 을 설정하지 않으면, 위에 값들이 적절히 있더라도 회의실이 생성되지 않는다. 반드시 "UNKNOWN" 이 아니어도 되는 것 같지만, 자세한 내용은 "userId" 문단에서 언급했듯이 시간이 없어서 보류했다.... userType 의 종류는 JavaDocs 문서를 참고해서 알 수 있었다.


6. 정리

 이로써 Zoom SDK 를 원하는 프로젝트로 가져와서 직접 Zoom Meeting 기능을 구현해볼 수 있었다. AccessToken 을 얻는 과정부터 공식 문서대로 따라해도 동작하지 않아서, 꽤 많은 시간을 할애 해버렸다. 그래도 해당 문제를 해결하면서 OAuth 2.0 flow 에 대해 알게되었고, Zoom Meeting SDK 의 credential 이 어떻게 사용되는지 좀 더 자세히 알 수 있었다.

 [Start Meeting] 을 구현하는 부분의 내용이 조금 맥락이 부자연스러울 수가 있다. 보통 실습하면서 실시간으로 블로그에 글을 정리하는데, AccessToken 부분에서 오래 막혀서 블로그 글이 그 부분에서 막혔다가 다시 시작했다. 그래서 조금 맥락이 이어지지 않는 느낌이 날 수도 있다. [Start Meeting] 의 내용이 조금 장황하지만, 간단히 정리하자면 다음 과정을 거친다고 보면 된다.

  1. Get Authorization code : Zoom 로그인과 동시에 인증된 계정인지 확인하는 과정이라 보면 된다.
  2. Get AccessToken : Authorization code 는 계정에 대한 확인이었다면, AccessToken 은 해당 계정이 Meeting Host 를 할 수 있는 권한이 있는지 확인하는 과정이라 보면 된다. 서버는 User 가 요청한 CodeChallenge 와 Verifier 를 사용해서 Authorization code 를 요청한 계정이 AccessToken 을 요청하는 것인지 확인한다.
  3. Get ZAK : 이제 Zoom Hosting 에 필요한 ZAK 를 발급받는다.

 위의 3단계 과정은 OAuth 2.0 flow 를 기반으로 하기 때문에 생긴 절차이다. ZAK 발급 과정에 대해 깊은 원리를 알고 싶다면, OAuth 2.0 flow 를 살펴보는 것을 추천한다. 필자 또한 AccessToken 에서 막혀있을 때, OAuth 2.0 에 대한 글을 보고 해결 방법을 찾을 수 있었다.

 다음 글은 Meeting SDK 로 UI 를 커스텀하는 것에 대해 정리한 글을 올려보고자 한다. Zoom Meeting SDK 는 완전한 Zoom Client 를 띄워주는 느낌이라 UI 커스텀이 많이 자유롭지 않을 것으로 추측된다. 그래도 Custom UI 메뉴가 가이드 문서에 존재하는 것을 보면 어느정도 있는 것 같다. 더 자유로운 커스텀은 Zoom Video SDK 에서 가능한 것 같으나, 이건 분(min)당 0.0035 달러(45원?) 를 해서 최대한 무료 SDK 선에서 필자가 원하는 앱을 구현할 수 있을지 살펴보고자 한다.

반응형