From b3525c6e4f908c6bb073432c7b6a1e99a86a74c8 Mon Sep 17 00:00:00 2001 From: Angelika Serwa Date: Wed, 21 Oct 2026 03:13:55 +0200 Subject: [PATCH] Add background audio Keep microphone capture and playback running while the app is in the background, with an Android foreground service and an iOS CallKit session. Co-authored-by: Cursor --- AGENTS.md | 1 + CHANGELOG.md | 2 + README.md | 65 ++++ SPEC.md | 56 ++++ .../io/fishjam/client/FishjamClientPlugin.kt | 51 ++- .../FishjamForegroundService.kt | 114 +++++++ .../ForegroundServiceController.kt | 101 ++++++ .../NotificationPermission.kt | 65 ++++ .../android/app/src/main/AndroidManifest.xml | 10 + example/ios/Runner/Info.plist | 5 + example/lib/room_screen.dart | 31 ++ .../fishjam_client/CallKitManager.swift | 292 ++++++++++++++++++ .../fishjam_client/FishjamClientPlugin.swift | 34 ++ lib/fishjam_client.dart | 16 + lib/src/background/call_kit.dart | 222 +++++++++++++ lib/src/background/foreground_service.dart | 110 +++++++ test/background/call_kit_test.dart | 188 +++++++++++ test/background/foreground_service_test.dart | 87 ++++++ 18 files changed, 1449 insertions(+), 1 deletion(-) create mode 100644 android/src/main/kotlin/io/fishjam/client/foregroundservice/FishjamForegroundService.kt create mode 100644 android/src/main/kotlin/io/fishjam/client/foregroundservice/ForegroundServiceController.kt create mode 100644 android/src/main/kotlin/io/fishjam/client/foregroundservice/NotificationPermission.kt create mode 100644 ios/fishjam_client/Sources/fishjam_client/CallKitManager.swift create mode 100644 lib/src/background/call_kit.dart create mode 100644 lib/src/background/foreground_service.dart create mode 100644 test/background/call_kit_test.dart create mode 100644 test/background/foreground_service_test.dart 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; + return [ + if (map.containsKey('started')) const CallKitStarted(), + if (map['ended'] case final String reason) + CallKitEnded( + CallEndedReason.values.firstWhere( + (r) => r.name == reason, + orElse: () => CallEndedReason.local, + ), + ), + if (map['failed'] case final String message) CallKitFailed(message), + if (map['muted'] case final bool isMuted) CallKitMuted(isMuted: isMuted), + if (map['held'] case final bool isHeld) CallKitHeld(isHeld: isHeld), + ]; + } +} diff --git a/lib/src/background/foreground_service.dart b/lib/src/background/foreground_service.dart new file mode 100644 index 0000000..340787b --- /dev/null +++ b/lib/src/background/foreground_service.dart @@ -0,0 +1,110 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Importance of the foreground service notification channel. +enum ForegroundServiceImportance { + /// Silent, persistent ongoing-call indicator. + low, + + /// Heads-up popup with sound. + high, +} + +/// Settings of the Android foreground service that keeps a call running in +/// the background. +@immutable +class ForegroundServiceConfig { + const ForegroundServiceConfig({ + this.enableCamera = false, + this.enableMicrophone = false, + this.enableScreenSharing = false, + this.channelId, + this.channelName, + this.notificationTitle, + this.notificationContent, + this.importance = ForegroundServiceImportance.high, + this.onlyAlertOnce = false, + }); + + /// Adds the `camera` service type. Takes effect only once `CAMERA` is + /// granted. + final bool enableCamera; + + /// Adds the `microphone` service type. Takes effect only once + /// `RECORD_AUDIO` is granted. + final bool enableMicrophone; + + /// Adds the `mediaProjection` service type. Get the user's consent with + /// `Helper.requestCapturePermission()` before starting the service. + final bool enableScreenSharing; + + /// Defaults to `com.fishjam.foregroundservice.channel`. + final String? channelId; + + /// Defaults to `Fishjam Notifications`. + final String? channelName; + final String? notificationTitle; + final String? notificationContent; + + /// Android binds the importance to the channel when it is first created + /// and ignores later changes for the same [channelId]. Use a distinct + /// [channelId] per importance to switch it at runtime. + final ForegroundServiceImportance importance; + final bool onlyAlertOnce; + + Map _toMap() => { + 'enableCamera': enableCamera, + 'enableMicrophone': enableMicrophone, + 'enableScreenSharing': enableScreenSharing, + 'channelId': ?channelId, + 'channelName': ?channelName, + 'notificationTitle': ?notificationTitle, + 'notificationContent': ?notificationContent, + 'importance': importance.name, + 'onlyAlertOnce': onlyAlertOnce, + }; +} + +/// Android foreground service that keeps the app, and so the call, running +/// while it is in the background. Start it after joining, stop it after +/// leaving. A no-op on other platforms. +/// +/// The app must declare the service and its permissions in +/// `AndroidManifest.xml`; see the README. +class ForegroundService { + /// A service on a custom channel and platform (tests only). + @internal + ForegroundService.internal({ + MethodChannel methodChannel = const MethodChannel( + 'io.fishjam.client/background', + ), + TargetPlatform Function()? platform, + }) : _methods = methodChannel, + _platform = platform ?? (() => defaultTargetPlatform); + + static final ForegroundService instance = ForegroundService.internal(); + + final MethodChannel _methods; + final TargetPlatform Function() _platform; + + bool get _isAndroid => _platform() == TargetPlatform.android; + + /// Starts the service, or updates the running one with [config]. + /// + /// On Android 13+ the first call asks for `POST_NOTIFICATIONS`. If the + /// user refuses, the service still runs without a visible notification. + Future start(ForegroundServiceConfig config) async { + if (_isAndroid) { + await _methods.invokeMethod( + 'startForegroundService', + config._toMap(), + ); + } + } + + Future stop() async { + if (_isAndroid) { + await _methods.invokeMethod('stopForegroundService'); + } + } +} diff --git a/test/background/call_kit_test.dart b/test/background/call_kit_test.dart new file mode 100644 index 0000000..4305bed --- /dev/null +++ b/test/background/call_kit_test.dart @@ -0,0 +1,188 @@ +import 'package:fishjam_client/fishjam_client.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _methods = MethodChannel('test/background'); +const _events = EventChannel('test/callkit_events'); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + late List calls; + late Object? Function(MethodCall call) reply; + + setUp(() { + calls = []; + reply = (_) => null; + messenger.setMockMethodCallHandler(_methods, (call) async { + calls.add(call); + return reply(call); + }); + }); + + tearDown(() { + messenger + ..setMockMethodCallHandler(_methods, null) + ..setMockStreamHandler(_events, null); + }); + + CallKit callKit(TargetPlatform platform) => CallKit.internal( + methodChannel: _methods, + eventChannel: _events, + platform: () => platform, + ); + + group('CallKit sessions (SPEC §20c)', () { + test('startSession defaults handle to displayName', () async { + await callKit(TargetPlatform.iOS) + .startSession(const CallKitConfig(displayName: 'Ann', isVideo: true)); + expect(calls.single.method, 'startCallKitSession'); + expect(calls.single.arguments, { + 'displayName': 'Ann', + 'handle': 'Ann', + 'isVideo': true, + 'isDialing': false, + }); + }); + + test('startSession sends handle and isDialing', () async { + await callKit(TargetPlatform.iOS).startSession( + const CallKitConfig( + displayName: 'Ann', + handle: 'user-1', + isVideo: false, + isDialing: true, + ), + ); + expect(calls.single.arguments, { + 'displayName': 'Ann', + 'handle': 'user-1', + 'isVideo': false, + 'isDialing': true, + }); + }); + + test('an empty displayName throws without calling native code', () { + expect( + () => callKit(TargetPlatform.iOS) + .startSession(const CallKitConfig(displayName: '', isVideo: false)), + throwsArgumentError, + ); + expect(calls, isEmpty); + }); + + test('endSession sends the reason name, local by default', () async { + final c = callKit(TargetPlatform.iOS); + await c.endSession(); + await c.endSession(CallEndedReason.answeredElsewhere); + expect(calls.map((c) => (c.method, c.arguments)), [ + ('endCallKitSession', 'local'), + ('endCallKitSession', 'answeredElsewhere'), + ]); + }); + + test('setHeld, setMuted and reportOutgoingCallConnected', () async { + final c = callKit(TargetPlatform.iOS); + await c.setHeld(onHold: true); + await c.setMuted(muted: false); + await c.reportOutgoingCallConnected(); + expect(calls.map((c) => (c.method, c.arguments)), [ + ('setCallKitCallHeld', true), + ('setCallKitMuted', false), + ('reportOutgoingCallConnected', null), + ]); + }); + + test('hasActiveSession and isHeld read native state', () async { + reply = (call) => call.method == 'hasActiveCallKitSession'; + final c = callKit(TargetPlatform.iOS); + expect(await c.hasActiveSession(), isTrue); + expect(await c.isHeld(), isFalse); + reply = (_) => null; + expect(await c.hasActiveSession(), isFalse); + }); + }); + + group('CallKit actions', () { + test('native events become typed actions', () async { + messenger.setMockStreamHandler( + _events, + MockStreamHandler.inline( + onListen: (_, sink) { + sink + ..success({'started': null}) + ..success({'unknown': 1}) + ..success({'muted': true}) + ..success({'held': false}) + ..success({'failed': 'boom'}) + ..success({'ended': 'remote'}) + ..success({'ended': 'whatever'}); + }, + ), + ); + final actions = await callKit(TargetPlatform.iOS).actions + .take(6) + .toList(); + expect(actions, [ + const CallKitStarted(), + const CallKitMuted(isMuted: true), + const CallKitHeld(isHeld: false), + const CallKitFailed('boom'), + const CallKitEnded(CallEndedReason.remote), + const CallKitEnded(CallEndedReason.local), + ]); + }); + + test('actions value equality and toString', () { + expect(const CallKitStarted().hashCode, const CallKitStarted().hashCode); + expect( + const CallKitMuted(isMuted: true), + isNot(const CallKitMuted(isMuted: false)), + ); + expect( + const CallKitHeld(isHeld: true).hashCode, + const CallKitHeld(isHeld: true).hashCode, + ); + expect( + const CallKitFailed('a').hashCode, + const CallKitFailed('a').hashCode, + ); + expect( + const CallKitEnded(CallEndedReason.failed).hashCode, + const CallKitEnded(CallEndedReason.failed).hashCode, + ); + for (final action in const [ + CallKitStarted(), + CallKitEnded(CallEndedReason.missed), + CallKitFailed('x'), + CallKitMuted(isMuted: true), + CallKitHeld(isHeld: true), + ]) { + expect(action.toString(), startsWith('CallKit')); + } + }); + }); + + group('on Android', () { + test('every call is a no-op and getters return false', () async { + final c = callKit(TargetPlatform.android); + await c.startSession( + const CallKitConfig(displayName: 'A', isVideo: true), + ); + await c.endSession(); + await c.setHeld(onHold: true); + await c.setMuted(muted: true); + await c.reportOutgoingCallConnected(); + expect(await c.hasActiveSession(), isFalse); + expect(await c.isHeld(), isFalse); + expect(await c.actions.isEmpty, isTrue); + expect(calls, isEmpty); + }); + }); + + test('instance is a single shared CallKit', () { + expect(CallKit.instance, same(CallKit.instance)); + }); +} diff --git a/test/background/foreground_service_test.dart b/test/background/foreground_service_test.dart new file mode 100644 index 0000000..9650b94 --- /dev/null +++ b/test/background/foreground_service_test.dart @@ -0,0 +1,87 @@ +import 'package:fishjam_client/fishjam_client.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _methods = MethodChannel('test/background'); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + late List calls; + + setUp(() { + calls = []; + messenger.setMockMethodCallHandler(_methods, (call) async { + calls.add(call); + return null; + }); + }); + + tearDown(() => messenger.setMockMethodCallHandler(_methods, null)); + + ForegroundService service(TargetPlatform platform) => + ForegroundService.internal( + methodChannel: _methods, + platform: () => platform, + ); + + group('ForegroundService (SPEC §20c)', () { + test('start sends the defaults and omits unset strings', () async { + await service(TargetPlatform.android) + .start(const ForegroundServiceConfig()); + expect(calls.single.method, 'startForegroundService'); + expect(calls.single.arguments, { + 'enableCamera': false, + 'enableMicrophone': false, + 'enableScreenSharing': false, + 'importance': 'high', + 'onlyAlertOnce': false, + }); + }); + + test('start sends every field that is set', () async { + await service(TargetPlatform.android).start( + const ForegroundServiceConfig( + enableCamera: true, + enableMicrophone: true, + enableScreenSharing: true, + channelId: 'id', + channelName: 'name', + notificationTitle: 'title', + notificationContent: 'content', + importance: ForegroundServiceImportance.low, + onlyAlertOnce: true, + ), + ); + expect(calls.single.arguments, { + 'enableCamera': true, + 'enableMicrophone': true, + 'enableScreenSharing': true, + 'channelId': 'id', + 'channelName': 'name', + 'notificationTitle': 'title', + 'notificationContent': 'content', + 'importance': 'low', + 'onlyAlertOnce': true, + }); + }); + + test('stop sends stopForegroundService', () async { + await service(TargetPlatform.android).stop(); + expect(calls.single.method, 'stopForegroundService'); + }); + + test('is a no-op on iOS', () async { + final s = service(TargetPlatform.iOS); + await s.start(const ForegroundServiceConfig(enableMicrophone: true)); + await s.stop(); + expect(calls, isEmpty); + }); + + test('instance is a single shared service', () { + expect(ForegroundService.instance, same(ForegroundService.instance)); + }); + }); +}