SDK mobile headless per Android: voce, condivisione dello schermo, chiamate pianificate ed email

Questo documento spiega come integrare e personalizzare l'SDK Headless Mobile nella tua app per Android. Include la voce, la condivisione dello schermo, le chiamate programmate e l'email.

Chiamata vocale

L'SDK Headless Mobile per Android fornisce il supporto per le chiamate vocali tramite i moduli CCAICall e CCAICallRed. La tua app è proprietaria della UI di chiamata personalizzata, mentre l'SDK gestisce la creazione di chiamate, la gestione delle chiamate in arrivo, l'integrazione del provider e lo stato del ciclo di vita delle chiamate.

Cosa fornisce l'SDK: API di servizi di chiamata per avviare chiamate istantanee, avviare la segreteria, gestire le chiamate in arrivo e osservare lo stato delle chiamate.

Cosa crei: l'esperienza di chiamata visiva, inclusi il punto di ingresso pre-chiamata, la richiesta di consenso alla registrazione, gli stati di chiamata o attesa, i controlli durante la chiamata, l'interfaccia utente del servizio in primo piano e qualsiasi navigazione post-chiamata.

Inizializza il modulo di chiamata

Inizializza il modulo di chiamata dopo aver inizializzato l'SDK principale:

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

Ispeziona le funzionalità di VoiceCallChannel

Prima di offrire una chiamata immediata, esamina VoiceCallChannel della coda per decidere quali punti di ingresso sono esposti (chiamata immediata, fallback della segreteria, deviazione a livello di canale) e se è necessario il consenso alla registrazione.

Campi chiave VoiceCallChannel

  • instantEnabled: Boolean: indica se per questa coda è consentita una chiamata vocale live (istantanea). Considera false come "non offrire un pulsante di chiamata istantanea". A differenza di voiceCall != null, che ti indica solo se la coda ha un canale vocale.

  • preSessionSmartAction: Boolean: indica se sono richieste azioni intelligenti pre-sessione su questo canale specifico prima di iniziare una chiamata. Consulta Azioni intelligenti pre-sessione in SDK Headless Mobile per Android: azioni intelligenti, allegati e deflezione per il percorso complementare a livello di coda.

  • scheduleEnabled: Boolean: indica se le chiamate pianificate sono supportate. Consulta la sezione Chiamate programmate.

  • recordingOption: RecordingOption?, il modello di consenso alla registrazione delle chiamate. Vedi Registrare il consenso. A differenza di RecordingPermission.NOT_ASKED, che viene utilizzato solo con le chiamate pianificate.

  • voicemailReason: String?. Questo campo viene compilato quando il canale è configurato per il routing alla segreteria.

  • phoneNumber: String?: numero di fallback PSTN configurato per il trasferimento o la segreteria.

  • scheduleDeflectionType: String? / deflected: Boolean? / deflectedReason: String?: metadati di deviazione a livello di canale. Quando deflected == true, mostra il messaggio deflectedReason configurato dall'amministratore e offri il fallback configurato (ad esempio, un numero di telefono da chiamare o un punto di accesso per le chiamate pianificate) anziché tentare una chiamata istantanea. Consulta la sezione Riduzione del tempo di attesa in Headless Mobile SDK per Android: Azioni intelligenti, allegati e riduzione per la gestione della riduzione a livello di coda.

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

Cosa fornisce l'SDK: un campo recordingOption: RecordingOption? su VoiceCallChannel che descrive il modello di consenso alla registrazione delle chiamate configurato dagli amministratori nel portale.

Cosa crei: la richiesta di consenso (quando ASK_USER) e la logica di ramificazione che rispetta la scelta dell'amministratore.

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

Avviare una chiamata istantanea

Prima di avviare una chiamata istantanea:

  1. Esamina VoiceCallChannel della coda selezionata.

  2. Verifica che le chiamate istantanee siano attive e non deviate.

  3. Gestisci il consenso alla registrazione in base ai recordingOption della coda.

  4. Richiedi l'autorizzazione microfono.

  5. Chiama il 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)
    }
}

Osservare lo stato della chiamata

Raccogli gli eventi del servizio di chiamata prima di avviare o accettare una chiamata:

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

Stato del provider e stato del server

stateChanged e callReceived.status sono indicatori indipendenti: leggili come colonne, non righe. stateChanged del provider segnala l'handshake VoIP locale; callReceived.status lato server segnala il ciclo di vita della chiamata sulla piattaforma. Attiva l'opzione "Connesso" e avvia il timer della chiamata da callReceived.status, non da stateChanged quando raggiunge lo stato connesso.

Indicatore Cosa viene segnalato Cosa dedurre
stateChanged (fornitore) Handshake VoIP locale - Connecting, Connected (local), Disconnected, Error(...). (Facoltativo) Indicatore di caricamento pre-stato prima dell'arrivo dello stato del server, della visualizzazione dell'errore e della pulizia di fine chiamata.
callReceived.status (server) Ciclo di vita lato server: WAITING, CONNECTING, CONNECTED, ON_HOLD, INTERRUPTED, ENDED. Chiamata con flip, avvio del timer di chiamata, UI assegnata dall'agente, overlay di trasferimento o riassegnazione e flusso di fine chiamata.

Chiamate in arrivo

Le chiamate vocali in arrivo vengono recapitate al dispositivo tramite FCM. Poiché le chiamate in arrivo possono arrivare mentre la tua app è in background o completamente chiusa, la tua integrazione deve essere pronta a riceverle da qualsiasi stato del processo.

Cosa fornisce l'SDK: un flusso IncomingCallEvent su callService che emette il ciclo di vita di una chiamata in arrivo: Arrived, Accepted, Rejected. Il servizio di chiamata espone anche acceptIncomingCall() e rejectIncomingCall() per agire sull'offerta.

Cosa crei: la UI delle chiamate in arrivo (schermata di squillo a schermo intero, presentazione nella schermata di blocco o banner in-app) e i gestori che accettano o rifiutano l'offerta.

Esempio di implementazione

Raccogli incomingCallEvent e visualizza o chiudi la tua UI in ogni evento del ciclo di vita, quindi accetta o rifiuta da un ambito di lunga durata. Definisci applicationScope una volta nella classe Application in modo che le coroutine di accettazione o rifiuto sopravvivano a qualsiasi smontaggio Activity di breve durata durante l'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()
    }
}

Collegamento di FCM all'interfaccia utente delle chiamate in arrivo

Le chiamate in arrivo inviate da FCM arrivano su un thread in background, spesso prima che esista un Activity. Utilizza un timeout breve per collegare il payload FCM alla prima emissione di IncomingCallEvent.Arrived, quindi avvia la tua UI.

Esempio di implementazione

Sospendi la navigazione al prossimo IncomingCallEvent.Arrived con withTimeoutOrNull, poi passa il turno al navigatore.

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

Per un'esperienza di chiamata in arrivo Android integrata, con suoneria a schermo intero, presentazione nella schermata di blocco e integrazione con il registro chiamate di sistema, registra un PhoneAccount e lascia che il framework Telecom gestisca l'esperienza utente della suoneria.

Cosa fornisce l'SDK: un helper TelecomCallService che registra un PhoneAccount per la tua app (registerPhoneAccount(context)), un TelecomConnectionService per collegare le chiamate CCAI in entrata ad Android Telecom e un'interfaccia TelecomConnectionCallback per i callback dello stato della connessione.

Cosa crei: la registrazione PhoneAccount una tantum durante l'inizializzazione dell'app e una dichiarazione del manifest per il servizio di connessione.

Esempio di implementazione

Registra l'account del telefono all'avvio dell'app, quindi dichiara il servizio di connessione e l'autorizzazione nel manifest.

// 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: attività di chiamata a schermo intero

Se preferisci eseguire il rendering della tua schermata di chiamata anziché utilizzare il framework Telecom, l'SDK espone IncomingCallService insieme all'interfaccia IncomingCallUiNavigator. Implementa l'interfaccia, poi passa l'implementazione a IncomingCallService durante l'inizializzazione dell'app.

Esempio di implementazione

Implementa IncomingCallUiNavigator e passalo a IncomingCallService una volta 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)
}

Servizio in primo piano per la suoneria attiva

Mentre una chiamata squilla, Android potrebbe terminare i processi in background. L'SDK include un IncomingRingingService che mantiene attivo il flusso di chiamata come servizio in primo piano. Dichiaralo nel manifest e l'SDK lo avvia e lo arresta automaticamente all'arrivo delle chiamate in entrata.

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

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

Stato della chiamata e stati lato server

Cosa fornisce l'SDK: un enum CallStatus basato sul server in ogni payload Call che descrive la fase del ciclo di vita della chiamata (ad esempio, CONNECTING, WAITING, CONNECTED, ON_HOLD, INTERRUPTED, ENDED, FAILED).

Cosa crei: la mappatura da CallStatus agli stati visibili ai clienti nella schermata di chiamata.

Esempio di implementazione

when-branch on call.status from each callReceived emission and render the matching UI state:

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

Controlli durante la chiamata (disattivazione audio, altoparlante, attesa)

Cosa fornisce l'SDK: proprietà di sola lettura su callService: isMuted, isSpeakerEnabled, isOnHold, insieme alle azioni per attivare/disattivare ogni stato (setMuted, setSpeakerEnabled, setOnHold).

Cosa crei: i pulsanti Muto, Altoparlante e Attesa nell'interfaccia utente durante la chiamata, collegati alle azioni dell'SDK e che riflettono lo stato attuale.

Esempio di implementazione

Leggi lo stato attuale da callService per inizializzare i pulsanti, quindi attiva/disattiva e aggiorna con un tocco.

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
}

Riprendere una chiamata in corso dopo un'interruzione

Quando un'interruzione della rete PSTN (ad esempio una chiamata cellulare in arrivo) mette in pausa la chiamata CCAI attiva, l'SDK mantiene attiva la sessione ed emette interruptionDetected. Al termine dell'interruzione, l'app può riprendere la chiamata.

Cosa fornisce l'SDK: getLastCallInProgress() per cercare una chiamata in corso (anche dopo il riavvio dell'app) e resumeCall(call) per riportarla in primo piano.

Cosa crei: la funzionalità di ripresa nell'interfaccia utente (in genere un banner o una ripresa automatica al termine dell'interruzione) e la navigazione per tornare alla schermata della chiamata.

Esempio di implementazione

Cerca la chiamata in corso con getLastCallInProgress, riprendila e torna alla schermata della chiamata.

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

Riassegnazione da un agente virtuale

Cosa fornisce l'SDK: canEscalate(allowSkipVirtualAgent: Boolean) è un metodo su CallResponse (il modello emesso da callService.callReceived), non su callService stesso. Indica se la chiamata attuale è idonea per il trasferimento a un operatore umano in base allo stato attuale dell'agente virtuale della chiamata.

Cosa crei: mantieni l'emissione callReceived più recente come "chiamata corrente", quindi associa la visibilità del pulsante "Parla con una persona" a currentCall?.canEscalate(...). L'argomento allowSkipVirtualAgent è un valore di configurazione del menu che recuperi separatamente dalla risposta alla chiamata stessa. Non codificare in modo permanente true in produzione.

Esempio di implementazione

Mantieni l'ultima Call in una proprietà, quindi richiama canEscalate. Risolvi allowSkipVirtualAgent dalla configurazione del 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

Segreteria

Cosa fornisce l'SDK: startVoicemail(VoicemailRequest) su callService, più un'enumerazione VoicemailReason che descrive il motivo per cui la piattaforma ha offerto la segreteria telefonica (ad esempio, fallback dopo l'orario di lavoro, fallback per capacità eccessiva o reindirizzamento temporaneo).

Cosa crei: il punto di accesso alla segreteria nella tua UI (in genere mostrato quando VoiceCallChannel.voicemailReason è compilato o quando il ciclo di vita della chiamata si risolve in un risultato di segreteria) e l'UI di registrazione stessa.

Esempio di implementazione

Mostra il pulsante della segreteria quando voicemailReason è impostato, poi chiama 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)
    }
}

Riduzione dei tempi di attesa

Quando la coda è aperta, ma il tempo di attesa supera la soglia configurata dall'amministratore, la piattaforma può offrire un percorso alternativo, in genere un richiamo programmato o un trasferimento alla rete PSTN.

Cosa fornisce l'SDK: un payload CallDeflection che descrive l'alternativa offerta, recuperabile utilizzando getCallDeflection(callId) su callService. La piattaforma emette lo stesso payload tramite il flusso deflectionOffered quando una deviazione diventa disponibile a metà attesa. Un flag CompanyResponse.preventDirectPstnCall separato indica se la composizione PSTN diretta deve essere nascosta nell'interfaccia utente di fallback.

Cosa crei: il prompt di deviazione (ad esempio "L'attesa stimata è di 12 minuti. Vuoi programmare una richiamata?") e la logica di ramificazione che rispetta la scelta dell'utente.

Esempio di implementazione

Recupera la deviazione offerta con l'ID della chiamata attiva, combinala con CompanyResponse.preventDirectPstnCall e presenta la richiesta.

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

Avanzata CallOptions

CallOptions espone un piccolo insieme di pulsanti di attivazione/disattivazione per le app che devono ottimizzare l'esperienza di chiamata predefinita.

  • connectingPollIntervalMs: Long: la frequenza con cui l'SDK esegue il polling della piattaforma mentre la chiamata è in stato di connessione.

  • connectedPollIntervalMs: Long: la frequenza con cui l'SDK esegue il polling della piattaforma mentre la chiamata è connessa.

  • endCallMaxRetries: Int: il numero di volte in cui l'SDK riprova la richiesta di terminazione della chiamata prima di mostrare un errore.

  • endCallRetryDelayMs: Long: ritardo tra i tentativi di chiamata.

Esempio di implementazione

Passa i valori ottimizzati in CallOptions durante l'inizializzazione del modulo di chiamata:

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

Condivisione schermo

La condivisione dello schermo consente agli agenti dell'assistenza di visualizzare lo schermo dell'utente per aiutarlo a risolvere i problemi. La piattaforma CCAI supporta due modalità di condivisione schermo:

  1. Condivisione in-app: l'agente può vedere solo cosa succede all'interno della tua app specifica.

  2. Condivisione completa del dispositivo (facoltativa): l'agente può vedere l'intero dispositivo dell'utente, inclusa la schermata Home di Android e altre app. Questa operazione richiede una configurazione avanzata di Android che utilizza MediaProjection e un servizio in primo piano.

Cosa fornisce l'SDK: il modulo CCAIScreenShare che crea e gestisce le sessioni, gestisce le modifiche dello stato ed elabora le richieste di consenso.

Cosa crei: la configurazione di inizializzazione, le richieste della UI per il consenso dell'utente e (se supporti l'intero dispositivo) l'integrazione MediaProjection integrata di Android con un servizio in primo piano.

Il servizio di condivisione schermo fornisce metodi per l'intero ciclo di vita della sessione: startSession, activateSession, stopSession, oltre a metodi di configurazione per il controllo remoto e la condivisione dell'intero dispositivo. Le sottosezioni successive illustrano il flusso di integrazione principale.

Per le firme complete dei metodi e i dettagli dei parametri per tutti i metodi di condivisione dello schermo, consulta il Riferimento API dell'SDK mobile headless per Android.

Inizializzare e configurare la condivisione schermo

Inizializza il servizio di condivisione schermo durante la configurazione dell'app fornendo la chiave di condivisione schermo:

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

Verifica l'idoneità alla condivisione dello schermo

Prima di attivare la funzionalità di condivisione schermo, verifica se è disponibile per la chat corrente:

import com.ccaiplatform.ccaiscreenshare.ScreenShareManager

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

Gestire le richieste di condivisione schermo

Implementa la gestione delle richieste di condivisione schermo utilizzando ScreenShareManager. Crea una sessione e gestisci la risposta asincrona utilizzando 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
    )
}

Gestire le modifiche dello stato di condivisione schermo

Ascolta le modifiche dello stato della sessione di condivisione dello schermo tramite il callback onSessionStateChanged fornito 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()
        }
    }
}

Riferimento ScreenShareCallbacks

Callback Descrizione
onSessionStateChanged Chiamato quando lo stato della sessione di condivisione dello schermo cambia (INACTIVE, PENDING, ACTIVE).
onSessionCreationError Chiamato quando la creazione della sessione non va a buon fine (problema di rete, servizio non disponibile e così via).
onSessionActivationRequest Chiamato quando la sessione è pronta per l'attivazione. Chiama ScreenShareManager.activateSession() per iniziare la condivisione. Per le sessioni avviate dall'utente, mostra prima un prompt di consenso; per le sessioni avviate dall'agente, attiva immediatamente.
onSessionRemoteControlRequest Chiamato quando l'agente richiede il controllo remoto dello schermo dell'utente. Mostra una richiesta di consenso prima di concedere il controllo remoto.
onSessionFullDeviceRequest Chiamato quando l'agente richiede la condivisione dell'intero dispositivo (vedi Condivisione dello schermo dell'intero dispositivo di seguito). Attiva il flusso di consenso per MediaProjection nella risposta.
onSessionDidSucceed Chiamato dopo che una sessione viene attivata correttamente e inizia lo streaming. Utilizza questo valore per aggiornare l'UI allo stato "Condivisione schermo attiva".

Per le definizioni complete dell'enumerazione dello stato e tutti i tipi associati, consulta la documentazione di riferimento dell'API Headless Mobile SDK per Android.

Condivisione schermo dell'intero dispositivo (avanzata)

La condivisione dello schermo dell'intero dispositivo consente agli agenti di visualizzare le schermate delle applicazioni al di fuori della tua, incluse le impostazioni di sistema e la navigazione tra le applicazioni. Per farlo, è necessario integrare l'API MediaProjection di Android ed eseguire un servizio in primo piano.

Dichiarare le autorizzazioni e il servizio in primo piano

Aggiungi le autorizzazioni richieste e la dichiarazione del servizio in primo piano al tuo 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>

Prima di acquisire lo schermo, devi chiedere il consenso all'utente utilizzando 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")
    }
}

Implementare il servizio in primo piano

Crea un servizio in primo piano che contenga la sessione 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
    }
}

Attivare la condivisione dell'intero dispositivo

Una volta attivato MediaProjection, abilita la condivisione dell'intero dispositivo tramite il servizio di condivisione dello schermo:

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

Considerazioni su Jetpack Compose per la condivisione schermo

  1. Racchiudi gli elementi interattivi chiave con le visualizzazioni Android: per le aree che devono essere cliccabili da remoto, racchiudile come controlli di visualizzazione Android standard (ad esempio android.widget.Button) utilizzando il composable AndroidView. In questo modo, i clic remoti funzionano in modo affidabile tramite il percorso basato sulla visualizzazione Android.

  2. Inoltra tocchi remoti con gestione personalizzata dei tocchi: per le aree di composizione che non possono essere sostituite con le visualizzazioni, utilizza i callback touch personalizzati per inoltrare gli eventi touch remoti alla logica di Compose.

  3. Fornisci indicazioni o fallback a livello di prodotto: indica chiaramente nelle schermate di composizione che "Gli agenti possono solo visualizzare; l'utente deve toccare localmente" o limita automaticamente le autorizzazioni di controllo remoto su queste pagine mantenendo l'accesso di sola visualizzazione.

Esempio di implementazione del wrapper della visualizzazione 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")
        }
    }
}

Chiamate pianificate

Le chiamate pianificate consentono agli utenti di prenotare una chiamata vocale futura anziché attendere in coda una chiamata immediata. Su Android, l'SDK espone i metodi di servizio di chiamata per controllare la disponibilità, recuperare le fasce orarie, prenotare chiamate, riprogrammare chiamate, annullare chiamate e gestire gli errori di pianificazione.

Rilevamento delle funzionalità

Cosa fornisce l'SDK: un flag scheduleEnabled su VoiceCallChannel che indica se la coda selezionata supporta la prenotazione di chiamate future.

Cosa crei: la logica per controllare questo flag prima di mostrare il punto di ingresso della pianificazione.

Esempio di implementazione

Controlla scheduleEnabled sul canale di chiamata vocale della coda. Non utilizzare voiceCall != null come proxy: una coda può supportare chiamate vocali istantanee senza supportare le chiamate pianificate e 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
}

Controllo facoltativo della disponibilità

Prima di mostrare il selettore di fasce orarie, puoi verificare se la coda selezionata dispone di slot di pianificazione disponibili.

Esempio di implementazione

Utilizza hasScheduledTimeSlots(menuId:) come controllo preliminare leggero. Se restituisce false, mostra un'opzione di stato vuoto o di riserva anziché aprire il selettore.

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 fasce orarie

Recupera gli orari disponibili per gli appuntamenti per la coda selezionata. L'SDK restituisce valori di data che la tua app può visualizzare nel fuso orario locale dell'utente.

Cosa fornisce l'SDK: timestamp degli slot disponibili per la coda selezionata, con supporto facoltativo per la riprogrammazione di una chiamata pianificata esistente.

Cosa crei: il calendario, il selettore dell'ora, la presentazione del fuso orario, la gestione dello stato vuoto e il comportamento di riprova.

Esempio di implementazione

Chiama getScheduledTimeSlots con l'ID menu della coda. Quando riprogrammi, passa l'ID chiamata pianificata esistente in modo che il servizio possa restituire la disponibilità appropriata.

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

Quando riprogrammi, trasmetti l'ID della chiamata pianificata esistente:

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

Creare una chiamata pianificata

Dopo che l'utente ha selezionato una fascia oraria, crea la chiamata pianificata tramite callService.

Cosa fornisce l'SDK: un'API createScheduledCall che prenota lo slot selezionato e restituisce il record della chiamata pianificata.

Cosa crei: convalida del numero di telefono E.164, UI di conferma, persistenza locale dell'ID della chiamata pianificata e recupero per conflitti di slot o risposte al di fuori dell'orario di lavoro.

Esempio di implementazione

Passa lo slot selezionato, il numero di telefono, l'ID coda e RecordingPermission.NOT_ASKED in 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}")
    }
}

Il numero di telefono deve essere nel formato E.164, ad esempio +14155551234.

Ripianificare una chiamata esistente

Per riprogrammare, recupera gli slot e crea la nuova chiamata pianificata con l'ID chiamata pianificata esistente.

Cosa fornisce l'SDK: le stesse API per la creazione e gli slot orari, con callIdForRescheduling utilizzato per identificare la prenotazione esistente.

Cosa crei: l'interfaccia utente "Modifica orario", la ricerca locale dell'ID chiamata pianificata memorizzato e lo stato di conferma aggiornato dopo la creazione della nuova prenotazione.

Esempio di implementazione

Trasferisci l'ID chiamata esistente sia a getScheduledTimeSlots che a 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}")
    }
}

Annullare una chiamata pianificata

Quando l'utente annulla una chiamata imminente, chiama cancelScheduledCall e cancella l'ID chiamata memorizzato localmente.

Cosa fornisce l'SDK: un'API di annullamento per un ID chiamata pianificata.

Cosa crei: il prompt di conferma, l'interfaccia utente di annullamento riuscito o di errore e la pulizia di qualsiasi stato di chiamata imminente memorizzato localmente.

Esempio di implementazione

Chiama cancelScheduledCall(callId:) con l'ID memorizzato, poi rimuovi l'ID dallo spazio di archiviazione dell'app.

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

Email

Cosa fornisce l'SDK: l'indirizzo email di destinazione configurato per quella coda specifica, insieme a eventuali istruzioni che l'amministratore vuole che l'utente visualizzi (ad esempio, includi il numero di conto bancario).

Cosa crei: la schermata di composizione delle email e la logica per monitorare il completamento. Puoi creare un modulo personalizzato o utilizzare l'intent Intent.ACTION_SENDTO integrato di Android per avviare il client di posta preferito dell'utente.

Esempio di implementazione

Questo codice estrae l'indirizzo email di assistenza dall'oggetto Channels dell'SDK su QueueMenu. Apre il client di posta Android integrato e gestisce il caso in cui non è installato alcun client di posta.

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

Cosa fornisce l'SDK: un elenco di URL (ad esempio il Centro assistenza della tua azienda, le norme sui rimborsi o un'app partner) e i relativi titoli visualizzati, esattamente come configurati nel portale amministratore.

Cosa crei: la logica per eseguire questi link quando un utente li tocca, aprendoli in un browser in-app (utilizzando le schede personalizzate di Android) o avviando il browser di sistema predefinito.

Quando l'utente tocca un link esterno dal menu, questo codice estrae la stringa URL fornita dall'SDK e utilizza le schede personalizzate di Chrome per visualizzare la pagina web in un'esperienza di browser in-app. Le schede personalizzate offrono un'esperienza più rapida e fluida rispetto all'avvio dell'app browser completa, mostrando comunque la barra degli indirizzi in modo che l'utente sappia di visualizzare contenuti esterni.

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

Implementazione di riserva (browser di sistema): se le schede personalizzate non sono disponibili o preferisci un approccio più semplice, puoi aprire il link nel browser predefinito dell'utente:

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

Esempio di integrazione completa

Questo codice legge il link di deviazione dall'oggetto Channels dell'SDK e lo presenta all'utente come pulsante toccabile nel menu del canale.

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 espone sia i campi url / displayName di primo livello sia un array links: List<ExternalDeflection>? interno. L'esempio precedente restituisce solo la voce di primo livello, che è sufficiente per le code a un solo link. Se gli amministratori configurano più link esterni in una singola coda, itera deflectionLink.links e visualizza un pulsante per ogni voce abilitata. Ogni ExternalDeflection ha il proprio url e displayName:

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