Mobile apps
Deewan in React Native, Flutter, and native iOS and Android apps — embedded in one component, or with your own interface.
Two ways to put a meeting in your app. Embed shows Deewan's own meeting — every feature, end-to-end encrypted, in the person's language — from a join link. Custom joins with a token and gives you participants, video tracks, chat and reactions to draw your own screens. People in apps and in browsers are in the same meeting either way.
Never ship an API key in an app. Your server creates the join link (POST /rooms/{id}/join-links) or token (POST /rooms/{id}/tokens) for the signed-in person and returns only that.
| Embed | Custom | |
|---|---|---|
| From your server | A join link | A token |
| Interface | Deewan's, with every feature | Yours |
| Strict rooms | Yes | No — tokens aren't issued for strict rooms |
| Expo Go | Yes | No — needs a development build |
Set a redirect_url on the join link (for example https://app.example.com/meeting-done). When the person leaves or the meeting ends, the embedded view doesn't open it — it tells your app, so you can close the screen.
React Native and Expo
# Expo
npx expo install @deewan/react-native react-native-webview
# React Native CLI
npm install @deewan/react-native react-native-webview && npx pod-installimport { DeewanMeetingView, requestMeetingPermissions } from "@deewan/react-native";
export function MeetingScreen({ joinUrl, onClose }: { joinUrl: string; onClose: () => void }) {
const [ready, setReady] = useState(false);
useEffect(() => {
requestMeetingPermissions().then(() => setReady(true));
}, []);
if (!ready) return null;
return <DeewanMeetingView joinUrl={joinUrl} onExit={onClose} onError={onClose} />;
}Add camera and microphone permissions: on iOS NSCameraUsageDescription and NSMicrophoneUsageDescription (in Expo, ios.infoPlist in app.json); on Android CAMERA, RECORD_AUDIO and MODIFY_AUDIO_SETTINGS. The view only loads links on deewan.io and grants the camera and microphone to Deewan alone.
npx expo install @deewan/react-native livekit-client @livekit/react-native @livekit/react-native-webrtc @livekit/react-native-expo-plugin @config-plugins/react-native-webrtc
# add "@livekit/react-native-expo-plugin" and "@config-plugins/react-native-webrtc" to "plugins" in app.json,
# then make a development build: npx expo run:ios / npx expo run:androidimport { VideoView } from "@livekit/react-native";
import { joinMeeting, setupDeewan, type Meeting } from "@deewan/react-native/custom";
setupDeewan(); // once, when the app starts
// Your server calls POST /rooms/{id}/tokens and returns the result.
const t = await fetch("https://app.example.com/api/meeting-token").then((r) => r.json());
const meeting: Meeting = await joinMeeting(
{ serverUrl: t.server_url, token: t.token, e2eePassphrase: t.e2ee_passphrase },
{ publish: { microphone: true, camera: true } },
);
meeting.on("participants", (people) => setPeople(people));
meeting.on("message", ({ name, text }) => addChat(name, text));
// For each person:
<VideoView videoTrack={meeting.videoTrack(person.id)} style={{ flex: 1 }} />;
// Later:
await meeting.leave();Flutter
flutter pub add deewanimport 'package:deewan/deewan.dart';
// Ask for camera and microphone first on Android (for example with permission_handler).
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => Scaffold(
body: SafeArea(
child: DeewanMeetingView(
joinUrl: joinUrl, // from your server
onExit: (_) => Navigator.of(context).pop(),
onError: (_) => Navigator.of(context).pop(),
),
),
),
));import 'package:deewan/custom.dart';
final meeting = await DeewanMeeting.join(
MeetingCredentials.fromJson(tokenResponseFromYourServer),
microphone: true,
camera: true,
);
// meeting.room changes as people come, go, mute and speak.
ListenableBuilder(
listenable: meeting.room,
builder: (context, _) => GridView.count(
crossAxisCount: 2,
children: [
for (final person in meeting.participants)
if (meeting.videoTrack(person.id) case final track?) VideoTrackRenderer(track) else Center(child: Text(person.name)),
],
),
);
meeting.messages.listen((m) => print('${m.name}: ${m.text}'));
await meeting.sendMessage('Hello');
await meeting.leave();Permissions: iOS NSCameraUsageDescription and NSMicrophoneUsageDescription in Info.plist; Android CAMERA, RECORD_AUDIO, MODIFY_AUDIO_SETTINGS and INTERNET in AndroidManifest.xml.
Native iOS and Android
Embed the join link in the system web view. Keep it on deewan.io, give the camera and microphone to Deewan only, and close the screen when it tries to leave (your redirect_url).
import UIKit
import WebKit
final class MeetingViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
private let joinURL: URL // from your server
init(joinURL: URL) { self.joinURL = joinURL; super.init(nibName: nil, bundle: nil) }
required init?(coder: NSCoder) { fatalError() }
override func loadView() {
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
config.mediaTypesRequiringUserActionForPlayback = []
let web = WKWebView(frame: .zero, configuration: config)
web.navigationDelegate = self
web.uiDelegate = self
view = web
web.load(URLRequest(url: joinURL))
}
// Camera and microphone for Deewan only.
func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
decisionHandler: @escaping (WKPermissionDecision) -> Void) {
decisionHandler(origin.protocol == "https" && origin.host == "deewan.io" ? .grant : .deny)
}
// Anywhere but Deewan (your redirect_url) ends the meeting screen.
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
let url = action.request.url
if url?.host == "deewan.io" || ["about", "blob", "data"].contains(url?.scheme ?? "") {
decisionHandler(.allow)
return
}
decisionHandler(.cancel)
if action.targetFrame?.isMainFrame == true { dismiss(animated: true) }
}
}class MeetingActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Request CAMERA and RECORD_AUDIO before opening this screen.
val web = WebView(this)
setContentView(web)
web.settings.javaScriptEnabled = true
web.settings.domStorageEnabled = true
web.settings.mediaPlaybackRequiresUserGesture = false
web.settings.allowFileAccess = false
// Camera and microphone for Deewan only.
web.webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
val deewan = request.origin.scheme == "https" && request.origin.host == "deewan.io"
val media = request.resources.all {
it == PermissionRequest.RESOURCE_VIDEO_CAPTURE || it == PermissionRequest.RESOURCE_AUDIO_CAPTURE
}
if (deewan && media) request.grant(request.resources) else request.deny()
}
}
// Anywhere but Deewan (your redirect_url) ends the meeting screen.
web.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
if (request.url.host == "deewan.io") return false
if (request.isForMainFrame) finish()
return true
}
}
web.loadUrl(requireNotNull(intent.getStringExtra("joinUrl"))) // from your server
}
}For a custom interface in Swift or Kotlin, use LiveKit's native SDK with the token response: connect to server_url with token, and turn on end-to-end encryption with a shared key set to e2ee_passphrase (default key derivation, key ring size 16, no ratcheting). Chat uses the data topic chat with {"id","text"} and reactions the topic reaction with {"emoji"}.