diff --git a/Makefile b/Makefile index 320b7b8..183ba19 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ apk: android/keepassgo-android.jar ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \ ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \ JAVA_HOME="$(JAVA_HOME)" \ - go tool gogio -target android \ + go run ./cmd/build-android-apk -target android \ -buildmode exe \ -appid $(APP_ID) \ -ldflags "$(GO_LDFLAGS)" \ diff --git a/cmd/build-android-apk/main.go b/cmd/build-android-apk/main.go new file mode 100644 index 0000000..833ef9b --- /dev/null +++ b/cmd/build-android-apk/main.go @@ -0,0 +1,292 @@ +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\n\t\n`)\n", + "\t\t\n{{.ApplicationSnippets}}\n\t\n`)\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() + }) +}