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
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.IntegerTable
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.newMainPendingIntent
/** The notification responsible for showing the indexer state. */
class IndexingNotification(private val context: Context) :
NotificationCompat.Builder(context, CHANNEL_ID) {
private val notificationManager = context.getSystemServiceSafe(NotificationManager::class)
ServiceNotification(context, INDEXER_CHANNEL) {
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)
setCategory(NotificationCompat.CATEGORY_PROGRESS)
setShowWhen(false)
@ -56,9 +42,8 @@ class IndexingNotification(private val context: Context) :
setProgress(0, 0, true)
}
fun renotify() {
notificationManager.notify(IntegerTable.INDEXER_NOTIFICATION_CODE, build())
}
override val code: Int
get() = IntegerTable.INDEXER_NOTIFICATION_CODE
fun updateIndexingState(indexing: Indexer.Indexing): Boolean {
when (indexing) {
@ -82,27 +67,11 @@ class IndexingNotification(private val context: Context) :
return false
}
companion object {
const val CHANNEL_ID = BuildConfig.APPLICATION_ID + ".channel.INDEXER"
}
}
/** The notification responsible for showing the indexer state. */
class ObservingNotification(context: Context) : NotificationCompat.Builder(context, CHANNEL_ID) {
private val notificationManager = context.getSystemServiceSafe(NotificationManager::class)
class ObservingNotification(context: Context) : ServiceNotification(context, INDEXER_CHANNEL) {
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)
setCategory(NotificationCompat.CATEGORY_SERVICE)
setShowWhen(false)
@ -113,11 +82,12 @@ class ObservingNotification(context: Context) : NotificationCompat.Builder(conte
setContentText(context.getString(R.string.lbl_observing_desc))
}
fun renotify() {
notificationManager.notify(IntegerTable.INDEXER_NOTIFICATION_CODE, build())
}
companion object {
const val CHANNEL_ID = BuildConfig.APPLICATION_ID + ".channel.INDEXER"
}
override val code: Int
get() = IntegerTable.INDEXER_NOTIFICATION_CODE
}
private val INDEXER_CHANNEL =
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
import android.app.Notification
import android.app.Service
import android.content.Intent
import android.database.ContentObserver
@ -25,17 +24,16 @@ import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.provider.MediaStore
import androidx.core.app.ServiceCompat
import coil.imageLoader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.system.ForegroundManager
import org.oxycblt.auxio.util.contentResolverSafe
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.
*
* @author OxygenCobalt
*
* TODO: Add abstractions for services. notifications, and generations
*/
class IndexerService : Service(), Indexer.Controller, Settings.Callback {
private val indexer = Indexer.getInstance()
@ -63,19 +59,20 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
private val playbackManager = PlaybackStateManager.getInstance()
private lateinit var settings: Settings
private var isForeground = false
private lateinit var foregroundManager: ForegroundManager
private lateinit var indexingNotification: IndexingNotification
private lateinit var observingNotification: ObservingNotification
override fun onCreate() {
super.onCreate()
settings = Settings(this, this)
indexerContentObserver = SystemContentObserver()
foregroundManager = ForegroundManager(this)
indexingNotification = IndexingNotification(this)
observingNotification = ObservingNotification(this)
settings = Settings(this, this)
indexerContentObserver = SystemContentObserver()
indexer.registerController(this)
if (musicStore.library == null && indexer.isIndeterminate) {
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() {
super.onDestroy()
foregroundManager.release()
// De-initialize the components first to prevent stray reloading events
settings.release()
indexerContentObserver.release()
@ -162,9 +161,9 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// notification when initially starting, we will not update the notification
// unless it indicates that we have changed it.
val changed = indexingNotification.updateIndexingState(state)
if (!tryStartForeground(indexingNotification.build()) && changed) {
if (!foregroundManager.tryStartForeground(indexingNotification) && changed) {
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.
// 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.
if (!tryStartForeground(observingNotification.build())) {
observingNotification.renotify()
if (!foregroundManager.tryStartForeground(observingNotification)) {
observingNotification.post()
}
} 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 ---
override fun onSettingChanged(key: String) {

View file

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

View file

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

View file

@ -18,7 +18,6 @@
package org.oxycblt.auxio.playback.system
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
@ -31,7 +30,7 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
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.newMainPendingIntent
@ -43,20 +42,8 @@ import org.oxycblt.auxio.util.newMainPendingIntent
*/
@SuppressLint("RestrictedApi")
class NotificationComponent(private val context: Context, sessionToken: MediaSessionCompat.Token) :
NotificationCompat.Builder(context, CHANNEL_ID) {
private val notificationManager = context.getSystemServiceSafe(NotificationManager::class)
ServiceNotification(context, CHANNEL_INFO) {
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)
setCategory(NotificationCompat.CATEGORY_TRANSPORT)
setShowWhen(false)
@ -75,9 +62,8 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
setStyle(MediaStyle().setMediaSession(sessionToken).setShowActionsInCompactView(1, 2, 3))
}
fun renotify() {
notificationManager.notify(IntegerTable.PLAYBACK_NOTIFICATION_CODE, build())
}
override val code: Int
get() = IntegerTable.PLAYBACK_NOTIFICATION_CODE
// --- STATE FUNCTIONS ---
@ -164,6 +150,10 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
}
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.media.AudioManager
import android.os.IBinder
import androidx.core.app.ServiceCompat
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
@ -45,7 +44,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Song
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.RepeatMode
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.system.ForegroundManager
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.widgets.WidgetComponent
import org.oxycblt.auxio.widgets.WidgetProvider
@ -93,7 +92,7 @@ class PlaybackService :
private lateinit var settings: Settings
// State
private var isForeground = false
private lateinit var foregroundManager: ForegroundManager
private var hasPlayed = false
// Coroutines
@ -106,6 +105,8 @@ class PlaybackService :
override fun onCreate() {
super.onCreate()
foregroundManager = ForegroundManager(this)
// --- PLAYER SETUP ---
replayGainProcessor = ReplayGainAudioProcessor(this)
@ -163,8 +164,7 @@ class PlaybackService :
override fun onDestroy() {
super.onDestroy()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
isForeground = false
foregroundManager.release()
// Pause just in case this destruction was unexpected.
playbackManager.isPlaying = false
@ -272,14 +272,17 @@ class PlaybackService :
player.playWhenReady = isPlaying
}
override fun onPostNotification(notification: NotificationComponent) {
if (hasPlayed && playbackManager.song != null) {
if (!isForeground) {
startForeground(IntegerTable.PLAYBACK_NOTIFICATION_CODE, notification.build())
isForeground = true
} else {
// If we are already in foreground just update the notification
notification.renotify()
override fun onPostNotification(notification: NotificationComponent?) {
if (notification == null) {
// This case is only here if I ever need to move foreground stopping from
// the player code to the notification code.
logD("No notification, ignoring")
return
}
if (hasPlayed) {
if (!foregroundManager.tryStartForeground(notification)) {
notification.post()
}
}
}
@ -329,10 +332,11 @@ class PlaybackService :
/** Stop the foreground state and hide the notification */
private fun stopAndSave() {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
isForeground = false
saveScope.launch {
playbackManager.saveState(PlaybackStateDatabase.getInstance(this@PlaybackService))
if (foregroundManager.tryStopForeground()) {
logD("Saving playback state")
saveScope.launch {
playbackManager.saveState(PlaybackStateDatabase.getInstance(this@PlaybackService))
}
}
}

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)
}