Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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)
Expand Down
46 changes: 46 additions & 0 deletions android/app/src/main/kotlin/org/bortnik/meteogram/WidgetUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Int, Int> {
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Loading