From be23208f72f920b2cf46c6bb400f79b4c04fa1d1 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 Apr 2024 23:44:35 -0600 Subject: [PATCH] service: break into components --- .../java/org/oxycblt/auxio/AuxioService.kt | 575 ++---------------- .../auxio/image/service/CoilBitmapLoader.kt | 2 +- .../auxio/music/service/IndexerComponent.kt | 184 ++++++ .../music/service/IndexerNotifications.kt | 56 +- ...ediaItemBrowser.kt => MediaItemBrowser.kt} | 37 +- .../music/service/MediaItemTranslation.kt | 4 + .../music/service/SystemContentObserver.kt | 79 +++ .../service/ExoPlaybackStateHolder.kt | 18 + .../playback/service/MediaSessionPlayer.kt | 14 +- .../service/MediaSessionServiceFragment.kt | 254 ++++++++ .../service/SystemPlaybackReciever.kt | 153 ++++- .../playback/state/PlaybackStateHolder.kt | 5 + .../playback/state/PlaybackStateManager.kt | 15 +- .../auxio/ui/ForegroundServiceNotification.kt | 71 --- .../oxycblt/auxio/widgets/WidgetProvider.kt | 14 +- 15 files changed, 826 insertions(+), 655 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/service/IndexerComponent.kt rename app/src/main/java/org/oxycblt/auxio/music/service/{MusicMediaItemBrowser.kt => MediaItemBrowser.kt} (88%) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/service/SystemContentObserver.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/ui/ForegroundServiceNotification.kt diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 4627d7f41..74ce64c14 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -19,575 +19,72 @@ 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.Progression -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 +import org.oxycblt.auxio.music.service.IndexingServiceFragment +import org.oxycblt.auxio.playback.service.MediaSessionServiceFragment @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 +class AuxioService : MediaLibraryService(), ForegroundListener { + @Inject lateinit var mediaSessionFragment: MediaSessionServiceFragment - @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 + @Inject lateinit var indexingFragment: IndexingServiceFragment @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) + mediaSessionFragment.attach(this, this) + indexingFragment.attach(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() - } + mediaSessionFragment.handleTaskRemoved() } 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() + indexingFragment.release() + mediaSessionFragment.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 (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_REMOVE) - } - // 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 + mediaSessionFragment.mediaSession override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { - logD("Notification update requested") - updateForeground(forMusic = false) + updateForeground(ForegroundListener.Change.MEDIA_SESSION) } - 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 = - 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> = - Futures.immediateFuture(LibraryResult.ofItem(musicMediaItemBrowser.root, params)) - - override fun onGetItem( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - mediaId: String - ): ListenableFuture> { - val result = - musicMediaItemBrowser.getItem(mediaId)?.let { LibraryResult.ofItem(it, null) } - ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) - return Futures.immediateFuture(result) - } - - override fun onSetMediaItems( - mediaSession: MediaSession, - controller: MediaSession.ControllerInfo, - mediaItems: MutableList, - startIndex: Int, - startPositionMs: Long - ): ListenableFuture = - Futures.immediateFuture( - MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs)) - - override fun onGetChildren( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - parentId: String, - page: Int, - pageSize: Int, - params: LibraryParams? - ): ListenableFuture>> { - 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> = - waitScope - .async { - val count = musicMediaItemBrowser.prepareSearch(query) - session.notifySearchResultChanged(browser, query, count, params) - 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) + override fun updateForeground(change: ForegroundListener.Change) { + if (mediaSessionFragment.hasNotification()) { + if (change == ForegroundListener.Change.MEDIA_SESSION) { + mediaSessionFragment.createNotification { + startForeground(it.notificationId, it.notification) + } + } + // Nothing changed, but don't show anything music related since we can always + // index during playback. + } else { + indexingFragment.createNotification { + if (it != null) { + startForeground(it.code, it.build()) + } else { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) } - ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) } - .asListenableFuture() - - // --- BUTTON MANAGEMENT --- - - override fun onPauseOnRepeatChanged() { - super.onPauseOnRepeatChanged() - updateCustomButtons() - } - - override fun onProgressionChanged(progression: Progression) { - super.onProgressionChanged(progression) - if (progression.isPlaying) { - inPlayback = true } } - - override fun onRepeatModeChanged(repeatMode: RepeatMode) { - super.onRepeatModeChanged(repeatMode) - updateCustomButtons() - } - - override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) { - super.onQueueReordered(queue, index, isShuffled) - updateCustomButtons() - } - - override fun onNotificationActionChanged() { - super.onNotificationActionChanged() - updateCustomButtons() - } - - private fun updateCustomButtons() { - val actions = mutableListOf() - - 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" - } +} + +interface ForegroundListener { + fun updateForeground(change: Change) + + enum class Change { + MEDIA_SESSION, + INDEXER + } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt b/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt index 22d4cceff..58e8b609d 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt @@ -29,7 +29,7 @@ import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.service.MediaSessionUID -class NeoBitmapLoader +class MediaSessionBitmapLoader @Inject constructor( private val musicRepository: MusicRepository, diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerComponent.kt b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerComponent.kt new file mode 100644 index 000000000..401b49145 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerComponent.kt @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2024 Auxio Project + * IndexerComponent.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 . + */ + +package org.oxycblt.auxio.music.service + +import android.content.Context +import android.os.PowerManager +import coil.ImageLoader +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.ForegroundListener +import org.oxycblt.auxio.music.IndexingState +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.util.getSystemServiceCompat +import org.oxycblt.auxio.util.logD + +class IndexingServiceFragment +@Inject +constructor( + @ApplicationContext override val workerContext: Context, + private val playbackManager: PlaybackStateManager, + private val musicRepository: MusicRepository, + private val musicSettings: MusicSettings, + private val contentObserver: SystemContentObserver, + private val imageLoader: ImageLoader +) : + MusicRepository.IndexingWorker, + MusicRepository.IndexingListener, + MusicRepository.UpdateListener, + MusicSettings.Listener { + private val indexJob = Job() + private val indexScope = CoroutineScope(indexJob + Dispatchers.IO) + private var currentIndexJob: Job? = null + private val indexingNotification = IndexingNotification(workerContext) + private val observingNotification = ObservingNotification(workerContext) + private var foregroundListener: ForegroundListener? = null + private val wakeLock = + workerContext + .getSystemServiceCompat(PowerManager::class) + .newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent") + + fun attach(listener: ForegroundListener) { + foregroundListener = listener + musicSettings.registerListener(this) + musicRepository.addUpdateListener(this) + musicRepository.addIndexingListener(this) + musicRepository.registerWorker(this) + contentObserver.attach() + } + + fun release() { + contentObserver.release() + musicSettings.registerListener(this) + musicRepository.addIndexingListener(this) + musicRepository.addUpdateListener(this) + musicRepository.removeIndexingListener(this) + foregroundListener = null + } + + fun createNotification(post: (IndexerNotification?) -> Unit) { + val state = musicRepository.indexingState + if (state is IndexingState.Indexing) { + // 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. + val changed = indexingNotification.updateIndexingState(state.progress) + if (changed) { + post(indexingNotification) + } + } else if (musicSettings.shouldBeObserving) { + // Not observing and done loading, exit foreground. + logD("Exiting foreground") + post(observingNotification) + } else { + post(null) + } + } + + 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 scope = indexScope + + override fun onIndexingStateChanged() { + foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) + val state = musicRepository.indexingState + if (state is IndexingState.Indexing) { + wakeLock.acquireSafe() + } else { + wakeLock.releaseSafe() + } + } + + 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) + } + } + + override fun onIndexingSettingChanged() { + super.onIndexingSettingChanged() + musicRepository.requestIndex(true) + } + + override fun onObservingChanged() { + super.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") + foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) + } + } + + /** 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() + } + } + + companion object { + const val WAKELOCK_TIMEOUT_MS = 60 * 1000L + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt index 2b1524fdf..d857ab32b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt @@ -20,23 +20,64 @@ package org.oxycblt.auxio.music.service import android.content.Context import android.os.SystemClock +import androidx.annotation.StringRes +import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.music.IndexingProgress -import org.oxycblt.auxio.ui.ForegroundServiceNotification import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newMainPendingIntent /** - * A dynamic [ForegroundServiceNotification] that shows the current music loading state. + * Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that + * signal a Service's ongoing foreground state. + * + * @author Alexander Capehart (OxygenCobalt) + */ +abstract class IndexerNotification(context: Context, info: ChannelInfo) : + NotificationCompat.Builder(context, info.id) { + private val notificationManager = NotificationManagerCompat.from(context) + + init { + // Set up the notification channel. Foreground notifications are non-substantial, and + // thus make no sense to have lights, vibration, or lead to a notification badge. + val channel = + NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW) + .setName(context.getString(info.nameRes)) + .setLightsEnabled(false) + .setVibrationEnabled(false) + .setShowBadge(false) + .build() + notificationManager.createNotificationChannel(channel) + } + + /** + * The code used to identify this notification. + * + * @see NotificationManagerCompat.notify + */ + abstract val code: Int + + /** + * Reduced representation of a [NotificationChannelCompat]. + * + * @param id The ID of the channel. + * @param nameRes A string resource ID corresponding to the human-readable name of this channel. + */ + data class ChannelInfo(val id: String, @StringRes val nameRes: Int) +} + +/** + * A dynamic [IndexerNotification] that shows the current music loading state. * * @param context [Context] required to create the notification. * @author Alexander Capehart (OxygenCobalt) */ class IndexingNotification(private val context: Context) : - ForegroundServiceNotification(context, indexerChannel) { + IndexerNotification(context, indexerChannel) { private var lastUpdateTime = -1L init { @@ -92,13 +133,12 @@ class IndexingNotification(private val context: Context) : } /** - * A static [ForegroundServiceNotification] that signals to the user that the app is currently - * monitoring the music library for changes. + * A static [IndexerNotification] that signals to the user that the app is currently monitoring the + * music library for changes. * * @author Alexander Capehart (OxygenCobalt) */ -class ObservingNotification(context: Context) : - ForegroundServiceNotification(context, indexerChannel) { +class ObservingNotification(context: Context) : IndexerNotification(context, indexerChannel) { init { setSmallIcon(R.drawable.ic_indexer_24) setCategory(NotificationCompat.CATEGORY_SERVICE) @@ -116,5 +156,5 @@ class ObservingNotification(context: Context) : /** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */ private val indexerChannel = - ForegroundServiceNotification.ChannelInfo( + IndexerNotification.ChannelInfo( id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt similarity index 88% rename from app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt rename to app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt index bf5e5a519..93f383917 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * MusicMediaItemBrowser.kt is part of Auxio. + * MediaItemBrowser.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 @@ -39,7 +39,7 @@ import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.user.UserLibrary import org.oxycblt.auxio.search.SearchEngine -class MusicMediaItemBrowser +class MediaItemBrowser @Inject constructor( @ApplicationContext private val context: Context, @@ -49,19 +49,42 @@ constructor( private val browserJob = Job() private val searchScope = CoroutineScope(browserJob + Dispatchers.Default) private val searchResults = mutableMapOf>() + private var invalidator: Invalidator? = null - fun attach() { + interface Invalidator { + fun invalidate(ids: List) + } + + fun attach(invalidator: Invalidator) { + this.invalidator = invalidator musicRepository.addUpdateListener(this) } fun release() { browserJob.cancel() + invalidator = null musicRepository.removeUpdateListener(this) } override fun onMusicChanges(changes: MusicRepository.Changes) { val deviceLibrary = musicRepository.deviceLibrary + var invalidateSearch = false if (changes.deviceLibrary && deviceLibrary != null) { + val ids = + MediaSessionUID.Category.IMPORTANT + + deviceLibrary.albums.map { MediaSessionUID.Single(it.uid) } + + deviceLibrary.artists.map { MediaSessionUID.Single(it.uid) } + + deviceLibrary.genres.map { MediaSessionUID.Single(it.uid) } + invalidator?.invalidate(ids.map { it.toString() }) + invalidateSearch = true + } + val userLibrary = musicRepository.userLibrary + if (changes.userLibrary && userLibrary != null) { + val ids = userLibrary.playlists.map { MediaSessionUID.Single(it.uid) } + invalidator?.invalidate(ids.map { it.toString() }) + invalidateSearch = true + } + if (invalidateSearch) { for (entry in searchResults.entries) { entry.value.cancel() } @@ -113,13 +136,7 @@ constructor( is MediaSessionUID.Category -> { when (mediaSessionUID) { MediaSessionUID.Category.ROOT -> - listOf( - MediaSessionUID.Category.SONGS, - MediaSessionUID.Category.ALBUMS, - MediaSessionUID.Category.ARTISTS, - MediaSessionUID.Category.GENRES, - MediaSessionUID.Category.PLAYLISTS) - .map { it.toMediaItem(context) } + MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) } MediaSessionUID.Category.SONGS -> deviceLibrary.songs.map { it.toMediaItem(context, null) } MediaSessionUID.Category.ALBUMS -> diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 97390fe8a..2a6175627 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -215,6 +215,10 @@ sealed interface MediaSessionUID { PLAYLISTS("playlists", R.string.lbl_playlists, MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS); override fun toString() = "$ID_CATEGORY:$id" + + companion object { + val IMPORTANT = listOf(SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS) + } } data class Single(val uid: Music.UID) : MediaSessionUID { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/SystemContentObserver.kt b/app/src/main/java/org/oxycblt/auxio/music/service/SystemContentObserver.kt new file mode 100644 index 000000000..b44c9785c --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/service/SystemContentObserver.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2024 Auxio Project + * SystemContentObserver.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 . + */ + +package org.oxycblt.auxio.music.service + +import android.content.Context +import android.database.ContentObserver +import android.os.Handler +import android.os.Looper +import android.provider.MediaStore +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.music.fs.contentResolverSafe +import org.oxycblt.auxio.util.logD + +/** + * 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. + */ +class SystemContentObserver +@Inject +constructor( + @ApplicationContext private val context: Context, + private val musicRepository: MusicRepository, + private val musicSettings: MusicSettings +) : ContentObserver(Handler(Looper.getMainLooper())), Runnable { + private val handler = Handler(Looper.getMainLooper()) + + fun attach() { + context.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) + context.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") + musicRepository.requestIndex(true) + } + } + + private companion object { + const val REINDEX_DELAY_MS = 500L + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 4159c8c46..f7dada9eb 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -81,6 +81,9 @@ class ExoPlaybackStateHolder( private var currentSaveJob: Job? = null private var openAudioEffectSession = false + var sessionOngoing = false + private set + fun attach() { player.addListener(this) playbackManager.registerStateHolder(this) @@ -358,6 +361,20 @@ class ExoPlaybackStateHolder( ack?.let { playbackManager.ack(this, it) } } + override fun endSession() { + // This session has ended, so we need to reset this flag for when the next + // session starts. + 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) + } + sessionOngoing = false + playbackManager.ack(this, StateAck.SessionEnded) + } + } + override fun reset(ack: StateAck.NewPlayback) { player.setMediaItems(listOf()) playbackManager.ack(this, ack) @@ -372,6 +389,7 @@ class ExoPlaybackStateHolder( if (player.playWhenReady) { // Mark that we have started playing so that the notification can now be posted. logD("Player has started playing") + sessionOngoing = true if (!openAudioEffectSession) { // Convention to start an audioeffect session on play/pause rather than // start/stop diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt index 0e52d38b3..fb662ef2f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt @@ -50,12 +50,12 @@ import org.oxycblt.auxio.util.logE /** * A thin wrapper around the player instance that drastically reduces the command surface and - * forwards all commands to PlaybackStateManager so I can ensure that all the unhinged commands - * that Media3 will throw at me will be handled in a predictable way, rather than just clobbering - * the playback state. Largely limited to the legacy media APIs. + * forwards all commands to PlaybackStateManager so I can ensure that all the unhinged commands that + * Media3 will throw at me will be handled in a predictable way, rather than just clobbering the + * playback state. Largely limited to the legacy media APIs. * - * I'll add more support as I go along when I can confirm that apps will use the Media3 API and - * send more advanced commands. + * I'll add more support as I go along when I can confirm that apps will use the Media3 API and send + * more advanced commands. * * @author Alexander Capehart */ @@ -229,6 +229,8 @@ class MediaSessionPlayer( override fun removeMediaItems(fromIndex: Int, toIndex: Int) = error("Any multi-item queue removal is unsupported") + override fun stop() = playbackManager.endSession() + // These methods I don't want MediaSession calling in any way since they'll do insane things // that I'm not tracking. If they do call them, I will know. @@ -280,8 +282,6 @@ class MediaSessionPlayer( override fun setPlayWhenReady(playWhenReady: Boolean) = notAllowed() - override fun stop() = notAllowed() - override fun hasNextMediaItem() = notAllowed() override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) = diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt new file mode 100644 index 000000000..3d8099b7e --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2024 Auxio Project + * MediaSessionServiceFragment.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 . + */ + +package org.oxycblt.auxio.playback.service + +import android.app.Notification +import android.content.Context +import android.os.Bundle +import androidx.media3.common.MediaItem +import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultActionFactory +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.MediaNotification.ActionFactory +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSession.ConnectionResult +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import dagger.hilt.android.qualifiers.ApplicationContext +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.BuildConfig +import org.oxycblt.auxio.ForegroundListener +import org.oxycblt.auxio.IntegerTable +import org.oxycblt.auxio.R +import org.oxycblt.auxio.image.service.MediaSessionBitmapLoader +import org.oxycblt.auxio.music.service.MediaItemBrowser +import org.oxycblt.auxio.playback.state.PlaybackStateManager + +class MediaSessionServiceFragment +@Inject +constructor( + @ApplicationContext private val context: Context, + private val playbackManager: PlaybackStateManager, + private val actionHandler: PlaybackActionHandler, + private val mediaItemBrowser: MediaItemBrowser, + private val bitmapLoader: MediaSessionBitmapLoader, + exoHolderFactory: ExoPlaybackStateHolder.Factory +) : + MediaLibrarySession.Callback, + PlaybackActionHandler.Callback, + MediaItemBrowser.Invalidator, + PlaybackStateManager.Listener { + private val waitJob = Job() + private val waitScope = CoroutineScope(waitJob + Dispatchers.Default) + private val exoHolder = exoHolderFactory.create() + + private lateinit var actionFactory: ActionFactory + private val mediaNotificationProvider = + DefaultMediaNotificationProvider.Builder(context) + .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) } + private var foregroundListener: ForegroundListener? = null + + lateinit var mediaSession: MediaLibrarySession + private set + + // --- MEDIASESSION CALLBACKS --- + + fun attach(service: MediaLibraryService, listener: ForegroundListener): MediaLibrarySession { + foregroundListener = listener + mediaSession = createSession(service) + service.addSession(mediaSession) + actionFactory = DefaultActionFactory(service) + playbackManager.addListener(this) + exoHolder.attach() + actionHandler.attach(this) + mediaItemBrowser.attach(this) + return mediaSession + } + + fun handleTaskRemoved() { + if (playbackManager.progression.isPlaying) { + playbackManager.endSession() + } + } + + fun release() { + waitJob.cancel() + mediaSession.release() + actionHandler.release() + exoHolder.release() + playbackManager.removeListener(this) + mediaSession.release() + foregroundListener = null + } + + fun hasNotification(): Boolean = exoHolder.sessionOngoing + + fun createNotification(post: (MediaNotification) -> Unit) { + val notification = + mediaNotificationProvider.createNotification( + mediaSession, mediaSession.customLayout, actionFactory) { notification -> + post(wrapMediaNotification(notification)) + } + post(wrapMediaNotification(notification)) + } + + private fun wrapMediaNotification(notification: MediaNotification): MediaNotification { + // Pulled from MediaNotificationManager: Need to specify MediaSession token manually + // in notification + val fwkToken = + mediaSession.sessionCompatToken.token as android.media.session.MediaSession.Token + notification.notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken) + return notification + } + + private fun createSession(service: MediaLibraryService) = + MediaLibrarySession.Builder(service, exoHolder.mediaSessionPlayer, this) + .setBitmapLoader(bitmapLoader) + .build() + + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo + ): ConnectionResult { + val sessionCommands = + actionHandler.withCommands(ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS) + return ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands(sessionCommands) + .setCustomLayout(actionHandler.createCustomLayout()) + .build() + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture = + if (actionHandler.handleCommand(customCommand)) { + Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } else { + super.onCustomCommand(session, controller, customCommand, args) + } + + override fun onGetLibraryRoot( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + params: MediaLibraryService.LibraryParams? + ): ListenableFuture> = + Futures.immediateFuture(LibraryResult.ofItem(mediaItemBrowser.root, params)) + + override fun onGetItem( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + mediaId: String + ): ListenableFuture> { + val result = + mediaItemBrowser.getItem(mediaId)?.let { LibraryResult.ofItem(it, null) } + ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + return Futures.immediateFuture(result) + } + + override fun onSetMediaItems( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: MutableList, + startIndex: Int, + startPositionMs: Long + ): ListenableFuture = + Futures.immediateFuture( + MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs)) + + override fun onGetChildren( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: MediaLibraryService.LibraryParams? + ): ListenableFuture>> { + val children = + mediaItemBrowser.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: MediaLibraryService.LibraryParams? + ): ListenableFuture> = + waitScope + .async { + val count = mediaItemBrowser.prepareSearch(query) + session.notifySearchResultChanged(browser, query, count, params) + LibraryResult.ofVoid() + } + .asListenableFuture() + + override fun onGetSearchResult( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + query: String, + page: Int, + pageSize: Int, + params: MediaLibraryService.LibraryParams? + ) = + waitScope + .async { + mediaItemBrowser.getSearchResult(query, page, pageSize)?.let { + LibraryResult.ofItemList(it, params) + } + ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + } + .asListenableFuture() + + override fun onSessionEnded() { + foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + } + + override fun onCustomLayoutChanged(layout: List) { + mediaSession.setCustomLayout(layout) + } + + override fun invalidate(ids: List) { + for (id in ids) { + mediaSession.notifyChildrenChanged(id, Int.MAX_VALUE, null) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt index 2d73d5897..5d89acd4c 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt @@ -23,14 +23,141 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.media.AudioManager +import android.os.Bundle +import androidx.core.content.ContextCompat +import androidx.media3.session.CommandButton +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionCommands +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -import org.oxycblt.auxio.AuxioService +import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.widgets.WidgetComponent import org.oxycblt.auxio.widgets.WidgetProvider +class PlaybackActionHandler +@Inject +constructor( + @ApplicationContext private val context: Context, + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings, + private val systemReceiver: SystemPlaybackReceiver +) : PlaybackStateManager.Listener, PlaybackSettings.Listener { + + interface Callback { + fun onCustomLayoutChanged(layout: List) + } + + private var callback: Callback? = null + + fun attach(callback: Callback) { + this.callback = callback + playbackManager.addListener(this) + playbackSettings.registerListener(this) + ContextCompat.registerReceiver( + context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED) + } + + fun release() { + callback = null + playbackManager.removeListener(this) + playbackSettings.unregisterListener(this) + context.unregisterReceiver(systemReceiver) + } + + fun withCommands(commands: SessionCommands) = + commands + .buildUpon() + .add(SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle.EMPTY)) + .add(SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle.EMPTY)) + .add(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle.EMPTY)) + .build() + + fun handleCommand(command: SessionCommand): Boolean { + when (command.customAction) { + PlaybackActions.ACTION_INC_REPEAT_MODE -> + playbackManager.repeatMode(playbackManager.repeatMode.increment()) + PlaybackActions.ACTION_INVERT_SHUFFLE -> + playbackManager.shuffled(!playbackManager.isShuffled) + PlaybackActions.ACTION_EXIT -> playbackManager.endSession() + else -> return false + } + return true + } + + fun createCustomLayout(): List { + val actions = mutableListOf() + + when (playbackSettings.notificationAction) { + ActionMode.REPEAT -> { + actions.add( + CommandButton.Builder() + .setIconResId(playbackManager.repeatMode.icon) + .setDisplayName(context.getString(R.string.desc_change_repeat)) + .setSessionCommand( + SessionCommand(PlaybackActions.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(context.getString(R.string.lbl_shuffle)) + .setSessionCommand( + SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle())) + .build()) + } + else -> {} + } + + actions.add( + CommandButton.Builder() + .setIconResId(R.drawable.ic_close_24) + .setDisplayName(context.getString(R.string.desc_exit)) + .setSessionCommand(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle())) + .build()) + + return actions + } + + override fun onPauseOnRepeatChanged() { + super.onPauseOnRepeatChanged() + callback?.onCustomLayoutChanged(createCustomLayout()) + } + + override fun onRepeatModeChanged(repeatMode: RepeatMode) { + super.onRepeatModeChanged(repeatMode) + callback?.onCustomLayoutChanged(createCustomLayout()) + } + + override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) { + super.onQueueReordered(queue, index, isShuffled) + callback?.onCustomLayoutChanged(createCustomLayout()) + } + + override fun onNotificationActionChanged() { + super.onNotificationActionChanged() + callback?.onCustomLayoutChanged(createCustomLayout()) + } +} + +object PlaybackActions { + 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" +} + /** * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an * active [IntentFilter] to be registered. @@ -48,11 +175,11 @@ constructor( IntentFilter().apply { addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) addAction(AudioManager.ACTION_HEADSET_PLUG) - addAction(AuxioService.ACTION_INC_REPEAT_MODE) - addAction(AuxioService.ACTION_INVERT_SHUFFLE) - addAction(AuxioService.ACTION_SKIP_PREV) - addAction(AuxioService.ACTION_PLAY_PAUSE) - addAction(AuxioService.ACTION_SKIP_NEXT) + addAction(PlaybackActions.ACTION_INC_REPEAT_MODE) + addAction(PlaybackActions.ACTION_INVERT_SHUFFLE) + addAction(PlaybackActions.ACTION_SKIP_PREV) + addAction(PlaybackActions.ACTION_PLAY_PAUSE) + addAction(PlaybackActions.ACTION_SKIP_NEXT) addAction(WidgetProvider.ACTION_WIDGET_UPDATE) } @@ -82,26 +209,30 @@ constructor( } // --- AUXIO EVENTS --- - AuxioService.ACTION_PLAY_PAUSE -> { + PlaybackActions.ACTION_PLAY_PAUSE -> { logD("Received play event") playbackManager.playing(!playbackManager.progression.isPlaying) } - AuxioService.ACTION_INC_REPEAT_MODE -> { + PlaybackActions.ACTION_INC_REPEAT_MODE -> { logD("Received repeat mode event") playbackManager.repeatMode(playbackManager.repeatMode.increment()) } - AuxioService.ACTION_INVERT_SHUFFLE -> { + PlaybackActions.ACTION_INVERT_SHUFFLE -> { logD("Received shuffle event") playbackManager.shuffled(!playbackManager.isShuffled) } - AuxioService.ACTION_SKIP_PREV -> { + PlaybackActions.ACTION_SKIP_PREV -> { logD("Received skip previous event") playbackManager.prev() } - AuxioService.ACTION_SKIP_NEXT -> { + PlaybackActions.ACTION_SKIP_NEXT -> { logD("Received skip next event") playbackManager.next() } + PlaybackActions.ACTION_EXIT -> { + logD("Received exit event") + playbackManager.endSession() + } WidgetProvider.ACTION_WIDGET_UPDATE -> { logD("Received widget update event") widgetComponent.update() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt index 8780dcdbb..857ac6898 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt @@ -147,6 +147,9 @@ interface PlaybackStateHolder { */ fun applySavedState(parent: MusicParent?, rawQueue: RawQueue, ack: StateAck.NewPlayback?) + /** End whatever ongoing playback session may be going on */ + fun endSession() + /** Reset this instance to an empty state. */ fun reset(ack: StateAck.NewPlayback) } @@ -195,6 +198,8 @@ sealed interface StateAck { /** @see PlaybackStateHolder.repeatMode */ data object RepeatModeChanged : StateAck + + data object SessionEnded : StateAck } /** diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index d9364fe91..347b099ca 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -233,8 +233,7 @@ interface PlaybackStateManager { */ fun seekTo(positionMs: Long) - /** Rewind to the beginning of the currently playing [Song]. */ - fun rewind() = seekTo(0) + fun endSession() /** * Converts the current state of this instance into a [SavedState]. @@ -313,6 +312,8 @@ interface PlaybackStateManager { * @param repeatMode The new [RepeatMode]. */ fun onRepeatModeChanged(repeatMode: RepeatMode) {} + + fun onSessionEnded() {} } /** @@ -564,6 +565,13 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { stateHolder.seekTo(positionMs) } + @Synchronized + override fun endSession() { + val stateHolder = stateHolder ?: return + logD("Ending session") + stateHolder.endSession() + } + @Synchronized override fun ack(stateHolder: PlaybackStateHolder, ack: StateAck) { if (BuildConfig.DEBUG && this.stateHolder !== stateHolder) { @@ -690,6 +698,9 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { ) listeners.forEach { it.onRepeatModeChanged(stateMirror.repeatMode) } } + is StateAck.SessionEnded -> { + listeners.forEach { it.onSessionEnded() } + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ForegroundServiceNotification.kt b/app/src/main/java/org/oxycblt/auxio/ui/ForegroundServiceNotification.kt deleted file mode 100644 index df1c1e604..000000000 --- a/app/src/main/java/org/oxycblt/auxio/ui/ForegroundServiceNotification.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * ForegroundServiceNotification.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 . - */ - -package org.oxycblt.auxio.ui - -import android.content.Context -import androidx.annotation.StringRes -import androidx.core.app.NotificationChannelCompat -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat - -/** - * Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that - * signal a Service's ongoing foreground state. - * - * @author Alexander Capehart (OxygenCobalt) - */ -abstract class ForegroundServiceNotification(context: Context, info: ChannelInfo) : - NotificationCompat.Builder(context, info.id) { - private val notificationManager = NotificationManagerCompat.from(context) - - init { - // Set up the notification channel. Foreground notifications are non-substantial, and - // thus make no sense to have lights, vibration, or lead to a notification badge. - val channel = - NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW) - .setName(context.getString(info.nameRes)) - .setLightsEnabled(false) - .setVibrationEnabled(false) - .setShowBadge(false) - .build() - notificationManager.createNotificationChannel(channel) - } - - /** - * The code used to identify this notification. - * - * @see NotificationManagerCompat.notify - */ - abstract val code: Int - - /** Post this notification using [NotificationManagerCompat]. */ - fun post() { - // This is safe to call without the POST_NOTIFICATIONS permission, as it's a foreground - // notification. - @Suppress("MissingPermission") notificationManager.notify(code, build()) - } - - /** - * Reduced representation of a [NotificationChannelCompat]. - * - * @param id The ID of the channel. - * @param nameRes A string resource ID corresponding to the human-readable name of this channel. - */ - data class ChannelInfo(val id: String, @StringRes val nameRes: Int) -} diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt index 9ffd7631f..a13cd9895 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt @@ -28,10 +28,10 @@ import android.os.Bundle import android.util.SizeF import android.view.View import android.widget.RemoteViews -import org.oxycblt.auxio.AuxioService import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.playback.service.PlaybackActions import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.logD @@ -339,7 +339,7 @@ class WidgetProvider : AppWidgetProvider() { // by PlaybackService. setOnClickPendingIntent( R.id.widget_play_pause, - context.newBroadcastPendingIntent(AuxioService.ACTION_PLAY_PAUSE)) + context.newBroadcastPendingIntent(PlaybackActions.ACTION_PLAY_PAUSE)) // Set up the play/pause button appearance. Like the Android 13 media controls, use // a circular FAB when paused, and a squircle FAB when playing. This does require us @@ -379,9 +379,11 @@ class WidgetProvider : AppWidgetProvider() { // Hook the skip buttons to the respective broadcasts that can be recognized // by PlaybackService. setOnClickPendingIntent( - R.id.widget_skip_prev, context.newBroadcastPendingIntent(AuxioService.ACTION_SKIP_PREV)) + R.id.widget_skip_prev, + context.newBroadcastPendingIntent(PlaybackActions.ACTION_SKIP_PREV)) setOnClickPendingIntent( - R.id.widget_skip_next, context.newBroadcastPendingIntent(AuxioService.ACTION_SKIP_NEXT)) + R.id.widget_skip_next, + context.newBroadcastPendingIntent(PlaybackActions.ACTION_SKIP_NEXT)) return this } @@ -403,10 +405,10 @@ class WidgetProvider : AppWidgetProvider() { // be recognized by PlaybackService. setOnClickPendingIntent( R.id.widget_repeat, - context.newBroadcastPendingIntent(AuxioService.ACTION_INC_REPEAT_MODE)) + context.newBroadcastPendingIntent(PlaybackActions.ACTION_INC_REPEAT_MODE)) setOnClickPendingIntent( R.id.widget_shuffle, - context.newBroadcastPendingIntent(AuxioService.ACTION_INVERT_SHUFFLE)) + context.newBroadcastPendingIntent(PlaybackActions.ACTION_INVERT_SHUFFLE)) // Set up the repeat/shuffle buttons. When working with RemoteViews, we will // need to hard-code different accent tinting configurations, as stateful drawables