Refactor notification management

Remove PlaybackNotificationHolder and replace it with a util file that is easier to work with.
This commit is contained in:
OxygenCobalt 2020-11-01 13:58:47 -07:00
parent 08bd0ece3a
commit 5da3fa866b
6 changed files with 231 additions and 230 deletions

View file

@ -16,6 +16,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:windowSoftInputMode="adjustPan"> android:windowSoftInputMode="adjustPan">
<intent-filter> <intent-filter>
@ -26,7 +27,8 @@
</activity> </activity>
<service <service
android:name=".playback.PlaybackService" android:name=".playback.PlaybackService"
android:icon="@drawable/ic_launcher_foreground" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:foregroundServiceType="mediaPlayback" android:foregroundServiceType="mediaPlayback"
android:exported="false" android:exported="false"
android:enabled="true" android:enabled="true"

View file

@ -14,6 +14,7 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentLoadingBinding import org.oxycblt.auxio.databinding.FragmentLoadingBinding
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.processing.MusicLoaderResponse import org.oxycblt.auxio.music.processing.MusicLoaderResponse
class LoadingFragment : Fragment(R.layout.fragment_loading) { class LoadingFragment : Fragment(R.layout.fragment_loading) {
@ -27,6 +28,13 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View? {
// If the music was already loaded, then don't do it again.
if (MusicStore.getInstance().loaded) {
findNavController().navigate(
LoadingFragmentDirections.actionToMain()
)
}
val binding = FragmentLoadingBinding.inflate(inflater) val binding = FragmentLoadingBinding.inflate(inflater)
// Set up the permission launcher, as its disallowed outside of onCreate. // Set up the permission launcher, as its disallowed outside of onCreate.

View file

@ -22,6 +22,9 @@ class MusicStore private constructor() {
private var mSongs = listOf<Song>() private var mSongs = listOf<Song>()
val songs: List<Song> get() = mSongs val songs: List<Song> get() = mSongs
var loaded = false
private set
// Load/Sort the entire library. // Load/Sort the entire library.
// ONLY CALL THIS FROM AN IO THREAD. // ONLY CALL THIS FROM AN IO THREAD.
fun load(app: Application): MusicLoaderResponse { fun load(app: Application): MusicLoaderResponse {
@ -69,6 +72,10 @@ class MusicStore private constructor() {
) )
} }
if (loader.response == MusicLoaderResponse.DONE) {
loaded = true
}
return loader.response return loader.response
} }

View file

@ -0,0 +1,152 @@
package org.oxycblt.auxio.playback
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.support.v4.media.session.MediaSessionCompat
import androidx.core.app.NotificationCompat
import androidx.media.app.NotificationCompat.MediaStyle
import org.oxycblt.auxio.MainActivity
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.coil.getBitmap
import org.oxycblt.auxio.playback.state.LoopMode
import org.oxycblt.auxio.playback.state.PlaybackStateManager
object NotificationUtils {
const val CHANNEL_ID = "CHANNEL_AUXIO_PLAYBACK"
const val NOTIFICATION_ID = 0xA0A0
const val REQUEST_CODE = 0xA0C0
const val ACTION_LOOP = "ACTION_AUXIO_LOOP"
const val ACTION_SKIP_PREV = "ACTION_AUXIO_SKIP_PREV"
const val ACTION_PLAY_PAUSE = "ACTION_AUXIO_PLAY_PAUSE"
const val ACTION_SKIP_NEXT = "ACTION_AUXIO_SKIP_NEXT"
const val ACTION_SHUFFLE = "ACTION_AUXIO_SHUFFLE"
}
fun NotificationManager.createMediaNotification(
context: Context,
mediaSession: MediaSessionCompat
): NotificationCompat.Builder {
// Create a notification channel if required
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
NotificationUtils.CHANNEL_ID,
context.getString(R.string.label_notification_playback),
NotificationManager.IMPORTANCE_DEFAULT
)
createNotificationChannel(channel)
}
val mainIntent = PendingIntent.getActivity(
context, NotificationUtils.REQUEST_CODE,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT
)
// TODO: It would be cool if the notification intent took you to the now playing screen.
return NotificationCompat.Builder(context, NotificationUtils.CHANNEL_ID)
.setSmallIcon(R.drawable.ic_song)
.setStyle(
MediaStyle()
.setMediaSession(mediaSession.sessionToken)
.setShowActionsInCompactView(1, 2, 3)
)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setChannelId(NotificationUtils.CHANNEL_ID)
.setShowWhen(false)
.setTicker(context.getString(R.string.title_playback))
.addAction(newAction(NotificationUtils.ACTION_LOOP, context))
.addAction(newAction(NotificationUtils.ACTION_SKIP_PREV, context))
.addAction(newAction(NotificationUtils.ACTION_PLAY_PAUSE, context))
.addAction(newAction(NotificationUtils.ACTION_SKIP_NEXT, context))
.addAction(newAction(NotificationUtils.ACTION_SHUFFLE, context))
.setSubText(context.getString(R.string.title_playback))
.setContentIntent(mainIntent)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
}
fun NotificationCompat.Builder.setMetadata(song: Song, context: Context, onDone: () -> Unit) {
setContentTitle(song.name)
setContentText(
song.album.artist.name,
)
getBitmap(song, context) {
setLargeIcon(it)
onDone()
}
}
@SuppressLint("RestrictedApi")
fun NotificationCompat.Builder.updateLoop(context: Context) {
mActions[0] = newAction(NotificationUtils.ACTION_LOOP, context)
}
@SuppressLint("RestrictedApi")
fun NotificationCompat.Builder.updatePlaying(context: Context) {
mActions[2] = newAction(NotificationUtils.ACTION_PLAY_PAUSE, context)
}
@SuppressLint("RestrictedApi")
fun NotificationCompat.Builder.updateShuffle(context: Context) {
mActions[4] = newAction(NotificationUtils.ACTION_SHUFFLE, context)
}
private fun newAction(action: String, context: Context): NotificationCompat.Action {
val playbackManager = PlaybackStateManager.getInstance()
val drawable = when (action) {
NotificationUtils.ACTION_LOOP -> {
when (playbackManager.loopMode) {
LoopMode.NONE -> R.drawable.ic_loop_disabled
LoopMode.ONCE -> R.drawable.ic_loop_one
LoopMode.INFINITE -> R.drawable.ic_loop
}
}
NotificationUtils.ACTION_SKIP_PREV -> {
R.drawable.ic_skip_prev
}
NotificationUtils.ACTION_PLAY_PAUSE -> {
if (playbackManager.isPlaying) {
R.drawable.ic_pause
} else {
R.drawable.ic_play
}
}
NotificationUtils.ACTION_SKIP_NEXT -> {
R.drawable.ic_skip_next
}
NotificationUtils.ACTION_SHUFFLE -> {
if (playbackManager.isShuffling) {
R.drawable.ic_shuffle
} else {
R.drawable.ic_shuffle_disabled
}
}
else -> R.drawable.ic_play
}
return NotificationCompat.Action.Builder(
drawable, action, newPlaybackIntent(action, context)
).build()
}
private fun newPlaybackIntent(action: String, context: Context): PendingIntent {
return PendingIntent.getBroadcast(
context, NotificationUtils.REQUEST_CODE, Intent(action), PendingIntent.FLAG_UPDATE_CURRENT
)
}

View file

@ -1,208 +0,0 @@
package org.oxycblt.auxio.playback
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.support.v4.media.session.MediaSessionCompat
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.media.app.NotificationCompat.MediaStyle
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.coil.getBitmap
import org.oxycblt.auxio.playback.state.LoopMode
import org.oxycblt.auxio.playback.state.PlaybackStateManager
// Holder for the playback notification, should only be used by PlaybackService.
// TODO: You really need to rewrite this class. Christ.
// TODO: Disable skip prev/next buttons when you cant do those actions
// TODO: Implement a way to exit the notification
class PlaybackNotificationHolder {
private lateinit var mNotification: Notification
private lateinit var notificationManager: NotificationManager
private lateinit var baseNotification: NotificationCompat.Builder
private val playbackManager = PlaybackStateManager.getInstance()
private var isForeground = false
fun init(context: Context, session: MediaSessionCompat, playbackService: PlaybackService) {
// Never run if the notification has already been created
if (!::mNotification.isInitialized) {
notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Create a notification channel if required
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.label_notification_playback),
NotificationManager.IMPORTANCE_DEFAULT
)
notificationManager.createNotificationChannel(channel)
}
baseNotification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_song)
.setStyle(
MediaStyle()
.setMediaSession(session.sessionToken)
.setShowActionsInCompactView(1, 2, 3)
)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setChannelId(CHANNEL_ID)
.setShowWhen(false)
.setTicker(playbackService.getString(R.string.title_playback))
.addAction(createAction(ACTION_LOOP, playbackService))
.addAction(createAction(ACTION_SKIP_PREV, playbackService))
.addAction(createAction(ACTION_PLAY_PAUSE, playbackService))
.addAction(createAction(ACTION_SKIP_NEXT, playbackService))
.addAction(createAction(ACTION_SHUFFLE, playbackService))
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
mNotification = baseNotification.build()
}
}
fun setMetadata(song: Song, playbackService: PlaybackService) {
// Set the basic metadata since MediaStyle wont do it yourself.
// Fun Fact: The documentation still says that MediaStyle will handle metadata changes
// from MediaSession, even though it doesn't. Its been 6 years. Fun.
baseNotification
.setContentTitle(song.name)
.setContentText(
playbackService.getString(
R.string.format_info,
song.album.artist.name,
song.album.name
)
)
getBitmap(song, playbackService) {
baseNotification.setLargeIcon(it)
startForegroundOrNotify(playbackService)
}
}
@SuppressLint("RestrictedApi")
fun updatePlaying(playbackService: PlaybackService) {
baseNotification.mActions[2] = createAction(ACTION_PLAY_PAUSE, playbackService)
Log.d(this::class.simpleName, baseNotification.mActions[1].iconCompat?.resId.toString())
startForegroundOrNotify(playbackService)
}
@SuppressLint("RestrictedApi")
fun updateLoop(playbackService: PlaybackService) {
baseNotification.mActions[0] = createAction(ACTION_LOOP, playbackService)
startForegroundOrNotify(playbackService)
}
@SuppressLint("RestrictedApi")
fun updateShuffle(playbackService: PlaybackService) {
baseNotification.mActions[4] = createAction(ACTION_SHUFFLE, playbackService)
startForegroundOrNotify(playbackService)
}
fun stop(playbackService: PlaybackService) {
playbackService.stopForeground(true)
notificationManager.cancel(NOTIFICATION_ID)
isForeground = false
}
private fun createAction(action: String, playbackService: PlaybackService): NotificationCompat.Action {
val drawable = when (action) {
ACTION_LOOP -> {
when (playbackManager.loopMode) {
LoopMode.NONE -> R.drawable.ic_loop_disabled
LoopMode.ONCE -> R.drawable.ic_loop_one
LoopMode.INFINITE -> R.drawable.ic_loop
}
}
ACTION_SKIP_PREV -> {
R.drawable.ic_skip_prev
}
ACTION_PLAY_PAUSE -> {
if (playbackManager.isPlaying) {
R.drawable.ic_pause
} else {
R.drawable.ic_play
}
}
ACTION_SKIP_NEXT -> {
R.drawable.ic_skip_next
}
ACTION_SHUFFLE -> {
if (playbackManager.isShuffling) {
R.drawable.ic_shuffle
} else {
R.drawable.ic_shuffle_disabled
}
}
else -> R.drawable.ic_play
}
return NotificationCompat.Action.Builder(
drawable, action, createPlaybackAction(action, playbackService)
).build()
}
private fun createPlaybackAction(action: String, playbackService: PlaybackService): PendingIntent {
val intent = Intent()
intent.action = action
return PendingIntent.getBroadcast(
playbackService,
REQUEST_CODE,
intent,
PendingIntent.FLAG_UPDATE_CURRENT
)
}
private fun startForegroundOrNotify(playbackService: PlaybackService) {
mNotification = baseNotification.build()
// Start the service in the foreground if haven't already.
if (!isForeground) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
playbackService.startForeground(
NOTIFICATION_ID, mNotification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
)
} else {
playbackService.startForeground(NOTIFICATION_ID, mNotification)
}
} else {
notificationManager.notify(NOTIFICATION_ID, mNotification)
}
}
companion object {
const val CHANNEL_ID = "CHANNEL_AUXIO_PLAYBACK"
const val NOTIFICATION_ID = 0xA0A0
const val REQUEST_CODE = 0xA0C0
const val ACTION_LOOP = "ACTION_AUXIO_LOOP"
const val ACTION_SKIP_PREV = "ACTION_AUXIO_SKIP_PREV"
const val ACTION_PLAY_PAUSE = "ACTION_AUXIO_PLAY_PAUSE"
const val ACTION_SKIP_NEXT = "ACTION_AUXIO_SKIP_NEXT"
const val ACTION_SHUFFLE = "ACTION_AUXIO_SHUFFLE"
}
}

View file

@ -1,11 +1,13 @@
package org.oxycblt.auxio.playback package org.oxycblt.auxio.playback
import android.app.NotificationManager
import android.app.Service import android.app.Service
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.ServiceInfo
import android.media.AudioManager import android.media.AudioManager
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
@ -14,6 +16,7 @@ import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import androidx.core.app.NotificationCompat
import com.google.android.exoplayer2.C import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlaybackException import com.google.android.exoplayer2.ExoPlaybackException
import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem
@ -47,9 +50,12 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
private val playbackManager = PlaybackStateManager.getInstance() private val playbackManager = PlaybackStateManager.getInstance()
private lateinit var mediaSession: MediaSessionCompat private lateinit var mediaSession: MediaSessionCompat
private lateinit var systemReceiver: SystemEventReceiver private lateinit var systemReceiver: SystemEventReceiver
private lateinit var notificationHolder: PlaybackNotificationHolder
private lateinit var notificationManager: NotificationManager
private lateinit var notification: NotificationCompat.Builder
private var changeIsFromAudioFocus = true private var changeIsFromAudioFocus = true
private var isForeground = false
private val serviceJob = Job() private val serviceJob = Job()
private val serviceScope = CoroutineScope( private val serviceScope = CoroutineScope(
@ -103,11 +109,12 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
// Set up callback for system events // Set up callback for system events
systemReceiver = SystemEventReceiver() systemReceiver = SystemEventReceiver()
IntentFilter().apply { IntentFilter().apply {
addAction(PlaybackNotificationHolder.ACTION_LOOP) addAction(NotificationUtils.ACTION_LOOP)
addAction(PlaybackNotificationHolder.ACTION_SKIP_PREV) addAction(NotificationUtils.ACTION_SKIP_PREV)
addAction(PlaybackNotificationHolder.ACTION_PLAY_PAUSE) addAction(NotificationUtils.ACTION_PLAY_PAUSE)
addAction(PlaybackNotificationHolder.ACTION_SKIP_NEXT) addAction(NotificationUtils.ACTION_SKIP_NEXT)
addAction(PlaybackNotificationHolder.ACTION_SHUFFLE) addAction(NotificationUtils.ACTION_SHUFFLE)
addAction(BluetoothDevice.ACTION_ACL_CONNECTED) addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED) addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
@ -118,9 +125,9 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
// --- NOTIFICATION SETUP --- // --- NOTIFICATION SETUP ---
notificationHolder = PlaybackNotificationHolder() notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationHolder.init(applicationContext, mediaSession, this) notification = notificationManager.createMediaNotification(this, mediaSession)
// --- PLAYBACKSTATEMANAGER SETUP --- // --- PLAYBACKSTATEMANAGER SETUP ---
@ -134,7 +141,7 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
notificationHolder.stop(this) stopForegroundAndNotification()
unregisterReceiver(systemReceiver) unregisterReceiver(systemReceiver)
// Release everything that could cause a memory leak if left around // Release everything that could cause a memory leak if left around
@ -199,14 +206,16 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
player.play() player.play()
uploadMetadataToSession(it) uploadMetadataToSession(it)
notificationHolder.setMetadata(playbackManager.song!!, this) notification.setMetadata(playbackManager.song!!, this) {
startForegroundOrNotify()
}
return return
} }
// Stop playing/the notification if there's nothing to play. // Stop playing/the notification if there's nothing to play.
player.stop() player.stop()
notificationHolder.stop(this) stopForegroundAndNotification()
} }
override fun onPlayingUpdate(isPlaying: Boolean) { override fun onPlayingUpdate(isPlaying: Boolean) {
@ -214,18 +223,23 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
if (isPlaying && !player.isPlaying) { if (isPlaying && !player.isPlaying) {
player.play() player.play()
notificationHolder.updatePlaying(this) notification.updatePlaying(this)
startForegroundOrNotify()
startPollingPosition() startPollingPosition()
} else { } else {
player.pause() player.pause()
notificationHolder.updatePlaying(this)
notification.updatePlaying(this)
startForegroundOrNotify()
} }
} }
override fun onShuffleUpdate(isShuffling: Boolean) { override fun onShuffleUpdate(isShuffling: Boolean) {
changeIsFromAudioFocus = false changeIsFromAudioFocus = false
notificationHolder.updateShuffle(this) notification.updateShuffle(this)
startForegroundOrNotify()
} }
override fun onLoopUpdate(mode: LoopMode) { override fun onLoopUpdate(mode: LoopMode) {
@ -240,7 +254,8 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
} }
} }
notificationHolder.updateLoop(this) notification.updateLoop(this)
startForegroundOrNotify()
} }
override fun onSeekConfirm(position: Long) { override fun onSeekConfirm(position: Long) {
@ -258,7 +273,9 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
player.prepare() player.prepare()
player.seekTo(playbackManager.position) player.seekTo(playbackManager.position)
notificationHolder.setMetadata(it, this) notification.setMetadata(it, this) {
startForegroundOrNotify()
}
} }
} }
@ -297,6 +314,29 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
} }
} }
private fun startForegroundOrNotify() {
// Start the service in the foreground if haven't already.
if (!isForeground) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NotificationUtils.NOTIFICATION_ID, notification.build(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
)
} else {
startForeground(NotificationUtils.NOTIFICATION_ID, notification.build())
}
} else {
notificationManager.notify(NotificationUtils.NOTIFICATION_ID, notification.build())
}
}
private fun stopForegroundAndNotification() {
stopForeground(true)
notificationManager.cancel(NotificationUtils.NOTIFICATION_ID)
isForeground = false
}
// Handle a media button event. // Handle a media button event.
private fun handleMediaButtonEvent(event: Intent): Boolean { private fun handleMediaButtonEvent(event: Intent): Boolean {
val item = event val item = event
@ -344,13 +384,13 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
action?.let { action?.let {
when (it) { when (it) {
PlaybackNotificationHolder.ACTION_LOOP -> NotificationUtils.ACTION_LOOP ->
playbackManager.setLoopMode(playbackManager.loopMode.increment()) playbackManager.setLoopMode(playbackManager.loopMode.increment())
PlaybackNotificationHolder.ACTION_SKIP_PREV -> playbackManager.prev() NotificationUtils.ACTION_SKIP_PREV -> playbackManager.prev()
PlaybackNotificationHolder.ACTION_PLAY_PAUSE -> NotificationUtils.ACTION_PLAY_PAUSE ->
playbackManager.setPlayingStatus(!playbackManager.isPlaying) playbackManager.setPlayingStatus(!playbackManager.isPlaying)
PlaybackNotificationHolder.ACTION_SKIP_NEXT -> playbackManager.next() NotificationUtils.ACTION_SKIP_NEXT -> playbackManager.next()
PlaybackNotificationHolder.ACTION_SHUFFLE -> NotificationUtils.ACTION_SHUFFLE ->
playbackManager.setShuffleStatus(!playbackManager.isShuffling) playbackManager.setShuffleStatus(!playbackManager.isShuffling)
BluetoothDevice.ACTION_ACL_CONNECTED -> resume() BluetoothDevice.ACTION_ACL_CONNECTED -> resume()