Resource Path Obfuscation

This CL allows aapt2 to obfuscate resource paths within the output apk
and move resources to shorter obfuscated paths. This reduces apk size
when there is a large number of resources since the path metadata exists
in 4 places in the apk.

This CL adds two arguments to aapt2, one to enable resource path
obfuscation and one to point to a path to output the path map to (for
later debugging).

Test: make aapt2_tests
Bug: b/75965637

Change-Id: I9cacafe1d17800d673566b2d61b0b88f3fb8d60c
diff --git a/tools/aapt2/Android.bp b/tools/aapt2/Android.bp
index ba498e1..a99cfe6 100644
--- a/tools/aapt2/Android.bp
+++ b/tools/aapt2/Android.bp
@@ -114,6 +114,7 @@
         "optimize/MultiApkGenerator.cpp",
         "optimize/ResourceDeduper.cpp",
         "optimize/ResourceFilter.cpp",
+        "optimize/ResourcePathShortener.cpp",
         "optimize/VersionCollapser.cpp",
         "process/SymbolTable.cpp",
         "split/TableSplitter.cpp",
diff --git a/tools/aapt2/LoadedApk.cpp b/tools/aapt2/LoadedApk.cpp
index b353ff0..45719ef 100644
--- a/tools/aapt2/LoadedApk.cpp
+++ b/tools/aapt2/LoadedApk.cpp
@@ -223,8 +223,17 @@
     io::IFile* file = iterator->Next();
     std::string path = file->GetSource().path;
 
+    std::string output_path = path;
+    bool is_resource = path.find("res/") == 0;
+    if (is_resource) {
+      auto it = options.shortened_path_map.find(path);
+      if (it != options.shortened_path_map.end()) {
+        output_path = it->second;
+      }
+    }
+
     // Skip resources that are not referenced if requested.
-    if (path.find("res/") == 0 && referenced_resources.find(path) == referenced_resources.end()) {
+    if (is_resource && referenced_resources.find(output_path) == referenced_resources.end()) {
       if (context->IsVerbose()) {
         context->GetDiagnostics()->Note(DiagMessage()
                                         << "Removing resource '" << path << "' from APK.");
@@ -283,7 +292,8 @@
         return false;
       }
     } else {
-      if (!io::CopyFileToArchivePreserveCompression(context, file, path, writer)) {
+      if (!io::CopyFileToArchivePreserveCompression(
+              context, file, output_path, writer)) {
         return false;
       }
     }
diff --git a/tools/aapt2/cmd/Optimize.cpp b/tools/aapt2/cmd/Optimize.cpp
index 328b0be..2e6af18 100644
--- a/tools/aapt2/cmd/Optimize.cpp
+++ b/tools/aapt2/cmd/Optimize.cpp
@@ -41,6 +41,7 @@
 #include "optimize/MultiApkGenerator.h"
 #include "optimize/ResourceDeduper.h"
 #include "optimize/ResourceFilter.h"
+#include "optimize/ResourcePathShortener.h"
 #include "optimize/VersionCollapser.h"
 #include "split/TableSplitter.h"
 #include "util/Files.h"
@@ -52,6 +53,7 @@
 using ::android::ResTable_config;
 using ::android::StringPiece;
 using ::android::base::ReadFileToString;
+using ::android::base::WriteStringToFile;
 using ::android::base::StringAppendF;
 using ::android::base::StringPrintf;
 
@@ -143,6 +145,21 @@
       return 1;
     }
 
+    if (options_.shorten_resource_paths) {
+      ResourcePathShortener shortener(options_.table_flattener_options.shortened_path_map);
+      if (!shortener.Consume(context_, apk->GetResourceTable())) {
+        context_->GetDiagnostics()->Error(DiagMessage() << "failed shortening resource paths");
+        return 1;
+      }
+      if (options_.shortened_paths_map_path
+          && !WriteShortenedPathsMap(options_.table_flattener_options.shortened_path_map,
+                                      options_.shortened_paths_map_path.value())) {
+        context_->GetDiagnostics()->Error(DiagMessage()
+                                          << "failed to write shortened resource paths to file");
+        return 1;
+      }
+    }
+
     // Adjust the SplitConstraints so that their SDK version is stripped if it is less than or
     // equal to the minSdk.
     options_.split_constraints =
@@ -264,6 +281,15 @@
                                         ArchiveEntry::kAlign, writer);
   }
 
+  bool WriteShortenedPathsMap(const std::map<std::string, std::string> &path_map,
+                               const std::string &file_path) {
+    std::stringstream ss;
+    for (auto it = path_map.cbegin(); it != path_map.cend(); ++it) {
+      ss << it->first << " -> " << it->second << "\n";
+    }
+    return WriteStringToFile(ss.str(), file_path);
+  }
+
   OptimizeOptions options_;
   OptimizeContext* context_;
 };
diff --git a/tools/aapt2/cmd/Optimize.h b/tools/aapt2/cmd/Optimize.h
index 43bc216..7df9cf7 100644
--- a/tools/aapt2/cmd/Optimize.h
+++ b/tools/aapt2/cmd/Optimize.h
@@ -55,6 +55,12 @@
   // Set of artifacts to keep when generating multi-APK splits. If the list is empty, all artifacts
   // are kept and will be written as output.
   std::unordered_set<std::string> kept_artifacts;
+
+  // Whether or not to shorten resource paths in the APK.
+  bool shorten_resource_paths;
+
+  // Path to the output map of original resource paths to shortened paths.
+  Maybe<std::string> shortened_paths_map_path;
 };
 
 class OptimizeCommand : public Command {
@@ -100,6 +106,12 @@
     AddOptionalSwitch("--enable-resource-obfuscation",
         "Enables obfuscation of key string pool to single value",
         &options_.table_flattener_options.collapse_key_stringpool);
+    AddOptionalSwitch("--enable-resource-path-shortening",
+        "Enables shortening of the path of the resources inside the APK.",
+        &options_.shorten_resource_paths);
+    AddOptionalFlag("--resource-path-shortening-map",
+        "Path to output the map of old resource paths to shortened paths.",
+        &options_.shortened_paths_map_path);
     AddOptionalSwitch("-v", "Enables verbose logging", &verbose_);
   }
 
@@ -108,6 +120,9 @@
  private:
   OptimizeOptions options_;
 
+  bool WriteObfuscatedPathsMap(const std::map<std::string, std::string> &path_map,
+                               const std::string &file_path);
+
   Maybe<std::string> config_path_;
   Maybe<std::string> whitelist_path_;
   Maybe<std::string> resources_config_path_;
@@ -121,4 +136,4 @@
 
 }// namespace aapt
 
-#endif //AAPT2_OPTIMIZE_H
\ No newline at end of file
+#endif //AAPT2_OPTIMIZE_H
diff --git a/tools/aapt2/format/binary/TableFlattener.h b/tools/aapt2/format/binary/TableFlattener.h
index 635cb21..71330e3 100644
--- a/tools/aapt2/format/binary/TableFlattener.h
+++ b/tools/aapt2/format/binary/TableFlattener.h
@@ -46,6 +46,9 @@
 
   // When true, sort the entries in the values string pool by priority and configuration.
   bool sort_stringpool_entries = true;
+
+  // Map from original resource paths to shortened resource paths.
+  std::map<std::string, std::string> shortened_path_map;
 };
 
 class TableFlattener : public IResourceTableConsumer {
diff --git a/tools/aapt2/optimize/ResourcePathShortener.cpp b/tools/aapt2/optimize/ResourcePathShortener.cpp
new file mode 100644
index 0000000..c5df3dd
--- /dev/null
+++ b/tools/aapt2/optimize/ResourcePathShortener.cpp
@@ -0,0 +1,112 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "optimize/ResourcePathShortener.h"
+
+#include <math.h>
+#include <unordered_set>
+
+#include "androidfw/StringPiece.h"
+
+#include "ResourceTable.h"
+#include "ValueVisitor.h"
+
+
+static const std::string base64_chars =
+             "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+             "abcdefghijklmnopqrstuvwxyz"
+             "0123456789-_";
+
+namespace aapt {
+
+ResourcePathShortener::ResourcePathShortener(
+    std::map<std::string, std::string>& path_map_out)
+    : path_map_(path_map_out) {
+}
+
+std::string ShortenFileName(const android::StringPiece& file_path, int output_length) {
+  std::size_t hash_num = std::hash<android::StringPiece>{}(file_path);
+  std::string result = "";
+  // Convert to (modified) base64 so that it is a proper file path.
+  for (int i = 0; i < output_length; i++) {
+    uint8_t sextet = hash_num & 0x3f;
+    hash_num >>= 6;
+    result += base64_chars[sextet];
+  }
+  return result;
+}
+
+
+// Calculate the optimal hash length such that an average of 10% of resources
+// collide in their shortened path.
+// Reference: http://matt.might.net/articles/counting-hash-collisions/
+int OptimalShortenedLength(int num_resources) {
+  int num_chars = 2;
+  double N = 64*64; // hash space when hash is 2 chars long
+  double max_collisions = num_resources * 0.1;
+  while (num_resources - N + N * pow((N - 1) / N, num_resources) > max_collisions) {
+    N *= 64;
+    num_chars++;
+  }
+  return num_chars;
+}
+
+std::string GetShortenedPath(const android::StringPiece& shortened_filename,
+    const android::StringPiece& extension, int collision_count) {
+  std::string shortened_path = "res/" + shortened_filename.to_string();
+  if (collision_count > 0) {
+    shortened_path += std::to_string(collision_count);
+  }
+  shortened_path += extension;
+  return shortened_path;
+}
+
+bool ResourcePathShortener::Consume(IAaptContext* context, ResourceTable* table) {
+  // used to detect collisions
+  std::unordered_set<std::string> shortened_paths;
+  std::unordered_set<FileReference*> file_refs;
+  for (auto& package : table->packages) {
+    for (auto& type : package->types) {
+      for (auto& entry : type->entries) {
+        for (auto& config_value : entry->values) {
+          FileReference* file_ref = ValueCast<FileReference>(config_value->value.get());
+          if (file_ref) {
+            file_refs.insert(file_ref);
+          }
+        }
+      }
+    }
+  }
+  int num_chars = OptimalShortenedLength(file_refs.size());
+  for (auto& file_ref : file_refs) {
+    android::StringPiece res_subdir, actual_filename, extension;
+    util::ExtractResFilePathParts(*file_ref->path, &res_subdir, &actual_filename, &extension);
+
+    std::string shortened_filename = ShortenFileName(*file_ref->path, num_chars);
+    int collision_count = 0;
+    std::string shortened_path = GetShortenedPath(shortened_filename, extension, collision_count);
+    while (shortened_paths.find(shortened_path) != shortened_paths.end()) {
+      collision_count++;
+      shortened_path = GetShortenedPath(shortened_filename, extension, collision_count);
+    }
+    shortened_paths.insert(shortened_path);
+    path_map_.insert({*file_ref->path, shortened_path});
+    file_ref->path = table->string_pool.MakeRef(shortened_path, file_ref->path.GetContext());
+  }
+  return true;
+}
+
+}  // namespace aapt
diff --git a/tools/aapt2/optimize/ResourcePathShortener.h b/tools/aapt2/optimize/ResourcePathShortener.h
new file mode 100644
index 0000000..f1074ef
--- /dev/null
+++ b/tools/aapt2/optimize/ResourcePathShortener.h
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef AAPT_OPTIMIZE_RESOURCEPATHSHORTENER_H
+#define AAPT_OPTIMIZE_RESOURCEPATHSHORTENER_H
+
+#include <map>
+
+#include "android-base/macros.h"
+
+#include "process/IResourceTableConsumer.h"
+
+namespace aapt {
+
+class ResourceTable;
+
+// Maps resources in the apk to shortened paths.
+class ResourcePathShortener : public IResourceTableConsumer {
+ public:
+  explicit ResourcePathShortener(std::map<std::string, std::string>& path_map_out);
+
+  bool Consume(IAaptContext* context, ResourceTable* table) override;
+
+ private:
+  DISALLOW_COPY_AND_ASSIGN(ResourcePathShortener);
+  std::map<std::string, std::string>& path_map_;
+};
+
+} // namespace aapt
+
+#endif  // AAPT_OPTIMIZE_RESOURCEPATHSHORTENER_H
diff --git a/tools/aapt2/optimize/ResourcePathShortener_test.cpp b/tools/aapt2/optimize/ResourcePathShortener_test.cpp
new file mode 100644
index 0000000..88cadc7
--- /dev/null
+++ b/tools/aapt2/optimize/ResourcePathShortener_test.cpp
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "optimize/ResourcePathShortener.h"
+
+#include "ResourceTable.h"
+#include "test/Test.h"
+
+using ::aapt::test::GetValue;
+using ::testing::Not;
+using ::testing::NotNull;
+using ::testing::Eq;
+
+namespace aapt {
+
+TEST(ResourcePathShortenerTest, FileRefPathsChangedInResourceTable) {
+  std::unique_ptr<IAaptContext> context = test::ContextBuilder().Build();
+
+  std::unique_ptr<ResourceTable> table =
+      test::ResourceTableBuilder()
+          .AddFileReference("android:drawable/xmlfile", "res/drawables/xmlfile.xml")
+          .AddFileReference("android:drawable/xmlfile2", "res/drawables/xmlfile2.xml")
+          .AddString("android:string/string", "res/should/still/be/the/same.png")
+          .Build();
+
+  std::map<std::string, std::string> path_map;
+  ASSERT_TRUE(ResourcePathShortener(path_map).Consume(context.get(), table.get()));
+
+  // Expect that the path map is populated
+  ASSERT_THAT(path_map.find("res/drawables/xmlfile.xml"), Not(Eq(path_map.end())));
+  ASSERT_THAT(path_map.find("res/drawables/xmlfile2.xml"), Not(Eq(path_map.end())));
+
+  // The file paths were changed
+  EXPECT_THAT(path_map.at("res/drawables/xmlfile.xml"), Not(Eq("res/drawables/xmlfile.xml")));
+  EXPECT_THAT(path_map.at("res/drawables/xmlfile2.xml"), Not(Eq("res/drawables/xmlfile2.xml")));
+
+  // Different file paths should remain different
+  EXPECT_THAT(path_map["res/drawables/xmlfile.xml"],
+              Not(Eq(path_map["res/drawables/xmlfile2.xml"])));
+
+  FileReference* ref =
+      GetValue<FileReference>(table.get(), "android:drawable/xmlfile");
+  ASSERT_THAT(ref, NotNull());
+  // The map correctly points to the new location of the file
+  EXPECT_THAT(path_map["res/drawables/xmlfile.xml"], Eq(*ref->path));
+
+  // Strings should not be affected, only file paths
+  EXPECT_THAT(
+      *GetValue<String>(table.get(), "android:string/string")->value,
+              Eq("res/should/still/be/the/same.png"));
+  EXPECT_THAT(path_map.find("res/should/still/be/the/same.png"), Eq(path_map.end()));
+}
+
+}   // namespace aapt