
Cometchat Android V5 Calls
- 2 installs
- 70 repo stars
- Updated June 23, 2026
- cometchat/cometchat-skills
Skill for Claude Code agents to integrate CometChat calling into Android v5 apps with pre-built components and patterns.
About
CometChat skill bundle provides framework-specific patterns and components for adding chat and calling to React, React Native, Angular, Android, Flutter, and iOS projects. Agents use it to generate integration code automatically.
- Framework detection and pattern selection (Vite, CRA, Next.js, etc)
- 40+ pre-configured features and 60+ component catalog
Cometchat Android V5 Calls by the numbers
- 2 all-time installs (skills.sh)
- Ranked #942 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cometchat/cometchat-skills --skill cometchat-android-v5-callsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 70 |
| Last updated | June 23, 2026 |
| Repository | cometchat/cometchat-skills ↗ |
What it does
Skill for Claude Code agents to integrate CometChat calling into Android v5 apps with pre-built components and patterns.
Files
Purpose
Production-grade voice + video calling for native Android v5. Loaded by the cometchat-calls dispatcher when the project is Android v5 (detected from chat-sdk-android:4.x / chatuikit-android:5.x or asked when greenfield). Operates in two modes:
- Standalone — calls is the product. No
chatuikit-androidUI Kit; justchat-sdk-android(for signaling) +calls-sdk-android+ your own UI surfaces (CallButton on profile, CallLogsActivity, OngoingCallActivity). - Additive — calls layered onto an existing v5 chat integration. The kit's
CometChatMessageHeaderalready exposes call buttons; this skill wires them to the Calls SDK and mounts the globalIncomingCalllistener at app root.
Read these other skills first:
cometchat-calls— the dispatcher (mode selection, hard rules, anti-patterns)cometchat-android-v5-core— Chat SDK init, login order,local.properties+BuildConfigcredential conventions, Application class wiring
Ground truth:
- SDK source —
~/Downloads/calls-sdk/calls-sdk-android-5/sdk/ - Sample app —
~/Downloads/calls-sdk/calls-sdk-android-5/samples/ - Pre-authored topic docs —
references/in this skill (16 docs, ~2300 lines, audited againstcalls-sdk-android@5.0.0-beta.2) - Public docs — https://www.cometchat.com/docs/calls/android/overview
---
How to use this skill
This SKILL.md is the index + hard rules + Android-specific gotchas. Deep topic content lives in references/. Always read this file end-to-end first; load references on demand.
| Topic | Reference file | When to load |
|---|---|---|
| SDK setup (Cloudsmith, init, permissions, Jetifier) | references/setup.md | Step 1 of every integration |
Joining a session (SessionSettingsBuilder, voice vs video) | references/join-session.md | Step 2 — every integration |
| Dual-SDK ringing (Chat SDK + Calls SDK together) | references/ringing-integration.md | Standalone or additive — every integration with peer-to-peer call flow |
All SessionSettingsBuilder options (layouts, mode, hide buttons) | references/session-settings.md | When customizing in-call UI behavior |
| Event listeners (status, participant, media, button-click, layout) | references/event-listeners.md | When wiring call lifecycle to app state |
| Call history list | references/call-logs.md | When adding /calls route or in-app history |
| Recording (auto-start, recording events) | references/recording.md | Feature add |
| Screen sharing (viewer + presenter status) | references/screen-sharing.md | Feature add |
| Picture-in-picture | references/picture-in-picture.md | Feature add |
| Foreground service for ongoing calls | references/background-handling.md | Required — every standalone integration on Android 14+ |
| VoIP push (ConnectionService + FCM high-priority + PhoneAccount) | references/voip-calling.md | Required — every standalone integration; optional but strongly recommended in additive |
| Audio controls (mute/unmute, device switching) | references/audio-controls.md | Default UI customization |
| Video controls (camera on/off, switch camera) | references/video-controls.md | Default UI customization |
| Participant management (mute/kick/raise hand) | references/participant-management.md | Group calls / moderator features |
| Custom UI (control panel, participant list, layout) | references/custom-ui.md | When the default UI doesn't fit |
| In-call chat (messaging during active session) | references/in-call-chat.md | Feature add |
references/README.md is a skim-friendly index of the same.
---
1. The seven hard rules
These are the production-grade non-negotiables from the cometchat-calls dispatcher, specialized for Android v5. Every integration this skill writes must satisfy all seven.
1.0 Calls SDK login is its own step (v5+)
The v5 Calls SDK has its own auth state, separate from the Chat SDK. After CometChat.login(uid, AUTH_KEY) succeeds, you MUST also call CometChatCalls.login(uid, AUTH_KEY, ...) — without it, the FIRST calls API call (initiateCall, joinSession, generateToken) throws "auth token cannot be null".
import com.cometchat.calls.core.CometChatCalls
import com.cometchat.calls.exceptions.CometChatException as CallsException
import com.cometchat.calls.model.CallUser // ← callback type, NOT chat User
// ✓ RIGHT — chat login first, then calls login
CometChat.login(uid, AUTH_KEY, object : CometChat.CallbackListener<User>() {
override fun onSuccess(user: User) {
CometChatCalls.login(uid, AUTH_KEY,
object : CometChatCalls.CallbackListener<CallUser>() {
override fun onSuccess(callUser: CallUser) { /* both ready */ }
override fun onError(e: CallsException) { /* surface */ }
})
}
override fun onError(e: CometChatException) { /* surface */ }
})Surprises:
- The chat-side
Userobject does NOT exposeauthTokenas a Kotlin property or Java getter on Android. Don't tryuser.authToken— use the(uid, apiKey)overload for dev, or fetch the auth token from your backend for production. - The Calls SDK callback returns
com.cometchat.calls.model.CallUser, NOTcom.cometchat.chat.models.User. Importing the wrong type produces "Type mismatch" at compile time. - The Calls SDK does NOT persist login across launches the way the Chat SDK does. Even if
CometChat.getLoggedInUser()returns a non-null user on cold start, you still need to callCometChatCalls.loginagain before any calls API works.
This trapped a real smoke run. The chat skill's loginAfter pattern doesn't transfer to calls; this is calls-specific.
1.1 Dual-SDK contract — Call lives in two places
The Chat SDK initiates ringing (CometChat.initiateCall(...)); the Calls SDK runs the WebRTC session (CometChatCalls.joinSession(...)). They are NOT interchangeable, and there are two `Call` classes with the same simple name:
- `com.cometchat.chat.core.Call` — Chat SDK. Used by
initiateCall,acceptCall,rejectCall. CarriessessionId,receiver,receiverType,callType. This is the one you almost always want. - `com.cometchat.chat.models.Call` — Chat SDK message model. Returned in conversation/message-list contexts. Different shape; rarely the right import in calls code.
// ✓ RIGHT — initiate ringing
import com.cometchat.chat.core.Call
import com.cometchat.chat.core.CometChat
val outgoing = Call(receiverUid, CometChatConstants.RECEIVER_TYPE_USER, CometChatConstants.CALL_TYPE_VIDEO)
CometChat.initiateCall(outgoing, object : CometChat.CallbackListener<Call>() {
override fun onSuccess(initiated: Call) {
// initiated.sessionId is what the Calls SDK will join
}
override fun onError(e: CometChatException) { /* surface to UI */ }
})// ✓ RIGHT — join the WebRTC session after the receiver accepts
import com.cometchat.calls.core.CometChatCalls
import com.cometchat.calls.core.CallSession
import com.cometchat.calls.types.SessionType
// SessionSettingsBuilder is a NESTED class on CometChatCalls — access it via
// CometChatCalls.SessionSettingsBuilder, NOT a top-level import.
// `import com.cometchat.calls.core.SessionSettingsBuilder` fails: no such class.
val settings = CometChatCalls.SessionSettingsBuilder()
.setSessionType(SessionType.VIDEO)
.setIsAudioOnly(false)
.build()
// 4-arg signature: sessionId (or token), settings, RelativeLayout container, callback.
CometChatCalls.joinSession(sessionId, settings, callContainer,
object : CometChatCalls.CallbackListener<CallSession>() {
override fun onSuccess(callSession: CallSession) {
// Hold onto callSession — in-call APIs (mute, video, layout, leave) live on it.
}
override fun onError(e: CometChatException) { /* surface */ }
})// ✗ WRONG — wrong Call class
import com.cometchat.chat.models.Call // message-model Call, not the core Call
val c = Call(...) // compile-time errors on shape; or worse, runtime ambiguity1.2 VoIP push is wired, not documented
Standalone-mode integration must ship working VoIP push: ConnectionService + FCM high-priority data messages + a registered PhoneAccount. Without it, missed calls don't ring → the integration isn't a product.
The full implementation is in references/voip-calling.md (~526 lines, the deepest doc in this skill). This skill's standalone-mode scaffold (Section 4) writes:
- A
MyConnectionServiceextendingandroid.telecom.ConnectionService - A
PhoneAccountHandleregistered inApplication.onCreate() - A high-priority FCM
FirebaseMessagingServicethat listens for incoming-call data messages MANAGE_OWN_CALLS+BIND_TELECOM_CONNECTION_SERVICEpermissions inAndroidManifest.xml- A
placeIncomingCallflow that hands off to ConnectionService so the OS rings (lock-screen UI, hardware buttons)
In additive mode, this is opt-in but strongly recommended — without it, the app must be foregrounded for incoming calls to ring, which contradicts user expectations.
1.3 Foreground service type — the silent crash
Android 14+ silently terminates ongoing-call foreground services that don't declare a correct foregroundServiceType. The Calls SDK ships CometChatOngoingCallService, but the integration must register it correctly:
<!-- AndroidManifest.xml -->
<service
android:name="com.cometchat.calls.service.CometChatOngoingCallService"
android:foregroundServiceType="phoneCall|microphone|camera"
android:exported="false" />Common failure mode: copying older sample-app manifests that omit phoneCall (the type that allows the OS to keep the service alive in low-memory). On Android 14+, the call dies with ForegroundServiceStartNotAllowedException — visible in adb logcat, invisible in-app.
Full background-handling guide in references/background-handling.md.
1.4 Server-minted auth tokens for calls in production
The Calls SDK consumes the same auth token the Chat SDK uses. In dev, an Auth Key is fine. In production:
- Mint a per-user token via the CometChat REST API on your server
- Hand it to the client; client calls
CometChat.login(authToken, callback)(Chat SDK) — Calls SDK reads the same auth context - Never embed Auth Key in `local.properties` for production builds. The skill's setup writes it for dev and the production-mode flow (handled by
cometchat-android-v5-production) replaces it with the token-endpoint pattern.
This rule mirrors the chat dispatcher's auth rule — cometchat-android-v5-core already enforces it.
1.5 Hangup cleanup — the camera light
The most common "looks fine in dev, fails review" bug: camera light stays on after hangup, or microphone keeps recording until the activity is destroyed.
Required teardown when ending a call:
override fun onCallEnded(call: CallSession) {
CallSession.getInstance().leaveSession() // 1. End the Calls SDK session (endSession() doesn't exist on Android)
callContainer?.removeAllViews() // 2. Detach the WebRTC view
audioManager?.mode = AudioManager.MODE_NORMAL // 3. Release audio routing
audioManager?.abandonAudioFocusRequest(focusReq) // 4. Abandon audio focus
stopService(Intent(this, MyOngoingCallService::class.java)) // 5. Stop foreground service
finish() // 6. Pop the call activity
}Skipping any of these strands a system resource. The verification step (Section 9) checks that all six are present in the call-end path.
1.6 Permissions with rationale
Standalone-mode integration prompts for four permissions, each with a rationale string:
<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <!-- Android 13+ -->
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" /> <!-- VoIP -->
<uses-permission android:name="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
tools:ignore="ProtectedPermissions" /> <!-- VoIP -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" /> <!-- Android 14+ -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <!-- Android 14+ -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" /> <!-- Android 14+ -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.INTERNET" />The runtime request (ActivityResultContracts.RequestMultiplePermissions) must include a denial-rationale dialog — Android lints this.
1.7 IncomingCall mounted at app root in standalone mode
In standalone mode, the CometChat.addCallListener(...) registration must happen in the Application.onCreate() (not in the foreground activity), so calls ring even when the app is backgrounded or the foreground activity has been destroyed. The listener routes to the ConnectionService (rule 1.2) which presents the OS-level incoming-call UI.
In additive mode (alongside chat), the listener can live in a CallsLifecycleObserver registered with the application's LifecycleOwner — the chat integration's CometChatActivity may be destroyed across configuration changes, but the observer survives.
---
2. Setup (always)
Detailed walkthrough in references/setup.md. Summary:
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://dl.cloudsmith.io/public/cometchat/cometchat/maven/") }
}
}
// app/build.gradle.kts
dependencies {
implementation("com.cometchat:chat-sdk-android:4.0.+") // signaling
implementation("com.cometchat:calls-sdk-android:5.0.+") // WebRTC session
// additive mode also has chatuikit-android already on the classpath; do not re-add
}
// gradle.properties
android.useAndroidX=true
android.enableJetifier=trueInit in Application.onCreate():
class App : Application() {
override fun onCreate() {
super.onCreate()
// 1. Chat SDK init (signaling — required for ringing)
val appSettings = AppSettings.AppSettingsBuilder()
.subscribePresenceForAllUsers()
.setRegion(BuildConfig.COMETCHAT_REGION)
.build()
CometChat.init(this, BuildConfig.COMETCHAT_APP_ID, appSettings, /* callback */)
// 2. Calls SDK init — must come after Chat SDK init
val callAppSettings = CallAppSettingsBuilder()
.setAppId(BuildConfig.COMETCHAT_APP_ID)
.setRegion(BuildConfig.COMETCHAT_REGION)
.build()
CometChatCalls.init(this, callAppSettings, /* callback */)
// 3. Standalone mode: register PhoneAccount + global call listener (rules 1.2 + 1.7)
registerPhoneAccount()
CometChat.addCallListener(LISTENER_ID, GlobalCallListener)
}
}---
3. Components catalog (Calls SDK only — no UI Kit in standalone)
The Calls SDK ships these primitives. Names are stable across v5.x:
| Class / type | Purpose | Where it lives |
|---|---|---|
CometChatCalls | Top-level facade — init, joinSession, endSession, generateToken | com.cometchat.calls.core |
CallAppSettingsBuilder | Init-time config (appId, region, host) | com.cometchat.calls.core |
CometChatCalls.SessionSettingsBuilder | Per-session config (layout, type, hide buttons, audio mode, recording) — nested class, access as CometChatCalls.SessionSettingsBuilder() | |
CometChatOngoingCallService | Foreground service for active calls | com.cometchat.calls.service |
SessionType | VOICE / VIDEO (NOT "AUDIO") | com.cometchat.calls.types |
LayoutType | TILE / SIDEBAR / SPOTLIGHT | com.cometchat.calls.types |
CallLogRequest.CallLogRequestBuilder | Paginated call history fetcher | com.cometchat.calls.core |
CometChatCallsEventsListener | Lifecycle + media + participant + button-click | com.cometchat.calls.listeners |
In additive mode, chatuikit-android's CometChatMessageHeader already exposes call buttons — see cometchat-android-v5-components for the kit-side view.
Component-level deep dives: references/audio-controls.md, references/video-controls.md, references/participant-management.md, references/custom-ui.md.
---
4. Standalone integration (calls is the product)
When product === "voice-video" and there is no existing chat integration.
Split by calling mode:
4a. Standalone — Session mode (meeting-room UX, no ringing)
Calls SDK ONLY. NO Chat SDK, NO ConnectionService, NO FCM-for-VoIP. Matches ~/Downloads/calls-sdk/calls-sdk-android-5/samples/sample-app/. Scaffold:
1. `Application` class — CometChatCalls.init(...) ONLY in onCreate. No CometChat.init, no PhoneAccount, no ConnectionService. 2. `JoinSessionActivity` — UID picker + Start/Join meeting + state. 3. `CallActivity` — Single-call CometChatCalls.joinSession(sessionId, sessionSettings, container, CallbackListener). SessionStatusListener + ButtonClickListener registered on the CallSession from onSuccess. CometChatOngoingCallService.launch/abort. See references/call-session.md. 4. `AndroidManifest.xml` — Camera + microphone permissions + FOREGROUND_SERVICE_MICROPHONE/CAMERA + CometChatOngoingCallService registration. NO ConnectionService. 5. App Links — https://yourapp.com/meet/<sessionId> deep-link routing.
Why no ConnectionService / no FCM-VoIP: session mode never receives an incoming-call FCM payload. No ringing.
4b. Standalone — Ringing mode (Chat SDK signaling + ConnectionService + FCM-VoIP)
Dual-SDK + telecom + push. Scaffold:
1. `Application` class — Chat SDK + Calls SDK init in onCreate, PhoneAccount registration, global call listener (rules 1.7, 1.2). 2. `CallButtonView` Kotlin component — voice + video buttons rendered next to a user profile / contact card. Calls CometChat.initiateCall(...) on tap, navigates to OutgoingCallActivity. 3. `OutgoingCallActivity` — shows the dialing UI; transitions to OngoingCallActivity when the receiver accepts (OutgoingCallStatusListener). 4. `OngoingCallActivity` — hosts the WebRTC view via CometChatCalls.joinSession(sessionId, settings, container, callback), handles all in-call controls (mute/camera/end), implements rule 1.5 teardown. 5. `CallLogsActivity` — /calls equivalent — paginated history via CallLogRequest.CallLogRequestBuilder. Tap a row → re-call. 6. `MyConnectionService` + `MyFirebaseMessagingService` — VoIP push end-to-end (rule 1.2). Wired in manifest with the right foregroundServiceType (rule 1.3). 7. Permission rationale dialog — ActivityResultContracts.RequestMultiplePermissions with denial-rationale strings (rule 1.6).
Detailed walkthrough: references/ringing-integration.md (dual-SDK setup), references/voip-calling.md (push end-to-end), references/background-handling.md (foreground service).
---
5. Additive integration (calls on top of existing chat)
When the project already has chatuikit-android integrated. The skill:
1. Patches `Application.onCreate()` — adds CometChatCalls.init(...) after the existing CometChat.init(...) block. Adds the global call listener. 2. Patches `AndroidManifest.xml` — adds the four FOREGROUND_SERVICE permissions (rule 1.6) and the CometChatOngoingCallService registration with the correct type (rule 1.3). 3. Wires `CometChatMessageHeader` call buttons — the kit's header already shows voice + video icons; the skill registers the listeners so they call CometChat.initiateCall (rule 1.1). 4. Adds an `IncomingCallListener` scoped to the application LifecycleOwner (rule 1.7) — survives chat-activity recreation. 5. VoIP push: opt-in. The skill asks the user before scaffolding ConnectionService — it's a substantial code addition, and additive-mode users may prefer to add it later. 6. Adds a `CallLogsFragment` that can be hosted alongside CometChatConversationsFragment in the existing tab/activity structure.
Important: do NOT re-add chat-sdk-android or chatuikit-android to build.gradle.kts — they're already there. Only add calls-sdk-android.
---
6. Anti-patterns
1. Calling `CometChatCalls.joinSession()` with the wrong `Call` import. Compile errors are obvious; the dangerous case is when an agent imports com.cometchat.chat.models.Call and casts it. Cross-reference rule 1.1.
2. Hardcoding `SessionType.AUDIO`. No such constant exists. The voice path is SessionType.VOICE. Listed here because it's the most common cargo-culted mistake — the iOS SDK uses "audio" terminology.
3. Registering the call listener in the foreground activity. Survives only as long as the activity does — meaning the app must be open for calls to ring. Rule 1.7 requires Application or a process-scoped LifecycleOwner.
4. Skipping `android.enableJetifier=true`. Without it, androidx.legacy.support artifacts inside the SDK throw Duplicate class android.support.v4.* at build. Rule lives in setup.
5. Omitting `foregroundServiceType="phoneCall"`. Silent crash on Android 14+ (rule 1.3). Common when copying older sample-app manifests.
6. Re-initializing the Calls SDK after every login. CometChatCalls.init() is process-scoped — call it once in Application.onCreate(). Re-init does not "refresh" the auth context; logout-handling lives in rule 1.4's token replay.
7. Using `addCallListener` without a stable listener ID. Two listeners with the same ID overwrite. Two listeners with different IDs both fire — easy duplicate-IncomingCall UI bug. Use a documented constant (const val LISTENER_ID = "global-call-listener").
---
7. Verification checklist
After scaffolding, verify (the skill writes Espresso-style smoke tests where possible; otherwise prompts the user to confirm):
Static (the agent checks before claiming done):
- [ ]
calls-sdk-androidinapp/build.gradle.ktsdependencies - [ ] Cloudsmith maven URL in
settings.gradle.kts - [ ]
android.useAndroidX=trueandandroid.enableJetifier=trueingradle.properties - [ ]
CometChat.initfollowed byCometChatCalls.initinApplication.onCreate - [ ] All four
RECORD_AUDIO/CAMERA/POST_NOTIFICATIONS/MANAGE_OWN_CALLSpermissions inAndroidManifest.xml - [ ] All three
FOREGROUND_SERVICE_*Android 14+ permissions inAndroidManifest.xml - [ ]
CometChatOngoingCallServicewithforegroundServiceType="phoneCall|microphone|camera" - [ ] Call listener registration uses a stable string ID
- [ ] Hangup path includes
endSession()+removeAllViews()+ audio-focus abandon + service stop + finish (rule 1.5) - [ ] Standalone only:
ConnectionServicesubclass +PhoneAccountregistration + FCMMessagingServicefor incoming-call data messages - [ ] Standalone only: Call listener registered in
Application.onCreate, not an activity
Runtime (real device — the skill prompts the user):
- [ ] Outgoing voice call connects and audio is two-way
- [ ] Outgoing video call connects and video is two-way
- [ ] Incoming call rings on lock screen with the device backgrounded (standalone) or with chat-activity destroyed (additive)
- [ ] Hangup releases the camera light and microphone within 2 seconds
- [ ] Call log entry appears after the call ends
- [ ] Permission rationale dialog appears the second time a permission is requested after denial
- [ ] On Android 14+ device: ongoing-call notification shows, swipe-up doesn't kill the call
---
8. Pointers to other skills
cometchat-calls— the dispatcher that loads this skillcometchat-android-v5-core— Chat SDK init, login, env conventionscometchat-android-v5-components—CometChatMessageHeadercall buttons (additive mode)cometchat-android-v5-push— FCM setup, notification handling (overlap with VoIP push but distinct)cometchat-android-v5-production— server-minted auth tokens, ProGuard rules for the Calls SDKcometchat-android-v5-troubleshooting— symptom-to-cause for the common failure modes (Jetifier, foregroundServiceType, MANAGE_OWN_CALLS not granted)
Adding calls to an existing chat integration (Android V5)
You have chatuikit-android (V5 cohort) working. Adding calls = calls-sdk-android + ConnectionService + foreground service permissions.
Read first: cometchat-android-v5-calls/SKILL.md — seven hard rules (init order, ConnectionService, foreground service type, hangup teardown).
---
Step 1 — Install Calls SDK from Cloudsmith
// settings.gradle.kts (or settings.gradle)
dependencyResolutionManagement {
repositories {
maven { url = uri("https://dl.cloudsmith.io/public/cometchat/cometchat/maven/") }
google()
mavenCentral()
}
}
// app/build.gradle.kts
dependencies {
implementation("com.cometchat:calls-sdk-android:5.+")
// existing chat-uikit dependency stays
}./gradlew :app:dependencies | grep calls-sdk-androidShould resolve to 5.x.x.
---
Step 2 — Permissions + manifest
<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA"/>
<application ...>
<service
android:name=".AppConnectionService"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
android:foregroundServiceType="microphone|camera"
android:exported="true">
<intent-filter>
<action android:name="android.telecom.ConnectionService"/>
</intent-filter>
</service>
</application>foregroundServiceType="microphone|camera" is mandatory on Android 14+.
---
Step 3 — Init order: chat → calls
In your existing Application.onCreate or your init activity:
import com.cometchat.calls.core.CometChatCalls
import com.cometchat.calls.core.CallAppSettings
// Existing chat init
CometChatUIKit.init(this, uikitSettings, object : CometChat.CallbackListener<String>() {
override fun onSuccess(s: String) {
// NEW: Calls init AFTER chat init
val callsSettings = CallAppSettings(appId = APP_ID, region = REGION)
CometChatCalls.init(callsSettings, object : CometChatCalls.CallbackListener<String>() {
override fun onSuccess(s: String) { /* both initialized */ }
override fun onError(e: CometChatException) { Log.e("App", "Calls init failed", e) }
})
}
override fun onError(e: CometChatException) {}
})---
Step 4 — Login both SDKs
After your existing login flow:
CometChat.login(uid, authKey, object : CometChat.CallbackListener<User>() {
override fun onSuccess(user: User) {
val authToken = user.authToken
CometChatCalls.login(authToken, object : CometChatCalls.CallbackListener<User>() {
override fun onSuccess(user: User) { /* calls SDK ready */ }
override fun onError(e: CometChatException) {}
})
}
override fun onError(e: CometChatException) {}
})---
Step 5 — IncomingCall via ConnectionService
Implement AppConnectionService (see cometchat-android-v5-calls/SKILL.md rule 4). The ConnectionService receives FCM data-message → calls addNewIncomingCall → OS shows incoming-call UI.
Plus an IncomingCallActivity that handles user action (accept/reject) and routes to your chat UI's call surface:
class IncomingCallActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Show over lock screen
setShowWhenLocked(true)
setTurnScreenOn(true)
// ... bind your incoming-call layout
}
}---
Step 6 — FCM token registration
Add a FirebaseMessagingService subclass and POST tokens to your server (see cometchat-android-v5-calls/references/server-fcm-voip.md):
class VoipMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
// POST to your server, keyed to current user UID
api.registerCallToken(currentUid, "android-fcm", token)
}
override fun onMessageReceived(message: RemoteMessage) {
if (message.data["type"] != "incoming_call") return
// Hand off to ConnectionService — see V5 server-fcm-voip reference
}
}Register in manifest:
<service
android:name=".VoipMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>---
Step 7 — Hangup teardown
fun endCall(sessionId: String) {
CometChatCalls.leaveSession()
CometChat.endCall(sessionId, object : CometChat.CallbackListener<Call>() { /* ... */ })
// ConnectionService disconnect
activeConnection?.setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
activeConnection?.destroy()
}---
Verification checklist
- [ ] Cloudsmith Maven repo added
- [ ]
calls-sdk-android:5.+in app/build.gradle - [ ] Manifest permissions: RECORD_AUDIO, CAMERA, MANAGE_OWN_CALLS, FOREGROUND_SERVICE_MICROPHONE/CAMERA
- [ ] AppConnectionService declared with
foregroundServiceType+ permission - [ ] Calls init runs AFTER chat init's onSuccess
- [ ] Calls login runs AFTER chat login's onSuccess
- [ ] FCM token registered + POSTed to server
- [ ] Hangup tears down all 3: leaveSession, endCall, ConnectionService disconnect
- [ ] Real-device smoke: app killed → caller dials → recipient phone rings on lock screen
- [ ] Run
cometchat verify --calls— should pass
---
Pointers
cometchat-react-calls/references/add-calls-to-existing-chat.md— canonicalcometchat-android-v5-calls/SKILL.md— seven hard rulescometchat-android-v5-calls/references/server-fcm-voip.md— server-side FCM
CometChat Calls SDK v5 — Audio Controls
Overview
Programmatically control the local microphone (mute/unmute) and audio output device (speaker, earpiece, Bluetooth, headphones) during an active call.
Key Imports
import com.cometchat.calls.core.CallSession
import com.cometchat.calls.model.AudioMode
import com.cometchat.calls.listeners.MediaEventsListenerImplementation
Mute / Unmute
val callSession = CallSession.getInstance()
callSession.muteAudio() // mute microphone
callSession.unMuteAudio() // unmute microphoneSwitch Audio Output
callSession.setAudioMode(AudioMode.SPEAKER) // loudspeaker
callSession.setAudioMode(AudioMode.EARPIECE) // phone earpiece
callSession.setAudioMode(AudioMode.BLUETOOTH) // connected Bluetooth device
callSession.setAudioMode(AudioMode.HEADPHONES) // wired headphonesListen for Audio Events
callSession.addMediaEventsListener(this, object : MediaEventsListener() {
override fun onAudioMuted() {
// Update mute button to "muted" state
}
override fun onAudioUnMuted() {
// Update mute button to "unmuted" state
}
override fun onAudioModeChanged(audioMode: AudioMode) {
when (audioMode) {
AudioMode.SPEAKER -> { /* update icon */ }
AudioMode.EARPIECE -> { /* update icon */ }
AudioMode.BLUETOOTH -> { /* update icon */ }
AudioMode.HEADPHONES -> { /* update icon */ }
}
}
// ... other required overrides
override fun onVideoPaused() {}
override fun onVideoResumed() {}
override fun onRecordingStarted() {}
override fun onRecordingStopped() {}
override fun onScreenShareStarted() {}
override fun onScreenShareStopped() {}
override fun onCameraFacingChanged(facing: CameraFacing) {}
})Initial Audio Settings (Pre-Session)
val sessionSettings = CometChatCalls.SessionSettingsBuilder()
.startAudioMuted(true) // join muted
.setAudioMode(AudioMode.SPEAKER) // initial output device
.hideToggleAudioButton(false) // show mute button
.hideAudioModeButton(false) // show audio mode button
.build()Gotchas
muteAudio()/unMuteAudio()only work during an active session- Audio mode changes trigger
onAudioModeChanged()onMediaEventsListener AudioModeenum:SPEAKER,EARPIECE,BLUETOOTH,HEADPHONES- For voice calls, default to
AudioMode.EARPIECE; for video calls, default toAudioMode.SPEAKER - Bluetooth audio mode only works when a Bluetooth device is connected
Sample App Reference
CallActivity.kt— SetsAudioMode.EARPIECEfor voice calls,AudioMode.SPEAKERfor video calls
CometChat Calls SDK v5 — Background Handling
Overview
CometChatOngoingCallService is a foreground service that keeps calls alive when users press HOME or switch apps. Shows an ongoing notification with tap-to-return and hangup actions.
Prerequisites
- Active call session
- Foreground service permissions in manifest
Key Imports
import com.cometchat.calls.services.CometChatOngoingCallService
import com.cometchat.calls.utils.OngoingNotification
import com.cometchat.calls.core.CallSession
import com.cometchat.calls.listeners.SessionStatusListener
import com.cometchat.calls.listeners.ButtonClickListenerImplementation
1. Manifest Permissions
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />2. Start/Stop Service
callSession.addSessionStatusListener(this, object : SessionStatusListener() {
override fun onSessionJoined() {
CometChatOngoingCallService.launch(this@CallActivity)
}
override fun onSessionLeft() {
CometChatOngoingCallService.abort(this@CallActivity)
finish()
}
override fun onConnectionClosed() {
CometChatOngoingCallService.abort(this@CallActivity)
finish()
}
override fun onSessionTimedOut() {
CometChatOngoingCallService.abort(this@CallActivity)
finish()
}
override fun onConnectionLost() {}
override fun onConnectionRestored() {}
})3. Handle Leave Button
callSession.addButtonClickListener(this, object : ButtonClickListener() {
override fun onLeaveSessionButtonClicked() {
endCall()
}
})4. Handle Remote Call End
Register a CometChat.CallListener to detect when the remote party ends the call, then clean up the foreground service:
CometChat.addCallListener(LISTENER_ID, object : CometChat.CallListener() {
override fun onCallEndedMessageReceived(call: Call?) {
val session = CallSession.getInstance()
if (session.isSessionActive) {
session.leaveSession()
}
CometChatOngoingCallService.abort(this@CallActivity)
runOnUiThread { finish() }
}
override fun onIncomingCallReceived(call: Call?) {}
override fun onOutgoingCallAccepted(call: Call?) {}
override fun onOutgoingCallRejected(call: Call?) {}
override fun onIncomingCallCancelled(call: Call?) {}
})5. Always Stop in onDestroy
override fun onDestroy() {
super.onDestroy()
CometChat.removeCallListener(LISTENER_ID)
CometChatOngoingCallService.abort(this)
}6. Custom Notification (Optional)
private fun buildCustomNotification(): Notification {
val channelId = "CometChat_Call_Ongoing_Conference" // must use this ID
val intent = Intent(this, CallActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_call)
.setContentTitle("Ongoing Call")
.setContentText("Tap to return to your call")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.build()
}
// Set before launching service
OngoingNotification.buildOngoingConferenceNotification(buildCustomNotification())
CometChatOngoingCallService.launch(this)7. VoIP Integration — Additional Cleanup (Optional)
When using background handling with VoIP calling (ConnectionService + TelecomManager), also disconnect the CallConnection and cancel any VoIP notifications on every terminal state:
// In each terminal callback (onSessionLeft, onSessionTimedOut, onConnectionClosed, onCallEndedMessageReceived):
CometChatOngoingCallService.abort(this@CallActivity)
CallConnectionHolder.endCall()
CallNotificationManager.cancelNotification(this@CallActivity)
finish()See the sample-app-voip CallActivity for the full VoIP-integrated pattern.
API Reference
| Method | Description |
|---|---|
CometChatOngoingCallService.launch(context) | Start foreground service |
CometChatOngoingCallService.abort(context) | Stop foreground service |
OngoingNotification.buildOngoingConferenceNotification(notification) | Set custom notification (call before launch()) |
Gotchas
- The notification channel ID must be
"CometChat_Call_Ongoing_Conference" - Always call
abort()inonDestroy()to prevent leaked services - Always remove
CometChat.CallListenerinonDestroy()to prevent leaks - This is for keeping active calls alive — for receiving calls when app is killed, use VoIP Calling
- Call
launch()only afteronSessionJoined()— not before joining - Custom notification must be set before calling
launch() - When using VoIP (ConnectionService), also call
CallConnectionHolder.endCall()in every terminal state to properly disconnect the Telecom connection
Sample App Reference
sample-app-ringing—CallActivity.kt— basic foreground service pattern withlaunch()inonSessionJoined(),abort()inonSessionLeft()andonDestroy()sample-app-voip—CallActivity.kt— full VoIP-integrated pattern with foreground service + CallConnection cleanup + notification cancellation on every terminal state
Call layouts on Android V5 (Views)
Same three layouts (TILE/SIDEBAR/SPOTLIGHT). Android V5 uses Java/Kotlin builders + MaterialButtonToggleGroup for the switcher (Material Components for Android).
Canonical docs: https://www.cometchat.com/docs/calls/android/call-layouts Read first: cometchat-react-calls/references/call-layouts.md — layout matrix + when to lock.
---
SDK API
import com.cometchat.calls.constants.CometChatCallsConstants
import com.cometchat.calls.core.CallSettingsBuilder
import com.cometchat.calls.core.CometChatCalls
val settings = CallSettingsBuilder(activity)
.setSessionType(CometChatCallsConstants.SESSION_TYPE_VIDEO)
.setLayout(CometChatCallsConstants.LAYOUT_TILE) // _TILE | _SIDEBAR | _SPOTLIGHT
.setHideChangeLayoutButton(false)
.build()
// Mid-call switch
CometChatCalls.setLayout(CometChatCallsConstants.LAYOUT_SPOTLIGHT)
// Listen
val callsEventListener = object : CometChatCallsEventsListener {
override fun onCallLayoutChanged(layout: String) {
activity.runOnUiThread {
// Update segmented button state
}
}
}---
XML — MaterialButtonToggleGroup
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/layoutToggle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:singleSelection="true"
app:selectionRequired="true"
android:contentDescription="@string/call_layout">
<com.google.android.material.button.MaterialButton
android:id="@+id/layoutTile"
style="@style/Widget.Material3.Button.OutlinedButton"
android:text="@string/layout_tile" />
<com.google.android.material.button.MaterialButton
android:id="@+id/layoutSidebar"
style="@style/Widget.Material3.Button.OutlinedButton"
android:text="@string/layout_sidebar" />
<com.google.android.material.button.MaterialButton
android:id="@+id/layoutSpotlight"
style="@style/Widget.Material3.Button.OutlinedButton"
android:text="@string/layout_spotlight" />
</com.google.android.material.button.MaterialButtonToggleGroup>---
Wire-up in Activity
binding.layoutToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener
val layout = when (checkedId) {
R.id.layoutTile -> CometChatCallsConstants.LAYOUT_TILE
R.id.layoutSidebar -> CometChatCallsConstants.LAYOUT_SIDEBAR
R.id.layoutSpotlight -> CometChatCallsConstants.LAYOUT_SPOTLIGHT
else -> return@addOnButtonCheckedListener
}
CometChatCalls.setLayout(layout)
}
// Sync from kit's switcher
override fun onCallLayoutChanged(layout: String) {
runOnUiThread {
val id = when (layout) {
CometChatCallsConstants.LAYOUT_TILE -> R.id.layoutTile
CometChatCallsConstants.LAYOUT_SIDEBAR -> R.id.layoutSidebar
CometChatCallsConstants.LAYOUT_SPOTLIGHT -> R.id.layoutSpotlight
else -> return@runOnUiThread
}
binding.layoutToggle.check(id)
}
}---
Anti-patterns
Web sister rules apply, plus Android-specific:
1. `Spinner` instead of `MaterialButtonToggleGroup`. Spinner hides options; toggle group is the radio-group native pattern in Material. 2. `onCheckedChange` without `isChecked` guard. Fires twice (once for unchecked, once for checked) — spurious setLayout calls. 3. Forgetting `runOnUiThread` in the listener. Crash on view updates from background thread.
---
Verification checklist
- [ ] Initial layout via
setLayouton builder - [ ]
MaterialButtonToggleGroupwithsingleSelection="true" - [ ]
addOnButtonCheckedListenerguards onisChecked - [ ]
onCallLayoutChangedupdates toggle group on UI thread - [ ] Real-device smoke: switcher cycles all 3, kit's switcher syncs custom UI
---
Pointers
cometchat-react-calls/references/call-layouts.md— sistercometchat-android-v5-callsSKILL.md- Canonical docs: https://www.cometchat.com/docs/calls/android/call-layouts
CometChat Calls SDK v5 — Call Logs
Overview
Retrieve call history using CallLogRequest with pagination, filtering by type, status, direction, recordings, and specific users/groups.
Key Imports
import com.cometchat.calls.core.CometChatCalls
import com.cometchat.calls.model.CallLog
import com.cometchat.calls.model.CallLogRequest
import com.cometchat.calls.model.Recording
import com.cometchat.calls.exceptions.CometChatExceptionImplementation
Basic Fetch
val callLogRequest = CallLogRequest.CallLogRequestBuilder()
.setLimit(30)
.build()
callLogRequest.fetchNext(object : CometChatCalls.CallbackListener<List<CallLog>>() {
override fun onSuccess(callLogs: List<CallLog>) {
for (log in callLogs) {
Log.d(TAG, "Session: ${log.sessionID}, Duration: ${log.totalDuration}, Status: ${log.status}")
}
}
override fun onError(e: CometChatException) {
Log.e(TAG, "Error: ${e.message}")
}
})Filtered Queries
// Video calls only
CallLogRequest.CallLogRequestBuilder().setSessionType("video").setLimit(20).build()
// Calls with recordings
CallLogRequest.CallLogRequestBuilder().setHasRecording(true).build()
// Missed incoming calls
CallLogRequest.CallLogRequestBuilder().setCallStatus("missed").setCallDirection("incoming").build()
// Calls with a specific user
CallLogRequest.CallLogRequestBuilder().setUid("user_id").build()
// Meeting calls only
CallLogRequest.CallLogRequestBuilder().setCallCategory("meet").build()Pagination
// Forward pagination
callLogRequest.fetchNext(listener)
// Backward pagination
callLogRequest.fetchPrevious(listener)Access Recordings
for (callLog in callLogs) {
if (callLog.isHasRecording) {
callLog.recordings?.forEach { recording ->
Log.d(TAG, "URL: ${recording.recordingURL}")
Log.d(TAG, "Duration: ${recording.duration} seconds")
}
}
}CallLog Properties
| Property | Type | Description |
|---|---|---|
sessionID | String | Session identifier |
type | String | video or audio |
status | String | ended, missed, rejected, cancelled, etc. |
callCategory | String | call or meet |
totalDuration | String | Human-readable duration |
totalParticipants | int | Participant count |
hasRecording | boolean | Whether recorded |
recordings | List\<Recording\> | Recording objects |
initiator | CallEntity | Who started the call |
receiver | CallEntity | Who received the call |
Gotchas
CallLogRequestis from the Calls SDK, not the Chat SDKsetSessionType()takes lowercase strings:"video"or"audio"setCallDirection()takes"incoming"or"outgoing"setCallCategory()takes"call"or"meet"- Create a new
CallLogRequestto reset pagination - The sample app uses Chat SDK's
MessagesRequestfor call logs — the Calls SDK'sCallLogRequestprovides richer data
Sample App Reference
Repository.kt—fetchCallLogs()(uses Chat SDK approach)- For Calls SDK approach, use
CallLogRequestas shown above
Call session — joinSession with no ringing (Android V5 / Views)
Server-generated sessionId, both parties enter it. Customer-validated against ~/Downloads/calls-sdk/calls-sdk-android-5/samples/sample-app/src/main/kotlin/com/cometchat/samplecalls/ui/activity/CallActivity.kt.
Read first: cometchat-react-calls/references/call-session.md — cross-platform architecture (sessionId strategies, server-side authorization). Then come back here for the Android shape.
---
Hard rules (Android-specific overrides on top of the cross-platform rules)
1. `CometChatCalls.joinSession(sessionId, sessionSettings, container, CallbackListener<CallSession>)` takes the sessionId DIRECTLY — token generation happens internally. No separate generateToken call required. Matches the upstream sample. 2. `CometChatCalls.SessionSettingsBuilder()` is the canonical settings shape (nested class on CometChatCalls). Chained .setTitle().startVideoPaused(false).startAudioMuted(false).build(). NOT SessionSettings(sessionType:, layout:) — that constructor does not exist on Android. 3. `SessionStatusListener` + `ButtonClickListener` are abstract classes registered on the `CallSession` instance returned in onSuccess. NOT CometChatCalls.addEventListener(CallEvent.SESSION_LEFT) — that does not exist. 4. `CometChatOngoingCallService.launch(this)` + `.abort(this)` manage the platform foreground service. Call launch() in onSessionJoined; call abort() in EVERY termination path (onSessionLeft, onConnectionClosed, onSessionTimedOut, onDestroy, endCall). Service notification persists otherwise. 5. For standalone session-only integrations, the Chat SDK is OPTIONAL. The upstream Android sample only depends on com.cometchat.calls-sdk-android. Keep CometChat.init / CometChat.login only for additive (chat + calls) integrations. 6. Runtime permission request for CAMERA + RECORD_AUDIO (Android 6.0+). The manifest entries are necessary but not sufficient on modern Android.
---
CallActivity (canonical XML/Views shape)
package com.example.calls
import android.os.Bundle
import android.util.Log
import android.widget.RelativeLayout
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import com.cometchat.calls.core.CallSession
import com.cometchat.calls.core.CometChatCalls
import com.cometchat.calls.exceptions.CometChatException
import com.cometchat.calls.listeners.ButtonClickListener
import com.cometchat.calls.listeners.SessionStatusListener
import com.cometchat.calls.services.CometChatOngoingCallService
class CallActivity : AppCompatActivity() {
companion object {
private const val TAG = "CallActivity"
const val EXTRA_SESSION_ID = "session_id"
}
private lateinit var callContainer: RelativeLayout
private lateinit var sessionId: String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_call)
sessionId = intent.getStringExtra(EXTRA_SESSION_ID) ?: run {
Toast.makeText(this, "Invalid session ID", Toast.LENGTH_SHORT).show()
finish()
return
}
callContainer = findViewById(R.id.call_container)
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() = endCall()
})
joinSession()
}
private fun joinSession() {
val sessionSettings = CometChatCalls.SessionSettingsBuilder()
.setTitle("CometChat Meeting")
.startVideoPaused(false)
.startAudioMuted(false)
.build()
CometChatCalls.joinSession(
sessionId,
sessionSettings,
callContainer,
object : CometChatCalls.CallbackListener<CallSession>() {
override fun onSuccess(callSession: CallSession) {
Log.d(TAG, "Joined session: $sessionId")
setupCallListeners(callSession)
}
override fun onError(e: CometChatException) {
Log.e(TAG, "Failed to join session: ${e.message}")
runOnUiThread {
Toast.makeText(this@CallActivity, "Failed to join: ${e.message}", Toast.LENGTH_LONG).show()
finish()
}
}
}
)
}
private fun setupCallListeners(callSession: CallSession) {
callSession.addSessionStatusListener(this, object : SessionStatusListener() {
override fun onSessionJoined() {
CometChatOngoingCallService.launch(this@CallActivity)
}
// All termination events funnel through ONE guarded handleTermination().
// The v5 Calls SDK fires onSessionLeft + onConnectionClosed in sequence
// on every hangup. Activity.finish() is idempotent (safe to call multiple
// times) but abort() isn't — multiple abort calls log warnings and waste
// foreground-service teardown work.
override fun onSessionLeft() = handleTermination()
override fun onConnectionClosed() = handleTermination()
override fun onSessionTimedOut() = handleTermination()
override fun onConnectionLost() {
runOnUiThread {
Toast.makeText(this@CallActivity, "Connection lost", Toast.LENGTH_SHORT).show()
}
}
override fun onConnectionRestored() {
runOnUiThread {
Toast.makeText(this@CallActivity, "Connection restored", Toast.LENGTH_SHORT).show()
}
}
})
callSession.addButtonClickListener(this, object : ButtonClickListener() {
override fun onLeaveSessionButtonClicked() = endCall()
})
}
private var isTerminating = false
private fun handleTermination() {
if (isTerminating) return
isTerminating = true
runOnUiThread {
CometChatOngoingCallService.abort(this)
if (!isFinishing) finish()
}
}
private fun endCall() {
val callSession = CallSession.getInstance()
if (callSession.isSessionActive) callSession.leaveSession()
handleTermination()
}
override fun onDestroy() {
super.onDestroy()
CometChatOngoingCallService.abort(this)
}
}XML (res/layout/activity_call.xml):
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/call_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black" />Why this shape:
- Single-call `joinSession(sessionId, settings, container, callback)` — token generation is internal. Customers don't manage tokens; the SDK does.
- `CometChatCalls.SessionSettingsBuilder()` (nested class) — the outer namespacing matters; raw
SessionSettingsBuilder()won't resolve. - Listener-on-`CallSession`-instance pattern — register against the
CallSessionfromonSuccess, NOT against the globalCometChatCallssingleton. Each session has its own listener registry. - `onSessionLeft`, `onConnectionClosed`, `onSessionTimedOut` ALL terminate the activity. All three are real termination paths.
- `CometChatOngoingCallService.launch/abort` — required for Android 14+ foreground-service compliance with
FOREGROUND_SERVICE_MICROPHONE. Skippingabort()leaves the persistent notification.
---
Manifest requirements
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<activity android:name=".CallActivity" />
<service
android:name="com.cometchat.calls.services.CometChatOngoingCallService"
android:foregroundServiceType="microphone|camera"
android:exported="false" />
</application>---
Runtime permissions (Kotlin)
val permLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { perms ->
val granted = perms[Manifest.permission.CAMERA] == true &&
perms[Manifest.permission.RECORD_AUDIO] == true
if (granted) {
// proceed to call
} else {
// show explanation + deep-link to app settings
}
}
permLauncher.launch(arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO,
Manifest.permission.POST_NOTIFICATIONS, // Android 13+
))---
Deep-link routing (App Links)
<activity android:name=".CallActivity">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="yourapp.com" android:pathPrefix="/meet/" />
</intent-filter>
</activity>MainActivity.onCreate / onNewIntent routes to CallActivity extracting the sessionId from intent.data.lastPathSegment.
See cometchat-android-v5-calls/references/share-invite.md for full deep-link config.
---
Anti-patterns
1. `CometChatCalls.generateToken(sessionId)` then `joinSession(callToken, ...)` two-step. The Android API takes sessionId directly via joinSession(sessionId, settings, container, callback). The two-step exists for advanced cases (custom auth tokens) but the canonical pattern is single-call. 2. `SessionSettings(sessionType:, layout:)` data-class constructor. Doesn't exist on Android. Use CometChatCalls.SessionSettingsBuilder().build(). 3. `CometChatCalls.addEventListener(CallEvent.SESSION_LEFT)`. Doesn't exist on Android. Register SessionStatusListener() on the CallSession instance from onSuccess. 4. Skipping `CometChatOngoingCallService.abort()` in `onDestroy` / `onConnectionClosed` / `onSessionTimedOut`. Foreground-service notification persists; user sees "Ongoing call" with no way to clear. 5. `addSessionStatusListener` without the `lifecycleOwner` (this) argument. The two-arg form (lifecycleOwner, listener) ties listener cleanup to the activity's lifecycle — required to avoid leaks. 6. Calling `leaveSession()` without checking `callSession.isSessionActive`. Throws on double-leave; can happen if back button + onSessionLeft fire concurrently. 7. Initializing Chat SDK for a session-only integration. Wastes time and adds two extra failure modes. Drop CometChat.init / CometChat.login entirely for standalone session apps.
---
Verification checklist
- [ ]
CometChatCalls.joinSession(sessionId, settings, container, CallbackListener)— single-call shape - [ ] Settings built via
CometChatCalls.SessionSettingsBuilder(), not constructor - [ ]
SessionStatusListener()registered on theCallSessionfromonSuccess(with lifecycle owner) - [ ]
ButtonClickListener()registered for the leave button - [ ]
CometChatOngoingCallService.launch(this)inonSessionJoined - [ ]
CometChatOngoingCallService.abort(this)in EVERY termination path - [ ] Manifest has
FOREGROUND_SERVICE_MICROPHONE+FOREGROUND_SERVICE_CAMERA(Android 14+) - [ ]
<service android:name="com.cometchat.calls.services.CometChatOngoingCallService" android:foregroundServiceType="microphone|camera" />in manifest - [ ] Runtime permission request for
CAMERA,RECORD_AUDIO,POST_NOTIFICATIONS - [ ] App Links intent-filter for
/meet/* - [ ] Standalone session-only: no
com.cometchat:chat-sdk-androiddependency — Calls SDK alone - [ ] Additive (chat + calls): dual-SDK contract preserved
- [ ] Real-device smoke: rotation doesn't double-join, leave clears the foreground notification
---
Pointers
cometchat-react-calls/references/call-session.md— cross-platform architecturecometchat-android-v5-calls/SKILL.md— Android V5 architecturecometchat-android-v5-calls/references/share-invite.md— App Links config- Upstream Android sample —
~/Downloads/calls-sdk/calls-sdk-android-5/samples/sample-app/src/main/kotlin/com/cometchat/samplecalls/ui/activity/CallActivity.kt - Canonical docs: https://www.cometchat.com/docs/calls/android/join-session
CometChat Calls SDK v5 — Custom UI
Overview
Build fully custom call interfaces by hiding the default SDK controls and implementing your own using CallSession actions and event listeners.
Key Imports
import com.cometchat.calls.core.CallSession
import com.cometchat.calls.core.CometChatCalls
import com.cometchat.calls.listeners.MediaEventsListener
import com.cometchat.calls.listeners.SessionStatusListener
import com.cometchat.calls.listeners.ParticipantEventListener
import com.cometchat.calls.model.*Implementation
1. Hide Default Controls
val sessionSettings = CometChatCalls.SessionSettingsBuilder()
.hideControlPanel(true) // hide entire bottom bar
.hideHeaderPanel(true) // hide top header
// Or hide individual buttons:
// .hideToggleAudioButton(true)
// .hideToggleVideoButton(true)
// .hideParticipantListButton(true)
.build()2. Custom Control Panel
Create your own buttons and wire them to CallSession actions:
val callSession = CallSession.getInstance()
var isAudioMuted = false
var isVideoPaused = false
btnMute.setOnClickListener {
if (isAudioMuted) callSession.unMuteAudio() else callSession.muteAudio()
}
btnVideo.setOnClickListener {
if (isVideoPaused) callSession.resumeVideo() else callSession.pauseVideo()
}
btnSwitchCamera.setOnClickListener { callSession.switchCamera() }
btnEndCall.setOnClickListener {
callSession.leaveSession()
finish()
}3. Sync UI with Media Events
callSession.addMediaEventsListener(this, object : MediaEventsListener() {
override fun onAudioMuted() {
runOnUiThread { isAudioMuted = true; btnMute.setImageResource(R.drawable.ic_mic_off) }
}
override fun onAudioUnMuted() {
runOnUiThread { isAudioMuted = false; btnMute.setImageResource(R.drawable.ic_mic_on) }
}
override fun onVideoPaused() {
runOnUiThread { isVideoPaused = true; btnVideo.setImageResource(R.drawable.ic_video_off) }
}
override fun onVideoResumed() {
runOnUiThread { isVideoPaused = false; btnVideo.setImageResource(R.drawable.ic_video_on) }
}
// ... other overrides
})4. Custom Participant List
Hide default and build with RecyclerView:
// Hide default
.hideParticipantListButton(true)
// Listen for participant updates
callSession.addParticipantEventListener(this, object : ParticipantEventListener() {
override fun onParticipantListChanged(participants: List<Participant>) {
runOnUiThread { adapter.updateParticipants(participants) }
}
// ... other overrides
})
// Participant actions from custom UI
adapter.onMuteClick = { participant -> callSession.muteParticipant(participant.uid) }
adapter.onPauseVideoClick = { participant -> callSession.pauseParticipantVideo(participant.uid) }
adapter.onPinClick = { participant ->
if (participant.isPinned) callSession.unPinParticipant()
else callSession.pinParticipant(participant.uid)
}5. Layout Control
// Change layout programmatically
callSession.setLayout(LayoutType.TILE)
callSession.setLayout(LayoutType.SPOTLIGHT)
// Listen for layout changes
callSession.addLayoutListener(this, object : LayoutListener() {
override fun onCallLayoutChanged(layoutType: LayoutType) { /* update UI */ }
override fun onParticipantListVisible() {}
override fun onParticipantListHidden() {}
override fun onPictureInPictureLayoutEnabled() {}
override fun onPictureInPictureLayoutDisabled() {}
})Available CallSession Actions
| Action | Method |
|---|---|
| Mute/unmute audio | muteAudio(), unMuteAudio() |
| Pause/resume video | pauseVideo(), resumeVideo() |
| Switch camera | switchCamera() |
| Change audio output | setAudioMode(AudioMode) |
| Change layout | setLayout(LayoutType) |
| Start/stop recording | startRecording(), stopRecording() |
| Pin/unpin participant | pinParticipant(uid), unPinParticipant() |
| Mute participant | muteParticipant(uid) |
| Pause participant video | pauseParticipantVideo(uid) |
| Leave session | leaveSession() |
| Enable/disable PiP | enablePictureInPictureLayout(), disablePictureInPictureLayout() |
| Set chat badge count | setChatButtonUnreadCount(count) |
Gotchas
- Always use
MediaEventsListenerto sync your custom UI with actual state runOnUiThread {}is required for UI updates from listener callbackshideControlPanel(true)hides the entire bottom bar — individual hide methods are ignored- The call view container still renders video tiles even with controls hidden
- Use
SessionStatusListenerto handle session end and navigate away
Sample App Reference
CallActivity.kt— Default UI withSessionStatusListenerandButtonClickListener
CometChat Calls SDK v5 — Event Listeners
Overview
Five lifecycle-aware listeners monitor call events. All are registered on CallSession.getInstance() and auto-removed when the LifecycleOwner (Activity/Fragment) is destroyed.
Key Imports
import com.cometchat.calls.core.CallSession
import com.cometchat.calls.listeners.SessionStatusListener
import com.cometchat.calls.listeners.ParticipantEventListener
import com.cometchat.calls.listeners.MediaEventsListener
import com.cometchat.calls.listeners.ButtonClickListener
import com.cometchat.calls.listeners.LayoutListener
import com.cometchat.calls.model.Participant
import com.cometchat.calls.model.AudioMode
import com.cometchat.calls.model.CameraFacing
import com.cometchat.calls.model.LayoutTypeImplementation
1. SessionStatusListener
val callSession = CallSession.getInstance()
callSession.addSessionStatusListener(this, object : SessionStatusListener() {
override fun onSessionJoined() { /* connected */ }
override fun onSessionLeft() { finish() }
override fun onSessionTimedOut() { finish() }
override fun onConnectionLost() { /* show reconnecting UI */ }
override fun onConnectionRestored() { /* hide reconnecting UI */ }
override fun onConnectionClosed() { finish() }
})2. ParticipantEventListener
callSession.addParticipantEventListener(this, object : ParticipantEventListener() {
override fun onParticipantJoined(participant: Participant) {}
override fun onParticipantLeft(participant: Participant) {}
override fun onParticipantListChanged(participants: List<Participant>) {}
override fun onParticipantAudioMuted(participant: Participant) {}
override fun onParticipantAudioUnmuted(participant: Participant) {}
override fun onParticipantVideoPaused(participant: Participant) {}
override fun onParticipantVideoResumed(participant: Participant) {}
override fun onParticipantHandRaised(participant: Participant) {}
override fun onParticipantHandLowered(participant: Participant) {}
override fun onParticipantStartedScreenShare(participant: Participant) {}
override fun onParticipantStoppedScreenShare(participant: Participant) {}
override fun onParticipantStartedRecording(participant: Participant) {}
override fun onParticipantStoppedRecording(participant: Participant) {}
override fun onDominantSpeakerChanged(participant: Participant) {}
})3. MediaEventsListener
callSession.addMediaEventsListener(this, object : MediaEventsListener() {
override fun onAudioMuted() {}
override fun onAudioUnMuted() {}
override fun onVideoPaused() {}
override fun onVideoResumed() {}
override fun onRecordingStarted() {}
override fun onRecordingStopped() {}
override fun onScreenShareStarted() {}
override fun onScreenShareStopped() {}
override fun onAudioModeChanged(audioMode: AudioMode) {}
override fun onCameraFacingChanged(facing: CameraFacing) {}
})4. ButtonClickListener
callSession.addButtonClickListener(this, object : ButtonClickListener() {
override fun onLeaveSessionButtonClicked() {}
override fun onToggleAudioButtonClicked() {}
override fun onToggleVideoButtonClicked() {}
override fun onSwitchCameraButtonClicked() {}
override fun onRaiseHandButtonClicked() {}
override fun onShareInviteButtonClicked() {}
override fun onChangeLayoutButtonClicked() {}
override fun onParticipantListButtonClicked() {}
override fun onChatButtonClicked() {}
override fun onRecordingToggleButtonClicked() {}
})5. LayoutListener
callSession.addLayoutListener(this, object : LayoutListener() {
override fun onCallLayoutChanged(layoutType: LayoutType) {}
override fun onParticipantListVisible() {}
override fun onParticipantListHidden() {}
override fun onPictureInPictureLayoutEnabled() {}
override fun onPictureInPictureLayoutDisabled() {}
})Gotchas
- All listeners are lifecycle-aware — no manual removal needed
- Pass
this(Activity/Fragment) as the first argument for lifecycle binding - Register listeners after
joinSession()succeeds (in theonSuccesscallback) - Button click events fire before the SDK's default action
Participantobject has:uid,name,avatar,isAudioMuted,isVideoPaused,isPresenting,isPinned,raisedHandTimestamp- Use
runOnUiThread {}for UI updates inside listener callbacks
Sample App Reference
CallActivity.kt—setupCallListeners()registersSessionStatusListenerandButtonClickListener
Group calls — broadcast meeting pattern (Android V5)
Group calls in CometChat use a different signaling channel than 1:1 user calls. The Ringing flow (CometChat.initiateCall → onIncomingCallReceived on peer) is 1:1 user only. For groups, the kit broadcasts a custom message of type `"meeting"` to the group; receivers see a "Join meeting" card in CometChatMessageList (kit) or need an explicit MessageListener.onCustomMessageReceived (custom UI).
By-design kit behavior. Same semantic across all CometChat kits — confirmed against the React Native kit source 2026-05-15.
Canonical docs: https://www.cometchat.com/docs/calls/android/group-calls
---
Architecture
Caller (uidA, member of groupX) CometChat Receivers (members of groupX)
│ │ │
│ CometChatCallButtons sends a │ │
│ CustomMessage(GUID, GROUP, "meeting", │ │
│ { callType, sessionId }) │ │
├──────────────────────────────────────>│ │
│ │ onCustomMessageReceived │
│ ├─────────────────────────────>│
│ │ │
│ caller mounts CometChatOngoingCallActivity │
│ with sessionID = groupGuid │ receiver taps Join │
│ │ → joinSession(GUID) │
├──────────────────────────────────────>│ <─────────────────────────── │
│ ───── WebRTC session active (sessionId = group GUID) ─────│| Channel | 1:1 user calls | Group calls |
|---|---|---|
| Signaling | CometChat.initiateCall(call, callback) | CometChat.sendCustomMessage(message, callback) (type="meeting") |
| Receiver event | CallListener.onIncomingCallReceived | MessageListener.onCustomMessageReceived |
| Session ID | server-generated unique | group's GUID (persistent) |
| Ring/decline | yes — acceptCall / rejectCall | no — receivers join or ignore |
| Auto-cancel timeout | yes (45s default) | no — meeting persists in chat history |
---
Hard rules
1. Group calls broadcast a custom message; they do NOT use the call listener. Custom UI with only CometChat.addCallListener receives NOTHING for group meetings. 2. Session ID = group GUID. Persistent, not auto-cancelled. 3. Joining = `CometChatCalls.joinSession(sessionId, sessionSettings, container, CallbackListener)` — single-call shape (token generation internal). startSession is a deprecated v4 shim; use joinSession. 4. No `CometChat.endCall(sessionId)` for groups — meeting messages have no call entity. Local CallSession.getInstance().leaveSession() only. 5. ConnectionService / VoIP push for group meetings is NOT built-in. The CometChat dashboard's push provider routes meeting messages as regular FCM notifications, not as ACTION_CALL. Customers wanting ConnectionService-ring for group meetings must intercept the FCM data payload, construct a Connection manually, and route into telecom.
---
Caller side — kit-based
// Inside a chat surface, e.g. inside CometChatMessageHeader:
val callButtons = CometChatCallButtons(this)
callButtons.setGroup(group)
// Tapping voice/video automatically:
// - sends CustomMessage(GUID, GROUP, "meeting", { callType, sessionId })
// - opens CometChatOngoingCallActivity with sessionID = group.guidNo additional code on the caller side. The kit signs the user up for the WebRTC session immediately.
Caller side — custom UI
import com.cometchat.chat.core.CometChat
import com.cometchat.chat.models.CustomMessage
import com.cometchat.chat.constants.CometChatConstants
import org.json.JSONObject
fun startGroupCall(groupGuid: String, callType: String /* "audio" | "video" */) {
val sessionId = groupGuid
val customData = JSONObject().apply {
put("callType", callType)
put("sessionId", sessionId)
}
val meetingMessage = CustomMessage(
groupGuid,
CometChatConstants.RECEIVER_TYPE_GROUP,
"meeting",
customData,
).apply {
category = CometChatConstants.CATEGORY_CUSTOM
metadata = JSONObject().apply {
put("incrementUnreadCount", true)
put("pushNotification", "meeting")
put("callType", callType)
put("sessionId", sessionId)
}
}
CometChat.sendCustomMessage(meetingMessage, object : CometChat.CallbackListener<CustomMessage>() {
override fun onSuccess(p0: CustomMessage) {
// Caller navigates to OngoingCallActivity with sessionId
CometChatOngoingCallActivity.launch(this@CallerActivity, sessionId, callType)
}
override fun onError(p0: CometChatException) { /* surface error */ }
})
}---
Receiver side — kit-based (auto-renders meeting card)
If your group's chat surface uses CometChatMessageList, the kit renders the meeting CustomMessage as a "Join meeting" card. Tap → kit calls getRTCToken + opens CometChatOngoingCallActivity with sessionID = group.guid.
val messageList = CometChatMessageList(this)
messageList.setGroup(group)
// Meeting cards render automatically.Receiver side — custom UI (Kotlin Views / Compose — needs MessageListener)
private val GROUP_MEETING_LISTENER_ID = "APP_ROOT_GROUP_MEETING_LISTENER"
CometChat.addMessageListener(GROUP_MEETING_LISTENER_ID, object : CometChat.MessageListener() {
override fun onCustomMessageReceived(msg: CustomMessage) {
if (msg.category != CometChatConstants.CATEGORY_CUSTOM) return
if (msg.type != "meeting") return
val customData = msg.customData ?: return
val sessionId = customData.optString("sessionId", msg.receiverUid)
val callType = customData.optString("callType", "video")
val fromUid = msg.sender.uid
val groupGuid = msg.receiverUid
// Switch to main thread for UI work
runOnUiThread {
// Show your custom incoming-meeting UI:
// - notification with "Join" action
// - or in-app banner / full-screen activity
}
}
})
// Tear down in onDestroy:
override fun onDestroy() {
super.onDestroy()
CometChat.removeMessageListener(GROUP_MEETING_LISTENER_ID)
}Register in your Application class or main Activity AFTER login — same lifecycle constraint as the 1:1 CallListener.
---
Edge cases
Late joining
Meeting messages persist in chat history. Tapping the card joins the live session — works even hours later, until everyone has left.
Cancelling / leaving
Each participant leaves independently. No "cancel meeting" — the message stays in history.
CallSession.getInstance().leaveSession()
// NO CometChat.endCall — meetings have no call entityPush notifications
The meeting CustomMessage carries metadata.pushNotification = "meeting". CometChat's push system delivers it as a regular FCM notification. To get ConnectionService-ringing for meetings, intercept the FCM data payload in FirebaseMessagingService.onMessageReceived and route to TelecomManager manually. Not auto-wired.
Mixed cohorts
If one side uses the kit (auto-renders meeting card in CometChatMessageList) and the other uses custom Activity-based UI (no message listener), the custom side receives nothing. Both sides must align on listener strategy.
---
Anti-patterns
1. Only `CometChat.addCallListener` registered, expecting group calls to ring. Won't fire. Add a MessageListener too. 2. `CometChat.endCall(sessionId)` after a group hangup. No call entity exists — API errors. Use CallSession.getInstance().leaveSession() locally only. 3. `<CometChatIncomingCall>` overlay activity expected for group meetings. It only listens to 1:1 channel. 4. Treating sessionId as ephemeral. It IS the group's GUID. Persistent. 5. Sending `CustomMessage` without `metadata.pushNotification = "meeting"`. Offline group members won't get any push. 6. Registering the `MessageListener` before login. The SDK silently drops listeners registered pre-login. Wire AFTER login, as part of CometChat.login's callback or addCallListener lifecycle.
---
Verification checklist
- [ ] If using kit caller:
CometChatCallButtons.setGroup(group)+ voice/video tap initiates the meeting message - [ ] If using custom caller:
CometChat.sendCustomMessagecalled withtype="meeting",category=CATEGORY_CUSTOM,customData={callType, sessionId: groupGuid},metadata.pushNotification="meeting" - [ ] If using kit receiver:
CometChatMessageListrenders the meeting card with a "Join" CTA - [ ] If using custom receiver UI:
CometChat.addMessageListenerregistered + filterscategory==CATEGORY_CUSTOM && type=="meeting" - [ ] Listener registered AFTER login, NOT before
- [ ] On hangup:
CallSession.getInstance().leaveSession()(NOTCometChat.endCall) - [ ] Late-joining works
- [ ] FCM data payload routed to ConnectionService manually if OS-ring for meetings is desired
---
Pointers
ringing-integration.md— 1:1 user calls (different signaling channel)cometchat-android-v5-calls/SKILL.mdrules — V5 hard rules (calls-sdk-android peer dep, etc.)- Canonical docs: https://www.cometchat.com/docs/calls/android/group-calls
- Reference impl (cross-platform semantic):
cometchat-react-calls/references/group-calls.md
Idle timeout on Android V5
SDK API: SessionSettingsBuilder setters + listener event. ViewModel + StateFlow pattern (cf. references/raise-hand.md). Material AlertDialog or BottomSheet for the prompt.
Canonical docs: https://www.cometchat.com/docs/calls/android/idle-timeout Read first: cometchat-react-calls/references/idle-timeout.md — settings + archetype timeouts.
---
SDK API
import com.cometchat.calls.core.SessionSettingsBuilder
import com.cometchat.calls.core.SessionType
val settings = SessionSettingsBuilder(context, callContainer)
.setSessionType(SessionType.VIDEO)
.setIdleTimeoutPeriodBeforePrompt(60_000)
.setIdleTimeoutPeriodAfterPrompt(120_000)
.build()Listener — implement on CometChatCallsEventsListener:
override fun onSessionTimedOut() {
// Runs on background thread — dispatch to main
runOnUiThread {
Toast.makeText(this, "Call ended due to inactivity", Toast.LENGTH_LONG).show()
finish() // or NavController.popBackStack()
}
}---
ViewModel pattern
data class CallUiState(val timedOut: Boolean = false)
class CallViewModel : ViewModel() {
private val _state = MutableStateFlow(CallUiState())
val state: StateFlow<CallUiState> = _state
fun onSessionTimedOut() {
_state.update { it.copy(timedOut = true) }
}
}
// Activity collects:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { state ->
if (state.timedOut) {
showIdleTimeoutToast()
finish()
}
}
}
}The bridge is the listener calling viewModel.onSessionTimedOut() (cf. raise-hand.md ViewModel pattern).
---
Prompt UI — Material AlertDialog
fun showIdlePrompt(onStay: () -> Unit, onEnd: () -> Unit) {
MaterialAlertDialogBuilder(this)
.setTitle("Still there?")
.setMessage("You're alone in this call. It'll end in 60 seconds.")
.setCancelable(false)
.setPositiveButton("Stay") { _, _ -> onStay() }
.setNegativeButton("End now") { _, _ -> onEnd() }
.show()
}setCancelable(false) prevents back-button dismiss; user must explicitly choose.
---
Anti-patterns
Web sister rules + Android-specific:
1. `Toast.makeText` from background thread. Crashes — must be on main. runOnUiThread { ... } or lifecycleScope.launch { ... }. 2. Dialog without `setCancelable(false)`. Back-button dismisses; the timer keeps running and disconnects without user response. 3. Listener attached to a Fragment that's been replaced. Idle timeout fires after fragment destroyed → IllegalStateException. Detach listener in onDestroyView.
---
Verification checklist
- [ ] SessionSettingsBuilder sets both idle periods
- [ ]
onSessionTimedOutdispatches to main thread (runOnUiThreador coroutine) - [ ] Dialog uses
setCancelable(false) - [ ] Listener attached in
onCreate/onResume, detached in matching teardown - [ ] Real-device smoke: 2 phones in call, hangup → other shows prompt after delay
---
Pointers
cometchat-react-calls/references/idle-timeout.md— sister referencecometchat-android-v5-callsSKILL.mdreferences/raise-hand.md— ViewModel + StateFlow sibling pattern- Canonical docs: https://www.cometchat.com/docs/calls/android/idle-timeout
CometChat Calls SDK v5 — In-Call Chat
Overview
Add real-time text messaging during calls. Uses a CometChat Group (GUID = session ID) for chat. Requires Chat SDK + UI Kit alongside the Calls SDK.
Prerequisites
- Calls SDK + Chat SDK + UI Kit integrated
- Dependencies:
implementation("com.cometchat:chat-sdk-android:4.0.+")
implementation("com.cometchat:calls-sdk-android:5.0.0-beta.2")
implementation("com.cometchat:chat-uikit-android:4.+")Key Imports
import com.cometchat.calls.core.CallSession
import com.cometchat.calls.listeners.ButtonClickListener
import com.cometchat.chat.core.CometChat
import com.cometchat.chat.constants.CometChatConstants
import com.cometchat.chat.models.GroupImplementation
1. Enable Chat Button
val sessionSettings = CometChatCalls.SessionSettingsBuilder()
.hideChatButton(false) // show chat button (hidden by default)
.build()2. Create/Join Chat Group
Use session ID as group GUID:
private fun setupChatGroup(sessionId: String, meetingName: String) {
CometChat.getGroup(sessionId, object : CometChat.CallbackListener<Group>() {
override fun onSuccess(group: Group) {
if (!group.isJoined) {
CometChat.joinGroup(sessionId, group.groupType, null, /* listener */)
}
}
override fun onError(e: CometChatException) {
if (e.code == "ERR_GUID_NOT_FOUND") {
val group = Group(sessionId, meetingName, CometChatConstants.GROUP_TYPE_PUBLIC, null)
CometChat.createGroup(group, /* listener */)
}
}
})
}3. Handle Chat Button Click
var unreadCount = 0
callSession.addButtonClickListener(this, object : ButtonClickListener() {
override fun onChatButtonClicked() {
unreadCount = 0
callSession.setChatButtonUnreadCount(0)
startActivity(Intent(this@CallActivity, ChatActivity::class.java).apply {
putExtra("SESSION_ID", sessionId)
})
}
// ... other overrides
})4. Track Unread Messages
CometChat.addMessageListener(TAG, object : CometChat.MessageListener() {
override fun onTextMessageReceived(textMessage: TextMessage) {
val receiver = textMessage.receiver
if (receiver is Group && receiver.guid == sessionId) {
unreadCount++
CallSession.getInstance().setChatButtonUnreadCount(unreadCount)
}
}
})
// Remove in onDestroy
override fun onDestroy() {
CometChat.removeMessageListener(TAG)
super.onDestroy()
}5. Chat Activity (UI Kit)
class ChatActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_chat)
val sessionId = intent.getStringExtra("SESSION_ID") ?: return
CometChat.getGroup(sessionId, object : CometChat.CallbackListener<Group>() {
override fun onSuccess(group: Group) {
findViewById<CometChatMessageList>(R.id.message_list).setGroup(group)
findViewById<CometChatMessageComposer>(R.id.message_composer).setGroup(group)
findViewById<CometChatMessageHeader>(R.id.message_header).setGroup(group)
}
override fun onError(e: CometChatException) {}
})
}
}Gotchas
- Chat button is hidden by default — use
.hideChatButton(false)to show it - The group GUID should match the session ID to link chat to the call
setChatButtonUnreadCount()updates the badge on the built-in chat button- Remove message listeners in
onDestroy()to prevent leaks - UI Kit components (
CometChatMessageList, etc.) require the UI Kit dependency - Create the group before or when joining the call — not after
Sample App Reference
CallActivity.kt— Session settings and button click listener setup
CometChat Calls SDK v5 — Join Session
Overview
Join a call session using CometChatCalls.joinSession(). Supports two approaches: direct session ID (simple) or manual token generation (advanced). Requires a RelativeLayout container and configured SessionSettings.
Prerequisites
- Calls SDK initialized and user logged in
- A valid session ID (from Chat SDK's
Call.sessionIdor your backend) - A
RelativeLayoutcontainer in your layout XML
Key Imports
import com.cometchat.calls.core.CometChatCalls
import com.cometchat.calls.core.CallSession
import com.cometchat.calls.model.SessionType
import com.cometchat.calls.model.LayoutType
import com.cometchat.calls.model.AudioMode
import com.cometchat.calls.exceptions.CometChatExceptionImplementation
Layout Container
<RelativeLayout
android:id="@+id/call_view_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />Join with Session ID (Recommended)
val callContainer = findViewById<RelativeLayout>(R.id.call_view_container)
val sessionSettings = CometChatCalls.SessionSettingsBuilder()
.setTitle("Team Meeting")
.setSessionType(if (isVoiceCall) SessionType.VOICE else SessionType.VIDEO)
.setLayout(LayoutType.TILE)
.setAudioMode(if (isVoiceCall) AudioMode.EARPIECE else AudioMode.SPEAKER)
.startAudioMuted(false)
.startVideoPaused(isVoiceCall)
.build()
CometChatCalls.joinSession(
sessionId,
sessionSettings,
callContainer,
object : CometChatCalls.CallbackListener<CallSession>() {
override fun onSuccess(callSession: CallSession) {
Log.d(TAG, "Joined session: $sessionId")
// Setup listeners on callSession
}
override fun onError(e: CometChatException) {
Log.e(TAG, "Failed to join: ${e.message}")
}
}
)Join with Token (Advanced)
// Step 1: Generate token
CometChatCalls.generateToken(sessionId, object : CometChatCalls.CallbackListener<GenerateToken>() {
override fun onSuccess(token: GenerateToken) {
// Step 2: Join with token
CometChatCalls.joinSession(token, sessionSettings, callContainer,
object : CometChatCalls.CallbackListener<CallSession>() {
override fun onSuccess(callSession: CallSession) { /* joined */ }
override fun onError(e: CometChatException) { /* error */ }
}
)
}
override fun onError(e: CometChatException) { /* token error */ }
})Voice Call vs Video Call
// Video call
val videoSettings = CometChatCalls.SessionSettingsBuilder()
.setSessionType(SessionType.VIDEO)
.setLayout(LayoutType.TILE)
.setAudioMode(AudioMode.SPEAKER)
.startVideoPaused(false)
.build()
// Voice call
val voiceSettings = CometChatCalls.SessionSettingsBuilder()
.setSessionType(SessionType.VOICE)
.setLayout(LayoutType.SPOTLIGHT)
.setAudioMode(AudioMode.EARPIECE)
.startVideoPaused(true)
.build()Gotchas
- The container must be a
RelativeLayout, notFrameLayout - All participants must use the same session ID to join the same call
SessionTypevalues areVIDEOandVOICE(not "AUDIO")- Call
joinSessiononly after SDK is initialized and user is logged in - The
CallSessionreturned inonSuccessis used to register all event listeners
Sample App Reference
CallActivity.kt—joinSession()method with voice/video branchingRepository.kt—loginCallsUser()for authentication before joining
CometChat Calls Android SDK v4 → v5 migration
com.cometchat:calls-sdk-android:5.+ is a drop-in upgrade for v4 — bump the Gradle dependency, existing code still compiles thanks to deprecated method shims.
Canonical docs: https://www.cometchat.com/docs/calls/android/migration-guide-v5
Important: This is the migration for the Calls SDK (v4 → v5 of the calls dependency). It is NOT the same as the UI Kit cohort migration (V5 cohort → V6 Compose cohort), which is documented in cometchat-android-v6-migration. You can be on UI Kit V5 cohort with Calls SDK v5 — they version independently.
---
Step 1 — Bump the Gradle dependency
// app/build.gradle.kts
dependencies {
implementation("com.cometchat:calls-sdk-android:5.+")
}./gradlew :app:dependencies | grep calls-sdkShould show com.cometchat:calls-sdk-android:5.x.x.
---
Step 2 — Migrate init to plain config
- val callAppSettings = CallAppSettings.Builder()
- .setAppId("APP_ID")
- .setRegion("REGION")
- .build()
- CometChatCalls.init(callAppSettings, object : CometChatCalls.CallbackListener<String>() {
- override fun onSuccess(s: String) { /* ... */ }
- override fun onError(e: CometChatException) { /* ... */ }
- })
+ val settings = CallAppSettings(appId = "APP_ID", region = "REGION")
+ CometChatCalls.init(settings, object : CometChatCalls.CallbackListener<String>() {
+ override fun onSuccess(s: String) { /* ... */ }
+ override fun onError(e: CometChatException) { /* ... */ }
+ })(V5 also supports coroutine-suspend init via kotlinx.coroutines extensions, if you've enabled them.)
---
Step 3 — Add Calls SDK login (mandatory)
In v5 the Calls SDK has its own auth state, separate from the Chat SDK. Without a Calls SDK login step, the FIRST calls API call (initiate, join, generateToken) throws "auth token cannot be null".
The Android v5 Calls SDK exposes two CometChatCalls.login overloads — match the API exactly:
import com.cometchat.calls.core.CometChatCalls
import com.cometchat.calls.exceptions.CometChatException as CallsException
import com.cometchat.calls.model.CallUser // ← callback returns CallUser, NOT User
// Dev mode (Auth Key)
CometChatCalls.login(uid, AUTH_KEY,
object : CometChatCalls.CallbackListener<CallUser>() {
override fun onSuccess(callUser: CallUser) {
// calls SDK is now ready
}
override fun onError(e: CallsException) {
// surface to user — common cause: typo in app id / auth key
}
})
// Production mode (server-minted auth token)
CometChatCalls.login(authToken,
object : CometChatCalls.CallbackListener<CallUser>() { /* … */ })Hard rules:
1. Call this AFTER `CometChat.login()` succeeds — the Calls SDK login won't work until the Chat SDK has a valid session. 2. The callback receives `com.cometchat.calls.model.CallUser`, not com.cometchat.chat.models.User. Importing the wrong type produces "Type mismatch" at compile time. 3. Re-login on every cold start — the Chat SDK persists login across launches via Shared Prefs; the Calls SDK does NOT. Each app launch where CometChat.getLoggedInUser() returns non-null still needs a fresh CometChatCalls.login call. 4. Don't try `user.authToken` — com.cometchat.chat.models.User does NOT expose authToken as a Kotlin property or Java getter on Android. Use the (uid, apiKey) overload for dev, or fetch the auth token from your backend for production. (This trapped a real smoke run — the chat-side User object hides the auth token.)
After this step, generateToken() no longer needs the authToken parameter — the Calls SDK uses its internal session.
---
Step 4 — Migrate CallSettings.Builder to SessionSettings
- val callSettings = CallSettings.Builder(activity)
- .setSessionType(SessionType.VIDEO)
- .startWithAudioMuted(false)
- .showRecordingButton(true)
- .build()
+ val sessionSettings = SessionSettings(
+ sessionType = SessionType.VIDEO,
+ startAudioMuted = false,
+ hideRecordingButton = false, // INVERTED
+ layout = LayoutType.TILE,
+ )---
Step 5 — Migrate listener interface to event listeners
- class MyCallEvents : CometChatCallsEventsListener {
- override fun onCallEnded() { /* ... */ }
- override fun onUserJoined(user: User) { /* ... */ }
- override fun onUserLeft(user: User) { /* ... */ }
- override fun onError(error: CometChatException) { /* ... */ }
- }
+ // Granular event subscriptions
+ val unsub1 = CometChatCalls.addEventListener(CallEvent.SESSION_LEFT) { /* ... */ }
+ val unsub2 = CometChatCalls.addEventListener(CallEvent.PARTICIPANT_JOINED) { participant -> /* ... */ }
+ val unsub3 = CometChatCalls.addEventListener(CallEvent.PARTICIPANT_LEFT) { participant -> /* ... */ }
+
+ // Cleanup in onDestroy or scope cancellation
+ override fun onDestroy() {
+ unsub1.invoke()
+ unsub2.invoke()
+ unsub3.invoke()
+ }---
Step 6 — Method renames + receiver shift (Android-specific)
v5 moved most call-control APIs from static methods on CometChatCalls to instance methods on `CallSession` (the object returned by joinSession's callback). The static endSession() is the only one preserved as a deprecated shim — the others throw "method not found" if you keep the v4 receiver.
// session lifecycle — receiver changes from CometChatCalls (static) to CallSession (instance)
- CometChatCalls.endSession()
+ callSession.leaveSession() // call on the CallSession from joinSession's onSuccess
+ // OR if you don't have the reference handy:
+ CallSession.getInstance().leaveSession()
// media controls — same receiver shift
- CometChatCalls.muteAudio(true)
+ callSession.muteAudio()
- CometChatCalls.muteAudio(false)
+ callSession.unmuteAudio()
- CometChatCalls.pauseVideo(true)
+ callSession.pauseVideo()
- CometChatCalls.pauseVideo(false)
+ callSession.resumeVideo()
- CometChatCalls.startScreenShare()
+ callSession.startScreenShare()
- CometChatCalls.stopScreenShare()
+ callSession.stopScreenShare()
// layout — static on CometChatCalls in v5 (different from session methods)
- CometChatCalls.setMode(mode)
+ CometChatCalls.setLayout(layout)Hold onto the `CallSession` reference returned by joinSession's onSuccess(callSession: CallSession) callback — most v5 in-call APIs live there. Stashing it in an Activity field or ViewModel is the canonical pattern; the SDK also exposes CallSession.getInstance() as a fallback if you've lost the reference.
---
Step 7 — Compose call surface unchanged
If you're using XML CometChatCallActivity, no changes. If you've embedded the call surface via CometChatOngoingCallView, the v5 SDK's view component takes SessionSettings directly:
- callView.startCall(callSettings)
+ callView.joinSession(sessionSettings)---
ConnectionService + ForegroundServiceType (no v5 changes)
The seven hard rules from cometchat-android-v5-calls/SKILL.md still apply post-v5:
- ConnectionService for incoming calls
foregroundServiceType="microphone"(orcamera|microphone) for active calls- Hangup teardown sequence
---
Verification checklist
- [ ]
app/build.gradle.ktslists calls-sdk-android v5+ - [ ]
./gradlew :app:dependenciesshows v5 resolved - [ ]
CometChatCalls.login(authToken)called afterCometChat.login - [ ]
CometChatCallsEventsListenerreplaced withaddEventListener - [ ]
CallSettings.Builderreplaced withSessionSettings - [ ] Method renames applied
- [ ] FCM VoIP push still arrives + ConnectionService still surfaces UI
- [ ] Real-device smoke: 2 phones, foreground + backgrounded recipient, mic/camera/screenshare/end
---
Pointers
- Canonical migration: https://www.cometchat.com/docs/calls/android/migration-guide-v5
cometchat-android-v5-calls/SKILL.md— seven hard rules (unchanged in v5)cometchat-android-v5-calls/references/server-fcm-voip.md— FCM (unchanged in v5)
CometChat Calls SDK v5 — Participant Management
Overview
Control other participants during calls: mute their audio, pause their video, pin/unpin in layout. Monitor participant state via ParticipantEventListener. Raise hand is available for self.
Key Imports
import com.cometchat.calls.core.CallSession
import com.cometchat.calls.listeners.ParticipantEventListener
import com.cometchat.calls.model.ParticipantImplementation
Participant Actions
val callSession = CallSession.getInstance()
// Mute a participant's audio
callSession.muteParticipant(participant.uid)
// Pause a participant's video
callSession.pauseParticipantVideo(participant.uid)
// Pin a participant (keeps them prominent in layout)
callSession.pinParticipant(participant.uid)
// Unpin (return to automatic speaker highlighting)
callSession.unPinParticipant()Raise Hand (Self)
Raise hand is controlled via the built-in UI button. Listen for it:
callSession.addButtonClickListener(this, object : ButtonClickListener() {
override fun onRaiseHandButtonClicked() {
Log.d(TAG, "Hand raised/lowered")
}
// ... other overrides
})Listen for Participant Events
callSession.addParticipantEventListener(this, object : ParticipantEventListener() {
override fun onParticipantJoined(participant: Participant) {}
override fun onParticipantLeft(participant: Participant) {}
override fun onParticipantListChanged(participants: List<Participant>) {
// Full participant list — use for UI updates
}
override fun onParticipantAudioMuted(participant: Participant) {}
override fun onParticipantAudioUnmuted(participant: Participant) {}
override fun onParticipantVideoPaused(participant: Participant) {}
override fun onParticipantVideoResumed(participant: Participant) {}
override fun onParticipantHandRaised(participant: Participant) {}
override fun onParticipantHandLowered(participant: Participant) {}
override fun onDominantSpeakerChanged(participant: Participant) {}
override fun onParticipantStartedScreenShare(participant: Participant) {}
override fun onParticipantStoppedScreenShare(participant: Participant) {}
override fun onParticipantStartedRecording(participant: Participant) {}
override fun onParticipantStoppedRecording(participant: Participant) {}
})Participant Object Properties
| Property | Type | Description |
|---|---|---|
uid | String | CometChat user ID |
name | String | Display name |
avatar | String | Avatar URL |
isAudioMuted | Boolean | Audio muted? |
isVideoPaused | Boolean | Video paused? |
isPinned | Boolean | Pinned in layout? |
isPresenting | Boolean | Screen sharing? |
raisedHandTimestamp | Long | 0 if not raised |
Show/Hide Participant List Button
val sessionSettings = CometChatCalls.SessionSettingsBuilder()
.hideParticipantListButton(false) // show participant list
.hideRaiseHandButton(false) // show raise hand
.build()Gotchas
- By default, all participants have moderator access (can mute/pause others)
- Implement role-based restrictions in your app logic if needed
- Pinning only affects your local view — other participants can pin independently
unPinParticipant()takes no arguments — unpins whoever is currently pinnedraisedHandTimestamp > 0means hand is raised- There is no "kick" API — only mute and pause video
Sample App Reference
CallActivity.kt—setupCallListeners()withButtonClickListener
CometChat Calls SDK v5 — Picture-in-Picture
Overview
PiP lets users continue calls in a floating window while using other apps. Your app manages Android's PiP APIs; the Calls SDK adjusts the call UI layout to fit the smaller window.
Prerequisites
- Android 8.0+ (API 26+)
- Active call session
Key Imports
import android.app.PictureInPictureParams
import android.util.Rational
import com.cometchat.calls.core.CallSession
import com.cometchat.calls.listeners.LayoutListener
import com.cometchat.calls.model.LayoutTypeImplementation
Manifest Configuration
<activity
android:name=".CallActivity"
android:supportsPictureInPicture="true"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" />Enable/Disable PiP Layout
val callSession = CallSession.getInstance()
// Tell SDK to adjust UI for PiP
callSession.enablePictureInPictureLayout()
// Restore full UI
callSession.disablePictureInPictureLayout()Auto-Enter PiP on Home Press
override fun onUserLeaveHint() {
super.onUserLeaveHint()
if (CallSession.getInstance().isSessionActive) {
enterPictureInPictureMode(
PictureInPictureParams.Builder()
.setAspectRatio(Rational(16, 9))
.build()
)
CallSession.getInstance().enablePictureInPictureLayout()
}
}Handle PiP Mode Changes
override fun onPictureInPictureModeChanged(isInPiPMode: Boolean, config: Configuration) {
super.onPictureInPictureModeChanged(isInPiPMode, config)
if (isInPiPMode) {
CallSession.getInstance().enablePictureInPictureLayout()
} else {
CallSession.getInstance().disablePictureInPictureLayout()
}
}Listen for PiP Events
callSession.addLayoutListener(this, object : LayoutListener() {
override fun onPictureInPictureLayoutEnabled() {
// Hide custom overlays/controls
}
override fun onPictureInPictureLayoutDisabled() {
// Show custom overlays/controls
}
override fun onCallLayoutChanged(layoutType: LayoutType) {}
override fun onParticipantListVisible() {}
override fun onParticipantListHidden() {}
})Gotchas
- The SDK only adjusts the call UI layout — your app must call
enterPictureInPictureMode()itself configChangesin manifest prevents activity restart during PiP transitions- PiP has no effect on Android < 8.0
- Hide custom controls in PiP mode — they won't be usable in the small window
- Use
isSessionActivecheck before entering PiP to avoid errors
Sample App Reference
CallActivity.kt— Call activity that could be extended with PiP support
Raise hand on Android V5 (Java + Kotlin Views)
Native CometChat Calls SDK for Android. The shape mirrors web (raise/lower + listener events + button-hide flag); Android-specific is the listener attachment lifecycle (Activity/Fragment) + accessibility via View.announceForAccessibility.
Canonical docs: https://www.cometchat.com/docs/calls/android/raise-hand Read first: cometchat-react-calls/references/raise-hand.md — UX shape + anti-patterns.
---
SDK API
import com.cometchat.calls.core.CometChatCalls
import com.cometchat.calls.listeners.CometChatCallsEventsListener
import com.cometchat.calls.model.Participant
// Local user
CometChatCalls.raiseHand()
CometChatCalls.lowerHand()
// Listener — attached at session start
val listener = object : CometChatCallsEventsListener {
override fun onParticipantHandRaised(participant: Participant) {
// participant.uid, participant.name
}
override fun onParticipantHandLowered(participant: Participant) {
// ...
}
// ... other event handlers
}
// Settings flag
val settings = SessionSettingsBuilder(context, callContainer)
.setSessionType(SessionType.VIDEO)
.hideRaiseHandButton(true)
.build()For Java:
CometChatCalls.raiseHand();
CometChatCalls.lowerHand();
CometChatCallsEventsListener listener = new CometChatCallsEventsListener() {
@Override
public void onParticipantHandRaised(Participant participant) { /* ... */ }
@Override
public void onParticipantHandLowered(Participant participant) { /* ... */ }
// ...
};---
Lifecycle: where to attach the listener
For a single-Activity call surface:
class CallActivity : AppCompatActivity() {
private lateinit var raiseHandViewModel: RaiseHandViewModel
private val callListener = object : CometChatCallsEventsListener {
override fun onParticipantHandRaised(p: Participant) {
raiseHandViewModel.onRaised(p)
}
override fun onParticipantHandLowered(p: Participant) {
raiseHandViewModel.onLowered(p)
}
// ...
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
raiseHandViewModel = ViewModelProvider(this)[RaiseHandViewModel::class.java]
// Listener attached when session starts (rule 1.7 in cometchat-android-v5-calls)
}
override fun onDestroy() {
super.onDestroy()
// Listener cleanup happens in CometChatCalls.endSession
}
}For Fragment-hosted calls, attach in onViewCreated, detach in onDestroyView.
---
ViewModel pattern (Kotlin coroutines + StateFlow)
data class RaisedParticipant(val uid: String, val name: String, val raisedAt: Long)
data class RaiseHandUiState(
val localRaised: Boolean = false,
val raised: List<RaisedParticipant> = emptyList(),
)
class RaiseHandViewModel : ViewModel() {
private val _state = MutableStateFlow(RaiseHandUiState())
val state: StateFlow<RaiseHandUiState> = _state
fun toggle() {
if (_state.value.localRaised) {
CometChatCalls.lowerHand()
} else {
CometChatCalls.raiseHand()
}
_state.update { it.copy(localRaised = !it.localRaised) }
}
fun onRaised(p: Participant) {
val entry = RaisedParticipant(p.uid, p.name, System.currentTimeMillis())
_state.update { state ->
val next = state.raised.filter { it.uid != p.uid } + entry
state.copy(raised = next.sortedBy { it.raisedAt })
}
}
fun onLowered(p: Participant) {
_state.update { state ->
state.copy(raised = state.raised.filter { it.uid != p.uid })
}
}
}StateFlow is the canonical Android single-source-of-truth pattern. Activities collect via viewLifecycleOwner.lifecycleScope.launch + repeatOnLifecycle(STARTED).
---
Toggle button
class RaiseHandButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : MaterialButton(context, attrs) {
init {
setOnClickListener {
val raised = (tag as? Boolean) ?: false
tag = !raised
// VM toggle handled by Activity wiring — see below
}
}
fun bind(viewModel: RaiseHandViewModel, lifecycleOwner: LifecycleOwner) {
setOnClickListener {
viewModel.toggle()
}
lifecycleOwner.lifecycleScope.launch {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { state ->
text = if (state.localRaised) "Lower" else "Raise"
icon = ContextCompat.getDrawable(context,
if (state.localRaised) R.drawable.ic_hand_raised_filled
else R.drawable.ic_hand_raised)
contentDescription = if (state.localRaised) "Lower hand" else "Raise hand"
// a11y announcement on state change
announceForAccessibility(if (state.localRaised) "Hand raised" else "Hand lowered")
isSelected = state.localRaised
}
}
}
}
}announceForAccessibility is the Android equivalent of web's aria-live and iOS's UIAccessibility.post(.announcement, ...). TalkBack picks it up.
---
Raised-hands BottomSheet
class RaisedHandsBottomSheet : BottomSheetDialogFragment() {
private val viewModel: RaiseHandViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = inflater.inflate(R.layout.bottomsheet_raised_hands, container, false)
val recyclerView = view.findViewById<RecyclerView>(R.id.raised_hands_list)
val adapter = RaisedHandsAdapter()
recyclerView.adapter = adapter
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { state ->
adapter.submitList(state.raised)
}
}
}
return view
}
}Use BottomSheetDialogFragment from com.google.android.material.bottomsheet — the canonical Material Design pattern for raised-hands roster on phones.
---
Anti-patterns
Web sister reference rules apply, plus Android-specific:
1. Listener in `init { }` of an Activity. Activity init runs before onCreate; SDK might not be ready. Attach in onCreate or later. 2. Skipping `repeatOnLifecycle(STARTED)`. Without it, StateFlow.collect runs even when Activity is paused — burns CPU during background. 3. Forgetting `lifecycleScope`. Detached coroutines leak across activity destruction. 4. `MutableStateFlow.value = ...` from background thread without `_state.update`. Race conditions on the list. Always use update { ... } for atomic state transitions. 5. `announceForAccessibility` from `setOnClickListener` before the visual state has changed. Order matters — update the View first, then announce.
---
Verification checklist
- [ ]
RaiseHandViewModelusesStateFlow(not LiveData) for the call state - [ ] Listener attached at session start, detached on
endSession - [ ]
repeatOnLifecycle(Lifecycle.State.STARTED)wraps every collect - [ ] Toggle button has
contentDescription+isSelectedset - [ ]
announceForAccessibilityon state change - [ ]
hideRaiseHandButton(true)in SessionSettingsBuilder if custom UI - [ ] BottomSheetDialogFragment for raised-hands roster (phones) OR side panel (tablets)
- [ ] Real-device smoke: 3 Android phones in same call, 2 raise → host's sheet shows both
- [ ] TalkBack smoke: hand raise + lower announces correctly
---
Pointers
cometchat-react-calls/references/raise-hand.md— sister reference (UX shape)cometchat-android-v5-callsSKILL.md — V5 hard rulesreferences/participant-management.md— group call moderation patternsreferences/custom-ui.md— Android custom call UI- Canonical docs: https://www.cometchat.com/docs/calls/android/raise-hand
- For V6 (Compose / Kotlin Views split):
cometchat-android-v6-calls/references/raise-hand.md
CometChat Calls SDK v5 — Android Skills
Agent skills for building with the CometChat Calls SDK v5 on Android. Install individually or browse all available skills.
How It Works
Skills are bundled in this repository under skills/. When you clone the repo and open it in a supported AI coding assistant, skills auto-trigger based on what you're doing — mention "join session" and the join-session skill loads, ask about "VoIP calling" and the voip-calling skill loads. No manual activation needed.
To use these skills in your own project, copy the skills/ folder into your project root:
cp -r skills/ /path/to/your/project/skills/Available Skills
Core
| Skill | Triggers On |
|---|---|
setup | SDK dependencies, Cloudsmith maven, CallAppSettings, init, permissions, Jetifier |
join-session | CometChatCalls.joinSession, SessionSettingsBuilder, voice vs video |
ringing-integration | Dual SDK (Chat + Calls), initiateCall, accept/reject/cancel, incoming/outgoing |
session-settings | All SessionSettingsBuilder options: layouts, session type, audio mode, hide buttons |
event-listeners | SessionStatus, Participant, Media, ButtonClick, Layout listeners |
call-logs | CallLogRequest, fetching and displaying call history |
Advanced
| Skill | Triggers On |
|---|---|
recording | Auto-start recording, recording events |
screen-sharing | Screen share setup, MediaProjection permissions |
picture-in-picture | PiP mode configuration |
background-handling | CometChatOngoingCallService, foreground service |
voip-calling | VoIP push notifications, ConnectionService |
audio-controls | Mute/unmute, audio device switching |
video-controls | Camera on/off, switch camera |
participant-management | Participant list, mute/kick, raise hand |
custom-ui | Custom control panel, participant list, layout customization |
in-call-chat | In-call messaging during active session |
How Auto-Detection Works
Each skill has a description field in its YAML frontmatter that lists trigger keywords. When you mention something related (like "join a call" or "add VoIP support"), the agent reads the description, decides the skill is relevant, and loads its full content. You never need to manually select a skill.
Compatibility
- CometChat Calls SDK v5 (5.0.0-beta.2+)
- CometChat Chat SDK v4 (4.0.+) — required for ringing and VoIP
- Kotlin, Java 17
- Gradle KTS, compileSdk 35, minSdk 26
- Works with: Kiro, Claude Code, Cursor, Copilot, and other AI coding assistants that support the skills ecosystem
CometChat Calls SDK v5 — Recording
Overview
Record call sessions server-side. Supports manual start/stop, auto-start via SessionSettings, and event listeners for recording state. Recordings are accessible via call logs or the CometChat Dashboard.
Key Imports
import com.cometchat.calls.core.CallSession
import com.cometchat.calls.core.CometChatCalls
import com.cometchat.calls.listeners.MediaEventsListener
import com.cometchat.calls.listeners.ParticipantEventListener
import com.cometchat.calls.model.CallLogRequest
import com.cometchat.calls.model.ParticipantImplementation
Start/Stop Recording
val callSession = CallSession.getInstance()
callSession.startRecording()
callSession.stopRecording()Auto-Start Recording (SessionSettings)
val sessionSettings = CometChatCalls.SessionSettingsBuilder()
.enableAutoStartRecording(true)
.hideRecordingButton(false) // show button (hidden by default)
.build()Listen for Recording Events (Local)
callSession.addMediaEventsListener(this, object : MediaEventsListener() {
override fun onRecordingStarted() { showRecordingIndicator() }
override fun onRecordingStopped() { hideRecordingIndicator() }
// ... other required overrides
override fun onAudioMuted() {}
override fun onAudioUnMuted() {}
override fun onVideoPaused() {}
override fun onVideoResumed() {}
override fun onScreenShareStarted() {}
override fun onScreenShareStopped() {}
override fun onAudioModeChanged(audioMode: AudioMode) {}
override fun onCameraFacingChanged(facing: CameraFacing) {}
})Track Participant Recording
callSession.addParticipantEventListener(this, object : ParticipantEventListener() {
override fun onParticipantStartedRecording(participant: Participant) {
Log.d(TAG, "${participant.name} started recording")
}
override fun onParticipantStoppedRecording(participant: Participant) {
Log.d(TAG, "${participant.name} stopped recording")
}
// ... other required overrides
})Access Recordings from Call Logs
val request = CallLogRequest.CallLogRequestBuilder().setHasRecording(true).build()
request.fetchNext(object : CometChatCalls.CallbackListener<List<CallLog>>() {
override fun onSuccess(callLogs: List<CallLog>) {
callLogs.forEach { log ->
log.recordings?.forEach { recording ->
Log.d(TAG, "URL: ${recording.recordingURL}, Duration: ${recording.duration}s")
}
}
}
override fun onError(e: CometChatException) {}
})Gotchas
- Recording must be enabled in your CometChat Dashboard
- The recording button is hidden by default — use
.hideRecordingButton(false)to show it - All participants are notified when recording starts
- Recordings are stored server-side and available after the call ends
Recordingobject has:rid,recordingURL,startTime,endTime,duration
Sample App Reference
CallActivity.kt— Session settings configuration (recording button visibility)
CometChat Calls SDK v5 — Ringing Integration
Overview
Implement call ringing using both CometChat Chat SDK (signaling) and Calls SDK (session). The Chat SDK handles initiating, accepting, rejecting, and cancelling calls. The Calls SDK manages the actual call session after acceptance.
Prerequisites
- Both SDKs added to
build.gradle.kts:
implementation("com.cometchat:chat-sdk-android:4.0.+")
implementation("com.cometchat:calls-sdk-android:5.0.0-beta.2")- Both SDKs initialized and user logged in to both
Key Imports
// Chat SDK — signaling
import com.cometchat.chat.core.CometChat
import com.cometchat.chat.core.Call // NOT com.cometchat.chat.models.Call
import com.cometchat.chat.constants.CometChatConstants
import com.cometchat.chat.exceptions.CometChatException as ChatException
// Calls SDK — session
import com.cometchat.calls.core.CometChatCalls
import com.cometchat.calls.core.CallSession
import com.cometchat.calls.exceptions.CometChatException as CallsExceptionImplementation
1. Initiate a Call (Caller Side)
val call = Call(receiverUID, CometChatConstants.RECEIVER_TYPE_USER, CometChatConstants.CALL_TYPE_VIDEO)
CometChat.initiateCall(call, object : CometChat.CallbackListener<Call>() {
override fun onSuccess(outgoingCall: Call) {
// Show outgoing call UI with outgoingCall.sessionId
}
override fun onError(e: ChatException) {
Log.e(TAG, "Initiate failed: ${e.message}")
}
})2. Listen for Incoming Calls (Global Listener)
Register in Application class:
CometChat.addCallListener(LISTENER_ID, object : CometChat.CallListener() {
override fun onIncomingCallReceived(call: Call) {
// Show incoming call UI
// call.sessionId, call.sender?.name, call.type
}
override fun onOutgoingCallAccepted(call: Call) {
// Navigate to CallActivity with call.sessionId
}
override fun onOutgoingCallRejected(call: Call) {
// Dismiss outgoing call UI
}
override fun onIncomingCallCancelled(call: Call) {
// Dismiss incoming call UI
}
override fun onCallEndedMessageReceived(call: Call) {
// Handle call ended
}
})3. Accept a Call (Receiver Side)
CometChat.acceptCall(sessionId, object : CometChat.CallbackListener<Call>() {
override fun onSuccess(call: Call) {
// Navigate to CallActivity, join session with call.sessionId
}
override fun onError(e: ChatException) { /* handle */ }
})4. Reject a Call
CometChat.rejectCall(sessionId, CometChatConstants.CALL_STATUS_REJECTED,
object : CometChat.CallbackListener<Call>() {
override fun onSuccess(call: Call) { finish() }
override fun onError(e: ChatException) { finish() }
})5. Cancel an Outgoing Call
CometChat.rejectCall(sessionId, CometChatConstants.CALL_STATUS_CANCELLED,
object : CometChat.CallbackListener<Call>() {
override fun onSuccess(call: Call) { finish() }
override fun onError(e: ChatException) { finish() }
})6. End a Call (During Session)
// 1. Leave the Calls SDK session
CallSession.getInstance().leaveSession()
// 2. Notify via Chat SDK
CometChat.endCall(sessionId, object : CometChat.CallbackListener<Call>() {
override fun onSuccess(call: Call) { finish() }
override fun onError(e: ChatException) { finish() }
})7. Handle Remote End
CometChat.addCallListener(LISTENER_ID, object : CometChat.CallListener() {
override fun onCallEndedMessageReceived(call: Call?) {
val session = CallSession.getInstance()
if (session.isSessionActive) session.leaveSession()
CometChatOngoingCallService.abort(this@CallActivity)
finish()
}
// ... other callbacks
})Gotchas
- Use `com.cometchat.chat.core.Call` — NOT
com.cometchat.chat.models.Call - Cancel uses
CometChat.rejectCall()withCALL_STATUS_CANCELLEDstatus - Always call
CometChat.endCall()when ending — otherwise the other party won't know - Remove call listeners in
onDestroy()to prevent memory leaks - Call type constants:
CALL_TYPE_VIDEOandCALL_TYPE_AUDIO(fromCometChatConstants) - Use alias imports to disambiguate exception classes between Chat and Calls SDKs
Sample App Reference
RingingApplication.kt— Global call listener registrationOutgoingCallActivity.kt— Cancel call, listen for accept/rejectIncomingCallActivity.kt— Accept/reject incoming callCallActivity.kt— Join session after accept, end call flowRepository.kt—initiateCall(),acceptCall(),rejectCall(),cancelCall(),endCall()
CometChat Calls SDK v5 — Screen Sharing
Overview
The Android Calls SDK can receive and display screen shares initiated from web clients. Android does not support initiating screen sharing. The call layout automatically adjusts to display shared content.
Key Imports
import com.cometchat.calls.core.CallSession
import com.cometchat.calls.listeners.ParticipantEventListener
import com.cometchat.calls.model.ParticipantImplementation
Listen for Screen Share Events
val callSession = CallSession.getInstance()
callSession.addParticipantEventListener(this, object : ParticipantEventListener() {
override fun onParticipantStartedScreenShare(participant: Participant) {
Log.d(TAG, "${participant.name} started screen sharing")
// Layout auto-adjusts to show shared screen
}
override fun onParticipantStoppedScreenShare(participant: Participant) {
Log.d(TAG, "${participant.name} stopped screen sharing")
// Layout returns to normal
}
// ... other required overrides
override fun onParticipantJoined(participant: Participant) {}
override fun onParticipantLeft(participant: Participant) {}
override fun onParticipantListChanged(participants: List<Participant>) {}
override fun onParticipantAudioMuted(participant: Participant) {}
override fun onParticipantAudioUnmuted(participant: Participant) {}
override fun onParticipantVideoPaused(participant: Participant) {}
override fun onParticipantVideoResumed(participant: Participant) {}
override fun onParticipantStartedRecording(participant: Participant) {}
override fun onParticipantStoppedRecording(participant: Participant) {}
override fun onParticipantHandRaised(participant: Participant) {}
override fun onParticipantHandLowered(participant: Participant) {}
override fun onDominantSpeakerChanged(participant: Participant) {}
})Check Presenter Status
override fun onParticipantListChanged(participants: List<Participant>) {
val presenter = participants.find { it.isPresenting }
if (presenter != null) {
Log.d(TAG, "${presenter.name} is sharing their screen")
}
}Show/Hide Screen Share Button
val sessionSettings = CometChatCalls.SessionSettingsBuilder()
.hideScreenSharingButton(true) // hide since Android can't initiate
.build()Gotchas
- Android cannot initiate screen sharing — only web clients can
- The SDK automatically adjusts the layout when a screen share starts
- Use
participant.isPresentingto check if someone is sharing - Consider hiding the screen share button on Android since it's not functional for initiating
MediaEventsListeneralso hasonScreenShareStarted()/onScreenShareStopped()for local events
Sample App Reference
CallActivity.kt— Session settings configuration