blob: 92e6e207dea19cfb7c258db915f9b225080a4bea [file] [log] [blame]
/*
* Copyright (C) 2022 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.
*/
package com.android.build.shrinker
import com.android.SdkConstants.DOT_9PNG
import com.android.SdkConstants.DOT_PNG
import com.android.SdkConstants.DOT_XML
import com.android.aapt.Resources
import com.android.build.shrinker.DummyContent.TINY_9PNG
import com.android.build.shrinker.DummyContent.TINY_9PNG_CRC
import com.android.build.shrinker.DummyContent.TINY_BINARY_XML
import com.android.build.shrinker.DummyContent.TINY_BINARY_XML_CRC
import com.android.build.shrinker.DummyContent.TINY_PNG
import com.android.build.shrinker.DummyContent.TINY_PNG_CRC
import com.android.build.shrinker.DummyContent.TINY_PROTO_XML
import com.android.build.shrinker.DummyContent.TINY_PROTO_XML_CRC
import com.android.build.shrinker.gatherer.ResourcesGatherer
import com.android.build.shrinker.graph.ResourcesGraphBuilder
import com.android.build.shrinker.obfuscation.ObfuscationMappingsRecorder
import com.android.build.shrinker.usages.ResourceUsageRecorder
import com.android.ide.common.resources.findUnusedResources
import com.android.ide.common.resources.usage.ResourceStore
import com.android.ide.common.resources.usage.ResourceUsageModel.Resource
import com.android.resources.FolderTypeRelationship
import com.android.resources.ResourceFolderType
import com.android.resources.ResourceType
import com.google.common.io.Files
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.util.jar.JarEntry
import java.util.jar.JarOutputStream
import java.util.zip.CRC32
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
/**
* Unit that analyzes all resources (after resource merging, compilation and code shrinking has been
* completed) and figures out which resources are unused, and replaces them with dummy content
* inside zip archive file.
*
* Resource shrinker implementation that allows to customize:
* <ul>
* <li>application resource gatherer (from R files, resource tables, etc);
* <li>recorder for mappings from obfuscated class/methods to original class/methods;
* <li>sources from which resource usages are recorded (Dex files, compiled JVM classes,
* AndroidManifests, etc);
* <li>resources graph builder that connects resources dependent on each other (analyzing raw
* resources content in XML, HTML, CSS, JS, analyzing resource content in proto compiled format);
* </ul>
*/
private class ResourceShrinkerImpl(
private val resourcesGatherers: List<ResourcesGatherer>,
private val obfuscationMappingsRecorder: ObfuscationMappingsRecorder?,
private val usageRecorders: List<ResourceUsageRecorder>,
private val graphBuilders: List<ResourcesGraphBuilder>,
private val debugReporter: ShrinkerDebugReporter,
private val supportMultipackages: Boolean,
private val usePreciseShrinking: Boolean,
) : ResourceShrinker {
private val model = ResourceShrinkerModel(debugReporter, supportMultipackages)
private lateinit var unused: List<Resource>
override fun analyze() {
resourcesGatherers.forEach { it.gatherResourceValues(model) }
obfuscationMappingsRecorder?.recordObfuscationMappings(model)
usageRecorders.forEach { it.recordUsages(model) }
graphBuilders.forEach { it.buildGraph(model) }
model.resourceStore.processToolsAttributes()
model.keepPossiblyReferencedResources()
debugReporter.debug { model.resourceStore.dumpResourceModel() }
unused =
findUnusedResources(model.resourceStore.resources) { roots ->
debugReporter.debug { "The root reachable resources are:" }
debugReporter.debug { roots.joinToString("\n", transform = { " $it" }) }
}
debugReporter.debug { "Unused resources are: " }
debugReporter.debug { unused.joinToString("\n", transform = { " $it" }) }
}
override fun close() {
debugReporter.close()
}
override fun getUnusedResourceCount(): Int {
return unused.size
}
override fun rewriteResourcesInApkFormat(
source: File,
dest: File,
format: LinkedResourcesFormat,
) {
rewriteResourceZip(source, dest, ApkArchiveFormat(model.resourceStore, format))
}
override fun rewriteResourcesInBundleFormat(
source: File,
dest: File,
moduleNameToPackageNameMap: Map<String, String>,
) {
rewriteResourceZip(
source,
dest,
BundleArchiveFormat(model.resourceStore, moduleNameToPackageNameMap),
)
}
private fun rewriteResourceZip(source: File, dest: File, format: ArchiveFormat) {
if (dest.exists() && !dest.delete()) {
throw IOException("Could not delete $dest")
}
JarOutputStream(BufferedOutputStream(FileOutputStream(dest))).use { zos ->
ZipFile(source).use { zip ->
// Rather than using Deflater.DEFAULT_COMPRESSION we use 9 here, since that seems
// to match the compressed sizes we observe in source .ap_ files encountered by the
// resource shrinker:
zos.setLevel(9)
zip.entries().asSequence().forEach {
if (format.fileIsNotReachable(it)) {
// If we don't use precise shrinking we don't remove the files, see:
// https://b.corp.google.com/issues/37010152
if (!usePreciseShrinking) {
replaceWithDummyEntry(zos, it, format.resourcesFormat)
}
} else if (it.name.endsWith("resources.pb") && usePreciseShrinking) {
removeResourceUnusedTableEntries(zip.getInputStream(it), zos, it)
} else {
copyToOutput(zip.getInputStream(it), zos, it)
}
}
}
}
// If net negative, copy original back. This is unusual, but can happen
// in some circumstances, such as the one described in
// https://plus.google.com/+SaidTahsinDane/posts/X9sTSwoVUhB
// "Removed unused resources: Binary resource data reduced from 588KB to 595KB: Removed -1%"
// Guard against that, and worst case, just use the original.
val before = source.length()
val after = dest.length()
if (after > before) {
debugReporter.info {
"Resource shrinking did not work (grew from $before to $after); using original " + "instead"
}
Files.copy(source, dest)
}
}
private fun removeResourceUnusedTableEntries(
zis: InputStream,
zos: JarOutputStream,
srcEntry: ZipEntry,
) {
val resourceIdsToRemove =
unused.filterNot { it.type == ResourceType.ID }.map { resource -> resource.value }
val shrunkenResourceTable =
Resources.ResourceTable.parseFrom(zis).nullOutEntriesWithIds(resourceIdsToRemove)
val bytes = shrunkenResourceTable.toByteArray()
val outEntry = JarEntry(srcEntry.name)
if (srcEntry.time != -1L) {
outEntry.time = srcEntry.time
}
if (srcEntry.method == JarEntry.STORED) {
outEntry.method = JarEntry.STORED
outEntry.size = bytes.size.toLong()
val crc = CRC32()
crc.update(bytes, 0, bytes.size)
outEntry.crc = crc.getValue()
}
zos.putNextEntry(outEntry)
zos.write(bytes)
zos.closeEntry()
}
/** Replaces the given entry with a minimal valid file of that type. */
private fun replaceWithDummyEntry(
zos: JarOutputStream,
entry: ZipEntry,
format: LinkedResourcesFormat,
) {
// Create a new entry so that the compressed len is recomputed.
val name = entry.name
val (bytes, crc) =
when {
// DOT_9PNG (.9.png) must be always before DOT_PNG (.png)
name.endsWith(DOT_9PNG) -> TINY_9PNG to TINY_9PNG_CRC
name.endsWith(DOT_PNG) -> TINY_PNG to TINY_PNG_CRC
name.endsWith(DOT_XML) && format == LinkedResourcesFormat.BINARY ->
TINY_BINARY_XML to TINY_BINARY_XML_CRC
name.endsWith(DOT_XML) && format == LinkedResourcesFormat.PROTO ->
TINY_PROTO_XML to TINY_PROTO_XML_CRC
else -> ByteArray(0) to 0L
}
val outEntry = JarEntry(name)
if (entry.time != -1L) {
outEntry.time = entry.time
}
if (entry.method == JarEntry.STORED) {
outEntry.method = JarEntry.STORED
outEntry.size = bytes.size.toLong()
outEntry.crc = crc
}
zos.putNextEntry(outEntry)
zos.write(bytes)
zos.closeEntry()
debugReporter.info {
"Skipped unused resource $name: ${entry.size} bytes (replaced with small dummy file " +
"of size ${bytes.size} bytes)"
}
}
private fun copyToOutput(zis: InputStream, zos: JarOutputStream, entry: ZipEntry) {
// We can't just compress all files; files that are not compressed in the source .ap_ file
// must be left uncompressed here, since for example RAW files need to remain uncompressed
// in the APK such that they can be mmap'ed at runtime.
// Preserve the STORED method of the input entry.
val outEntry =
when (entry.method) {
JarEntry.STORED -> JarEntry(entry)
else -> JarEntry(entry.name)
}
if (entry.time != -1L) {
outEntry.time = entry.time
}
zos.putNextEntry(outEntry)
if (!entry.isDirectory) {
zis.transferTo(zos)
}
zos.closeEntry()
}
}
private interface ArchiveFormat {
val resourcesFormat: LinkedResourcesFormat
fun fileIsNotReachable(entry: ZipEntry): Boolean
}
private class ApkArchiveFormat(
private val store: ResourceStore,
override val resourcesFormat: LinkedResourcesFormat,
) : ArchiveFormat {
override fun fileIsNotReachable(entry: ZipEntry): Boolean {
if (entry.isDirectory || !entry.name.startsWith("res/")) {
return false
}
val (_, folder, name) = entry.name.split('/', limit = 3)
return !store.isJarPathReachable(folder, name)
}
}
private class BundleArchiveFormat(
private val store: ResourceStore,
private val moduleNameToPackageName: Map<String, String>,
) : ArchiveFormat {
override val resourcesFormat = LinkedResourcesFormat.PROTO
override fun fileIsNotReachable(entry: ZipEntry): Boolean {
val module = entry.name.substringBefore('/')
val packageName = moduleNameToPackageName[module]
if (entry.isDirectory || packageName == null || !entry.name.startsWith("$module/res/")) {
return false
}
val (_, _, folder, name) = entry.name.split('/', limit = 4)
return !store.isJarPathReachable(folder, name)
}
}
private fun ResourceStore.isJarPathReachable(folder: String, name: String): Boolean {
val folderType = ResourceFolderType.getFolderType(folder) ?: return true
val resourceName = name.substringBefore('.')
// Bundle format has a restriction: in case the same resource is duplicated in multiple modules
// its content should be the same in all of them. This restriction means that we can't replace
// resource with dummy content if its duplicate is used in some module.
return FolderTypeRelationship.getRelatedResourceTypes(folderType)
.filterNot { it == ResourceType.ID }
.flatMap { getResources(it, resourceName) }
.any { it.isReachable }
}
public fun ResourceStore.isJarPathReachable(path: String): Boolean {
val (_, folder, name) = path.split('/', limit = 3)
return isJarPathReachable(folder, name)
}
public fun ResourceStore.getResourcesFor(path: String): List<Resource> {
val (_, folder, name) = path.split('/', limit = 3)
val folderType = ResourceFolderType.getFolderType(folder) ?: return emptyList()
val resourceName = name.substringBefore('.')
return FolderTypeRelationship.getRelatedResourceTypes(folderType)
.filterNot { it == ResourceType.ID }
.flatMap { getResources(it, resourceName) }
.toList()
}