diff --git a/audio_service/CHANGELOG.md b/audio_service/CHANGELOG.md index 5ce976e2..0db20e60 100644 --- a/audio_service/CHANGELOG.md +++ b/audio_service/CHANGELOG.md @@ -1,5 +1,7 @@ ## 0.18.13 +* Fix Android NPE in sendNotificationClicked and onConnected. +* Catch and broadcast async platform exceptions. * Fix setAndroidPlaybackInfo call blocking (@julianscheel). * Pass through missing extras to playFrom.../prepareFrom... (@Ruchit2759). diff --git a/audio_service/README.md b/audio_service/README.md index ef892c91..8ca0cc1c 100644 --- a/audio_service/README.md +++ b/audio_service/README.md @@ -306,10 +306,6 @@ Note: If your app uses a number of different audio plugins, e.g. for audio recor ## Android setup -These instructions assume that your project follows the Flutter 1.12 project template or later. If your project was created prior to 1.12 and uses the old project structure, you can update your project to follow the [new project template](https://github.com/flutter/flutter/wiki/Upgrading-pre-1.12-Android-projects). - -Additionally: - 1. Make the following changes to your project's `AndroidManifest.xml` file: ```xml @@ -349,7 +345,7 @@ Additionally: ``` -Note: when targeting Android 12 or above, you must set `android:exported` on each component that has an intent filter (the main activity, the service and the receiver). If the manifest merging process causes `"Instantiable"` lint warnings, use `tools:ignore="Instantiable"` (as above) to suppress them. +Note: As of Android 12, an app must have permission to restart a foreground service in the background, otherwise a `ForegroundServiceStartNotAllowedException` will be thrown. To avoid such an exception, you can either set `androidStopForegroundOnPause` to `false` in your `AudioServiceConfig` which keeps the service in the foreground during a pause so that restarting the foreground service is unnecessary, or you can keep the default `androidStopForegroundOnPause` setting of `true` (in line with best practices) and request the user to turn of battery optimisation for your app via the [optimize_battery](https://pub.dev/packages/optimize_battery) package. For more information, read [this page](https://developer.android.com/training/monitoring-device-state/doze-standby#support_for_other_use_cases). 2. If you use any custom icons in notification, create the file `android/app/src/main/res/raw/keep.xml` to prevent them from being stripped during the build process: diff --git a/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServicePlugin.java b/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServicePlugin.java index 7a8fec0e..bbfe877b 100644 --- a/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServicePlugin.java +++ b/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServicePlugin.java @@ -197,6 +197,7 @@ static AudioHandlerInterface audioHandlerInterface() throws Exception { private final MediaBrowserCompat.ConnectionCallback connectionCallback = new MediaBrowserCompat.ConnectionCallback() { @Override public void onConnected() { + if (applicationContext == null) return; try { MediaSessionCompat.Token token = mediaBrowser.getSessionToken(); mediaController = new MediaControllerCompat(applicationContext, token); @@ -215,8 +216,13 @@ public void onConnected() { configureResult = null; } } catch (Exception e) { + System.out.println("onConnected error: " + e.getMessage()); e.printStackTrace(); - throw new RuntimeException(e); + if (configureResult != null) { + configureResult.error("onConnected error: " + e.getMessage(), null, null); + } else { + clientInterface.setServiceConnectionFailed(true); + } } } @@ -379,7 +385,7 @@ private void registerOnNewIntentListener() { private void sendNotificationClicked() { Activity activity = clientInterface.activity; - if (activity.getIntent().getAction() != null) { + if (audioHandlerInterface != null && activity.getIntent().getAction() != null) { boolean clicked = activity.getIntent().getAction().equals(AudioService.NOTIFICATION_CLICK_ACTION); audioHandlerInterface.invokeMethod("onNotificationClicked", mapOf("clicked", clicked)); } @@ -810,168 +816,173 @@ public void onDestroy() { @Override public void onMethodCall(MethodCall call, Result result) { - Map args = (Map)call.arguments; - switch (call.method) { - case "setMediaItem": { - Executors.newSingleThreadExecutor().execute(() -> { - try { - Map rawMediaItem = (Map)args.get("mediaItem"); - MediaMetadataCompat mediaMetadata = createMediaMetadata(rawMediaItem); - AudioService.instance.setMetadata(mediaMetadata); - handler.post(() -> result.success(null)); - } catch (Exception e) { - handler.post(() -> { - result.error("UNEXPECTED_ERROR", "Unexpected error", Log.getStackTraceString(e)); - }); + try { + Map args = (Map)call.arguments; + switch (call.method) { + case "setMediaItem": { + Executors.newSingleThreadExecutor().execute(() -> { + try { + Map rawMediaItem = (Map)args.get("mediaItem"); + MediaMetadataCompat mediaMetadata = createMediaMetadata(rawMediaItem); + AudioService.instance.setMetadata(mediaMetadata); + handler.post(() -> result.success(null)); + } catch (Exception e) { + handler.post(() -> { + result.error("UNEXPECTED_ERROR", "Unexpected error", Log.getStackTraceString(e)); + }); + } + }); + break; + } + case "setQueue": { + Executors.newSingleThreadExecutor().execute(() -> { + try { + @SuppressWarnings("unchecked") List> rawQueue = (List>) args.get("queue"); + List queue = raw2queue(rawQueue); + AudioService.instance.setQueue(queue); + handler.post(() -> result.success(null)); + } catch (Exception e) { + handler.post(() -> { + result.error("UNEXPECTED_ERROR", "Unexpected error", Log.getStackTraceString(e)); + }); + } + }); + break; + } + case "setState": { + Map stateMap = (Map)args.get("state"); + AudioProcessingState processingState = AudioProcessingState.values()[(Integer)stateMap.get("processingState")]; + boolean playing = (Boolean)stateMap.get("playing"); + @SuppressWarnings("unchecked") List> rawControls = (List>)stateMap.get("controls"); + @SuppressWarnings("unchecked") List compactActionIndexList = (List)stateMap.get("androidCompactActionIndices"); + @SuppressWarnings("unchecked") List rawSystemActions = (List)stateMap.get("systemActions"); + long position = getLong(stateMap.get("updatePosition")); + long bufferedPosition = getLong(stateMap.get("bufferedPosition")); + float speed = (float)((double)((Double)stateMap.get("speed"))); + long updateTimeSinceEpoch = stateMap.get("updateTime") == null ? System.currentTimeMillis() : getLong(stateMap.get("updateTime")); + Integer errorCode = (Integer)stateMap.get("errorCode"); + String errorMessage = (String)stateMap.get("errorMessage"); + int repeatMode = (Integer)stateMap.get("repeatMode"); + int shuffleMode = (Integer)stateMap.get("shuffleMode"); + Long queueIndex = getLong(stateMap.get("queueIndex")); + boolean captioningEnabled = (Boolean)stateMap.get("captioningEnabled"); + + // On the flutter side, we represent the update time relative to the epoch. + // On the native side, we must represent the update time relative to the boot time. + long updateTimeSinceBoot = updateTimeSinceEpoch - bootTime; + + List actions = new ArrayList<>(); + long actionBits = 0; + for (Map rawControl : rawControls) { + String resource = (String)rawControl.get("androidIcon"); + String label = (String)rawControl.get("label"); + long actionCode = 1 << ((Integer)rawControl.get("action")); + actionBits |= actionCode; + Map customActionMap = (Map)rawControl.get("customAction"); + CustomMediaAction customAction = null; + if (customActionMap != null) { + String name = (String) customActionMap.get("name"); + Map extras = (Map) customActionMap.get("extras"); + customAction = new CustomMediaAction(name, extras); + } + actions.add(new MediaControl(resource, label, actionCode, customAction)); } - }); - break; - } - case "setQueue": { - Executors.newSingleThreadExecutor().execute(() -> { - try { - @SuppressWarnings("unchecked") List> rawQueue = (List>) args.get("queue"); - List queue = raw2queue(rawQueue); - AudioService.instance.setQueue(queue); - handler.post(() -> result.success(null)); - } catch (Exception e) { - handler.post(() -> { - result.error("UNEXPECTED_ERROR", "Unexpected error", Log.getStackTraceString(e)); - }); + for (Integer rawSystemAction : rawSystemActions) { + long actionCode = 1 << rawSystemAction; + actionBits |= actionCode; } - }); - break; - } - case "setState": { - Map stateMap = (Map)args.get("state"); - AudioProcessingState processingState = AudioProcessingState.values()[(Integer)stateMap.get("processingState")]; - boolean playing = (Boolean)stateMap.get("playing"); - @SuppressWarnings("unchecked") List> rawControls = (List>)stateMap.get("controls"); - @SuppressWarnings("unchecked") List compactActionIndexList = (List)stateMap.get("androidCompactActionIndices"); - @SuppressWarnings("unchecked") List rawSystemActions = (List)stateMap.get("systemActions"); - long position = getLong(stateMap.get("updatePosition")); - long bufferedPosition = getLong(stateMap.get("bufferedPosition")); - float speed = (float)((double)((Double)stateMap.get("speed"))); - long updateTimeSinceEpoch = stateMap.get("updateTime") == null ? System.currentTimeMillis() : getLong(stateMap.get("updateTime")); - Integer errorCode = (Integer)stateMap.get("errorCode"); - String errorMessage = (String)stateMap.get("errorMessage"); - int repeatMode = (Integer)stateMap.get("repeatMode"); - int shuffleMode = (Integer)stateMap.get("shuffleMode"); - Long queueIndex = getLong(stateMap.get("queueIndex")); - boolean captioningEnabled = (Boolean)stateMap.get("captioningEnabled"); - - // On the flutter side, we represent the update time relative to the epoch. - // On the native side, we must represent the update time relative to the boot time. - long updateTimeSinceBoot = updateTimeSinceEpoch - bootTime; - - List actions = new ArrayList<>(); - long actionBits = 0; - for (Map rawControl : rawControls) { - String resource = (String)rawControl.get("androidIcon"); - String label = (String)rawControl.get("label"); - long actionCode = 1 << ((Integer)rawControl.get("action")); - actionBits |= actionCode; - Map customActionMap = (Map)rawControl.get("customAction"); - CustomMediaAction customAction = null; - if (customActionMap != null) { - String name = (String) customActionMap.get("name"); - Map extras = (Map) customActionMap.get("extras"); - customAction = new CustomMediaAction(name, extras); + int[] compactActionIndices = null; + if (compactActionIndexList != null) { + compactActionIndices = new int[Math.min(AudioService.MAX_COMPACT_ACTIONS, compactActionIndexList.size())]; + for (int i = 0; i < compactActionIndices.length; i++) + compactActionIndices[i] = (Integer)compactActionIndexList.get(i); } - actions.add(new MediaControl(resource, label, actionCode, customAction)); + AudioService.instance.setState( + actions, + actionBits, + compactActionIndices, + processingState, + playing, + position, + bufferedPosition, + speed, + updateTimeSinceBoot, + errorCode, + errorMessage, + repeatMode, + shuffleMode, + captioningEnabled, + queueIndex); + result.success(null); + break; } - for (Integer rawSystemAction : rawSystemActions) { - long actionCode = 1 << rawSystemAction; - actionBits |= actionCode; + case "setAndroidPlaybackInfo": { + Map playbackInfo = (Map)args.get("playbackInfo"); + final int playbackType = (Integer)playbackInfo.get("playbackType"); + final Integer volumeControlType = (Integer)playbackInfo.get("volumeControlType"); + final Integer maxVolume = (Integer)playbackInfo.get("maxVolume"); + final Integer volume = (Integer)playbackInfo.get("volume"); + AudioService.instance.setPlaybackInfo(playbackType, volumeControlType, maxVolume, volume); + result.success(null); + break; } - int[] compactActionIndices = null; - if (compactActionIndexList != null) { - compactActionIndices = new int[Math.min(AudioService.MAX_COMPACT_ACTIONS, compactActionIndexList.size())]; - for (int i = 0; i < compactActionIndices.length; i++) - compactActionIndices[i] = (Integer)compactActionIndexList.get(i); + case "notifyChildrenChanged": { + String parentMediaId = (String)args.get("parentMediaId"); + Map options = (Map)args.get("options"); + AudioService.instance.notifyChildrenChanged(parentMediaId, mapToBundle(options)); + result.success(null); + break; } - AudioService.instance.setState( - actions, - actionBits, - compactActionIndices, - processingState, - playing, - position, - bufferedPosition, - speed, - updateTimeSinceBoot, - errorCode, - errorMessage, - repeatMode, - shuffleMode, - captioningEnabled, - queueIndex); - result.success(null); - break; - } - case "setAndroidPlaybackInfo": { - Map playbackInfo = (Map)args.get("playbackInfo"); - final int playbackType = (Integer)playbackInfo.get("playbackType"); - final Integer volumeControlType = (Integer)playbackInfo.get("volumeControlType"); - final Integer maxVolume = (Integer)playbackInfo.get("maxVolume"); - final Integer volume = (Integer)playbackInfo.get("volume"); - AudioService.instance.setPlaybackInfo(playbackType, volumeControlType, maxVolume, volume); - result.success(null); - break; - } - case "notifyChildrenChanged": { - String parentMediaId = (String)args.get("parentMediaId"); - Map options = (Map)args.get("options"); - AudioService.instance.notifyChildrenChanged(parentMediaId, mapToBundle(options)); - result.success(null); - break; - } - case "androidForceEnableMediaButtons": { - // Just play a short amount of silence. This convinces Android - // that we are playing "real" audio so that it will route - // media buttons to us. - // See: https://issuetracker.google.com/issues/65344811 - if (silenceAudioTrack == null) { - byte[] silence = new byte[2048]; - // TODO: Uncomment this after moving to a minSdkVersion of 21. - /* AudioAttributes audioAttributes = new AudioAttributes.Builder() */ - /* .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) */ - /* .setUsage(AudioAttributes.USAGE_MEDIA) */ - /* .build(); */ - /* AudioFormat audioFormat = new AudioFormat.Builder() */ - /* .setChannelMask(AudioFormat.CHANNEL_CONFIGURATION_MONO) */ - /* .setEncoding(AudioFormat.ENCODING_PCM_8BIT) */ - /* .setSampleRate(SILENCE_SAMPLE_RATE) */ - /* .build(); */ - /* silenceAudioTrack = new AudioTrack.Builder() */ - /* .setAudioAttributes(audioAttributes) */ - /* .setAudioFormat(audioFormat) */ - /* .setBufferSizeInBytes(silence.length) */ - /* .setTransferMode(AudioTrack.MODE_STATIC) */ - /* .build(); */ - @SuppressWarnings("deprecation") - final AudioTrack audioTrack = new AudioTrack( - AudioManager.STREAM_MUSIC, - SILENCE_SAMPLE_RATE, - AudioFormat.CHANNEL_CONFIGURATION_MONO, - AudioFormat.ENCODING_PCM_8BIT, - silence.length, - AudioTrack.MODE_STATIC); - silenceAudioTrack = audioTrack; - silenceAudioTrack.write(silence, 0, silence.length); + case "androidForceEnableMediaButtons": { + // Just play a short amount of silence. This convinces Android + // that we are playing "real" audio so that it will route + // media buttons to us. + // See: https://issuetracker.google.com/issues/65344811 + if (silenceAudioTrack == null) { + byte[] silence = new byte[2048]; + // TODO: Uncomment this after moving to a minSdkVersion of 21. + /* AudioAttributes audioAttributes = new AudioAttributes.Builder() */ + /* .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) */ + /* .setUsage(AudioAttributes.USAGE_MEDIA) */ + /* .build(); */ + /* AudioFormat audioFormat = new AudioFormat.Builder() */ + /* .setChannelMask(AudioFormat.CHANNEL_CONFIGURATION_MONO) */ + /* .setEncoding(AudioFormat.ENCODING_PCM_8BIT) */ + /* .setSampleRate(SILENCE_SAMPLE_RATE) */ + /* .build(); */ + /* silenceAudioTrack = new AudioTrack.Builder() */ + /* .setAudioAttributes(audioAttributes) */ + /* .setAudioFormat(audioFormat) */ + /* .setBufferSizeInBytes(silence.length) */ + /* .setTransferMode(AudioTrack.MODE_STATIC) */ + /* .build(); */ + @SuppressWarnings("deprecation") + final AudioTrack audioTrack = new AudioTrack( + AudioManager.STREAM_MUSIC, + SILENCE_SAMPLE_RATE, + AudioFormat.CHANNEL_CONFIGURATION_MONO, + AudioFormat.ENCODING_PCM_8BIT, + silence.length, + AudioTrack.MODE_STATIC); + silenceAudioTrack = audioTrack; + silenceAudioTrack.write(silence, 0, silence.length); + } + silenceAudioTrack.reloadStaticData(); + silenceAudioTrack.play(); + result.success(null); + break; } - silenceAudioTrack.reloadStaticData(); - silenceAudioTrack.play(); - result.success(null); - break; - } - case "stopService": { - if (AudioService.instance != null) { - AudioService.instance.stop(); + case "stopService": { + if (AudioService.instance != null) { + AudioService.instance.stop(); + } + result.success(null); + break; } - result.success(null); - break; - } + } + } catch (Exception e) { + e.printStackTrace(); + result.error(e.getMessage(), null, null); } } diff --git a/audio_service/example/lib/example_multiple_handlers.dart b/audio_service/example/lib/example_multiple_handlers.dart index d270d4b3..1e6fc3cd 100644 --- a/audio_service/example/lib/example_multiple_handlers.dart +++ b/audio_service/example/lib/example_multiple_handlers.dart @@ -1,4 +1,4 @@ -// ignore_for_file: public_member_api_docs +// ignore_for_file: public_member_api_docs, avoid_print // This example demonstrates: // @@ -50,6 +50,8 @@ Future main() async { androidNotificationOngoing: true, ), ); + // Log async errors from the platform. + AudioService.asyncError.listen((e) => print('Async error: $e')); runApp(const MyApp()); } diff --git a/audio_service/lib/audio_service.dart b/audio_service/lib/audio_service.dart index 88fe1de4..236da843 100644 --- a/audio_service/lib/audio_service.dart +++ b/audio_service/lib/audio_service.dart @@ -971,6 +971,11 @@ class AudioService { /// A stream that broadcasts the status of the notificationClick event. static ValueStream get notificationClicked => _notificationClicked; + static final _asyncError = PublishSubject(); + + /// A stream that broadcasts any exceptions that occur asynchronously. + static Stream get asyncError => _asyncError; + static final _compatibilitySwitcher = SwitchAudioHandler(); /// Register the app's [AudioHandler] with configuration options. This must be @@ -1023,8 +1028,10 @@ class AudioService { artFetchOperationId = operationId; final artUri = mediaItem.artUri; if (artUri == null || artUri.scheme == 'content') { - _platform.setMediaItem( - SetMediaItemRequest(mediaItem: mediaItem._toMessage())); + _platform + .setMediaItem( + SetMediaItemRequest(mediaItem: mediaItem._toMessage())) + .catchError(_asyncError.add); } else { /// Sends media item to the platform. /// We potentially need to fetch the art before that. @@ -1041,7 +1048,7 @@ class AudioService { } if (artUri.scheme == 'file') { - sendToPlatform(artUri.toFilePath()); + sendToPlatform(artUri.toFilePath()).catchError(_asyncError.add); } else { // Try to load a cached file from memory. final fileInfo = @@ -1053,12 +1060,17 @@ class AudioService { if (filePath != null) { // If we successfully downloaded the art call to platform. - sendToPlatform(filePath); + sendToPlatform(filePath).catchError(_asyncError.add); } else { // We haven't fetched the art yet, so show the metadata now, and again // after we load the art. - await _platform.setMediaItem( - SetMediaItemRequest(mediaItem: mediaItem._toMessage())); + try { + await _platform.setMediaItem( + SetMediaItemRequest(mediaItem: mediaItem._toMessage())); + } catch (e) { + _asyncError.add(e); + return; + } if (operationId != artFetchOperationId) { return; } @@ -1069,7 +1081,7 @@ class AudioService { } // If we successfully downloaded the art, call to platform. if (loadedFilePath != null) { - sendToPlatform(loadedFilePath); + sendToPlatform(loadedFilePath).catchError(_asyncError.add); } } } @@ -1079,9 +1091,13 @@ class AudioService { static Future _observeAndroidPlaybackInfo() async { await for (var playbackInfo in _handler.androidPlaybackInfo) { - await _platform.setAndroidPlaybackInfo(SetAndroidPlaybackInfoRequest( - playbackInfo: playbackInfo._toMessage(), - )); + try { + await _platform.setAndroidPlaybackInfo(SetAndroidPlaybackInfoRequest( + playbackInfo: playbackInfo._toMessage(), + )); + } catch (e) { + _asyncError.add(e); + } } } @@ -1090,21 +1106,29 @@ class AudioService { if (_config.preloadArtwork) { _loadAllArtwork(queue); } - await _platform.setQueue(SetQueueRequest( - queue: queue.map((item) => item._toMessage()).toList())); + try { + await _platform.setQueue(SetQueueRequest( + queue: queue.map((item) => item._toMessage()).toList())); + } catch (e) { + _asyncError.add(e); + } } } static Future _observePlaybackState() async { var previousState = _handler.playbackState.nvalue; await for (var playbackState in _handler.playbackState) { - await _platform - .setState(SetStateRequest(state: playbackState._toMessage())); - if (playbackState.processingState == AudioProcessingState.idle && - previousState?.processingState != AudioProcessingState.idle) { - await AudioService._stop(); + try { + await _platform + .setState(SetStateRequest(state: playbackState._toMessage())); + if (playbackState.processingState == AudioProcessingState.idle && + previousState?.processingState != AudioProcessingState.idle) { + await AudioService._stop(); + } + previousState = playbackState; + } catch (e) { + _asyncError.add(e); } - previousState = playbackState; } } diff --git a/audio_service/pubspec.yaml b/audio_service/pubspec.yaml index 8124cb76..bb5418a9 100644 --- a/audio_service/pubspec.yaml +++ b/audio_service/pubspec.yaml @@ -36,7 +36,7 @@ dependencies: audio_session: ^0.1.16 rxdart: '>=0.26.0 <0.28.0' - flutter_cache_manager: ^3.0.1 + flutter_cache_manager: ^3.3.1 clock: ^1.1.0 js: ^0.6.3 flutter: