diff --git a/AGENTS.md b/AGENTS.md
index 5ccba8d..c5d46c5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -64,6 +64,7 @@ Run analyze, format and tests before every commit.
| `lib/src/data/` | reliable/lossy data channels and `ChannelMessage` framing |
| `lib/src/stats/` | periodic stats report |
| `lib/src/livestream/` | WHIP/WHEP over plain HTTP, independent of the room connection |
+| `lib/src/audio_output/`, `lib/src/background/` | Dart side of the SDK's native plugin: audio routing, Android foreground service, iOS CallKit |
| `lib/src/models/`, `wire.dart`, `logger.dart`, `version.dart` | public immutable types and shared helpers |
| `lib/src/fishjam_client.dart`, `lib/src/livestream_api.dart`, `lib/fishjam_client.dart` | the public facade; wiring and type conversion only |
| `test/fakes/`, `test/support/`, `test/fixtures/` | fakes, fixture replayer, golden sequences |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf13bd7..d264b72 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,4 +11,6 @@ Initial release.
- Automatic reconnection (`ReconnectConfig`), optionally re-publishing local tracks.
- Periodic statistics reporting to the server; `getStatistics()`.
- Livestreams over WHIP (`publishLivestream`) and WHEP (`receiveLivestream`).
+- Background calls: Android foreground service (`ForegroundService`) and iOS CallKit session (`CallKit`)
+ keep microphone capture and playback running while the app is in the background.
- Behavioral differences from the Fishjam web SDK are listed in SPEC.md §22.
diff --git a/README.md b/README.md
index db60299..6a425b4 100644
--- a/README.md
+++ b/README.md
@@ -112,6 +112,71 @@ A lost connection is re-established automatically (`ReconnectConfig`):
Livestreams without a room use `publishLivestream` (WHIP) and `receiveLivestream` (WHEP).
+## Background calls
+
+By default the OS suspends the app shortly after it goes to the background, which cuts the call's audio.
+Start these after joining and stop them after leaving:
+
+```dart
+// Android: a foreground service with an ongoing-call notification. No-op on iOS.
+await ForegroundService.instance.start(const ForegroundServiceConfig(
+ enableMicrophone: true,
+ enableCamera: true,
+ notificationTitle: 'Your call is ongoing',
+ notificationContent: 'Tap to return to the call.',
+));
+
+// iOS: shows the call in the system call UI (CallKit). No-op on Android.
+await CallKit.instance.startSession(const CallKitConfig(displayName: 'Ann', isVideo: true));
+CallKit.instance.actions.listen((action) {
+ switch (action) {
+ case CallKitEnded(): // the user ended the call from the system UI
+ case CallKitMuted(:final isMuted): // apply to the microphone track
+ default:
+ }
+});
+
+// After leaving:
+await ForegroundService.instance.stop();
+await CallKit.instance.endSession();
+```
+
+Start the foreground service after `getUserMedia`: the camera and microphone service types apply only once
+those permissions are granted. On Android 13+ the first `start` asks for the notifications permission.
+
+The SDK's plugin declares nothing in your app, so add the entries yourself.
+
+**Android** `AndroidManifest.xml`:
+
+```xml
+
+
+
+
+
+
+
+
+```
+
+For screen sharing, also add `FOREGROUND_SERVICE_MEDIA_PROJECTION`, add `mediaProjection` to
+`foregroundServiceType`, and use this order: `Helper.requestCapturePermission()`, then
+`ForegroundService.instance.start(... enableScreenSharing: true)`, then `getDisplayMedia`.
+
+**iOS** `Info.plist`:
+
+```xml
+UIBackgroundModes
+
+ audio
+ voip
+
+```
+
For prototyping without a backend, the Fishjam dashboard offers a Sandbox API that returns peer tokens. It is
unauthenticated: never use it in production. The [example app](example/) shows both flows.
diff --git a/SPEC.md b/SPEC.md
index 9c8702f..79ad743 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -627,6 +627,62 @@ React Native SDK's `AudioOutputManager` and talks to the SDK's native plugin ove
in progress. The iOS route observer starts when Dart subscribes to `onAudioOutputChanged`, so a change is
delivered even when no device getter has run yet.
+## 20c. Background calls
+
+Two singletons keep a call running while the app is in the background. They mirror the React Native SDK's
+`useForegroundService` and `useCallKitService` / `useCallKit` / `useCallKitEvent`. Both talk to the SDK's
+native plugin over the method channel `io.fishjam.client/background`; CallKit actions arrive on the event
+channel `io.fishjam.client/callkit_events`. The client never starts either on its own: the app starts them
+when it joins and stops them when it leaves.
+
+### Android foreground service
+
+`ForegroundService.instance.start(ForegroundServiceConfig)` and `stop()`. Both are no-ops on other platforms.
+
+- Config: `enableCamera`, `enableMicrophone`, `enableScreenSharing` (all `false` by default), `channelId`
+ (default `com.fishjam.foregroundservice.channel`), `channelName` (default `Fishjam Notifications`),
+ `notificationTitle`, `notificationContent`, `importance` (`low` | `high`, default `high`), `onlyAlertOnce`
+ (default `false`). Unset strings keep the native defaults.
+- `start` sends `startForegroundService` with the config as a map; `importance` is sent by name and unset
+ strings are omitted. Calling `start` again replaces the config of the running service.
+- On Android 13+ the first `start` asks for `POST_NOTIFICATIONS`. A refusal is logged and the service starts
+ anyway: the notification is hidden but the app keeps running.
+- The service type is the union of `camera` (when enabled and `CAMERA` is granted), `microphone` (when enabled
+ and `RECORD_AUDIO` is granted) and `mediaProjection` (when `enableScreenSharing`, API 29+). With no type
+ left the service stops.
+- `stop` sends `stopForegroundService` and stops the service.
+- The plugin's manifest declares nothing. The app declares the service
+ `io.fishjam.client.foregroundservice.FishjamForegroundService` and the `FOREGROUND_SERVICE*` permissions,
+ so apps that do not use it never need the matching Play Store declarations.
+- Screen sharing: flutter_webrtc reports no capture start, so `mediaProjection` is added as soon as
+ `enableScreenSharing` is set. Android 14+ requires the user's consent before that, so the order is
+ `Helper.requestCapturePermission()`, then `start(enableScreenSharing: true)`, then `getDisplayMedia`.
+
+### iOS CallKit
+
+`CallKit.instance`. Every method is a no-op on other platforms; the getters return `false` there and
+`actions` never emits.
+
+- `startSession(CallKitConfig)`: `{displayName, handle, isVideo, isDialing}`. `handle` defaults to
+ `displayName`, `isDialing` to `false`. An empty `displayName` throws `ArgumentError` without calling native
+ code. Sends `startCallKitSession`. A second session while one is active is ignored by native code.
+ Without `isDialing` the call is reported as connected right away; with it, the app calls
+ `reportOutgoingCallConnected()` once the other side joins.
+- `endSession([CallEndedReason reason = local])` sends `endCallKitSession` with the reason name. `local` and
+ `rejected` end the call through a CallKit transaction; the others report the call as ended with the
+ matching CallKit reason (`missed` → unanswered, `remote` → remote ended, `answeredElsewhere`, `failed`).
+- `setHeld(onHold:)` / `setMuted(muted:)` ask CallKit to change the state; the result arrives as an action.
+- `hasActiveSession()` and `isHeld()` read native state. They return futures because platform channels have
+ no synchronous calls.
+- `actions` (broadcast) emits `CallKitStarted`, `CallKitEnded(reason)`, `CallKitFailed(message)`,
+ `CallKitMuted(isMuted)`, `CallKitHeld(isHeld)`. Native events are maps with one key (`started`, `ended`,
+ `failed`, `muted`, `held`); unknown keys are dropped and an unknown end reason becomes `local`.
+- CallKit activates the `AVAudioSession`. The plugin forwards `didActivate` / `didDeactivate` to flutter_webrtc's
+ `RTCAudioSession`, looked up by name at runtime so the plugin has no build dependency on the WebRTC pod.
+ This keeps microphone capture and playback running. It does not keep the camera running.
+- The app adds `audio` and `voip` to `UIBackgroundModes`. Out of scope: VoIP push, incoming calls, answering,
+ ring timeouts.
+
## 21. Logging
One SDK logger, created from `FishjamClient(debug: bool)`. Messages are prefixed with `[FISHJAM]` and emitted
diff --git a/android/src/main/kotlin/io/fishjam/client/FishjamClientPlugin.kt b/android/src/main/kotlin/io/fishjam/client/FishjamClientPlugin.kt
index c36cf3e..95ddfeb 100644
--- a/android/src/main/kotlin/io/fishjam/client/FishjamClientPlugin.kt
+++ b/android/src/main/kotlin/io/fishjam/client/FishjamClientPlugin.kt
@@ -1,19 +1,29 @@
package io.fishjam.client
+import android.content.Context
import android.util.Log
+import io.fishjam.client.foregroundservice.ForegroundServiceController
+import io.fishjam.client.foregroundservice.NotificationPermission
import io.flutter.embedding.engine.plugins.FlutterPlugin
+import io.flutter.embedding.engine.plugins.activity.ActivityAware
+import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.Result
-class FishjamClientPlugin : FlutterPlugin {
+class FishjamClientPlugin : FlutterPlugin, ActivityAware {
+ private lateinit var context: Context
private lateinit var audioChannel: MethodChannel
private lateinit var audioEvents: EventChannel
private lateinit var audioOutput: AudioOutputManager
private var audioEventSink: EventChannel.EventSink? = null
+ private lateinit var backgroundChannel: MethodChannel
+ private val notificationPermission = NotificationPermission()
+ private var activityBinding: ActivityPluginBinding? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
+ context = binding.applicationContext
disableFlutterWebrtcAudioRouting()
audioOutput = AudioOutputManager(binding.applicationContext) { event ->
audioEventSink?.success(event)
@@ -31,6 +41,8 @@ class FishjamClientPlugin : FlutterPlugin {
audioEventSink = null
}
})
+ backgroundChannel = MethodChannel(binding.binaryMessenger, BACKGROUND_CHANNEL)
+ backgroundChannel.setMethodCallHandler(::onBackgroundMethodCall)
}
private fun onAudioMethodCall(call: MethodCall, result: Result) {
@@ -50,11 +62,47 @@ class FishjamClientPlugin : FlutterPlugin {
}
}
+ private fun onBackgroundMethodCall(call: MethodCall, result: Result) {
+ when (call.method) {
+ "startForegroundService" -> {
+ val config = call.arguments as? Map<*, *> ?: emptyMap()
+ notificationPermission.request(context) {
+ ForegroundServiceController.start(context, config)
+ result.success(null)
+ }
+ }
+ "stopForegroundService" -> {
+ ForegroundServiceController.stop(context)
+ result.success(null)
+ }
+ else -> result.notImplemented()
+ }
+ }
+
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
audioChannel.setMethodCallHandler(null)
audioEvents.setStreamHandler(null)
audioEventSink = null
audioOutput.stopObserving()
+ backgroundChannel.setMethodCallHandler(null)
+ }
+
+ override fun onAttachedToActivity(binding: ActivityPluginBinding) {
+ activityBinding = binding
+ notificationPermission.activity = binding.activity
+ binding.addRequestPermissionsResultListener(notificationPermission)
+ }
+
+ override fun onDetachedFromActivityForConfigChanges() = onDetachedFromActivity()
+
+ override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) =
+ onAttachedToActivity(binding)
+
+ override fun onDetachedFromActivity() {
+ activityBinding?.removeRequestPermissionsResultListener(notificationPermission)
+ activityBinding = null
+ notificationPermission.activity = null
+ notificationPermission.flush()
}
/**
@@ -85,5 +133,6 @@ class FishjamClientPlugin : FlutterPlugin {
private const val TAG = "FishjamClientPlugin"
private const val AUDIO_CHANNEL = "io.fishjam.client/audio_output"
private const val AUDIO_EVENTS_CHANNEL = "io.fishjam.client/audio_output_events"
+ private const val BACKGROUND_CHANNEL = "io.fishjam.client/background"
}
}
diff --git a/android/src/main/kotlin/io/fishjam/client/foregroundservice/FishjamForegroundService.kt b/android/src/main/kotlin/io/fishjam/client/foregroundservice/FishjamForegroundService.kt
new file mode 100644
index 0000000..6cda3b5
--- /dev/null
+++ b/android/src/main/kotlin/io/fishjam/client/foregroundservice/FishjamForegroundService.kt
@@ -0,0 +1,114 @@
+package io.fishjam.client.foregroundservice
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.os.Build
+import android.os.IBinder
+import android.util.Log
+
+/**
+ * Foreground service that keeps the app process, and so the call, running while the app is in the
+ * background. The app declares it in its AndroidManifest.xml.
+ */
+class FishjamForegroundService : Service() {
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (intent != null) {
+ startInForeground(intent)
+ }
+ return START_NOT_STICKY
+ }
+
+ private fun startInForeground(intent: Intent) {
+ val channelId = intent.getStringExtra(EXTRA_CHANNEL_ID) ?: return
+ val channelName = intent.getStringExtra(EXTRA_CHANNEL_NAME) ?: return
+ val importance = intent.getStringExtra(EXTRA_IMPORTANCE) ?: "high"
+ val types = intent.getIntArrayExtra(EXTRA_TYPES)?.fold(0) { acc, type -> acc or type } ?: 0
+
+ createChannel(channelId, channelName, importance)
+ val notification = buildNotification(
+ channelId,
+ intent.getStringExtra(EXTRA_TITLE),
+ intent.getStringExtra(EXTRA_CONTENT),
+ importance,
+ intent.getBooleanExtra(EXTRA_ONLY_ALERT_ONCE, false),
+ )
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ startForeground(NOTIFICATION_ID, notification)
+ return
+ }
+ try {
+ startForeground(NOTIFICATION_ID, notification, types)
+ } catch (e: SecurityException) {
+ // Android 14+ refuses mediaProjection before the user consents to screen capture.
+ // A service that never reaches the foreground crashes the app, so retry without it.
+ val withoutProjection = types and ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION.inv()
+ Log.w(TAG, "Could not start with types $types, retrying with $withoutProjection", e)
+ startForeground(NOTIFICATION_ID, notification, withoutProjection)
+ }
+ }
+
+ private fun buildNotification(
+ channelId: String,
+ title: String?,
+ content: String?,
+ importance: String,
+ onlyAlertOnce: Boolean,
+ ): Notification {
+ val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
+ addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ }
+ val contentIntent = launchIntent?.let {
+ PendingIntent.getActivity(
+ this, 0, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+ val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ Notification.Builder(this, channelId)
+ } else {
+ @Suppress("DEPRECATION")
+ Notification.Builder(this).setPriority(
+ if (importance == "low") Notification.PRIORITY_LOW else Notification.PRIORITY_HIGH,
+ )
+ }
+ return builder
+ .setContentTitle(title)
+ .setContentText(content)
+ .setContentIntent(contentIntent)
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
+ .setOnlyAlertOnce(onlyAlertOnce)
+ .setOngoing(true)
+ .build()
+ }
+
+ private fun createChannel(channelId: String, channelName: String, importance: String) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ val level = if (importance == "low") {
+ NotificationManager.IMPORTANCE_LOW
+ } else {
+ NotificationManager.IMPORTANCE_HIGH
+ }
+ getSystemService(NotificationManager::class.java)
+ ?.createNotificationChannel(NotificationChannel(channelId, channelName, level))
+ }
+
+ companion object {
+ private const val TAG = "FishjamForegroundService"
+ private const val NOTIFICATION_ID = 0x46_4A_46_53
+
+ const val EXTRA_CHANNEL_ID = "channelId"
+ const val EXTRA_CHANNEL_NAME = "channelName"
+ const val EXTRA_TITLE = "notificationTitle"
+ const val EXTRA_CONTENT = "notificationContent"
+ const val EXTRA_IMPORTANCE = "importance"
+ const val EXTRA_ONLY_ALERT_ONCE = "onlyAlertOnce"
+ const val EXTRA_TYPES = "foregroundServiceTypes"
+ }
+}
diff --git a/android/src/main/kotlin/io/fishjam/client/foregroundservice/ForegroundServiceController.kt b/android/src/main/kotlin/io/fishjam/client/foregroundservice/ForegroundServiceController.kt
new file mode 100644
index 0000000..6e68623
--- /dev/null
+++ b/android/src/main/kotlin/io/fishjam/client/foregroundservice/ForegroundServiceController.kt
@@ -0,0 +1,101 @@
+package io.fishjam.client.foregroundservice
+
+import android.Manifest
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.content.pm.ServiceInfo
+import android.os.Build
+import android.util.Log
+
+/** Keeps the requested service state and starts, updates or stops [FishjamForegroundService]. */
+object ForegroundServiceController {
+ private const val TAG = "FishjamForegroundService"
+
+ private var cameraRequested = false
+ private var microphoneRequested = false
+ private var screenSharingRequested = false
+
+ private var channelId = "com.fishjam.foregroundservice.channel"
+ private var channelName = "Fishjam Notifications"
+ private var notificationTitle = "Your call is ongoing"
+ private var notificationContent = "Tap to return to the call."
+ private var importance = "high"
+ private var onlyAlertOnce = false
+
+ @Synchronized
+ fun start(context: Context, config: Map<*, *>) {
+ cameraRequested = config["enableCamera"] as? Boolean ?: false
+ microphoneRequested = config["enableMicrophone"] as? Boolean ?: false
+ screenSharingRequested = config["enableScreenSharing"] as? Boolean ?: false
+ (config["channelId"] as? String)?.let { channelId = it }
+ (config["channelName"] as? String)?.let { channelName = it }
+ (config["notificationTitle"] as? String)?.let { notificationTitle = it }
+ (config["notificationContent"] as? String)?.let { notificationContent = it }
+ (config["importance"] as? String)?.let { importance = it }
+ onlyAlertOnce = config["onlyAlertOnce"] as? Boolean ?: false
+ applyState(context)
+ }
+
+ @Synchronized
+ fun stop(context: Context) {
+ cameraRequested = false
+ microphoneRequested = false
+ screenSharingRequested = false
+ applyState(context)
+ }
+
+ private fun applyState(context: Context) {
+ val intent = Intent(context, FishjamForegroundService::class.java)
+ val types = serviceTypes(context)
+ if (types.isEmpty()) {
+ context.stopService(intent)
+ return
+ }
+
+ intent.putExtra(FishjamForegroundService.EXTRA_CHANNEL_ID, channelId)
+ .putExtra(FishjamForegroundService.EXTRA_CHANNEL_NAME, channelName)
+ .putExtra(FishjamForegroundService.EXTRA_TITLE, notificationTitle)
+ .putExtra(FishjamForegroundService.EXTRA_CONTENT, notificationContent)
+ .putExtra(FishjamForegroundService.EXTRA_IMPORTANCE, importance)
+ .putExtra(FishjamForegroundService.EXTRA_ONLY_ALERT_ONCE, onlyAlertOnce)
+ .putExtra(FishjamForegroundService.EXTRA_TYPES, types.toIntArray())
+
+ try {
+ val component = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ if (component == null) {
+ Log.e(
+ TAG,
+ "${FishjamForegroundService::class.java.name} is not declared in AndroidManifest.xml",
+ )
+ }
+ } catch (e: RuntimeException) {
+ Log.e(TAG, "Failed to start the foreground service", e)
+ }
+ }
+
+ private fun serviceTypes(context: Context): List {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ // Service types do not exist yet; any non-empty list starts the service.
+ return if (cameraRequested || microphoneRequested || screenSharingRequested) listOf(0) else emptyList()
+ }
+ return buildList {
+ if (cameraRequested && isGranted(context, Manifest.permission.CAMERA)) {
+ add(ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA)
+ }
+ if (microphoneRequested && isGranted(context, Manifest.permission.RECORD_AUDIO)) {
+ add(ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE)
+ }
+ if (screenSharingRequested) {
+ add(ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
+ }
+ }
+ }
+
+ private fun isGranted(context: Context, permission: String) =
+ context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
+}
diff --git a/android/src/main/kotlin/io/fishjam/client/foregroundservice/NotificationPermission.kt b/android/src/main/kotlin/io/fishjam/client/foregroundservice/NotificationPermission.kt
new file mode 100644
index 0000000..b09b6e2
--- /dev/null
+++ b/android/src/main/kotlin/io/fishjam/client/foregroundservice/NotificationPermission.kt
@@ -0,0 +1,65 @@
+package io.fishjam.client.foregroundservice
+
+import android.Manifest
+import android.app.Activity
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import android.util.Log
+import io.flutter.plugin.common.PluginRegistry
+
+/**
+ * Asks for POST_NOTIFICATIONS (Android 13+) once per process. A refusal only hides the
+ * foreground service notification, so [request] always calls back.
+ */
+class NotificationPermission : PluginRegistry.RequestPermissionsResultListener {
+ var activity: Activity? = null
+ private var asked = false
+ private val pending = mutableListOf<() -> Unit>()
+
+ fun request(context: Context, onDone: () -> Unit) {
+ val activity = activity
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
+ context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
+ PackageManager.PERMISSION_GRANTED
+ ) {
+ onDone()
+ return
+ }
+ if (asked || activity == null) {
+ Log.w(TAG, "Notifications permission not granted; the call notification is hidden")
+ onDone()
+ return
+ }
+ pending += onDone
+ if (pending.size == 1) {
+ activity.requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQUEST_CODE)
+ }
+ }
+
+ override fun onRequestPermissionsResult(
+ requestCode: Int,
+ permissions: Array,
+ grantResults: IntArray,
+ ): Boolean {
+ if (requestCode != REQUEST_CODE) return false
+ asked = true
+ if (grantResults.firstOrNull() != PackageManager.PERMISSION_GRANTED) {
+ Log.w(TAG, "Notifications permission refused; the call notification is hidden")
+ }
+ flush()
+ return true
+ }
+
+ /** Runs waiting callbacks, e.g. when the activity goes away before the user answers. */
+ fun flush() {
+ val callbacks = pending.toList()
+ pending.clear()
+ callbacks.forEach { it() }
+ }
+
+ companion object {
+ private const val TAG = "FishjamForegroundService"
+ private const val REQUEST_CODE = 0x464A
+ }
+}
diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml
index 25dbb88..50bb465 100644
--- a/example/android/app/src/main/AndroidManifest.xml
+++ b/example/android/app/src/main/AndroidManifest.xml
@@ -9,6 +9,11 @@
+
+
+
+
+
+
The camera is published to the Fishjam room.
NSMicrophoneUsageDescription
The microphone is published to the Fishjam room.
+ UIBackgroundModes
+
+ audio
+ voip
+
NSAppTransportSecurity
diff --git a/example/lib/room_screen.dart b/example/lib/room_screen.dart
index ac1b12a..85d706a 100644
--- a/example/lib/room_screen.dart
+++ b/example/lib/room_screen.dart
@@ -59,6 +59,7 @@ class _RoomScreenState extends State {
bool _microphoneOn = true;
String _status = 'Connecting…';
final _audioOutput = AudioOutputManager.instance;
+ final _callKit = CallKit.instance;
AudioOutputChangedInfo _audio = const AudioOutputChangedInfo(
currentAudioOutput: null,
availableAudioOutputs: [],
@@ -72,10 +73,23 @@ class _RoomScreenState extends State {
(info) => setState(() => _audio = info),
),
);
+ _subscriptions.add(_callKit.actions.listen(_onCallKitAction));
unawaited(_refreshAudioOutputs());
unawaited(_start());
}
+ /// The system call UI can end the call or mute the microphone.
+ void _onCallKitAction(CallKitAction action) {
+ switch (action) {
+ case CallKitEnded():
+ unawaited(_leave());
+ case CallKitMuted(:final isMuted) when isMuted == _microphoneOn:
+ unawaited(_toggleMicrophone());
+ default:
+ break;
+ }
+ }
+
Future _refreshAudioOutputs() async {
try {
final current = await _audioOutput.getCurrentAudioOutput();
@@ -187,6 +201,21 @@ class _RoomScreenState extends State {
_localRenderer.srcObject = stream;
if (mounted) setState(() {});
+ // Keeps the call running in the background. The service types follow
+ // the permissions granted by getUserMedia above.
+ await ForegroundService.instance.start(
+ const ForegroundServiceConfig(
+ enableCamera: true,
+ enableMicrophone: true,
+ channelName: 'Fishjam Notifications',
+ notificationTitle: 'Your video call is ongoing',
+ notificationContent: 'Tap to return to the call.',
+ ),
+ );
+ await _callKit.startSession(
+ CallKitConfig(displayName: widget.displayName, isVideo: true),
+ );
+
await _client.connect(
url: widget.credentials.url,
token: widget.credentials.peerToken,
@@ -377,6 +406,8 @@ class _RoomScreenState extends State {
}
unawaited(_localRenderer.dispose());
unawaited(_client.dispose());
+ unawaited(ForegroundService.instance.stop());
+ unawaited(_callKit.endSession());
final stream = _localStream;
if (stream != null) {
for (final track in stream.getTracks()) {
diff --git a/ios/fishjam_client/Sources/fishjam_client/CallKitManager.swift b/ios/fishjam_client/Sources/fishjam_client/CallKitManager.swift
new file mode 100644
index 0000000..01d1cb7
--- /dev/null
+++ b/ios/fishjam_client/Sources/fishjam_client/CallKitManager.swift
@@ -0,0 +1,292 @@
+import AVFoundation
+import CallKit
+import Flutter
+import os.log
+import UIKit
+
+/// Reports the room as a CallKit call so iOS keeps its audio running in the
+/// background. Port of the outgoing-call path of the React Native SDK's
+/// CallKitManager; incoming calls and VoIP push are not supported.
+final class CallKitManager: NSObject, CXProviderDelegate {
+ private let emit: ([String: Any]) -> Void
+ private let provider: CXProvider
+ private let callController = CXCallController()
+ private var currentCallUUID: UUID?
+ private var isDialing = false
+ private var isCallOnHold = false
+ private var isVideoCall = false
+
+ init(emit: @escaping ([String: Any]) -> Void) {
+ self.emit = emit
+ let configuration: CXProviderConfiguration
+ if #available(iOS 14.0, *) {
+ configuration = CXProviderConfiguration()
+ } else {
+ configuration = CXProviderConfiguration(
+ localizedName: Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? ""
+ )
+ }
+ configuration.supportsVideo = true
+ configuration.supportedHandleTypes = [.generic]
+ configuration.maximumCallsPerCallGroup = 1
+ configuration.maximumCallGroups = 1
+ configuration.includesCallsInRecents = false
+ provider = CXProvider(configuration: configuration)
+ super.init()
+ provider.setDelegate(self, queue: nil)
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(willResignActive),
+ name: UIApplication.willResignActiveNotification,
+ object: nil
+ )
+ }
+
+ deinit {
+ NotificationCenter.default.removeObserver(self)
+ }
+
+ func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
+ switch call.method {
+ case "startCallKitSession":
+ let args = call.arguments as? [String: Any] ?? [:]
+ guard let displayName = args["displayName"] as? String, !displayName.isEmpty else {
+ result(
+ FlutterError(
+ code: "E_CALLKIT_INVALID_DISPLAY_NAME", message: "displayName is required", details: nil
+ ))
+ return
+ }
+ let handle = (args["handle"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? displayName
+ startCall(
+ displayName: displayName,
+ handle: handle,
+ isVideo: args["isVideo"] as? Bool ?? false,
+ isDialing: args["isDialing"] as? Bool ?? false
+ )
+ result(nil)
+ case "endCallKitSession":
+ endCall(reason: call.arguments as? String ?? "local")
+ result(nil)
+ case "reportOutgoingCallConnected":
+ reportOutgoingCallConnected()
+ result(nil)
+ case "setCallKitCallHeld":
+ if let uuid = activeCall(), let onHold = call.arguments as? Bool {
+ request(CXSetHeldCallAction(call: uuid, onHold: onHold))
+ }
+ result(nil)
+ case "setCallKitMuted":
+ if let uuid = activeCall(), let muted = call.arguments as? Bool {
+ request(CXSetMutedCallAction(call: uuid, muted: muted))
+ }
+ result(nil)
+ case "hasActiveCallKitSession":
+ result(currentCallUUID != nil)
+ case "isCallKitCallHeld":
+ result(isCallOnHold)
+ default:
+ result(FlutterMethodNotImplemented)
+ }
+ }
+
+ private func startCall(displayName: String, handle: String, isVideo: Bool, isDialing: Bool) {
+ guard currentCallUUID == nil else {
+ os_log("[CallKitManager] Call already in progress")
+ return
+ }
+ let uuid = UUID()
+ currentCallUUID = uuid
+ self.isDialing = isDialing
+ isVideoCall = isVideo
+ if isVideo { setMultitaskingCameraAccess(true) }
+
+ let action = CXStartCallAction(call: uuid, handle: CXHandle(type: .generic, value: handle))
+ action.isVideo = isVideo
+ action.contactIdentifier = displayName
+
+ callController.request(CXTransaction(action: action)) { [weak self] error in
+ DispatchQueue.main.async {
+ guard let self else { return }
+ if let error {
+ os_log("[CallKitManager] Failed to start call: %{public}@", error.localizedDescription)
+ self.cleanupCurrentCall()
+ self.emit(["failed": error.localizedDescription])
+ return
+ }
+ self.emit(["started": NSNull()])
+ let update = CXCallUpdate()
+ update.localizedCallerName = displayName
+ update.supportsHolding = true
+ update.supportsGrouping = false
+ update.supportsUngrouping = false
+ update.supportsDTMF = false
+ update.hasVideo = isVideo
+ self.provider.reportCall(with: uuid, updated: update)
+ }
+ }
+ }
+
+ private func endCall(reason: String) {
+ guard let uuid = currentCallUUID else {
+ os_log("[CallKitManager] No active call to end")
+ return
+ }
+ if reason == "local" || reason == "rejected" {
+ // performEndCallAction emits `ended` once the transaction is fulfilled.
+ callController.request(CXTransaction(action: CXEndCallAction(call: uuid))) { error in
+ if let error {
+ os_log("[CallKitManager] Failed to end call: %{public}@", error.localizedDescription)
+ }
+ }
+ return
+ }
+ provider.reportCall(with: uuid, endedAt: Date(), reason: endedReason(reason))
+ emit(["ended": reason])
+ cleanupCurrentCall()
+ }
+
+ private func endedReason(_ reason: String) -> CXCallEndedReason {
+ switch reason {
+ case "missed": return .unanswered
+ case "answeredElsewhere": return .answeredElsewhere
+ case "failed": return .failed
+ default: return .remoteEnded
+ }
+ }
+
+ private func reportOutgoingCallConnected() {
+ guard let uuid = currentCallUUID, isDialing else {
+ os_log("[CallKitManager] No dialing call to report as connected")
+ return
+ }
+ isDialing = false
+ provider.reportOutgoingCall(with: uuid, connectedAt: Date())
+ }
+
+ private func activeCall() -> UUID? {
+ if currentCallUUID == nil {
+ os_log("[CallKitManager] No active call")
+ }
+ return currentCallUUID
+ }
+
+ private func request(_ action: CXCallAction) {
+ callController.request(CXTransaction(action: action)) { error in
+ if let error {
+ os_log("[CallKitManager] Call action failed: %{public}@", error.localizedDescription)
+ }
+ }
+ }
+
+ private func cleanupCurrentCall() {
+ currentCallUUID = nil
+ isDialing = false
+ isCallOnHold = false
+ if isVideoCall { setMultitaskingCameraAccess(false) }
+ isVideoCall = false
+ }
+
+ /// iOS stops the camera when the app backgrounds. A video call opts the
+ /// capture session into multitasking camera access, which is what keeps it
+ /// running. The session may not exist yet when the call starts, so this
+ /// runs again as the app resigns active.
+ @objc private func willResignActive() {
+ guard isVideoCall else { return }
+ setMultitaskingCameraAccess(true)
+ }
+
+ private static var didLogCameraAccessFailure = false
+
+ private func setMultitaskingCameraAccess(_ enabled: Bool) {
+ guard #available(iOS 16.0, *) else { return }
+ guard let session = Self.captureSession() else { return }
+ guard session.isMultitaskingCameraAccessSupported else {
+ if enabled { Self.logCameraAccessFailure() }
+ return
+ }
+ guard session.isMultitaskingCameraAccessEnabled != enabled else { return }
+ session.beginConfiguration()
+ session.isMultitaskingCameraAccessEnabled = enabled
+ session.commitConfiguration()
+ }
+
+ private static func captureSession() -> AVCaptureSession? {
+ guard let type = NSClassFromString("FlutterWebRTCPlugin") as? NSObject.Type else { return nil }
+ let selector = NSSelectorFromString("sharedSingleton")
+ guard type.responds(to: selector),
+ let plugin = type.perform(selector)?.takeUnretainedValue() as? NSObject
+ else { return nil }
+ return (plugin.value(forKey: "videoCapturer") as? NSObject)?
+ .value(forKey: "captureSession") as? AVCaptureSession
+ }
+
+ private static func logCameraAccessFailure() {
+ guard !didLogCameraAccessFailure else { return }
+ didLogCameraAccessFailure = true
+ os_log(
+ "[CallKitManager] The camera will stop in the background: multitasking camera access is not supported. On iOS 18 or newer, add voip to UIBackgroundModes. Before that, the com.apple.developer.avfoundation.multitasking-camera-access entitlement is also required."
+ )
+ }
+
+ /// RTCAudioSession comes from flutter_webrtc's WebRTC framework. Looking it
+ /// up by name keeps this plugin free of a build dependency on that pod.
+ private func forwardToRtcAudioSession(_ selector: String, _ session: AVAudioSession) {
+ guard let rtcClass = NSClassFromString("RTCAudioSession") as? NSObject.Type,
+ let shared = rtcClass.perform(NSSelectorFromString("sharedInstance"))?
+ .takeUnretainedValue() as? NSObject
+ else {
+ os_log("[CallKitManager] RTCAudioSession not found; is flutter_webrtc linked?")
+ return
+ }
+ shared.perform(NSSelectorFromString(selector), with: session)
+ }
+
+ // MARK: - CXProviderDelegate
+
+ func providerDidReset(_ provider: CXProvider) {
+ cleanupCurrentCall()
+ }
+
+ func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
+ let now = Date()
+ provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: now)
+ if !isDialing {
+ provider.reportOutgoingCall(with: action.callUUID, connectedAt: now)
+ }
+ action.fulfill()
+ }
+
+ func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
+ guard action.callUUID == currentCallUUID else {
+ action.fulfill()
+ return
+ }
+ emit(["ended": "local"])
+ action.fulfill()
+ cleanupCurrentCall()
+ }
+
+ func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
+ isCallOnHold = action.isOnHold
+ emit(["held": action.isOnHold])
+ action.fulfill()
+ }
+
+ func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
+ emit(["muted": action.isMuted])
+ action.fulfill()
+ }
+
+ func provider(_ provider: CXProvider, perform action: CXSetGroupCallAction) {
+ action.fail()
+ }
+
+ func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
+ forwardToRtcAudioSession("audioSessionDidActivate:", audioSession)
+ }
+
+ func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
+ forwardToRtcAudioSession("audioSessionDidDeactivate:", audioSession)
+ }
+}
diff --git a/ios/fishjam_client/Sources/fishjam_client/FishjamClientPlugin.swift b/ios/fishjam_client/Sources/fishjam_client/FishjamClientPlugin.swift
index d3546d8..06d1daa 100644
--- a/ios/fishjam_client/Sources/fishjam_client/FishjamClientPlugin.swift
+++ b/ios/fishjam_client/Sources/fishjam_client/FishjamClientPlugin.swift
@@ -6,6 +6,10 @@ public class FishjamClientPlugin: NSObject, FlutterPlugin, FlutterStreamHandler
private lazy var audioOutput = AudioOutputManager { [weak self] event in
self?.audioEventSink?(event)
}
+ private let callKitEvents = EventSinkHandler()
+ private lazy var callKit = CallKitManager { [weak self] event in
+ self?.callKitEvents.sink?(event)
+ }
public static func register(with registrar: FlutterPluginRegistrar) {
let instance = FishjamClientPlugin()
@@ -22,6 +26,20 @@ public class FishjamClientPlugin: NSObject, FlutterPlugin, FlutterStreamHandler
binaryMessenger: registrar.messenger()
)
audioEvents.setStreamHandler(instance)
+
+ let backgroundChannel = FlutterMethodChannel(
+ name: "io.fishjam.client/background",
+ binaryMessenger: registrar.messenger()
+ )
+ backgroundChannel.setMethodCallHandler { [weak instance] call, result in
+ guard let instance else { return result(FlutterMethodNotImplemented) }
+ instance.callKit.handle(call, result: result)
+ }
+ let callKitEvents = FlutterEventChannel(
+ name: "io.fishjam.client/callkit_events",
+ binaryMessenger: registrar.messenger()
+ )
+ callKitEvents.setStreamHandler(instance.callKitEvents)
}
private func handleAudio(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
@@ -62,3 +80,19 @@ public class FishjamClientPlugin: NSObject, FlutterPlugin, FlutterStreamHandler
return nil
}
}
+
+final class EventSinkHandler: NSObject, FlutterStreamHandler {
+ private(set) var sink: FlutterEventSink?
+
+ func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
+ -> FlutterError?
+ {
+ sink = events
+ return nil
+ }
+
+ func onCancel(withArguments arguments: Any?) -> FlutterError? {
+ sink = nil
+ return nil
+ }
+}
diff --git a/lib/fishjam_client.dart b/lib/fishjam_client.dart
index d618c9d..a193d7c 100644
--- a/lib/fishjam_client.dart
+++ b/lib/fishjam_client.dart
@@ -10,6 +10,22 @@ export 'src/audio_output/audio_output_manager.dart'
AudioOutputManager,
IosAudioOutput,
IosAudioOutputOverride;
+export 'src/background/call_kit.dart'
+ show
+ CallEndedReason,
+ CallKit,
+ CallKitAction,
+ CallKitConfig,
+ CallKitEnded,
+ CallKitFailed,
+ CallKitHeld,
+ CallKitMuted,
+ CallKitStarted;
+export 'src/background/foreground_service.dart'
+ show
+ ForegroundService,
+ ForegroundServiceConfig,
+ ForegroundServiceImportance;
export 'src/fishjam_client.dart' show FishjamClient;
export 'src/livestream_api.dart'
show
diff --git a/lib/src/background/call_kit.dart b/lib/src/background/call_kit.dart
new file mode 100644
index 0000000..9ffc5a3
--- /dev/null
+++ b/lib/src/background/call_kit.dart
@@ -0,0 +1,222 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+
+/// Why a CallKit call ended.
+enum CallEndedReason {
+ local,
+ rejected,
+ missed,
+ remote,
+ answeredElsewhere,
+ failed,
+}
+
+/// The call shown in the iOS system call UI.
+@immutable
+class CallKitConfig {
+ const CallKitConfig({
+ required this.displayName,
+ required this.isVideo,
+ this.handle,
+ this.isDialing = false,
+ });
+
+ /// Label shown in the system call UI and in Recents. Must not be empty.
+ final String displayName;
+
+ /// Stable identifier of the other party (e.g. a user id), the identity
+ /// iOS keeps in Recents. Defaults to [displayName].
+ final String? handle;
+ final bool isVideo;
+
+ /// The call is ringing the other side. Call
+ /// `CallKit.reportOutgoingCallConnected` once it joins; otherwise the call
+ /// is reported as connected right away.
+ final bool isDialing;
+}
+
+/// A change reported by CallKit, often made by the user in the system UI.
+@immutable
+sealed class CallKitAction {
+ const CallKitAction();
+}
+
+/// The call was started.
+final class CallKitStarted extends CallKitAction {
+ const CallKitStarted();
+
+ @override
+ bool operator ==(Object other) => other is CallKitStarted;
+
+ @override
+ int get hashCode => (CallKitStarted).hashCode;
+
+ @override
+ String toString() => 'CallKitStarted()';
+}
+
+/// The call ended, e.g. the user tapped the end button in the system UI.
+final class CallKitEnded extends CallKitAction {
+ const CallKitEnded(this.reason);
+
+ final CallEndedReason reason;
+
+ @override
+ bool operator ==(Object other) =>
+ other is CallKitEnded && other.reason == reason;
+
+ @override
+ int get hashCode => Object.hash(CallKitEnded, reason);
+
+ @override
+ String toString() => 'CallKitEnded(${reason.name})';
+}
+
+/// CallKit refused to start the call.
+final class CallKitFailed extends CallKitAction {
+ const CallKitFailed(this.message);
+
+ final String message;
+
+ @override
+ bool operator ==(Object other) =>
+ other is CallKitFailed && other.message == message;
+
+ @override
+ int get hashCode => Object.hash(CallKitFailed, message);
+
+ @override
+ String toString() => 'CallKitFailed($message)';
+}
+
+/// The microphone was muted or unmuted in the system UI. Apply it to the
+/// microphone track.
+final class CallKitMuted extends CallKitAction {
+ const CallKitMuted({required this.isMuted});
+
+ final bool isMuted;
+
+ @override
+ bool operator ==(Object other) =>
+ other is CallKitMuted && other.isMuted == isMuted;
+
+ @override
+ int get hashCode => Object.hash(CallKitMuted, isMuted);
+
+ @override
+ String toString() => 'CallKitMuted($isMuted)';
+}
+
+/// The call was put on hold or resumed, e.g. by another incoming call.
+final class CallKitHeld extends CallKitAction {
+ const CallKitHeld({required this.isHeld});
+
+ final bool isHeld;
+
+ @override
+ bool operator ==(Object other) =>
+ other is CallKitHeld && other.isHeld == isHeld;
+
+ @override
+ int get hashCode => Object.hash(CallKitHeld, isHeld);
+
+ @override
+ String toString() => 'CallKitHeld($isHeld)';
+}
+
+/// iOS CallKit session: shows the call in the system UI and keeps its audio
+/// running in the background. Start it after joining, end it after leaving.
+/// Every call is a no-op on other platforms.
+///
+/// The app must add `audio` and `voip` to `UIBackgroundModes` in
+/// `Info.plist`.
+class CallKit {
+ /// A CallKit wrapper on custom channels and platform (tests only).
+ @internal
+ CallKit.internal({
+ MethodChannel methodChannel = const MethodChannel(
+ 'io.fishjam.client/background',
+ ),
+ this._eventChannel = const EventChannel('io.fishjam.client/callkit_events'),
+ TargetPlatform Function()? platform,
+ }) : _methods = methodChannel,
+ _platform = platform ?? (() => defaultTargetPlatform);
+
+ static final CallKit instance = CallKit.internal();
+
+ final MethodChannel _methods;
+ final EventChannel _eventChannel;
+ final TargetPlatform Function() _platform;
+
+ bool get _isIos => _platform() == TargetPlatform.iOS;
+
+ /// Reports a new call to CallKit. Ignored while a call is active; a
+ /// refusal arrives as [CallKitFailed].
+ ///
+ /// Throws `ArgumentError` when `displayName` is empty.
+ Future startSession(CallKitConfig config) async {
+ if (config.displayName.isEmpty) {
+ throw ArgumentError.value(
+ config.displayName,
+ 'displayName',
+ 'must not be empty',
+ );
+ }
+ if (!_isIos) return;
+ await _methods.invokeMethod('startCallKitSession', {
+ 'displayName': config.displayName,
+ 'handle': config.handle ?? config.displayName,
+ 'isVideo': config.isVideo,
+ 'isDialing': config.isDialing,
+ });
+ }
+
+ Future endSession([CallEndedReason reason = CallEndedReason.local]) =>
+ _call('endCallKitSession', reason.name);
+
+ /// Reports a dialing call (`CallKitConfig.isDialing`) as connected.
+ Future reportOutgoingCallConnected() =>
+ _call('reportOutgoingCallConnected');
+
+ /// The result arrives as [CallKitHeld].
+ Future setHeld({required bool onHold}) =>
+ _call('setCallKitCallHeld', onHold);
+
+ /// The result arrives as [CallKitMuted].
+ Future setMuted({required bool muted}) =>
+ _call('setCallKitMuted', muted);
+
+ Future hasActiveSession() => _flag('hasActiveCallKitSession');
+
+ Future isHeld() => _flag('isCallKitCallHeld');
+
+ /// Changes reported by CallKit (broadcast). Never emits on other
+ /// platforms.
+ late final Stream actions = _isIos
+ ? _eventChannel.receiveBroadcastStream().expand(_parse)
+ : const Stream.empty();
+
+ Future _call(String method, [Object? arguments]) async {
+ if (_isIos) await _methods.invokeMethod(method, arguments);
+ }
+
+ Future _flag(String method) async =>
+ _isIos && (await _methods.invokeMethod(method) ?? false);
+
+ static Iterable _parse(Object? event) {
+ final map = event! as Map