Files
keepassgo/cmd/build-android-apk/main.go
T
2026-04-13 22:03:00 -07:00

293 lines
8.0 KiB
Go

package main
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
)
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "build_android_apk: %v\n", err)
os.Exit(1)
}
}
func run() error {
workDir, err := os.Getwd()
if err != nil {
return err
}
gogioDir, err := commandOutput("go", "list", "-m", "-f", "{{.Dir}}", "gioui.org/cmd")
if err != nil {
return err
}
tempDir, err := os.MkdirTemp("", "keepassgo-gogio-")
if err != nil {
return err
}
defer os.RemoveAll(tempDir)
tempModule := filepath.Join(tempDir, "gioui-cmd")
if err := copyDir(gogioDir, tempModule); err != nil {
return err
}
targetFile := filepath.Join(tempModule, "gogio", "androidbuild.go")
if err := patchAndroidBuild(targetFile); err != nil {
return err
}
if err := runCommand(tempModule, "gofmt", "-w", targetFile); err != nil {
return err
}
if err := refreshTempModuleDeps(tempModule); err != nil {
return err
}
javaHome := os.Getenv("JAVA_HOME")
wrappedJavaHome := javaHome
var cleanup func()
if major := javaMajor(javaHome); major >= 26 {
wrappedJavaHome, cleanup, err = wrapJavaHome(javaHome)
if err != nil {
return err
}
defer cleanup()
}
patchedArgs := append([]string(nil), os.Args[1:]...)
if len(patchedArgs) != 0 {
last := patchedArgs[len(patchedArgs)-1]
if strings.HasPrefix(last, ".") {
patchedArgs[len(patchedArgs)-1] = filepath.Join(workDir, last)
}
}
gogioFiles, err := filepath.Glob(filepath.Join(tempModule, "gogio", "*.go"))
if err != nil {
return err
}
filteredFiles := make([]string, 0, len(gogioFiles))
for _, file := range gogioFiles {
if strings.HasSuffix(file, "_test.go") {
continue
}
filteredFiles = append(filteredFiles, file)
}
if len(filteredFiles) == 0 {
return fmt.Errorf("no gogio go files found in %s", tempModule)
}
args := append([]string{"run"}, filteredFiles...)
args = append(args, patchedArgs...)
cmd := exec.Command("go", args...)
cmd.Dir = workDir
cmd.Env = append(os.Environ(), "JAVA_HOME="+wrappedJavaHome)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func patchAndroidBuild(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
content := string(data)
content = strings.Replace(content,
"\tAppName string\n}",
"\tAppName string\n\tApplicationSnippets string\n}",
1,
)
content = strings.Replace(content,
"\tif err := visitPkg(pkgs[0]); err != nil {\n\t\treturn err\n\t}\n\n\tif err := compileAndroid(tmpDir, tools, bi); err != nil {\n",
"\tif err := visitPkg(pkgs[0]); err != nil {\n\t\treturn err\n\t}\n\tmoduleRoot := androidModuleRoot(bi.pkgDir)\n\tmoduleAndroidJars, err := filepath.Glob(filepath.Join(moduleRoot, \"android\", \"*.jar\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\textraJars = append(extraJars, moduleAndroidJars...)\n\n\tif err := compileAndroid(tmpDir, tools, bi); err != nil {\n",
1,
)
content = strings.Replace(content,
"\terr = os.WriteFile(filepath.Join(v21Dir, \"themes.xml\"), []byte(themesV21), 0660)\n\tif err != nil {\n\t\treturn err\n\t}\n\tresZip := filepath.Join(tmpDir, \"resources.zip\")\n",
"\terr = os.WriteFile(filepath.Join(v21Dir, \"themes.xml\"), []byte(themesV21), 0660)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmoduleRoot := androidModuleRoot(bi.pkgDir)\n\tcustomResDir := filepath.Join(moduleRoot, \"android\", \"res\")\n\tif _, err := os.Stat(customResDir); err == nil {\n\t\tif err := copyAndroidDir(customResDir, resDir); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tresZip := filepath.Join(tmpDir, \"resources.zip\")\n",
1,
)
content = strings.Replace(content,
"\t\tIconSnip: iconSnip,\n\t\tAppName: appName,\n\t}\n",
"\t\tIconSnip: iconSnip,\n\t\tAppName: appName,\n\t\tApplicationSnippets: loadAndroidSnippet(filepath.Join(moduleRoot, \"android\", \"application_snippets.xml\")),\n\t}\n",
1,
)
content = strings.Replace(content,
"\t\t</activity>\n\t</application>\n</manifest>`)\n",
"\t\t</activity>\n{{.ApplicationSnippets}}\n\t</application>\n</manifest>`)\n",
1,
)
insertPos := strings.Index(content, "func findNDK(")
if insertPos < 0 {
return fmt.Errorf("findNDK not found")
}
helpers := `
func loadAndroidSnippet(path string) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
content := strings.TrimSpace(string(data))
if content == "" {
return ""
}
return "\n" + content
}
func copyAndroidDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, 0755)
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(target, data, 0660)
})
}
func androidModuleRoot(start string) string {
dir := start
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return start
}
dir = parent
}
}
`
content = content[:insertPos] + helpers + content[insertPos:]
return os.WriteFile(path, []byte(content), 0o644)
}
func commandOutput(name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
out, err := cmd.Output()
if err != nil {
var stderr []byte
if exitErr, ok := err.(*exec.ExitError); ok {
stderr = exitErr.Stderr
}
return "", fmt.Errorf("%s %s failed: %s%s", name, strings.Join(args, " "), out, stderr)
}
return strings.TrimSpace(string(out)), nil
}
func runCommand(dir, name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func refreshTempModuleDeps(dir string) error {
overrides := []string{
"golang.org/x/image@v0.37.0",
"golang.org/x/mod@v0.33.0",
"golang.org/x/sync@v0.20.0",
"golang.org/x/text@v0.35.0",
"golang.org/x/tools@v0.42.0",
}
args := append([]string{"get"}, overrides...)
return runCommand(dir, "go", args...)
}
func javaMajor(javaHome string) int {
if strings.TrimSpace(javaHome) == "" {
return 0
}
cmd := exec.Command(filepath.Join(javaHome, "bin", "java"), "-version")
output, err := cmd.CombinedOutput()
if err != nil {
return 0
}
matches := regexp.MustCompile(`version "([0-9]+)`).FindStringSubmatch(string(output))
if len(matches) != 2 {
return 0
}
var major int
if _, err := fmt.Sscanf(matches[1], "%d", &major); err != nil {
return 0
}
return major
}
func wrapJavaHome(javaHome string) (string, func(), error) {
tempDir, err := os.MkdirTemp("", "keepassgo-java-home-")
if err != nil {
return "", nil, err
}
binDir := filepath.Join(tempDir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
os.RemoveAll(tempDir)
return "", nil, err
}
realBin := filepath.Join(javaHome, "bin")
if err := os.WriteFile(filepath.Join(binDir, "javac"), []byte("#!/bin/sh\nexport JAVA_TOOL_OPTIONS=-Xint\nexec "+shellQuote(filepath.Join(realBin, "javac"))+" \"$@\"\n"), 0o755); err != nil {
os.RemoveAll(tempDir)
return "", nil, err
}
for _, name := range []string{"java", "jar"} {
if err := os.WriteFile(filepath.Join(binDir, name), []byte("#!/bin/sh\nexec "+shellQuote(filepath.Join(realBin, name))+" \"$@\"\n"), 0o755); err != nil {
os.RemoveAll(tempDir)
return "", nil, err
}
}
return tempDir, func() { os.RemoveAll(tempDir) }, nil
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}
func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, 0o755)
}
in, err := os.Open(path)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(target)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Close()
})
}