SDK para dispositivos móviles sin interfaz gráfica para Android: voz, uso compartido de pantalla, llamadas programadas y correo electrónico

En este documento, se explica cómo integrar y personalizar el SDK para dispositivos móviles sin encabezado en tu aplicación para Android. Abarca la voz, el uso compartido de pantalla, las llamadas programadas y el correo electrónico.

Llamada de voz

El SDK de Headless Mobile para Android proporciona compatibilidad con llamadas de voz a través de los módulos CCAICall y CCAICallRed. Tu app es propietaria de la IU de llamadas personalizada, mientras que el SDK controla la creación de llamadas, el control de llamadas entrantes, la integración del proveedor y el estado del ciclo de vida de las llamadas.

Qué proporciona el SDK: APIs de servicio de llamadas para iniciar llamadas instantáneas, iniciar mensajes de voz, controlar llamadas entrantes y observar el estado de las llamadas.

Lo que compilas: La experiencia visual de la llamada, incluido el punto de entrada previo a la llamada, el mensaje de consentimiento para la grabación, los estados de llamada o espera, los controles durante la llamada, la IU del servicio en primer plano y cualquier navegación posterior a la llamada.

Inicializa el módulo de llamadas

Inicializa el módulo de llamadas después de inicializar el 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()
)

Inspecciona las capacidades de VoiceCallChannel

Antes de ofrecer una llamada instantánea, inspecciona el objeto VoiceCallChannel de la fila para decidir qué puntos de entrada se exponen (llamada instantánea, fallback de correo de voz, desvío a nivel del canal) y si se requiere consentimiento para la grabación.

Campos clave de VoiceCallChannel

  • instantEnabled: Boolean: Indica si se permite una llamada de voz en vivo (instantánea) para esta fila. Trata false como "no ofrecer un botón de llamada instantánea". Es diferente de voiceCall != null, que solo te indica que la fila tiene un canal de voz.

  • preSessionSmartAction: Boolean: Indica si se requieren acciones inteligentes previas a la sesión en este canal específico antes de iniciar una llamada. Consulta Acciones inteligentes previas a la sesión en SDK de Mobile sin interfaz gráfica para Android: Acciones inteligentes, archivos adjuntos y desvío para conocer la ruta complementaria a nivel de la fila.

  • scheduleEnabled: Boolean: Indica si se admiten las llamadas programadas. Consulta la sección Llamadas programadas.

  • recordingOption: RecordingOption?: Es el modelo de consentimiento para la grabación de llamadas. Consulta Consentimiento para grabar. Es diferente de RecordingPermission.NOT_ASKED, que solo se usa con llamadas programadas.

  • voicemailReason: String? (se completa cuando el canal está configurado para enrutar al buzón de voz)

  • phoneNumber: String?: Es el número de respaldo de PSTN configurado para desvío o buzón de voz.

  • scheduleDeflectionType: String? / deflected: Boolean? / deflectedReason: String?: Son los metadatos de desvío a nivel del canal. Cuando es deflected == true, muestra el mensaje deflectedReason configurado por el administrador y ofrece la alternativa configurada (por ejemplo, un número de teléfono para marcar o un punto de entrada de llamada programada) en lugar de intentar una llamada instantánea. Consulta la sección Desviación del tiempo de espera en el SDK de Headless Mobile para Android: Acciones inteligentes, adjuntos y desviación para el control de la desviación a nivel de la 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)
    }
}

Qué proporciona el SDK: Un campo recordingOption: RecordingOption? en VoiceCallChannel que describe los administradores del modelo de consentimiento de grabación de llamadas configurados en el portal.

Lo que compilarás: El mensaje de consentimiento (cuando ASK_USER) y la lógica de ramificación que respeta la elección del 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)
        }
    }
}

Cómo iniciar una llamada instantánea

Antes de iniciar una llamada instantánea, haz lo siguiente:

  1. Inspecciona el VoiceCallChannel de la fila seleccionada.

  2. Verifica que las llamadas instantáneas estén habilitadas y no se desvíen.

  3. Maneja el consentimiento de grabación según el recordingOption de la fila.

  4. Solicita permiso de acceso al micrófono.

  5. Llama a 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)
    }
}

Observa el estado de la llamada

Recopila eventos de servicio de llamadas antes de iniciar o aceptar una llamada:

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 del proveedor en comparación con el estado del servidor

stateChanged y callReceived.status son indicadores independientes. Léelos como columnas, no como filas. El stateChanged del proveedor informa el handshake de VoIP local; el callReceived.status del servidor informa el ciclo de vida de la llamada en la plataforma. El cambio a "Conectado" y el inicio del temporizador de llamadas se activan desde callReceived.status, no desde stateChanged cuando alcanza el estado de conexión.

Indicador Qué se informa Qué puedes obtener de ella
stateChanged (proveedor) Negociación de VoIP local: Connecting, Connected (local), Disconnected, Error(...). Spinner previo al estado opcional antes de que llegue el estado del servidor, se muestren los errores y se realice la limpieza al final de la llamada.
callReceived.status (servidor) Ciclo de vida del servidor: WAITING, CONNECTING, CONNECTED, ON_HOLD, INTERRUPTED, ENDED. Volteo conectado, inicio del temporizador de llamadas, IU asignada por el agente, superposiciones de transferencia o derivación, y flujo de finalización de llamadas

Llamadas entrantes

Las llamadas de voz entrantes se entregan al dispositivo a través de FCM. Dado que las llamadas entrantes pueden llegar mientras tu app se ejecuta en segundo plano o se cierra por completo, tu integración debe estar lista para recibirlas desde cualquier estado del proceso.

Qué proporciona el SDK: Un flujo de IncomingCallEvent en callService que emite el ciclo de vida de una llamada entrante: Arrived, Accepted, Rejected. El servicio de llamadas también expone acceptIncomingCall() y rejectIncomingCall() para actuar en la oferta.

Lo que compilas: La IU de llamadas entrantes (pantalla de llamada en pantalla completa, presentación en la pantalla de bloqueo o banner integrado en la app) y los controladores que aceptan o rechazan la oferta.

Ejemplo de implementación

Recopila incomingCallEvent y renderiza o descarta tu IU en cada evento del ciclo de vida, y, luego, acepta o rechaza desde un alcance de larga duración. Define applicationScope una vez en tu clase Application para que las corrutinas de aceptación o rechazo sobrevivan a cualquier cierre de Activity de corta duración durante el 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()
    }
}

Cómo conectar FCM a la IU de llamadas entrantes

Las llamadas entrantes que se entregan a través de FCM llegan en un subproceso en segundo plano, a menudo antes de que exista cualquier Activity. Usa un tiempo de espera corto para conectar la carga útil de FCM con la primera emisión de IncomingCallEvent.Arrived y, luego, inicia tu IU.

Ejemplo de implementación

Suspende en el próximo IncomingCallEvent.Arrived con withTimeoutOrNull y, luego, entrégale el dispositivo a tu 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 una experiencia de llamada entrante integrada en Android (timbre en pantalla completa, presentación en pantalla de bloqueo e integración con el registro de llamadas del sistema), registra un PhoneAccount y permite que el framework de Telecom controle la UX de timbre.

Qué proporciona el SDK: Un asistente TelecomCallService que registra un PhoneAccount para tu app (registerPhoneAccount(context)), un TelecomConnectionService para conectar las llamadas entrantes de CCAI a Android Telecom y una interfaz TelecomConnectionCallback para las devoluciones de llamada del estado de conexión.

Qué compilarás: El registro único de PhoneAccount durante la inicialización de la app y una declaración del manifiesto para el servicio de conexión.

Ejemplo de implementación

Registra la cuenta del teléfono cuando se inicie la app y, luego, declara el servicio de conexión y el permiso en el manifiesto.

// 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: Actividad de llamada en pantalla completa

Si prefieres renderizar tu propia pantalla de llamada en lugar de usar el framework de Telecom, el SDK expone IncomingCallService junto con la interfaz IncomingCallUiNavigator. Implementa la interfaz y, luego, entrega tu implementación a IncomingCallService durante la inicialización de la app.

Ejemplo de implementación

Implementa IncomingCallUiNavigator y pásalo a IncomingCallService una 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)
}

Servicio en primer plano para el tono activo

Mientras suena una llamada, es posible que Android finalice los procesos en segundo plano. El SDK incluye un IncomingRingingService que mantiene activo el flujo de llamadas como un servicio en primer plano. Decláralo en tu manifiesto, y el SDK lo iniciará y detendrá automáticamente cuando lleguen llamadas entrantes.

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

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

Estado de la llamada y estados del servidor

Qué proporciona el SDK: Un enum CallStatus controlado por el servidor en cada carga útil de Call que describe en qué punto de su ciclo de vida se encuentra la llamada (por ejemplo, CONNECTING, WAITING, CONNECTED, ON_HOLD, INTERRUPTED, ENDED, FAILED).

Lo que compilas: La asignación de CallStatus a los estados visibles para el cliente en la pantalla de llamada.

Ejemplo de implementación

Bifurca when en call.status desde cada emisión de callReceived y renderiza el estado de la IU coincidente:

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 la llamada (silenciar, bocina, espera)

Qué proporciona el SDK: Propiedades de solo lectura en callService (isMuted, isSpeakerEnabled, isOnHold) junto con las acciones para activar o desactivar cada estado (setMuted, setSpeakerEnabled, setOnHold).

Qué compilarás: Los botones de silencio, altavoz y espera en la IU de la llamada, conectados a las acciones del SDK y que reflejan el estado actual.

Ejemplo de implementación

Lee el estado actual de callService para inicializar los botones y, luego, alterna y actualiza cuando se presione.

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
}

Cómo reanudar una llamada en curso después de una interrupción

Cuando una interrupción de la RTPC (como una llamada celular entrante) pausa la llamada activa de la CCAI, el SDK mantiene activa la sesión y emite interruptionDetected. Una vez que finalice la interrupción, tu app podrá reanudar la llamada.

Qué proporciona el SDK: getLastCallInProgress() para buscar una llamada en curso (incluso después de reiniciar la app) y resumeCall(call) para volver a ponerla en primer plano.

Lo que compilas: La función de reanudación en tu IU (por lo general, un banner o una reanudación automática después de que finaliza la interrupción) y la navegación de regreso a la pantalla de llamadas.

Ejemplo de implementación

Busca la llamada en curso con getLastCallInProgress, reanúdala y vuelve a la pantalla de llamadas.

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

Derivación desde un agente virtual

Qué proporciona el SDK: canEscalate(allowSkipVirtualAgent: Boolean) es un método en CallResponse (el modelo que emite callService.callReceived), no en callService en sí. Informa si la llamada actual es apta para derivarse a un agente humano según el estado actual del agente virtual de la llamada.

Qué compilarás: Conserva la emisión de callReceived más reciente como tu "llamada actual" y, luego, vincula la visibilidad de tu botón "Hablar con una persona" a currentCall?.canEscalate(...). El argumento allowSkipVirtualAgent es un valor de configuración del menú que recuperas por separado de la respuesta de la llamada. No codifiques true de forma rígida en la producción.

Ejemplo de implementación

Conserva el Call más reciente en una propiedad y, luego, invoca canEscalate en él. Resuelve allowSkipVirtualAgent desde la configuración del menú.

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

Mensaje de voz

Qué proporciona el SDK: startVoicemail(VoicemailRequest) en callService, además de un enum VoicemailReason que describe por qué la plataforma ofreció el correo de voz (por ejemplo, redireccionamiento de poshorario, redireccionamiento por exceso de capacidad o redireccionamiento temporal).

Lo que compilas: El punto de entrada del buzón de voz en tu IU (por lo general, se muestra cuando se completa VoiceCallChannel.voicemailReason o cuando el ciclo de vida de la llamada se resuelve en un resultado de buzón de voz) y la IU de grabación en sí.

Ejemplo de implementación

Muestra el botón de correo de voz cuando se establece voicemailReason y, luego, llama a startVoicemail con un 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)
    }
}

Desviación del tiempo de espera

Cuando la fila está abierta, pero el tiempo de espera supera el umbral configurado por el administrador, la plataforma puede ofrecer una ruta alternativa, que suele ser una devolución de llamada programada o un desvío a la PSTN.

Qué proporciona el SDK: Una carga útil de CallDeflection que describe la alternativa ofrecida, que se puede recuperar con getCallDeflection(callId) en callService. La plataforma también emite la misma carga útil a través del flujo deflectionOffered cuando una desviación está disponible a mitad de la espera. Una marca CompanyResponse.preventDirectPstnCall separada indica si se debe ocultar la marcación directa de la PSTN en la IU de resguardo.

Lo que compilarás: El mensaje de desvío (por ejemplo, "La espera estimada es de 12 minutos. ¿Prefieres programar una devolución de llamada?") y la lógica de ramificación que respeta la elección del usuario.

Ejemplo de implementación

Recupera la desviación ofrecida con el ID de la llamada activa, combínala con CompanyResponse.preventDirectPstnCall y presenta el mensaje.

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

Nivel avanzado CallOptions

CallOptions expone un pequeño conjunto de activadores para las apps que necesitan ajustar la experiencia de llamadas predeterminada.

  • connectingPollIntervalMs: Long: Indica con qué frecuencia el SDK sondea la plataforma mientras la llamada está en estado de conexión.

  • connectedPollIntervalMs: Long: Con qué frecuencia el SDK sondea la plataforma mientras la llamada está conectada.

  • endCallMaxRetries: Int: Indica cuántas veces el SDK vuelve a intentar la solicitud de finalización de la llamada antes de mostrar un error.

  • endCallRetryDelayMs: Long: Es la demora entre los reintentos de finalización de la llamada.

Ejemplo de implementación

Pasa los valores ajustados a CallOptions cuando inicialices el módulo de llamada:

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

Compartir pantalla

La función de compartir pantalla permite que los agentes de asistencia vean la pantalla del usuario para ayudar a solucionar problemas. CCAI Platform admite dos modos de uso compartido de pantalla:

  1. Uso compartido en la app: El agente solo puede ver lo que sucede dentro de tu app específica.

  2. Compartir todo el dispositivo (opcional): El agente puede ver todo el dispositivo del usuario, incluida la pantalla principal de Android y otras apps. Esto requiere una configuración avanzada de Android con MediaProjection y un servicio en primer plano.

Qué proporciona el SDK: El módulo CCAIScreenShare que crea y administra sesiones, controla los cambios de estado y procesa las solicitudes de consentimiento.

Lo que compilarás: La configuración de inicialización, los mensajes de la IU para el consentimiento del usuario y (si se admite el dispositivo completo) la integración de MediaProjection de Android integrada con un servicio en primer plano.

El servicio de uso compartido de pantalla proporciona métodos para el ciclo de vida completo de la sesión (startSession, activateSession, stopSession), además de métodos de configuración para el control remoto y el uso compartido de todo el dispositivo. En las siguientes subsecciones, se explica el flujo de integración principal.

Para obtener firmas de métodos completas y detalles de parámetros para todos los métodos de uso compartido de pantalla, consulta la referencia de la API del SDK para dispositivos móviles sin interfaz gráfica de usuario de Android.

Inicializa y configura el uso compartido de pantalla

Inicializa el servicio de uso compartido de pantalla durante la configuración de la app proporcionando tu clave de uso compartido de pantalla:

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

Cómo verificar si se puede compartir la pantalla

Antes de habilitar la función de compartir pantalla, verifica si está disponible para el chat actual:

import com.ccaiplatform.ccaiscreenshare.ScreenShareManager

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

Cómo controlar las solicitudes para compartir pantalla

Implementa el control de solicitudes de uso compartido de pantalla con ScreenShareManager. Creas una sesión y controlas la respuesta asíncrona con 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
    )
}

Cómo controlar los cambios de estado de la función para compartir pantalla

Detecta los cambios de estado de la sesión de uso compartido de pantalla a través de la devolución de llamada onSessionStateChanged que se proporciona a 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()
        }
    }
}

Referencia de ScreenShareCallbacks

Devolución de llamada Descripción
onSessionStateChanged Se llama cuando cambia el estado de la sesión de uso compartido de pantalla (INACTIVE, PENDING, ACTIVE).
onSessionCreationError Se llama cuando falla la creación de la sesión (problema de red, servicio no disponible, etc.).
onSessionActivationRequest Se llama a este método cuando la sesión está lista para activarse. Llama a ScreenShareManager.activateSession() para comenzar a compartir. En el caso de las sesiones iniciadas por el usuario, primero muestra un mensaje de consentimiento; en el caso de las sesiones iniciadas por el agente, actívalas de inmediato.
onSessionRemoteControlRequest Se llama a este método cuando el agente solicita el control remoto de la pantalla del usuario. Mostrar un mensaje de consentimiento antes de otorgar el control remoto
onSessionFullDeviceRequest Se llama cuando el agente solicita compartir todo el dispositivo (consulta Compartir la pantalla de todo el dispositivo a continuación). Activa tu flujo de consentimiento de MediaProjection en la respuesta.
onSessionDidSucceed Se llama después de que una sesión se activa correctamente y comienza la transmisión. Úsalo para actualizar tu IU al estado "compartir pantalla activo".

Para ver las definiciones completas de la enumeración de estados y todos los tipos asociados, consulta la Referencia de la API de Headless Mobile SDK para Android.

Compartir la pantalla completa del dispositivo (avanzado)

La función de compartir pantalla de todo el dispositivo permite que los agentes vean pantallas de aplicaciones externas a la tuya, incluida la configuración del sistema y la navegación entre aplicaciones. Esto requiere integrar la API de MediaProjection de Android y ejecutar un servicio en primer plano.

Declara permisos y servicios en primer plano

Agrega los permisos obligatorios y la declaración de servicio en primer plano a tu 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 la pantalla, debes solicitar el consentimiento del usuario con 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")
    }
}

Implementa el servicio en primer plano

Crea un servicio en primer plano que contenga la sesión de 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
    }
}

Cómo habilitar el uso compartido de todo el dispositivo

Después de que MediaProjection esté activo, habilita el uso compartido de todo el dispositivo a través del servicio de uso compartido de pantalla:

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

Consideraciones de Jetpack Compose para el uso compartido de pantalla

  1. Encapsula los elementos interactivos clave con vistas de Android: Para las áreas que deben ser clickeables de forma remota, encapsúlalas como controles de vistas de Android estándar (por ejemplo, android.widget.Button) con el elemento AndroidView componible. Esto garantiza que los clics remotos funcionen de manera confiable a través de la ruta basada en vistas de Android.

  2. Reenvía toques remotos con un control táctil personalizado: Para las áreas de Compose que no se pueden reemplazar con Views, usa devoluciones de llamada táctiles personalizadas para reenviar eventos de toque remotos a tu lógica de Compose.

  3. Proporcionar orientación o alternativas a nivel del producto: Indicar claramente en las pantallas de Compose que "los agentes solo pueden ver; el usuario debe presionar de forma local", o restringir automáticamente los permisos de control remoto en estas páginas y mantener el acceso de solo lectura.

Ejemplo de implementación del wrapper de vistas de 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")
        }
    }
}

Llamadas programadas

Las llamadas programadas permiten a los usuarios reservar una llamada de voz para el futuro en lugar de esperar en la fila para una llamada instantánea. En Android, el SDK expone métodos de servicio de llamadas para verificar la disponibilidad, recuperar intervalos de tiempo, reservar llamadas, reprogramar llamadas, cancelar llamadas y controlar errores de programación.

Detección de capacidades

Qué proporciona el SDK: Una marca scheduleEnabled en VoiceCallChannel que indica si la cola seleccionada admite la reserva de llamadas futuras.

Lo que compilarás: La lógica para verificar esta marca antes de mostrar tu punto de entrada de programación.

Ejemplo de implementación

Verifica scheduleEnabled en el canal de llamadas de voz en cola. No uses voiceCall != null como proxy. Una cola puede admitir llamadas de voz instantáneas sin admitir llamadas programadas y viceversa.

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
}

Verificación de disponibilidad opcional

Antes de mostrar el selector de horarios, puedes verificar si la fila seleccionada tiene horarios disponibles.

Ejemplo de implementación

Usa hasScheduledTimeSlots(menuId:) como una verificación previa ligera. Si devuelve false, muestra un estado vacío o una opción de resguardo en lugar de abrir el selector.

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

Recupera los horarios

Recupera los horarios disponibles para la fila seleccionada. El SDK devuelve valores de fecha que tu app puede mostrar en la zona horaria local del usuario.

Qué proporciona el SDK: Marcas de tiempo de los intervalos disponibles para la cola seleccionada, con compatibilidad opcional para reprogramar una llamada programada existente.

Lo que compilarás: El calendario, el selector de tiempo, la presentación de la zona horaria, el control del estado vacío y el comportamiento de reintento.

Ejemplo de implementación

Llama a getScheduledTimeSlots con el ID del menú de cola. Cuando reprogrames la llamada, pasa el ID de llamada programada existente para que el servicio pueda devolver la disponibilidad adecuada.

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

Cuando reprogrames la llamada, pasa el ID de la llamada programada existente:

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

Cómo crear una llamada programada

Después de que el usuario selecciona un horario, crea la llamada programada a través de callService.

Qué proporciona el SDK: Una API de createScheduledCall que reserva el horario seleccionado y devuelve el registro de la llamada programada.

Lo que compilarás: Validación de números de teléfono E.164, IU de confirmación, persistencia local del ID de la llamada programada y recuperación para conflictos de ranuras o respuestas fuera del horario de atención

Ejemplo de implementación

Pasa el horario seleccionado, el número de teléfono, el ID de la fila y RecordingPermission.NOT_ASKED en un 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}")
    }
}

El número de teléfono debe estar en formato E.164, como +14155551234.

Cómo reprogramar una llamada existente

Para reprogramar la llamada, recupera los intervalos y crea la nueva llamada programada con el ID de llamada programada existente.

Qué proporciona el SDK: Las mismas APIs de creación y de intervalos, con callIdForRescheduling para identificar la reserva existente.

Lo que compilas: La IU de "cambio de hora", la búsqueda local del ID de llamada programada almacenado y el estado de confirmación actualizado después de que se crea la nueva reserva.

Ejemplo de implementación

Pasa el ID de llamada existente a getScheduledTimeSlots y 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}")
    }
}

Cómo cancelar una llamada programada

Cuando el usuario cancele una llamada próxima, llama a cancelScheduledCall y borra el ID de llamada almacenado de forma local.

Qué proporciona el SDK: Una API de cancelación para un ID de llamada programada.

Qué compilarás: El mensaje de confirmación, la IU de éxito o error de cancelación y la limpieza de cualquier estado de llamada próxima almacenado de forma local.

Ejemplo de implementación

Llama a cancelScheduledCall(callId:) con el ID almacenado y, luego, quita el ID del almacenamiento de tu app.

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

Correo electrónico

Qué proporciona el SDK: La dirección de correo electrónico de destino configurada para esa cola específica, junto con las instrucciones que el administrador desea que vea el usuario (por ejemplo, incluye tu número de cuenta).

Qué compilarás: La pantalla del redactor de correos electrónicos y la lógica para hacer un seguimiento de su finalización Puedes compilar tu propio formulario personalizado o usar el Intent.ACTION_SENDTO integrado de Android para iniciar el cliente de correo electrónico preferido del usuario.

Ejemplo de implementación

Este código extrae la dirección de correo electrónico de asistencia del objeto Channels del SDK en el objeto QueueMenu. Abre el cliente de correo electrónico integrado de Android y controla el caso en el que no hay ningún cliente de correo electrónico 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")
    }
}

Qué proporciona el SDK: Una lista de URLs (como el Centro de ayuda de tu empresa, la política de reembolsos o una app de socio) y sus títulos visibles, exactamente como se configuraron en el Portal de administradores.

Lo que compilas: La lógica para ejecutar esos vínculos cuando un usuario los presiona, ya sea abriéndolos en un navegador integrado en la app (con las pestañas personalizadas de Android) o iniciando el navegador predeterminado del sistema.

Cuando el usuario presiona un vínculo externo desde tu menú, este código extrae la cadena de URL proporcionada por el SDK y usa las pestañas personalizadas de Chrome para mostrar la página web en una experiencia de navegador integrada en la app. Las pestañas personalizadas proporcionan una experiencia más rápida y fluida que iniciar la app del navegador completa, y, al mismo tiempo, muestran la barra de direcciones para que el usuario sepa que está viendo contenido 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)
}

Implementación de resguardo (navegador del sistema): Si las pestañas personalizadas no están disponibles o prefieres un enfoque más simple, puedes abrir el vínculo en el navegador predeterminado del usuario:

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

Ejemplo de integración completa

Este código lee el vínculo de desvío del objeto Channels del SDK y se lo presenta al usuario como un botón en el que se puede presionar en el menú de tu 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 expone tanto los campos url / displayName de nivel superior como un array links: List<ExternalDeflection>? interno. En el ejemplo anterior, solo se renderiza la entrada de nivel superior, lo que es suficiente para las colas de un solo vínculo. Si tus administradores configuran varios vínculos externos en una sola fila, itera deflectionLink.links y renderiza un botón por entrada habilitada. Cada ExternalDeflection incluye sus propios url y displayName:

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