SDK headless para dispositivos móveis para Android: voz, compartilhamento de tela, chamadas programadas e e-mail

Este documento explica como integrar e personalizar o SDK Headless Mobile no seu app Android. Ela abrange voz, compartilhamento de tela, chamadas programadas e e-mail.

Ligação

O SDK Headless Mobile para Android oferece suporte a chamadas de voz pelos módulos CCAICall e CCAICallRed. Seu app é proprietário da UI de chamada personalizada, enquanto o SDK processa a criação de chamadas, o tratamento de chamadas recebidas, a integração de provedores e o estado do ciclo de vida da chamada.

O que o SDK oferece: APIs de serviço de chamadas para iniciar chamadas instantâneas, iniciar correio de voz, atender chamadas recebidas e observar o estado da chamada.

O que você cria: a experiência de chamada visual, incluindo o ponto de entrada antes da chamada, o aviso de consentimento de gravação, os estados de toque ou espera, os controles na chamada, UI serviço em primeiro plano e qualquer navegação após a chamada.

Inicializar o módulo de chamada

Inicialize o módulo de chamada depois de inicializar o SDK principal:

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()
)

Inspecionar recursos do VoiceCallChannel

Antes de oferecer uma ligação instantânea, inspecione o VoiceCallChannel da fila para decidir quais pontos de entrada serão expostos (ligação instantânea, fallback de caixa postal, redução no nível do canal) e se é necessário consentimento para gravação.

Campos VoiceCallChannel principais

  • instantEnabled: Boolean — se uma ligação ao vivo (instantânea) é permitida para essa fila. Trate false como "não oferecer um botão de chamada instantânea". Diferente de voiceCall != null, que apenas informa que a fila tem um canal de voz.

  • preSessionSmartAction: Boolean — se as ações inteligentes pré-sessão são necessárias nesse canal específico antes de iniciar uma chamada. Consulte Ações inteligentes pré-sessão em SDK headless para dispositivos móveis para Android: ações inteligentes, anexos e desvio para o caminho complementar no nível da fila.

  • scheduleEnabled: Boolean — indica se as chamadas programadas são compatíveis. Consulte a seção Chamadas programadas.

  • recordingOption: RecordingOption? — o modelo de consentimento para gravação de chamada. Consulte Consentimento para gravação. Diferente de RecordingPermission.NOT_ASKED, que é usado apenas com chamadas programadas.

  • voicemailReason: String? — preenchido quando o canal é configurado para encaminhar para o correio de voz.

  • phoneNumber: String? — número alternativo da RPTC configurado para desvio ou correio de voz.

  • scheduleDeflectionType: String? / deflected: Boolean? / deflectedReason: String? — metadados de evasão no nível do canal. Quando deflected == true, mostre a mensagem deflectedReason configurada pelo administrador e ofereça o substituto configurado (por exemplo, um número de telefone para discar ou um ponto de entrada de chamada agendada) em vez de tentar uma chamada instantânea. Consulte a seção Redução do tempo de espera no SDK headless para dispositivos móveis do Android: ações inteligentes, anexos e redução para processamento de redução no nível da fila.

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

O que o SDK oferece: um campo recordingOption: RecordingOption? em VoiceCallChannel que descreve o modelo de consentimento para gravação de chamadas dos administradores configurado no portal.

O que você cria: a solicitação de consentimento (quando ASK_USER) e a lógica de ramificação que respeita a escolha do administrador.

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

Iniciar uma chamada instantânea

Antes de iniciar uma chamada instantânea:

  1. Inspecione o VoiceCallChannel da fila selecionada.

  2. Verifique se as chamadas instantâneas estão ativadas e não foram desviadas.

  3. Gerencie o consentimento de gravação de acordo com o recordingOption da fila.

  4. Solicite permissão de acesso ao microfone.

  5. Ligue para 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)
    }
}

Observar o estado da chamada

Colete eventos de serviço de chamada antes de iniciar ou aceitar uma ligação:

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

Estado do provedor x status do servidor

stateChanged e callReceived.status são indicadores independentes. Leia-os como colunas, não linhas. O stateChanged do provedor informa o handshake VoIP local, e o callReceived.status do lado do servidor informa o ciclo de vida da chamada na plataforma. Faça a inversão "Conectado" e o início do timer de chamada de callReceived.status, não de stateChanged atingindo o estado conectado.

Sinal O que ele informa O que extrair disso
stateChanged (provedor) Handshake VoIP local — Connecting, Connected (local), Disconnected, Error(...). Spinner de pré-status opcional antes da chegada do status do servidor, da exibição de erros e da limpeza de fim de chamada.
callReceived.status (servidor) Ciclo de vida do lado do servidor: WAITING, CONNECTING, CONNECTED, ON_HOLD, INTERRUPTED, ENDED. Inversão conectada, início do cronômetro de chamada, UI atribuída ao agente, sobreposições de transferência ou encaminhamento e o fluxo de encerramento de chamada.

Chamadas recebidas

As chamadas de voz recebidas são entregues ao dispositivo usando o FCM. Como as chamadas recebidas podem chegar enquanto o app está em segundo plano ou totalmente encerrado, sua integração precisa estar pronta para recebê-las de qualquer estado de processo.

O que o SDK oferece: um fluxo IncomingCallEvent em callService que emite o ciclo de vida de uma chamada recebida: Arrived, Accepted, Rejected. O serviço de chamada também expõe acceptIncomingCall() e rejectIncomingCall() para agir na oferta.

O que você vai criar: UI de chamadas recebidas (tela de toque em tela cheia, apresentação na tela de bloqueio ou banner no app) e os manipuladores que aceitam ou rejeitam a oferta.

Exemplo de implementação

Colete incomingCallEvent e renderize ou dispense sua UI em cada evento de ciclo de vida. Depois, aceite ou rejeite de um escopo de longa duração. Defina applicationScope uma vez na sua classe Application para que as corrotinas de aceitação ou rejeição sobrevivam a qualquer encerramento de Activity de curta duração durante o handshake.

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

Como fazer a ponte entre o FCM e a interface de chamadas recebidas

As chamadas recebidas entregues pelo FCM chegam em uma linha de execução em segundo plano, geralmente antes de qualquer Activity. Use um tempo limite curto para fazer a ponte da carga útil do FCM com a primeira emissão de IncomingCallEvent.Arrived e inicie a UI.

Exemplo de implementação

Faça uma pausa no próximo IncomingCallEvent.Arrived com withTimeoutOrNull e entregue o dispositivo ao navegador.

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

Para uma experiência de chamada recebida do Android integrada (campainha em tela cheia, apresentação na tela de bloqueio e integração com o registro de chamadas do sistema), registre um PhoneAccount e deixe o framework Telecom cuidar da UX de toque.

O que o SDK oferece: um auxiliar TelecomCallService que registra um PhoneAccount para seu app (registerPhoneAccount(context)), um TelecomConnectionService para fazer a ponte entre as chamadas da CCAI e o Android Telecom e uma interface TelecomConnectionCallback para callbacks de estado de conexão.

O que você vai criar: o registro único PhoneAccount durante a inicialização do app e uma declaração de manifesto para o serviço de conexão.

Exemplo de implementação

Registre a conta de telefone na inicialização do app e declare o serviço de conexão e a permissão no manifesto.

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

Alternativa: atividade de toque em tela cheia

Se você preferir renderizar sua própria tela de toque em vez de usar o framework Telecom, o SDK vai expor IncomingCallService junto com a interface IncomingCallUiNavigator. Implemente a interface e entregue sua implementação a IncomingCallService durante a inicialização do app.

Exemplo de implementação

Implemente IncomingCallUiNavigator e transmita para IncomingCallService uma vez durante Application.onCreate().

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

Serviço em primeiro plano para toque ativo.

Enquanto uma chamada está tocando, o Android pode encerrar processos em segundo plano. O SDK envia um IncomingRingingService que mantém o fluxo de toque ativo como um serviço em primeiro plano. Declare no manifesto, e o SDK vai iniciar e parar automaticamente quando as chamadas chegarem.

<service
    android:name="com.ccaiplatform.android.call.IncomingRingingService"
    android:foregroundServiceType="phoneCall"
    android:exported="false" />

<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />

Status da chamada e estados do lado do servidor

O que o SDK oferece: um enumerador CallStatus controlado pelo servidor em cada payload Call que descreve em que ponto do ciclo de vida a chamada está (por exemplo, CONNECTING, WAITING, CONNECTED, ON_HOLD, INTERRUPTED, ENDED, FAILED).

O que você cria: o mapeamento de CallStatus para os estados voltados ao cliente na tela de chamada.

Exemplo de implementação

Ramificação when em call.status de cada emissão callReceived e renderização do estado da UI correspondente:

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

Controles durante a chamada (silenciar, alto-falante, espera)

O que o SDK oferece: propriedades somente leitura em callService — isMuted, isSpeakerEnabled, isOnHold — junto com as ações para alternar cada estado (setMuted, setSpeakerEnabled, setOnHold).

O que você vai criar: os botões de mudo, alto-falante e espera na UI de chamada, conectados às ações do SDK e refletindo o estado atual.

Exemplo de implementação

Leia o estado atual de callService para inicializar os botões. Depois, alterne e atualize ao tocar.

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
}

Retomar uma chamada em andamento após uma interrupção

Quando uma interrupção da PSTN (como uma chamada de celular recebida) pausa a chamada ativa da CCAI, o SDK mantém a sessão ativa e emite interruptionDetected. Depois que a interrupção terminar, o app poderá retomar a chamada.

O que o SDK oferece: getLastCallInProgress() para pesquisar uma chamada em andamento (inclusive em reinicializações do app) e resumeCall(call) para trazê-la para o primeiro plano.

O que você cria: a opção de retomar na sua UI (normalmente um banner ou uma retomada automática após o fim da interrupção) e a navegação de volta para a tela de chamada.

Exemplo de implementação

Procure a chamada em andamento com getLastCallInProgress, retome e volte para a tela de chamada.

lifecycleScope.launch {
    val call = CCAI.callService?.getLastCallInProgress() ?: return@launch
    try {
        CCAI.callService?.resumeCall(call)
        navigateToCallScreen(call)
    } catch (e: Exception) {
        showCallResumeError(e)
    }
}

Encaminhamento de um agente virtual

O que o SDK oferece: canEscalate(allowSkipVirtualAgent: Boolean) é um método em CallResponse (o modelo emitido por callService.callReceived), não em callService. Ele informa se a chamada atual está qualificada para encaminhamento a um agente humano com base no status atual do agente virtual.

O que você vai criar: mantenha a emissão callReceived mais recente como sua "chamada atual" e vincule a visibilidade do botão "Falar com uma pessoa" a currentCall?.canEscalate(...). O argumento allowSkipVirtualAgent é um valor de configuração do menu que você busca separadamente da resposta da chamada. Não codifique true na produção.

Exemplo de implementação

Mantenha o Call mais recente em uma propriedade e invoque canEscalate nela. Resolva allowSkipVirtualAgent na configuração do menu.

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

Correio de voz

O que o SDK oferece: startVoicemail(VoicemailRequest) em callService, além de uma enumeração VoicemailReason que descreve por que a plataforma ofereceu correio de voz (por exemplo, fallback após o horário de expediente, fallback por excesso de capacidade ou redirecionamento temporário).

O que você cria: o ponto de entrada de correio de voz na sua UI (normalmente mostrado quando VoiceCallChannel.voicemailReason é preenchido ou quando o ciclo de vida da chamada resulta em um correio de voz) e a própria UI de gravação.

Exemplo de implementação

Mostre o botão de correio de voz quando voicemailReason estiver definido e chame startVoicemail com um VoicemailRequest.

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

Redução do tempo de espera

Quando a fila está aberta, mas o tempo de espera excede o limite configurado pelo administrador, a plataforma pode oferecer um caminho alternativo, geralmente um retorno de chamada programado ou um desvio de PSTN.

O que o SDK oferece: um payload CallDeflection que descreve a alternativa oferecida, recuperável usando getCallDeflection(callId) em callService. A plataforma também emite o mesmo payload pelo fluxo deflectionOffered quando uma deflexão fica disponível no meio da espera. Uma flag CompanyResponse.preventDirectPstnCall separada indica se a discagem direta da PSTN deve ser ocultada na UI alternativa.

O que você vai criar: o aviso de redirecionamento (por exemplo, "A espera estimada é de 12 minutos. Prefere agendar uma ligação?") e a lógica de ramificação que respeita a escolha do usuário.

Exemplo de implementação

Extraia a sugestão de evasão com o ID da chamada ativa, combine com CompanyResponse.preventDirectPstnCall e apresente o aviso.

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

Avançado CallOptions

O CallOptions expõe um pequeno conjunto de alternâncias para apps que precisam ajustar a experiência de chamada padrão.

  • connectingPollIntervalMs: Long — a frequência com que o SDK pesquisa a plataforma enquanto a chamada está no estado de conexão.

  • connectedPollIntervalMs: Long — a frequência com que o SDK pesquisa a plataforma enquanto a chamada está conectada.

  • endCallMaxRetries: Int — quantas vezes o SDK tenta novamente a solicitação de encerramento da chamada antes de mostrar um erro.

  • endCallRetryDelayMs: Long — atraso entre as novas tentativas de encerrar a chamada.

Exemplo de implementação

Transmita os valores ajustados para CallOptions ao inicializar o módulo de chamada:

CCAI.initializeCall(
    context = this,
    options = CallOptions(
        connectingPollIntervalMs = 2_000L,
        connectedPollIntervalMs = 5_000L,
        endCallMaxRetries = 3,
        endCallRetryDelayMs = 1_000L
    )
)

Compartilhamento de tela

Com o compartilhamento de tela, os representantes de suporte podem visualizar a tela do usuário para ajudar a resolver problemas. A plataforma CCAI oferece suporte a dois modos de compartilhamento de tela:

  1. Compartilhamento no app: o agente só pode ver o que está acontecendo no seu app específico.

  2. Compartilhamento completo do dispositivo (opcional): o agente pode acessar todo o dispositivo do usuário, incluindo a tela inicial do Android e outros apps. Isso exige configuração avançada do Android usando MediaProjection e um serviço em primeiro plano.

O que o SDK oferece: o módulo CCAIScreenShare que cria e gerencia sessões, processa mudanças de estado e solicitações de consentimento.

O que você vai criar: a configuração de inicialização, os comandos da UI para consentimento do usuário e (se houver suporte para o dispositivo completo) a integração MediaProjection integrada do Android com um serviço em primeiro plano.

O serviço de compartilhamento de tela oferece métodos para o ciclo de vida completo da sessão: startSession, activateSession e stopSession, além de métodos de configuração para controle remoto e compartilhamento de dispositivo completo. As próximas subseções explicam o fluxo principal de integração.

Para ver assinaturas de métodos completas e detalhes de parâmetros de todos os métodos de compartilhamento de tela, consulte a Referência da API do SDK móvel sem interface gráfica - Android.

Inicializar e configurar o compartilhamento de tela

Inicialize o serviço de compartilhamento de tela durante a configuração do app fornecendo sua chave de compartilhamento de tela:

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

Verificar a qualificação para compartilhamento de tela

Antes de ativar o compartilhamento de tela, verifique se ele está disponível para o chat atual:

import com.ccaiplatform.ccaiscreenshare.ScreenShareManager

fun isScreenShareEnabled(chat: ChatResponse): Boolean {
    return CCAI.screenShareService != null
        && chat.supportScreenShare == true
}

Gerenciar solicitações de compartilhamento de tela

Implemente o processamento de solicitações de compartilhamento de tela usando ScreenShareManager. Você cria uma sessão e processa a resposta assíncrona usando 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
    )
}

Processar mudanças de estado do compartilhamento de tela

Detecte mudanças no estado da sessão de compartilhamento de tela usando o callback onSessionStateChanged fornecido para ScreenShareCallbacks:

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

Referência de ScreenShareCallbacks

Callback Descrição
onSessionStateChanged Chamado quando o estado da sessão de compartilhamento de tela muda (INACTIVE, PENDING, ACTIVE).
onSessionCreationError Chamado quando a criação da sessão falha (problema de rede, serviço indisponível etc.).
onSessionActivationRequest Chamado quando a sessão está pronta para ser ativada. Chame ScreenShareManager.activateSession() para começar a compartilhar. Para sessões iniciadas pelo usuário, mostre primeiro uma solicitação de consentimento. Para sessões iniciadas pelo agente, ative imediatamente.
onSessionRemoteControlRequest Chamado quando o agente solicita o controle remoto da tela do usuário. Mostrar uma solicitação de consentimento antes de conceder o controle remoto.
onSessionFullDeviceRequest Chamado quando o agente solicita o compartilhamento completo do dispositivo (consulte Compartilhamento de tela do dispositivo completo abaixo). Acione o fluxo de consentimento MediaProjection em resposta.
onSessionDidSucceed Chamado depois que uma sessão é ativada e começa a transmitir. Use isso para atualizar a UI do usuário para o estado "compartilhamento de tela ativo".

Para conferir as definições completas de enumeração de estado e todos os tipos associados, consulte a Referência da API do SDK Headless Mobile para Android.

Compartilhamento de tela em todo o dispositivo (avançado)

Com o compartilhamento de tela do dispositivo completo, os atendentes podem ver telas de aplicativos fora do seu, incluindo configurações do sistema e navegação entre aplicativos. Isso exige a integração da API MediaProjection do Android e a execução de um serviço em primeiro plano.

Declarar permissões e serviço em primeiro plano

Adicione as permissões necessárias e a declaração de serviço em primeiro plano ao seu 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>

Antes de capturar a tela, peça o consentimento do usuário usando 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")
    }
}

Implementar o serviço em primeiro plano

Crie um serviço em primeiro plano que mantenha a sessão 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
    }
}

Ativar o compartilhamento de dispositivos completos

Depois que o MediaProjection estiver ativo, ative o compartilhamento de todo o dispositivo pelo serviço de compartilhamento de tela:

// After MediaProjection consent is granted and foreground service is started
ScreenShareManager.enableFullDeviceSharing(true)

Considerações sobre o Jetpack Compose para compartilhamento de tela

  1. Encapsule os principais elementos interativos com visualizações do Android: para áreas que precisam ser clicáveis remotamente, encapsule-as como controles de visualização padrão do Android (por exemplo, android.widget.Button) usando o elemento combinável AndroidView. Isso garante que os cliques remotos funcionem de maneira confiável pelo caminho baseado em visualização do Android.

  2. Encaminhar toques remotos com processamento de toque personalizado: para áreas do Compose que não podem ser substituídas por Views, use callbacks de toque personalizados para encaminhar eventos de toque remoto à sua lógica do Compose.

  3. Forneça orientação ou substituto no nível do produto: indique claramente nas telas do Compose que "Os agentes só podem visualizar; o usuário precisa tocar localmente" ou restrinja automaticamente as permissões de controle remoto nessas páginas, mantendo o acesso de visualização.

Exemplo de implementação de wrapper de visualização do 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")
        }
    }
}

Chamadas programadas

Com as chamadas programadas, os usuários podem agendar uma ligação por voz para o futuro em vez de esperar na fila por uma chamada instantânea. No Android, o SDK expõe métodos de chamada de serviço para verificar a disponibilidade, buscar horários, agendar, reagendar e cancelar ligações, além de processar erros de agendamento.

Detecção de recursos

O que o SDK oferece: uma flag scheduleEnabled em VoiceCallChannel que informa se a fila selecionada aceita agendamento de chamadas futuras.

O que você vai criar: a lógica para verificar essa flag antes de mostrar o ponto de entrada de agendamento.

Exemplo de implementação

Verifique scheduleEnabled na ligação de voz da fila. Não use voiceCall != null como um proxy. Uma fila pode oferecer suporte a chamadas de voz instantâneas sem oferecer suporte a chamadas programadas e vice-versa.

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
}

Verificação de disponibilidade opcional

Antes de mostrar o seletor de horários, verifique se a fila selecionada tem horários disponíveis.

Exemplo de implementação

Use hasScheduledTimeSlots(menuId:) como uma pré-verificação leve. Se ele retornar false, mostre um estado vazio ou uma opção de substituição em vez de abrir o seletor.

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

Buscar horários

Recupera os horários disponíveis para a fila selecionada. O SDK retorna valores de data que seu app pode mostrar no fuso horário local do usuário.

O que o SDK oferece: carimbos de data/hora de slots disponíveis para a fila selecionada, com suporte opcional para reagendar uma chamada programada.

O que você cria: a agenda, o seletor de hora, a apresentação do fuso horário, o processamento de estado vazio e o comportamento de nova tentativa.

Exemplo de implementação

Chame getScheduledTimeSlots com o ID do menu da fila. Ao reagendar, transmita o ID da chamada programada para que o serviço possa retornar a disponibilidade adequada.

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

Ao reagendar, transmita o ID da chamada agendada atual:

val slots = CCAI.callService?.getScheduledTimeSlots(
    menuId = menu.id,
    callIdForRescheduling = existingCallId
) ?: emptyList()

Criar uma chamada programada

Depois que o usuário selecionar um horário, crie a chamada programada usando callService.

O que o SDK oferece: uma API createScheduledCall que agenda o horário selecionado e retorna o registro da chamada agendada.

O que você vai criar: validação de número de telefone E.164, UI de confirmação, persistência local do ID da chamada programada e recuperação para conflitos de horários ou respostas fora do horário comercial.

Exemplo de implementação

Transmita o slot selecionado, o número de telefone, o ID da fila e RecordingPermission.NOT_ASKED em um 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}")
    }
}

O número de telefone precisa estar no formato E.164, como +14155551234.

Remarcar uma chamada

Para reagendar, busque horários e crie a nova chamada programada com o ID scheduled-call atual.

O que o SDK oferece: as mesmas APIs de criação e intervalo de tempo, com callIdForRescheduling usado para identificar a reserva atual.

O que você vai criar: a interface "mudar horário", a pesquisa local do ID da chamada programada armazenada e o estado de confirmação atualizado após a criação da nova reserva.

Exemplo de implementação

Transmita o ID da chamada atual para getScheduledTimeSlots e 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}")
    }
}

Cancelar uma ligação programada

Quando o usuário cancelar uma chamada futura, chame cancelScheduledCall e limpe o ID da chamada armazenado localmente.

O que o SDK oferece: uma API de cancelamento para um ID de chamada programada.

O que você vai criar: o comando de confirmação, a UI de cancelamento bem-sucedido ou de erro e a revisão dos dados de qualquer estado de próxima chamada armazenado localmente.

Exemplo de implementação

Chame cancelScheduledCall(callId:) com o ID armazenado e remova o ID do armazenamento do app.

lifecycleScope.launch {
    try {
        CCAI.callService?.cancelScheduledCall(callId = existingCallId)
        clearScheduledCallId()
        showCancellationConfirmation()
    } catch (e: Exception) {
        Log.e("CCAI", "Failed to cancel call: ${e.message}")
    }
}

E-mail

O que o SDK fornece: o endereço de e-mail de destino configurado para essa fila específica, além de instruções que o administrador quer que o usuário veja (por exemplo, inclua o número da sua conta).

O que você vai criar: a tela do compositor de e-mail e a lógica para rastrear a conclusão dela. Você pode criar seu próprio formulário personalizado ou usar o Intent.ACTION_SENDTO integrado do Android para iniciar o cliente de e-mail preferido do usuário.

Exemplo de implementação

Esse código extrai o endereço de e-mail de suporte do objeto Channels do SDK no QueueMenu. Ele abre o cliente de e-mail Android integrado e processa o caso em que nenhum cliente de e-mail está instalado.

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

O que o SDK oferece: uma lista de URLs (como a Central de Ajuda da sua empresa, a política de reembolso ou um app parceiro) e os títulos de exibição deles, exatamente como configurados no Portal do administrador.

O que você cria: a lógica para executar esses links quando um usuário toca neles, seja abrindo em um navegador no app (usando as abas personalizadas do Android) ou iniciando o navegador padrão do sistema.

Quando o usuário toca em um link externo no seu menu, esse código extrai a string de URL fornecida pelo SDK e usa as guias personalizadas do Chrome para mostrar a página da Web em uma experiência de navegador no app. As guias personalizadas oferecem uma experiência mais rápida e integrada do que iniciar o app de navegador completo, mas ainda mostram a barra de endereço para que o usuário saiba que está visualizando conteúdo externo.

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

Implementação de substituição (navegador do sistema): se as guias personalizadas não estiverem disponíveis ou você preferir uma abordagem mais simples, abra o link no navegador padrão do usuário:

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

Exemplo de integração completa

Esse código lê o link de rejeição do objeto Channels do SDK e o apresenta ao usuário como um botão clicável no menu do canal.

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 expõe os campos de nível superior url / displayName e uma matriz links: List<ExternalDeflection>? interna. O exemplo anterior renderiza apenas a entrada de nível superior, o que é suficiente para filas de link único. Se os administradores configurarem vários links externos em uma única fila, itere deflectionLink.links e renderize um botão por entrada ativada. Cada ExternalDeflection tem seu próprio url e displayName:

deflectionLink.links
    ?.filter { it.enabled == true }
    ?.forEach { link ->
        val button = MaterialButton(this).apply {
            text = link.displayName
            setOnClickListener { openHelpCenterLink(link.url) }
        }
        helpCenterContainer.addView(button)
    }