diff --git a/README.md b/README.md index c1e9784..e7a3a16 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,16 @@ go build ./... KeePassGO uses Gio, so Android packaging is done with `gogio`. +The repo now has automated tests for the packaging contract: +- default APK build arguments +- required Android SDK / NDK / JDK layout checks + +Those are covered by normal test runs: + +```bash +go test ./... +``` + Install: ```bash diff --git a/buildapk/config.go b/buildapk/config.go new file mode 100644 index 0000000..100d750 --- /dev/null +++ b/buildapk/config.go @@ -0,0 +1,90 @@ +package buildapk + +import ( + "fmt" + "os" + "path/filepath" +) + +const ( + DefaultSDKRoot = "/opt/android-sdk" + DefaultNDKRoot = "/opt/android-ndk" + DefaultJavaHome = "/usr/lib/jvm/java-25-openjdk" + DefaultAppID = "org.julianfamily.keepassgo" + DefaultAPKOut = "build/keepassgo.apk" + DefaultVersion = "0.1.0.1" + DefaultMinSDK = "28" + DefaultTargetSDK = "35" +) + +type Config struct { + SDKRoot string + NDKRoot string + JavaHome string + AppID string + APKOut string + Version string + MinSDK string + TargetSDK string +} + +func DefaultConfig() Config { + return Config{ + SDKRoot: DefaultSDKRoot, + NDKRoot: DefaultNDKRoot, + JavaHome: DefaultJavaHome, + AppID: DefaultAppID, + APKOut: DefaultAPKOut, + Version: DefaultVersion, + MinSDK: DefaultMinSDK, + TargetSDK: DefaultTargetSDK, + } +} + +func (c Config) GogioArgs() []string { + return []string{ + "-target", "android", + "-buildmode", "exe", + "-appid", c.AppID, + "-o", c.APKOut, + "-version", c.Version, + "-minsdk", c.MinSDK, + "-targetsdk", c.TargetSDK, + ".", + } +} + +func (c Config) Validate() error { + if !isExecutable(filepath.Join(c.JavaHome, "bin", "java")) { + return fmt.Errorf("JAVA_HOME must point to a JDK 17+ install") + } + if !isDir(c.SDKRoot) { + return fmt.Errorf("ANDROID_SDK_ROOT must point to an Android SDK install") + } + if !isDir(c.NDKRoot) { + return fmt.Errorf("ANDROID_NDK_ROOT must point to an Android NDK install") + } + if !isExecutable(filepath.Join(c.SDKRoot, "cmdline-tools", "latest", "bin", "sdkmanager")) { + return fmt.Errorf("Android SDK cmdline-tools are missing") + } + if !isDir(filepath.Join(c.SDKRoot, "platforms", "android-"+c.TargetSDK)) { + return fmt.Errorf("Android platform android-%s is missing", c.TargetSDK) + } + if !isDir(filepath.Join(c.SDKRoot, "build-tools")) { + return fmt.Errorf("Android build-tools are missing") + } + return nil +} + +func isDir(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +func isExecutable(path string) bool { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return false + } + return info.Mode()&0o111 != 0 +} diff --git a/buildapk/config_test.go b/buildapk/config_test.go new file mode 100644 index 0000000..c6be37f --- /dev/null +++ b/buildapk/config_test.go @@ -0,0 +1,95 @@ +package buildapk + +import ( + "os" + "path/filepath" + "slices" + "testing" +) + +func TestDefaultConfigGogioArgs(t *testing.T) { + t.Parallel() + + cfg := DefaultConfig() + want := []string{ + "-target", "android", + "-buildmode", "exe", + "-appid", DefaultAppID, + "-o", DefaultAPKOut, + "-version", DefaultVersion, + "-minsdk", DefaultMinSDK, + "-targetsdk", DefaultTargetSDK, + ".", + } + + if got := cfg.GogioArgs(); !slices.Equal(got, want) { + t.Fatalf("GogioArgs() = %v, want %v", got, want) + } +} + +func TestValidateAcceptsCompleteAndroidToolchainLayout(t *testing.T) { + t.Parallel() + + root := t.TempDir() + sdkRoot := filepath.Join(root, "sdk") + ndkRoot := filepath.Join(root, "ndk") + javaHome := filepath.Join(root, "java") + + mkdirAll(t, filepath.Join(javaHome, "bin")) + mkdirAll(t, filepath.Join(sdkRoot, "cmdline-tools", "latest", "bin")) + mkdirAll(t, filepath.Join(sdkRoot, "platforms", "android-"+DefaultTargetSDK)) + mkdirAll(t, filepath.Join(sdkRoot, "build-tools")) + mkdirAll(t, ndkRoot) + + makeExecutable(t, filepath.Join(javaHome, "bin", "java")) + makeExecutable(t, filepath.Join(sdkRoot, "cmdline-tools", "latest", "bin", "sdkmanager")) + + cfg := Config{ + SDKRoot: sdkRoot, + NDKRoot: ndkRoot, + JavaHome: javaHome, + AppID: DefaultAppID, + APKOut: DefaultAPKOut, + Version: DefaultVersion, + MinSDK: DefaultMinSDK, + TargetSDK: DefaultTargetSDK, + } + + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } +} + +func TestValidateRejectsMissingPrerequisites(t *testing.T) { + t.Parallel() + + root := t.TempDir() + cfg := Config{ + SDKRoot: filepath.Join(root, "missing-sdk"), + NDKRoot: filepath.Join(root, "missing-ndk"), + JavaHome: filepath.Join(root, "missing-java"), + AppID: DefaultAppID, + APKOut: DefaultAPKOut, + Version: DefaultVersion, + MinSDK: DefaultMinSDK, + TargetSDK: DefaultTargetSDK, + } + + if err := cfg.Validate(); err == nil { + t.Fatal("Validate() error = nil, want missing prerequisite error") + } +} + +func mkdirAll(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatalf("MkdirAll(%q) error = %v", path, err) + } +} + +func makeExecutable(t *testing.T, path string) { + t.Helper() + if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("WriteFile(%q) error = %v", path, err) + } +}