| /* |
| * Copyright (C) 2015 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.tools.lint.checks |
| |
| import com.android.SdkConstants.ANDROID_URI |
| import com.android.SdkConstants.ATTR_TITLE |
| import com.android.SdkConstants.PREFIX_RESOURCE_REF |
| import com.android.SdkConstants.STRING_PREFIX |
| import com.android.SdkConstants.XMLNS_PREFIX |
| import com.android.resources.ResourceFolderType |
| import com.android.tools.lint.detector.api.Category |
| import com.android.tools.lint.detector.api.Implementation |
| import com.android.tools.lint.detector.api.Issue |
| import com.android.tools.lint.detector.api.ResourceXmlDetector |
| import com.android.tools.lint.detector.api.Scope |
| import com.android.tools.lint.detector.api.Severity |
| import com.android.tools.lint.detector.api.XmlContext |
| import com.android.tools.lint.detector.api.getChildren |
| import com.google.common.annotations.VisibleForTesting |
| import com.google.common.collect.Maps |
| import org.w3c.dom.Attr |
| import org.w3c.dom.Document |
| import org.w3c.dom.Element |
| import org.w3c.dom.Node |
| |
| /** |
| * Check which makes sure that an application restrictions file is correct. The rules are specified |
| * in https://developer.android.com/reference/android/content/RestrictionsManager.html |
| */ |
| class RestrictionsDetector : ResourceXmlDetector() { |
| |
| override fun appliesTo(folderType: ResourceFolderType): Boolean { |
| return folderType == ResourceFolderType.XML |
| } |
| |
| override fun visitDocument(context: XmlContext, document: Document) { |
| val root = document.documentElement ?: return |
| if (TAG_RESTRICTIONS != root.tagName) { |
| return |
| } |
| |
| val keys = Maps.newHashMap<String, Element>() |
| validateNestedRestrictions(context, root, null, keys, 0) |
| } |
| |
| /** Validates the `<restriction>` **children** of the given element */ |
| private fun validateNestedRestrictions( |
| context: XmlContext, |
| element: Element, |
| restrictionType: String?, |
| keys: MutableMap<String, Element>, |
| depth: Int |
| ) { |
| assert(depth == 0 || restrictionType != null) |
| |
| val children = getChildren(element) |
| |
| // Only restrictions of type bundle and bundle_array can have one or multiple nested |
| // restriction elements. |
| if (depth == 0 || |
| restrictionType == VALUE_BUNDLE || |
| restrictionType == VALUE_BUNDLE_ARRAY |
| ) { |
| // Bundle and bundle array should not have a default value |
| val defaultValue = element.getAttributeNodeNS(ANDROID_URI, VALUE_DEFAULT_VALUE) |
| if (defaultValue != null) { |
| context.report( |
| ISSUE, |
| element, |
| context.getLocation(defaultValue), |
| String.format( |
| "Restriction type `%1\$s` should not have a default value", |
| restrictionType |
| ) |
| ) |
| } |
| for (child in children) { |
| if (verifyRestrictionTagName(context, child)) { |
| validateRestriction(context, child, depth + 1, keys) |
| } |
| } |
| |
| if (depth == 0) { |
| // It's okay to have <restrictions /> |
| } else if (restrictionType == VALUE_BUNDLE_ARRAY) { |
| if (children.size != 1) { |
| context.report( |
| ISSUE, |
| element, |
| context.getElementLocation(element), |
| "Expected exactly one child for restriction of type `bundle_array`" |
| ) |
| } |
| } else { |
| assert(restrictionType == VALUE_BUNDLE) |
| if (children.isEmpty()) { |
| context.report( |
| ISSUE, |
| element, |
| context.getElementLocation(element), |
| "Restriction type `bundle` should have at least one nested restriction" |
| ) |
| } |
| } |
| |
| if (children.size > MAX_NUMBER_OF_NESTED_RESTRICTIONS) { |
| context.report( |
| ISSUE, |
| element, |
| context.getElementLocation(element), |
| String.format( |
| // TODO: Reference Google Play store restriction here in error message, |
| // e.g. that violating this will cause APK to be rejected? |
| "Invalid nested restriction: too many nested restrictions (was %1\$d, max %2\$d)", |
| children.size, MAX_NUMBER_OF_NESTED_RESTRICTIONS |
| ) |
| ) |
| } else if (depth > MAX_NESTING_DEPTH) { |
| // Same comment as for MAX_NUMBER_OF_NESTED_RESTRICTIONS: include source? |
| context.report( |
| ISSUE, |
| element, |
| context.getElementLocation(element), |
| String.format( |
| "Invalid nested restriction: nesting depth %1\$d too large (max %2\$d", |
| depth, MAX_NESTING_DEPTH |
| ) |
| ) |
| } |
| } else if (!children.isEmpty()) { |
| context.report( |
| ISSUE, |
| element, |
| context.getNameLocation(element), |
| "Only restrictions of type `bundle` and `bundle_array` can have one or multiple nested restriction elements" |
| ) |
| } |
| } |
| |
| /** Validates a `<restriction>` element (and recurses to validate the children) */ |
| private fun validateRestriction( |
| context: XmlContext, |
| node: Node, |
| depth: Int, |
| keys: MutableMap<String, Element> |
| ) { |
| |
| if (node.nodeType != Node.ELEMENT_NODE) { |
| return |
| } |
| val element = node as Element |
| |
| // key, title and restrictionType are mandatory. |
| val restrictionType = checkRequiredAttribute(context, element, ATTR_RESTRICTION_TYPE) |
| val key = checkRequiredAttribute(context, element, ATTR_KEY) |
| val title = checkRequiredAttribute(context, element, ATTR_TITLE) |
| if (restrictionType == null || key == null || title == null) { |
| return |
| } |
| |
| // You use each restriction's android:key attribute to read its value from a |
| // restrictions bundle. For this reason, each restriction must have a unique key string, |
| // and the string cannot be localized. It must be specified with a string literal. |
| when { |
| key.startsWith(STRING_PREFIX) -> { |
| val attribute = element.getAttributeNodeNS(ANDROID_URI, ATTR_KEY) |
| val valueLocation = context.getValueLocation(attribute) |
| context.report( |
| ISSUE, |
| element, |
| valueLocation, |
| "Keys cannot be localized, they should be specified with a string literal" |
| ) |
| } |
| keys.containsKey(key) -> { |
| val thisAttribute = element.getAttributeNodeNS(ANDROID_URI, ATTR_KEY) |
| val location = context.getValueLocation(thisAttribute) |
| val prev = keys[key] ?: return |
| val prevAttribute = prev.getAttributeNodeNS(ANDROID_URI, ATTR_KEY) |
| val previousLocation = context.getValueLocation(prevAttribute) |
| previousLocation.message = "Previous use of key here" |
| location.secondary = previousLocation |
| context.report( |
| ISSUE, |
| element, |
| location, |
| String.format("Duplicate key `%1\$s`", key) |
| ) |
| } |
| else -> keys[key] = element |
| } |
| |
| if (restrictionType == VALUE_CHOICE || restrictionType == VALUE_MULTI_SELECT) { |
| // entries and entryValues are required if restrictionType is choice or multi-select. |
| |
| checkRequiredAttribute( |
| context, |
| element, |
| VALUE_ENTRIES |
| ) != null || |
| // deliberate short circuit evaluation |
| checkRequiredAttribute( |
| context, |
| element, |
| VALUE_ENTRY_VALUES |
| ) != null |
| } else if (restrictionType == VALUE_HIDDEN) { |
| // hidden type must have a defaultValue |
| checkRequiredAttribute(context, element, VALUE_DEFAULT_VALUE) |
| } else if (restrictionType == VALUE_INTEGER) { |
| val defaultValue = element.getAttributeNodeNS(ANDROID_URI, VALUE_DEFAULT_VALUE) |
| if (defaultValue != null && !defaultValue.value.startsWith(PREFIX_RESOURCE_REF)) { |
| try { |
| |
| Integer.decode(defaultValue.value) |
| } catch (e: NumberFormatException) { |
| context.report( |
| ISSUE, |
| element, |
| context.getValueLocation(defaultValue), |
| "Invalid number" |
| ) |
| } |
| } |
| } |
| |
| validateNestedRestrictions(context, element, restrictionType, keys, depth) |
| } |
| |
| /** |
| * Makes sure that the given element corresponds to a restriction tag, and if not, reports it |
| * and return false |
| */ |
| private fun verifyRestrictionTagName(context: XmlContext, element: Element): Boolean { |
| val tagName = element.tagName |
| if (tagName != TAG_RESTRICTION) { |
| context.report( |
| ISSUE, |
| element, |
| context.getNameLocation(element), |
| String.format( |
| "Unexpected tag `<%1\$s>`, expected `<%2\$s>`", |
| tagName, TAG_RESTRICTION |
| ) |
| ) |
| return false |
| } |
| return true |
| } |
| |
| private fun checkRequiredAttribute( |
| context: XmlContext, |
| element: Element, |
| attribute: String |
| ): String? { |
| var fullAttribute = attribute |
| if (!element.hasAttributeNS(ANDROID_URI, fullAttribute)) { |
| var prefix: String? = element.ownerDocument.lookupNamespaceURI(ANDROID_URI) |
| if (prefix == null) { |
| val root = element.ownerDocument.documentElement |
| val attributes = root.attributes |
| var i = 0 |
| val n = attributes.length |
| while (i < n) { |
| val a = attributes.item(i) as Attr |
| if (a.name.startsWith(XMLNS_PREFIX) && ANDROID_URI == a.value) { |
| prefix = a.name.substring(XMLNS_PREFIX.length) |
| break |
| } |
| i++ |
| } |
| } |
| if (prefix != null) { |
| fullAttribute = prefix + ':'.toString() + fullAttribute |
| } |
| context.report( |
| ISSUE, |
| element, |
| context.getElementLocation(element), |
| // TODO: Include namespace prefix? |
| String.format("Missing required attribute `%1\$s`", fullAttribute) |
| ) |
| return null |
| } |
| return element.getAttributeNS(ANDROID_URI, fullAttribute) |
| } |
| |
| companion object { |
| |
| // Copied from Google Play store's AppRestrictionBuilder |
| @VisibleForTesting |
| const val MAX_NESTING_DEPTH = 20 |
| |
| // Copied from Google Play store's AppRestrictionBuilder |
| @VisibleForTesting |
| const val MAX_NUMBER_OF_NESTED_RESTRICTIONS = 1000 |
| |
| /** Validation of `<restrictions>` XML elements */ |
| @JvmField |
| val ISSUE = Issue.create( |
| id = "ValidRestrictions", |
| briefDescription = "Invalid Restrictions Descriptor", |
| explanation = "Ensures that an applications restrictions XML file is properly formed", |
| moreInfo = "https://developer.android.com/reference/android/content/RestrictionsManager.html", |
| category = Category.CORRECTNESS, |
| priority = 5, |
| severity = Severity.FATAL, |
| implementation = Implementation( |
| RestrictionsDetector::class.java, Scope.RESOURCE_FILE_SCOPE |
| ) |
| ) |
| |
| const val TAG_RESTRICTIONS = "restrictions" |
| private const val TAG_RESTRICTION = "restriction" |
| private const val ATTR_RESTRICTION_TYPE = "restrictionType" |
| private const val ATTR_KEY = "key" |
| const val ATTR_DESCRIPTION = "description" |
| private const val VALUE_BUNDLE = "bundle" |
| private const val VALUE_BUNDLE_ARRAY = "bundle_array" |
| private const val VALUE_CHOICE = "choice" |
| private const val VALUE_MULTI_SELECT = "multi-select" |
| private const val VALUE_ENTRIES = "entries" |
| private const val VALUE_ENTRY_VALUES = "entryValues" |
| private const val VALUE_HIDDEN = "hidden" |
| private const val VALUE_DEFAULT_VALUE = "defaultValue" |
| private const val VALUE_INTEGER = "integer" |
| } |
| } |