From 192acd9d09b0533312fdb4f2326ace4c8b76a3e8 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 16 Apr 2026 08:42:03 -0700 Subject: [PATCH] Add Android packaging hooks for app resources --- gogio/androidbuild.go | 168 +++++++++++++++++++++++++++---------- gogio/androidbuild_test.go | 103 +++++++++++++++++++++++ 2 files changed, 228 insertions(+), 43 deletions(-) create mode 100644 gogio/androidbuild_test.go diff --git a/gogio/androidbuild.go b/gogio/androidbuild.go index 77136a1..cb686cf 100644 --- a/gogio/androidbuild.go +++ b/gogio/androidbuild.go @@ -50,6 +50,8 @@ type manifestData struct { AppName string Schemes []string PackageQueries []string + ManifestSnip string + AppSnip string } const ( @@ -123,7 +125,7 @@ func buildAndroid(tmpDir string, bi *buildInfo) error { return nil } dir := filepath.Dir(p.GoFiles[0]) - jars, err := filepath.Glob(filepath.Join(dir, "*.jar")) + jars, err := androidExtraJars(dir) if err != nil { return err } @@ -422,6 +424,9 @@ func exeAndroid(tmpDir string, tools *androidTools, bi *buildInfo, extraJars, pe if err != nil { return err } + if err := copyTree(filepath.Join(bi.pkgDir, "android", "res"), resDir); err != nil { + return err + } resZip := filepath.Join(tmpDir, "resources.zip") aapt2 := filepath.Join(tools.buildtools, "aapt2") _, err = runCmd(exec.Command( @@ -447,52 +452,15 @@ func exeAndroid(tmpDir string, tools *androidTools, bi *buildInfo, extraJars, pe AppName: appName, Schemes: bi.schemes, PackageQueries: bi.packageQueries, + ManifestSnip: readOptionalText(filepath.Join(bi.pkgDir, "android", "manifest_snippets.xml")), + AppSnip: readOptionalText(filepath.Join(bi.pkgDir, "android", "application_snippets.xml")), } - tmpl, err := template.New("test").Parse( - ` - - {{if .PackageQueries}} - - {{range .PackageQueries}} - - {{end}} - - {{end}} - -{{range .Permissions}} -{{end}}{{range .Features}} -{{end}} - - - - - - {{range .Schemes}} - - - - - - - {{end}} - - -`) - var manifestBuffer bytes.Buffer - if err := tmpl.Execute(&manifestBuffer, manifestSrc); err != nil { + manifestBuffer, err := renderAndroidManifest(manifestSrc) + if err != nil { return err } manifest := filepath.Join(tmpDir, "AndroidManifest.xml") - if err := os.WriteFile(manifest, manifestBuffer.Bytes(), 0o660); err != nil { + if err := os.WriteFile(manifest, manifestBuffer, 0o660); err != nil { return err } @@ -757,6 +725,120 @@ func defaultAndroidKeystore(tmpDir string, bi *buildInfo) error { return err } +func renderAndroidManifest(data manifestData) ([]byte, error) { + tmpl, err := template.New("test").Parse( + ` + + {{if .PackageQueries}} + + {{range .PackageQueries}} + + {{end}} + + {{end}} + +{{range .Permissions}} +{{end}}{{range .Features}} +{{end}}{{.ManifestSnip}} +{{.AppSnip}} + + + + + + {{range .Schemes}} + + + + + + + {{end}} + + +`) + if err != nil { + return nil, err + } + var manifestBuffer bytes.Buffer + if err := tmpl.Execute(&manifestBuffer, data); err != nil { + return nil, err + } + return manifestBuffer.Bytes(), nil +} + +func readOptionalText(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + if len(data) == 0 { + return "" + } + return "\n" + string(data) + "\n" +} + +func copyTree(src, dst string) error { + info, err := os.Stat(src) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if !info.IsDir() { + return fmt.Errorf("extra Android resources path is not a directory: %s", src) + } + return filepath.Walk(src, func(path string, entry os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + target := filepath.Join(dst, rel) + if entry.IsDir() { + return os.MkdirAll(target, 0o755) + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(target, data, 0o660) + }) +} + +func androidExtraJars(dir string) ([]string, error) { + var jars []string + for _, pattern := range []string{ + filepath.Join(dir, "*.jar"), + filepath.Join(dir, "android", "*.jar"), + } { + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, err + } + jars = append(jars, matches...) + } + return jars, nil +} + func findNDK(androidHome string) (string, error) { ndks, err := filepath.Glob(filepath.Join(androidHome, "ndk", "*")) if err != nil { diff --git a/gogio/androidbuild_test.go b/gogio/androidbuild_test.go new file mode 100644 index 0000000..f72cded --- /dev/null +++ b/gogio/androidbuild_test.go @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAndroidExtraJarsIncludesAndroidSubdirectory(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + rootJar := filepath.Join(dir, "crew.jar") + androidJar := filepath.Join(dir, "android", "vault.jar") + writeTestFile(t, rootJar, "root") + writeTestFile(t, androidJar, "android") + + got, err := androidExtraJars(dir) + if err != nil { + t.Fatalf("androidExtraJars() error = %v", err) + } + want := []string{rootJar, androidJar} + for _, jar := range want { + if !containsString(got, jar) { + t.Fatalf("androidExtraJars() = %v, want %q included", got, jar) + } + } +} + +func TestRenderAndroidManifestIncludesOptionalSnippets(t *testing.T) { + t.Parallel() + + manifest, err := renderAndroidManifest(manifestData{ + AppID: "org.example.heist", + Version: Semver{Major: 1, Minor: 2, Patch: 3, VersionCode: 4}, + MinSDK: 28, + TargetSDK: 35, + Permissions: []string{"android.permission.INTERNET"}, + Features: []string{`name="android.hardware.fingerprint"`}, + IconSnip: `android:icon="@mipmap/ic_launcher"`, + AppName: "Bellagio Crew", + ManifestSnip: "\n\t\n", + AppSnip: "\n\t\t\n", + }) + if err != nil { + t.Fatalf("renderAndroidManifest() error = %v", err) + } + + got := string(manifest) + for _, want := range []string{ + `android.permission.POST_NOTIFICATIONS`, + ``, + `android.permission.INTERNET`, + `android.hardware.fingerprint`, + } { + if !strings.Contains(got, want) { + t.Fatalf("renderAndroidManifest() missing %q in %s", want, got) + } + } +} + +func TestCopyTreeCopiesNestedResources(t *testing.T) { + t.Parallel() + + src := filepath.Join(t.TempDir(), "android", "res") + dst := filepath.Join(t.TempDir(), "merged") + resourcePath := filepath.Join(src, "xml", "heist_service.xml") + writeTestFile(t, resourcePath, "") + + if err := copyTree(src, dst); err != nil { + t.Fatalf("copyTree() error = %v", err) + } + + got, err := os.ReadFile(filepath.Join(dst, "xml", "heist_service.xml")) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if string(got) != "" { + t.Fatalf("copyTree() copied %q, want %q", string(got), "") + } +} + +func writeTestFile(t *testing.T, path, contents string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll(%q) error = %v", path, err) + } + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", path, err) + } +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +}