diff --git a/cmd/fyne/internal/commands/data.go b/cmd/fyne/internal/commands/data.go index 88c1d6af..c2572cbf 100644 --- a/cmd/fyne/internal/commands/data.go +++ b/cmd/fyne/internal/commands/data.go @@ -9,6 +9,7 @@ type appData struct { ResGoString string Release, rawIcon bool AdaptiveIcon *metadata.AdaptiveIcon + Splash *metadata.Splash CustomMetadata map[string]string Migrations map[string]bool CanOpen *metadata.CanOpen @@ -48,6 +49,7 @@ func (a *appData) mergeMetadata(data *metadata.FyneApp) { a.AppBuild = data.Details.Build } a.AdaptiveIcon = data.AdaptiveIcon + a.Splash = data.Splash if a.Release { a.appendCustomMetadata(data.Release) diff --git a/cmd/fyne/internal/commands/package-mobile.go b/cmd/fyne/internal/commands/package-mobile.go index 888424eb..b59c99cb 100644 --- a/cmd/fyne/internal/commands/package-mobile.go +++ b/cmd/fyne/internal/commands/package-mobile.go @@ -2,11 +2,13 @@ package commands import ( "bytes" + "errors" "fmt" "os" "os/exec" "path/filepath" "strconv" + "strings" "fyne.io/fyne/v2" "fyne.io/tools/cmd/fyne/internal/mobile" @@ -14,6 +16,10 @@ import ( "fyne.io/tools/cmd/fyne/internal/util" ) +// splashIconSize is the point size of the centred launch screen icon, matching the 288dp +// drawable size of the Android 12+ system splash so the launch looks the same on both platforms. +const splashIconSize = 288 + func (p *Packager) packageAndroid(arch string, tags []string) error { iconFG, iconBG, iconMono := "", "", "" if p.AdaptiveIcon != nil { @@ -23,12 +29,12 @@ func (p *Packager) packageAndroid(arch string, tags []string) error { } return mobile.RunNewBuild(arch, p.AppID, p.icon, p.Name, p.AppVersion, p.AppBuild, p.release, p.distribution, - "", "", tags, iconFG, iconBG, iconMono, p.verbose, p.Capabilities) + "", "", tags, iconFG, iconBG, iconMono, p.verbose, p.Capabilities, p.appData.Splash) } func (p *Packager) packageIOS(target string, tags []string) error { err := mobile.RunNewBuild(target, p.AppID, p.icon, p.Name, p.AppVersion, p.AppBuild, p.release, p.distribution, - p.certificate, p.profile, tags, "", "", "", p.verbose, p.Capabilities) + p.certificate, p.profile, tags, "", "", "", p.verbose, p.Capabilities, nil) if err != nil { return err } @@ -60,14 +66,109 @@ func (p *Packager) packageIOS(target string, tags []string) error { } } + if p.appData.Splash != nil { + splashIcon := p.appData.Splash.Icon + if splashIcon == "" { + splashIcon = p.icon + } + if err = writeSplashIconSet(assetDir, splashIcon); err != nil { + return err + } + } + appDir := filepath.Join(p.dir, mobile.AppOutputName(p.os, p.Name, p.release)) if p.verbose { fmt.Println("Creating icons for", relPath(appDir)) } - - return runCmdCaptureOutput("xcrun", "actool", "Images.xcassets", "--compile", appDir, "--platform", + err = runCmdCaptureOutput("xcrun", "actool", "Images.xcassets", "--compile", appDir, "--platform", "iphoneos", "--target-device", "iphone", "--minimum-deployment-target", "9.0", "--app-icon", "AppIcon", "--output-format", "human-readable-text", "--output-partial-info-plist", "/dev/null") + if err != nil { + return err + } + + if p.appData.Splash != nil { + if err = compileLaunchScreen(p.dir, appDir, p.appData.Splash.Background); err != nil { + return err + } + } + + if target == "iossimulator" { + // The build signed the bundle before the resources above were added, and iOS ignores + // a launch storyboard that is not sealed by the signature, so sign it again. + return runCmdCaptureOutput("codesign", "--force", "--sign", "-", appDir) + } + return nil +} + +// writeSplashIconSet adds the launch screen icon to the asset catalog as SplashIcon, scaled for 3x displays. +func writeSplashIconSet(assetDir, icon string) error { + imageDir := util.EnsureSubDir(assetDir, "SplashIcon.imageset") + if err := util.WriteScaledPNG(icon, filepath.Join(imageDir, "splash.png"), splashIconSize*3); err != nil { + return fmt.Errorf("failed to write splash icon: %w", err) + } + + return os.WriteFile(filepath.Join(imageDir, "Contents.json"), []byte(`{ + "images" : [ + { + "filename" : "splash.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +}`), 0o644) +} + +// compileLaunchScreen builds the LaunchScreen storyboard named in Info.plist into the app bundle, +// with the splash background colour and the centred SplashIcon. +func compileLaunchScreen(dir, appDir, background string) error { + r, g, b, err := parseHexColor(background) + if err != nil { + return fmt.Errorf("invalid splash background colour %q: %w", background, err) + } + data := struct { + Red, Green, Blue float64 + IconSize int + }{Red: r, Green: g, Blue: b, IconSize: splashIconSize} + + storyboard := filepath.Join(dir, "LaunchScreen.storyboard") + out, err := os.Create(storyboard) + if err != nil { + return err + } + defer os.Remove(storyboard) + err = templates.LaunchScreenIOS.Execute(out, data) + _ = out.Close() + if err != nil { + return fmt.Errorf("failed to write launch screen storyboard: %w", err) + } + + return runCmdCaptureOutput("xcrun", "ibtool", "--compile", filepath.Join(appDir, "LaunchScreen.storyboardc"), + "--target-device", "iphone", "--minimum-deployment-target", "9.0", storyboard) +} + +// parseHexColor converts a "#RRGGBB" colour into 0-1 components, defaulting to white. +func parseHexColor(hex string) (r, g, b float64, err error) { + if hex == "" { + return 1, 1, 1, nil + } + hex = strings.TrimPrefix(hex, "#") + if len(hex) != 6 { + return 0, 0, 0, errors.New("expected #RRGGBB") + } + var channels [3]float64 + for i := range channels { + v, err := strconv.ParseUint(hex[i*2:i*2+2], 16, 8) + if err != nil { + return 0, 0, 0, err + } + channels[i] = float64(v) / 255 + } + return channels[0], channels[1], channels[2], nil } func copyResizeIcon(size int, dir, source string) error { diff --git a/cmd/fyne/internal/commands/package-mobile_test.go b/cmd/fyne/internal/commands/package-mobile_test.go new file mode 100644 index 00000000..bec0b39d --- /dev/null +++ b/cmd/fyne/internal/commands/package-mobile_test.go @@ -0,0 +1,24 @@ +package commands + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_parseHexColor(t *testing.T) { + r, g, b, err := parseHexColor("#015952") + assert.Nil(t, err) + assert.InDelta(t, 1.0/255, r, 0.001) + assert.InDelta(t, 0x59/255.0, g, 0.001) + assert.InDelta(t, 0x52/255.0, b, 0.001) + + r, g, b, err = parseHexColor("") + assert.Nil(t, err) + assert.Equal(t, []float64{1, 1, 1}, []float64{r, g, b}) + + _, _, _, err = parseHexColor("#0159") + assert.NotNil(t, err) + _, _, _, err = parseHexColor("#01595z") + assert.NotNil(t, err) +} diff --git a/cmd/fyne/internal/commands/package-util-mock_test.go b/cmd/fyne/internal/commands/package-util-mock_test.go index 38f7f4c1..2bd57eda 100644 --- a/cmd/fyne/internal/commands/package-util-mock_test.go +++ b/cmd/fyne/internal/commands/package-util-mock_test.go @@ -164,6 +164,10 @@ func (m mockUtil) MakePathRelativeTo(root, path string) string { return utilMakePathRelativeToMock(root, path) } +func (m mockUtil) WriteScaledPNG(src, dst string, size int) error { + return nil +} + func (m mockUtil) RequireAndroidSDK() error { return utilRequireAndroidSDKMock() } diff --git a/cmd/fyne/internal/commands/package-util.go b/cmd/fyne/internal/commands/package-util.go index ae0b3a9a..0ee3ec67 100644 --- a/cmd/fyne/internal/commands/package-util.go +++ b/cmd/fyne/internal/commands/package-util.go @@ -15,6 +15,7 @@ type packagerUtil interface { EnsureSubDir(parent, name string) string EnsureAbsPath(path string) string MakePathRelativeTo(root, path string) string + WriteScaledPNG(src, dst string, size int) error RequireAndroidSDK() error AndroidBuildToolsPath() string @@ -54,6 +55,10 @@ func (d defaultUtil) MakePathRelativeTo(root, path string) string { return util.MakePathRelativeTo(root, path) } +func (d defaultUtil) WriteScaledPNG(src, dst string, size int) error { + return util.WriteScaledPNG(src, dst, size) +} + func (d defaultUtil) RequireAndroidSDK() error { return util.RequireAndroidSDK() } diff --git a/cmd/fyne/internal/commands/package.go b/cmd/fyne/internal/commands/package.go index 31b02090..28d16abf 100644 --- a/cmd/fyne/internal/commands/package.go +++ b/cmd/fyne/internal/commands/package.go @@ -297,6 +297,9 @@ func (p *Packager) validate() (err error) { if data.Details.Icon != "" { data.Details.Icon = pkgUtil.MakePathRelativeTo(p.srcDir, data.Details.Icon) } + if data.Splash != nil && data.Splash.Icon != "" { + data.Splash.Icon = util.MakePathRelativeTo(p.srcDir, data.Splash.Icon) + } p.mergeMetadata(data) p.sourceMetadata = data.Source diff --git a/cmd/fyne/internal/metadata/data.go b/cmd/fyne/internal/metadata/data.go index be21243f..0108244c 100644 --- a/cmd/fyne/internal/metadata/data.go +++ b/cmd/fyne/internal/metadata/data.go @@ -6,6 +6,7 @@ type FyneApp struct { Description string `toml:",omitempty"` Details AppDetails AdaptiveIcon *AdaptiveIcon `toml:",omitempty"` + Splash *Splash `toml:",omitempty"` Development map[string]string `toml:",omitempty"` Release map[string]string `toml:",omitempty"` @@ -31,6 +32,14 @@ type AdaptiveIcon struct { Monochrome string `toml:",omitempty"` } +// Splash describes the native launch screen that iOS and Android show while the app loads. +// Background is a hex colour such as "#015952" and Icon is an optional square PNG that will be centered. +// The icon defaults to the adaptive icon foreground or the app icon. +type Splash struct { + Background string `toml:",omitempty"` + Icon string `toml:",omitempty"` +} + type AppSource struct { Repo, Dir string `toml:",omitempty"` } diff --git a/cmd/fyne/internal/metadata/load_test.go b/cmd/fyne/internal/metadata/load_test.go index 6d61508f..814cff6a 100644 --- a/cmd/fyne/internal/metadata/load_test.go +++ b/cmd/fyne/internal/metadata/load_test.go @@ -29,6 +29,10 @@ func TestLoadAppMetadata(t *testing.T) { assert.NotNil(t, data.Source) assert.Equal(t, data.Source.Repo, "https://github.com/fyne-io/fyne") assert.Equal(t, data.Source.Dir, "internal/metadata/testdata") + + assert.NotNil(t, data.Splash) + assert.Equal(t, "#015952", data.Splash.Background) + assert.Equal(t, "assets/splash-icon.png", data.Splash.Icon) } func TestLoadAppMetadata_Permissions(t *testing.T) { diff --git a/cmd/fyne/internal/metadata/testdata/FyneApp.toml b/cmd/fyne/internal/metadata/testdata/FyneApp.toml index d72a58d3..4d7ae381 100644 --- a/cmd/fyne/internal/metadata/testdata/FyneApp.toml +++ b/cmd/fyne/internal/metadata/testdata/FyneApp.toml @@ -7,6 +7,10 @@ ID = "io.fyne.fyne" Version = "1.0.0" Build = 1 +[Splash] +Background = "#015952" +Icon = "assets/splash-icon.png" + [Development] InDevelopmentOnly = "Value4" Test = "Value2" diff --git a/cmd/fyne/internal/mobile/android_resources.go b/cmd/fyne/internal/mobile/android_resources.go index fa16e673..16e4fe15 100644 --- a/cmd/fyne/internal/mobile/android_resources.go +++ b/cmd/fyne/internal/mobile/android_resources.go @@ -6,6 +6,7 @@ import ( "os/exec" "path/filepath" + "fyne.io/tools/cmd/fyne/internal/metadata" "fyne.io/tools/cmd/fyne/internal/mobile/binres" "fyne.io/tools/cmd/fyne/internal/util" ) @@ -87,9 +88,104 @@ func writeAdaptiveIconResources(resDir, foregroundPath, backgroundPath, monochro return nil } +// writeLauncherIconResources creates a plain (non-adaptive) launcher icon resource +func writeLauncherIconResources(resDir, iconPath string) error { + xxxhdpiDir := filepath.Join(resDir, "mipmap-xxxhdpi") + if err := os.MkdirAll(xxxhdpiDir, 0o755); err != nil { + return fmt.Errorf("failed to create mipmap-xxxhdpi directory: %w", err) + } + + for _, name := range []string{"ic_launcher.png", "ic_launcher_round.png"} { + if err := util.CopyFile(iconPath, filepath.Join(xxxhdpiDir, name)); err != nil { + return fmt.Errorf("failed to copy launcher icon: %w", err) + } + } + return nil +} + +// splashIconSize is the pixel size of the xxxhdpi splash icon, 288dp to match the drawable +// size that the Android 12+ system splash screen lays out its icon at. +const splashIconSize = 288 * 4 + +// writeSplashResources creates the SplashTheme that the launcher activity uses. +// Before Android 12 the window background (colour plus centred icon) is shown while +// the app starts; from Android 12 the system splash screen uses the same colour and icon. +func writeSplashResources(resDir string, splash *metadata.Splash, iconPath string) error { + background := splash.Background + if background == "" { + background = "#FFFFFF" + } + + drawableDir := filepath.Join(resDir, "drawable") + bitmapDir := filepath.Join(resDir, "drawable-xxxhdpi") + valuesDir := filepath.Join(resDir, "values") + valuesV31Dir := filepath.Join(resDir, "values-v31") + for _, dir := range []string{drawableDir, bitmapDir, valuesDir, valuesV31Dir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create %s directory: %w", filepath.Base(dir), err) + } + } + + icon := splash.Icon + if icon == "" { + icon = iconPath + } + if err := util.WriteScaledPNG(icon, filepath.Join(bitmapDir, "splash_icon.png"), splashIconSize); err != nil { + return fmt.Errorf("failed to write splash icon: %w", err) + } + + window := ` + + + + + + +` + if err := os.WriteFile(filepath.Join(drawableDir, "splash_background.xml"), []byte(window), 0o644); err != nil { + return fmt.Errorf("failed to write splash_background.xml: %w", err) + } + + colors := fmt.Sprintf(` + + %s + +`, background) + if err := os.WriteFile(filepath.Join(valuesDir, "colors.xml"), []byte(colors), 0o644); err != nil { + return fmt.Errorf("failed to write colors.xml: %w", err) + } + + styles := ` + + + +` + if err := os.WriteFile(filepath.Join(valuesDir, "styles.xml"), []byte(styles), 0o644); err != nil { + return fmt.Errorf("failed to write styles.xml: %w", err) + } + + stylesV31 := ` + + + +` + if err := os.WriteFile(filepath.Join(valuesV31Dir, "styles.xml"), []byte(stylesV31), 0o644); err != nil { + return fmt.Errorf("failed to write v31 styles.xml: %w", err) + } + return nil +} + // compileAndroidResources compiles Android resources using aapt2 // Returns: resources.arsc path, res/ directory path, compiled AndroidManifest.xml path, error -func compileAndroidResources(tempDir string, manifestData []byte, foregroundPath, backgroundPath, monochromePath string, targetSDK, versionCode int, versionName string) (arscPath string, resDir string, manifestPath string, err error) { +func compileAndroidResources(tempDir string, manifestData []byte, iconPath, foregroundPath, backgroundPath, monochromePath string, + splash *metadata.Splash, targetSDK, versionCode int, versionName string, +) (arscPath string, resDir string, manifestPath string, err error) { aapt2, err := util.Aapt2Path() if err != nil { return "", "", "", err @@ -100,10 +196,24 @@ func compileAndroidResources(tempDir string, manifestData []byte, foregroundPath return "", "", "", fmt.Errorf("failed to create res directory: %w", err) } - if err := writeAdaptiveIconResources(resDir, foregroundPath, backgroundPath, monochromePath); err != nil { + if foregroundPath != "" { + if err := writeAdaptiveIconResources(resDir, foregroundPath, backgroundPath, monochromePath); err != nil { + return "", "", "", err + } + } else if err := writeLauncherIconResources(resDir, iconPath); err != nil { return "", "", "", err } + if splash != nil { + splashIcon := foregroundPath + if splashIcon == "" { + splashIcon = iconPath + } + if err := writeSplashResources(resDir, splash, splashIcon); err != nil { + return "", "", "", err + } + } + compiledDir := filepath.Join(tempDir, "compiled") if err := os.MkdirAll(compiledDir, util.DirPermDefault); err != nil { return "", "", "", fmt.Errorf("failed to create compiled directory: %w", err) diff --git a/cmd/fyne/internal/mobile/android_resources_test.go b/cmd/fyne/internal/mobile/android_resources_test.go new file mode 100644 index 00000000..3eda8351 --- /dev/null +++ b/cmd/fyne/internal/mobile/android_resources_test.go @@ -0,0 +1,70 @@ +package mobile + +import ( + "image" + "image/png" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + + "fyne.io/tools/cmd/fyne/internal/metadata" +) + +func writeTestPNG(t *testing.T, path string, size int) { + f, err := os.Create(path) + assert.Nil(t, err) + assert.Nil(t, png.Encode(f, image.NewRGBA(image.Rect(0, 0, size, size)))) + assert.Nil(t, f.Close()) +} + +func pngSize(t *testing.T, path string) int { + f, err := os.Open(path) + assert.Nil(t, err) + defer f.Close() + conf, err := png.DecodeConfig(f) + assert.Nil(t, err) + assert.Equal(t, conf.Width, conf.Height) + return conf.Width +} + +func Test_writeSplashResources(t *testing.T) { + dir := t.TempDir() + icon := filepath.Join(dir, "icon.png") + custom := filepath.Join(dir, "custom.png") + writeTestPNG(t, icon, 64) + writeTestPNG(t, custom, 32) + + res := filepath.Join(dir, "res") + err := writeSplashResources(res, &metadata.Splash{Background: "#015952"}, icon) + assert.Nil(t, err) + + // the fallback icon is scaled to the fixed splash size + assert.Equal(t, splashIconSize, pngSize(t, filepath.Join(res, "drawable-xxxhdpi", "splash_icon.png"))) + + data, err := os.ReadFile(filepath.Join(res, "values", "colors.xml")) + assert.Nil(t, err) + assert.Contains(t, string(data), `#015952`) + data, err = os.ReadFile(filepath.Join(res, "drawable", "splash_background.xml")) + assert.Nil(t, err) + assert.Contains(t, string(data), "@color/splash_background") + assert.Contains(t, string(data), "@drawable/splash_icon") + data, err = os.ReadFile(filepath.Join(res, "values", "styles.xml")) + assert.Nil(t, err) + assert.Contains(t, string(data), "android:windowBackground") + data, err = os.ReadFile(filepath.Join(res, "values-v31", "styles.xml")) + assert.Nil(t, err) + assert.Contains(t, string(data), "android:windowSplashScreenAnimatedIcon") + + // a custom icon is preferred and the colour defaults to white + res = filepath.Join(dir, "res2") + assert.Nil(t, writeSplashResources(res, &metadata.Splash{Icon: custom}, icon)) + assert.Equal(t, splashIconSize, pngSize(t, filepath.Join(res, "drawable-xxxhdpi", "splash_icon.png"))) + data, err = os.ReadFile(filepath.Join(res, "values", "colors.xml")) + assert.Nil(t, err) + assert.Contains(t, string(data), "#FFFFFF") + + // a missing icon is an error + assert.NotNil(t, writeSplashResources(filepath.Join(dir, "res3"), &metadata.Splash{}, filepath.Join(dir, "none.png"))) +} diff --git a/cmd/fyne/internal/mobile/build.go b/cmd/fyne/internal/mobile/build.go index 564676bd..16444c9d 100644 --- a/cmd/fyne/internal/mobile/build.go +++ b/cmd/fyne/internal/mobile/build.go @@ -124,7 +124,7 @@ func runBuildImpl(cmd *command) (*packages.Package, error) { target = androidTargetDebug // TODO once we have gomobile debug signing working for v2 android signs } nmpkgs, err = goAndroidBuild(pkg, buildBundleID, targetArchs, cmd.IconPath, cmd.AppName, cmd.Version, cmd.Build, - target, buildRelease, cmd.iconFG, cmd.iconBG, cmd.iconMono, cmd.capabilities) + target, buildRelease, cmd.iconFG, cmd.iconBG, cmd.iconMono, cmd.capabilities, cmd.splash) if err != nil { return nil, err } @@ -253,7 +253,7 @@ var ( // RunNewBuild executes a new mobile build for the specified configuration func RunNewBuild(target, appID, icon, name, version string, build int, release, distribution bool, cert, profile string, - tags []string, iconFG, iconBG, iconMono string, verbose bool, capabilities metadata.Capabilities, + tags []string, iconFG, iconBG, iconMono string, verbose bool, capabilities metadata.Capabilities, splash *metadata.Splash, ) error { buildTarget = target buildBundleID = appID @@ -278,6 +278,7 @@ func RunNewBuild(target, appID, icon, name, version string, build int, release, cmd.iconBG = iconBG cmd.iconMono = iconMono cmd.capabilities = capabilities + cmd.splash = splash return runBuild(cmd) } diff --git a/cmd/fyne/internal/mobile/build_androidapp.go b/cmd/fyne/internal/mobile/build_androidapp.go index a53a5640..d9079d5a 100644 --- a/cmd/fyne/internal/mobile/build_androidapp.go +++ b/cmd/fyne/internal/mobile/build_androidapp.go @@ -35,7 +35,9 @@ type manifestTmplData struct { Version string Build int AdaptiveIcon bool + Icon bool Microphone bool + Splash bool } const ( @@ -45,7 +47,7 @@ const ( func goAndroidBuild(pkg *packages.Package, bundleID string, androidArchs []string, iconPath, appName, version string, build, target int, release bool, iconFG, iconBG, iconMono string, - capabilities metadata.Capabilities, + capabilities metadata.Capabilities, splash *metadata.Splash, ) (map[string]bool, error) { var env []string if release { // Google Play Store requires 16K alignment @@ -69,28 +71,11 @@ func goAndroidBuild(pkg *packages.Package, bundleID string, androidArchs []strin return nil, err } - foreground, _, _ := detectAdaptiveIcons(dir, iconFG, iconBG, iconMono) - adaptive := foreground != "" && util.Exists(foreground) - - buf := new(bytes.Buffer) - buf.WriteString(``) - err := templates.ManifestAndroid.Execute(buf, manifestTmplData{ - JavaPkgPath: bundleID, - Name: strings.Title(appName), //lint:ignore SA1019 It is fine for our uses. - Debug: !buildRelease, - LibName: libName, - Version: version, - Build: build, - AdaptiveIcon: adaptive, - Microphone: capabilities.Microphone, - }) + manifestData, err = generateAndroidManifest(dir, bundleID, appName, libName, version, build, + iconPath, iconFG, iconBG, iconMono, capabilities, splash) if err != nil { return nil, err } - manifestData = buf.Bytes() - if buildV { - fmt.Fprintf(os.Stderr, "generated %s:\n%s\n", fileAndroidManifestXML, manifestData) - } } else { libName, err = manifestLibName(manifestData) if err != nil { @@ -163,7 +148,7 @@ func goAndroidBuild(pkg *packages.Package, bundleID string, androidArchs []strin if err != nil { return nil, err } - err = addAssets(apkw, manifestData, dir, iconPath, target, build, version, iconFG, iconBG, iconMono) + err = addAssets(apkw, manifestData, dir, iconPath, target, build, version, iconFG, iconBG, iconMono, splash) if err != nil { return nil, err } @@ -192,6 +177,40 @@ func goAndroidBuild(pkg *packages.Package, bundleID string, androidArchs []strin return nmpkgs[androidArchs[0]], nil } +// generateAndroidManifest renders the AndroidManifest.xml template for an app that does not provide its own. +// The icon and splash screen resources are declared in the manifest when the source images are available. +func generateAndroidManifest(dir, bundleID, appName, libName, version string, build int, + iconPath, iconFG, iconBG, iconMono string, capabilities metadata.Capabilities, splash *metadata.Splash, +) ([]byte, error) { + foreground, _, _ := detectAdaptiveIcons(dir, iconFG, iconBG, iconMono) + adaptive := foreground != "" && util.Exists(foreground) + hasSplash := splash != nil && util.Exists(iconPath) + + buf := new(bytes.Buffer) + buf.WriteString(``) + err := templates.ManifestAndroid.Execute(buf, manifestTmplData{ + JavaPkgPath: bundleID, + Name: strings.Title(appName), //lint:ignore SA1019 It is fine for our uses. + Debug: !buildRelease, + LibName: libName, + Version: version, + Build: build, + AdaptiveIcon: adaptive, + Icon: adaptive || hasSplash, + Microphone: capabilities.Microphone, + Splash: hasSplash, + }) + if err != nil { + return nil, err + } + + manifestData := buf.Bytes() + if buildV { + fmt.Fprintf(os.Stderr, "generated %s:\n%s\n", fileAndroidManifestXML, manifestData) + } + return manifestData, nil +} + // detectAdaptiveIcons checks for adaptive icon layers based on metadata or convention // Returns: foreground path, background path, monochrome path (empty strings if not found) func detectAdaptiveIcons(dir, foreground, background, monochrome string) (string, string, string) { @@ -222,7 +241,7 @@ func detectAdaptiveIcons(dir, foreground, background, monochrome string) (string } func addAssets(apkw *Writer, manifestData []byte, dir, iconPath string, target int, versionCode int, - versionName, iconFG, iconBG, iconMono string, + versionName, iconFG, iconBG, iconMono string, splash *metadata.Splash, ) error { // Add any assets. var arsc struct { @@ -272,8 +291,8 @@ func addAssets(apkw *Writer, manifestData []byte, dir, iconPath string, target i iconForeground, iconBackground, iconMonochrome := detectAdaptiveIcons(dir, iconFG, iconBG, iconMono) - // No adaptive icons, use legacy build - if iconForeground == "" { + // No adaptive icons or splash screen, use legacy build + if iconForeground == "" && (splash == nil || arsc.iconPath == "") { return legacyAddAssets(apkw, manifestData, arsc.iconPath, target) } @@ -282,19 +301,21 @@ func addAssets(apkw *Writer, manifestData []byte, dir, iconPath string, target i iconBackground = iconForeground } - // Compile adaptive icon resources with aapt2 + // Compile icon and splash resources with aapt2 arscPath, resDir, compiledManifestPath, err := compileAndroidResources( tmpdir, manifestData, + arsc.iconPath, iconForeground, iconBackground, iconMonochrome, + splash, target, versionCode, versionName, ) if err != nil { - return fmt.Errorf("failed to compile adaptive icon resources: %w", err) + return fmt.Errorf("failed to compile android resources: %w", err) } w, err := apkwCreate("resources.arsc", apkw) diff --git a/cmd/fyne/internal/mobile/main.go b/cmd/fyne/internal/mobile/main.go index 9e294f36..fc61bc67 100644 --- a/cmd/fyne/internal/mobile/main.go +++ b/cmd/fyne/internal/mobile/main.go @@ -21,4 +21,5 @@ type command struct { iconFG, iconBG, iconMono string capabilities metadata.Capabilities + splash *metadata.Splash } diff --git a/cmd/fyne/internal/templates/bundled.go b/cmd/fyne/internal/templates/bundled.go index a215fb34..489042e0 100644 --- a/cmd/fyne/internal/templates/bundled.go +++ b/cmd/fyne/internal/templates/bundled.go @@ -38,6 +38,9 @@ var fynePprof string //go:embed data/xcassets.JSON var xcassetsJSON string +//go:embed data/LaunchScreen.storyboard +var launchScreenStoryboard string + //go:embed data/index.html var indexHTML string diff --git a/cmd/fyne/internal/templates/data/AndroidManifest.xml b/cmd/fyne/internal/templates/data/AndroidManifest.xml index 29b1f028..9ff8ecff 100644 --- a/cmd/fyne/internal/templates/data/AndroidManifest.xml +++ b/cmd/fyne/internal/templates/data/AndroidManifest.xml @@ -5,12 +5,12 @@ android:versionName="{{.Version}}"> + {{if .Icon}}android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round"{{end}}> + android:theme="{{if .Splash}}@style/SplashTheme{{else}}@android:style/Theme{{end}}"> diff --git a/cmd/fyne/internal/templates/data/LaunchScreen.storyboard b/cmd/fyne/internal/templates/data/LaunchScreen.storyboard new file mode 100644 index 00000000..5f02ee1f --- /dev/null +++ b/cmd/fyne/internal/templates/data/LaunchScreen.storyboard @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cmd/fyne/internal/templates/templates.go b/cmd/fyne/internal/templates/templates.go index 1c9f16b2..b74b539e 100644 --- a/cmd/fyne/internal/templates/templates.go +++ b/cmd/fyne/internal/templates/templates.go @@ -39,6 +39,9 @@ var ( // XCAssetsDarwin is the Contents.json file for darwin xcassets bundle XCAssetsDarwin = template.Must(template.New("XCAssets").Parse(xcassetsJSON)) + // LaunchScreenIOS is the storyboard shown by iOS while the app loads + LaunchScreenIOS = template.Must(template.New("LaunchScreen").Parse(launchScreenStoryboard)) + // IndexHTML is the index.html used to serve web package IndexHTML = template.Must(template.New("index.html").Parse(indexHTML)) diff --git a/cmd/fyne/internal/templates/templates_test.go b/cmd/fyne/internal/templates/templates_test.go index 30e78e88..34533164 100644 --- a/cmd/fyne/internal/templates/templates_test.go +++ b/cmd/fyne/internal/templates/templates_test.go @@ -35,3 +35,19 @@ func TestMicrophoneCapability(t *testing.T) { assert.NoError(t, err) assert.Contains(t, buf.String(), "Notes & memos") } + +func TestLaunchScreenIOS(t *testing.T) { + buf := &bytes.Buffer{} + err := LaunchScreenIOS.Execute(buf, struct { + Red, Green, Blue float64 + IconSize int + }{Red: 0.5, Green: 0.25, Blue: 1, IconSize: 288}) + assert.Nil(t, err) + + out := buf.String() + assert.Contains(t, out, `red="0.5" green="0.25" blue="1"`) + assert.Contains(t, out, `image="SplashIcon"`) + assert.Contains(t, out, `firstAttribute="width" constant="288"`) + assert.Contains(t, out, `firstAttribute="height" constant="288"`) + assert.Contains(t, out, ``) +} diff --git a/cmd/fyne/internal/util/image.go b/cmd/fyne/internal/util/image.go new file mode 100644 index 00000000..04f13eed --- /dev/null +++ b/cmd/fyne/internal/util/image.go @@ -0,0 +1,32 @@ +package util + +import ( + "fmt" + "image/png" + "os" + + "github.com/nfnt/resize" +) + +// WriteScaledPNG decodes the PNG at src, scales it to size pixels square and writes it to dst. +func WriteScaledPNG(src, dst string, size int) error { + in, err := os.Open(src) + if err != nil { + return err + } + img, err := png.Decode(in) + _ = in.Close() + if err != nil { + return fmt.Errorf("failed to decode PNG %s: %w", src, err) + } + + out, err := os.Create(dst) + if err != nil { + return err + } + err = png.Encode(out, resize.Resize(uint(size), uint(size), img, resize.Lanczos3)) + if closeErr := out.Close(); err == nil { + err = closeErr + } + return err +} diff --git a/cmd/fyne/internal/util/image_test.go b/cmd/fyne/internal/util/image_test.go new file mode 100644 index 00000000..2aadedc4 --- /dev/null +++ b/cmd/fyne/internal/util/image_test.go @@ -0,0 +1,33 @@ +package util + +import ( + "image" + "image/png" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWriteScaledPNG(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "in.png") + f, err := os.Create(src) + assert.Nil(t, err) + assert.Nil(t, png.Encode(f, image.NewRGBA(image.Rect(0, 0, 64, 64)))) + assert.Nil(t, f.Close()) + + dst := filepath.Join(dir, "out.png") + assert.Nil(t, WriteScaledPNG(src, dst, 16)) + + f, err = os.Open(dst) + assert.Nil(t, err) + defer f.Close() + conf, err := png.DecodeConfig(f) + assert.Nil(t, err) + assert.Equal(t, 16, conf.Width) + assert.Equal(t, 16, conf.Height) + + assert.NotNil(t, WriteScaledPNG(filepath.Join(dir, "missing.png"), dst, 16)) +}