diff --git a/src/SingleProject/Resizetizer/src/CreatePartialInfoPlistTask.cs b/src/SingleProject/Resizetizer/src/CreatePartialInfoPlistTask.cs index 956630fea7e3..e50bfb48aa3c 100644 --- a/src/SingleProject/Resizetizer/src/CreatePartialInfoPlistTask.cs +++ b/src/SingleProject/Resizetizer/src/CreatePartialInfoPlistTask.cs @@ -16,6 +16,10 @@ public class CreatePartialInfoPlistTask : Task public string Storyboard { get; set; } + public string LaunchScreenImage { get; set; } + + public string LaunchScreenColor { get; set; } + const string plistHeader = @" @@ -52,7 +56,26 @@ public override bool Execute() f.WriteLine(" "); } - if (!string.IsNullOrEmpty(Storyboard)) + if (!string.IsNullOrEmpty(LaunchScreenImage) || !string.IsNullOrEmpty(LaunchScreenColor)) + { + f.WriteLine(" UILaunchScreen"); + f.WriteLine(" "); + + if (!string.IsNullOrEmpty(LaunchScreenImage)) + { + f.WriteLine(" UIImageName"); + f.WriteLine($" {LaunchScreenImage}"); + } + + if (!string.IsNullOrEmpty(LaunchScreenColor)) + { + f.WriteLine(" UIColorName"); + f.WriteLine($" {LaunchScreenColor}"); + } + + f.WriteLine(" "); + } + else if (!string.IsNullOrEmpty(Storyboard)) { f.WriteLine(" UILaunchStoryboardName"); f.WriteLine($" {Path.GetFileNameWithoutExtension(Storyboard)}"); diff --git a/src/SingleProject/Resizetizer/src/DpiPath.cs b/src/SingleProject/Resizetizer/src/DpiPath.cs index b38fb37cd5d6..abf17614405a 100644 --- a/src/SingleProject/Resizetizer/src/DpiPath.cs +++ b/src/SingleProject/Resizetizer/src/DpiPath.cs @@ -48,6 +48,9 @@ public static class Android public static DpiPath Original => new DpiPath("drawable", 1.0m); + public static DpiPath OriginalNight => + new DpiPath("drawable-night", 1.0m); + public static DpiPath[] Image => new[] { @@ -58,6 +61,16 @@ public static DpiPath[] Image new DpiPath("drawable-xxxhdpi", 4.0m), }; + public static DpiPath[] ImageNight + => new[] + { + new DpiPath("drawable-night-mdpi", 1.0m), + new DpiPath("drawable-night-hdpi", 1.5m), + new DpiPath("drawable-night-xhdpi", 2.0m), + new DpiPath("drawable-night-xxhdpi", 3.0m), + new DpiPath("drawable-night-xxxhdpi", 4.0m), + }; + public static DpiPath[] AppIcon => new[] { @@ -89,6 +102,11 @@ public static DpiPath[] AppIconParts public static class Ios { public const string AppIconPath = "Assets.xcassets/{name}.appiconset"; + public const string SplashImageName = "MauiSplashImage"; + public const string SplashImageDarkName = "MauiSplashImageDark"; + public const string SplashColorName = "MauiSplashColor"; + public const string SplashImageSetPath = "Assets.xcassets/MauiSplashImage.imageset"; + public const string SplashColorSetPath = "Assets.xcassets/MauiSplashColor.colorset"; public static DpiPath Original => new DpiPath("Resources", 1.0m); @@ -101,6 +119,14 @@ public static DpiPath[] Image new DpiPath("", 3.0m, null,"@3x"), }; + public static DpiPath[] SplashImageAsset + => new[] + { + new DpiPath(SplashImageSetPath, 1.0m), + new DpiPath(SplashImageSetPath, 2.0m, null, "@2x"), + new DpiPath(SplashImageSetPath, 3.0m, null, "@3x"), + }; + public static DpiPath[] AppIcon => new[] { diff --git a/src/SingleProject/Resizetizer/src/GenerateSplashAndroidResources.cs b/src/SingleProject/Resizetizer/src/GenerateSplashAndroidResources.cs index 0e0fd63fd880..fc9fa6920341 100644 --- a/src/SingleProject/Resizetizer/src/GenerateSplashAndroidResources.cs +++ b/src/SingleProject/Resizetizer/src/GenerateSplashAndroidResources.cs @@ -43,11 +43,33 @@ public override bool Execute() var info = ResizeImageInfo.Parse(splash); var resizer = new Resizer(info, IntermediateOutputPath, this); + Resizer darkResizer = null; + if (info.HasDarkMode) + { + var darkInfo = info.CreateDarkVariant(GetDarkOutputAlias(info)); + if (darkInfo.BaseSize is null && !string.IsNullOrWhiteSpace(info.DarkFilename)) + darkInfo.BaseSize = resizer.BaseSize ?? resizer.GetOriginalSize(); + darkResizer = new Resizer(darkInfo, IntermediateOutputPath, this); + } WriteImages(resizer); - WriteColors(resizer); - WriteDrawable(resizer); - WriteDrawable_v31(resizer); + if (darkResizer is not null) + { + CleanStaleNightImageResources(darkResizer.Info.Resize); + WriteImages(darkResizer, dark: true); + } + else + CleanNightResources(); + + WriteColors(resizer, darkResizer); + WriteDrawable(resizer, dark: false); + WriteDrawable_v31(resizer, dark: false); + + if (darkResizer is not null) + { + WriteDrawable(darkResizer, dark: true); + WriteDrawable_v31(darkResizer, dark: true); + } return !Log.HasLoggedErrors; } @@ -63,11 +85,11 @@ public override bool Execute() const string Comment = "This file was auto-generated by .NET MAUI."; const float PreferredImageSize = 108f; - private void WriteImages(Resizer resizer) + private void WriteImages(Resizer resizer, bool dark = false) { if (resizer.Info.Resize) { - foreach (var dpi in DpiPath.Android.Image) + foreach (var dpi in dark ? DpiPath.Android.ImageNight : DpiPath.Android.Image) { Log.LogMessage(MessageImportance.Low, $"Splash Screen Resize: " + dpi); resizer.Resize(dpi, InputsFile); @@ -75,16 +97,26 @@ private void WriteImages(Resizer resizer) } else { - var dpi = DpiPath.Android.Original; + var dpi = dark ? DpiPath.Android.OriginalNight : DpiPath.Android.Original; Log.LogMessage(MessageImportance.Low, $"Splash Screen Copy: " + dpi); resizer.CopyFile(dpi, InputsFile); } } - void WriteColors(Resizer resizer) + void WriteColors(Resizer resizer, Resizer darkResizer) { - var dir = Path.Combine(IntermediateOutputPath, "values"); + WriteColors(resizer.Info.Color, "values"); + + if (darkResizer?.Info.Color is not null) + WriteColors(darkResizer.Info.Color, "values-night"); + else if (darkResizer is not null) + DeleteNightColors(); + } + + void WriteColors(SKColor? color, string directory) + { + var dir = Path.Combine(IntermediateOutputPath, directory); Directory.CreateDirectory(dir); var colorsFile = Path.Combine(dir, "maui_colors.xml"); @@ -95,25 +127,25 @@ void WriteColors(Resizer resizer) writer.WriteComment(Comment); writer.WriteStartElement("resources"); - if (resizer.Info.Color is not null) + if (color is not null) { writer.WriteStartElement("color"); writer.WriteAttributeString("name", "maui_splash_color"); - writer.WriteString(resizer.Info.Color.ToString()); + writer.WriteString(color.ToString()); writer.WriteEndElement(); } writer.WriteEndDocument(); } - void WriteDrawable(Resizer resizer) + void WriteDrawable(Resizer resizer, bool dark) { - var dir = Path.Combine(IntermediateOutputPath, "drawable"); + var dir = Path.Combine(IntermediateOutputPath, dark ? "drawable-night" : "drawable"); Directory.CreateDirectory(dir); var drawableFile = Path.Combine(dir, "maui_splash_image.xml"); - Log.LogMessage(MessageImportance.Low, $"Splash Screen Drawable: " + drawableFile); + Log.LogMessage(MessageImportance.Low, $"Splash Screen Drawable{(dark ? " (night)" : "")}: " + drawableFile); using var writer = XmlWriter.Create(drawableFile, Settings); writer.WriteComment(Comment); @@ -130,16 +162,16 @@ void WriteDrawable(Resizer resizer) writer.WriteEndDocument(); } - void WriteDrawable_v31(Resizer resizer) + void WriteDrawable_v31(Resizer resizer, bool dark) { var size = CalculateScaledSize(resizer); - var dir = Path.Combine(IntermediateOutputPath, "drawable-v31"); + var dir = Path.Combine(IntermediateOutputPath, dark ? "drawable-night-v31" : "drawable-v31"); Directory.CreateDirectory(dir); var drawableFile = Path.Combine(dir, "maui_splash_image.xml"); - Log.LogMessage(MessageImportance.Low, $"Splash Screen Drawable (v31): " + drawableFile); + Log.LogMessage(MessageImportance.Low, $"Splash Screen Drawable ({(dark ? "night " : "")}v31): " + drawableFile); using var writer = XmlWriter.Create(drawableFile, Settings); writer.WriteComment(Comment); @@ -164,6 +196,68 @@ void ILogger.Log(string message) Log?.LogMessage(message); } + void CleanNightResources() + { + foreach (var directory in new[] + { + "values-night", + "drawable-night", + "drawable-night-v31", + "drawable-night-mdpi", + "drawable-night-hdpi", + "drawable-night-xhdpi", + "drawable-night-xxhdpi", + "drawable-night-xxxhdpi", + }) + { + var path = Path.Combine(IntermediateOutputPath, directory); + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + } + + void DeleteNightColors() + { + var colorsFile = Path.Combine(IntermediateOutputPath, "values-night", "maui_colors.xml"); + if (File.Exists(colorsFile)) + File.Delete(colorsFile); + } + + void CleanStaleNightImageResources(bool resize) + { + foreach (var directory in resize ? NightOriginalResourceDirectories : NightDensityResourceDirectories) + { + var path = Path.Combine(IntermediateOutputPath, directory); + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + } + + static readonly string[] NightOriginalResourceDirectories = + { + "drawable-night", + }; + + static readonly string[] NightDensityResourceDirectories = + { + "drawable-night-mdpi", + "drawable-night-hdpi", + "drawable-night-xhdpi", + "drawable-night-xxhdpi", + "drawable-night-xxxhdpi", + }; + + static string GetDarkOutputAlias(ResizeImageInfo info) + { + var extension = info.DarkIsVector || (string.IsNullOrWhiteSpace(info.DarkFilename) && info.IsVector) + ? Resizer.RasterFileExtension + : !string.IsNullOrWhiteSpace(info.DarkFilename) + ? Path.GetExtension(info.DarkFilename) + : info.OutputExtension; + + return info.OutputName + extension; + } + static SKSize CalculateScaledSize(Resizer resizer) { var size = resizer.BaseSize ?? resizer.GetOriginalSize(); diff --git a/src/SingleProject/Resizetizer/src/GenerateSplashAssetCatalog.cs b/src/SingleProject/Resizetizer/src/GenerateSplashAssetCatalog.cs new file mode 100644 index 000000000000..f492d0b74906 --- /dev/null +++ b/src/SingleProject/Resizetizer/src/GenerateSplashAssetCatalog.cs @@ -0,0 +1,303 @@ +#nullable enable +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using SkiaSharp; + +namespace Microsoft.Maui.Resizetizer +{ + /// + /// Generates iOS asset catalog resources for themed launch screens. + /// + public class GenerateSplashAssetCatalog : Task, ILogger + { + [Required] + public string IntermediateOutputPath { get; set; } = null!; + + public ITaskItem[]? MauiSplashScreen { get; set; } + + public string? InputsFile { get; set; } + + public override bool Execute() + { + Log.LogMessage(MessageImportance.Low, $"Splash Screen Asset Catalog: Intermediate Path " + IntermediateOutputPath); + + try + { + var splash = MauiSplashScreen?.FirstOrDefault(); + if (splash is null) + return true; + + CleanStoryboard(); + CleanImageSet(); + + var info = ResizeImageInfo.Parse(splash); + var lightInfo = CloneForAsset(info, DpiPath.Ios.SplashImageName); + var lightResizer = new Resizer(lightInfo, IntermediateOutputPath, this); + var darkInfo = CloneForAsset(info.CreateDarkVariant(), DpiPath.Ios.SplashImageDarkName); + if (darkInfo.BaseSize is null && !string.IsNullOrWhiteSpace(info.DarkFilename)) + darkInfo.BaseSize = lightResizer.BaseSize ?? lightResizer.GetOriginalSize(); + + WriteImages(lightResizer); + WriteImages(new Resizer(darkInfo, IntermediateOutputPath, this)); + WriteImageSet(lightInfo, darkInfo); + if (info.Color is not null || info.DarkColor is not null) + { + if (info.Color is null && info.DarkColor is not null) + Log.LogWarning("MauiSplashScreen DarkColor was specified without Color; white will be used as the light-mode launch screen background."); + + WriteColorSet(info.Color ?? SKColors.White, info.DarkColor ?? info.Color ?? SKColors.White); + } + else + { + DeleteColorSet(); + } + + return !Log.HasLoggedErrors; + } + catch (Exception ex) + { + Log.LogError(Resources.ErrorMessages.AppleResourceProcessing, ErrorCodes.AppleResourceProcessingCode, null, null, 0, 0, 0, 0, string.Format(Resources.ErrorMessages.AppleResourceProcessingError, ex.ToString())); + return false; + } + } + + private static ResizeImageInfo CloneForAsset(ResizeImageInfo info, string alias) => + new ResizeImageInfo + { + ItemSpec = info.ItemSpec, + Alias = alias + GetAssetExtension(info), + Filename = info.Filename, + BaseSize = info.BaseSize, + Resize = info.Resize, + TintColor = info.TintColor, + Color = info.Color, + }; + + private static string GetAssetExtension(ResizeImageInfo info) => + !info.Resize && !info.IsVector && !string.IsNullOrEmpty(info.OutputExtension) + ? info.OutputExtension + : Resizer.RasterFileExtension; + + private void WriteImages(Resizer resizer) + { + foreach (var dpi in DpiPath.Ios.SplashImageAsset) + { + if (resizer.Info.Resize) + { + Log.LogMessage(MessageImportance.Low, $"Splash Screen Asset Catalog Resize: " + dpi); + resizer.Resize(dpi, InputsFile); + } + else + { + Log.LogMessage(MessageImportance.Low, $"Splash Screen Asset Catalog Copy: " + dpi); + resizer.CopyFile(dpi, InputsFile); + } + } + } + + private void WriteImageSet(ResizeImageInfo lightInfo, ResizeImageInfo darkInfo) + { + var imageSetPath = Path.Combine(IntermediateOutputPath, DpiPath.Ios.SplashImageSetPath); + Directory.CreateDirectory(imageSetPath); + + var contentsFile = Path.Combine(imageSetPath, "Contents.json"); + File.WriteAllText(contentsFile, + $$""" + { + "images": [ + { + "idiom": "universal", + "filename": "{{GetAssetFilename(lightInfo, DpiPath.Ios.SplashImageAsset[0])}}", + "scale": "1x" + }, + { + "idiom": "universal", + "filename": "{{GetAssetFilename(lightInfo, DpiPath.Ios.SplashImageAsset[1])}}", + "scale": "2x" + }, + { + "idiom": "universal", + "filename": "{{GetAssetFilename(lightInfo, DpiPath.Ios.SplashImageAsset[2])}}", + "scale": "3x" + }, + { + "idiom": "universal", + "filename": "{{GetAssetFilename(lightInfo, DpiPath.Ios.SplashImageAsset[0])}}", + "scale": "1x", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "universal", + "filename": "{{GetAssetFilename(lightInfo, DpiPath.Ios.SplashImageAsset[1])}}", + "scale": "2x", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "universal", + "filename": "{{GetAssetFilename(lightInfo, DpiPath.Ios.SplashImageAsset[2])}}", + "scale": "3x", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "universal", + "filename": "{{GetAssetFilename(darkInfo, DpiPath.Ios.SplashImageAsset[0])}}", + "scale": "1x", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "universal", + "filename": "{{GetAssetFilename(darkInfo, DpiPath.Ios.SplashImageAsset[1])}}", + "scale": "2x", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "universal", + "filename": "{{GetAssetFilename(darkInfo, DpiPath.Ios.SplashImageAsset[2])}}", + "scale": "3x", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "version": 1, + "author": "xcode" + } + } + """); + } + + private static string GetAssetFilename(ResizeImageInfo info, DpiPath dpi) => + info.OutputName + dpi.FileSuffix + info.OutputExtension; + + private void WriteColorSet(SKColor lightColor, SKColor darkColor) + { + var colorSetPath = Path.Combine(IntermediateOutputPath, DpiPath.Ios.SplashColorSetPath); + Directory.CreateDirectory(colorSetPath); + + var contentsFile = Path.Combine(colorSetPath, "Contents.json"); + File.WriteAllText(contentsFile, + $$""" + { + "colors": [ + { + "idiom": "universal", + "color": { + "color-space": "srgb", + "components": { + "red": "{{ToComponent(lightColor.Red)}}", + "green": "{{ToComponent(lightColor.Green)}}", + "blue": "{{ToComponent(lightColor.Blue)}}", + "alpha": "{{ToComponent(lightColor.Alpha)}}" + } + } + }, + { + "idiom": "universal", + "color": { + "color-space": "srgb", + "components": { + "red": "{{ToComponent(lightColor.Red)}}", + "green": "{{ToComponent(lightColor.Green)}}", + "blue": "{{ToComponent(lightColor.Blue)}}", + "alpha": "{{ToComponent(lightColor.Alpha)}}" + } + }, + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "universal", + "color": { + "color-space": "srgb", + "components": { + "red": "{{ToComponent(darkColor.Red)}}", + "green": "{{ToComponent(darkColor.Green)}}", + "blue": "{{ToComponent(darkColor.Blue)}}", + "alpha": "{{ToComponent(darkColor.Alpha)}}" + } + }, + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "version": 1, + "author": "xcode" + } + } + """); + } + + private static string ToComponent(byte value) => + value == byte.MaxValue + ? "1.0" + : (value / (float)byte.MaxValue).ToString("0.#######", CultureInfo.InvariantCulture); + + private void CleanStoryboard() + { + var storyboard = Path.Combine(IntermediateOutputPath, "MauiSplash.storyboard"); + if (File.Exists(storyboard)) + File.Delete(storyboard); + } + + private void CleanImageSet() + { + var imageSetPath = Path.Combine(IntermediateOutputPath, DpiPath.Ios.SplashImageSetPath); + if (Directory.Exists(imageSetPath)) + Directory.Delete(imageSetPath, recursive: true); + } + + private void DeleteColorSet() + { + var colorSetPath = Path.Combine(IntermediateOutputPath, DpiPath.Ios.SplashColorSetPath); + if (Directory.Exists(colorSetPath)) + Directory.Delete(colorSetPath, recursive: true); + } + + void ILogger.Log(string message) + { + Log?.LogMessage(message); + } + } +} diff --git a/src/SingleProject/Resizetizer/src/GenerateSplashStoryboard.cs b/src/SingleProject/Resizetizer/src/GenerateSplashStoryboard.cs index 472495b83154..b9d676826de6 100644 --- a/src/SingleProject/Resizetizer/src/GenerateSplashStoryboard.cs +++ b/src/SingleProject/Resizetizer/src/GenerateSplashStoryboard.cs @@ -28,6 +28,8 @@ public override bool Execute() try { + CleanThemedAssets(); + var splash = MauiSplashScreen?.FirstOrDefault(); if (splash is null) { @@ -145,5 +147,12 @@ void ILogger.Log(string message) { Log?.LogMessage(message); } + + void CleanThemedAssets() + { + var assetsPath = Path.Combine(IntermediateOutputPath, "Assets.xcassets"); + if (Directory.Exists(assetsPath)) + Directory.Delete(assetsPath, recursive: true); + } } } diff --git a/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs b/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs index 79913d4b6bba..604aadbdcf50 100644 --- a/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs +++ b/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs @@ -40,8 +40,21 @@ internal class ResizeImageInfo public SKColor? Color { get; set; } + public SKColor? DarkTintColor { get; set; } + + public SKColor? DarkColor { get; set; } + public bool IsVector => IsVectorFilename(Filename); + public string? DarkFilename { get; set; } + + public bool DarkIsVector => IsVectorFilename(DarkFilename); + + public bool HasDarkMode => + DarkColor is not null || + DarkTintColor is not null || + !string.IsNullOrWhiteSpace(DarkFilename); + public bool IsAppIcon { get; set; } public string? ForegroundFilename { get; set; } @@ -112,6 +125,16 @@ public static ResizeImageInfo Parse(ITaskItem image) if (info.Color is null && !string.IsNullOrEmpty(color)) throw new InvalidDataException($"Unable to parse color value '{color}' for '{info.Filename}'."); + var darkTintColor = image.GetMetadata("DarkTintColor"); + info.DarkTintColor = Utils.ParseColorString(darkTintColor); + if (info.DarkTintColor is null && !string.IsNullOrEmpty(darkTintColor)) + throw new InvalidDataException($"Unable to parse color value '{darkTintColor}' for '{info.Filename}'."); + + var darkColor = image.GetMetadata("DarkColor"); + info.DarkColor = Utils.ParseColorString(darkColor); + if (info.DarkColor is null && !string.IsNullOrEmpty(darkColor)) + throw new InvalidDataException($"Unable to parse color value '{darkColor}' for '{info.Filename}'."); + if (bool.TryParse(image.GetMetadata("IsAppIcon"), out var iai)) info.IsAppIcon = iai; @@ -128,6 +151,16 @@ public static ResizeImageInfo Parse(ITaskItem image) info.ForegroundFilename = fgFileInfo.FullName; } + var darkFile = image.GetMetadata("DarkFile"); + if (!string.IsNullOrEmpty(darkFile)) + { + var darkFileInfo = new FileInfo(darkFile); + if (!darkFileInfo.Exists) + throw new FileNotFoundException("Unable to find dark file: " + darkFileInfo.FullName, darkFileInfo.FullName); + + info.DarkFilename = darkFileInfo.FullName; + } + var monoFile = image.GetMetadata("MonochromeFile"); if (!string.IsNullOrEmpty(monoFile)) { @@ -150,5 +183,25 @@ public static ResizeImageInfo Parse(ITaskItem image) return info; } + + public ResizeImageInfo CreateDarkVariant(string? alias = null) + { + var hasDarkFile = !string.IsNullOrWhiteSpace(DarkFilename); + + return new ResizeImageInfo + { + ItemSpec = ItemSpec, + Alias = alias ?? Alias, + Filename = hasDarkFile ? DarkFilename : Filename, + BaseSize = BaseSize, + Resize = Resize, + TintColor = DarkTintColor ?? (hasDarkFile ? null : TintColor), + Color = DarkColor ?? Color, + IsAppIcon = IsAppIcon, + ForegroundFilename = ForegroundFilename, + ForegroundScale = ForegroundScale, + MonochromeFilename = MonochromeFilename, + }; + } } } diff --git a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets index d3fd5474e8bb..4786cd71357a 100644 --- a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets +++ b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets @@ -33,6 +33,10 @@ AssemblyFile="$(_ResizetizerTaskAssemblyName)" TaskName="Microsoft.Maui.Resizetizer.GenerateSplashStoryboard" /> + + @@ -257,7 +261,8 @@ - + + @@ -337,6 +342,23 @@ + + <_MauiSplashScreenWithHashes Update="@(_MauiSplashScreenWithHashes)" DarkFile="$([System.IO.Path]::GetFullPath('%(_MauiSplashScreenWithHashes.DarkFile)'))" Condition="'%(_MauiSplashScreenWithHashes.DarkFile)' != ''" /> + + + + <_MauiSplashScreenDarkFilesForHash>@(_MauiSplashScreenWithHashes->'%(DarkFile)', '|') + <_MauiFirstSplashScreenDarkFileForHash Condition="'@(_MauiSplashScreenWithHashes)' != ''">$(_MauiSplashScreenDarkFilesForHash.Split('|')[0]) + + + + <_MauiSplashScreenDarkFile Include="$(_MauiFirstSplashScreenDarkFileForHash)" Condition="'$(_MauiFirstSplashScreenDarkFileForHash)' != ''" /> + + + + + + @@ -451,10 +473,36 @@ <_MauiHasSplashScreens>false <_MauiHasSplashScreens Condition="'@(MauiSplashScreen->Count())' != '0'">true + <_MauiSplashScreenIdentities>@(MauiSplashScreen->'%(Identity)', '|') + <_MauiSplashScreenColors>@(MauiSplashScreen->'%(Color)', '|') + <_MauiSplashScreenDarkColors>@(MauiSplashScreen->'%(DarkColor)', '|') + <_MauiSplashScreenDarkFiles>@(MauiSplashScreen->'%(DarkFile)', '|') + <_MauiSplashScreenDarkTintColors>@(MauiSplashScreen->'%(DarkTintColor)', '|') + <_MauiFirstSplashScreenIdentity Condition="'$(_MauiHasSplashScreens)' == 'true'">$(_MauiSplashScreenIdentities.Split('|')[0]) + <_MauiFirstSplashScreenColor Condition="'$(_MauiHasSplashScreens)' == 'true'">$(_MauiSplashScreenColors.Split('|')[0]) + <_MauiFirstSplashScreenDarkColor Condition="'$(_MauiHasSplashScreens)' == 'true'">$(_MauiSplashScreenDarkColors.Split('|')[0]) + <_MauiFirstSplashScreenDarkFile Condition="'$(_MauiHasSplashScreens)' == 'true'">$(_MauiSplashScreenDarkFiles.Split('|')[0]) + <_MauiFirstSplashScreenDarkTintColor Condition="'$(_MauiHasSplashScreens)' == 'true'">$(_MauiSplashScreenDarkTintColors.Split('|')[0]) <_MauiShouldGenerateSplashScreen Condition="'$(_MauiHasSplashScreens)' == 'true'">true <_MauiShouldGenerateSplashScreen Condition="'$(_ResizetizerIsiOSSpecificApp)' == 'True' and '$(_MauiHasSplashScreens)' != 'true' and '$(EnableBlankMauiSplashScreen)' == 'true'">true + <_MauiAppleThemedSplashSupported Condition="'$(SupportedOSPlatformVersion)' != '' and $([MSBuild]::VersionGreaterThanOrEquals('$(SupportedOSPlatformVersion)', '14.0'))">true + + + + <_MauiHasThemedSplashScreen Condition="'$(_MauiFirstSplashScreenDarkFile)' != '' Or '$(_MauiFirstSplashScreenDarkColor)' != '' Or '$(_MauiFirstSplashScreenDarkTintColor)' != ''">true + <_MauiLaunchScreenColorName Condition="'$(_MauiFirstSplashScreenColor)' != '' Or '$(_MauiFirstSplashScreenDarkColor)' != ''">MauiSplashColor + <_MauiShouldUseThemedAppleSplashScreen Condition="'$(_ResizetizerIsiOSApp)' == 'True' and '$(_MauiHasSplashScreens)' == 'true' and '$(_MauiHasThemedSplashScreen)' == 'true' and '$(_MauiAppleThemedSplashSupported)' == 'true'">true + + + @@ -481,8 +529,13 @@ - - + + + + <_MauiSplashScreenWithHashesInFilename Include="@(_MauiSplashScreenWithHashes->HasMetadata('Link'))" @@ -494,22 +547,38 @@ Link="%(Filename)_%(InputsFileHash)%(Extension)" /> - + + <_MauiIntermediateStoryboard>$(_MauiIntermediateSplashScreen)MauiSplash.storyboard <_MauiIntermediatePList>$(_MauiIntermediateSplashScreen)MauiInfo.plist + + <_MauiIntermediatePList>$(_MauiIntermediateSplashScreen)MauiInfo.plist + - + + <_MauiSplashAssets Include="$(_MauiIntermediateSplashScreen)**\*" /> <_MauiSplashStoryboard Include="$(_MauiIntermediateStoryboard)" /> <_MauiSplashPList Include="$(_MauiIntermediatePList)" /> @@ -524,6 +593,16 @@ %(_MauiSplashImages.Filename)%(_MauiSplashImages.Extension) + + <_MauiSplashPList Include="$(_MauiIntermediatePList)" /> + <_MauiSplashAssetCatalog Include="$(_MauiIntermediateSplashScreen)Assets.xcassets\**\*" /> + + + Assets.xcassets\%(_MauiSplashAssetCatalog.RecursiveDir)%(_MauiSplashAssetCatalog.Filename)%(_MauiSplashAssetCatalog.Extension) + Assets.xcassets\%(_MauiSplashAssetCatalog.RecursiveDir)%(_MauiSplashAssetCatalog.Filename)%(_MauiSplashAssetCatalog.Extension) + Assets.xcassets\%(_MauiSplashAssetCatalog.RecursiveDir)%(_MauiSplashAssetCatalog.Filename)%(_MauiSplashAssetCatalog.Extension) + + { readonly string _colors; + readonly string _colorsNight; readonly string _drawable; + readonly string _drawableNight; readonly string _drawable_v31; + readonly string _drawableNight_v31; static readonly Dictionary ResizeMetadata = new() { ["Resize"] = "true" }; @@ -22,8 +26,11 @@ public GenerateSplashAndroidResourcesTests(ITestOutputHelper outputHelper) : base(outputHelper) { _colors = Path.Combine(DestinationDirectory, "values", "maui_colors.xml"); + _colorsNight = Path.Combine(DestinationDirectory, "values-night", "maui_colors.xml"); _drawable = Path.Combine(DestinationDirectory, "drawable", "maui_splash_image.xml"); + _drawableNight = Path.Combine(DestinationDirectory, "drawable-night", "maui_splash_image.xml"); _drawable_v31 = Path.Combine(DestinationDirectory, "drawable-v31", "maui_splash_image.xml"); + _drawableNight_v31 = Path.Combine(DestinationDirectory, "drawable-night-v31", "maui_splash_image.xml"); } protected GenerateSplashAndroidResources GetNewTask(params ITaskItem[] splash) => @@ -49,9 +56,220 @@ public void XmlIsValid(string inputColor, string outputColor) var success = task.Execute(); Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); - AssertColorsFile("maui_colors.xml", outputColor); + AssertDefaultColorsFile("maui_colors.xml", outputColor); AssertImageFile("maui_splash_image.xml", _drawable, "@drawable/appiconfg"); AssertImageFile("maui_splash_image_v31.xml", _drawable_v31, "@drawable/appiconfg"); + Assert.False(File.Exists(_colorsNight), "Night colors should not be generated without dark metadata."); + Assert.False(File.Exists(_drawableNight), "Night drawable should not be generated without dark metadata."); + Assert.False(File.Exists(_drawableNight_v31), "Night v31 drawable should not be generated without dark metadata."); + } + + [Fact] + public void DarkColorGeneratesNightValuesFile() + { + var splash = new TaskItem("images/appiconfg.svg", new Dictionary + { + ["Color"] = "#ffffff", + ["DarkColor"] = "#000000", + }); + + var task = GetNewTask(splash); + var success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + + AssertColorsFile(_colors, "#ffffffff"); + AssertColorsFile(_colorsNight, "#ff000000"); + AssertImageFile("maui_splash_image.xml", _drawable, "@drawable/appiconfg"); + AssertImageFile("maui_splash_image.xml", _drawableNight, "@drawable/appiconfg"); + AssertImageFile("maui_splash_image_v31.xml", _drawable_v31, "@drawable/appiconfg"); + AssertImageFile("maui_splash_image_v31.xml", _drawableNight_v31, "@drawable/appiconfg"); + } + + [Fact] + public void DarkFileGeneratesNightQualifiedImages() + { + var splash = new TaskItem("images/camera.png", new Dictionary + { + ["Resize"] = bool.TrueString, + ["DarkFile"] = "images/camera_color.png", + ["DarkColor"] = "#000000", + }); + + var task = GetNewTask(splash); + var success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + + AssertFileSize("drawable-mdpi/camera.png", 1792, 1792); + AssertFileSize("drawable-night-mdpi/camera.png", 1792, 1792); + AssertFileSize("drawable-xhdpi/camera.png", 3584, 3584); + AssertFileSize("drawable-night-xhdpi/camera.png", 3584, 3584); + AssertImageFile("maui_splash_image.xml", _drawableNight, "@drawable/camera"); + AssertImageFile("maui_splash_image_v31.xml", _drawableNight_v31, "@drawable/camera"); + } + + [Fact] + public void DarkTintColorOnlyGeneratesTintedNightImage() + { + var splash = new TaskItem("images/camera.svg", new Dictionary + { + ["DarkTintColor"] = "#ff0000", + }); + + var task = GetNewTask(splash); + var success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + + AssertFileSize("drawable-mdpi/camera.png", 1792, 1792); + AssertFileSize("drawable-night-mdpi/camera.png", 1792, 1792); + AssertFileContains("drawable-night-mdpi/camera.png", SKColors.Red, 350, 350); + AssertFileDoesNotContain("drawable-mdpi/camera.png", SKColors.Red, 350, 350); + AssertImageFile("maui_splash_image.xml", _drawableNight, "@drawable/camera"); + Assert.False(File.Exists(_colorsNight), "Night colors should not be generated without dark color metadata."); + } + + [Fact] + public void DarkRasterFileWithLightVectorUsesRasterOutputExtension() + { + var splash = new TaskItem("images/appiconfg.svg", new Dictionary + { + ["Resize"] = bool.TrueString, + ["DarkFile"] = "images/camera_color.png", + }); + + var task = GetNewTask(splash); + var success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + + AssertFileExists("drawable-night-mdpi/appiconfg.png"); + AssertFileNotExists("drawable-night-mdpi/appiconfg.svg"); + AssertImageFile("maui_splash_image.xml", _drawableNight, "@drawable/appiconfg"); + AssertImageFile("maui_splash_image_v31.xml", _drawableNight_v31, "@drawable/appiconfg"); + } + + [Fact] + public void ChangingDarkImageFromResizedToOriginalCleansStaleNightDensityImages() + { + var resizedSplash = new TaskItem("images/camera.png", new Dictionary + { + ["Resize"] = bool.TrueString, + ["DarkFile"] = "images/camera_color.png", + }); + + var firstTask = GetNewTask(resizedSplash); + var firstSuccess = firstTask.Execute(); + Assert.True(firstSuccess, LogErrorEvents.FirstOrDefault()?.Message); + AssertFileExists("drawable-night-mdpi/camera.png"); + + var originalSplash = new TaskItem("images/camera.png", new Dictionary + { + ["Resize"] = bool.FalseString, + ["DarkFile"] = "images/camera_color.png", + }); + + var secondTask = GetNewTask(originalSplash); + var secondSuccess = secondTask.Execute(); + Assert.True(secondSuccess, LogErrorEvents.FirstOrDefault()?.Message); + + AssertFileExists("drawable-night/camera.png"); + AssertFileNotExists("drawable-night-mdpi/camera.png"); + AssertFileNotExists("drawable-night-hdpi/camera.png"); + } + + [Fact] + public void ChangingDarkImageFromOriginalToResizedCleansStaleNightOriginalImage() + { + var originalSplash = new TaskItem("images/camera.png", new Dictionary + { + ["Resize"] = bool.FalseString, + ["DarkFile"] = "images/camera_color.png", + }); + + var firstTask = GetNewTask(originalSplash); + var firstSuccess = firstTask.Execute(); + Assert.True(firstSuccess, LogErrorEvents.FirstOrDefault()?.Message); + AssertFileExists("drawable-night/camera.png"); + + var resizedSplash = new TaskItem("images/camera.png", new Dictionary + { + ["Resize"] = bool.TrueString, + ["DarkFile"] = "images/camera_color.png", + }); + + var secondTask = GetNewTask(resizedSplash); + var secondSuccess = secondTask.Execute(); + Assert.True(secondSuccess, LogErrorEvents.FirstOrDefault()?.Message); + + AssertFileNotExists("drawable-night/camera.png"); + AssertFileExists("drawable-night-mdpi/camera.png"); + AssertImageFile("maui_splash_image.xml", _drawableNight, "@drawable/camera"); + } + + [Fact] + public void RemovingDarkColorUpdatesNightValuesToLightColor() + { + var splashWithDarkColor = new TaskItem("images/camera.png", new Dictionary + { + ["DarkColor"] = "#000000", + ["DarkFile"] = "images/camera_color.png", + }); + + var firstTask = GetNewTask(splashWithDarkColor); + var firstSuccess = firstTask.Execute(); + Assert.True(firstSuccess, LogErrorEvents.FirstOrDefault()?.Message); + Assert.True(File.Exists(_colorsNight), "Night colors should be generated when DarkColor is set."); + + var splashWithoutDarkColor = new TaskItem("images/camera.png", new Dictionary + { + ["Color"] = "#ffffff", + ["DarkFile"] = "images/camera_color.png", + }); + + var secondTask = GetNewTask(splashWithoutDarkColor); + var secondSuccess = secondTask.Execute(); + Assert.True(secondSuccess, LogErrorEvents.FirstOrDefault()?.Message); + AssertColorsFile(_colorsNight, "#ffffffff"); + } + + [Fact] + public void RemovingDarkColorAndLightColorRemovesStaleNightValuesFile() + { + var splashWithDarkColor = new TaskItem("images/camera.png", new Dictionary + { + ["DarkColor"] = "#000000", + ["DarkFile"] = "images/camera_color.png", + }); + + var firstTask = GetNewTask(splashWithDarkColor); + var firstSuccess = firstTask.Execute(); + Assert.True(firstSuccess, LogErrorEvents.FirstOrDefault()?.Message); + Assert.True(File.Exists(_colorsNight), "Night colors should be generated when DarkColor is set."); + + var splashWithoutColors = new TaskItem("images/camera.png", new Dictionary + { + ["DarkFile"] = "images/camera_color.png", + }); + + var secondTask = GetNewTask(splashWithoutColors); + var secondSuccess = secondTask.Execute(); + Assert.True(secondSuccess, LogErrorEvents.FirstOrDefault()?.Message); + Assert.False(File.Exists(_colorsNight), "Night colors should be removed when dark mode remains but no color metadata remains."); + } + + [Fact] + public void DarkFileWithLightColorWritesNightValuesFileEvenWhenColorsMatch() + { + var splash = new TaskItem("images/camera.png", new Dictionary + { + ["Color"] = "#ffffff", + ["DarkFile"] = "images/camera_color.png", + }); + + var task = GetNewTask(splash); + var success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + + AssertColorsFile(_colors, "#ffffffff"); + AssertColorsFile(_colorsNight, "#ffffffff"); } [Theory] @@ -292,15 +510,18 @@ public void SingleImageWithBaseSizeSucceeds(string alias, string outputName) AssertFileSize($"drawable-xhdpi/{outputName}.png", 88, 88); } - void AssertColorsFile(string expectedFilename, string color) + void AssertDefaultColorsFile(string expectedFilename, string color) + => AssertColorsFile(Path.Combine(DestinationDirectory, "values", expectedFilename), color); + + void AssertColorsFile(string actualFilename, string color) { - var expectedXml = File.ReadAllText($"testdata/androidsplash/" + expectedFilename) + var expectedXml = File.ReadAllText($"testdata/androidsplash/maui_colors.xml") .Replace("{maui_splash_color}", color, StringComparison.OrdinalIgnoreCase); - var actual = XElement.Load(_colors); + var actual = XElement.Load(actualFilename); var expected = XElement.Parse(expectedXml); - Assert.True(XNode.DeepEquals(actual, expected), $"{_colors} did not match:\n{actual}"); + Assert.True(XNode.DeepEquals(actual, expected), $"{actualFilename} did not match:\n{actual}"); } void AssertImageFile(string expectedFilename, string actualFilename, string image, string width = "108", string height = "108") diff --git a/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashAssetCatalogTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashAssetCatalogTests.cs new file mode 100644 index 000000000000..24ace2f5df30 --- /dev/null +++ b/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashAssetCatalogTests.cs @@ -0,0 +1,311 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Xml.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using SkiaSharp; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Maui.Resizetizer.Tests +{ + public class GenerateSplashAssetCatalogTests : MSBuildTaskTestFixture + { + public GenerateSplashAssetCatalogTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + protected GenerateSplashAssetCatalog GetNewTask(params ITaskItem[] splash) => + new() + { + IntermediateOutputPath = DestinationDirectory, + InputsFile = "mauisplash.inputs", + MauiSplashScreen = splash, + BuildEngine = this, + }; + + [Fact] + public void DarkMetadataGeneratesImageAndColorAssetCatalogs() + { + var splash = new TaskItem("images/camera.png", new Dictionary + { + ["Color"] = "#ffffff", + ["DarkColor"] = "#000000", + ["DarkFile"] = "images/camera_color.png", + ["BaseSize"] = "44", + }); + + var task = GetNewTask(splash); + var success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + + AssertFileExists("Assets.xcassets/MauiSplashImage.imageset/Contents.json"); + AssertFileExists("Assets.xcassets/MauiSplashColor.colorset/Contents.json"); + AssertFileSize("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImage.png", 44, 44); + AssertFileSize("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImage@2x.png", 88, 88); + AssertFileSize("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImageDark.png", 44, 44); + + using var imageJson = JsonDocument.Parse(File.ReadAllText(Path.Combine(DestinationDirectory, "Assets.xcassets", "MauiSplashImage.imageset", "Contents.json"))); + var images = imageJson.RootElement.GetProperty("images").EnumerateArray().ToArray(); + Assert.Contains(images, image => !image.TryGetProperty("appearances", out _)); + Assert.Contains(images, image => GetAppearanceValue(image) == "light"); + Assert.Contains(images, image => GetAppearanceValue(image) == "dark"); + + using var colorJson = JsonDocument.Parse(File.ReadAllText(Path.Combine(DestinationDirectory, "Assets.xcassets", "MauiSplashColor.colorset", "Contents.json"))); + var colors = colorJson.RootElement.GetProperty("colors").EnumerateArray().ToArray(); + Assert.Contains(colors, color => !color.TryGetProperty("appearances", out _)); + Assert.Contains(colors, color => GetAppearanceValue(color) == "light"); + Assert.All(colors, color => + Assert.Equal("1.0", color.GetProperty("color").GetProperty("components").GetProperty("alpha").GetString())); + var darkColor = Assert.Single(colors, color => GetAppearanceValue(color) == "dark"); + Assert.Equal("0", darkColor.GetProperty("color").GetProperty("components").GetProperty("red").GetString()); + Assert.Equal("0", darkColor.GetProperty("color").GetProperty("components").GetProperty("green").GetString()); + Assert.Equal("0", darkColor.GetProperty("color").GetProperty("components").GetProperty("blue").GetString()); + } + + [Fact] + public void RasterWithoutBaseSizeGeneratesAllReferencedImageFiles() + { + var splash = new TaskItem("images/camera.png", new Dictionary + { + ["DarkFile"] = "images/camera_color.png", + }); + + var task = GetNewTask(splash); + var success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + + AssertAllImageSetFilesExist(); + } + + [Fact] + public void RasterWithoutResizePreservesOriginalImageDimensions() + { + var splash = new TaskItem("images/camera.png", new Dictionary + { + ["Resize"] = bool.FalseString, + ["DarkFile"] = "images/camera_color.png", + }); + + var task = GetNewTask(splash); + var success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + + AssertFileSize("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImage.png", 1792, 1792); + AssertFileSize("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImage@2x.png", 1792, 1792); + AssertFileSize("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImage@3x.png", 1792, 1792); + AssertFileSize("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImageDark.png", 256, 256); + AssertFileSize("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImageDark@2x.png", 256, 256); + AssertFileSize("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImageDark@3x.png", 256, 256); + } + + [Fact] + public void NonPngRasterWithoutResizeUsesMatchingAssetFilenames() + { + var inputDirectory = Path.Combine(Path.GetTempPath(), "Microsoft.Maui.Resizetizer.Tests", nameof(GenerateSplashAssetCatalogTests), Path.GetRandomFileName()); + var lightFile = CopyImageWithExtension(inputDirectory, "camera.jpg", "images/camera.png"); + var darkFile = CopyImageWithExtension(inputDirectory, "camera_color.jpg", "images/camera_color.png"); + var splash = new TaskItem(lightFile, new Dictionary + { + ["Resize"] = bool.FalseString, + ["DarkFile"] = darkFile, + }); + var imageSetPath = Path.Combine(DestinationDirectory, "Assets.xcassets", "MauiSplashImage.imageset"); + Directory.CreateDirectory(imageSetPath); + File.WriteAllText(Path.Combine(imageSetPath, "MauiSplashImage.png"), "stale"); + File.WriteAllText(Path.Combine(imageSetPath, "MauiSplashImageDark.png"), "stale"); + + var task = GetNewTask(splash); + var success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + + AssertFileExists("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImage.jpg"); + AssertFileExists("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImage@2x.jpg"); + AssertFileExists("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImageDark.jpg"); + AssertFileExists("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImageDark@2x.jpg"); + AssertFileNotExists("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImage.png"); + AssertFileNotExists("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImageDark.png"); + + using var imageJson = JsonDocument.Parse(File.ReadAllText(Path.Combine(DestinationDirectory, "Assets.xcassets", "MauiSplashImage.imageset", "Contents.json"))); + var filenames = imageJson.RootElement.GetProperty("images").EnumerateArray() + .Select(image => image.GetProperty("filename").GetString()) + .ToArray(); + Assert.Contains("MauiSplashImage.jpg", filenames); + Assert.Contains("MauiSplashImage@2x.jpg", filenames); + Assert.Contains("MauiSplashImageDark.jpg", filenames); + Assert.Contains("MauiSplashImageDark@2x.jpg", filenames); + Assert.DoesNotContain("MauiSplashImage.png", filenames); + Assert.DoesNotContain("MauiSplashImageDark.png", filenames); + } + + [Fact] + public void DarkFileWithoutColorDoesNotGenerateColorAsset() + { + var splash = new TaskItem("images/camera.png", new Dictionary + { + ["DarkFile"] = "images/camera_color.png", + }); + + var task = GetNewTask(splash); + var success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + + AssertFileExists("Assets.xcassets/MauiSplashImage.imageset/Contents.json"); + AssertFileNotExists("Assets.xcassets/MauiSplashColor.colorset/Contents.json"); + } + + [Fact] + public void RemovingColorMetadataDeletesStaleColorAsset() + { + var splashWithColor = new TaskItem("images/camera.png", new Dictionary + { + ["Color"] = "#ffffff", + ["DarkColor"] = "#000000", + ["DarkFile"] = "images/camera_color.png", + ["BaseSize"] = "44", + }); + + var firstTask = GetNewTask(splashWithColor); + var firstSuccess = firstTask.Execute(); + Assert.True(firstSuccess, LogErrorEvents.FirstOrDefault()?.Message); + AssertFileExists("Assets.xcassets/MauiSplashColor.colorset/Contents.json"); + + var splashWithoutColor = new TaskItem("images/camera.png", new Dictionary + { + ["DarkFile"] = "images/camera_color.png", + ["BaseSize"] = "44", + }); + + var secondTask = GetNewTask(splashWithoutColor); + var secondSuccess = secondTask.Execute(); + Assert.True(secondSuccess, LogErrorEvents.FirstOrDefault()?.Message); + + AssertFileNotExists("Assets.xcassets/MauiSplashColor.colorset/Contents.json"); + } + + [Fact] + public void DarkTintColorOnlyGeneratesTintedDarkImage() + { + var splash = new TaskItem("images/camera.svg", new Dictionary + { + ["DarkTintColor"] = "#ff0000", + ["BaseSize"] = "44", + }); + + var task = GetNewTask(splash); + var success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + + AssertFileExists("Assets.xcassets/MauiSplashImage.imageset/Contents.json"); + AssertFileSize("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImage.png", 44, 44); + AssertFileSize("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImageDark.png", 44, 44); + AssertFileContains("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImageDark.png", SKColors.Red); + AssertFileDoesNotContain("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImage.png", SKColors.Red); + AssertFileNotExists("Assets.xcassets/MauiSplashColor.colorset/Contents.json"); + } + + [Fact] + public void DarkColorOnlyGeneratesImageSetAndColorAssetWithWarning() + { + var splash = new TaskItem("images/camera.png", new Dictionary + { + ["DarkColor"] = "#000000", + ["BaseSize"] = "44", + }); + + var task = GetNewTask(splash); + var success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + + AssertFileExists("Assets.xcassets/MauiSplashImage.imageset/Contents.json"); + AssertFileExists("Assets.xcassets/MauiSplashColor.colorset/Contents.json"); + AssertFileSize("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImage.png", 44, 44); + AssertFileSize("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImageDark.png", 44, 44); + Assert.Contains(LogWarningEvents, warning => warning.Message.Contains("DarkColor was specified without Color", StringComparison.Ordinal)); + + using var colorJson = JsonDocument.Parse(File.ReadAllText(Path.Combine(DestinationDirectory, "Assets.xcassets", "MauiSplashColor.colorset", "Contents.json"))); + var colors = colorJson.RootElement.GetProperty("colors").EnumerateArray().ToArray(); + var anyColor = Assert.Single(colors, color => !color.TryGetProperty("appearances", out _)); + var darkColor = Assert.Single(colors, color => GetAppearanceValue(color) == "dark"); + Assert.Equal("1.0", anyColor.GetProperty("color").GetProperty("components").GetProperty("red").GetString()); + Assert.Equal("0", darkColor.GetProperty("color").GetProperty("components").GetProperty("red").GetString()); + } + + [Fact] + public void LaunchScreenPlistUsesNamedAssets() + { + var task = new CreatePartialInfoPlistTask + { + IntermediateOutputPath = DestinationDirectory, + PlistName = "MauiInfo.plist", + LaunchScreenImage = "MauiSplashImage", + LaunchScreenColor = "MauiSplashColor", + BuildEngine = this, + }; + + var success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + + var plist = XElement.Load(Path.Combine(DestinationDirectory, "MauiInfo.plist")); + var text = plist.ToString(); + Assert.Contains("UILaunchScreen", text, StringComparison.Ordinal); + Assert.Contains("UIImageName", text, StringComparison.Ordinal); + Assert.Contains("MauiSplashImage", text, StringComparison.Ordinal); + Assert.Contains("UIColorName", text, StringComparison.Ordinal); + Assert.Contains("MauiSplashColor", text, StringComparison.Ordinal); + Assert.DoesNotContain("UILaunchStoryboardName", text, StringComparison.Ordinal); + } + + [Fact] + public void LaunchScreenPlistOmitsColorWhenNoNamedColorIsProvided() + { + var task = new CreatePartialInfoPlistTask + { + IntermediateOutputPath = DestinationDirectory, + PlistName = "MauiInfo.plist", + LaunchScreenImage = "MauiSplashImage", + BuildEngine = this, + }; + + var success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + + var plist = XElement.Load(Path.Combine(DestinationDirectory, "MauiInfo.plist")); + var text = plist.ToString(); + Assert.Contains("UILaunchScreen", text, StringComparison.Ordinal); + Assert.Contains("UIImageName", text, StringComparison.Ordinal); + Assert.DoesNotContain("UIColorName", text, StringComparison.Ordinal); + Assert.DoesNotContain("MauiSplashColor", text, StringComparison.Ordinal); + } + + void AssertAllImageSetFilesExist() + { + var imageSetPath = Path.Combine(DestinationDirectory, "Assets.xcassets", "MauiSplashImage.imageset"); + using var imageJson = JsonDocument.Parse(File.ReadAllText(Path.Combine(imageSetPath, "Contents.json"))); + + foreach (var image in imageJson.RootElement.GetProperty("images").EnumerateArray()) + { + if (image.TryGetProperty("filename", out var filename)) + Assert.True(File.Exists(Path.Combine(imageSetPath, filename.GetString()!)), $"Expected {filename.GetString()} to exist."); + } + } + + static string GetAppearanceValue(JsonElement element) => + element.TryGetProperty("appearances", out var appearances) && appearances.GetArrayLength() > 0 + ? appearances[0].GetProperty("value").GetString() + : null; + + static string CopyImageWithExtension(string inputDirectory, string filename, string sourceFile) + { + Directory.CreateDirectory(inputDirectory); + var destination = Path.Combine(inputDirectory, filename); + File.Copy(sourceFile, destination); + + return destination; + } + } +} diff --git a/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashStoryboardTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashStoryboardTests.cs index 13dea67d637c..7d506eeb61c7 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashStoryboardTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashStoryboardTests.cs @@ -82,6 +82,26 @@ public void XmlIsValidForNoSplash() AssertFile(_storyboard); } + [Fact] + public void RemovesThemedAssetCatalogWhenGeneratingStoryboard() + { + var assetCatalog = Path.Combine(DestinationDirectory, "Assets.xcassets"); + Directory.CreateDirectory(Path.Combine(assetCatalog, "MauiSplashImage.imageset")); + File.WriteAllText(Path.Combine(assetCatalog, "MauiSplashImage.imageset", "Contents.json"), "{}"); + + var splash = new TaskItem("images/appiconfg.svg", new Dictionary + { + ["Color"] = "#ffffff", + }); + + var task = GetNewTask(splash); + var success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + + Assert.False(Directory.Exists(assetCatalog), "Themed asset catalog should be removed when falling back to storyboard generation."); + Assert.True(File.Exists(_storyboard), "Storyboard should be generated."); + } + [Theory] [InlineData(null, "appiconfg.png")] [InlineData("images/CustomAlias.svg", "CustomAlias.png")] diff --git a/src/SingleProject/Resizetizer/test/UnitTests/MauiResizetizerTargetsTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/MauiResizetizerTargetsTests.cs new file mode 100644 index 000000000000..64b31f3df9c4 --- /dev/null +++ b/src/SingleProject/Resizetizer/test/UnitTests/MauiResizetizerTargetsTests.cs @@ -0,0 +1,229 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Maui.Resizetizer.Tests +{ + public class MauiResizetizerTargetsTests : BaseTest + { + public MauiResizetizerTargetsTests(ITestOutputHelper output) + : base(output) + { + } + + [Fact] + public void ProcessMauiSplashScreensSelectsFirstSplashForThemedAppleMetadata() + { + var targetFile = GetTargetFile(); + var metadataElements = GetSplashMetadataElements(targetFile); + var projectFile = Path.Combine(DestinationDirectory, "SelectFirstSplash.proj"); + Directory.CreateDirectory(DestinationDirectory); + + File.WriteAllText(projectFile, + $$""" + + + <_ResizetizerIsiOSApp>True + 14.0 + + + + + + + {{metadataElements}} + + + + + + + + + + """); + + var output = RunDotnetMSBuild(projectFile); + + Assert.Contains("First=splash.png; DarkFile=first-dark.png; FirstColor=; Themed=true; Color=", output, StringComparison.Ordinal); + } + + [Fact] + public void ProcessMauiSplashScreensWarnsWhenThemedAppleSplashIsUnsupported() + { + var targetFile = GetTargetFile(); + var metadataAndWarnings = GetSplashMetadataElements(targetFile, includeWarnings: true); + var projectFile = Path.Combine(DestinationDirectory, "UnsupportedThemedSplash.proj"); + Directory.CreateDirectory(DestinationDirectory); + + File.WriteAllText(projectFile, + $$""" + + + <_ResizetizerIsiOSSpecificApp>True + 13.0 + + + + + + {{metadataAndWarnings}} + + + + + """); + + var output = RunDotnetMSBuild(projectFile); + + Assert.Contains("Themed MauiSplashScreen assets require iOS or iPadOS 14.0 or later", output, StringComparison.Ordinal); + } + + [Fact] + public void ProcessMauiSplashScreensPassesNormalizedSplashItemsToAndroid() + { + var targetFile = GetTargetFile(); + + var doc = XDocument.Load(targetFile); + var processSplashScreens = Assert.Single(doc.Root.Elements().Where(e => e.Name.LocalName == "Target" && e.Attribute("Name")?.Value == "ProcessMauiSplashScreens")); + var generateAndroid = Assert.Single(processSplashScreens.Elements().Where(e => e.Name.LocalName == "GenerateSplashAndroidResources")); + + Assert.Equal("@(_MauiSplashScreenWithHashes)", generateAndroid.Attribute("MauiSplashScreen")?.Value); + } + + [Fact] + public void ResizetizeCollectItemsHashesOnlyFirstSplashDarkFile() + { + var targetFile = GetTargetFile(); + var darkFileHashElements = GetDarkFileHashElements(targetFile); + var projectFile = Path.Combine(DestinationDirectory, "FirstDarkFileHash.proj"); + var firstDarkFile = Path.Combine(DestinationDirectory, "first-dark.png"); + var secondDarkFile = Path.Combine(DestinationDirectory, "second-dark.png"); + Directory.CreateDirectory(DestinationDirectory); + + File.WriteAllText(projectFile, + $$""" + + + <_MauiSplashScreenWithHashes Include="first.png" DarkFile="{{firstDarkFile}}" /> + <_MauiSplashScreenWithHashes Include="second.png" DarkFile="{{secondDarkFile}}" /> + + + {{darkFileHashElements}} + + + + + """); + + var output = RunDotnetMSBuild(projectFile); + + Assert.Contains($"DarkFiles={firstDarkFile}", output, StringComparison.Ordinal); + } + + static string GetSplashMetadataElements(string targetFile, bool includeWarnings = false) + { + var doc = XDocument.Load(targetFile); + var processSplashScreens = Assert.Single(doc.Root.Elements().Where(e => e.Name.LocalName == "Target" && e.Attribute("Name")?.Value == "ProcessMauiSplashScreens")); + var propertyGroups = processSplashScreens + .Elements() + .Where(e => e.Name.LocalName == "PropertyGroup") + .ToArray(); + + var firstSplashMetadata = Assert.Single(propertyGroups.Where(e => e.Elements().Any(child => child.Name.LocalName == "_MauiHasSplashScreens"))); + var themedSplashMetadata = Assert.Single(propertyGroups.Where(e => e.Elements().Any(child => child.Name.LocalName == "_MauiHasThemedSplashScreen"))); + + var elements = includeWarnings + ? new[] { firstSplashMetadata, themedSplashMetadata }.Concat(processSplashScreens.Elements().Where(e => e.Name.LocalName == "Warning")) + : new[] { firstSplashMetadata, themedSplashMetadata }; + + return string.Join(Environment.NewLine, elements.Select(e => StripNamespace(e).ToString(SaveOptions.DisableFormatting))); + } + + static string GetDarkFileHashElements(string targetFile) + { + var doc = XDocument.Load(targetFile); + var collectItems = Assert.Single(doc.Root.Elements().Where(e => e.Name.LocalName == "Target" && e.Attribute("Name")?.Value == "ResizetizeCollectItems")); + var darkFileUpdate = Assert.Single(collectItems.Elements().Where(e => + e.Name.LocalName == "ItemGroup" && + e.Elements().Any(child => child.Name.LocalName == "_MauiSplashScreenWithHashes" && child.Attribute("DarkFile") is not null))); + var firstDarkFileMetadata = Assert.Single(collectItems.Elements().Where(e => + e.Name.LocalName == "PropertyGroup" && + e.Elements().Any(child => child.Name.LocalName == "_MauiFirstSplashScreenDarkFileForHash"))); + var darkFileHashItem = Assert.Single(collectItems.Elements().Where(e => + e.Name.LocalName == "ItemGroup" && + e.Elements().Any(child => child.Name.LocalName == "_MauiSplashScreenDarkFile"))); + + return string.Join(Environment.NewLine, new[] { darkFileUpdate, firstDarkFileMetadata, darkFileHashItem }.Select(e => StripNamespace(e).ToString(SaveOptions.DisableFormatting))); + } + + static XElement StripNamespace(XElement element) => + new( + element.Name.LocalName, + element.Attributes().Where(a => !a.IsNamespaceDeclaration).Select(a => new XAttribute(a.Name.LocalName, a.Value)), + element.Nodes().Select(n => n is XElement child ? StripNamespace(child) : n)); + + string RunDotnetMSBuild(string projectFile) + { + const int timeoutMilliseconds = 60_000; + var startInfo = new ProcessStartInfo + { + FileName = GetDotNetHost(), + WorkingDirectory = DestinationDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + startInfo.ArgumentList.Add("msbuild"); + startInfo.ArgumentList.Add(projectFile); + startInfo.ArgumentList.Add("-t:Validate"); + startInfo.ArgumentList.Add("-nologo"); + startInfo.ArgumentList.Add("-v:minimal"); + + using var process = Process.Start(startInfo); + Assert.NotNull(process); + + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + if (!process.WaitForExit(timeoutMilliseconds)) + { + process.Kill(entireProcessTree: true); + process.WaitForExit(); + var timedOutOutput = outputTask.GetAwaiter().GetResult(); + var timedOutError = errorTask.GetAwaiter().GetResult(); + Assert.Fail($"MSBuild timed out after {timeoutMilliseconds}ms.\n{timedOutOutput}{timedOutError}"); + } + + var output = outputTask.GetAwaiter().GetResult(); + var error = errorTask.GetAwaiter().GetResult(); + + Output.WriteLine(output); + Output.WriteLine(error); + + Assert.Equal(0, process.ExitCode); + + return output + error; + } + + static string GetDotNetHost() + { + var dotnetHost = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH"); + + return !string.IsNullOrWhiteSpace(dotnetHost) && File.Exists(dotnetHost) + ? dotnetHost + : "dotnet"; + } + + static string GetTargetFile() + { + var targetFile = Path.Combine(AppContext.BaseDirectory, "Microsoft.Maui.Resizetizer.After.targets"); + Assert.True(File.Exists(targetFile), $"Expected target file to be copied to test output: {targetFile}"); + + return targetFile; + } + } +} diff --git a/src/SingleProject/Resizetizer/test/UnitTests/ResizeImageInfoTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/ResizeImageInfoTests.cs index ee07b7e8a423..456052385caf 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/ResizeImageInfoTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/ResizeImageInfoTests.cs @@ -1,9 +1,65 @@ +using System.Collections.Generic; +using System; +using Microsoft.Build.Utilities; +using SkiaSharp; using Xunit; namespace Microsoft.Maui.Resizetizer.Tests { public class ResizeImageInfoTests { + public class Parse + { + [Fact] + public void SupportsDarkSplashMetadata() + { + var image = new TaskItem("images/camera.png", new Dictionary + { + ["DarkFile"] = "images/camera_color.png", + ["DarkColor"] = "#000000", + ["DarkTintColor"] = "#ffffff", + }); + + var info = ResizeImageInfo.Parse(image); + + Assert.Equal(SKColors.Black, info.DarkColor); + Assert.Equal(SKColors.White, info.DarkTintColor); + Assert.EndsWith("camera_color.png", info.DarkFilename, StringComparison.Ordinal); + Assert.False(info.DarkIsVector); + } + + [Fact] + public void DarkTintColorFallsBackToTintColorOnlyWhenDarkFileIsNotSpecified() + { + var image = new TaskItem("images/camera.png", new Dictionary + { + ["TintColor"] = "#ff0000", + }); + + var info = ResizeImageInfo.Parse(image); + var darkInfo = info.CreateDarkVariant(); + + Assert.Equal(SKColors.Red, darkInfo.TintColor); + Assert.Equal(info.Filename, darkInfo.Filename); + } + + [Fact] + public void DarkTintColorDoesNotFallbackToTintColorWhenDarkFileIsSpecified() + { + var image = new TaskItem("images/camera.png", new Dictionary + { + ["TintColor"] = "#ff0000", + ["DarkFile"] = "images/camera_color.png", + }); + + var info = ResizeImageInfo.Parse(image); + var darkInfo = info.CreateDarkVariant(); + + Assert.Null(darkInfo.TintColor); + Assert.EndsWith("camera_color.png", darkInfo.Filename, StringComparison.Ordinal); + } + } + public class IsVector { [Theory] diff --git a/src/SingleProject/Resizetizer/test/UnitTests/Resizetizer.UnitTests.csproj b/src/SingleProject/Resizetizer/test/UnitTests/Resizetizer.UnitTests.csproj index 87fb8cdf65fa..92dd03795da9 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/Resizetizer.UnitTests.csproj +++ b/src/SingleProject/Resizetizer/test/UnitTests/Resizetizer.UnitTests.csproj @@ -26,6 +26,7 @@ +