Rico Wind | a6e4efc | 2023-08-03 07:51:44 +0200 | [diff] [blame] | 1 | /* |
| 2 | * Copyright (C) 2022 The Android Open Source Project |
| 3 | * |
| 4 | * Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | * you may not use this file except in compliance with the License. |
| 6 | * You may obtain a copy of the License at |
| 7 | * |
| 8 | * http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | * |
| 10 | * Unless required by applicable law or agreed to in writing, software |
| 11 | * distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | * See the License for the specific language governing permissions and |
| 14 | * limitations under the License. |
| 15 | */ |
| 16 | |
| 17 | package com.android.build.shrinker |
| 18 | |
| 19 | import com.android.SdkConstants.DOT_9PNG |
| 20 | import com.android.SdkConstants.DOT_PNG |
| 21 | import com.android.SdkConstants.DOT_XML |
| 22 | import com.android.aapt.Resources |
| 23 | import com.android.build.shrinker.DummyContent.TINY_9PNG |
| 24 | import com.android.build.shrinker.DummyContent.TINY_9PNG_CRC |
| 25 | import com.android.build.shrinker.DummyContent.TINY_BINARY_XML |
| 26 | import com.android.build.shrinker.DummyContent.TINY_BINARY_XML_CRC |
| 27 | import com.android.build.shrinker.DummyContent.TINY_PNG |
| 28 | import com.android.build.shrinker.DummyContent.TINY_PNG_CRC |
| 29 | import com.android.build.shrinker.DummyContent.TINY_PROTO_XML |
| 30 | import com.android.build.shrinker.DummyContent.TINY_PROTO_XML_CRC |
| 31 | import com.android.build.shrinker.gatherer.ResourcesGatherer |
| 32 | import com.android.build.shrinker.graph.ResourcesGraphBuilder |
| 33 | import com.android.build.shrinker.obfuscation.ObfuscationMappingsRecorder |
| 34 | import com.android.build.shrinker.usages.ResourceUsageRecorder |
| 35 | import com.android.ide.common.resources.findUnusedResources |
| 36 | import com.android.ide.common.resources.usage.ResourceStore |
| 37 | import com.android.ide.common.resources.usage.ResourceUsageModel.Resource |
| 38 | import com.android.resources.FolderTypeRelationship |
| 39 | import com.android.resources.ResourceFolderType |
| 40 | import com.android.resources.ResourceType |
| 41 | import com.google.common.io.ByteStreams |
| 42 | import com.google.common.io.Files |
| 43 | import java.io.BufferedOutputStream |
| 44 | import java.io.File |
| 45 | import java.io.FileOutputStream |
| 46 | import java.io.IOException |
| 47 | import java.io.InputStream |
| 48 | import java.util.jar.JarEntry |
| 49 | import java.util.jar.JarOutputStream |
| 50 | import java.util.zip.CRC32 |
| 51 | import java.util.zip.ZipEntry |
| 52 | import java.util.zip.ZipFile |
| 53 | |
| 54 | /** |
| 55 | * Unit that analyzes all resources (after resource merging, compilation and code shrinking has |
| 56 | * been completed) and figures out which resources are unused, and replaces them with dummy content |
| 57 | * inside zip archive file. |
| 58 | * |
| 59 | * Resource shrinker implementation that allows to customize: |
| 60 | * <ul> |
| 61 | * <li>application resource gatherer (from R files, resource tables, etc); |
| 62 | * <li>recorder for mappings from obfuscated class/methods to original class/methods; |
| 63 | * <li>sources from which resource usages are recorded (Dex files, compiled JVM classes, |
| 64 | * AndroidManifests, etc); |
| 65 | * <li>resources graph builder that connects resources dependent on each other (analyzing |
| 66 | * raw resources content in XML, HTML, CSS, JS, analyzing resource content in proto |
| 67 | * compiled format); |
| 68 | * </ul> |
| 69 | */ |
| 70 | class ResourceShrinkerImpl( |
| 71 | private val resourcesGatherers: List<ResourcesGatherer>, |
| 72 | private val obfuscationMappingsRecorder: ObfuscationMappingsRecorder?, |
| 73 | private val usageRecorders: List<ResourceUsageRecorder>, |
| 74 | private val graphBuilders: List<ResourcesGraphBuilder>, |
| 75 | private val debugReporter: ShrinkerDebugReporter, |
| 76 | val supportMultipackages: Boolean, |
| 77 | private val usePreciseShrinking: Boolean |
| 78 | ) : ResourceShrinker { |
| 79 | val model = ResourceShrinkerModel(debugReporter, supportMultipackages) |
| 80 | private lateinit var unused: List<Resource> |
| 81 | |
| 82 | override fun analyze() { |
| 83 | resourcesGatherers.forEach { it.gatherResourceValues(model) } |
| 84 | obfuscationMappingsRecorder?.recordObfuscationMappings(model) |
| 85 | usageRecorders.forEach { it.recordUsages(model) } |
| 86 | graphBuilders.forEach { it.buildGraph(model) } |
| 87 | |
| 88 | model.resourceStore.processToolsAttributes() |
| 89 | model.keepPossiblyReferencedResources() |
| 90 | |
| 91 | debugReporter.debug { model.resourceStore.dumpResourceModel() } |
| 92 | |
| 93 | unused = findUnusedResources(model.resourceStore.resources) { roots -> |
| 94 | debugReporter.debug { "The root reachable resources are:" } |
| 95 | debugReporter.debug { roots.joinToString("\n", transform = { " $it" }) } |
| 96 | } |
| 97 | debugReporter.debug { "Unused resources are: " } |
| 98 | debugReporter.debug { unused.joinToString("\n", transform = { " $it" })} |
| 99 | |
| 100 | } |
| 101 | |
| 102 | override fun close() { |
| 103 | debugReporter.close() |
| 104 | } |
| 105 | |
| 106 | override fun getUnusedResourceCount(): Int { |
| 107 | return unused.size |
| 108 | } |
| 109 | |
| 110 | override fun rewriteResourcesInApkFormat( |
| 111 | source: File, |
| 112 | dest: File, |
| 113 | format: LinkedResourcesFormat |
| 114 | ) { |
| 115 | rewriteResourceZip(source, dest, ApkArchiveFormat(model.resourceStore, format)) |
| 116 | } |
| 117 | |
| 118 | override fun rewriteResourcesInBundleFormat( |
| 119 | source: File, |
| 120 | dest: File, |
| 121 | moduleNameToPackageNameMap: Map<String, String> |
| 122 | ) { |
| 123 | rewriteResourceZip( |
| 124 | source, |
| 125 | dest, |
| 126 | BundleArchiveFormat(model.resourceStore, moduleNameToPackageNameMap) |
| 127 | ) |
| 128 | } |
| 129 | |
| 130 | private fun rewriteResourceZip(source: File, dest: File, format: ArchiveFormat) { |
| 131 | if (dest.exists() && !dest.delete()) { |
| 132 | throw IOException("Could not delete $dest") |
| 133 | } |
| 134 | JarOutputStream(BufferedOutputStream(FileOutputStream(dest))).use { zos -> |
| 135 | ZipFile(source).use { zip -> |
| 136 | // Rather than using Deflater.DEFAULT_COMPRESSION we use 9 here, since that seems |
| 137 | // to match the compressed sizes we observe in source .ap_ files encountered by the |
| 138 | // resource shrinker: |
| 139 | zos.setLevel(9) |
| 140 | zip.entries().asSequence().forEach { |
| 141 | if (format.fileIsNotReachable(it)) { |
| 142 | // If we don't use precise shrinking we don't remove the files, see: |
| 143 | // https://b.corp.google.com/issues/37010152 |
| 144 | if (!usePreciseShrinking) { |
| 145 | replaceWithDummyEntry(zos, it, format.resourcesFormat) |
| 146 | } |
| 147 | } else if (it.name.endsWith("resources.pb") && usePreciseShrinking) { |
| 148 | removeResourceUnusedTableEntries(zip.getInputStream(it), zos, it) |
| 149 | } else { |
| 150 | copyToOutput(zip.getInputStream(it), zos, it) |
| 151 | } |
| 152 | } |
| 153 | } |
| 154 | } |
| 155 | // If net negative, copy original back. This is unusual, but can happen |
| 156 | // in some circumstances, such as the one described in |
| 157 | // https://plus.google.com/+SaidTahsinDane/posts/X9sTSwoVUhB |
| 158 | // "Removed unused resources: Binary resource data reduced from 588KB to 595KB: Removed -1%" |
| 159 | // Guard against that, and worst case, just use the original. |
| 160 | val before = source.length() |
| 161 | val after = dest.length() |
| 162 | if (after > before) { |
| 163 | debugReporter.info { |
| 164 | "Resource shrinking did not work (grew from $before to $after); using original " + |
| 165 | "instead" |
| 166 | } |
| 167 | Files.copy(source, dest) |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | private fun removeResourceUnusedTableEntries(zis: InputStream, |
| 172 | zos: JarOutputStream, |
| 173 | srcEntry: ZipEntry) { |
Rico Wind | 26aaf90 | 2024-01-29 12:59:53 +0100 | [diff] [blame] | 174 | val resourceIdsToRemove = unused |
| 175 | .filterNot { it.type == ResourceType.ID } |
| 176 | .map { resource -> resource.value } |
Rico Wind | a6e4efc | 2023-08-03 07:51:44 +0200 | [diff] [blame] | 177 | val shrunkenResourceTable = Resources.ResourceTable.parseFrom(zis) |
| 178 | .nullOutEntriesWithIds(resourceIdsToRemove) |
| 179 | val bytes = shrunkenResourceTable.toByteArray() |
| 180 | val outEntry = JarEntry(srcEntry.name) |
| 181 | if (srcEntry.time != -1L) { |
| 182 | outEntry.time = srcEntry.time |
| 183 | } |
| 184 | if (srcEntry.method == JarEntry.STORED) { |
| 185 | outEntry.method = JarEntry.STORED |
| 186 | outEntry.size = bytes.size.toLong() |
| 187 | val crc = CRC32() |
| 188 | crc.update(bytes, 0, bytes.size) |
| 189 | outEntry.crc = crc.getValue() |
| 190 | } |
| 191 | zos.putNextEntry(outEntry) |
| 192 | zos.write(bytes) |
| 193 | zos.closeEntry() |
| 194 | } |
| 195 | |
| 196 | /** Replaces the given entry with a minimal valid file of that type. */ |
| 197 | private fun replaceWithDummyEntry( |
| 198 | zos: JarOutputStream, |
| 199 | entry: ZipEntry, |
| 200 | format: LinkedResourcesFormat |
| 201 | ) { |
| 202 | // Create a new entry so that the compressed len is recomputed. |
| 203 | val name = entry.name |
| 204 | val (bytes, crc) = when { |
| 205 | // DOT_9PNG (.9.png) must be always before DOT_PNG (.png) |
| 206 | name.endsWith(DOT_9PNG) -> TINY_9PNG to TINY_9PNG_CRC |
| 207 | name.endsWith(DOT_PNG) -> TINY_PNG to TINY_PNG_CRC |
| 208 | name.endsWith(DOT_XML) && format == LinkedResourcesFormat.BINARY -> |
| 209 | TINY_BINARY_XML to TINY_BINARY_XML_CRC |
| 210 | name.endsWith(DOT_XML) && format == LinkedResourcesFormat.PROTO -> |
| 211 | TINY_PROTO_XML to TINY_PROTO_XML_CRC |
| 212 | else -> ByteArray(0) to 0L |
| 213 | } |
| 214 | |
| 215 | val outEntry = JarEntry(name) |
| 216 | if (entry.time != -1L) { |
| 217 | outEntry.time = entry.time |
| 218 | } |
| 219 | if (entry.method == JarEntry.STORED) { |
| 220 | outEntry.method = JarEntry.STORED |
| 221 | outEntry.size = bytes.size.toLong() |
| 222 | outEntry.crc = crc |
| 223 | } |
| 224 | zos.putNextEntry(outEntry) |
| 225 | zos.write(bytes) |
| 226 | zos.closeEntry() |
| 227 | debugReporter.info { |
| 228 | "Skipped unused resource $name: ${entry.size} bytes (replaced with small dummy file " + |
| 229 | "of size ${bytes.size} bytes)" |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | private fun copyToOutput(zis: InputStream, zos: JarOutputStream, entry: ZipEntry) { |
| 234 | // We can't just compress all files; files that are not compressed in the source .ap_ file |
| 235 | // must be left uncompressed here, since for example RAW files need to remain uncompressed |
| 236 | // in the APK such that they can be mmap'ed at runtime. |
| 237 | // Preserve the STORED method of the input entry. |
| 238 | val outEntry = when (entry.method) { |
| 239 | JarEntry.STORED -> JarEntry(entry) |
| 240 | else -> JarEntry(entry.name) |
| 241 | } |
| 242 | if (entry.time != -1L) { |
| 243 | outEntry.time = entry.time |
| 244 | } |
| 245 | zos.putNextEntry(outEntry) |
| 246 | if (!entry.isDirectory) { |
Rico Wind | 6c8de57 | 2023-10-31 15:22:52 +0100 | [diff] [blame] | 247 | zis.transferTo(zos); |
Rico Wind | a6e4efc | 2023-08-03 07:51:44 +0200 | [diff] [blame] | 248 | } |
| 249 | zos.closeEntry() |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | private interface ArchiveFormat { |
| 254 | val resourcesFormat: LinkedResourcesFormat |
| 255 | fun fileIsNotReachable(entry: ZipEntry): Boolean |
| 256 | } |
| 257 | |
| 258 | private class ApkArchiveFormat( |
| 259 | private val store: ResourceStore, |
| 260 | override val resourcesFormat: LinkedResourcesFormat |
| 261 | ) : ArchiveFormat { |
| 262 | |
| 263 | override fun fileIsNotReachable(entry: ZipEntry): Boolean { |
| 264 | if (entry.isDirectory || !entry.name.startsWith("res/")) { |
| 265 | return false |
| 266 | } |
| 267 | val (_, folder, name) = entry.name.split('/', limit = 3) |
| 268 | return !store.isJarPathReachable(folder, name) |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | private class BundleArchiveFormat( |
| 273 | private val store: ResourceStore, |
| 274 | private val moduleNameToPackageName: Map<String, String> |
| 275 | ) : ArchiveFormat { |
| 276 | |
| 277 | override val resourcesFormat = LinkedResourcesFormat.PROTO |
| 278 | |
| 279 | override fun fileIsNotReachable(entry: ZipEntry): Boolean { |
| 280 | val module = entry.name.substringBefore('/') |
| 281 | val packageName = moduleNameToPackageName[module] |
| 282 | if (entry.isDirectory || packageName == null || !entry.name.startsWith("$module/res/")) { |
| 283 | return false |
| 284 | } |
| 285 | val (_, _, folder, name) = entry.name.split('/', limit = 4) |
| 286 | return !store.isJarPathReachable(folder, name) |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | private fun ResourceStore.isJarPathReachable( |
| 291 | folder: String, |
| 292 | name: String |
| 293 | ): Boolean { |
| 294 | val folderType = ResourceFolderType.getFolderType(folder) ?: return true |
| 295 | val resourceName = name.substringBefore('.') |
| 296 | // Bundle format has a restriction: in case the same resource is duplicated in multiple modules |
| 297 | // its content should be the same in all of them. This restriction means that we can't replace |
| 298 | // resource with dummy content if its duplicate is used in some module. |
| 299 | return FolderTypeRelationship.getRelatedResourceTypes(folderType) |
| 300 | .filterNot { it == ResourceType.ID } |
| 301 | .flatMap { getResources(it, resourceName) } |
| 302 | .any { it.isReachable } |
| 303 | } |
| 304 | |
Rico Wind | 441f03d | 2023-10-26 12:40:07 +0200 | [diff] [blame] | 305 | fun ResourceStore.isJarPathReachable(path: String) : Boolean { |
| 306 | val (_, folder, name) = path.split('/', limit = 3) |
| 307 | return isJarPathReachable(folder, name); |
| 308 | } |
| 309 | |
Rico Wind | 32420ca | 2024-12-19 15:04:52 +0100 | [diff] [blame] | 310 | fun ResourceStore.getResourcesFor(path: String): List<Resource> { |
| 311 | val (_, folder, name) = path.split('/', limit = 3) |
| 312 | val folderType = ResourceFolderType.getFolderType(folder) ?: return emptyList() |
Rico Wind | a6e4efc | 2023-08-03 07:51:44 +0200 | [diff] [blame] | 313 | val resourceName = name.substringBefore('.') |
| 314 | return FolderTypeRelationship.getRelatedResourceTypes(folderType) |
| 315 | .filterNot { it == ResourceType.ID } |
| 316 | .flatMap { getResources(it, resourceName) } |
Rico Wind | 32420ca | 2024-12-19 15:04:52 +0100 | [diff] [blame] | 317 | .toList() |
Rico Wind | a6e4efc | 2023-08-03 07:51:44 +0200 | [diff] [blame] | 318 | } |