cmd/gomobile: icon support for android

Provides support for resources.arsc generation enabling
the setting of an application icon.

If an asset/icon.png is encountered during build, then
the resources.arsc is generated to identify a single
xxxhdpi resource and the manifest will be updated to
reference resource as app icon.

References golang/go#9985

Change-Id: I9ef59fff45dcd612a41c479b2c679d22c094ab36
Reviewed-on: https://go-review.googlesource.com/30019
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
diff --git a/cmd/gomobile/build_androidapp.go b/cmd/gomobile/build_androidapp.go
index 8d261f3..6bbdf8e 100644
--- a/cmd/gomobile/build_androidapp.go
+++ b/cmd/gomobile/build_androidapp.go
@@ -9,6 +9,7 @@
 	"crypto/x509"
 	"encoding/base64"
 	"encoding/pem"
+	"encoding/xml"
 	"errors"
 	"fmt"
 	"go/build"
@@ -19,6 +20,8 @@
 	"path"
 	"path/filepath"
 	"strings"
+
+	"golang.org/x/mobile/internal/binres"
 )
 
 func goAndroidBuild(pkg *build.Package, androidArchs []string) (map[string]bool, error) {
@@ -143,15 +146,7 @@
 		return nil
 	}
 
-	w, err := apkwCreate("AndroidManifest.xml")
-	if err != nil {
-		return nil, err
-	}
-	if _, err := w.Write(manifestData); err != nil {
-		return nil, err
-	}
-
-	w, err = apkwCreate("classes.dex")
+	w, err := apkwCreate("classes.dex")
 	if err != nil {
 		return nil, err
 	}
@@ -184,6 +179,9 @@
 	}
 
 	// Add any assets.
+	var arsc struct {
+		iconPath string
+	}
 	assetsDir := filepath.Join(pkg.Dir, "assets")
 	assetsDirExists := true
 	fi, err := os.Stat(assetsDir)
@@ -213,6 +211,15 @@
 			if info.IsDir() {
 				return nil
 			}
+
+			if rel, err := filepath.Rel(assetsDir, path); rel == "icon.png" && err == nil {
+				arsc.iconPath = path
+				// TODO returning here does not write the assets/icon.png to the final assets output,
+				// making it unavailable via the assets API. Should the file be duplicated into assets
+				// or should assets API be able to retrieve files from the generated resource table?
+				return nil
+			}
+
 			name := "assets/" + path[len(assetsDir)+1:]
 			return apkwWriteFile(name, path)
 		})
@@ -221,6 +228,46 @@
 		}
 	}
 
+	bxml, err := binres.UnmarshalXML(bytes.NewReader(manifestData), arsc.iconPath != "")
+	if err != nil {
+		return nil, err
+	}
+
+	// generate resources.arsc identifying single xxxhdpi icon resource.
+	if arsc.iconPath != "" {
+		pkgname, err := bxml.RawValueByName("manifest", xml.Name{Local: "package"})
+		if err != nil {
+			return nil, err
+		}
+		tbl, name := binres.NewMipmapTable(pkgname)
+		if err := apkwWriteFile(name, arsc.iconPath); err != nil {
+			return nil, err
+		}
+		w, err := apkwCreate("resources.arsc")
+		if err != nil {
+			return nil, err
+		}
+		bin, err := tbl.MarshalBinary()
+		if err != nil {
+			return nil, err
+		}
+		if _, err := w.Write(bin); err != nil {
+			return nil, err
+		}
+	}
+
+	w, err = apkwCreate("AndroidManifest.xml")
+	if err != nil {
+		return nil, err
+	}
+	bin, err := bxml.MarshalBinary()
+	if err != nil {
+		return nil, err
+	}
+	if _, err := w.Write(bin); err != nil {
+		return nil, err
+	}
+
 	// TODO: add gdbserver to apk?
 
 	if !buildN {
diff --git a/cmd/gomobile/writer.go b/cmd/gomobile/writer.go
index 616f267..0f3c29b 100644
--- a/cmd/gomobile/writer.go
+++ b/cmd/gomobile/writer.go
@@ -74,8 +74,6 @@
 	"fmt"
 	"hash"
 	"io"
-
-	"golang.org/x/mobile/internal/binres"
 )
 
 // NewWriter returns a new Writer writing an APK file to w.
@@ -103,14 +101,6 @@
 	if err := w.clearCur(); err != nil {
 		return nil, fmt.Errorf("apk: Create(%s): %v", name, err)
 	}
-	if name == "AndroidManifest.xml" {
-		w.cur = &fileWriter{
-			name: name,
-			w:    new(bytes.Buffer),
-			sha1: sha1.New(),
-		}
-		return w.cur, nil
-	}
 	res, err := w.create(name)
 	if err != nil {
 		return nil, fmt.Errorf("apk: Create(%s): %v", name, err)
@@ -234,24 +224,6 @@
 	if w.cur == nil {
 		return nil
 	}
-	if w.cur.name == "AndroidManifest.xml" {
-		buf := w.cur.w.(*bytes.Buffer)
-		bxml, err := binres.UnmarshalXML(buf)
-		if err != nil {
-			return err
-		}
-		b, err := bxml.MarshalBinary()
-		if err != nil {
-			return err
-		}
-		f, err := w.create("AndroidManifest.xml")
-		if err != nil {
-			return err
-		}
-		if _, err := f.Write(b); err != nil {
-			return err
-		}
-	}
 	w.manifest = append(w.manifest, manifestEntry{
 		name: w.cur.name,
 		sha1: w.cur.sha1,
diff --git a/internal/binres/binres.go b/internal/binres/binres.go
index f8da0a8..7b5c3bf 100644
--- a/internal/binres/binres.go
+++ b/internal/binres/binres.go
@@ -160,6 +160,38 @@
 	stack []*Element
 }
 
+// RawValueByName returns the original raw string value of first matching element attribute, or error if not exists.
+// Given <manifest package="VAL" ...> then RawValueByName("manifest", xml.Name{Local: "package"}) returns "VAL".
+func (bx *XML) RawValueByName(elname string, attrname xml.Name) (string, error) {
+	elref, err := bx.Pool.RefByName(elname)
+	if err != nil {
+		return "", err
+	}
+	nref, err := bx.Pool.RefByName(attrname.Local)
+	if err != nil {
+		return "", err
+	}
+	nsref := PoolRef(NoEntry)
+	if attrname.Space != "" {
+		nsref, err = bx.Pool.RefByName(attrname.Space)
+		if err != nil {
+			return "", err
+		}
+	}
+
+	for el := range bx.iterElements() {
+		if el.Name == elref {
+			for _, attr := range el.attrs {
+				// TODO enforce TypedValue DataString constraint?
+				if nsref == attr.NS && nref == attr.Name {
+					return bx.Pool.strings[int(attr.RawValue)], nil
+				}
+			}
+		}
+	}
+	return "", fmt.Errorf("no matching element %q for attribute %+v found", elname, attrname)
+}
+
 const (
 	androidSchema = "http://schemas.android.com/apk/res/android"
 	toolsSchema   = "http://schemas.android.com/tools"
@@ -170,7 +202,7 @@
 
 // UnmarshalXML decodes an AndroidManifest.xml document returning type XML
 // containing decoded resources.
-func UnmarshalXML(r io.Reader) (*XML, error) {
+func UnmarshalXML(r io.Reader, withIcon bool) (*XML, error) {
 	tbl, err := OpenTable()
 	if err != nil {
 		return nil, err
@@ -247,6 +279,25 @@
 
 					q = append(q, ltoken{s, line}, ltoken{e, line})
 				}
+			case "application":
+				if !skipSynthesize {
+					for _, attr := range tkn.Attr {
+						if attr.Name.Space == androidSchema && attr.Name.Local == "icon" {
+							return nil, fmt.Errorf("manual declaration of android:icon in AndroidManifest.xml not supported")
+						}
+					}
+					if withIcon {
+						tkn.Attr = append(tkn.Attr,
+							xml.Attr{
+								Name: xml.Name{
+									Space: androidSchema,
+									Local: "icon",
+								},
+								Value: "@mipmap/icon",
+							})
+					}
+				}
+				q = append(q, ltoken{tkn, line})
 			}
 		default:
 			q = append(q, ltoken{tkn, line})
@@ -371,6 +422,14 @@
 							nattr.TypedValue.Type = DataReference
 							dref, err := tbl.RefByName(attr.Value)
 							if err != nil {
+								if strings.HasPrefix(attr.Value, "@mipmap") {
+									// firstDrawableId is a TableRef matching first entry of mipmap spec initialized by NewMipmapTable.
+									// 7f is default package, 02 is mipmap spec, 0000 is first entry; e.g. R.drawable.icon
+									// TODO resource table should generate ids as required.
+									const firstDrawableId = 0x7f020000
+									nattr.TypedValue.Value = firstDrawableId
+									continue
+								}
 								return nil, err
 							}
 							nattr.TypedValue.Value = uint32(dref)
diff --git a/internal/binres/binres_test.go b/internal/binres/binres_test.go
index 2e80233..6fba715 100644
--- a/internal/binres/binres_test.go
+++ b/internal/binres/binres_test.go
@@ -7,6 +7,7 @@
 import (
 	"bytes"
 	"encoding"
+	"encoding/xml"
 	"fmt"
 	"io/ioutil"
 	"log"
@@ -209,7 +210,7 @@
 		t.Fatal(err)
 	}
 
-	bx, err := UnmarshalXML(f)
+	bx, err := UnmarshalXML(f, false)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -236,7 +237,7 @@
 	}
 
 	if err := compareElements(bx, bxml); err != nil {
-		t.Fatal(err)
+		t.Error(err)
 	}
 
 	// Current output byte-for-byte of pkg binres is close, but not exact, to output of aapt.
@@ -253,6 +254,23 @@
 	// }
 }
 
+func TestRawValueByName(t *testing.T) {
+	f, err := os.Open("testdata/bootstrap.xml")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	bx, err := UnmarshalXML(f, false)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	pkgname, err := bx.RawValueByName("manifest", xml.Name{Local: "package"})
+	if want := "com.zentus.balloon"; err != nil || pkgname != want {
+		t.Fatalf("have (%q, %v), want (%q, nil)", pkgname, err, want)
+	}
+}
+
 type byAttrName []*Attribute
 
 func (a byAttrName) Len() int           { return len(a) }
diff --git a/internal/binres/pool.go b/internal/binres/pool.go
index bb9e89d..5e5958d 100644
--- a/internal/binres/pool.go
+++ b/internal/binres/pool.go
@@ -65,6 +65,16 @@
 	return PoolRef(len(pl.strings) - 1)
 }
 
+// RefByName returns the PoolRef of s, or error if not exists.
+func (pl *Pool) RefByName(s string) (PoolRef, error) {
+	for i, x := range pl.strings {
+		if s == x {
+			return PoolRef(i), nil
+		}
+	}
+	return 0, fmt.Errorf("PoolRef by name %q does not exist", s)
+}
+
 func (pl *Pool) IsSorted() bool { return pl.flags&SortedFlag == SortedFlag }
 func (pl *Pool) IsUTF8() bool   { return pl.flags&UTF8Flag == UTF8Flag }
 
diff --git a/internal/binres/table.go b/internal/binres/table.go
index 22b987a..6c80aff 100644
--- a/internal/binres/table.go
+++ b/internal/binres/table.go
@@ -56,6 +56,40 @@
 	pkgs []*Package
 }
 
+// NewMipmapTable returns a resource table initialized for a single xxxhdpi mipmap resource
+// and the path to write resource data to.
+func NewMipmapTable(pkgname string) (*Table, string) {
+	pkg := &Package{id: 127, name: pkgname, typePool: &Pool{}, keyPool: &Pool{}}
+
+	attr := pkg.typePool.ref("attr")
+	mipmap := pkg.typePool.ref("mipmap")
+	icon := pkg.keyPool.ref("icon")
+
+	nt := &Entry{values: []*Value{{data: &Data{Type: DataString}}}}
+	typ := &Type{id: 2, indices: []uint32{0}, entries: []*Entry{nt}}
+	typ.config.screenType.density = 640
+	typ.config.version.sdk = 4
+
+	pkg.specs = append(pkg.specs,
+		&TypeSpec{
+			id: uint8(attr) + 1, //1,
+		},
+		&TypeSpec{
+			id:         uint8(mipmap) + 1, //2,
+			entryCount: 1,
+			entries:    []uint32{uint32(icon)}, // {0}
+			types:      []*Type{typ},
+		})
+
+	pkg.lastPublicType = uint32(len(pkg.typePool.strings)) // 2
+	pkg.lastPublicKey = uint32(len(pkg.keyPool.strings))   // 1
+
+	name := "res/mipmap-xxxhdpi-v4/icon.png"
+	tbl := &Table{pool: &Pool{}, pkgs: []*Package{pkg}}
+	tbl.pool.ref(name)
+	return tbl, name
+}
+
 // OpenSDKTable decodes resources.arsc from sdk platform jar.
 func OpenSDKTable() (*Table, error) {
 	bin, err := apiResources()
@@ -89,7 +123,8 @@
 }
 
 // SpecByName parses the spec name from an entry string if necessary and returns
-// the Package and TypeSpec associated with that name.
+// the Package and TypeSpec associated with that name along with their respective
+// indices.
 //
 // For example:
 //  tbl.SpecByName("@android:style/Theme.NoTitleBar")
@@ -261,7 +296,7 @@
 	}
 
 	buf := bin[idOffset:]
-	for len(pkg.specs) < len(pkg.typePool.strings) {
+	for len(buf) > 0 {
 		t := ResType(btou16(buf))
 		switch t {
 		case ResTableTypeSpec:
@@ -288,9 +323,11 @@
 }
 
 func (pkg *Package) MarshalBinary() ([]byte, error) {
-	bin := make([]byte, 284)
+	// Package header size is determined by C++ struct ResTable_package
+	// see frameworks/base/include/ResourceTypes.h
+	bin := make([]byte, 288)
 	putu16(bin, uint16(ResTablePackage))
-	putu16(bin[2:], 284)
+	putu16(bin[2:], 288)
 
 	putu32(bin[8:], pkg.id)
 	p := utf16.Encode([]rune(pkg.name))
@@ -532,6 +569,31 @@
 	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
+	// API level is not supported by this marshal implementation but will be forward-compatible.
+	putu32(bin[20:], 52)
+
+	putu16(bin[24:], typ.config.imsi.mcc)
+	putu16(bin[26:], typ.config.imsi.mnc)
+	putu16(bin[28:], typ.config.locale.language)
+	putu16(bin[30:], typ.config.locale.country)
+	bin[32] = typ.config.screenType.orientation
+	bin[33] = typ.config.screenType.touchscreen
+	putu16(bin[34:], typ.config.screenType.density)
+	bin[36] = typ.config.input.keyboard
+	bin[37] = typ.config.input.navigation
+	bin[38] = typ.config.input.inputFlags
+	bin[39] = typ.config.input.inputPad0
+	putu16(bin[40:], typ.config.screenSize.width)
+	putu16(bin[42:], typ.config.screenSize.height)
+	putu16(bin[44:], typ.config.version.sdk)
+	putu16(bin[46:], typ.config.version.minor)
+	bin[48] = typ.config.screenConfig.layout
+	bin[49] = typ.config.screenConfig.uiMode
+	putu16(bin[50:], typ.config.screenConfig.smallestWidthDP)
+	putu16(bin[52:], typ.config.screenSizeDP.width)
+	putu16(bin[54:], typ.config.screenSizeDP.height)
+
 	var ntbin []byte
 	for i, nt := range typ.entries {
 		if nt == nil { // NoEntry
@@ -594,11 +656,15 @@
 
 func (nt *Entry) MarshalBinary() ([]byte, error) {
 	bin := make([]byte, 8)
-	putu16(bin, nt.size)
+	sz := nt.size
+	if sz == 0 {
+		sz = 8
+	}
+	putu16(bin, sz)
 	putu16(bin[2:], nt.flags)
 	putu32(bin[4:], uint32(nt.key))
 
-	if nt.size == 16 {
+	if sz == 16 {
 		bin = append(bin, make([]byte, 8+len(nt.values)*12)...)
 		putu32(bin[8:], uint32(nt.parent))
 		putu32(bin[12:], uint32(len(nt.values)))
diff --git a/internal/binres/testdata/bootstrap-res/mipmap-xxxhdpi/icon.png b/internal/binres/testdata/bootstrap-res/mipmap-xxxhdpi/icon.png
new file mode 100644
index 0000000..c0745e6
--- /dev/null
+++ b/internal/binres/testdata/bootstrap-res/mipmap-xxxhdpi/icon.png
Binary files differ
diff --git a/internal/binres/testdata/bootstrap.arsc b/internal/binres/testdata/bootstrap.arsc
new file mode 100644
index 0000000..60c1dda
--- /dev/null
+++ b/internal/binres/testdata/bootstrap.arsc
Binary files differ
diff --git a/internal/binres/testdata/bootstrap.bin b/internal/binres/testdata/bootstrap.bin
index ba6a240..35d3df6 100644
--- a/internal/binres/testdata/bootstrap.bin
+++ b/internal/binres/testdata/bootstrap.bin
Binary files differ
diff --git a/internal/binres/testdata/bootstrap.xml b/internal/binres/testdata/bootstrap.xml
index a169737..88df676 100644
--- a/internal/binres/testdata/bootstrap.xml
+++ b/internal/binres/testdata/bootstrap.xml
@@ -17,6 +17,7 @@
 			android:label="Balloon世界"
 			android:allowBackup="true"
 			android:hasCode="false"
+			android:icon="@mipmap/icon"
 			foo="bar"
 			android:debuggable="true"
 			baz="bar"
diff --git a/internal/binres/testdata/gen.sh b/internal/binres/testdata/gen.sh
index 91fd856..e37f21b 100755
--- a/internal/binres/testdata/gen.sh
+++ b/internal/binres/testdata/gen.sh
@@ -1,4 +1,4 @@
-#! /usr/bin/sh
+#! /usr/bin/env bash
 
 # version of build-tools tests run against
 AAPT=${ANDROID_HOME}/build-tools/23.0.1/aapt
@@ -7,9 +7,14 @@
 APIJAR=${ANDROID_HOME}/platforms/android-15/android.jar
 
 for f in *.xml; do
+	RES=""
+	if [ -d "${f:0:-4}-res" ]; then
+		RES="-S ${f:0:-4}-res"
+	fi
 	cp "$f" AndroidManifest.xml
-	"$AAPT" p -M AndroidManifest.xml -I "$APIJAR" -F tmp.apk
-	unzip -qq -o tmp.apk
+	"$AAPT" p -M AndroidManifest.xml $RES -I "$APIJAR" -F tmp.apk
+	unzip -qq -o tmp.apk AndroidManifest.xml resources.arsc
 	mv AndroidManifest.xml "${f:0:-3}bin"
+	mv resources.arsc "${f:0:-3}arsc"
 	rm tmp.apk
 done