Skip to content
Merged
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 @@ -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) {
Expand Down Expand Up @@ -249,6 +255,147 @@ 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.
* 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,
appWidgetId: Int,
prefs: SharedPreferences,
weatherData: WeatherData?,
widthPx: Int,
heightPx: Int,
nativeOnly: Boolean = false
): 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")
}
}

// 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)
?: 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,
Expand Down Expand Up @@ -280,49 +427,18 @@ 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)

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 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")
}

if (lightBitmap != null || darkBitmap != null) {
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)

// 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
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.
// 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 ->
buildWidgetViews(context, appWidgetId, prefs, weatherData, w, h, nativeOnly = true)
?.also { rendered = true }
}
if (rendered) return
}

Log.d(logTag, "Native generation failed or no weather data, triggering weather fetch")
Expand Down Expand Up @@ -353,8 +469,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)
Expand Down Expand Up @@ -385,78 +499,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)
}
}

Expand Down
Loading