From 6e6eaee241f12a57925c86e7a5a3c464b20c6df2 Mon Sep 17 00:00:00 2001 From: nt4f04uNd Date: Mon, 14 Jun 2021 18:07:22 +0300 Subject: [PATCH 01/16] added isolate unit tests --- .../lib/example_multiple_handlers.dart | 1 - audio_service/lib/audio_service.dart | 489 +++++--- audio_service/test/isolate_test.dart | 1063 +++++++++++++++++ audio_service/test/mock_audio_handler.dart | 421 +++++++ 4 files changed, 1805 insertions(+), 169 deletions(-) create mode 100644 audio_service/test/isolate_test.dart create mode 100644 audio_service/test/mock_audio_handler.dart diff --git a/audio_service/example/lib/example_multiple_handlers.dart b/audio_service/example/lib/example_multiple_handlers.dart index ed11739a..3e9c6725 100644 --- a/audio_service/example/lib/example_multiple_handlers.dart +++ b/audio_service/example/lib/example_multiple_handlers.dart @@ -287,7 +287,6 @@ class MainSwitchHandler extends SwitchAudioHandler { /// An [AudioHandler] for playing a list of podcast episodes. class AudioPlayerHandler extends BaseAudioHandler with QueueHandler, SeekHandler { - // ignore: close_sinks final BehaviorSubject> _recentSubject = BehaviorSubject.seeded([]); final _mediaLibrary = MediaLibrary(); diff --git a/audio_service/lib/audio_service.dart b/audio_service/lib/audio_service.dart index 630001fe..1845023e 100644 --- a/audio_service/lib/audio_service.dart +++ b/audio_service/lib/audio_service.dart @@ -798,7 +798,6 @@ class AudioService { /// The root media ID for browsing the most recently played item(s). static const String recentRootId = 'recent'; - // ignore: close_sinks static final BehaviorSubject _notificationClickEvent = BehaviorSubject.seeded(false); @@ -806,25 +805,24 @@ class AudioService { static ValueStream get notificationClickEvent => _notificationClickEvent; - // ignore: close_sinks static BehaviorSubject? _positionSubject; - static late ReceivePort _customActionReceivePort; - - /// Connect to the [AudioHandler] from another isolate. The [AudioHandler] - /// must have been initialised via [init] prior to connecting. - static Future connectFromIsolate() async { - WidgetsFlutterBinding.ensureInitialized(); - return _IsolateAudioHandler(); - } - static final _compatibilitySwitcher = SwitchAudioHandler(); - /// Register the app's [AudioHandler] with configuration options. This must be - /// called during the app's initialisation so that it is prepared to handle - /// audio requests immediately after a cold restart (e.g. if the user clicks - /// on the play button in the media notification while your app is not running - /// and your app needs to be woken up). + /// Register the app's [AudioHandler] with configuration options. + /// + /// Must be called from the main isolate during the app's initialisation so + /// that it is prepared to handle audio requests immediately after a cold restart + /// (e.g. if the user clicks on the play button in the media notification + /// while your app is not running and your app needs to be woken up). + /// + /// Calling this method not from the main isolates may have unintended results, + /// for example the isolate may become unreachable, because it was destroyed, + /// or its engine was destroyed. + /// + /// This method automatically hosts audio handler. so other isolates can + /// reach out to the handler with [connectFromIsolate]. For more details + /// on the lifecycle of hosted handler, see [hostHandler] documentation. /// /// You may optionally specify a [cacheManager] to use when loading artwork to /// display in the media notification and lock screen. This defaults to @@ -848,185 +846,274 @@ class AudioService { _handler = handler; _platform.setHandlerCallbacks(_HandlerCallbacks(handler)); - // This port listens to connections from other isolates. + hostHandler(handler); + _observeMediaItem(); + _observeAndroidPlaybackInfo(); + _observeQueue(); + _observePlaybackState(); + + return handler; + } + + /// Port to host the handler on with [hostHandler]. + static late ReceivePort _hostReceivePort; + static const _hostIsolatePortName = 'com.ryanheise.audioservice.port'; + + /// Connect to the [IsolateAudioHandler] from another isolate. + /// + /// Prior this, some [AudioHandler] must be hosted by calling [init] or + /// [hostHandler] + static Future connectFromIsolate() async { + return IsolateAudioHandler(); + } + + /// Hosts the audio handler to other isolates. + /// + /// Must be called from the main isolate, other isolates can connect + /// to the handler via [connectFromIsolate]. + /// + /// Calling this method not from the main isolates may have unintended results, + /// for example the isolate may become unreachable, because it was destroyed, + /// or its engine was destroyed. After that all the handlers from isolates + /// will stop receiving updates and calls to their methods will timeout. + /// + /// During the time the isolate the handler was hosted from is alive, + /// any calls to this method from any isolate will throw. A new handler + /// can be registered once more only when this isolate dies. + static Future hostHandler(AudioHandler handler) async { if (!kIsWeb) { - _customActionReceivePort = ReceivePort(); - _customActionReceivePort.listen((dynamic event) async { - final request = event as _IsolateRequest; + final sendPort = IsolateNameServer.lookupPortByName(_hostIsolatePortName); + assert(sendPort == null, "Some isolate has already hosted its handler"); + + void syncStream(Stream stream, IsolateRequest request) { + final sendPort = request.arguments![0] as SendPort; + final toSkip = []; + stream.listen((dynamic event) { + if (toSkip.contains(event)) { + toSkip.remove(event); + } else { + sendPort.send(IsolateStreamSyncRequest(event)); + } + }); + if (stream is StreamController) { + final receivePort = ReceivePort(); + receivePort.listen((dynamic message) { + toSkip.add(message); + (stream as StreamController).add(message); + }); + request.sendPort.send(receivePort.sendPort); + } else { + request.sendPort.send(null); + } + } + + _hostReceivePort = ReceivePort(); + _hostReceivePort.listen((dynamic event) async { + final request = event as IsolateRequest; switch (request.method) { + case 'playbackState': + syncStream(handler.playbackState, request); + request.sendPort.send(null); + break; + case 'queue': + syncStream(handler.queue, request); + request.sendPort.send(null); + break; + case 'queueTitle': + syncStream(handler.queueTitle, request); + request.sendPort.send(null); + break; + case 'mediaItem': + syncStream(handler.mediaItem, request); + request.sendPort.send(null); + break; + case 'androidPlaybackInfo': + syncStream(handler.androidPlaybackInfo, request); + request.sendPort.send(null); + break; + case 'ratingStyle': + syncStream(handler.ratingStyle, request); + request.sendPort.send(null); + break; + case 'customEvent': + syncStream(handler.customEvent, request); + request.sendPort.send(null); + break; + case 'customState': + syncStream(handler.customState, request); + request.sendPort.send(null); + break; case 'prepare': - await _handler.prepare(); + await handler.prepare(); request.sendPort.send(null); break; case 'prepareFromMediaId': - await _handler.prepareFromMediaId( + await handler.prepareFromMediaId( request.arguments![0] as String, request.arguments![1] as Map?, ); request.sendPort.send(null); break; case 'prepareFromSearch': - await _handler.prepareFromSearch( + await handler.prepareFromSearch( request.arguments![0] as String, request.arguments![1] as Map?, ); request.sendPort.send(null); break; case 'prepareFromUri': - await _handler.prepareFromUri( + await handler.prepareFromUri( request.arguments![0] as Uri, request.arguments![1] as Map?, ); request.sendPort.send(null); break; case 'play': - await _handler.play(); + await handler.play(); request.sendPort.send(null); break; case 'playFromMediaId': - await _handler.playFromMediaId( + await handler.playFromMediaId( request.arguments![0] as String, request.arguments![1] as Map?, ); request.sendPort.send(null); break; case 'playFromSearch': - await _handler.playFromSearch( + await handler.playFromSearch( request.arguments![0] as String, request.arguments![1] as Map?, ); request.sendPort.send(null); break; case 'playFromUri': - await _handler.playFromUri( + await handler.playFromUri( request.arguments![0] as Uri, request.arguments![1] as Map?, ); request.sendPort.send(null); break; case 'playMediaItem': - await _handler.playMediaItem(request.arguments![0] as MediaItem); + await handler.playMediaItem(request.arguments![0] as MediaItem); request.sendPort.send(null); break; case 'pause': - await _handler.pause(); + await handler.pause(); request.sendPort.send(null); break; case 'click': - await _handler.click(request.arguments![0] as MediaButton); + await handler.click(request.arguments![0] as MediaButton); request.sendPort.send(null); break; case 'stop': - await _handler.stop(); + await handler.stop(); request.sendPort.send(null); break; case 'addQueueItem': - await _handler.addQueueItem(request.arguments![0] as MediaItem); + await handler.addQueueItem(request.arguments![0] as MediaItem); request.sendPort.send(null); break; case 'addQueueItems': - await _handler + await handler .addQueueItems(request.arguments![0] as List); request.sendPort.send(null); break; case 'insertQueueItem': - await _handler.insertQueueItem( + await handler.insertQueueItem( request.arguments![0] as int, request.arguments![1] as MediaItem, ); request.sendPort.send(null); break; case 'updateQueue': - await _handler - .updateQueue(request.arguments![0] as List); + await handler.updateQueue(request.arguments![0] as List); request.sendPort.send(null); break; case 'updateMediaItem': - await _handler.updateMediaItem(request.arguments![0] as MediaItem); + await handler.updateMediaItem(request.arguments![0] as MediaItem); request.sendPort.send(null); break; case 'removeQueueItem': - await _handler.removeQueueItem(request.arguments![0] as MediaItem); + await handler.removeQueueItem(request.arguments![0] as MediaItem); request.sendPort.send(null); break; case 'removeQueueItemAt': - await _handler.removeQueueItemAt(request.arguments![0] as int); + await handler.removeQueueItemAt(request.arguments![0] as int); request.sendPort.send(null); break; case 'skipToNext': - await _handler.skipToNext(); + await handler.skipToNext(); request.sendPort.send(null); break; case 'skipToPrevious': - await _handler.skipToPrevious(); + await handler.skipToPrevious(); request.sendPort.send(null); break; case 'fastForward': - await _handler.fastForward(); + await handler.fastForward(); request.sendPort.send(null); break; case 'rewind': - await _handler.rewind(); + await handler.rewind(); request.sendPort.send(null); break; case 'skipToQueueItem': - await _handler.skipToQueueItem(request.arguments![0] as int); + await handler.skipToQueueItem(request.arguments![0] as int); request.sendPort.send(null); break; case 'seek': - await _handler.seek(request.arguments![0] as Duration); + await handler.seek(request.arguments![0] as Duration); request.sendPort.send(null); break; case 'setRating': - await _handler.setRating( + await handler.setRating( request.arguments![0] as Rating, request.arguments![1] as Map?, ); request.sendPort.send(null); break; case 'setCaptioningEnabled': - await _handler.setCaptioningEnabled(request.arguments![0] as bool); + await handler.setCaptioningEnabled(request.arguments![0] as bool); request.sendPort.send(null); break; case 'setRepeatMode': - await _handler + await handler .setRepeatMode(request.arguments![0] as AudioServiceRepeatMode); request.sendPort.send(null); break; case 'setShuffleMode': - await _handler.setShuffleMode( + await handler.setShuffleMode( request.arguments![0] as AudioServiceShuffleMode); request.sendPort.send(null); break; case 'seekBackward': - await _handler.seekBackward(request.arguments![0] as bool); + await handler.seekBackward(request.arguments![0] as bool); request.sendPort.send(null); break; case 'seekForward': - await _handler.seekForward(request.arguments![0] as bool); + await handler.seekForward(request.arguments![0] as bool); request.sendPort.send(null); break; case 'setSpeed': - await _handler.setSpeed(request.arguments![0] as double); + await handler.setSpeed(request.arguments![0] as double); request.sendPort.send(null); break; case 'customAction': - await _handler.customAction( + request.sendPort.send(await handler.customAction( request.arguments![0] as String, request.arguments![1] as Map?, - ); - request.sendPort.send(null); + )); break; case 'onTaskRemoved': - await _handler.onTaskRemoved(); + await handler.onTaskRemoved(); request.sendPort.send(null); break; case 'onNotificationDeleted': - await _handler.onNotificationDeleted(); + await handler.onNotificationDeleted(); request.sendPort.send(null); break; case 'getChildren': - request.sendPort.send(await _handler.getChildren( + request.sendPort.send(await handler.getChildren( request.arguments![0] as String, request.arguments![1] as Map?, )); @@ -1034,7 +1121,7 @@ class AudioService { case 'subscribeToChildren': final parentMediaId = request.arguments![0] as String; final sendPort = request.arguments![1] as SendPort?; - _handler + handler .subscribeToChildren(parentMediaId) .listen((Map options) { sendPort!.send(options); @@ -1042,35 +1129,28 @@ class AudioService { break; case 'getMediaItem': request.sendPort.send( - await _handler.getMediaItem(request.arguments![0] as String)); + await handler.getMediaItem(request.arguments![0] as String)); break; case 'search': - request.sendPort.send(await _handler.search( + request.sendPort.send(await handler.search( request.arguments![0] as String, request.arguments![1] as Map?, )); break; case 'androidAdjustRemoteVolume': - await _handler.androidAdjustRemoteVolume( + await handler.androidAdjustRemoteVolume( request.arguments![0] as AndroidVolumeDirection); request.sendPort.send(null); break; case 'androidSetRemoteVolume': - await _handler.androidSetRemoteVolume(request.arguments![0] as int); + await handler.androidSetRemoteVolume(request.arguments![0] as int); request.sendPort.send(null); break; } }); - //IsolateNameServer.removePortNameMapping(_isolatePortName); IsolateNameServer.registerPortWithName( - _customActionReceivePort.sendPort, _isolatePortName); + _hostReceivePort.sendPort, _hostIsolatePortName); } - _observeMediaItem(); - _observeAndroidPlaybackInfo(); - _observeQueue(); - _observePlaybackState(); - - return handler; } static Future _observeMediaItem() async { @@ -1185,7 +1265,6 @@ class AudioService { assert(minPeriod <= maxPeriod); assert(minPeriod > Duration.zero); Duration? last; - // ignore: close_sinks late StreamController controller; late StreamSubscription mediaItemSubscription; late StreamSubscription playbackStateSubscription; @@ -2350,193 +2429,279 @@ class CompositeAudioHandler extends AudioHandler { ValueStream get customState => _inner.customState; } -class _IsolateRequest { +/// A message to be sent to the audio handler hosted with [AudioService.hostHandler]. +class IsolateRequest { /// The send port for sending the response of this request. final SendPort sendPort; + + /// The method of the [AudioHandler]. final String method; + + /// A list of arguments. final List? arguments; - _IsolateRequest(this.sendPort, this.method, [this.arguments]); + /// Creates a request. + IsolateRequest(this.sendPort, this.method, [this.arguments]); } -const _isolatePortName = 'com.ryanheise.audioservice.port'; +/// A message to be from host isolate to the connected to synchronize +/// the their streams. +class IsolateStreamSyncRequest { + /// Event data. + final dynamic event; -class _IsolateAudioHandler extends AudioHandler { - final _childrenSubjects = >>{}; + /// The time this request was sent. + final DateTime time; + + /// Creates a request. + IsolateStreamSyncRequest(this.event) : time = DateTime.now(); +} +/// Handler that connects to the handler hosted with [AudioService.hostHandler]. +/// +/// Instantiating this class is equal to calling [AudioService.connectFromIsolate]. +class IsolateAudioHandler extends AudioHandler { @override - // ignore: close_sinks - final BehaviorSubject playbackState = - BehaviorSubject.seeded(PlaybackState()); + final BehaviorSubject playbackState = BehaviorSubject(); + @override - // ignore: close_sinks - final BehaviorSubject?> queue = - BehaviorSubject.seeded([]); + final BehaviorSubject?> queue = BehaviorSubject(); + @override - // TODO - // ignore: close_sinks - final BehaviorSubject queueTitle = BehaviorSubject.seeded(''); + final BehaviorSubject queueTitle = BehaviorSubject(); + @override - // ignore: close_sinks - final BehaviorSubject mediaItem = BehaviorSubject.seeded(null); + final BehaviorSubject mediaItem = BehaviorSubject(); + @override - // TODO - // ignore: close_sinks final BehaviorSubject androidPlaybackInfo = BehaviorSubject(); + @override - // TODO - // ignore: close_sinks final BehaviorSubject ratingStyle = BehaviorSubject(); + @override - // TODO - // ignore: close_sinks final PublishSubject customEvent = PublishSubject(); @override - // TODO - // ignore: close_sinks final BehaviorSubject customState = BehaviorSubject(); - _IsolateAudioHandler() : super._(); + /// Creates an isolate audio handler. + IsolateAudioHandler() : super._() { + syncSubjects(); + } + + /// Called in constructor to sync streams with . + void syncSubjects() { + syncSubject(playbackState, 'playbackState'); + syncSubject(queue, 'queue'); + syncSubject(queueTitle, 'queueTitle'); + syncSubject(mediaItem, 'mediaItem'); + syncSubject(androidPlaybackInfo, 'androidPlaybackInfo'); + syncSubject(ratingStyle, 'ratingStyle'); + syncSubject(customEvent, 'customEvent'); + syncSubject(customState, 'customState'); + } + + /// Sends a message to hosted audio handler. + Future send(String method, [List? arguments]) async { + final sendPort = + IsolateNameServer.lookupPortByName(AudioService._hostIsolatePortName); + if (sendPort == null) { + throw StateError( + "No isolate was hosted. " + "You mast call `AudioService.init` or `AudioService.hostHandler` first", + ); + } + final receivePort = ReceivePort(); + sendPort.send(IsolateRequest(receivePort.sendPort, method, arguments)); + final result = await (receivePort.first) + .timeout(const Duration(seconds: 10), onTimeout: () { + print( + "The call to the hosted isolate has timed out, the isolate has likely died " + "See ${AudioService.hostHandler} for more details", + ); + return null; + }) as T; + receivePort.close(); + return result; + } + + /// Synchronizes some stream with some in the hosted audio handler by its [name]. + /// + /// It sends a send port to pipe events into, from some stream in hosted handler. + /// This port should receive a message on connection. + /// + /// In return the hosted handler should also return a send port, and pipe the + /// messages that are sent over it into the same stream. + /// + /// The hosted handler may also respond with `null` instead of a send port, + /// when it can't pipe the events into the stream. + Future syncSubject(Subject subject, String name) async { + DateTime? recentUpdate; + final receivePort = ReceivePort(); + final toSkip = []; + SendPort? sendPort; + receivePort.listen((dynamic message) { + final request = message as IsolateStreamSyncRequest; + if (recentUpdate == null || + request.time.difference(recentUpdate!) > Duration.zero) { + recentUpdate = request.time; + subject.add(request.event); + if (sendPort != null) { + toSkip.add(request.event); + } + } + }); + sendPort = await send(name, [receivePort.sendPort]); + if (sendPort != null) { + subject.listen((dynamic event) { + if (toSkip.contains(event)) { + toSkip.remove(event); + } else { + recentUpdate = DateTime.now(); + sendPort!.send(event); + } + }); + } + } @override - Future prepare() => _send('prepare'); + Future prepare() => send('prepare'); @override Future prepareFromMediaId(String mediaId, [Map? extras]) => - _send('prepareFromMediaId', [mediaId, extras]); + send('prepareFromMediaId', [mediaId, extras]); @override Future prepareFromSearch(String query, [Map? extras]) => - _send('prepareFromSearch', [query, extras]); + send('prepareFromSearch', [query, extras]); @override Future prepareFromUri(Uri uri, [Map? extras]) => - _send('prepareFromUri', [uri, extras]); + send('prepareFromUri', [uri, extras]); @override - Future play() => _send('play'); + Future play() => send('play'); @override Future playFromMediaId(String mediaId, [Map? extras]) => - _send('playFromMediaId', [mediaId, extras]); + send('playFromMediaId', [mediaId, extras]); @override Future playFromSearch(String query, [Map? extras]) => - _send('playFromSearch', [query, extras]); + send('playFromSearch', [query, extras]); @override Future playFromUri(Uri uri, [Map? extras]) => - _send('playFromUri', [uri, extras]); + send('playFromUri', [uri, extras]); @override Future playMediaItem(MediaItem mediaItem) => - _send('playMediaItem', [mediaItem]); + send('playMediaItem', [mediaItem]); @override - Future pause() => _send('pause'); + Future pause() => send('pause'); @override Future click([MediaButton button = MediaButton.media]) => - _send('click', [button]); + send('click', [button]); @override - @mustCallSuper - Future stop() => _send('stop'); + Future stop() => send('stop'); @override Future addQueueItem(MediaItem mediaItem) => - _send('addQueueItem', [mediaItem]); + send('addQueueItem', [mediaItem]); @override Future addQueueItems(List mediaItems) => - _send('addQueueItems', [mediaItems]); + send('addQueueItems', [mediaItems]); @override Future insertQueueItem(int index, MediaItem mediaItem) => - _send('insertQueueItem', [index, mediaItem]); + send('insertQueueItem', [index, mediaItem]); @override Future updateQueue(List queue) => - _send('updateQueue', [queue]); + send('updateQueue', [queue]); @override Future updateMediaItem(MediaItem mediaItem) => - _send('updateMediaItem', [mediaItem]); + send('updateMediaItem', [mediaItem]); @override Future removeQueueItem(MediaItem mediaItem) => - _send('removeQueueItem', [mediaItem]); + send('removeQueueItem', [mediaItem]); @override Future removeQueueItemAt(int index) => - _send('removeQueueItemAt', [index]); + send('removeQueueItemAt', [index]); @override - Future skipToNext() => _send('skipToNext'); + Future skipToNext() => send('skipToNext'); @override - Future skipToPrevious() => _send('skipToPrevious'); + Future skipToPrevious() => send('skipToPrevious'); @override - Future fastForward() => _send('fastForward'); + Future fastForward() => send('fastForward'); @override - Future rewind() => _send('rewind'); + Future rewind() => send('rewind'); @override Future skipToQueueItem(int index) => - _send('skipToQueueItem', [index]); + send('skipToQueueItem', [index]); @override - Future seek(Duration position) => _send('seek', [position]); + Future seek(Duration position) => send('seek', [position]); @override Future setRating(Rating rating, Map? extras) => - _send('setRating', [rating, extras]); + send('setRating', [rating, extras]); @override Future setCaptioningEnabled(bool enabled) => - _send('setCaptioningEnabled', [enabled]); + send('setCaptioningEnabled', [enabled]); @override Future setRepeatMode(AudioServiceRepeatMode repeatMode) => - _send('setRepeatMode', [repeatMode]); + send('setRepeatMode', [repeatMode]); @override Future setShuffleMode(AudioServiceShuffleMode shuffleMode) => - _send('setShuffleMode', [shuffleMode]); + send('setShuffleMode', [shuffleMode]); @override Future seekBackward(bool begin) => - _send('seekBackward', [begin]); + send('seekBackward', [begin]); @override - Future seekForward(bool begin) => - _send('seekForward', [begin]); + Future seekForward(bool begin) => send('seekForward', [begin]); @override - Future setSpeed(double speed) => _send('setSpeed', [speed]); + Future setSpeed(double speed) => send('setSpeed', [speed]); @override Future customAction(String name, Map? arguments) => - _send('customAction', [name, arguments]); + send('customAction', [name, arguments]); @override - Future onTaskRemoved() => _send('onTaskRemoved'); + Future onTaskRemoved() => send('onTaskRemoved'); @override - Future onNotificationDeleted() => _send('onNotificationDeleted'); + Future onNotificationDeleted() => send('onNotificationDeleted'); @override Future> getChildren(String parentMediaId, [Map? options]) async => - (await _send('getChildren', [parentMediaId, options])) - as List; + send('getChildren', [parentMediaId, options]); + + final _childrenSubjects = >>{}; @override ValueStream> subscribeToChildren(String parentMediaId) { @@ -2547,39 +2712,28 @@ class _IsolateAudioHandler extends AudioHandler { receivePort.listen((dynamic options) { childrenSubject!.add(options as Map); }); - _send('subscribeToChildren', + send('subscribeToChildren', [parentMediaId, receivePort.sendPort]); } return childrenSubject; } @override - Future getMediaItem(String mediaId) async => - (await _send('getMediaItem', [mediaId])) as MediaItem?; + Future getMediaItem(String mediaId) => + send('getMediaItem', [mediaId]); @override Future> search(String query, - [Map? extras]) async => - (await _send('search', [query, extras])) as List; + [Map? extras]) => + send('search', [query, extras]); @override Future androidAdjustRemoteVolume(AndroidVolumeDirection direction) => - _send('androidAdjustRemoteVolume', [direction]); + send('androidAdjustRemoteVolume', [direction]); @override Future androidSetRemoteVolume(int volumeIndex) => - _send('androidSetRemoteVolume', [volumeIndex]); - - Future _send(String method, [List? arguments]) async { - final sendPort = IsolateNameServer.lookupPortByName(_isolatePortName); - if (sendPort == null) return null; - final receivePort = ReceivePort(); - sendPort.send(_IsolateRequest(receivePort.sendPort, method, arguments)); - final dynamic result = await receivePort.first; - print("isolate result received: $result"); - receivePort.close(); - return result; - } + send('androidSetRemoteVolume', [volumeIndex]); } /// Base class for implementations of [AudioHandler]. It provides default @@ -2646,7 +2800,6 @@ class BaseAudioHandler extends AudioHandler { /// The state changes broadcast via this stream can be listened to via the /// Flutter app's UI @override - // ignore: close_sinks final BehaviorSubject playbackState = BehaviorSubject.seeded(PlaybackState()); @@ -2667,7 +2820,6 @@ class BaseAudioHandler extends AudioHandler { /// queueTitle.add(newTitle); /// ``` @override - // ignore: close_sinks final BehaviorSubject queueTitle = BehaviorSubject.seeded(''); /// A controller for broadcasting the current media item to the app's UI, @@ -2677,7 +2829,6 @@ class BaseAudioHandler extends AudioHandler { /// mediaItem.add(item); /// ``` @override - // ignore: close_sinks final BehaviorSubject mediaItem = BehaviorSubject.seeded(null); /// A controller for broadcasting the current [AndroidPlaybackInfo] to the app's UI, @@ -2687,7 +2838,6 @@ class BaseAudioHandler extends AudioHandler { /// androidPlaybackInfo.add(newPlaybackInfo); /// ``` @override - // ignore: close_sinks final BehaviorSubject androidPlaybackInfo = BehaviorSubject(); @@ -2698,7 +2848,6 @@ class BaseAudioHandler extends AudioHandler { /// ratingStyle.add(style); /// ``` @override - // ignore: close_sinks final BehaviorSubject ratingStyle = BehaviorSubject(); /// A controller for broadcasting a custom event to the app's UI. @@ -2709,7 +2858,6 @@ class BaseAudioHandler extends AudioHandler { /// customEventSubject.add(MyCustomEvent(arg: 3)); /// ``` @override - // ignore: close_sinks final PublishSubject customEvent = PublishSubject(); /// A controller for broadcasting the current custom state to the app's UI. @@ -2719,7 +2867,6 @@ class BaseAudioHandler extends AudioHandler { /// customState.add(MyCustomState(...)); /// ``` @override - // ignore: close_sinks final BehaviorSubject customState = BehaviorSubject(); /// Constructor. Normally this is called from subclasses via `super`. @@ -3309,16 +3456,16 @@ extension _MediaButtonMessageExtension on MediaButtonMessage { /// An enum of volume direction controls on Android. class AndroidVolumeDirection { /// Lower the ringer volume. - static final lower = AndroidVolumeDirection._(-1); + static const lower = AndroidVolumeDirection._(-1); /// Keep the previous ringer volume. - static final same = AndroidVolumeDirection._(0); + static const same = AndroidVolumeDirection._(0); /// Raise the ringer volume. - static final raise = AndroidVolumeDirection._(1); + static const raise = AndroidVolumeDirection._(1); /// A map of indices to values. - static final values = { + static const values = { -1: lower, 0: same, 1: raise, @@ -3327,7 +3474,7 @@ class AndroidVolumeDirection { /// The index for this enum value. final int index; - AndroidVolumeDirection._(this.index); + const AndroidVolumeDirection._(this.index); @override String toString() => '$index'; @@ -3348,6 +3495,9 @@ enum AndroidVolumeControlType { /// Information about volume control for either local or remote playback /// depending on the subclass. abstract class AndroidPlaybackInfo { + /// Creates information about volume control. + const AndroidPlaybackInfo(); + AndroidPlaybackInfoMessage _toMessage(); @override @@ -3368,7 +3518,7 @@ class RemoteAndroidPlaybackInfo extends AndroidPlaybackInfo { final int volume; // ignore: public_member_api_docs - RemoteAndroidPlaybackInfo({ + const RemoteAndroidPlaybackInfo({ required this.volumeControlType, required this.maxVolume, required this.volume, @@ -3398,6 +3548,9 @@ class RemoteAndroidPlaybackInfo extends AndroidPlaybackInfo { /// Playback information for local volume handling. class LocalAndroidPlaybackInfo extends AndroidPlaybackInfo { + // ignore: public_member_api_docs + const LocalAndroidPlaybackInfo(); + @override LocalAndroidPlaybackInfoMessage _toMessage() => const LocalAndroidPlaybackInfoMessage(); diff --git a/audio_service/test/isolate_test.dart b/audio_service/test/isolate_test.dart new file mode 100644 index 00000000..41b48c16 --- /dev/null +++ b/audio_service/test/isolate_test.dart @@ -0,0 +1,1063 @@ +import 'dart:async'; +import 'dart:isolate'; + +import 'package:collection/collection.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rxdart/rxdart.dart'; + +import 'mock_audio_handler.dart'; + +const isolateInitMessage = Object(); + +const id = 'id'; +const query = 'query'; +final uri = Uri.file('uri'); +const map = {'key': 'value'}; +const mediaItem = MediaItem(id: 'id', title: 'title'); +const queue = [ + MediaItem(id: 'id1', title: 'title'), + MediaItem(id: 'id2', title: 'title'), + MediaItem(id: 'id3', title: 'title'), +]; +const mediaButton = MediaButton.next; +const duration = Duration(seconds: 123); +const rating = Rating.newPercentageRating(50); +const repeatMode = AudioServiceRepeatMode.all; +const shuffleMode = AudioServiceShuffleMode.all; +const customActionName = 'customActionName'; +const customActionArguments = { + 'arg1': [1, 2, 3], + 'arg2': map, +}; +const androidVolumeDirection = AndroidVolumeDirection.lower; + +final playbackStateStreamValues = [ + PlaybackState(), + PlaybackState( + processingState: AudioProcessingState.loading, + ), + PlaybackState( + processingState: AudioProcessingState.completed, + ), +]; + +final queueStreamValues = ?>[ + [mediaItem], + null, + [mediaItem, mediaItem, mediaItem], +]; + +final queueTitleStreamValues = [ + 'title_1', + 'title_2', + 'title_3', +]; + +const mediaItemStreamValues = [ + MediaItem(id: 'id_1', title: ''), + null, + MediaItem(id: 'id_3', title: ''), +]; + +const androidPlaybackInfoStreamValues = [ + LocalAndroidPlaybackInfo(), + RemoteAndroidPlaybackInfo( + volumeControlType: AndroidVolumeControlType.absolute, + maxVolume: 100, + volume: 0, + ), + RemoteAndroidPlaybackInfo( + volumeControlType: AndroidVolumeControlType.absolute, + maxVolume: 100, + volume: 50, + ), +]; + +const customEventStreamValues = [ + 1, + 2, + 3, +]; + +const customStateStreamValues = [ + 1, + 2, + 3, +]; + +class MockIsolateAudioHandler extends IsolateAudioHandler { + final bool _syncSubjects; + MockIsolateAudioHandler({bool syncSubjects = false}) + : _syncSubjects = syncSubjects; + + @override + void syncSubjects() { + if (_syncSubjects) { + super.syncSubjects(); + } + } +} + +Future main() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + final handler = MockBaseAudioHandler(); + await AudioService.hostHandler(handler); + + Isolate? isolate; + + /// Runs the isolate and waits until it returns a result. + Future runIsolate(Function(SendPort) function) async { + final receivePort = ReceivePort(); + isolate = await Isolate.spawn(function, receivePort.sendPort); + final result = await receivePort.first as T; + return result; + } + + /// Runs isolate, but doesn't wait until it returns a result. + Future runIsolateWithDeferredResult( + Function(SendPort) function) async { + final receivePort = ReceivePort(); + await Isolate.spawn(function, receivePort.sendPort); + return receivePort; + } + + setUp(() { + handler.reset(); + }); + + tearDown(() { + if (isolate != null) { + isolate!.kill(priority: Isolate.immediate); + } + }); + + void expectCall(String method, [List arguments = const [null]]) { + final actualMethod = handler.log.firstOrNull; + final actualArguments = handler.argumentsLog.firstOrNull; + expect(actualMethod, method); + expect(actualArguments, arguments); + } + + test("Subjects receive the most recent update", () async { + handler.stubPlaybackState = + BehaviorSubject.seeded(playbackStateStreamValues[0]); + final receivePort = await runIsolateWithDeferredResult(subjectsAreRecent); + final values = []; + final isolateValues = []; + handler.stubPlaybackState.listen((value) { + values.add(value); + }); + var completer = Completer(); + receivePort.listen((Object? message) { + if (message == isolateInitMessage) { + completer.complete(); + completer = Completer(); + } else { + isolateValues.add(message as PlaybackState); + if (message.processingState == + playbackStateStreamValues.last.processingState) { + completer.complete(); + } + } + }); + // wait until isolate connects and receives first message with data + await completer.future; + + // send our message + handler.stubPlaybackState.add(playbackStateStreamValues[1]); + + // and the last one is sent from the isolate + + // wait until isolate delivers all results back + await completer.future; + expectCall('playbackState'); + expect( + values.map((e) => e.processingState).toList(), + playbackStateStreamValues.map((e) => e.processingState).toList(), + ); + expect( + isolateValues.single.processingState, + playbackStateStreamValues.last.processingState, + ); + }); + + test("playbackState", () async { + handler.stubPlaybackState = + BehaviorSubject.seeded(playbackStateStreamValues[0]); + final receivePort = + await runIsolateWithDeferredResult(playbackStateSubject); + final values = []; + final isolateValues = []; + handler.stubPlaybackState.listen((value) { + values.add(value); + }); + var completer = Completer(); + receivePort.listen((Object? message) { + if (message == isolateInitMessage) { + completer.complete(); + completer = Completer(); + } else { + isolateValues.add(message as PlaybackState); + if (isolateValues.length == 3) { + completer.complete(); + } + } + }); + // wait until isolate connects and receives first message with data + await completer.future; + + // send our message + handler.stubPlaybackState.add(playbackStateStreamValues[1]); + + // and the last one is sent from the isolate + + // wait until isolate delivers all results back + await completer.future; + expectCall('playbackState'); + expect( + values.map((e) => e.toString()).toList(), + isolateValues.map((e) => e.toString()).toList(), + ); + }); + + test("queue", () async { + handler.stubQueue = BehaviorSubject.seeded(queueStreamValues[0]); + final receivePort = await runIsolateWithDeferredResult(queueSubject); + final values = ?>[]; + final isolateValues = ?>[]; + handler.stubQueue.listen((value) { + values.add(value); + }); + var completer = Completer(); + receivePort.listen((Object? message) { + if (message == isolateInitMessage) { + completer.complete(); + completer = Completer(); + } else { + isolateValues.add(message as List?); + if (isolateValues.length == 3) { + completer.complete(); + } + } + }); + // wait until isolate connects and receives first message with data + await completer.future; + + // send our message + handler.stubQueue.add(queueStreamValues[1]); + + // and the last one is sent from the isolate + + // wait until isolate delivers all results back + await completer.future; + expectCall('queue'); + expect( + values.map((e) => e.toString()).toList(), + isolateValues.map((e) => e.toString()).toList(), + ); + }); + + test("queueTitle", () async { + handler.stubQueueTitle = BehaviorSubject.seeded(queueTitleStreamValues[0]); + final receivePort = await runIsolateWithDeferredResult(queueTitleSubject); + final values = []; + final isolateValues = []; + handler.stubQueueTitle.listen((value) { + values.add(value); + }); + var completer = Completer(); + receivePort.listen((Object? message) { + if (message == isolateInitMessage) { + completer.complete(); + completer = Completer(); + } else { + isolateValues.add(message as String); + if (isolateValues.length == 3) { + completer.complete(); + } + } + }); + // wait until isolate connects and receives first message with data + await completer.future; + + // send our message + handler.stubQueueTitle.add(queueTitleStreamValues[1]); + + // and the last one is sent from the isolate + + // wait until isolate delivers all results back + await completer.future; + expectCall('queueTitle'); + expect( + values.map((e) => e.toString()).toList(), + isolateValues.map((e) => e.toString()).toList(), + ); + }); + + test("mediaItem", () async { + handler.stubMediaItem = BehaviorSubject.seeded(mediaItemStreamValues[0]); + final receivePort = await runIsolateWithDeferredResult(mediaItemSubject); + final values = []; + final isolateValues = []; + handler.stubMediaItem.listen((value) { + values.add(value); + }); + var completer = Completer(); + receivePort.listen((Object? message) { + if (message == isolateInitMessage) { + completer.complete(); + completer = Completer(); + } else { + isolateValues.add(message as MediaItem?); + if (isolateValues.length == 3) { + completer.complete(); + } + } + }); + // wait until isolate connects and receives first message with data + await completer.future; + + // send our message + handler.stubMediaItem.add(mediaItemStreamValues[1]); + + // and the last one is sent from the isolate + + // wait until isolate delivers all results back + await completer.future; + expectCall('mediaItem'); + expect( + values.map((e) => e.toString()).toList(), + isolateValues.map((e) => e.toString()).toList(), + ); + }); + + test("androidPlaybackInfo", () async { + handler.stubAndroidPlaybackInfo = + BehaviorSubject.seeded(androidPlaybackInfoStreamValues[0]); + final receivePort = + await runIsolateWithDeferredResult(androidPlaybackInfoSubject); + final values = []; + final isolateValues = []; + handler.stubAndroidPlaybackInfo.listen((value) { + values.add(value); + }); + var completer = Completer(); + receivePort.listen((Object? message) { + if (message == isolateInitMessage) { + completer.complete(); + completer = Completer(); + } else { + isolateValues.add(message as AndroidPlaybackInfo); + if (isolateValues.length == 3) { + completer.complete(); + } + } + }); + // wait until isolate connects and receives first message with data + await completer.future; + + // send our message + handler.stubAndroidPlaybackInfo.add(androidPlaybackInfoStreamValues[1]); + + // and the last one is sent from the isolate + + // wait until isolate delivers all results back + await completer.future; + expectCall('androidPlaybackInfo'); + expect( + values.map((e) => e.toString()).toList(), + isolateValues.map((e) => e.toString()).toList(), + ); + }); + + test("customEvent", () async { + handler.stubCustomEvent = PublishSubject(); + final receivePort = await runIsolateWithDeferredResult(customEventSubject); + final values = []; + final isolateValues = []; + handler.stubCustomEvent.listen((value) { + values.add(value); + }); + var completer = Completer(); + receivePort.listen((Object? message) { + if (message == isolateInitMessage) { + completer.complete(); + completer = Completer(); + } else { + isolateValues.add(message); + if (isolateValues.length == 1) { + completer.complete(); + completer = Completer(); + } else if (isolateValues.length == 3) { + completer.complete(); + } + } + }); + // wait until isolate connects and receives first message with data + await completer.future; + + // send our messages + handler.stubCustomEvent.add(customEventStreamValues[0]); + await completer.future; + handler.stubCustomEvent.add(customEventStreamValues[1]); + + // and the last one is sent from the isolate + + // wait until isolate delivers all results back + await completer.future; + expectCall('customEvent'); + expect( + values.map((e) => e.toString()).toList(), + isolateValues.map((e) => e.toString()).toList(), + ); + }); + + test("customState", () async { + handler.stubCustomState = + BehaviorSubject.seeded(customStateStreamValues[0]); + final receivePort = await runIsolateWithDeferredResult(customStateSubject); + final values = []; + final isolateValues = []; + handler.stubCustomState.listen((value) { + values.add(value); + }); + var completer = Completer(); + receivePort.listen((Object? message) { + if (message == isolateInitMessage) { + completer.complete(); + completer = Completer(); + } else { + isolateValues.add(message); + if (isolateValues.length == 3) { + completer.complete(); + } + } + }); + // wait until isolate connects and receives first message with data + await completer.future; + + // send our messages + handler.stubCustomState.add(customStateStreamValues[1]); + + // and the last one is sent from the isolate + + // wait until isolate delivers all results back + await completer.future; + expectCall('customState'); + expect( + values.map((e) => e.toString()).toList(), + isolateValues.map((e) => e.toString()).toList(), + ); + }); + + test("prepare", () async { + await runIsolate(prepare); + expectCall('prepare'); + }); + + test("prepareFromMediaId", () async { + await runIsolate(prepareFromMediaId); + expectCall('prepareFromMediaId', const [id, map]); + }); + + test("prepareFromSearch", () async { + await runIsolate(prepareFromSearch); + expectCall('prepareFromSearch', const [query, map]); + }); + + test("prepareFromUri", () async { + await runIsolate(prepareFromUri); + expectCall('prepareFromUri', [uri, map]); + }); + + test("play", () async { + await runIsolate(play); + expectCall('play'); + }); + + test("playFromMediaId", () async { + await runIsolate(playFromMediaId); + expectCall('playFromMediaId', const [id, map]); + }); + + test("playFromSearch", () async { + await runIsolate(playFromSearch); + expectCall('playFromSearch', const [query, map]); + }); + + test("playFromUri", () async { + await runIsolate(playFromUri); + expectCall('playFromUri', [uri, map]); + }); + + test("playMediaItem", () async { + await runIsolate(playMediaItem); + expectCall('playMediaItem', const [mediaItem]); + }); + + test("pause", () async { + await runIsolate(pause); + expectCall('pause'); + }); + + test("click", () async { + await runIsolate(click); + expectCall('click', const [mediaButton]); + }); + + test("stop", () async { + await runIsolate(stop); + expectCall('stop'); + }); + + test("addQueueItem", () async { + await runIsolate(addQueueItem); + expectCall('addQueueItem', const [mediaItem]); + }); + + test("addQueueItems", () async { + await runIsolate(addQueueItems); + expectCall('addQueueItems', const [queue]); + }); + + test("insertQueueItem", () async { + await runIsolate(insertQueueItem); + expectCall('insertQueueItem', const [0, mediaItem]); + }); + + test("updateQueue", () async { + await runIsolate(updateQueue); + expectCall('updateQueue', const [queue]); + }); + + test("updateMediaItem", () async { + await runIsolate(updateMediaItem); + expectCall('updateMediaItem', const [mediaItem]); + }); + + test("removeQueueItem", () async { + await runIsolate(removeQueueItem); + expectCall('removeQueueItem', const [mediaItem]); + }); + + test("removeQueueItemAt", () async { + await runIsolate(removeQueueItemAt); + expectCall('removeQueueItemAt', const [0]); + }); + + test("skipToNext", () async { + await runIsolate(skipToNext); + expectCall('skipToNext'); + }); + + test("skipToPrevious", () async { + await runIsolate(skipToPrevious); + expectCall('skipToPrevious'); + }); + + test("fastForward", () async { + await runIsolate(fastForward); + expectCall('fastForward'); + }); + + test("rewind", () async { + await runIsolate(rewind); + expectCall('rewind'); + }); + + test("skipToQueueItem", () async { + await runIsolate(skipToQueueItem); + expectCall('skipToQueueItem', const [0]); + }); + + test("seek", () async { + await runIsolate(seek); + expectCall('seek', const [duration]); + }); + + test("setRating", () async { + await runIsolate(setRating); + expectCall('setRating', const [rating, map]); + }); + + test("setCaptioningEnabled", () async { + await runIsolate(setCaptioningEnabled); + expectCall('setCaptioningEnabled', const [false]); + }); + + test("setRepeatMode", () async { + await runIsolate(setRepeatMode); + expectCall('setRepeatMode', const [repeatMode]); + }); + + test("setShuffleMode", () async { + await runIsolate(setShuffleMode); + expectCall('setShuffleMode', const [shuffleMode]); + }); + + test("seekBackward", () async { + await runIsolate(seekBackward); + expectCall('seekBackward', const [false]); + }); + + test("seekForward", () async { + await runIsolate(seekForward); + expectCall('seekForward', const [false]); + }); + + test("setSpeed", () async { + await runIsolate(setSpeed); + expectCall('setSpeed', const [0.1]); + }); + + test("customAction", () async { + final expectedResult = 'custom_action_result'; + handler.stubCustomAction = expectedResult; + final result = await runIsolate(customAction); + expectCall('customAction', const [customActionName, customActionArguments]); + expect(result, expectedResult); + }); + + test("onTaskRemoved", () async { + await runIsolate(onTaskRemoved); + expectCall('onTaskRemoved'); + }); + + test("onNotificationDeleted", () async { + await runIsolate(onNotificationDeleted); + expectCall('onNotificationDeleted'); + }); + + test("getChildren", () async { + final expectedResult = queue; + handler.stubGetChildren = expectedResult; + final result = await runIsolate(getChildren); + expectCall('getChildren', const [id, map]); + expect(result, expectedResult); + }); + + test("subscribeToChildren", () async { + final expectedResult = >[ + {'key1': 'value1'}, + {'key2': 'value2'}, + {'key3': 'value3'} + ]; + handler.stubSubscribeToChildren = BehaviorSubject(); + final receivePort = await runIsolateWithDeferredResult(subscribeToChildren); + final result = >[]; + var completer = Completer(); + receivePort.listen((Object? message) { + if (result.isEmpty && message == isolateInitMessage) { + completer.complete(); + completer = Completer(); + } else { + result.add(message as Map); + if (result.length == 3) { + completer.complete(); + } + } + }); + // wait until isolate connects + await completer.future; + + for (final options in expectedResult) { + handler.stubSubscribeToChildren.add(options); + } + // wait until isolate delivers all results + await completer.future; + expectCall('subscribeToChildren', const [id]); + expect(result, expectedResult); + }); + + test("getMediaItem", () async { + final expectedResult = mediaItem; + handler.stubGetMediaItem = expectedResult; + final result = await runIsolate(getMediaItem); + expectCall('getMediaItem', const [id]); + expect(result, expectedResult); + }); + + test("search", () async { + final expectedResult = queue; + handler.stubSearch = expectedResult; + final result = await runIsolate(search); + expectCall('search', const [query, map]); + expect(result, expectedResult); + }); + + test("androidAdjustRemoteVolume", () async { + await runIsolate(androidAdjustRemoteVolume); + expectCall('androidAdjustRemoteVolume', [androidVolumeDirection]); + }); + + test("androidSetRemoteVolume", () async { + await runIsolate(androidSetRemoteVolume); + expectCall('androidSetRemoteVolume', [0]); + }); +} + +void subjectsAreRecent(SendPort port) async { + final handler = MockIsolateAudioHandler(); + final playbackState = handler.playbackState; + await handler.syncSubject(playbackState, 'playbackState'); + port.send(isolateInitMessage); + playbackState.listen((value) { + port.send(value); + }); + playbackState.add(playbackStateStreamValues[2]); +} + +void playbackStateSubject(SendPort port) async { + final handler = MockIsolateAudioHandler(); + final playbackState = handler.playbackState; + await handler.syncSubject(playbackState, 'playbackState'); + port.send(isolateInitMessage); + var updates = 0; + playbackState.listen((value) { + port.send(value); + updates += 1; + if (updates == 2) { + playbackState.add(playbackStateStreamValues[2]); + } + }); +} + +void queueSubject(SendPort port) async { + final handler = MockIsolateAudioHandler(); + final queue = handler.queue; + await handler.syncSubject(queue, 'queue'); + port.send(isolateInitMessage); + var updates = 0; + queue.listen((value) { + port.send(value); + updates += 1; + if (updates == 2) { + queue.add(queueStreamValues[2]); + } + }); +} + +void queueTitleSubject(SendPort port) async { + final handler = MockIsolateAudioHandler(); + final queueTitle = handler.queueTitle; + await handler.syncSubject(queueTitle, 'queueTitle'); + port.send(isolateInitMessage); + var updates = 0; + queueTitle.listen((value) { + port.send(value); + updates += 1; + if (updates == 2) { + queueTitle.add(queueTitleStreamValues[2]); + } + }); +} + +void mediaItemSubject(SendPort port) async { + final handler = MockIsolateAudioHandler(); + final mediaItem = handler.mediaItem; + await handler.syncSubject(mediaItem, 'mediaItem'); + port.send(isolateInitMessage); + var updates = 0; + mediaItem.listen((value) { + port.send(value); + updates += 1; + if (updates == 2) { + mediaItem.add(mediaItemStreamValues[2]); + } + }); +} + +void androidPlaybackInfoSubject(SendPort port) async { + final handler = MockIsolateAudioHandler(); + final androidPlaybackInfo = handler.androidPlaybackInfo; + await handler.syncSubject(androidPlaybackInfo, 'androidPlaybackInfo'); + port.send(isolateInitMessage); + var updates = 0; + androidPlaybackInfo.listen((value) { + port.send(value); + updates += 1; + if (updates == 2) { + androidPlaybackInfo.add(androidPlaybackInfoStreamValues[2]); + } + }); +} + +void customEventSubject(SendPort port) async { + final handler = MockIsolateAudioHandler(); + final customEvent = handler.customEvent; + await handler.syncSubject(customEvent, 'customEvent'); + port.send(isolateInitMessage); + var updates = 0; + customEvent.listen((dynamic value) { + port.send(value); + updates += 1; + if (updates == 2) { + customEvent.add(customEventStreamValues[2]); + } + }); +} + +void customStateSubject(SendPort port) async { + final handler = MockIsolateAudioHandler(); + final customState = handler.customState; + await handler.syncSubject(customState, 'customState'); + port.send(isolateInitMessage); + var updates = 0; + customState.listen((dynamic value) { + port.send(value); + updates += 1; + if (updates == 2) { + customState.add(customStateStreamValues[2]); + } + }); +} + +void prepare(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.prepare(); + port.send(isolateInitMessage); +} + +void prepareFromMediaId(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.prepareFromMediaId(id, map); + port.send(isolateInitMessage); +} + +void prepareFromSearch(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.prepareFromSearch(query, map); + port.send(isolateInitMessage); +} + +void prepareFromUri(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.prepareFromUri(uri, map); + port.send(isolateInitMessage); +} + +void play(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.play(); + port.send(isolateInitMessage); +} + +void playFromMediaId(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.playFromMediaId(id, map); + port.send(isolateInitMessage); +} + +void playFromSearch(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.playFromSearch(query, map); + port.send(isolateInitMessage); +} + +void playFromUri(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.playFromUri(uri, map); + port.send(isolateInitMessage); +} + +void playMediaItem(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.playMediaItem(mediaItem); + port.send(isolateInitMessage); +} + +void pause(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.pause(); + port.send(isolateInitMessage); +} + +void click(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.click(MediaButton.next); + port.send(isolateInitMessage); +} + +void stop(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.stop(); + port.send(isolateInitMessage); +} + +void addQueueItem(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.addQueueItem(mediaItem); + port.send(isolateInitMessage); +} + +void addQueueItems(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.addQueueItems(queue); + port.send(isolateInitMessage); +} + +void insertQueueItem(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.insertQueueItem(0, mediaItem); + port.send(isolateInitMessage); +} + +void updateQueue(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.updateQueue(queue); + port.send(isolateInitMessage); +} + +void updateMediaItem(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.updateMediaItem(mediaItem); + port.send(isolateInitMessage); +} + +void removeQueueItem(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.removeQueueItem(mediaItem); + port.send(isolateInitMessage); +} + +void removeQueueItemAt(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.removeQueueItemAt(0); + port.send(isolateInitMessage); +} + +void skipToNext(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.skipToNext(); + port.send(isolateInitMessage); +} + +void skipToPrevious(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.skipToPrevious(); + port.send(isolateInitMessage); +} + +void fastForward(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.fastForward(); + port.send(isolateInitMessage); +} + +void rewind(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.rewind(); + port.send(isolateInitMessage); +} + +void skipToQueueItem(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.skipToQueueItem(0); + port.send(isolateInitMessage); +} + +void seek(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.seek(duration); + port.send(isolateInitMessage); +} + +void setRating(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.setRating(rating, map); + port.send(isolateInitMessage); +} + +void setCaptioningEnabled(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.setCaptioningEnabled(false); + port.send(isolateInitMessage); +} + +void setRepeatMode(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.setRepeatMode(repeatMode); + port.send(isolateInitMessage); +} + +void setShuffleMode(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.setShuffleMode(shuffleMode); + port.send(isolateInitMessage); +} + +void seekBackward(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.seekBackward(false); + port.send(isolateInitMessage); +} + +void seekForward(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.seekForward(false); + port.send(isolateInitMessage); +} + +void setSpeed(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.setSpeed(0.1); + port.send(isolateInitMessage); +} + +void customAction(SendPort port) async { + final handler = MockIsolateAudioHandler(); + port.send(await handler.customAction( + customActionName, + customActionArguments, + )); +} + +void onTaskRemoved(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.onTaskRemoved(); + port.send(isolateInitMessage); +} + +void onNotificationDeleted(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.onNotificationDeleted(); + port.send(isolateInitMessage); +} + +void getChildren(SendPort port) async { + final handler = MockIsolateAudioHandler(); + port.send(await handler.getChildren(id, map)); +} + +void subscribeToChildren(SendPort port) async { + final handler = MockIsolateAudioHandler(); + final result = handler.subscribeToChildren(id); + port.send(isolateInitMessage); + result.listen((event) { + port.send(event); + }); +} + +void getMediaItem(SendPort port) async { + final handler = MockIsolateAudioHandler(); + port.send(await handler.getMediaItem(id)); +} + +void search(SendPort port) async { + final handler = MockIsolateAudioHandler(); + port.send(await handler.search(query, map)); +} + +void androidAdjustRemoteVolume(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.androidAdjustRemoteVolume(androidVolumeDirection); + port.send(isolateInitMessage); +} + +void androidSetRemoteVolume(SendPort port) async { + final handler = MockIsolateAudioHandler(); + await handler.androidSetRemoteVolume(0); + port.send(isolateInitMessage); +} diff --git a/audio_service/test/mock_audio_handler.dart b/audio_service/test/mock_audio_handler.dart new file mode 100644 index 00000000..2a859008 --- /dev/null +++ b/audio_service/test/mock_audio_handler.dart @@ -0,0 +1,421 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:rxdart/rxdart.dart'; + +/// A stub wrapper that ensures that the [value] was called +/// only once and only once during the tests, until the new value is set. +/// +/// Automatically disposes stream controllers and subjects. +class _Stub { + int _count = 0; + + T? _value; + T get value { + assert(_count != 0, "Stub was not set"); + assert( + _count == 1, + "Stub value was used more than once" + "Update the stub value if you want to use it once more", + ); + _count += 1; + return _value!; + } + + T get exposeValue { + assert(_count != 0, "Stub was not set"); + return _value!; + } + + set value(T newValue) { + _value = newValue; + _count = 1; + } + + void reset() { + _count = 0; + _value = null; + final value = _value; + if (value is StreamController) { + value.close(); + } + } +} + +class MockBaseAudioHandler implements BaseAudioHandler { + final List log = []; + final List argumentsLog = []; + + final _Stub> _stubPlaybackState = _Stub(); + BehaviorSubject get stubPlaybackState => + _stubPlaybackState.exposeValue; + set stubPlaybackState(BehaviorSubject value) { + _stubPlaybackState.value = value; + } + + final _Stub?>> _stubQueue = _Stub(); + BehaviorSubject?> get stubQueue => _stubQueue.exposeValue; + set stubQueue(BehaviorSubject?> value) { + _stubQueue.value = value; + } + + final _Stub> _stubQueueTitle = _Stub(); + BehaviorSubject get stubQueueTitle => _stubQueueTitle.exposeValue; + set stubQueueTitle(BehaviorSubject value) { + _stubQueueTitle.value = value; + } + + final _Stub> _stubMediaItem = _Stub(); + BehaviorSubject get stubMediaItem => _stubMediaItem.exposeValue; + set stubMediaItem(BehaviorSubject value) { + _stubMediaItem.value = value; + } + + final _Stub> _stubRatingStyle = _Stub(); + BehaviorSubject get stubRatingStyle => + _stubRatingStyle.exposeValue; + set stubRatingStyle(BehaviorSubject value) { + _stubRatingStyle.value = value; + } + + final _Stub> _stubAndroidPlaybackInfo = + _Stub(); + BehaviorSubject get stubAndroidPlaybackInfo => + _stubAndroidPlaybackInfo.exposeValue; + set stubAndroidPlaybackInfo(BehaviorSubject value) { + _stubAndroidPlaybackInfo.value = value; + } + + final _Stub> _stubCustomEvent = _Stub(); + PublishSubject get stubCustomEvent => _stubCustomEvent.exposeValue; + set stubCustomEvent(PublishSubject value) { + _stubCustomEvent.value = value; + } + + final _Stub> _stubCustomState = _Stub(); + BehaviorSubject get stubCustomState => _stubCustomState.exposeValue; + set stubCustomState(BehaviorSubject value) { + _stubCustomState.value = value; + } + + final _Stub _stubCustomAction = _Stub(); + Object? get stubCustomAction => _stubCustomAction.exposeValue; + set stubCustomAction(Object? value) { + _stubCustomAction.value = value; + } + + final _Stub> _stubGetChildren = _Stub(); + List get stubGetChildren => _stubGetChildren.exposeValue; + set stubGetChildren(List value) { + _stubGetChildren.value = value; + } + + final _Stub>> _stubSubscribeToChildren = + _Stub(); + BehaviorSubject> get stubSubscribeToChildren => + _stubSubscribeToChildren.exposeValue; + set stubSubscribeToChildren(BehaviorSubject> value) { + _stubSubscribeToChildren.value = value; + } + + final _Stub _stubGetMediaItem = _Stub(); + MediaItem? get stubGetMediaItem => _stubGetMediaItem.exposeValue; + set stubGetMediaItem(MediaItem? value) { + _stubGetMediaItem.value = value; + } + + final _Stub> _stubSearch = _Stub(); + List get stubSearch => _stubSearch.exposeValue; + set stubSearch(List value) { + _stubSearch.value = value; + } + + void reset() { + _stubPlaybackState.reset(); + _stubQueue.reset(); + _stubQueueTitle.reset(); + _stubMediaItem.reset(); + _stubRatingStyle.reset(); + _stubAndroidPlaybackInfo.reset(); + _stubCustomEvent.reset(); + _stubCustomState.reset(); + _stubCustomAction.reset(); + _stubGetChildren.reset(); + _stubSubscribeToChildren.reset(); + _stubGetMediaItem.reset(); + _stubSearch.reset(); + log.clear(); + argumentsLog.clear(); + } + + void _log(String method, [List arguments = const [null]]) { + log.add(method); + argumentsLog.add(arguments); + } + + @override + BehaviorSubject get playbackState { + _log('playbackState'); + return _stubPlaybackState.value; + } + + @override + BehaviorSubject?> get queue { + _log('queue'); + return _stubQueue.value; + } + + @override + BehaviorSubject get queueTitle { + _log('queueTitle'); + return _stubQueueTitle.value; + } + + @override + BehaviorSubject get mediaItem { + _log('mediaItem'); + return _stubMediaItem.value; + } + + @override + BehaviorSubject get ratingStyle { + _log('ratingStyle'); + return _stubRatingStyle.value; + } + + @override + BehaviorSubject get androidPlaybackInfo { + _log('androidPlaybackInfo'); + return _stubAndroidPlaybackInfo.value; + } + + @override + PublishSubject get customEvent { + _log('customEvent'); + return _stubCustomEvent.value; + } + + @override + BehaviorSubject get customState { + _log('customState'); + return _stubCustomState.value; + } + + @override + Future prepare() async { + _log('prepare'); + } + + @override + Future prepareFromMediaId(String mediaId, + [Map? extras]) async { + _log('prepareFromMediaId', [mediaId, extras]); + } + + @override + Future prepareFromSearch(String query, + [Map? extras]) async { + _log('prepareFromSearch', [query, extras]); + } + + @override + Future prepareFromUri(Uri uri, [Map? extras]) async { + _log('prepareFromUri', [uri, extras]); + } + + @override + Future play() async { + _log('play'); + } + + @override + Future playFromMediaId(String mediaId, + [Map? extras]) async { + _log('playFromMediaId', [mediaId, extras]); + } + + @override + Future playFromSearch(String query, + [Map? extras]) async { + _log('playFromSearch', [query, extras]); + } + + @override + Future playFromUri(Uri uri, [Map? extras]) async { + _log('playFromUri', [uri, extras]); + } + + @override + Future playMediaItem(MediaItem mediaItem) async { + _log('playMediaItem', [mediaItem]); + } + + @override + Future pause() async { + _log('pause'); + } + + @override + Future click([MediaButton button = MediaButton.media]) async { + _log('click', [button]); + } + + @override + Future stop() async { + _log('stop'); + } + + @override + Future addQueueItem(MediaItem mediaItem) async { + _log('addQueueItem', [mediaItem]); + } + + @override + Future addQueueItems(List mediaItems) async { + _log('addQueueItems', [mediaItems]); + } + + @override + Future insertQueueItem(int index, MediaItem mediaItem) async { + _log('insertQueueItem', [index, mediaItem]); + } + + @override + Future updateQueue(List queue) async { + _log('updateQueue', [queue]); + } + + @override + Future updateMediaItem(MediaItem mediaItem) async { + _log('updateMediaItem', [mediaItem]); + } + + @override + Future removeQueueItem(MediaItem mediaItem) async { + _log('removeQueueItem', [mediaItem]); + } + + @override + Future removeQueueItemAt(int index) async { + _log('removeQueueItemAt', [index]); + } + + @override + Future skipToNext() async { + _log('skipToNext'); + } + + @override + Future skipToPrevious() async { + _log('skipToPrevious'); + } + + @override + Future fastForward() async { + _log('fastForward'); + } + + @override + Future rewind() async { + _log('rewind'); + } + + @override + Future skipToQueueItem(int index) async { + _log('skipToQueueItem', [index]); + } + + @override + Future seek(Duration position) async { + _log('seek', [position]); + } + + @override + Future setRating(Rating rating, Map? extras) async { + _log('setRating', [rating, extras]); + } + + @override + Future setCaptioningEnabled(bool enabled) async { + _log('setCaptioningEnabled', [enabled]); + } + + @override + Future setRepeatMode(AudioServiceRepeatMode repeatMode) async { + _log('setRepeatMode', [repeatMode]); + } + + @override + Future setShuffleMode(AudioServiceShuffleMode shuffleMode) async { + _log('setShuffleMode', [shuffleMode]); + } + + @override + Future seekBackward(bool begin) async { + _log('seekBackward', [begin]); + } + + @override + Future seekForward(bool begin) async { + _log('seekForward', [begin]); + } + + @override + Future setSpeed(double speed) async { + _log('setSpeed', [speed]); + } + + @override + Future customAction( + String name, Map? extras) async { + _log('customAction', [name, extras]); + return _stubCustomAction.value; + } + + @override + Future onTaskRemoved() async { + _log('onTaskRemoved'); + } + + @override + Future onNotificationDeleted() async { + _log('onNotificationDeleted'); + } + + @override + Future> getChildren(String parentMediaId, + [Map? options]) async { + _log('getChildren', [parentMediaId, options]); + return _stubGetChildren.value; + } + + @override + ValueStream> subscribeToChildren(String parentMediaId) { + _log('subscribeToChildren', [parentMediaId]); + return _stubSubscribeToChildren.value; + } + + @override + Future getMediaItem(String mediaId) async { + _log('getMediaItem', [mediaId]); + return _stubGetMediaItem.value; + } + + @override + Future> search(String query, + [Map? extras]) async { + _log('search', [query, extras]); + return _stubSearch.value; + } + + @override + Future androidSetRemoteVolume(int volumeIndex) async { + _log('androidSetRemoteVolume', [volumeIndex]); + } + + @override + Future androidAdjustRemoteVolume( + AndroidVolumeDirection direction) async { + _log('androidAdjustRemoteVolume', [direction]); + } +} From 5da34d1fc03e2bd628fa155cf708459063d4eb36 Mon Sep 17 00:00:00 2001 From: nt4f04uNd Date: Mon, 14 Jun 2021 20:51:34 +0300 Subject: [PATCH 02/16] fix docs, add throw tests --- audio_service/lib/audio_service.dart | 39 +- audio_service/test/isolate_test.dart | 1002 +++++++++++++------------- 2 files changed, 543 insertions(+), 498 deletions(-) diff --git a/audio_service/lib/audio_service.dart b/audio_service/lib/audio_service.dart index 1845023e..bbf466e0 100644 --- a/audio_service/lib/audio_service.dart +++ b/audio_service/lib/audio_service.dart @@ -816,9 +816,9 @@ class AudioService { /// (e.g. if the user clicks on the play button in the media notification /// while your app is not running and your app needs to be woken up). /// - /// Calling this method not from the main isolates may have unintended results, - /// for example the isolate may become unreachable, because it was destroyed, - /// or its engine was destroyed. + /// Calling this method not from the main isolate may have unintended consequences, + /// for example the isolate may become unreachable, because of being destroyed, + /// or its engine being destroyed. /// /// This method automatically hosts audio handler. so other isolates can /// reach out to the handler with [connectFromIsolate]. For more details @@ -856,10 +856,10 @@ class AudioService { } /// Port to host the handler on with [hostHandler]. - static late ReceivePort _hostReceivePort; + static ReceivePort? _hostReceivePort; static const _hostIsolatePortName = 'com.ryanheise.audioservice.port'; - /// Connect to the [IsolateAudioHandler] from another isolate. + /// Connect to the [udioHandler] from another isolate. /// /// Prior this, some [AudioHandler] must be hosted by calling [init] or /// [hostHandler] @@ -872,18 +872,27 @@ class AudioService { /// Must be called from the main isolate, other isolates can connect /// to the handler via [connectFromIsolate]. /// - /// Calling this method not from the main isolates may have unintended results, - /// for example the isolate may become unreachable, because it was destroyed, - /// or its engine was destroyed. After that all the handlers from isolates - /// will stop receiving updates and calls to their methods will timeout. + /// Calling this method not from the main isolate may have unintended consequences, + /// for example the isolate may become unreachable, because of being destroyed, + /// or its engine being destroyed. As a result of that, all the handlers from connected + /// isolates will stop receiving updates and calls to their methods will timeout. /// - /// During the time the isolate the handler was hosted from is alive, - /// any calls to this method from any isolate will throw. A new handler - /// can be registered once more only when this isolate dies. + /// During the time the host isolate is alive, any calls to this method from any + /// isolate will throw. A new handle can be registered once again only when + /// the host isolate dies. static Future hostHandler(AudioHandler handler) async { if (!kIsWeb) { final sendPort = IsolateNameServer.lookupPortByName(_hostIsolatePortName); - assert(sendPort == null, "Some isolate has already hosted its handler"); + + if (handler is IsolateAudioHandler) { + throw ArgumentError( + "Registering IsolateAudioHandler is not allowed, as this will lead " + "to an infinite loop when its methods are called", + ); + } + if (sendPort != null || _hostReceivePort != null) { + throw StateError("Some isolate has already hosted a handler"); + } void syncStream(Stream stream, IsolateRequest request) { final sendPort = request.arguments![0] as SendPort; @@ -908,7 +917,7 @@ class AudioService { } _hostReceivePort = ReceivePort(); - _hostReceivePort.listen((dynamic event) async { + _hostReceivePort!.listen((dynamic event) async { final request = event as IsolateRequest; switch (request.method) { case 'playbackState': @@ -1149,7 +1158,7 @@ class AudioService { } }); IsolateNameServer.registerPortWithName( - _hostReceivePort.sendPort, _hostIsolatePortName); + _hostReceivePort!.sendPort, _hostIsolatePortName); } } diff --git a/audio_service/test/isolate_test.dart b/audio_service/test/isolate_test.dart index 41b48c16..9ebc8216 100644 --- a/audio_service/test/isolate_test.dart +++ b/audio_service/test/isolate_test.dart @@ -133,568 +133,604 @@ Future main() async { } }); - void expectCall(String method, [List arguments = const [null]]) { - final actualMethod = handler.log.firstOrNull; - final actualArguments = handler.argumentsLog.firstOrNull; - expect(actualMethod, method); - expect(actualArguments, arguments); - } + group("Connection setup ▮", () { + test("throws when attempting to host IsolateAudioHandler", () { + expect( + () => AudioService.hostHandler(MockIsolateAudioHandler()), + throwsA( + isA().having( + (e) => e.message, + 'message', + "Registering IsolateAudioHandler is not allowed, as this will lead " + "to an infinite loop when its methods are called", + ), + ), + ); + }); - test("Subjects receive the most recent update", () async { - handler.stubPlaybackState = - BehaviorSubject.seeded(playbackStateStreamValues[0]); - final receivePort = await runIsolateWithDeferredResult(subjectsAreRecent); - final values = []; - final isolateValues = []; - handler.stubPlaybackState.listen((value) { - values.add(value); + test("throws when attempting to host more than once", () async { + expect( + () => AudioService.hostHandler(BaseAudioHandler()), + throwsA( + isA().having( + (e) => e.message, + 'message', + "Some isolate has already hosted a handler", + ), + ), + ); }); - var completer = Completer(); - receivePort.listen((Object? message) { - if (message == isolateInitMessage) { - completer.complete(); - completer = Completer(); - } else { - isolateValues.add(message as PlaybackState); - if (message.processingState == - playbackStateStreamValues.last.processingState) { + }); + + group("IsolateAudioHandler ▮", () { + void expectCall(String method, [List arguments = const [null]]) { + final actualMethod = handler.log.firstOrNull; + final actualArguments = handler.argumentsLog.firstOrNull; + expect(actualMethod, method); + expect(actualArguments, arguments); + } + + test("subjects receive the most recent update", () async { + handler.stubPlaybackState = + BehaviorSubject.seeded(playbackStateStreamValues[0]); + final receivePort = await runIsolateWithDeferredResult(subjectsAreRecent); + final values = []; + final isolateValues = []; + handler.stubPlaybackState.listen((value) { + values.add(value); + }); + var completer = Completer(); + receivePort.listen((Object? message) { + if (message == isolateInitMessage) { completer.complete(); + completer = Completer(); + } else { + isolateValues.add(message as PlaybackState); + if (message.processingState == + playbackStateStreamValues.last.processingState) { + completer.complete(); + } } - } + }); + // wait until isolate connects + await completer.future; + + // send our message + handler.stubPlaybackState.add(playbackStateStreamValues[1]); + + // and the last one is sent from the isolate + + // wait until isolate delivers all results back + await completer.future; + expectCall('playbackState'); + expect( + values.map((e) => e.processingState).toList(), + playbackStateStreamValues.map((e) => e.processingState).toList(), + ); + expect( + isolateValues.single.processingState, + playbackStateStreamValues.last.processingState, + ); }); - // wait until isolate connects and receives first message with data - await completer.future; - - // send our message - handler.stubPlaybackState.add(playbackStateStreamValues[1]); - - // and the last one is sent from the isolate - - // wait until isolate delivers all results back - await completer.future; - expectCall('playbackState'); - expect( - values.map((e) => e.processingState).toList(), - playbackStateStreamValues.map((e) => e.processingState).toList(), - ); - expect( - isolateValues.single.processingState, - playbackStateStreamValues.last.processingState, - ); - }); - test("playbackState", () async { - handler.stubPlaybackState = - BehaviorSubject.seeded(playbackStateStreamValues[0]); - final receivePort = - await runIsolateWithDeferredResult(playbackStateSubject); - final values = []; - final isolateValues = []; - handler.stubPlaybackState.listen((value) { - values.add(value); - }); - var completer = Completer(); - receivePort.listen((Object? message) { - if (message == isolateInitMessage) { - completer.complete(); - completer = Completer(); - } else { - isolateValues.add(message as PlaybackState); - if (isolateValues.length == 3) { + test("playbackState", () async { + handler.stubPlaybackState = + BehaviorSubject.seeded(playbackStateStreamValues[0]); + final receivePort = + await runIsolateWithDeferredResult(playbackStateSubject); + final values = []; + final isolateValues = []; + handler.stubPlaybackState.listen((value) { + values.add(value); + }); + var completer = Completer(); + receivePort.listen((Object? message) { + if (message == isolateInitMessage) { completer.complete(); + completer = Completer(); + } else { + isolateValues.add(message as PlaybackState); + if (isolateValues.length == 3) { + completer.complete(); + } } - } + }); + // wait until isolate connects + await completer.future; + + // send our message + handler.stubPlaybackState.add(playbackStateStreamValues[1]); + + // and the last one is sent from the isolate + + // wait until isolate delivers all results back + await completer.future; + expectCall('playbackState'); + expect( + values.map((e) => e.toString()).toList(), + isolateValues.map((e) => e.toString()).toList(), + ); }); - // wait until isolate connects and receives first message with data - await completer.future; - - // send our message - handler.stubPlaybackState.add(playbackStateStreamValues[1]); - - // and the last one is sent from the isolate - // wait until isolate delivers all results back - await completer.future; - expectCall('playbackState'); - expect( - values.map((e) => e.toString()).toList(), - isolateValues.map((e) => e.toString()).toList(), - ); - }); - - test("queue", () async { - handler.stubQueue = BehaviorSubject.seeded(queueStreamValues[0]); - final receivePort = await runIsolateWithDeferredResult(queueSubject); - final values = ?>[]; - final isolateValues = ?>[]; - handler.stubQueue.listen((value) { - values.add(value); - }); - var completer = Completer(); - receivePort.listen((Object? message) { - if (message == isolateInitMessage) { - completer.complete(); - completer = Completer(); - } else { - isolateValues.add(message as List?); - if (isolateValues.length == 3) { + test("queue", () async { + handler.stubQueue = BehaviorSubject.seeded(queueStreamValues[0]); + final receivePort = await runIsolateWithDeferredResult(queueSubject); + final values = ?>[]; + final isolateValues = ?>[]; + handler.stubQueue.listen((value) { + values.add(value); + }); + var completer = Completer(); + receivePort.listen((Object? message) { + if (message == isolateInitMessage) { completer.complete(); + completer = Completer(); + } else { + isolateValues.add(message as List?); + if (isolateValues.length == 3) { + completer.complete(); + } } - } + }); + // wait until isolate connects + await completer.future; + + // send our message + handler.stubQueue.add(queueStreamValues[1]); + + // and the last one is sent from the isolate + + // wait until isolate delivers all results back + await completer.future; + expectCall('queue'); + expect( + values.map((e) => e.toString()).toList(), + isolateValues.map((e) => e.toString()).toList(), + ); }); - // wait until isolate connects and receives first message with data - await completer.future; - - // send our message - handler.stubQueue.add(queueStreamValues[1]); - - // and the last one is sent from the isolate - - // wait until isolate delivers all results back - await completer.future; - expectCall('queue'); - expect( - values.map((e) => e.toString()).toList(), - isolateValues.map((e) => e.toString()).toList(), - ); - }); - test("queueTitle", () async { - handler.stubQueueTitle = BehaviorSubject.seeded(queueTitleStreamValues[0]); - final receivePort = await runIsolateWithDeferredResult(queueTitleSubject); - final values = []; - final isolateValues = []; - handler.stubQueueTitle.listen((value) { - values.add(value); - }); - var completer = Completer(); - receivePort.listen((Object? message) { - if (message == isolateInitMessage) { - completer.complete(); - completer = Completer(); - } else { - isolateValues.add(message as String); - if (isolateValues.length == 3) { + test("queueTitle", () async { + handler.stubQueueTitle = + BehaviorSubject.seeded(queueTitleStreamValues[0]); + final receivePort = await runIsolateWithDeferredResult(queueTitleSubject); + final values = []; + final isolateValues = []; + handler.stubQueueTitle.listen((value) { + values.add(value); + }); + var completer = Completer(); + receivePort.listen((Object? message) { + if (message == isolateInitMessage) { completer.complete(); + completer = Completer(); + } else { + isolateValues.add(message as String); + if (isolateValues.length == 3) { + completer.complete(); + } } - } + }); + // wait until isolate connects + await completer.future; + + // send our message + handler.stubQueueTitle.add(queueTitleStreamValues[1]); + + // and the last one is sent from the isolate + + // wait until isolate delivers all results back + await completer.future; + expectCall('queueTitle'); + expect( + values.map((e) => e.toString()).toList(), + isolateValues.map((e) => e.toString()).toList(), + ); }); - // wait until isolate connects and receives first message with data - await completer.future; - - // send our message - handler.stubQueueTitle.add(queueTitleStreamValues[1]); - // and the last one is sent from the isolate - - // wait until isolate delivers all results back - await completer.future; - expectCall('queueTitle'); - expect( - values.map((e) => e.toString()).toList(), - isolateValues.map((e) => e.toString()).toList(), - ); - }); - - test("mediaItem", () async { - handler.stubMediaItem = BehaviorSubject.seeded(mediaItemStreamValues[0]); - final receivePort = await runIsolateWithDeferredResult(mediaItemSubject); - final values = []; - final isolateValues = []; - handler.stubMediaItem.listen((value) { - values.add(value); - }); - var completer = Completer(); - receivePort.listen((Object? message) { - if (message == isolateInitMessage) { - completer.complete(); - completer = Completer(); - } else { - isolateValues.add(message as MediaItem?); - if (isolateValues.length == 3) { + test("mediaItem", () async { + handler.stubMediaItem = BehaviorSubject.seeded(mediaItemStreamValues[0]); + final receivePort = await runIsolateWithDeferredResult(mediaItemSubject); + final values = []; + final isolateValues = []; + handler.stubMediaItem.listen((value) { + values.add(value); + }); + var completer = Completer(); + receivePort.listen((Object? message) { + if (message == isolateInitMessage) { completer.complete(); + completer = Completer(); + } else { + isolateValues.add(message as MediaItem?); + if (isolateValues.length == 3) { + completer.complete(); + } } - } + }); + // wait until isolate connects + await completer.future; + + // send our message + handler.stubMediaItem.add(mediaItemStreamValues[1]); + + // and the last one is sent from the isolate + + // wait until isolate delivers all results back + await completer.future; + expectCall('mediaItem'); + expect( + values.map((e) => e.toString()).toList(), + isolateValues.map((e) => e.toString()).toList(), + ); }); - // wait until isolate connects and receives first message with data - await completer.future; - - // send our message - handler.stubMediaItem.add(mediaItemStreamValues[1]); - - // and the last one is sent from the isolate - // wait until isolate delivers all results back - await completer.future; - expectCall('mediaItem'); - expect( - values.map((e) => e.toString()).toList(), - isolateValues.map((e) => e.toString()).toList(), - ); - }); - - test("androidPlaybackInfo", () async { - handler.stubAndroidPlaybackInfo = - BehaviorSubject.seeded(androidPlaybackInfoStreamValues[0]); - final receivePort = - await runIsolateWithDeferredResult(androidPlaybackInfoSubject); - final values = []; - final isolateValues = []; - handler.stubAndroidPlaybackInfo.listen((value) { - values.add(value); - }); - var completer = Completer(); - receivePort.listen((Object? message) { - if (message == isolateInitMessage) { - completer.complete(); - completer = Completer(); - } else { - isolateValues.add(message as AndroidPlaybackInfo); - if (isolateValues.length == 3) { + test("androidPlaybackInfo", () async { + handler.stubAndroidPlaybackInfo = + BehaviorSubject.seeded(androidPlaybackInfoStreamValues[0]); + final receivePort = + await runIsolateWithDeferredResult(androidPlaybackInfoSubject); + final values = []; + final isolateValues = []; + handler.stubAndroidPlaybackInfo.listen((value) { + values.add(value); + }); + var completer = Completer(); + receivePort.listen((Object? message) { + if (message == isolateInitMessage) { completer.complete(); + completer = Completer(); + } else { + isolateValues.add(message as AndroidPlaybackInfo); + if (isolateValues.length == 3) { + completer.complete(); + } } - } + }); + // wait until isolate connects + await completer.future; + + // send our message + handler.stubAndroidPlaybackInfo.add(androidPlaybackInfoStreamValues[1]); + + // and the last one is sent from the isolate + + // wait until isolate delivers all results back + await completer.future; + expectCall('androidPlaybackInfo'); + expect( + values.map((e) => e.toString()).toList(), + isolateValues.map((e) => e.toString()).toList(), + ); }); - // wait until isolate connects and receives first message with data - await completer.future; - - // send our message - handler.stubAndroidPlaybackInfo.add(androidPlaybackInfoStreamValues[1]); - - // and the last one is sent from the isolate - - // wait until isolate delivers all results back - await completer.future; - expectCall('androidPlaybackInfo'); - expect( - values.map((e) => e.toString()).toList(), - isolateValues.map((e) => e.toString()).toList(), - ); - }); - test("customEvent", () async { - handler.stubCustomEvent = PublishSubject(); - final receivePort = await runIsolateWithDeferredResult(customEventSubject); - final values = []; - final isolateValues = []; - handler.stubCustomEvent.listen((value) { - values.add(value); - }); - var completer = Completer(); - receivePort.listen((Object? message) { - if (message == isolateInitMessage) { - completer.complete(); - completer = Completer(); - } else { - isolateValues.add(message); - if (isolateValues.length == 1) { + test("customEvent", () async { + handler.stubCustomEvent = PublishSubject(); + final receivePort = + await runIsolateWithDeferredResult(customEventSubject); + final values = []; + final isolateValues = []; + handler.stubCustomEvent.listen((value) { + values.add(value); + }); + var completer = Completer(); + receivePort.listen((Object? message) { + if (message == isolateInitMessage) { completer.complete(); completer = Completer(); - } else if (isolateValues.length == 3) { - completer.complete(); + } else { + isolateValues.add(message); + if (isolateValues.length == 1) { + completer.complete(); + completer = Completer(); + } else if (isolateValues.length == 3) { + completer.complete(); + } } - } + }); + // wait until isolate connects + await completer.future; + + // send our messages + handler.stubCustomEvent.add(customEventStreamValues[0]); + await completer.future; + handler.stubCustomEvent.add(customEventStreamValues[1]); + + // and the last one is sent from the isolate + + // wait until isolate delivers all results back + await completer.future; + expectCall('customEvent'); + expect( + values.map((e) => e.toString()).toList(), + isolateValues.map((e) => e.toString()).toList(), + ); }); - // wait until isolate connects and receives first message with data - await completer.future; - - // send our messages - handler.stubCustomEvent.add(customEventStreamValues[0]); - await completer.future; - handler.stubCustomEvent.add(customEventStreamValues[1]); - - // and the last one is sent from the isolate - - // wait until isolate delivers all results back - await completer.future; - expectCall('customEvent'); - expect( - values.map((e) => e.toString()).toList(), - isolateValues.map((e) => e.toString()).toList(), - ); - }); - test("customState", () async { - handler.stubCustomState = - BehaviorSubject.seeded(customStateStreamValues[0]); - final receivePort = await runIsolateWithDeferredResult(customStateSubject); - final values = []; - final isolateValues = []; - handler.stubCustomState.listen((value) { - values.add(value); - }); - var completer = Completer(); - receivePort.listen((Object? message) { - if (message == isolateInitMessage) { - completer.complete(); - completer = Completer(); - } else { - isolateValues.add(message); - if (isolateValues.length == 3) { + test("customState", () async { + handler.stubCustomState = + BehaviorSubject.seeded(customStateStreamValues[0]); + final receivePort = + await runIsolateWithDeferredResult(customStateSubject); + final values = []; + final isolateValues = []; + handler.stubCustomState.listen((value) { + values.add(value); + }); + var completer = Completer(); + receivePort.listen((Object? message) { + if (message == isolateInitMessage) { completer.complete(); + completer = Completer(); + } else { + isolateValues.add(message); + if (isolateValues.length == 3) { + completer.complete(); + } } - } + }); + // wait until isolate connects + await completer.future; + + // send our messages + handler.stubCustomState.add(customStateStreamValues[1]); + + // and the last one is sent from the isolate + + // wait until isolate delivers all results back + await completer.future; + expectCall('customState'); + expect( + values.map((e) => e.toString()).toList(), + isolateValues.map((e) => e.toString()).toList(), + ); }); - // wait until isolate connects and receives first message with data - await completer.future; - - // send our messages - handler.stubCustomState.add(customStateStreamValues[1]); - - // and the last one is sent from the isolate - // wait until isolate delivers all results back - await completer.future; - expectCall('customState'); - expect( - values.map((e) => e.toString()).toList(), - isolateValues.map((e) => e.toString()).toList(), - ); - }); - - test("prepare", () async { - await runIsolate(prepare); - expectCall('prepare'); - }); + test("prepare", () async { + await runIsolate(prepare); + expectCall('prepare'); + }); - test("prepareFromMediaId", () async { - await runIsolate(prepareFromMediaId); - expectCall('prepareFromMediaId', const [id, map]); - }); + test("prepareFromMediaId", () async { + await runIsolate(prepareFromMediaId); + expectCall('prepareFromMediaId', const [id, map]); + }); - test("prepareFromSearch", () async { - await runIsolate(prepareFromSearch); - expectCall('prepareFromSearch', const [query, map]); - }); + test("prepareFromSearch", () async { + await runIsolate(prepareFromSearch); + expectCall('prepareFromSearch', const [query, map]); + }); - test("prepareFromUri", () async { - await runIsolate(prepareFromUri); - expectCall('prepareFromUri', [uri, map]); - }); + test("prepareFromUri", () async { + await runIsolate(prepareFromUri); + expectCall('prepareFromUri', [uri, map]); + }); - test("play", () async { - await runIsolate(play); - expectCall('play'); - }); + test("play", () async { + await runIsolate(play); + expectCall('play'); + }); - test("playFromMediaId", () async { - await runIsolate(playFromMediaId); - expectCall('playFromMediaId', const [id, map]); - }); + test("playFromMediaId", () async { + await runIsolate(playFromMediaId); + expectCall('playFromMediaId', const [id, map]); + }); - test("playFromSearch", () async { - await runIsolate(playFromSearch); - expectCall('playFromSearch', const [query, map]); - }); + test("playFromSearch", () async { + await runIsolate(playFromSearch); + expectCall('playFromSearch', const [query, map]); + }); - test("playFromUri", () async { - await runIsolate(playFromUri); - expectCall('playFromUri', [uri, map]); - }); + test("playFromUri", () async { + await runIsolate(playFromUri); + expectCall('playFromUri', [uri, map]); + }); - test("playMediaItem", () async { - await runIsolate(playMediaItem); - expectCall('playMediaItem', const [mediaItem]); - }); + test("playMediaItem", () async { + await runIsolate(playMediaItem); + expectCall('playMediaItem', const [mediaItem]); + }); - test("pause", () async { - await runIsolate(pause); - expectCall('pause'); - }); + test("pause", () async { + await runIsolate(pause); + expectCall('pause'); + }); - test("click", () async { - await runIsolate(click); - expectCall('click', const [mediaButton]); - }); + test("click", () async { + await runIsolate(click); + expectCall('click', const [mediaButton]); + }); - test("stop", () async { - await runIsolate(stop); - expectCall('stop'); - }); + test("stop", () async { + await runIsolate(stop); + expectCall('stop'); + }); - test("addQueueItem", () async { - await runIsolate(addQueueItem); - expectCall('addQueueItem', const [mediaItem]); - }); + test("addQueueItem", () async { + await runIsolate(addQueueItem); + expectCall('addQueueItem', const [mediaItem]); + }); - test("addQueueItems", () async { - await runIsolate(addQueueItems); - expectCall('addQueueItems', const [queue]); - }); + test("addQueueItems", () async { + await runIsolate(addQueueItems); + expectCall('addQueueItems', const [queue]); + }); - test("insertQueueItem", () async { - await runIsolate(insertQueueItem); - expectCall('insertQueueItem', const [0, mediaItem]); - }); + test("insertQueueItem", () async { + await runIsolate(insertQueueItem); + expectCall('insertQueueItem', const [0, mediaItem]); + }); - test("updateQueue", () async { - await runIsolate(updateQueue); - expectCall('updateQueue', const [queue]); - }); + test("updateQueue", () async { + await runIsolate(updateQueue); + expectCall('updateQueue', const [queue]); + }); - test("updateMediaItem", () async { - await runIsolate(updateMediaItem); - expectCall('updateMediaItem', const [mediaItem]); - }); + test("updateMediaItem", () async { + await runIsolate(updateMediaItem); + expectCall('updateMediaItem', const [mediaItem]); + }); - test("removeQueueItem", () async { - await runIsolate(removeQueueItem); - expectCall('removeQueueItem', const [mediaItem]); - }); + test("removeQueueItem", () async { + await runIsolate(removeQueueItem); + expectCall('removeQueueItem', const [mediaItem]); + }); - test("removeQueueItemAt", () async { - await runIsolate(removeQueueItemAt); - expectCall('removeQueueItemAt', const [0]); - }); + test("removeQueueItemAt", () async { + await runIsolate(removeQueueItemAt); + expectCall('removeQueueItemAt', const [0]); + }); - test("skipToNext", () async { - await runIsolate(skipToNext); - expectCall('skipToNext'); - }); + test("skipToNext", () async { + await runIsolate(skipToNext); + expectCall('skipToNext'); + }); - test("skipToPrevious", () async { - await runIsolate(skipToPrevious); - expectCall('skipToPrevious'); - }); + test("skipToPrevious", () async { + await runIsolate(skipToPrevious); + expectCall('skipToPrevious'); + }); - test("fastForward", () async { - await runIsolate(fastForward); - expectCall('fastForward'); - }); + test("fastForward", () async { + await runIsolate(fastForward); + expectCall('fastForward'); + }); - test("rewind", () async { - await runIsolate(rewind); - expectCall('rewind'); - }); + test("rewind", () async { + await runIsolate(rewind); + expectCall('rewind'); + }); - test("skipToQueueItem", () async { - await runIsolate(skipToQueueItem); - expectCall('skipToQueueItem', const [0]); - }); + test("skipToQueueItem", () async { + await runIsolate(skipToQueueItem); + expectCall('skipToQueueItem', const [0]); + }); - test("seek", () async { - await runIsolate(seek); - expectCall('seek', const [duration]); - }); + test("seek", () async { + await runIsolate(seek); + expectCall('seek', const [duration]); + }); - test("setRating", () async { - await runIsolate(setRating); - expectCall('setRating', const [rating, map]); - }); + test("setRating", () async { + await runIsolate(setRating); + expectCall('setRating', const [rating, map]); + }); - test("setCaptioningEnabled", () async { - await runIsolate(setCaptioningEnabled); - expectCall('setCaptioningEnabled', const [false]); - }); + test("setCaptioningEnabled", () async { + await runIsolate(setCaptioningEnabled); + expectCall('setCaptioningEnabled', const [false]); + }); - test("setRepeatMode", () async { - await runIsolate(setRepeatMode); - expectCall('setRepeatMode', const [repeatMode]); - }); + test("setRepeatMode", () async { + await runIsolate(setRepeatMode); + expectCall('setRepeatMode', const [repeatMode]); + }); - test("setShuffleMode", () async { - await runIsolate(setShuffleMode); - expectCall('setShuffleMode', const [shuffleMode]); - }); + test("setShuffleMode", () async { + await runIsolate(setShuffleMode); + expectCall('setShuffleMode', const [shuffleMode]); + }); - test("seekBackward", () async { - await runIsolate(seekBackward); - expectCall('seekBackward', const [false]); - }); + test("seekBackward", () async { + await runIsolate(seekBackward); + expectCall('seekBackward', const [false]); + }); - test("seekForward", () async { - await runIsolate(seekForward); - expectCall('seekForward', const [false]); - }); + test("seekForward", () async { + await runIsolate(seekForward); + expectCall('seekForward', const [false]); + }); - test("setSpeed", () async { - await runIsolate(setSpeed); - expectCall('setSpeed', const [0.1]); - }); + test("setSpeed", () async { + await runIsolate(setSpeed); + expectCall('setSpeed', const [0.1]); + }); - test("customAction", () async { - final expectedResult = 'custom_action_result'; - handler.stubCustomAction = expectedResult; - final result = await runIsolate(customAction); - expectCall('customAction', const [customActionName, customActionArguments]); - expect(result, expectedResult); - }); + test("customAction", () async { + final expectedResult = 'custom_action_result'; + handler.stubCustomAction = expectedResult; + final result = await runIsolate(customAction); + expectCall( + 'customAction', const [customActionName, customActionArguments]); + expect(result, expectedResult); + }); - test("onTaskRemoved", () async { - await runIsolate(onTaskRemoved); - expectCall('onTaskRemoved'); - }); + test("onTaskRemoved", () async { + await runIsolate(onTaskRemoved); + expectCall('onTaskRemoved'); + }); - test("onNotificationDeleted", () async { - await runIsolate(onNotificationDeleted); - expectCall('onNotificationDeleted'); - }); + test("onNotificationDeleted", () async { + await runIsolate(onNotificationDeleted); + expectCall('onNotificationDeleted'); + }); - test("getChildren", () async { - final expectedResult = queue; - handler.stubGetChildren = expectedResult; - final result = await runIsolate(getChildren); - expectCall('getChildren', const [id, map]); - expect(result, expectedResult); - }); + test("getChildren", () async { + final expectedResult = queue; + handler.stubGetChildren = expectedResult; + final result = await runIsolate(getChildren); + expectCall('getChildren', const [id, map]); + expect(result, expectedResult); + }); - test("subscribeToChildren", () async { - final expectedResult = >[ - {'key1': 'value1'}, - {'key2': 'value2'}, - {'key3': 'value3'} - ]; - handler.stubSubscribeToChildren = BehaviorSubject(); - final receivePort = await runIsolateWithDeferredResult(subscribeToChildren); - final result = >[]; - var completer = Completer(); - receivePort.listen((Object? message) { - if (result.isEmpty && message == isolateInitMessage) { - completer.complete(); - completer = Completer(); - } else { - result.add(message as Map); - if (result.length == 3) { + test("subscribeToChildren", () async { + final expectedResult = >[ + {'key1': 'value1'}, + {'key2': 'value2'}, + {'key3': 'value3'} + ]; + handler.stubSubscribeToChildren = BehaviorSubject(); + final receivePort = + await runIsolateWithDeferredResult(subscribeToChildren); + final result = >[]; + var completer = Completer(); + receivePort.listen((Object? message) { + if (result.isEmpty && message == isolateInitMessage) { completer.complete(); + completer = Completer(); + } else { + result.add(message as Map); + if (result.length == 3) { + completer.complete(); + } } + }); + // wait until isolate connects + await completer.future; + + for (final options in expectedResult) { + handler.stubSubscribeToChildren.add(options); } + // wait until isolate delivers all results + await completer.future; + expectCall('subscribeToChildren', const [id]); + expect(result, expectedResult); }); - // wait until isolate connects - await completer.future; - for (final options in expectedResult) { - handler.stubSubscribeToChildren.add(options); - } - // wait until isolate delivers all results - await completer.future; - expectCall('subscribeToChildren', const [id]); - expect(result, expectedResult); - }); - - test("getMediaItem", () async { - final expectedResult = mediaItem; - handler.stubGetMediaItem = expectedResult; - final result = await runIsolate(getMediaItem); - expectCall('getMediaItem', const [id]); - expect(result, expectedResult); - }); + test("getMediaItem", () async { + final expectedResult = mediaItem; + handler.stubGetMediaItem = expectedResult; + final result = await runIsolate(getMediaItem); + expectCall('getMediaItem', const [id]); + expect(result, expectedResult); + }); - test("search", () async { - final expectedResult = queue; - handler.stubSearch = expectedResult; - final result = await runIsolate(search); - expectCall('search', const [query, map]); - expect(result, expectedResult); - }); + test("search", () async { + final expectedResult = queue; + handler.stubSearch = expectedResult; + final result = await runIsolate(search); + expectCall('search', const [query, map]); + expect(result, expectedResult); + }); - test("androidAdjustRemoteVolume", () async { - await runIsolate(androidAdjustRemoteVolume); - expectCall('androidAdjustRemoteVolume', [androidVolumeDirection]); - }); + test("androidAdjustRemoteVolume", () async { + await runIsolate(androidAdjustRemoteVolume); + expectCall('androidAdjustRemoteVolume', [androidVolumeDirection]); + }); - test("androidSetRemoteVolume", () async { - await runIsolate(androidSetRemoteVolume); - expectCall('androidSetRemoteVolume', [0]); + test("androidSetRemoteVolume", () async { + await runIsolate(androidSetRemoteVolume); + expectCall('androidSetRemoteVolume', [0]); + }); }); } From cd5d8ea88ed63eb591147fee848311aeeb4deca9 Mon Sep 17 00:00:00 2001 From: nt4f04uNd Date: Mon, 14 Jun 2021 20:54:36 +0300 Subject: [PATCH 03/16] doc --- audio_service/lib/audio_service.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/audio_service/lib/audio_service.dart b/audio_service/lib/audio_service.dart index bbf466e0..8fae71d2 100644 --- a/audio_service/lib/audio_service.dart +++ b/audio_service/lib/audio_service.dart @@ -859,10 +859,8 @@ class AudioService { static ReceivePort? _hostReceivePort; static const _hostIsolatePortName = 'com.ryanheise.audioservice.port'; - /// Connect to the [udioHandler] from another isolate. - /// - /// Prior this, some [AudioHandler] must be hosted by calling [init] or - /// [hostHandler] + /// Connect to the [AudioHandler], which was hosted by calling [init] or + /// [hostHandler], from another isolate. static Future connectFromIsolate() async { return IsolateAudioHandler(); } From 874a5deae6ad8e229b786228e7b31192bf39c7c0 Mon Sep 17 00:00:00 2001 From: nt4f04uNd Date: Mon, 14 Jun 2021 21:36:18 +0300 Subject: [PATCH 04/16] added rating test --- audio_service/test/isolate_test.dart | 60 ++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/audio_service/test/isolate_test.dart b/audio_service/test/isolate_test.dart index 9ebc8216..76b16177 100644 --- a/audio_service/test/isolate_test.dart +++ b/audio_service/test/isolate_test.dart @@ -60,6 +60,12 @@ const mediaItemStreamValues = [ MediaItem(id: 'id_3', title: ''), ]; +const ratingStyleStreamValues = [ + RatingStyle.percentage, + RatingStyle.range4stars, + RatingStyle.heart, +]; + const androidPlaybackInfoStreamValues = [ LocalAndroidPlaybackInfo(), RemoteAndroidPlaybackInfo( @@ -364,6 +370,45 @@ Future main() async { ); }); + test("ratingStyle", () async { + handler.stubRatingStyle = + BehaviorSubject.seeded(ratingStyleStreamValues[0]); + final receivePort = + await runIsolateWithDeferredResult(ratingStyleSubject); + final values = []; + final isolateValues = []; + handler.stubRatingStyle.listen((value) { + values.add(value); + }); + var completer = Completer(); + receivePort.listen((Object? message) { + if (message == isolateInitMessage) { + completer.complete(); + completer = Completer(); + } else { + isolateValues.add(message as RatingStyle); + if (isolateValues.length == 3) { + completer.complete(); + } + } + }); + // wait until isolate connects + await completer.future; + + // send our message + handler.stubRatingStyle.add(ratingStyleStreamValues[1]); + + // and the last one is sent from the isolate + + // wait until isolate delivers all results back + await completer.future; + expectCall('ratingStyle'); + expect( + values.map((e) => e.toString()).toList(), + isolateValues.map((e) => e.toString()).toList(), + ); + }); + test("androidPlaybackInfo", () async { handler.stubAndroidPlaybackInfo = BehaviorSubject.seeded(androidPlaybackInfoStreamValues[0]); @@ -805,6 +850,21 @@ void mediaItemSubject(SendPort port) async { }); } +void ratingStyleSubject(SendPort port) async { + final handler = MockIsolateAudioHandler(); + final ratingStyle = handler.ratingStyle; + await handler.syncSubject(ratingStyle, 'ratingStyle'); + port.send(isolateInitMessage); + var updates = 0; + ratingStyle.listen((value) { + port.send(value); + updates += 1; + if (updates == 2) { + ratingStyle.add(ratingStyleStreamValues[2]); + } + }); +} + void androidPlaybackInfoSubject(SendPort port) async { final handler = MockIsolateAudioHandler(); final androidPlaybackInfo = handler.androidPlaybackInfo; From 614ea987ca36d0ed0a5446a552039f2f9c14551b Mon Sep 17 00:00:00 2001 From: nt4f04uNd Date: Mon, 14 Jun 2021 21:38:47 +0300 Subject: [PATCH 05/16] rename mock base audio handler file --- .../{mock_audio_handler.dart => mock_base_audio_handler.dart} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename audio_service/test/{mock_audio_handler.dart => mock_base_audio_handler.dart} (100%) diff --git a/audio_service/test/mock_audio_handler.dart b/audio_service/test/mock_base_audio_handler.dart similarity index 100% rename from audio_service/test/mock_audio_handler.dart rename to audio_service/test/mock_base_audio_handler.dart From 64c7bd4ba7cfdfbba97b536a81d36ebe9bebee9c Mon Sep 17 00:00:00 2001 From: nt4f04uNd Date: Mon, 14 Jun 2021 21:39:04 +0300 Subject: [PATCH 06/16] + --- audio_service/test/isolate_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/audio_service/test/isolate_test.dart b/audio_service/test/isolate_test.dart index 76b16177..fafcd01c 100644 --- a/audio_service/test/isolate_test.dart +++ b/audio_service/test/isolate_test.dart @@ -6,7 +6,7 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:rxdart/rxdart.dart'; -import 'mock_audio_handler.dart'; +import 'mock_base_audio_handler.dart'; const isolateInitMessage = Object(); From 39917e2a43f1aad0d9a9c55c6bde578497669720 Mon Sep 17 00:00:00 2001 From: nt4f04uNd Date: Tue, 15 Jun 2021 10:38:43 +0300 Subject: [PATCH 07/16] More tests --- audio_service/lib/audio_service.dart | 102 ++++++++------- audio_service/test/isolate_test.dart | 180 +++++++++++++++++---------- 2 files changed, 173 insertions(+), 109 deletions(-) diff --git a/audio_service/lib/audio_service.dart b/audio_service/lib/audio_service.dart index 8fae71d2..a0f0b43a 100644 --- a/audio_service/lib/audio_service.dart +++ b/audio_service/lib/audio_service.dart @@ -857,41 +857,49 @@ class AudioService { /// Port to host the handler on with [hostHandler]. static ReceivePort? _hostReceivePort; - static const _hostIsolatePortName = 'com.ryanheise.audioservice.port'; + + /// Used for testing purposes, don't use this, as it may lead to + /// unintended behaviors. + @visibleForTesting + static const hostIsolatePortName = 'com.ryanheise.audioservice.port'; /// Connect to the [AudioHandler], which was hosted by calling [init] or /// [hostHandler], from another isolate. static Future connectFromIsolate() async { - return IsolateAudioHandler(); + final handler = IsolateAudioHandler(); + await handler.init(); + return handler; + } + + /// Whether currently there is a hosted handler available. + static bool get isHosting { + final sendPort = IsolateNameServer.lookupPortByName(hostIsolatePortName); + return sendPort != null; } /// Hosts the audio handler to other isolates. /// /// Must be called from the main isolate, other isolates can connect - /// to the handler via [connectFromIsolate]. + /// to the handler via [connectFromIsolate]. Can be called only once, + /// all consecutive calls will throw. /// /// Calling this method not from the main isolate may have unintended consequences, /// for example the isolate may become unreachable, because of being destroyed, /// or its engine being destroyed. As a result of that, all the handlers from connected /// isolates will stop receiving updates and calls to their methods will timeout. - /// - /// During the time the host isolate is alive, any calls to this method from any - /// isolate will throw. A new handle can be registered once again only when - /// the host isolate dies. - static Future hostHandler(AudioHandler handler) async { + static void hostHandler(AudioHandler handler) { if (!kIsWeb) { - final sendPort = IsolateNameServer.lookupPortByName(_hostIsolatePortName); - if (handler is IsolateAudioHandler) { throw ArgumentError( "Registering IsolateAudioHandler is not allowed, as this will lead " "to an infinite loop when its methods are called", ); } - if (sendPort != null || _hostReceivePort != null) { + if (isHosting) { throw StateError("Some isolate has already hosted a handler"); } + /// More comments on that are available in [IsolateAudioHandler.syncSubject]. void syncStream(Stream stream, IsolateRequest request) { final sendPort = request.arguments![0] as SendPort; final toSkip = []; @@ -1156,7 +1164,7 @@ class AudioService { } }); IsolateNameServer.registerPortWithName( - _hostReceivePort!.sendPort, _hostIsolatePortName); + _hostReceivePort!.sendPort, hostIsolatePortName); } } @@ -2451,7 +2459,7 @@ class IsolateRequest { IsolateRequest(this.sendPort, this.method, [this.arguments]); } -/// A message to be from host isolate to the connected to synchronize +/// A message to be sent from host isolate to the connected to synchronize /// the their streams. class IsolateStreamSyncRequest { /// Event data. @@ -2466,7 +2474,8 @@ class IsolateStreamSyncRequest { /// Handler that connects to the handler hosted with [AudioService.hostHandler]. /// -/// Instantiating this class is equal to calling [AudioService.connectFromIsolate]. +/// For convenience, it's better to use [AudioService.connectFromIsolate] which +/// creates this class and calls [init]. class IsolateAudioHandler extends AudioHandler { @override final BehaviorSubject playbackState = BehaviorSubject(); @@ -2494,56 +2503,63 @@ class IsolateAudioHandler extends AudioHandler { final BehaviorSubject customState = BehaviorSubject(); /// Creates an isolate audio handler. - IsolateAudioHandler() : super._() { - syncSubjects(); - } - - /// Called in constructor to sync streams with . - void syncSubjects() { - syncSubject(playbackState, 'playbackState'); - syncSubject(queue, 'queue'); - syncSubject(queueTitle, 'queueTitle'); - syncSubject(mediaItem, 'mediaItem'); - syncSubject(androidPlaybackInfo, 'androidPlaybackInfo'); - syncSubject(ratingStyle, 'ratingStyle'); - syncSubject(customEvent, 'customEvent'); - syncSubject(customState, 'customState'); + /// You should call the [init] right away after instantiation. + IsolateAudioHandler() : super._(); + + /// Synchronizes the subjects with the hosted isolate. + Future init() { + return Future.wait([ + syncSubject(playbackState, 'playbackState'), + syncSubject(queue, 'queue'), + syncSubject(queueTitle, 'queueTitle'), + syncSubject(mediaItem, 'mediaItem'), + syncSubject(androidPlaybackInfo, 'androidPlaybackInfo'), + syncSubject(ratingStyle, 'ratingStyle'), + syncSubject(customEvent, 'customEvent'), + syncSubject(customState, 'customState'), + ]); } /// Sends a message to hosted audio handler. Future send(String method, [List? arguments]) async { final sendPort = - IsolateNameServer.lookupPortByName(AudioService._hostIsolatePortName); + IsolateNameServer.lookupPortByName(AudioService.hostIsolatePortName); if (sendPort == null) { throw StateError( "No isolate was hosted. " - "You mast call `AudioService.init` or `AudioService.hostHandler` first", + "You must call `AudioService.init` or `AudioService.hostHandler` first", ); } final receivePort = ReceivePort(); sendPort.send(IsolateRequest(receivePort.sendPort, method, arguments)); - final result = await (receivePort.first) - .timeout(const Duration(seconds: 10), onTimeout: () { - print( - "The call to the hosted isolate has timed out, the isolate has likely died " - "See ${AudioService.hostHandler} for more details", + final result = await (receivePort.first).timeout(const Duration(seconds: 3), + onTimeout: () { + throw TimeoutException( + "The call to the hosted isolate has timed out, the isolate has likely died. " + "See $AudioService.hostHandler for more details", ); - return null; }) as T; receivePort.close(); return result; } - /// Synchronizes some stream with some in the hosted audio handler by its [name]. + /// Synchronizes some stream by its [name] with a corresponding one in the + /// hosted audio handler /// - /// It sends a send port to pipe events into, from some stream in hosted handler. - /// This port should receive a message on connection. - /// - /// In return the hosted handler should also return a send port, and pipe the - /// messages that are sent over it into the same stream. + /// It sends a send port to feed events into from the remote stream and in + /// return the hosted handler should also return a send port, and pipe + /// the messages that are sent over it into the same stream. /// /// The hosted handler may also respond with `null` instead of a send port, - /// when it can't pipe the events into the stream. + /// when it can't pipe the events into its stream. + /// + /// The host handler sends to the connected isolates a special [IsolateStreamSyncRequest], + /// that has a time record on it, so isolates can check whether they had some + /// more recent event in them than what the host isolate has sent. + /// + /// On both ends the messages that are received via ports should be + /// filtered out of the stream listener notifiers, because otherwise it + /// will lead to that messages will be sent back and forth forever. Future syncSubject(Subject subject, String name) async { DateTime? recentUpdate; final receivePort = ReceivePort(); diff --git a/audio_service/test/isolate_test.dart b/audio_service/test/isolate_test.dart index fafcd01c..36c644cd 100644 --- a/audio_service/test/isolate_test.dart +++ b/audio_service/test/isolate_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:isolate'; +import 'dart:ui'; import 'package:collection/collection.dart'; import 'package:audio_service/audio_service.dart'; @@ -92,24 +93,16 @@ const customStateStreamValues = [ 3, ]; -class MockIsolateAudioHandler extends IsolateAudioHandler { - final bool _syncSubjects; - MockIsolateAudioHandler({bool syncSubjects = false}) - : _syncSubjects = syncSubjects; - - @override - void syncSubjects() { - if (_syncSubjects) { - super.syncSubjects(); - } - } -} - Future main() async { TestWidgetsFlutterBinding.ensureInitialized(); final handler = MockBaseAudioHandler(); - await AudioService.hostHandler(handler); + + void host() { + if (!AudioService.isHosting) { + AudioService.hostHandler(handler); + } + } Isolate? isolate; @@ -140,9 +133,52 @@ Future main() async { }); group("Connection setup ▮", () { + test("can host from spawned isolate and connect from the main", () async { + await runIsolate(hostHandlerIsolate); + final handler = await AudioService.connectFromIsolate(); + expect(handler.queue.value, const []); + expect(AudioService.isHosting, true); + IsolateNameServer.removePortNameMapping(AudioService.hostIsolatePortName); + }); + + test("throws timeout exception when host isolate dies", () async { + await runIsolate(hostHandlerIsolate); + isolate!.kill(); + final handler = IsolateAudioHandler(); + expect( + () => handler.play(), + throwsA( + isA().having( + (e) => e.message, + 'message', + "The call to the hosted isolate has timed out, the isolate has likely died. " + "See $AudioService.hostHandler for more details", + ), + ), + ); + IsolateNameServer.removePortNameMapping(AudioService.hostIsolatePortName); + }); + + test("throws when no isolate is hosted", () { + expect( + () => AudioService.connectFromIsolate(), + throwsA( + isA().having( + (e) => e.message, + 'message', + "No isolate was hosted. " + "You must call `AudioService.init` or `AudioService.hostHandler` first", + ), + ), + ); + }); + test("throws when attempting to host IsolateAudioHandler", () { expect( - () => AudioService.hostHandler(MockIsolateAudioHandler()), + () { + host(); + AudioService.hostHandler(IsolateAudioHandler()); + }, throwsA( isA().having( (e) => e.message, @@ -156,7 +192,10 @@ Future main() async { test("throws when attempting to host more than once", () async { expect( - () => AudioService.hostHandler(BaseAudioHandler()), + () { + host(); + AudioService.hostHandler(BaseAudioHandler()); + }, throwsA( isA().having( (e) => e.message, @@ -176,6 +215,10 @@ Future main() async { expect(actualArguments, arguments); } + setUpAll(() { + host(); + }); + test("subjects receive the most recent update", () async { handler.stubPlaybackState = BehaviorSubject.seeded(playbackStateStreamValues[0]); @@ -779,8 +822,13 @@ Future main() async { }); } +void hostHandlerIsolate(SendPort port) async { + AudioService.hostHandler(BaseAudioHandler()); + port.send(isolateInitMessage); +} + void subjectsAreRecent(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); final playbackState = handler.playbackState; await handler.syncSubject(playbackState, 'playbackState'); port.send(isolateInitMessage); @@ -791,7 +839,7 @@ void subjectsAreRecent(SendPort port) async { } void playbackStateSubject(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); final playbackState = handler.playbackState; await handler.syncSubject(playbackState, 'playbackState'); port.send(isolateInitMessage); @@ -806,7 +854,7 @@ void playbackStateSubject(SendPort port) async { } void queueSubject(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); final queue = handler.queue; await handler.syncSubject(queue, 'queue'); port.send(isolateInitMessage); @@ -821,7 +869,7 @@ void queueSubject(SendPort port) async { } void queueTitleSubject(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); final queueTitle = handler.queueTitle; await handler.syncSubject(queueTitle, 'queueTitle'); port.send(isolateInitMessage); @@ -836,7 +884,7 @@ void queueTitleSubject(SendPort port) async { } void mediaItemSubject(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); final mediaItem = handler.mediaItem; await handler.syncSubject(mediaItem, 'mediaItem'); port.send(isolateInitMessage); @@ -851,7 +899,7 @@ void mediaItemSubject(SendPort port) async { } void ratingStyleSubject(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); final ratingStyle = handler.ratingStyle; await handler.syncSubject(ratingStyle, 'ratingStyle'); port.send(isolateInitMessage); @@ -866,7 +914,7 @@ void ratingStyleSubject(SendPort port) async { } void androidPlaybackInfoSubject(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); final androidPlaybackInfo = handler.androidPlaybackInfo; await handler.syncSubject(androidPlaybackInfo, 'androidPlaybackInfo'); port.send(isolateInitMessage); @@ -881,7 +929,7 @@ void androidPlaybackInfoSubject(SendPort port) async { } void customEventSubject(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); final customEvent = handler.customEvent; await handler.syncSubject(customEvent, 'customEvent'); port.send(isolateInitMessage); @@ -896,7 +944,7 @@ void customEventSubject(SendPort port) async { } void customStateSubject(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); final customState = handler.customState; await handler.syncSubject(customState, 'customState'); port.send(isolateInitMessage); @@ -911,199 +959,199 @@ void customStateSubject(SendPort port) async { } void prepare(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.prepare(); port.send(isolateInitMessage); } void prepareFromMediaId(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.prepareFromMediaId(id, map); port.send(isolateInitMessage); } void prepareFromSearch(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.prepareFromSearch(query, map); port.send(isolateInitMessage); } void prepareFromUri(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.prepareFromUri(uri, map); port.send(isolateInitMessage); } void play(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.play(); port.send(isolateInitMessage); } void playFromMediaId(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.playFromMediaId(id, map); port.send(isolateInitMessage); } void playFromSearch(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.playFromSearch(query, map); port.send(isolateInitMessage); } void playFromUri(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.playFromUri(uri, map); port.send(isolateInitMessage); } void playMediaItem(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.playMediaItem(mediaItem); port.send(isolateInitMessage); } void pause(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.pause(); port.send(isolateInitMessage); } void click(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.click(MediaButton.next); port.send(isolateInitMessage); } void stop(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.stop(); port.send(isolateInitMessage); } void addQueueItem(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.addQueueItem(mediaItem); port.send(isolateInitMessage); } void addQueueItems(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.addQueueItems(queue); port.send(isolateInitMessage); } void insertQueueItem(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.insertQueueItem(0, mediaItem); port.send(isolateInitMessage); } void updateQueue(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.updateQueue(queue); port.send(isolateInitMessage); } void updateMediaItem(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.updateMediaItem(mediaItem); port.send(isolateInitMessage); } void removeQueueItem(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.removeQueueItem(mediaItem); port.send(isolateInitMessage); } void removeQueueItemAt(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.removeQueueItemAt(0); port.send(isolateInitMessage); } void skipToNext(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.skipToNext(); port.send(isolateInitMessage); } void skipToPrevious(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.skipToPrevious(); port.send(isolateInitMessage); } void fastForward(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.fastForward(); port.send(isolateInitMessage); } void rewind(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.rewind(); port.send(isolateInitMessage); } void skipToQueueItem(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.skipToQueueItem(0); port.send(isolateInitMessage); } void seek(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.seek(duration); port.send(isolateInitMessage); } void setRating(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.setRating(rating, map); port.send(isolateInitMessage); } void setCaptioningEnabled(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.setCaptioningEnabled(false); port.send(isolateInitMessage); } void setRepeatMode(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.setRepeatMode(repeatMode); port.send(isolateInitMessage); } void setShuffleMode(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.setShuffleMode(shuffleMode); port.send(isolateInitMessage); } void seekBackward(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.seekBackward(false); port.send(isolateInitMessage); } void seekForward(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.seekForward(false); port.send(isolateInitMessage); } void setSpeed(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.setSpeed(0.1); port.send(isolateInitMessage); } void customAction(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); port.send(await handler.customAction( customActionName, customActionArguments, @@ -1111,24 +1159,24 @@ void customAction(SendPort port) async { } void onTaskRemoved(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.onTaskRemoved(); port.send(isolateInitMessage); } void onNotificationDeleted(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.onNotificationDeleted(); port.send(isolateInitMessage); } void getChildren(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); port.send(await handler.getChildren(id, map)); } void subscribeToChildren(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); final result = handler.subscribeToChildren(id); port.send(isolateInitMessage); result.listen((event) { @@ -1137,23 +1185,23 @@ void subscribeToChildren(SendPort port) async { } void getMediaItem(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); port.send(await handler.getMediaItem(id)); } void search(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); port.send(await handler.search(query, map)); } void androidAdjustRemoteVolume(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.androidAdjustRemoteVolume(androidVolumeDirection); port.send(isolateInitMessage); } void androidSetRemoteVolume(SendPort port) async { - final handler = MockIsolateAudioHandler(); + final handler = IsolateAudioHandler(); await handler.androidSetRemoteVolume(0); port.send(isolateInitMessage); } From 50b53c1ae345d557f4068c4f0ac5c20ad810dc14 Mon Sep 17 00:00:00 2001 From: nt4f04uNd Date: Tue, 15 Jun 2021 10:43:48 +0300 Subject: [PATCH 08/16] Better test isolation --- audio_service/test/isolate_test.dart | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/audio_service/test/isolate_test.dart b/audio_service/test/isolate_test.dart index 36c644cd..1b75176d 100644 --- a/audio_service/test/isolate_test.dart +++ b/audio_service/test/isolate_test.dart @@ -122,28 +122,36 @@ Future main() async { return receivePort; } + void killIsolate() { + if (isolate != null) { + isolate!.kill(priority: Isolate.immediate); + isolate = null; + } + } + setUp(() { handler.reset(); }); tearDown(() { - if (isolate != null) { - isolate!.kill(priority: Isolate.immediate); - } + killIsolate(); }); group("Connection setup ▮", () { + tearDown(() { + IsolateNameServer.removePortNameMapping(AudioService.hostIsolatePortName); + }); + test("can host from spawned isolate and connect from the main", () async { await runIsolate(hostHandlerIsolate); final handler = await AudioService.connectFromIsolate(); expect(handler.queue.value, const []); expect(AudioService.isHosting, true); - IsolateNameServer.removePortNameMapping(AudioService.hostIsolatePortName); }); test("throws timeout exception when host isolate dies", () async { await runIsolate(hostHandlerIsolate); - isolate!.kill(); + killIsolate(); final handler = IsolateAudioHandler(); expect( () => handler.play(), @@ -156,7 +164,6 @@ Future main() async { ), ), ); - IsolateNameServer.removePortNameMapping(AudioService.hostIsolatePortName); }); test("throws when no isolate is hosted", () { @@ -175,10 +182,7 @@ Future main() async { test("throws when attempting to host IsolateAudioHandler", () { expect( - () { - host(); - AudioService.hostHandler(IsolateAudioHandler()); - }, + () => AudioService.hostHandler(IsolateAudioHandler()), throwsA( isA().having( (e) => e.message, @@ -193,7 +197,7 @@ Future main() async { test("throws when attempting to host more than once", () async { expect( () { - host(); + AudioService.hostHandler(BaseAudioHandler()); AudioService.hostHandler(BaseAudioHandler()); }, throwsA( From 04c9f147a5d72302f87975e4a6917cb2fd1028b3 Mon Sep 17 00:00:00 2001 From: nt4f04uNd Date: Sun, 20 Jun 2021 14:59:41 +0300 Subject: [PATCH 09/16] apply optional parameters --- audio_service/test/mock_base_audio_handler.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/audio_service/test/mock_base_audio_handler.dart b/audio_service/test/mock_base_audio_handler.dart index 2a859008..18c7ebe6 100644 --- a/audio_service/test/mock_base_audio_handler.dart +++ b/audio_service/test/mock_base_audio_handler.dart @@ -331,7 +331,7 @@ class MockBaseAudioHandler implements BaseAudioHandler { } @override - Future setRating(Rating rating, Map? extras) async { + Future setRating(Rating rating, [Map? extras]) async { _log('setRating', [rating, extras]); } @@ -367,7 +367,7 @@ class MockBaseAudioHandler implements BaseAudioHandler { @override Future customAction( - String name, Map? extras) async { + String name, [Map? extras]) async { _log('customAction', [name, extras]); return _stubCustomAction.value; } From 807811bba45bf4e2232472afbce97ac932013795 Mon Sep 17 00:00:00 2001 From: nt4f04uNd Date: Sun, 4 Jul 2021 16:41:25 +0300 Subject: [PATCH 10/16] grammar --- audio_service/lib/audio_service.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/audio_service/lib/audio_service.dart b/audio_service/lib/audio_service.dart index c112e554..93e36434 100644 --- a/audio_service/lib/audio_service.dart +++ b/audio_service/lib/audio_service.dart @@ -820,7 +820,7 @@ class AudioService { /// for example the isolate may become unreachable, because of being destroyed, /// or its engine being destroyed. /// - /// This method automatically hosts audio handler. so other isolates can + /// This method automatically hosts audio handler, so other isolates can /// reach out to the handler with [connectFromIsolate]. For more details /// on the lifecycle of hosted handler, see [hostHandler] documentation. /// @@ -2546,8 +2546,8 @@ class IsolateAudioHandler extends AudioHandler { /// Synchronizes some stream by its [name] with a corresponding one in the /// hosted audio handler /// - /// It sends a send port to feed events into from the remote stream and in - /// return the hosted handler should also return a send port, and pipe + /// It sends a send port to feed events into from the remote stream, and in + /// return the hosted handler should also return a send port and pipe /// the messages that are sent over it into the same stream. /// /// The hosted handler may also respond with `null` instead of a send port, @@ -2558,8 +2558,8 @@ class IsolateAudioHandler extends AudioHandler { /// more recent event in them than what the host isolate has sent. /// /// On both ends the messages that are received via ports should be - /// filtered out of the stream listener notifiers, because otherwise it - /// will lead to that messages will be sent back and forth forever. + /// filtered out of the stream listener notifiers, because otherwise + /// messages will be sent back and forth forever. Future syncSubject(Subject subject, String name) async { DateTime? recentUpdate; final receivePort = ReceivePort(); From 9fe429414a284ff07d049dfe9c478b2e9a27f2be Mon Sep 17 00:00:00 2001 From: nt4f04uNd Date: Sun, 4 Jul 2021 17:57:51 +0300 Subject: [PATCH 11/16] make IsolateAudioHandler private --- audio_service/lib/audio_service.dart | 86 +++++++++++------ audio_service/test/isolate_test.dart | 136 ++++++++++++--------------- 2 files changed, 116 insertions(+), 106 deletions(-) diff --git a/audio_service/lib/audio_service.dart b/audio_service/lib/audio_service.dart index 93e36434..568e718f 100644 --- a/audio_service/lib/audio_service.dart +++ b/audio_service/lib/audio_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:isolate'; import 'dart:ui'; @@ -863,10 +864,26 @@ class AudioService { @visibleForTesting static const hostIsolatePortName = 'com.ryanheise.audioservice.port'; + /// Set this to false to disable stream synching in tests for the [connectFromIsolate]. + /// Not in test environment will do nothing. + @visibleForTesting + static set testSyncIsolate(bool value) { + if (kDebugMode) { + if (value) { + IsolateNameServer.removePortNameMapping(_testSyncIsolateKey); + } else { + final port = ReceivePort(); + IsolateNameServer.registerPortWithName(port.sendPort, _testSyncIsolateKey); + port.close(); + } + } + } + static const _testSyncIsolateKey = 'com.ryanheise.audioservice.testSyncIsolate'; + /// Connect to the [AudioHandler], which was hosted by calling [init] or /// [hostHandler], from another isolate. - static Future connectFromIsolate() async { - final handler = IsolateAudioHandler(); + static Future connectFromIsolate() async { + final handler = _IsolateAudioHandler(IsolateNameServer.lookupPortByName(_testSyncIsolateKey) == null); await handler.init(); return handler; } @@ -889,9 +906,9 @@ class AudioService { /// isolates will stop receiving updates and calls to their methods will timeout. static void hostHandler(AudioHandler handler) { if (!kIsWeb) { - if (handler is IsolateAudioHandler) { + if (handler is _IsolateAudioHandler) { throw ArgumentError( - "Registering IsolateAudioHandler is not allowed, as this will lead " + "Registering _IsolateAudioHandler is not allowed, as this will lead " "to an infinite loop when its methods are called", ); } @@ -899,15 +916,15 @@ class AudioService { throw StateError("Some isolate has already hosted a handler"); } - /// More comments on that are available in [IsolateAudioHandler.syncSubject]. - void syncStream(Stream stream, IsolateRequest request) { + /// More comments on that are available in [_IsolateAudioHandler.syncSubject]. + void syncStream(Stream stream, _IsolateRequest request) { final sendPort = request.arguments![0] as SendPort; final toSkip = []; stream.listen((dynamic event) { if (toSkip.contains(event)) { toSkip.remove(event); } else { - sendPort.send(IsolateStreamSyncRequest(event)); + sendPort.send(_IsolateStreamSyncRequest(event)); } }); if (stream is StreamController) { @@ -924,7 +941,7 @@ class AudioService { _hostReceivePort = ReceivePort(); _hostReceivePort!.listen((dynamic event) async { - final request = event as IsolateRequest; + final request = event as _IsolateRequest; switch (request.method) { case 'playbackState': syncStream(handler.playbackState, request); @@ -2445,7 +2462,7 @@ class CompositeAudioHandler extends AudioHandler { } /// A message to be sent to the audio handler hosted with [AudioService.hostHandler]. -class IsolateRequest { +class _IsolateRequest { /// The send port for sending the response of this request. final SendPort sendPort; @@ -2456,12 +2473,12 @@ class IsolateRequest { final List? arguments; /// Creates a request. - IsolateRequest(this.sendPort, this.method, [this.arguments]); + _IsolateRequest(this.sendPort, this.method, [this.arguments]); } /// A message to be sent from host isolate to the connected to synchronize /// the their streams. -class IsolateStreamSyncRequest { +class _IsolateStreamSyncRequest { /// Event data. final dynamic event; @@ -2469,14 +2486,13 @@ class IsolateStreamSyncRequest { final DateTime time; /// Creates a request. - IsolateStreamSyncRequest(this.event) : time = DateTime.now(); + _IsolateStreamSyncRequest(this.event) : time = DateTime.now(); } /// Handler that connects to the handler hosted with [AudioService.hostHandler]. /// -/// For convenience, it's better to use [AudioService.connectFromIsolate] which -/// creates this class and calls [init]. -class IsolateAudioHandler extends AudioHandler { +/// Used [AudioService.connectFromIsolate] in. +class _IsolateAudioHandler implements BaseAudioHandler { @override final BehaviorSubject playbackState = BehaviorSubject(); @@ -2503,21 +2519,29 @@ class IsolateAudioHandler extends AudioHandler { final BehaviorSubject customState = BehaviorSubject(); /// Creates an isolate audio handler. - /// You should call the [init] right away after instantiation. - IsolateAudioHandler() : super._(); + /// The [init] should be called right away after that. + _IsolateAudioHandler(this.testSyncIsolate); + + /// Set this to true to disable stream synching in tests. + /// Not in test environment will do nothing. + final bool testSyncIsolate; /// Synchronizes the subjects with the hosted isolate. - Future init() { - return Future.wait([ - syncSubject(playbackState, 'playbackState'), - syncSubject(queue, 'queue'), - syncSubject(queueTitle, 'queueTitle'), - syncSubject(mediaItem, 'mediaItem'), - syncSubject(androidPlaybackInfo, 'androidPlaybackInfo'), - syncSubject(ratingStyle, 'ratingStyle'), - syncSubject(customEvent, 'customEvent'), - syncSubject(customState, 'customState'), - ]); + Future init() async { + // Disable the synching for tests as this causes a lot of side effects + // in unit tests. + if (testSyncIsolate || !kDebugMode || kIsWeb || !Platform.environment.containsKey('FLUTTER_TEST')) { + await Future.wait([ + syncSubject(playbackState, 'playbackState'), + syncSubject(queue, 'queue'), + syncSubject(queueTitle, 'queueTitle'), + syncSubject(mediaItem, 'mediaItem'), + syncSubject(androidPlaybackInfo, 'androidPlaybackInfo'), + syncSubject(ratingStyle, 'ratingStyle'), + syncSubject(customEvent, 'customEvent'), + syncSubject(customState, 'customState'), + ]); + } } /// Sends a message to hosted audio handler. @@ -2531,7 +2555,7 @@ class IsolateAudioHandler extends AudioHandler { ); } final receivePort = ReceivePort(); - sendPort.send(IsolateRequest(receivePort.sendPort, method, arguments)); + sendPort.send(_IsolateRequest(receivePort.sendPort, method, arguments)); final result = await (receivePort.first).timeout(const Duration(seconds: 3), onTimeout: () { throw TimeoutException( @@ -2553,7 +2577,7 @@ class IsolateAudioHandler extends AudioHandler { /// The hosted handler may also respond with `null` instead of a send port, /// when it can't pipe the events into its stream. /// - /// The host handler sends to the connected isolates a special [IsolateStreamSyncRequest], + /// The host handler sends to the connected isolates a special [_IsolateStreamSyncRequest], /// that has a time record on it, so isolates can check whether they had some /// more recent event in them than what the host isolate has sent. /// @@ -2566,7 +2590,7 @@ class IsolateAudioHandler extends AudioHandler { final toSkip = []; SendPort? sendPort; receivePort.listen((dynamic message) { - final request = message as IsolateStreamSyncRequest; + final request = message as _IsolateStreamSyncRequest; if (recentUpdate == null || request.time.difference(recentUpdate!) > Duration.zero) { recentUpdate = request.time; diff --git a/audio_service/test/isolate_test.dart b/audio_service/test/isolate_test.dart index 1b75176d..2cf8a8b4 100644 --- a/audio_service/test/isolate_test.dart +++ b/audio_service/test/isolate_test.dart @@ -100,6 +100,7 @@ Future main() async { void host() { if (!AudioService.isHosting) { + AudioService.testSyncIsolate = false; AudioService.hostHandler(handler); } } @@ -152,9 +153,8 @@ Future main() async { test("throws timeout exception when host isolate dies", () async { await runIsolate(hostHandlerIsolate); killIsolate(); - final handler = IsolateAudioHandler(); expect( - () => handler.play(), + () => AudioService.connectFromIsolate(), throwsA( isA().having( (e) => e.message, @@ -180,20 +180,6 @@ Future main() async { ); }); - test("throws when attempting to host IsolateAudioHandler", () { - expect( - () => AudioService.hostHandler(IsolateAudioHandler()), - throwsA( - isA().having( - (e) => e.message, - 'message', - "Registering IsolateAudioHandler is not allowed, as this will lead " - "to an infinite loop when its methods are called", - ), - ), - ); - }); - test("throws when attempting to host more than once", () async { expect( () { @@ -832,9 +818,9 @@ void hostHandlerIsolate(SendPort port) async { } void subjectsAreRecent(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); final playbackState = handler.playbackState; - await handler.syncSubject(playbackState, 'playbackState'); + await (handler as dynamic).syncSubject(playbackState, 'playbackState'); port.send(isolateInitMessage); playbackState.listen((value) { port.send(value); @@ -843,9 +829,9 @@ void subjectsAreRecent(SendPort port) async { } void playbackStateSubject(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); final playbackState = handler.playbackState; - await handler.syncSubject(playbackState, 'playbackState'); + await (handler as dynamic).syncSubject(playbackState, 'playbackState'); port.send(isolateInitMessage); var updates = 0; playbackState.listen((value) { @@ -858,9 +844,9 @@ void playbackStateSubject(SendPort port) async { } void queueSubject(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); final queue = handler.queue; - await handler.syncSubject(queue, 'queue'); + await (handler as dynamic).syncSubject(queue, 'queue'); port.send(isolateInitMessage); var updates = 0; queue.listen((value) { @@ -873,9 +859,9 @@ void queueSubject(SendPort port) async { } void queueTitleSubject(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); final queueTitle = handler.queueTitle; - await handler.syncSubject(queueTitle, 'queueTitle'); + await (handler as dynamic).syncSubject(queueTitle, 'queueTitle'); port.send(isolateInitMessage); var updates = 0; queueTitle.listen((value) { @@ -888,9 +874,9 @@ void queueTitleSubject(SendPort port) async { } void mediaItemSubject(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); final mediaItem = handler.mediaItem; - await handler.syncSubject(mediaItem, 'mediaItem'); + await (handler as dynamic).syncSubject(mediaItem, 'mediaItem'); port.send(isolateInitMessage); var updates = 0; mediaItem.listen((value) { @@ -903,9 +889,9 @@ void mediaItemSubject(SendPort port) async { } void ratingStyleSubject(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); final ratingStyle = handler.ratingStyle; - await handler.syncSubject(ratingStyle, 'ratingStyle'); + await (handler as dynamic).syncSubject(ratingStyle, 'ratingStyle'); port.send(isolateInitMessage); var updates = 0; ratingStyle.listen((value) { @@ -918,9 +904,9 @@ void ratingStyleSubject(SendPort port) async { } void androidPlaybackInfoSubject(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); final androidPlaybackInfo = handler.androidPlaybackInfo; - await handler.syncSubject(androidPlaybackInfo, 'androidPlaybackInfo'); + await (handler as dynamic).syncSubject(androidPlaybackInfo, 'androidPlaybackInfo'); port.send(isolateInitMessage); var updates = 0; androidPlaybackInfo.listen((value) { @@ -933,9 +919,9 @@ void androidPlaybackInfoSubject(SendPort port) async { } void customEventSubject(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); final customEvent = handler.customEvent; - await handler.syncSubject(customEvent, 'customEvent'); + await (handler as dynamic).syncSubject(customEvent, 'customEvent'); port.send(isolateInitMessage); var updates = 0; customEvent.listen((dynamic value) { @@ -948,9 +934,9 @@ void customEventSubject(SendPort port) async { } void customStateSubject(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); final customState = handler.customState; - await handler.syncSubject(customState, 'customState'); + await (handler as dynamic).syncSubject(customState, 'customState'); port.send(isolateInitMessage); var updates = 0; customState.listen((dynamic value) { @@ -963,199 +949,199 @@ void customStateSubject(SendPort port) async { } void prepare(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.prepare(); port.send(isolateInitMessage); } void prepareFromMediaId(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.prepareFromMediaId(id, map); port.send(isolateInitMessage); } void prepareFromSearch(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.prepareFromSearch(query, map); port.send(isolateInitMessage); } void prepareFromUri(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.prepareFromUri(uri, map); port.send(isolateInitMessage); } void play(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.play(); port.send(isolateInitMessage); } void playFromMediaId(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.playFromMediaId(id, map); port.send(isolateInitMessage); } void playFromSearch(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.playFromSearch(query, map); port.send(isolateInitMessage); } void playFromUri(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.playFromUri(uri, map); port.send(isolateInitMessage); } void playMediaItem(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.playMediaItem(mediaItem); port.send(isolateInitMessage); } void pause(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.pause(); port.send(isolateInitMessage); } void click(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.click(MediaButton.next); port.send(isolateInitMessage); } void stop(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.stop(); port.send(isolateInitMessage); } void addQueueItem(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.addQueueItem(mediaItem); port.send(isolateInitMessage); } void addQueueItems(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.addQueueItems(queue); port.send(isolateInitMessage); } void insertQueueItem(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.insertQueueItem(0, mediaItem); port.send(isolateInitMessage); } void updateQueue(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.updateQueue(queue); port.send(isolateInitMessage); } void updateMediaItem(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.updateMediaItem(mediaItem); port.send(isolateInitMessage); } void removeQueueItem(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.removeQueueItem(mediaItem); port.send(isolateInitMessage); } void removeQueueItemAt(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.removeQueueItemAt(0); port.send(isolateInitMessage); } void skipToNext(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.skipToNext(); port.send(isolateInitMessage); } void skipToPrevious(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.skipToPrevious(); port.send(isolateInitMessage); } void fastForward(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.fastForward(); port.send(isolateInitMessage); } void rewind(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.rewind(); port.send(isolateInitMessage); } void skipToQueueItem(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.skipToQueueItem(0); port.send(isolateInitMessage); } void seek(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.seek(duration); port.send(isolateInitMessage); } void setRating(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.setRating(rating, map); port.send(isolateInitMessage); } void setCaptioningEnabled(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.setCaptioningEnabled(false); port.send(isolateInitMessage); } void setRepeatMode(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.setRepeatMode(repeatMode); port.send(isolateInitMessage); } void setShuffleMode(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.setShuffleMode(shuffleMode); port.send(isolateInitMessage); } void seekBackward(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.seekBackward(false); port.send(isolateInitMessage); } void seekForward(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.seekForward(false); port.send(isolateInitMessage); } void setSpeed(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.setSpeed(0.1); port.send(isolateInitMessage); } void customAction(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); port.send(await handler.customAction( customActionName, customActionArguments, @@ -1163,24 +1149,24 @@ void customAction(SendPort port) async { } void onTaskRemoved(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.onTaskRemoved(); port.send(isolateInitMessage); } void onNotificationDeleted(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.onNotificationDeleted(); port.send(isolateInitMessage); } void getChildren(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); port.send(await handler.getChildren(id, map)); } void subscribeToChildren(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); final result = handler.subscribeToChildren(id); port.send(isolateInitMessage); result.listen((event) { @@ -1189,23 +1175,23 @@ void subscribeToChildren(SendPort port) async { } void getMediaItem(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); port.send(await handler.getMediaItem(id)); } void search(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); port.send(await handler.search(query, map)); } void androidAdjustRemoteVolume(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.androidAdjustRemoteVolume(androidVolumeDirection); port.send(isolateInitMessage); } void androidSetRemoteVolume(SendPort port) async { - final handler = IsolateAudioHandler(); + final handler = await AudioService.connectFromIsolate(); await handler.androidSetRemoteVolume(0); port.send(isolateInitMessage); } From e0a11f768477fd77883ca9d8c7983339f03cf867 Mon Sep 17 00:00:00 2001 From: nt4f04uNd Date: Sun, 4 Jul 2021 18:18:43 +0300 Subject: [PATCH 12/16] fix wrong comment --- audio_service/lib/audio_service.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/audio_service/lib/audio_service.dart b/audio_service/lib/audio_service.dart index 568e718f..8cad029b 100644 --- a/audio_service/lib/audio_service.dart +++ b/audio_service/lib/audio_service.dart @@ -896,9 +896,8 @@ class AudioService { /// Hosts the audio handler to other isolates. /// - /// Must be called from the main isolate, other isolates can connect - /// to the handler via [connectFromIsolate]. Can be called only once, - /// all consecutive calls will throw. + /// Other isolates can connect to the handler via [connectFromIsolate]. + /// Can be called only once, all consecutive calls will throw. /// /// Calling this method not from the main isolate may have unintended consequences, /// for example the isolate may become unreachable, because of being destroyed, From 5125edc1887d6e428b5ead903e234661c42567c1 Mon Sep 17 00:00:00 2001 From: nt4f04uNd Date: Mon, 5 Jul 2021 16:03:32 +0300 Subject: [PATCH 13/16] marked hostHandler as only for testing --- audio_service/lib/audio_service.dart | 58 ++++++++++++++-------------- audio_service/test/isolate_test.dart | 12 ++++-- 2 files changed, 38 insertions(+), 32 deletions(-) diff --git a/audio_service/lib/audio_service.dart b/audio_service/lib/audio_service.dart index 8cad029b..ceac8b9f 100644 --- a/audio_service/lib/audio_service.dart +++ b/audio_service/lib/audio_service.dart @@ -821,9 +821,7 @@ class AudioService { /// for example the isolate may become unreachable, because of being destroyed, /// or its engine being destroyed. /// - /// This method automatically hosts audio handler, so other isolates can - /// reach out to the handler with [connectFromIsolate]. For more details - /// on the lifecycle of hosted handler, see [hostHandler] documentation. + /// Other isolates can reach out to the handler with [connectFromIsolate]. /// /// You may optionally specify a [cacheManager] to use when loading artwork to /// display in the media notification and lock screen. This defaults to @@ -856,6 +854,15 @@ class AudioService { return handler; } + /// Connect to the [AudioHandler] from another isolate. The [AudioHandler] + /// must have been initialised via [init] prior to connecting. + static Future connectFromIsolate() async { + final handler = _IsolateAudioHandler( + IsolateNameServer.lookupPortByName(_testSyncIsolatePortName) == null); + await handler.init(); + return handler; + } + /// Port to host the handler on with [hostHandler]. static ReceivePort? _hostReceivePort; @@ -863,6 +870,8 @@ class AudioService { /// unintended behaviors. @visibleForTesting static const hostIsolatePortName = 'com.ryanheise.audioservice.port'; + static const _testSyncIsolatePortName = + 'com.ryanheise.audioservice.testSyncIsolate'; /// Set this to false to disable stream synching in tests for the [connectFromIsolate]. /// Not in test environment will do nothing. @@ -870,39 +879,27 @@ class AudioService { static set testSyncIsolate(bool value) { if (kDebugMode) { if (value) { - IsolateNameServer.removePortNameMapping(_testSyncIsolateKey); + IsolateNameServer.removePortNameMapping(_testSyncIsolatePortName); } else { final port = ReceivePort(); - IsolateNameServer.registerPortWithName(port.sendPort, _testSyncIsolateKey); + IsolateNameServer.registerPortWithName( + port.sendPort, _testSyncIsolatePortName); port.close(); } } } - static const _testSyncIsolateKey = 'com.ryanheise.audioservice.testSyncIsolate'; - - /// Connect to the [AudioHandler], which was hosted by calling [init] or - /// [hostHandler], from another isolate. - static Future connectFromIsolate() async { - final handler = _IsolateAudioHandler(IsolateNameServer.lookupPortByName(_testSyncIsolateKey) == null); - await handler.init(); - return handler; - } /// Whether currently there is a hosted handler available. - static bool get isHosting { + static bool get _isHosting { final sendPort = IsolateNameServer.lookupPortByName(hostIsolatePortName); return sendPort != null; } - /// Hosts the audio handler to other isolates. + /// Hosts the audio handler to other isolates, used for testing the [connectFromIsolate]. /// - /// Other isolates can connect to the handler via [connectFromIsolate]. - /// Can be called only once, all consecutive calls will throw. - /// - /// Calling this method not from the main isolate may have unintended consequences, - /// for example the isolate may become unreachable, because of being destroyed, - /// or its engine being destroyed. As a result of that, all the handlers from connected - /// isolates will stop receiving updates and calls to their methods will timeout. + /// Can be called only once, all consecutive calls will throw, + /// unless the isolate is manually removed from [IsolateNameServer]. + @visibleForTesting static void hostHandler(AudioHandler handler) { if (!kIsWeb) { if (handler is _IsolateAudioHandler) { @@ -911,7 +908,7 @@ class AudioService { "to an infinite loop when its methods are called", ); } - if (isHosting) { + if (_isHosting) { throw StateError("Some isolate has already hosted a handler"); } @@ -2490,7 +2487,7 @@ class _IsolateStreamSyncRequest { /// Handler that connects to the handler hosted with [AudioService.hostHandler]. /// -/// Used [AudioService.connectFromIsolate] in. +/// Used in [AudioService.connectFromIsolate]. class _IsolateAudioHandler implements BaseAudioHandler { @override final BehaviorSubject playbackState = BehaviorSubject(); @@ -2521,15 +2518,18 @@ class _IsolateAudioHandler implements BaseAudioHandler { /// The [init] should be called right away after that. _IsolateAudioHandler(this.testSyncIsolate); - /// Set this to true to disable stream synching in tests. + /// Set this to true to disable stream synching in tests, this is useful, + /// beucase synching can cause a lot of side-effects that prevent clean unit testing. + /// /// Not in test environment will do nothing. final bool testSyncIsolate; /// Synchronizes the subjects with the hosted isolate. Future init() async { - // Disable the synching for tests as this causes a lot of side effects - // in unit tests. - if (testSyncIsolate || !kDebugMode || kIsWeb || !Platform.environment.containsKey('FLUTTER_TEST')) { + if (testSyncIsolate || + !kDebugMode || + kIsWeb || + !Platform.environment.containsKey('FLUTTER_TEST')) { await Future.wait([ syncSubject(playbackState, 'playbackState'), syncSubject(queue, 'queue'), diff --git a/audio_service/test/isolate_test.dart b/audio_service/test/isolate_test.dart index 2cf8a8b4..b73f8b1a 100644 --- a/audio_service/test/isolate_test.dart +++ b/audio_service/test/isolate_test.dart @@ -93,13 +93,18 @@ const customStateStreamValues = [ 3, ]; +bool get isHosting { + return IsolateNameServer.lookupPortByName(AudioService.hostIsolatePortName) != + null; +} + Future main() async { TestWidgetsFlutterBinding.ensureInitialized(); final handler = MockBaseAudioHandler(); void host() { - if (!AudioService.isHosting) { + if (!isHosting) { AudioService.testSyncIsolate = false; AudioService.hostHandler(handler); } @@ -147,7 +152,7 @@ Future main() async { await runIsolate(hostHandlerIsolate); final handler = await AudioService.connectFromIsolate(); expect(handler.queue.value, const []); - expect(AudioService.isHosting, true); + expect(isHosting, true); }); test("throws timeout exception when host isolate dies", () async { @@ -906,7 +911,8 @@ void ratingStyleSubject(SendPort port) async { void androidPlaybackInfoSubject(SendPort port) async { final handler = await AudioService.connectFromIsolate(); final androidPlaybackInfo = handler.androidPlaybackInfo; - await (handler as dynamic).syncSubject(androidPlaybackInfo, 'androidPlaybackInfo'); + await (handler as dynamic) + .syncSubject(androidPlaybackInfo, 'androidPlaybackInfo'); port.send(isolateInitMessage); var updates = 0; androidPlaybackInfo.listen((value) { From ecf73910e95ef435425c5c486b1097aa6ad4ae5d Mon Sep 17 00:00:00 2001 From: nt4f04uNd Date: Mon, 5 Jul 2021 16:37:52 +0300 Subject: [PATCH 14/16] web error message --- audio_service/lib/audio_service.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/audio_service/lib/audio_service.dart b/audio_service/lib/audio_service.dart index ceac8b9f..ac6bcf1e 100644 --- a/audio_service/lib/audio_service.dart +++ b/audio_service/lib/audio_service.dart @@ -857,6 +857,7 @@ class AudioService { /// Connect to the [AudioHandler] from another isolate. The [AudioHandler] /// must have been initialised via [init] prior to connecting. static Future connectFromIsolate() async { + assert(!kIsWeb, "Isolates are not supported on web"); final handler = _IsolateAudioHandler( IsolateNameServer.lookupPortByName(_testSyncIsolatePortName) == null); await handler.init(); From 141b309f87db6e9fb4b440a527062801671320ca Mon Sep 17 00:00:00 2001 From: nt4f04uNd Date: Mon, 5 Jul 2021 18:18:00 +0300 Subject: [PATCH 15/16] remove unnecessary condition --- audio_service/lib/audio_service.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/audio_service/lib/audio_service.dart b/audio_service/lib/audio_service.dart index ac6bcf1e..e5b7ee65 100644 --- a/audio_service/lib/audio_service.dart +++ b/audio_service/lib/audio_service.dart @@ -2529,7 +2529,6 @@ class _IsolateAudioHandler implements BaseAudioHandler { Future init() async { if (testSyncIsolate || !kDebugMode || - kIsWeb || !Platform.environment.containsKey('FLUTTER_TEST')) { await Future.wait([ syncSubject(playbackState, 'playbackState'), From a06a115254bef4f012eafe2f5a3954fd2966e884 Mon Sep 17 00:00:00 2001 From: nt4f04uNd Date: Tue, 6 Jul 2021 18:33:17 +0300 Subject: [PATCH 16/16] + --- audio_service/example/lib/example_multiple_handlers.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/audio_service/example/lib/example_multiple_handlers.dart b/audio_service/example/lib/example_multiple_handlers.dart index 279c4383..6853ba04 100644 --- a/audio_service/example/lib/example_multiple_handlers.dart +++ b/audio_service/example/lib/example_multiple_handlers.dart @@ -235,7 +235,7 @@ class MainScreen extends StatelessWidget { } class QueueState { - final List? queue; + final List queue; final MediaItem? mediaItem; QueueState(this.queue, this.mediaItem);