Android용 헤드리스 모바일 SDK: 스마트 작업, 첨부파일, 전환

이 문서에서는 Android 애플리케이션에서 헤드리스 모바일 SDK를 통합하고 맞춤설정하는 방법을 보여줍니다. 스마트 작업, 첨부파일, 전환을 다룹니다.

스마트 작업 및 첨부파일 구성

스마트 작업을 통해 상담사와 소비자는 사진, 동영상, 텍스트 스니펫, 생체 인식 인증과 같은 풍부한 구조화된 정보를 교환할 수 있습니다.

헤드리스 아키텍처에서 SDK는 엄격하게 보안 전송 계층으로 작동합니다. 작업이 필요하거나 요청된 시점을 알려주고 데이터를 CCAI 플랫폼으로 안전하게 전송합니다. 사용자 인터페이스를 소유하므로 앱은 카메라 화면, 사진 선택기, 텍스트 상자를 빌드하고 Android의 내장 생체 인식 프롬프트를 트리거하는 데 전적으로 책임이 있습니다.

세션 전 스마트 작업

세션 전 작업은 사용자가 대기열에 참여하기 전에 컨텍스트 (예: 계정 번호 또는 고장난 제품의 동영상)를 수집하여 상담사 처리 시간을 크게 줄입니다.

SDK에서 제공하는 항목: 특정 큐의 규칙 및 요구사항 (예: 참여하기 전에 생체 인식 인증, 사진 업로드, 텍스트 입력이 선택사항인지 필수인지)

빌드하는 항목: 사용자가 대기열을 탭하고 대기실에 입장하는 사이에 표시되는 사전 세션 UI 화면 (카메라, 텍스트 입력, 생체 인식 프롬프트)과 세션이 연결될 때까지 미디어를 안전하게 보관하는 디스크 캐싱 로직

구현 예시

이 코드는 선택한 대기열에 사전 세션 요구사항이 있는지 SDK의 구성을 확인합니다. 동영상을 메모리에 보관하는 대신 디스크에 Uri 파일 참조를 유지하여 동영상 요구사항을 처리하는 방법을 보여줍니다.

// 1. Check requirements and collect data BEFORE joining the queue
fun checkPreSessionRequirements(queueMenu: QueueMenu) {
    val smartActions = queueMenu.settings
    if (smartActions.isNullOrEmpty()) {
        startChat(queueMenu)
        return
    }

    // Example: handle a video requirement
    presentVideoPicker { localFileUri ->
        // IMPORTANT: localFileUri should point to context.cacheDir
        MySessionStateManager.pendingPreSessionVideo = localFileUri
        startChat(queueMenu)
    }
}

// 2. Upload and clean up AFTER the session officially connects
fun handleSessionConnected() {
    val pendingVideoUri = MySessionStateManager.pendingPreSessionVideo ?: return

    // Upload using the standard in-session pipeline
    // then clean up the temporary file
    val file = File(pendingVideoUri.path ?: return)
    file.delete()
    MySessionStateManager.pendingPreSessionVideo = null
}

세션 전 스마트 작업 구성 읽기

SDK는 두 개의 보완 경로를 통해 세션 전 스마트 작업 구성을 노출합니다. 두 가지를 모두 확인하여 특정 대기열과 채널에 세션 전 캡처가 필요한지 확인합니다.

경로 A - 대기열 수준 (QueueMenu.settings[].sdk.preSessionSmartAction)

// `QueueMenuSetting` and `QueueMenuSDKSetting` (simplified)
data class QueueMenuSetting(
    val sdk: QueueMenuSDKSetting?
    // ... other fields ...
)

data class QueueMenuSDKSetting(
    val preSessionSmartAction: Boolean?
    // ... other fields ...
)

// Usage - "does any queue-level setting require a pre-session smart action?"
fun queueRequiresPreSession(menu: QueueMenu): Boolean {
    return menu.settings?.any { it.sdk?.preSessionSmartAction == true } ?: false
}

경로 B - 채널 수준 (VoiceCallChannel.preSessionSmartAction)

// Usage - "does the voice channel specifically require pre-session capture?"
val requiresPreSessionOnVoice =
    menu.channels.voiceCall?.preSessionSmartAction == true

경로 A를 사용하여 사용자가 채널을 선택하기 전에 세션 전 게이트를 표시할지 결정합니다. 채널이 선택된 후 채널별 개선사항 (예: 인스턴트 음성 통화 전에만 프롬프트 표시)에는 경로 B를 사용합니다.

세션 내 스마트 작업 (상담사 시작)

활성 채팅 또는 음성 통화 중에 상담사는 대시보드의 버튼을 클릭하여 사용자에게 데이터를 요청할 수 있습니다 (예: 신분증 사진 보내기 또는 생체 인식으로 인증하기).

SDK에서 제공하는 기능: 에이전트가 특정 유형의 스마트 작업을 요청했음을 앱에 알리는 실시간 이벤트

빌드하는 항목: 사용자에게 요청을 알리고, 적절한 내장 도구 (카메라, 사진 라이브러리, 텍스트 필드 또는 Android BiometricPrompt)를 실행하고, 결과를 SDK에 다시 전달하는 채팅 또는 통화 내 알림

구현 예시

여기서 앱은 SDK 이벤트를 수신 대기합니다. 에이전트 요청을 감지하면 알림을 표시한 다음 적절한 Android 도구를 엽니다.

import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat

// Listen for Smart Action requests from the active SDK session.
//
// `SmartActionRequest` exposes a nested `smartAction: SmartAction`, and the
// switchable type lives at `request.smartAction.type`. The `SmartActionType`
// enum is `PascalCase: Unknown`, `Verification`, `Screenshot`, `Photo`, `Video`,
// `TextInput`, `ScreenShare`. Use `Verification` for Face ID / fingerprint flows.
fun handleSmartActionRequest(request: SmartActionRequest) {
    when (request.smartAction.type) {
        SmartActionType.Photo -> {
            promptUserForPhoto(message = "The agent requested a photo.") { capturedBitmap ->
                // Send the image back to the agent using the SDK.
                sendAttachment(image = capturedBitmap)
            }
        }

        SmartActionType.Verification -> {
            val executor = ContextCompat.getMainExecutor(this)
            val biometricPrompt = BiometricPrompt(this, executor,
                object : BiometricPrompt.AuthenticationCallback() {
                    override fun onAuthenticationSucceeded(
                        result: BiometricPrompt.AuthenticationResult
                    ) {
                        sendBiometricResult(success = true)
                    }

                    override fun onAuthenticationFailed() {
                        sendBiometricResult(success = false)
                    }

                    override fun onAuthenticationError(
                        errorCode: Int, errString: CharSequence
                    ) {
                        sendBiometricResult(success = false)
                    }
                }
            )

            val promptInfo = BiometricPrompt.PromptInfo.Builder()
                .setTitle("Identity Verification")
                .setSubtitle("Verify your identity to continue")
                .setNegativeBtnText("Cancel")
                .build()

            biometricPrompt.authenticate(promptInfo)
        }

        SmartActionType.TextInput -> {
            presentTextInputPrompt(message = "The agent requested text input.") { text ->
                sendSmartActionText(text)
            }
        }

        SmartActionType.Video -> {
            // Agent-requested video capture. Reuse the same disk-cached file
            // pattern recommended for pre-session media keep the recording on
            // disk and pass the local Uri to the SDK rather than holding a
            // large `ByteArray` in memory.
            promptUserForVideo(message = "The agent requested a video.") { capturedVideoUri ->
                sendAttachment(videoUri = capturedVideoUri)
            }
        }

        SmartActionType.Screenshot -> {
            // The SDK exposes `Screenshot` as a distinct request type the
            // user captures (or selects), a single still image of the current
            // screen. This is **not** the same as `ScreenShare`, which
            // starts a live co-browse session.
            promptUserForScreenshot(message = "The agent requested a screenshot.") { capturedBitmap ->
                sendAttachment(image = capturedBitmap)
            }
        }

        SmartActionType.ScreenShare -> {
            // Hand off to the full screen share flow (see Headless Mobile SDK for Android: Voice, screen share, scheduled call, and email).
            // Don't confuse with `Screenshot`;  `ScreenShare` starts a
            // live co-browse session using `CCAIScreenShare`, while `Screenshot`
            // captures a single still frame.
            startScreenShareFlow()
        }

        SmartActionType.Unknown -> {
            // Future-proof: a smart action type the SDK does not recognize.
            // Log and continue; don't block the user.
            Log.w("CCAI", "Received unknown smart action type")
        }
    }
}

에이전트가 시작한 스마트 작업은 활성 세션의 SDK 이벤트를 통해 도착합니다. 세션을 시작하기 전에 관련 채팅 또는 통화 이벤트를 구독하여 요청이 도착하는 즉시 앱이 적절한 고객 대상 메시지를 표시할 수 있도록 합니다.

스마트 작업 알림 및 추적

사용자가 스마트 작업을 진행할 때 smartActionService를 사용하여 스마트 작업을 확인하고 업데이트합니다.

val router = CommunicationRouter(
    id = chatId,
    type = CommunicationType.Chat
)

// Notify the server that a smart action has been received.
lifecycleScope.launch {
    try {
        CCAI.smartActionService?.notifySmartActionReceived(
            router = router,
            smartActionId = smartActionId
        )
        Log.d("CCAI", "Smart action notification sent successfully")
    } catch (e: Exception) {
        Log.e("CCAI", "Failed to notify smart action: ${e.message}")
    }
}

// Update the smart action status as the user completes it.
lifecycleScope.launch {
    try {
        CCAI.smartActionService?.updateSmartActionStatus(
            router = router,
            smartActionId = smartActionId,
            status = SmartActionStatus.Finished
        )
        Log.d("CCAI", "Smart action status updated successfully")
    } catch (e: Exception) {
        Log.e("CCAI", "Failed to update smart action status: ${e.message}")
    }
}

작업에서 텍스트 입력을 요청하는 경우 UI에서 텍스트를 수집하고 스마트 작업 서비스를 통해 전송한 후 작업을 완료로 표시합니다.

웹 양식

SDK에서 제공하는 기능: 채팅 세션 중에 에이전트가 트리거한 웹 양식을 가져오고, 표시하고, 검증하는 API

빌드하는 항목: 웹 양식 렌더링 UI와 사용자의 응답을 패키징하는 제출 흐름

구현 예시

이 코드는 외부 ID와 스마트 액션 ID로 웹 양식을 가져온 다음 제출 전에 응답을 검증합니다.

// Fetch a web form - `fetchWebForm` takes two positional parameters on Android.
lifecycleScope.launch {
    try {
        val webFormResponse = CCAI.chatService?.fetchWebForm(
            externalFormId = "form123",
            smartActionId = 123
        )
        if (webFormResponse != null) {
            Log.d("CCAI", "Web form received: $webFormResponse")
            // Render the web form in your custom UI.
            renderWebForm(webFormResponse)
        }
    } catch (e: Exception) {
        Log.e("CCAI", "Failed to fetch web form: ${e.message}")
    }
}

// Validate a web form response.
lifecycleScope.launch {
    try {
        val isValid = CCAI.chatService?.validateWebForm(webFormResponse)
        if (isValid == true) {
            Log.d("CCAI", "Web form is valid")
        } else {
            Log.d("CCAI", "Web form validation failed")
        }
    } catch (e: Exception) {
        Log.e("CCAI", "Failed to validate web form: ${e.message}")
    }
}

맞춤 양식

SDK에서 제공하는 기능: 구성 가능한 양식 정의 (여러 질문 유형 포함)를 가져오고 사용자의 답변을 제출하는 API

빌드할 항목: 각 질문 유형 (텍스트 필드, 드롭다운 등)을 렌더링하고, 입력을 검증하고, 제출을 위한 응답을 수집하는 동적 양식 UI

구현 예시

이 코드는 맞춤 양식의 질문 정의를 가져온 다음 사용자의 답변을 패키징하고 제출합니다.

// Retrieve custom form details
lifecycleScope.launch {
    try {
        val formDetails = CCAI.chatService?.getCustomFormDetails(formId = 123)
        if (formDetails != null) {
            Log.d("CCAI", "Form Title: ${formDetails.title ?: "No title"}")
            Log.d("CCAI", "Questions: ${formDetails.questions.size}")
            // Render the form dynamically based on question types
            renderCustomForm(formDetails)
        }
    } catch (e: Exception) {
        Log.e("CCAI", "Failed to get custom form details: ${e.message}")
    }
}

// Submit a custom form
val request = SubmitCustomFormRequest(
    smartActionId = 123,
    formResponse = listOf(
        SubmitCustomFormRequest.Answer(questionId = 1, value = "John Doe"),
        SubmitCustomFormRequest.Answer(questionId = 2, value = "john@example.com")
    )
)

lifecycleScope.launch {
    try {
        CCAI.chatService?.submitCustomForm(request = request)
        Log.d("CCAI", "Form submitted successfully")
    } catch (e: Exception) {
        Log.e("CCAI", "Failed to submit form: ${e.message}")
    }
}

CustomFormDetailsResponse, CustomFormQuestion, CustomFormQuestionOption, CustomFormQuestionType 전체 열거형 정의는 헤드리스 모바일 SDK - Android API 참조를 참고하세요.

소비자가 시작한 첨부파일 (채팅)

사용자는 상담사가 요청할 때까지 기다릴 필요 없이 채팅 중에 이미지와 동영상을 먼저 보낼 수 있습니다. SDK는 엄격한 파일 크기 제한을 적용하고 전송 전에 자동으로 압축을 시도합니다.

첨부파일 크기 한도

  • 이미지: ≤ 2MB

  • 동영상: ≤ 20MB

SDK에서 제공하는 기능: 네트워크 전송을 처리하고 CCAI Platform의 제한을 충족하도록 파일을 자동으로 압축하는 안전한 업로드 파이프라인

빌드하는 항목: 채팅 UI의 클립 / 첨부파일 버튼, 사진/동영상 선택기, 업로드 상태(로드 중, 전송됨, 실패)를 보여주는 시각적 채팅 풍선

구현 예시

사용자가 갤러리에서 사진이나 동영상을 선택하면 모든 나가는 메시지에 사용되는 동일한 sendMessage API를 사용하여 로컬 파일 Uri를 SDK에 전달합니다. 자세한 내용은 메시지 보내기를 참고하세요.

SDK가 압축 및 업로드를 처리합니다.

// Called when the user picks an image from their photo gallery
fun userDidSelectImage(imageUri: Uri) {
    val messageBubble = myChatAdapter.addLoadingImageBubble()

    lifecycleScope.launch {
        try {
            CCAI.chatService?.sendMessage(
                OutgoingMessageContent.Photos(
                    images = listOf(imageUri),
                    smartAction = null
                )
            )
            messageBubble.markAsSent()
        } catch (e: Exception) {
            messageBubble.showError("Failed to send: ${e.message}")
        }
    }
}

상담사가 시작한 첨부파일 (채팅)

에이전트는 이미지, 동영상, 오디오 클립, 문서 (PDF, CSV 등)를 비롯한 파일을 사용자에게 다시 보낼 수 있습니다.

지원되는 파일 형식

CCAI Platform 플랫폼은 4개 카테고리에 걸쳐 19개 파일 형식을 허용합니다. 맞춤 UI는 각 카테고리를 적절하게 처리해야 합니다 (이미지 미리보기, 동영상 플레이어, 오디오 플레이어, 문서 다운로드).

카테고리 지원되는 유형
이미지 JPEG, PNG, GIF, WebP
동영상 MP4, MOV, AVI, WMV, WebM
오디오 MP3, WAV, M4A, WEBA
문서 PDF, DOC, XLS, PPT, CSV, TXT

SDK에서 제공하는 항목: 첨부파일 메타데이터가 포함된 채팅 서비스의 메시지 Flow를 사용하여 전송되는 수신 채팅 메시지 페이로드(Android용 헤드리스 모바일 SDK: 고급 주제, 현지화, 세션 후 흐름 참고) 채팅 서비스는 에이전트가 보낸 미디어를 안전하게 가져오기 위한 downloadMedia도 제공합니다.

빌드하는 항목: 채팅 피드에 이러한 첨부파일을 렌더링하는 UI 이미지 미리보기, 동영상 썸네일, 오디오 재생 컨트롤 또는 문서 다운로드 버튼을 표시할지 여부는 앱에서 결정합니다.

구현 예시

채팅 서비스의 메시지 Flow를 사용하여 수신 메시지를 관찰합니다. 새 메시지에 미디어 메타데이터가 포함된 경우 파일을 렌더링하거나 열기 전에 downloadMedia를 호출합니다.

downloadMedia는 완전히 다운로드된 바이트와 선택적 표시 파일 이름을 더한 DownloadMediaResponse { data: ByteArray, fileName: String? }를 반환합니다. SDK는 다운로드를 캐시하지 않습니다. UI가 이전 풍선으로 다시 스크롤되거나 채팅 뷰에 다시 진입하는 경우 앱은 디코딩된 이미지를 캐시하거나 바이트를 유지해야 합니다. downloadMedia를 다시 호출하면 다른 네트워크 가져오기가 트리거됩니다.

import android.graphics.BitmapFactory
import java.io.File

lifecycleScope.launch {
    CCAI.chatService?.messagesReceived?.collect { messages ->
        for (message in messages) {
            renderIncomingMessage(message)
        }
    }
}

fun renderIncomingMessage(message: ChatMessage) {
    val media = message.media

    if (media == null) {
        myChatAdapter.insertTextBubble(text = message.text)
        return
    }

    lifecycleScope.launch {
        try {
            // `downloadMedia` returns the raw bytes plus an optional filename.
            val response = CCAI.chatService?.downloadMedia(
                mediaId = media.id,
                mediaType = media.type
            ) ?: return@launch

            when (media.type) {
                DownloadableMediaType.Photo -> {
                    // Decode in-memory; no disk write needed.
                    val bitmap = BitmapFactory.decodeByteArray(
                        response.data, 0, response.data.size
                    )
                    myChatAdapter.insertImageBubble(
                        bitmap = bitmap,
                        fileName = response.fileName
                    )
                }
                DownloadableMediaType.Video,
                DownloadableMediaType.Audio,
                DownloadableMediaType.Document -> {
                    // Larger media usually needs an on-disk file for a player
                    // or document viewer. Write to the app's cache directory
                    // yourself and hand the resulting File/Uri to your UI.
                    val file = File(
                        applicationContext.cacheDir,
                        response.fileName ?: "attachment"
                    )
                    file.writeBytes(response.data)
                    myChatAdapter.insertAttachmentBubble(
                        file = file,
                        mediaType = media.type
                    )
                }
            }
        } catch (e: Exception) {
            myChatAdapter.insertAttachmentErrorBubble(fileName = message.media?.name)
        }
    }
}

전환 (시간 외 및 용량 초과)

편향은 지원팀이 응대할 수 없을 때 사용자를 리디렉션합니다. 이러한 상황은 보통 다음 두 가지 시나리오에서 발생합니다.

  • 영업시간 외: 사용자가 구성된 영업시간 외에 지원팀에 문의하려고 합니다.

  • 초과 용량: 대기열이 열려 있지만 대기 시간이 관리자가 구성한 기준을 초과합니다.

헤드리스 아키텍처에서 SDK는 사용자를 자동으로 차단하거나 오류 화면을 팝업하지 않습니다. 대신 리디렉션 상태와 관리자가 구성한 대체 옵션을 앱에 전달합니다. 앱은 세션을 시작하기 전에 이 상태를 확인해야 합니다.

회피 상태 처리

SDK에서 제공하는 항목: 다른 대기열을 이 대기열로 라우팅하는 수신 전환 규칙을 나열하는 QueueMenu의 deflectionFrom: List<QueueMenuDeflection>? 각 QueueMenuDeflection에는 다음 두 필드만 포함됩니다.

  • deflectionType: DeflectionType?: 전환 카테고리 (예: 근무 시간 외 또는 수용량 초과)

  • menuId: Int?: 이 큐로 리디렉션되는 소스 큐의 ID입니다.

QueueMenuDeflection에는 표시 메시지나 사람이 읽을 수 있는 라벨이 없습니다. 사용자 대상 문구 (예: 사무실이 문을 닫았습니다)의 경우 대기열의 afterHoursMessage (QueueMenu.settings[].afterHours에서 파생됨) 또는 관리자가 구성한 문자열을 사용합니다. 확인된 대상 대기열의 표시 이름은 이미 가져온 메뉴 트리에서 menuId로 조회합니다.

빌드하는 항목: 사용자를 대기열에 넣기 전에 이러한 전환 상태를 확인하는 로직과 적절한 메시지 및 대체 라우팅 버튼을 표시하는 맞춤 UI (예: 전체 화면 알림 또는 배너)

구현 예시

다음 코드는 사용자가 채팅을 시작하려고 시도하는 것을 가로챕니다. QueueMenu에서 SDK의 전환 메타데이터를 확인하고 규칙이 활성화된 경우 대기열의 afterHoursMessage (또는 대체)를 사용하여 전환 화면을 표시합니다.

fun onSupportMenuTapped(queueMenu: QueueMenu) {
    // 1. Check if any deflection rules route into this queue.
    val deflections = queueMenu.deflectionFrom
    if (!deflections.isNullOrEmpty()) {
        // Deflection is active - stop routing. Show the custom deflection screen instead.
        val message = queueMenu.afterHoursMessage?.text
            ?: "Support is currently unavailable."
        showDeflectionScreen(
            message = message,
            deflections = deflections
        )
    } else {
        // No deflection rules apply. Safe to proceed to channel selection or directly to chat/call.
        startSupportFlow(queueMenu)
    }
}

렌더링 편향 옵션

전환 규칙이 활성화되면 관리자는 사용자가 도움을 받을 수 있는 대체 방법을 구성할 가능성이 높습니다. 일반적으로 다른 대기열을 가리키거나 (menuId을 사용하여 해결할 수 있음) 현재 대기열에 구성된 afterHoursMessage을 표시합니다.

SDK에서 제공하는 항목: deflectionFrom의 QueueMenuDeflection 항목 목록입니다. 각 항목에는 deflectionType 및 menuId이 있습니다.

빌드하는 항목: 이러한 항목을 통해 루프를 실행하고, 이미 가져온 메뉴 트리와 비교하여 각 menuId을 확인하여 표시 이름을 가져오고, 적절한 버튼을 생성하는 동적 메뉴

구현 예시

이 코드는 편향 항목을 가져와 동적으로 화면을 빌드합니다. 이름을 사용할 수 없는 경우 deflectionType로 대체되는 소스 대기열의 이름 (menuId에서 확인됨)을 버튼 라벨로 사용합니다.

fun showDeflectionScreen(message: String, deflections: List<QueueMenuDeflection>) {
    // 1. Update your UI with the admin's configured message (or your fallback).
    deflectionTitleTextView.text = message

    // 2. Loop through the incoming deflection rules and generate buttons.
    for (deflection in deflections) {
        val sourceMenu = deflection.menuId?.let { findMenuById(it) }
        val label = sourceMenu?.name
            ?: deflection.deflectionType?.name
            ?: "Alternative option"

        val button = MaterialButton(this).apply {
            text = label
            setOnClickListener {
                handleDeflectionAction(deflection)
            }
        }
        menuLinearLayout.addView(button)
    }

    // 3. Present the compiled screen to the user.
    deflectionContainer.isVisible = true
}

// Walk your previously fetched menu tree (for example, by using `QueueMenu.flatten()`) to resolve a `menuId`.
private fun findMenuById(id: Int): QueueMenu? =
    cachedRootMenus.flatMap { it.flatten() }.firstOrNull { it.id == id }

deflectionFrom는 이 메뉴로 라우팅되는 전환 규칙 목록입니다. 각 항목의 menuId는 현재 항목으로 전환되는 소스 대기열을 식별합니다. 앱에서 이러한 정보를 노출하는 방법을 결정합니다. 일부 통합에서는 대상 대기열을 대신 전송될 위치로 표시하는 반면, 다른 통합에서는 규칙의 존재를 일반적인 나중에 다시 시도하세요 화면을 표시하는 게이트로 사용합니다.