From 4e4bb2d45a7d8699fb90b489b576d80677b03fa6 Mon Sep 17 00:00:00 2001 From: Tymofiy Bortnyk Date: Tue, 30 Jun 2026 21:51:33 +0300 Subject: [PATCH 1/2] fix(chart): render in-app meteogram as image, not a PlatformView The in-app chart was an AndroidView PlatformView. On Flutter 3.4x that composites via Texture Layer Hybrid Composition: the native ImageView's surface is rendered into an ImageReader and handed to Impeller as an external texture. On the Vulkan backend (e.g. Adreno / Android 12), the raster thread's Image.getHardwareBuffer() JNI call can throw on a closed/expired image, and the engine turns any pending JNI exception into a fatal abort (platform_view_android_jni_impl.cc CheckException) -- flutter/flutter#175267. Crash seen on v1.2.3 (Flutter 3.44.1); still latent on main (3.44.4), as the engine fix does not cover this case. The chart is a static, non-interactive bitmap, so the PlatformView is overkill. Rasterize the SVG to PNG natively (reuse the already-present MainActivity.renderSvgToPng, previously dead code) and display it with a plain Flutter Image.memory. This removes the only TextureLayer in the app, eliminating the Impeller external-texture path entirely. Rendering is unchanged (same AndroidSVG rasterizer as the widget). - Drop NativeSvgChartView, SvgChartPlatformView, SvgChartViewFactory and the registerViewFactory call. - Add NativeSvgService.renderSvgToPng; cache PNG bytes (stable instance so MemoryImage hits the image cache; gaplessPlayback avoids flicker). - Move the renderSvg handler off the platform thread. - Real Flutter Semantics now reach the charts: they carry resource-ids (home_hourly_chart / home_weekly_chart) and a localized content-desc, replacing the native content-desc hack. e2e/docs updated to match. Verified: make analyze clean; 123 Dart tests; Kotlin tests + debug build (JDK 17); runs on emulator with both charts rendering and the a11y ids + labels surfacing. Field confirmation needs a Vulkan device (x86_64 emulator cannot reproduce it). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../org/bortnik/meteogram/MainActivity.kt | 23 ++- .../bortnik/meteogram/SvgChartPlatformView.kt | 149 ------------------ .../bortnik/meteogram/SvgChartViewFactory.kt | 18 --- e2e/README.md | 7 +- e2e/a11y_ids.js | 6 +- e2e/specs/home_happy_path.e2e.js | 4 +- lib/a11y_ids.dart | 11 +- lib/screens/home_screen.dart | 84 ++++++---- lib/services/native_svg_service.dart | 27 ++++ lib/widgets/native_svg_chart_view.dart | 91 ----------- test/home_screen_test.dart | 15 ++ test/native_svg_service_test.dart | 57 +++++++ 12 files changed, 178 insertions(+), 314 deletions(-) delete mode 100644 android/app/src/main/kotlin/org/bortnik/meteogram/SvgChartPlatformView.kt delete mode 100644 android/app/src/main/kotlin/org/bortnik/meteogram/SvgChartViewFactory.kt delete mode 100644 lib/widgets/native_svg_chart_view.dart diff --git a/android/app/src/main/kotlin/org/bortnik/meteogram/MainActivity.kt b/android/app/src/main/kotlin/org/bortnik/meteogram/MainActivity.kt index 238f140..9ffeaca 100644 --- a/android/app/src/main/kotlin/org/bortnik/meteogram/MainActivity.kt +++ b/android/app/src/main/kotlin/org/bortnik/meteogram/MainActivity.kt @@ -34,12 +34,6 @@ class MainActivity : FlutterActivity() { super.configureFlutterEngine(flutterEngine) - // Register PlatformView for native SVG chart rendering - flutterEngine.platformViewsController.registry.registerViewFactory( - "svg_chart_view", - SvgChartViewFactory(flutterEngine.dartExecutor.binaryMessenger) - ) - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> when (call.method) { "generateSvg" -> { @@ -76,12 +70,17 @@ class MainActivity : FlutterActivity() { return@setMethodCallHandler } - try { - val pngBytes = renderSvgToPng(svgString, width, height) - result.success(pngBytes) - } catch (e: Exception) { - result.error("RENDER_ERROR", e.message, null) - } + // Rasterize off the platform thread — PNG encode of a + // full-resolution chart bitmap is too heavy for the UI thread. + Thread { + try { + val pngBytes = renderSvgToPng(svgString, width, height) + runOnUiThread { result.success(pngBytes) } + } catch (e: Exception) { + Log.e(TAG, "Error rasterizing SVG", e) + runOnUiThread { result.error("RENDER_ERROR", e.message, null) } + } + }.start() } "fetchWeather" -> { val latitude = call.argument("latitude") diff --git a/android/app/src/main/kotlin/org/bortnik/meteogram/SvgChartPlatformView.kt b/android/app/src/main/kotlin/org/bortnik/meteogram/SvgChartPlatformView.kt deleted file mode 100644 index f8ee5ae..0000000 --- a/android/app/src/main/kotlin/org/bortnik/meteogram/SvgChartPlatformView.kt +++ /dev/null @@ -1,149 +0,0 @@ -package org.bortnik.meteogram - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.Canvas -import android.util.Log -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import com.caverock.androidsvg.SVG -import io.flutter.plugin.common.BinaryMessenger -import io.flutter.plugin.common.MethodChannel -import io.flutter.plugin.platform.PlatformView -import java.io.ByteArrayInputStream -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors -import java.util.concurrent.atomic.AtomicBoolean - -/** - * Platform view for rendering SVG charts. - * SVG rendering is performed on a background thread to avoid blocking the UI. - */ -class SvgChartPlatformView( - context: Context, - private val viewId: Int, - messenger: BinaryMessenger, - creationParams: Map? -) : PlatformView { - - companion object { - private const val TAG = "SvgChartView" - // Shared executor for all instances to limit thread creation - private val renderExecutor: ExecutorService = Executors.newSingleThreadExecutor() - } - - private var pendingSvg: String? = null - private var lastRenderedSvg: String? = null - private var currentBitmap: Bitmap? = null - private val isRendering = AtomicBoolean(false) - - private val imageView = ImageView(context).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - scaleType = ImageView.ScaleType.FIT_XY - } - - private val methodChannel = MethodChannel( - messenger, - "org.bortnik.svg_chart_view_$viewId" - ) - - init { - methodChannel.setMethodCallHandler { call, result -> - when (call.method) { - "renderSvg" -> { - val svg = call.argument("svg") - if (svg != null) { - pendingSvg = svg - renderAtViewSize() - result.success(null) - } else { - result.error("INVALID_ARGS", "SVG string required", null) - } - } - else -> result.notImplemented() - } - } - - // Store SVG for rendering once view is laid out - creationParams?.let { - pendingSvg = it["svg"] as? String - // Accessibility label for the chart image. A Flutter Semantics - // wrapper cannot reach this hybrid-composition PlatformView, so the - // label is passed natively and set as content-desc (read by TalkBack - // and UiAutomator2). - (it["a11yLabel"] as? String)?.let { label -> - imageView.contentDescription = label - } - } - - // Render when view is laid out and we know actual size - imageView.viewTreeObserver.addOnGlobalLayoutListener { - renderAtViewSize() - } - } - - private fun renderAtViewSize() { - val svg = pendingSvg ?: return - val width = imageView.width - val height = imageView.height - - if (width <= 0 || height <= 0) return - - // Skip if already rendering - will re-check after current render completes - if (!isRendering.compareAndSet(false, true)) { - Log.d(TAG, "Render in progress - will check for updates after completion") - return - } - - // Track what we're rendering to detect changes during render - val svgToRender = svg - Log.d(TAG, "Rendering at actual view size: ${width}x${height}px") - - // Render on background thread to avoid blocking UI - renderExecutor.execute { - var bitmap: Bitmap? = null - try { - val parsedSvg = SVG.getFromInputStream(ByteArrayInputStream(svgToRender.toByteArray())) - parsedSvg.documentWidth = width.toFloat() - parsedSvg.documentHeight = height.toFloat() - - bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bitmap) - parsedSvg.renderToCanvas(canvas) - - // Post result to main thread - val finalBitmap = bitmap - imageView.post { - // Recycle old bitmap on main thread (safer) - currentBitmap?.recycle() - currentBitmap = finalBitmap - imageView.setImageBitmap(finalBitmap) - lastRenderedSvg = svgToRender - isRendering.set(false) - - // Check if SVG changed while we were rendering - if so, re-render - if (pendingSvg != null && pendingSvg != lastRenderedSvg) { - Log.d(TAG, "SVG changed during render - re-rendering") - renderAtViewSize() - } - } - } catch (e: Exception) { - Log.e(TAG, "Error rendering SVG", e) - bitmap?.recycle() - isRendering.set(false) - } - } - } - - override fun getView(): View = imageView - - override fun dispose() { - methodChannel.setMethodCallHandler(null) - currentBitmap?.recycle() - currentBitmap = null - } -} diff --git a/android/app/src/main/kotlin/org/bortnik/meteogram/SvgChartViewFactory.kt b/android/app/src/main/kotlin/org/bortnik/meteogram/SvgChartViewFactory.kt deleted file mode 100644 index 4cb24fe..0000000 --- a/android/app/src/main/kotlin/org/bortnik/meteogram/SvgChartViewFactory.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.bortnik.meteogram - -import android.content.Context -import io.flutter.plugin.common.BinaryMessenger -import io.flutter.plugin.common.StandardMessageCodec -import io.flutter.plugin.platform.PlatformView -import io.flutter.plugin.platform.PlatformViewFactory - -class SvgChartViewFactory( - private val messenger: BinaryMessenger -) : PlatformViewFactory(StandardMessageCodec.INSTANCE) { - - override fun create(context: Context, viewId: Int, args: Any?): PlatformView { - @Suppress("UNCHECKED_CAST") - val creationParams = args as? Map - return SvgChartPlatformView(context, viewId, messenger, creationParams) - } -} diff --git a/e2e/README.md b/e2e/README.md index c183a01..5b6b6b3 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -39,9 +39,10 @@ Test a different build with `APP_PATH=/abs/path/to.apk npm test`. - **Driver pin:** `uiautomator2@4.2.9` is the last driver compatible with Appium 2.x (5.x+ require Appium 3). Bump both together. -- **Charts** are hybrid-composition PlatformViews with no `resource-id`; they - expose a native `content-desc` (set in `SvgChartPlatformView.kt`) and are - located by accessibility-id. +- **Charts** are plain Flutter `Image` widgets (PNG rasterized natively), so a + normal `Semantics` reaches them: they carry both a `resource-id` + (`homeHourlyChart` / `homeWeeklyChart`) and a localized `content-desc` label + (`descriptionContains("48-hour")` / `"7-day"`). Locate by either. - Flutter text surfaces as `content-desc`, not the `text` attribute — locate by `resourceId` or `description*`, never `.text()`. - CI: `.github/workflows/e2e.yml` (PR + manual) builds the x86_64 APK then runs diff --git a/e2e/a11y_ids.js b/e2e/a11y_ids.js index 70b5b95..4016e34 100644 --- a/e2e/a11y_ids.js +++ b/e2e/a11y_ids.js @@ -9,8 +9,10 @@ module.exports = { homeLocationSelector: 'home_location_selector', homeOpenMeteoLink: 'home_open_meteo_link', homeGithubLink: 'home_github_link', - // Charts are hybrid-composition PlatformViews with no resource-id; they carry - // a native content-desc instead (locate via accessibility-id if needed). + // Charts are plain Flutter Image widgets, so a normal Semantics reaches them: + // they carry both a resource-id (below) and a localized content-desc label. + homeHourlyChart: 'home_hourly_chart', + homeWeeklyChart: 'home_weekly_chart', // Location picker sheet locationSearchField: 'location_search_field', diff --git a/e2e/specs/home_happy_path.e2e.js b/e2e/specs/home_happy_path.e2e.js index 29c0b35..fd624a1 100644 --- a/e2e/specs/home_happy_path.e2e.js +++ b/e2e/specs/home_happy_path.e2e.js @@ -17,8 +17,8 @@ describe('Meteograph — home happy path', () => { await byId(ids.homeThemeButton).waitForDisplayed({ timeout: READY }); await expect(byId(ids.homeLocationSelector)).toBeDisplayed(); - // Both meteogram charts render (located by their native content-desc, since - // a PlatformView has no resource-id). + // Both meteogram charts render (located by their content-desc label; they + // also carry homeHourlyChart / homeWeeklyChart resource-ids). const hourly = await $$('android=new UiSelector().descriptionContains("48-hour")'); const weekly = await $$('android=new UiSelector().descriptionContains("7-day")'); expect(hourly.length).toBeGreaterThanOrEqual(1); diff --git a/lib/a11y_ids.dart b/lib/a11y_ids.dart index 73b251e..03ace55 100644 --- a/lib/a11y_ids.dart +++ b/lib/a11y_ids.dart @@ -19,11 +19,12 @@ class A11yIds { static const String homeLocationSelector = 'home_location_selector'; static const String homeOpenMeteoLink = 'home_open_meteo_link'; static const String homeGithubLink = 'home_github_link'; - // NOTE: the hourly/weekly charts are hybrid-composition PlatformViews — a - // Flutter Semantics(identifier:) does NOT reach them, so they carry no - // resource-id. Their accessibility label is set natively as the ImageView's - // content-desc (see NativeSvgChartView.a11yLabel / SvgChartPlatformView.kt); - // locate them by accessibility-id if needed. + // The hourly/weekly charts are plain Flutter `Image` widgets (PNG rasterized + // natively), so a normal `Semantics` reaches them: they carry both a + // resource-id (below) and a localized content-desc label. Tests can locate + // them by either. + static const String homeHourlyChart = 'home_hourly_chart'; + static const String homeWeeklyChart = 'home_weekly_chart'; // Location picker sheet static const String locationSearchField = 'location_search_field'; diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 4030e2c..d2b6bf4 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data' show Uint8List; import 'dart:ui' show PlatformDispatcher; import 'package:flutter/material.dart'; import '../a11y_ids.dart'; @@ -11,7 +12,6 @@ import '../services/units_service.dart'; import '../services/material_you_service.dart'; import '../services/widget_store.dart'; import '../theme/app_theme.dart'; -import '../widgets/native_svg_chart_view.dart'; import '../generated/version.dart'; /// Wraps [child] so black-box UI tests (Appium + UiAutomator2) can locate it by @@ -87,7 +87,7 @@ class _HomeScreenState extends State with WidgetsBindingObserver { void _invalidateChartCaches() { for (final entry in _chartCache.values) { - entry.svg = null; + entry.png = null; } } @@ -421,7 +421,9 @@ class _HomeScreenState extends State with WidgetsBindingObserver { return isDark ? widget.materialYouColors!.dark : widget.materialYouColors!.light; } - /// Generate SVG asynchronously using native Kotlin generator. + /// Generate the chart asynchronously using the native Kotlin generator, then + /// rasterize it to PNG natively for display via a plain Flutter [Image] + /// (no PlatformView — see [NativeSvgService.renderSvgToPng]). /// Updates the cache entry for [mode] and triggers rebuild when complete. /// For hourly mode, also syncs current_temperature_celsius with nowIndex. Future _generateSvgAsync({ @@ -439,25 +441,31 @@ class _HomeScreenState extends State with WidgetsBindingObserver { isLight: isLight, usesFahrenheit: usesFahrenheit, ); + if (svgString == null || !mounted) return; - if (svgString != null && mounted) { - final updatedTemp = mode == NativeSvgService.chartModeHourly - ? await NativeSvgService.getCurrentTemperatureCelsius() - : null; + final png = await NativeSvgService.renderSvgToPng( + svg: svgString, + width: width, + height: height, + ); + if (png == null || !mounted) return; - setState(() { - final cache = _chartCache[mode]!; - cache.svg = svgString; - cache.width = width; - cache.height = height; - cache.isLight = isLight; - if (updatedTemp != null) { - _currentTemperatureCelsius = updatedTemp; - } - }); - } + final updatedTemp = mode == NativeSvgService.chartModeHourly + ? await NativeSvgService.getCurrentTemperatureCelsius() + : null; + + setState(() { + final cache = _chartCache[mode]!; + cache.png = png; + cache.width = width; + cache.height = height; + cache.isLight = isLight; + if (updatedTemp != null) { + _currentTemperatureCelsius = updatedTemp; + } + }); } catch (e) { - debugPrint('Error generating SVG ($mode): $e'); + debugPrint('Error generating chart ($mode): $e'); } } @@ -830,7 +838,7 @@ class _HomeScreenState extends State with WidgetsBindingObserver { final deviceHeightPx = (chartHeight * dpr).round(); final cache = _chartCache[mode]!; - final needsRegeneration = cache.svg == null || + final needsRegeneration = cache.png == null || cache.width != deviceWidthPx || cache.height != deviceHeightPx || cache.isLight != isLight; @@ -850,20 +858,29 @@ class _HomeScreenState extends State with WidgetsBindingObserver { final chartLabel = isHourly ? l10n.hourlyChartLabel : l10n.weeklyChartLabel; - if (cache.svg != null) { - // The chart is a hybrid-composition PlatformView: a Flutter Semantics - // wrapper does NOT reach the embedded native view (verified — the id - // never surfaces to UiAutomator2). The accessibility label is set on - // the native ImageView via a11yLabel instead (SvgChartPlatformView.kt), - // surfacing as content-desc for TalkBack and Appium. + if (cache.png != null) { + // Plain Flutter Image over natively-rasterized PNG bytes — NOT a + // PlatformView, so it stays out of Impeller's external-texture path + // (which crashes on some Vulkan devices). A real Semantics node now + // reaches the widget, carrying both a resource-id and a content-desc + // label for TalkBack and Appium. `gaplessPlayback` keeps the current + // frame on screen while a new one (theme/resize) decodes. return SizedBox( width: chartWidth, height: chartHeight, - child: NativeSvgChartView( - svgString: cache.svg!, - width: deviceWidthPx.toDouble(), - height: deviceHeightPx.toDouble(), - a11yLabel: chartLabel, + child: Semantics( + identifier: isHourly + ? A11yIds.homeHourlyChart + : A11yIds.homeWeeklyChart, + label: chartLabel, + image: true, + child: Image.memory( + cache.png!, + width: chartWidth, + height: chartHeight, + fit: BoxFit.fill, + gaplessPlayback: true, + ), ), ); } @@ -1301,7 +1318,10 @@ class _LocationPickerSheetState extends State<_LocationPickerSheet> { /// Per-mode cache of the last-rendered SVG plus the params used to generate it. class _ChartCacheEntry { - String? svg; + /// Rasterized PNG bytes for the chart, displayed via [Image.memory]. + /// The same instance is reused across rebuilds so [MemoryImage] equality + /// hits Flutter's image cache and the bitmap is not re-decoded. + Uint8List? png; int? width; int? height; bool? isLight; diff --git a/lib/services/native_svg_service.dart b/lib/services/native_svg_service.dart index 6ea2e27..dffe654 100644 --- a/lib/services/native_svg_service.dart +++ b/lib/services/native_svg_service.dart @@ -171,6 +171,33 @@ class NativeSvgService { } } + /// Rasterize an SVG string to PNG bytes natively (AndroidSVG → Bitmap → PNG). + /// + /// The in-app chart is displayed with a plain Flutter [Image] over these + /// bytes rather than a native PlatformView. This keeps the meteogram out of + /// Impeller's external-texture path, whose `Image.getHardwareBuffer()` JNI + /// call fatally aborts on some Vulkan devices (e.g. Adreno / Android 12 — + /// flutter/flutter#175267). Rasterization is identical to the home-screen + /// widget's (same AndroidSVG renderer in `MainActivity.renderSvgToPng`). + /// + /// Returns null if rasterization fails. + static Future renderSvgToPng({ + required String svg, + required int width, + required int height, + }) async { + try { + return await _channel.invokeMethod('renderSvg', { + 'svg': svg, + 'width': width, + 'height': height, + }); + } on PlatformException catch (e) { + debugPrint('Native SVG rasterization failed: ${e.message}'); + return null; + } + } + /// Generate both light and dark SVG strings. /// Returns a record with light and dark SVGs, or nulls if generation fails. static Future<({String? light, String? dark})> generateSvgPair({ diff --git a/lib/widgets/native_svg_chart_view.dart b/lib/widgets/native_svg_chart_view.dart deleted file mode 100644 index d1f11d1..0000000 --- a/lib/widgets/native_svg_chart_view.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; - -/// Displays an SVG chart using native Android rendering via PlatformView. -/// This bypasses Flutter's image compositor for 1:1 pixel mapping. -class NativeSvgChartView extends StatefulWidget { - final String svgString; - final double width; - final double height; - - /// Accessibility label set on the native ImageView's `contentDescription` - /// (surfaces to TalkBack / UiAutomator2 as `content-desc`). A Flutter - /// `Semantics` wrapper cannot reach this hybrid-composition PlatformView, so - /// the label must travel natively. See SvgChartPlatformView.kt. - final String? a11yLabel; - - const NativeSvgChartView({ - super.key, - required this.svgString, - required this.width, - required this.height, - this.a11yLabel, - }); - - @override - State createState() => _NativeSvgChartViewState(); -} - -class _NativeSvgChartViewState extends State { - MethodChannel? _channel; - int? _viewId; - String? _lastRenderedSvg; - - @override - Widget build(BuildContext context) { - // Only supported on Android - if (defaultTargetPlatform != TargetPlatform.android) { - return const SizedBox.shrink(); - } - - return AndroidView( - viewType: 'svg_chart_view', - creationParams: { - 'svg': widget.svgString, - 'width': widget.width.round(), - 'height': widget.height.round(), - 'a11yLabel': widget.a11yLabel, - }, - creationParamsCodec: const StandardMessageCodec(), - onPlatformViewCreated: _onPlatformViewCreated, - gestureRecognizers: const >{}, - ); - } - - void _onPlatformViewCreated(int viewId) { - _viewId = viewId; - _channel = MethodChannel('org.bortnik.svg_chart_view_$viewId'); - - // If SVG changed while view was being created, render the latest version - if (_lastRenderedSvg != null && _lastRenderedSvg != widget.svgString) { - _channel!.invokeMethod('renderSvg', { - 'svg': widget.svgString, - 'width': widget.width.round(), - 'height': widget.height.round(), - }); - } - _lastRenderedSvg = widget.svgString; - } - - @override - void didUpdateWidget(NativeSvgChartView oldWidget) { - super.didUpdateWidget(oldWidget); - - // Re-render if SVG or dimensions changed - if (oldWidget.svgString != widget.svgString || - oldWidget.width != widget.width || - oldWidget.height != widget.height) { - _lastRenderedSvg = widget.svgString; - if (_viewId != null && _channel != null) { - _channel!.invokeMethod('renderSvg', { - 'svg': widget.svgString, - 'width': widget.width.round(), - 'height': widget.height.round(), - }); - } - // If view not ready yet, it will render the latest SVG when created - } - } -} diff --git a/test/home_screen_test.dart b/test/home_screen_test.dart index 4a42d09..3f1b6d5 100644 --- a/test/home_screen_test.dart +++ b/test/home_screen_test.dart @@ -7,6 +7,17 @@ import 'package:meteogram_widget/screens/home_screen.dart'; import 'package:meteogram_widget/services/material_you_service.dart'; import 'package:meteogram_widget/theme/app_theme.dart'; +/// A 1x1 transparent PNG — what the native `renderSvg` rasterizer returns, +/// minimal but valid so `Image.memory` decodes it without error. +final kTransparentPixelPng = Uint8List.fromList([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, + 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, + 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, +]); + /// Widget tests for HomeScreen. /// /// These tests verify UI rendering for different states: @@ -62,6 +73,10 @@ void main() { case 'generateSvg': // Return minimal valid SVG return ''; + case 'renderSvg': + // Native rasterizer returns PNG bytes; a 1x1 transparent PNG is + // enough for Image.memory to decode without error. + return kTransparentPixelPng; case 'reverseGeocode': // Native Geocoder lookup (coords -> city name) return mockCityName; diff --git a/test/native_svg_service_test.dart b/test/native_svg_service_test.dart index 49e1ff2..b6e4bdc 100644 --- a/test/native_svg_service_test.dart +++ b/test/native_svg_service_test.dart @@ -175,5 +175,62 @@ void main() { expect(capturedUsesFahrenheit, [true, true]); }); }); + + group('renderSvgToPng', () { + test('calls renderSvg with the svg and dimensions', () async { + String? capturedMethod; + Map? capturedArgs; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + capturedMethod = methodCall.method; + capturedArgs = methodCall.arguments as Map; + return Uint8List.fromList([1, 2, 3]); + }); + + await NativeSvgService.renderSvgToPng( + svg: 'chart', + width: 1000, + height: 500, + ); + + expect(capturedMethod, 'renderSvg'); + expect(capturedArgs?['svg'], 'chart'); + expect(capturedArgs?['width'], 1000); + expect(capturedArgs?['height'], 500); + }); + + test('returns PNG bytes on success', () async { + final png = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47]); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return png; + }); + + final result = await NativeSvgService.renderSvgToPng( + svg: '', + width: 100, + height: 50, + ); + + expect(result, png); + }); + + test('returns null on PlatformException', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + throw PlatformException(code: 'RENDER_ERROR', message: 'boom'); + }); + + final result = await NativeSvgService.renderSvgToPng( + svg: '', + width: 100, + height: 50, + ); + + expect(result, isNull); + }); + }); }); } From 1ab0fcb4f70c795775fbcea0a2274d13252c8b71 Mon Sep 17 00:00:00 2001 From: Tymofiy Bortnyk Date: Tue, 30 Jun 2026 22:15:30 +0300 Subject: [PATCH 2/2] fix(chart): keep last frame on refresh; guard async render; sync docs Address PR review on the render-to-image change: - Gapless refresh: _invalidateChartCaches now marks entries stale instead of nulling the PNG, so the current chart stays on screen until its replacement is rasterized. Previously it fell back to a blank SizedBox on weather/half-hour/theme refreshes, defeating gaplessPlayback. - Async safety: _generateSvgAsync is single-flight (generating guard), claims the dirty flag up front so mid-render updates re-kick, and re-checks mounted after the final await before setState. A slow older render can no longer overwrite a newer one or setState after dispose. - e2e: home happy-path locates charts by the new resource-ids (homeHourlyChart / homeWeeklyChart) instead of localized content-desc. - test: assert both charts render as Images with their semantics ids; a regression to the placeholder SizedBox would now fail. - docs: update CLAUDE.md + docs/ai/{architecture,widget}.md and drop the stale ProGuard keep-rules for the deleted PlatformView classes. make analyze clean; 124 Dart tests; Kotlin tests + debug build (JDK 17). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 12 +++------ android/app/proguard-rules.pro | 4 --- docs/ai/architecture.md | 43 ++++++++++++++++---------------- docs/ai/widget.md | 21 +++++++++++----- e2e/specs/home_happy_path.e2e.js | 10 +++----- lib/screens/home_screen.dart | 36 +++++++++++++++++++++----- test/home_screen_test.dart | 23 +++++++++++++++++ 7 files changed, 98 insertions(+), 51 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a5f880e..74440b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,13 +51,11 @@ lib/ │ ├── location_service.dart # Native location (LocationBridge) with fallback │ ├── widget_service.dart # Triggers native widget refresh + resize flag │ ├── widget_store.dart # Method-channel KV bridge to HomeWidgetPreferences (replaces home_widget) -│ └── native_svg_service.dart # Method channel to native (weather fetch, SVG gen, cache) +│ └── native_svg_service.dart # Method channel to native (weather fetch, SVG gen + rasterize-to-PNG, cache) ├── theme/ │ └── app_theme.dart # MeteogramColors, WeatherGradients -├── widgets/ -│ └── native_svg_chart_view.dart # Native SVG PlatformView └── screens/ - └── home_screen.dart # Main UI with SVG chart + └── home_screen.dart # Main UI; chart via Image.memory over native PNG android/app/src/main/ ├── kotlin/.../ @@ -73,9 +71,7 @@ android/app/src/main/ │ ├── WeatherFetcher.kt # Native HTTP client for Open-Meteo API │ ├── WeatherDataParser.kt # Parse cached weather JSON │ ├── SvgChartGenerator.kt # Native SVG generation (single source) -│ ├── MaterialYouColorExtractor.kt # Native Material You color extraction -│ ├── SvgChartPlatformView.kt # Native SVG rendering for in-app -│ └── SvgChartViewFactory.kt # PlatformView factory +│ └── MaterialYouColorExtractor.kt # Native Material You color extraction └── res/ ├── layout/meteogram_widget.xml # RemoteViews layout ├── xml/meteogram_widget_info.xml # Widget config @@ -131,7 +127,7 @@ Android widgets use RemoteViews which only support: **NOT supported:** View, Space, custom views, most Material widgets ### Data Flow -1. **In-app**: `home_screen.dart` gets location → calls `NativeSvgService.fetchWeather()` → Kotlin fetches from Open-Meteo → caches to SharedPreferences → Dart reads cache → Kotlin generates SVG → rendered via `NativeSvgChartView` +1. **In-app**: `home_screen.dart` gets location → calls `NativeSvgService.fetchWeather()` → Kotlin fetches from Open-Meteo → caches to SharedPreferences → Dart reads cache → Kotlin generates SVG → Kotlin rasterizes SVG to PNG (`renderSvgToPng`) → Dart displays bytes with `Image.memory` (no PlatformView — avoids the Impeller Vulkan external-texture crash) 2. **Widget**: Native code reads cached weather from SharedPreferences → `SvgChartGenerator.kt` generates SVG → AndroidSVG renders to bitmap → ImageView ### Background Refresh (fully native) diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 20dffa1..133123a 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -24,10 +24,6 @@ -keep class org.bortnik.meteogram.WeatherUpdateWorker { *; } -keep class org.bortnik.meteogram.MeteogramApplication { *; } -# Keep platform views --keep class org.bortnik.meteogram.SvgChartViewFactory { *; } --keep class org.bortnik.meteogram.SvgChartPlatformView { *; } - # Kotlin serialization (if used) -keepattributes *Annotation*, InnerClasses -dontnote kotlinx.serialization.AnnotationsKt diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md index 4ef247c..f45b30a 100644 --- a/docs/ai/architecture.md +++ b/docs/ai/architecture.md @@ -34,7 +34,7 @@ The app follows a standard Flutter architecture with clear separation of concern 2. Weather service fetches data from Open-Meteo 3. Data is parsed into `WeatherData` model 4. SVG chart generated via `SvgChartGenerator` -5. In-app: SVG rendered via PlatformView (AndroidView → native ImageView) +5. In-app: SVG rasterized natively to PNG (`MainActivity.renderSvgToPng`), displayed with a plain Flutter `Image.memory` (no PlatformView) 6. Widget: SVG saved to file, native provider renders via AndroidSVG ### Widget Updates @@ -93,11 +93,16 @@ Native Kotlin SVG generation - single source of truth for both widget and in-app **Key benefit:** Works in background without Dart/Flutter engine -### NativeSvgChartView (`lib/widgets/native_svg_chart_view.dart`) -PlatformView wrapper for in-app SVG display. Responsibilities: -- Embed native Android ImageView via AndroidView -- Pass SVG string to native side via MethodChannel -- Bypass Flutter's image compositor for 1:1 pixel rendering +### In-app chart display (`lib/screens/home_screen.dart`) +The chart is a plain Flutter `Image.memory`, NOT a PlatformView. Flow: +- `NativeSvgService.renderSvgToPng()` sends the generated SVG + target pixel + size to native; `MainActivity.renderSvgToPng` rasterizes it (AndroidSVG → + Bitmap → PNG) off the platform thread and returns the bytes. +- `_buildChart` caches the bytes and renders them with `Image.memory` + (`gaplessPlayback`, stable instance so the bitmap isn't re-decoded). +- This keeps the chart out of Impeller's external-texture path (the old + `AndroidView` PlatformView composited as a `TextureLayer`, which fatally + aborted on some Vulkan devices — see `widget.md`). ### WeatherFetcher (`android/.../WeatherFetcher.kt`) Native HTTP client for Open-Meteo API. Responsibilities: @@ -173,18 +178,16 @@ lib/ │ ├── app_ar.arb # Arabic │ └── ... # 30+ locales ├── screens/ -│ └── home_screen.dart # Main screen with NativeSvgChartView +│ └── home_screen.dart # Main screen; chart via Image.memory over native PNG ├── services/ │ ├── location_service.dart # GPS/fallback location │ ├── widget_service.dart # Home widget integration │ └── native_svg_service.dart # Method channel to native -├── theme/ -│ └── app_theme.dart # Colors, light/dark themes -└── widgets/ - └── native_svg_chart_view.dart # PlatformView SVG display +└── theme/ + └── app_theme.dart # Colors, light/dark themes android/app/src/main/kotlin/.../ -├── MainActivity.kt # PlatformView factory, Material You colors +├── MainActivity.kt # SVG generate + rasterize-to-PNG channel, Material You colors ├── MeteogramApplication.kt # Registers receivers, schedules alarm ├── MeteogramWidgetProvider.kt # Home screen widget provider ├── WidgetEventReceiver.kt # Handles locale/timezone changes @@ -196,9 +199,7 @@ android/app/src/main/kotlin/.../ ├── WeatherFetcher.kt # Native HTTP client for Open-Meteo ├── WeatherDataParser.kt # Parse cached weather JSON ├── SvgChartGenerator.kt # Native SVG generation -├── MaterialYouColorExtractor.kt # Native Material You color extraction -├── SvgChartViewFactory.kt # Creates PlatformView instances -└── SvgChartPlatformView.kt # Native ImageView + AndroidSVG rendering +└── MaterialYouColorExtractor.kt # Native Material You color extraction scripts/ └── generate_version.sh # Generates version.dart from git @@ -213,12 +214,12 @@ scripts/ - Background in `res/drawable/widget_background.xml` (gradient + rounded corners) - Chart: reads SVG file → AndroidSVG → Bitmap → ImageView -### Android In-App (PlatformView) -- `SvgChartViewFactory` registered in MainActivity -- `SvgChartPlatformView` embeds native ImageView -- Receives SVG string via MethodChannel -- Renders via AndroidSVG → Bitmap → ImageView -- Bypasses Flutter's image compositor for 1:1 pixel rendering +### Android In-App (render-to-image) +- `MainActivity` exposes a `renderSvg` method channel +- Receives SVG string + pixel size, rasterizes via AndroidSVG → Bitmap → PNG +- Returns PNG bytes to Dart, which displays them with `Image.memory` +- No PlatformView / `TextureLayer`, so it avoids Impeller's external-texture + crash path on Vulkan devices (the reason this replaced the old `AndroidView`) ### iOS Widget Not yet implemented. Would require: diff --git a/docs/ai/widget.md b/docs/ai/widget.md index 0bc1d46..b908d8f 100644 --- a/docs/ai/widget.md +++ b/docs/ai/widget.md @@ -178,12 +178,20 @@ await WidgetStore.updateWidget(androidName: 'MeteogramWidgetProvider'); `WidgetService.triggerWidgetUpdate()` refreshes **both** providers (`MeteogramWidgetProvider` and `MeteogramWeeklyWidgetProvider`). -### In-app chart display (`lib/widgets/native_svg_chart_view.dart`) +### In-app chart display (`lib/screens/home_screen.dart`) -A PlatformView (`AndroidView`, viewType `svg_chart_view`) embeds a native -Android view that renders the SVG via AndroidSVG — bypassing Flutter's -compositor for 1:1 pixel rendering. The factory is registered in -`MainActivity` (`SvgChartViewFactory` → `SvgChartPlatformView`). +The chart is a plain Flutter `Image.memory`, **not** a PlatformView. +`NativeSvgService.renderSvgToPng()` sends the generated SVG + target pixel size +to `MainActivity`'s `renderSvg` channel, which rasterizes it (AndroidSVG → +Bitmap → PNG) off the platform thread; `_buildChart` displays the returned bytes +with `gaplessPlayback`. + +This replaced an `AndroidView` PlatformView (viewType `svg_chart_view`). That +view was composited by Impeller as a `TextureLayer` / external texture, whose +`Image.getHardwareBuffer()` JNI call fatally aborts on some Vulkan devices +(Adreno / Android 12 — flutter/flutter#175267). Rendering bytes in pure Flutter +removes the only `TextureLayer` in the app and the entire crash path, with +identical AndroidSVG rasterization (same as the widget). ## Background Refresh (fully native) @@ -230,7 +238,8 @@ the app process is alive. `NativeSvgService.fetchWeather(lat, lon)` → Kotlin `WeatherFetcher` hits Open-Meteo and caches the JSON to SharedPreferences. 2. **In-app chart**: Dart calls `generateSvg` → Kotlin reads the cache and - returns an SVG string → `NativeSvgChartView` renders it. + returns an SVG string → Dart calls `renderSvg` → Kotlin rasterizes it to PNG + bytes → `home_screen.dart` shows them with `Image.memory`. 3. **Widget chart**: native `onUpdate` reads the cache, generates light+dark SVGs with `SvgChartGenerator`, rasterises via AndroidSVG → `Bitmap` → `ImageView`. diff --git a/e2e/specs/home_happy_path.e2e.js b/e2e/specs/home_happy_path.e2e.js index fd624a1..0edc900 100644 --- a/e2e/specs/home_happy_path.e2e.js +++ b/e2e/specs/home_happy_path.e2e.js @@ -17,12 +17,10 @@ describe('Meteograph — home happy path', () => { await byId(ids.homeThemeButton).waitForDisplayed({ timeout: READY }); await expect(byId(ids.homeLocationSelector)).toBeDisplayed(); - // Both meteogram charts render (located by their content-desc label; they - // also carry homeHourlyChart / homeWeeklyChart resource-ids). - const hourly = await $$('android=new UiSelector().descriptionContains("48-hour")'); - const weekly = await $$('android=new UiSelector().descriptionContains("7-day")'); - expect(hourly.length).toBeGreaterThanOrEqual(1); - expect(weekly.length).toBeGreaterThanOrEqual(1); + // Both meteogram charts render — located by their stable resource-ids + // (locale-independent, unlike the content-desc label). + await expect(byId(ids.homeHourlyChart)).toBeDisplayed(); + await expect(byId(ids.homeWeeklyChart)).toBeDisplayed(); }); it('opens the location picker from the location selector', async () => { diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index d2b6bf4..4359d84 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -86,8 +86,10 @@ class _HomeScreenState extends State with WidgetsBindingObserver { }; void _invalidateChartCaches() { + // Mark stale rather than dropping the bytes, so the current chart stays on + // screen until its replacement is rasterized (gapless refresh). for (final entry in _chartCache.values) { - entry.png = null; + entry.stale = true; } } @@ -433,6 +435,12 @@ class _HomeScreenState extends State with WidgetsBindingObserver { required bool isLight, required bool usesFahrenheit, }) async { + final cache = _chartCache[mode]!; + cache.generating = true; + // Claim the current dirty state up front. If new data arrives mid-render, + // [stale] flips back to true and the post-build re-kick picks it up, so a + // freshly-set dirty flag is never silently cleared by this render. + cache.stale = false; try { final svgString = await NativeSvgService.generateSvg( mode: mode, @@ -441,21 +449,23 @@ class _HomeScreenState extends State with WidgetsBindingObserver { isLight: isLight, usesFahrenheit: usesFahrenheit, ); - if (svgString == null || !mounted) return; + if (svgString == null) return; final png = await NativeSvgService.renderSvgToPng( svg: svgString, width: width, height: height, ); - if (png == null || !mounted) return; + if (png == null) return; final updatedTemp = mode == NativeSvgService.chartModeHourly ? await NativeSvgService.getCurrentTemperatureCelsius() : null; + // Re-check after the final await: the widget may have been disposed. + if (!mounted) return; + setState(() { - final cache = _chartCache[mode]!; cache.png = png; cache.width = width; cache.height = height; @@ -466,6 +476,8 @@ class _HomeScreenState extends State with WidgetsBindingObserver { }); } catch (e) { debugPrint('Error generating chart ($mode): $e'); + } finally { + cache.generating = false; } } @@ -839,11 +851,12 @@ class _HomeScreenState extends State with WidgetsBindingObserver { final cache = _chartCache[mode]!; final needsRegeneration = cache.png == null || + cache.stale || cache.width != deviceWidthPx || cache.height != deviceHeightPx || cache.isLight != isLight; - if (needsRegeneration) { + if (needsRegeneration && !cache.generating) { _generateSvgAsync( mode: mode, width: deviceWidthPx, @@ -1320,9 +1333,20 @@ class _LocationPickerSheetState extends State<_LocationPickerSheet> { class _ChartCacheEntry { /// Rasterized PNG bytes for the chart, displayed via [Image.memory]. /// The same instance is reused across rebuilds so [MemoryImage] equality - /// hits Flutter's image cache and the bitmap is not re-decoded. + /// hits Flutter's image cache and the bitmap is not re-decoded. Kept on + /// screen until a fresh frame is ready so updates stay gapless — never + /// nulled on invalidation. Uint8List? png; int? width; int? height; bool? isLight; + + /// The chart content (weather data / "now" marker) changed and needs a + /// fresh render. The current [png] keeps showing until the new frame lands. + bool stale = false; + + /// A render is in flight. Stops rapid rebuilds from spawning duplicate + /// native rasterizations; the build re-kicks once it clears if the target + /// or [stale] changed in the meantime. + bool generating = false; } diff --git a/test/home_screen_test.dart b/test/home_screen_test.dart index 3f1b6d5..10c397c 100644 --- a/test/home_screen_test.dart +++ b/test/home_screen_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:meteogram_widget/a11y_ids.dart'; import 'package:meteogram_widget/l10n/app_localizations.dart'; import 'package:meteogram_widget/screens/home_screen.dart'; import 'package:meteogram_widget/services/material_you_service.dart'; @@ -161,6 +162,28 @@ void main() { expect(tempFinder, findsWidgets); }); + testWidgets('renders both charts as images, not the placeholder', (tester) async { + homeWidgetData['last_weather_update'] = mockTimestamp; + homeWidgetData['current_temperature_celsius'] = mockTemperature; + homeWidgetData['cached_city_name'] = mockCityName; + homeWidgetData['cached_location_source'] = mockLocationSource; + + await tester.pumpWidget(createTestApp()); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); + + // The Semantics(identifier:) + Image only exist once the native PNG is + // cached; the fallback path is a bare SizedBox with neither. Asserting + // the identifiers proves the real chart path was taken for both modes. + Finder chartById(String id) => find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.identifier == id, + ); + expect(chartById(A11yIds.homeHourlyChart), findsOneWidget); + expect(chartById(A11yIds.homeWeeklyChart), findsOneWidget); + // Each chart is a real Flutter Image over the rasterized bytes. + expect(find.byType(Image), findsNWidgets(2)); + }); + testWidgets('displays location name after loading', (tester) async { homeWidgetData['last_weather_update'] = mockTimestamp; homeWidgetData['current_temperature_celsius'] = mockTemperature;