4 Commits

Author SHA1 Message Date
Joe Julian 1762d36dde gogio: deduplicate Android jars 2026-04-16 21:04:56 -07:00
Joe Julian 7b5a6b418c gogio: load module-root Android jars 2026-04-16 21:03:09 -07:00
Joe Julian f71579e799 gogio: load Android assets from module root 2026-04-16 20:59:20 -07:00
Joe Julian 192acd9d09 Add Android packaging hooks for app resources 2026-04-16 08:42:03 -07:00
2 changed files with 349 additions and 44 deletions
+156 -44
View File
@@ -50,6 +50,8 @@ type manifestData struct {
AppName string AppName string
Schemes []string Schemes []string
PackageQueries []string PackageQueries []string
ManifestSnip string
AppSnip string
} }
const ( const (
@@ -115,7 +117,9 @@ func buildAndroid(tmpDir string, bi *buildInfo) error {
if err != nil { if err != nil {
return err return err
} }
moduleRoot := moduleRootForDir(bi.pkgDir)
var extraJars []string var extraJars []string
seenJars := make(map[string]bool)
visitedPkgs := make(map[string]bool) visitedPkgs := make(map[string]bool)
var visitPkg func(*packages.Package) error var visitPkg func(*packages.Package) error
visitPkg = func(p *packages.Package) error { visitPkg = func(p *packages.Package) error {
@@ -123,11 +127,17 @@ func buildAndroid(tmpDir string, bi *buildInfo) error {
return nil return nil
} }
dir := filepath.Dir(p.GoFiles[0]) dir := filepath.Dir(p.GoFiles[0])
jars, err := filepath.Glob(filepath.Join(dir, "*.jar")) jars, err := androidExtraJars(dir, moduleRoot)
if err != nil { if err != nil {
return err return err
} }
extraJars = append(extraJars, jars...) for _, jar := range jars {
if seenJars[jar] {
continue
}
seenJars[jar] = true
extraJars = append(extraJars, jar)
}
switch { switch {
case p.PkgPath == "net": case p.PkgPath == "net":
perms = append(perms, "network") perms = append(perms, "network")
@@ -422,6 +432,10 @@ func exeAndroid(tmpDir string, tools *androidTools, bi *buildInfo, extraJars, pe
if err != nil { if err != nil {
return err return err
} }
moduleRoot := moduleRootForDir(bi.pkgDir)
if err := copyTree(filepath.Join(moduleRoot, "android", "res"), resDir); err != nil {
return err
}
resZip := filepath.Join(tmpDir, "resources.zip") resZip := filepath.Join(tmpDir, "resources.zip")
aapt2 := filepath.Join(tools.buildtools, "aapt2") aapt2 := filepath.Join(tools.buildtools, "aapt2")
_, err = runCmd(exec.Command( _, err = runCmd(exec.Command(
@@ -447,52 +461,15 @@ func exeAndroid(tmpDir string, tools *androidTools, bi *buildInfo, extraJars, pe
AppName: appName, AppName: appName,
Schemes: bi.schemes, Schemes: bi.schemes,
PackageQueries: bi.packageQueries, PackageQueries: bi.packageQueries,
ManifestSnip: readOptionalText(filepath.Join(moduleRoot, "android", "manifest_snippets.xml")),
AppSnip: readOptionalText(filepath.Join(moduleRoot, "android", "application_snippets.xml")),
} }
tmpl, err := template.New("test").Parse( manifestBuffer, err := renderAndroidManifest(manifestSrc)
`<?xml version="1.0" encoding="utf-8"?> if err != nil {
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="{{.AppID}}"
android:versionCode="{{.Version.VersionCode}}"
android:versionName="{{.Version}}">
{{if .PackageQueries}}
<queries>
{{range .PackageQueries}}
<package android:name="{{.}}" />
{{end}}
</queries>
{{end}}
<uses-sdk android:minSdkVersion="{{.MinSDK}}" android:targetSdkVersion="{{.TargetSDK}}" />
{{range .Permissions}} <uses-permission android:name="{{.}}"/>
{{end}}{{range .Features}} <uses-feature android:{{.}} android:required="false"/>
{{end}} <application {{.IconSnip}} android:label="{{.AppName}}">
<activity android:name="org.gioui.GioActivity"
android:label="{{.AppName}}"
android:theme="@style/Theme.GioApp"
android:configChanges="screenSize|screenLayout|smallestScreenSize|orientation|keyboardHidden"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleInstance"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
{{range .Schemes}}
<intent-filter>
<action android:name="android.intent.action.VIEW"></action>
<category android:name="android.intent.category.DEFAULT"></category>
<category android:name="android.intent.category.BROWSABLE"></category>
<data android:scheme="{{.}}"></data>
</intent-filter>
{{end}}
</activity>
</application>
</manifest>`)
var manifestBuffer bytes.Buffer
if err := tmpl.Execute(&manifestBuffer, manifestSrc); err != nil {
return err return err
} }
manifest := filepath.Join(tmpDir, "AndroidManifest.xml") 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 return err
} }
@@ -757,6 +734,141 @@ func defaultAndroidKeystore(tmpDir string, bi *buildInfo) error {
return err return err
} }
func renderAndroidManifest(data manifestData) ([]byte, error) {
tmpl, err := template.New("test").Parse(
`<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="{{.AppID}}"
android:versionCode="{{.Version.VersionCode}}"
android:versionName="{{.Version}}">
{{if .PackageQueries}}
<queries>
{{range .PackageQueries}}
<package android:name="{{.}}" />
{{end}}
</queries>
{{end}}
<uses-sdk android:minSdkVersion="{{.MinSDK}}" android:targetSdkVersion="{{.TargetSDK}}" />
{{range .Permissions}} <uses-permission android:name="{{.}}"/>
{{end}}{{range .Features}} <uses-feature android:{{.}} android:required="false"/>
{{end}}{{.ManifestSnip}} <application {{.IconSnip}} android:label="{{.AppName}}">
{{.AppSnip}}
<activity android:name="org.gioui.GioActivity"
android:label="{{.AppName}}"
android:theme="@style/Theme.GioApp"
android:configChanges="screenSize|screenLayout|smallestScreenSize|orientation|keyboardHidden"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleInstance"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
{{range .Schemes}}
<intent-filter>
<action android:name="android.intent.action.VIEW"></action>
<category android:name="android.intent.category.DEFAULT"></category>
<category android:name="android.intent.category.BROWSABLE"></category>
<data android:scheme="{{.}}"></data>
</intent-filter>
{{end}}
</activity>
</application>
</manifest>`)
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, moduleRoot string) ([]string, error) {
var jars []string
patterns := []string{
filepath.Join(dir, "*.jar"),
filepath.Join(dir, "android", "*.jar"),
}
if moduleRoot != "" && moduleRoot != dir {
patterns = append(patterns,
filepath.Join(moduleRoot, "*.jar"),
filepath.Join(moduleRoot, "android", "*.jar"),
)
}
for _, pattern := range patterns {
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, err
}
jars = append(jars, matches...)
}
return jars, nil
}
func moduleRootForDir(dir string) string {
current := dir
for {
if _, err := os.Stat(filepath.Join(current, "go.mod")); err == nil {
return current
}
parent := filepath.Dir(current)
if parent == current {
return dir
}
current = parent
}
}
func findNDK(androidHome string) (string, error) { func findNDK(androidHome string) (string, error) {
ndks, err := filepath.Glob(filepath.Join(androidHome, "ndk", "*")) ndks, err := filepath.Glob(filepath.Join(androidHome, "ndk", "*"))
if err != nil { if err != nil {
+193
View File
@@ -0,0 +1,193 @@
// 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, 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 TestAndroidExtraJarsIncludesModuleRootAndroidSubdirectory(t *testing.T) {
t.Parallel()
root := t.TempDir()
dir := filepath.Join(root, "cmd", "keepassgo")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll(%q) error = %v", dir, err)
}
moduleAndroidJar := filepath.Join(root, "android", "keepassgo-android.jar")
writeTestFile(t, moduleAndroidJar, "module-android")
got, err := androidExtraJars(dir, root)
if err != nil {
t.Fatalf("androidExtraJars() error = %v", err)
}
if !containsString(got, moduleAndroidJar) {
t.Fatalf("androidExtraJars() = %v, want %q included", got, moduleAndroidJar)
}
}
func TestAndroidExtraJarsDoesNotRepeatSharedModuleJarWhenCollectedAcrossPackages(t *testing.T) {
t.Parallel()
root := t.TempDir()
moduleAndroidJar := filepath.Join(root, "android", "keepassgo-android.jar")
writeTestFile(t, filepath.Join(root, "go.mod"), "module example.invalid/crew\n")
writeTestFile(t, moduleAndroidJar, "module-android")
dirs := []string{
filepath.Join(root, "cmd", "keepassgo"),
filepath.Join(root, "internal", "appui"),
}
seen := make(map[string]bool)
var collected []string
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll(%q) error = %v", dir, err)
}
jars, err := androidExtraJars(dir, root)
if err != nil {
t.Fatalf("androidExtraJars(%q) error = %v", dir, err)
}
for _, jar := range jars {
if seen[jar] {
continue
}
seen[jar] = true
collected = append(collected, jar)
}
}
count := 0
for _, jar := range collected {
if jar == moduleAndroidJar {
count++
}
}
if count != 1 {
t.Fatalf("collected module jar count = %d, want 1 in %v", count, collected)
}
}
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<uses-permission android:name=\"android.permission.POST_NOTIFICATIONS\" />\n",
AppSnip: "\n\t\t<service android:name=\".CrewService\" />\n",
})
if err != nil {
t.Fatalf("renderAndroidManifest() error = %v", err)
}
got := string(manifest)
for _, want := range []string{
`android.permission.POST_NOTIFICATIONS`,
`<service android:name=".CrewService" />`,
`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, "<service />")
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) != "<service />" {
t.Fatalf("copyTree() copied %q, want %q", string(got), "<service />")
}
}
func TestModuleRootForDirFindsOwningModule(t *testing.T) {
t.Parallel()
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "go.mod"), "module example.invalid/crew\n")
dir := filepath.Join(root, "cmd", "keepassgo")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll(%q) error = %v", dir, err)
}
if got := moduleRootForDir(dir); got != root {
t.Fatalf("moduleRootForDir(%q) = %q, want %q", dir, got, root)
}
}
func TestModuleRootForDirFallsBackToInputDir(t *testing.T) {
t.Parallel()
dir := filepath.Join(t.TempDir(), "cmd", "keepassgo")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll(%q) error = %v", dir, err)
}
if got := moduleRootForDir(dir); got != dir {
t.Fatalf("moduleRootForDir(%q) = %q, want %q", dir, got, dir)
}
}
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
}