Android 向け Headless Mobile SDK: 音声、画面共有、スケジュールされた通話、メール

このドキュメントでは、Android アプリケーションで Headless Mobile SDK を統合してカスタマイズする方法について説明します。音声、画面共有、予約通話、メールが対象となります。

音声通話

Android 向け Headless Mobile SDK は、CCAICall モジュールと CCAICallRed モジュールを通じて音声通話のサポートを提供します。アプリがカスタム通話 UI を所有し、SDK が通話の作成、着信通話の処理、プロバイダの統合、通話のライフサイクル状態を処理します。

SDK が提供するもの: インスタント通話の開始、ボイスメールの開始、着信通話の処理、通話状態のモニタリングを行うための通話サービス API。

構築するもの: 通話前のエントリ ポイント、録音同意プロンプト、着信または待機状態、通話中のコントロール、フォアグラウンド サービス UI、通話後のナビゲーションなど、視覚的な通話体験。

通話モジュールを初期化する

コア SDK を初期化した後に、通話モジュールを初期化します。

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

VoiceCallChannel の機能を検査する

インスタント コールを提供する前に、キューの VoiceCallChannel を調べて、どのエントリ ポイント(インスタント コール、ボイスメール フォールバック、チャネルレベルの転送)を公開するか、録音の同意が必要かどうかを判断します。

主な VoiceCallChannel フィールド

  • instantEnabled: Boolean - このキューでライブ(インスタント)音声通話が許可されているかどうか。false は「インスタント通話ボタンを表示しない」として扱います。キューに音声チャンネルがあるかどうかのみを示す voiceCall != null とは異なります。

  • preSessionSmartAction: Boolean - 通話を開始する前に、この特定のチャネルでセッション前のスマート アクションが必要かどうか。補完的なキューレベルのパスについては、Android 向けヘッドレス モバイル SDK: スマート アクション、添付ファイル、転送のセッション前のスマート アクションをご覧ください。

  • scheduleEnabled: Boolean - スケジュール設定された通話がサポートされているかどうか。スケジュールされた呼び出しセクションをご覧ください。

  • recordingOption: RecordingOption? - 通話の録音の同意モデル。同意を記録するをご覧ください。スケジュールされた呼び出しでのみ使用される RecordingPermission.NOT_ASKED とは異なります。

  • voicemailReason: String? - チャネルがボイスメールに転送するように構成されている場合に設定されます。

  • phoneNumber: String? - 転送またはボイスメール用に構成された PSTN フォールバック番号。

  • scheduleDeflectionType: String? / deflected: Boolean? / deflectedReason: String? - チャネルレベルの転送メタデータ。deflected == true の場合、管理者が設定した deflectedReason メッセージを表示し、インスタント通話を試みる代わりに、設定されたフォールバック(電話番号をダイヤルする、通話のスケジュールを設定するエントリ ポイントなど)を提供します。キューレベルの回避処理については、Android 向けヘッドレス モバイル SDK: スマート アクション、添付ファイル、回避の待ち時間回避のセクションを参照してください。

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

SDK が提供するもの: ポータルで管理者が構成した通話の録音の同意モデルを記述する VoiceCallChannel の recordingOption: RecordingOption? フィールド。

作成するもの: 同意モードのプロンプト(ASK_USER の場合)と、管理者の選択を尊重する分岐ロジック。

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

インスタント通話を開始する

即席通話を開始する前に:

  1. 選択したキューの VoiceCallChannel を検査します。

  2. インスタント コールが有効になっていて、転送されていないことを確認します。

  3. キューの recordingOption に従って録音の同意を処理します。

  4. マイクの権限をリクエストします。

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

通話の状態を監視する

通話を開始または応答する前に、通話サービス イベントを収集します。

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

プロバイダの状態とサーバーのステータス

stateChanged と callReceived.status は独立したシグナルです。行ではなく列として読み取ります。プロバイダの stateChanged はローカル VoIP ハンドシェイクをレポートし、サーバーサイドの callReceived.status はプラットフォーム上の通話のライフサイクルをレポートします。stateChanged が接続状態に達したときではなく、callReceived.status から「接続済み」フリップと通話タイマーの開始を制御します。

シグナル レポートの内容 そこから導き出すもの
stateChanged(プロバイダ) ローカル VoIP ハンドシェイク - Connecting, Connected (local), Disconnected, Error(...). サーバーのステータスが届く前、エラーが表示される前、通話終了後のクリーンアップの前に表示されるオプションのステータス スピナー。
callReceived.status(サーバー) サーバーサイドのライフサイクル - WAITING、CONNECTING、CONNECTED、ON_HOLD、INTERRUPTED、ENDED。 接続されたフリップ、通話タイマーの開始、エージェント割り当て UI、転送またはエスカレーションのオーバーレイ、通話終了フロー。

着信

着信音声通話は、FCM を使用してデバイスに配信されます。アプリがバックグラウンドで実行されているときや完全に終了しているときにも着信がある可能性があるため、統合はどのプロセス状態からでも着信を受け取れるようにする必要があります。

SDK が提供するもの: 着信通話のライフサイクル(Arrived、Accepted、Rejected)を送信する callService の IncomingCallEvent Flow。コールサービスは、オファーに対応するために acceptIncomingCall() と rejectIncomingCall() も公開します。

構築するもの: 着信 UI(全画面の着信画面、ロック画面のプレゼンテーション、アプリ内バナー)と、提案を承諾または拒否するハンドラ。

実装例

incomingCallEvent を収集し、各ライフサイクル イベントで UI をレンダリングまたは破棄してから、長期間存続するスコープから受け入れるか拒否します。Application クラスで applicationScope を一度定義して、ハンドシェイク中に短命の Activity の破棄があっても、コルーチンの承認または拒否が継続するようにします。

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

FCM を着信通話 UI にブリッジする

FCM で配信された着信は、バックグラウンド スレッドに届きます。多くの場合、Activity が存在する前に届きます。短いタイムアウトを使用して FCM ペイロードを最初の IncomingCallEvent.Arrived エミッションにブリッジし、UI を起動します。

実装例

次の IncomingCallEvent.Arrived で withTimeoutOrNull を使用して一時停止し、ナビゲーターに引き継ぎます。

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

Android の組み込みの着信通話エクスペリエンス(全画面の着信音、ロック画面の表示、システム通話ログとの統合)については、PhoneAccount を登録し、Telecom フレームワークに呼び出し音の UX を所有させます。

SDK が提供するもの: アプリ(registerPhoneAccount(context))の PhoneAccount を登録する TelecomCallService ヘルパー、着信 CCAI 通話を Android Telecom にブリッジする TelecomConnectionService、接続状態コールバック用の TelecomConnectionCallback インターフェース。

構築するもの: アプリの初期化時の 1 回限りの PhoneAccount 登録と、接続サービスの宣言マニフェスト。

実装例

アプリの起動時に電話アカウントを登録し、マニフェストで接続サービスと権限を宣言します。

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

代替案: 全画面着信アクティビティ

Telecom フレームワークを使用する代わりに独自の着信画面をレンダリングする場合は、SDK が IncomingCallUiNavigator インターフェースとともに IncomingCallService を公開します。インターフェースを実装し、アプリの初期化時に実装を IncomingCallService に渡します。

実装例

IncomingCallUiNavigator を実装し、Application.onCreate() の間に 1 回 IncomingCallService に渡します。

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

着信中のフォアグラウンド サービス

着信音が鳴っている間、Android はバックグラウンド プロセスを強制終了する可能性があります。SDK には、着信フローをフォアグラウンド サービスとして維持する IncomingRingingService が含まれています。マニフェストで宣言すると、着信時に SDK が自動的に開始と停止を行います。

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

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

通話ステータスとサーバーサイドの状態

SDK が提供するもの: 各 Call ペイロードのサーバー駆動型 CallStatus 列挙型。呼び出しがライフサイクルのどこにあるかを記述します(CONNECTING、WAITING、CONNECTED、ON_HOLD、INTERRUPTED、ENDED、FAILED など)。

構築するもの: CallStatus から通話画面のユーザー向けの状態へのマッピング。

実装例

when各 callReceived 出力から call.status で分岐し、一致する UI 状態をレンダリングします。

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

通話中のコントロール(ミュート、スピーカー、保留)

SDK が提供するもの: callService の読み取り専用プロパティ(isMuted、isSpeakerEnabled、isOnHold)と、各状態を切り替えるアクション(setMuted、setSpeakerEnabled、setOnHold)。

作成するもの: SDK のアクションに接続され、現在の状態を反映する、通話 UI のミュート、スピーカー、保留ボタン。

実装例

callService から現在の状態を読み取ってボタンを初期化し、タップで切り替えて更新します。

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
}

中断された通話を再開する

PSTN の中断(携帯電話への着信など)によってアクティブな CCAI 通話が一時停止すると、SDK はセッションを維持し、interruptionDetected を出力します。割り込みが終了すると、アプリは通話を再開できます。

SDK が提供するもの: 進行中の通話を検索する getLastCallInProgress()(アプリの再起動後も含む)、通話をフォアグラウンドに戻す resumeCall(call)。

構築するもの: UI の再開アフォーダンス(通常はバナー、または割り込み終了後の自動再開)と、通話画面に戻るナビゲーション。

実装例

getLastCallInProgress で進行中の通話を検索し、再開して、通話画面に戻ります。

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

仮想エージェントからのエスカレーション

SDK が提供するもの: canEscalate(allowSkipVirtualAgent: Boolean) は callService 自体ではなく、CallResponse(callService.callReceived によって出力されるモデル)のメソッドです。現在の通話が、通話の現在の仮想エージェントのステータスに基づいて、人間のエージェントへのエスカレーションの対象となるかどうかを報告します。

構築するもの: 最新の callReceived エミッションを「現在の通話」として保持し、「担当者に連絡」ボタンの可視性を currentCall?.canEscalate(...) にバインドします。allowSkipVirtualAgent 引数は、呼び出しレスポンス自体から個別に取得するメニュー構成値です。本番環境で true をハードコードしないでください。

実装例

最新の Call をプロパティに保持し、そのプロパティで canEscalate を呼び出します。メニュー構成から allowSkipVirtualAgent を解決します。

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

ボイスメール

SDK が提供するもの: callService の startVoicemail(VoicemailRequest) と、プラットフォームがボイスメールを提供した理由(営業時間外のフォールバック、容量超過のフォールバック、一時的な転送など)を説明する VoicemailReason 列挙型。

構築するもの: UI のボイスメール エントリ ポイント(通常は VoiceCallChannel.voicemailReason が入力されたとき、または通話ライフサイクルがボイスメールの結果に解決されたときに表示されます)と、録音 UI 自体。

実装例

voicemailReason が設定されている場合はボイスメール ボタンを表示し、VoicemailRequest を使用して startVoicemail を呼び出します。

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

待ち時間の削減

キューが開いているにもかかわらず、待ち時間が管理者が設定したしきい値を超えている場合、プラットフォームは代替パス(通常はコールバックのスケジュール設定または PSTN の転送)を提案できます。

SDK が提供するもの: callService の getCallDeflection(callId) を使用して取得できる、提供される代替案を説明する CallDeflection ペイロード。また、待機中に転送が可能になると、プラットフォームは deflectionOffered フローを介して同じペイロードを送信します。別の CompanyResponse.preventDirectPstnCall フラグは、フォールバック UI で PSTN の直接ダイヤルを非表示にするかどうかを示します。

構築するもの: 転換プロンプト(「予想待ち時間は 12 分です。コールバックを予約しますか?」など)と、ユーザーの選択を尊重する分岐ロジック。

実装例

アクティブな通話の ID を使用して提案された回避策を取得し、CompanyResponse.preventDirectPstnCall と組み合わせてプロンプトを表示します。

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

詳細CallOptions

CallOptions は、デフォルトの通話エクスペリエンスを調整する必要があるアプリ向けに、少数の切り替えを公開します。

  • connectingPollIntervalMs: Long - 呼び出しが接続状態にある間、SDK がプラットフォームをポーリングする頻度。

  • connectedPollIntervalMs: Long - 通話が接続されている間、SDK がプラットフォームをポーリングする頻度。

  • endCallMaxRetries: Int - エラーが発生する前に SDK が通話終了リクエストを再試行する回数。

  • endCallRetryDelayMs: Long - 通話終了の再試行間の遅延。

実装例

呼び出しモジュールを初期化するときに、調整された値を CallOptions に渡します。

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

画面共有

画面共有を使用すると、サポート エージェントがユーザーの画面を表示して、問題のトラブルシューティングを行うことができます。CCAI プラットフォームは、次の 2 つの画面共有モードをサポートしています。

  1. アプリ内共有: エージェントは、特定のアプリ内で発生していることのみを確認できます。

  2. デバイス全体の共有(オプション): エージェントは、Android のホーム画面や他のアプリなど、ユーザーのデバイス全体を確認できます。これには、MediaProjection とフォアグラウンド サービスを使用した高度な Android 構成が必要です。

SDK が提供するもの: セッションの作成と管理、状態の変化の処理、同意リクエストの処理を行う CCAIScreenShare モジュール。

構築するもの: 初期化構成、ユーザーの同意を求める UI プロンプト、(フルデバイスをサポートする場合)フォアグラウンド サービスとの組み込み Android MediaProjection 統合。

画面共有サービスは、セッションのライフサイクル全体(startSession、activateSession、stopSession)のメソッドと、リモート コントロールとデバイス全体の共有の設定メソッドを提供します。次のサブセクションでは、コア統合フローについて説明します。

すべての画面共有メソッドのメソッド シグネチャとパラメータの詳細については、Headless Mobile SDK - Android API リファレンスをご覧ください。

画面共有を初期化して構成する

画面共有キーを指定して、アプリのセットアップ時に画面共有サービスを初期化します。

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

画面共有の利用資格を確認する

画面共有機能を有効にする前に、現在のチャットで利用可能かどうかを確認します。

import com.ccaiplatform.ccaiscreenshare.ScreenShareManager

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

画面共有リクエストを処理する

ScreenShareManager を使用して画面共有リクエスト処理を実装します。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
    )
}

画面共有の状態変更を処理する

ScreenShareCallbacks に提供された onSessionStateChanged コールバックを通じて、画面共有セッションの状態の変化をリッスンします。

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

ScreenShareCallbacks リファレンス

コールバック 説明
onSessionStateChanged 画面共有セッションの状態が変化したとき(INACTIVE、PENDING、ACTIVE)に呼び出されます。
onSessionCreationError セッションの作成に失敗した場合(ネットワークの問題、サービスが利用できないなど)に呼び出されます。
onSessionActivationRequest セッションを有効にする準備ができたときに呼び出されます。ScreenShareManager.activateSession() を呼び出して共有を開始します。ユーザーが開始したセッションの場合は、まず同意プロンプトを表示します。エージェントが開始したセッションの場合は、すぐに有効にします。
onSessionRemoteControlRequest エージェントがユーザーの画面のリモート操作をリクエストしたときに呼び出されます。リモート コントロールを許可する前に同意を求めるプロンプトを表示します。
onSessionFullDeviceRequest エージェントがデバイス全体の共有をリクエストしたときに呼び出されます(下記のデバイス全体の画面共有を参照)。レスポンスで MediaProjection 同意フローをトリガーします。
onSessionDidSucceed セッションが正常にアクティブ化され、ストリーミングが開始された後に呼び出されます。これを使用して、UI を「画面共有中」の状態に更新します。

状態列挙型の定義と関連するすべての型については、Headless Mobile SDK - Android API リファレンスをご覧ください。

デバイス全体の画面共有(高度な設定)

デバイス全体の画面共有により、エージェントは、システム設定やアプリ間のナビゲーションなど、独自のアプリ以外のアプリの画面を表示できます。そのためには、Android の MediaProjection API を統合し、フォアグラウンド サービスを実行する必要があります。

権限とフォアグラウンド サービスを宣言する

必要な権限とフォアグラウンド サービスの宣言を 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>

画面をキャプチャする前に、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")
    }
}

フォアグラウンド サービスを実装する

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

デバイス全体の共有を有効にする

MediaProjection がアクティブになったら、画面共有サービスを通じてデバイス全体の共有を有効にします。

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

画面共有に関する Jetpack Compose の考慮事項

  1. 主要なインタラクティブ要素を Android ビューでラップする - リモートでクリックする必要がある領域については、AndroidView コンポーザブルを使用して、標準の Android ビュー コントロール(android.widget.Button など)としてラップします。これにより、Android ビューベースのパスを介してリモートクリックが確実に機能します。

  2. カスタム タッチ処理でリモート タッチを転送 - View に置き換えられない Compose 領域では、カスタム タッチ コールバックを使用して、リモート タッチイベントを Compose ロジックに転送します。

  3. プロダクト レベルのガイダンスまたはフォールバックを提供する - 「エージェントは閲覧のみ可能。ユーザーはローカルでタップする必要がある」ことを Compose 画面に明示的に表示するか、閲覧のみのアクセスを維持しながら、これらのページでのリモート コントロールの権限を自動的に制限します。

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

スケジュール設定された通話

スケジュールされた通話を使用すると、ユーザーは即時通話の順番を待つのではなく、将来の音声通話を予約できます。Android では、SDK は、空き状況の確認、時間帯の取得、通話の予約、通話のリスケジュール、通話のキャンセル、スケジューリング エラーの処理を行うための call-service メソッドを公開しています。

機能の検出

SDK が提供するもの: 選択したキューが将来の呼び出しの予約をサポートしているかどうかを示す VoiceCallChannel の scheduleEnabled フラグ。

作成するもの: スケジューリング エントリ ポイントを表示する前にこのフラグを確認するロジック。

実装例

キューの音声通話チャネルで scheduleEnabled を確認します。voiceCall != null をプロキシとして使用しないでください。キューは、スケジュールされた通話に対応していなくても、インスタント音声通話に対応できます。逆も同様です。

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
}

オプションの可用性チェック

時間帯選択ツールを表示する前に、選択したキューに予約可能な時間帯があるかどうかを確認できます。

実装例

hasScheduledTimeSlots(menuId:) を軽量な事前チェックとして使用します。false が返された場合は、ピッカーを開く代わりに、空の状態またはフォールバック オプションを表示します。

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

時間枠を取得する

選択したキューで予約可能な時間を取得します。SDK は、アプリがユーザーの現地タイムゾーンで表示できる Date 値を返します。

SDK が提供するもの: 選択したキューで利用可能なスロットのタイムスタンプ。既存のスケジュールされた呼び出しの再スケジュールをサポートするオプション。

構築するもの: カレンダー、時間選択ツール、タイムゾーンの表示、空の状態の処理、再試行の動作。

実装例

キュー メニュー ID を指定して getScheduledTimeSlots を呼び出します。スケジュールを変更するときは、既存のスケジュール設定された呼び出し ID を渡して、サービスが適切な可用性を返せるようにします。

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

スケジュールを変更する場合は、既存のスケジュールされたコール ID を渡します。

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

通話のスケジュールを設定する

ユーザーが時間帯を選択したら、callService を使用して予約通話を作成します。

SDK が提供するもの: 選択したスロットを予約し、スケジュール設定された通話レコードを返す createScheduledCall API。

作成するもの: E.164 電話番号の検証、確認 UI、スケジュールされた通話 ID のローカル永続性、スロットの競合または営業時間外の応答の復元。

実装例

選択したスロット、電話番号、キュー ID、RecordingPermission.NOT_ASKED を 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}")
    }
}

電話番号は E.164 形式(+14155551234 など)にする必要があります。

既存の電話相談のスケジュールを変更する

スケジュールを変更するには、スロットを取得し、既存の scheduled-call ID を使用して新しいスケジュール設定された通話を作成します。

SDK が提供するもの: 同じタイムスロットと作成 API。既存の予約を識別するために callIdForRescheduling が使用されます。

構築するもの: 「変更時間」の UI、保存されたスケジュール設定された通話 ID のローカル ルックアップ、新しい予約の作成後の確認ステータスの更新。

実装例

既存の通話 ID を getScheduledTimeSlots と 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}")
    }
}

スケジュール設定した通話をキャンセルする

ユーザーが予定されている通話をキャンセルした場合は、cancelScheduledCall を呼び出して、ローカルに保存されている通話 ID をクリアします。

SDK が提供するもの: スケジュールされた呼び出し ID のキャンセル API。

構築するもの: 確認プロンプト、キャンセル成功またはエラー UI、ローカルに保存された今後の通話状態のクリーンアップ。

実装例

保存した ID を使用して cancelScheduledCall(callId:) を呼び出し、アプリのストレージから ID を削除します。

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

メール

SDK が提供するもの: その特定のキュー用に構成された宛先メールアドレスと、管理者がユーザーに表示させたい指示(「口座番号を含めてください」など)。

作成するもの: メール作成画面と、その完了を追跡するロジック。独自のカスタム フォームを作成することも、Android の組み込み Intent.ACTION_SENDTO を使用してユーザーが選択したメール クライアントを起動することもできます。

実装例

このコードは、QueueMenu の SDK の Channels オブジェクトからサポート メールアドレスを取得します。組み込みの Android メール クライアントを開き、メール クライアントがインストールされていない場合を処理します。

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

SDK が提供するもの: 管理ポータルで設定されたとおりの URL(会社のヘルプセンター、払い戻しポリシー、パートナー アプリなど)とその表示タイトルのリスト。

作成するもの: ユーザーがリンクをタップしたときに実行するロジック。アプリ内ブラウザで開く(Android のカスタムタブを使用)か、デフォルトのシステム ブラウザを起動します。

ユーザーがメニューから外部リンクをタップすると、このコードは SDK から提供された URL 文字列を抽出し、Chrome カスタムタブを使用してアプリ内ブラウザでウェブページを表示します。カスタムタブは、ブラウザアプリ全体を起動するよりも高速でシームレスなエクスペリエンスを提供しつつ、アドレスバーを表示して、ユーザーが外部コンテンツを閲覧していることを認識できるようにします。

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

フォールバック実装(システム ブラウザ): カスタムタブが利用できない場合や、よりシンプルなアプローチを希望する場合は、ユーザーのデフォルトのブラウザでリンクを開くことができます。

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

完全な統合の例

このコードは、SDK の Channels オブジェクトから回避リンクを読み取り、チャネル メニューでタップ可能なボタンとしてユーザーに表示します。

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 は、最上位の url / displayName フィールドと内部の links: List<ExternalDeflection>? 配列の両方を公開します。上記の例では、トップレベルのエントリのみがレンダリングされます。これは、単一リンクキューには十分です。管理者が 1 つのキューに複数の外部リンクを設定している場合は、deflectionLink.links を繰り返し処理し、有効なエントリごとに 1 つのボタンをレンダリングします。各 ExternalDeflection は独自の url と displayName を保持します。

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