widgets: responsive layout

This commit is contained in:
Thibault Deckers 2024-07-06 00:17:58 +02:00
parent fdb34edf13
commit b743d0de47
4 changed files with 119 additions and 46 deletions

View file

@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file.
- switching to PiP when changing device orientation on Android >=13 - switching to PiP when changing device orientation on Android >=13
- handling wallpaper intent without URI - handling wallpaper intent without URI
- sizing widgets with some launchers on Android >=12
## <a id="v1.11.3"></a>[v1.11.3] - 2024-06-17 ## <a id="v1.11.3"></a>[v1.11.3] - 2024-06-17

View file

@ -12,6 +12,7 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.util.SizeF
import android.widget.RemoteViews import android.widget.RemoteViews
import app.loup.streams_channel.StreamsChannel import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
@ -42,10 +43,10 @@ class HomeWidgetProvider : AppWidgetProvider() {
defaultScope.launch { defaultScope.launch {
val backgroundProps = getProps(context, widgetId, widgetInfo, drawEntryImage = false) val backgroundProps = getProps(context, widgetId, widgetInfo, drawEntryImage = false)
updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, backgroundProps) updateWidgetImage(context, appWidgetManager, widgetId, backgroundProps)
val imageProps = getProps(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = false) val imageProps = getProps(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = false)
updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, imageProps) updateWidgetImage(context, appWidgetManager, widgetId, imageProps)
} }
} }
} }
@ -61,20 +62,32 @@ class HomeWidgetProvider : AppWidgetProvider() {
imageByteFetchJob = defaultScope.launch { imageByteFetchJob = defaultScope.launch {
delay(500) delay(500)
val imageProps = getProps(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = true) val imageProps = getProps(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = true)
updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, imageProps) updateWidgetImage(context, appWidgetManager, widgetId, imageProps)
} }
} }
private fun getDevicePixelRatio(): Float = Resources.getSystem().displayMetrics.density private fun getDevicePixelRatio(): Float = Resources.getSystem().displayMetrics.density
private fun getWidgetSizePx(context: Context, widgetInfo: Bundle): Pair<Int, Int> { private fun getWidgetSizesDip(context: Context, widgetInfo: Bundle): List<FieldMap> {
val devicePixelRatio = getDevicePixelRatio() var sizes: List<SizeF>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val isPortrait = context.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT widgetInfo.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES, SizeF::class.java)
val widthKey = if (isPortrait) AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH else AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val heightKey = if (isPortrait) AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT else AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT @Suppress("DEPRECATION")
val widthPx = (widgetInfo.getInt(widthKey) * devicePixelRatio).roundToInt() widgetInfo.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES)
val heightPx = (widgetInfo.getInt(heightKey) * devicePixelRatio).roundToInt() } else {
return Pair(widthPx, heightPx) null
}
if (sizes.isNullOrEmpty()) {
val isPortrait = context.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT
val widthKey = if (isPortrait) AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH else AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH
val heightKey = if (isPortrait) AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT else AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT
val widthDip = widgetInfo.getInt(widthKey)
val heightDip = widgetInfo.getInt(heightKey)
sizes = listOf(SizeF(widthDip.toFloat(), heightDip.toFloat()))
}
return sizes.map { size -> hashMapOf("widthDip" to size.width, "heightDip" to size.height) }
} }
private suspend fun getProps( private suspend fun getProps(
@ -84,8 +97,11 @@ class HomeWidgetProvider : AppWidgetProvider() {
drawEntryImage: Boolean, drawEntryImage: Boolean,
reuseEntry: Boolean = false, reuseEntry: Boolean = false,
): FieldMap? { ): FieldMap? {
val (widthPx, heightPx) = getWidgetSizePx(context, widgetInfo) val sizesDip = getWidgetSizesDip(context, widgetInfo)
if (widthPx == 0 || heightPx == 0) return null if (sizesDip.isEmpty()) return null
val sizeDip = sizesDip.first()
if (sizeDip["widthDip"] == 0 || sizeDip["heightDip"] == 0) return null
val isNightModeOn = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES val isNightModeOn = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
@ -98,8 +114,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
FlutterUtils.runOnUiThread { FlutterUtils.runOnUiThread {
channel.invokeMethod("drawWidget", hashMapOf( channel.invokeMethod("drawWidget", hashMapOf(
"widgetId" to widgetId, "widgetId" to widgetId,
"widthPx" to widthPx, "sizesDip" to sizesDip,
"heightPx" to heightPx,
"devicePixelRatio" to getDevicePixelRatio(), "devicePixelRatio" to getDevicePixelRatio(),
"drawEntryImage" to drawEntryImage, "drawEntryImage" to drawEntryImage,
"reuseEntry" to reuseEntry, "reuseEntry" to reuseEntry,
@ -127,7 +142,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
@Suppress("unchecked_cast") @Suppress("unchecked_cast")
return props as FieldMap? return props as FieldMap?
} catch (e: Exception) { } catch (e: Exception) {
Log.e(LOG_TAG, "failed to draw widget for widgetId=$widgetId widthPx=$widthPx heightPx=$heightPx", e) Log.e(LOG_TAG, "failed to draw widget for widgetId=$widgetId sizesPx=$sizesDip", e)
} }
return null return null
} }
@ -136,36 +151,83 @@ class HomeWidgetProvider : AppWidgetProvider() {
context: Context, context: Context,
appWidgetManager: AppWidgetManager, appWidgetManager: AppWidgetManager,
widgetId: Int, widgetId: Int,
widgetInfo: Bundle,
props: FieldMap?, props: FieldMap?,
) { ) {
props ?: return props ?: return
val bytes = props["bytes"] as ByteArray? val bytesBySizeDip = (props["bytesBySizeDip"] as List<*>?)?.mapNotNull {
if (it is Map<*, *>) {
val widthDip = (it["widthDip"] as Number?)?.toFloat()
val heightDip = (it["heightDip"] as Number?)?.toFloat()
val bytes = it["bytes"] as ByteArray?
if (widthDip != null && heightDip != null && bytes != null) {
Pair(SizeF(widthDip, heightDip), bytes)
} else null
} else null
}
val updateOnTap = props["updateOnTap"] as Boolean? val updateOnTap = props["updateOnTap"] as Boolean?
if (bytes == null || updateOnTap == null) { if (bytesBySizeDip == null || updateOnTap == null) {
Log.e(LOG_TAG, "missing arguments") Log.e(LOG_TAG, "missing arguments")
return return
} }
val (widthPx, heightPx) = getWidgetSizePx(context, widgetInfo) if (bytesBySizeDip.isEmpty()) {
if (widthPx == 0 || heightPx == 0) return Log.e(LOG_TAG, "empty image list")
return
}
val bitmaps = ArrayList<Bitmap>()
fun createRemoteViewsForSize(
context: Context,
widgetId: Int,
sizeDip: SizeF,
bytes: ByteArray,
updateOnTap: Boolean,
): RemoteViews? {
val devicePixelRatio = getDevicePixelRatio()
val widthPx = (sizeDip.width * devicePixelRatio).roundToInt()
val heightPx = (sizeDip.height * devicePixelRatio).roundToInt()
try {
val bitmap = Bitmap.createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888).also {
bitmaps.add(it)
it.copyPixelsFromBuffer(ByteBuffer.wrap(bytes))
}
val pendingIntent = if (updateOnTap) buildUpdateIntent(context, widgetId) else buildOpenAppIntent(context, widgetId)
return RemoteViews(context.packageName, R.layout.app_widget).apply {
setImageViewBitmap(R.id.widget_img, bitmap)
setOnClickPendingIntent(R.id.widget_img, pendingIntent)
}
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to draw widget", e)
}
return null
}
try { try {
val bitmap = Bitmap.createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(bytes)) // multiple rendering for all possible sizes
val views = RemoteViews(
val pendingIntent = if (updateOnTap) buildUpdateIntent(context, widgetId) else buildOpenAppIntent(context, widgetId) bytesBySizeDip.associateBy(
{ (sizeDip, _) -> sizeDip },
val views = RemoteViews(context.packageName, R.layout.app_widget).apply { { (sizeDip, bytes) -> createRemoteViewsForSize(context, widgetId, sizeDip, bytes, updateOnTap) },
setImageViewBitmap(R.id.widget_img, bitmap) ).filterValues { it != null }.mapValues { (_, view) -> view!! }
setOnClickPendingIntent(R.id.widget_img, pendingIntent) )
appWidgetManager.updateAppWidget(widgetId, views)
} else {
// single rendering
val (sizeDip, bytes) = bytesBySizeDip.first()
val views = createRemoteViewsForSize(context, widgetId, sizeDip, bytes, updateOnTap)
appWidgetManager.updateAppWidget(widgetId, views)
} }
appWidgetManager.updateAppWidget(widgetId, views)
bitmap.recycle()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(LOG_TAG, "failed to draw widget", e) Log.e(LOG_TAG, "failed to draw widget", e)
} finally {
bitmaps.forEach { it.recycle() }
bitmaps.clear()
} }
} }

View file

@ -38,8 +38,9 @@ void widgetMainCommon(AppFlavor flavor) async {
Future<Map<String, dynamic>> _drawWidget(dynamic args) async { Future<Map<String, dynamic>> _drawWidget(dynamic args) async {
final widgetId = args['widgetId'] as int; final widgetId = args['widgetId'] as int;
final widthPx = args['widthPx'] as int; final sizesDip = (args['sizesDip'] as List).cast<Map>().map((kv) {
final heightPx = args['heightPx'] as int; return Size(kv['widthDip'] as double, kv['heightDip'] as double);
}).toList();
final cornerRadiusPx = args['cornerRadiusPx'] as double?; final cornerRadiusPx = args['cornerRadiusPx'] as double?;
final devicePixelRatio = args['devicePixelRatio'] as double; final devicePixelRatio = args['devicePixelRatio'] as double;
final drawEntryImage = args['drawEntryImage'] as bool; final drawEntryImage = args['drawEntryImage'] as bool;
@ -54,15 +55,22 @@ Future<Map<String, dynamic>> _drawWidget(dynamic args) async {
entry: entry, entry: entry,
devicePixelRatio: devicePixelRatio, devicePixelRatio: devicePixelRatio,
); );
final bytes = await painter.drawWidget( final bytesBySizeDip = <Map<String, dynamic>>[];
widthPx: widthPx, await Future.forEach(sizesDip, (sizeDip) async {
heightPx: heightPx, final bytes = await painter.drawWidget(
cornerRadiusPx: cornerRadiusPx, sizeDip: sizeDip,
outline: outline, cornerRadiusPx: cornerRadiusPx,
shape: settings.getWidgetShape(widgetId), outline: outline,
); shape: settings.getWidgetShape(widgetId),
);
bytesBySizeDip.add({
'widthDip': sizeDip.width,
'heightDip': sizeDip.height,
'bytes': bytes,
});
});
return { return {
'bytes': bytes, 'bytesBySizeDip': bytesBySizeDip,
'updateOnTap': settings.getWidgetOpenPage(widgetId) == WidgetOpenPage.updateWidget, 'updateOnTap': settings.getWidgetOpenPage(widgetId) == WidgetOpenPage.updateWidget,
}; };
} }

View file

@ -27,14 +27,16 @@ class HomeWidgetPainter {
}); });
Future<Uint8List> drawWidget({ Future<Uint8List> drawWidget({
required int widthPx, required Size sizeDip,
required int heightPx,
required double? cornerRadiusPx, required double? cornerRadiusPx,
required Color? outline, required Color? outline,
required WidgetShape shape, required WidgetShape shape,
ui.ImageByteFormat format = ui.ImageByteFormat.rawRgba, ui.ImageByteFormat format = ui.ImageByteFormat.rawRgba,
}) async { }) async {
final widgetSizePx = Size(widthPx.toDouble(), heightPx.toDouble()); final widthPx = sizeDip.width * devicePixelRatio;
final heightPx = sizeDip.height * devicePixelRatio;
final widgetSizePx = Size(widthPx, heightPx);
debugPrint('draw widget for $sizeDip dp ($widgetSizePx px), entry=$entry');
final ui.Image? entryImage; final ui.Image? entryImage;
if (entry != null) { if (entry != null) {
final extent = shape.extentPx(widgetSizePx, entry!) / devicePixelRatio; final extent = shape.extentPx(widgetSizePx, entry!) / devicePixelRatio;
@ -57,7 +59,7 @@ class HomeWidgetPainter {
if (outline != null) { if (outline != null) {
drawOutline(canvas, path, devicePixelRatio, outline); drawOutline(canvas, path, devicePixelRatio, outline);
} }
final widgetImage = await recorder.endRecording().toImage(widthPx, heightPx); final widgetImage = await recorder.endRecording().toImage(widthPx.round(), heightPx.round());
final byteData = await widgetImage.toByteData(format: format); final byteData = await widgetImage.toByteData(format: format);
return byteData?.buffer.asUint8List() ?? Uint8List(0); return byteData?.buffer.asUint8List() ?? Uint8List(0);
} }