Audio and video calls
Users can make one-to-one and group voice and video calls in the app. The Flutter SDK handles all call signaling and media: starting a call, ringing, answering, rejecting and hanging up, ringing on several devices at once, recovering after disconnects, connecting to the media service, and opening the microphone and camera. When the app isn't in the foreground, it also reports incoming calls to the system call screen (CallKit on iOS, full-screen call notifications on Android). Your app only shows the call UI, using the SDK's widgets for video.
Audio and video run on the media service the IM service provides. You don't deploy or configure any media server or add any other audio/video SDK; just depend on the deeprespond_im_call package.
import 'package:deeprespond_im_call/deeprespond_im_call.dart';
// 发起一对一视频通话
final call = await im.calls.start(toUser: 'bob', media: CallMedia.video);
// 对方和自己的画面(Widget,放进你的通话界面)
final remote = call.remoteView('bob');
final local = call.localView();
// 挂断
await call.hangup();For the full call rules (busy, multiple devices, group call size, end reasons, billing), see Audio and video calls in the server docs.
| You want to | See |
|---|---|
| Set up calls for the first time | Setup |
| Start a one-to-one or group call | Start a one-to-one call, Start a group call |
| Show incoming calls, answer and reject | Receive calls, Answer and reject |
| Build the call screen: status, video, mute, switch camera | Show call status, Show video, In-call actions |
| Hang up and show why a call ended | Hang up and end |
| Show an "in a call" banner on the group chat page | Group call banner |
| Handle calls after network loss or the app being killed | Disconnects and recovery |
| Handle missing microphone or camera permission | Microphone and camera |
| Fix call buttons not showing or calls being refused | Turn on audio and video, Runtime policies and plans |
Setup
Turn on audio and video
Audio and video are off by default. Turn on rtc_enabled in Runtime policies. Under the new billing model the app's audio/video balance must also be positive. Before the switch-over, the legacy plan switch still applies, and the app status, shared concurrency and media service must all allow it.
Use im.config?.rtcEnabled to decide whether to show call buttons, and listen for config changes. The balance and service status can change after the buttons are shown, so the server's answer when starting or answering a call is final:
void updateCallButtons() => setCallButtonsVisible(im.config?.rtcEnabled ?? false);
updateCallButtons();
im.on<ConfigChanged>().listen((_) => updateCallButtons());When the config doesn't allow calls, the call rejects with permission_denied (rtc_disabled). When you get this error, check the app's policy, the app's current balance, top-up approval and the switch-over date, rather than just dialing again.
Add dependencies
dependencies:
deeprespond_im_flutter: ^1.0.0-alpha.4
deeprespond_im_push: ^1.0.0-alpha.4
deeprespond_im_call: ^1.0.0-alpha.4Adding deeprespond_im_call is enough to turn it on: the call media connection and video widgets become available. Incoming calls while the app isn't in the foreground arrive by push, so set up push first as in Push notifications (APNs on iOS, FCM and Chinese phone-maker channels on Android).
Give call options when creating the client
With DRClientOptions.call, the SDK uses the system call screen (iOS and Android):
final im = await DRClient.create(DRClientOptions(
appKey: '1575529652#demo',
apiUrl: Uri.parse('https://im.example.com'),
platform: DRFlutterPlatform.instance,
push: const DRPushOptions(
credentials: {PushChannel.apns: 'ios-prod', PushChannel.fcm: 'fcm-global'},
androidChannels: [
AndroidChannel(id: 'im_message', name: '消息', kind: AndroidChannelKind.message),
AndroidChannel(id: 'im_call', name: '来电', kind: AndroidChannelKind.call),
],
),
call: const DRCallOptions(),
));| Option | Default | Description |
|---|---|---|
useCallKit | true | iOS: use CallKit and PushKit (VoIP push) so incoming calls show the system call screen. Builds released on the App Store in mainland China must set it to false; see Builds for mainland China |
useConnectionService | true | Android: register calls as system calls with Core-Telecom, so the system handles audio focus and the answer button on Bluetooth headsets, and an IM call can be held during a carrier call |
autoBusy | true | Automatically reject incoming calls as busy while the user is on a call (a carrier call or another app's call) |
ringtone | null | iOS ringtone file name (in the app's resources). If you call DRCallKit.setUp() in AppDelegate, give the ringtone there instead; see the next section |
Without the call option the app can still make and answer calls, but there's no system call screen when the app isn't in the foreground, and DRCallUI.of(im) is null.
iOS setup
In Xcode, add these capabilities to the Runner target:
- Push Notifications.
- Background Modes: check Voice over IP, Audio, AirPlay, and Picture in Picture, and Remote notifications.
In Info.plist, describe what the microphone and camera are used for (the system shows this the first time they're used):
<key>NSMicrophoneUsageDescription</key>
<string>用于语音和视频通话</string>
<key>NSCameraUsageDescription</key>
<string>用于视频通话</string>
<key>UIBackgroundModes</key>
<array>
<string>voip</string>
<string>audio</string>
<string>remote-notification</string>
</array>Call DRCallKit.setUp() synchronously in application(_:didFinishLaunchingWithOptions:) in AppDelegate. When a VoIP push launches the app in the background, the system requires the call to be reported to CallKit immediately, so this must happen before the Flutter engine and plugins are registered:
import Flutter
import UIKit
import deeprespond_im_call
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
#if !CN_APP_STORE
// 来电界面上显示的 App 名称默认取 CFBundleDisplayName;ringtone 为 App 资源中的声音文件
DRCallKit.setUp(ringtone: "ringtone.caf")
#endif
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}VoIP pushes use the APNs credential. Credentials using a .p8 key can send VoIP pushes directly; those using a .p12 certificate need {Bundle ID}.voip registered in the certificate; see Push settings. The VoIP token is registered with the apns credential by default; to use a different one, give it in DRPushOptions.voipCredential.
Builds for mainland China
The App Store doesn't allow apps released in mainland China to use CallKit. For builds released on the App Store in mainland China:
- Create the client with
DRCallOptions(useCallKit: false). - Don't call
DRCallKit.setUp(). (The example above uses the compile conditionCN_APP_STORE; define it in Swift Compiler - Custom Flags in this build's configuration.)
The SDK then doesn't register a VoIP token. Incoming calls while the app isn't in the foreground arrive as regular high-priority notifications; when the user taps one to enter the app, you show the incoming call screen; see Tap an incoming call notification. Tell the two builds apart in Dart with --dart-define:
// flutter build ipa --dart-define=CN_APP_STORE=true
const cnAppStore = bool.fromEnvironment('CN_APP_STORE');
final callOptions = DRCallOptions(useCallKit: !cnAppStore);Android setup
- Permissions. The permissions calls need (microphone, camera,
MANAGE_OWN_CALLS, full-screen notifications, foreground service and so on) are declared in thedeeprespond_im_callmanifest and merged into the app at build time; you don't declare them. - Call channel. Give an
AndroidChannelKind.callchannel inDRPushOptions.androidChannelsfor full-screen call notifications. Without one, the SDK creates a channel named "来电" (Incoming calls). This channel decides the ringtone and vibration for calls. - FCM background handler. Calls while the app is in the background or killed arrive as FCM data messages, so you must register a background handler as described in Push notifications and call
drFirebaseBackgroundHandlerin it. - Full-screen notification declaration. From Android 14, full-screen call notifications need a separate permission, and apps on Google Play must declare their use in Play Console; see Full-screen notification permission on Android 14.
Desktop
On desktop platforms such as macOS you can make and answer calls, but there's no system call screen; incoming calls only reach the app as CallIncoming events. On macOS, describe microphone and camera use in Info.plist and enable com.apple.security.device.audio-input and com.apple.security.device.camera in the entitlements.
Start a one-to-one call
start() takes the other user's username and the media type: CallMedia.audio for voice or CallMedia.video for video (it can't change during the call). You can attach custom fields in ext (such as an order number), which reach the other person with the call.
try {
final call = await im.calls.start(toUser: 'bob', media: CallMedia.video, ext: {'order_id': '8812'});
final info = call.snapshot.call;
if (info.status == 'ended' && info.endReason == 'busy') {
showToast('对方忙线中');
} else {
openCallPage(call);
}
} on DRException catch (e) {
if (e.code == 'call_in_progress') {
showToast('你正在另一个通话中,请先挂断');
} else if (e.code == 'user_blocked') {
showToast('对方拒绝了你的通话');
} else {
rethrow;
}
}- Once started, the user is in the call. The SDK connects to the media service right away and opens the microphone (and camera for video) while waiting for the other person to answer, so you can show the user's own preview.
- If the other person is in another call,
start()still returns, but the call has already ended (endReasonisbusy); it doesn't ring or connect media. - If the user is already in a call, it rejects with
call_in_progress, anddetails['call_id']is that call. Once the user confirms, hang that one up first, then start the new one. - The rules match one-to-one messaging: if the other person blocked the user it rejects with
user_blocked; if the app only allows friends to message and they aren't friends,not_friend; if the user is globally muted for one-to-one chats,user_muted. - Starting calls too often rejects with
rate_limited, which the SDK doesn't retry. Network errors are retried automatically without starting a duplicate call. - With CallKit on iOS, or
useConnectionServiceon Android, the SDK also registers the call as an outgoing system call.
Once the other person answers, the call's status becomes active; see Show call status.
Start a group call
Start a group call in a group, with usernames being the group members to ring. You can also invite nobody and let other members join from the group call banner.
try {
final call = await im.calls.start(toGroup: groupId, usernames: ['lisi', 'wangwu'], media: CallMedia.audio);
// 没有振铃的人(正在其他通话中、在群里被禁言等)
final notRinging = call.snapshot.call.members.where((m) => m.role == 'invitee' && m.state != 'ringing').map((m) => m.username);
if (notRinging.isNotEmpty) showToast('${notRinging.join('、')} 暂时无法接听');
} on DRException catch (e) {
if (e.code == 'already_exists') {
// 群里已有进行中的群通话:改为加入它
final existing = await im.calls.get('${e.details?['call_id']}');
await existing.join();
} else {
rethrow;
}
}- The caller must be able to speak in the group (not muted in the group, and the owner or an admin when everyone is muted). Invitees must be group members who can speak in the group, or they don't ring. Invitees on another call are marked busy (
busy) and don't ring. - A group has at most one group call at a time. If there's already one in progress, it rejects with
already_exists, anddetails['call_id']is that call; join it instead. - The number of people in the call at once is capped by the runtime config
maxGroupCallParticipants(16 by default, including the caller). You can invite at most that number minus one at a time; more is rejected locally by the SDK withlocal_validation(too_many_invitees). - Group calls don't check personal blocklists.
Receive calls
Incoming calls (one-to-one calls and group call invitations) arrive differently depending on the app's state:
| App state | iOS | Android |
|---|---|---|
| Foreground | Over the connection; the SDK emits CallIncoming and you show your in-app incoming call screen | Same |
| Background, locked, killed | With CallKit: a VoIP push wakes the app and the system call screen shows Without CallKit (builds for mainland China): a regular high-priority notification | FCM: a full-screen call notification (shows the call screen directly when locked) Chinese phone-maker channels: a regular high-priority notification |
The same call may arrive several ways, over the connection and by push. The SDK de-duplicates by call ID and never reports it twice.
App in the foreground
In the foreground, the SDK only emits a CallIncoming event. It doesn't show the system call screen or play a ringtone: you show the in-app incoming call screen and play a ringtone with your own audio plugin.
im.on<CallIncoming>().listen((e) {
final call = e.call;
openCallPage(call); // 来电界面:snapshot.inviter 为邀请人,snapshot.group 为群通话的群
// App 不在前台时由系统来电界面负责振铃,这里只在前台播放铃声
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) startRingtone();
// 停止振铃:对方取消、超时、在其他设备上接听或拒绝、本人接听或拒绝
late final StreamSubscription<CallSnapshot> sub;
sub = call.stream.listen((s) {
if (s.ringing) return;
stopRingtone();
if (s.call.status == 'ended' || s.self?.state != 'joined') closeCallPage(call);
unawaited(sub.cancel());
});
});What the incoming call screen can show:
final s = call.snapshot;
final inviter = s.inviter;
final from = (inviter?.nickname.isNotEmpty ?? false) ? inviter!.nickname : inviter?.username;
final avatar = inviter?.avatarUrl;
final groupName = s.group?.name; // 群通话的群名,一对一为 null
final isVideo = s.call.media == 'video'; // 'audio' 或 'video'App in the background or killed
The SDK reports the call to the system call screen, with no code from you:
- iOS (with CallKit). A VoIP push wakes the app, and the SDK reports the call to CallKit immediately from native code, so the system shows the call screen (full-screen when locked). The SDK then looks up the call and updates the caller's name. If the call already ended, or the user handled it on another device, it ends the incoming call.
- Android (FCM). The FCM background handler receives the call and shows a full-screen call notification (the call screen directly when locked, otherwise a heads-up notification) with Answer and Decline buttons. Without the full-screen notification permission it shows as an expanded, persistent heads-up notification on the lock screen; see Full-screen notification permission on Android 14.
- App in the background with the connection still up. The call arrives over the connection, and the SDK reports it to the system call screen the same way.
When the caller cancels, ringing times out, or the user answers or rejects on another device, the SDK ends the call on the system screen. If the device didn't get these messages in time (for example it was offline), they're cleared the next time the app opens, returns to the foreground or reconnects.
Actions on the system call screen
When the user answers, rejects or hangs up on the system call screen, the SDK has already carried out the action. It then tells you through DRCallUI.of(im).actions so you can open or close your call screen. Subscribe right after creating the client so you don't miss actions from a cold start:
DRCallUI.of(im)?.actions.listen((a) async {
switch (a.action) {
case 'answer': // 在系统来电界面或通知上接听了:SDK 已经接听,打开通话界面
case 'show': // 点击了全屏来电通知:打开来电界面
openCallPage(await im.calls.get(a.callId));
case 'reject':
case 'hangup':
closeCallPageById(a.callId);
case 'open': // 点击了未接来电的通知
openCallHistory();
}
});action values: answer, reject, hangup, mute and unmute (actions on the iOS CallKit screen; the SDK has already switched the microphone), show (the full-screen call notification was tapped) and open (a missed call notification was tapped).
- Tapping Answer on an Android notification launches the app, and the SDK answers once it opens. Tapping Decline doesn't need to launch the app; if it isn't running, the decline is handled at the next launch (and the server also ends the call when ringing times out).
- When the system call screen launches the app after it was killed, the SDK answers once sign-in is restored, and
im.calls.currentis then that call.
Tap an incoming call notification
For calls delivered as regular notifications (iOS builds without CallKit, Android Chinese phone-maker channels), the user taps the notification to enter the app, and you show the incoming call screen by call ID in Handle notification taps:
Future<void> onCallPushOpened(CallPush payload) async {
if (payload.event != 'incoming') return;
final call = await im.calls.get(payload.callId);
if (call.snapshot.ringing) {
openCallPage(call); // 仍在振铃:显示来电界面
} else {
showToast('通话已结束');
}
}
DRPush.of(im)?.onOpened.listen((p) {
if (p is CallPush) unawaited(onCallPushOpened(p));
});Other rules for incoming calls
- Calls ringing now.
im.calls.incomingholds every call ringing right now. The user may get several at once and choose which to answer. - Calls while offline. When the app opens or the network comes back, the SDK looks up calls that are ringing and emits
CallIncomingfor any not reported yet. The same call is never reported twice. - Ring time. Set by the runtime policy
rtc_ring_timeout_seconds(60 seconds by default). If nobody answers in time, the SDK stops ringing (the snapshot'sringingbecomesfalse); a one-to-one call ends withno_answer, and in a group call the user's invitation is markedmissed. - Ringing on several devices. Once the user answers or rejects on another device (another phone, a computer), ringing stops here. When answered elsewhere, the
CallChangedevent'schange.kindisanswered_elsewhere, so you can show "Answered on another device". - Busy. While the user is on an IM call, new one-to-one calls are marked busy by the server and don't ring; group call invitations are marked busy. While the user is on a carrier call or another app's call, the SDK rejects incoming calls as busy automatically (
DRCallOptions.autoBusy). iOS can detect carrier calls, FaceTime and other CallKit apps' calls; Android judges by the system's call audio mode, which catches carrier calls and most calling apps.
Answer and reject
// “接听”按钮
Future<void> onAccept() async {
await call.join();
openCallPage(call);
}
// “拒绝”按钮
Future<void> onDecline() async {
await call.reject();
}join()answers an incoming call or joins a group call. On success the SDK connects to the media service and opens the microphone and camera, and the call becomesactive(for a group call, once two people are in it for the first time).reject()rejects. A one-to-one call then ends (endReasonisrejected); in a group call only the user's invitation ends (declined) and others aren't affected. The user's other devices stop ringing.reject(reason: RejectReason.busy)rejects as busy.- If the user is already in another call,
join()rejects withcall_in_progress, anddetails['call_id']is that call; hang it up first. - If the call already ended (the caller cancelled), it rejects with
call_ended. If the user already answered on another device, it rejects withcall_in_progress(joined_on_other_device). - If the group call is full, it rejects with
limit_exceeded(call_full). - When answered in the in-app UI, the SDK also tells the system (CallKit on iOS, the system call on Android) that the call was answered and closes the call notification.
Show call status
All of a call's state is in its handle's snapshot, call.snapshot. call.stream emits the current snapshot on subscribe and a new one on every change, so StreamBuilder keeps the UI up to date:
import 'package:deeprespond_im_flutter/deeprespond_im_flutter.dart';
import 'package:flutter/material.dart';
class CallStatusText extends StatelessWidget {
const CallStatusText({super.key, required this.call});
final CallHandle call;
@override
Widget build(BuildContext context) => StreamBuilder<CallSnapshot>(
stream: call.stream,
initialData: call.snapshot,
builder: (context, snapshot) {
final s = snapshot.data!;
final text = switch (s.call.status) {
'ringing' => s.ringing ? '邀请你通话' : '等待对方接听',
'active' => s.media == 'reconnecting' ? '网络不佳,正在重新连接' : '通话中 ${s.call.durationSeconds} 秒',
_ => '通话已结束',
};
return Text(text);
},
);
}| Snapshot field | Description |
|---|---|
call | The call; see Call: status, members, endReason and so on |
self | The user's state in the call: state is the user's member state, and joinedOnThisDevice is whether the user is in the call on this device |
ringing | Whether it's ringing here |
media | This device's media connection: none, connecting, connected, reconnecting (the network is unstable, reconnecting) or disconnected |
local | Whether the user's microphone and camera are on |
remote | Other members' media: one item per person, where audio and video say whether their microphone and camera are on |
resumable | See Resume a call after the app is killed |
durationSeconds is the duration from the server and only updates when the call changes. For a ticking timer, compute it yourself from answeredAt.
To know what happened (who joined, declined or left), listen for CallChanged; change.kind says what changed:
im.on<CallChanged>().listen((e) {
final change = e.change;
if (change == null) return;
final who = change.usernames.whereType<String>().join('、');
switch (change.kind) {
case 'joined':
showToast('$who 加入了通话');
case 'declined':
showToast('$who 拒绝了邀请');
case 'left':
showToast('$who 离开了通话');
case 'answered_elsewhere':
showToast('已在其他设备接听');
case 'ended':
showToast('通话已结束:${e.call.snapshot.call.endReason}');
}
});change.kind | Description |
|---|---|
created | The user started a call on another device; you can show "In a call" here with a hang-up option |
invited | New invitations in a group call |
joined | Someone answered or joined |
answered_elsewhere | The user answered on another device |
declined | Someone declined |
missed | A group call invitation timed out unanswered |
left | Someone left or was removed; reason is the leave reason |
canceled | A ringing invitation in a group call was cancelled |
ended | The call ended; reason is the end reason |
The user's own actions on this device (answering, rejecting, hanging up) also emit CallChanged, with change null.
Show video
deeprespond_im_call adds video widgets to the call handle, keyed by member username:
call.localView(): the user's own camera (mirrored).call.remoteView(username): one member's video.
The widgets track changes themselves: they update when members turn their camera on or off, join or leave. When there's no video they show placeholder (blank by default). Audio plays automatically from the SDK, with no widget needed.
import 'package:deeprespond_im_flutter/deeprespond_im_flutter.dart';
import 'package:deeprespond_im_call/deeprespond_im_call.dart';
import 'package:flutter/material.dart';
/// 一对一视频通话:对方的画面铺满,自己的画面在右上角
class OneToOneVideo extends StatelessWidget {
const OneToOneVideo({super.key, required this.call, required this.peer});
final CallHandle call;
final String peer;
@override
Widget build(BuildContext context) => Stack(children: [
Positioned.fill(
child: call.remoteView(peer, placeholder: (_) => const ColoredBox(color: Colors.black)),
),
Positioned(right: 16, top: 48, width: 120, height: 160, child: call.localView()),
]);
}
/// 群通话:在通话中的每个成员一格
class GroupVideoGrid extends StatelessWidget {
const GroupVideoGrid({super.key, required this.call, required this.me});
final CallHandle call;
final String me;
@override
Widget build(BuildContext context) => StreamBuilder<CallSnapshot>(
stream: call.stream,
initialData: call.snapshot,
builder: (context, snapshot) {
final others = snapshot.data!.call.members
.where((m) => m.state == 'joined' && m.username != null && m.username != me)
.map((m) => m.username!)
.toList();
return GridView.count(crossAxisCount: 2, children: [
call.localView(),
for (final username in others)
call.remoteView(username, fit: BoxFit.contain, placeholder: (_) => Center(child: Text(username))),
]);
},
);
}fitisBoxFit.cover(the default: fill and crop) orBoxFit.contain(show it all, with black bars).- Take member order and nicknames from the snapshot's
call.members. To show "Muted" or "Camera off" for others, use each person'saudioandvideoin the snapshot'sremote. - To control the widget yourself, use
CallVideoView(call: call, local: true)orCallVideoView(call: call, username: 'bob')directly. - Voice calls have no video and don't need these widgets.
In-call actions
Mute and camera on/off
// 静音 / 取消静音
await call.setMicrophoneEnabled(!call.snapshot.local.microphone);
// 关闭 / 打开摄像头(只用于视频通话)
await call.setCameraEnabled(!call.snapshot.local.camera);- Other members see the change (in
remotein their snapshots). Muting on the iOS CallKit screen also turns off the microphone. - Turning on the camera in a voice call rejects with
invalid_state(audio_call). A video call with the camera turned off is still recorded as a video call. - Before the media is connected, these reject with
invalid_state(media_not_connected).
Switch camera and speaker
// 在前置和后置摄像头之间切换
await call.switchCamera();
// 打开扬声器(免提);false 为听筒或耳机
await call.setSpeakerphoneOn(true);Both only work once the media is connected; calling them before has no effect.
Invite more people
In a group call, members in the call can invite other group members. Members who declined, missed or left can be invited again and ring again. A result comes back for each person, and one failure doesn't affect the others:
final results = await call.invite(['zhaoliu', 'sunqi']);
for (final r in results) {
if (!r.ok) {
showToast('${r.username} 无法邀请:${r.code}');
} else if (r.status == 'busy') {
showToast('${r.username} 正在通话中');
}
}Successful results have status invited (ringing), busy (on another call, not ringing) or already_in_call (already in this call). Failures have a code, such as not_group_member, group_muted, rate_limited (inviting too often) or limit_exceeded (the call is full). One-to-one calls can't invite others.
Remove members
Clients can't remove others from a call. To remove someone (for example for a violation), have your server call the call management API. The removed user's snapshot updates as usual and their media connection is closed.
Send data messages
sendData() sends data to the other members over the media connection. It suits state that runs alongside the call, such as "raise hand" or whiteboard strokes. Data only goes to people in the call right now and isn't stored.
import 'dart:convert';
await call.sendData(utf8.encode(jsonEncode({'action': 'raise_hand'})));
call.data.listen((m) {
final payload = jsonDecode(utf8.decode(m.data)) as Map<String, Object?>;
if (payload['action'] == 'raise_hand') showToast('${m.username} 举手了');
});The SDK already provides state such as "the other person is muted" (in the snapshot's remote), so you don't need data messages for it.
Hang up and end
// 挂断:接通前为取消,接通后为离开,正在振铃时等同拒绝
await call.hangup();
// 结束群通话:全部成员离开(只有发起人、群主和群管理员可以)
await call.end();- In a one-to-one call, either side hanging up ends the call. In a group call, hanging up only means the user leaves and the others continue; the user can
join()again afterward. - The group call's caller, the group owner and group admins can end the whole group call with
end(); others getpermission_denied(not_host). For one-to-one calls,end()is the same as hanging up. - A group call ends automatically once everyone in it has left. It also ends after 60 seconds with only one person in it and no invitations ringing.
- Any of the user's devices can hang up. Hanging up on the system call screen, or on Android's in-call notification, has the same effect.
- After hanging up, the SDK disconnects media, turns off the microphone and camera, and ends the call on the system screen.
Once the call ends, the snapshot's call.status is ended, call.endReason is the end reason, and the snapshot stops changing. What to show is up to you; the table below is only a suggestion. For the full list, see End reasons:
endReason | Caller sees | Callee sees |
|---|---|---|
completed | Call duration | Call duration |
canceled | Cancelled | Caller cancelled |
rejected | Declined | You declined |
busy | Busy | Missed call |
no_answer | No answer | Missed call |
connection_lost | Call dropped | Call dropped |
| Other | Call ended | Call ended |
New end reasons may be added. Show unknown values as "Call ended".
Group call banner
When a group call starts, when the number of people in it changes, and when it ends, all online group members are notified, so the group chat page can show a "3 people in a call" banner with a way to join. The SDK records this locally and emits GroupCallChanged, and im.calls.groupCallBanner(groupId) synchronously returns the group's current banner (null if none).
After the app is reopened, or if the user wasn't a group member when the notification was sent, the device may not know about the group's call. Call im.calls.groupCall(groupId) once when opening a group's chat page:
void refreshBanner() {
final banner = im.calls.groupCallBanner(groupId);
if (banner == null || banner.status == 'ended') {
renderBanner(null);
} else {
renderBanner(banner.joinedCount > 1 ? '${banner.joinedCount} 人正在通话' : '等待其他成员加入');
}
}
// 打开群聊页面时
await im.calls.groupCall(groupId).catchError((Object _) => null);
refreshBanner();
final sub = im.on<GroupCallChanged>().where((e) => e.groupId == groupId).listen((_) => refreshBanner());
// 点击横幅加入
Future<void> onJoinFromBanner() async {
final call = await im.calls.groupCall(groupId);
if (call != null) await call.join();
}The count in the banner may briefly be off; the call snapshot has the exact members. Cancel the subscription (sub.cancel()) when leaving the group chat page.
Disconnects and recovery
The SDK sends heartbeats and renews media credentials automatically; you don't handle them.
- Network blips and network switches. The media connection reconnects automatically. Meanwhile the snapshot's
mediaisreconnecting, so you can show "Poor connection, reconnecting". If it recovers within 45 seconds, the call isn't affected. - Offline for more than 45 seconds. The server treats the user as dropped. A one-to-one call ends with
connection_lost; in a group call the user leaves (connection_lost) and canjoin()again once the network is back. - After the network comes back or the app returns to the foreground. The SDK looks up the user's current call and ringing calls and reconciles them locally: calls that ended update to
ended, calls that came in while offline start ringing, and stale calls on the system screen are ended. - Going to the background. An ongoing call isn't interrupted. On Android the SDK starts a foreground service when answering or starting a call, with "In a call" and a hang-up button in the notification shade. On iOS, CallKit and the audio background mode keep it going (builds without CallKit rely on the Audio background mode).
Resume a call after the app is killed
If the user swipes the app away during a call (or the system ends it), the server still considers them in the call. When the app reopens and sign-in is restored, the SDK finds the call: im.calls.current is that call, the snapshot's resumable is true, and the SDK sends heartbeats on the user's behalf right away to keep the call alive. Call resume() to reconnect media and open the microphone and camera. Resume quickly: if it isn't resumed within 45 seconds, the server treats the user as dropped.
Future<void> checkResumable() async {
final call = im.calls.current;
if (call == null || !call.snapshot.resumable) return;
await call.resume();
openCallPage(call);
}
// 登录、重新连接后,SDK 同步完通话状态时
im.on<SyncCompleted>().where((e) => e.modules.contains(SyncModule.calls)).listen((_) => unawaited(checkResumable()));- You can also show "Tap to resume the call" and let the user decide.
- Calling
resume()whenresumableisn'ttruerejects withinvalid_state(not_resumable). - If connecting to the media service fails after answering (the snapshot's
mediaisdisconnected),resumablealso becomestrue, and you can retry withresume().
Microphone and camera
The SDK opens the microphone and camera when a call connects or resumes, and on setMicrophoneEnabled(true) and setCameraEnabled(true). If permission hasn't been granted, the system shows a prompt. Prompts are awkward when the user answers on the system call screen, so we recommend asking for microphone and camera permission with a plugin such as permission_handler before the user's first call (for example on the call settings page or the first time they start a call).
When opening a device fails:
- Without permission, it rejects with
permission_required, anddetails['permissions']lists the missing permissions (microphone,camera). - If the device doesn't exist or another program is using it, it rejects with
device_error;details['device']ismicrophoneorcameraanddetails['reason']isnot_foundorbusy.
When join() and resume() hit either error, the call is already connected; only that device isn't on (false in the snapshot's local). Before the other person can hear the user, guide the user to grant permission, then call setMicrophoneEnabled(true):
try {
await call.join();
} on DRException catch (e) {
if (e.code == 'permission_required') {
showPermissionGuide(e.details?['permissions']); // 引导用户在系统设置中允许麦克风、摄像头
} else if (e.code == 'device_error') {
showToast(e.details?['device'] == 'camera' ? '摄像头不可用' : '麦克风不可用');
} else {
rethrow;
}
}
// 用户授权后
await call.setMicrophoneEnabled(true);When starting a call, the SDK only connects media and opens devices after start() returns, so permission problems never make start() fail. Once media is connected (the snapshot's media is connected) and local.microphone is still false, call setMicrophoneEnabled(true) to get the specific error.
Full-screen notification permission on Android 14
From Android 14, apps targeting SDK 34 or later need the "full-screen notifications" permission to show full-screen notifications. Google Play only grants it by default to apps that declare calling or alarms as a core feature in Play Console and are approved. Without it, incoming calls show on the lock screen as an expanded, persistent heads-up notification instead of lighting up the screen with the call screen.
- If the app is on Google Play, describe its calling use in the "Full-screen intent" declaration in Play Console.
- Check the permission in the app and guide the user to turn it on if it's missing:
final ui = DRCallUI.of(im);
if (ui != null && !await ui.canUseFullScreenIntent()) {
// 向用户说明:开启后,锁屏时来电可以直接显示来电界面
await ui.openFullScreenIntentSettings();
}canUseFullScreenIntent() is always true on Android 13 and earlier, and on iOS.
Runtime policies and plans
| Setting | Effect |
|---|---|
Audio and video calls rtc_enabled | When off, users can't start, answer or join calls or invite others; calls reject with permission_denied (rtc_disabled). Calls in progress aren't affected |
Ring time rtc_ring_timeout_seconds | 60 seconds by default |
Group call size max_group_call_participants | Including the caller, set by the plan, 0 means no limit |
Call records rtc_call_record_enabled | When on, a call record message is written to the conversation after a call ends |
See Runtime policies for these. Also:
- Before the switch-over, a plan that disables audio/video affects
rtcEnabled. Under the new model, an insufficient audio/video balance also refuses new calls; for the exact rules, see Audio/video balance. - When the app's concurrent calls reach the plan's quota, starting a call rejects with
limit_exceeded(app_concurrent_calls). - When the app is read-only (for example for unpaid bills), starting calls and inviting reject with
app_unavailable; calls in progress aren't affected. - When the platform has suspended the app's audio and video, starting a call rejects with
permission_denied(rtc_suspended); when the audio/video service is temporarily unavailable,permission_denied(rtc_not_configured). Both are only known when starting a call, so tell the user according to the reason. - A rejection with
permission_denied(media_provider_unsupported) means this version of the Flutter SDK doesn't support the media service the platform provides for the app. Upgrade the SDK, and tell users "Calls aren't supported in this version".
Call records
Call history
im.calls.history() returns the calls the user took part in (including missed and declined ones) page by page, newest first, kept for 180 days:
final page = await im.calls.history(limit: 20);
for (final c in page.items) {
final target = c.type == 'single' ? c.peer : c.groupId;
print('$target ${c.media} ${c.endReason} ${c.durationSeconds} ${c.createdAt}');
}Call record messages in conversations
After a call ends, the server writes a message of type call to the conversation (both sides' one-to-one conversation for one-to-one calls, the group conversation for group calls). Use the display helper renderCallRecord() to get text such as "通话时长 01:32" (call duration 01:32) or "对方已拒绝" (declined):
import 'package:deeprespond_im_flutter/render.dart';
if (message.type == 'call') {
final text = renderCallRecord(message, self: im.auth.currentUser?.username ?? '');
print(text);
}For the message format, see Call record call; for display helpers, see Messages.
API reference
Methods returning a Future throw DRException on failure: not_signed_in when not signed in, and network_error or timeout for network errors. For error codes, see Events and errors and the server's Error codes.
im.calls.start()
Starts a one-to-one or group call. Once started, the user is in the call and the SDK connects to the media service.
| Parameter | Type | Required | Description |
|---|---|---|---|
toUser | String? | One of the two (named) | The other user in a one-to-one call |
toGroup | String? | One of the two (named) | Group ID for a group call |
usernames | List<String>? | No (named) | Members to ring in a group call; at most maxGroupCallParticipants minus one, not including the user |
media | CallMedia | Yes (named) | CallMedia.audio or CallMedia.video; can't change during the call |
ext | Map<String, String>? | No (named) | Custom fields, up to 16 entries and 1 KB as JSON, delivered with the call |
Returns: Future<CallHandle>. If the other person in a one-to-one call is on another call, the returned call has already ended (endReason is busy).
Errors: permission_denied (rtc_disabled: the app hasn't turned on audio and video; rtc_suspended, rtc_not_configured: temporarily unavailable; media_provider_unsupported: not supported in this version), call_in_progress (the user is already in a call; details['call_id'] is that call), already_exists (the group already has a group call in progress; details['call_id'] is that call), user_blocked, not_friend, user_muted, not_group_member, group_muted, group_disabled, not_found (the user or group doesn't exist), limit_exceeded (app_concurrent_calls), app_unavailable, rate_limited (call_rate, pair_call_rate, group_call_rate), invalid_argument (such as self_call for calling yourself, or invalid_ext), local_validation (missing parameters, both toUser and toGroup given, too_many_invitees).
im.calls.current
Read-only CallHandle?: the user's current call (member state joined), including one on another device (self.joinedOnThisDevice is false); null if none.
im.calls.incoming
Read-only List<CallHandle>: calls ringing right now.
im.calls.get()
Gets a call's handle by call ID, returning it from local state if present and otherwise asking the server.
| Parameter | Type | Required | Description |
|---|---|---|---|
callId | String | Yes | Call ID |
Returns: Future<CallHandle>.
Errors: not_found (the call doesn't exist, or the user can't see it: one-to-one calls are visible only to the two people, and group calls to the group's current members).
im.calls.history()
Returns the calls the user took part in, page by page, newest first.
| Parameter | Type | Required | Description |
|---|---|---|---|
cursor | String? | No (named) | nextCursor from the previous page |
limit | int? | No (named) | Items per page; default 20, max 50 |
Returns: Future<DRPage<CallSummary>>.
Errors: rate_limited (query_rate).
im.calls.groupCall()
Gets a group's current group call and updates the local group call banner.
| Parameter | Type | Required | Description |
|---|---|---|---|
groupId | String | Yes | Group ID |
Returns: Future<CallHandle?>; null if there's no group call in progress.
Errors: not_found (the group doesn't exist), not_group_member, rate_limited.
im.calls.groupCallBanner()
Synchronously returns the group call banner recorded locally for this group.
| Parameter | Type | Required | Description |
|---|---|---|---|
groupId | String | Yes | Group ID |
Returns: GroupCallBanner?. null means there's no group call in progress, or the device doesn't know yet (after the app reopens, for groups not queried yet); see Group call banner.
call.snapshot
Read-only CallSnapshot: the call's current snapshot (immutable, replaced as a whole on change); see CallSnapshot.
call.stream
Read-only Stream<CallSnapshot>: emits the current snapshot on subscribe, then a new one on every change. It stops changing once the call ends.
call.tracks
Read-only Stream<CallTrackEvent>: media tracks being added and removed, both the user's own (local is true) and other members'. The video widgets already handle tracks, so you usually don't subscribe.
call.data
Read-only Stream<CallDataMessage>: data other members sent with sendData().
call.join()
Answers an incoming call or a group call invitation, joins a group call from the banner, or rejoins a group call after leaving.
Returns: Future<void>, completing once answered and connected to media.
Errors: call_in_progress (in_other_call: the user is in another call, details['call_id'] is that call; joined_on_other_device: the user already answered on another device), call_ended (the call has ended), limit_exceeded (call_full: the group call is full; call_member_limit: the group call has reached its limit on total members involved), version_conflict (the user's current state can't answer, such as a one-to-one call already declined), permission_denied (rtc_disabled and so on), not_group_member, group_muted, user_muted, permission_required, device_error (for these two the call is already answered; see Microphone and camera), network_error (media_connect_failed: answered but connecting to the media service failed; see Resume a call after the app is killed), rate_limited (signal_rate; if the wait is 5 seconds or less the SDK retries once automatically).
call.reject()
Rejects an incoming call or group call invitation.
| Parameter | Type | Required | Description |
|---|---|---|---|
reason | RejectReason | No (named) | RejectReason.declined (default) or RejectReason.busy |
Returns: Future<void>. Rejecting again counts as success.
Errors: version_conflict (the user already answered, or wasn't invited), rate_limited.
call.hangup()
Hangs up: cancels if the caller hasn't connected yet, leaves if in the call, and rejects if ringing. This device's media is disconnected whether or not it succeeds.
Returns: Future<void>. Hanging up an ended call counts as success.
Errors: rate_limited, and network errors such as network_error.
call.end()
Ends the whole group call: everyone leaves and ringing invitations are cancelled. Only the caller, the group owner and group admins can. For one-to-one calls it's the same as hanging up.
Returns: Future<void>.
Errors: permission_denied (not_host), rate_limited.
call.invite()
Invites more group members to a group call.
| Parameter | Type | Required | Description |
|---|---|---|---|
usernames | List<String> | Yes | Group members to invite, 1 to 1000, not including the user |
Returns: Future<List<CallInviteResult>>, a result per person; see Invite more people.
Errors: invalid_argument (group_call_only: one-to-one calls can't invite; invalid_usernames; too_many_invitees), permission_denied (not_in_call: the user isn't in the call; rtc_disabled), call_ended, app_unavailable, local_validation (empty usernames).
call.resume()
Resumes the call on this device after the app was killed and reopened, or after the media connection failed: reconnects media and opens the microphone and camera. Only available when the snapshot's resumable is true.
Returns: Future<void>.
Errors: invalid_state (not_resumable), call_ended, version_conflict (the user is no longer in the call), permission_required, device_error, network_error.
call.setMicrophoneEnabled()
Turns the microphone on or off (mute).
| Parameter | Type | Required | Description |
|---|---|---|---|
on | bool | Yes | true for on, false for off |
Returns: Future<void>.
Errors: invalid_state (media_not_connected: media isn't connected yet), permission_required, device_error.
call.setCameraEnabled()
Turns the camera on or off; video calls only.
| Parameter | Type | Required | Description |
|---|---|---|---|
on | bool | Yes | true for on, false for off |
Returns: Future<void>.
Errors: invalid_state (audio_call: voice calls can't turn on the camera; media_not_connected), permission_required, device_error.
call.sendData()
Sends data to the other members over the media connection.
| Parameter | Type | Required | Description |
|---|---|---|---|
data | List<int> | Yes | The data; encode strings with utf8.encode() first |
Returns: Future<void>.
Errors: invalid_state (media_not_connected).
call.localView(), call.remoteView()
Extension methods from deeprespond_im_call that return a Widget showing video.
| Method | Parameters | Description |
|---|---|---|
localView({BoxFit fit = BoxFit.cover, WidgetBuilder? placeholder}) | fit: scaling; placeholder: what to show when there's no video | The user's own camera, mirrored |
remoteView(String username, {BoxFit fit = BoxFit.cover, WidgetBuilder? placeholder}) | username: the member's username; the rest as above | One member's video |
Returns: Widget (CallVideoView). fit only distinguishes BoxFit.contain from everything else (treated as cover).
call.switchCamera()
Extension method from deeprespond_im_call: switches between the front and back cameras. Does nothing when the camera is off.
Returns: Future<void>.
call.setSpeakerphoneOn()
Extension method from deeprespond_im_call: turns the speaker (hands-free) on or off.
| Parameter | Type | Required | Description |
|---|---|---|---|
on | bool | Yes | true for the speaker, false for the earpiece or headphones |
Returns: Future<void>.
DRCallUI.of()
Gets the client's system call screen object.
| Parameter | Type | Required | Description |
|---|---|---|---|
client | DRClient | Yes | The client |
Returns: DRCallUI?; null if the client was created without the call option, or on platforms other than iOS and Android.
callUI.actions
Read-only Stream<CallAction>: the user's actions on the system call screen and call notifications. The SDK has already answered, rejected or hung up; you open or close your call screen accordingly. See Actions on the system call screen.
callUI.canUseFullScreenIntent()
Whether the app can show full-screen notifications (from Android 14).
Returns: Future<bool>. Always true on Android 13 and earlier, and on iOS.
callUI.openFullScreenIntentSettings()
Opens the app's full-screen notification permission page in system settings (the notification settings page on Android 13 and earlier).
Returns: Future<void>.
DRCallKit.setUp() (iOS native)
Swift API from deeprespond_im_call. Call it synchronously in application(_:didFinishLaunchingWithOptions:) in AppDelegate to create the PushKit and CallKit objects as early as possible. Don't call it in builds released on the App Store in mainland China.
| Parameter | Type | Required | Description |
|---|---|---|---|
appName | String? | No | App name shown on the call screen; defaults to CFBundleDisplayName |
ringtone | String? | No | Ringtone, a sound file name in the app's resources |
Events
Listen with im.on<T>(); see Events and errors.
| Event | Fields | Description |
|---|---|---|
CallIncoming | call (CallHandle) | An incoming call started ringing. In the foreground, you show the incoming call screen |
CallChanged | call (CallHandle); change (CallChange?) | The call changed; see Show call status. change is null for this device's own actions |
GroupCallChanged | groupId (String); banner (GroupCallBanner?) | A group's call banner changed; banner is null when the group has no call |
Types
CallSnapshot
A handle's snapshot.
| Field | Type | Description |
|---|---|---|
call | Call | The call |
self | CallSelf? | The user's state in the call: state is the user's member state (null if not a member), and joinedOnThisDevice is whether the user is in the call on this device |
inviter | CallInviter? | Who's calling: username, nickname, avatarUrl; only for incoming calls |
group | CallGroup? | The group of a group call: groupId, name, avatarUrl; null for one-to-one calls, and only for incoming calls |
media | String | This device's media connection: none, connecting, connected, reconnecting, disconnected |
local | CallLocalMedia | Whether the user's microphone and camera are on: microphone, camera |
remote | List<CallRemoteMedia> | Other members' media, one item per person: username, mediaUid, audio, video |
ringing | bool | Whether it's ringing here; becomes false automatically when ringing times out |
resumable | bool | The user's call on this device has no media connection and can be resumed with resume() |
Call
A call. The raw data is in raw; for fields, see Call object in the server docs.
| Field | Type | Description |
|---|---|---|
callId | String | Call ID |
type | String | single (one-to-one) or group |
groupId | String? | Group of a group call; null for one-to-one |
media | String | audio or video |
status | String | ringing (waiting to connect), active (connected) or ended; only moves forward in this order |
initiator | String? | The caller's username |
maxParticipants | int | Cap on people in the call at once, fixed when it starts |
joinedCount | int | Number of people in the call right now |
members | List<CallMember> | Members; empty in call history |
createdAt | DateTime? | When it started |
answeredAt | DateTime? | When it connected; null if it never did |
endedAt | DateTime? | When it ended |
endReason | String? | End reason; see Hang up and end |
endedBy | String? | Who ended the call; null when your server, the platform or the system ended it |
durationSeconds | int | Seconds from connecting to ending; 0 if it never connected |
version | int | Version, incremented on every change |
ext | Map<String, String>? | Custom fields given when starting |
self | CallSelf? | The user's state in this call (in call history and lookup results) |
member(username) returns a member, or null if there's none.
CallMember
A call member.
| Field | Type | Description |
|---|---|---|
username | String? | Username; null for deleted users |
role | String | initiator (caller), invitee (invited) or joiner (joined a group call by themselves) |
state | String | ringing, joined (in the call), left, declined, busy, missed (didn't answer) or canceled (invitation cancelled) |
mediaUid | int | The member's number in this call |
invitedBy | String? | Who last invited them |
ringExpiresAt | DateTime? | When ringing stops |
joinedAt | DateTime? | When they last joined |
leftAt | DateTime? | When they last left |
leaveReason | String? | Why they last left; see Leave reasons |
CallSummary
An item from im.calls.history(): a Call (without members), plus:
| Field | Type | Description |
|---|---|---|
peer | String? | The other user in a one-to-one call; null for group calls |
CallChange
The change in a CallChanged event.
| Field | Type | Description |
|---|---|---|
kind | String | Kind of change; see Show call status |
usernames | List<String?> | Members whose state changed |
actor | String? | Who did it; null when your server, the platform or the system did |
reason | String? | Decline reason, leave reason or end reason |
CallInviteResult
One person's result from invite().
| Field | Type | Description |
|---|---|---|
username | String | Username |
ok | bool | Whether it succeeded (no code) |
status | String? | On success: invited, busy, already_in_call |
code | String? | On failure: the error code |
message | String? | On failure: a description |
details | Map<String, Object?>? | On failure: details |
GroupCallBanner
A group call banner.
| Field | Type | Description |
|---|---|---|
groupId | String | Group ID |
callId | String | Call ID |
media | String | audio or video |
status | String | Call status: ringing, active, ended |
joinedCount | int | Number of people in the call; may briefly be off |
initiator | String? | The caller |
version | int | Version |
CallTrackEvent
| Field | Type | Description |
|---|---|---|
added | bool | true when added, false when removed |
username | String? | The member's username |
mediaUid | int | The member's number in the call |
kind | String | audio or video |
local | bool | Whether it's the user's own track |
track | Object | The media track object, for the video widgets |
CallDataMessage
| Field | Type | Description |
|---|---|---|
username | String? | The sender's username |
mediaUid | int | The sender's number in the call |
data | List<int> | The data |
CallAction
| Field | Type | Description |
|---|---|---|
action | String | answer, reject, hangup, mute, unmute, show, open; see Actions on the system call screen |
callId | String | Call ID |
DRCallOptions
See Give call options when creating the client.
| Field | Type | Description |
|---|---|---|
useCallKit | bool | iOS: use CallKit and PushKit. Default true |
useConnectionService | bool | Android: register system calls with Core-Telecom. Default true |
autoBusy | bool | Automatically reject incoming calls as busy while on a call. Default true |
ringtone | String? | iOS ringtone file name |
Standalone channels
The calling, ringing and answering on this page belong to calls. For meetings and voice rooms that don't need an IM group, use standalone RTC channels, which don't use the incoming call UI or notifications. They share media usage with calls, but billing is defined separately.
