| /* | |
| * Copyright (C) 2020 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.graph | |
| import com.android.aapt.Resources | |
| import com.android.aapt.Resources.Entry | |
| import com.android.aapt.Resources.FileReference | |
| import com.android.aapt.Resources.FileReference.Type.PROTO_XML | |
| import com.android.aapt.Resources.Reference | |
| import com.android.aapt.Resources.ResourceTable | |
| import com.android.aapt.Resources.XmlAttribute | |
| import com.android.aapt.Resources.XmlElement | |
| import com.android.aapt.Resources.XmlNode | |
| import com.android.build.shrinker.ResourceShrinkerModel | |
| import com.android.build.shrinker.entriesSequence | |
| import com.android.ide.common.resources.usage.ResourceUsageModel | |
| import com.android.ide.common.resources.usage.ResourceUsageModel.Resource | |
| import com.android.ide.common.resources.usage.WebTokenizers | |
| import com.android.resources.ResourceType | |
| import com.android.utils.SdkUtils.IMAGE_EXTENSIONS | |
| import com.google.common.base.Ascii | |
| import java.io.IOException | |
| import java.nio.charset.StandardCharsets | |
| import java.nio.file.Files | |
| import java.nio.file.Path | |
| /** | |
| * Builds resources graph starting from each resource in resource table in proto format and follow | |
| * all references to other resources inside inlined values and external files from res/ folder. | |
| * | |
| * <p>Supports external files in the following formats: | |
| * <ul> | |
| * <li>XML files compiled to proto format; | |
| * <li>HTML, CSS, JS files inside res/raw folder; | |
| * <li>Unknown files inside res/raw folder (only looks for 'android_res/<type>/<name>' pattern); | |
| * </ul> | |
| * | |
| * <p>As ID resources don't specify parent-child relations between resources but are just | |
| * identifiers for a resource or some part of the resource we don't gather them as references to | |
| * examined resource. | |
| * | |
| * @param resourceRoot path to <module>/res/ folder. | |
| * @param resourceTable path to resource table in proto format. | |
| */ | |
| class ProtoResourcesGraphBuilder( | |
| private val resourceRoot: ResFolderFileTree, | |
| private val resourceTableProducer: (ResourceShrinkerModel) -> ResourceTable | |
| ) : ResourcesGraphBuilder { | |
| constructor(resourceRootPath: Path, resourceTablePath: Path) : this( | |
| object : ResFolderFileTree { | |
| override fun getEntryByName(pathInRes: String): ByteArray { | |
| val lazyVal : ByteArray by lazy { | |
| Files.readAllBytes(resourceRootPath.resolve(pathInRes)) | |
| } | |
| return lazyVal | |
| } | |
| }, | |
| { model -> model.readResourceTable(resourceTablePath) } | |
| ) | |
| override fun buildGraph(model: ResourceShrinkerModel) { | |
| resourceTableProducer(model).entriesSequence() | |
| .map { (id, _, _, entry) -> | |
| model.resourceStore.getResource(id)?.let { | |
| ReferencesForResourceFinder(resourceRoot, model, entry, it) | |
| } | |
| } | |
| .filterNotNull() | |
| .forEach { it.findReferences() } | |
| } | |
| } | |
| interface ResFolderFileTree { | |
| fun getEntryByName(pathInRes: String) : ByteArray | |
| } | |
| private class ReferencesForResourceFinder( | |
| private val resourcesRoot: ResFolderFileTree, | |
| private val model: ResourceShrinkerModel, | |
| private val entry: Entry, | |
| private val current: Resource | |
| ) { | |
| companion object { | |
| /** | |
| * 'android_res/' is a synthetic directory for resource references in URL format. For | |
| * example: file:///android_res/raw/intro_page. | |
| */ | |
| private const val ANDROID_RES = "android_res/" | |
| private const val CONSTRAINT_REFERENCED_IDS = "constraint_referenced_ids" | |
| private fun Reference.asItem(): Resources.Item = | |
| Resources.Item.newBuilder().setRef(this).build() | |
| } | |
| private val webTokenizers: WebTokenizers by lazy { | |
| WebTokenizers(object : WebTokenizers.WebTokensCallback { | |
| override fun referencedHtmlAttribute(tag: String?, attribute: String?, value: String) { | |
| if (attribute == "href" || attribute == "src") { | |
| referencedUrl(value) | |
| } | |
| } | |
| override fun referencedJsString(jsString: String) { | |
| referencedStringFromWebContent(jsString) | |
| } | |
| override fun referencedCssUrl(url: String) { | |
| referencedUrl(url) | |
| } | |
| private fun referencedUrl(url: String) { | |
| // 1. if url contains '/' try to find resources from this web url. | |
| // 2. if there is no '/' it might be just relative reference to another resource. | |
| val resources = when { | |
| url.contains('/') -> model.resourceStore.getResourcesFromWebUrl(url) | |
| else -> | |
| model.resourceStore.getResources(ResourceType.RAW, url.substringBefore('.')) | |
| } | |
| if (resources.isNotEmpty()) { | |
| resources.forEach { current.addReference(it) } | |
| } else { | |
| // if there is no resources found by provided url just gather this string as | |
| // found inside web content to process it afterwards. | |
| referencedStringFromWebContent(url) | |
| } | |
| } | |
| private fun referencedStringFromWebContent(string: String) { | |
| if (string.isNotEmpty() && string.length <= 80) { | |
| model.addStringConstant(string) | |
| model.isFoundWebContent = true | |
| } | |
| } | |
| }) | |
| } | |
| fun findReferences() { | |
| // Walk through all values of the entry and find all Item instances that may reference | |
| // other resources in resource table itself or specify external files that should be | |
| // analyzed for references. | |
| entry.configValueList.asSequence() | |
| .map { it.value } | |
| .flatMap { value -> | |
| val compoundValue = value.compoundValue | |
| // compoundValue.attr and compoundValue.styleable are skipped, attr defines | |
| // references to ID resources only, but ID and STYLEABLE resources are not supported | |
| // by shrinker. | |
| when { | |
| value.hasItem() -> | |
| sequenceOf(value.item) | |
| compoundValue.hasStyle() -> | |
| sequenceOf(compoundValue.style.parent.asItem()) + | |
| compoundValue.style.entryList.asSequence().flatMap { | |
| sequenceOf( | |
| it.item, | |
| it.key.asItem() | |
| ) | |
| } | |
| compoundValue.hasArray() -> | |
| compoundValue.array.elementList.asSequence().map { it.item } | |
| compoundValue.hasPlural() -> | |
| compoundValue.plural.entryList.asSequence().map { it.item } | |
| else -> emptySequence() | |
| } | |
| } | |
| .forEach { findFromItem(it) } | |
| } | |
| private fun findFromItem(item: Resources.Item) { | |
| try { | |
| when { | |
| item.hasRef() -> findFromReference(item.ref) | |
| item.hasFile() && item.file.path.startsWith("res/") -> findFromFile(item.file) | |
| } | |
| } catch (e: IOException) { | |
| model.debugReporter.debug { "File '${item.file.path}' can not be processed. Skipping." } | |
| } | |
| } | |
| private fun findFromReference(reference: Reference) { | |
| // Reference object may have id of referenced resource, in this case prefer resolved id. | |
| // In case id is not provided try to find referenced resource by name. Name is converted | |
| // to resource url here, because name in resource table is not normalized to R style field | |
| // and to find it we need normalize it first (for example, in case name in resource table is | |
| // MyStyle.Child in R file it is R.style.MyStyle_child). | |
| val referencedResources = when { | |
| reference.id != 0 -> listOf(model.resourceStore.getResource(reference.id)) | |
| reference.name.isNotEmpty() -> | |
| model.resourceStore.getResourcesFromUrl("@${reference.name}") | |
| else -> emptyList() | |
| } | |
| // IDs are not supported by shrinker for now, just skip it. | |
| referencedResources.asSequence() | |
| .filterNotNull() | |
| .filter { it.type != ResourceType.ID } | |
| .forEach { current.addReference(it) } | |
| } | |
| private fun findFromFile(file: FileReference) { | |
| val bytes = resourcesRoot.getEntryByName(file.path.substringAfter("res/")) | |
| val content: String by lazy { String(bytes, StandardCharsets.UTF_8) } | |
| val extension = Ascii.toLowerCase(file.path.substringAfterLast('.')) | |
| when { | |
| file.type == PROTO_XML -> fillFromXmlNode(XmlNode.parseFrom(bytes)) | |
| extension in listOf("html", "htm") -> webTokenizers.tokenizeHtml(content) | |
| extension == "css" -> webTokenizers.tokenizeCss(content) | |
| extension == "js" -> webTokenizers.tokenizeJs(content) | |
| extension !in IMAGE_EXTENSIONS -> maybeAndroidResUrl(content, markAsReachable = false) | |
| } | |
| } | |
| private fun fillFromXmlNode(node: XmlNode) { | |
| // Check for possible reference as 'android_res/<type>/<name>' pattern inside element text. | |
| if (current.type == ResourceType.XML) { | |
| maybeAndroidResUrl(node.text, markAsReachable = true) | |
| } | |
| // Check special xml element <rawPathResId> which provides reference to res/raw/ apk | |
| // resource for wear application. Applies to all XML files for now but might be re-scoped | |
| // to only apply to <wearableApp> XMLs. | |
| maybeWearAppReference(node.element) | |
| node.element.attributeList.forEach { fillFromAttribute(it) } | |
| node.element.childList.forEach { fillFromXmlNode(it) } | |
| } | |
| private fun fillFromAttribute(attribute: XmlAttribute) { | |
| if (attribute.name == CONSTRAINT_REFERENCED_IDS) { | |
| fillFromConstraintReferencedIds(attribute.value) | |
| } | |
| if (attribute.hasCompiledItem()) { | |
| findFromItem(attribute.compiledItem) | |
| } | |
| // Check for possible reference as 'android_res/<type>/<name>' pattern inside attribute val. | |
| if (current.type == ResourceType.XML) { | |
| maybeAndroidResUrl(attribute.value, markAsReachable = true) | |
| } | |
| } | |
| private fun fillFromConstraintReferencedIds(value: String?) { | |
| value | |
| ?.split(",") | |
| ?.map { it.trim() } | |
| ?.forEach { | |
| model.resourceStore.getResources(ResourceType.ID, it) | |
| .forEach(ResourceUsageModel::markReachable) | |
| } | |
| } | |
| private fun maybeAndroidResUrl(text: String, markAsReachable: Boolean) { | |
| findAndroidResReferencesInText(text) | |
| .map { it.split('/', limit = 2) } | |
| .filter { it.size == 2 } | |
| .map { (dir, fileName) -> | |
| Pair( | |
| ResourceType.fromFolderName(dir.substringBefore('-')), | |
| fileName.substringBefore('.') | |
| ) | |
| } | |
| .filter { (type, _) -> type != null } | |
| .flatMap { (type, name) -> model.resourceStore.getResources(type!!, name).asSequence() } | |
| .forEach { | |
| if (markAsReachable) { | |
| ResourceUsageModel.markReachable(it) | |
| } else { | |
| current.addReference(it) | |
| } | |
| } | |
| } | |
| private fun maybeWearAppReference(element: XmlElement) { | |
| if (element.name == "rawPathResId") { | |
| val rawResourceName = element.childList | |
| .map { it.text } | |
| .joinToString(separator = "") | |
| .trim() | |
| model.resourceStore.getResources(ResourceType.RAW, rawResourceName) | |
| .forEach { current.addReference(it) } | |
| } | |
| } | |
| /** | |
| * Splits input text to parts that starts with 'android_res/' and returns sequence of strings | |
| * between 'android_res/' occurrences and first whitespace after it. This method is used instead | |
| * of {@link CharSequence#splitToSequence} because does not spawn full substrings between | |
| * 'android_res/' in memory when text is big enough. | |
| */ | |
| private fun findAndroidResReferencesInText(text: String): Sequence<String> = sequence { | |
| var start = 0 | |
| while (start < text.length) { | |
| start = text.indexOf(ANDROID_RES, start) | |
| if (start == -1) { | |
| break | |
| } | |
| var end = start + ANDROID_RES.length | |
| while (end < text.length && !Character.isWhitespace(text[end])) { | |
| end++ | |
| } | |
| yield(text.substring(start + ANDROID_RES.length, end)) | |
| start = end | |
| } | |
| } | |
| } |