Quickstart
This page builds a Flutter app that sends and receives messages, from scratch: platform configuration, creating the client, signing in with a ticket from your backend, showing the conversation list, opening a conversation, sending text, alerting on new messages, and signing out. At the end you'll have a runnable example to build your own UI on.
Before you start
- In the console, use the
demoapp created automatically when you registered, or create an app. Under "Access credentials" on the overview, copy the AppKey (such as1575529652#demo) and the IM service URL, shown as the "REST endpoint" (such ashttps://im.example.com). - Integrate your backend with the server REST API and add an endpoint that returns an IM sign-in ticket: after a user signs in to your system, the app calls this endpoint, and your backend issues a ticket for that user and returns it, such as
{"ticket": "ult_..."}. - Install Flutter 3.38.1 or later. Building for iOS or macOS needs a Mac with Xcode; building for Android needs the Android SDK and JDK 17.
A ticket is valid for 5 minutes and can be used once. It works like a temporary password for the user, so only return it to that user's app over HTTPS. The App Token must stay on your server and never go into the app. During development and testing, you can also issue a ticket with curl and paste it into the app.
Create the project and install
flutter create --platforms=android,ios im_demo
cd im_demo
flutter pub add deeprespond_im_flutterdeeprespond_im_flutter contains every API needed to send and receive messages, and it's all this page uses. Add deeprespond_im_push and deeprespond_im_call later for offline push and audio/video calls; see the overview.
Platform configuration
Encrypting the local database (optional)
The SDK's local database is plain SQLite by default. To use SQLCipher encryption, add the build hook configuration to your app's pubspec.yaml, and pass encryptDatabase: true when creating the client:
hooks:
user_defines:
sqlite3:
source:
android: sqlcipher
ios: sqlcipher
macos: sqlcipher
linux: system
default: sqlcipherfinal im = await DRClient.create(DRClientOptions(
// ...
encryptDatabase: true,
));Don't turn on
encryptDatabaseunless the build has switched to SQLCipher; otherwise the SDK refuses to create the client withunsupported.
Android
The network, network state and audio recording permissions the SDK needs are declared in the package and merged into your app's manifest at build time; you don't need to add them. What to check:
In
android/app/build.gradle.kts,minSdkmust be at least 24, andcompileSdk36 is recommended. The defaults since Flutter 3.38 (flutter.minSdkVersion,flutter.compileSdkVersion) already meet this. If your project sets lower values, change them to:kotlinandroid { compileSdk = 36 defaultConfig { minSdk = 24 } }Backups: the SDK keeps the sign-in state in the system's secure storage. If Android's automatic backup carries that encrypted data to a new phone, it can't be decrypted and the user has to sign in again. We recommend excluding it from backups (the SDK's own database isn't backed up, so it needs no configuration). Set backup rules on
<application>inandroid/app/src/main/AndroidManifest.xml:xml<application android:fullBackupContent="@xml/backup_rules" android:dataExtractionRules="@xml/data_extraction_rules" ...>android/app/src/main/res/xml/backup_rules.xml(Android 11 and earlier):xml<?xml version="1.0" encoding="utf-8"?> <full-backup-content> <exclude domain="sharedpref" path="deeprespond_im.xml" /> <exclude domain="sharedpref" path="FlutterSecureKeyStorage:deeprespond_im.xml" /> </full-backup-content>android/app/src/main/res/xml/data_extraction_rules.xml(Android 12 and later):xml<?xml version="1.0" encoding="utf-8"?> <data-extraction-rules> <cloud-backup> <exclude domain="sharedpref" path="deeprespond_im.xml" /> <exclude domain="sharedpref" path="FlutterSecureKeyStorage:deeprespond_im.xml" /> </cloud-backup> <device-transfer> <exclude domain="sharedpref" path="deeprespond_im.xml" /> <exclude domain="sharedpref" path="FlutterSecureKeyStorage:deeprespond_im.xml" /> </device-transfer> </data-extraction-rules>If your app doesn't need backups at all, you can instead set
android:allowBackup="false"on<application>.During development the IM service URL is
http://, so cleartext traffic must be allowed. Put this only inandroid/app/src/debug/AndroidManifest.xml, never in the release build:xml<application android:usesCleartextTraffic="true" />
iOS
Deployment target: iOS 15.0 or later. Set
platform :ios, '15.0'inios/Podfile, and set the Runner's Minimum Deployments to 15.0 in Xcode.Usage descriptions: add a microphone usage description to
ios/Runner/Info.plist(the SDK can record voice); if you use video calls, add a camera usage description too:xml<key>NSMicrophoneUsageDescription</key> <string>用于发送语音消息和语音通话</string> <key>NSCameraUsageDescription</key> <string>用于视频通话</string>During development the IM service URL is an
http://LAN address, so allow cleartext requests to the local network in the developmentInfo.plist(use HTTPS in release builds):xml<key>NSAppTransportSecurity</key> <dict> <key>NSAllowsLocalNetworking</key> <true/> </dict>
macOS
When building for macOS, the deployment target must be macOS 12.0 or later (platform :osx, '12.0' in macos/Podfile and Minimum Deployments in Xcode). Add the network client and keychain entitlements to both macos/Runner/DebugProfile.entitlements and Release.entitlements (the SDK keeps the sign-in state in the keychain):
<key>com.apple.security.network.client</key>
<true/>
<key>keychain-access-groups</key>
<array/>Push and incoming calls
This page's example doesn't need them. For setup, see Offline push and Audio and video calls; in short:
- iOS: in Xcode, add the Push Notifications and Background Modes capabilities to Runner. Under background modes check Remote notifications (for push), and for calls also Voice over IP and Audio, AirPlay, and Picture in Picture;
UIBackgroundModesinInfo.plistbecomesremote-notification,voipandaudioaccordingly. If you use CallKit, callDRCallKit.setUp()inAppDelegate; see Audio and video calls. - Android: FCM needs
google-services.jsonand the Google Services Gradle plugin; Chinese vendor channels need each vendor's configuration; see Offline push. - Where in
main(): callFirebase.initializeApp(),FirebaseMessaging.onBackgroundMessage(...)(which callsdrFirebaseBackgroundHandler) and the vendor channel registration functions (such asregisterXiaomiPush(...)) before creating the client. See the comments in Create the client below and Initialization and background handlers.
Create the client
Create the client in main(), before runApp, and share this one instance across the app:
import 'package:deeprespond_im_flutter/deeprespond_im_flutter.dart';
import 'package:flutter/material.dart';
late final DRClient im;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// 接入推送时,在这里(创建客户端之前):
// Android 上 await Firebase.initializeApp(); FirebaseMessaging.onBackgroundMessage(onBackgroundMessage);
// (onBackgroundMessage 中调用 drFirebaseBackgroundHandler),以及厂商通道的 registerXiaomiPush(...) 等,
// 并在下面的选项中给出 push、call,见“离线推送”“音视频通话”
im = await DRClient.create(DRClientOptions(
appKey: '1575529652#demo',
apiUrl: Uri.parse('https://im.example.com'),
platform: DRFlutterPlatform.instance,
));
runApp(const MaterialApp(home: Placeholder()));
}- Call
WidgetsFlutterBinding.ensureInitialized()beforeDRClient.create. - Creating the client sends no requests; before returning, it reads the locally saved sign-in state. If someone already signed in on this device, the SDK resumes that session right away (
im.auth.currentUserisn'tnull) with no need to sign in again.
Sign in
Get a ticket from your backend and give it to the SDK to sign in. The example uses HttpClient from dart:io to call your backend; use whatever networking library you prefer:
import 'dart:convert';
import 'dart:io';
import 'package:deeprespond_im_flutter/deeprespond_im_flutter.dart';
/// 向业务服务端获取 IM 登录凭证(/api/im-ticket 是你自己定义的接口,用户的身份取自你的业务系统的登录状态)。
Future<String> fetchTicket() async {
final http = HttpClient();
try {
final req = await http.postUrl(Uri.parse('https://your-server.example.com/api/im-ticket'));
req.headers.set(HttpHeaders.authorizationHeader, 'Bearer 你的业务系统的登录令牌');
final res = await req.close();
final text = await res.transform(utf8.decoder).join();
if (res.statusCode != 200) throw HttpException('获取 IM 登录凭证失败:${res.statusCode}');
return (jsonDecode(text) as Map<String, Object?>)['ticket']! as String;
} finally {
http.close();
}
}
Future<DRClient> createClient() => DRClient.create(DRClientOptions(
appKey: '1575529652#demo',
apiUrl: Uri.parse('https://im.example.com'),
platform: DRFlutterPlatform.instance,
// 凭证失效、或登录状态过期时,SDK 用它重新取一次凭证
ticketProvider: (context) => fetchTicket(),
));
Future<void> signIn(DRClient im) async {
if (im.auth.currentUser != null) return; // 已经登录(自动恢复)
final result = await im.auth.loginWithTicket(ticket: await fetchTicket());
print('已登录 ${result.user.username}');
}After signing in, the SDK connects and syncs data in the background. When the user closes and reopens the app, the session is restored automatically, so you only need to sign in when im.auth.currentUser is null. For handling sign-in failures, see Initialization, sign-in and connection.
Show the conversation list
im.conversations.list() returns a live conversation list, sorted by pinned state and the time of the latest message. Data is read locally first and updates as new data syncs in; use LiveListBuilder to rebuild the UI when the list changes:
import 'package:deeprespond_im_flutter/render.dart';
import 'package:deeprespond_im_flutter/deeprespond_im_flutter.dart';
import 'package:flutter/material.dart';
class ConversationsView extends StatefulWidget {
const ConversationsView({super.key, required this.im, required this.onOpen});
final DRClient im;
final void Function(String conversationKey) onOpen;
@override
State<ConversationsView> createState() => _ConversationsViewState();
}
class _ConversationsViewState extends State<ConversationsView> {
// 列表必须在登录之后创建;不再显示时释放
late final ConversationList _list = widget.im.conversations.list();
@override
void dispose() {
_list.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final im = widget.im;
final me = im.auth.currentUser?.username;
return LiveListBuilder<ConversationView>(
list: _list,
builder: (context, snapshot) => ListView.builder(
itemCount: snapshot.items.length,
itemBuilder: (context, i) {
final c = snapshot.items[i];
final last = c.lastMessage;
return ListTile(
key: ValueKey(c.conversationKey),
title: Text(conversationTitle(im, c)),
subtitle: Text(last == null ? '' : summarize(last, nameOf: (u) => displayName(im, u), self: me), maxLines: 1),
trailing: c.unreadCount > 0 ? Badge(label: Text('${c.unreadCount}')) : null,
onTap: () => widget.onOpen(c.conversationKey),
);
},
),
);
}
}conversationTitle gives a conversation's title: the other user's remark or nickname for one-to-one chats, or the group name for groups. summarize gives a summary of the last message (such as "[Image]"). Create the list after signing in; calling it while signed out throws not_signed_in.
Open a conversation and show messages
When the user taps a conversation, open its message list with its conversationKey. To start chatting with someone, first get the one-to-one conversation with openSingle:
// 与某个人开始聊天(还没有会话时得到一个本地的草稿会话)
final view = await im.conversations.openSingle('lisi');
final messages = im.messages.open(view.conversationKey);
messages.stream.listen((snapshot) {
for (final m in snapshot.items) {
final text = m.type == 'text' ? '${m.body?['text'] ?? ''}' : '[${m.type}]';
print('${m.sender}:$text(${m.local.status})');
}
});
messages.setViewing(visible: true, atBottom: true); // 用户正在看这个会话的最新消息
messages.markRead(); // 把这个会话标记为已读- Messages are in order with the newest last; messages still being sent come last, with
local.statussuch assendingorfailed. - Opening shows the newest local page; call
messages.loadOlder()when the user scrolls up. - Call
messages.dispose()to release the list when leaving the conversation.
Send a text message
final sent = await im.messages.send(SendTarget.conversation(messages.conversationKey), OutgoingContent.text('你好'));
print('${sent.clientMsgId} ${sent.local.status}'); // queued:已进入发送队列send returns as soon as the message is in the send queue, and the message appears in the open message list right away. The SDK then handles sending and retries, reporting status changes through the message list. While the network is down, messages wait in the queue and are sent when it returns. You can also send without opening a conversation, straight to a user or group: SendTarget.user('lisi'), SendTarget.group('group ID').
Listen for new messages and connection state
im.on<MessageReceived>().listen((e) {
// 按免打扰、提醒设置判断是否需要提醒;App 在前台时由你在界面中提示
if (e.notify.notify) showToast(e.notify.body ?? '${e.message.sender} 发来一条新消息');
});
im.connection.stateStream.listen((state) {
// ConnectionReady:已连接并同步完成;ConnectionWaiting:等待重连;ConnectionOffline:网络不可用
print(switch (state) {
ConnectionReady() => '',
ConnectionOffline() => '网络不可用',
_ => '连接中…',
});
});
im.auth.stateStream.listen((state) {
if (state is SessionEnded) showToast('登录已失效,请重新登录(${state.reason})');
});When new messages arrive, open conversation and message lists update themselves; you don't need to handle that in events. MessageReceived is only for showing an in-app alert, playing a sound and the like. Alerts while the app is in the background come from offline push; see Offline push. For all events, see Events and errors.
Sign out
await im.auth.logout();Signing out clears the local sign-in state and closes the connection; open lists are emptied and disposed. On shared devices, logout(clearLocalData: true) also deletes this user's local chat history.
Complete example
Replace lib/main.dart with the code below, change the AppKey, service URL and ticket endpoint, and run flutter run. Sign in as two users on two devices (or a device and an emulator), and they can message each other.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:deeprespond_im_flutter/render.dart';
import 'package:deeprespond_im_flutter/deeprespond_im_flutter.dart';
import 'package:flutter/material.dart';
Future<String> fetchTicket() async {
final http = HttpClient();
try {
final req = await http.postUrl(Uri.parse('https://your-server.example.com/api/im-ticket'));
req.headers.set(HttpHeaders.authorizationHeader, 'Bearer 你的业务系统的登录令牌');
final res = await req.close();
final text = await res.transform(utf8.decoder).join();
if (res.statusCode != 200) throw HttpException('获取 IM 登录凭证失败:${res.statusCode}');
return (jsonDecode(text) as Map<String, Object?>)['ticket']! as String;
} finally {
http.close();
}
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final im = await DRClient.create(DRClientOptions(
appKey: '1575529652#demo',
apiUrl: Uri.parse('https://im.example.com'),
platform: DRFlutterPlatform.instance,
ticketProvider: (context) => fetchTicket(),
));
runApp(DemoApp(im: im));
}
class DemoApp extends StatefulWidget {
const DemoApp({super.key, required this.im});
final DRClient im;
@override
State<DemoApp> createState() => _DemoAppState();
}
class _DemoAppState extends State<DemoApp> {
final _navigator = GlobalKey<NavigatorState>();
final _messenger = GlobalKey<ScaffoldMessengerState>();
final _subs = <StreamSubscription<Object?>>[];
DRClient get im => widget.im;
@override
void initState() {
super.initState();
_subs.add(im.auth.stateStream.listen((state) {
// 登录、退出、会话结束时切换页面
_navigator.currentState?.popUntil((route) => route.isFirst);
setState(() {});
if (state is SessionEnded) _toast('登录已失效,请重新登录');
}));
_subs.add(im.on<MessageReceived>().listen((e) {
if (e.notify.notify) _toast('${displayName(im, e.message.sender)}:${summarize(e.message, nameOf: (u) => displayName(im, u))}');
}));
}
@override
void dispose() {
for (final sub in _subs) {
unawaited(sub.cancel());
}
super.dispose();
}
void _toast(String text) => _messenger.currentState?.showSnackBar(SnackBar(content: Text(text)));
@override
Widget build(BuildContext context) {
final state = im.auth.state;
final signedIn = state is SignedIn || state is Suspended;
return MaterialApp(
navigatorKey: _navigator,
scaffoldMessengerKey: _messenger,
title: 'IM 示例',
home: signedIn ? ConversationsPage(im: im) : LoginPage(im: im),
);
}
}
class LoginPage extends StatefulWidget {
const LoginPage({super.key, required this.im});
final DRClient im;
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
bool _busy = false;
String? _error;
Future<void> _login() async {
setState(() {
_busy = true;
_error = null;
});
try {
await widget.im.auth.loginWithTicket(ticket: await fetchTicket());
} on DRException catch (e) {
setState(() => _error = describeError(e));
} catch (e) {
setState(() => _error = '$e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('登录')),
body: Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
if (_error != null) Text(_error!, style: const TextStyle(color: Colors.red)),
FilledButton(onPressed: _busy ? null : () => unawaited(_login()), child: const Text('登录 IM')),
]),
),
);
}
class ConversationsPage extends StatefulWidget {
const ConversationsPage({super.key, required this.im});
final DRClient im;
@override
State<ConversationsPage> createState() => _ConversationsPageState();
}
class _ConversationsPageState extends State<ConversationsPage> {
late final ConversationList _list = widget.im.conversations.list();
final _peer = TextEditingController();
DRClient get im => widget.im;
@override
void dispose() {
_list.dispose();
_peer.dispose();
super.dispose();
}
void _open(ConversationView c) {
unawaited(Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => ChatPage(im: im, conversationKey: c.conversationKey, title: conversationTitle(im, c)))));
}
Future<void> _startChat() async {
final username = _peer.text.trim();
if (username.isEmpty) return;
_open(await im.conversations.openSingle(username));
}
@override
Widget build(BuildContext context) {
final me = im.auth.currentUser?.username;
return Scaffold(
appBar: AppBar(
title: StreamBuilder<DRConnectionState>(
stream: im.connection.stateStream,
initialData: im.connection.state,
builder: (context, s) => Text(switch (s.data) {
ConnectionReady() => '$me',
ConnectionOffline() => '网络不可用',
_ => '连接中…',
}),
),
actions: [IconButton(icon: const Icon(Icons.logout), onPressed: () => unawaited(im.auth.logout()))],
),
body: Column(children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(children: [
Expanded(child: TextField(controller: _peer, decoration: const InputDecoration(labelText: '对方的用户名'))),
TextButton(onPressed: () => unawaited(_startChat()), child: const Text('开始聊天')),
]),
),
Expanded(
child: LiveListBuilder<ConversationView>(
list: _list,
builder: (context, snapshot) => ListView.builder(
itemCount: snapshot.items.length,
itemBuilder: (context, i) {
final c = snapshot.items[i];
final last = c.lastMessage;
return ListTile(
key: ValueKey(c.conversationKey),
title: Text(conversationTitle(im, c)),
subtitle: Text(last == null ? '' : summarize(last, nameOf: (u) => displayName(im, u), self: me), maxLines: 1),
trailing: c.unreadCount > 0 ? Badge(label: Text('${c.unreadCount}')) : null,
onTap: () => _open(c),
);
},
),
),
),
]),
);
}
}
class ChatPage extends StatefulWidget {
const ChatPage({super.key, required this.im, required this.conversationKey, required this.title});
final DRClient im;
final String conversationKey;
final String title;
@override
State<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
late final MessageList _list = widget.im.messages.open(widget.conversationKey);
final _input = TextEditingController();
DRClient get im => widget.im;
@override
void initState() {
super.initState();
_list.setViewing(visible: true, atBottom: true);
_list.markRead();
}
@override
void dispose() {
_list.dispose();
_input.dispose();
super.dispose();
}
Future<void> _send() async {
final text = _input.text.trim();
if (text.isEmpty) return;
_input.clear();
try {
// 草稿会话发出第一条消息后会换成正式的会话,_list.conversationKey 随之更新
await im.messages.send(SendTarget.conversation(_list.conversationKey), OutgoingContent.text(text));
} on DRException catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(describeError(e))));
}
}
@override
Widget build(BuildContext context) {
final me = im.auth.currentUser?.username;
return Scaffold(
appBar: AppBar(title: Text(widget.title)),
body: Column(children: [
Expanded(
child: StreamBuilder<MessageListSnapshot>(
stream: _list.stream,
initialData: _list.snapshot,
builder: (context, s) {
final items = s.data?.items ?? const <MessageView>[];
return ListView.builder(
reverse: true, // 最新的消息在底部
itemCount: items.length,
itemBuilder: (context, i) {
final m = items[items.length - 1 - i];
final mine = m.sender == me;
final text = m.type == 'text' ? '${m.body?['text'] ?? ''}' : summarize(m, nameOf: (u) => displayName(im, u), self: me);
final status = m.local.status == 'sent' ? '' : '(${m.local.status})';
return Align(
key: ValueKey(m.clientMsgId ?? m.messageId),
alignment: mine ? Alignment.centerRight : Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Text(mine ? '$text$status' : '${displayName(im, m.sender)}:$text'),
),
);
},
);
},
),
),
SafeArea(
child: Row(children: [
Expanded(child: Padding(padding: const EdgeInsets.all(8), child: TextField(controller: _input, onSubmitted: (_) => unawaited(_send())))),
IconButton(icon: const Icon(Icons.send), onPressed: () => unawaited(_send())),
]),
),
]),
);
}
}In this example:
im.auth.stateStreamemits a new state on sign-in, sign-out and session end, and the example switches between the login page and the conversation list accordingly; an app that was signed in before opens straight to the conversation list.- The conversation list and message list display only depend on the lists' snapshots, so new messages, send status changes, read receipts from other devices and so on all show up automatically.
- The open chat page reports that the user is viewing it with
setViewing; to mark new messages as read, callmarkRead()or turn on theautoMarkReadoption; see Initialization, sign-in and connection.
Next steps
- Initialization, sign-in and connection: every initialization option, password sign-in, why sessions end, connection state and foreground/background.
- Conversations, Messages: unread counts, drafts, image and file messages, recall, read receipts and more.
- Using it in Flutter UIs:
LiveListBuilder,DRImage, voice recording. - Offline push, Audio and video calls: push and the system incoming-call UI.
- Events and errors: every event and error code.
Join a standalone channel
After signing in, you can join channels your backend created by following Channels. The app must have channels enabled and your backend must allow the user to join; joining doesn't turn on the camera or microphone by default.
