blob: b13a28b8bd448efa4a2564d382b77775789b5f93 [file] [log] [blame]
Rico Winda6e4efc2023-08-03 07:51:44 +02001/*
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
17package com.android.build.shrinker
18
19import com.android.SdkConstants.DOT_9PNG
20import com.android.SdkConstants.DOT_PNG
21import com.android.SdkConstants.DOT_XML
22import com.android.aapt.Resources
23import com.android.build.shrinker.DummyContent.TINY_9PNG
24import com.android.build.shrinker.DummyContent.TINY_9PNG_CRC
25import com.android.build.shrinker.DummyContent.TINY_BINARY_XML
26import com.android.build.shrinker.DummyContent.TINY_BINARY_XML_CRC
27import com.android.build.shrinker.DummyContent.TINY_PNG
28import com.android.build.shrinker.DummyContent.TINY_PNG_CRC
29import com.android.build.shrinker.DummyContent.TINY_PROTO_XML
30import com.android.build.shrinker.DummyContent.TINY_PROTO_XML_CRC
31import com.android.build.shrinker.gatherer.ResourcesGatherer
32import com.android.build.shrinker.graph.ResourcesGraphBuilder
33import com.android.build.shrinker.obfuscation.ObfuscationMappingsRecorder
34import com.android.build.shrinker.usages.ResourceUsageRecorder
35import com.android.ide.common.resources.findUnusedResources
36import com.android.ide.common.resources.usage.ResourceStore
37import com.android.ide.common.resources.usage.ResourceUsageModel.Resource
38import com.android.resources.FolderTypeRelationship
39import com.android.resources.ResourceFolderType
40import com.android.resources.ResourceType
41import com.google.common.io.ByteStreams
42import com.google.common.io.Files
43import java.io.BufferedOutputStream
44import java.io.File
45import java.io.FileOutputStream
46import java.io.IOException
47import java.io.InputStream
48import java.util.jar.JarEntry
49import java.util.jar.JarOutputStream
50import java.util.zip.CRC32
51import java.util.zip.ZipEntry
52import 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 */
70class 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 Wind26aaf902024-01-29 12:59:53 +0100174 val resourceIdsToRemove = unused
175 .filterNot { it.type == ResourceType.ID }
176 .map { resource -> resource.value }
Rico Winda6e4efc2023-08-03 07:51:44 +0200177 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 Wind6c8de572023-10-31 15:22:52 +0100247 zis.transferTo(zos);
Rico Winda6e4efc2023-08-03 07:51:44 +0200248 }
249 zos.closeEntry()
250 }
251}
252
253private interface ArchiveFormat {
254 val resourcesFormat: LinkedResourcesFormat
255 fun fileIsNotReachable(entry: ZipEntry): Boolean
256}
257
258private 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
272private 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
290private 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 Wind441f03d2023-10-26 12:40:07 +0200305fun ResourceStore.isJarPathReachable(path: String) : Boolean {
306 val (_, folder, name) = path.split('/', limit = 3)
307 return isJarPathReachable(folder, name);
308}
309
Rico Wind32420ca2024-12-19 15:04:52 +0100310fun ResourceStore.getResourcesFor(path: String): List<Resource> {
311 val (_, folder, name) = path.split('/', limit = 3)
312 val folderType = ResourceFolderType.getFolderType(folder) ?: return emptyList()
Rico Winda6e4efc2023-08-03 07:51:44 +0200313 val resourceName = name.substringBefore('.')
314 return FolderTypeRelationship.getRelatedResourceTypes(folderType)
315 .filterNot { it == ResourceType.ID }
316 .flatMap { getResources(it, resourceName) }
Rico Wind32420ca2024-12-19 15:04:52 +0100317 .toList()
Rico Winda6e4efc2023-08-03 07:51:44 +0200318}