From 66be3da7e3977343cf1e510459f001c29c176d25 Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Sun, 1 Aug 2021 17:32:38 -0600 Subject: [PATCH] widgets: add minimal widget [#8] Add the first widget implementation, the minimal widget. This took a good 2 days of hacking and frustration to get the first prototype working. And even then, its currently scoped to android 12 until I'm able to port them. The implementation is still quite rough, but should become manageable over time. More widgets will come. --- app/src/main/AndroidManifest.xml | 24 +++- .../java/org/oxycblt/auxio/MainActivity.kt | 3 + .../oxycblt/auxio/library/LibraryAdapter.kt | 2 + .../auxio/playback/system/PlaybackService.kt | 35 ++++- .../org/oxycblt/auxio/ui/SlideLinearLayout.kt | 7 +- .../org/oxycblt/auxio/widget/BaseWidget.kt | 121 ++++++++++++++++++ .../auxio/widget/MinimalWidgetProvider.kt | 80 ++++++++++++ app/src/main/res/drawable/ic_song_clear.xml | 11 ++ app/src/main/res/drawable/ui_widget_black.xml | 6 + app/src/main/res/drawable/ui_widget_day.xml | 6 + app/src/main/res/drawable/ui_widget_night.xml | 6 + .../main/res/layout-v31/widget_minimal.xml | 62 +++++++++ app/src/main/res/values-night/colors.xml | 2 +- app/src/main/res/values-v31/bools.xml | 7 + app/src/main/res/values-v31/styles_core.xml | 7 + app/src/main/res/values/bools.xml | 4 + app/src/main/res/values/colors.xml | 21 ++- app/src/main/res/values/strings.xml | 1 + app/src/main/res/values/styles_android.xml | 2 +- app/src/main/res/values/styles_component.xml | 2 - app/src/main/res/values/styles_core.xml | 2 +- app/src/main/res/xml-v31/widget_minimal.xml | 12 ++ 22 files changed, 404 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/widget/BaseWidget.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/widget/MinimalWidgetProvider.kt create mode 100644 app/src/main/res/drawable/ic_song_clear.xml create mode 100644 app/src/main/res/drawable/ui_widget_black.xml create mode 100644 app/src/main/res/drawable/ui_widget_day.xml create mode 100644 app/src/main/res/drawable/ui_widget_night.xml create mode 100644 app/src/main/res/layout-v31/widget_minimal.xml create mode 100644 app/src/main/res/values-v31/bools.xml create mode 100644 app/src/main/res/values-v31/styles_core.xml create mode 100644 app/src/main/res/values/bools.xml create mode 100644 app/src/main/res/xml-v31/widget_minimal.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d283aa656..13dc201eb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,8 +7,10 @@ - - + + @@ -21,12 +23,13 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Base"> + @@ -46,10 +49,25 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index 673b0db11..abefa566b 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -19,6 +19,9 @@ import org.oxycblt.auxio.ui.isNight /** * The single [AppCompatActivity] for Auxio. + * TODO: Port widgets to non-12 android + * TODO: Change how I handle lifecycle owners + * TODO: Fix intent issues */ class MainActivity : AppCompatActivity() { private val playbackModel: PlaybackViewModel by viewModels() diff --git a/app/src/main/java/org/oxycblt/auxio/library/LibraryAdapter.kt b/app/src/main/java/org/oxycblt/auxio/library/LibraryAdapter.kt index 4dafdc54b..493c4464d 100644 --- a/app/src/main/java/org/oxycblt/auxio/library/LibraryAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/library/LibraryAdapter.kt @@ -1,5 +1,6 @@ package org.oxycblt.auxio.library +import android.annotation.SuppressLint import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView @@ -61,6 +62,7 @@ class LibraryAdapter( /** * Update the data with [newData]. [notifyDataSetChanged] will be called. */ + @SuppressLint("NotifyDataSetChanged") fun updateData(newData: List) { data = newData 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 7bf692edb..280aa00c2 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 @@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.launch +import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.logD import org.oxycblt.auxio.music.Parent import org.oxycblt.auxio.music.Song @@ -41,6 +42,8 @@ import org.oxycblt.auxio.playback.state.LoopMode import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.ui.getSystemServiceSafe +import org.oxycblt.auxio.widget.BaseWidget +import org.oxycblt.auxio.widget.MinimalWidgetProvider /** * A service that manages the system-side aspects of playback, such as: @@ -48,26 +51,36 @@ import org.oxycblt.auxio.ui.getSystemServiceSafe * - The [MediaSessionCompat] * - The Media Notification * - Headset management + * - Widgets * * This service relies on [PlaybackStateManager.Callback] and [SettingsManager.Callback], * so therefore there's no need to bind to it to deliver commands. * @author OxygenCobalt */ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callback, SettingsManager.Callback { + + // Player components private lateinit var player: SimpleExoPlayer private lateinit var mediaSession: MediaSessionCompat private lateinit var connector: PlaybackSessionConnector + // Notification components private lateinit var notification: PlaybackNotification private lateinit var notificationManager: NotificationManager + // System backend components private lateinit var audioReactor: AudioReactor private lateinit var wakeLock: PowerManager.WakeLock private val systemReceiver = SystemEventReceiver() + // Managers private val playbackManager = PlaybackStateManager.getInstance() private val settingsManager = SettingsManager.getInstance() + // Widgets + private val minimalWidget = MinimalWidgetProvider.new() + + // State private var isForeground = false private val serviceJob = Job() @@ -81,8 +94,8 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac return START_NOT_STICKY } - // No binding, service is headless. - // Deliver updates through PlaybackStateManager/SettingsManager instead. + // No binding, service is headless + // Communicate using PlaybackStateManager, SettingsManager, or Broadcasts instead. override fun onBind(intent: Intent): IBinder? = null override fun onCreate() { @@ -128,6 +141,8 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) addAction(Intent.ACTION_HEADSET_PLUG) + addAction(BaseWidget.ACTION_WIDGET_UPDATE) + registerReceiver(systemReceiver, this) } @@ -231,11 +246,14 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac this, song, settingsManager.colorizeNotif, ::startForegroundOrNotify ) + minimalWidget?.update(this, playbackManager) + return } - // Stop playing/the notification if there's nothing to play. + // Clear if there's nothing to play. player.stop() + minimalWidget?.stop(this) stopForegroundAndNotification() } @@ -476,6 +494,15 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac DISCONNECTED -> pauseFromPlug() } } + + // Newly added widgets need PlaybackService to + BaseWidget.ACTION_WIDGET_UPDATE -> { + when (intent.getIntExtra(BaseWidget.KEY_WIDGET_TYPE, -1)) { + MinimalWidgetProvider.TYPE -> minimalWidget?.update( + this@PlaybackService, playbackManager + ) + } + } } } @@ -507,5 +534,7 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac private const val CONNECTED = 1 private const val WAKELOCK_TIME = 25000L private const val POS_POLL_INTERVAL = 500L + + const val BROADCAST_WIDGET_START = BuildConfig.APPLICATION_ID + ".key.WIDGETS_START" } } diff --git a/app/src/main/java/org/oxycblt/auxio/ui/SlideLinearLayout.kt b/app/src/main/java/org/oxycblt/auxio/ui/SlideLinearLayout.kt index 81208bac8..e5f83dddc 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/SlideLinearLayout.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/SlideLinearLayout.kt @@ -1,5 +1,6 @@ package org.oxycblt.auxio.ui +import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas import android.util.AttributeSet @@ -24,7 +25,11 @@ class SlideLinearLayout @JvmOverloads constructor( attrs: AttributeSet? = null, defStyleAttr: Int = -1 ) : LinearLayout(context, attrs, defStyleAttr) { + @SuppressLint("DiscouragedPrivateApi") private val disappearingChildrenField: Field? = try { + // We need to read this field to correctly draw the disappearing children + // If google ever hides this API I am going to scream, because its their busted + // ViewGroup code that forces me to do this in the first place ViewGroup::class.java.getDeclaredField("mDisappearingChildren").also { it.isAccessible = true } @@ -90,13 +95,13 @@ class SlideLinearLayout @JvmOverloads constructor( return true } - @Suppress("UNCHECKED_CAST") private fun getDisappearingChildren(): List? { if (disappearingChildrenField == null || disappearingChildren != null) { return disappearingChildren } try { + @Suppress("UNCHECKED_CAST") disappearingChildren = disappearingChildrenField.get(this) as List? } catch (e: Exception) { logD("Could not get list of disappearing children.") diff --git a/app/src/main/java/org/oxycblt/auxio/widget/BaseWidget.kt b/app/src/main/java/org/oxycblt/auxio/widget/BaseWidget.kt new file mode 100644 index 000000000..c9f935331 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/widget/BaseWidget.kt @@ -0,0 +1,121 @@ +package org.oxycblt.auxio.widget + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.Build +import android.widget.RemoteViews +import androidx.annotation.LayoutRes +import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.MainActivity +import org.oxycblt.auxio.R +import org.oxycblt.auxio.logD +import org.oxycblt.auxio.playback.state.PlaybackStateManager + +/** + * The base widget class for all widget implementations in Auxio. + */ +abstract class BaseWidget : AppWidgetProvider() { + abstract val type: Int + + /* + * Returns the default view for this widget. This should be the "No music playing" screen + * in pretty much all cases. + */ + protected abstract fun getDefaultViews(context: Context): RemoteViews + + /* + * Update views based off of the playback state. This job is asynchronous and should be + * notified as completed by calling [onDone] + */ + protected abstract fun updateViews( + context: Context, + playbackManager: PlaybackStateManager, + onDone: (RemoteViews) -> Unit + ) + + /* + * Update the widget based on the playback state. + */ + fun update(context: Context, playbackManager: PlaybackStateManager) { + logD("Dispatching playback state update") + + val manager = AppWidgetManager.getInstance(context) + + // View updates are often async due to image loading, so only push the views + // when the callback is called. + updateViews(context, playbackManager) { views -> + manager.applyViews(context, views) + } + } + + /* + * Stop this widget, reverting it to its default state. + */ + fun stop(context: Context) { + logD("Stopping widget") + + val manager = AppWidgetManager.getInstance(context) + manager.applyViews(context, getDefaultViews(context)) + } + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + appWidgetManager.applyViews(context, getDefaultViews(context)) + + logD("Sending update intent to PlaybackService") + + val intent = Intent(ACTION_WIDGET_UPDATE) + .putExtra(KEY_WIDGET_TYPE, type) + .addFlags(Intent.FLAG_RECEIVER_REGISTERED_ONLY) + + context.sendBroadcast(intent) + } + + protected fun getRemoteViews(context: Context, @LayoutRes layout: Int): RemoteViews { + val views = RemoteViews(context.packageName, layout) + + views.setOnClickPendingIntent( + android.R.id.background, + PendingIntent.getActivity( + context, 0xA0A0, Intent(context, MainActivity::class.java), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + ) + ) + + return views + } + + private fun AppWidgetManager.applyViews(context: Context, views: RemoteViews) { + val ids = getAppWidgetIds(ComponentName(context, this::class.java)) + + if (ids.isNotEmpty()) { + // Existing widgets found, update thoughs + ids.forEach { id -> + updateAppWidget(id, views) + } + } else { + // No existing widgets found. Fall back to the name of the widget class + updateAppWidget(ComponentName(context, this@BaseWidget::class.java), views) + } + } + + companion object { + const val ACTION_WIDGET_UPDATE = BuildConfig.APPLICATION_ID + ".action.WIDGET_UPDATE" + const val KEY_WIDGET_TYPE = BuildConfig.APPLICATION_ID + ".key.WIDGET_TYPE" + } +} + +// PLAN: +// - Service calls methods on the widgets, think a subset of PlaybackStateManager.Callback +// - Widgets send BACK broadcasts to control playback, as other parts of the code do +// - Since widgets are broadcastrecievers, this is okay, shouldn't cause memory leaks +// - Can't use playbackstatemanager here since that would make the app unkillable +// - Callbacks also need to handle PlaybackService dying and being unable to send any +// more updates, and PlaybackService starting up as well diff --git a/app/src/main/java/org/oxycblt/auxio/widget/MinimalWidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widget/MinimalWidgetProvider.kt new file mode 100644 index 000000000..967f1d36a --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/widget/MinimalWidgetProvider.kt @@ -0,0 +1,80 @@ +package org.oxycblt.auxio.widget + +import android.content.Context +import android.os.Build +import android.view.View +import android.widget.RemoteViews +import org.oxycblt.auxio.R +import org.oxycblt.auxio.coil.loadBitmap +import org.oxycblt.auxio.logD +import org.oxycblt.auxio.playback.state.PlaybackStateManager + +// Workaround to make studio shut up about perfectly valid layouts somehow +// being invalid for remote views. + +/** + * The minimal widget. This widget only shows the album, song name, and artist without any + * controls. Because you know. Minimalism. + */ +class MinimalWidgetProvider : BaseWidget() { + override val type: Int get() = TYPE + + override fun getDefaultViews(context: Context): RemoteViews { + val views = getRemoteViews(context, LAYOUT) + + views.setInt(R.id.widget_cover, "setImageResource", R.drawable.ic_song_clear) + views.setInt(R.id.widget_cover, "setVisibility", View.VISIBLE) + views.setInt(R.id.widget_placeholder_msg, "setVisibility", View.VISIBLE) + views.setInt(R.id.widget_meta, "setVisibility", View.GONE) + + return views + } + + override fun updateViews( + context: Context, + playbackManager: PlaybackStateManager, + onDone: (RemoteViews) -> Unit + ) { + val views = getRemoteViews(context, LAYOUT) + val song = playbackManager.song + + if (song != null) { + logD("updating view to ${song.name}") + + // Show the proper widget views + views.setInt(R.id.widget_placeholder_msg, "setVisibility", View.GONE) + views.setInt(R.id.widget_meta, "setVisibility", View.VISIBLE) + + // Update the metadata + views.setTextViewText(R.id.widget_song, song.name) + views.setTextViewText(R.id.widget_artist, song.album.artist.name) + + // loadBitmap is async, hence the need for onDone + loadBitmap(context, song) { bitmap -> + if (bitmap != null) { + views.setBitmap(R.id.widget_cover, "setImageBitmap", bitmap) + } else { + views.setInt(R.id.widget_cover, "setImageResource", R.drawable.ic_song_clear) + } + + onDone(views) + } + } else { + views.setInt(R.id.widget_cover, "setImageResource", R.drawable.ic_song_clear) + onDone(views) + } + } + + companion object { + const val TYPE = 0xA0D0 + const val LAYOUT = R.layout.widget_minimal + + fun new(): MinimalWidgetProvider? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MinimalWidgetProvider() + } else { + null + } + } + } +} diff --git a/app/src/main/res/drawable/ic_song_clear.xml b/app/src/main/res/drawable/ic_song_clear.xml new file mode 100644 index 000000000..4f9f68627 --- /dev/null +++ b/app/src/main/res/drawable/ic_song_clear.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ui_widget_black.xml b/app/src/main/res/drawable/ui_widget_black.xml new file mode 100644 index 000000000..fb887911b --- /dev/null +++ b/app/src/main/res/drawable/ui_widget_black.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ui_widget_day.xml b/app/src/main/res/drawable/ui_widget_day.xml new file mode 100644 index 000000000..f1d95642f --- /dev/null +++ b/app/src/main/res/drawable/ui_widget_day.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ui_widget_night.xml b/app/src/main/res/drawable/ui_widget_night.xml new file mode 100644 index 000000000..cea57ae3a --- /dev/null +++ b/app/src/main/res/drawable/ui_widget_night.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/layout-v31/widget_minimal.xml b/app/src/main/res/layout-v31/widget_minimal.xml new file mode 100644 index 000000000..212318c4d --- /dev/null +++ b/app/src/main/res/layout-v31/widget_minimal.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 484429c40..4b02d8367 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -1,6 +1,6 @@ - #151515 + @color/surface_night #323232 #484848 #404040 diff --git a/app/src/main/res/values-v31/bools.xml b/app/src/main/res/values-v31/bools.xml new file mode 100644 index 000000000..1ea4bc90a --- /dev/null +++ b/app/src/main/res/values-v31/bools.xml @@ -0,0 +1,7 @@ + + + + true + \ No newline at end of file diff --git a/app/src/main/res/values-v31/styles_core.xml b/app/src/main/res/values-v31/styles_core.xml new file mode 100644 index 000000000..5be1df180 --- /dev/null +++ b/app/src/main/res/values-v31/styles_core.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/bools.xml b/app/src/main/res/values/bools.xml new file mode 100644 index 000000000..9ec485ae0 --- /dev/null +++ b/app/src/main/res/values/bools.xml @@ -0,0 +1,4 @@ + + + false + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 7b3349e1c..d6d3f3258 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,13 +1,20 @@ - #fafafa - #cbcbcb - #cbcbcb - #c4c4c4 - #202020 - #01fafafa + #fffafafa + #ff151515 + #ff000000 - #000000 + #ff000000 + #ff323232 + #ffffffff + #ffbebebe + + @color/surface_day + #cbcbcb + #202020 + #cbcbcb + #c4c4c4 + #01fafafa Red diff --git a/app/src/main/res/values/styles_android.xml b/app/src/main/res/values/styles_android.xml index ec70e1d96..ef98f1369 100644 --- a/app/src/main/res/values/styles_android.xml +++ b/app/src/main/res/values/styles_android.xml @@ -36,7 +36,7 @@ diff --git a/app/src/main/res/values/styles_component.xml b/app/src/main/res/values/styles_component.xml index 2e840279a..68deedcda 100644 --- a/app/src/main/res/values/styles_component.xml +++ b/app/src/main/res/values/styles_component.xml @@ -157,8 +157,6 @@ ?attr/colorPrimary - - \ No newline at end of file diff --git a/app/src/main/res/xml-v31/widget_minimal.xml b/app/src/main/res/xml-v31/widget_minimal.xml new file mode 100644 index 000000000..a4255ba1a --- /dev/null +++ b/app/src/main/res/xml-v31/widget_minimal.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file