all: modernize handling of Android SDK and NDK paths

This change removes Gomobile's dependency on ANDROID_HOME and
ANDROID_NDK_HOME.  Setting ANDROID_HOME is generally optional,
and ANDROID_NDK_HOME is deprecated.

This change also increases the minimum API version to 16, as
all SDKs that supported API 15 are now deprecated.

Fixes golang/go#52470

Change-Id: I546365774a089e5d7ae1be0a538efd72741d92ac
Reviewed-on: https://go-review.googlesource.com/c/mobile/+/401574
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Daniel Skinner <daniel@dasa.cc>
Reviewed-by: Suzy Mueller <suzmue@golang.org>
Reviewed-by: Hajime Hoshi <hajimehoshi@gmail.com>
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
diff --git a/bind/java/seq_test.go b/bind/java/seq_test.go
index 8f741c6..a5151d4 100644
--- a/bind/java/seq_test.go
+++ b/bind/java/seq_test.go
@@ -17,6 +17,7 @@
 	"testing"
 
 	"golang.org/x/mobile/internal/importers/java"
+	"golang.org/x/mobile/internal/sdkpath"
 )
 
 var gomobileBin string
@@ -98,8 +99,8 @@
 // runTest runs the Android java test class specified with javaCls. If javaPkg is
 // set, it is passed with the -javapkg flag to gomobile. The pkgNames lists the Go
 // packages to bind for the test.
-// This requires the gradle command in PATH and
-// the Android SDK whose path is available through ANDROID_HOME environment variable.
+// This requires the gradle command to be in PATH and the Android SDK to be
+// installed.
 func runTest(t *testing.T, pkgNames []string, javaPkg, javaCls string) {
 	if gomobileBin == "" {
 		t.Skipf("no gomobile on %s", runtime.GOOS)
@@ -108,8 +109,8 @@
 	if err != nil {
 		t.Skip("command gradle not found, skipping")
 	}
-	if sdk := os.Getenv("ANDROID_HOME"); sdk == "" {
-		t.Skip("ANDROID_HOME environment var not set, skipping")
+	if _, err := sdkpath.AndroidHome(); err != nil {
+		t.Skip("Android SDK not found, skipping")
 	}
 
 	cwd, err := os.Getwd()
diff --git a/cmd/gomobile/bind.go b/cmd/gomobile/bind.go
index efbc896..c4552e3 100644
--- a/cmd/gomobile/bind.go
+++ b/cmd/gomobile/bind.go
@@ -16,6 +16,7 @@
 	"path/filepath"
 	"strings"
 
+	"golang.org/x/mobile/internal/sdkpath"
 	"golang.org/x/mod/modfile"
 	"golang.org/x/tools/go/packages"
 )
@@ -42,9 +43,10 @@
 the module import wizard (File > New > New Module > Import .JAR or
 .AAR package), and setting it as a new dependency
 (File > Project Structure > Dependencies).  This requires 'javac'
-(version 1.7+) and Android SDK (API level 15 or newer) to build the
-library for Android. The environment variable ANDROID_HOME must be set
-to the path to Android SDK. Use the -javapkg flag to specify the Java
+(version 1.7+) and Android SDK (API level 16 or newer) to build the
+library for Android. The ANDROID_HOME and ANDROID_NDK_HOME environment
+variables can be used to specify the Android SDK and NDK if they are
+not in the default locations. Use the -javapkg flag to specify the Java
 package prefix for the generated classes.
 
 By default, -target=android builds shared libraries for all supported
@@ -85,7 +87,7 @@
 		if bindPrefix != "" {
 			return fmt.Errorf("-prefix is supported only for Apple targets")
 		}
-		if _, err := ndkRoot(); err != nil {
+		if _, err := ndkRoot(targets[0]); err != nil {
 			return err
 		}
 	} else {
@@ -156,7 +158,7 @@
 	if bindBootClasspath != "" {
 		return bindBootClasspath, nil
 	}
-	apiPath, err := androidAPIPath()
+	apiPath, err := sdkpath.AndroidAPIPath(buildAndroidAPI)
 	if err != nil {
 		return "", err
 	}
diff --git a/cmd/gomobile/bind_androidapp.go b/cmd/gomobile/bind_androidapp.go
index 7703868..a56fd82 100644
--- a/cmd/gomobile/bind_androidapp.go
+++ b/cmd/gomobile/bind_androidapp.go
@@ -12,15 +12,15 @@
 	"os"
 	"os/exec"
 	"path/filepath"
-	"strconv"
 	"strings"
 
+	"golang.org/x/mobile/internal/sdkpath"
 	"golang.org/x/tools/go/packages"
 )
 
 func goAndroidBind(gobind string, pkgs []*packages.Package, targets []targetInfo) error {
-	if sdkDir := os.Getenv("ANDROID_HOME"); sdkDir == "" {
-		return fmt.Errorf("this command requires ANDROID_HOME environment variable (path to the Android SDK)")
+	if _, err := sdkpath.AndroidHome(); err != nil {
+		return fmt.Errorf("this command requires the Android SDK to be installed: %w", err)
 	}
 
 	// Run gobind to generate the bindings
@@ -270,7 +270,7 @@
 
 const (
 	javacTargetVer = "1.7"
-	minAndroidAPI  = 15
+	minAndroidAPI  = 16
 )
 
 func buildJar(w io.Writer, srcDir string) error {
@@ -370,46 +370,3 @@
 	}
 	return jarw.Close()
 }
-
-// androidAPIPath returns an android SDK platform directory under ANDROID_HOME.
-// If there are multiple platforms that satisfy the minimum version requirement
-// androidAPIPath returns the latest one among them.
-func androidAPIPath() (string, error) {
-	sdk := os.Getenv("ANDROID_HOME")
-	if sdk == "" {
-		return "", fmt.Errorf("ANDROID_HOME environment var is not set")
-	}
-	sdkDir, err := os.Open(filepath.Join(sdk, "platforms"))
-	if err != nil {
-		return "", fmt.Errorf("failed to find android SDK platform: %v", err)
-	}
-	defer sdkDir.Close()
-	fis, err := sdkDir.Readdir(-1)
-	if err != nil {
-		return "", fmt.Errorf("failed to find android SDK platform (API level: %d): %v", buildAndroidAPI, err)
-	}
-
-	var apiPath string
-	var apiVer int
-	for _, fi := range fis {
-		name := fi.Name()
-		if !strings.HasPrefix(name, "android-") {
-			continue
-		}
-		n, err := strconv.Atoi(name[len("android-"):])
-		if err != nil || n < buildAndroidAPI {
-			continue
-		}
-		p := filepath.Join(sdkDir.Name(), name)
-		_, err = os.Stat(filepath.Join(p, "android.jar"))
-		if err == nil && apiVer < n {
-			apiPath = p
-			apiVer = n
-		}
-	}
-	if apiVer == 0 {
-		return "", fmt.Errorf("failed to find android SDK platform (API level: %d) in %s",
-			buildAndroidAPI, sdkDir.Name())
-	}
-	return apiPath, nil
-}
diff --git a/cmd/gomobile/bind_test.go b/cmd/gomobile/bind_test.go
index 0e9f401..cac3511 100644
--- a/cmd/gomobile/bind_test.go
+++ b/cmd/gomobile/bind_test.go
@@ -14,18 +14,22 @@
 	"strings"
 	"testing"
 	"text/template"
+
+	"golang.org/x/mobile/internal/sdkpath"
 )
 
 func TestBindAndroid(t *testing.T) {
-	androidHome := os.Getenv("ANDROID_HOME")
-	if androidHome == "" {
-		t.Skip("ANDROID_HOME not found, skipping bind")
-	}
-	platform, err := androidAPIPath()
+	platform, err := sdkpath.AndroidAPIPath(minAndroidAPI)
 	if err != nil {
-		t.Skip("No android API platform found in $ANDROID_HOME, skipping bind")
+		t.Skip("No compatible Android API platform found, skipping bind")
 	}
-	platform = strings.Replace(platform, androidHome, "$ANDROID_HOME", -1)
+	// platform is a path like "/path/to/Android/sdk/platforms/android-32"
+	components := strings.Split(platform, string(filepath.Separator))
+	if len(components) < 2 {
+		t.Fatalf("API path is too short: %s", platform)
+	}
+	components = components[len(components)-2:]
+	platformRel := filepath.Join("$ANDROID_HOME", components[0], components[1])
 
 	defer func() {
 		xout = os.Stderr
@@ -77,7 +81,7 @@
 			JavaPkg         string
 		}{
 			outputData:      output,
-			AndroidPlatform: platform,
+			AndroidPlatform: platformRel,
 			JavaPkg:         tc.javaPkg,
 		}
 
@@ -273,12 +277,8 @@
 		t.Run(target, func(t *testing.T) {
 			switch target {
 			case "android":
-				androidHome := os.Getenv("ANDROID_HOME")
-				if androidHome == "" {
-					t.Skip("ANDROID_HOME not found, skipping bind")
-				}
-				if _, err := androidAPIPath(); err != nil {
-					t.Skip("No android API platform found in $ANDROID_HOME, skipping bind")
+				if _, err := sdkpath.AndroidAPIPath(minAndroidAPI); err != nil {
+					t.Skip("No compatible Android API platform found, skipping bind")
 				}
 			case "ios":
 				if !xcodeAvailable() {
diff --git a/cmd/gomobile/build.go b/cmd/gomobile/build.go
index 4c83ca0..64a28fe 100644
--- a/cmd/gomobile/build.go
+++ b/cmd/gomobile/build.go
@@ -17,6 +17,7 @@
 	"strconv"
 	"strings"
 
+	"golang.org/x/mobile/internal/sdkpath"
 	"golang.org/x/tools/go/packages"
 )
 
@@ -60,7 +61,7 @@
 The default version is 13.0.
 
 Flag -androidapi sets the Android API version to compile against.
-The default and minimum is 15.
+The default and minimum is 16.
 
 The -bundleid flag is required for -target ios and sets the bundle ID to use
 with the app.
@@ -215,7 +216,7 @@
 	if tmpdir != "" {
 		cmd = strings.Replace(cmd, tmpdir, "$WORK", -1)
 	}
-	if androidHome := os.Getenv("ANDROID_HOME"); androidHome != "" {
+	if androidHome, err := sdkpath.AndroidHome(); err == nil {
 		cmd = strings.Replace(cmd, androidHome, "$ANDROID_HOME", -1)
 	}
 	if gomobilepath != "" {
diff --git a/cmd/gomobile/build_androidapp.go b/cmd/gomobile/build_androidapp.go
index b06ea29..bcd2664 100644
--- a/cmd/gomobile/build_androidapp.go
+++ b/cmd/gomobile/build_androidapp.go
@@ -25,7 +25,7 @@
 )
 
 func goAndroidBuild(pkg *packages.Package, targets []targetInfo) (map[string]bool, error) {
-	ndkRoot, err := ndkRoot()
+	ndkRoot, err := ndkRoot(targets...)
 	if err != nil {
 		return nil, err
 	}
diff --git a/cmd/gomobile/build_test.go b/cmd/gomobile/build_test.go
index ff21f87..448c6dd 100644
--- a/cmd/gomobile/build_test.go
+++ b/cmd/gomobile/build_test.go
@@ -14,6 +14,8 @@
 	"strings"
 	"testing"
 	"text/template"
+
+	"golang.org/x/mobile/internal/sdkpath"
 )
 
 func TestRFC1034Label(t *testing.T) {
@@ -228,12 +230,8 @@
 		t.Run(target, func(t *testing.T) {
 			switch target {
 			case "android":
-				androidHome := os.Getenv("ANDROID_HOME")
-				if androidHome == "" {
-					t.Skip("ANDROID_HOME not found, skipping bind")
-				}
-				if _, err := androidAPIPath(); err != nil {
-					t.Skip("No android API platform found in $ANDROID_HOME, skipping bind")
+				if _, err := sdkpath.AndroidAPIPath(minAndroidAPI); err != nil {
+					t.Skip("No compatible android API platform found, skipping bind")
 				}
 			case "ios":
 				if !xcodeAvailable() {
diff --git a/cmd/gomobile/env.go b/cmd/gomobile/env.go
index ff32517..43f24b9 100644
--- a/cmd/gomobile/env.go
+++ b/cmd/gomobile/env.go
@@ -1,6 +1,8 @@
 package main
 
 import (
+	"bufio"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"io/fs"
@@ -10,6 +12,8 @@
 	"path/filepath"
 	"runtime"
 	"strings"
+
+	"golang.org/x/mobile/internal/sdkpath"
 )
 
 // General mobile build environment. Initialized by envInit.
@@ -276,29 +280,155 @@
 	return nil
 }
 
-func ndkRoot() (string, error) {
+// abi maps GOARCH values to Android ABI strings.
+// See https://developer.android.com/ndk/guides/abis
+func abi(goarch string) string {
+	switch goarch {
+	case "arm":
+		return "armeabi-v7a"
+	case "arm64":
+		return "arm64-v8a"
+	case "386":
+		return "x86"
+	case "amd64":
+		return "x86_64"
+	default:
+		return ""
+	}
+}
+
+// checkNDKRoot returns nil if the NDK in `ndkRoot` supports the current configured
+// API version and all the specified Android targets.
+func checkNDKRoot(ndkRoot string, targets []targetInfo) error {
+	platformsJson, err := os.Open(filepath.Join(ndkRoot, "meta", "platforms.json"))
+	if err != nil {
+		return err
+	}
+	defer platformsJson.Close()
+	decoder := json.NewDecoder(platformsJson)
+	supportedVersions := struct {
+		Min int
+		Max int
+	}{}
+	if err := decoder.Decode(&supportedVersions); err != nil {
+		return err
+	}
+	if supportedVersions.Min > buildAndroidAPI ||
+		supportedVersions.Max < buildAndroidAPI {
+		return fmt.Errorf("unsupported API version %d (not in %d..%d)", buildAndroidAPI, supportedVersions.Min, supportedVersions.Max)
+	}
+	abisJson, err := os.Open(filepath.Join(ndkRoot, "meta", "abis.json"))
+	if err != nil {
+		return err
+	}
+	defer abisJson.Close()
+	decoder = json.NewDecoder(abisJson)
+	abis := make(map[string]struct{})
+	if err := decoder.Decode(&abis); err != nil {
+		return err
+	}
+	for _, target := range targets {
+		if !isAndroidPlatform(target.platform) {
+			continue
+		}
+		if _, found := abis[abi(target.arch)]; !found {
+			return fmt.Errorf("ndk does not support %s", target.platform)
+		}
+	}
+	return nil
+}
+
+// compatibleNDKRoots searches the side-by-side NDK dirs for compatible SDKs.
+func compatibleNDKRoots(ndkForest string, targets []targetInfo) ([]string, error) {
+	ndkDirs, err := ioutil.ReadDir(ndkForest)
+	if err != nil {
+		return nil, err
+	}
+	compatibleNDKRoots := []string{}
+	var lastErr error
+	for _, dirent := range ndkDirs {
+		ndkRoot := filepath.Join(ndkForest, dirent.Name())
+		lastErr = checkNDKRoot(ndkRoot, targets)
+		if lastErr == nil {
+			compatibleNDKRoots = append(compatibleNDKRoots, ndkRoot)
+		}
+	}
+	if len(compatibleNDKRoots) > 0 {
+		return compatibleNDKRoots, nil
+	}
+	return nil, lastErr
+}
+
+// ndkVersion returns the full version number of an installed copy of the NDK,
+// or "" if it cannot be determined.
+func ndkVersion(ndkRoot string) string {
+	properties, err := os.Open(filepath.Join(ndkRoot, "source.properties"))
+	if err != nil {
+		return ""
+	}
+	defer properties.Close()
+	// Parse the version number out of the .properties file.
+	// See https://en.wikipedia.org/wiki/.properties
+	scanner := bufio.NewScanner(properties)
+	for scanner.Scan() {
+		line := scanner.Text()
+		tokens := strings.SplitN(line, "=", 2)
+		if len(tokens) != 2 {
+			continue
+		}
+		if strings.TrimSpace(tokens[0]) == "Pkg.Revision" {
+			return strings.TrimSpace(tokens[1])
+		}
+	}
+	return ""
+}
+
+// ndkRoot returns the root path of an installed NDK that supports all the
+// specified Android targets. For details of NDK locations, see
+// https://github.com/android/ndk-samples/wiki/Configure-NDK-Path
+func ndkRoot(targets ...targetInfo) (string, error) {
 	if buildN {
 		return "$NDK_PATH", nil
 	}
 
-	androidHome := os.Getenv("ANDROID_HOME")
-	if androidHome != "" {
-		ndkRoot := filepath.Join(androidHome, "ndk-bundle")
-		_, err := os.Stat(ndkRoot)
-		if err == nil {
-			return ndkRoot, nil
+	// Try the ANDROID_NDK_HOME variable.  This approach is deprecated, but it
+	// has the highest priority because it represents an explicit user choice.
+	if ndkRoot := os.Getenv("ANDROID_NDK_HOME"); ndkRoot != "" {
+		if err := checkNDKRoot(ndkRoot, targets); err != nil {
+			return "", fmt.Errorf("ANDROID_NDK_HOME specifies %s, which is unusable: %w", ndkRoot, err)
 		}
+		return ndkRoot, nil
 	}
 
-	ndkRoot := os.Getenv("ANDROID_NDK_HOME")
-	if ndkRoot != "" {
-		_, err := os.Stat(ndkRoot)
-		if err == nil {
-			return ndkRoot, nil
-		}
+	androidHome, err := sdkpath.AndroidHome()
+	if err != nil {
+		return "", fmt.Errorf("could not locate Android SDK: %w", err)
 	}
 
-	return "", fmt.Errorf("no Android NDK found in $ANDROID_HOME/ndk-bundle nor in $ANDROID_NDK_HOME")
+	// Use the newest compatible NDK under the side-by-side path arrangement.
+	ndkForest := filepath.Join(androidHome, "ndk")
+	ndkRoots, sideBySideErr := compatibleNDKRoots(ndkForest, targets)
+	if len(ndkRoots) != 0 {
+		// Choose the latest version that supports the build configuration.
+		// NDKs whose version cannot be determined will be least preferred.
+		// In the event of a tie, the later ndkRoot will win.
+		maxVersion := ""
+		var selected string
+		for _, ndkRoot := range ndkRoots {
+			version := ndkVersion(ndkRoot)
+			if version >= maxVersion {
+				maxVersion = version
+				selected = ndkRoot
+			}
+		}
+		return selected, nil
+	}
+	// Try the deprecated NDK location.
+	ndkRoot := filepath.Join(androidHome, "ndk-bundle")
+	if legacyErr := checkNDKRoot(ndkRoot, targets); legacyErr != nil {
+		return "", fmt.Errorf("no usable NDK in %s: %w, %v", androidHome, sideBySideErr, legacyErr)
+	}
+	return ndkRoot, nil
 }
 
 func envClang(sdkName string) (clang, cflags string, err error) {
diff --git a/cmd/gomobile/env_test.go b/cmd/gomobile/env_test.go
index 2eef8ab..e5ad282 100644
--- a/cmd/gomobile/env_test.go
+++ b/cmd/gomobile/env_test.go
@@ -5,6 +5,7 @@
 package main
 
 import (
+	"fmt"
 	"io/ioutil"
 	"os"
 	"path/filepath"
@@ -18,47 +19,143 @@
 	}
 
 	homeorig := os.Getenv("ANDROID_HOME")
+	os.Unsetenv("ANDROID_HOME")
 	ndkhomeorig := os.Getenv("ANDROID_NDK_HOME")
+	os.Unsetenv("ANDROID_NDK_HOME")
+
 	defer func() {
 		os.Setenv("ANDROID_HOME", homeorig)
 		os.Setenv("ANDROID_NDK_HOME", ndkhomeorig)
 		os.RemoveAll(home)
 	}()
 
-	os.Setenv("ANDROID_HOME", home)
-	sdkNDK := filepath.Join(home, "ndk-bundle")
-	envNDK := filepath.Join(home, "android-ndk")
-	os.Setenv("ANDROID_NDK_HOME", envNDK)
-
-	if ndk, err := ndkRoot(); err == nil {
-		t.Errorf("expected error but got %q", ndk)
-	}
-
-	for _, dir := range []string{sdkNDK, envNDK} {
+	makeMockNDK := func(path, version, platforms, abis string) string {
+		dir := filepath.Join(home, path)
 		if err := os.Mkdir(dir, 0755); err != nil {
 			t.Fatalf("couldn't mkdir %q", dir)
 		}
+		propertiesPath := filepath.Join(dir, "source.properties")
+		propertiesData := []byte("Pkg.Revision = " + version)
+		if err := os.WriteFile(propertiesPath, propertiesData, 0644); err != nil {
+			t.Fatalf("couldn't write source.properties: %v", err)
+		}
+		metaDir := filepath.Join(dir, "meta")
+		if err := os.Mkdir(metaDir, 0755); err != nil {
+			t.Fatalf("couldn't mkdir %q", metaDir)
+		}
+		platformsPath := filepath.Join(metaDir, "platforms.json")
+		platformsData := []byte(platforms)
+		if err := os.WriteFile(platformsPath, platformsData, 0644); err != nil {
+			t.Fatalf("couldn't write platforms.json: %v", err)
+		}
+		abisPath := filepath.Join(metaDir, "abis.json")
+		abisData := []byte(abis)
+		if err := os.WriteFile(abisPath, abisData, 0644); err != nil {
+			t.Fatalf("couldn't populate abis.json: %v", err)
+		}
+		return dir
 	}
 
-	if ndk, _ := ndkRoot(); ndk != sdkNDK {
-		t.Errorf("got %q want %q", ndk, sdkNDK)
-	}
+	t.Run("no NDK in the default location", func(t *testing.T) {
+		os.Setenv("ANDROID_HOME", home)
+		defer os.Unsetenv("ANDROID_HOME")
+		if ndk, err := ndkRoot(); err == nil {
+			t.Errorf("expected error but got %q", ndk)
+		}
+	})
 
-	os.Unsetenv("ANDROID_HOME")
+	t.Run("NDK location is set but is wrong", func(t *testing.T) {
+		os.Setenv("ANDROID_NDK_HOME", filepath.Join(home, "no-such-path"))
+		defer os.Unsetenv("ANDROID_NDK_HOME")
+		if ndk, err := ndkRoot(); err == nil {
+			t.Errorf("expected error but got %q", ndk)
+		}
+	})
 
-	if ndk, _ := ndkRoot(); ndk != envNDK {
-		t.Errorf("got %q want %q", ndk, envNDK)
-	}
+	t.Run("Two NDKs installed", func(t *testing.T) {
+		// Default path for pre-side-by-side NDKs.
+		sdkNDK := makeMockNDK("ndk-bundle", "fake-version", `{"min":16,"max":32}`, "{}")
+		defer os.RemoveAll(sdkNDK)
+		// Arbitrary location for testing ANDROID_NDK_HOME.
+		envNDK := makeMockNDK("custom-location", "fake-version", `{"min":16,"max":32}`, "{}")
 
-	os.RemoveAll(envNDK)
+		// ANDROID_NDK_HOME is sufficient.
+		os.Setenv("ANDROID_NDK_HOME", envNDK)
+		if ndk, err := ndkRoot(); ndk != envNDK {
+			t.Errorf("got (%q, %v) want (%q, nil)", ndk, err, envNDK)
+		}
 
-	if ndk, err := ndkRoot(); err == nil {
-		t.Errorf("expected error but got %q", ndk)
-	}
+		// ANDROID_NDK_HOME takes precedence over ANDROID_HOME.
+		os.Setenv("ANDROID_HOME", home)
+		if ndk, err := ndkRoot(); ndk != envNDK {
+			t.Errorf("got (%q, %v) want (%q, nil)", ndk, err, envNDK)
+		}
 
-	os.Setenv("ANDROID_HOME", home)
+		// ANDROID_NDK_HOME is respected even if there is no NDK there.
+		os.RemoveAll(envNDK)
+		if ndk, err := ndkRoot(); err == nil {
+			t.Errorf("expected error but got %q", ndk)
+		}
 
-	if ndk, _ := ndkRoot(); ndk != sdkNDK {
-		t.Errorf("got %q want %q", ndk, sdkNDK)
-	}
+		// ANDROID_HOME is used if ANDROID_NDK_HOME is not set.
+		os.Unsetenv("ANDROID_NDK_HOME")
+		if ndk, err := ndkRoot(); ndk != sdkNDK {
+			t.Errorf("got (%q, %v) want (%q, nil)", ndk, err, envNDK)
+		}
+	})
+
+	t.Run("Modern 'side-by-side' NDK selection", func(t *testing.T) {
+		defer func() {
+			buildAndroidAPI = minAndroidAPI
+		}()
+
+		ndkForest := filepath.Join(home, "ndk")
+		if err := os.Mkdir(ndkForest, 0755); err != nil {
+			t.Fatalf("couldn't mkdir %q", ndkForest)
+		}
+
+		path := filepath.Join("ndk", "newer")
+		platforms := `{"min":19,"max":32}`
+		abis := `{"arm64-v8a": {}, "armeabi-v7a": {}, "x86_64": {}}`
+		version := "17.2.0"
+		newerNDK := makeMockNDK(path, version, platforms, abis)
+
+		path = filepath.Join("ndk", "older")
+		platforms = `{"min":16,"max":31}`
+		abis = `{"arm64-v8a": {}, "armeabi-v7a": {}, "x86": {}}`
+		version = "17.1.0"
+		olderNDK := makeMockNDK(path, version, platforms, abis)
+
+		testCases := []struct {
+			api         int
+			targets     []targetInfo
+			wantNDKRoot string
+		}{
+			{15, nil, ""},
+			{16, nil, olderNDK},
+			{16, []targetInfo{{"android", "arm"}}, olderNDK},
+			{16, []targetInfo{{"android", "arm"}, {"android", "arm64"}}, olderNDK},
+			{16, []targetInfo{{"android", "x86_64"}}, ""},
+			{19, nil, newerNDK},
+			{19, []targetInfo{{"android", "arm"}}, newerNDK},
+			{19, []targetInfo{{"android", "arm"}, {"android", "arm64"}, {"android", "386"}}, olderNDK},
+			{32, nil, newerNDK},
+			{32, []targetInfo{{"android", "arm"}}, newerNDK},
+			{32, []targetInfo{{"android", "386"}}, ""},
+		}
+
+		for i, tc := range testCases {
+			t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+				buildAndroidAPI = tc.api
+				ndk, err := ndkRoot(tc.targets...)
+				if len(tc.wantNDKRoot) != 0 {
+					if ndk != tc.wantNDKRoot || err != nil {
+						t.Errorf("got (%q, %v), want (%q, nil)", ndk, err, tc.wantNDKRoot)
+					}
+				} else if err == nil {
+					t.Error("expected error")
+				}
+			})
+		}
+	})
 }
diff --git a/cmd/gomobile/gendex.go b/cmd/gomobile/gendex.go
index be7470c..88cf554 100644
--- a/cmd/gomobile/gendex.go
+++ b/cmd/gomobile/gendex.go
@@ -13,7 +13,7 @@
 // however that would limit gomobile to working with newer versions of
 // the Android OS, so we do this while we wait.
 //
-// Requires ANDROID_HOME be set to the path of the Android SDK, and
+// Respects ANDROID_HOME to set the path of the Android SDK.
 // javac must be on the PATH.
 package main
 
@@ -29,6 +29,8 @@
 	"os"
 	"os/exec"
 	"path/filepath"
+
+	"golang.org/x/mobile/internal/sdkpath"
 )
 
 var outfile = flag.String("o", "", "result will be written file")
@@ -52,9 +54,9 @@
 }
 
 func gendex() error {
-	androidHome := os.Getenv("ANDROID_HOME")
-	if androidHome == "" {
-		return errors.New("ANDROID_HOME not set")
+	androidHome, err := sdkpath.AndroidHome()
+	if err != nil {
+		return fmt.Errorf("couldn't find Android SDK: %w", err)
 	}
 	if err := os.MkdirAll(tmpdir+"/work/org/golang/app", 0775); err != nil {
 		return err
diff --git a/cmd/gomobile/init.go b/cmd/gomobile/init.go
index 26da302..e7392df 100644
--- a/cmd/gomobile/init.go
+++ b/cmd/gomobile/init.go
@@ -16,6 +16,8 @@
 	"runtime"
 	"strings"
 	"time"
+
+	"golang.org/x/mobile/internal/sdkpath"
 )
 
 var (
@@ -122,11 +124,10 @@
 	if buildN {
 		cmake = "cmake"
 	} else {
-		sdkRoot := os.Getenv("ANDROID_HOME")
-		if sdkRoot == "" {
+		sdkRoot, err := sdkpath.AndroidHome()
+		if err != nil {
 			return nil
 		}
-		var err error
 		cmake, err = exec.LookPath("cmake")
 		if err != nil {
 			cmakePath := filepath.Join(sdkRoot, "cmake")
diff --git a/cmd/gomobile/version.go b/cmd/gomobile/version.go
index b791556..27fd6bd 100644
--- a/cmd/gomobile/version.go
+++ b/cmd/gomobile/version.go
@@ -11,6 +11,8 @@
 	"os/exec"
 	"path/filepath"
 	"strings"
+
+	"golang.org/x/mobile/internal/sdkpath"
 )
 
 var cmdVersion = &command{
@@ -56,8 +58,7 @@
 		platforms += "," + strings.Join(applePlatforms, ",")
 	}
 
-	// ANDROID_HOME, sdk build tool version
-	androidapi, _ := androidAPIPath()
+	androidapi, _ := sdkpath.AndroidAPIPath(buildAndroidAPI)
 
 	fmt.Printf("gomobile version %s (%s); androidSDK=%s\n", version, platforms, androidapi)
 	return nil
diff --git a/example/ivy/android/README.md b/example/ivy/android/README.md
index d5923ea..dc56712 100644
--- a/example/ivy/android/README.md
+++ b/example/ivy/android/README.md
@@ -11,16 +11,12 @@
   - Android NDK
   - `golang.org/x/mobile/cmd/gomobile`
 
-The `gomobile` command uses `ANDROID_HOME` and `ANDROID_NDK_HOME` environment variables.
+The `gomobile` command respects the `ANDROID_HOME` and `ANDROID_NDK_HOME` environment variables.  If `gomobile` can't find your SDK and NDK, you can set these environment variables to specify their locations:
 ```
 export ANDROID_HOME=/path/to/sdk-directory
 export ANDROID_NDK_HOME=/path/to/ndk-directory
 ```
 
-Note: Both ANDROID_SDK_ROOT and ANDROID_NDK_HOME are deprecated in Android tooling, but `gomobile` still uses it. In many cases, `ANDROID_HOME` corresponds to the new [`ANDROID_SDK_ROOT`](https://developer.android.com/studio/command-line/variables). If you installed NDK using [Android Studio's SDK manager](https://developer.android.com/studio/projects/install-ndk#default-version), use the `$ANDROID_SDK_ROOT/ndk/<ndk-version>/` directory as `ANDROID_NDK_HOME` (where `<ndk-version>` is the NDK version you want to use when compiling `robpike.io/ivy` for Android.
-
-(TODO: update `gomobile` to work with modern Android layout)
-
 From this directory, run:
 
 ```sh
diff --git a/internal/binres/binres.go b/internal/binres/binres.go
index 78b874f..d77bde2 100644
--- a/internal/binres/binres.go
+++ b/internal/binres/binres.go
@@ -82,10 +82,13 @@
 
 	ResXMLResourceMap ResType = 0x0180
 
-	ResTablePackage  ResType = 0x0200
-	ResTableType     ResType = 0x0201
-	ResTableTypeSpec ResType = 0x0202
-	ResTableLibrary  ResType = 0x0203
+	ResTablePackage           ResType = 0x0200
+	ResTableType              ResType = 0x0201
+	ResTableTypeSpec          ResType = 0x0202
+	ResTableLibrary           ResType = 0x0203
+	ResTableOverlayable       ResType = 0x0204
+	ResTableOverlayablePolicy ResType = 0x0205
+	ResTableStagedAlias       ResType = 0x0206
 )
 
 var (
@@ -247,14 +250,14 @@
 							Space: "",
 							Local: "platformBuildVersionCode",
 						},
-						Value: "15",
+						Value: "16",
 					},
 					xml.Attr{
 						Name: xml.Name{
 							Space: "",
 							Local: "platformBuildVersionName",
 						},
-						Value: "4.0.4-1406430",
+						Value: "4.1.2-1425332",
 					})
 
 				q = append(q, ltoken{tkn, line})
diff --git a/internal/binres/binres_test.go b/internal/binres/binres_test.go
index 93e7a18..dbac875 100644
--- a/internal/binres/binres_test.go
+++ b/internal/binres/binres_test.go
@@ -16,6 +16,8 @@
 	"sort"
 	"strings"
 	"testing"
+
+	"golang.org/x/mobile/internal/sdkpath"
 )
 
 func init() {
@@ -408,10 +410,9 @@
 			}
 		}
 		if err == nil && v == "__" {
-			if !strings.HasPrefix(x, "4.0.") {
+			if !strings.HasPrefix(x, "4.1.") {
 				// as of the time of this writing, the current version of build tools being targeted
-				// reports 4.0.4-1406430. Previously, this was 4.0.3. This number is likely still due
-				// to change so only report error if 4.x incremented.
+				// reports 4.1.2-1425332.
 				//
 				// TODO this check has the potential to hide real errors but can be fixed once more
 				// of the xml document is unmarshalled and XML can be queried to assure this is related
@@ -455,9 +456,8 @@
 }
 
 func TestOpenTable(t *testing.T) {
-	sdkdir := os.Getenv("ANDROID_HOME")
-	if sdkdir == "" {
-		t.Skip("ANDROID_HOME env var not set")
+	if _, err := sdkpath.AndroidHome(); err != nil {
+		t.Skipf("Could not locate Android SDK: %v", err)
 	}
 	tbl, err := OpenTable()
 	if err != nil {
@@ -577,8 +577,10 @@
 			if typ.entryCount != xtyp.entryCount {
 				t.Fatal("typ.entryCount doesn't match")
 			}
-			if typ.entriesStart != xtyp.entriesStart {
-				t.Fatal("typ.entriesStart doesn't match")
+			// Config size can differ after serialization due to the loss of extended fields
+			// during reserialization, but the fixed portions of the Type header must not change.
+			if uint32(typ.headerByteSize)-typ.config.size != uint32(xtyp.headerByteSize)-uint32(xtyp.config.size) {
+				t.Fatal("fixed size header portions don't match")
 			}
 			if len(typ.indices) != len(xtyp.indices) {
 				t.Fatal("typ.indices length don't match")
@@ -629,9 +631,8 @@
 
 func checkResources(t *testing.T) {
 	t.Helper()
-	sdkdir := os.Getenv("ANDROID_HOME")
-	if sdkdir == "" {
-		t.Skip("ANDROID_HOME env var not set")
+	if _, err := sdkpath.AndroidHome(); err != nil {
+		t.Skip("Could not locate Android SDK")
 	}
 	rscPath, err := apiResourcesPath()
 	if err != nil {
@@ -643,9 +644,8 @@
 }
 
 func BenchmarkTableRefByName(b *testing.B) {
-	sdkdir := os.Getenv("ANDROID_HOME")
-	if sdkdir == "" {
-		b.Fatal("ANDROID_HOME env var not set")
+	if _, err := sdkpath.AndroidHome(); err != nil {
+		b.Fatal("Could not locate Android SDK")
 	}
 
 	b.ReportAllocs()
diff --git a/internal/binres/genarsc.go b/internal/binres/genarsc.go
index e93ae88..4ec35fb 100644
--- a/internal/binres/genarsc.go
+++ b/internal/binres/genarsc.go
@@ -8,8 +8,7 @@
 // Genarsc generates stripped down version of android.jar resources used
 // for validation of manifest entries.
 //
-// Requires ANDROID_HOME be set to the path of the Android SDK and the
-// current sdk platform installed that matches MinSDK.
+// Requires the selected Android SDK to support the MinSDK platform version.
 package main
 
 import (
diff --git a/internal/binres/sdk.go b/internal/binres/sdk.go
index 6c1c343..607e0b7 100644
--- a/internal/binres/sdk.go
+++ b/internal/binres/sdk.go
@@ -7,13 +7,14 @@
 	"fmt"
 	"io"
 	"os"
-	"path"
+	"path/filepath"
+
+	"golang.org/x/mobile/internal/sdkpath"
 )
 
 // MinSDK is the targeted sdk version for support by package binres.
-const MinSDK = 15
+const MinSDK = 16
 
-// Requires environment variable ANDROID_HOME to be set.
 func apiResources() ([]byte, error) {
 	apiResPath, err := apiResourcesPath()
 	if err != nil {
@@ -50,14 +51,11 @@
 }
 
 func apiResourcesPath() (string, error) {
-	// TODO(elias.naur): use the logic from gomobile's androidAPIPath and use the any installed version of the
-	// Android SDK instead. Currently, the binres_test.go tests fail on anything newer than android-15.
-	sdkdir := os.Getenv("ANDROID_HOME")
-	if sdkdir == "" {
-		return "", fmt.Errorf("ANDROID_HOME env var not set")
+	platformDir, err := sdkpath.AndroidAPIPath(MinSDK)
+	if err != nil {
+		return "", err
 	}
-	platform := fmt.Sprintf("android-%v", MinSDK)
-	return path.Join(sdkdir, "platforms", platform, "android.jar"), nil
+	return filepath.Join(platformDir, "android.jar"), nil
 }
 
 // PackResources produces a stripped down gzip version of the resources.arsc from api jar.
diff --git a/internal/binres/table.go b/internal/binres/table.go
index 304736d..b552cba 100644
--- a/internal/binres/table.go
+++ b/internal/binres/table.go
@@ -248,7 +248,8 @@
 	typePool *Pool // type names; e.g. theme
 	keyPool  *Pool // resource names; e.g. Theme.NoTitleBar
 
-	specs []*TypeSpec
+	aliases []*StagedAlias
+	specs   []*TypeSpec
 }
 
 func (pkg *Package) UnmarshalBinary(bin []byte) error {
@@ -298,7 +299,7 @@
 		return nil
 	}
 
-	buf := bin[idOffset:]
+	buf := bin[idOffset:pkg.byteSize]
 	for len(buf) > 0 {
 		t := ResType(btou16(buf))
 		switch t {
@@ -317,8 +318,15 @@
 			last := pkg.specs[len(pkg.specs)-1]
 			last.types = append(last.types, typ)
 			buf = buf[typ.byteSize:]
+		case ResTableStagedAlias:
+			alias := new(StagedAlias)
+			if err := alias.UnmarshalBinary(buf); err != nil {
+				return err
+			}
+			pkg.aliases = append(pkg.aliases, alias)
+			buf = buf[alias.byteSize:]
 		default:
-			return errWrongType(t, ResTableTypeSpec, ResTableType)
+			return errWrongType(t, ResTableTypeSpec, ResTableType, ResTableStagedAlias)
 		}
 	}
 
@@ -371,6 +379,14 @@
 		bin = append(bin, b...)
 	}
 
+	for _, alias := range pkg.aliases {
+		b, err := alias.MarshalBinary()
+		if err != nil {
+			return nil, err
+		}
+		bin = append(bin, b...)
+	}
+
 	for _, spec := range pkg.specs {
 		b, err := spec.MarshalBinary()
 		if err != nil {
@@ -537,14 +553,15 @@
 	// fmt.Println("language/country:", u16tos(typ.config.locale.language), u16tos(typ.config.locale.country))
 
 	buf := bin[typ.headerByteSize:typ.entriesStart]
-	for len(buf) > 0 {
-		typ.indices = append(typ.indices, btou32(buf))
-		buf = buf[4:]
+	if len(buf) != 4*int(typ.entryCount) {
+		return fmt.Errorf("index buffer len[%v] doesn't match entryCount[%v]", len(buf), typ.entryCount)
 	}
 
-	if len(typ.indices) != int(typ.entryCount) {
-		return fmt.Errorf("indices len[%v] doesn't match entryCount[%v]", len(typ.indices), typ.entryCount)
+	typ.indices = make([]uint32, typ.entryCount)
+	for i := range typ.indices {
+		typ.indices[i] = btou32(buf[i*4:])
 	}
+
 	typ.entries = make([]*Entry, typ.entryCount)
 
 	for i, x := range typ.indices {
@@ -572,9 +589,9 @@
 	putu32(bin[12:], uint32(len(typ.entries)))
 	putu32(bin[16:], uint32(56+len(typ.entries)*4))
 
-	// assure typ.config.size is always written as 52; extended configuration beyond supported
+	// assure typ.config.size is always written as 36; extended configuration beyond supported
 	// API level is not supported by this marshal implementation but will be forward-compatible.
-	putu32(bin[20:], 52)
+	putu32(bin[20:], 36)
 
 	putu16(bin[24:], typ.config.imsi.mcc)
 	putu16(bin[26:], typ.config.imsi.mnc)
@@ -616,6 +633,65 @@
 	return bin, nil
 }
 
+type StagedAliasEntry struct {
+	stagedID    uint32
+	finalizedID uint32
+}
+
+func (ae *StagedAliasEntry) MarshalBinary() ([]byte, error) {
+	bin := make([]byte, 8)
+	putu32(bin, ae.stagedID)
+	putu32(bin[4:], ae.finalizedID)
+	return bin, nil
+}
+
+func (ae *StagedAliasEntry) UnmarshalBinary(bin []byte) error {
+	ae.stagedID = btou32(bin)
+	ae.finalizedID = btou32(bin[4:])
+	return nil
+}
+
+type StagedAlias struct {
+	chunkHeader
+	count   uint32
+	entries []StagedAliasEntry
+}
+
+func (a *StagedAlias) UnmarshalBinary(bin []byte) error {
+	if err := (&a.chunkHeader).UnmarshalBinary(bin); err != nil {
+		return err
+	}
+	if a.typ != ResTableStagedAlias {
+		return errWrongType(a.typ, ResTableStagedAlias)
+	}
+	a.count = btou32(bin[8:])
+	a.entries = make([]StagedAliasEntry, a.count)
+	for i := range a.entries {
+		if err := a.entries[i].UnmarshalBinary(bin[12+i*8:]); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (a *StagedAlias) MarshalBinary() ([]byte, error) {
+	chunkHeaderBin, err := a.chunkHeader.MarshalBinary()
+	if err != nil {
+		return nil, err
+	}
+	countBin := make([]byte, 4)
+	putu32(countBin, a.count)
+	bin := append(chunkHeaderBin, countBin...)
+	for _, entry := range a.entries {
+		entryBin, err := entry.MarshalBinary()
+		if err != nil {
+			return nil, err
+		}
+		bin = append(bin, entryBin...)
+	}
+	return bin, nil
+}
+
 // Entry is a resource key typically followed by a value or resource map.
 type Entry struct {
 	size  uint16
diff --git a/internal/binres/testdata/bootstrap.arsc b/internal/binres/testdata/bootstrap.arsc
index 60c1dda..4705191 100644
--- a/internal/binres/testdata/bootstrap.arsc
+++ b/internal/binres/testdata/bootstrap.arsc
Binary files differ
diff --git a/internal/binres/testdata/bootstrap.bin b/internal/binres/testdata/bootstrap.bin
index 35d3df6..ff5617c 100644
--- a/internal/binres/testdata/bootstrap.bin
+++ b/internal/binres/testdata/bootstrap.bin
Binary files differ
diff --git a/internal/binres/testdata/gen.sh b/internal/binres/testdata/gen.sh
index e37f21b..b19318f 100755
--- a/internal/binres/testdata/gen.sh
+++ b/internal/binres/testdata/gen.sh
@@ -1,10 +1,10 @@
 #! /usr/bin/env bash
 
 # version of build-tools tests run against
-AAPT=${ANDROID_HOME}/build-tools/23.0.1/aapt
+AAPT=${ANDROID_HOME:-${HOME}/Android/Sdk}/build-tools/32.0.0/aapt
 
 # minimum version of android api for resource identifiers supported
-APIJAR=${ANDROID_HOME}/platforms/android-15/android.jar
+APIJAR=${ANDROID_HOME:-${HOME}/Android/Sdk}/platforms/android-16/android.jar
 
 for f in *.xml; do
 	RES=""
diff --git a/internal/sdkpath/sdkpath.go b/internal/sdkpath/sdkpath.go
new file mode 100644
index 0000000..91e9a51
--- /dev/null
+++ b/internal/sdkpath/sdkpath.go
@@ -0,0 +1,89 @@
+// Copyright 2022 The Go Authors.  All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package sdkpath provides functions for locating the Android SDK.
+// These functions respect the ANDROID_HOME environment variable, and
+// otherwise use the default SDK location.
+package sdkpath
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strconv"
+	"strings"
+)
+
+// AndroidHome returns the absolute path of the selected Android SDK,
+// if one can be found.
+func AndroidHome() (string, error) {
+	androidHome := os.Getenv("ANDROID_HOME")
+	if androidHome == "" {
+		home, err := os.UserHomeDir()
+		if err != nil {
+			return "", err
+		}
+		switch runtime.GOOS {
+		case "windows":
+			// See https://android.googlesource.com/platform/tools/adt/idea/+/85b4bfb7a10ad858a30ffa4003085b54f9424087/native/installer/win/setup_android_studio.nsi#100
+			androidHome = filepath.Join(home, "AppData", "Local", "Android", "sdk")
+		case "darwin":
+			// See https://android.googlesource.com/platform/tools/asuite/+/67e0cd9604379e9663df57f16a318d76423c0aa8/aidegen/lib/ide_util.py#88
+			androidHome = filepath.Join(home, "Library", "Android", "sdk")
+		default: // Linux, BSDs, etc.
+			// See LINUX_ANDROID_SDK_PATH in ide_util.py above.
+			androidHome = filepath.Join(home, "Android", "Sdk")
+		}
+	}
+	if info, err := os.Stat(androidHome); err != nil {
+		return "", fmt.Errorf("%w; Android SDK was not found at %s", err, androidHome)
+	} else if !info.IsDir() {
+		return "", fmt.Errorf("%s is not a directory", androidHome)
+	}
+	return androidHome, nil
+}
+
+// AndroidAPIPath returns an android SDK platform directory within the configured SDK.
+// If there are multiple platforms that satisfy the minimum version requirement,
+// AndroidAPIPath returns the latest one among them.
+func AndroidAPIPath(api int) (string, error) {
+	sdk, err := AndroidHome()
+	if err != nil {
+		return "", err
+	}
+	sdkDir, err := os.Open(filepath.Join(sdk, "platforms"))
+	if err != nil {
+		return "", fmt.Errorf("failed to find android SDK platform: %w", err)
+	}
+	defer sdkDir.Close()
+	fis, err := sdkDir.Readdir(-1)
+	if err != nil {
+		return "", fmt.Errorf("failed to find android SDK platform (API level: %d): %w", api, err)
+	}
+
+	var apiPath string
+	var apiVer int
+	for _, fi := range fis {
+		name := fi.Name()
+		if !strings.HasPrefix(name, "android-") {
+			continue
+		}
+		n, err := strconv.Atoi(name[len("android-"):])
+		if err != nil || n < api {
+			continue
+		}
+		p := filepath.Join(sdkDir.Name(), name)
+		_, err = os.Stat(filepath.Join(p, "android.jar"))
+		if err == nil && apiVer < n {
+			apiPath = p
+			apiVer = n
+		}
+	}
+	if apiVer == 0 {
+		return "", fmt.Errorf("failed to find android SDK platform (API level: %d) in %s",
+			api, sdkDir.Name())
+	}
+	return apiPath, nil
+}