관리 메뉴

나만을 위한 블로그

[Android] 샌드버드 SDK v4를 사용한 1:1 채팅 기능 구현하기 본문

Android

[Android] 샌드버드 SDK v4를 사용한 1:1 채팅 기능 구현하기

참깨빵위에참깨빵 2023. 8. 23. 19:14
728x90
반응형

※ 이 포스팅의 코드는 예제 수준이기 때문에 실제로 사용하려면 반드시 리팩토링해서 사용하자

 

앱에서 채팅을 구현하려면 웹소켓, 파이어베이스 등 여러 방법이 있다. 하지만 SaaS를 사용해 채팅을 구축할 수도 있는데 그 방법 중 하나가 샌드버드라는 회사에서 제공하는 SDK와 API를 사용해서도 가능하다.

 

https://sendbird.com/

 

A complete in-app chat API and SDK platform

Retail & travel Sendbird helps retailers connect buyers and sellers within the mobile app to increase conversions and engagement, while boosting retention with better support.

sendbird.com

 

기존에 사용하던 개발자들은 v3을 사용했겠지만 2023년 7월부터는 v3에 대한 지원이 중단된다. 공식 깃허브에선 v4 사용을 권장하고 있다.

 

https://github.com/sendbird/Sendbird-Android

 

GitHub - sendbird/SendBird-Android: A guide of the installation and functions of Sendbird Chat, and SyncManager for Android samp

A guide of the installation and functions of Sendbird Chat, and SyncManager for Android samples. - GitHub - sendbird/SendBird-Android: A guide of the installation and functions of Sendbird Chat, an...

github.com

 

문서가 잘 만들어져 있어서 기존의 소스코드를 바탕으로 v4를 적용해 봤는데, 이것에 대해 다루는 내용이 없어서 포스팅을 작성한다.

 

코드의 전체적인 틀은 아래 링크의 코드를 바탕으로 작성했다.

 

https://gift123.tistory.com/40

 

안드로이드 개발 (12) SendBird Chat SDK

안녕하세요 오늘 공부한 것을 정리를 이어서 샌드버드에 대해 알아보겠습니다. 확정은 아니지만 어쩌면 조만간 외주 프로젝트에서 투입되서 SendBird 를 사용할지도 몰라서 미리 공부를 해봤습니

gift123.tistory.com

 

또한 대시보드에서 먼저 프로젝트 기본 설정을 입맛에 맞게 바꿔주고, 미리 테스트 ID를 2개 만들어 둔다. 각각 다른 기기에서 채팅이 실시간으로 전송되는 것을 확인하기 위해서다.

참고로 샌드버드 계정을 새로 만들면 1달은 무료로 사용할 수 있으며 이 포스팅에선 계정 및 대시보드 생성 과정은 생략한다.

그리고 테스트 ID 생성 시 accessToken을 발급할 수 있는데, 난 밑에서 API로 accessToken을 발급받아 사용하는 식으로 구현했기 때문에, ID 생성할 때 따로 토큰을 발급하진 않았다.

UI 수정은 일체 없고 아래 링크의 코드에서 변수명 조금 바꾸고 v3에서 사용하던 요소들을 v4로 마이그레이션했다.

 

먼저 앱 gradle과 프로젝트 gradle에 샌드버드 의존성을 추가하는 것부터 시작한다. 첫 번째 코드는 앱 gradle, 두 번째 코드는 프로젝트 gradle 또는 settings.gradle에 작성한다. 안드로이드 스튜디오 Giraffe에선 settings.gradle에 작성해야 한다.

 

implementation 'com.sendbird.sdk:sendbird-chat:4.11.0'
maven { url "https://repo.sendbird.com/public/maven" }

 

Sync now를 눌러 프로젝트에 적용한 다음 Application을 상속하는 클래스에서 샌드버드 라이브러리의 초기화를 진행한다.

지금은 예제기 때문에 Application에서 초기화하지만 굳이 여기서 초기화할 필요는 없다. 자신의 프로젝트에 맞춰 초기화 시점을 변경해도 된다.

 

SendbirdChat.init(
    InitParams("대시보드에서 확인한 Application ID", applicationContext, useCaching = true),
    object : InitResultHandler {
        override fun onMigrationStarted() {
            MyLog.e("## onMigrationStarted()")
        }

        override fun onInitFailed(e: SendbirdException) {
            MyLog.e("## onInitFailed() : $e")
        }

        override fun onInitSucceed() {
            MyLog.e("## onInitSucceed()")
        }
    }
)

 

InitParams()의 첫 번째 매개변수 문자열은 대시보드에서 확인한 Application ID를 넣어 준다. useCaching은 로컬 캐싱을 사용하겠다면 true, 아니면 false로 설정한다.

이렇게만 설정하고 실행해도 곧바로 onInitSucceed()가 호출되는 걸 볼 수 있을 것이다.

 

이제 액티비티를 만든다.

 

<?xml version="1.0" encoding="utf-8"?>
<layout 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">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".presentation.views.SendbirdTestActivity">

        <TextView
            android:id="@+id/tvChat"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="32dp"
            app:layout_constraintBottom_toTopOf="@+id/etMessage"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:text="chat" />

        <EditText
            android:id="@+id/etMessage"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:ems="10"
            android:inputType="textPersonName"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toStartOf="@+id/btnSend"
            app:layout_constraintStart_toStartOf="parent" />

        <Button
            android:id="@+id/btnSend"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginEnd="16dp"
            android:text="Button"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent" />

        <Button
            android:id="@+id/btnInvite"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:layout_marginEnd="16dp"
            android:text="다른 유저 초대"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <Button
            android:id="@+id/btnExit"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="나가기"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>
class SendbirdTestActivity : BaseActivity<ActivitySendbirdTestBinding>(R.layout.activity_sendbird_test) {

    private var currentUrl: String = ""
    private val params = GroupChannelListQueryParams().apply {
        includeEmpty = true
        order = GroupChannelListQueryOrder.CHRONOLOGICAL
        publicChannelFilter = PublicChannelFilter.ALL
        superChannelFilter = SuperChannelFilter.ALL
    }

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

        /* SessionHandler는 샌드버드 서버 연결 전에 설정해야 함 */
        SendbirdChat.setSessionHandler(object : SessionHandler() {
            override fun onSessionClosed() {
                MyLog.e("## onSessionClosed()")
            }

            override fun onSessionTokenRequired(sessionTokenRequester: SessionTokenRequester) {
                MyLog.e("## onSessionTokenRequired() - requester : $sessionTokenRequester")
                SendbirdChat.Options.setSessionTokenRefreshTimeout(60)
            }

        })

        // TODO : 상대 폰에서 빌드할 때 이거 사용
        val opponentAccessToken = "accessToken"
        // TODO : 내 폰에서 빌드할 때 이거 사용
        val currentAccessToken = "accessToken2"
        val currentUser = getString(R.string.currentUser)
        MyLog.e("## 이 폰에 접속한 유저 : $currentUser")
        SendbirdChat.connect(currentUser, currentSessionToken) { user, e ->
            if (e == null) {
                GroupChannel.createMyGroupChannelListQuery(params).next { list, _ ->
                    MyLog.e("## createMyGroupChannelListQuery() - list : $list")
                    if (list.isNullOrEmpty()) {
                        createChannel()
                    } else {
                        Toast.makeText(this, "이미 채팅방이 존재합니다.", Toast.LENGTH_SHORT).show()
                        currentUrl = list.first().url
                        MyLog.e("## 이미 채팅방이 존재할 때 그 채팅방의 url : $currentUrl")
                        getPreviousMessage()
                    }
                }
            } else {
                MyLog.e("## 샌드버드 서버 연결 에러 : $e")
            }
        }

        binding.btnSend.setOnClickListener {
            GroupChannel.getChannel(currentUrl) { groupChannel, e ->
                groupChannel?.join { e ->
                    if (e != null) {
                        MyLog.e("## join error : $e")
                    } else {
                        groupChannel.sendUserMessage(binding.etMessage.text.toString()) { msg, e ->
                            binding.tvChat.append("${msg?.message}\n")
                        }
                        binding.etMessage.setText("")
                    }
                }
            }
        }

        binding.btnInvite.setOnClickListener {
            val userIds = listOf(getString(R.string.opponentUser))
            MyLog.e("## 초대 버튼 클릭 - 초대할 유저 : $userIds")
            getGroupUrl(currentUrl) { groupChannel ->
                groupChannel?.invite(userIds) { e ->
                    if (e != null) {
                        MyLog.e("## invite error : $e")
                    } else {
                        MyLog.e("## 다른 유저 초대 > invite() 호출 - userIds : $userIds")
                    }
                }
            }
        }

        binding.btnExit.setOnClickListener {
            MyLog.e("## 나가기 클릭")
            getGroupUrl(currentUrl) { groupChannel ->
                groupChannel?.leave { Toast.makeText(this, "탈퇴", Toast.LENGTH_SHORT).show() }
            }
        }

    }

    private fun createChannel() {
        val userList = listOf(getString(R.string.currentUser))
        val params = GroupChannelCreateParams().apply {
            isPublic = true
            isEphemeral = false
            isDistinct = false
            isSuper = false
            userIds = userList
            name = getString(R.string.currentUser)
        }
        GroupChannel.createChannel(params) { groupChannel, e ->
            if (e != null) {
                MyLog.e("## 채팅방 생성 에러 : $e")
            } else {
                currentUrl = groupChannel?.url.toString()
                MyLog.e("## 채팅방 생성! public 여부 : ${groupChannel?.isPublic}, groupChannel?.url.toString() : ${groupChannel?.url.toString()}")
                Toast.makeText(this, "채팅방 생성!", Toast.LENGTH_SHORT).show()
            }
        }
    }

    private fun getPreviousMessage() = GroupChannel.createMyGroupChannelListQuery(params).next { list, e ->
        if (e != null) {
            MyLog.e("## getPreviousMessage() 에러 : $e")
        } else {
            list?.forEach {
                binding.tvChat.append("${it.lastMessage?.message}\n")
            }
        }
    }

    private fun getGroupUrl(url: String, afterLogin: (GroupChannel?) -> Unit) {
        GroupChannel.getChannel(url) { groupChannel, e ->
            if (e != null) {
                MyLog.e("## getChannel() 에러 : $e")
            } else {
                MyLog.e("## getGroupUrl() - url : $url")
                afterLogin(groupChannel)
            }
        }
    }

    override fun onResume() {
        super.onResume()
        SendbirdChat.addChannelHandler(
            identifier = "GROUP_HANDLER_ID",
            handler = object : GroupChannelHandler() {
                override fun onMessageReceived(channel: BaseChannel, message: BaseMessage) {
                    MyLog.e("## onMessageReceived() - 받은 메시지 : ${message.message}")
                    binding.tvChat.append("${message.message}\n")
                }

                override fun onUserJoined(channel: GroupChannel, user: User) {
                    super.onUserJoined(channel, user)
                }

                override fun onUserLeft(channel: GroupChannel, user: User) {
                    super.onUserLeft(channel, user)
                }
            }
        )
    }

    override fun onPause() {
        super.onPause()
        GroupChannel.getChannel(currentUrl) { groupChannel, e ->
            if (e != null) {
                MyLog.e("## onPause() first error : $e")
            } else {
                groupChannel?.leave { error ->
                    if (error != null) {
                        MyLog.e("## 채널 떠나기 에러 : $error")
                    } else {
                        MyLog.e("## 채널 떠나기 성공")
                    }
                }
            }
        }
    }

}

 

currentUser, opponentUser에서 컴파일 에러가 발생할 텐데 당연히 발생할 수밖에 없다. 아직 저 키에 해당하는 값을 strings.xml에 만들지 않았기 때문이다. 이 2개 문자열에 대해선 밑에서 설명할테니 계속 읽어보라.

 

이제 accessToken이 남았다. 이 토큰은 샌드버드 서버에 연결하는 connect()를 호출할 때 필요하며 콜백 핸들러 안에서 채널이 없으면 생성하고, 채널이 있으면 채널 생성 없이 기존 채널에 입장하는 기능을 구현하기 위해 필요하다.

포스트맨 같은 API를 호출할 수 있는 툴을 준비한 다음 아래 경로로 요청해서 accessToken을 발급받아야 한다. 당연한 것이지만 중간의 중괄호들은 모두 없애고 호출해야 한다.

 

POST https://api-{대시보드의 Application ID}.sendbird.com/v3/users/{유저 ID}/token

 

만약 대시보드에서 확인한 Application ID가 "aaa-bbb"고 유저 ID가 "test"라면 요청하는 URL은 아래와 같다.

 

https://api-aaa-bbb.sendbird.com/v3/users/test/token

 

그리고 POST로 요청하면서 헤더와 요청 body도 같이 넣어줘야 한다. 아래를 참고해서 넣어준다.

 

  • API Header의 키는 Api-Token이고, 값으로는 대시보드에서 확인할 수 있는 master api token 값을 넣어주면 된다
  • body로는 빈 JSON을 넘겨도 된다. 필요하다면 expires_at 키를 추가해서 유닉스 시간 형태로 토큰 만료 시간을 정할 수 있다. 만료 시간을 넣지 않아도 자동으로 생성되니 안 넣어도 된다

 

포스트맨의 경우 아래와 같이 body를 구성한다.

 

 

절대로 none 상태 그대로 호출하면 안 된다. none 선택 후 호출 시 400403 에러 코드와 함께 Invalid value: "JSON body" 에러가 표시된다. API 호출을 해도 에러만 나온다면 헤더의 키를 Api-Token으로 설정하고 값을 대시보드의 master api token 값을 잘 넣었는지, url에 대시보드의 Application ID와 유저 ID를 잘 넣었는지 꼼꼼하게 확인해 본다.

 

이렇게 하면 만료 기간이 설정된 accessToken을 얻을 수 있는데, 아까 대시보드에서 2명의 유저를 만들었을 것이다. 그 유저 ID를 URL에 넣어서 API를 각각 호출해 accessToken들을 얻어준다. 이로써 2개의 accessToken을 얻었으니 액티비티 코드 안의 opponentAccessToken, currentAccessToken에 각각 넣어준다.

그리고 strings.xml에 currentUser, opponentUser 문자열을 만들고 대시보드에서 만든 유저 ID 2개를 적절하게 넣어준다.

 

<string name="currentUser">aaa</string>
<string name="opponentUser">TestUser1</string>

 

난 aaa, TestUser1이라는 테스트 ID를 만들었기 때문에 저렇게 작성한 것이다. 각자 만든 ID를 저 안에 적절하게 넣어주자.

이제 2개의 기기를 준비한다. 하나는 핸드폰, 다른 하나는 에뮬레이터라도 상관없다.

둘 중 한 기기에 앱을 빌드했다면, 다른 기기에 앱을 빌드할 때는 반드시 아래의 것들을 수정했는지 확인한 다음 빌드한다.

 

  • currentAccessToken으로 처음 빌드했다면 connect()의 2번째 매개변수를 currentAccessToken -> opponentAccessToken으로 변경한다. 반대로 opponentAccessToken로 처음 빌드했다면 opponentAccessToken -> currentAccessToken으로 변경한다
  • strings.xml의 currentUser, oppnentUser 값을 서로 뒤바꾼다
  • 이미 빌드한 기기에 다시 빌드하지 않게 다른 기기를 선택한다

 

확인 결과 정상적으로 변경됐다면 빌드해서 확인해 본다. 채팅방을 만든 적이 없다면 채팅방 생성 토스트가 표시되고, 다른 기기에서도 같은 토스트가 표시될 것이다. 이후 채팅방을 나갔다가 다시 들어오면 이미 채팅방이 존재한다는 토스트가 표시될 것이다.

이후 한 기기에서 다른 유저 초대 버튼을 누르면, 다른 기기와 실시간으로 채팅을 할 수 있다. 채팅 로그는 대시보드에서도 확인할 수 있다.

 

이렇게 하면 UI는 일체 신경쓰지 않은 1:1 채팅 기능은 구현 완료다. 텍스트뷰에 append()를 통해 받은 메시지를 표시하는 부분을 리사이클러뷰나 LazyColumn으로 바꾸는 것과 전체적인 채팅 UI를 좀 다듬고 코드를 적절하게 리팩토링한다면 완전 날 것 상태의 1:1 채팅 앱을 만들 수 있다.

그러나 전체적으로 UI가 휑하며 초대받은 상대가 초대를 수락, 거절하는 프로세스도 없고, 앱을 끈 상태에서 상대방이 채팅을 치면 푸시 알림으로 채팅이 왔다는 알림을 받는 기능도 없다. 이 부분은 각자 공식문서를 보면서 삽질해보며 구현해 보는 것도 좋을 것이다.

반응형
Comments