Skip to content

Commit 441e8cd

Browse files
authored
Merge pull request #115 from andydotxyz/fix/adaptiveicon
2 parents 7139fdf + 0842691 commit 441e8cd

11 files changed

Lines changed: 475 additions & 34 deletions

File tree

cmd/fyne/internal/commands/data.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ type appData struct {
88
AppBuild int
99
ResGoString string
1010
Release, rawIcon bool
11+
AdaptiveIcon *metadata.AdaptiveIcon
1112
CustomMetadata map[string]string
1213
Migrations map[string]bool
1314
CanOpen *metadata.CanOpen
@@ -45,6 +46,8 @@ func (a *appData) mergeMetadata(data *metadata.FyneApp) {
4546
if a.AppBuild == 0 {
4647
a.AppBuild = data.Details.Build
4748
}
49+
a.AdaptiveIcon = data.AdaptiveIcon
50+
4851
if a.Release {
4952
a.appendCustomMetadata(data.Release)
5053
} else {

cmd/fyne/internal/commands/package-mobile.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,20 @@ import (
1414
)
1515

1616
func (p *Packager) packageAndroid(arch string, tags []string) error {
17-
return mobile.RunNewBuild(arch, p.AppID, p.icon, p.Name, p.AppVersion, p.AppBuild, p.release, p.distribution, "", "", tags)
17+
iconFG, iconBG, iconMono := "", "", ""
18+
if p.appData.AdaptiveIcon != nil {
19+
iconFG = p.appData.AdaptiveIcon.Foreground
20+
iconBG = p.appData.AdaptiveIcon.Background
21+
iconMono = p.appData.AdaptiveIcon.Monochrome
22+
}
23+
24+
return mobile.RunNewBuild(arch, p.AppID, p.icon, p.Name, p.AppVersion, p.AppBuild, p.release, p.distribution,
25+
"", "", tags, iconFG, iconBG, iconMono)
1826
}
1927

2028
func (p *Packager) packageIOS(target string, tags []string) error {
21-
err := mobile.RunNewBuild(target, p.AppID, p.icon, p.Name, p.AppVersion, p.AppBuild, p.release, p.distribution, p.certificate, p.profile, tags)
29+
err := mobile.RunNewBuild(target, p.AppID, p.icon, p.Name, p.AppVersion, p.AppBuild, p.release, p.distribution,
30+
p.certificate, p.profile, tags, "", "", "")
2231
if err != nil {
2332
return err
2433
}

cmd/fyne/internal/metadata/data.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package metadata
22

33
// FyneApp describes the top level metadata for building a fyne application
44
type FyneApp struct {
5-
Website string `toml:",omitempty"`
6-
Description string `toml:",omitempty"`
7-
Details AppDetails
5+
Website string `toml:",omitempty"`
6+
Description string `toml:",omitempty"`
7+
Details AppDetails
8+
AdaptiveIcon *AdaptiveIcon `toml:",omitempty"`
9+
810
Development map[string]string `toml:",omitempty"`
911
Release map[string]string `toml:",omitempty"`
1012
Source *AppSource `toml:",omitempty"`
@@ -22,6 +24,12 @@ type AppDetails struct {
2224
Build int `toml:",omitempty"`
2325
}
2426

27+
type AdaptiveIcon struct {
28+
Foreground string `toml:",omitempty"`
29+
Background string `toml:",omitempty"`
30+
Monochrome string `toml:",omitempty"`
31+
}
32+
2533
type AppSource struct {
2634
Repo, Dir string `toml:",omitempty"`
2735
}
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
package mobile
2+
3+
import (
4+
"archive/zip"
5+
"fmt"
6+
"io"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"strings"
11+
12+
"fyne.io/tools/cmd/fyne/internal/mobile/binres"
13+
"fyne.io/tools/cmd/fyne/internal/util"
14+
)
15+
16+
// generateAdaptiveIconXML creates the XML definition for an adaptive icon
17+
func generateAdaptiveIconXML(hasMonochrome bool) string {
18+
xml := `<?xml version="1.0" encoding="utf-8"?>
19+
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
20+
<background android:drawable="@mipmap/ic_launcher_background"/>
21+
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>`
22+
23+
if hasMonochrome {
24+
xml += `
25+
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>`
26+
}
27+
28+
xml += `
29+
</adaptive-icon>
30+
`
31+
return xml
32+
}
33+
34+
// writeAdaptiveIconResources creates all necessary resource files for adaptive icons
35+
func writeAdaptiveIconResources(resDir, foregroundPath, backgroundPath, monochromePath string) error {
36+
xxxhdpiDir := filepath.Join(resDir, "mipmap-xxxhdpi")
37+
if err := os.MkdirAll(xxxhdpiDir, 0o755); err != nil {
38+
return fmt.Errorf("failed to create mipmap-xxxhdpi directory: %w", err)
39+
}
40+
41+
if err := util.CopyFile(foregroundPath, filepath.Join(xxxhdpiDir, "ic_launcher_foreground.png")); err != nil {
42+
return fmt.Errorf("failed to copy foreground icon: %w", err)
43+
}
44+
45+
if backgroundPath != "" {
46+
if err := util.CopyFile(backgroundPath, filepath.Join(xxxhdpiDir, "ic_launcher_background.png")); err != nil {
47+
return fmt.Errorf("failed to copy background icon: %w", err)
48+
}
49+
}
50+
51+
hasMonochrome := monochromePath != ""
52+
if hasMonochrome {
53+
if err := util.CopyFile(monochromePath, filepath.Join(xxxhdpiDir, "ic_launcher_monochrome.png")); err != nil {
54+
return fmt.Errorf("failed to copy monochrome icon: %w", err)
55+
}
56+
}
57+
58+
// Create mipmap-anydpi-v26 metadata for adaptive icon XML
59+
anydpiV26Dir := filepath.Join(resDir, "mipmap-anydpi-v26")
60+
if err := os.MkdirAll(anydpiV26Dir, 0o755); err != nil {
61+
return fmt.Errorf("failed to create mipmap-anydpi-v26 directory: %w", err)
62+
}
63+
64+
adaptiveXML := generateAdaptiveIconXML(false) // v26 doesn't include monochrome
65+
if err := os.WriteFile(filepath.Join(anydpiV26Dir, "ic_launcher.xml"), []byte(adaptiveXML), 0o644); err != nil {
66+
return fmt.Errorf("failed to write ic_launcher.xml: %w", err)
67+
}
68+
69+
if err := os.WriteFile(filepath.Join(anydpiV26Dir, "ic_launcher_round.xml"), []byte(adaptiveXML), 0o644); err != nil {
70+
return fmt.Errorf("failed to write ic_launcher_round.xml: %w", err)
71+
}
72+
73+
if !hasMonochrome {
74+
return nil
75+
}
76+
// If monochrome provided, create v33 resources with monochrome support
77+
anydpiV33Dir := filepath.Join(resDir, "mipmap-anydpi-v33")
78+
if err := os.MkdirAll(anydpiV33Dir, 0o755); err != nil {
79+
return fmt.Errorf("failed to create mipmap-anydpi-v33 directory: %w", err)
80+
}
81+
82+
adaptiveXMLWithMono := generateAdaptiveIconXML(true)
83+
if err := os.WriteFile(filepath.Join(anydpiV33Dir, "ic_launcher.xml"), []byte(adaptiveXMLWithMono), 0o644); err != nil {
84+
return fmt.Errorf("failed to write v33 ic_launcher.xml: %w", err)
85+
}
86+
87+
if err := os.WriteFile(filepath.Join(anydpiV33Dir, "ic_launcher_round.xml"), []byte(adaptiveXMLWithMono), 0o644); err != nil {
88+
return fmt.Errorf("failed to write v33 ic_launcher_round.xml: %w", err)
89+
}
90+
return nil
91+
}
92+
93+
// compileAndroidResources compiles Android resources using aapt2
94+
// Returns: resources.arsc path, res/ directory path, compiled AndroidManifest.xml path, error
95+
func compileAndroidResources(tempDir string, manifestData []byte, foregroundPath, backgroundPath, monochromePath string, targetSDK, versionCode int, versionName string) (arscPath string, resDir string, manifestPath string, err error) {
96+
aapt2, err := util.Aapt2Path()
97+
if err != nil {
98+
return "", "", "", err
99+
}
100+
101+
resDir = filepath.Join(tempDir, "res")
102+
if err := os.MkdirAll(resDir, 0o755); err != nil {
103+
return "", "", "", fmt.Errorf("failed to create res directory: %w", err)
104+
}
105+
106+
if err := writeAdaptiveIconResources(resDir, foregroundPath, backgroundPath, monochromePath); err != nil {
107+
return "", "", "", err
108+
}
109+
110+
compiledDir := filepath.Join(tempDir, "compiled")
111+
if err := os.MkdirAll(compiledDir, 0o755); err != nil {
112+
return "", "", "", fmt.Errorf("failed to create compiled directory: %w", err)
113+
}
114+
115+
tempManifestPath := filepath.Join(tempDir, "AndroidManifest.xml")
116+
if err := os.WriteFile(tempManifestPath, manifestData, 0o644); err != nil {
117+
return "", "", "", fmt.Errorf("failed to write AndroidManifest.xml: %w", err)
118+
}
119+
120+
err = filepath.Walk(resDir, func(path string, info os.FileInfo, err error) error {
121+
if err != nil {
122+
return err
123+
}
124+
if info.IsDir() {
125+
return nil
126+
}
127+
128+
cmd := exec.Command(aapt2, "compile", "-o", compiledDir, path)
129+
if buildV {
130+
cmd.Stdout = os.Stderr
131+
cmd.Stderr = os.Stderr
132+
}
133+
if err := cmd.Run(); err != nil {
134+
return fmt.Errorf("aapt2 compile failed for %s: %w", path, err)
135+
}
136+
return nil
137+
})
138+
if err != nil {
139+
return "", "", "", err
140+
}
141+
142+
androidJar, err := binres.LatestAPIResourcesPath()
143+
if err != nil || !util.Exists(androidJar) {
144+
return "", "", "", fmt.Errorf("android.jar not found for API %d at %s", binres.MinSDK, androidJar)
145+
}
146+
147+
outputAPK := filepath.Join(tempDir, "resources.apk")
148+
linkArgs := []string{
149+
"link",
150+
"-o", outputAPK,
151+
"--manifest", tempManifestPath,
152+
"--min-sdk-version", "21", // Android 5.0, minimum for adaptive icons (v26) with backward compat
153+
"--target-sdk-version", fmt.Sprintf("%d", targetSDK),
154+
"--version-code", fmt.Sprintf("%d", versionCode),
155+
"--version-name", versionName,
156+
"-I", androidJar,
157+
"--auto-add-overlay",
158+
}
159+
160+
flatFiles, err := filepath.Glob(filepath.Join(compiledDir, "*.flat"))
161+
if err != nil {
162+
return "", "", "", fmt.Errorf("failed to find compiled flat files: %w", err)
163+
}
164+
linkArgs = append(linkArgs, flatFiles...)
165+
166+
cmd := exec.Command(aapt2, linkArgs...)
167+
if buildV {
168+
cmd.Stdout = os.Stderr
169+
cmd.Stderr = os.Stderr
170+
printcmd("%s %v", aapt2, linkArgs)
171+
}
172+
if err := cmd.Run(); err != nil {
173+
return "", "", "", fmt.Errorf("aapt2 link failed: %w", err)
174+
}
175+
176+
// Extract resources.arsc from the output APK
177+
arscPath = filepath.Join(tempDir, "resources.arsc")
178+
if err := extractFileFromZip(outputAPK, "resources.arsc", arscPath); err != nil {
179+
return "", "", "", fmt.Errorf("failed to extract resources.arsc: %w", err)
180+
}
181+
182+
// Extract compiled AndroidManifest.xml from the output APK
183+
manifestPath = filepath.Join(tempDir, "AndroidManifest.xml")
184+
if err := extractFileFromZip(outputAPK, "AndroidManifest.xml", manifestPath); err != nil {
185+
return "", "", "", fmt.Errorf("failed to extract AndroidManifest.xml: %w", err)
186+
}
187+
188+
// Extract res/ directory from the output APK
189+
extractedResDir := filepath.Join(tempDir, "extracted_res")
190+
if err := extractDirFromZip(outputAPK, "res/", extractedResDir); err != nil {
191+
return "", "", "", fmt.Errorf("failed to extract res/ directory: %w", err)
192+
}
193+
194+
return arscPath, extractedResDir, manifestPath, nil
195+
}
196+
197+
// extractFileFromZip extracts a single file from a zip archive
198+
func extractFileFromZip(zipPath, fileName, destPath string) error {
199+
r, err := zip.OpenReader(zipPath)
200+
if err != nil {
201+
return err
202+
}
203+
defer r.Close()
204+
205+
for _, f := range r.File {
206+
if f.Name != fileName {
207+
continue
208+
}
209+
210+
rc, err := f.Open()
211+
if err != nil {
212+
return err
213+
}
214+
defer rc.Close()
215+
216+
dest, err := os.Create(destPath)
217+
if err != nil {
218+
return err
219+
}
220+
defer dest.Close()
221+
222+
_, err = io.Copy(dest, rc)
223+
return err
224+
}
225+
return fmt.Errorf("file %s not found in zip", fileName)
226+
}
227+
228+
// extractDirFromZip extracts all files with a given prefix from a zip archive
229+
func extractDirFromZip(zipPath, prefix, destDir string) error {
230+
r, err := zip.OpenReader(zipPath)
231+
if err != nil {
232+
return err
233+
}
234+
defer r.Close()
235+
236+
if err := os.MkdirAll(destDir, 0o755); err != nil {
237+
return err
238+
}
239+
240+
for _, f := range r.File {
241+
if !strings.HasPrefix(f.Name, prefix) {
242+
continue
243+
}
244+
245+
relPath := f.Name[len(prefix):]
246+
if relPath == "" {
247+
continue
248+
}
249+
destPath := filepath.Join(destDir, relPath)
250+
251+
if f.FileInfo().IsDir() {
252+
if err := os.MkdirAll(destPath, 0o755); err != nil {
253+
return err
254+
}
255+
continue
256+
}
257+
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
258+
return err
259+
}
260+
261+
// Extract file
262+
rc, err := f.Open()
263+
if err != nil {
264+
return err
265+
}
266+
267+
dest, err := os.Create(destPath)
268+
if err != nil {
269+
rc.Close()
270+
return err
271+
}
272+
273+
_, err = io.Copy(dest, rc)
274+
rc.Close()
275+
dest.Close()
276+
if err != nil {
277+
return err
278+
}
279+
}
280+
281+
return nil
282+
}

cmd/fyne/internal/mobile/binres/binres_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"fmt"
1313
"math"
1414
"os"
15+
"path/filepath"
1516
"sort"
1617
"strings"
1718
"testing"
@@ -465,13 +466,18 @@ func checkResources(t *testing.T) {
465466
if sdkdir == "" {
466467
t.Skip("ANDROID_HOME env var not set")
467468
}
468-
rscPath, err := apiResourcesPath()
469+
rscPath, err := LatestAPIResourcesPath()
469470
if err != nil {
470471
t.Skipf("failed to find resources: %v", err)
471472
}
472473
if _, err := os.Stat(rscPath); err != nil {
473474
t.Skipf("failed to find resources: %v", err)
474475
}
476+
477+
version := filepath.Base(filepath.Dir(rscPath))
478+
if version > "android-15" {
479+
t.Skipf("Cannot run legacy tests after SDK 15: %s", version)
480+
}
475481
}
476482

477483
func BenchmarkTableRefByName(b *testing.B) {

0 commit comments

Comments
 (0)