
com.android.tools.lint.checks.AndroidTvDetector Maven / Gradle / Ivy
The newest version!
/*
* 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 static com.android.SdkConstants.ANDROID_URI;
import static com.android.SdkConstants.ATTR_NAME;
import static com.android.SdkConstants.TAG_USES_FEATURE;
import static com.android.SdkConstants.TAG_USES_PERMISSION;
import static com.android.tools.lint.detector.api.TextFormat.RAW;
import static com.android.xml.AndroidManifest.ATTRIBUTE_REQUIRED;
import static com.android.xml.AndroidManifest.NODE_ACTIVITY;
import static com.android.xml.AndroidManifest.NODE_ACTIVITY_ALIAS;
import static com.android.xml.AndroidManifest.NODE_APPLICATION;
import static com.android.xml.AndroidManifest.NODE_CATEGORY;
import static com.android.xml.AndroidManifest.NODE_INTENT;
import static com.android.xml.AndroidManifest.NODE_USES_FEATURE;
import static com.android.xml.AndroidManifest.NODE_USES_PERMISSION;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.Context;
import com.android.tools.lint.detector.api.Detector;
import com.android.tools.lint.detector.api.Implementation;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.LintUtils;
import com.android.tools.lint.detector.api.Project;
import com.android.tools.lint.detector.api.Scope;
import com.android.tools.lint.detector.api.Severity;
import com.android.tools.lint.detector.api.TextFormat;
import com.android.tools.lint.detector.api.XmlContext;
import com.google.common.collect.Collections2;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
/**
* Detects various issues for Android TV.
*/
public class AndroidTvDetector extends Detector implements Detector.XmlScanner {
private static final Implementation IMPLEMENTATION = new Implementation(
AndroidTvDetector.class,
Scope.MANIFEST_SCOPE
);
/** Using hardware unsupported by TV */
public static final Issue UNSUPPORTED_TV_HARDWARE = Issue.create(
"UnsupportedTvHardware",
"Unsupported TV Hardware Feature",
"The element should not require this unsupported TV hardware feature. " +
"Any uses-feature not explicitly marked with required=\"false\" is necessary on the " +
"device to be installed on. " +
"Ensure that any features that might prevent it from being installed on a TV device " +
"are reviewed and marked as not required in the manifest.",
Category.CORRECTNESS,
6,
Severity.ERROR,
IMPLEMENTATION).addMoreInfo(
"https://developer.android.com/training/tv/start/hardware.html#unsupported-features");
public static final Issue IMPLIED_TOUCHSCREEN_HARDWARE = Issue.create(
"ImpliedTouchscreenHardware",
"Hardware feature touchscreen not explicitly marked as optional",
"Apps require the `android.hardware.touchscreen` feature by default. If you want " +
"your app to be available on TV, you must also explicitly declare that a touchscreen " +
"is not required as follows:\n" +
"` `",
Category.CORRECTNESS,
6,
Severity.ERROR,
IMPLEMENTATION).addMoreInfo(
"https://developer.android.com/guide/topics/manifest/uses-feature-element.html");
/** Missing leanback launcher intent filter */
public static final Issue MISSING_LEANBACK_LAUNCHER = Issue.create(
"MissingLeanbackLauncher",
"Missing Leanback Launcher Intent Filter.",
"An application intended to run on TV devices must declare a launcher activity " +
"for TV in its manifest using a `android.intent.category.LEANBACK_LAUNCHER` " +
"intent filter.",
Category.CORRECTNESS,
8,
Severity.ERROR,
IMPLEMENTATION)
.addMoreInfo("https://developer.android.com/training/tv/start/start.html#tv-activity");
/** Missing leanback support */
public static final Issue MISSING_LEANBACK_SUPPORT = Issue.create(
"MissingLeanbackSupport",
"Missing Leanback Support.",
"The manifest should declare the use of the Leanback user interface required " +
"by Android TV.\n" +
"To fix this, add\n" +
"` `\n" +
"to your manifest.",
Category.CORRECTNESS,
6,
Severity.ERROR,
IMPLEMENTATION)
.addMoreInfo("https://developer.android.com/training/tv/start/start.html#leanback-req");
/** Permission implies required hardware unsupported by TV */
public static final Issue PERMISSION_IMPLIES_UNSUPPORTED_HARDWARE = Issue.create(
"PermissionImpliesUnsupportedHardware",
"Permission Implies Unsupported Hardware",
"The element should not require a permission that implies an " +
"unsupported TV hardware feature. Google Play assumes that certain hardware related " +
"permissions indicate that the underlying hardware features are required by default. " +
"To fix the issue, consider declaring the corresponding uses-feature element with " +
"required=\"false\" attribute.",
Category.CORRECTNESS,
3,
Severity.WARNING,
IMPLEMENTATION).addMoreInfo(
"http://developer.android.com/guide/topics/manifest/uses-feature-element.html#permissions");
/** Missing banner attibute */
public static final Issue MISSING_BANNER = Issue.create(
"MissingTvBanner",
"TV Missing Banner",
"A TV application must provide a home screen banner for each localization if it " +
"includes a Leanback launcher intent filter. The banner is the app launch point that " +
"appears on the home screen in the apps and games rows.",
Category.CORRECTNESS,
7,
Severity.ERROR,
IMPLEMENTATION)
.addMoreInfo("http://developer.android.com/training/tv/start/start.html#banner");
public static final String SOFTWARE_FEATURE_LEANBACK =
"android.software.leanback";
public static final String HARDWARE_FEATURE_TOUCHSCREEN =
"android.hardware.touchscreen";
private static final String LEANBACK_LIB_ARTIFACT =
"com.android.support:leanback-v17";
private static final String CATEGORY_LEANBACK_LAUNCHER =
"android.intent.category.LEANBACK_LAUNCHER";
private static final String HARDWARE_FEATURE_CAMERA = "android.hardware.camera";
private static final String HARDWARE_FEATURE_LOCATION_GPS =
"android.hardware.location.gps";
private static final String HARDWARE_FEATURE_TELEPHONY =
"android.hardware.telephony";
private static final String HARDWARE_FEATURE_BLUETOOTH =
"android.hardware.bluetooth";
private static final String ATTR_BANNER = "banner";
private static final String HARDWARE_FEATURE_MICROPHONE =
"android.hardware.microphone";
// https://developer.android.com/training/tv/start/hardware.html
private static final String[] UNSUPPORTED_HARDWARE_FEATURES = new String[]{
HARDWARE_FEATURE_TOUCHSCREEN,
"android.hardware.faketouch",
HARDWARE_FEATURE_TELEPHONY,
HARDWARE_FEATURE_CAMERA,
HARDWARE_FEATURE_BLUETOOTH,
"android.hardware.nfc",
HARDWARE_FEATURE_LOCATION_GPS,
HARDWARE_FEATURE_MICROPHONE,
"android.hardware.sensors"
};
/**
* If you change number of parameters or order, update
* {@link #getHardwareFeature(String, TextFormat)}
*/
private static final String USES_HARDWARE_ERROR_MESSAGE_FORMAT =
"Permission exists without corresponding hardware `` tag.";
/** Constructs a new {@link AndroidTvDetector} check */
public AndroidTvDetector() {
}
/** Used for {@link #MISSING_LEANBACK_LAUNCHER} */
private boolean mHasLeanbackLauncherActivity;
/** Used for {@link #MISSING_LEANBACK_SUPPORT} */
private boolean mHasLeanbackSupport;
/** Whether the app has a leanback-v7 dependency */
private boolean mHasLeanbackDependency;
/** Used for {@link #MISSING_BANNER} */
private boolean mHasApplicationBanner;
/** No. of activities that have the leanback intent but
* dont declare banners */
private int mLeanbackActivitiesWithoutBanners;
/** All permissions that imply unsupported tv hardware. */
private List mUnsupportedHardwareImpliedPermissions;
/** All Unsupported TV uses features in use by the current manifest.*/
private Set mAllUnsupportedTvUsesFeatures;
/** Set containing unsupported TV uses-features elements without required="false" */
private Set mUnsupportedTvUsesFeatures;
@Override
public Collection getApplicableElements() {
return Arrays.asList(
NODE_APPLICATION,
NODE_ACTIVITY,
NODE_ACTIVITY_ALIAS,
NODE_USES_FEATURE,
NODE_USES_PERMISSION
);
}
@Override
public void beforeCheckFile(@NonNull Context context) {
mHasLeanbackLauncherActivity = false;
mHasLeanbackSupport = false;
mHasApplicationBanner = false;
mLeanbackActivitiesWithoutBanners = 0;
mUnsupportedHardwareImpliedPermissions = Lists.newArrayListWithExpectedSize(2);
mUnsupportedTvUsesFeatures = Sets.newHashSetWithExpectedSize(2);
mAllUnsupportedTvUsesFeatures = Sets.newHashSetWithExpectedSize(2);
// Check gradle dependency
Project mainProject = context.getMainProject();
mHasLeanbackDependency = (mainProject.isGradleProject()
&& Boolean.TRUE.equals(mainProject.dependsOn(LEANBACK_LIB_ARTIFACT)));
}
@Override
public void afterCheckFile(@NonNull Context context) {
boolean isTvApp = mHasLeanbackSupport
|| mHasLeanbackDependency
|| mHasLeanbackLauncherActivity;
if (!context.getMainProject().isLibrary() && isTvApp) {
XmlContext xmlContext = (XmlContext) context;
// Report an error if there's not at least one leanback launcher intent filter activity
if (!mHasLeanbackLauncherActivity
&& xmlContext.isEnabled(MISSING_LEANBACK_LAUNCHER)) {
// No launch activity
Node manifestNode = xmlContext.document.getDocumentElement();
if (manifestNode != null) {
xmlContext.report(MISSING_LEANBACK_LAUNCHER, manifestNode,
xmlContext.getLocation(manifestNode),
"Expecting an activity to have `" + CATEGORY_LEANBACK_LAUNCHER +
"` intent filter.");
}
}
// Report an issue if there is no leanback tag.
if (!mHasLeanbackSupport
&& xmlContext.isEnabled(MISSING_LEANBACK_SUPPORT)) {
Node manifestNode = xmlContext.document.getDocumentElement();
if (manifestNode != null) {
xmlContext.report(MISSING_LEANBACK_SUPPORT, manifestNode,
xmlContext.getLocation(manifestNode),
"Expecting tag.");
}
}
// Report missing banners
if (!mHasApplicationBanner // no application banner
&& mLeanbackActivitiesWithoutBanners > 0 // leanback activity without banner
&& xmlContext.isEnabled(MISSING_BANNER)) {
Node applicationElement = getApplicationElement(xmlContext.document);
if (applicationElement != null) {
xmlContext.report(MISSING_BANNER, applicationElement,
xmlContext.getLocation(applicationElement),
"Expecting `android:banner` with the `` tag or each "
+ "Leanback launcher activity.");
}
}
// Report all unsupported TV hardware uses-feature.
// These point to all unsupported tv uses features that have not be marked
// required = false;
if (!mUnsupportedTvUsesFeatures.isEmpty()
&& xmlContext.isEnabled(UNSUPPORTED_TV_HARDWARE)) {
List usesFeatureElements =
findUsesFeatureElements(mUnsupportedTvUsesFeatures, xmlContext.document);
for (Element element : usesFeatureElements) {
Attr attrRequired = element.getAttributeNodeNS(ANDROID_URI, ATTRIBUTE_REQUIRED);
Node location = attrRequired == null ? element : attrRequired;
xmlContext.report(UNSUPPORTED_TV_HARDWARE, location,
xmlContext.getLocation(location),
"Expecting `android:required=\"false\"` for this hardware "
+ "feature that may not be supported by all Android TVs.");
}
}
// touchscreen is a special implied hardware that is enabled by default
// unless specifically marked with required="false"
if (!mAllUnsupportedTvUsesFeatures.contains(HARDWARE_FEATURE_TOUCHSCREEN)
&& xmlContext.isEnabled(IMPLIED_TOUCHSCREEN_HARDWARE)) {
Element manifestElement = xmlContext.document.getDocumentElement();
xmlContext.report(IMPLIED_TOUCHSCREEN_HARDWARE, manifestElement,
xmlContext.getLocation(manifestElement),
"Hardware feature `android.hardware.touchscreen` not explicitly marked "
+ "as optional ");
}
// Report permissions implying unsupported hardware
if (!mUnsupportedHardwareImpliedPermissions.isEmpty()
&& xmlContext.isEnabled(PERMISSION_IMPLIES_UNSUPPORTED_HARDWARE)) {
Collection filteredPermissions = Collections2.filter(
mUnsupportedHardwareImpliedPermissions,
input -> {
// Filter out all permissions that already have their
// corresponding implied hardware declared in
// the AndroidManifest.xml
String usesFeature = input != null
? getImpliedUnsupportedHardware(input) : null;
return usesFeature != null
&& !mAllUnsupportedTvUsesFeatures.contains(usesFeature);
});
List permissionsWithoutUsesFeatures =
findPermissionElements(filteredPermissions, xmlContext.document);
for (Element permissionElement : permissionsWithoutUsesFeatures) {
String name = permissionElement.getAttributeNS(ANDROID_URI, ATTR_NAME);
String unsupportedHardwareName = getImpliedUnsupportedHardware(name);
if (unsupportedHardwareName != null) {
String message = String.format(
USES_HARDWARE_ERROR_MESSAGE_FORMAT, unsupportedHardwareName);
xmlContext.report(PERMISSION_IMPLIES_UNSUPPORTED_HARDWARE,
permissionElement,
xmlContext.getLocation(permissionElement), message);
}
}
}
}
}
@Nullable
private static String getImpliedUnsupportedHardware(@NonNull String permission) {
switch (permission) {
case "android.permission.BLUETOOTH":
return HARDWARE_FEATURE_BLUETOOTH;
case "android.permission.BLUETOOTH_ADMIN":
return HARDWARE_FEATURE_BLUETOOTH;
case "android.permission.CAMERA":
return HARDWARE_FEATURE_CAMERA;
case "android.permission.RECORD_AUDIO":
return HARDWARE_FEATURE_MICROPHONE;
case "android.permission.ACCESS_FINE_LOCATION":
return HARDWARE_FEATURE_LOCATION_GPS;
case "android.permission.CALL_PHONE":
return HARDWARE_FEATURE_TELEPHONY;
case "android.permission.CALL_PRIVILEGED":
return HARDWARE_FEATURE_TELEPHONY;
case "android.permission.PROCESS_OUTGOING_CALLS":
return HARDWARE_FEATURE_TELEPHONY;
case "android.permission.READ_SMS":
return HARDWARE_FEATURE_TELEPHONY;
case "android.permission.RECEIVE_SMS":
return HARDWARE_FEATURE_TELEPHONY;
case "android.permission.RECEIVE_MMS":
return HARDWARE_FEATURE_TELEPHONY;
case "android.permission.RECEIVE_WAP_PUSH":
return HARDWARE_FEATURE_TELEPHONY;
case "android.permission.SEND_SMS":
return HARDWARE_FEATURE_TELEPHONY;
case "android.permission.WRITE_APN_SETTINGS":
return HARDWARE_FEATURE_TELEPHONY;
case "android.permission.WRITE_SMS":
return HARDWARE_FEATURE_TELEPHONY;
default:
return null;
}
}
static List findPermissionElements(Collection permissions,
Document document) {
Node manifestElement = document.getDocumentElement();
if (manifestElement == null) {
return Collections.emptyList();
}
List nodes = new ArrayList<>(permissions.size());
for (Element child : LintUtils.getChildren(manifestElement)) {
if (TAG_USES_PERMISSION.equals(child.getTagName())
&& permissions.contains(child.getAttributeNS(ANDROID_URI, ATTR_NAME))) {
nodes.add(child);
}
}
return nodes;
}
/**
* Method to find all matching uses-feature elements in one go.
* Rather than iterating over the entire list of child nodes only to return the one that
* match a particular featureName, we use this method to iterate and return all the
* uses-feature elements of interest in a single iteration of the manifest element's children.
*
* @param featureNames The set of all features to look for inside the
* <manifest>
node of the document.
* @param document The document/root node to use for iterating.
* @return A list of all <uses-feature>
elements that match the featureNames.
*/
static List findUsesFeatureElements(@NonNull Set featureNames,
@NonNull Document document) {
Node manifestElement = document.getDocumentElement();
if (manifestElement == null) {
return Collections.emptyList();
}
List nodes = new ArrayList<>(featureNames.size());
for (Element child : LintUtils.getChildren(manifestElement)) {
if (TAG_USES_FEATURE.equals(child.getTagName())
&& featureNames.contains(child.getAttributeNS(ANDROID_URI, ATTR_NAME))) {
nodes.add(child);
}
}
return nodes;
}
/**
* @param document The root of the document.
* @return The Node pointing to the {@link com.android.xml.AndroidManifest#NODE_APPLICATION}
* of the document.
*/
private static Node getApplicationElement(Document document) {
Node manifestNode = document.getDocumentElement();
if (manifestNode != null) {
return getElementWithTagName(NODE_APPLICATION, manifestNode);
}
return null;
}
@Override
public void visitElement(@NonNull XmlContext context, @NonNull Element element) {
String elementName = element.getTagName();
if (NODE_APPLICATION.equals(elementName)) {
mHasApplicationBanner = element.hasAttributeNS(ANDROID_URI, ATTR_BANNER);
} else if (NODE_USES_FEATURE.equals(elementName)) {
// Ensures that unsupported hardware features aren't required.
Attr name = element.getAttributeNodeNS(ANDROID_URI, ATTR_NAME);
if (name != null) {
String featureName = name.getValue();
if (isUnsupportedHardwareFeature(featureName)) {
mAllUnsupportedTvUsesFeatures.add(featureName);
Attr required =
element.getAttributeNodeNS(ANDROID_URI, ATTRIBUTE_REQUIRED);
if (required == null || Boolean.parseBoolean(required.getValue())) {
mUnsupportedTvUsesFeatures.add(featureName);
}
}
}
if (!mHasLeanbackSupport && hasLeanbackSupport(element)) {
mHasLeanbackSupport = true;
}
} else if ((NODE_ACTIVITY.equals(elementName)
|| NODE_ACTIVITY_ALIAS.equals(elementName))
&& hasLeanbackIntentFilter(element)) {
mHasLeanbackLauncherActivity = true;
// Since this activity has a leanback launcher intent filter,
// Make sure it has a home screen banner
if (!element.hasAttributeNS(ANDROID_URI, ATTR_BANNER)) {
mLeanbackActivitiesWithoutBanners++;
}
} else if (NODE_USES_PERMISSION.equals(elementName)) {
// Store all tags that imply unsupported hardware)
String permissionName = element.getAttributeNS(ANDROID_URI, ATTR_NAME);
if (getImpliedUnsupportedHardware(permissionName) != null) {
mUnsupportedHardwareImpliedPermissions.add(permissionName);
}
}
}
private static boolean hasLeanbackSupport(Element element) {
assert NODE_USES_FEATURE.equals(element.getTagName()) : element.getTagName();
return SOFTWARE_FEATURE_LEANBACK.equals(element.getAttributeNS(ANDROID_URI, ATTR_NAME));
}
private static boolean isUnsupportedHardwareFeature(@NonNull String featureName) {
for (String prefix : UNSUPPORTED_HARDWARE_FEATURES) {
if (featureName.startsWith(prefix)) {
return true;
}
}
return false;
}
private static boolean hasLeanbackIntentFilter(@NonNull Node activityNode) {
// Visit every intent filter
for (Element activityChild : LintUtils.getChildren(activityNode)) {
if (NODE_INTENT.equals(activityChild.getNodeName())) {
for (Element intentFilterChild : LintUtils.getChildren(activityChild)) {
// Check to see if the category is the leanback launcher
String attrName = intentFilterChild.getAttributeNS(ANDROID_URI, ATTR_NAME);
if (NODE_CATEGORY.equals(intentFilterChild.getNodeName())
&& CATEGORY_LEANBACK_LAUNCHER.equals(attrName)) {
return true;
}
}
}
}
return false;
}
/**
* Assumes that the node is a direct child of the given Node.
*/
private static Node getElementWithTagName(@NonNull String tagName, @NonNull Node node) {
for (Element child : LintUtils.getChildren(node)) {
if (tagName.equals(child.getTagName())) {
return child;
}
}
return null;
}
/**
* Given an error message created by this lint check, return the corresponding featureName
* that it suggests should be added.
* (Intended to support quickfix implementations for this lint check.)
*
* @param errorMessage The error message originally produced by this detector.
* @param format The format of the error message.
* @return the corresponding featureName, or null if not recognized
*/
@SuppressWarnings("unused") // Used by the IDE
@Nullable
public static String getHardwareFeature(@NonNull String errorMessage,
@NonNull TextFormat format) {
List parameters = LintUtils.getFormattedParameters(
RAW.convertTo(USES_HARDWARE_ERROR_MESSAGE_FORMAT, format),
errorMessage);
if (parameters.size() == 1) {
return parameters.get(0);
}
return null;
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy