이 문서에서는 Android 애플리케이션에서 헤드리스 모바일 SDK를 통합하고 맞춤설정하는 방법을 설명합니다. 음성, 화면 공유, 예약된 통화, 이메일을 다룹니다.
음성 통화
Android용 헤드리스 모바일 SDK는 CCAICall 및 CCAICallRed 모듈을 통해 음성 통화 지원을 제공합니다. 앱은 맞춤 통화 UI를 소유하고 SDK는 통화 생성, 수신 전화 처리, 제공업체 통합, 통화 수명 주기 상태를 처리합니다.
SDK에서 제공하는 기능: 인스턴트 통화 시작, 음성메일 시작, 수신 전화 처리, 통화 상태 관찰을 위한 호출 서비스 API
빌드하는 항목: 통화 전 진입점, 녹음 동의 프롬프트, 벨소리 또는 대기 상태, 통화 중 컨트롤, 포그라운드 서비스 UI, 통화 후 탐색 등 시각적 통화 환경
통화 모듈 초기화
핵심 SDK를 초기화한 후 호출 모듈을 초기화합니다.
import com.ccaiplatform.ccaikit.CCAI
import com.ccaiplatform.ccaicall.CallOptions
import com.ccaiplatform.ccaicall.initializeCall
CCAI.initialize(context = applicationContext, options = initOptions)
CCAI.initializeCall(
context = applicationContext,
options = CallOptions()
)
VoiceCallChannel 기능 검사
즉석 통화를 제공하기 전에 대기열의 VoiceCallChannel를 검사하여 노출되는 진입점 (즉석 통화, 음성메일 대체, 채널 수준 전환)과 녹음 동의가 필요한지 여부를 결정합니다.
주요 VoiceCallChannel 필드
instantEnabled:Boolean- 이 대기열에 라이브 (인스턴트) 음성 통화가 허용되는지 여부입니다.false를 '인스턴트 통화 버튼을 제공하지 않음'으로 처리 대기열에 음성 채널이 있는지 여부만 알려주는voiceCall != null와는 다릅니다.preSessionSmartAction:Boolean- 통화를 시작하기 전에 이 특정 채널에 사전 세션 스마트 작업이 필요한지 여부입니다. 보완적인 대기열 수준 경로는 Android용 헤드리스 모바일 SDK: 스마트 작업, 첨부파일, 전환의 세션 전 스마트 작업을 참고하세요.scheduleEnabled:Boolean- 예약된 통화가 지원되는지 여부 예약된 통화 섹션을 참고하세요.recordingOption:RecordingOption?— 통화 녹음 동의 모델입니다. 동의 기록을 참고하세요. 예약된 통화에만 사용되는RecordingPermission.NOT_ASKED와는 다릅니다.voicemailReason:String?— 채널이 음성사서함으로 라우팅되도록 구성된 경우 채워집니다.phoneNumber:String?— 전환 또는 음성사서함에 대해 구성된 PSTN 대체 번호입니다.scheduleDeflectionType:String?/deflected:Boolean?/deflectedReason:String?— 채널 수준 전환 메타데이터입니다.deflected == true인 경우 즉시 통화를 시도하는 대신 관리자가 구성한deflectedReason메시지를 표시하고 구성된 대체 (예: 다이얼할 전화번호 또는 예약된 통화 진입점)를 제공합니다. 대기 시간 전환에 관한 내용은 Android용 헤드리스 모바일 SDK: 스마트 작업, 첨부파일, 대기열 수준 전환 처리를 위한 전환 섹션을 참고하세요.
fun voiceEntryPoints(menu: QueueMenu) {
val voice = menu.channels.voiceCall
if (voice == null) {
callNowButton.isVisible = false
voicemailButton.isVisible = false
return
}
// Instant call - only when explicitly enabled and not deflected
callNowButton.isVisible = voice.instantEnabled && voice.deflected != true
// Voicemail fallback
val number = voice.phoneNumber
if (voice.voicemailReason != null && number != null) {
voicemailButton.isVisible = true
voicemailButton.text = "Leave a voicemail"
voicemailButton.setOnClickListener {
dialVoicemail(number, reason = voice.voicemailReason)
}
}
// Channel-level deflection
if (voice.deflected == true) {
showChannelDeflection(reason = voice.deflectedReason)
}
}
동의 기록
SDK에서 제공하는 항목: 포털에서 관리자가 구성한 통화 녹음 동의 모델을 설명하는 VoiceCallChannel의 recordingOption: RecordingOption? 필드
빌드하는 항목: 동의 메시지 (ASK_USER인 경우) 및 관리자의 선택을 따르는 분기 로직
enum class RecordingOption {
ALWAYS, // Calls are always recorded - surface a disclosure before connecting
NEVER, // Calls are never recorded - no disclosure needed
ASK_USER // Prompt the user for consent before the call connects
}
fun handleRecordingConsent(voice: VoiceCallChannel, onResult: (Boolean) -> Unit) {
when (voice.recordingOption) {
RecordingOption.ALWAYS -> {
showRecordingDisclosure("This call will be recorded.")
onResult(true)
}
RecordingOption.NEVER, null -> onResult(true)
RecordingOption.ASK_USER -> presentConsentPrompt { granted ->
onResult(granted)
}
}
}
즉석 통화 시작하기
인스턴트 통화를 시작하기 전에 다음 사항을 확인하세요.
선택한 대기열의
VoiceCallChannel를 검사합니다.인스턴트 통화가 사용 설정되어 있고 리디렉션되지 않았는지 확인합니다.
대기열의
recordingOption에 따라 녹화 동의를 처리합니다.마이크 사용 권한을 요청합니다.
startInstantCall를 호출합니다.
suspend fun initiateInstantCall(queueMenu: QueueMenu) {
val voice = queueMenu.channels.voiceCall ?: return
if (!voice.instantEnabled || voice.deflected == true) {
return
}
val recordingPermission = resolveRecordingPermission(voice)
requestMicrophonePermissionIfNeeded()
try {
CCAI.callService?.startInstantCall(
menuId = queueMenu.id,
recordingPermission = recordingPermission
)
} catch (e: Exception) {
showCallStartError(e)
}
}
통화 상태 관찰
통화를 시작하거나 수락하기 전에 통화 서비스 이벤트를 수집합니다.
val service = CCAI.callService ?: return
lifecycleScope.launch {
service.stateChanged.collect { state ->
updateCallConnectionState(state)
}
}
lifecycleScope.launch {
service.callReceived.collect { call ->
updateCallDetails(call)
}
}
lifecycleScope.launch {
service.incomingCallEvent.collect { event ->
handleIncomingCallEvent(event)
}
}
lifecycleScope.launch {
service.waitTimeUpdated.collect { waitTime ->
updateEstimatedWaitTime(waitTime)
}
}
lifecycleScope.launch {
service.participantUpdated.collect { participant ->
updateParticipantInfo(participant)
}
}
lifecycleScope.launch {
service.interruptionDetected.collect { interruption ->
// A PSTN interruption (for example, an incoming cellular call)
// has paused the active CCAI call. Surface a "call interrupted"
// banner and offer a Resume action after the interruption ends.
showInterruptionBanner(interruption)
}
}
lifecycleScope.launch {
service.deflectionOffered.collect { deflection ->
// The platform offered a wait-time deflection (for example, a
// scheduled callback or PSTN fallback). Render the prompt and
// honor the user's choice.
presentWaitTimeDeflection(deflection)
}
}
공급자 상태와 서버 상태 비교
stateChanged와 callReceived.status는 독립적인 신호입니다. 행이 아닌 열로 읽으세요. 제공자의 stateChanged는 로컬 VoIP 핸드셰이크를 보고하고 서버 측 callReceived.status는 플랫폼에서 통화의 수명 주기를 보고합니다. stateChanged이 연결 상태에 도달하는 것이 아니라 callReceived.status에서 '연결됨' 전환과 통화 타이머 시작을 유도합니다.
| 신호 | 보고 내용 | 어떤 항목을 가져올지 |
|---|---|---|
stateChanged (제공업체) |
로컬 VoIP 핸드셰이크 — Connecting, Connected (local), Disconnected, Error(...). |
서버 상태가 도착하고 오류가 표시되고 통화 종료 정리 전에 선택적 사전 상태 스피너입니다. |
callReceived.status (서버) |
서버 측 수명 주기: WAITING, CONNECTING, CONNECTED, ON_HOLD, INTERRUPTED, ENDED |
연결된 플립, 통화 타이머 시작, 상담사 할당 UI, 트랜스퍼 또는 에스컬레이션 오버레이, 통화 종료 흐름 |
수신 전화
수신 음성 통화는 FCM을 사용하여 기기로 전송됩니다. 앱이 백그라운드에 있거나 완전히 종료된 상태에서 수신 전화가 걸려올 수 있으므로 통합은 모든 프로세스 상태에서 수신 전화를 받을 준비가 되어 있어야 합니다.
SDK 제공 항목: 수신 전화의 수명 주기(Arrived, Accepted, Rejected)를 내보내는 callService의 IncomingCallEvent 흐름 호출 서비스는 제안에 따라 작동하도록 acceptIncomingCall() 및 rejectIncomingCall()도 노출합니다.
빌드하는 항목: 수신 전화 UI (전체 화면 벨소리 화면, 잠금 화면 프레젠테이션 또는 인앱 배너)와 제안을 수락하거나 거부하는 핸들러
구현 예시
각 수명 주기 이벤트에서 incomingCallEvent를 수집하고 UI를 렌더링하거나 닫은 다음, 수명이 긴 범위에서 수락하거나 거부합니다.
핸드셰이크 중에 수락 또는 거부 코루틴이 단기 Activity 해체에서 유지되도록 Application 클래스에서 applicationScope를 한 번 정의합니다.
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
// Define this in your Application class (or another long-lived singleton).
// Expose it however your app prefers as top-level property, dependency
// injection, or a static field on the Application subclass.
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
lifecycleScope.launch {
CCAI.callService?.incomingCallEvent?.collect { event ->
when (event) {
is IncomingCallEvent.Arrived -> showIncomingCallUi(event.call)
is IncomingCallEvent.Accepted -> navigateToCallScreen(event.call)
is IncomingCallEvent.Rejected -> dismissIncomingCallUi()
}
}
}
// User taps "Accept" in your custom UI.
fun onAcceptTapped() {
// Important: accept/reject helpers suspend internally. Launch them on
// `applicationScope` (defined above) so the call survives if the
// ringing Activity is dismissed during the handshake.
applicationScope.launch {
CCAI.callService?.acceptIncomingCall()
}
}
fun onRejectTapped() {
applicationScope.launch {
CCAI.callService?.rejectIncomingCall()
}
}
FCM을 수신 전화 UI에 연결
FCM으로 전송된 수신 전화는 백그라운드 스레드에 도착하며 Activity가 존재하기 전인 경우가 많습니다. 짧은 타임아웃을 사용하여 FCM 페이로드를 첫 번째 IncomingCallEvent.Arrived 방출에 연결한 다음 UI를 시작합니다.
구현 예시
다음 IncomingCallEvent.Arrived에서 withTimeoutOrNull로 일시중지한 다음 내비게이터에게 넘깁니다.
suspend fun handleIncomingCallPush() {
val arrived = withTimeoutOrNull(60_000L) {
CCAI.callService?.incomingCallEvent
?.filterIsInstance<IncomingCallEvent.Arrived>()
?.first()
} ?: return
// `myNavigator` is the `IncomingCallUiNavigator` implementation you wire
// up during app initialization (see "Alternative: full-screen ringing
// Activity" below). Hold the reference somewhere long-lived (for
// example, on your `Application` subclass) so it can be reused here.
myNavigator.showRingingUi(applicationContext, arrived.call.id)
}
통신 프레임워크 통합 (권장)
내장 Android 수신 전화 환경(전체 화면 벨소리, 잠금 화면 표시, 시스템 통화 기록과의 통합)의 경우 PhoneAccount를 등록하고 Telecom 프레임워크가 벨소리 UX를 소유하도록 합니다.
SDK에서 제공하는 항목: 앱 (registerPhoneAccount(context))용 PhoneAccount, 수신 CCAI 호출을 Android Telecom으로 브리징하는 TelecomConnectionService, 연결 상태 콜백용 TelecomConnectionCallback 인터페이스를 등록하는 TelecomCallService 도우미
빌드하는 항목: 앱 초기화 중 일회성 PhoneAccount 등록 및 연결 서비스의 매니페스트 선언
구현 예시
앱 시작 시 전화 계정을 등록한 다음 매니페스트에서 연결 서비스와 권한을 선언합니다.
// In Application.onCreate(), after CCAI.initializeCall(...)
TelecomCallService.registerPhoneAccount(context = this)
<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<service
android:name="com.ccaiplatform.android.call.TelecomConnectionService"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
</service>
대안: 전체 화면 벨소리 울림 활동
Telecom 프레임워크를 사용하는 대신 자체 벨소리 화면을 렌더링하려면 SDK가 IncomingCallUiNavigator 인터페이스와 함께 IncomingCallService를 노출합니다. 인터페이스를 구현한 다음 앱 초기화 중에 구현을 IncomingCallService에 전달합니다.
구현 예시
IncomingCallUiNavigator를 구현하고 Application.onCreate() 중에 한 번 IncomingCallService에 전달합니다.
// 1. Implement the navigator. Override the three interface methods to
// declare the ringing Activity intent and to show/hide your UI as the
// call lifecycle changes.
class MyNavigator : IncomingCallUiNavigator {
override fun ringingActivityIntent(
context: Context,
callId: Long,
callerName: String?
): Intent = Intent(context, RingingActivity::class.java).apply {
putExtra("callId", callId)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
override fun showRingingUi(context: Context, callId: Long) {
// Optional: surface an in-app banner or push your ringing UI.
}
override fun hideRingingUi(context: Context, callId: Long) {
// Optional: dismiss whatever you surfaced in showRingingUi.
}
}
// 2. Wire it up during app initialization (typically in Application.onCreate()).
val incomingCallService = IncomingCallService(
applicationContext,
MyNavigator()
)
if (!CCAI.canShowIncomingCallFullScreen(context)) {
CCAI.openFullScreenIntentSettings(context)
}
활성 벨소리를 위한 포그라운드 서비스
통화가 울리는 동안 Android는 백그라운드 프로세스를 종료할 수 있습니다. SDK는 벨소리 흐름을 포그라운드 서비스로 유지하는 IncomingRingingService를 제공합니다. 매니페스트에서 선언하면 수신 전화가 도착할 때 SDK가 자동으로 시작하고 중지합니다.
<service
android:name="com.ccaiplatform.android.call.IncomingRingingService"
android:foregroundServiceType="phoneCall"
android:exported="false" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
통화 상태 및 서버 측 상태
SDK에서 제공하는 항목: 각 Call 페이로드에 서버 기반 CallStatus enum을 제공하여 호출이 수명 주기에서 어디에 있는지 설명합니다 (예: CONNECTING, WAITING, CONNECTED, ON_HOLD, INTERRUPTED, ENDED, FAILED).
빌드하는 항목: CallStatus에서 통화 화면의 고객 대상 상태로의 매핑
구현 예시
각 callReceived 방출에서 call.status의 when 브랜치를 가져와 일치하는 UI 상태를 렌더링합니다.
fun updateCallDetails(call: Call) {
when (call.status) {
CallStatus.CONNECTING -> renderConnectingState()
CallStatus.WAITING -> renderWaitingInQueueState(call.estimatedWait)
CallStatus.CONNECTED -> renderConnectedState(call.agent)
CallStatus.ON_HOLD -> renderOnHoldState()
CallStatus.INTERRUPTED -> renderInterruptedState()
CallStatus.ENDED, CallStatus.FAILED -> renderEndedState(call.endReason)
}
}
통화 중 제어 (음소거, 스피커, 대기)
SDK 제공 사항: 각 상태를 전환하는 작업(setMuted, setSpeakerEnabled, setOnHold)과 함께 callService~isMuted, isSpeakerEnabled, isOnHold의 읽기 전용 속성
빌드하는 항목: SDK의 작업에 연결되고 현재 상태를 반영하는 통화 UI의 음소거, 스피커, 통화 보류 버튼
구현 예시
callService에서 현재 상태를 읽어 버튼을 초기화한 다음 탭에서 전환하고 업데이트합니다.
muteButton.isSelected = CCAI.callService?.isMuted == true
speakerButton.isSelected = CCAI.callService?.isSpeakerEnabled == true
holdButton.isSelected = CCAI.callService?.isOnHold == true
fun onMuteTapped() {
val next = CCAI.callService?.isMuted != true
CCAI.callService?.setMuted(next)
muteButton.isSelected = next
}
fun onSpeakerTapped() {
val next = CCAI.callService?.isSpeakerEnabled != true
CCAI.callService?.setSpeakerEnabled(next)
speakerButton.isSelected = next
}
fun onHoldTapped() {
val next = CCAI.callService?.isOnHold != true
CCAI.callService?.setOnHold(next)
holdButton.isSelected = next
}
통화가 중단된 후 진행 중인 통화 재개
PSTN 인터럽트 (예: 수신되는 모바일 통화)로 인해 활성 CCAI 통화가 일시중지되면 SDK는 세션을 활성 상태로 유지하고 interruptionDetected를 내보냅니다.
중단이 끝나면 앱에서 통화를 재개할 수 있습니다.
SDK에서 제공하는 기능: 진행 중인 통화를 조회하는 getLastCallInProgress() (앱 다시 시작 포함) 및 통화를 포그라운드로 다시 가져오는 resumeCall(call)
빌드하는 항목: UI의 다시 시작 어포던스 (일반적으로 배너 또는 중단이 끝난 후 자동 다시 시작) 및 통화 화면으로 돌아가는 탐색
구현 예시
getLastCallInProgress로 진행 중인 통화를 조회하고, 통화를 재개하고, 통화 화면으로 다시 이동합니다.
lifecycleScope.launch {
val call = CCAI.callService?.getLastCallInProgress() ?: return@launch
try {
CCAI.callService?.resumeCall(call)
navigateToCallScreen(call)
} catch (e: Exception) {
showCallResumeError(e)
}
}
가상 에이전트의 에스컬레이션
SDK에서 제공하는 항목: canEscalate(allowSkipVirtualAgent: Boolean)는 callService 자체가 아닌 CallResponse (callService.callReceived에서 내보낸 모델)의 메서드입니다. 현재 통화가 통화의 현재 가상 상담사 상태에 따라 실제 상담사에게 에스컬레이션될 수 있는지 여부를 보고합니다.
빌드하는 항목: 가장 최근의 callReceived 배출을 '현재 통화'로 유지한 다음 '사람과 대화' 버튼의 표시 상태를 currentCall?.canEscalate(...)에 바인딩합니다. allowSkipVirtualAgent 인수는 호출 응답 자체에서 별도로 가져오는 메뉴 구성 값입니다. 프로덕션에서 true을 하드코딩하지 마세요.
구현 예시
속성에 최신 Call를 유지한 다음 canEscalate를 호출합니다. 메뉴 구성에서 allowSkipVirtualAgent를 해결합니다.
// Held from the most recent `callReceived` emission
private var currentCall: Call? = null
// `allowSkipVirtualAgent` is fetched separately from menu configuration
// it is not derived from the CallResponse.
val allowSkip = menuConfiguration.allowSkipVirtualAgent
val allowed = currentCall?.canEscalate(allowSkipVirtualAgent = allowSkip) == true
talkToAgentButton.isVisible = allowed
음성메시지
SDK 제공 사항: callService의 startVoicemail(VoicemailRequest)와 플랫폼에서 음성 메일을 제공한 이유를 설명하는 VoicemailReason enum(예: 근무 시간 외 대체, 용량 초과 대체 또는 임시 리디렉션)
빌드하는 항목: UI의 음성사서함 진입점 (일반적으로 VoiceCallChannel.voicemailReason이 채워지거나 통화 수명 주기가 음성사서함 결과로 해결될 때 표시됨) 및 녹음 UI 자체
구현 예시
voicemailReason가 설정되면 음성사서함 버튼을 표시하고 VoicemailRequest로 startVoicemail을 호출합니다.
enum class VoicemailReason {
AFTER_HOUR_DEFLECTION,
OVER_CAPACITY_DEFLECTION,
TEMPORARY_REDIRECTION
}
suspend fun offerVoicemail(menu: QueueMenu) {
val voice = menu.channels.voiceCall ?: return
if (voice.voicemailReason == null) return
try {
CCAI.callService?.startVoicemail(
VoicemailRequest(menuId = menu.id)
)
} catch (e: Exception) {
showVoicemailError(e)
}
}
대기 시간 전환
대기열이 열려 있지만 대기 시간이 관리자가 구성한 기준점을 초과하는 경우 플랫폼에서 대체 경로(일반적으로 예약된 콜백 또는 PSTN 전환)를 제공할 수 있습니다.
SDK 제공 사항: 제공된 대안을 설명하는 CallDeflection 페이로드로, callService에서 getCallDeflection(callId)를 사용하여 검색할 수 있습니다. 또한 플랫폼은 대기 중에 전환이 가능해지면 deflectionOffered 흐름을 통해 동일한 페이로드를 내보냅니다. 별도의 CompanyResponse.preventDirectPstnCall 플래그는 대체 UI에서 직접 PSTN 다이얼링을 숨겨야 하는지 여부를 나타냅니다.
빌드하는 항목: 전환 프롬프트 (예: '예상 대기 시간은 12분입니다. 대신 콜백을 예약하시겠어요?') 및 사용자의 선택을 따르는 분기 로직
구현 예시
진행 중인 통화의 ID로 제공된 전환을 가져오고 CompanyResponse.preventDirectPstnCall와 결합하여 프롬프트를 표시합니다.
lifecycleScope.launch {
val currentCall = CCAI.callService?.getLastCallInProgress()
?: return@launch
val deflection = CCAI.callService?.getCallDeflection(callId = currentCall.id)
?: return@launch
val company = CCAI.companyService?.get()
val allowDirectPstn = company?.preventDirectPstnCall != true
presentWaitTimeDeflection(deflection, allowDirectPstn)
}
고급 CallOptions
CallOptions는 기본 통화 환경을 조정해야 하는 앱을 위한 작은 전환 버튼 세트를 노출합니다.
connectingPollIntervalMs:Long— 통화가 연결 상태에 있는 동안 SDK가 플랫폼을 폴링하는 빈도입니다.connectedPollIntervalMs:Long— 통화가 연결되어 있는 동안 SDK가 플랫폼을 폴링하는 빈도입니다.endCallMaxRetries:Int— 오류가 표시되기 전에 SDK가 종료 호출 요청을 재시도하는 횟수입니다.endCallRetryDelayMs:Long- 통화 종료 재시도 간 지연 시간
구현 예시
호출 모듈을 초기화할 때 조정된 값을 CallOptions에 전달합니다.
CCAI.initializeCall(
context = this,
options = CallOptions(
connectingPollIntervalMs = 2_000L,
connectedPollIntervalMs = 5_000L,
endCallMaxRetries = 3,
endCallRetryDelayMs = 1_000L
)
)
화면 공유
화면 공유를 통해 지원 상담사는 사용자의 화면을 보고 문제를 해결할 수 있습니다. CCAI Platform은 두 가지 화면 공유 모드를 지원합니다.
인앱 공유: 상담사는 특정 앱 내부에서 일어나는 일만 볼 수 있습니다.
전체 기기 공유 (선택사항): 상담사가 Android 홈 화면 및 기타 앱을 비롯한 사용자의 전체 기기를 볼 수 있습니다. 이를 위해서는
MediaProjection와 포그라운드 서비스를 사용하여 고급 Android 구성을 해야 합니다.
SDK에서 제공하는 항목: 세션을 만들고 관리하며, 상태 변경을 처리하고, 동의 요청을 처리하는 CCAIScreenShare 모듈
빌드하는 항목: 초기화 구성, 사용자 동의를 위한 UI 프롬프트, (전체 기기를 지원하는 경우) 포그라운드 서비스와의 내장 Android MediaProjection 통합
화면 공유 서비스는 전체 세션 수명 주기(startSession, activateSession, stopSession)와 원격 제어 및 전체 기기 공유를 위한 구성 메서드를 제공합니다. 다음 하위 섹션에서는 핵심 통합 흐름을 안내합니다.
모든 화면 공유 메서드의 전체 메서드 서명과 파라미터 세부정보는 헤드리스 모바일 SDK - Android API 참조를 참고하세요.
화면 공유 초기화 및 구성
화면 공유 키를 제공하여 앱 설정 중에 화면 공유 서비스를 초기화합니다.
import com.ccaiplatform.android.CCAI
import com.ccaiplatform.android.ScreenShareOptions
// Minimal - domain defaults to your instance's configured provider
CCAI.initializeScreenShare(
context = this,
screenShareOptions = ScreenShareOptions(
key = "YOUR_SCREEN_SHARE_KEY"
)
)
// If your instance or provider requires an explicit domain:
// CCAI.initializeScreenShare(
// context = this,
// screenShareOptions = ScreenShareOptions(
// key = "YOUR_SCREEN_SHARE_KEY",
// domain = "your_subdomain.ccaiplatform.com"
// )
// )
화면 공유 자격 요건 확인
화면 공유 기능을 사용 설정하기 전에 현재 채팅에서 사용할 수 있는지 확인합니다.
import com.ccaiplatform.ccaiscreenshare.ScreenShareManager
fun isScreenShareEnabled(chat: ChatResponse): Boolean {
return CCAI.screenShareService != null
&& chat.supportScreenShare == true
}
화면 공유 요청 처리
ScreenShareManager를 사용하여 화면 공유 요청 처리를 구현합니다. ScreenShareCallbacks를 사용하여 세션을 만들고 비동기 응답을 처리합니다.
import com.ccaiplatform.ccaiscreenshare.ScreenShareManager
import com.ccaiplatform.ccaiscreenshare.ScreenShareCallbacks
fun requestScreenShare(chatId: String, isFromRemote: Boolean) {
val screenShareService = CCAI.screenShareService
if (screenShareService == null) {
Log.e("ScreenShare", "Screen share service not available")
return
}
// Track whether this session is agent-initiated
screenShareInitiatedFrom = if (isFromRemote) "agent" else "endUser"
if (!isFromRemote) {
sendScreenShareMessage(event = "screenShareRequestedFromEndUser")
}
val callbacks = ScreenShareCallbacks(
onSessionStateChanged = { state ->
Log.d("ScreenShare", "State changed: $state")
handleScreenShareStateChange(state)
},
onSessionCreationError = { error ->
Log.e("ScreenShare", "Session creation failed: ${error.message}")
sendScreenShareMessage(event = "screenShareFailed")
showErrorToast("Screen share failed to start.")
},
onSessionActivationRequest = {
Log.d("ScreenShare", "Activation request received")
// For agent-initiated sessions, activate immediately
// For user-initiated sessions, show a confirmation dialog first
if (screenShareInitiatedFrom == "agent") {
ScreenShareManager.activateSession()
} else {
showScreenShareConsentDialog {
ScreenShareManager.activateSession()
}
}
}
)
ScreenShareManager.startSession(
request = ScreenShareRequest(
communicationId = chatId,
communicationType = CommunicationType.Chat,
initiatedFrom = if (isFromRemote) ScreenShareFrom.AGENT else ScreenShareFrom.END_USER
),
callbacks = callbacks
)
}
화면 공유 상태 변경 처리
ScreenShareCallbacks에 제공된 onSessionStateChanged 콜백을 통해 화면 공유 세션 상태 변경을 수신합니다.
fun handleScreenShareStateChange(state: ScreenShareSessionState) {
currentScreenShareSessionState = state
when (state) {
ScreenShareSessionState.INACTIVE -> {
Log.d("ScreenShare", "No active session")
}
ScreenShareSessionState.PENDING -> {
Log.d("ScreenShare", "Session pending - waiting for activation")
// For agent-initiated sessions, activate immediately
if (screenShareInitiatedFrom == "agent") {
ScreenShareManager.activateSession()
}
}
ScreenShareSessionState.ACTIVE -> {
Log.d("ScreenShare", "Screen share active")
sendScreenShareMessage(event = "screenShareStarted")
updateUiForActiveScreenShare()
}
}
}
ScreenShareCallbacks 참조
| 콜백 | 설명 |
|---|---|
onSessionStateChanged |
화면 공유 세션 상태가 변경될 때 호출됩니다 (INACTIVE, PENDING, ACTIVE). |
onSessionCreationError |
세션 생성에 실패할 때 호출됩니다 (네트워크 문제, 서비스를 사용할 수 없음 등). |
onSessionActivationRequest |
세션이 활성화될 준비가 되면 호출됩니다. ScreenShareManager.activateSession()를 호출하여 공유를 시작하세요. 사용자 시작 세션의 경우 먼저 동의 메시지를 표시하고, 상담사 시작 세션의 경우 즉시 활성화합니다. |
onSessionRemoteControlRequest |
에이전트가 사용자 화면의 원격 제어를 요청할 때 호출됩니다. 원격 제어 권한을 부여하기 전에 동의 프롬프트를 표시합니다. |
onSessionFullDeviceRequest | 에이전트가 전체 기기 공유를 요청할 때 호출됩니다 (아래 전체 기기 화면 공유 참고). MediaProjection 동의 흐름을 트리거합니다. |
onSessionDidSucceed |
세션이 성공적으로 활성화되고 스트리밍이 시작된 후 호출됩니다. 이를 사용하여 UI를 '화면 공유 활성' 상태로 업데이트합니다. |
전체 상태 enum 정의 및 모든 관련 유형은 Headless Mobile SDK - Android API 참조를 참고하세요.
전체 기기 화면 공유 (고급)
전체 기기 화면 공유를 사용하면 상담사가 시스템 설정, 애플리케이션 간 탐색 등 자체 애플리케이션 외부의 애플리케이션에서 화면을 볼 수 있습니다.
이를 위해서는 Android의 MediaProjection API를 통합하고 포그라운드 서비스를 실행해야 합니다.
권한 및 포그라운드 서비스 선언
필수 권한과 포그라운드 서비스 선언을 AndroidManifest.xml에 추가합니다.
<manifest>
<!-- Required for screen capture -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<application>
<service
android:name=".screenshare.ScreenShareForegroundService"
android:foregroundServiceType="mediaProjection"
android:exported="false" />
</application>
</manifest>
MediaProjection 동의 요청
화면을 캡처하기 전에 MediaProjectionManager를 사용하여 사용자에게 동의를 요청해야 합니다.
import android.media.projection.MediaProjectionManager
import android.content.Context
private val mediaProjectionManager by lazy {
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
}
fun requestScreenCapturePermission() {
val captureIntent = mediaProjectionManager.createScreenCaptureIntent()
screenCaptureResultLauncher.launch(captureIntent)
}
// Handle the result in your Activity
private val screenCaptureResultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
// User granted screen capture permission
startScreenShareForegroundService(result.resultCode, result.data!!)
} else {
Log.d("ScreenShare", "User denied screen capture permission")
}
}
포그라운드 서비스 구현
MediaProjection 세션을 보유하는 포그라운드 서비스를 만듭니다.
import android.app.Service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Intent
import android.os.IBinder
class ScreenShareForegroundService : Service() {
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = buildNotification()
startForeground(NOTIFICATION_ID, notification)
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
"Screen Share",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Active screen share session"
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
private fun buildNotification(): Notification {
return Notification.Builder(this, CHANNEL_ID)
.setContentTitle("Screen Sharing")
.setContentText("An agent is viewing your screen.")
.setSmallIcon(R.drawable.ic_screen_share)
.build()
}
companion object {
private const val CHANNEL_ID = "screen_share_channel"
private const val NOTIFICATION_ID = 1001
}
}
전체 기기 공유 사용 설정
MediaProjection가 활성화된 후 화면 공유 서비스를 통해 전체 기기 공유를 사용 설정합니다.
// After MediaProjection consent is granted and foreground service is started
ScreenShareManager.enableFullDeviceSharing(true)
화면 공유를 위한 Jetpack Compose 고려사항
Jetpack Compose의 원격 상호작용 권장 방법
주요 대화형 요소를 Android 뷰로 래핑 - 원격으로 클릭해야 하는 영역의 경우
AndroidView컴포저블을 사용하여 표준 Android 뷰 컨트롤 (예:android.widget.Button)로 래핑합니다. 이렇게 하면 Android 뷰 기반 경로를 통해 원격 클릭이 안정적으로 작동합니다.맞춤 터치 처리로 원격 터치 전달 - 뷰로 대체할 수 없는 Compose 영역의 경우 맞춤 터치 콜백을 사용하여 원격 터치 이벤트를 Compose 로직에 전달합니다.
상품 수준 안내 또는 대체 제공 - Compose 화면에 '상담사는 보기만 가능하며 사용자는 로컬로 탭해야 함'을 명확하게 표시하거나 보기 전용 액세스를 유지하면서 이러한 페이지에서 원격 제어 권한을 자동으로 제한합니다.
Android 뷰 래퍼 구현 예
import androidx.compose.runtime.Composable
import androidx.compose.ui.viewinterop.AndroidView
import android.widget.Button
@Composable
fun RemoteControlButton(
title: String,
onClick: () -> Unit
) {
AndroidView(
factory = { context ->
Button(context).apply {
text = title
setOnClickListener { onClick() }
}
},
update = { button ->
button.text = title
}
)
}
// Usage in a Composable
@Composable
fun ScreenShareSupportScreen() {
Column {
Text("This screen supports remote control")
RemoteControlButton(title = "Clickable Button") {
println("Button tapped remotely or locally")
}
}
}
예약된 통화
예약된 통화를 사용하면 사용자가 즉석 통화를 위해 대기열에서 기다리는 대신 향후 음성 통화를 예약할 수 있습니다. Android에서 SDK는 사용 가능 여부 확인, 시간대 가져오기, 통화 예약, 통화 일정 변경, 통화 취소, 일정 오류 처리를 위한 call-service 메서드를 노출합니다.
기능 감지
SDK에서 제공하는 항목: 선택한 대기열에서 향후 통화 예약을 지원하는지 여부를 알려주는 VoiceCallChannel의 scheduleEnabled 플래그
빌드하는 항목: 예약 진입점을 표시하기 전에 이 플래그를 확인하는 로직
구현 예시
대기열 음성 통화 채널에서 scheduleEnabled를 확인합니다. voiceCall != null을 프록시로 사용하지 마세요. 대기열은 예약된 통화를 지원하지 않고 즉시 음성 통화를 지원할 수 있으며 그 반대의 경우도 마찬가지입니다.
fun setupScheduledCallEntryPoint(queueMenu: QueueMenu) {
val voiceChannel = queueMenu.channels.voiceCall
if (voiceChannel == null) {
callNowButton.isVisible = false
scheduleCallButton.isVisible = false
return
}
callNowButton.isVisible = voiceChannel.instantEnabled && voiceChannel.deflected != true
scheduleCallButton.isVisible = voiceChannel.scheduleEnabled == true
}
선택적 사용 가능 여부 확인
시간대 선택기를 표시하기 전에 선택한 대기열에 예약 가능한 시간대가 있는지 확인할 수 있습니다.
구현 예시
hasScheduledTimeSlots(menuId:)을 경량 사전 검사로 사용합니다. false를 반환하는 경우 선택기를 여는 대신 빈 상태 또는 대체 옵션을 표시합니다.
lifecycleScope.launch {
try {
val available = CCAI.callService?.hasScheduledTimeSlots(menuId = menu.id) == true
if (available) {
showScheduleCallUI()
} else {
showNoSlotsMessage()
}
} catch (e: Exception) {
Log.e("CCAI", "Availability check failed: ${e.message}")
showNoSlotsMessage()
}
}
시간 슬롯 가져오기
선택한 대기열의 예약 가능한 시간을 가져옵니다. SDK는 앱이 사용자의 현지 시간대로 표시할 수 있는 Date 값을 반환합니다.
SDK에서 제공하는 항목: 선택한 대기열의 사용 가능한 슬롯 타임스탬프(기존 예약된 통화의 재예약 지원 옵션 포함)
빌드하는 항목: 캘린더, 시간 선택기, 시간대 표시, 빈 상태 처리, 재시도 동작
구현 예시
대기열 메뉴 ID를 사용하여 getScheduledTimeSlots를 호출합니다.
일정을 다시 잡을 때는 서비스가 적절한 사용 가능 여부를 반환할 수 있도록 기존 예약 통화 ID를 전달하세요.
lifecycleScope.launch {
try {
val slots: List<Date> = CCAI.callService?.getScheduledTimeSlots(
menuId = menu.id,
callIdForRescheduling = null
) ?: emptyList()
if (slots.isEmpty()) {
showNoSlotsMessage()
} else {
displayTimeSlotPicker(slots)
}
} catch (e: Exception) {
Log.e("CCAI", "Failed to fetch time slots: ${e.message}")
}
}
일정을 변경할 때는 기존 예약 통화 ID를 전달합니다.
val slots = CCAI.callService?.getScheduledTimeSlots(
menuId = menu.id,
callIdForRescheduling = existingCallId
) ?: emptyList()
예약 통화 만들기
사용자가 시간대를 선택하면 callService를 통해 예약된 통화를 만듭니다.
SDK 제공 사항: 선택한 슬롯을 예약하고 예약된 통화 기록을 반환하는 createScheduledCall API
빌드하는 항목: E.164 전화번호 유효성 검사, 확인 UI, 예약된 통화 ID의 로컬 지속성, 슬롯 충돌 또는 근무 시간 외 응답 복구
구현 예시
선택한 슬롯, 전화번호, 대기열 ID, RecordingPermission.NOT_ASKED을 ScheduledCallRequest에 전달합니다.
lifecycleScope.launch {
try {
val response = CCAI.callService?.createScheduledCall(
ScheduledCallRequest(
menuId = menu.id,
phoneNumber = userPhoneNumber,
scheduleTime = selectedSlot,
recordingPermission = RecordingPermission.NOT_ASKED,
callIdForRescheduling = null,
customData = null,
ticketId = null
)
)
saveScheduledCallId(response?.id)
showConfirmation(scheduledAt = response?.scheduledAt)
} catch (e: CallCreationError.AfterHours) {
showMessage(e.displayMessage)
refreshTimeSlots()
} catch (e: Exception) {
Log.e("CCAI", "Failed to schedule call: ${e.message}")
}
}
전화번호는 +14155551234와 같은 E.164 형식이어야 합니다.
기존 통화 일정 변경
일정을 변경하려면 시간대를 가져오고 기존 scheduled-call ID를 사용하여 새 예약 통화를 만듭니다.
SDK에서 제공하는 항목: 동일한 시간대 및 생성 API이며, callIdForRescheduling는 기존 예약을 식별하는 데 사용됩니다.
빌드하는 항목: '시간 변경' UI, 저장된 예약 통화 ID의 로컬 조회, 새 예약이 생성된 후 업데이트된 확인 상태
구현 예시
기존 통화 ID를 getScheduledTimeSlots와 createScheduledCall 모두에 전달합니다.
lifecycleScope.launch {
try {
val slots = CCAI.callService?.getScheduledTimeSlots(
menuId = menu.id,
callIdForRescheduling = existingCallId
) ?: emptyList()
val response = CCAI.callService?.createScheduledCall(
ScheduledCallRequest(
menuId = menu.id,
phoneNumber = userPhoneNumber,
scheduleTime = newSelectedSlot,
recordingPermission = RecordingPermission.NOT_ASKED,
callIdForRescheduling = existingCallId,
customData = null,
ticketId = null
)
)
saveScheduledCallId(response?.id)
showConfirmation(scheduledAt = response?.scheduledAt)
} catch (e: CallCreationError.AfterHours) {
showMessage(e.displayMessage)
refreshTimeSlots()
} catch (e: Exception) {
Log.e("CCAI", "Failed to reschedule: ${e.message}")
}
}
예약된 통화 취소
사용자가 예정된 통화를 취소하면 cancelScheduledCall를 호출하고 로컬에 저장된 통화 ID를 삭제합니다.
SDK에서 제공하는 항목: 예약된 호출 ID의 취소 API
빌드하는 항목: 확인 프롬프트, 취소 성공 또는 오류 UI, 로컬에 저장된 예정된 통화 상태 정리
구현 예시
저장된 ID로 cancelScheduledCall(callId:)를 호출한 다음 앱의 저장소에서 ID를 삭제합니다.
lifecycleScope.launch {
try {
CCAI.callService?.cancelScheduledCall(callId = existingCallId)
clearScheduledCallId()
showCancellationConfirmation()
} catch (e: Exception) {
Log.e("CCAI", "Failed to cancel call: ${e.message}")
}
}
이메일
SDK에서 제공하는 항목: 특정 대기열에 대해 구성된 대상 이메일 주소와 관리자가 사용자에게 표시되기를 원하는 안내 (예: 계정 번호 포함)
빌드하는 항목: 이메일 작성기 화면과 완료를 추적하는 로직 맞춤 양식을 직접 빌드하거나 Android의 기본 Intent.ACTION_SENDTO를 사용하여 사용자가 선호하는 이메일 클라이언트를 실행할 수 있습니다.
구현 예시
이 코드는 QueueMenu에서 SDK의 Channels 객체에서 지원 이메일 주소를 가져옵니다. 내장 Android 이메일 클라이언트를 열고 이메일 클라이언트가 설치되지 않은 경우를 처리합니다.
import android.content.Intent
import android.net.Uri
import android.widget.Toast
class SupportActivity : AppCompatActivity() {
// 1. Trigger the email flow
fun openEmailComposer(queueMenu: QueueMenu) {
val emailChannel = queueMenu.channels.email ?: return
val emailAddress = emailChannel.email ?: return
val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
data = Uri.parse("mailto:")
putExtra(Intent.EXTRA_EMAIL, arrayOf(emailAddress))
putExtra(Intent.EXTRA_SUBJECT, "Support Request: ${queueMenu.name ?: ""}")
// If the queue provides an instruction message, include it in the body
// putExtra(Intent.EXTRA_TEXT, emailChannel.instructionMessage ?: "")
}
if (emailIntent.resolveActivity(packageManager) != null) {
emailResultLauncher.launch(emailIntent)
} else {
Toast.makeText(
this,
"No email client installed on this device.",
Toast.LENGTH_LONG
).show()
}
}
// 2. Track the outcome using ActivityResultContract
private val emailResultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
// Note: Most Android email clients don't return a meaningful
// result code. Unlike iOS's MFMailComposeViewControllerDelegate,
// Android's email intent doesn't reliably report whether the
// email was sent, saved as draft, or cancelled.
// Log this event to your analytics and assume the user
// interacted with the email composer.
Log.d("CCAI", "Email composer closed. Result code: ${result.resultCode}")
MyAnalytics.logEvent("email_composer_closed")
}
}
외부 전환 링크
SDK에서 제공하는 항목: 관리 포털에서 구성된 대로 URL (예: 회사 고객센터, 환불 정책, 파트너 앱) 및 표시 제목 목록
빌드하는 항목: 사용자가 링크를 탭할 때 링크를 실행하는 로직입니다. 인앱 브라우저 (Android의 맞춤 탭 사용)에서 링크를 열거나 기본 시스템 브라우저를 실행합니다.
구현 예 (맞춤 탭 - 권장)
사용자가 메뉴에서 외부 링크를 탭하면 이 코드는 SDK에서 제공하는 URL 문자열을 추출하고 Chrome 맞춤 탭을 사용하여 인앱 브라우저 환경에서 웹페이지를 표시합니다. 맞춤 탭은 풀 브라우저 앱을 실행하는 것보다 빠르고 원활한 환경을 제공하면서도 사용자가 외부 콘텐츠를 보고 있음을 알 수 있도록 주소 표시줄을 표시합니다.
import androidx.browser.customtabs.CustomTabsIntent
import android.net.Uri
fun openHelpCenterLink(urlString: String) {
val uri = Uri.parse(urlString) ?: return
val customTabsIntent = CustomTabsIntent.Builder()
.setShowTitle(true)
.build()
customTabsIntent.launchUrl(this, uri)
}
대체 구현 (시스템 브라우저): 맞춤 탭을 사용할 수 없거나 더 간단한 방법을 선호하는 경우 사용자의 기본 브라우저에서 링크를 열면 됩니다.
import android.content.Intent
import android.net.Uri
fun openHelpCenterLink(urlString: String) {
val uri = Uri.parse(urlString) ?: return
val intent = Intent(Intent.ACTION_VIEW, uri)
if (intent.resolveActivity(packageManager) != null) {
startActivity(intent)
} else {
showErrorToast("No browser available to open this link.")
}
}
전체 통합 예시
이 코드는 SDK의 Channels 객체에서 전환 링크를 읽고 채널 메뉴에서 탭할 수 있는 버튼으로 사용자에게 표시합니다.
fun setupDeflectionLinks(queueMenu: QueueMenu) {
val deflectionLink = queueMenu.channels.externalDeflectionLink
if (deflectionLink != null) {
helpCenterButton.isVisible = true
helpCenterButton.setOnClickListener {
// Read the URL from the structured ExternalDeflectionLink type
val url = deflectionLink.url ?: return@setOnClickListener
openHelpCenterLink(url)
}
} else {
helpCenterButton.isVisible = false
}
}
ExternalDeflectionLink는 최상위 url / displayName 필드와 내부 links: List<ExternalDeflection>? 배열을 모두 노출합니다. 위의 예에서는 최상위 항목만 렌더링하며, 이는 단일 링크 대기열에 충분합니다. 관리자가 단일 대기열에 여러 외부 링크를 구성하는 경우 deflectionLink.links를 반복하고 사용 설정된 항목당 하나의 버튼을 렌더링합니다. 각 ExternalDeflection에는 자체 url 및 displayName가 있습니다.
deflectionLink.links
?.filter { it.enabled == true }
?.forEach { link ->
val button = MaterialButton(this).apply {
text = link.displayName
setOnClickListener { openHelpCenterLink(link.url) }
}
helpCenterContainer.addView(button)
}