Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a homescreen widget #144

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ android {
signingConfig = signingConfigs.release
}
}
buildFeatures {
viewBinding true
}
}

flutter {
Expand Down
12 changes: 12 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>

<receiver
android:name=".MusicPlayerAppWidget"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>

<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/music_player_info" />
</receiver>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
package com.nt4f04und.sweyer

import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProviderInfo
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.res.Resources
import android.graphics.*
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.CancellationSignal
import android.os.OperationCanceledException
import android.util.Log
import android.util.Size
import android.util.TypedValue
import android.view.KeyEvent
import android.view.View.GONE
import android.view.View.VISIBLE
import android.widget.RemoteViews
import es.antonborri.home_widget.*
import java.io.IOException
import java.net.URISyntaxException
import java.util.concurrent.Executors
import kotlin.math.max
import kotlin.math.roundToInt
import com.nt4f04und.sweyer.sweyer_plugin.Constants

/**
* Sweyer music App Widget.
* The widget is freely resizable and has a playback control button bar at the bottom.
* Depending on the horizontal size available, there is eiter just a play/pause button,
* a play/pause and skip button, or a play/pause, skip and previous button.
* The background is the current song cover if available. The edges of the widget are rounded.
*/
class MusicPlayerAppWidget : HomeWidgetProvider() {
override fun onUpdate(
context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray, widgetData: SharedPreferences
) = updateWidgets(context, WidgetInfoProvider(context, appWidgetManager, appWidgetIds), widgetData)

override fun onAppWidgetOptionsChanged(
context: Context?, appWidgetManager: AppWidgetManager?, appWidgetId: Int, newOptions: Bundle?
) {
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)
if (context == null || appWidgetManager == null) {
return
}
updateWidgets(
context,
WidgetInfoProvider(context, appWidgetManager, intArrayOf(appWidgetId), newOptions),
HomeWidgetPlugin.getData(context),
)
}

/**
* Update all widgets to for the current playback state and their widget size.
Abestanis marked this conversation as resolved.
Show resolved Hide resolved
* [widgetData] contains the current play state and currently playing song.
*/
private fun updateWidgets(context: Context, widgetInfoProvider: WidgetInfoProvider, widgetData: SharedPreferences) {
val playing = widgetData.getBoolean("playing", false)
val songUri = try {
widgetData.getString("song", null)?.let { uri -> Uri.parse(uri) }
} catch (ignored: URISyntaxException) {
null
}
if (songUri == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
Abestanis marked this conversation as resolved.
Show resolved Hide resolved
widgetInfoProvider.updateAll { size ->
buildUi(context, size, playing)
}
return
}
val signal = CancellationSignal()
Executors.newSingleThreadExecutor().execute {
val bitmap = try {
context.contentResolver.loadThumbnail(songUri, widgetInfoProvider.maxSize.square(), signal)
} catch (ignored: OperationCanceledException) {
null
} catch (error: IOException) {
Log.w(Constants.LogTag, "Song thumbnail load failed for $songUri", error)
null
}
widgetInfoProvider.updateAll { size ->
buildUi(context, size, playing, bitmap)
nt4f04uNd marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

/**
* Build the UI of an app widget instance for the given [size] and [playing] state.
* If available, use the [songArt] as a background image.
*/
private fun buildUi(
context: Context,
size: Size,
playing: Boolean,
songArt: Bitmap? = null,
): RemoteViews {
return RemoteViews(context.packageName, R.layout.music_player_widget).apply {
// Open App on Widget Click
val pendingIntent = HomeWidgetLaunchIntent.getActivity(context, MainActivity::class.java)
setOnClickPendingIntent(android.R.id.background, pendingIntent)
setOnClickPendingIntent(
R.id.music_player_widget_play_pause_button, AudioServiceBackgroundIntent.getPlayPause(context)
)
setOnClickPendingIntent(
R.id.music_player_widget_previous_button, AudioServiceBackgroundIntent.getPrevious(context)
)
setOnClickPendingIntent(
R.id.music_player_widget_skip_next_button, AudioServiceBackgroundIntent.getNext(context)
)
if (songArt != null) {
setImageViewBitmap(
R.id.music_player_widget_song_art,
// On Android versions since S the system rounds the corners for us.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) songArt else createRoundedBitmap(
songArt, size, context.resources.getDimension(R.dimen.appWidgetRadius)
)
)
setViewVisibility(R.id.music_player_widget_song_art, VISIBLE)
setViewVisibility(R.id.music_player_widget_no_song_art, GONE)
} else {
nt4f04uNd marked this conversation as resolved.
Show resolved Hide resolved
setViewVisibility(R.id.music_player_widget_song_art, GONE)
setViewVisibility(R.id.music_player_widget_no_song_art, VISIBLE)
}
val imageButtonWidth = context.resources.getDimension(R.dimen.musicPlayerWidgetButtonSize)
setViewVisibility(
R.id.music_player_widget_previous_button, if (size.width < imageButtonWidth * 3) GONE else VISIBLE
)
setViewVisibility(
R.id.music_player_widget_skip_next_button, if (size.width < imageButtonWidth * 2) GONE else VISIBLE
)
setViewVisibility(
R.id.music_player_widget_play_pause_button, if (size.width < imageButtonWidth) GONE else VISIBLE
)
setImageViewResource(
R.id.music_player_widget_play_pause_button,
if (playing) R.drawable.round_pause else R.drawable.round_play_arrow
)
}
}

/**
* Convert the given [bitmap] into a bitmap of the given [size] with rounded corners with the given [cornerRadius].
*/
private fun createRoundedBitmap(bitmap: Bitmap, size: Size, cornerRadius: Float): Bitmap {
val scaleFactor = max(size.width / bitmap.width.toDouble(), size.height / bitmap.height.toDouble())
val matrix = Matrix()
matrix.setScale(scaleFactor.toFloat(), scaleFactor.toFloat())
val xOffset = ((bitmap.width - (size.width / scaleFactor)) / 2.0).roundToInt()
val yOffset = ((bitmap.height - (size.height / scaleFactor)) / 2.0).roundToInt()
val scaledBitmap = Bitmap.createBitmap(
bitmap,
xOffset,
yOffset,
(size.width / scaleFactor).roundToInt(),
(size.height / scaleFactor).roundToInt(),
matrix,
true
)
val imageRounded = Bitmap.createBitmap(size.width, size.height, bitmap.config)
val canvas = Canvas(imageRounded)
val paint = Paint()
paint.isAntiAlias = true
paint.shader = BitmapShader(scaledBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
canvas.drawRoundRect(
RectF(0f, 0f, size.width.toFloat(), size.height.toFloat()), cornerRadius, cornerRadius, paint
)
return imageRounded
}
}

/**
* Utility class for calculating and updating sizing information for the app widgets.
*/
internal class WidgetInfoProvider(
private val context: Context,
private val appWidgetManager: AppWidgetManager,
private val appWidgetIds: IntArray,
newOptions: Bundle? = null
) {
/**
* The [sizes] of all instances of this app widget on the home screen, as well as the [max] size.
*/
class SizeInfo(val sizes: Array<Size>, val max: Size)

/**
* Information about the sizes of all instances of this widget on the home screen.
*/
private val sizeInfo = calculateSizes(newOptions)

/**
* The maximum size of all instances of this widget on the home screen.
*/
val maxSize = sizeInfo.max

/*
* Calculate size information of all instances of the widget on the home screen,
* using the [newOptions] if available.
*/
private fun calculateSizes(newOptions: Bundle? = null): SizeInfo {
var maxWidth = 0
var maxHeight = 0
val isPortraitOrientation = context.resources.getBoolean(R.bool.isPortraitScreen)
val sizes = appWidgetIds.map { appWidgetId ->
val width: Int
val height: Int

// Get current dimensions (in DIP, scaled by DisplayMetrics) of this
// Widget, if API Level allows to
val mAppWidgetOptions = newOptions ?: appWidgetManager.getAppWidgetOptions(appWidgetId)
if (mAppWidgetOptions != null && mAppWidgetOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) > 0) {
if (isPortraitOrientation) { // Depends on the home-screen orientation
width = mAppWidgetOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH).dipToPixels()
height = mAppWidgetOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT).dipToPixels()
} else {
width = mAppWidgetOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH).dipToPixels()
height = mAppWidgetOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT).dipToPixels()
}
} else {
// Get min dimensions from provider info
val providerInfo: AppWidgetProviderInfo? = appWidgetManager.getAppWidgetInfo(appWidgetId)
if (providerInfo == null) {
val displayMetrics = Resources.getSystem().displayMetrics
width = displayMetrics.widthPixels
height = displayMetrics.heightPixels
} else {
width = providerInfo.minWidth.dipToPixels()
height = providerInfo.minHeight.dipToPixels()
}
}
if (maxWidth < width) {
maxWidth = width
}
if (maxHeight < height) {
maxHeight = height
}
Size(width, height)
}.toTypedArray()
return SizeInfo(sizes, Size(maxWidth, maxHeight))
}

/**
* Update the appearance of each app widget by calling the [builder] with the size of the corresponding widget.
*/
fun updateAll(builder: (Size) -> RemoteViews) {
for ((appWidgetId, size) in appWidgetIds.zip(sizeInfo.sizes)) {
appWidgetManager.updateAppWidget(appWidgetId, builder(size))
}
}
}

/**
* Utility to build background intents to interact with the AudioService from the widget.
*/
object AudioServiceBackgroundIntent {
/**
* Build a pending intent for the given [context] emulating a key-press of the given [keyEvent] to the AudioService.
*/
private fun getIntent(context: Context, keyEvent: KeyEvent): PendingIntent {
val intent = Intent(context, com.ryanheise.audioservice.AudioService::class.java)
intent.action = Intent.ACTION_MEDIA_BUTTON
intent.putExtra(Intent.EXTRA_KEY_EVENT, keyEvent)
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
flags = flags or PendingIntent.FLAG_IMMUTABLE
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
intent.identifier = keyEvent.toString()
} else {
intent.type = keyEvent.toString()
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
PendingIntent.getForegroundService(context, 0, intent, flags)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm looking into the ANR, but we definitely need to use getForegroundService, because otherwise the intent will be ignored on newer Android versions.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we might be able to work around this issue by using the broadcast receiver that listens to media playback button presses and that starts the service for us.

During my testing this worked very well, except for one time where we failed to create a notification with an ForegroundServiceStartNotAllowedException. This was after killing the app and I think it was caused by an extremely slow startup, which might have caused us to exceed some timeframe. But I have not been able to replicate that, it worked every other attempt.

} else {
PendingIntent.getService(context, 0, intent, flags)
}
}

/**
* Create an intent that emulates pressing the media play/pause button.
*/
fun getPlayPause(context: Context): PendingIntent =
getIntent(context, KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE))

/**
* Create an intent that emulates pressing the media next button.
*/
fun getNext(context: Context): PendingIntent =
getIntent(context, KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT))

/**
* Create an intent that emulates pressing the media previous button.
*/
fun getPrevious(context: Context): PendingIntent =
getIntent(context, KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PREVIOUS))
}

/**
* Convert a float DPI value to pixels.
*/
internal fun Float.dipToPixels() = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, this, Resources.getSystem().displayMetrics
)

/**
* Convert an integer DPI value to rounded pixels.
*/
internal fun Int.dipToPixels() = toFloat().dipToPixels().roundToInt()

/**
* Get a size with equal length sides from the maximum side length of this Size.
*/
internal fun Size.square(): Size {
val maxSize = max(width, height)
return Size(maxSize, maxSize)
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious, how did you generate this preview? Can we document this process somehow somewhere?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, we need to update the preview image after you added padding

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious, how did you generate this preview? Can we document this process somehow somewhere?

Android emulators with an API <= 26 have a Widget Preview app that can generate the preview. I added a a comment in the preview XML layout with a reminder and instructions on how to update it.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/main"/>
<corners android:radius="16dp"/>
</shape>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/widget_button_bar_background"/>
<corners android:radius="16dp"/>
Abestanis marked this conversation as resolved.
Show resolved Hide resolved
</shape>
Loading