SDK mobile headless pour Android : voix, partage d'écran, appels programmés et e-mail

Ce document explique comment intégrer et personnaliser le SDK Headless Mobile dans votre application Android. Il couvre la voix, le partage d'écran, les appels planifiés et l'e-mail.

Appel vocal

Le SDK Headless Mobile pour Android permet de passer des appels vocaux grâce aux modules CCAICall et CCAICallRed. Votre application possède l'UI d'appel personnalisée, tandis que le SDK gère la création d'appels, la gestion des appels entrants, l'intégration du fournisseur et l'état du cycle de vie des appels.

Ce que fournit le SDK : des API de service d'appel pour lancer des appels instantanés, démarrer la messagerie vocale, gérer les appels entrants et observer l'état des appels.

Ce que vous allez créer : l'expérience d'appel visuelle, y compris le point d'entrée avant l'appel, l'invite de consentement à l'enregistrement, les états de sonnerie ou d'attente, les commandes en cours d'appel, l'UI du service de premier plan et toute navigation après l'appel.

Initialiser le module d'appel

Initialisez le module d'appel après avoir initialisé le 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()
)

Inspecter les fonctionnalités de VoiceCallChannel

Avant de proposer un appel instantané, inspectez le VoiceCallChannel de la file d'attente pour décider quels points d'entrée sont exposés (appel instantané, renvoi vers la messagerie vocale, redirection au niveau du canal) et si le consentement à l'enregistrement est requis.

Champs VoiceCallChannel clés

  • instantEnabled : Boolean indique si un appel vocal en direct (instantané) est autorisé pour cette file d'attente. Traite false comme "ne pas proposer de bouton d'appel instantané". Contrairement à voiceCall != null, qui vous indique uniquement que la file d'attente dispose d'un canal vocal.

  • preSessionSmartAction : Boolean indique si des actions intelligentes avant session sont requises sur ce canal spécifique avant de commencer un appel. Consultez Actions intelligentes avant la session dans SDK mobile sans interface utilisateur pour Android : actions intelligentes, pièces jointes et évitement pour le chemin complémentaire au niveau de la file d'attente.

  • scheduleEnabled : Boolean (indique si les appels programmés sont acceptés). Consultez la section Appels programmés.

  • recordingOption : RecordingOption? : modèle de consentement pour l'enregistrement des appels. Consultez Enregistrer le consentement. Différent de RecordingPermission.NOT_ASKED, qui n'est utilisé qu'avec les appels planifiés.

  • voicemailReason : String? — renseigné lorsque le canal est configuré pour être redirigé vers la messagerie vocale.

  • phoneNumber : String? — Numéro de secours RTCP configuré pour la déviation ou la messagerie vocale.

  • scheduleDeflectionType : String? / deflected : Boolean?/ deflectedReason : String? : métadonnées de déviation au niveau du canal. Lorsque deflected == true, affichez le message deflectedReason configuré par l'administrateur et proposez la solution de repli configurée (par exemple, un numéro de téléphone à composer ou un point d'entrée d'appel planifié) au lieu de tenter un appel instantané. Consultez la section Déflection du temps d'attente dans le SDK Mobile headless pour Android : actions intelligentes, pièces jointes et déflection pour la gestion de la déflection au niveau de la file d'attente.

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

Ce que fournit le SDK : un champ recordingOption : RecordingOption? sur VoiceCallChannel qui décrit le modèle de consentement pour l'enregistrement des appels configuré par les administrateurs dans le portail.

Ce que vous allez créer : l'invite de consentement (lorsque ASK_USER) et la logique de branchement qui respecte le choix de l'administrateur.

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

Démarrer un appel instantané

Avant de démarrer un appel instantané :

  1. Inspectez le VoiceCallChannel de la file d'attente sélectionnée.

  2. Vérifiez que les appels instantanés sont activés et qu'ils ne sont pas redirigés.

  3. Gérez l'autorisation d'enregistrement en fonction des recordingOption de la file d'attente.

  4. Demandez l'autorisation d'accéder au micro.

  5. Appelez le 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)
    }
}

Observer l'état de l'appel

Collectez les événements du service d'appel avant de démarrer ou d'accepter un appel :

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

État du fournisseur et état du serveur

stateChanged et callReceived.status sont des signaux indépendants. Lisez-les comme des colonnes, et non comme des lignes. Le stateChanged du fournisseur indique le handshake VoIP local, tandis que le callReceived.status côté serveur indique le cycle de vie de l'appel sur la plate-forme. Le flip "Connecté" et le minuteur d'appel sont déclenchés par callReceived.status, et non par stateChanged lorsque l'état "Connecté" est atteint.

Signal Informations fournies Ce que vous pouvez en tirer
stateChanged (fournisseur) Échange local VoIP : Connecting, Connected (local), Disconnected, Error(...). Spinner d'état facultatif avant l'arrivée de l'état du serveur, l'affichage de l'erreur et le nettoyage de fin d'appel.
callReceived.status (serveur) Cycle de vie côté serveur : WAITING, CONNECTING, CONNECTED, ON_HOLD, INTERRUPTED, ENDED. le flip connecté, le début du minuteur d'appel, l'UI attribuée à l'agent, les calques de transfert ou d'escalade, et le flux de fin d'appel.

Appels entrants

Les appels vocaux entrants sont transmis à l'appareil à l'aide de FCM. Étant donné que les appels entrants peuvent arriver lorsque votre application est en arrière-plan ou complètement arrêtée, votre intégration doit être prête à les recevoir depuis n'importe quel état de processus.

Ce que fournit le SDK : un flux IncomingCallEvent sur callService qui émet le cycle de vie d'un appel entrant : Arrived, Accepted, Rejected. Le service d'appel expose également acceptIncomingCall() et rejectIncomingCall() pour agir sur l'offre.

Ce que vous allez créer : l'UI des appels entrants (écran de sonnerie en plein écran, présentation sur l'écran de verrouillage ou bannière dans l'application) et les gestionnaires qui acceptent ou refusent l'offre.

Exemple de mise en œuvre

Collectez incomingCallEvent et affichez ou fermez votre UI à chaque événement de cycle de vie, puis acceptez ou refusez à partir d'un champ d'application de longue durée. Définissez applicationScope une seule fois dans votre classe Application afin que les coroutines d'acceptation ou de refus survivent à toute suppression Activity de courte durée pendant l'établissement de la connexion.

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

Faire le pont entre FCM et l'UI de vos appels entrants

Les appels entrants transmis par FCM arrivent sur un thread en arrière-plan, souvent avant qu'un Activity existe. Utilisez un délai d'expiration court pour relier la charge utile FCM à la première émission IncomingCallEvent.Arrived, puis démarrez votre UI.

Exemple de mise en œuvre

Suspendez l'exécution sur le prochain IncomingCallEvent.Arrived avec withTimeoutOrNull, puis passez la main à votre navigateur.

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

Pour une expérience d'appel entrant Android intégrée (sonnerie en plein écran, affichage sur l'écran de verrouillage et intégration au journal d'appels système), enregistrez un PhoneAccount et laissez le framework Telecom gérer l'expérience utilisateur de la sonnerie.

Ce que fournit le SDK : un assistant TelecomCallService qui enregistre un PhoneAccount pour votre application (registerPhoneAccount(context)), un TelecomConnectionService pour relier les appels CCAI entrants à Android Telecom et une interface TelecomConnectionCallback pour les rappels d'état de connexion.

Ce que vous allez créer : l'enregistrement PhoneAccount unique lors de l'initialisation de l'application et une déclaration de fichier manifeste pour le service de connexion.

Exemple de mise en œuvre

Enregistrez le compte de téléphone au démarrage de l'application, puis déclarez le service de connexion et l'autorisation dans votre fichier manifeste.

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

Alternative : activité d'appel en plein écran

Si vous préférez afficher votre propre écran de sonnerie au lieu d'utiliser le framework Telecom, le SDK expose IncomingCallService avec l'interface IncomingCallUiNavigator. Implémentez l'interface, puis transmettez votre implémentation à IncomingCallService lors de l'initialisation de l'application.

Exemple de mise en œuvre

Implémentez IncomingCallUiNavigator et transmettez-le à IncomingCallService une fois pendant 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)
}

Service de premier plan pour la sonnerie active

Lorsqu'un appel sonne, Android peut fermer les processus en arrière-plan. Le SDK fournit un IncomingRingingService qui maintient le flux de sonnerie actif en tant que service de premier plan. Déclarez-le dans votre fichier manifeste. Le SDK le démarre et l'arrête automatiquement à l'arrivée d'appels.

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

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

État de l'appel et états côté serveur

Ce que fournit le SDK : une énumération CallStatus pilotée par le serveur sur chaque charge utile Call qui décrit l'état de l'appel dans son cycle de vie (par exemple, CONNECTING, WAITING, CONNECTED, ON_HOLD, INTERRUPTED, ENDED, FAILED).

Ce que vous allez créer : le mappage de CallStatus aux états visibles par le client sur l'écran d'appel.

Exemple de mise en œuvre

Branchez when sur call.status à partir de chaque émission callReceived et affichez l'état de l'UI correspondant :

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

Commandes en cours d'appel (couper le son, haut-parleur, mise en attente)

Ce que fournit le SDK : propriétés en lecture seule sur callService – isMuted, isSpeakerEnabled, isOnHold – ainsi que les actions permettant d'activer/désactiver chaque état (setMuted, setSpeakerEnabled, setOnHold).

Ce que vous allez créer : les boutons de désactivation du micro, du haut-parleur et de mise en attente dans votre UI d'appel, connectés aux actions du SDK et reflétant l'état actuel.

Exemple de mise en œuvre

Lisez l'état actuel à partir de callService pour initialiser vos boutons, puis activez et mettez à jour l'état au moment de l'appui.

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
}

Reprendre un appel en cours après une interruption

Lorsqu'une interruption du réseau téléphonique public commuté (comme un appel mobile entrant) met en pause l'appel CCAI actif, le SDK maintient la session active et émet interruptionDetected. Une fois l'interruption terminée, votre application peut reprendre l'appel.

Ce que fournit le SDK : getLastCallInProgress() pour rechercher un appel en cours (y compris lors des redémarrages de l'application) et resumeCall(call) pour le ramener au premier plan.

Ce que vous allez créer : l'affordance de reprise dans votre UI (généralement une bannière ou une reprise automatique après la fin de l'interruption) et la navigation vers l'écran d'appel.

Exemple de mise en œuvre

Recherchez l'appel en cours avec getLastCallInProgress, reprenez-le et revenez à l'écran d'appel.

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

Escalade depuis un agent virtuel

Ce que fournit le SDK : canEscalate(allowSkipVirtualAgent: Boolean) est une méthode sur CallResponse (le modèle émis par callService.callReceived), et non sur callService lui-même. Il indique si l'appel en cours peut être transféré à un agent humain en fonction de l'état actuel de l'agent virtuel de l'appel.

Ce que vous allez créer : conservez la dernière émission callReceived comme "appel en cours", puis associez la visibilité de votre bouton "Parler à une personne" à currentCall?.canEscalate(...). L'argument allowSkipVirtualAgent est une valeur de configuration de menu que vous récupérez séparément de la réponse à l'appel lui-même. Ne codez pas en dur true en production.

Exemple de mise en œuvre

Conservez le dernier Call dans une propriété, puis appelez canEscalate dessus. Résolvez allowSkipVirtualAgent à partir de la configuration de votre 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

Messagerie vocale

Ce que fournit le SDK : startVoicemail(VoicemailRequest) sur callService, ainsi qu'une énumération VoicemailReason qui décrit pourquoi la plate-forme a proposé la messagerie vocale (par exemple, en cas de redirection en dehors des heures d'ouverture, en cas de redirection en raison d'une capacité dépassée ou en cas de redirection temporaire).

Ce que vous allez créer : le point d'entrée de la messagerie vocale dans votre UI (généralement affiché lorsque VoiceCallChannel.voicemailReason est renseigné ou lorsque le cycle de vie de l'appel aboutit à un message vocal) et l'UI d'enregistrement elle-même.

Exemple de mise en œuvre

Affichez le bouton de messagerie vocale lorsque voicemailReason est défini, puis appelez startVoicemail avec 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)
    }
}

Déflection du temps d'attente

Lorsque la file d'attente est ouverte, mais que le temps d'attente dépasse le seuil configuré par l'administrateur, la plate-forme peut proposer une autre solution (généralement un rappel programmé ou une redirection vers le réseau téléphonique public commuté).

Ce que fournit le SDK : une charge utile CallDeflection décrivant l'alternative proposée, récupérable à l'aide de getCallDeflection(callId) sur callService. La plate-forme émet également la même charge utile via le flux deflectionOffered lorsqu'une redirection devient disponible en cours d'attente. Un indicateur CompanyResponse.preventDirectPstnCall distinct indique si la numérotation PSTN directe doit être masquée dans votre UI de secours.

Ce que vous allez créer : l'invite de redirection (par exemple, "Le temps d'attente est estimé à 12 minutes. Souhaitez-vous plutôt planifier un rappel ?") et la logique de branchement qui respecte le choix de l'utilisateur.

Exemple de mise en œuvre

Récupérez la déviation proposée avec l'ID de l'appel actif, combinez-la avec CompanyResponse.preventDirectPstnCall et présentez l'invite.

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

Avancée CallOptions

CallOptions expose un petit ensemble de boutons à bascule pour les applications qui doivent ajuster l'expérience d'appel par défaut.

  • connectingPollIntervalMs : Long — fréquence à laquelle le SDK interroge la plate-forme lorsque l'appel est à l'état "Connexion".

  • connectedPollIntervalMs : Long — fréquence à laquelle le SDK interroge la plate-forme lorsque l'appel est connecté.

  • endCallMaxRetries : Int — nombre de fois où le SDK tente d'envoyer la requête de fin d'appel avant d'afficher une erreur.

  • endCallRetryDelayMs : Long – délai entre les nouvelles tentatives de fin d'appel.

Exemple de mise en œuvre

Transmettez les valeurs ajustées à CallOptions lors de l'initialisation du module d'appel :

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

Partage d'écran

Le partage d'écran permet aux agents d'assistance de voir l'écran de l'utilisateur pour l'aider à résoudre les problèmes. CCAI Platform est compatible avec deux modes de partage d'écran :

  1. Partage dans l'application : l'agent ne peut voir que ce qui se passe dans votre application spécifique.

  2. Partage complet de l'appareil (facultatif) : l'agent peut voir l'intégralité de l'appareil de l'utilisateur, y compris l'écran d'accueil Android et d'autres applications. Cela nécessite une configuration Android avancée à l'aide de MediaProjection et d'un service de premier plan.

Ce que fournit le SDK : le module CCAIScreenShare qui crée et gère les sessions, gère les changements d'état et traite les demandes de consentement.

Ce que vous allez créer : la configuration de l'initialisation, les invites de l'UI pour le consentement de l'utilisateur et (si vous prenez en charge l'appareil complet) l'intégration MediaProjection Android intégrée avec un service de premier plan.

Le service de partage d'écran fournit des méthodes pour l'ensemble du cycle de vie de la session : startSession, activateSession, stopSession, ainsi que des méthodes de configuration pour le contrôle à distance et le partage de l'appareil complet. Les sous-sections suivantes présentent le flux d'intégration principal.

Pour obtenir les signatures de méthode complètes et les détails des paramètres de toutes les méthodes de partage d'écran, consultez la documentation de référence de l'API Headless Mobile SDK pour Android.

Initialiser et configurer le partage d'écran

Initialisez le service de partage d'écran lors de la configuration de l'application en fournissant votre clé de partage d'écran :

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

Vérifier l'éligibilité au partage d'écran

Avant d'activer la fonctionnalité de partage d'écran, vérifiez si elle est disponible pour la discussion en cours :

import com.ccaiplatform.ccaiscreenshare.ScreenShareManager

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

Traiter les demandes de partage d'écran

Implémentez la gestion des demandes de partage d'écran à l'aide de ScreenShareManager. Créez une session et gérez la réponse asynchrone à l'aide de 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
    )
}

Gérer les changements d'état du partage d'écran

Écoutez les changements d'état de la session de partage d'écran via le rappel onSessionStateChanged fourni à 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()
        }
    }
}

Référence ScreenShareCallbacks

Rappel Description
onSessionStateChanged Appelé lorsque l'état de la session de partage d'écran change (INACTIVE, PENDING, ACTIVE).
onSessionCreationError Appelé en cas d'échec de la création de la session (problème de réseau, service indisponible, etc.).
onSessionActivationRequest Appelé lorsque la session est prête à être activée. Appelez ScreenShareManager.activateSession() pour commencer le partage. Pour les sessions initiées par l'utilisateur, affichez d'abord une invite de consentement. Pour les sessions initiées par l'agent, activez-les immédiatement.
onSessionRemoteControlRequest Appelé lorsque l'agent demande le contrôle à distance de l'écran de l'utilisateur. Afficher une invite de consentement avant d'accorder le contrôle à distance
onSessionFullDeviceRequest Appelée lorsque l'agent demande le partage de l'intégralité de l'appareil (voir Partage d'écran de l'intégralité de l'appareil ci-dessous). Déclenchez votre flux de consentement MediaProjection en réponse.
onSessionDidSucceed Appelé après l'activation et le début du streaming d'une session. Utilisez-le pour mettre à jour votre UI et la faire passer à l'état "partage d'écran actif".

Pour obtenir la définition complète de l'énumération d'état et de tous les types associés, consultez la documentation de référence de l'API Headless Mobile SDK pour Android.

Partage d'écran complet (avancé)

Le partage d'écran complet de l'appareil permet aux agents d'afficher les écrans des applications autres que les vôtres, y compris les paramètres système et la navigation entre les applications. Cela nécessite d'intégrer l'API MediaProjection d'Android et d'exécuter un service de premier plan.

Déclarer les autorisations et le service de premier plan

Ajoutez les autorisations requises et la déclaration de service de premier plan à votre 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>

Avant de capturer l'écran, vous devez demander le consentement de l'utilisateur à l'aide de 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")
    }
}

Implémenter le service de premier plan

Créez un service de premier plan qui contient la session 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
    }
}

Activer le partage complet de l'appareil

Une fois MediaProjection activé, activez le partage de l'intégralité de l'appareil via le service de partage d'écran :

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

Considérations concernant Jetpack Compose pour le partage d'écran

  1. Encapsulez les principaux éléments interactifs avec des vues Android : pour les zones qui doivent être cliquables à distance, encapsulez-les en tant que commandes de vue Android standards (par exemple, android.widget.Button) à l'aide du composable AndroidView. Cela garantit que les clics à distance fonctionnent de manière fiable via le chemin basé sur les vues Android.

  2. Transférer les événements tactiles à distance avec une gestion tactile personnalisée : pour les zones Compose qui ne peuvent pas être remplacées par des vues, utilisez des rappels tactiles personnalisés pour transférer les événements tactiles à distance vers votre logique Compose.

  3. Fournir des conseils ou des solutions de secours au niveau du produit : indiquez clairement sur les écrans Compose que "les agents ne peuvent que consulter les informations ; l'utilisateur doit appuyer localement", ou limitez automatiquement les autorisations de contrôle à distance sur ces pages tout en conservant l'accès en lecture seule.

Exemple d'implémentation d'un wrapper de vue 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")
        }
    }
}

Appels programmés

Les appels planifiés permettent aux utilisateurs de réserver un appel vocal à une date ultérieure au lieu d'attendre dans la file d'attente pour un appel instantané. Sur Android, le SDK expose des méthodes d'appel de service pour vérifier la disponibilité, récupérer des créneaux horaires, réserver des appels, les reprogrammer, les annuler et gérer les erreurs de planification.

Détection des fonctionnalités

Ce que fournit le SDK : un indicateur scheduleEnabled sur VoiceCallChannel qui vous indique si la file d'attente sélectionnée permet de planifier des appels.

Ce que vous allez créer : la logique permettant de vérifier ce flag avant d'afficher votre point d'entrée de planification.

Exemple de mise en œuvre

Consultez scheduleEnabled sur le canal d'appel vocal de la file d'attente. N'utilisez pas voiceCall != null comme proxy. Une file d'attente peut prendre en charge les appels vocaux instantanés sans prendre en charge les appels planifiés, et inversement.

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
}

Vérification de la disponibilité facultative

Avant d'afficher le sélecteur de créneaux horaires, vous pouvez vérifier si la file d'attente sélectionnée dispose de créneaux horaires disponibles.

Exemple de mise en œuvre

Utilisez hasScheduledTimeSlots(menuId:) comme prévérification légère. Si elle renvoie false, affichez un état vide ou une option de remplacement au lieu d'ouvrir le sélecteur.

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

Récupérer les créneaux horaires

Récupérez les créneaux disponibles pour la file d'attente sélectionnée. Le SDK renvoie des valeurs de date que votre application peut afficher dans le fuseau horaire local de l'utilisateur.

Ce que fournit le SDK : les codes temporels des créneaux disponibles pour la file d'attente sélectionnée, avec une option permettant de reprogrammer un appel déjà planifié.

Ce que vous allez créer : le calendrier, le sélecteur d'heure, la présentation du fuseau horaire, la gestion de l'état vide et le comportement de nouvelle tentative.

Exemple de mise en œuvre

Appelez getScheduledTimeSlots avec l'ID du menu de file d'attente. Lorsque vous reprogrammez un appel, transmettez l'ID de l'appel planifié existant afin que le service puisse renvoyer les disponibilités appropriées.

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

Lorsque vous reprogrammez un appel, transmettez l'ID de l'appel planifié existant :

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

Planifier un appel

Une fois que l'utilisateur a sélectionné un créneau horaire, créez l'appel programmé via callService.

Ce que fournit le SDK : une API createScheduledCall qui réserve le créneau sélectionné et renvoie l'enregistrement de l'appel programmé.

Ce que vous allez créer : validation du numéro de téléphone au format E.164, UI de confirmation, persistance locale de l'ID de l'appel programmé et récupération en cas de conflits de créneaux ou de réponses en dehors des heures d'ouverture.

Exemple de mise en œuvre

Transmettez le créneau sélectionné, le numéro de téléphone, l'ID de file d'attente et RecordingPermission.NOT_ASKED dans 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}")
    }
}

Le numéro de téléphone doit être au format E.164, par exemple +14155551234.

Reprogrammer un appel existant

Pour reprogrammer un appel, récupérez les créneaux horaires et créez le nouvel appel programmé avec l'ID d'appel programmé existant.

Ce que fournit le SDK : les mêmes API de création et de créneaux horaires, avec callIdForRescheduling utilisé pour identifier la réservation existante.

Ce que vous allez créer : l'UI "change time" (modifier l'heure), la recherche locale de l'ID de l'appel programmé stocké et l'état de confirmation mis à jour après la création de la nouvelle réservation.

Exemple de mise en œuvre

Transmettez l'ID d'appel existant à getScheduledTimeSlots et 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}")
    }
}

Annuler un appel programmé

Lorsque l'utilisateur annule un appel à venir, appelez cancelScheduledCall et effacez l'ID d'appel stocké localement.

Ce que fournit le SDK : une API d'annulation pour un ID d'appel programmé.

Ce que vous allez créer : l'invite de confirmation, l'UI de réussite ou d'erreur d'annulation, et le nettoyage de tout état d'appel à venir stocké localement.

Exemple de mise en œuvre

Appelez cancelScheduledCall(callId:) avec l'ID stocké, puis supprimez l'ID du stockage de votre application.

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

E-mail

Ce que fournit le SDK : l'adresse e-mail de destination configurée pour cette file d'attente spécifique, ainsi que toutes les instructions que l'administrateur souhaite que l'utilisateur voie (par exemple, indiquez votre numéro de compte).

Ce que vous allez créer : l'écran de rédaction d'e-mails et la logique permettant de suivre son achèvement. Vous pouvez créer votre propre formulaire personnalisé ou utiliser Intent.ACTION_SENDTO intégré à Android pour lancer le client de messagerie préféré de l'utilisateur.

Exemple de mise en œuvre

Ce code extrait l'adresse e-mail d'assistance de l'objet Channels du SDK sur QueueMenu. Il ouvre le client de messagerie Android intégré et gère le cas où aucun client de messagerie n'est installé.

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

Ce que fournit le SDK : une liste d'URL (par exemple, le centre d'aide de votre entreprise, les conditions de remboursement ou une application partenaire) et leurs titres à afficher, exactement tels qu'ils sont configurés dans le portail d'administration.

Ce que vous allez créer : la logique permettant d'exécuter ces liens lorsqu'un utilisateur appuie dessus, soit en les ouvrant dans un navigateur intégré dans l'application (à l'aide des onglets personnalisés d'Android), soit en lançant le navigateur système par défaut.

Lorsque l'utilisateur appuie sur un lien externe de votre menu, ce code extrait la chaîne d'URL fournie par le SDK et utilise les onglets personnalisés Chrome pour afficher la page Web dans un navigateur intégré à l'application. Les onglets personnalisés offrent une expérience plus rapide et plus fluide que le lancement de l'application de navigateur complète, tout en affichant la barre d'adresse pour que l'utilisateur sache qu'il consulte du contenu externe.

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

Implémentation de secours (navigateur système) : si les onglets personnalisés ne sont pas disponibles ou si vous préférez une approche plus simple, vous pouvez ouvrir le lien dans le navigateur par défaut de l'utilisateur :

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

Exemple d'intégration complète

Ce code lit le lien de redirection à partir de l'objet Channels du SDK et le présente à l'utilisateur sous la forme d'un bouton cliquable dans le menu de votre chaîne.

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 expose à la fois les champs url / displayName de premier niveau et un tableau links: List<ExternalDeflection>? interne. L'exemple précédent n'affiche que l'entrée de premier niveau, ce qui est suffisant pour les files d'attente à un seul lien. Si vos administrateurs configurent plusieurs liens externes dans une même file d'attente, itérez deflectionLink.links et affichez un bouton par entrée activée. Chaque ExternalDeflection comporte ses propres url et displayName :

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