From 4d972bfab0f1af36f175fce51acdc3ec4f76b79a Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 16 Apr 2026 20:47:51 -0700 Subject: [PATCH] Simplify Android packaging around gogio --- APK.md | 18 +- Makefile | 39 +++- README.md | 7 +- cmd/build-android-apk/main.go | 292 ------------------------ packaging/docker/android-apk/Dockerfile | 16 ++ 5 files changed, 67 insertions(+), 305 deletions(-) delete mode 100644 cmd/build-android-apk/main.go create mode 100644 packaging/docker/android-apk/Dockerfile diff --git a/APK.md b/APK.md index 3f7e7d9..71af8c6 100644 --- a/APK.md +++ b/APK.md @@ -6,11 +6,17 @@ Build the APK with: make apk ``` +`make apk` uses a local Java 25 install when `JAVA_HOME` points to one. +If the host does not have a working Java 25 install, it falls back to the +repo-managed Docker image in `packaging/docker/android-apk/`, which also builds +with Java 25. + Environment: - `ANDROID_SDK_ROOT` defaults to `/opt/android-sdk`. - `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`. - `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`. +- `APK_BUILD_IMAGE` overrides the Docker image name used by `make apk-container`. - `APP_ID` overrides the Android application id. - `APP_VERSION` overrides the version shown inside KeePassGO itself. - `APK_OUT` overrides the output path. @@ -24,9 +30,9 @@ Installed machine prerequisites expected by this repo: - `android-sdk-build-tools` - `android-platform-35` - `android-sdk-platform-tools` -- a working JDK install +- a working Java 25 JDK install for `make apk-local`, or Docker for `make apk` -The repo tracks `gogio` as a Go tool, so the build runs through: +The repo tracks `gogio` as a Go tool, and the local build runs through: ```sh go tool gogio -target android ./cmd/keepassgo ... @@ -38,10 +44,10 @@ The Android build uses the branded icon asset at: Note: -- Gio's Android doc currently references Java 1.8, but the Android build-tools - installed on this machine (`d8` from build-tools 37) do not run on Java 8. -- In this environment, KeePassGO's APK build requires a newer JDK runtime on - `PATH`, which is why the repo defaults `JAVA_HOME` to `/usr/lib/jvm/java-25-openjdk`. +- KeePassGO's documented Android build uses Java 25 locally. +- If that host setup is unavailable, `make apk` falls back to the Docker image + so the build still runs under Java 25 instead of encoding a newer host JDK as + a requirement. - Android runtime testing on the `KeepassGoAPI35` emulator showed a black-screen regression with `gioui.org v0.9.0` while a stock Gio example and KeePassGO both rendered correctly with `gioui.org v0.8.0` on the same emulator and SDK/JDK diff --git a/Makefile b/Makefile index 183ba19..cf5eae4 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ ANDROID_SDK_ROOT ?= /opt/android-sdk ANDROID_NDK_ROOT ?= /opt/android-ndk JAVA_HOME ?= /usr/lib/jvm/java-25-openjdk PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_SDK_ROOT)/platform-tools:$(PATH) +APK_BUILD_IMAGE ?= keepassgo/android-apk-build:java25 APP_ID ?= org.julianfamily.keepassgo APK_OUT ?= build/keepassgo.apk APK_VERSION ?= 0.1.0.1 @@ -25,8 +26,16 @@ ifneq ($(strip $(SIGNPASS)),) GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS) endif -.PHONY: apk archlinux-pkgbuild browser-bridge browser-extension-validate -apk: android/keepassgo-android.jar +.PHONY: apk apk-local apk-container apk-container-image archlinux-pkgbuild browser-bridge browser-extension-validate +apk: + @if [ -x "$(JAVA_HOME)/bin/java" ] && "$(JAVA_HOME)/bin/java" -version 2>&1 | grep -q 'version "25'; then \ + $(MAKE) apk-local JAVA_HOME="$(JAVA_HOME)"; \ + else \ + echo "Using Dockerized Java 25 Android build because JAVA_HOME is not a working Java 25 install."; \ + $(MAKE) apk-container; \ + fi + +apk-local: android/keepassgo-android.jar @test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; } @test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; } @test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; } @@ -38,7 +47,7 @@ apk: android/keepassgo-android.jar ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \ ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \ JAVA_HOME="$(JAVA_HOME)" \ - go run ./cmd/build-android-apk -target android \ + go tool gogio -target android \ -buildmode exe \ -appid $(APP_ID) \ -ldflags "$(GO_LDFLAGS)" \ @@ -50,12 +59,32 @@ apk: android/keepassgo-android.jar -icon internal/assets/keepassgo-icon.png \ ./cmd/keepassgo +apk-container: apk-container-image + @command -v docker >/dev/null 2>&1 || { echo "docker is required for apk-container"; exit 1; } + @test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; } + @test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; } + docker run --rm \ + -u "$$(id -u):$$(id -g)" \ + -v "$(CURDIR):$(CURDIR)" \ + -w "$(CURDIR)" \ + -v "$(ANDROID_SDK_ROOT):$(ANDROID_SDK_ROOT)" \ + -v "$(ANDROID_NDK_ROOT):$(ANDROID_NDK_ROOT)" \ + -e ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \ + -e ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \ + -e JAVA_HOME=/opt/java/openjdk \ + $(APK_BUILD_IMAGE) \ + make apk-local JAVA_HOME=/opt/java/openjdk + +apk-container-image: + @command -v docker >/dev/null 2>&1 || { echo "docker is required for apk-container-image"; exit 1; } + docker build --load -t $(APK_BUILD_IMAGE) packaging/docker/android-apk + android/keepassgo-android.jar: $(shell find androidsrc -type f | sort) @test -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; } @test -f "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" || { echo "Android platform android-$(ANDROID_TARGET_SDK) is missing"; exit 1; } @mkdir -p android - @zsh -lc 'tmpdir=$$(mktemp -d); \ - trap '\''python3 -c "import shutil,sys; shutil.rmtree(sys.argv[1], ignore_errors=True)" "$$tmpdir"'\'' EXIT; \ + @sh -ec 'tmpdir=$$(mktemp -d); \ + trap "rm -rf $$tmpdir" EXIT; \ "$(JAVA_HOME)/bin/javac" \ -classpath "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" \ -d "$$tmpdir" \ diff --git a/README.md b/README.md index a84c875..16197cb 100644 --- a/README.md +++ b/README.md @@ -90,10 +90,13 @@ go get -tool gioui.org/cmd/gogio@latest Package: ```bash -go tool gogio -target android -icon internal/assets/keepassgo-icon.png ./cmd/keepassgo +make apk ``` -You will need the Android SDK and NDK installed and configured for real device or release packaging. +`make apk` prefers a local Java 25 install at `JAVA_HOME`. If that is not +available, it falls back to the repo-managed Docker build image, which also +uses Java 25. You still need the Android SDK and NDK installed and configured +for real device or release packaging. ## Automation diff --git a/cmd/build-android-apk/main.go b/cmd/build-android-apk/main.go deleted file mode 100644 index 833ef9b..0000000 --- a/cmd/build-android-apk/main.go +++ /dev/null @@ -1,292 +0,0 @@ -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() - }) -} diff --git a/packaging/docker/android-apk/Dockerfile b/packaging/docker/android-apk/Dockerfile new file mode 100644 index 0000000..7556788 --- /dev/null +++ b/packaging/docker/android-apk/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.26-bookworm AS gobase + +FROM eclipse-temurin:25-jdk + +RUN apt-get update && apt-get install -y --no-install-recommends \ + findutils \ + git \ + make \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=gobase /usr/local/go /usr/local/go + +ENV JAVA_HOME=/opt/java/openjdk +ENV PATH=/usr/local/go/bin:${PATH} + +WORKDIR /workspace