androidx / media

Jetpack Media3 support libraries for media use cases, including ExoPlayer, an extensible media player for Android
https://developer.android.com/media/media3
Apache License 2.0
1.69k stars 405 forks source link

How to handle ForegroundServiceStartNotAllowedException while retrying playback after a network timeout error #1342

Open saravr opened 6 months ago

saravr commented 6 months ago

I want to retry playback when a player error is detected (SourceException indicating timeout). However, while attempting to retry, I get this exception. UI (MediaController) is intact (player in idle state). My retry code in the service determines that foreground service is not running (as determined by isServiceRunningInForeground()) in the following code. However, I see it is still running when I use the adb shell command.

adb shell dumpsys activity services com.myapp.MyMediaService | grep app=
    app=ProcessRecord{dd404dc 7272:com.myapp.player/u0a172}

I also see allowStartForeground=PROC_STATE_TOP in the above command output (while playback is going on). And then it changes to DENIED which indicates

  1. Is the foreground service really running? not sure why I get inconsistent observation
  2. how do I make retry work in this case? are there any best practices document that explains how this can be achieved while using Media3?

Here is my code:

    class MyMediaService : MediaSessionService() {
        private var retryJob: Job? = null
        private var mediaSession: MediaSession? = null
        private val retryScope = CoroutineScope(Dispatchers.IO)

        override fun onCreate() {
            super.onCreate()
            val customFactory = object : MediaSource.Factory { ... }
            val player = ExoPlayer.Builder(this)
                .setMediaSourceFactory(customFactory)
                .build()
                .apply {
                    addListener(object : Player.Listener {
                        override fun onPlayerError(error: PlaybackException) {
                            super.onPlayerError(error)
                            when (error) {
                                is ExoPlaybackException -> {
                                    if (error.type == ExoPlaybackException.TYPE_SOURCE) {
                                        retryJob?.cancel()
                                        retryJob = retryScope.launch { // Just retry once
                                            delay(5000L)
                                            restartPlayback()
                                        }
                                    }
                                }
                            }
                        }
                    })
                    playWhenReady = true
                }

            val intent = packageManager.getLaunchIntentForPackage(packageName)
            val pendingIntent = PendingIntent.getActivity(this, 0, intent, FLAG_IMMUTABLE)
            mediaSession = MediaSession.Builder(this, player)
                .setId("XXX")
                .setSessionActivity(pendingIntent)
                .build()

            setListener(object : Listener {
                override fun onForegroundServiceStartNotAllowedException() {
                    super.onForegroundServiceStartNotAllowedException()
                    Timber.d("Overriding onForegroundServiceStartNotAllowedException()")
                }
            })
        }

        override fun onTaskRemoved(rootIntent: Intent?) {
            mediaSession?.player?.stop()
            stopForeground(STOP_FOREGROUND_REMOVE)
            stopSelf()
            super.onTaskRemoved(rootIntent)
        }

        override fun onDestroy() {
            mediaSession?.run {
                player.release()
                release()
                mediaSession = null
            }
            super.onDestroy()
        }

        override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
            return mediaSession
        }

        @Suppress("DEPRECATION")
        private fun isServiceRunningInForeground(): Boolean {
            val manager = getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
            val runningServices = manager?.getRunningServices(Int.MAX_VALUE)
            return runningServices?.any {
                it.foreground && it.service.className == MyMediaService::class.java.name
            } ?: false
        }

        private fun startForegroundService() {
            val serviceIntent = Intent(this, MyMediaService::class.java)
            try {
                ContextCompat.startForegroundService(this, serviceIntent)
            } catch (e: ForegroundServiceStartNotAllowedException) {
                Timber.e("ForegroundServiceStartNotAllowedException caught: ${e.message}")
            }
        }

        private suspend fun restartPlayback() = withContext(Dispatchers.Main) {
            if (!isServiceRunningInForeground()) {
                startForegroundService()
                //startForeground()
                mediaSession?.player?.let { player ->
                    if (player.playbackState == Player.STATE_IDLE || player.playbackState == Player.STATE_ENDED) {
                        player.prepare()
                    } else if (player.playbackState == Player.STATE_READY) {
                        player.play()
                    }
                }
            } else {
                mediaSession?.player?.prepare()
                mediaSession?.player?.play()
            }
        }
    }
marcbaechinger commented 6 months ago

Thanks for reporting!

Is the foreground service really running? not sure why I get inconsistent observation

The service is running but not in the foreground. The session housed by the service is alive as long as the service isn't terminated. To verify, you can do a adb shell dumpsys media_session to see that the session is there but in state 7 which is the error state.

If it is kept in this state, then the platform will terminate it after a short period of time. To get the service into the foreground again a user interaction from the foreground is required.

how do I make retry work in this case? are there any best practices document that explains how this can be achieved while using Media3?

In this state, it is sufficient to call player.prepare()/play() again in your restartService method because the service is still running. However, once the service is off the foreground, resuming is only allowed from the foreground which is normally a user interaction on an app UI that is in the foreground (for instance an activity), or from a notification.

My understanding is that from the service itself that is in the background, the only option would be to post a notification when you detect the error. The user can then use the notification to initiate resuming playback which puts the service into the foreground again.

One way to do such a notification is implementing playback resumption with a MediaLibraryService. In this case a user can retry by using the playback resumption notification posted by System UI (or pressing play on the BT headset). It has the advantage that it knows about the last playing item and will look identically to the notification/UMO the user is used to. The disadvantage is that it isn't very prominently placed.

I tried this out by adding playback resumption to the session demo app. With this in place the workflow is as follows. I changed the URI of "Intro - The Way Of Waking Up" of the Kyoto Connection in catalogue.json so that the URI is producing an error because the file does not exist:

  1. User starts app and playback.
  2. User start playback of the song that can't be loaded and produces a source exception.
  3. Player fails and the platform session is set to state ERROR=7. (playbackStateCompat.getState() == 7; verify with adb shell dumpsys media_session)
  4. Media3 takes the service off the foreground and removes the notification. Note that the service is still running and the session alive.
  5. System UI places the playback resumption notification with the artwork and metadata of the item that failed playing. The button has a play button.
  6. User presses the play button.
  7. The service is still running with the session in error state and receives the play command and prepares the player again and issues a play command.
  8. For my case, the URI is still wrong, so I see the execptions in the logcat. However, if for your case the network is available again, playback would now continue without causing an exception because the user has initiated this by an interaction from the foreground (notiifcation).

I think this is how you can solve this. If you don't want to use playback resumption, you can instead post your own notification that the user can see. This has the advantage that it is more prominent and it's more likely the user sees it. The playback resumption notification is a bit hidden. The disadvantage of doing your own notification is that you need to do it on your own which may be difficult or a bit of effort if it should look the same as the UMO.

saravr commented 6 months ago

@marcbaechinger thank you so much for the detailed response. I will evaluate and try this approach out. I am noticing that some of the popular media playback apps maintain the notification even when the network is turned off in the middle. Explicit notification to restart playback is ok but ideally, I would like to have this behavior where notification is "maintained" throughout. Anyways, I will try this approach and will let know how this helps.