Fix persistence bugs with notification

Fix issues where the notification for Auxio will show up incomplete during the restore process.
This commit is contained in:
OxygenCobalt 2020-11-17 11:23:14 -07:00
parent 27777bf352
commit da224ffda0
6 changed files with 122 additions and 78 deletions

View file

@ -12,7 +12,6 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import androidx.navigation.findNavController
import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.NavigationUI import androidx.navigation.ui.NavigationUI
@ -50,8 +49,8 @@ class MainFragment : Fragment() {
getInactiveAlpha(accent.first) getInactiveAlpha(accent.first)
) )
// Set up the tints for the navigation icon // Set up the tints for the navigation icons + text
val navIconTints = ColorStateList( val navTints = ColorStateList(
arrayOf( arrayOf(
intArrayOf(-android.R.attr.state_checked), intArrayOf(-android.R.attr.state_checked),
intArrayOf(android.R.attr.state_checked) intArrayOf(android.R.attr.state_checked)
@ -68,8 +67,8 @@ class MainFragment : Fragment() {
binding.lifecycleOwner = this binding.lifecycleOwner = this
binding.navBar.itemIconTintList = navIconTints binding.navBar.itemIconTintList = navTints
binding.navBar.itemTextColor = navIconTints binding.navBar.itemTextColor = navTints
navController?.let { navController?.let {
binding.navBar.setOnNavigationItemSelectedListener { item -> binding.navBar.setOnNavigationItemSelectedListener { item ->
@ -93,6 +92,8 @@ class MainFragment : Fragment() {
} }
} }
playbackModel.restorePlaybackIfNeeded(requireContext())
Log.d(this::class.simpleName, "Fragment Created.") Log.d(this::class.simpleName, "Fragment Created.")
return binding.root return binding.root

View file

@ -4,14 +4,27 @@ import android.net.Uri
// --- MUSIC MODELS --- // --- MUSIC MODELS ---
// The base model for all music /**
// This is used in a lot of general functions in order to have generic utilities * The base data object for all music.
* @property id The ID that is assigned to this object
* @property name The name of this object (Such as a song title)
* @author OxygenCobalt
*/
sealed class BaseModel { sealed class BaseModel {
abstract val id: Long abstract val id: Long
abstract val name: String abstract val name: String
} }
// Song /**
* The data object for a song. Inherits [BaseModel].
* @property albumId The Song's Album ID. Never use this outside of when attaching a song to its album.
* @property track The Song's Track number
* @property duration The duration of the song, in millis.
* @property album The Song's parent album. Use this instead of [albumId].
* @property seconds The Song's duration in seconds
* @property formattedDuration The Song's duration as a duration string.
* @author OxygenCobalt
*/
data class Song( data class Song(
override val id: Long = -1, override val id: Long = -1,
override var name: String, override var name: String,
@ -25,7 +38,17 @@ data class Song(
val formattedDuration: String = seconds.toDuration() val formattedDuration: String = seconds.toDuration()
} }
// Album /**
* The data object for an album. Inherits [BaseModel].
* @property artistId The Album's parent artist ID. Do not use this outside of attaching an album to its parent artist.
* @property coverUri The [Uri] for the album's cover. **Load this using Coil.**
* @property year The year this album was released. 0 if there is none in the metadata.
* @property artist The Album's parent [Artist]. use this instead of [artistId]
* @property songs The Album's child [Song]s.
* @property numSongs The amount of songs in an Album.
* @property totalDuration The combined duration of all of the album's child songs, formatted.
* @author OxygenCobalt
*/
data class Album( data class Album(
override val id: Long = -1, override val id: Long = -1,
override val name: String, override val name: String,
@ -46,7 +69,10 @@ data class Album(
} }
} }
// Artist /**
* The data object for an artist. Inherits [BaseModel]
* @author OxygenCobalt
*/
data class Artist( data class Artist(
override val id: Long = -1, override val id: Long = -1,
override var name: String override var name: String
@ -71,7 +97,12 @@ data class Artist(
} }
} }
// Genre /**
* The data object for a genre. Inherits [BaseModel]
* @property artists The list of all [Artist]s in this genre
* @property songs The list of all [Song]s in this genre.
* @author OxygenCobalt
*/
data class Genre( data class Genre(
override val id: Long = -1, override val id: Long = -1,
override var name: String, override var name: String,
@ -102,7 +133,9 @@ data class Genre(
} }
} }
// Header [Used for search, nothing else] /**
* A data object used solely for the "Header" UI element. Inherits [BaseModel].
*/
data class Header( data class Header(
override val id: Long = -1, override val id: Long = -1,
override var name: String = "", override var name: String = "",

View file

@ -19,8 +19,6 @@ import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
object NotificationUtils { object NotificationUtils {
val DO_COMPAT_SUBTEXT = Build.VERSION.SDK_INT < Build.VERSION_CODES.O
const val CHANNEL_ID = "CHANNEL_AUXIO_PLAYBACK" const val CHANNEL_ID = "CHANNEL_AUXIO_PLAYBACK"
const val NOTIFICATION_ID = 0xA0A0 const val NOTIFICATION_ID = 0xA0A0
const val REQUEST_CODE = 0xA0C0 const val REQUEST_CODE = 0xA0C0
@ -81,6 +79,13 @@ fun NotificationManager.createMediaNotification(
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
} }
/**
* Set the current metadata of a media notification.
* @param song The [Song] that the notification should reflect
* @param context The [Context] needed to load the cover bitmap
* @param onDone A callback for when the process is finished
* @author OxygenCobalt
*/
fun NotificationCompat.Builder.setMetadata(song: Song, context: Context, onDone: () -> Unit) { fun NotificationCompat.Builder.setMetadata(song: Song, context: Context, onDone: () -> Unit) {
setContentTitle(song.name) setContentTitle(song.name)
setContentText( setContentText(
@ -89,7 +94,7 @@ fun NotificationCompat.Builder.setMetadata(song: Song, context: Context, onDone:
// On older versions of android [API <26], show the song's album on the subtext instead of // On older versions of android [API <26], show the song's album on the subtext instead of
// the current mode, as that makes more sense for the old style of media notifications. // the current mode, as that makes more sense for the old style of media notifications.
if (NotificationUtils.DO_COMPAT_SUBTEXT) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
setSubText(song.album.name) setSubText(song.album.name)
} }
@ -102,7 +107,7 @@ fun NotificationCompat.Builder.setMetadata(song: Song, context: Context, onDone:
} }
} }
// I have no idea how to update actions on the fly so I have to use these restricted APIs. // I have no idea how to update a specific action on the fly so I have to use these restricted APIs
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
fun NotificationCompat.Builder.updatePlaying(context: Context) { fun NotificationCompat.Builder.updatePlaying(context: Context) {
mActions[2] = newAction(NotificationUtils.ACTION_PLAY_PAUSE, context) mActions[2] = newAction(NotificationUtils.ACTION_PLAY_PAUSE, context)
@ -114,7 +119,7 @@ fun NotificationCompat.Builder.updateLoop(context: Context) {
} }
fun NotificationCompat.Builder.updateMode(context: Context) { fun NotificationCompat.Builder.updateMode(context: Context) {
if (!NotificationUtils.DO_COMPAT_SUBTEXT) { if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) {
val playbackManager = PlaybackStateManager.getInstance() val playbackManager = PlaybackStateManager.getInstance()
// If playing from all songs, set the subtext as that, otherwise the currently played parent. // If playing from all songs, set the subtext as that, otherwise the currently played parent.
@ -126,6 +131,11 @@ fun NotificationCompat.Builder.updateMode(context: Context) {
} }
} }
/**
* Create a new [NotificationCompat.Action].
* @param action The action that the notification action should represent
* @param context The [Context] needed to create the action
*/
private fun newAction(action: String, context: Context): NotificationCompat.Action { private fun newAction(action: String, context: Context): NotificationCompat.Action {
val playbackManager = PlaybackStateManager.getInstance() val playbackManager = PlaybackStateManager.getInstance()

View file

@ -145,6 +145,19 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
if (playbackManager.song != null) { if (playbackManager.song != null) {
restorePlayer() restorePlayer()
notification.updateLoop(this)
notification.updateMode(this)
notification.updatePlaying(this)
playbackManager.song?.let {
notification.setMetadata(it, this) {
if (playbackManager.isPlaying) {
startForegroundOrNotify("Restore")
} else {
stopForegroundAndNotification()
}
}
}
} }
} }
@ -223,8 +236,13 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
} }
uploadMetadataToSession(it) uploadMetadataToSession(it)
notification.setMetadata(playbackManager.song!!, this) {
startForegroundOrNotify() if (playbackManager.isRestored) {
notification.setMetadata(playbackManager.song!!, this) {
startForegroundOrNotify("Song")
}
} else {
notification.setMetadata(playbackManager.song!!, this) {}
} }
return return
@ -238,7 +256,7 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
override fun onModeUpdate(mode: PlaybackMode) { override fun onModeUpdate(mode: PlaybackMode) {
notification.updateMode(this) notification.updateMode(this)
startForegroundOrNotify() startForegroundOrNotify("Mode")
} }
override fun onPlayingUpdate(isPlaying: Boolean) { override fun onPlayingUpdate(isPlaying: Boolean) {
@ -247,13 +265,13 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
if (isPlaying && !player.isPlaying) { if (isPlaying && !player.isPlaying) {
player.play() player.play()
notification.updatePlaying(this) notification.updatePlaying(this)
startForegroundOrNotify() startForegroundOrNotify("Play")
startPollingPosition() startPollingPosition()
} else { } else {
player.pause() player.pause()
notification.updatePlaying(this) notification.updatePlaying(this)
startForegroundOrNotify() startForegroundOrNotify("Pause")
} }
} }
@ -270,7 +288,7 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
} }
notification.updateLoop(this) notification.updateLoop(this)
startForegroundOrNotify() startForegroundOrNotify("Loop")
} }
override fun onSeekConfirm(position: Long) { override fun onSeekConfirm(position: Long) {
@ -279,20 +297,6 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
player.seekTo(position) player.seekTo(position)
} }
override fun onNeedContextToRestoreState() {
Log.d(this::class.simpleName, "Giving context to PlaybackStateManager")
serviceScope.launch {
playbackManager.getStateFromDatabase(this@PlaybackService)
}
Log.d(this::class.simpleName, "notification restore")
// FIXME: Current position just will not show on notification when state is restored
restorePlayer()
restoreNotification()
}
// --- OTHER FUNCTIONS --- // --- OTHER FUNCTIONS ---
private fun restorePlayer() { private fun restorePlayer() {
@ -301,27 +305,13 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
player.setMediaItem(item) player.setMediaItem(item)
player.prepare() player.prepare()
player.seekTo(playbackManager.position) player.seekTo(playbackManager.position)
notification.setMetadata(it, this) {
startForegroundOrNotify()
}
} }
} }
private fun restoreNotification() { override fun onRestoreFinish() {
playbackManager.song?.let { Log.d(this::class.simpleName, "Restore done")
uploadMetadataToSession(it)
notification.updateLoop(this)
notification.updateMode(this)
notification.updatePlaying(this)
notification.setMetadata(it, this) {
startForegroundOrNotify()
}
return restorePlayer()
}
stopForegroundAndNotification()
} }
private fun uploadMetadataToSession(song: Song) { private fun uploadMetadataToSession(song: Song) {
@ -356,19 +346,23 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
} }
} }
private fun startForegroundOrNotify() { private fun startForegroundOrNotify(reason: String) {
// Start the service in the foreground if haven't already. // Start the service in the foreground if haven't already.
if (!isForeground) { if (playbackManager.isRestored) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { Log.d(this::class.simpleName, "Starting foreground because of $reason")
startForeground(
NotificationUtils.NOTIFICATION_ID, notification.build(), if (!isForeground) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK 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 { } else {
startForeground(NotificationUtils.NOTIFICATION_ID, notification.build()) notificationManager.notify(NotificationUtils.NOTIFICATION_ID, notification.build())
} }
} else {
notificationManager.notify(NotificationUtils.NOTIFICATION_ID, notification.build())
} }
} }

View file

@ -1,10 +1,13 @@
package org.oxycblt.auxio.playback package org.oxycblt.auxio.playback
import android.content.Context
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel import org.oxycblt.auxio.music.BaseModel
@ -78,14 +81,10 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
// If the PlaybackViewModel was cleared [Signified by PlaybackStateManager still being // If the PlaybackViewModel was cleared [Signified by PlaybackStateManager still being
// around & the fact that we are in the init function], then attempt to restore the // around & the fact that we are in the init function], then attempt to restore the
// viewmodel state. // viewmodel state. If it isn't, then wait for MainFragment to give the command to restore
// PlaybackStateManager.
if (playbackManager.isRestored) { if (playbackManager.isRestored) {
restorePlaybackState() restorePlaybackState()
} else {
// If [PlaybackStateManger] was also cleared [Due to Auxio's process dying], then
// attempt to restore it [Albeit PlaybackService will need to do the job as it
// has a context while PlaybackViewModel doesn't].
playbackManager.needContextToRestoreState()
} }
} }
@ -249,6 +248,14 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
mIsSeeking.value = value mIsSeeking.value = value
} }
fun restorePlaybackIfNeeded(context: Context) {
if (!playbackManager.isRestored) {
viewModelScope.launch {
playbackManager.getStateFromDatabase(context)
}
}
}
// --- OVERRIDES --- // --- OVERRIDES ---
override fun onCleared() { override fun onCleared() {

View file

@ -24,7 +24,6 @@ import kotlin.random.Random
* *
* All instantiation should be done with [PlaybackStateManager.from()]. * All instantiation should be done with [PlaybackStateManager.from()].
* @author OxygenCobalt * @author OxygenCobalt
* FIXME: Add started variable instead of relying on mSong being null
*/ */
class PlaybackStateManager private constructor() { class PlaybackStateManager private constructor() {
// Playback // Playback
@ -455,10 +454,6 @@ class PlaybackStateManager private constructor() {
// TODO: Persist queue edits? // TODO: Persist queue edits?
// FIXME: Shuffling w/o knowing the original queue edit from keepSong will cause issues // FIXME: Shuffling w/o knowing the original queue edit from keepSong will cause issues
fun needContextToRestoreState() {
callbacks.forEach { it.onNeedContextToRestoreState() }
}
suspend fun saveStateToDatabase(context: Context) { suspend fun saveStateToDatabase(context: Context) {
Log.d(this::class.simpleName, "Saving state to DB.") Log.d(this::class.simpleName, "Saving state to DB.")
@ -483,16 +478,19 @@ class PlaybackStateManager private constructor() {
return@withContext states return@withContext states
} }
mIsRestored = true
if (states.isEmpty()) { if (states.isEmpty()) {
Log.d(this::class.simpleName, "Nothing here. Not restoring.") Log.d(this::class.simpleName, "Nothing here. Not restoring.")
mIsRestored = true
return return
} }
Log.d(this::class.simpleName, "Old state found, ${states[0]}") Log.d(this::class.simpleName, "Old state found, ${states[0]}")
unpackFromPlaybackState(states[0]) unpackFromPlaybackState(states[0])
mIsRestored = true
} }
private fun packToPlaybackState(): PlaybackState { private fun packToPlaybackState(): PlaybackState {
@ -580,6 +578,7 @@ class PlaybackStateManager private constructor() {
callbacks.forEach { callbacks.forEach {
it.onSeekConfirm(mPosition) it.onSeekConfirm(mPosition)
it.onModeUpdate(mMode) it.onModeUpdate(mMode)
it.onRestoreFinish()
} }
} }
@ -600,7 +599,7 @@ class PlaybackStateManager private constructor() {
fun onShuffleUpdate(isShuffling: Boolean) {} fun onShuffleUpdate(isShuffling: Boolean) {}
fun onLoopUpdate(mode: LoopMode) {} fun onLoopUpdate(mode: LoopMode) {}
fun onSeekConfirm(position: Long) {} fun onSeekConfirm(position: Long) {}
fun onNeedContextToRestoreState() {} fun onRestoreFinish() {}
} }
companion object { companion object {