适用于 Android 的无头移动 SDK:语音、屏幕共享、预定通话和电子邮件

本文档介绍了如何在 Android 应用中集成和自定义无头移动 SDK。它涵盖语音、屏幕共享、预定通话和电子邮件。

语音通话

Android 版无头移动 SDK 通过 CCAICall 和 CCAICallRed 模块提供语音通话支持。您的应用拥有自定义通话界面,而 SDK 则负责处理通话创建、来电处理、提供程序集成和通话生命周期状态。

SDK 提供的内容:用于发起即时通话、启动语音留言、处理来电和观察通话状态的通话服务 API。

您构建的内容:可视化通话体验,包括通话前入口点、录制同意提示、响铃或等待状态、通话中控件、前台服务界面和任何通话后导航。

初始化通话模块

在初始化核心 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)
        }
    }
}

发起即时通话

开始即时通话之前:

  1. 检查所选队列的 VoiceCallChannel。

  2. 检查确认已启用即时通话且未转接。

  3. 根据队列的 recordingOption 处理录制同意情况。

  4. 请求麦克风权限。

  5. 调用 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 会报告平台上的通话生命周期。使“已连接”翻转和通话计时器从 callReceived.status 开始,而不是从 stateChanged 达到连接状态开始。

信号 报告内容 从该数据中得出什么结论
stateChanged(提供商) 本地 VoIP 握手 - Connecting, Connected (local), Disconnected, Error(...). 在服务器状态到达、错误显示和通话结束清理之前,可选的预状态微调器。
callReceived.status(服务器) 服务器端生命周期 - WAITING、CONNECTING、CONNECTED、ON_HOLD、INTERRUPTED、ENDED。 已关联的翻盖、通话计时器启动、代理分配的界面、转接或上报叠加层以及结束通话流程。

来电

系统会使用 FCM 将来电传递到设备。由于来电可能会在应用处于后台运行或完全终止时到达,因此您的集成需要准备好从任何进程状态接收来电。

SDK 提供的内容:callService 上发出传入来电生命周期的 IncomingCallEvent Flow:Arrived、Accepted、Rejected。调用服务还公开了 acceptIncomingCall() 和 rejectIncomingCall() 来处理优惠。

您构建的内容:来电界面(全屏响铃界面、锁屏界面呈现或应用内横幅)以及接受或拒绝优惠的处理程序。

实现示例

在每个生命周期事件中收集 incomingCallEvent 并呈现或关闭界面,然后从长期有效的范围内接受或拒绝。在 Application 类中定义 applicationScope 一次,以便接受或拒绝协程在握手期间在任何短暂的 Activity 拆解中存活下来。

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 桥接到来电界面

通过 FCM 传送的来电会在后台线程中到达,通常在任何 Activity 存在之前。使用较短的超时时间将 FCM 载荷桥接到第一个 IncomingCallEvent.Arrived 发射,然后启动界面。

实现示例

在下一个 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 框架拥有响铃用户体验。

SDK 提供的内容:一个用于为应用 (registerPhoneAccount(context)) 注册 PhoneAccount 的 TelecomCallService 帮助程序、一个用于将传入的 CCAI 调用桥接到 Android Telecom 的 TelecomConnectionService,以及一个用于连接状态回调的 TelecomConnectionCallback 接口。

您要构建的内容:应用初始化期间的一次性 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>

替代方案:全屏响铃 activity

如果您希望自行渲染响铃界面,而不是使用 Telecom 框架,则 SDK 会公开 IncomingCallService 以及 IncomingCallUiNavigator 接口。实现该接口,然后在应用初始化期间将您的实现传递给 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 枚举,用于描述调用在其生命周期中所处的位置(例如 CONNECTING、WAITING、CONNECTED、ON_HOLD、INTERRUPTED、ENDED、FAILED)。

您要构建的内容:从 CallStatus 到通话屏幕中面向客户的状态的映射。

实现示例

根据每次 callReceived 排放的 call.status 进行分支,并渲染匹配的界面状态:when

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 提供的内容:callService 上的只读属性(isMuted、isSpeakerEnabled、isOnHold),以及用于切换每种状态的操作(setMuted、setSpeakerEnabled、setOnHold)。

您将构建的内容:通话界面中的静音、免提和保持按钮,这些按钮已连接到 SDK 的操作并反映当前状态。

实现示例

从 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) 用于将其恢复到前台。

您需要构建的内容:界面中的恢复可供性(通常是横幅或在中断结束后自动恢复)以及返回通话界面的导航。

实现示例

使用 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) 是 CallResponse(由 callService.callReceived 发出的模型)上的方法,而不是 callService 本身上的方法。它会根据通话的当前虚拟助理状态报告当前通话是否符合升级到人工客服的条件。

您要构建的内容:将最新的 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 枚举,用于说明平台提供语音信箱的原因(例如,下班后回退、容量过载回退或临时重定向)。

您要构建的内容:界面中的语音留言入口点(通常在填充 VoiceCallChannel.voicemailReason 时或通话生命周期解析为语音留言结果时显示)和录制界面本身。

实现示例

当 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 标志用于指示是否应在回退界面中隐藏直接拨打 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 平台支持两种屏幕共享模式:

  1. 应用内分享:智能体只能看到特定应用内发生的情况。

  2. 全设备共享(可选):客服人员可以查看用户的整个设备,包括 Android 主屏幕和其他应用。这需要使用 MediaProjection 和前台服务进行高级 Android 配置。

SDK 提供的内容:用于创建和管理会话、处理状态变化以及处理同意情况请求的 CCAIScreenShare 模块。

您将构建的内容:初始化配置、征求用户同意的界面提示,以及(如果支持全设备)内置 Android MediaProjection 与前台服务的集成。

屏幕共享服务提供用于完整会话生命周期(startSession、activateSession、stopSession)的方法,以及用于远程控制和全设备共享的配置方法。接下来的小节将介绍核心集成流程。

如需查看所有屏幕共享方法的完整方法签名和参数详细信息,请参阅 Headless Mobile 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 在会话成功激活并开始流式传输后调用。使用此方法可将界面更新为“屏幕共享已激活”状态。

如需查看完整的状态枚举定义和所有关联类型,请参阅 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>

在捕获屏幕之前,您必须使用 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 在屏幕共享方面的注意事项

  1. 使用 Android 视图封装关键互动元素 - 对于需要远程点击的区域,请使用 AndroidView 可组合函数将其封装为标准 Android 视图控件(例如 android.widget.Button)。这样可确保远程点击通过基于 Android view 的路径可靠地运行。

  2. 通过自定义触控处理转发远程触控 - 对于无法替换为视图的 Compose 区域,请使用自定义触控回调将远程触控事件转发到您的 Compose 逻辑。

  3. 提供产品级指导或回退 - 在撰写屏幕上明确指出“客服人员只能查看;用户必须在本地点按”,或者自动限制这些页面上的远程控制权限,同时保持只读访问权限。

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 会返回日期值,您的应用可以按用户的本地时区显示这些值。

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 手机号码验证、确认界面、预定通话 ID 的本地持久性,以及针对时段冲突或下班后回复的恢复功能。

实现示例

在 ScheduledCallRequest 中传递所选的 slot、手机号码、队列 ID 和 RecordingPermission.NOT_ASKED。

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

手机号码必须采用 E.164 格式,例如 +14155551234。

重新安排现有通话

如需重新安排,请获取空档并使用现有的预定通话 ID 创建新的预定通话。

SDK 提供的内容:相同的时段和创建 API,使用 callIdForRescheduling 标识现有预订。

您将构建的内容:用于更改时间的界面、对存储的预定通话 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。

您将构建的内容:确认提示、取消成功或错误界面,以及清理任何本地存储的即将到来的通话状态。

实现示例

使用存储的 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 提供的内容:网址(例如贵公司的帮助中心、退款政策或合作伙伴应用)及其显示标题的列表,与在管理门户中配置的完全一致。

您要构建的内容:当用户点按这些链接时执行的逻辑 - 通过在应用内浏览器中打开这些链接(使用 Android 的自定义标签页)或启动默认系统浏览器。

当用户点按菜单中的外部链接时,此代码会提取 SDK 提供的网址字符串,并使用 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)
    }