diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt index 8e56fb7be..accb30a6c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt @@ -23,17 +23,17 @@ import androidx.core.app.NotificationCompat import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R -import org.oxycblt.auxio.shared.ServiceNotification +import org.oxycblt.auxio.shared.ForegroundServiceNotification import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newMainPendingIntent /** - * A dynamic [ServiceNotification] that shows the current music loading state. + * A dynamic [ForegroundServiceNotification] 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) : - ServiceNotification(context, INDEXER_CHANNEL) { + ForegroundServiceNotification(context, INDEXER_CHANNEL) { private var lastUpdateTime = -1L init { @@ -89,11 +89,11 @@ class IndexingNotification(private val context: Context) : } /** - * A static [ServiceNotification] that signals to the user that the app is currently monitoring + * A static [ForegroundServiceNotification] that signals to the user that the app is currently monitoring * the music library for changes. * @author Alexander Capehart (OxygenCobalt) */ -class ObservingNotification(context: Context) : ServiceNotification(context, INDEXER_CHANNEL) { +class ObservingNotification(context: Context) : ForegroundServiceNotification(context, INDEXER_CHANNEL) { init { setSmallIcon(R.drawable.ic_indexer_24) setCategory(NotificationCompat.CATEGORY_SERVICE) @@ -111,5 +111,5 @@ class ObservingNotification(context: Context) : ServiceNotification(context, IND /** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */ private val INDEXER_CHANNEL = - ServiceNotification.ChannelInfo( + ForegroundServiceNotification.ChannelInfo( id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBottomSheetBehavior.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBottomSheetBehavior.kt index d08f09860..f94aa0ffc 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBottomSheetBehavior.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBottomSheetBehavior.kt @@ -25,7 +25,7 @@ import android.view.View import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.shape.MaterialShapeDrawable import org.oxycblt.auxio.R -import org.oxycblt.auxio.shared.AuxioBottomSheetBehavior +import org.oxycblt.auxio.playback.ui.BaseBottomSheetBehavior import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getDimen @@ -35,7 +35,7 @@ import org.oxycblt.auxio.util.getDimen * @author Alexander Capehart (OxygenCobalt) */ class PlaybackBottomSheetBehavior(context: Context, attributeSet: AttributeSet?) : - AuxioBottomSheetBehavior(context, attributeSet) { + BaseBottomSheetBehavior(context, attributeSet) { val sheetBackgroundDrawable = MaterialShapeDrawable.createWithElevationOverlay(context).apply { fillColor = context.getAttrColorCompat(R.attr.colorSurface) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueBottomSheetBehavior.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueBottomSheetBehavior.kt index 16004fe1d..361c334c1 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueBottomSheetBehavior.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueBottomSheetBehavior.kt @@ -24,7 +24,7 @@ import android.view.WindowInsets import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.shape.MaterialShapeDrawable import org.oxycblt.auxio.R -import org.oxycblt.auxio.shared.AuxioBottomSheetBehavior +import org.oxycblt.auxio.playback.ui.BaseBottomSheetBehavior import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getDimen import org.oxycblt.auxio.util.getDimenPixels @@ -36,7 +36,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat * @author Alexander Capehart (OxygenCobalt) */ class QueueBottomSheetBehavior(context: Context, attributeSet: AttributeSet?) : - AuxioBottomSheetBehavior(context, attributeSet) { + BaseBottomSheetBehavior(context, attributeSet) { private var barHeight = 0 private var barSpacing = context.getDimenPixels(R.dimen.spacing_small) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt index 253b55bc5..d61599f62 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt @@ -29,7 +29,7 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.shared.ServiceNotification +import org.oxycblt.auxio.shared.ForegroundServiceNotification import org.oxycblt.auxio.util.newBroadcastPendingIntent import org.oxycblt.auxio.util.newMainPendingIntent @@ -41,7 +41,7 @@ import org.oxycblt.auxio.util.newMainPendingIntent */ @SuppressLint("RestrictedApi") class NotificationComponent(private val context: Context, sessionToken: MediaSessionCompat.Token) : - ServiceNotification(context, CHANNEL_INFO) { + ForegroundServiceNotification(context, CHANNEL_INFO) { init { setSmallIcon(R.drawable.ic_auxio_24) setCategory(NotificationCompat.CATEGORY_TRANSPORT) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index a45323a1a..48e5d1154 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -451,7 +451,7 @@ class PlaybackService : playbackManager.changePlaying(false) stopAndSave() } - WidgetProvider.ACTION_WIDGET_UPDATE -> widgetComponent.updateNowPlaying() + WidgetProvider.ACTION_WIDGET_UPDATE -> widgetComponent.update() } } diff --git a/app/src/main/java/org/oxycblt/auxio/shared/AuxioBottomSheetBehavior.kt b/app/src/main/java/org/oxycblt/auxio/playback/ui/BaseBottomSheetBehavior.kt similarity index 61% rename from app/src/main/java/org/oxycblt/auxio/shared/AuxioBottomSheetBehavior.kt rename to app/src/main/java/org/oxycblt/auxio/playback/ui/BaseBottomSheetBehavior.kt index dceb4423f..a9b30d1d9 100644 --- a/app/src/main/java/org/oxycblt/auxio/shared/AuxioBottomSheetBehavior.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/ui/BaseBottomSheetBehavior.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.shared +package org.oxycblt.auxio.playback.ui import android.content.Context import android.graphics.drawable.Drawable @@ -30,24 +30,34 @@ import org.oxycblt.auxio.util.getDimen import org.oxycblt.auxio.util.systemGestureInsetsCompat /** - * Implements a reasonable enough skeleton around BottomSheetBehavior (Excluding auxio extensions in - * the vendored code because of course I have to) for normal use without absurd bugs. - * @author Alexander Capehart (OxygenCobalt) + * A BottomSheetBehavior that resolves several issues with the default implementation, including: + * 1. */ -abstract class AuxioBottomSheetBehavior(context: Context, attributeSet: AttributeSet?) : +abstract class BaseBottomSheetBehavior(context: Context, attributeSet: AttributeSet?) : NeoBottomSheetBehavior(context, attributeSet) { - private var setup = false + private var initalized = false init { - // We need to disable isFitToContents for us to have our bottom sheet expand to the - // whole of the screen and not just whatever portion it takes up. + // Disable isFitToContents to make the bottom sheet expand to the top of the screen and + // not just how much the content takes up. isFitToContents = false } - /** Called when the sheet background is being created */ + /** + * Create a background [Drawable] to use for this [BaseBottomSheetBehavior]'s child [View]. + * @param context [Context] that can be used to draw the [Drawable]. + * @return A background drawable. + */ abstract fun createBackground(context: Context): Drawable - /** Called when the child the bottom sheet applies to receives window insets. */ + /** + * Called when window insets are being applied to the [View] this [BaseBottomSheetBehavior] + * is linked to. + * @param child The child view recieving the [WindowInsets]. + * @param insets The [WindowInsets] to apply. + * @return The (possibly modified) [WindowInsets]. + * @see View.onApplyWindowInsets + */ open fun applyWindowInsets(child: View, insets: WindowInsets): WindowInsets { // All sheet behaviors derive their peek height from the size of the "bar" (i.e the // first child) plus the gesture insets. @@ -56,8 +66,7 @@ abstract class AuxioBottomSheetBehavior(context: Context, attributeSet return insets } - // Enable experimental settings to allow us to skip the half expanded state without - // dumb hacks. + // Enable experimental settings that allow us to skip the half-expanded state. override fun shouldSkipHalfExpandedStateWhenDragging() = true override fun shouldExpandOnUpwardDrag(dragDurationMillis: Long, yPositionPercentage: Float) = true @@ -65,18 +74,21 @@ abstract class AuxioBottomSheetBehavior(context: Context, attributeSet override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean { val layout = super.onLayoutChild(parent, child, layoutDirection) - if (!setup) { + // Don't repeat redundant initialization. + if (!initalized) { child.apply { + // Set up compat elevation attributes. These are only shown below API 28. translationZ = context.getDimen(R.dimen.elevation_normal) + // Background differs depending on concrete implementation. background = createBackground(context) setOnApplyWindowInsetsListener(::applyWindowInsets) } - setup = true + initalized = true } - // Sometimes CoordinatorLayout tries to be "hElpfUl" and just does not dispatch window - // insets sometimes. Ensure that we get them. + // Sometimes CoordinatorLayout doesn't dispatch window insets to us, likely due to how + // much we overload it. Ensure that we get them. child.requestApplyInsets() return layout diff --git a/app/src/main/java/org/oxycblt/auxio/shared/BottomSheetContentBehavior.kt b/app/src/main/java/org/oxycblt/auxio/playback/ui/BottomSheetContentBehavior.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/shared/BottomSheetContentBehavior.kt rename to app/src/main/java/org/oxycblt/auxio/playback/ui/BottomSheetContentBehavior.kt index 290c40111..e8d1b1d7c 100644 --- a/app/src/main/java/org/oxycblt/auxio/shared/BottomSheetContentBehavior.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/ui/BottomSheetContentBehavior.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.shared +package org.oxycblt.auxio.playback.ui import android.content.Context import android.util.AttributeSet diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index 13de99c64..3f3f49d18 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -79,7 +79,7 @@ class SearchFragment : ListFragment() { menu.findItem(searchModel.getFilterOptionId()).isChecked = true setNavigationOnClickListener { - // Keyboard is no longer needed, drop it. + // Keyboard is no longer needed. imm.hide() findNavController().navigateUp() } @@ -126,6 +126,7 @@ class SearchFragment : ListFragment() { if (item.itemId != R.id.submenu_filtering) { // Is a change in filter mode and not just a junk submenu click, update // the filtering within SearchViewModel. + item.isChecked = true searchModel.setFilterOptionId(item.itemId) return true } @@ -182,16 +183,16 @@ class SearchFragment : ListFragment() { is Genre -> SearchFragmentDirections.actionShowGenre(item.uid) else -> return } - - findNavController().navigate(action) - // Drop keyboard as it's no longer needed + // Keyboard is no longer needed. imm.hide() + findNavController().navigate(action) } private fun updateSelection(selected: List) { searchAdapter.setSelectedItems(selected) if (requireBinding().searchSelectionToolbar.updateSelectionAmount(selected.size) && selected.isNotEmpty()) { + // Make selection of obscured items easier by hiding the keyboard. imm.hide() } } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index 64725a723..4aaf87e9c 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -20,7 +20,6 @@ package org.oxycblt.auxio.search import android.app.Application import androidx.annotation.IdRes import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import java.text.Normalizer import kotlinx.coroutines.Job @@ -76,7 +75,9 @@ class SearchViewModel(application: Application) : } /** - * Use [query] to perform a search of the music library. Will push results to [searchResults]. + * Asynchronously search the music library. Results will be pushed to [searchResults]. Will + * cancel any previous search operations started prior. + * @param query The query to search the music library for. */ fun search(query: String?) { // Cancel the previous background search. @@ -107,30 +108,30 @@ class SearchViewModel(application: Application) : // Note: A null filter mode maps to the "All" filter option, hence the check. if (filterMode == null || filterMode == MusicMode.ARTISTS) { - library.artists.filterArtistsBy(query)?.let { artists -> + library.artists.searchListImpl(query)?.let { results.add(Header(R.string.lbl_artists)) - results.addAll(sort.artists(artists)) + results.addAll(sort.artists(it)) } } if (filterMode == null || filterMode == MusicMode.ALBUMS) { - library.albums.filterAlbumsBy(query)?.let { albums -> + library.albums.searchListImpl(query)?.let { results.add(Header(R.string.lbl_albums)) - results.addAll(sort.albums(albums)) + results.addAll(sort.albums(it)) } } if (filterMode == null || filterMode == MusicMode.GENRES) { - library.genres.filterGenresBy(query)?.let { genres -> + library.genres.searchListImpl(query)?.let { results.add(Header(R.string.lbl_genres)) - results.addAll(sort.genres(genres)) + results.addAll(sort.genres(it)) } } if (filterMode == null || filterMode == MusicMode.SONGS) { - library.songs.filterSongsBy(query)?.let { songs -> + library.songs.searchListImpl(query) { q, song -> song.path.name.contains(q) }?.let { results.add(Header(R.string.lbl_songs)) - results.addAll(sort.songs(songs)) + results.addAll(sort.songs(it)) } } @@ -138,26 +139,18 @@ class SearchViewModel(application: Application) : return results } - private fun List.filterSongsBy(value: String) = - searchListImpl(value) { - // Include both the sort name (can have normalized versions of titles) and - // file name (helpful for poorly tagged songs) to the filtering. - it.rawSortName?.contains(value, ignoreCase = true) == true || - it.path.name.contains(value) - } - - private fun List.filterAlbumsBy(value: String) = - // Include the sort name (can have normalized versions of names) to the filtering. - searchListImpl(value) { it.rawSortName?.contains(value, ignoreCase = true) == true } - - private fun List.filterArtistsBy(value: String) = - // Include the sort name (can have normalized versions of names) to the filtering. - searchListImpl(value) { it.rawSortName?.contains(value, ignoreCase = true) == true } - - private fun List.filterGenresBy(value: String) = searchListImpl(value) { false } - - private inline fun List.searchListImpl(query: String, fallback: (T) -> Boolean) = - filter { + /** + * Search a given [Music] list. + * @param query The query to search for. The routine will compare this query to the names + * of each object in the list and + * @param fallback Additional comparison code to run if the item does not match the query + * initially. This can be used to compare against additional attributes to improve search + * result quality. + */ + private inline fun List.searchListImpl( + query: String, + fallback: (String, T) -> Boolean = { _, _ -> false } + ) = filter { // See if the plain resolved name matches the query. This works for most situations. val name = it.resolveName(context) if (name.contains(query, ignoreCase = true)) { @@ -180,7 +173,7 @@ class SearchViewModel(application: Application) : return@filter true } - fallback(it) + fallback(query, it) } .ifEmpty { null } diff --git a/app/src/main/java/org/oxycblt/auxio/shared/AuxioAppBarLayout.kt b/app/src/main/java/org/oxycblt/auxio/shared/AuxioAppBarLayout.kt index bbb3157ce..effc64de4 100644 --- a/app/src/main/java/org/oxycblt/auxio/shared/AuxioAppBarLayout.kt +++ b/app/src/main/java/org/oxycblt/auxio/shared/AuxioAppBarLayout.kt @@ -31,10 +31,11 @@ import com.google.android.material.appbar.AppBarLayout import org.oxycblt.auxio.util.coordinatorLayoutBehavior /** - * An [AppBarLayout] that fixes several bugs with the default implementation where the lifted state - * will not properly respond to RecyclerView events. + * An [AppBarLayout] that resolves two issues with the default implementation: + * 1. Lift state failing to update when list data changes. + * 2. Expansion causing jumping in [RecyclerView] instances. * - * **Note:** This layout relies on [AppBarLayout.liftOnScrollTargetViewId] to figure out what + * Note: This layout relies on [AppBarLayout.liftOnScrollTargetViewId] to figure out what * scrolling view to use. Failure to specify this will result in the layout not working. * * Derived from Material Files: https://github.com/zhanghai/MaterialFiles @@ -46,8 +47,8 @@ open class AuxioAppBarLayout constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : AppBarLayout(context, attrs, defStyleAttr) { private var scrollingChild: View? = null - private val tConsumed = IntArray(2) + private val tConsumed = IntArray(2) private val onPreDraw = ViewTreeObserver.OnPreDrawListener { val child = findScrollingChild() @@ -67,7 +68,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr } /** - * Expand this app bar layout with the given recyclerview, preventing it from jumping around. + * Expand this [AppBarLayout] with respect to the given [RecyclerView], preventing it from + * jumping around. + * @param recycler [RecyclerView] to expand with, or null if one is currently unavailable. + * TODO: Is it possible to use liftOnScrollTargetViewId to avoid the [RecyclerView] argument? */ fun expandWithRecycler(recycler: RecyclerView?) { setExpanded(true) @@ -82,7 +86,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr override fun setLiftOnScrollTargetViewId(liftOnScrollTargetViewId: Int) { super.setLiftOnScrollTargetViewId(liftOnScrollTargetViewId) - // Sometimes we dynamically set the scrolling child [such as in HomeFragment], so clear it // and re-draw when that occurs. scrollingChild = null @@ -103,26 +106,40 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr return scrollingChild } - /** Hack to prevent RecyclerView jumping when the appbar expands. */ + /** + * An [AppBarLayout.OnOffsetChangedListener] that will automatically move the given + * [RecyclerView] as the [AppBarLayout] expands. Should be added right when the view + * is expanding. Will be removed automatically. + * @param recycler [RecyclerView] to scroll with the [AppBarLayout]. + */ private class ExpansionHackListener(private val recycler: RecyclerView) : OnOffsetChangedListener { - private val offsetAnimationMaxEndTime = (AnimationUtils.currentAnimationTimeMillis() + 600) - - private var lastVerticalOffset: Int? = null + private val offsetAnimationMaxEndTime = (AnimationUtils.currentAnimationTimeMillis() + + APP_BAR_LAYOUT_MAX_OFFSET_ANIMATION_DURATION) + private var currentVerticalOffset: Int? = null override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { if (verticalOffset == 0 || AnimationUtils.currentAnimationTimeMillis() > offsetAnimationMaxEndTime) { // AppBarLayout crashes with IndexOutOfBoundsException when a non-last listener - // removes - // itself, so we have to do the removal asynchronously. - appBarLayout.postOnAnimation { appBarLayout.removeOnOffsetChangedListener(this) } + // removes itself, so we have to do the removal asynchronously. + appBarLayout.postOnAnimation { + appBarLayout.removeOnOffsetChangedListener(this) } } - val lastVerticalOffset = lastVerticalOffset - this.lastVerticalOffset = verticalOffset - if (lastVerticalOffset != null) { - recycler.scrollBy(0, verticalOffset - lastVerticalOffset) + + // If possible, scroll by the offset delta between this update and the last update. + val oldVerticalOffset = currentVerticalOffset + currentVerticalOffset = verticalOffset + if (oldVerticalOffset != null) { + recycler.scrollBy(0, verticalOffset - oldVerticalOffset) } } } + + companion object { + /** + * @see AppBarLayout.BaseBehavior.MAX_OFFSET_ANIMATION_DURATION + */ + private const val APP_BAR_LAYOUT_MAX_OFFSET_ANIMATION_DURATION = 600 + } } diff --git a/app/src/main/java/org/oxycblt/auxio/shared/ForegroundManager.kt b/app/src/main/java/org/oxycblt/auxio/shared/ForegroundManager.kt index 88addebcf..aeb9544d5 100644 --- a/app/src/main/java/org/oxycblt/auxio/shared/ForegroundManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/shared/ForegroundManager.kt @@ -22,47 +22,55 @@ import androidx.core.app.ServiceCompat import org.oxycblt.auxio.util.logD /** - * Wrapper to create consistent behavior regarding a service's foreground state. + * A utility to create consistent foreground behavior for a given [Service]. + * @param service [Service] to wrap in this instance. * @author Alexander Capehart (OxygenCobalt) + * + * TODO: Merge with unified service when done. */ class ForegroundManager(private val service: Service) { private var isForeground = false + /** + * Release this instance. + */ fun release() { tryStopForeground() } /** - * Try to enter a foreground state. Returns false if already in foreground, returns true if - * state was entered. + * Try to enter a foreground state. + * @param notification The [ForegroundServiceNotification] to show in order to signal the foreground + * state. + * @return true if the state was changed, false otherwise + * @see Service.startForeground */ - fun tryStartForeground(notification: ServiceNotification): Boolean { + fun tryStartForeground(notification: ForegroundServiceNotification): Boolean { if (isForeground) { + // Nothing to do. return false } logD("Starting foreground state") - service.startForeground(notification.code, notification.build()) isForeground = true - return true } /** - * Try to stop a foreground state. Returns false if already in backend, returns true if state - * was stopped. + * Try to exit a foreground state. Will remove the foreground notification. + * @return true if the state was changed, false otherwise + * @see Service.stopForeground */ fun tryStopForeground(): Boolean { if (!isForeground) { + // Nothing to do. return false } logD("Stopping foreground state") - ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE) isForeground = false - return true } } diff --git a/app/src/main/java/org/oxycblt/auxio/shared/ServiceNotification.kt b/app/src/main/java/org/oxycblt/auxio/shared/ForegroundServiceNotification.kt similarity index 67% rename from app/src/main/java/org/oxycblt/auxio/shared/ServiceNotification.kt rename to app/src/main/java/org/oxycblt/auxio/shared/ForegroundServiceNotification.kt index 930ba0086..282fd22a8 100644 --- a/app/src/main/java/org/oxycblt/auxio/shared/ServiceNotification.kt +++ b/app/src/main/java/org/oxycblt/auxio/shared/ForegroundServiceNotification.kt @@ -24,15 +24,17 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat /** - * Wrapper around [NotificationCompat.Builder] that automates parts of the notification setup, under - * the assumption that the notification will be used in a service. + * Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that + * signal a Service's ongoing foreground state. * @author Alexander Capehart (OxygenCobalt) */ -abstract class ServiceNotification(context: Context, info: ChannelInfo) : +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)) @@ -40,18 +42,29 @@ abstract class ServiceNotification(context: Context, info: ChannelInfo) : .setVibrationEnabled(false) .setShowBadge(false) .build() - notificationManager.createNotificationChannel(channel) } + /** + * The code used to identify this notification. + * @see NotificationManagerCompat.notify + */ abstract val code: Int - @Suppress("MissingPermission") + /** + * 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/shared/NavigationViewModel.kt b/app/src/main/java/org/oxycblt/auxio/shared/NavigationViewModel.kt index 006d7aae0..376257fc8 100644 --- a/app/src/main/java/org/oxycblt/auxio/shared/NavigationViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/shared/NavigationViewModel.kt @@ -26,35 +26,40 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.util.logD /** - * A ViewModel that handles complicated navigation situations. - * @author Alexander Capehart (OxygenCobalt) + * A [ViewModel] that handles complicated navigation functionality. */ class NavigationViewModel : ViewModel() { private val _mainNavigationAction = MutableStateFlow(null) - - /** Flag for main fragment navigation. Intended for MainFragment use only. */ + /** + * Flag for navigation within the main navigation graph. Only intended for use by + * MainFragment. + */ val mainNavigationAction: StateFlow get() = _mainNavigationAction private val _exploreNavigationItem = MutableStateFlow(null) - /** - * Flag for navigation within the explore fragments. Observe this to coordinate navigation to an - * item's UI. + * Flag for navigation within the explore navigation graph. Observe this to coordinate + * navigation to a specific [Music] item. */ val exploreNavigationItem: StateFlow get() = _exploreNavigationItem private val _exploreNavigationArtists = MutableStateFlow?>(null) - /** - * Flag for navigation within the explore fragments. In this case, it involves an ambiguous list - * of artist choices. + * Variation of [exploreNavigationItem] for situations where the choice of [Artist] + * to navigate to is ambiguous. Only intended for use by MainFragment, as the resolved + * choice will eventually be assigned to [exploreNavigationItem]. */ val exploreNavigationArtists: StateFlow?> get() = _exploreNavigationArtists - /** Notify MainFragment to navigate to the location outlined in [MainNavigationAction]. */ + /** + * Navigate to something in the main navigation graph. This can be used by UIs in the explore + * navigation graph to trigger navigation in the higher-level main navigation graph. + * Will do nothing if already navigating. + * @param action The [MainNavigationAction] to perform. + */ fun mainNavigateTo(action: MainNavigationAction) { if (_mainNavigationAction.value != null) { logD("Already navigating, not doing main action") @@ -65,13 +70,20 @@ class NavigationViewModel : ViewModel() { _mainNavigationAction.value = action } - /** Mark that the main navigation process is done. */ + /** + * Mark that the navigation process within the main navigation graph (initiated by + * [mainNavigateTo]) was completed. + */ fun finishMainNavigation() { logD("Finishing main navigation process") _mainNavigationAction.value = null } - /** Navigate to an item's detail menu, whether a song/album/artist */ + /** + * Navigate to a given [Music] item. Will do nothing if already navigating. + * @param item The [Music] to navigate to. + * TODO: Extend to song properties??? + */ fun exploreNavigateTo(item: Music) { if (_exploreNavigationItem.value != null) { logD("Already navigating, not doing explore action") @@ -82,22 +94,29 @@ class NavigationViewModel : ViewModel() { _exploreNavigationItem.value = item } - /** Navigate to one item out of a list of items. */ - fun exploreNavigateTo(items: List) { + /** + * Navigate to an [Artist] out of a list of [Artist]s, like [exploreNavigateTo]. + * @param artists The [Artist]s to navigate to. In the case of multiple artists, the + * user will be prompted with a choice on which [Artist] to navigate to. + */ + fun exploreNavigateTo(artists: List) { if (_exploreNavigationArtists.value != null) { logD("Already navigating, not doing explore action") return } - if (items.size == 1) { - exploreNavigateTo(items[0]) + if (artists.size == 1) { + exploreNavigateTo(artists[0]) } else { - logD("Navigating to a choice of ${items.map { it.rawName }}") - _exploreNavigationArtists.value = items + logD("Navigating to a choice of ${artists.map { it.rawName }}") + _exploreNavigationArtists.value = artists } } - /** Mark that the item navigation process is done. */ + /** + * Mark that the navigation process within the explore navigation graph (initiated by + * [exploreNavigateTo]) was completed. + */ fun finishExploreNavigation() { logD("Finishing explore navigation process") _exploreNavigationItem.value = null @@ -106,17 +125,22 @@ class NavigationViewModel : ViewModel() { } /** - * Represents the navigation options for the Main Fragment, which tends to be multiple layers above - * normal fragments. This can be passed to [NavigationViewModel.mainNavigateTo] in order to - * facilitate navigation without workarounds.. + * Represents the possible actions within the main navigation graph. This can be used with + * [NavigationViewModel] to initiate navigation in the main navigation graph from anywhere + * in the app, including outside the main navigation graph. + * @author Alexander Capehart (OxygenCobalt) */ sealed class MainNavigationAction { /** Expand the playback panel. */ object Expand : MainNavigationAction() - /** Collapse the playback panel. */ + /** Collapse the playback bottom sheet. */ object Collapse : MainNavigationAction() - /** Provide raw navigation directions. */ + /** + * Navigate to the given [NavDirections]. + * @param directions The [NavDirections] to navigate to. Assumed to be part of the main + * navigation graph. + */ data class Directions(val directions: NavDirections) : MainNavigationAction() } diff --git a/app/src/main/java/org/oxycblt/auxio/shared/ViewBindingDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/shared/ViewBindingDialogFragment.kt index e4837c7b5..f23a3c541 100644 --- a/app/src/main/java/org/oxycblt/auxio/shared/ViewBindingDialogFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/shared/ViewBindingDialogFragment.kt @@ -32,41 +32,51 @@ import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A dialog fragment enabling ViewBinding inflation and usage across the dialog fragment lifecycle. + * A lifecycle-aware [DialogFragment] that automatically manages the [ViewBinding] lifecycle. * @author Alexander Capehart (OxygenCobalt) */ abstract class ViewBindingDialogFragment : DialogFragment() { private var _binding: VB? = null private var lifecycleObjects = mutableListOf>() - /** Called during [onCreateDialog]. Dialog elements should be configured here. */ + /** + * Configure the [AlertDialog.Builder] during [onCreateDialog]. + * @param builder The [AlertDialog.Builder] to configure. + * @see onCreateDialog + */ protected open fun onConfigDialog(builder: AlertDialog.Builder) {} /** - * Inflate the binding from the given [inflater]. This should usually be done by the binding - * implementation's inflate function. + * Inflate the [ViewBinding] during [onCreateView]. + * @param inflater The [LayoutInflater] to inflate the [ViewBinding] with. + * @return A new [ViewBinding] instance. + * @see onCreateView */ protected abstract fun onCreateBinding(inflater: LayoutInflater): VB /** - * Called during [onViewCreated] when the binding was successfully inflated and set as the view. - * This is where view setup should occur. + * Configure the newly-inflated [ViewBinding] during [onViewCreated]. + * @param binding The [ViewBinding] to configure. + * @param savedInstanceState The previously saved state of the UI. + * @see onViewCreated */ protected open fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) {} /** - * Called during [onDestroyView] when the binding should be destroyed and all callbacks or - * leaking elements be released. + * Free memory held by the [ViewBinding] during [onDestroyView] + * @param binding The [ViewBinding] to release. + * @see onDestroyView */ protected open fun onDestroyBinding(binding: VB) {} - /** Maybe get the binding. This will be null outside of the fragment view lifecycle. */ + /** The [ViewBinding], or null if it has not been inflated yet. */ protected val binding: VB? get() = _binding /** - * Get the binding under the assumption that the fragment has a view at this state in the - * lifecycle. This will throw an exception if the fragment is not in a valid lifecycle. + * Get the [ViewBinding] under the assumption that it has been inflated. + * @return The currently-inflated [ViewBinding]. + * @throws IllegalStateException if the [ViewBinding] is not inflated. */ protected fun requireBinding(): VB { return requireNotNull(_binding) { @@ -74,7 +84,12 @@ abstract class ViewBindingDialogFragment : DialogFragment() { "right now, but instead it was ${lifecycle.currentState}" } } - // TODO: Phase this out + + /** + * Delegate to automatically create and destroy an object derived from the [ViewBinding]. + * TODO: Phase this out, it's really dumb + * @param create Block to create the object from the [ViewBinding]. + */ fun lifecycleObject(create: (VB) -> T): ReadOnlyProperty { lifecycleObjects.add(LifecycleObject(null, create)) @@ -90,35 +105,44 @@ abstract class ViewBindingDialogFragment : DialogFragment() { } } - override fun onCreateView( + final override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View = onCreateBinding(inflater).also { _binding = it }.root + ) = onCreateBinding(inflater).also { _binding = it }.root - override fun onCreateDialog(savedInstanceState: Bundle?) = + final override fun onCreateDialog(savedInstanceState: Bundle?) = + // Use a material-styled dialog for all implementations. MaterialAlertDialogBuilder(requireActivity(), theme).run { onConfigDialog(this) create() } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val binding = unlikelyToBeNull(_binding) + // Populate lifecycle-dependent objects lifecycleObjects.forEach { it.populate(binding) } + // Configure binding onBindingCreated(requireBinding(), savedInstanceState) + // Apply the newly-configured view to the dialog. (requireDialog() as AlertDialog).setView(view) logD("Fragment created") } - override fun onDestroyView() { + final override fun onDestroyView() { super.onDestroyView() onDestroyBinding(unlikelyToBeNull(_binding)) + // Clear the lifecycle-dependent objects lifecycleObjects.forEach { it.clear() } + // Clear binding _binding = null logD("Fragment destroyed") } + /** + * Internal implementation of [lifecycleObject]. + */ private data class LifecycleObject(var data: T?, val create: (VB) -> T) { fun populate(binding: VB) { data = create(binding) diff --git a/app/src/main/java/org/oxycblt/auxio/shared/ViewBindingFragment.kt b/app/src/main/java/org/oxycblt/auxio/shared/ViewBindingFragment.kt index 401d3c0a7..1140d960c 100644 --- a/app/src/main/java/org/oxycblt/auxio/shared/ViewBindingFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/shared/ViewBindingFragment.kt @@ -37,30 +37,36 @@ abstract class ViewBindingFragment : Fragment() { private var lifecycleObjects = mutableListOf>() /** - * Inflate the binding from the given [inflater]. This should usually be done by the binding - * implementation's inflate function. + * Inflate the [ViewBinding] during [onCreateView]. + * @param inflater The [LayoutInflater] to inflate the [ViewBinding] with. + * @return A new [ViewBinding] instance. + * @see onCreateView */ protected abstract fun onCreateBinding(inflater: LayoutInflater): VB /** - * Called during [onViewCreated] when the binding was successfully inflated and set as the view. - * This is where view setup should occur. + * Configure the newly-inflated [ViewBinding] during [onViewCreated]. + * @param binding The [ViewBinding] to configure. + * @param savedInstanceState The previously saved state of the UI. + * @see onViewCreated */ protected open fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) {} /** - * Called during [onDestroyView] when the binding should be destroyed and all callbacks or - * leaking elements be released. + * Free memory held by the [ViewBinding] during [onDestroyView] + * @param binding The [ViewBinding] to release. + * @see onDestroyView */ protected open fun onDestroyBinding(binding: VB) {} - /** Maybe get the binding. This will be null outside of the fragment view lifecycle. */ + /** The [ViewBinding], or null if it has not been inflated yet. */ protected val binding: VB? get() = _binding /** - * Get the binding under the assumption that the fragment has a view at this state in the - * lifecycle. This will throw an exception if the fragment is not in a valid lifecycle. + * Get the [ViewBinding] under the assumption that it has been inflated. + * @return The currently-inflated [ViewBinding]. + * @throws IllegalStateException if the [ViewBinding] is not inflated. */ protected fun requireBinding(): VB { return requireNotNull(_binding) { @@ -70,8 +76,9 @@ abstract class ViewBindingFragment : Fragment() { } /** - * Shortcut to create a member bound to the lifecycle of this fragment. This is automatically - * populated in onBindingCreated, and destroyed in onDestroyBinding. + * Delegate to automatically create and destroy an object derived from the [ViewBinding]. + * TODO: Phase this out, it's really dumb + * @param create Block to create the object from the [ViewBinding]. */ fun lifecycleObject(create: (VB) -> T): ReadOnlyProperty { lifecycleObjects.add(LifecycleObject(null, create)) @@ -88,28 +95,35 @@ abstract class ViewBindingFragment : Fragment() { } } - override fun onCreateView( + final override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View = onCreateBinding(inflater).also { _binding = it }.root + ) = onCreateBinding(inflater).also { _binding = it }.root - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val binding = unlikelyToBeNull(_binding) + // Populate lifecycle-dependent objects lifecycleObjects.forEach { it.populate(binding) } - onBindingCreated(binding, savedInstanceState) + // Configure binding + onBindingCreated(requireBinding(), savedInstanceState) logD("Fragment created") } - override fun onDestroyView() { + final override fun onDestroyView() { super.onDestroyView() onDestroyBinding(unlikelyToBeNull(_binding)) + // Clear the lifecycle-dependent objects lifecycleObjects.forEach { it.clear() } + // Clear binding _binding = null logD("Fragment destroyed") } + /** + * Internal implementation of [lifecycleObject]. + */ private data class LifecycleObject(var data: T?, val create: (VB) -> T) { fun populate(binding: VB) { data = create(binding) diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 22c9dcd55..d2558edbd 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -22,7 +22,6 @@ import android.graphics.Bitmap import android.os.Build import coil.request.ImageRequest import coil.transform.RoundedCornersTransformation -import kotlin.math.sqrt import org.oxycblt.auxio.R import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.image.extractor.SquareFrameTransform @@ -36,9 +35,9 @@ import org.oxycblt.auxio.util.getDimenPixels import org.oxycblt.auxio.util.logD /** - * A component that manages the state of all of Auxio's widgets. - * This is kept separate from the AppWidgetProviders themselves to prevent possible memory - * leaks and enable the main functionality to be extended to more widgets in the future. + * A component that manages the "Now Playing" state. + * This is kept separate from the [WidgetProvider] itself to prevent possible memory + * leaks and enable extension to more widgets in the future. * @param context [Context] required to manage AppWidgetProviders. * @author Alexander Capehart (OxygenCobalt) */ @@ -56,7 +55,7 @@ class WidgetComponent(private val context: Context) : /** * Update [WidgetProvider] with the current playback state. */ - fun updateNowPlaying() { + fun update() { val song = playbackManager.song if (song == null) { logD("No song, resetting widget") @@ -85,22 +84,16 @@ class WidgetComponent(private val context: Context) : 0 } - val metrics = context.resources.displayMetrics - val sw = metrics.widthPixels - val sh = metrics.heightPixels - return if (cornerRadius > 0) { - // Reduce the size by 10x, not only to make 16dp-ish corners, but also - // to work around a bug in Android 13 where the bitmaps aren't pooled - // properly, massively reducing the memory size we can work with. + // If rounded, educe the bitmap size further to obtain more pronounced + // rounded corners. builder - .size(computeWidgetImageSize(sw, sh, 10f)) + .size(getSafeRemoteViewsImageSize(context, 10f)) .transformations( SquareFrameTransform.INSTANCE, RoundedCornersTransformation(cornerRadius.toFloat())) } else { - // Divide by two to really make sure we aren't hitting the memory limit. - builder.size(computeWidgetImageSize(sw, sh, 2f)) + builder.size(getSafeRemoteViewsImageSize(context)) } } @@ -111,18 +104,6 @@ class WidgetComponent(private val context: Context) : }) } - /** - * Get the recommended image size to load for use. - * @param sw The current screen width - * @param sh The current screen height - * @param modifier Modifier to reduce the image size. - * @return An image size that is guaranteed not to exceed the widget bitmap memory limit. - */ - private fun computeWidgetImageSize(sw: Int, sh: Int, modifier: Float) = - // Maximum size is 1/3 total screen area * 4 bytes per pixel. Reverse - // that to obtain the image size. - sqrt((6f / 4f / modifier) * sw * sh).toInt() - /** * Release this instance, preventing any further events from updating the widget instances. */ @@ -137,15 +118,15 @@ class WidgetComponent(private val context: Context) : // Hook all the major song-changing updates + the major player state updates // to updating the "Now Playing" widget. - override fun onIndexMoved(index: Int) = updateNowPlaying() - override fun onNewPlayback(index: Int, queue: List, parent: MusicParent?) = updateNowPlaying() - override fun onStateChanged(state: InternalPlayer.State) = updateNowPlaying() - override fun onShuffledChanged(isShuffled: Boolean) = updateNowPlaying() - override fun onRepeatChanged(repeatMode: RepeatMode) = updateNowPlaying() + override fun onIndexMoved(index: Int) = update() + override fun onNewPlayback(index: Int, queue: List, parent: MusicParent?) = update() + override fun onStateChanged(state: InternalPlayer.State) = update() + override fun onShuffledChanged(isShuffled: Boolean) = update() + override fun onRepeatChanged(repeatMode: RepeatMode) = update() override fun onSettingChanged(key: String) { if (key == context.getString(R.string.set_key_cover_mode) || key == context.getString(R.string.set_key_round_mode)) { - updateNowPlaying() + update() } } diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt index 32382643b..ec1b73e13 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt @@ -12,6 +12,7 @@ import androidx.annotation.LayoutRes import org.oxycblt.auxio.util.isLandscape import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newMainPendingIntent +import kotlin.math.sqrt /** * Create a [RemoteViews] instance with the specified layout and an automatic click handler @@ -26,6 +27,23 @@ fun newRemoteViews(context: Context, @LayoutRes layoutRes: Int): RemoteViews { return views } +/** + * Get an image size guaranteed to not exceed the [RemoteViews] bitmap memory limit, assuming + * that there is only one image. + * @param context [Context] required to perform calculation. + * @param reduce Optional multiplier to reduce the image size. Recommended value is 2 to avoid + * device-specific variations in memory limit. + * @return The dimension of a bitmap that can be safely used in [RemoteViews]. + */ +fun getSafeRemoteViewsImageSize(context: Context, reduce: Float = 2f): Int { + val metrics = context.resources.displayMetrics + val sw = metrics.widthPixels + val sh = metrics.heightPixels + // Maximum size is 1/3 total screen area * 4 bytes per pixel. Reverse + // that to obtain the image size. + return sqrt((6f / 4f / reduce) * sw * sh).toInt() +} + /** * Set the background resource of a [RemoteViews] View. * @param viewId The ID of the view to update. diff --git a/app/src/main/res/layout-w600dp-land/fragment_main.xml b/app/src/main/res/layout-w600dp-land/fragment_main.xml index 0c31f7110..46457d7a7 100644 --- a/app/src/main/res/layout-w600dp-land/fragment_main.xml +++ b/app/src/main/res/layout-w600dp-land/fragment_main.xml @@ -12,7 +12,7 @@ android:name="androidx.navigation.fragment.NavHostFragment" android:layout_width="match_parent" android:layout_height="match_parent" - app:layout_behavior="org.oxycblt.auxio.shared.BottomSheetContentBehavior" + app:layout_behavior="org.oxycblt.auxio.playback.ui.BottomSheetContentBehavior" app:navGraph="@navigation/nav_explore" tools:layout="@layout/fragment_home" /> diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml index 5c94899e5..3746c1996 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -13,7 +13,7 @@ android:name="androidx.navigation.fragment.NavHostFragment" android:layout_width="match_parent" android:layout_height="match_parent" - app:layout_behavior="org.oxycblt.auxio.shared.BottomSheetContentBehavior" + app:layout_behavior="org.oxycblt.auxio.playback.ui.BottomSheetContentBehavior" app:navGraph="@navigation/nav_explore" tools:layout="@layout/fragment_home" /> @@ -35,7 +35,7 @@ android:name="org.oxycblt.auxio.playback.PlaybackPanelFragment" android:layout_width="match_parent" android:layout_height="match_parent" - app:layout_behavior="org.oxycblt.auxio.shared.BottomSheetContentBehavior" /> + app:layout_behavior="org.oxycblt.auxio.playback.ui.BottomSheetContentBehavior" />