diff --git a/dogfooding/lib/app/app_content.dart b/dogfooding/lib/app/app_content.dart index a5bb675a8..2effb7945 100644 --- a/dogfooding/lib/app/app_content.dart +++ b/dogfooding/lib/app/app_content.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -181,7 +183,11 @@ class _StreamDogFoodingAppContentState void _observeFcmMessages() { FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler); _compositeSubscription.add( - FirebaseMessaging.onMessage.listen(handleRemoteMessage), + FirebaseMessaging.onMessage.listen( + (message) => unawaited( + StreamVideo.instance.handleRingingFlowNotifications(message.data), + ), + ), ); } diff --git a/dogfooding/lib/app/firebase_messaging_handler.dart b/dogfooding/lib/app/firebase_messaging_handler.dart index ce9fa67d3..efd071255 100644 --- a/dogfooding/lib/app/firebase_messaging_handler.dart +++ b/dogfooding/lib/app/firebase_messaging_handler.dart @@ -2,62 +2,52 @@ import 'dart:async'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:stream_video_flutter/stream_video_flutter.dart'; +import 'package:stream_video_push_notification/stream_video_push_notification.dart'; import '../core/repos/app_preferences.dart'; import '../core/repos/token_service.dart'; import '../di/injector.dart'; import '../firebase_options.dart'; -// As this runs in a separate isolate, we need to setup the app again. +// On Android this runs in a separate isolate, which never ran main(), so the +// app has to be set up again from scratch. The SDK owns the client's lifecycle +// from there: observing the ringing events a background isolate can act on, +// and disposing once the user has answered, declined, or let the call time +// out. @pragma('vm:entry-point') Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { - // Initialise Firebase await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); - // Initialise the app. - await AppInjector.init(); - - try { - // Return if the user is not logged in. - final prefs = locator.get(); - final credentials = prefs.userCredentials; - if (credentials == null) return; - - final tokenResponse = await locator.get().loadToken( - userId: credentials.userInfo.id, - environment: prefs.environment, - ); - - // Initialise the video client. - final streamVideo = AppInjector.registerStreamVideo( - tokenResponse, - credentials.userInfo.toUser(), - prefs.environment, - ); - - final subscription = streamVideo.observeCoreRingingEventsForBackground(); - - streamVideo.disposeAfterResolvingRinging( - disposingCallback: () { - subscription.cancel(); - AppInjector.reset(); - }, - ); - - // Handle the message. - await handleRemoteMessage(message); - } catch (e, stk) { - debugPrint('Error handling remote message: $e'); - debugPrint(stk.toString()); - } - - // Reset the injector once the message is handled. - return AppInjector.reset(); + + final ownsInjector = !locator.isRegistered(); + + await StreamVideoPushHandler.handleBackgroundMessage( + message, + createStreamVideo: () => + _createStreamVideo(initialiseInjector: ownsInjector), + onDispose: ownsInjector ? AppInjector.reset : null, + ); } -Future handleRemoteMessage(RemoteMessage message) async { - final streamVideo = locator.get(); - return streamVideo.handleRingingFlowNotifications(message.data); +/// Builds the client for the background isolate, or nothing when nobody is +/// logged in. +Future _createStreamVideo({ + required bool initialiseInjector, +}) async { + if (initialiseInjector) await AppInjector.init(); + + final prefs = locator.get(); + final credentials = prefs.userCredentials; + if (credentials == null) return null; + + final tokenResponse = await locator.get().loadToken( + userId: credentials.userInfo.id, + environment: prefs.environment, + ); + + return AppInjector.registerStreamVideo( + tokenResponse, + credentials.userInfo.toUser(), + prefs.environment, + ); } diff --git a/packages/stream_video_push_notification/CHANGELOG.md b/packages/stream_video_push_notification/CHANGELOG.md index 095d4db41..3bc9f57ca 100644 --- a/packages/stream_video_push_notification/CHANGELOG.md +++ b/packages/stream_video_push_notification/CHANGELOG.md @@ -2,6 +2,24 @@ ### ✅ Added +- Added `StreamVideoPushHandler.handleBackgroundMessage`, which simplifies the ringing setup by running a Stream ringing push through its whole background lifecycle from your Firebase background handler: it builds the client through the factory you give it, observes the ringing events a background isolate can act on, and disposes the client — along with whatever that factory set up — once the user has answered, declined, or let the call time out. It replaces the setup and teardown each integration had to write by hand. Pass `existingClient` if your app does not keep its client in the `StreamVideo` singleton. + +```dart +@pragma('vm:entry-point') +Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + + await StreamVideoPushHandler.handleBackgroundMessage( + message, + createStreamVideo: () async { + // Read credentials, fetch a token, build the client. Return null when + // nobody is logged in. + }, + onDispose: () => MyDependencies.reset(), + ); +} +``` + - [Android] Added a Telecom integration for the ringing flow, which registers incoming and outgoing ringing calls with the platform's [Telecom stack](https://developer.android.com/develop/connectivity/telecom) through Jetpack Telecom. This gives the call proper audio focus and a place in the system call state, and lets it be answered or hung up from a paired watch, a car head unit or a Bluetooth headset. The incoming call notification and full-screen ringing UI are unchanged. It is **on by default from Android 17**: for an app targeting API 37 the platform will not play a ringtone from a service started by a push unless the call is in the Telecom stack, so ringing does not work correctly without it. The default follows the Android version of the device rather than your `targetSdk`, so it is on for any app running on Android 17 — if you target below API 37 the restriction does not apply to you and you can opt out with `AndroidPushConfiguration(telecom: TelecomPushConfiguration(enabled: false))`. It is **off by default below Android 17**, where ringing works either way, so an existing integration is unaffected unless you pass `enabled: true`. - [iOS] Added `reportCallEnded`, which reports how a call ended to CallKit so it is listed correctly in the system Recents. diff --git a/packages/stream_video_push_notification/lib/src/background_push_handler.dart b/packages/stream_video_push_notification/lib/src/background_push_handler.dart new file mode 100644 index 000000000..eb10cad0e --- /dev/null +++ b/packages/stream_video_push_notification/lib/src/background_push_handler.dart @@ -0,0 +1,302 @@ +import 'dart:async'; + +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:meta/meta.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_video/stream_video.dart'; + +/// Builds the client a background push needs, or returns null when there is +/// nobody to build it for. +/// +/// Called from a fresh isolate that never ran `main()`, so it has to do +/// everything from scratch: read stored credentials, fetch a token, construct +/// [StreamVideo]. Returning null is the normal way to say "no user is logged +/// in", not an error. +typedef CreateStreamVideo = Future Function(); + +/// Returns the client this isolate already has, or null when it has none. +/// +/// Answers the one question the handler cannot answer for itself: whether it +/// was called on the app's own isolate, where a client is already running and +/// its lifecycle belongs to the app. +/// +/// Defaults to the [StreamVideo] singleton. Pass your own when your app builds +/// its client with [StreamVideo.create], or holds it anywhere else: such a +/// client installs no singleton, so there is nothing for the default to find +/// and the handler would build a second one over the top of it. +typedef ResolveExistingClient = StreamVideo? Function(); + +/// Runs a Stream ringing push through its whole background lifecycle. +/// +/// On Android, Firebase delivers background messages to an isolate that never +/// ran `main()`, so nothing you set up at launch exists there. Everything +/// from building the client to tearing it down again needs to happen inside the +/// handler. On Apple platforms, the same handler is called on the app's own +/// isolate, where the client already exists and must be left alone—both cases +/// are handled here. +/// +/// You keep the parts that are genuinely yours, such as Firebase +/// initialization and credential management, and hand the rest over: +/// +/// ```dart +/// @pragma('vm:entry-point') +/// Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { +/// await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); +/// +/// await StreamVideoPushHandler.handleBackgroundMessage( +/// message, +/// createStreamVideo: () async { +/// // Read credentials, fetch a token, build the client. Return null if +/// // nobody is logged in. +/// }, +/// onDispose: () => MyDependencies.reset(), +/// ); +/// } +/// ``` +/// +/// The `@pragma('vm:entry-point')` function remains your responsibility on +/// purpose. It is the only thing the background isolate can reach, so the +/// factory must be reachable from inside it—a factory registered at app launch +/// would simply not be there. +class StreamVideoPushHandler { + const StreamVideoPushHandler._(); + + static const _tag = 'SV:BackgroundPush'; + + /// How long to wait after the user resolves the notification before tearing + /// the client down, so the flow that is resolving it finishes first. + static const _resolutionGrace = Duration(seconds: 1); + + /// The client and observers for this isolate, or null when none is running. + /// + /// Static because the isolate outlives a single message: a second push can + /// arrive while the first call is still ringing. + static _BackgroundSession? _session; + + /// Handles a background [message] that may be a Stream ringing push notification. + /// + /// Returns `true` if the message was successfully identified and processed as a + /// Stream call notification; otherwise, returns `false`. + /// + /// On the first relevant message, this method uses [createStreamVideo] to + /// construct a [StreamVideo] client within the background isolate, sets up + /// observers to process ringing events, and manages client disposal after the + /// notification flow completes—whether the user answers, declines, or the call + /// times out. The [onDispose] callback is always executed last to allow the app + /// to clean up resources established in [createStreamVideo], including scenarios + /// where initialization fails. + /// + /// If a [StreamVideo] client already exists (i.e., not constructed by this handler), + /// it indicates the message was delivered on the main isolate, which owns the + /// client's lifecycle. In this case, the notification is simply forwarded to the + /// running client, and no new observers or teardowns occur. The [existingClient] + /// callback provides access to the current client; if not provided, the default is + /// the [StreamVideo] singleton (which may not reflect your app's actual client instance). + /// + /// Any background message not intended for Stream Video is ignored and reported as + /// unhandled, allowing your application to process its own background notifications + /// after this handler completes. + static Future handleBackgroundMessage( + RemoteMessage message, { + required CreateStreamVideo createStreamVideo, + ResolveExistingClient? existingClient, + FutureOr Function()? onDispose, + }) async { + try { + final running = _session; + if (running != null) return await _handleWithSession(running, message); + + final appClient = _existingClient(existingClient); + if (appClient != null) { + streamLog.d( + _tag, + () => '[handleBackgroundMessage] forwarding to the running client', + ); + + return await appClient.handleRingingFlowNotifications(message.data); + } + + final session = await _startSession(createStreamVideo, onDispose); + + // Nobody is logged in, so there is no client to show anything with and + // nothing to tear down but what the app itself set up. + if (session == null) { + await onDispose?.call(); + return false; + } + + return await _handleWithSession(session, message); + } catch (e, stk) { + streamLog.e(_tag, () => '[handleBackgroundMessage] failed: $e; $stk'); + await _session?.release(); + return false; + } + } + + /// Runs [message] through [session]'s client, releasing the session when + /// nothing is left for it to wait for. + static Future _handleWithSession( + _BackgroundSession session, + RemoteMessage message, + ) async { + final handled = await session.streamVideo.handleRingingFlowNotifications( + message.data, + ); + + if (handled && _isRingingPush(message.data)) { + session.awaitingResolution = true; + } else if (!session.awaitingResolution) { + // Nothing that would release the session is coming: the message was + // either not ours, or one that only posts a notification and is done. + if (handled) { + session.releaseAfter(_resolutionGrace); + } else { + await session.release(); + } + } + + return handled; + } + + /// Whether [payload] is the ringing push, the only one whose flow waits for + /// the user. + /// + /// The SDK reports a missed call as handled too, but a missed call + /// notification has no ringing lifecycle: no accept, decline, timeout or end + /// is ever emitted for it, so treating it as pending would hold the client + /// and the app's dependencies for as long as the isolate lives. + static bool _isRingingPush(Map payload) => + payload['sender'] == 'stream.video' && payload['type'] == 'call.ring'; + + /// The client already living in this isolate, if there is a usable one. + /// + /// Only ever a client this handler did not build: a session of its own is + /// checked first. + static StreamVideo? _existingClient(ResolveExistingClient? resolve) { + final client = resolve != null ? resolve() : _singleton(); + if (client == null || client.isDisposed) return null; + + return client; + } + + /// The installed singleton, or null when there is none. + /// + /// An app can run without one: [StreamVideo.create] builds a client that + /// never takes the slot. Nothing here may assume the slot is filled, or that + /// what fills it is the client at hand. + static StreamVideo? _singleton() => + StreamVideo.isInitialized() ? StreamVideo.instance : null; + + /// Clears the singleton slot if [client] is what is in it. + /// + /// Disposing a client leaves the slot occupied, and a later message in the + /// same isolate would find it holding something dead. A client that never + /// took the slot leaves nothing to clear, and one belonging to somebody else + /// is not this handler's to take. + static Future _releaseSingleton(StreamVideo? client) async { + if (client == null || !identical(_singleton(), client)) return; + + await StreamVideo.reset(); + } + + /// Starts this isolate's session, or returns null when nobody is logged in. + static Future<_BackgroundSession?> _startSession( + CreateStreamVideo createStreamVideo, + FutureOr Function()? onDispose, + ) async { + final stale = _singleton(); + if (stale != null && stale.isDisposed) await _releaseSingleton(stale); + + StreamVideo? streamVideo; + try { + streamVideo = await createStreamVideo(); + if (streamVideo == null) return null; + + // ignore: cancel_subscriptions -- cancelled by _BackgroundSession.release. + final observers = streamVideo.observeCoreRingingEventsForBackground(); + + final session = _BackgroundSession( + streamVideo: streamVideo, + observers: observers, + onDispose: onDispose, + ); + + // Whatever the user does with the notification is what ends this + // isolate's work. + session.resolution = streamVideo.onRingingEvent((event) { + if (event is ActionCallAccept || + event is ActionCallDecline || + event is ActionCallTimeout || + event is ActionCallEnded) { + session.releaseAfter(_resolutionGrace); + } + }); + + _session = session; + return session; + } catch (e, stk) { + streamLog.e(_tag, () => '[startSession] failed: $e; $stk'); + + await streamVideo?.dispose(); + await _releaseSingleton(streamVideo); + await onDispose?.call(); + + rethrow; + } + } + + /// Drops the session this isolate is holding, for tests that run more than + /// one case in the same isolate. + @visibleForTesting + static Future releaseForTesting() => + _session?.release() ?? Future.value(); +} + +/// One isolate's client, its observers, and how to let go of them. +class _BackgroundSession { + _BackgroundSession({ + required this.streamVideo, + required this.observers, + required this.onDispose, + }); + + final StreamVideo streamVideo; + final CompositeSubscription observers; + final FutureOr Function()? onDispose; + + StreamSubscription? resolution; + + /// Whether a ringing flow is still waiting to be resolved by the user. + bool awaitingResolution = false; + + bool _released = false; + + /// Releases after [delay], so a flow that is still running finishes first. + void releaseAfter(Duration delay) => + unawaited(Future.delayed(delay, release)); + + /// Tears the session down, once. + /// + /// Idempotent because more than one thing can decide the isolate is done: a + /// resolved notification, a message that turned out not to be ours, or a + /// failure part way through. + Future release() async { + if (_released) return; + _released = true; + + if (StreamVideoPushHandler._session == this) { + StreamVideoPushHandler._session = null; + } + + await resolution?.cancel(); + await observers.cancel(); + await streamVideo.dispose(); + + // `dispose` leaves the singleton installed, if this client is what is + // installed at all: one built with `StreamVideo.create` never took the + // slot. + await StreamVideoPushHandler._releaseSingleton(streamVideo); + + await onDispose?.call(); + } +} diff --git a/packages/stream_video_push_notification/lib/stream_video_push_notification.dart b/packages/stream_video_push_notification/lib/stream_video_push_notification.dart index 45b66ea02..9a4533c5f 100644 --- a/packages/stream_video_push_notification/lib/stream_video_push_notification.dart +++ b/packages/stream_video_push_notification/lib/stream_video_push_notification.dart @@ -10,6 +10,7 @@ /// ringing experience. library stream_video_push_notification; +export 'src/background_push_handler.dart'; export 'src/stream_video_push_configuration.dart'; export 'src/stream_video_push_notification.dart' hide RingingEventBroadcaster, StreamTokenProvider, resolveCallsToEnd; diff --git a/packages/stream_video_push_notification/test/background_push_handler_test.dart b/packages/stream_video_push_notification/test/background_push_handler_test.dart new file mode 100644 index 000000000..122ee68b2 --- /dev/null +++ b/packages/stream_video_push_notification/test/background_push_handler_test.dart @@ -0,0 +1,386 @@ +import 'dart:async'; + +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_video/stream_video.dart'; +import 'package:stream_video_push_notification/stream_video_push_notification.dart'; + +class MockStreamVideo extends Mock implements StreamVideo { + // StreamVideo marks dispose as @mustBeOverridden. Routed back through the + // mock rather than stubbed out here, since teardown is what these tests + // verify. + @override + Future dispose() => + noSuchMethod(Invocation.method(#dispose, const [])) as Future; +} + +void _ignore(RingingEvent _) {} + +/// The payload of a ringing push, the only one whose flow waits for the user. +const _ringingPush = {'sender': 'stream.video', 'type': 'call.ring'}; + +/// A missed call: reported as handled, but nothing ever resolves it. +const _missedCallPush = {'sender': 'stream.video', 'type': 'call.missed'}; + +RemoteMessage _message([Map data = _ringingPush]) => + RemoteMessage(data: data); + +void main() { + registerFallbackValue((RingingEvent _) {}); + + late MockStreamVideo client; + late StreamController ringing; + + /// The callback the handler registered for ringing resolution. + void Function(RingingEvent)? onRingingEvent; + + /// Whether the handler asked for a client, and how many times. + late int factoryCalls; + late bool onDisposeCalled; + + /// A factory returning [client], or null to stand in for "nobody logged in". + CreateStreamVideo factoryReturning(StreamVideo? value) => () async { + factoryCalls++; + return value; + }; + + Future handle({ + required CreateStreamVideo createStreamVideo, + ResolveExistingClient? existingClient, + Map data = _ringingPush, + }) { + return StreamVideoPushHandler.handleBackgroundMessage( + _message(data), + createStreamVideo: createStreamVideo, + existingClient: existingClient, + onDispose: () => onDisposeCalled = true, + ); + } + + /// A client stubbed for the happy path: live, observable, and handling + /// whatever it is given. + MockStreamVideo stubbedClient() { + final stub = MockStreamVideo(); + + when(() => stub.isDisposed).thenReturn(false); + when(stub.dispose).thenAnswer((_) async {}); + when( + stub.observeCoreRingingEventsForBackground, + ).thenReturn(CompositeSubscription()); + when(() => stub.onRingingEvent(captureAny())).thenAnswer(( + invocation, + ) { + onRingingEvent = + invocation.positionalArguments.first as void Function(RingingEvent); + return ringing.stream.listen(_ignore); + }); + when( + () => stub.handleRingingFlowNotifications(any()), + ).thenAnswer((_) async => true); + + return stub; + } + + setUp(() { + ringing = StreamController.broadcast(); + onRingingEvent = null; + factoryCalls = 0; + onDisposeCalled = false; + client = stubbedClient(); + }); + + tearDown(() async { + await StreamVideoPushHandler.releaseForTesting(); + await ringing.close(); + }); + + group('StreamVideoPushHandler', () { + test( + 'reports a ringing push as handled and keeps the client alive', + () async { + final handled = await handle( + createStreamVideo: factoryReturning(client), + ); + + expect(handled, isTrue); + // Still ringing: disposing here would take the notification with it. + verifyNever(() => client.dispose()); + expect(onDisposeCalled, isFalse); + }, + ); + + test('does nothing when nobody is logged in', () async { + final handled = await handle(createStreamVideo: factoryReturning(null)); + + expect(handled, isFalse); + // Whatever the factory set up on its way to finding no credentials still + // has to come back down. + expect(onDisposeCalled, isTrue); + }); + + test('releases once the user resolves the notification', () async { + await handle(createStreamVideo: factoryReturning(client)); + + onRingingEvent!(const ActionCallDecline(data: CallData(uuid: 'u'))); + // Teardown is deferred so the flow resolving the call finishes first. + verifyNever(() => client.dispose()); + + await Future.delayed(const Duration(milliseconds: 1200)); + + verify(() => client.dispose()).called(1); + expect(onDisposeCalled, isTrue); + }); + + test('ignores ringing events that are not a resolution', () async { + await handle(createStreamVideo: factoryReturning(client)); + + onRingingEvent!(const ActionCallIncoming(data: CallData(uuid: 'u'))); + await Future.delayed(const Duration(milliseconds: 1200)); + + // An incoming call is the *start* of the flow this isolate exists for. + verifyNever(() => client.dispose()); + }); + + test('releases a message that turns out not to be ours', () async { + when( + () => client.handleRingingFlowNotifications(any()), + ).thenAnswer((_) async => false); + + final handled = await handle(createStreamVideo: factoryReturning(client)); + + expect(handled, isFalse); + // No ringing event is ever coming, so nothing else would let go of the + // isolate. + verify(() => client.dispose()).called(1); + expect(onDisposeCalled, isTrue); + }); + + test( + 'builds the client once for two messages in the same isolate', + () async { + await handle(createStreamVideo: factoryReturning(client)); + await handle(createStreamVideo: factoryReturning(client)); + + // Calling the factory twice would install a second singleton over a live + // one, which throws. + expect(factoryCalls, 1); + verify(() => client.handleRingingFlowNotifications(any())).called(2); + // And the second message must not wire a second set of observers. + verify(client.observeCoreRingingEventsForBackground).called(1); + }, + ); + + test( + 'a foreign message arriving mid-ring does not cut the ring short', + () async { + await handle(createStreamVideo: factoryReturning(client)); + + when( + () => client.handleRingingFlowNotifications(any()), + ).thenAnswer((_) async => false); + await handle(createStreamVideo: factoryReturning(client)); + + verifyNever(() => client.dispose()); + expect(onDisposeCalled, isFalse); + }, + ); + + test('releases when handling throws', () async { + when( + () => client.handleRingingFlowNotifications(any()), + ).thenThrow(Exception('boom')); + + final handled = await handle(createStreamVideo: factoryReturning(client)); + + expect(handled, isFalse); + verify(() => client.dispose()).called(1); + expect(onDisposeCalled, isTrue); + }); + + test('releases a missed call, which nothing else resolves', () async { + final handled = await handle( + createStreamVideo: factoryReturning(client), + data: _missedCallPush, + ); + + // Handled — the notification was posted — but no accept, decline, + // timeout or end is ever emitted for a missed call. + expect(handled, isTrue); + // Not immediately: the SDK posts the notification without waiting. + verifyNever(() => client.dispose()); + + await Future.delayed(const Duration(milliseconds: 1200)); + + verify(() => client.dispose()).called(1); + expect(onDisposeCalled, isTrue); + }); + + test( + 'a missed call arriving mid-ring does not cut the ring short', + () async { + await handle(createStreamVideo: factoryReturning(client)); + await handle( + createStreamVideo: factoryReturning(client), + data: _missedCallPush, + ); + + await Future.delayed(const Duration(milliseconds: 1200)); + + verifyNever(() => client.dispose()); + expect(onDisposeCalled, isFalse); + }, + ); + + group('when the setup fails part way through', () { + test('undoes what the factory managed to set up', () async { + final handled = await handle( + createStreamVideo: () async { + factoryCalls++; + throw Exception('no credentials'); + }, + ); + + expect(handled, isFalse); + // Whatever the factory registered before it threw has to come back + // down, or the next message finds a half set up isolate. + expect(onDisposeCalled, isTrue); + }); + + test('disposes a client built before the failure', () async { + when( + client.observeCoreRingingEventsForBackground, + ).thenThrow(Exception('boom')); + + final handled = await handle( + createStreamVideo: factoryReturning(client), + ); + + expect(handled, isFalse); + verify(() => client.dispose()).called(1); + expect(onDisposeCalled, isTrue); + }); + + test('leaves nothing behind for the next message', () async { + var fail = true; + Future factory() async { + factoryCalls++; + if (fail) throw Exception('no network'); + return client; + } + + await handle(createStreamVideo: factory); + fail = false; + final handled = await handle(createStreamVideo: factory); + + expect(handled, isTrue); + expect(factoryCalls, 2); + }); + }); + + group('when the app is already running in this isolate', () { + // Firebase only spins up a background isolate on Android; on Apple + // platforms this handler is called on the app's own isolate, where a + // client is already live. An app that builds its client with + // `StreamVideo.create` installs no singleton, so it says where the + // client is. + late ResolveExistingClient existingClient; + + setUp(() => existingClient = () => client); + + Future handleOnAppIsolate({ + CreateStreamVideo? createStreamVideo, + Map data = _ringingPush, + }) { + return handle( + createStreamVideo: createStreamVideo ?? factoryReturning(client), + existingClient: existingClient, + data: data, + ); + } + + test('forwards the message to the running client', () async { + final handled = await handleOnAppIsolate( + createStreamVideo: factoryReturning(MockStreamVideo()), + ); + + expect(handled, isTrue); + verify(() => client.handleRingingFlowNotifications(any())).called(1); + // Nothing to build: the running app already has a client. + expect(factoryCalls, 0); + }); + + test( + 'never observes ringing events on a client it does not own', + () async { + await handleOnAppIsolate(); + + // The running app has its own observers; a second set would show every + // incoming call twice. + verifyNever(client.observeCoreRingingEventsForBackground); + }, + ); + + test('ignores a disposed client and builds its own', () async { + // Nothing can be forwarded to a client that is already gone. + when(() => client.isDisposed).thenReturn(true); + final ours = stubbedClient(); + + final handled = await handleOnAppIsolate( + createStreamVideo: factoryReturning(ours), + ); + + expect(handled, isTrue); + expect(factoryCalls, 1); + verifyNever(() => client.handleRingingFlowNotifications(any())); + verify(() => ours.handleRingingFlowNotifications(any())).called(1); + }); + + test('leaves the running client alone for a foreign message', () async { + when( + () => client.handleRingingFlowNotifications(any()), + ).thenAnswer((_) async => false); + + final handled = await handleOnAppIsolate(); + + expect(handled, isFalse); + // Disposing here would take the live app's client and dependencies + // down with it. + verifyNever(() => client.dispose()); + expect(onDisposeCalled, isFalse); + }); + + test('leaves the running client alone once a call resolves', () async { + await handleOnAppIsolate(); + + // No resolution observer was registered, so nothing is scheduled. + expect(onRingingEvent, isNull); + + await Future.delayed(const Duration(milliseconds: 1200)); + + verifyNever(() => client.dispose()); + expect(onDisposeCalled, isFalse); + }); + }); + + test( + 'starts a fresh session after the previous one was released', + () async { + when( + () => client.handleRingingFlowNotifications(any()), + ).thenAnswer((_) async => false); + await handle(createStreamVideo: factoryReturning(client)); + + when( + () => client.handleRingingFlowNotifications(any()), + ).thenAnswer((_) async => true); + await handle(createStreamVideo: factoryReturning(client)); + + // The released session must not be reused, or the second message would + // be handed a disposed client. + expect(factoryCalls, 2); + }, + ); + }); +}