Simplify Android packaging around gogio
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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</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()
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user