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..5843f42 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,74 @@ 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() + } + + /** + * 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.6x screen ${screenArea()}", + area <= (screenArea() * 3L) / 5L + ) + } + + @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)) + } }