From d425f2d767248d169b3c55ad1f9906b9cc5f93e3 Mon Sep 17 00:00:00 2001 From: Tymofiy Bortnyk Date: Sun, 28 Jun 2026 11:42:08 +0300 Subject: [PATCH 1/2] fix(widget): clamp chart bitmaps to launcher's memory budget on large resize Resizing the widget to cover more than ~75% of the screen area crashed both the widget and the app. The widget sets two bitmaps per update (light + dark charts, toggled by night-mode qualifiers), and the launcher rejects a RemoteViews update whose total bitmap memory exceeds 1.5x the screen area, throwing IllegalArgumentException from updateAppWidget(): RemoteViews for widget update exceeds maximum bitmap memory usage (used: 200000000, max: 26956800) at MeteogramWidgetProvider.onAppWidgetOptionsChanged(...) The receiver runs in the app process, so the uncaught throw took the app and widget down together. Stock launchers cap widget size below the threshold, but third-party launchers (e.g. Smart Launcher) allow resizing past it. Fix: - WidgetUtils.clampChartDimensions() scales requested bitmap dimensions down (preserving aspect ratio) so two bitmaps fit the budget, targeting 0.6x screen area each for margin. Applied on resize and on update. - Wrap both updateAppWidget() calls in try/catch as a safety net so an over-budget update can never crash the in-process receiver. Covers MeteogramWidgetProvider and the inheriting weekly provider. Adds unit tests for the clamp math (pass-through, full-screen scaling, very tall/narrow, non-positive inputs). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../meteogram/MeteogramWidgetProvider.kt | 39 ++++++++--- .../org/bortnik/meteogram/WidgetUtils.kt | 46 +++++++++++++ .../org/bortnik/meteogram/WidgetUtilsTest.kt | 65 +++++++++++++++++++ 3 files changed, 142 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/kotlin/org/bortnik/meteogram/MeteogramWidgetProvider.kt b/android/app/src/main/kotlin/org/bortnik/meteogram/MeteogramWidgetProvider.kt index 5943b50..1dfb142 100644 --- a/android/app/src/main/kotlin/org/bortnik/meteogram/MeteogramWidgetProvider.kt +++ b/android/app/src/main/kotlin/org/bortnik/meteogram/MeteogramWidgetProvider.kt @@ -260,8 +260,11 @@ open class MeteogramWidgetProvider : AppWidgetProvider() { val minWidth = newOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) val maxHeight = newOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) val density = context.resources.displayMetrics.density - val widthPx = (minWidth * density).toInt() - val heightPx = (maxHeight * density).toInt() + val (widthPx, heightPx) = WidgetUtils.clampChartDimensions( + context, + (minWidth * density).toInt(), + (maxHeight * density).toInt() + ) Log.d(logTag, "Widget $appWidgetId new dimensions: ${minWidth}dp x ${maxHeight}dp = ${widthPx}px x ${heightPx}px") @@ -308,8 +311,16 @@ open class MeteogramWidgetProvider : AppWidgetProvider() { ) views.setOnClickPendingIntent(R.id.widget_root, pendingIntent) - appWidgetManager.updateAppWidget(appWidgetId, views) - Log.d(logTag, "Widget $appWidgetId updated with native charts") + // Guard against the launcher rejecting an over-budget bitmap + // update (see WidgetUtils.clampChartDimensions). Clamping should + // keep us under the limit, but a throw here must never crash the + // app process — the widget receiver runs in-process. + try { + appWidgetManager.updateAppWidget(appWidgetId, views) + Log.d(logTag, "Widget $appWidgetId updated with native charts") + } catch (e: Exception) { + Log.e(logTag, "Failed to update widget $appWidgetId after resize", e) + } return } } @@ -349,8 +360,13 @@ open class MeteogramWidgetProvider : AppWidgetProvider() { val maxHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) val density = context.resources.displayMetrics.density - var widthPx = (minWidth * density).toInt() - var heightPx = (maxHeight * density).toInt() + val clamped = WidgetUtils.clampChartDimensions( + context, + (minWidth * density).toInt(), + (maxHeight * density).toInt() + ) + var widthPx = clamped.first + var heightPx = clamped.second Log.d(logTag, "Widget $appWidgetId dimensions: ${minWidth}dp x ${maxHeight}dp = ${widthPx}px x ${heightPx}px") @@ -433,8 +449,15 @@ open class MeteogramWidgetProvider : AppWidgetProvider() { applyThemeOverride(context, views) } - appWidgetManager.updateAppWidget(appWidgetId, views) - Log.d(logTag, "Updated widget $appWidgetId") + // Guard against the launcher rejecting an over-budget bitmap update + // (see WidgetUtils.clampChartDimensions) — a throw must never crash + // the in-process widget receiver. + try { + appWidgetManager.updateAppWidget(appWidgetId, views) + Log.d(logTag, "Updated widget $appWidgetId") + } catch (e: Exception) { + Log.e(logTag, "Failed to update widget $appWidgetId", e) + } } WidgetUtils.updateLastRenderTime(context) diff --git a/android/app/src/main/kotlin/org/bortnik/meteogram/WidgetUtils.kt b/android/app/src/main/kotlin/org/bortnik/meteogram/WidgetUtils.kt index 6c074d9..0e38162 100644 --- a/android/app/src/main/kotlin/org/bortnik/meteogram/WidgetUtils.kt +++ b/android/app/src/main/kotlin/org/bortnik/meteogram/WidgetUtils.kt @@ -8,6 +8,7 @@ import android.content.Context import android.util.Log import android.view.View import java.util.concurrent.Executors +import kotlin.math.sqrt /** * Shared utility functions and constants for widget operations. @@ -74,6 +75,51 @@ object WidgetUtils { return Pair(width, height) } + /** + * Clamp chart bitmap dimensions so the widget's RemoteViews stay within the + * launcher's per-widget bitmap budget. + * + * AppWidgetService rejects an update whose total bitmap memory exceeds + * `1.5 x screenArea x 4` bytes, throwing IllegalArgumentException from + * `updateAppWidget()`. We set TWO bitmaps per update (light + dark charts + * toggled by night-mode qualifiers), so the two together must fit — i.e. + * each may use at most `0.75 x screenArea`. Stock launchers cap widget size + * below this, but some third-party launchers (e.g. Smart Launcher) allow + * resizing past it, which crashed the whole app process (the widget receiver + * runs in-process, taking the app and widget down together). + * + * When the requested size would overflow the budget, scale both dimensions + * down proportionally (preserving aspect ratio). The ImageView stretches the + * bitmap to the view (FIT_XY), so a slightly lower-resolution raster of the + * same SVG chart is visually indistinguishable — and far better than a crash. + * A 0.6 (not 0.75) target leaves margin for the small placeholder/indicator + * bitmaps and for the system measuring against the full display while + * `displayMetrics` may report a smaller (decor-excluded) area. + * + * @return (width, height) in pixels, guaranteed to fit the budget. + */ + fun clampChartDimensions(context: Context, widthPx: Int, heightPx: Int): Pair { + if (widthPx <= 0 || heightPx <= 0) return Pair(widthPx, heightPx) + + val metrics = context.resources.displayMetrics + val screenArea = metrics.widthPixels.toLong() * metrics.heightPixels.toLong() + if (screenArea <= 0L) return Pair(widthPx, heightPx) + + val maxBitmapArea = (screenArea * 0.6).toLong() + val requestedArea = widthPx.toLong() * heightPx.toLong() + if (requestedArea <= maxBitmapArea) return Pair(widthPx, heightPx) + + val scale = sqrt(maxBitmapArea.toDouble() / requestedArea.toDouble()) + val clampedWidth = (widthPx * scale).toInt().coerceAtLeast(1) + val clampedHeight = (heightPx * scale).toInt().coerceAtLeast(1) + Log.d( + TAG, + "Clamped chart dimensions ${widthPx}x$heightPx -> ${clampedWidth}x$clampedHeight " + + "(screen area $screenArea, max bitmap area $maxBitmapArea)" + ) + return Pair(clampedWidth, clampedHeight) + } + /** * Fetch weather natively via Open-Meteo API. * Runs asynchronously on a background thread and updates widget on completion. diff --git a/android/app/src/test/kotlin/org/bortnik/meteogram/WidgetUtilsTest.kt b/android/app/src/test/kotlin/org/bortnik/meteogram/WidgetUtilsTest.kt index 74619d3..670be74 100644 --- a/android/app/src/test/kotlin/org/bortnik/meteogram/WidgetUtilsTest.kt +++ b/android/app/src/test/kotlin/org/bortnik/meteogram/WidgetUtilsTest.kt @@ -207,4 +207,69 @@ class WidgetUtilsTest { fun `chartVisibilityForThemeMode defers to system for unknown value`() { assertNull(WidgetUtils.chartVisibilityForThemeMode("bogus")) } + + // ---- clampChartDimensions ---- + // + // The widget sets two bitmaps (light + dark) per update, and the launcher + // rejects updates whose total bitmap memory exceeds 1.5 x screen area, + // crashing the in-process receiver. So each bitmap must fit 0.75 x screen. + + private fun setScreen(width: Int, height: Int) { + val dm = context.resources.displayMetrics + dm.widthPixels = width + dm.heightPixels = height + } + + private fun screenArea(): Long { + val dm = context.resources.displayMetrics + return dm.widthPixels.toLong() * dm.heightPixels.toLong() + } + + /** Two bitmaps of [w]x[h] must fit the launcher's 1.5x-screen budget. */ + private fun assertFitsBudget(w: Int, h: Int) { + val area = w.toLong() * h.toLong() + assertTrue( + "bitmap area $area must be <= 0.75x screen ${screenArea()} so two fit 1.5x", + area <= (screenArea() * 3L) / 4L + ) + } + + @Test + fun `clampChartDimensions leaves in-budget dimensions unchanged`() { + setScreen(1080, 2400) + val (w, h) = WidgetUtils.clampChartDimensions(context, 800, 400) + assertEquals(800, w) + assertEquals(400, h) + } + + @Test + fun `clampChartDimensions scales a full-screen request to fit the budget`() { + setScreen(1080, 2400) + val dm = context.resources.displayMetrics + val (w, h) = WidgetUtils.clampChartDimensions(context, dm.widthPixels, dm.heightPixels) + assertFitsBudget(w, h) + // Aspect ratio preserved (within integer rounding). + assertEquals( + dm.widthPixels.toDouble() / dm.heightPixels, + w.toDouble() / h, + 0.02 + ) + } + + @Test + fun `clampChartDimensions clamps a very tall narrow widget`() { + setScreen(1440, 3120) + // Smart Launcher can request a widget far taller than the screen. + val (w, h) = WidgetUtils.clampChartDimensions(context, 600, 12000) + assertFitsBudget(w, h) + assertTrue("clamped dims stay positive", w >= 1 && h >= 1) + assertEquals(600.0 / 12000.0, w.toDouble() / h, 0.02) + } + + @Test + fun `clampChartDimensions returns non-positive dimensions unchanged`() { + setScreen(1080, 2400) + assertEquals(Pair(0, 500), WidgetUtils.clampChartDimensions(context, 0, 500)) + assertEquals(Pair(-1, -1), WidgetUtils.clampChartDimensions(context, -1, -1)) + } } From aa1a84305f9cf854ab2df5deb9656cb1382ee6c7 Mon Sep 17 00:00:00 2001 From: Tymofiy Bortnyk Date: Sun, 28 Jun 2026 11:58:22 +0300 Subject: [PATCH 2/2] test(widget): assert clamp's 0.6x-screen safety target, not just the cap assertFitsBudget only checked the launcher's hard 0.75x-per-bitmap limit, so a regression loosening clampChartDimensions' intended 0.6x margin to 0.75x would still pass. Tighten the bound to 0.6x screen area to lock in the safety margin. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../kotlin/org/bortnik/meteogram/WidgetUtilsTest.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/android/app/src/test/kotlin/org/bortnik/meteogram/WidgetUtilsTest.kt b/android/app/src/test/kotlin/org/bortnik/meteogram/WidgetUtilsTest.kt index 670be74..5843f42 100644 --- a/android/app/src/test/kotlin/org/bortnik/meteogram/WidgetUtilsTest.kt +++ b/android/app/src/test/kotlin/org/bortnik/meteogram/WidgetUtilsTest.kt @@ -225,12 +225,17 @@ class WidgetUtilsTest { return dm.widthPixels.toLong() * dm.heightPixels.toLong() } - /** Two bitmaps of [w]x[h] must fit the launcher's 1.5x-screen budget. */ + /** + * Each bitmap must stay within clampChartDimensions' 0.6x-screen target — + * the safety margin below the launcher's hard 0.75x-per-bitmap (1.5x-total) + * limit. Asserting the 0.6 target, not just the 0.75 cap, locks in the + * margin so a regression that loosens it is caught. + */ private fun assertFitsBudget(w: Int, h: Int) { val area = w.toLong() * h.toLong() assertTrue( - "bitmap area $area must be <= 0.75x screen ${screenArea()} so two fit 1.5x", - area <= (screenArea() * 3L) / 4L + "bitmap area $area must be <= 0.6x screen ${screenArea()}", + area <= (screenArea() * 3L) / 5L ) }