Headless Mobile SDK untuk Android: Suara, berbagi layar, panggilan terjadwal, dan email

Dokumen ini menjelaskan cara mengintegrasikan dan menyesuaikan Headless Mobile SDK di aplikasi Android Anda. Fitur ini mencakup suara, Berbagi Layar, panggilan terjadwal, dan email.

Panggilan suara

Headless Mobile SDK untuk Android menyediakan dukungan panggilan suara melalui modul CCAICall dan CCAICallRed. Aplikasi Anda memiliki UI panggilan kustom, sementara SDK menangani pembuatan panggilan, penanganan panggilan masuk, integrasi penyedia, dan status siklus proses panggilan.

Yang disediakan SDK: API layanan panggilan untuk memulai panggilan instan, memulai pesan suara, menangani panggilan masuk, dan mengamati status panggilan.

Apa yang Anda bangun: Pengalaman panggilan visual, termasuk titik masuk pra-panggilan, dialog izin perekaman, status berdering atau menunggu, kontrol dalam panggilan, UI layanan latar depan, dan navigasi pasca-panggilan.

Melakukan inisialisasi modul panggilan

Lakukan inisialisasi modul panggilan setelah melakukan inisialisasi SDK inti:

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

Memeriksa kemampuan VoiceCallChannel

Sebelum menawarkan panggilan instan, periksa VoiceCallChannel antrean untuk memutuskan titik entri mana yang diekspos (panggilan instan, penggantian pesan suara, pengalihan tingkat saluran) dan apakah izin perekaman diperlukan.

Kolom VoiceCallChannel utama

  • instantEnabled: Boolean — apakah panggilan suara live (instan) diizinkan untuk antrean ini. Perlakukan false sebagai "jangan menawarkan tombol panggilan instan". Berbeda dengan voiceCall != null, yang hanya memberi tahu Anda bahwa antrean memiliki channel suara.

  • preSessionSmartAction: Boolean — apakah tindakan smart pra-sesi diperlukan di saluran tertentu ini sebelum memulai panggilan. Lihat Tindakan smart pra-sesi di Headless Mobile SDK for Android: Smart Actions, Attachments, and Deflection untuk jalur tingkat antrean yang saling melengkapi.

  • scheduleEnabled: Boolean — apakah panggilan terjadwal didukung. Lihat bagian Panggilan terjadwal.

  • recordingOption: RecordingOption? — model izin perekaman panggilan. Lihat Merekam izin. Berbeda dengan RecordingPermission.NOT_ASKED, yang hanya digunakan dengan panggilan terjadwal.

  • voicemailReason: String? — diisi saat saluran dikonfigurasi untuk merutekan ke pesan suara.

  • phoneNumber: String? — Nomor pengganti PSTN yang dikonfigurasi untuk pengalihan atau pesan suara.

  • scheduleDeflectionType: String? / deflected: Boolean? / deflectedReason: String? — metadata pengalihan tingkat channel. Jika deflected == true, tampilkan pesan deflectedReason yang dikonfigurasi admin dan tawarkan penggantian yang dikonfigurasi (misalnya, nomor telepon yang akan dihubungi atau titik entri panggilan terjadwal) daripada mencoba panggilan instan. Lihat bagian Pengalihan waktu tunggu. di Headless Mobile SDK for Android: Smart Actions, Attachments, and Deflection untuk penanganan pengalihan tingkat antrean.

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

Yang disediakan SDK: Kolom recordingOption: RecordingOption? di VoiceCallChannel yang menjelaskan model izin perekaman panggilan yang dikonfigurasi admin di portal.

Yang Anda bangun: Dialog izin (saat ASK_USER) dan logika percabangan yang mematuhi pilihan admin.

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

Memulai panggilan instan

Sebelum memulai panggilan instan:

  1. Periksa VoiceCallChannel antrean yang dipilih.

  2. Pastikan panggilan instan diaktifkan dan tidak dialihkan.

  3. Tangani izin perekaman sesuai dengan recordingOption antrean.

  4. Minta izin mikrofon.

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

Mengamati status panggilan

Kumpulkan peristiwa layanan panggilan sebelum memulai atau menerima panggilan:

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

Status penyedia vs. status server

stateChanged dan callReceived.status adalah sinyal independen — baca sebagai kolom, bukan baris. stateChanged penyedia melaporkan handshake VoIP lokal; callReceived.status sisi server melaporkan siklus proses panggilan di platform. Mendorong flip "Terhubung" dan memulai timer panggilan dari callReceived.status — bukan dari stateChanged yang mencapai status terhubung.

Sinyal Yang dilaporkan Yang dapat disimpulkan dari hal tersebut
stateChanged (penyedia) Handshake VoIP lokal — Connecting, Connected (local), Disconnected, Error(...). Spinner pra-status opsional sebelum status server tiba, error muncul, dan pembersihan akhir panggilan.
callReceived.status (server) Siklus proses sisi server — WAITING, CONNECTING, CONNECTED, ON_HOLD, INTERRUPTED, ENDED. Flip terhubung, mulai timer panggilan, UI yang ditetapkan agen, overlay transfer atau eskalasi, dan alur mengakhiri panggilan.

Panggilan masuk

Panggilan suara masuk dikirimkan ke perangkat menggunakan FCM. Karena panggilan masuk dapat tiba saat aplikasi Anda berada di latar belakang atau dihentikan sepenuhnya, integrasi Anda harus siap menerimanya dari status proses apa pun.

Yang disediakan SDK: Alur IncomingCallEvent di callService yang memancarkan siklus proses panggilan masuk: Arrived, Accepted, Rejected. Layanan panggilan juga mengekspos acceptIncomingCall() dan rejectIncomingCall() untuk menindaklanjuti penawaran.

Yang Anda buat: UI panggilan masuk Anda (layar dering layar penuh, presentasi layar kunci, atau banner dalam aplikasi) dan handler yang menerima atau menolak penawaran.

Contoh penerapan

Kumpulkan incomingCallEvent dan render atau tutup UI Anda di setiap peristiwa siklus proses, lalu terima atau tolak dari cakupan yang berjalan lama. Tentukan applicationScope sekali di class Application Anda sehingga terima atau tolak coroutine bertahan dari penghentian Activity singkat selama 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()
    }
}

Menghubungkan FCM ke UI panggilan masuk Anda

Panggilan masuk yang dikirim FCM tiba di thread latar belakang, sering kali sebelum ada Activity. Gunakan waktu tunggu singkat untuk menjembatani payload FCM ke emisi IncomingCallEvent.Arrived pertama, lalu mulai UI Anda.

Contoh penerapan

Tunda di IncomingCallEvent.Arrived berikutnya dengan withTimeoutOrNull, lalu serahkan ke navigator Anda.

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

Untuk pengalaman panggilan masuk Android bawaan — dering layar penuh, presentasi layar kunci, dan integrasi dengan log panggilan sistem — daftarkan PhoneAccount dan biarkan framework Telecom memiliki UX dering.

Yang disediakan SDK: Helper TelecomCallService yang mendaftarkan PhoneAccount untuk aplikasi Anda (registerPhoneAccount(context)), TelecomConnectionService untuk menjembatani panggilan CCAI masuk ke Android Telecom, dan antarmuka TelecomConnectionCallback untuk callback status koneksi.

Yang Anda bangun: Pendaftaran PhoneAccount satu kali selama inisialisasi aplikasi dan deklarasi manifes untuk layanan koneksi.

Contoh penerapan

Daftarkan akun ponsel saat aplikasi dimulai, lalu deklarasikan layanan dan izin koneksi dalam manifes Anda.

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

Alternatif: aktivitas dering layar penuh

Jika Anda lebih suka merender layar dering sendiri daripada menggunakan framework Telecom, SDK akan mengekspos IncomingCallService bersama dengan antarmuka IncomingCallUiNavigator. Terapkan antarmuka, lalu serahkan penerapan Anda ke IncomingCallService selama inisialisasi aplikasi.

Contoh penerapan

Terapkan IncomingCallUiNavigator dan teruskan ke IncomingCallService sekali selama 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)
}

Layanan latar depan untuk dering aktif

Saat panggilan berdering, Android dapat menghentikan proses latar belakang. SDK mengirimkan IncomingRingingService yang membuat alur dering tetap aktif sebagai layanan latar depan. Deklarasikan di manifes Anda dan SDK akan memulai dan menghentikannya secara otomatis saat panggilan masuk tiba.

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

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

Status panggilan dan status sisi server

Yang disediakan SDK: Enum CallStatus yang didorong server di setiap payload Call yang menjelaskan posisi panggilan dalam siklus prosesnya (misalnya, CONNECTING, WAITING, CONNECTED, ON_HOLD, INTERRUPTED, ENDED, FAILED).

Apa yang Anda buat: Pemetaan dari CallStatus ke status yang ditampilkan kepada pelanggan di layar panggilan Anda.

Contoh penerapan

Cabang when pada call.status dari setiap emisi callReceived dan merender status UI yang cocok:

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

Kontrol dalam panggilan (bisukan audio, speaker, tunggu)

Yang disediakan SDK: Properti hanya baca di callService — isMuted, isSpeakerEnabled, isOnHold — beserta tindakan untuk mengubah setiap status (setMuted, setSpeakerEnabled, setOnHold).

Yang Anda bangun: Tombol bisu, speaker, dan tunggu di UI dalam panggilan Anda, terhubung ke tindakan SDK dan mencerminkan status saat ini.

Contoh penerapan

Baca status saat ini dari callService untuk menginisialisasi tombol, lalu alihkan dan perbarui saat diketuk.

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
}

Melanjutkan panggilan yang sedang berlangsung setelah terganggu

Saat gangguan PSTN (seperti panggilan seluler masuk) menjeda panggilan CCAI aktif, SDK akan mempertahankan sesi tetap aktif dan memancarkan interruptionDetected. Setelah gangguan berakhir, aplikasi Anda dapat melanjutkan panggilan.

Yang disediakan SDK: getLastCallInProgress() untuk mencari panggilan yang sedang berlangsung (termasuk di seluruh proses mulai ulang aplikasi) dan resumeCall(call) untuk membawanya kembali ke latar depan.

Yang Anda bangun: Kemampuan melanjutkan di UI Anda (biasanya banner atau melanjutkan otomatis setelah gangguan berakhir) dan navigasi kembali ke layar panggilan Anda.

Contoh penerapan

Cari panggilan yang sedang berlangsung dengan getLastCallInProgress, lanjutkan, dan kembali ke layar panggilan Anda.

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

Eskalasi dari agen virtual

Yang disediakan SDK: canEscalate(allowSkipVirtualAgent: Boolean) adalah metode pada CallResponse (model yang dikeluarkan oleh callService.callReceived), bukan pada callService itu sendiri. API ini melaporkan apakah panggilan saat ini memenuhi syarat untuk eskalasi ke agen manusia berdasarkan status agen virtual panggilan saat ini.

Yang Anda bangun: Pertahankan emisi callReceived terbaru sebagai "panggilan saat ini", lalu ikat visibilitas tombol "Bicara dengan petugas" ke currentCall?.canEscalate(...). Argumen allowSkipVirtualAgent adalah nilai konfigurasi menu yang Anda ambil secara terpisah dari respons panggilan itu sendiri — jangan hardcode true dalam produksi.

Contoh penerapan

Simpan Call terbaru dalam properti, lalu panggil canEscalate di dalamnya. Atasi allowSkipVirtualAgent dari konfigurasi menu Anda.

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

Pesan suara

Yang disediakan SDK: startVoicemail(VoicemailRequest) di callService, serta enum VoicemailReason yang menjelaskan alasan platform menawarkan pesan suara (misalnya, penggantian setelah jam kerja, penggantian karena kapasitas berlebih, atau pengalihan sementara).

Yang Anda bangun: Titik entri pesan suara di UI Anda (biasanya ditampilkan saat VoiceCallChannel.voicemailReason diisi, atau saat siklus proses panggilan diselesaikan ke hasil pesan suara) dan UI perekaman itu sendiri.

Contoh penerapan

Tampilkan tombol pesan suara saat voicemailReason disetel, lalu panggil startVoicemail dengan 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)
    }
}

Pengalihan waktu tunggu

Jika antrean terbuka, tetapi waktu tunggu melebihi batas yang dikonfigurasi admin, platform dapat menawarkan jalur alternatif, biasanya berupa panggilan balik terjadwal atau pengalihan PSTN.

Yang disediakan SDK: Payload CallDeflection yang menjelaskan alternatif yang ditawarkan, yang dapat diambil menggunakan getCallDeflection(callId) di callService. Platform juga memancarkan payload yang sama melalui Alur deflectionOffered saat pengalihan tersedia di tengah penantian. Tanda CompanyResponse.preventDirectPstnCall terpisah menunjukkan apakah panggilan PSTN langsung harus disembunyikan di UI penggantian Anda.

Yang Anda buat: Perintah pengalihan (misalnya, "Perkiraan waktu tunggu adalah 12 menit — apakah Anda ingin menjadwalkan panggilan balik?") dan logika percabangan yang menghormati pilihan pengguna.

Contoh penerapan

Ambil pengalihan yang ditawarkan dengan ID panggilan aktif, gabungkan dengan CompanyResponse.preventDirectPstnCall, dan tampilkan perintah.

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

Lanjutan CallOptions

CallOptions mengekspos sekumpulan kecil tombol untuk aplikasi yang perlu menyesuaikan pengalaman panggilan default.

  • connectingPollIntervalMs: Long — seberapa sering SDK melakukan polling pada platform saat panggilan berada dalam status menghubungkan.

  • connectedPollIntervalMs: Long — seberapa sering SDK melakukan polling platform saat panggilan terhubung.

  • endCallMaxRetries: Int — berapa kali SDK mencoba ulang permintaan akhiri panggilan sebelum menampilkan error.

  • endCallRetryDelayMs: Long — penundaan antara percobaan ulang panggilan yang diakhiri.

Contoh penerapan

Teruskan nilai yang disesuaikan ke CallOptions saat menginisialisasi modul panggilan:

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

Berbagi layar

Berbagi layar memungkinkan agen dukungan melihat layar pengguna untuk membantu memecahkan masalah. Platform CCAI mendukung dua mode berbagi layar:

  1. Berbagi dalam aplikasi: agen hanya dapat melihat apa yang terjadi di dalam aplikasi spesifik Anda.

  2. Berbagi seluruh perangkat (opsional): agen dapat melihat seluruh perangkat pengguna, termasuk layar utama Android dan aplikasi lainnya. Tindakan ini memerlukan konfigurasi Android lanjutan menggunakan MediaProjection dan layanan latar depan.

Yang disediakan SDK: Modul CCAIScreenShare yang membuat dan mengelola sesi, menangani perubahan status, dan memproses permintaan izin.

Yang Anda bangun: Konfigurasi inisialisasi, perintah UI untuk izin pengguna, dan (jika mendukung perangkat lengkap) integrasi MediaProjection Android bawaan dengan layanan latar depan.

Layanan berbagi layar menyediakan metode untuk siklus proses sesi penuh — startSession, activateSession, stopSession — serta metode konfigurasi untuk kontrol jarak jauh dan berbagi perangkat penuh. Subbagian berikutnya akan membahas alur integrasi inti.

Untuk mengetahui tanda tangan metode dan detail parameter lengkap untuk semua metode berbagi layar, lihat Headless Mobile SDK - Android API Reference.

Melakukan inisialisasi dan mengonfigurasi berbagi layar

Inisialisasi layanan berbagi layar selama penyiapan aplikasi dengan memberikan kunci berbagi layar Anda:

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

Memeriksa kelayakan berbagi layar

Sebelum mengaktifkan fungsi berbagi layar, periksa apakah fungsi tersebut tersedia untuk chat saat ini:

import com.ccaiplatform.ccaiscreenshare.ScreenShareManager

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

Menangani permintaan berbagi layar

Terapkan penanganan permintaan berbagi layar menggunakan ScreenShareManager. Anda membuat sesi dan menangani respons asinkron menggunakan 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
    )
}

Menangani perubahan status berbagi layar

Mendengarkan perubahan status sesi berbagi layar melalui callback onSessionStateChanged yang diberikan ke 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()
        }
    }
}

Referensi ScreenShareCallbacks

Callback Deskripsi
onSessionStateChanged Dipanggil saat status sesi berbagi layar berubah (INACTIVE, PENDING, ACTIVE).
onSessionCreationError Dipanggil saat pembuatan sesi gagal (masalah jaringan, layanan tidak tersedia, dll.).
onSessionActivationRequest Dipanggil saat sesi siap diaktifkan — panggil ScreenShareManager.activateSession() untuk mulai berbagi. Untuk sesi yang dimulai pengguna, tampilkan dialog izin terlebih dahulu; untuk sesi yang dimulai agen, aktifkan segera.
onSessionRemoteControlRequest Dipanggil saat agen meminta kontrol jarak jauh layar pengguna. Menampilkan dialog izin sebelum memberikan kontrol jarak jauh.
onSessionFullDeviceRequest Dipanggil saat agen meminta berbagi perangkat penuh (lihat Berbagi layar perangkat penuh di bawah). Aktifkan alur izin MediaProjection sebagai respons.
onSessionDidSucceed Dipanggil setelah sesi berhasil diaktifkan dan mulai melakukan streaming. Gunakan ini untuk mengupdate UI Anda ke status "berbagi layar aktif".

Untuk definisi enum status lengkap dan semua jenis terkait, lihat Headless Mobile SDK - Android API Reference.

Berbagi layar seluruh perangkat (lanjutan)

Berbagi layar seluruh perangkat memungkinkan agen melihat layar dari aplikasi di luar aplikasi Anda sendiri, termasuk setelan sistem dan navigasi antar-aplikasi. Hal ini memerlukan integrasi API MediaProjection Android dan menjalankan layanan latar depan.

Mendeklarasikan izin dan layanan latar depan

Tambahkan izin yang diperlukan dan pernyataan layanan latar depan ke 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>

Sebelum merekam layar, Anda harus meminta izin pengguna menggunakan 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")
    }
}

Menerapkan layanan latar depan

Buat layanan latar depan yang menyimpan sesi 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
    }
}

Mengaktifkan berbagi perangkat penuh

Setelah MediaProjection aktif, aktifkan berbagi seluruh perangkat melalui layanan berbagi layar:

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

Pertimbangan Jetpack Compose untuk berbagi layar

  1. Menggabungkan elemen interaktif utama dengan tampilan Android — untuk area yang perlu dapat diklik dari jarak jauh, gabungkan sebagai kontrol tampilan Android standar (misalnya, android.widget.Button) menggunakan composable AndroidView. Hal ini memastikan klik jarak jauh berfungsi dengan andal melalui jalur berbasis tampilan Android.

  2. Meneruskan sentuhan jarak jauh dengan penanganan sentuhan kustom — untuk area Compose yang tidak dapat diganti dengan View, gunakan callback sentuhan kustom untuk meneruskan peristiwa sentuhan jarak jauh ke logika Compose Anda.

  3. Memberikan panduan atau penggantian tingkat produk — tunjukkan dengan jelas di layar Compose bahwa "Agen hanya dapat melihat; pengguna harus mengetuk secara lokal", atau batasi izin kontrol jarak jauh secara otomatis di halaman ini sambil mempertahankan akses hanya lihat.

Contoh penerapan wrapper tampilan 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")
        }
    }
}

Panggilan terjadwal

Panggilan terjadwal memungkinkan pengguna memesan panggilan suara di masa mendatang, bukan menunggu dalam antrean untuk panggilan instan. Di Android, SDK mengekspos metode layanan panggilan untuk memeriksa ketersediaan, mengambil slot waktu, memesan panggilan, menjadwalkan ulang panggilan, membatalkan panggilan, dan menangani error penjadwalan.

Deteksi kemampuan

Yang disediakan SDK: Flag scheduleEnabled di VoiceCallChannel yang memberi tahu Anda apakah antrean yang dipilih mendukung pemesanan panggilan mendatang.

Yang Anda buat: Logika untuk memeriksa tanda ini sebelum menampilkan titik entri penjadwalan Anda.

Contoh penerapan

Periksa scheduleEnabled di saluran panggilan suara antrean. Jangan gunakan voiceCall != null sebagai proxy — antrean dapat mendukung panggilan suara instan tanpa mendukung panggilan terjadwal, dan sebaliknya.

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
}

Pemeriksaan ketersediaan opsional

Sebelum menampilkan pemilih slot waktu, Anda dapat memeriksa apakah antrean yang dipilih memiliki slot penjadwalan yang tersedia.

Contoh penerapan

Gunakan hasScheduledTimeSlots(menuId:) sebagai pemeriksaan awal yang ringan. Jika menampilkan false, tampilkan opsi status kosong atau penggantian daripada membuka pemilih.

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

Mengambil slot waktu

Mengambil waktu janji temu yang tersedia untuk antrean yang dipilih. SDK menampilkan nilai Tanggal yang dapat ditampilkan aplikasi Anda di zona waktu lokal pengguna.

Yang disediakan SDK: Stempel waktu slot yang tersedia untuk antrean yang dipilih, dengan dukungan opsional untuk menjadwalkan ulang panggilan terjadwal yang ada.

Yang akan Anda bangun: Kalender, pemilih waktu, presentasi zona waktu, penanganan status kosong, dan perilaku percobaan ulang.

Contoh penerapan

Panggil getScheduledTimeSlots dengan ID menu antrean. Saat menjadwalkan ulang, teruskan ID panggilan terjadwal yang ada sehingga layanan dapat menampilkan ketersediaan yang sesuai.

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

Saat menjadwalkan ulang, teruskan ID panggilan terjadwal yang ada:

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

Membuat panggilan terjadwal

Setelah pengguna memilih slot waktu, buat panggilan terjadwal melalui callService.

Yang disediakan SDK: API createScheduledCall yang memesan slot yang dipilih dan menampilkan catatan panggilan terjadwal.

Yang Anda buat: Validasi nomor telepon E.164, UI konfirmasi, persistensi lokal ID panggilan terjadwal, dan pemulihan untuk konflik slot atau respons di luar jam kerja.

Contoh penerapan

Teruskan slot yang dipilih, nomor telepon, ID antrean, dan RecordingPermission.NOT_ASKED dalam 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}")
    }
}

Nomor telepon harus dalam format E.164, seperti +14155551234.

Menjadwalkan ulang panggilan yang ada

Untuk menjadwalkan ulang, ambil slot dan buat panggilan terjadwal baru dengan ID panggilan terjadwal yang ada.

Yang disediakan SDK: API slot waktu dan pembuatan yang sama, dengan callIdForRescheduling digunakan untuk mengidentifikasi pemesanan yang ada.

Yang Anda buat: UI "ubah waktu", penelusuran lokal ID panggilan terjadwal yang disimpan, dan status konfirmasi yang diperbarui setelah pemesanan baru dibuat.

Contoh penerapan

Teruskan ID panggilan yang ada ke getScheduledTimeSlots dan 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}")
    }
}

Membatalkan panggilan terjadwal

Saat pengguna membatalkan panggilan mendatang, panggil cancelScheduledCall dan hapus ID panggilan yang disimpan secara lokal.

Yang disediakan SDK: API pembatalan untuk ID panggilan terjadwal.

Yang Anda bangun: Prompt konfirmasi, UI keberhasilan atau error pembatalan, dan pembersihan status panggilan mendatang yang disimpan secara lokal.

Contoh penerapan

Panggil cancelScheduledCall(callId:) dengan ID yang disimpan, lalu hapus ID dari penyimpanan aplikasi Anda.

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

Email

Yang disediakan SDK: Alamat email tujuan yang dikonfigurasi untuk antrean tertentu tersebut, beserta petunjuk apa pun yang ingin ditampilkan administrator kepada pengguna (misalnya, sertakan nomor akun Anda).

Yang Anda buat: Layar pembuat email dan logika untuk melacak penyelesaiannya. Anda dapat membuat formulir kustom sendiri, atau menggunakan Intent.ACTION_SENDTO bawaan Android untuk meluncurkan program email pilihan pengguna.

Contoh penerapan

Kode ini menarik alamat email dukungan dari objek Channels SDK di QueueMenu. Aplikasi ini akan membuka program email Android bawaan dan menangani kasus saat tidak ada program email yang diinstal.

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

Yang disediakan SDK: Daftar URL (seperti Pusat Bantuan perusahaan Anda, kebijakan pengembalian dana, atau aplikasi partner) dan judul tampilannya, persis seperti yang dikonfigurasi di Portal Admin.

Yang Anda bangun: Logika untuk mengeksekusi link tersebut saat pengguna mengetuknya - baik dengan membukanya di browser dalam aplikasi (menggunakan tab kustomAndroid) atau meluncurkan browser sistem default.

Saat pengguna mengetuk link eksternal dari menu Anda, kode ini akan mengekstrak string URL yang disediakan oleh SDK dan menggunakan Tab Kustom Chrome untuk menampilkan halaman web dalam pengalaman browser dalam aplikasi. Tab kustom memberikan pengalaman yang lebih cepat dan lancar daripada meluncurkan aplikasi browser lengkap, sekaligus tetap menampilkan kolom URL sehingga pengguna tahu bahwa mereka sedang melihat konten eksternal.

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

Implementasi penggantian (browser sistem): Jika Tab Khusus tidak tersedia atau Anda lebih memilih pendekatan yang lebih sederhana, Anda dapat membuka link di browser default pengguna:

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

Contoh integrasi penuh

Kode ini membaca link pengalihan dari objek Channels SDK dan menampilkannya kepada pengguna sebagai tombol yang dapat diketuk di menu channel Anda.

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 mengekspos kolom url / displayName tingkat teratas dan array links: List<ExternalDeflection>? dalam. Contoh sebelumnya hanya merender entri tingkat teratas, yang cukup untuk antrean link tunggal. Jika admin Anda mengonfigurasi beberapa link eksternal dalam satu antrean, lakukan iterasi deflectionLink.links dan render satu tombol per entri yang diaktifkan - setiap ExternalDeflection memiliki url dan displayName-nya sendiri:

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