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))
+}