// SPDX-License-Identifier: Unlicense OR MIT package main import ( "archive/zip" "bytes" "errors" "fmt" "io" "io/ioutil" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "text/template" "golang.org/x/sync/errgroup" "golang.org/x/tools/go/packages" ) type androidTools struct { buildtools string androidjar string } // zip.Writer with a sticky error. type zipWriter struct { err error w *zip.Writer } // Writer that saves any errors. type errWriter struct { w io.Writer err *error } var exeSuffix string type manifestData struct { AppID string Version int MinSDK int TargetSDK int Permissions []string Features []string IconSnip string AppName string } const ( themes = ` ` themesV21 = ` ` ) func init() { if runtime.GOOS == "windows" { exeSuffix = ".exe" } } func buildAndroid(tmpDir string, bi *buildInfo) error { sdk := os.Getenv("ANDROID_SDK_ROOT") if sdk == "" { return errors.New("please set ANDROID_SDK_ROOT to the Android SDK path") } if _, err := os.Stat(sdk); err != nil { return err } platform, err := latestPlatform(sdk) if err != nil { return err } buildtools, err := latestTools(sdk) if err != nil { return err } tools := &androidTools{ buildtools: buildtools, androidjar: filepath.Join(platform, "android.jar"), } perms := []string{"default"} const permPref = "github.com/p9c/p9/pkg/gel/gio/app/permission/" cfg := &packages.Config{ Mode: packages.NeedName + packages.NeedFiles + packages.NeedImports + packages.NeedDeps, Env: append( os.Environ(), "GOOS=android", "CGO_ENABLED=1", ), } pkgs, err := packages.Load(cfg, bi.pkgPath) if err != nil { return err } var extraJars []string visitedPkgs := make(map[string]bool) var visitPkg func(*packages.Package) error visitPkg = func(p *packages.Package) error { if len(p.GoFiles) == 0 { return nil } dir := filepath.Dir(p.GoFiles[0]) jars, err := filepath.Glob(filepath.Join(dir, "*.jar")) if err != nil { return err } extraJars = append(extraJars, jars...) switch { case p.PkgPath == "net": perms = append(perms, "network") case strings.HasPrefix(p.PkgPath, permPref): perms = append(perms, p.PkgPath[len(permPref):]) } for _, imp := range p.Imports { if !visitedPkgs[imp.ID] { visitPkg(imp) visitedPkgs[imp.ID] = true } } return nil } if err := visitPkg(pkgs[0]); err != nil { return err } if err := compileAndroid(tmpDir, tools, bi); err != nil { return err } switch *buildMode { case "archive": return archiveAndroid(tmpDir, bi, perms) case "exe": file := *destPath if file == "" { file = fmt.Sprintf("%s.apk", bi.name) } isBundle := false switch filepath.Ext(file) { case ".apk": case ".aab": isBundle = true default: return fmt.Errorf("the specified output %q does not end in '.apk' or '.aab'", file) } if err := exeAndroid(tmpDir, tools, bi, extraJars, perms, isBundle); err != nil { return err } if isBundle { return signAAB(tmpDir, file, tools, bi) } return signAPK(tmpDir, file, tools, bi) default: panic("unreachable") } } func compileAndroid(tmpDir string, tools *androidTools, bi *buildInfo) (err error) { androidHome := os.Getenv("ANDROID_SDK_ROOT") if androidHome == "" { return errors.New("ANDROID_SDK_ROOT is not set. Please point it to the root of the Android SDK") } javac, err := findJavaC() if err != nil { return fmt.Errorf("could not find javac: %v", err) } ndkRoot, err := findNDK(androidHome) if err != nil { return err } minSDK := 16 if bi.minsdk > minSDK { minSDK = bi.minsdk } tcRoot := filepath.Join(ndkRoot, "toolchains", "llvm", "prebuilt", archNDK()) var builds errgroup.Group for _, a := range bi.archs { arch := allArchs[a] clang, err := latestCompiler(tcRoot, a, minSDK) if err != nil { return fmt.Errorf("%s. Please make sure you have NDK >= r19c installed. Use the command `sdkmanager ndk-bundle` to install it.", err) } if runtime.GOOS == "windows" { // Because of https://github.com/android-ndk/ndk/issues/920, // we need NDK r19c, not just r19b. Check for the presence of // clang++.cmd which is only available in r19c. clangpp := clang + "++.cmd" if _, err := os.Stat(clangpp); err != nil { return fmt.Errorf("NDK version r19b detected, but >= r19c is required. Use the command `sdkmanager ndk-bundle` to install it") } } archDir := filepath.Join(tmpDir, "jni", arch.jniArch) if err := os.MkdirAll(archDir, 0755); err != nil { return fmt.Errorf("failed to create %q: %v", archDir, err) } libFile := filepath.Join(archDir, "libgio.so") cmd := exec.Command( "go", "build", "-ldflags=-w -s "+bi.ldflags, "-buildmode=c-shared", "-tags", bi.tags, "-o", libFile, bi.pkgPath, ) cmd.Env = append( os.Environ(), "GOOS=android", "GOARCH="+a, "GOARM=7", // Avoid softfloat. "CGO_ENABLED=1", "CC="+clang, ) builds.Go(func() error { _, err := runCmd(cmd) return err }) } appDir, err := runCmd(exec.Command("go", "list", "-f", "{{.Dir}}", "github.com/p9c/p9/pkg/gel/gio/app/internal/wm")) if err != nil { return err } javaFiles, err := filepath.Glob(filepath.Join(appDir, "*.java")) if err != nil { return err } if len(javaFiles) > 0 { classes := filepath.Join(tmpDir, "classes") if err := os.MkdirAll(classes, 0755); err != nil { return err } javac := exec.Command( javac, "-target", "1.8", "-source", "1.8", "-sourcepath", appDir, "-bootclasspath", tools.androidjar, "-d", classes, ) javac.Args = append(javac.Args, javaFiles...) builds.Go(func() error { _, err := runCmd(javac) return err }) } return builds.Wait() } func archiveAndroid(tmpDir string, bi *buildInfo, perms []string) (err error) { aarFile := *destPath if aarFile == "" { aarFile = fmt.Sprintf("%s.aar", bi.name) } if filepath.Ext(aarFile) != ".aar" { return fmt.Errorf("the specified output %q does not end in '.aar'", aarFile) } aar, err := os.Create(aarFile) if err != nil { return err } defer func() { if cerr := aar.Close(); err == nil { err = cerr } }() aarw := newZipWriter(aar) defer aarw.Close() aarw.Create("R.txt") themesXML := aarw.Create("res/values/themes.xml") themesXML.Write([]byte(themes)) themesXML21 := aarw.Create("res/values-v21/themes.xml") themesXML21.Write([]byte(themesV21)) permissions, features := getPermissions(perms) // Disable input emulation on ChromeOS. manifest := aarw.Create("AndroidManifest.xml") manifestSrc := manifestData{ AppID: bi.appID, MinSDK: bi.minsdk, Permissions: permissions, Features: features, } tmpl, err := template.New("manifest").Parse( ` {{range .Permissions}} {{end}}{{range .Features}} {{end}} `) if err != nil { panic(err) } err = tmpl.Execute(manifest, manifestSrc) proguard := aarw.Create("proguard.txt") proguard.Write([]byte(`-keep class org.gioui.** { *; }`)) for _, a := range bi.archs { arch := allArchs[a] libFile := filepath.Join("jni", arch.jniArch, "libgio.so") aarw.Add(filepath.ToSlash(libFile), filepath.Join(tmpDir, libFile)) } classes := filepath.Join(tmpDir, "classes") if _, err := os.Stat(classes); err == nil { jarFile := filepath.Join(tmpDir, "classes.jar") if err := writeJar(jarFile, classes); err != nil { return err } aarw.Add("classes.jar", jarFile) } return aarw.Close() } func exeAndroid(tmpDir string, tools *androidTools, bi *buildInfo, extraJars, perms []string, isBundle bool) (err error) { classes := filepath.Join(tmpDir, "classes") var classFiles []string err = filepath.Walk(classes, func(path string, f os.FileInfo, err error) error { if err != nil { return err } if filepath.Ext(path) == ".class" { classFiles = append(classFiles, path) } return nil }) classFiles = append(classFiles, extraJars...) dexDir := filepath.Join(tmpDir, "apk") if err := os.MkdirAll(dexDir, 0755); err != nil { return err } if len(classFiles) > 0 { d8 := exec.Command( filepath.Join(tools.buildtools, "d8"), "--classpath", tools.androidjar, "--output", dexDir, ) d8.Args = append(d8.Args, classFiles...) if _, err := runCmd(d8); err != nil { return err } } // Compile resources. resDir := filepath.Join(tmpDir, "res") valDir := filepath.Join(resDir, "values") v21Dir := filepath.Join(resDir, "values-v21") for _, dir := range []string{valDir, v21Dir} { if err := os.MkdirAll(dir, 0755); err != nil { return err } } iconSnip := "" if _, err := os.Stat(bi.iconPath); err == nil { err := buildIcons(resDir, bi.iconPath, []iconVariant{ {path: filepath.Join("mipmap-hdpi", "ic_launcher.png"), size: 72}, {path: filepath.Join("mipmap-xhdpi", "ic_launcher.png"), size: 96}, {path: filepath.Join("mipmap-xxhdpi", "ic_launcher.png"), size: 144}, {path: filepath.Join("mipmap-xxxhdpi", "ic_launcher.png"), size: 192}, }) if err != nil { return err } iconSnip = `android:icon="@mipmap/ic_launcher"` } err = ioutil.WriteFile(filepath.Join(valDir, "themes.xml"), []byte(themes), 0660) if err != nil { return err } err = ioutil.WriteFile(filepath.Join(v21Dir, "themes.xml"), []byte(themesV21), 0660) if err != nil { return err } resZip := filepath.Join(tmpDir, "resources.zip") aapt2 := filepath.Join(tools.buildtools, "aapt2") _, err = runCmd(exec.Command( aapt2, "compile", "-o", resZip, "--dir", resDir)) if err != nil { return err } // Link APK. // Currently, new apps must have a target SDK version of at least 30. // https://developer.android.com/distribute/best-practices/develop/target-sdk targetSDK := 30 if bi.minsdk > targetSDK { targetSDK = bi.minsdk } minSDK := 16 if bi.minsdk > minSDK { minSDK = bi.minsdk } permissions, features := getPermissions(perms) appName := strings.Title(bi.name) manifestSrc := manifestData{ AppID: bi.appID, Version: bi.version, MinSDK: minSDK, TargetSDK: targetSDK, Permissions: permissions, Features: features, IconSnip: iconSnip, AppName: appName, } tmpl, err := template.New("test").Parse( ` {{range .Permissions}} {{end}}{{range .Features}} {{end}} `) var manifestBuffer bytes.Buffer if err := tmpl.Execute(&manifestBuffer, manifestSrc); err != nil { return err } manifest := filepath.Join(tmpDir, "AndroidManifest.xml") if err := ioutil.WriteFile(manifest, manifestBuffer.Bytes(), 0660); err != nil { return err } linkAPK := filepath.Join(tmpDir, "link.apk") args := []string{ "link", "--manifest", manifest, "-I", tools.androidjar, "-o", linkAPK, } if isBundle { args = append(args, "--proto-format") } args = append(args, resZip) if _, err := runCmd(exec.Command(aapt2, args...)); err != nil { return err } // The Go standard library archive/zip doesn't support appending to zip // files. Copy files from `link.apk` (generated by aapt2) along with classes.dex and // the Go libraries to a new `app.zip` file. // Load link.apk as zip. linkAPKZip, err := zip.OpenReader(linkAPK) if err != nil { return err } defer linkAPKZip.Close() // Create new "APK". unsignedAPK := filepath.Join(tmpDir, "app.zip") unsignedAPKFile, err := os.Create(unsignedAPK) if err != nil { return err } defer func() { if cerr := unsignedAPKFile.Close(); err == nil { err = cerr } }() unsignedAPKZip := zip.NewWriter(unsignedAPKFile) defer unsignedAPKZip.Close() // Copy files from linkAPK to unsignedAPK. for _, f := range linkAPKZip.File { header := zip.FileHeader{ Name: f.FileHeader.Name, Method: f.FileHeader.Method, } if isBundle { // AAB have pre-defined folders. switch header.Name { case "AndroidManifest.xml": header.Name = "manifest/AndroidManifest.xml" } } w, err := unsignedAPKZip.CreateHeader(&header) if err != nil { return err } r, err := f.Open() if err != nil { return err } if _, err := io.Copy(w, r); err != nil { return err } } // Append new files (that doesn't exists inside the link.apk). appendToZip := func(path string, file string) error { f, err := os.Open(file) if err != nil { return err } defer f.Close() w, err := unsignedAPKZip.CreateHeader(&zip.FileHeader{ Name: filepath.ToSlash(path), Method: zip.Deflate, }) if err != nil { return err } _, err = io.Copy(w, f) return err } // Append Go binaries (libgio.so). for _, a := range bi.archs { arch := allArchs[a] libFile := filepath.Join(arch.jniArch, "libgio.so") if err := appendToZip(filepath.Join("lib", libFile), filepath.Join(tmpDir, "jni", libFile)); err != nil { return err } } // Append classes.dex. classesFolder := "classes.dex" if isBundle { classesFolder = "dex/classes.dex" } if err := appendToZip(classesFolder, filepath.Join(dexDir, "classes.dex")); err != nil { return err } return unsignedAPKZip.Close() } func signAPK(tmpDir string, apkFile string, tools *androidTools, bi *buildInfo) error { if err := zipalign(tools, filepath.Join(tmpDir, "app.zip"), apkFile); err != nil { return err } if bi.key == "" { if err := defaultAndroidKeystore(tmpDir, bi); err != nil { return err } } _, err := runCmd(exec.Command( filepath.Join(tools.buildtools, "apksigner"), "sign", "--ks-pass", "pass:"+bi.password, "--ks", bi.key, apkFile, )) return err } func signAAB(tmpDir string, aabFile string, tools *androidTools, bi *buildInfo) error { allBundleTools, err := filepath.Glob(filepath.Join(tools.buildtools, "bundletool*.jar")) if err != nil { return err } bundletool := "" for _, v := range allBundleTools { bundletool = v break } if bundletool == "" { return fmt.Errorf("bundletool was not found at %s. Download it from https://github.com/google/bundletool/releases and move to the respective folder", tools.buildtools) } _, err = runCmd(exec.Command( "java", "-jar", bundletool, "build-bundle", "--modules="+filepath.Join(tmpDir, "app.zip"), "--output="+filepath.Join(tmpDir, "app.aab"), )) if err != nil { return err } if err := zipalign(tools, filepath.Join(tmpDir, "app.aab"), aabFile); err != nil { return err } if bi.key == "" { if err := defaultAndroidKeystore(tmpDir, bi); err != nil { return err } } keytoolList, err := runCmd(exec.Command( "keytool", "-keystore", bi.key, "-list", "-keypass", bi.password, "-v", )) if err != nil { return err } var alias string for _, t := range strings.Split(keytoolList, "\n") { if i, _ := fmt.Sscanf(t, "Alias name: %s", &alias); i > 0 { break } } _, err = runCmd(exec.Command( filepath.Join("jarsigner"), "-sigalg", "SHA256withRSA", "-digestalg", "SHA-256", "-keystore", bi.key, "-storepass", bi.password, aabFile, strings.TrimSpace(alias), )) return err } func zipalign(tools *androidTools, input, output string) error { _, err := runCmd(exec.Command( filepath.Join(tools.buildtools, "zipalign"), "-f", "4", // 32-bit alignment. input, output, )) return err } func defaultAndroidKeystore(tmpDir string, bi *buildInfo) error { home, err := os.UserHomeDir() if err != nil { return err } // Use debug.keystore, if exists. bi.key = filepath.Join(home, ".android", "debug.keystore") bi.password = "android" if _, err := os.Stat(bi.key); err == nil { return nil } // Generate new key. bi.key = filepath.Join(tmpDir, "sign.keystore") keytool, err := findKeytool() if err != nil { return err } _, err = runCmd(exec.Command( keytool, "-genkey", "-keystore", bi.key, "-storepass", bi.password, "-alias", "android", "-keyalg", "RSA", "-keysize", "2048", "-validity", "10000", "-noprompt", "-dname", "CN=android", )) return err } func findNDK(androidHome string) (string, error) { ndks, err := filepath.Glob(filepath.Join(androidHome, "ndk", "*")) if err != nil { return "", err } if bestNDK, found := latestVersionPath(ndks); found { return bestNDK, nil } // The old NDK path was $ANDROID_SDK_ROOT/ndk-bundle. ndkBundle := filepath.Join(androidHome, "ndk-bundle") if _, err := os.Stat(ndkBundle); err == nil { return ndkBundle, nil } // Certain non-standard NDK isntallations set the $ANDROID_NDK_ROOT // environment variable if ndkBundle, ok := os.LookupEnv("ANDROID_NDK_ROOT"); ok { if _, err := os.Stat(ndkBundle); err == nil { return ndkBundle, nil } } return "", fmt.Errorf("no NDK found in $ANDROID_SDK_ROOT (%s). Set $ANDROID_NDK_ROOT or use `sdkmanager ndk-bundle` to install the NDK", androidHome) } func findKeytool() (string, error) { keytool, err := exec.LookPath("keytool") if err == nil { return keytool, err } javaHome := os.Getenv("JAVA_HOME") if javaHome == "" { return "", err } keytool = filepath.Join(javaHome, "jre", "bin", "keytool"+exeSuffix) if _, serr := os.Stat(keytool); serr == nil { return keytool, nil } return "", err } func findJavaC() (string, error) { javac, err := exec.LookPath("javac") if err == nil { return javac, err } javaHome := os.Getenv("JAVA_HOME") if javaHome == "" { return "", err } javac = filepath.Join(javaHome, "bin", "javac"+exeSuffix) if _, serr := os.Stat(javac); serr == nil { return javac, nil } return "", err } func writeJar(jarFile, dir string) (err error) { jar, err := os.Create(jarFile) if err != nil { return err } defer func() { if cerr := jar.Close(); err == nil { err = cerr } }() jarw := newZipWriter(jar) const manifestHeader = `Manifest-Version: 1.0 Created-By: 1.0 (Go) ` jarw.Create("META-INF/MANIFEST.MF").Write([]byte(manifestHeader)) err = filepath.Walk(dir, func(path string, f os.FileInfo, err error) error { if err != nil { return err } if f.IsDir() { return nil } if filepath.Ext(path) == ".class" { rel := filepath.ToSlash(path[len(dir)+1:]) jarw.Add(rel, path) } return nil }) if err != nil { return err } return jarw.Close() } func archNDK() string { var arch string switch runtime.GOARCH { case "386": arch = "x86" case "amd64": arch = "x86_64" default: panic("unsupported GOARCH: " + runtime.GOARCH) } return runtime.GOOS + "-" + arch } func getPermissions(ps []string) ([]string, []string) { var permissions, features []string seenPermissions := make(map[string]bool) seenFeatures := make(map[string]bool) for _, perm := range ps { for _, x := range AndroidPermissions[perm] { if !seenPermissions[x] { permissions = append(permissions, x) seenPermissions[x] = true } } for _, x := range AndroidFeatures[perm] { if !seenFeatures[x] { features = append(features, x) seenFeatures[x] = true } } } return permissions, features } func latestPlatform(sdk string) (string, error) { allPlats, err := filepath.Glob(filepath.Join(sdk, "platforms", "android-*")) if err != nil { return "", err } var bestVer int var bestPlat string for _, platform := range allPlats { _, name := filepath.Split(platform) // The glob above guarantees the "android-" prefix. verStr := name[len("android-"):] ver, err := strconv.Atoi(verStr) if err != nil { continue } if ver < bestVer { continue } bestVer = ver bestPlat = platform } if bestPlat == "" { return "", fmt.Errorf("no platforms found in %q", sdk) } return bestPlat, nil } func latestCompiler(tcRoot, a string, minsdk int) (string, error) { arch := allArchs[a] allComps, err := filepath.Glob(filepath.Join(tcRoot, "bin", arch.clangArch+"*-clang")) if err != nil { return "", err } var bestVer int var firstVer int var bestCompiler string var firstCompiler string for _, compiler := range allComps { var ver int pattern := filepath.Join(tcRoot, "bin", arch.clangArch) + "%d-clang" if n, err := fmt.Sscanf(compiler, pattern, &ver); n < 1 || err != nil { continue } if firstCompiler == "" || ver < firstVer { firstVer = ver firstCompiler = compiler } if ver < bestVer { continue } if ver > minsdk { continue } bestVer = ver bestCompiler = compiler } if bestCompiler == "" { bestCompiler = firstCompiler } if bestCompiler == "" { return "", fmt.Errorf("no NDK compiler found for architecture %s in %s", a, tcRoot) } return bestCompiler, nil } func latestTools(sdk string) (string, error) { allTools, err := filepath.Glob(filepath.Join(sdk, "build-tools", "*")) if err != nil { return "", err } tools, found := latestVersionPath(allTools) if !found { return "", fmt.Errorf("no build-tools found in %q", sdk) } return tools, nil } // latestVersionFile finds the path with the highest version // among paths on the form // // /some/path/major.minor.patch func latestVersionPath(paths []string) (string, bool) { var bestVer [3]int var bestDir string loop: for _, path := range paths { name := filepath.Base(path) s := strings.SplitN(name, ".", 3) if len(s) != len(bestVer) { continue } var version [3]int for i, v := range s { v, err := strconv.Atoi(v) if err != nil { continue loop } if v < bestVer[i] { continue loop } if v > bestVer[i] { break } version[i] = v } bestVer = version bestDir = path } return bestDir, bestDir != "" } func newZipWriter(w io.Writer) *zipWriter { return &zipWriter{ w: zip.NewWriter(w), } } func (z *zipWriter) Close() error { err := z.w.Close() if z.err == nil { z.err = err } return z.err } func (z *zipWriter) Create(name string) io.Writer { if z.err != nil { return ioutil.Discard } w, err := z.w.Create(name) if err != nil { z.err = err return ioutil.Discard } return &errWriter{w: w, err: &z.err} } func (z *zipWriter) Store(name, file string) { z.add(name, file, false) } func (z *zipWriter) Add(name, file string) { z.add(name, file, true) } func (z *zipWriter) add(name, file string, compressed bool) { if z.err != nil { return } f, err := os.Open(file) if err != nil { z.err = err return } defer f.Close() fh := &zip.FileHeader{ Name: name, } if compressed { fh.Method = zip.Deflate } w, err := z.w.CreateHeader(fh) if err != nil { z.err = err return } if _, err := io.Copy(w, f); err != nil { z.err = err return } } func (w *errWriter) Write(p []byte) (n int, err error) { if err := *w.err; err != nil { return 0, err } n, err = w.w.Write(p) *w.err = err return }