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.
This commit is contained in:
OxygenCobalt 2021-08-01 17:32:38 -06:00
parent 58bea1dda5
commit 66be3da7e3
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
22 changed files with 404 additions and 19 deletions

View file

@ -7,8 +7,10 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Workaround for ExoPlayer's online functionality requiring unnecessary permissions -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove"/>
<!-- Workaround for ExoPlayer requiring unnecessary permissions -->
<uses-permission
android:name="android.permission.ACCESS_NETWORK_STATE"
tools:node="remove" />
<queries />
@ -21,12 +23,13 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Base">
<activity
android:name=".MainActivity"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:launchMode="singleTask"
android:roundIcon="@mipmap/ic_launcher_round"
android:exported="true"
android:windowSoftInputMode="adjustPan">
<intent-filter>
@ -46,10 +49,25 @@
<data android:mimeType="audio/*" />
</intent-filter>
</activity>
<service
android:name=".playback.system.PlaybackService"
android:foregroundServiceType="mediaPlayback"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round" />
<receiver
android:name=".widget.MinimalWidgetProvider"
android:exported="false"
android:enabled="@bool/widgets_supported">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_minimal" />
</receiver>
</application>
</manifest>

View file

@ -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()

View file

@ -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<Parent>) {
data = newData

View file

@ -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"
}
}

View file

@ -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<View>? {
if (disappearingChildrenField == null || disappearingChildren != null) {
return disappearingChildren
}
try {
@Suppress("UNCHECKED_CAST")
disappearingChildren = disappearingChildrenField.get(this) as List<View>?
} catch (e: Exception) {
logD("Could not get list of disappearing children.")

View file

@ -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

View file

@ -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
}
}
}
}

View file

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:alpha="0.3"
android:tint="?attr/colorPrimary"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12 3v10.55C11.41 13.21 10.73 13 10 13c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
</vector>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners />
<solid android:color="@color/surface_black" />
</shape>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners />
<solid android:color="@color/surface_day" />
</shape>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners />
<solid android:color="@color/surface_night" />
</shape>

View file

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@android:id/background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?android:attr/colorBackground"
android:orientation="vertical"
android:theme="@style/Theme.Widget">
<android.widget.ImageView
android:id="@+id/widget_cover"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:scaleType="centerCrop"
tools:ignore="contentDescription"
tools:src="@drawable/ic_song_clear" />
<android.widget.LinearLayout
android:id="@+id/widget_meta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="?android:attr/colorBackground"
android:orientation="vertical"
android:padding="@dimen/spacing_medium">
<android.widget.TextView
android:id="@+id/widget_song"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
android:textColor="?android:attr/textColorPrimary"
android:textStyle="bold"
tools:text="Song Name" />
<android.widget.TextView
android:id="@+id/widget_artist"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textColor="?android:attr/textColorSecondary"
tools:text="Artist Name" />
</android.widget.LinearLayout>
<android.widget.TextView
android:id="@+id/widget_placeholder_msg"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/spacing_medium"
android:text="@string/placeholder_playback"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
android:textColor="?android:attr/textColorPrimary"
android:textStyle="bold" />
</LinearLayout>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="surface_color">#151515</color>
<color name="surface_color">@color/surface_night</color>
<color name="divider_color">#323232</color>
<color name="selection_color">#484848</color>
<color name="inactive_color">#404040</color>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
Widgets are limited to android 12 until I can find an okay-ish way to backport them.
-->
<bool name="widgets_supported">true</bool>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Widget" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="colorPrimary">?android:attr/colorAccent</item>
<item name="colorSecondary">?android:attr/colorAccent</item>
</style>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="widgets_supported">false</bool>
</resources>

View file

@ -1,13 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="surface_color">#fafafa</color>
<color name="divider_color">#cbcbcb</color>
<color name="selection_color">#cbcbcb</color>
<color name="inactive_color">#c4c4c4</color>
<color name="control_color">#202020</color>
<color name="nav_color">#01fafafa</color>
<color name="surface_day">#fffafafa</color>
<color name="surface_night">#ff151515</color>
<color name="surface_black">#ff000000</color>
<color name="surface_color_black">#000000</color>
<color name="text_primary_day">#ff000000</color>
<color name="text_secondary_day">#ff323232</color>
<color name="text_primary_night">#ffffffff</color>
<color name="text_secondary_night">#ffbebebe</color>
<color name="surface_color">@color/surface_day</color>
<color name="selection_color">#cbcbcb</color>
<color name="control_color">#202020</color>
<color name="divider_color">#cbcbcb</color>
<color name="inactive_color">#c4c4c4</color>
<color name="nav_color">#01fafafa</color>
<!--
Base color set derived from Music Player GO.

View file

@ -134,6 +134,7 @@
<string name="placeholder_artist">Unknown Artist</string>
<string name="placeholder_album">Unknown Album</string>
<string name="placeholder_no_date">No Date</string>
<string name="placeholder_playback">No music playing</string>
<!-- Color Label namespace | Accent names -->
<string name="color_label_red">Red</string>

View file

@ -36,7 +36,7 @@
<!-- Black theme dialog theme -->
<style name="Theme.CustomDialog.Black" parent="Theme.CustomDialog.Base">
<item name="colorSurface">@color/surface_color_black</item>
<item name="colorSurface">@color/surface_black</item>
</style>
<!-- Material-specific dialog style -->

View file

@ -157,8 +157,6 @@
<item name="android:thumbTint">?attr/colorPrimary</item>
</style>
<!-- Base style for the material buttons -->
<style name="Widget.MaterialComponents.Button.Base" parent="@style/Widget.MaterialComponents.Button.TextButton">
<item name="android:layout_width">0dp</item>

View file

@ -39,7 +39,7 @@
<!-- The basic black theme derived in all black accents. -->
<style name="Theme.Base.Black" parent="Theme.Base">
<item name="colorSurface">@color/surface_color_black</item>
<item name="colorSurface">@color/surface_black</item>
<item name="materialAlertDialogTheme">@style/Theme.CustomDialog.Black</item>
</style>
</resources>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_minimal"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="0"
android:widgetCategory="home_screen">
</appwidget-provider>