Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 19 additions & 1 deletion lib/android.dart
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ void _createAndroidSplash({
fileName: 'android12splash.png',
);

_applyAndroid12SplashIcon(
showIcon: android12ImagePath != null || android12DarkImagePath != null,
);

_applyImageAndroid(
imagePath: android12BrandingImagePath,
android12: true,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -526,7 +544,7 @@ Future<void> _updateStylesFile({
_replaceElement(
launchTheme: launchTheme,
name: 'android:windowSplashScreenAnimatedIcon',
value: '@drawable/android12splash',
value: '@drawable/android12splash_icon',
);
}

Expand Down
4 changes: 4 additions & 0 deletions lib/flavor_helper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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/';
}
Expand Down
18 changes: 18 additions & 0 deletions lib/templates.dart
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,24 @@ const String _androidV31StylesNightXml = '''
</resources>
''';

/// 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 = '''
<?xml version="1.0" encoding="utf-8"?>
<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
android:oneshot="true">
<item
android:drawable="@drawable/android12splash"
android:duration="0" />
</animation-list>
''';

// iOS-related templates
const String _iOSLaunchScreenStoryboardContent = '''
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
Expand Down
140 changes: 140 additions & 0 deletions test/android12_splash_icon_test.dart
Original file line number Diff line number Diff line change
@@ -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('''
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity android:name=".MainActivity"/>
</application>
</manifest>
''');

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<void> _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<void>.delayed(const Duration(milliseconds: 50));
}
}