From b743d0de47255231b29a47cff0350a7e4c88d114 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sat, 6 Jul 2024 00:17:58 +0200 Subject: [PATCH] widgets: responsive layout --- CHANGELOG.md | 1 + .../thibault/aves/HomeWidgetProvider.kt | 126 +++++++++++++----- lib/widget_common.dart | 28 ++-- lib/widgets/home_widget.dart | 10 +- 4 files changed, 119 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2c1bcc8a..f5519768f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - handling wallpaper intent without URI +- sizing widgets with some launchers on Android >=12 ## [v1.11.3] - 2024-06-17 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/HomeWidgetProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/HomeWidgetProvider.kt index 3fa77730b..27a70d25f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/HomeWidgetProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/HomeWidgetProvider.kt @@ -12,6 +12,7 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.util.Log +import android.util.SizeF import android.widget.RemoteViews import app.loup.streams_channel.StreamsChannel import deckers.thibault.aves.channel.AvesByteSendingMethodCodec @@ -42,10 +43,10 @@ class HomeWidgetProvider : AppWidgetProvider() { defaultScope.launch { 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) - updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, imageProps) + updateWidgetImage(context, appWidgetManager, widgetId, imageProps) } } } @@ -61,20 +62,32 @@ class HomeWidgetProvider : AppWidgetProvider() { imageByteFetchJob = defaultScope.launch { delay(500) 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 getWidgetSizePx(context: Context, widgetInfo: Bundle): Pair { - val devicePixelRatio = getDevicePixelRatio() - 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 widthPx = (widgetInfo.getInt(widthKey) * devicePixelRatio).roundToInt() - val heightPx = (widgetInfo.getInt(heightKey) * devicePixelRatio).roundToInt() - return Pair(widthPx, heightPx) + private fun getWidgetSizesDip(context: Context, widgetInfo: Bundle): List { + var sizes: List? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + widgetInfo.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES, SizeF::class.java) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + @Suppress("DEPRECATION") + widgetInfo.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES) + } else { + 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( @@ -84,8 +97,11 @@ class HomeWidgetProvider : AppWidgetProvider() { drawEntryImage: Boolean, reuseEntry: Boolean = false, ): FieldMap? { - val (widthPx, heightPx) = getWidgetSizePx(context, widgetInfo) - if (widthPx == 0 || heightPx == 0) return null + val sizesDip = getWidgetSizesDip(context, widgetInfo) + 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 @@ -98,8 +114,7 @@ class HomeWidgetProvider : AppWidgetProvider() { FlutterUtils.runOnUiThread { channel.invokeMethod("drawWidget", hashMapOf( "widgetId" to widgetId, - "widthPx" to widthPx, - "heightPx" to heightPx, + "sizesDip" to sizesDip, "devicePixelRatio" to getDevicePixelRatio(), "drawEntryImage" to drawEntryImage, "reuseEntry" to reuseEntry, @@ -127,7 +142,7 @@ class HomeWidgetProvider : AppWidgetProvider() { @Suppress("unchecked_cast") return props as FieldMap? } 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 } @@ -136,36 +151,83 @@ class HomeWidgetProvider : AppWidgetProvider() { context: Context, appWidgetManager: AppWidgetManager, widgetId: Int, - widgetInfo: Bundle, props: FieldMap?, ) { 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? - if (bytes == null || updateOnTap == null) { + if (bytesBySizeDip == null || updateOnTap == null) { Log.e(LOG_TAG, "missing arguments") return } - val (widthPx, heightPx) = getWidgetSizePx(context, widgetInfo) - if (widthPx == 0 || heightPx == 0) return + if (bytesBySizeDip.isEmpty()) { + Log.e(LOG_TAG, "empty image list") + return + } + + val bitmaps = ArrayList() + + 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 { - val bitmap = Bitmap.createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888) - bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(bytes)) - - val pendingIntent = if (updateOnTap) buildUpdateIntent(context, widgetId) else buildOpenAppIntent(context, widgetId) - - val views = RemoteViews(context.packageName, R.layout.app_widget).apply { - setImageViewBitmap(R.id.widget_img, bitmap) - setOnClickPendingIntent(R.id.widget_img, pendingIntent) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // multiple rendering for all possible sizes + val views = RemoteViews( + bytesBySizeDip.associateBy( + { (sizeDip, _) -> sizeDip }, + { (sizeDip, bytes) -> createRemoteViewsForSize(context, widgetId, sizeDip, bytes, updateOnTap) }, + ).filterValues { it != null }.mapValues { (_, view) -> view!! } + ) + 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) { Log.e(LOG_TAG, "failed to draw widget", e) + } finally { + bitmaps.forEach { it.recycle() } + bitmaps.clear() } } diff --git a/lib/widget_common.dart b/lib/widget_common.dart index bf115cd07..34a8067cb 100644 --- a/lib/widget_common.dart +++ b/lib/widget_common.dart @@ -38,8 +38,9 @@ void widgetMainCommon(AppFlavor flavor) async { Future> _drawWidget(dynamic args) async { final widgetId = args['widgetId'] as int; - final widthPx = args['widthPx'] as int; - final heightPx = args['heightPx'] as int; + final sizesDip = (args['sizesDip'] as List).cast().map((kv) { + return Size(kv['widthDip'] as double, kv['heightDip'] as double); + }).toList(); final cornerRadiusPx = args['cornerRadiusPx'] as double?; final devicePixelRatio = args['devicePixelRatio'] as double; final drawEntryImage = args['drawEntryImage'] as bool; @@ -54,15 +55,22 @@ Future> _drawWidget(dynamic args) async { entry: entry, devicePixelRatio: devicePixelRatio, ); - final bytes = await painter.drawWidget( - widthPx: widthPx, - heightPx: heightPx, - cornerRadiusPx: cornerRadiusPx, - outline: outline, - shape: settings.getWidgetShape(widgetId), - ); + final bytesBySizeDip = >[]; + await Future.forEach(sizesDip, (sizeDip) async { + final bytes = await painter.drawWidget( + sizeDip: sizeDip, + cornerRadiusPx: cornerRadiusPx, + outline: outline, + shape: settings.getWidgetShape(widgetId), + ); + bytesBySizeDip.add({ + 'widthDip': sizeDip.width, + 'heightDip': sizeDip.height, + 'bytes': bytes, + }); + }); return { - 'bytes': bytes, + 'bytesBySizeDip': bytesBySizeDip, 'updateOnTap': settings.getWidgetOpenPage(widgetId) == WidgetOpenPage.updateWidget, }; } diff --git a/lib/widgets/home_widget.dart b/lib/widgets/home_widget.dart index 10c2c30f4..6f6d4a509 100644 --- a/lib/widgets/home_widget.dart +++ b/lib/widgets/home_widget.dart @@ -27,14 +27,16 @@ class HomeWidgetPainter { }); Future drawWidget({ - required int widthPx, - required int heightPx, + required Size sizeDip, required double? cornerRadiusPx, required Color? outline, required WidgetShape shape, ui.ImageByteFormat format = ui.ImageByteFormat.rawRgba, }) 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; if (entry != null) { final extent = shape.extentPx(widgetSizePx, entry!) / devicePixelRatio; @@ -57,7 +59,7 @@ class HomeWidgetPainter { if (outline != null) { 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); return byteData?.buffer.asUint8List() ?? Uint8List(0); }