blob: a0a1f76023e11218f75209e198b67af5aa2abea6 [file] [log] [blame]
/*
* 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
}
}
}