Android 版无头移动 SDK:智能操作、附件和拒接

本文档介绍了如何在 Android 应用中集成和自定义 Headless Mobile SDK。它涵盖了智能操作、附件和分流。

配置智能操作和附件

借助智能操作,代理和消费者可以交换丰富的结构化信息,例如照片、视频、文本片段和生物识别验证信息。

在无头架构中,SDK 严格充当安全传输层。它会告知您何时需要或请求执行某项操作,并安全地将数据传输到 CCAI Platform 平台。由于您拥有用户界面,因此您的应用完全负责构建相机界面、照片选择器、文本框,以及触发 Android 的内置生物识别提示。

会前智能操作

会前操作会在用户加入队列之前收集相关背景信息(例如账号或损坏产品的视频),从而显著缩短客服人员的处理时间。

SDK 提供的内容:特定队列的规则和要求(例如,加入队列之前是否必须进行生物识别验证、上传照片或输入文字)。

您将构建的内容:在用户点按队列和进入等候室之间显示的前会话界面屏幕(摄像头、文本输入和生物识别提示),以及用于安全保存媒体直到会话连接的磁盘缓存逻辑。

实现示例

此代码会检查 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
}

Path 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}")
    }
}

如果操作需要文本输入,请在界面中收集文本,并通过智能操作服务发送文本,然后再将操作标记为已完成。

网络表单

SDK 提供的内容:API,用于在聊天会话期间提取、显示和验证由代理触发的 Web 表单。

您将构建的内容:用于呈现 Web 表单的界面和用于打包用户回答的提交流程。

实现示例

此代码按外部 ID 和智能操作 ID 获取 Web 表单,然后在提交之前验证响应。

// 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。

构建内容:一个动态表单界面,用于呈现每种问题类型(文本字段、下拉菜单等)、验证输入内容,并收集回答以供提交。

实现示例

此代码会检索自定义表单的问题定义,然后打包并提交用户的回答。

// 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 会强制执行严格的文件大小限制,并在传输之前自动尝试压缩。

附件大小限制

  • 图片:≤ 2 MB

  • 视频:≤ 20 MB

SDK 提供的内容:一个安全的上传流水线,可处理网络传输并自动压缩文件以满足 CCAI Platform 的限制。

您构建的内容:聊天界面中的回形针 / 附件按钮、照片/视频选择器,以及显示上传状态(正在加载、已发送或失败)的可视化聊天气泡。

实现示例

当用户从图库中选择照片或视频时,请使用与所有传出消息相同的 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 平台接受 4 个类别中的 19 种文件类型。您的自定义界面应妥善处理每个类别(图片预览、视频播放器、音频播放器、文档下载)。

类别 受支持的类型
图片 JPEG、PNG、GIF 和 WebP。
视频 MP4、MOV、AVI、WMV 和 WebM。
音频 MP3、WAV、M4A 和 WEBA。
文档 PDF、DOC、XLS、PPT、CSV 和 TXT。

SDK 提供的内容:使用聊天服务的消息 Flow 传送的传入聊天消息载荷,其中包含附件元数据 - 请参阅无头移动 SDK for Android:高级主题、本地化和会话后流程。聊天服务还提供 downloadMedia,用于安全地检索代理发送的媒体。

您将构建的内容:用于在聊天 Feed 中呈现这些附件的界面。应用会自行决定是否显示图片预览、视频缩略图、音频播放控件或文档下载按钮。

实现示例

使用聊天服务的消息 Flow 观察传入的消息。当新消息包含媒体元数据时,在渲染或打开文件之前调用 downloadMedia。

downloadMedia 返回 DownloadMediaResponse { data: ByteArray, fileName: String? } - 完全下载的字节,以及可选的显示文件名。SDK 不会缓存下载内容。如果界面滚动回旧气泡或重新进入聊天视图,您的应用负责缓存解码后的图片或持久保存字节;再次调用 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 从已提取的菜单树中查找。

您需要构建的内容:在将用户放入队列之前检查这些分流状态的逻辑,以及显示相应消息和替代路由按钮的自定义界面(例如全屏提醒或横幅)。

实现示例

以下代码会拦截用户尝试发起聊天。它会检查 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 以获取显示名称,并生成相应的按钮。

实现示例

此代码会获取偏转条目并动态构建屏幕。它使用源队列的名称(从 menuId 解析)作为按钮标签,在没有名称时回退到 deflectionType。

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 标识一个来源队列,该队列会转接到当前队列。您的应用会决定如何公开这些内容。有些集成会将目标队列显示为我们会将您转到此处,而另一些集成则仅使用任何规则的存在作为门控来显示通用的稍后重试界面。