blob: 153f1cefc89448001349b78223fb3d8e94f494da [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) {
174 val resourceIdsToRemove =
175 model.resourceStore.resources.filter { !it.isReachable }.map { it.value }.toList()
176 val shrunkenResourceTable = Resources.ResourceTable.parseFrom(zis)
177 .nullOutEntriesWithIds(resourceIdsToRemove)
178 val bytes = shrunkenResourceTable.toByteArray()
179 val outEntry = JarEntry(srcEntry.name)
180 if (srcEntry.time != -1L) {
181 outEntry.time = srcEntry.time
182 }
183 if (srcEntry.method == JarEntry.STORED) {
184 outEntry.method = JarEntry.STORED
185 outEntry.size = bytes.size.toLong()
186 val crc = CRC32()
187 crc.update(bytes, 0, bytes.size)
188 outEntry.crc = crc.getValue()
189 }
190 zos.putNextEntry(outEntry)
191 zos.write(bytes)
192 zos.closeEntry()
193 }
194
195 /** Replaces the given entry with a minimal valid file of that type. */
196 private fun replaceWithDummyEntry(
197 zos: JarOutputStream,
198 entry: ZipEntry,
199 format: LinkedResourcesFormat
200 ) {
201 // Create a new entry so that the compressed len is recomputed.
202 val name = entry.name
203 val (bytes, crc) = when {
204 // DOT_9PNG (.9.png) must be always before DOT_PNG (.png)
205 name.endsWith(DOT_9PNG) -> TINY_9PNG to TINY_9PNG_CRC
206 name.endsWith(DOT_PNG) -> TINY_PNG to TINY_PNG_CRC
207 name.endsWith(DOT_XML) && format == LinkedResourcesFormat.BINARY ->
208 TINY_BINARY_XML to TINY_BINARY_XML_CRC
209 name.endsWith(DOT_XML) && format == LinkedResourcesFormat.PROTO ->
210 TINY_PROTO_XML to TINY_PROTO_XML_CRC
211 else -> ByteArray(0) to 0L
212 }
213
214 val outEntry = JarEntry(name)
215 if (entry.time != -1L) {
216 outEntry.time = entry.time
217 }
218 if (entry.method == JarEntry.STORED) {
219 outEntry.method = JarEntry.STORED
220 outEntry.size = bytes.size.toLong()
221 outEntry.crc = crc
222 }
223 zos.putNextEntry(outEntry)
224 zos.write(bytes)
225 zos.closeEntry()
226 debugReporter.info {
227 "Skipped unused resource $name: ${entry.size} bytes (replaced with small dummy file " +
228 "of size ${bytes.size} bytes)"
229 }
230 }
231
232 private fun copyToOutput(zis: InputStream, zos: JarOutputStream, entry: ZipEntry) {
233 // We can't just compress all files; files that are not compressed in the source .ap_ file
234 // must be left uncompressed here, since for example RAW files need to remain uncompressed
235 // in the APK such that they can be mmap'ed at runtime.
236 // Preserve the STORED method of the input entry.
237 val outEntry = when (entry.method) {
238 JarEntry.STORED -> JarEntry(entry)
239 else -> JarEntry(entry.name)
240 }
241 if (entry.time != -1L) {
242 outEntry.time = entry.time
243 }
244 zos.putNextEntry(outEntry)
245 if (!entry.isDirectory) {
246 zos.write(ByteStreams.toByteArray(zis))
247 }
248 zos.closeEntry()
249 }
250}
251
252private interface ArchiveFormat {
253 val resourcesFormat: LinkedResourcesFormat
254 fun fileIsNotReachable(entry: ZipEntry): Boolean
255}
256
257private class ApkArchiveFormat(
258 private val store: ResourceStore,
259 override val resourcesFormat: LinkedResourcesFormat
260) : ArchiveFormat {
261
262 override fun fileIsNotReachable(entry: ZipEntry): Boolean {
263 if (entry.isDirectory || !entry.name.startsWith("res/")) {
264 return false
265 }
266 val (_, folder, name) = entry.name.split('/', limit = 3)
267 return !store.isJarPathReachable(folder, name)
268 }
269}
270
271private class BundleArchiveFormat(
272 private val store: ResourceStore,
273 private val moduleNameToPackageName: Map<String, String>
274) : ArchiveFormat {
275
276 override val resourcesFormat = LinkedResourcesFormat.PROTO
277
278 override fun fileIsNotReachable(entry: ZipEntry): Boolean {
279 val module = entry.name.substringBefore('/')
280 val packageName = moduleNameToPackageName[module]
281 if (entry.isDirectory || packageName == null || !entry.name.startsWith("$module/res/")) {
282 return false
283 }
284 val (_, _, folder, name) = entry.name.split('/', limit = 4)
285 return !store.isJarPathReachable(folder, name)
286 }
287}
288
289private fun ResourceStore.isJarPathReachable(
290 folder: String,
291 name: String
292): Boolean {
293 val folderType = ResourceFolderType.getFolderType(folder) ?: return true
294 val resourceName = name.substringBefore('.')
295 // Bundle format has a restriction: in case the same resource is duplicated in multiple modules
296 // its content should be the same in all of them. This restriction means that we can't replace
297 // resource with dummy content if its duplicate is used in some module.
298 return FolderTypeRelationship.getRelatedResourceTypes(folderType)
299 .filterNot { it == ResourceType.ID }
300 .flatMap { getResources(it, resourceName) }
301 .any { it.isReachable }
302}
303
Rico Wind988e2972023-10-26 10:30:44 +0200304fun ResourceStore.isJarPathReachable(path: String) : Boolean {
305 val (_, folder, name) = path.split('/', limit = 3)
306 return isJarPathReachable(folder, name);
307}
308
Rico Winda6e4efc2023-08-03 07:51:44 +0200309private fun ResourceStore.getResourceId(
310 folder: String,
311 name: String
312): Int {
313 val folderType = ResourceFolderType.getFolderType(folder) ?: return -1
314 val resourceName = name.substringBefore('.')
315 return FolderTypeRelationship.getRelatedResourceTypes(folderType)
316 .filterNot { it == ResourceType.ID }
317 .flatMap { getResources(it, resourceName) }
318 .map { it.value }
319 .getOrElse(0) { -1 }
320
321}