From 3de00a1d791b811a2a3b5131663ea46d6d1a9ed4 Mon Sep 17 00:00:00 2001 From: Tymofiy Bortnyk Date: Mon, 29 Jun 2026 12:11:54 +0300 Subject: [PATCH 1/2] fix(widget): recover from launcher bitmap-budget rejection instead of freezing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When updateAppWidget() is rejected for exceeding the launcher's per-widget bitmap-memory budget, the #35 try/catch only swallowed the throw, leaving the widget permanently and silently frozen: every later update recomputed the same over-budget size and was rejected again, fixable only by re-creating the widget (the "widget stops updating, new widget works, app fails to update on tap" report). Add updateWidgetWithRetry(): on rejection, halve both bitmap dimensions (quartering memory) and re-render via the new pure buildWidgetViews(w, h), up to 4 attempts down to a 64px floor. The chart is vector (SVG stretched FIT_XY), so a lower-res raster is visually identical — far better than a freeze. The happy path is unchanged (one render, one update). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../meteogram/MeteogramWidgetProvider.kt | 263 +++++++++++------- 1 file changed, 163 insertions(+), 100 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 1dfb142..3235902 100644 --- a/android/app/src/main/kotlin/org/bortnik/meteogram/MeteogramWidgetProvider.kt +++ b/android/app/src/main/kotlin/org/bortnik/meteogram/MeteogramWidgetProvider.kt @@ -49,6 +49,12 @@ open class MeteogramWidgetProvider : AppWidgetProvider() { companion object { private const val KEY_WIDGET_IDS = "widget_ids" + + // Recovery bounds for updateWidgetWithRetry: how many times to shrink the + // chart bitmaps when the launcher rejects an over-budget update, and the + // pixel floor below which retrying is pointless. + private const val MAX_UPDATE_ATTEMPTS = 4 + private const val MIN_CHART_DIMENSION_PX = 64 } private fun saveWidgetDimensions(prefs: SharedPreferences, widgetId: Int, widthPx: Int, heightPx: Int, density: Float) { @@ -249,6 +255,137 @@ open class MeteogramWidgetProvider : AppWidgetProvider() { return null } + /** + * Push [buildViews]'s RemoteViews to the launcher, shrinking the chart + * bitmaps and retrying if the launcher rejects the update for exceeding its + * per-widget bitmap-memory budget (`updateAppWidget` throws + * IllegalArgumentException). + * + * [WidgetUtils.clampChartDimensions] should keep us under the budget, but the + * launcher's budget basis can differ from our displayMetrics (split-screen, + * multi-window, third-party launchers). Without recovery a single rejection + * freezes the widget permanently — every later update recomputes the same + * over-budget size and is rejected again, fixable only by re-creating the + * widget. The chart is vector, so a lower-res raster stretched FIT_XY looks + * identical; halving both dimensions quarters the bitmap memory and almost + * always fits on the first retry. + * + * @param buildViews builds the complete RemoteViews for the given pixel size, + * or null if there's nothing to show at that size (caller handles it). + */ + private fun updateWidgetWithRetry( + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + widthPx: Int, + heightPx: Int, + buildViews: (width: Int, height: Int) -> RemoteViews? + ) { + var w = widthPx + var h = heightPx + for (attempt in 0 until MAX_UPDATE_ATTEMPTS) { + val views = buildViews(w, h) ?: return + try { + appWidgetManager.updateAppWidget(appWidgetId, views) + if (attempt == 0) { + Log.d(logTag, "Updated widget $appWidgetId") + } else { + Log.w(logTag, "Updated widget $appWidgetId at reduced ${w}x$h after $attempt retr${if (attempt == 1) "y" else "ies"}") + } + return + } catch (e: Exception) { + Log.e(logTag, "Widget $appWidgetId update rejected at ${w}x$h (attempt ${attempt + 1}/$MAX_UPDATE_ATTEMPTS)", e) + w /= 2 + h /= 2 + if (w < MIN_CHART_DIMENSION_PX || h < MIN_CHART_DIMENSION_PX) break + } + } + Log.e(logTag, "Gave up updating widget $appWidgetId — could not fit launcher bitmap budget") + } + + /** + * Build the complete RemoteViews for one widget at the given pixel size: + * click intent, native light/dark charts (falling back to saved SVG files, + * then a "tap to load" placeholder), refresh indicator, and theme override. + * A pure function of its inputs, so [updateWidgetWithRetry] can rebuild it at + * a smaller size when the launcher rejects an over-budget update. + */ + private fun buildWidgetViews( + context: Context, + appWidgetId: Int, + prefs: SharedPreferences, + weatherData: WeatherData?, + widthPx: Int, + heightPx: Int + ): RemoteViews { + val views = RemoteViews(context.packageName, layoutRes) + + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + setPackage(context.packageName) + } + val pendingIntent = PendingIntent.getActivity( + context, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + views.setOnClickPendingIntent(R.id.widget_root, pendingIntent) + + var hasChart = false + + if (weatherData != null && widthPx > 0 && heightPx > 0) { + val lightColors = WidgetChartColors.get(context, isLight = true) + val lightBitmap = generateChartBitmap(context, weatherData, lightColors, widthPx, heightPx) + if (lightBitmap != null) { + views.setImageViewBitmap(R.id.widget_chart_light, lightBitmap) + hasChart = true + Log.d(logTag, "Widget $appWidgetId native light chart generated") + } + + val darkColors = WidgetChartColors.get(context, isLight = false) + val darkBitmap = generateChartBitmap(context, weatherData, darkColors, widthPx, heightPx) + if (darkBitmap != null) { + views.setImageViewBitmap(R.id.widget_chart_dark, darkBitmap) + hasChart = true + Log.d(logTag, "Widget $appWidgetId native dark chart generated") + } + } + + if (!hasChart) { + val svgLightPath = prefs.getString("svg_path_light_$appWidgetId", null) + ?: prefs.getString("svg_path_light", null) + val svgDarkPath = prefs.getString("svg_path_dark_$appWidgetId", null) + ?: prefs.getString("svg_path_dark", null) + Log.d(logTag, "Widget $appWidgetId falling back to SVG files - light: $svgLightPath, dark: $svgDarkPath") + + val lightBitmap = loadChartBitmap(svgLightPath, null, widthPx, heightPx) + if (lightBitmap != null) { + views.setImageViewBitmap(R.id.widget_chart_light, lightBitmap) + hasChart = true + Log.d(logTag, "Widget $appWidgetId light chart loaded from file") + } + + val darkBitmap = loadChartBitmap(svgDarkPath, null, widthPx, heightPx) + if (darkBitmap != null) { + views.setImageViewBitmap(R.id.widget_chart_dark, darkBitmap) + hasChart = true + Log.d(logTag, "Widget $appWidgetId dark chart loaded from file") + } + } + + if (!hasChart) { + views.setViewVisibility(R.id.widget_placeholder, View.VISIBLE) + Log.d(logTag, "Widget $appWidgetId showing placeholder - no weather data") + } else { + views.setViewVisibility(R.id.widget_placeholder, View.GONE) + } + + views.setViewVisibility(R.id.widget_refresh_indicator, View.GONE) + if (hasChart) { + applyThemeOverride(context, views) + } + + return views + } + override fun onAppWidgetOptionsChanged( context: Context, appWidgetManager: AppWidgetManager, @@ -280,23 +417,28 @@ open class MeteogramWidgetProvider : AppWidgetProvider() { if (weatherData != null && widthPx > 0 && heightPx > 0) { Log.d(logTag, "Generating native chart for resize") - val views = RemoteViews(context.packageName, layoutRes) + var rendered = false + // Clamping should keep us under the launcher's bitmap budget; if a + // host rejects the update anyway, updateWidgetWithRetry shrinks and + // retries rather than letting the throw freeze the widget forever. + updateWidgetWithRetry(appWidgetManager, appWidgetId, widthPx, heightPx) { w, h -> + val views = RemoteViews(context.packageName, layoutRes) - val lightColors = WidgetChartColors.get(context, isLight = true) - val lightBitmap = generateChartBitmap(context, weatherData, lightColors, widthPx, heightPx) - if (lightBitmap != null) { - views.setImageViewBitmap(R.id.widget_chart_light, lightBitmap) - Log.d(logTag, "Native light chart generated") - } + val lightColors = WidgetChartColors.get(context, isLight = true) + val lightBitmap = generateChartBitmap(context, weatherData, lightColors, w, h) + if (lightBitmap != null) { + views.setImageViewBitmap(R.id.widget_chart_light, lightBitmap) + } - val darkColors = WidgetChartColors.get(context, isLight = false) - val darkBitmap = generateChartBitmap(context, weatherData, darkColors, widthPx, heightPx) - if (darkBitmap != null) { - views.setImageViewBitmap(R.id.widget_chart_dark, darkBitmap) - Log.d(logTag, "Native dark chart generated") - } + val darkColors = WidgetChartColors.get(context, isLight = false) + val darkBitmap = generateChartBitmap(context, weatherData, darkColors, w, h) + if (darkBitmap != null) { + views.setImageViewBitmap(R.id.widget_chart_dark, darkBitmap) + } - if (lightBitmap != null || darkBitmap != null) { + if (lightBitmap == null && darkBitmap == null) return@updateWidgetWithRetry null + + rendered = true views.setViewVisibility(R.id.widget_placeholder, View.GONE) views.setViewVisibility(R.id.widget_refresh_indicator, View.GONE) applyThemeOverride(context, views) @@ -310,19 +452,9 @@ open class MeteogramWidgetProvider : AppWidgetProvider() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) views.setOnClickPendingIntent(R.id.widget_root, pendingIntent) - - // 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 + views } + if (rendered) return } Log.d(logTag, "Native generation failed or no weather data, triggering weather fetch") @@ -353,8 +485,6 @@ open class MeteogramWidgetProvider : AppWidgetProvider() { val weatherData = WeatherDataParser.parseFromPrefs(context) for (appWidgetId in appWidgetIds) { - val views = RemoteViews(context.packageName, layoutRes) - val options = appWidgetManager.getAppWidgetOptions(appWidgetId) val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) val maxHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) @@ -385,78 +515,11 @@ open class MeteogramWidgetProvider : AppWidgetProvider() { } } - val intent = Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - setPackage(context.packageName) - } - val pendingIntent = PendingIntent.getActivity( - context, 0, intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - views.setOnClickPendingIntent(R.id.widget_root, pendingIntent) - - var hasChart = false - - if (weatherData != null && widthPx > 0 && heightPx > 0) { - val lightColors = WidgetChartColors.get(context, isLight = true) - val lightBitmap = generateChartBitmap(context, weatherData, lightColors, widthPx, heightPx) - if (lightBitmap != null) { - views.setImageViewBitmap(R.id.widget_chart_light, lightBitmap) - hasChart = true - Log.d(logTag, "Widget $appWidgetId native light chart generated") - } - - val darkColors = WidgetChartColors.get(context, isLight = false) - val darkBitmap = generateChartBitmap(context, weatherData, darkColors, widthPx, heightPx) - if (darkBitmap != null) { - views.setImageViewBitmap(R.id.widget_chart_dark, darkBitmap) - hasChart = true - Log.d(logTag, "Widget $appWidgetId native dark chart generated") - } - } - - if (!hasChart) { - val svgLightPath = widgetData.getString("svg_path_light_$appWidgetId", null) - ?: widgetData.getString("svg_path_light", null) - val svgDarkPath = widgetData.getString("svg_path_dark_$appWidgetId", null) - ?: widgetData.getString("svg_path_dark", null) - Log.d(logTag, "Widget $appWidgetId falling back to SVG files - light: $svgLightPath, dark: $svgDarkPath") - - val lightBitmap = loadChartBitmap(svgLightPath, null, widthPx, heightPx) - if (lightBitmap != null) { - views.setImageViewBitmap(R.id.widget_chart_light, lightBitmap) - hasChart = true - Log.d(logTag, "Widget $appWidgetId light chart loaded from file") - } - - val darkBitmap = loadChartBitmap(svgDarkPath, null, widthPx, heightPx) - if (darkBitmap != null) { - views.setImageViewBitmap(R.id.widget_chart_dark, darkBitmap) - hasChart = true - Log.d(logTag, "Widget $appWidgetId dark chart loaded from file") - } - } - - if (!hasChart) { - views.setViewVisibility(R.id.widget_placeholder, View.VISIBLE) - Log.d(logTag, "Widget $appWidgetId showing placeholder - no weather data") - } else { - views.setViewVisibility(R.id.widget_placeholder, View.GONE) - } - - views.setViewVisibility(R.id.widget_refresh_indicator, View.GONE) - if (hasChart) { - applyThemeOverride(context, views) - } - - // 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) + // Clamping should keep the two chart bitmaps under the launcher's + // budget; if a host rejects the update anyway, updateWidgetWithRetry + // shrinks and retries so the widget never freezes permanently. + updateWidgetWithRetry(appWidgetManager, appWidgetId, widthPx, heightPx) { w, h -> + buildWidgetViews(context, appWidgetId, widgetData, weatherData, w, h) } } From 964fec47a1f73af64bd51d2f4252fb0287c8282e Mon Sep 17 00:00:00 2001 From: Tymofiy Bortnyk Date: Mon, 29 Jun 2026 12:25:35 +0300 Subject: [PATCH 2/2] refactor(widget): unify resize path through buildWidgetViews (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review: - Resize callback no longer re-implements the click intent + native chart generation + theme override; it now calls buildWidgetViews(..., nativeOnly = true). The nativeOnly flag skips the saved-SVG-file fallback and placeholder and returns null when no fresh chart is produced, preserving the resize path's "render now or fetch" behaviour while removing the duplicate builder (no more drift risk between the resize and update paths). - Reword buildWidgetViews doc: it reads prefs, logs, and rasterizes, so it isn't "pure" — the relevant contract is deterministic-for-inputs / safe to re-invoke at a smaller size. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../meteogram/MeteogramWidgetProvider.kt | 56 +++++++------------ 1 file changed, 20 insertions(+), 36 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 3235902..95a94af 100644 --- a/android/app/src/main/kotlin/org/bortnik/meteogram/MeteogramWidgetProvider.kt +++ b/android/app/src/main/kotlin/org/bortnik/meteogram/MeteogramWidgetProvider.kt @@ -306,8 +306,13 @@ open class MeteogramWidgetProvider : AppWidgetProvider() { * Build the complete RemoteViews for one widget at the given pixel size: * click intent, native light/dark charts (falling back to saved SVG files, * then a "tap to load" placeholder), refresh indicator, and theme override. - * A pure function of its inputs, so [updateWidgetWithRetry] can rebuild it at - * a smaller size when the launcher rejects an over-budget update. + * Deterministic for the given inputs and safe to re-invoke at a smaller size, + * so [updateWidgetWithRetry] can rebuild it when the launcher rejects an + * over-budget update. + * + * @param nativeOnly when true (the resize path), skip the saved-SVG-file + * fallback and the placeholder and return null if no native chart could be + * generated, so the caller can trigger a weather fetch instead. */ private fun buildWidgetViews( context: Context, @@ -315,8 +320,9 @@ open class MeteogramWidgetProvider : AppWidgetProvider() { prefs: SharedPreferences, weatherData: WeatherData?, widthPx: Int, - heightPx: Int - ): RemoteViews { + heightPx: Int, + nativeOnly: Boolean = false + ): RemoteViews? { val views = RemoteViews(context.packageName, layoutRes) val intent = Intent(context, MainActivity::class.java).apply { @@ -349,7 +355,11 @@ open class MeteogramWidgetProvider : AppWidgetProvider() { } } - if (!hasChart) { + // Resize wants a freshly generated chart only: skip the stale-file + // fallback/placeholder and signal the caller to fetch instead. + if (nativeOnly) { + if (!hasChart) return null + } else if (!hasChart) { val svgLightPath = prefs.getString("svg_path_light_$appWidgetId", null) ?: prefs.getString("svg_path_light", null) val svgDarkPath = prefs.getString("svg_path_dark_$appWidgetId", null) @@ -421,38 +431,12 @@ open class MeteogramWidgetProvider : AppWidgetProvider() { // Clamping should keep us under the launcher's bitmap budget; if a // host rejects the update anyway, updateWidgetWithRetry shrinks and // retries rather than letting the throw freeze the widget forever. + // nativeOnly: on resize we want only a freshly generated chart (no + // stale-file/placeholder fallback), so a null result falls through to + // the weather fetch below. updateWidgetWithRetry(appWidgetManager, appWidgetId, widthPx, heightPx) { w, h -> - val views = RemoteViews(context.packageName, layoutRes) - - val lightColors = WidgetChartColors.get(context, isLight = true) - val lightBitmap = generateChartBitmap(context, weatherData, lightColors, w, h) - if (lightBitmap != null) { - views.setImageViewBitmap(R.id.widget_chart_light, lightBitmap) - } - - val darkColors = WidgetChartColors.get(context, isLight = false) - val darkBitmap = generateChartBitmap(context, weatherData, darkColors, w, h) - if (darkBitmap != null) { - views.setImageViewBitmap(R.id.widget_chart_dark, darkBitmap) - } - - if (lightBitmap == null && darkBitmap == null) return@updateWidgetWithRetry null - - rendered = true - views.setViewVisibility(R.id.widget_placeholder, View.GONE) - views.setViewVisibility(R.id.widget_refresh_indicator, View.GONE) - applyThemeOverride(context, views) - - val intent = Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - setPackage(context.packageName) - } - val pendingIntent = PendingIntent.getActivity( - context, 0, intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - views.setOnClickPendingIntent(R.id.widget_root, pendingIntent) - views + buildWidgetViews(context, appWidgetId, prefs, weatherData, w, h, nativeOnly = true) + ?.also { rendered = true } } if (rendered) return }