ui: add service/notification abstractions

Add additional abstractions for service foreground states and their
notifications.
This commit is contained in:
OxygenCobalt 2022-07-09 13:16:41 -06:00
parent a63b3791d2
commit caa755c12f
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
8 changed files with 188 additions and 117 deletions

View file

@ -17,34 +17,20 @@
package org.oxycblt.auxio.music.system package org.oxycblt.auxio.music.system
import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getSystemServiceSafe import org.oxycblt.auxio.ui.system.ServiceNotification
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.newMainPendingIntent import org.oxycblt.auxio.util.newMainPendingIntent
/** The notification responsible for showing the indexer state. */ /** The notification responsible for showing the indexer state. */
class IndexingNotification(private val context: Context) : class IndexingNotification(private val context: Context) :
NotificationCompat.Builder(context, CHANNEL_ID) { ServiceNotification(context, INDEXER_CHANNEL) {
private val notificationManager = context.getSystemServiceSafe(NotificationManager::class)
init { init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.info_indexer_channel_name),
NotificationManager.IMPORTANCE_LOW)
notificationManager.createNotificationChannel(channel)
}
setSmallIcon(R.drawable.ic_indexer_24) setSmallIcon(R.drawable.ic_indexer_24)
setCategory(NotificationCompat.CATEGORY_PROGRESS) setCategory(NotificationCompat.CATEGORY_PROGRESS)
setShowWhen(false) setShowWhen(false)
@ -56,9 +42,8 @@ class IndexingNotification(private val context: Context) :
setProgress(0, 0, true) setProgress(0, 0, true)
} }
fun renotify() { override val code: Int
notificationManager.notify(IntegerTable.INDEXER_NOTIFICATION_CODE, build()) get() = IntegerTable.INDEXER_NOTIFICATION_CODE
}
fun updateIndexingState(indexing: Indexer.Indexing): Boolean { fun updateIndexingState(indexing: Indexer.Indexing): Boolean {
when (indexing) { when (indexing) {
@ -82,27 +67,11 @@ class IndexingNotification(private val context: Context) :
return false return false
} }
companion object {
const val CHANNEL_ID = BuildConfig.APPLICATION_ID + ".channel.INDEXER"
}
} }
/** The notification responsible for showing the indexer state. */ /** The notification responsible for showing the indexer state. */
class ObservingNotification(context: Context) : NotificationCompat.Builder(context, CHANNEL_ID) { class ObservingNotification(context: Context) : ServiceNotification(context, INDEXER_CHANNEL) {
private val notificationManager = context.getSystemServiceSafe(NotificationManager::class)
init { init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.info_indexer_channel_name),
NotificationManager.IMPORTANCE_LOW)
notificationManager.createNotificationChannel(channel)
}
setSmallIcon(R.drawable.ic_indexer_24) setSmallIcon(R.drawable.ic_indexer_24)
setCategory(NotificationCompat.CATEGORY_SERVICE) setCategory(NotificationCompat.CATEGORY_SERVICE)
setShowWhen(false) setShowWhen(false)
@ -113,11 +82,12 @@ class ObservingNotification(context: Context) : NotificationCompat.Builder(conte
setContentText(context.getString(R.string.lbl_observing_desc)) setContentText(context.getString(R.string.lbl_observing_desc))
} }
fun renotify() { override val code: Int
notificationManager.notify(IntegerTable.INDEXER_NOTIFICATION_CODE, build()) get() = IntegerTable.INDEXER_NOTIFICATION_CODE
} }
companion object { private val INDEXER_CHANNEL =
const val CHANNEL_ID = BuildConfig.APPLICATION_ID + ".channel.INDEXER" ServiceNotification.ChannelInfo(
} id = BuildConfig.APPLICATION_ID + ".channel.INDEXER",
} R.string.info_indexer_channel_name,
NotificationManager.IMPORTANCE_LOW)

View file

@ -17,7 +17,6 @@
package org.oxycblt.auxio.music.system package org.oxycblt.auxio.music.system
import android.app.Notification
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.database.ContentObserver import android.database.ContentObserver
@ -25,17 +24,16 @@ import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper import android.os.Looper
import android.provider.MediaStore import android.provider.MediaStore
import androidx.core.app.ServiceCompat
import coil.imageLoader import coil.imageLoader
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.system.ForegroundManager
import org.oxycblt.auxio.util.contentResolverSafe import org.oxycblt.auxio.util.contentResolverSafe
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -49,8 +47,6 @@ import org.oxycblt.auxio.util.logD
* boilerplate you skip is not worth the insanity of androidx. * boilerplate you skip is not worth the insanity of androidx.
* *
* @author OxygenCobalt * @author OxygenCobalt
*
* TODO: Add abstractions for services. notifications, and generations
*/ */
class IndexerService : Service(), Indexer.Controller, Settings.Callback { class IndexerService : Service(), Indexer.Controller, Settings.Callback {
private val indexer = Indexer.getInstance() private val indexer = Indexer.getInstance()
@ -63,19 +59,20 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
private val playbackManager = PlaybackStateManager.getInstance() private val playbackManager = PlaybackStateManager.getInstance()
private lateinit var settings: Settings private lateinit var settings: Settings
private var isForeground = false private lateinit var foregroundManager: ForegroundManager
private lateinit var indexingNotification: IndexingNotification private lateinit var indexingNotification: IndexingNotification
private lateinit var observingNotification: ObservingNotification private lateinit var observingNotification: ObservingNotification
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
settings = Settings(this, this) foregroundManager = ForegroundManager(this)
indexerContentObserver = SystemContentObserver()
indexingNotification = IndexingNotification(this) indexingNotification = IndexingNotification(this)
observingNotification = ObservingNotification(this) observingNotification = ObservingNotification(this)
settings = Settings(this, this)
indexerContentObserver = SystemContentObserver()
indexer.registerController(this) indexer.registerController(this)
if (musicStore.library == null && indexer.isIndeterminate) { if (musicStore.library == null && indexer.isIndeterminate) {
logD("No library present and no previous response, indexing music now") logD("No library present and no previous response, indexing music now")
@ -92,6 +89,8 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
foregroundManager.release()
// De-initialize the components first to prevent stray reloading events // De-initialize the components first to prevent stray reloading events
settings.release() settings.release()
indexerContentObserver.release() indexerContentObserver.release()
@ -162,9 +161,9 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// notification when initially starting, we will not update the notification // notification when initially starting, we will not update the notification
// unless it indicates that we have changed it. // unless it indicates that we have changed it.
val changed = indexingNotification.updateIndexingState(state) val changed = indexingNotification.updateIndexingState(state)
if (!tryStartForeground(indexingNotification.build()) && changed) { if (!foregroundManager.tryStartForeground(indexingNotification) && changed) {
logD("Notification changed, re-posting notification") logD("Notification changed, re-posting notification")
indexingNotification.renotify() indexingNotification.post()
} }
} }
@ -176,34 +175,14 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// we can go foreground later. // we can go foreground later.
// 2. If a non-foreground service is killed, the app will probably still be alive, // 2. If a non-foreground service is killed, the app will probably still be alive,
// and thus the music library will not be updated at all. // and thus the music library will not be updated at all.
if (!tryStartForeground(observingNotification.build())) { if (!foregroundManager.tryStartForeground(observingNotification)) {
observingNotification.renotify() observingNotification.post()
} }
} else { } else {
tryStopForeground() foregroundManager.tryStopForeground()
} }
} }
private fun tryStartForeground(notification: Notification): Boolean {
if (isForeground) {
return false
}
startForeground(IntegerTable.INDEXER_NOTIFICATION_CODE, notification)
isForeground = true
return true
}
private fun tryStopForeground() {
if (!isForeground) {
return
}
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
isForeground = false
}
// --- SETTING CALLBACKS --- // --- SETTING CALLBACKS ---
override fun onSettingChanged(key: String) { override fun onSettingChanged(key: String) {

View file

@ -423,9 +423,11 @@ class PlaybackStateManager private constructor() {
isPlaying = false isPlaying = false
notifyNewPlayback() notifyNewPlayback()
if (index > -1) {
// Controller may have reloaded the media item, re-seek to the previous position // Controller may have reloaded the media item, re-seek to the previous position
seekTo(oldPosition) seekTo(oldPosition)
} }
}
private fun makeStateImpl() = private fun makeStateImpl() =
PlaybackStateDatabase.SavedState( PlaybackStateDatabase.SavedState(

View file

@ -43,6 +43,9 @@ import org.oxycblt.auxio.util.logD
* using something like MediaSessionConnector is more or less impossible. * using something like MediaSessionConnector is more or less impossible.
* *
* @author OxygenCobalt * @author OxygenCobalt
*
* TODO: Update textual metadata first, then cover metadata later. Janky, yes, but also resolves
* some coherency issues.
*/ */
class MediaSessionComponent( class MediaSessionComponent(
private val context: Context, private val context: Context,
@ -54,12 +57,13 @@ class MediaSessionComponent(
PlaybackStateManager.Callback, PlaybackStateManager.Callback,
Settings.Callback { Settings.Callback {
interface Callback { interface Callback {
fun onPostNotification(notification: NotificationComponent) fun onPostNotification(notification: NotificationComponent?)
} }
val mediaSession = MediaSessionCompat(context, context.packageName).apply { isActive = true }
private val playbackManager = PlaybackStateManager.getInstance() private val playbackManager = PlaybackStateManager.getInstance()
private val settings = Settings(context, this) private val settings = Settings(context, this)
val mediaSession = MediaSessionCompat(context, context.packageName).apply { isActive = true }
private val notification = NotificationComponent(context, mediaSession.sessionToken) private val notification = NotificationComponent(context, mediaSession.sessionToken)
private val provider = BitmapProvider(context) private val provider = BitmapProvider(context)
@ -98,6 +102,7 @@ class MediaSessionComponent(
private fun updateMediaMetadata(song: Song?, parent: MusicParent?) { private fun updateMediaMetadata(song: Song?, parent: MusicParent?) {
if (song == null) { if (song == null) {
mediaSession.setMetadata(emptyMetadata) mediaSession.setMetadata(emptyMetadata)
callback.onPostNotification(null)
return return
} }

View file

@ -18,7 +18,6 @@
package org.oxycblt.auxio.playback.system package org.oxycblt.auxio.playback.system
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
@ -31,7 +30,7 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.util.getSystemServiceSafe import org.oxycblt.auxio.ui.system.ServiceNotification
import org.oxycblt.auxio.util.newBroadcastPendingIntent import org.oxycblt.auxio.util.newBroadcastPendingIntent
import org.oxycblt.auxio.util.newMainPendingIntent import org.oxycblt.auxio.util.newMainPendingIntent
@ -43,20 +42,8 @@ import org.oxycblt.auxio.util.newMainPendingIntent
*/ */
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
class NotificationComponent(private val context: Context, sessionToken: MediaSessionCompat.Token) : class NotificationComponent(private val context: Context, sessionToken: MediaSessionCompat.Token) :
NotificationCompat.Builder(context, CHANNEL_ID) { ServiceNotification(context, CHANNEL_INFO) {
private val notificationManager = context.getSystemServiceSafe(NotificationManager::class)
init { init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.info_playback_channel_name),
NotificationManager.IMPORTANCE_DEFAULT)
notificationManager.createNotificationChannel(channel)
}
setSmallIcon(R.drawable.ic_auxio_24) setSmallIcon(R.drawable.ic_auxio_24)
setCategory(NotificationCompat.CATEGORY_TRANSPORT) setCategory(NotificationCompat.CATEGORY_TRANSPORT)
setShowWhen(false) setShowWhen(false)
@ -75,9 +62,8 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
setStyle(MediaStyle().setMediaSession(sessionToken).setShowActionsInCompactView(1, 2, 3)) setStyle(MediaStyle().setMediaSession(sessionToken).setShowActionsInCompactView(1, 2, 3))
} }
fun renotify() { override val code: Int
notificationManager.notify(IntegerTable.PLAYBACK_NOTIFICATION_CODE, build()) get() = IntegerTable.PLAYBACK_NOTIFICATION_CODE
}
// --- STATE FUNCTIONS --- // --- STATE FUNCTIONS ---
@ -164,6 +150,10 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
} }
companion object { companion object {
const val CHANNEL_ID = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK" val CHANNEL_INFO =
ChannelInfo(
id = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK",
nameRes = R.string.info_playback_channel_name,
importance = NotificationManager.IMPORTANCE_LOW)
} }
} }

View file

@ -24,7 +24,6 @@ import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.media.AudioManager import android.media.AudioManager
import android.os.IBinder import android.os.IBinder
import androidx.core.app.ServiceCompat
import com.google.android.exoplayer2.C import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem
@ -45,7 +44,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
@ -53,6 +51,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateDatabase
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.system.ForegroundManager
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.widgets.WidgetComponent import org.oxycblt.auxio.widgets.WidgetComponent
import org.oxycblt.auxio.widgets.WidgetProvider import org.oxycblt.auxio.widgets.WidgetProvider
@ -93,7 +92,7 @@ class PlaybackService :
private lateinit var settings: Settings private lateinit var settings: Settings
// State // State
private var isForeground = false private lateinit var foregroundManager: ForegroundManager
private var hasPlayed = false private var hasPlayed = false
// Coroutines // Coroutines
@ -106,6 +105,8 @@ class PlaybackService :
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
foregroundManager = ForegroundManager(this)
// --- PLAYER SETUP --- // --- PLAYER SETUP ---
replayGainProcessor = ReplayGainAudioProcessor(this) replayGainProcessor = ReplayGainAudioProcessor(this)
@ -163,8 +164,7 @@ class PlaybackService :
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) foregroundManager.release()
isForeground = false
// Pause just in case this destruction was unexpected. // Pause just in case this destruction was unexpected.
playbackManager.isPlaying = false playbackManager.isPlaying = false
@ -272,14 +272,17 @@ class PlaybackService :
player.playWhenReady = isPlaying player.playWhenReady = isPlaying
} }
override fun onPostNotification(notification: NotificationComponent) { override fun onPostNotification(notification: NotificationComponent?) {
if (hasPlayed && playbackManager.song != null) { if (notification == null) {
if (!isForeground) { // This case is only here if I ever need to move foreground stopping from
startForeground(IntegerTable.PLAYBACK_NOTIFICATION_CODE, notification.build()) // the player code to the notification code.
isForeground = true logD("No notification, ignoring")
} else { return
// If we are already in foreground just update the notification }
notification.renotify()
if (hasPlayed) {
if (!foregroundManager.tryStartForeground(notification)) {
notification.post()
} }
} }
} }
@ -329,12 +332,13 @@ class PlaybackService :
/** Stop the foreground state and hide the notification */ /** Stop the foreground state and hide the notification */
private fun stopAndSave() { private fun stopAndSave() {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) if (foregroundManager.tryStopForeground()) {
isForeground = false logD("Saving playback state")
saveScope.launch { saveScope.launch {
playbackManager.saveState(PlaybackStateDatabase.getInstance(this@PlaybackService)) playbackManager.saveState(PlaybackStateDatabase.getInstance(this@PlaybackService))
} }
} }
}
/** A [BroadcastReceiver] for receiving general playback events from the system. */ /** A [BroadcastReceiver] for receiving general playback events from the system. */
private inner class PlaybackReceiver : BroadcastReceiver() { private inner class PlaybackReceiver : BroadcastReceiver() {

View file

@ -0,0 +1,68 @@
/*
* Copyright (c) 2022 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.ui.system
import android.app.Service
import androidx.core.app.ServiceCompat
import org.oxycblt.auxio.util.logD
/**
* Wrapper to create consistent behavior regarding a service's foreground state.
* @author OxygenCobalt
*/
class ForegroundManager(private val service: Service) {
private var isForeground = false
fun release() {
tryStopForeground()
}
/**
* Try to enter a foreground state. Returns false if already in foreground, returns true
* if state was entered.
*/
fun tryStartForeground(notification: ServiceNotification): Boolean {
if (isForeground) {
return false
}
logD("Starting foreground state")
service.startForeground(notification.code, notification.build())
isForeground = true
return true
}
/**
* Try to stop a foreground state. Returns false if already in backend, returns true
* if state was stopped.
*/
fun tryStopForeground(): Boolean {
if (!isForeground) {
return false
}
logD("Stopping foreground state")
ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE)
isForeground = false
return true
}
}

View file

@ -0,0 +1,53 @@
/*
* Copyright (c) 2022 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.ui.system
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.annotation.StringRes
import androidx.core.app.NotificationCompat
import org.oxycblt.auxio.util.getSystemServiceSafe
/**
* Wrapper around [NotificationCompat.Builder] that automates parts of the notification setup.
* @author OxygenCobalt
*/
abstract class ServiceNotification(context: Context, info: ChannelInfo) :
NotificationCompat.Builder(context, info.id) {
private val notificationManager = context.getSystemServiceSafe(NotificationManager::class)
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel(info.id, context.getString(info.nameRes), info.importance)
notificationManager.createNotificationChannel(channel)
}
}
abstract val code: Int
fun post() {
notificationManager.notify(code, build())
}
data class ChannelInfo(val id: String, @StringRes val nameRes: Int, val importance: Int)
}