Auxio/app/src/main/java/org/oxycblt/auxio/AuxioService.kt
Alexander Capehart 99a527983b
service: break up
Break up the monster AuxioService into sub-classes, keeping just the
major lifecycle and music stuff in AuxioService for now (which will
likely be split out itself eventually)
2024-04-10 19:18:04 -06:00

577 lines
23 KiB
Kotlin

/*
* Copyright (c) 2024 Auxio Project
* AuxioService.kt is part of Auxio.
*
* 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
import android.annotation.SuppressLint
import android.app.Notification
import android.content.Context
import android.content.Intent
import android.database.ContentObserver
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.provider.MediaStore
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.media3.common.MediaItem
import androidx.media3.session.CommandButton
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaLibraryService.MediaLibrarySession
import androidx.media3.session.MediaNotification
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSession.ConnectionResult
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import coil.ImageLoader
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.guava.asListenableFuture
import org.oxycblt.auxio.image.service.NeoBitmapLoader
import org.oxycblt.auxio.music.IndexingProgress
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.fs.contentResolverSafe
import org.oxycblt.auxio.music.service.IndexingNotification
import org.oxycblt.auxio.music.service.MusicMediaItemBrowser
import org.oxycblt.auxio.music.service.ObservingNotification
import org.oxycblt.auxio.playback.ActionMode
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.service.ExoPlaybackStateHolder
import org.oxycblt.auxio.playback.service.SystemPlaybackReceiver
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
// TODO: Android Auto Hookup
// TODO: Custom notif
@AndroidEntryPoint
class AuxioService :
MediaLibraryService(),
MediaLibrarySession.Callback,
MusicRepository.IndexingWorker,
MusicRepository.IndexingListener,
MusicRepository.UpdateListener,
MusicSettings.Listener,
PlaybackStateManager.Listener,
PlaybackSettings.Listener {
private val serviceJob = Job()
private var inPlayback = false
@Inject lateinit var musicRepository: MusicRepository
@Inject lateinit var musicSettings: MusicSettings
private lateinit var indexingNotification: IndexingNotification
private lateinit var observingNotification: ObservingNotification
private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var indexerContentObserver: SystemContentObserver
private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO)
private var currentIndexJob: Job? = null
@Inject lateinit var playbackManager: PlaybackStateManager
@Inject lateinit var playbackSettings: PlaybackSettings
@Inject lateinit var systemReceiver: SystemPlaybackReceiver
@Inject lateinit var exoHolderFactory: ExoPlaybackStateHolder.Factory
private lateinit var exoHolder: ExoPlaybackStateHolder
@Inject lateinit var bitmapLoader: NeoBitmapLoader
@Inject lateinit var imageLoader: ImageLoader
@Inject lateinit var musicMediaItemBrowser: MusicMediaItemBrowser
private val waitScope = CoroutineScope(serviceJob + Dispatchers.Default)
private lateinit var mediaSession: MediaLibrarySession
@SuppressLint("WrongConstant")
override fun onCreate() {
super.onCreate()
indexingNotification = IndexingNotification(this)
observingNotification = ObservingNotification(this)
wakeLock =
getSystemServiceCompat(PowerManager::class)
.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexerService")
exoHolder = exoHolderFactory.create()
mediaSession =
MediaLibrarySession.Builder(this, exoHolder.mediaSessionPlayer, this)
.setBitmapLoader(bitmapLoader)
.build()
// Initialize any listener-dependent components last as we wouldn't want a listener race
// condition to cause us to load music before we were fully initialize.
indexerContentObserver = SystemContentObserver()
setMediaNotificationProvider(
DefaultMediaNotificationProvider.Builder(this)
.setNotificationId(IntegerTable.PLAYBACK_NOTIFICATION_CODE)
.setChannelId(BuildConfig.APPLICATION_ID + ".channel.PLAYBACK")
.setChannelName(R.string.lbl_playback)
.build()
.also { it.setSmallIcon(R.drawable.ic_auxio_24) })
addSession(mediaSession)
updateCustomButtons()
// Initialize any listener-dependent components last as we wouldn't want a listener race
// condition to cause us to load music before we were fully initialize.
exoHolder.attach()
playbackManager.addListener(this)
playbackSettings.registerListener(this)
ContextCompat.registerReceiver(
this, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED)
musicMediaItemBrowser.attach()
musicSettings.registerListener(this)
musicRepository.addUpdateListener(this)
musicRepository.addIndexingListener(this)
musicRepository.registerWorker(this)
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
if (!playbackManager.progression.isPlaying) {
// Stop the service if not playing, continue playing in the background
// otherwise.
endSession()
}
}
override fun onDestroy() {
super.onDestroy()
// De-initialize core service components first.
serviceJob.cancel()
wakeLock.releaseSafe()
// Then cancel the listener-dependent components to ensure that stray reloading
// events will not occur.
indexerContentObserver.release()
exoHolder.release()
musicSettings.unregisterListener(this)
musicRepository.removeUpdateListener(this)
musicRepository.removeIndexingListener(this)
musicRepository.unregisterWorker(this)
// Pause just in case this destruction was unexpected.
playbackManager.playing(false)
playbackManager.unregisterStateHolder(exoHolder)
playbackSettings.unregisterListener(this)
removeSession(mediaSession)
mediaSession.release()
unregisterReceiver(systemReceiver)
exoHolder.release()
}
// --- INDEXER OVERRIDES ---
override fun requestIndex(withCache: Boolean) {
logD("Starting new indexing job (previous=${currentIndexJob?.hashCode()})")
// Cancel the previous music loading job.
currentIndexJob?.cancel()
// Start a new music loading job on a co-routine.
currentIndexJob = musicRepository.index(this, withCache)
}
override val workerContext: Context
get() = this
override val scope = indexScope
override fun onIndexingStateChanged() {
updateForeground(forMusic = true)
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
val deviceLibrary = musicRepository.deviceLibrary ?: return
logD("Music changed, updating shared objects")
// Wipe possibly-invalidated outdated covers
imageLoader.memoryCache?.clear()
// Clear invalid models from PlaybackStateManager. This is not connected
// to a listener as it is bad practice for a shared object to attach to
// the listener system of another.
playbackManager.toSavedState()?.let { savedState ->
playbackManager.applySavedState(
savedState.copy(
heap =
savedState.heap.map { song ->
song?.let { deviceLibrary.findSong(it.uid) }
}),
true)
}
}
// --- INTERNAL ---
private fun updateForeground(forMusic: Boolean) {
if (playbackManager.progression.isPlaying) {
inPlayback = true
}
if (inPlayback) {
if (!forMusic) {
val notification =
mediaNotificationProvider.createNotification(
mediaSession,
mediaSession.customLayout,
mediaNotificationManager.actionFactory) { notification ->
postMediaNotification(notification, mediaSession)
}
postMediaNotification(notification, mediaSession)
}
return
}
val state = musicRepository.indexingState
if (state is IndexingState.Indexing) {
updateLoadingForeground(state.progress)
} else {
updateIdleForeground()
}
}
private fun updateLoadingForeground(progress: IndexingProgress) {
// When loading, we want to enter the foreground state so that android does
// not shut off the loading process. Note that while we will always post the
// notification when initially starting, we will not update the notification
// unless it indicates that it has changed.
val changed = indexingNotification.updateIndexingState(progress)
if (changed) {
logD("Notification changed, re-posting notification")
startForeground(indexingNotification.code, indexingNotification.build())
}
// Make sure we can keep the CPU on while loading music
wakeLock.acquireSafe()
}
private fun updateIdleForeground() {
if (musicSettings.shouldBeObserving) {
// There are a few reasons why we stay in the foreground with automatic rescanning:
// 1. Newer versions of Android have become more and more restrictive regarding
// how a foreground service starts. Thus, it's best to go foreground now so that
// 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.
// TODO: Assuming I unify this with PlaybackService, it's possible that I won't need
// this anymore, or at least I only have to use it when the app task is not removed.
logD("Need to observe, staying in foreground")
startForeground(observingNotification.code, observingNotification.build())
} else {
// Not observing and done loading, exit foreground.
logD("Exiting foreground")
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH)
}
// Release our wake lock (if we were using it)
wakeLock.releaseSafe()
}
/** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */
private fun PowerManager.WakeLock.acquireSafe() {
// Avoid unnecessary acquire calls.
if (!wakeLock.isHeld) {
logD("Acquiring wake lock")
// Time out after a minute, which is the average music loading time for a medium-sized
// library. If this runs out, we will re-request the lock, and if music loading is
// shorter than the timeout, it will be released early.
acquire(WAKELOCK_TIMEOUT_MS)
}
}
/** Utility to safely release a [PowerManager.WakeLock] without crashes/inefficiency. */
private fun PowerManager.WakeLock.releaseSafe() {
// Avoid unnecessary release calls.
if (wakeLock.isHeld) {
logD("Releasing wake lock")
release()
}
}
// --- SETTING CALLBACKS ---
override fun onIndexingSettingChanged() {
// Music loading configuration changed, need to reload music.
requestIndex(true)
}
override fun onObservingChanged() {
// Make sure we don't override the service state with the observing
// notification if we were actively loading when the automatic rescanning
// setting changed. In such a case, the state will still be updated when
// the music loading process ends.
if (currentIndexJob == null) {
logD("Not loading, updating idle session")
updateForeground(forMusic = false)
}
}
/**
* A [ContentObserver] that observes the [MediaStore] music database for changes, a behavior
* known to the user as automatic rescanning. The active (and not passive) nature of observing
* the database is what requires [IndexerService] to stay foreground when this is enabled.
*/
private inner class SystemContentObserver :
ContentObserver(Handler(Looper.getMainLooper())), Runnable {
private val handler = Handler(Looper.getMainLooper())
init {
contentResolverSafe.registerContentObserver(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, this)
}
/**
* Release this instance, preventing it from further observing the database and cancelling
* any pending update events.
*/
fun release() {
handler.removeCallbacks(this)
contentResolverSafe.unregisterContentObserver(this)
}
override fun onChange(selfChange: Boolean) {
// Batch rapid-fire updates to the library into a single call to run after 500ms
handler.removeCallbacks(this)
handler.postDelayed(this, REINDEX_DELAY_MS)
}
override fun run() {
// Check here if we should even start a reindex. This is much less bug-prone than
// registering and de-registering this component as this setting changes.
if (musicSettings.shouldBeObserving) {
logD("MediaStore changed, starting re-index")
requestIndex(true)
}
}
}
// --- SERVICE MANAGEMENT ---
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession =
mediaSession
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
logD("Notification update requested")
updateForeground(forMusic = false)
}
private fun postMediaNotification(notification: MediaNotification, session: MediaSession) {
// Pulled from MediaNotificationManager: Need to specify MediaSession token manually
// in notification
val fwkToken = session.sessionCompatToken.token as android.media.session.MediaSession.Token
notification.notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken)
startForeground(notification.notificationId, notification.notification)
}
// --- MEDIASESSION CALLBACKS ---
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo
): ConnectionResult {
val sessionCommands =
ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
.add(SessionCommand(ACTION_INC_REPEAT_MODE, Bundle.EMPTY))
.add(SessionCommand(ACTION_INVERT_SHUFFLE, Bundle.EMPTY))
.add(SessionCommand(ACTION_EXIT, Bundle.EMPTY))
.build()
return ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(sessionCommands)
.build()
}
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> =
when (customCommand.customAction) {
ACTION_INC_REPEAT_MODE -> {
logD(playbackManager.repeatMode.increment())
playbackManager.repeatMode(playbackManager.repeatMode.increment())
Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
ACTION_INVERT_SHUFFLE -> {
playbackManager.shuffled(!playbackManager.isShuffled)
Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
ACTION_EXIT -> {
endSession()
Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
else -> super.onCustomCommand(session, controller, customCommand, args)
}
override fun onGetLibraryRoot(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
params: LibraryParams?
): ListenableFuture<LibraryResult<MediaItem>> =
Futures.immediateFuture(LibraryResult.ofItem(musicMediaItemBrowser.root, params))
override fun onGetItem(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
mediaId: String
): ListenableFuture<LibraryResult<MediaItem>> {
val result =
musicMediaItemBrowser.getItem(mediaId)?.let { LibraryResult.ofItem(it, null) }
?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
return Futures.immediateFuture(result)
}
override fun onGetChildren(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
parentId: String,
page: Int,
pageSize: Int,
params: LibraryParams?
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val children =
musicMediaItemBrowser.getChildren(parentId, page, pageSize)?.let {
LibraryResult.ofItemList(it, params)
}
?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
return Futures.immediateFuture(children)
}
override fun onSearch(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
query: String,
params: LibraryParams?
): ListenableFuture<LibraryResult<Void>> =
waitScope
.async {
musicMediaItemBrowser.prepareSearch(query)
LibraryResult.ofVoid()
}
.asListenableFuture()
override fun onGetSearchResult(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
query: String,
page: Int,
pageSize: Int,
params: LibraryParams?
) =
waitScope
.async {
musicMediaItemBrowser.getSearchResult(query, page, pageSize)?.let {
LibraryResult.ofItemList(it, params)
}
?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
}
.asListenableFuture()
// --- BUTTON MANAGEMENT ---
override fun onPauseOnRepeatChanged() {
super.onPauseOnRepeatChanged()
updateCustomButtons()
}
override fun onQueueReordered(queue: List<Song>, index: Int, isShuffled: Boolean) {
super.onQueueReordered(queue, index, isShuffled)
updateCustomButtons()
}
override fun onRepeatModeChanged(repeatMode: RepeatMode) {
super.onRepeatModeChanged(repeatMode)
updateCustomButtons()
}
override fun onNotificationActionChanged() {
super.onNotificationActionChanged()
updateCustomButtons()
}
private fun updateCustomButtons() {
val actions = mutableListOf<CommandButton>()
when (playbackSettings.notificationAction) {
ActionMode.REPEAT -> {
actions.add(
CommandButton.Builder()
.setIconResId(playbackManager.repeatMode.icon)
.setDisplayName(getString(R.string.desc_change_repeat))
.setSessionCommand(SessionCommand(ACTION_INC_REPEAT_MODE, Bundle()))
.build())
}
ActionMode.SHUFFLE -> {
actions.add(
CommandButton.Builder()
.setIconResId(
if (playbackManager.isShuffled) R.drawable.ic_shuffle_on_24
else R.drawable.ic_shuffle_off_24)
.setDisplayName(getString(R.string.lbl_shuffle))
.setSessionCommand(SessionCommand(ACTION_INVERT_SHUFFLE, Bundle()))
.build())
}
else -> {}
}
actions.add(
CommandButton.Builder()
.setIconResId(R.drawable.ic_close_24)
.setDisplayName(getString(R.string.desc_exit))
.setSessionCommand(SessionCommand(ACTION_EXIT, Bundle()))
.build())
mediaSession.setCustomLayout(actions)
}
private fun endSession() {
// This session has ended, so we need to reset this flag for when the next
// session starts.
exoHolder.save {
// User could feasibly start playing again if they were fast enough, so
// we need to avoid stopping the foreground state if that's the case.
if (playbackManager.progression.isPlaying) {
playbackManager.playing(false)
}
inPlayback = false
updateForeground(forMusic = false)
}
}
companion object {
const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
const val REINDEX_DELAY_MS = 500L
const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP"
const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE"
const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV"
const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE"
const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT"
const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT"
}
}