music: add indexer service

Add a dedicated service towards the loading of the music library.

This new service was created for two reasons:
1. Music loading is slow and resource-intensive, so putting it on the
ViewModel layer just didn't seem right and made it vulnerable to the
OS simply stopping the loading process.
2. For automatic rescanning [#72], there must be something watching
the music library and waiting for a change in the background. This
would require a service as that is probably the least insane way to
do that kind of background work.

I have no garuntees how viable the service might be. If anything, it
might be halted by some insane android restriction or issue that
makes it more or less impossible to use for most apps, and I will have
to largely drop truly automatic rescanning.
This commit is contained in:
OxygenCobalt 2022-06-03 20:12:27 -06:00
parent e3708bf5f5
commit 08caa01dca
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
16 changed files with 233 additions and 59 deletions

View file

@ -67,6 +67,13 @@
</intent-filter>
</activity>
<service
android:name=".music.IndexerService"
android:foregroundServiceType="dataSync"
android:icon="@mipmap/ic_launcher"
android:exported="false"
android:roundIcon="@mipmap/ic_launcher" />
<service
android:name=".playback.system.PlaybackService"
android:foregroundServiceType="mediaPlayback"
@ -74,6 +81,7 @@
android:exported="false"
android:roundIcon="@mipmap/ic_launcher" />
<!--
Work around apps that blindly query for ACTION_MEDIA_BUTTON working.
See the class for more info.

View file

@ -52,8 +52,10 @@ object IntegerTable {
/** QueueSongViewHolder */
const val ITEM_TYPE_QUEUE_SONG = 0xA00E
/** "Music playback" Notification code */
/** "Music playback" notification code */
const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0
/** "Music loading" notification code */
const val INDEXER_NOTIFICATION_CODE = 0xA0A1
/** Intent request code */
const val REQUEST_CODE = 0xA0C0

View file

@ -28,6 +28,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.view.updatePadding
import org.oxycblt.auxio.databinding.ActivityMainBinding
import org.oxycblt.auxio.music.IndexerService
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.system.PlaybackService
import org.oxycblt.auxio.settings.SettingsManager
@ -66,6 +67,7 @@ class MainActivity : AppCompatActivity() {
override fun onStart() {
super.onStart()
startService(Intent(this, IndexerService::class.java))
startService(Intent(this, PlaybackService::class.java))
// If we have a file URI already, open it. Otherwise, restore the playback state.

View file

@ -70,11 +70,6 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
// --- VIEWMODEL SETUP ---
// Initialize music loading. Do it here so that it shows on every fragment that this
// one contains.
// TODO: Move this to a service [automatic rescanning]
musicModel.index(requireContext())
launch { navModel.mainNavigationAction.collect(::handleMainNavigation) }
launch { navModel.exploreNavigationItem.collect(::handleExploreNavigation) }
launch { playbackModel.song.collect(::updateSong) }

View file

@ -81,7 +81,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
// Build the permission launcher here as you can only do it in onCreateView/onCreate
storagePermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
indexerModel.reindex(requireContext())
indexerModel.reindex()
}
binding.homeToolbar.apply {
@ -287,7 +287,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
binding.homeLoadingAction.apply {
visibility = View.VISIBLE
text = getString(R.string.lbl_retry)
setOnClickListener { indexerModel.reindex(requireContext()) }
setOnClickListener { indexerModel.reindex() }
}
}
is Indexer.Response.NoMusic -> {
@ -296,7 +296,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
binding.homeLoadingAction.apply {
visibility = View.VISIBLE
text = getString(R.string.lbl_retry)
setOnClickListener { indexerModel.reindex(requireContext()) }
setOnClickListener { indexerModel.reindex() }
}
}
is Indexer.Response.NoPerms -> {

View file

@ -24,7 +24,6 @@ import android.database.Cursor
import android.os.Build
import androidx.core.content.ContextCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.music.backend.Api21MediaStoreBackend
import org.oxycblt.auxio.music.backend.Api30MediaStoreBackend
@ -77,6 +76,7 @@ class Indexer {
if (notGranted) {
emitState(State.Complete(Response.NoPerms), generation)
return
}
val response =
@ -101,6 +101,12 @@ class Indexer {
emitState(State.Complete(response), generation)
}
fun requestReindex() {
for (callback in callbacks) {
callback.onRequestReindex()
}
}
/**
* "Cancel" the last job by making it unable to send further state updates. This should be
* called if an object that called [index] is about to be destroyed and thus will have it's task
@ -313,6 +319,7 @@ class Indexer {
interface Callback {
fun onIndexerStateChanged(state: State?)
fun onRequestReindex() {}
}
/** Represents a backend that metadata can be extracted from. */

View file

@ -0,0 +1,172 @@
/*
* 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.music
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
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.util.logD
import org.oxycblt.auxio.util.newMainPendingIntent
/**
* A [Service] that handles the music loading process.
*
* Loading music is actually somewhat time-consuming, to the point where it's likely better suited
* to a service that is less likely to be
*
* TODO: Rename all instances of loading in-app with indexing
*
* @author OxygenCobalt
*/
class IndexerService : Service(), Indexer.Callback {
private val indexer = Indexer.getInstance()
private val musicStore = MusicStore.getInstance()
private val serviceJob = Job()
private val indexScope = CoroutineScope(serviceJob + Dispatchers.Default)
private var isForeground = false
private lateinit var notification: IndexerNotification
override fun onCreate() {
super.onCreate()
notification = IndexerNotification(this)
indexer.addCallback(this)
if (musicStore.library == null) {
logD("No library present, loading music now")
onRequestReindex()
}
logD("Service created.")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = START_NOT_STICKY
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
super.onDestroy()
stopForeground(true)
isForeground = false
indexer.removeCallback(this)
indexer.cancelLast()
serviceJob.cancel()
}
override fun onIndexerStateChanged(state: Indexer.State?) {
when (state) {
is Indexer.State.Complete -> {
if (state.response is Indexer.Response.Ok) {
// Load was completed successfully, so apply the new library.
musicStore.library = state.response.library
}
// On errors, while we would want to show a notification that displays the
// error, in practice that comes into conflict with the upcoming Android 13
// notification permission, and there is no point implementing permission
// on-boarding for such when it will only be used for this.
// Note that we don't stop the service here, as (in the future)
// this service will be used to reload music and observe the music
// database.
stopForegroundSession()
}
is Indexer.State.Query,
is Indexer.State.Loading -> {
// We are loading, so we want to the enter the foreground state so that android
// does not kill our app. Note that while we would prefer to display the current
// loading progress, updates tend to be too rapid-fire for it too work well.
startForegroundSession()
}
null -> {
// Null is the indeterminate state that occurs on app startup or after
// the cancellation of a load, so in that case we want to stop foreground
// since (technically) nothing is loading.
stopForegroundSession()
}
}
}
override fun onRequestReindex() {
indexScope.launch { indexer.index(this@IndexerService) }
}
private fun startForegroundSession() {
if (!isForeground) {
logD("Starting foreground service")
startForeground(IntegerTable.INDEXER_NOTIFICATION_CODE, notification.build())
isForeground = true
}
}
private fun stopForegroundSession() {
if (isForeground) {
stopForeground(true)
isForeground = false
}
}
}
private class IndexerNotification(context: Context) :
NotificationCompat.Builder(context, CHANNEL_ID) {
private val notificationManager = context.getSystemServiceSafe(NotificationManager::class)
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)
setCategory(NotificationCompat.CATEGORY_SERVICE)
setShowWhen(false)
setSilent(true)
setContentIntent(context.newMainPendingIntent())
setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
setContentTitle(context.getString(R.string.info_indexer_channel_name))
setContentText(context.getString(R.string.lbl_loading))
setProgress(0, 0, true)
}
companion object {
const val CHANNEL_ID = BuildConfig.APPLICATION_ID + ".channel.INDEXER"
}
}

View file

@ -17,18 +17,13 @@
package org.oxycblt.auxio.music
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.oxycblt.auxio.util.logD
/** A ViewModel representing the current music indexing state. */
class IndexerViewModel : ViewModel(), Indexer.Callback {
private val indexer = Indexer.getInstance()
private val musicStore = MusicStore.getInstance()
private val _state = MutableStateFlow<Indexer.State?>(null)
val state: StateFlow<Indexer.State?> = _state
@ -37,40 +32,15 @@ class IndexerViewModel : ViewModel(), Indexer.Callback {
indexer.addCallback(this)
}
/** Initiate the indexing process. */
fun index(context: Context) {
if (state.value != null) {
logD("Loader is already loading/is completed, not reloading")
return
}
indexImpl(context)
}
/**
* Reload the music library. Note that this call will result in unexpected behavior in the case
* that music is reloaded after a loading process has already exceeded.
*/
fun reindex(context: Context) {
logD("Reloading music library")
indexImpl(context)
}
private fun indexImpl(context: Context) {
viewModelScope.launch { indexer.index(context) }
fun reindex() {
indexer.requestReindex()
}
override fun onIndexerStateChanged(state: Indexer.State?) {
_state.value = state
if (state is Indexer.State.Complete && state.response is Indexer.Response.Ok) {
musicStore.library = state.response.library
}
}
override fun onCleared() {
super.onCleared()
indexer.cancelLast()
indexer.removeCallback(this)
}
}

View file

@ -24,8 +24,8 @@ import org.oxycblt.auxio.music.backend.useQuery
import org.oxycblt.auxio.util.contentResolverSafe
/**
* The main storage for music items. Getting an instance of this object is more complicated as it
* loads asynchronously. See the companion object for more.
* The main storage for music items. The items themselves are located in a [Library], however this
* might not be available at all times.
*
* TODO: Add automatic rescanning [major change]
* @author OxygenCobalt
@ -88,6 +88,7 @@ class MusicStore private constructor() {
companion object {
@Volatile private var INSTANCE: MusicStore? = null
/** Get the process-level instance of [MusicStore] */
fun getInstance(): MusicStore {
val currentInstance = INSTANCE

View file

@ -121,6 +121,7 @@ class PlaybackPanelFragment :
binding.playbackToolbar.setOnMenuItemClickListener(null)
binding.playbackSong.isSelected = false
binding.playbackSeekBar.callback = null
queueItem = null
}
override fun onMenuItemClick(item: MenuItem): Boolean {

View file

@ -40,9 +40,10 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* The ViewModel that provides a UI frontend for [PlaybackStateManager].
*
* **PLEASE Use this instead of [PlaybackStateManager], UI's are extremely volatile and this
* **PLEASE Use this instead of [PlaybackStateManager], UIs are extremely volatile and this
* provides an interface that properly sanitizes input and abstracts functions unlike the master
* class.**
*
* @author OxygenCobalt
*/
class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback, MusicStore.Callback {

View file

@ -35,8 +35,8 @@ import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.util.getSystemServiceSafe
import org.oxycblt.auxio.util.newBroadcastIntent
import org.oxycblt.auxio.util.newMainIntent
import org.oxycblt.auxio.util.newBroadcastPendingIntent
import org.oxycblt.auxio.util.newMainPendingIntent
/**
* The unified notification for [PlaybackService]. Due to the nature of how this notification is
@ -68,7 +68,7 @@ class NotificationComponent(
setCategory(NotificationCompat.CATEGORY_SERVICE)
setShowWhen(false)
setSilent(true)
setContentIntent(context.newMainIntent())
setContentIntent(context.newMainPendingIntent())
setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
addAction(buildRepeatAction(context, RepeatMode.NONE))
@ -182,7 +182,7 @@ class NotificationComponent(
): NotificationCompat.Action {
val action =
NotificationCompat.Action.Builder(
iconRes, actionName, context.newBroadcastIntent(actionName))
iconRes, actionName, context.newBroadcastPendingIntent(actionName))
return action.build()
}

View file

@ -222,7 +222,7 @@ fun Context.showToast(@StringRes str: Int) {
}
/** Create a [PendingIntent] that leads to Auxio's [MainActivity] */
fun Context.newMainIntent(): PendingIntent =
fun Context.newMainPendingIntent(): PendingIntent =
PendingIntent.getActivity(
this,
IntegerTable.REQUEST_CODE,
@ -230,7 +230,7 @@ fun Context.newMainIntent(): PendingIntent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
/** Create a broadcast [PendingIntent] */
fun Context.newBroadcastIntent(what: String): PendingIntent =
fun Context.newBroadcastPendingIntent(what: String): PendingIntent =
PendingIntent.getBroadcast(
this,
IntegerTable.REQUEST_CODE,

View file

@ -23,8 +23,8 @@ import androidx.annotation.LayoutRes
import org.oxycblt.auxio.R
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.playback.system.PlaybackService
import org.oxycblt.auxio.util.newBroadcastIntent
import org.oxycblt.auxio.util.newMainIntent
import org.oxycblt.auxio.util.newBroadcastPendingIntent
import org.oxycblt.auxio.util.newMainPendingIntent
/**
* The default widget is displayed whenever there is no music playing. It just shows the message "No
@ -73,7 +73,7 @@ fun createLargeWidget(context: Context, state: WidgetComponent.WidgetState): Rem
private fun createViews(context: Context, @LayoutRes layout: Int): RemoteViews {
val views = RemoteViews(context.packageName, layout)
views.setOnClickPendingIntent(android.R.id.background, context.newMainIntent())
views.setOnClickPendingIntent(android.R.id.background, context.newMainPendingIntent())
return views
}
@ -111,7 +111,8 @@ private fun RemoteViews.applyPlayPauseControls(
state: WidgetComponent.WidgetState
): RemoteViews {
setOnClickPendingIntent(
R.id.widget_play_pause, context.newBroadcastIntent(PlaybackService.ACTION_PLAY_PAUSE))
R.id.widget_play_pause,
context.newBroadcastPendingIntent(PlaybackService.ACTION_PLAY_PAUSE))
setImageViewResource(
R.id.widget_play_pause,
@ -131,10 +132,10 @@ private fun RemoteViews.applyBasicControls(
applyPlayPauseControls(context, state)
setOnClickPendingIntent(
R.id.widget_skip_prev, context.newBroadcastIntent(PlaybackService.ACTION_SKIP_PREV))
R.id.widget_skip_prev, context.newBroadcastPendingIntent(PlaybackService.ACTION_SKIP_PREV))
setOnClickPendingIntent(
R.id.widget_skip_next, context.newBroadcastIntent(PlaybackService.ACTION_SKIP_NEXT))
R.id.widget_skip_next, context.newBroadcastPendingIntent(PlaybackService.ACTION_SKIP_NEXT))
return this
}
@ -146,10 +147,12 @@ private fun RemoteViews.applyFullControls(
applyBasicControls(context, state)
setOnClickPendingIntent(
R.id.widget_repeat, context.newBroadcastIntent(PlaybackService.ACTION_INC_REPEAT_MODE))
R.id.widget_repeat,
context.newBroadcastPendingIntent(PlaybackService.ACTION_INC_REPEAT_MODE))
setOnClickPendingIntent(
R.id.widget_shuffle, context.newBroadcastIntent(PlaybackService.ACTION_INVERT_SHUFFLE))
R.id.widget_shuffle,
context.newBroadcastPendingIntent(PlaybackService.ACTION_INVERT_SHUFFLE))
// Like notifications, use the remote variants of icons since we really don't want to hack
// indicators.

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M4.2,5l-0.7,1.9L17.6,12L3,12v8h18v-8.86L4.2,5zM7,17L5,17v-2h2v2zM19,17L9,17v-2h10v2z"/>
</vector>

View file

@ -3,6 +3,7 @@
<!-- Info namespace | App labels -->
<string name="info_app_desc">A simple, rational music player for android.</string>
<string name="info_playback_channel_name">Music Playback</string>
<string name="info_indexer_channel_name">Music Loading</string>
<string name="info_widget_desc">View and control music playback</string>
<!-- Label Namespace | Static Labels -->