This guide walks you through implementing the "Make a Call" feature with the AtomicXCore SDK. You'll use three core building blocks—DeviceStore, CallStore, and the main UI component CallCoreView—to build a complete calling experience.
Core Features
AtomicXCore offers three essential modules for building multi-party Audio/Video Call scenarios:
|
| The main call UI component. It observes CallStore data and handles video rendering automatically, and supports customization of layout, avatars, and icons. |
| Manages the call lifecycle (place a call, answer, reject, hang up) and exposes real-time participant audio/video status, call duration, call logs, and more. |
| Controls audio/video devices: microphone (toggle/volume), camera (toggle/switch/quality), screen sharing, and real-time device status monitoring. |
Preparation
Step 1: Activate the Service
Follow the instructions in Activate the service to obtain either a trial or paid edition of the SDK. Step 2: Integrate the SDK
Add dependencies: Include api "io.trtc.uikit:atomicx-core:latest.release" and api "com.tencent.imsdk:imsdk-plus:8.7.7201" in your build.gradle file, then run Gradle Sync.
dependencies {
api "io.trtc.uikit:atomicx-core:latest.release"
api "com.tencent.imsdk:imsdk-plus:8.7.7201"
}
Step 3: Initialize and Log In
To enable calling features, first initialize CallStore, then log in the user. CallStore syncs user info by listening for login success and then enters the ready state. See the flowchart and sample code below:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
CallStore.shared
val sdkAppId = 1400000001
val userId = "test_001"
val userSig = "xxxxxxxxxxx"
LoginStore.shared.login(
this,
sdkAppId,
userId,
userSig,
object : CompletionHandler {
override fun onSuccess() {
TUICallEngine.createInstance(this@MainActivity).init(sdkAppId, userId, userSig, null)
Log.d("Login", "login success");
}
override fun onFailure(code: Int, desc: String) {
Log.e("Login", "login failed, code: $code, error: $desc");
}
}
)
}
}
|
| | Unique identifier for the current user. Only letters, numbers, hyphens, and underscores are allowed. Avoid simple IDs like 1 or 123 to prevent multi-device login conflicts. |
| | Obtain from the console; typically a 10-digit integer starting with 140 or 160. |
| | Authentication token for TRTC. |
Implementation
Note:
You must log in before making a call. The following steps show how to make a call.
Step 1: Create Call Interface
Set up a dedicated call screen that appears when a call is initiated:
1. Create the call screen: Implement a new Activity to host the call UI and handle navigation when a call is received.
2. Attach CallCoreView: This core view component observes CallStore data and renders the call UI. It supports layout switching, avatar, and icon customization. class CallActivity : AppCompatActivity() {
private var callCoreView: CallCoreView? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
callCoreView = CallCoreView(this)
setContentView(callCoreView)
}
}
CallCoreView Component Features:
|
| Switch between different layout modes. If not specified, layout adapts automatically based on participant count. | |
| Customize avatars for specific users via resource path. | |
Set Volume Indicator Icon | Personalize volume indicator icons for different volume levels. | Customize Volume Indicator Icon |
Set Network Indicator Icon | Show network quality status with real-time indicator icons. | Customize Network Indicator Icon |
Set Waiting Animation for Users | Display GIF animations for users in the waiting state during multi-party calls. | Customize Loading Animation |
Step 2: Add Call Controls
DeviceStore: Microphone (toggle and volume), camera (toggle, switch, quality), and screen sharing. Wire these APIs to your buttons and observe device state to update the UI in real time.
CallStore: Call actions (answer, hang up, reject). Wire these to button clicks and show/hide or enable/disable buttons based on call status.
Icons: You can download button icons from GitHub. They are designed for TUICallKit and free for use. Example: Adding hang up, microphone, and camera buttons
1.1 Create Bottom Bar Container: Add a container at the bottom of the call screen to hold the hang up, microphone, and camera buttons.
import io.trtc.tuikit.atomicxcore.api.call.CallStore
import io.trtc.tuikit.atomicxcore.api.view.CallCoreView
import io.trtc.tuikit.atomicxcore.api.device.DeviceStore
class CallActivity : AppCompatActivity() {
private var buttonContainer: Button? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
createButtonContainer()
setContentView(buttonContainer)
}
private fun createButtonContainer() {
buttonContainer = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT
).apply {
gravity = Gravity.BOTTOM
bottomMargin = dpToPx(80)
}
}
}
}
1.2 Add Hang Up Button: Insert a hang up button into the bottom toolbar. On click, call the hangup API and close the call screen. import io.trtc.tuikit.atomicxcore.api.call.CallStore
import io.trtc.tuikit.atomicxcore.api.view.CallCoreView
import io.trtc.tuikit.atomicxcore.api.device.DeviceStore
class CallActivity : AppCompatActivity() {
private var buttonContainer: Button? = null
private var buttonHangup: Button? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
addHangupButton()
setContentView(buttonContainer)
}
private fun addHangupButton() {
buttonHangup = Button(this).apply {
text = "Hang Up"
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
marginEnd = dpToPx(8)
}
setOnClickListener {
CallStore.shared.hangup(null)
finish()
}
}
buttonContainer?.addView(buttonHangup)
}
}
import io.trtc.tuikit.atomicxcore.api.call.CallStore
import io.trtc.tuikit.atomicxcore.api.view.CallCoreView
import io.trtc.tuikit.atomicxcore.api.device.DeviceStore
class CallActivity : AppCompatActivity() {
private var buttonContainer: Button? = null
private var buttonMicrophone: Button? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
addMicrophoneButton()
setContentView(buttonContainer)
}
private fun addMicrophoneButton() {
buttonMicrophone = Button(this).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
setMargins(16, 0, 16, 0)
}
setOnClickListener {
val isMicrophoneOpen = DeviceStore.shared().deviceState.microphoneStatus.value == DeviceStatus.ON
if (isMicrophoneOpen) {
DeviceStore.shared().closeLocalMicrophone()
} else {
DeviceStore.shared().openLocalMicrophone(null)
}
}
}
val isMicrophoneOpen = DeviceStore.shared().deviceState.microphoneStatus.value == DeviceStatus.ON
buttonMicrophone?.text = if (isMicrophoneOpen) "Mute Microphone" else "Unmute Microphone"
buttonContainer?.addView(buttonMicrophone)
}
}
import io.trtc.tuikit.atomicxcore.api.call.CallStore
import io.trtc.tuikit.atomicxcore.api.view.CallCoreView
import io.trtc.tuikit.atomicxcore.api.device.DeviceStore
class CallActivity : AppCompatActivity() {
private var buttonContainer: Button? = null
private var buttonCamera: Button? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
addCameraButton()
setContentView(buttonContainer)
}
private fun addCameraButton() {
buttonCamera = Button(this).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
setMargins(16, 0, 16, 0)
}
setOnClickListener {
val isCameraOpen = DeviceStore.shared().deviceState.cameraStatus.value == DeviceStatus.ON
if (isCameraOpen) {
DeviceStore.shared().closeLocalCamera()
} else {
val isFrontCamera = DeviceStore.shared().deviceState.isFrontCamera.value
DeviceStore.shared().openLocalCamera(isFrontCamera, null)
}
}
}
val isCameraOpen = DeviceStore.shared().deviceState.cameraStatus.value == DeviceStatus.ON
buttonCamera?.text = if (isCameraOpen) "Turn Off Camera" else "Turn On Camera"
buttonContainer?.addView(buttonCamera)
}
}
1.5 Update Button Text in Real Time: Listen for microphone and camera status changes and update button text accordingly.
import io.trtc.tuikit.atomicxcore.api.call.*
import io.trtc.tuikit.atomicxcore.api.device.DeviceStore
import kotlinx.coroutines.*
class CallActivity : AppCompatActivity() {
private var buttonCamera: Button? = null
private var buttonMicrophone: Button? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
observeDeviceState()
}
private fun observeDeviceState() {
deviceStateJob = CoroutineScope(Dispatchers.Main).launch {
supervisorScope {
launch {
DeviceStore.shared().deviceState.cameraStatus.collect { status ->
buttonCamera?.text = if (status == DeviceStatus.ON) "Turn Off Camera" else "Turn On Camera"
}
}
launch {
DeviceStore.shared().deviceState.microphoneStatus.collect { status ->
buttonMicrophone?.text = if (status == DeviceStatus.ON) "Mute Microphone" else "Unmute Microphone"
}
}
}
}
}
}
Step 3: Request Microphone and Camera Permissions
Before making a call, check for audio and video permissions. If permissions are missing, prompt the user to grant them dynamically.
1. Declare permissions: Add camera and microphone permissions to your AndroidManifest.xml.
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<application>
<activity
android:name=".CallActivity"
android:exported="false"
android:screenOrientation="portrait" />
</application>
</manifest>
1.1 Request permissions at runtime: Dynamically request audio and video permissions as needed when making a call.
private fun requestAllCallPermissions() {
val allPermissions = arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
if (!checkPermissions(allPermissions)) {
ActivityCompat.requestPermissions(this, allPermissions, PERMISSION_REQUEST_CODE)
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSION_REQUEST_CODE) {
var allGranted = true
for (result in grantResults) {
if (result != PackageManager.PERMISSION_GRANTED) {
allGranted = false
break
}
}
if (allGranted) {
} else {
}
}
}
Step 4: Make a Call
After the calls API succeeds, navigate to the call screen. For a better experience, enable the microphone (and camera for video calls) automatically based on the media type. 1. Initiate the call: Use the calls API to start a call.
2. Enable media devices: Once the call is initiated, turn on the microphone; for video calls, also enable the camera.
3. Open the call screen: On successful call initiation, launch the call Activity.
import io.trtc.tuikit.atomicxcore.api.CompletionHandler
import io.trtc.tuikit.atomicxcore.api.call.CallMediaType
import io.trtc.tuikit.atomicxcore.api.call.CallStore
class MainActivity : ComponentActivity() {
private fun startCall(userIdList: List<String>, mediaType: CallMediaType) {
CallStore.shared.calls(userIdList, mediaType, null, object : CompletionHandler {
override fun onFailure(code: Int, desc: String) {
}
override fun onSuccess() {
openDeviceForMediaType(mediaType)
val intent = Intent(this@MainActivity, CallActivity::class.java)
startActivity(intent)
}
})
}
private fun openDeviceForMediaType(mediaType: CallMediaType?) {
if (mediaType == null) {
return
}
DeviceStore.shared().openLocalMicrophone(null)
if (mediaType == CallMediaType.Video) {
val isFrontCamera = DeviceStore.shared().deviceState.isFrontCamera.value
DeviceStore.shared().openLocalCamera(isFrontCamera, null)
}
}
}
calls API Parameter Reference:
|
| | | List of target user userIds. |
| | | Call media type: audio or video. CallMediaType.Video: Video call. CallMediaType.Audio: Audio call. |
| | | Optional call parameters, such as room ID, call invitation timeout, etc. roomId (String): Room ID, optional. If not specified, the server assigns one. timeout (Int): Call timeout (seconds).
userData (String): Custom user data.
chatGroupId (String): Chat group ID for group calls.
isEphemeralCall (Boolean): If true, the call is encrypted and not logged.
|
Step 5: End the Call
Whether the local user hangs up via hangup or the remote party ends the call, the onCallEnded callback is fired. Subscribe to it and finish the call Activity when the call ends. 1. Listen for call end events: Subscribe to the onCallEnded event.
2. Close the call screen: When onCallEnded is triggered, finish the Activity.
import io.trtc.tuikit.atomicxcore.api.call.CallEndReason
import io.trtc.tuikit.atomicxcore.api.call.CallListener
import io.trtc.tuikit.atomicxcore.api.call.CallMediaType
import io.trtc.tuikit.atomicxcore.api.call.CallStore
class CallActivity : AppCompatActivity() {
private var callListener: CallListener? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
addListener()
}
private fun addListener() {
callListener = object : CallListener() {
override fun onCallEnded(callId: String, mediaType: CallMediaType, reason: CallEndReason, userId: String) {
finish()
}
}
callListener?.let { CallStore.shared.addListener(it) }
}
}
onCallEnded Event Parameters:
|
| | Unique identifier for the call. |
| | Call media type: audio or video. CallMediaType.Video: Video call.
CallMediaType.Audio: Audio call.
|
| | Reason for call end. Unknown: Unknown reason.
Hangup: User ended the call.
Reject: Callee rejected the call.
NoResponse: No answer within timeout.
Offline: Callee is offline.
LineBusy: Callee is busy.
Canceled: Caller canceled before callee answered.
OtherDeviceAccepted: Call answered on another device.
OtherDeviceReject: Call rejected on another device.
EndByServer: Call ended by server.
|
| | ID of the user who triggered the end event. |
Result
Once you complete these five steps, your app will look like this:
Customization
CallCoreView supports rich UI customization, including avatars and volume indicators. You can download ready-to-use icons from GitHub to speed up integration; they are designed for TUICallKit and free for commercial use. Custom Volume Indicator Icons
setVolumeLevelIcons Example:
private fun setIconResourcePath() {
val volumeLevelIcons = mapOf(VolumeLevel.Mute to "path/to/icon/resource")
val callCoreView = CallCoreView(context)
callCoreView.setVolumeLevelIcons(volumeLevelIcons)
}
setVolumeLevelIcons API Parameters:
|
| | | Maps each volume level to an icon resource. Key (VolumeLevel): VolumeLevel.Mute: Microphone muted.
VolumeLevel.Low: Volume (0-25].
VolumeLevel.Medium: Volume (25-50].
VolumeLevel.High: Volume (50-75].
VolumeLevel.Peak: Volume (75-100].
Value (String): Path to the icon resource for each level. |
Volume Indicator Icons:
|
| Volume indicator. Use for VolumeLevel.Low or VolumeLevel.Medium when the user's volume is above the corresponding threshold. | |
| Mute indicator. Use for VolumeLevel.Mute when the user is muted. | |
Custom Network Indicator Icons
setNetworkQualityIcons Example:
private fun setNetworkQualityIcons() {
val volumeLevelIcons = mapOf(NetworkQuality.BAD to "path/to/icon")
val callCoreView = CallCoreView(context)
callCoreView.setNetworkQualityIcons(volumeLevelIcons)
}
setNetworkQualityIcons API Parameters:
|
| | | Maps each network quality level to an icon resource. Key (NetworkQuality): NetworkQuality.UNKNOWN: Unknown.
NetworkQuality.EXCELLENT: Excellent.
NetworkQuality.GOOD: Good.
NetworkQuality.POOR: Poor.
NetworkQuality.BAD: Bad.
NetworkQuality.VERY_BAD: Very bad.
NetworkQuality.DOWN: Disconnected.
Value (String): Path to the icon resource for each status. |
Poor Network Indicator Icon:
|
| Poor network indicator. Use for NetworkQuality.BAD, VERY_BAD, or DOWN when the network is poor or disconnected. | |
Custom Default Avatar
Use setParticipantAvatars to assign avatars to users. We recommend observing the data streams of allParticipants: display custom avatars when available, and fall back to the default avatar if not set or if loading fails. setParticipantAvatars Example:
private fun setParticipantAvatars() {
val avatars: MutableMap<String, String> = mutableMapOf()
val userId = ""
val avatarPath = ""
avatars[userId] = avatarPath
val callCoreView = CallCoreView(context)
callCoreView.setParticipantAvatars(avatars)
}
setParticipantAvatars API Parameters:
|
| | | Maps user IDs to avatar resources. Key: User's userID. Value: Absolute path to the user's avatar resource. |
Default Avatar Resource:
|
| Default avatar. Use when a user's avatar is not set or fails to load. | |
Custom Loading Animation
Use setWaitingAnimation to set a GIF animation for users in the waiting state, improving the user experience. setWaitingAnimation Example:
private fun setWaitingAnimation() {
val waitingAnimationPath = ""
val callCoreView = CallCoreView(context)
callCoreView.setWaitingAnimation(waitingAnimationPath)
}
setWaitingAnimation API Parameters:
|
| | | Absolute path to the GIF animation resource. |
Waiting Animation:
|
| Waiting-for-answer animation. Use in group calls when a participant's status is waiting. | |
Add Call Duration Display
You can display call duration in real time using the activeCall duration field. 1. Subscribe to the data layer: Observe CallStore.observerState.activeCall for updates to the current call.
2. Bind duration data: Bind activeCall.duration to a UI control. The UI updates automatically—no need to manage a timer manually.
import android.content.Context
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.content.ContextCompat
import com.tencent.qcloud.tuicore.util.DateTimeUtil
import io.trtc.tuikit.atomicxcore.api.call.CallStore
import io.trtc.tuikit.atomicxcore.api.call.CallParticipantStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
class TimerView(context: Context) : AppCompatTextView(context) {
private var subscribeStateJob: Job? = null
override fun onAttachedToWindow() {
super.onAttachedToWindow()
registerActiveCallObserver()
}
private fun registerActiveCallObserver() {
subscribeStateJob = CoroutineScope(Dispatchers.Main).launch {
CallStore.shared.observerState.activeCall.collect { activeCall ->
updateDurationView(activeCall)
}
}
}
private fun updateDurationView(activeCall: CallInfo) {
val currentDuration = activeCall.duration
text = DateTimeUtil.formatSecondsTo00(currentDuration.toInt())
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
subscribeStateJob?.cancel()
}
}
Note:
For more reactive call status data, see CallState. More Features
Set Avatar and Nickname
Before a call starts, use setSelfInfo to set your nickname and avatar. setSelfInfo Example:
val user = UserProfile()
user.userID = ""
user.avatarURL = ""
user.nickname = ""
LoginStore.shared.setSelfInfo(user, object : CompletionHandler {
override fun onSuccess() {
}
override fun onFailure(code: Int, desc: String) {
}
})
setSelfInfo API Parameters:
|
| | | User profile struct. userID (String): User ID.
avatarURL (String): Avatar URL.
nickname (String): Nickname.
|
| | | Callback for the result of the operation. |
Switch Layout Mode
Use setLayoutTemplate to switch between layout modes. If not set, CallCoreView adapts automatically: 1v1 calls use Float mode by default, while multi-party calls use Grid mode. Layout mode descriptions: |
| | |
Layout: Full screen self-view while waiting; after connecting, full screen remote view with self-view as a floating window. Interaction: Floating window supports drag and click-to-swap with the main view. | Layout: Grid layout for all participants, suitable for 2+ users. Supports click-to-enlarge. Interaction: Click any participant's view to enlarge. | Layout: 1v1 shows fixed remote view; multi-party uses active speaker strategy with full screen for the current speaker. Interaction: Waiting state shows self-view; after connecting, call duration is displayed. |
setLayoutTemplate Example:
private fun setLayoutTemplate() {
val callCoreView = CallCoreView(context)
val template = CallLayoutTemplate.Grid
callCoreView.setLayoutTemplate(template)
setContentView(callCoreView)
}
setLayoutTemplate API Parameters:
|
| | | CallCoreView layout mode. CallLayoutTemplate.Float: Full screen self-view while waiting; after connecting, full screen remote view with self-view as a floating window.
CallLayoutTemplate.Grid: Grid layout for all members, suitable for 2+ participants, supports click-to-enlarge.
CallLayoutTemplate.Pip: 1v1 fixed remote view; multi-party uses active speaker strategy.
|
Set Default Call Timeout
When making a call with calls, set the timeout field in CallParams to specify the waiting timeout. val callParams = CallParams()
callParams.timeout = 30
CallStore.shared.calls(userIdList, CallMediaType.Video, callParams, null)
calls API Parameter Reference:
|
| | | |
| | | Call media type: audio or video. CallMediaType.Video: Video call.
CallMediaType.Audio: Audio call.
|
| | | Optional call parameters, such as room ID, call invitation timeout, etc. roomId (String): Room ID, optional.
timeout (Int): Call timeout (seconds).
userData (String): Custom user data.
chatGroupId (String): Chat group ID for group calls.
isEphemeralCall (Boolean): If true, the call is encrypted and not logged.
|
Implement Picture-in-Picture (PiP)
Picture-in-Picture requires Android 8.0 (API 26) or higher. For best results, switch CallCoreView to Pip layout when entering PiP mode.
Start PiP: Call the system method enterPictureInPictureMode.
private fun enterPictureInPictureModeWithBuild() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && hasPipModePermission()) {
val pictureInPictureParams: PictureInPictureParams.Builder = PictureInPictureParams.Builder()
val floatViewWidth = resources.getDimensionPixelSize(R.dimen.callkit_video_small_view_width)
val floatViewHeight = resources.getDimensionPixelSize(R.dimen.callkit_video_small_view_height)
val aspectRatio = Rational(floatViewWidth, floatViewHeight)
pictureInPictureParams.setAspectRatio(aspectRatio).build()
this.enterPictureInPictureMode(pictureInPictureParams.build())
}
}
On PiP Entered: In onPictureInPictureModeChanged, set CallCoreView layout to Pip when entering PiP mode.
val callCoreView = CallCoreView(context)
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
if (isInPictureInPictureMode) {
callCoreView.setLayoutTemplate(CallLayoutTemplate.Pip)
}
}
Return from PiP: When returning from PiP (full screen), onResume is triggered. Reset the layout based on participant count: use Float for 1v1, Grid for multi-party.
val callCoreView = CallCoreView(context)
override fun onResume() {
super.onResume()
val allParticipants = CallStore.shared.observerState.allParticipants.value
if (allParticipants.size > 2) {
callCoreView?.setLayoutTemplate(CallLayoutTemplate.Grid)
} else {
callCoreView?.setLayoutTemplate(CallLayoutTemplate.Float)
}
}
Keep Screen Awake During Call
To keep the screen on during a call, use the WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON flag. This is the recommended approach.
class CallActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD or
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
setContentView(R.layout.activity_call)
}
}
Play Waiting Ringtone
You can observe call state and play a ringtone while waiting for an answer. Stop the ringtone when the call is answered or ends.
MainScope().launch {
CallStore.shared.observerState.selfInfo.collect { selfInfo ->
if (selfInfo.status == CallParticipantStatus.Accept || selfInfo.status == CallParticipantStatus.None) {
return@collect
}
if (selfInfo.status == CallParticipantStatus.Waiting) {
}
}
Enable Background Audio/Video Capture
1. Configure permissions and service (AndroidManifest.xml): Starting with Android 9.0 (API 28), you must declare foreground service permissions. Android 14 (API 34) requires explicit service types (microphone and camera).
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<application>
<service
android:name=".CallForegroundService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="camera|microphone" />
</application>
</manifest>
2. Create a Foreground Service class (CallForegroundService):
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
class CallForegroundService : Service() {
companion object {
private const val NOTIFICATION_ID = 1001
private const val CHANNEL_ID = "call_foreground_channel"
fun start(context: Context) {
val intent = Intent(context, CallForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
val intent = Intent(context, CallForegroundService::class.java)
context.stopService(intent)
}
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
startForeground(NOTIFICATION_ID, createNotification())
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createNotification(): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("In Call")
.setContentText("App is running in the background to keep the call active")
.setSmallIcon(android.R.drawable.ic_menu_call)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Call Keep-Alive Service",
NotificationManager.IMPORTANCE_HIGH
)
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
}
Next Steps
Congratulations! You've completed the "Make a Call" feature. Next, you can implement the Answer the First Call feature. See the table below for details:
|
| Step-by-step guide to integrating the answer flow, including launching the call UI, and implementing answer and reject controls. | |
FAQs
Why does the UI show incorrectly when I open other activities after entering Picture-in-Picture?
Reason: Android's PiP mode is based on the task stack. By default, all Activities share the same task stack. If you launch a new Activity during PiP, it may appear in the PiP window, causing UI issues.
Solution: Declare the call Activity as a separate task stack in AndroidManifest.xml.
<activity
android:name=".view.CallActivity" <!-- Your call interface -->
android:launchMode="singleTask"
android:taskAffinity="${applicationId}.call" />
If the callee comes online before the call invitation times out, do they get the incoming call?
For single calls, if the callee comes online within the timeout, they will receive an incoming call invitation. For group calls, up to 20 unhandled group messages are pulled when coming online within the timeout; if a call invitation exists, the incoming call event is triggered.