From 12d5c8dd59a1f76f0b5b57561654b11df6fca106 Mon Sep 17 00:00:00 2001 From: Josh Burton Date: Wed, 7 Oct 2026 10:29:06 +1300 Subject: [PATCH] Fix blurry Android 12+ splash icon Android 12+ pre-draws a still windowSplashScreenAnimatedIcon into a bitmap at the 108dp default icon size, then scales that bitmap up to the final icon size (160dp, or 192dp without an icon background). The icon is soft however large the source image is. Animatable icons skip the pre-draw and are drawn straight from the drawable at the final size. The package now writes drawable-v31/android12splash_icon.xml, a one-frame, one-shot animation-list that wraps the existing android12splash bitmaps, and points the v31 styles at it. One frame never schedules a redraw, so nothing moves. The file is deleted when no Android 12 image is configured. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 + lib/android.dart | 20 +++- lib/flavor_helper.dart | 4 + lib/templates.dart | 18 ++++ test/android12_splash_icon_test.dart | 140 +++++++++++++++++++++++++++ 5 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 test/android12_splash_icon_test.dart diff --git a/README.md b/README.md index 2baeede..815675a 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,8 @@ Be aware of the following considerations regarding these elements: 5. `branding` parameter. The branding image dimensions must be 800x320 px. +6. The package writes `drawable-v31/android12splash_icon.xml`, a one-frame animation that wraps the `image` bitmaps, and points the splash theme at it. Android 12+ draws a still icon at a reduced size and scales it up, which blurs it; an animated icon is drawn at full size, so the image stays sharp. Nothing moves. + **_PLEASE NOTE:_** The splash screen may not appear when you launch the app from Android Studio on API 31. However, it should appear when you launch by clicking on the launch icon in Android. This seems to be resolved in API 32+. **_PLEASE NOTE:_** There are a number of reports that non-Google launchers do not display the launch image correctly. If the launch image does not display correctly, please try the Google launcher to confirm that this package is working. diff --git a/lib/android.dart b/lib/android.dart index 938fff9..59adb23 100644 --- a/lib/android.dart +++ b/lib/android.dart @@ -122,6 +122,10 @@ void _createAndroidSplash({ fileName: 'android12splash.png', ); + _applyAndroid12SplashIcon( + showIcon: android12ImagePath != null || android12DarkImagePath != null, + ); + _applyImageAndroid( imagePath: android12BrandingImagePath, android12: true, @@ -346,6 +350,20 @@ void _deleteImageAndroid({ } } +/// Creates or deletes the drawable that Android 12+ styles use as the splash +/// icon. It points at the android12splash bitmaps; see +/// [_androidV31SplashIconXml] for why they are wrapped. +void _applyAndroid12SplashIcon({required bool showIcon}) { + final file = File(_flavorHelper.androidV31SplashIconFile); + if (showIcon) { + file.createSync(recursive: true); + file.writeAsStringSync(_androidV31SplashIconXml); + } else if (file.existsSync()) { + print('[Android] Deleting android12splash_icon.xml'); + file.deleteSync(); + } +} + /// Updates launch_background.xml adding splash image path void _applyLaunchBackgroundXml({ required String launchBackgroundFilePath, @@ -526,7 +544,7 @@ Future _updateStylesFile({ _replaceElement( launchTheme: launchTheme, name: 'android:windowSplashScreenAnimatedIcon', - value: '@drawable/android12splash', + value: '@drawable/android12splash_icon', ); } diff --git a/lib/flavor_helper.dart b/lib/flavor_helper.dart index 6b734d9..4f84aeb 100644 --- a/lib/flavor_helper.dart +++ b/lib/flavor_helper.dart @@ -55,6 +55,10 @@ class _FlavorHelper { return '${_androidResFolder}values-night-v31/styles.xml'; } + String get androidV31SplashIconFile { + return '${_androidResFolder}drawable-v31/android12splash_icon.xml'; + } + String get androidV21DrawableFolder { return '${_androidResFolder}drawable-v21/'; } diff --git a/lib/templates.dart b/lib/templates.dart index 010baed..5b7816d 100644 --- a/lib/templates.dart +++ b/lib/templates.dart @@ -104,6 +104,24 @@ const String _androidV31StylesNightXml = ''' '''; +/// Wraps the Android 12+ splash icon bitmaps in a one-frame animation-list. +/// +/// Android 12+ pre-draws a still windowSplashScreenAnimatedIcon into a bitmap +/// at the 108dp default icon size and then scales that bitmap up to the final +/// icon size (160dp, or 192dp without an icon background), so a still icon is +/// blurry however large the source image is. An Animatable icon is drawn +/// straight from its drawable at the final size instead. One frame played +/// once never schedules a redraw, so nothing moves. +const String _androidV31SplashIconXml = ''' + + + + +'''; + // iOS-related templates const String _iOSLaunchScreenStoryboardContent = ''' diff --git a/test/android12_splash_icon_test.dart b/test/android12_splash_icon_test.dart new file mode 100644 index 0000000..8043499 --- /dev/null +++ b/test/android12_splash_icon_test.dart @@ -0,0 +1,140 @@ +import 'dart:io'; + +import 'package:flutter_native_splash/cli_commands.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:path/path.dart' as p; +import 'package:xml/xml.dart'; + +void main() { + late Directory originalDirectory; + late Directory temporaryDirectory; + + final res = p.join('android', 'app', 'src', 'main', 'res'); + final iconFile = p.join(res, 'drawable-v31', 'android12splash_icon.xml'); + + setUp(() { + originalDirectory = Directory.current; + temporaryDirectory = Directory.systemTemp.createTempSync( + 'flutter-native-splash-android12-test', + ); + Directory.current = temporaryDirectory; + + final manifest = + File(p.join('android', 'app', 'src', 'main', 'AndroidManifest.xml')) + ..createSync(recursive: true); + manifest.writeAsStringSync(''' + + + + + +'''); + + File(p.join('assets', 'logo.png')) + ..createSync(recursive: true) + ..writeAsBytesSync(img.encodePng(img.Image(width: 8, height: 8))); + }); + + tearDown(() { + Directory.current = originalDirectory; + temporaryDirectory.deleteSync(recursive: true); + }); + + String? splashIcon(String stylesFolder) { + final styles = XmlDocument.parse( + File(p.join(res, stylesFolder, 'styles.xml')).readAsStringSync(), + ); + final launchTheme = styles.findAllElements('style').singleWhere( + (style) => style.getAttribute('name') == 'LaunchTheme', + ); + return launchTheme + .findElements('item') + .where( + (item) => + item.getAttribute('name') == + 'android:windowSplashScreenAnimatedIcon', + ) + .singleOrNull + ?.innerText; + } + + test('wraps the Android 12 icon so the system draws it at full size', + () async { + File('flutter_native_splash.yaml').writeAsStringSync(''' +flutter_native_splash: + ios: false + web: false + color: "#ffffff" + android_12: + image: assets/logo.png +'''); + + createSplash(path: 'flutter_native_splash.yaml', flavor: null); + await _waitForAndroid12Bitmaps(res); + + expect(splashIcon('values-v31'), '@drawable/android12splash_icon'); + expect(splashIcon('values-night-v31'), '@drawable/android12splash_icon'); + + final animationList = + XmlDocument.parse(File(iconFile).readAsStringSync()).rootElement; + expect(animationList.name.local, 'animation-list'); + expect(animationList.getAttribute('android:oneshot'), 'true'); + final frames = animationList.findElements('item').toList(); + expect(frames, hasLength(1)); + expect( + frames.single.getAttribute('android:drawable'), + '@drawable/android12splash', + ); + }); + + test('removes the wrapper when no Android 12 image is configured', () async { + File('flutter_native_splash.yaml').writeAsStringSync(''' +flutter_native_splash: + ios: false + web: false + color: "#ffffff" + android_12: + image: assets/logo.png +'''); + createSplash(path: 'flutter_native_splash.yaml', flavor: null); + await _waitForAndroid12Bitmaps(res); + expect(File(iconFile).existsSync(), isTrue); + + File('flutter_native_splash.yaml').writeAsStringSync(''' +flutter_native_splash: + ios: false + web: false + color: "#ffffff" +'''); + createSplash(path: 'flutter_native_splash.yaml', flavor: null); + + expect(File(iconFile).existsSync(), isFalse); + expect(splashIcon('values-v31'), isNull); + expect(splashIcon('values-night-v31'), isNull); + }); +} + +/// Waits for the light and dark android12splash bitmaps in every density. +/// +/// createSplash writes them from isolates that it does not await, so a test +/// must wait before it runs createSplash again or leaves its temporary +/// directory; otherwise a late write lands in the wrong place. +Future _waitForAndroid12Bitmaps(String res) async { + const densities = ['mdpi', 'hdpi', 'xhdpi', 'xxhdpi', 'xxxhdpi']; + final bitmaps = [ + for (final night in ['', '-night']) + for (final density in densities) + File(p.join(res, 'drawable$night-$density', 'android12splash.png')), + ]; + bool decoded(File file) => + file.existsSync() && img.decodePng(file.readAsBytesSync()) != null; + + final deadline = DateTime.now().add(const Duration(seconds: 10)); + while (!bitmaps.every(decoded)) { + if (DateTime.now().isAfter(deadline)) { + fail('Timed out waiting for the android12splash bitmaps.'); + } + await Future.delayed(const Duration(milliseconds: 50)); + } +}