Replace android.WriteFile rule with android.WriteFileRule

The android.WriteFile rule takes careful escaping to produce the
right contents.  Wrap it in an android.WriteFileRule that handles
the escaping.

Test: compare all android.WriteFile outputs
Change-Id: If71a5843af47a37ca61714e1a1ebb32d08536c31
diff --git a/android/apex.go b/android/apex.go
index e70ec4f..276f7a4 100644
--- a/android/apex.go
+++ b/android/apex.go
@@ -598,36 +598,22 @@
 	var fullContent strings.Builder
 	var flatContent strings.Builder
 
-	fmt.Fprintf(&fullContent, "%s(minSdkVersion:%s):\\n", ctx.ModuleName(), minSdkVersion)
+	fmt.Fprintf(&fullContent, "%s(minSdkVersion:%s):\n", ctx.ModuleName(), minSdkVersion)
 	for _, key := range FirstUniqueStrings(SortedStringKeys(depInfos)) {
 		info := depInfos[key]
 		toName := fmt.Sprintf("%s(minSdkVersion:%s)", info.To, info.MinSdkVersion)
 		if info.IsExternal {
 			toName = toName + " (external)"
 		}
-		fmt.Fprintf(&fullContent, "  %s <- %s\\n", toName, strings.Join(SortedUniqueStrings(info.From), ", "))
-		fmt.Fprintf(&flatContent, "%s\\n", toName)
+		fmt.Fprintf(&fullContent, "  %s <- %s\n", toName, strings.Join(SortedUniqueStrings(info.From), ", "))
+		fmt.Fprintf(&flatContent, "%s\n", toName)
 	}
 
 	d.fullListPath = PathForModuleOut(ctx, "depsinfo", "fulllist.txt").OutputPath
-	ctx.Build(pctx, BuildParams{
-		Rule:        WriteFile,
-		Description: "Full Dependency Info",
-		Output:      d.fullListPath,
-		Args: map[string]string{
-			"content": fullContent.String(),
-		},
-	})
+	WriteFileRule(ctx, d.fullListPath, fullContent.String())
 
 	d.flatListPath = PathForModuleOut(ctx, "depsinfo", "flatlist.txt").OutputPath
-	ctx.Build(pctx, BuildParams{
-		Rule:        WriteFile,
-		Description: "Flat Dependency Info",
-		Output:      d.flatListPath,
-		Args: map[string]string{
-			"content": flatContent.String(),
-		},
-	})
+	WriteFileRule(ctx, d.flatListPath, flatContent.String())
 }
 
 // TODO(b/158059172): remove minSdkVersion allowlist
diff --git a/android/api_levels.go b/android/api_levels.go
index bace3d4..08328e1 100644
--- a/android/api_levels.go
+++ b/android/api_levels.go
@@ -225,14 +225,7 @@
 		ctx.Errorf(err.Error())
 	}
 
-	ctx.Build(pctx, BuildParams{
-		Rule:        WriteFile,
-		Description: "generate " + file.Base(),
-		Output:      file,
-		Args: map[string]string{
-			"content": string(jsonStr[:]),
-		},
-	})
+	WriteFileRule(ctx, file, string(jsonStr))
 }
 
 func GetApiLevelsJson(ctx PathContext) WritablePath {
diff --git a/android/defs.go b/android/defs.go
index 2b1bd85..631dfe8 100644
--- a/android/defs.go
+++ b/android/defs.go
@@ -15,8 +15,12 @@
 package android
 
 import (
+	"strings"
+	"testing"
+
 	"github.com/google/blueprint"
 	_ "github.com/google/blueprint/bootstrap"
+	"github.com/google/blueprint/proptools"
 )
 
 var (
@@ -91,9 +95,9 @@
 	// ubuntu 14.04 offcially use dash for /bin/sh, and its builtin echo command
 	// doesn't support -e option. Therefore we force to use /bin/bash when writing out
 	// content to file.
-	WriteFile = pctx.AndroidStaticRule("WriteFile",
+	writeFile = pctx.AndroidStaticRule("writeFile",
 		blueprint.RuleParams{
-			Command:     "/bin/bash -c 'echo -e $$0 > $out' '$content'",
+			Command:     `/bin/bash -c 'echo -e "$$0" > $out' $content`,
 			Description: "writing file $out",
 		},
 		"content")
@@ -111,3 +115,64 @@
 func init() {
 	pctx.Import("github.com/google/blueprint/bootstrap")
 }
+
+var (
+	// echoEscaper escapes a string such that passing it to "echo -e" will produce the input value.
+	echoEscaper = strings.NewReplacer(
+		`\`, `\\`, // First escape existing backslashes so they aren't interpreted by `echo -e`.
+		"\n", `\n`, // Then replace newlines with \n
+	)
+
+	// echoEscaper reverses echoEscaper.
+	echoUnescaper = strings.NewReplacer(
+		`\n`, "\n",
+		`\\`, `\`,
+	)
+
+	// shellUnescaper reverses the replacer in proptools.ShellEscape
+	shellUnescaper = strings.NewReplacer(`'\''`, `'`)
+)
+
+// WriteFileRule creates a ninja rule to write contents to a file.  The contents will be escaped
+// so that the file contains exactly the contents passed to the function, plus a trailing newline.
+func WriteFileRule(ctx BuilderContext, outputFile WritablePath, content string) {
+	content = echoEscaper.Replace(content)
+	content = proptools.ShellEscape(content)
+	if content == "" {
+		content = "''"
+	}
+	ctx.Build(pctx, BuildParams{
+		Rule:        writeFile,
+		Output:      outputFile,
+		Description: "write " + outputFile.Base(),
+		Args: map[string]string{
+			"content": content,
+		},
+	})
+}
+
+// shellUnescape reverses proptools.ShellEscape
+func shellUnescape(s string) string {
+	// Remove leading and trailing quotes if present
+	if len(s) >= 2 && s[0] == '\'' {
+		s = s[1 : len(s)-1]
+	}
+	s = shellUnescaper.Replace(s)
+	return s
+}
+
+// ContentFromFileRuleForTests returns the content that was passed to a WriteFileRule for use
+// in tests.
+func ContentFromFileRuleForTests(t *testing.T, params TestingBuildParams) string {
+	t.Helper()
+	if g, w := params.Rule, writeFile; g != w {
+		t.Errorf("expected params.Rule to be %q, was %q", w, g)
+		return ""
+	}
+
+	content := params.Args["content"]
+	content = shellUnescape(content)
+	content = echoUnescaper.Replace(content)
+
+	return content
+}