Support the rich content for accessibility service (1/n)

Goal: 1. let third party developer can use html text that include from their local image file and
         animated image instead of only plain text to rich their content.
      2. Avoid malicious links made by third party developer

Action: 1. Add html, static text, and animated image preferences.
        2. Add android:AnimatedImageDrawable, and android:htmlDescription attributes.
        3. Fine-tune interface and integration
        4. Add custom tag filter

Bug: 136292241
Test: Maunal & make RunSettingsRoboTests
Change-Id: I82cd5319efb7faa1ff7e8354a279828fce5135b8
This commit is contained in:
Peter_Liang
2019-10-31 11:45:16 +08:00
parent d7ba70ee54
commit 64f1374c64
11 changed files with 555 additions and 10 deletions

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2019 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.
-->
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:importantForAccessibility="noHideDescendants">
<ImageView
android:id="@+id/animated_img"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:focusable="false"
android:clickable="false"
android:adjustViewBounds="true"/>
</FrameLayout>

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2019 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.
-->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?android:attr/listPreferredItemHeight"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:background="?android:attr/selectableItemBackground"
android:clipToPadding="false">
<FrameLayout
android:id="@+id/icon_frame"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="56dp"
android:paddingEnd="12dp"
android:paddingTop="16dp"
android:paddingBottom="4dp">
<ImageView
android:id="@+android:id/icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</FrameLayout>
<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingBottom="16dp"
android:paddingTop="16dp">
<TextView android:id="@+android:id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<TextView android:id="@+android:id/summary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@android:id/title"
android:layout_alignStart="@android:id/title"
android:textColor="?android:attr/textColorSecondary"/>
</RelativeLayout>
<LinearLayout android:id="@+android:id/widget_frame"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="vertical" />
</LinearLayout>

View File

@@ -164,7 +164,10 @@ public class AccessibilityDetailsSettingsFragment extends InstrumentedFragment {
new ComponentName(packageName, settingsClassName).flattenToString());
}
extras.putParcelable(AccessibilitySettings.EXTRA_COMPONENT_NAME, componentName);
extras.putInt(AccessibilitySettings.EXTRA_ANIMATED_IMAGE_RES, info.getAnimatedImageRes());
final String htmlDescription = info.loadHtmlDescription(getActivity().getPackageManager());
extras.putString(AccessibilitySettings.EXTRA_HTML_DESCRIPTION, htmlDescription);
return extras;
}

View File

@@ -111,6 +111,8 @@ public class AccessibilitySettings extends DashboardFragment {
static final String EXTRA_SETTINGS_COMPONENT_NAME = "settings_component_name";
static final String EXTRA_VIDEO_RAW_RESOURCE_ID = "video_resource";
static final String EXTRA_LAUNCHED_FROM_SUW = "from_suw";
static final String EXTRA_ANIMATED_IMAGE_RES = "animated_image_res";
static final String EXTRA_HTML_DESCRIPTION = "html_description";
// Timeout before we update the services if packages are added/removed
// since the AccessibilityManagerService has to do that processing first
@@ -409,6 +411,10 @@ public class AccessibilitySettings extends DashboardFragment {
extras.putString(EXTRA_TITLE, title);
extras.putParcelable(EXTRA_RESOLVE_INFO, resolveInfo);
extras.putString(EXTRA_SUMMARY, description);
extras.putInt(EXTRA_ANIMATED_IMAGE_RES, info.getAnimatedImageRes());
final String htmlDescription = info.loadHtmlDescription(getPackageManager());
extras.putString(AccessibilitySettings.EXTRA_HTML_DESCRIPTION, htmlDescription);
final String settingsClassName = info.getSettingsActivityName();
if (!TextUtils.isEmpty(settingsClassName)) {

View File

@@ -169,6 +169,9 @@ public class AccessibilitySettingsForSetupWizard extends SettingsPreferenceFragm
description = getString(R.string.accessibility_service_default_description);
}
extras.putString(AccessibilitySettings.EXTRA_SUMMARY, description);
final String htmlDescription = info.loadHtmlDescription(getPackageManager());
extras.putString(AccessibilitySettings.EXTRA_HTML_DESCRIPTION, htmlDescription);
}
private static void configureMagnificationPreferenceIfNeeded(Preference preference) {

View File

@@ -0,0 +1,85 @@
/*
* Copyright (C) 2019 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.settings.accessibility;
import android.content.Context;
import android.graphics.drawable.AnimatedImageDrawable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.widget.ImageView;
import androidx.preference.Preference;
import androidx.preference.PreferenceViewHolder;
import com.android.settings.R;
/**
* A custom {@link ImageView} preference for showing animated or static image, such as <a
* href="https://developers.google.com/speed/webp/">animated webp</a> and static png.
*/
public class AnimatedImagePreference extends Preference {
private boolean mDividerAllowedAbove = false;
private Uri mImageUri;
AnimatedImagePreference(Context context) {
super(context);
setLayoutResource(R.layout.preference_animated_image);
}
@Override
public void onBindViewHolder(PreferenceViewHolder holder) {
super.onBindViewHolder(holder);
holder.setDividerAllowedAbove(mDividerAllowedAbove);
final ImageView imageView = holder.itemView.findViewById(R.id.animated_img);
if (imageView != null && mImageUri != null) {
imageView.setImageURI(mImageUri);
final Drawable drawable = imageView.getDrawable();
if (drawable != null) {
if (drawable instanceof AnimatedImageDrawable) {
((AnimatedImageDrawable) drawable).start();
}
}
}
}
/**
* Sets divider whether to show in preference above.
*
* @param allowed true will be drawn on above this item
*/
public void setDividerAllowedAbove(boolean allowed) {
if (allowed != mDividerAllowedAbove) {
mDividerAllowedAbove = allowed;
notifyChanged();
}
}
/**
* Set image uri to display image in {@link ImageView}
*
* @param imageUri the Uri of an image
*/
public void setImageUri(Uri imageUri) {
if (imageUri != null && !imageUri.equals(mImageUri)) {
mImageUri = imageUri;
notifyChanged();
}
}
}

View File

@@ -0,0 +1,139 @@
/*
* Copyright (C) 2019 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.settings.accessibility;
import android.content.Context;
import android.text.Html;
import android.text.TextUtils;
import android.widget.TextView;
import androidx.preference.PreferenceViewHolder;
import java.util.List;
import java.util.regex.Pattern;
/**
* A custom {@link android.widget.TextView} preference that shows html text with a custom tag
* filter.
*/
public final class HtmlTextPreference extends StaticTextPreference {
private boolean mDividerAllowedAbove = false;
private int mFlag = Html.FROM_HTML_MODE_COMPACT;
private Html.ImageGetter mImageGetter;
private Html.TagHandler mTagHandler;
private List<String> mUnsupportedTagList;
HtmlTextPreference(Context context) {
super(context);
}
@Override
public void onBindViewHolder(PreferenceViewHolder holder) {
super.onBindViewHolder(holder);
holder.setDividerAllowedAbove(mDividerAllowedAbove);
final TextView summaryView = holder.itemView.findViewById(android.R.id.summary);
if (summaryView != null && !TextUtils.isEmpty(getSummary())) {
final String filteredText = getFilteredText(getSummary().toString());
summaryView.setText(Html.fromHtml(filteredText, mFlag, mImageGetter, mTagHandler));
}
}
/**
* Sets divider whether to show in preference above.
*
* @param allowed true will be drawn on above this item
*/
public void setDividerAllowedAbove(boolean allowed) {
if (allowed != mDividerAllowedAbove) {
mDividerAllowedAbove = allowed;
notifyChanged();
}
}
/**
* Sets the flag to which text format to be applied.
*
* @param flag to indicate that html text format
*/
public void setFlag(int flag) {
if (flag != mFlag) {
mFlag = flag;
notifyChanged();
}
}
/**
* Sets image getter and help to load corresponding images when parsing.
*
* @param imageGetter to load image by image tag content
*/
public void setImageGetter(Html.ImageGetter imageGetter) {
if (imageGetter != null && !imageGetter.equals(mImageGetter)) {
mImageGetter = imageGetter;
notifyChanged();
}
}
/**
* Sets tag handler to handle the unsupported tags.
*
* @param tagHandler the handler for unhandled tags
*/
public void setTagHandler(Html.TagHandler tagHandler) {
if (tagHandler != null && !tagHandler.equals(mTagHandler)) {
mTagHandler = tagHandler;
notifyChanged();
}
}
/**
* Sets unsupported tag list, the text will be filtered though this list in advanced.
*
* @param unsupportedTagList the list of unsupported tags
*/
public void setUnsupportedTagList(List<String> unsupportedTagList) {
if (unsupportedTagList != null && !unsupportedTagList.equals(mUnsupportedTagList)) {
mUnsupportedTagList = unsupportedTagList;
notifyChanged();
}
}
private String getFilteredText(String text) {
if (mUnsupportedTagList == null) {
return text;
}
int i = 1;
for (String tag : mUnsupportedTagList) {
if (!TextUtils.isEmpty(text)) {
final String index = String.valueOf(i++);
final String targetStart1 = "(?i)<" + tag + " ";
final String targetStart2 = "(?i)<" + tag + ">";
final String replacementStart1 = "<unsupportedtag" + index + " ";
final String replacementStart2 = "<unsupportedtag" + index + ">";
final String targetEnd = "(?i)</" + tag + ">";
final String replacementEnd = "</unsupportedtag" + index + ">";
text = Pattern.compile(targetStart1).matcher(text).replaceAll(replacementStart1);
text = Pattern.compile(targetStart2).matcher(text).replaceAll(replacementStart2);
text = Pattern.compile(targetEnd).matcher(text).replaceAll(replacementEnd);
}
}
return text;
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (C) 2019 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.settings.accessibility;
import android.content.Context;
import androidx.preference.Preference;
import androidx.preference.PreferenceViewHolder;
import com.android.settings.R;
/**
* A custom {@link android.widget.TextView} preference that removes the title and summary
* restriction from platform {@link Preference} implementation and the icon location is kept as
* gravity top instead of center.
*/
public class StaticTextPreference extends Preference {
StaticTextPreference(Context context) {
super(context);
setLayoutResource(R.layout.preference_static_text);
}
@Override
public void onBindViewHolder(PreferenceViewHolder holder) {
super.onBindViewHolder(holder);
}
}

View File

@@ -24,6 +24,7 @@ import android.app.Dialog;
import android.app.admin.DevicePolicyManager;
import android.app.settings.SettingsEnums;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
@@ -69,8 +70,6 @@ public class ToggleAccessibilityServicePreferenceFragment extends ToggleFeatureP
}
};
private ComponentName mComponentName;
private Dialog mDialog;
private final View.OnClickListener mViewOnClickListener =
@@ -341,5 +340,15 @@ public class ToggleAccessibilityServicePreferenceFragment extends ToggleFeatureP
}
mComponentName = arguments.getParcelable(AccessibilitySettings.EXTRA_COMPONENT_NAME);
// Settings animated image.
int animatedImageRes = arguments.getInt(AccessibilitySettings.EXTRA_ANIMATED_IMAGE_RES);
mImageUri = new Uri.Builder().scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
.authority(mComponentName.getPackageName())
.appendPath(String.valueOf(animatedImageRes))
.build();
// Settings html description.
mHtmlDescription = arguments.getCharSequence(AccessibilitySettings.EXTRA_HTML_DESCRIPTION);
}
}

View File

@@ -16,10 +16,16 @@
package com.android.settings.accessibility;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Intent;
import android.content.pm.ResolveInfo;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.text.Html;
import android.view.View;
import android.widget.ImageView;
import androidx.preference.Preference;
import androidx.preference.PreferenceScreen;
@@ -29,7 +35,9 @@ import com.android.settings.SettingsActivity;
import com.android.settings.SettingsPreferenceFragment;
import com.android.settings.widget.SwitchBar;
import com.android.settings.widget.ToggleSwitch;
import com.android.settingslib.widget.FooterPreference;
import java.util.ArrayList;
import java.util.List;
public abstract class ToggleFeaturePreferenceFragment extends SettingsPreferenceFragment {
@@ -40,6 +48,30 @@ public abstract class ToggleFeaturePreferenceFragment extends SettingsPreference
protected CharSequence mSettingsTitle;
protected Intent mSettingsIntent;
protected ComponentName mComponentName;
protected Uri mImageUri;
protected CharSequence mStaticDescription;
protected CharSequence mHtmlDescription;
private static final String ANCHOR_TAG = "a";
private static final String DRAWABLE_FOLDER = "drawable";
// For html description of accessibility service, third party developer must follow the rule,
// such as <img src="R.drawable.fileName"/>, a11y settings will get third party resources
// by this.
private static final String IMG_PREFIX = "R.drawable.";
private ImageView mImageGetterCacheView;
private final Html.ImageGetter mImageGetter = (String str) -> {
if (str != null && str.startsWith(IMG_PREFIX)) {
final String fileName = str.substring(IMG_PREFIX.length());
return getDrawableFromUri(Uri.parse(
ContentResolver.SCHEME_ANDROID_RESOURCE + "://"
+ mComponentName.getPackageName() + "/" + DRAWABLE_FOLDER + "/"
+ fileName));
}
return null;
};
@Override
public void onCreate(Bundle savedInstanceState) {
@@ -63,15 +95,45 @@ public abstract class ToggleFeaturePreferenceFragment extends SettingsPreference
onProcessArguments(getArguments());
updateSwitchBarText(mSwitchBar);
PreferenceScreen preferenceScreen = getPreferenceScreen();
// Show the "Settings" menu as if it were a preference screen
if (mSettingsTitle != null && mSettingsIntent != null) {
PreferenceScreen preferenceScreen = getPreferenceScreen();
Preference settingsPref = new Preference(preferenceScreen.getContext());
settingsPref.setTitle(mSettingsTitle);
settingsPref.setIconSpaceReserved(true);
settingsPref.setIntent(mSettingsIntent);
preferenceScreen.addPreference(settingsPref);
}
if (mImageUri != null) {
final AnimatedImagePreference animatedImagePreference = new AnimatedImagePreference(
preferenceScreen.getContext());
animatedImagePreference.setImageUri(mImageUri);
animatedImagePreference.setDividerAllowedAbove(true);
preferenceScreen.addPreference(animatedImagePreference);
}
if (mStaticDescription != null) {
final StaticTextPreference staticTextPreference = new StaticTextPreference(
preferenceScreen.getContext());
staticTextPreference.setSummary(mStaticDescription);
preferenceScreen.addPreference(staticTextPreference);
}
if (mHtmlDescription != null) {
// For accessibility service, avoid malicious links made by third party developer
final List<String> unsupportedTagList = new ArrayList<>();
unsupportedTagList.add(ANCHOR_TAG);
final HtmlTextPreference htmlTextPreference = new HtmlTextPreference(
preferenceScreen.getContext());
htmlTextPreference.setSummary(mHtmlDescription);
htmlTextPreference.setImageGetter(mImageGetter);
htmlTextPreference.setUnsupportedTagList(unsupportedTagList);
htmlTextPreference.setDividerAllowedAbove(true);
preferenceScreen.addPreference(htmlTextPreference);
}
}
@Override
@@ -139,17 +201,30 @@ public abstract class ToggleFeaturePreferenceFragment extends SettingsPreference
// Summary.
if (arguments.containsKey(AccessibilitySettings.EXTRA_SUMMARY_RES)) {
final int summary = arguments.getInt(AccessibilitySettings.EXTRA_SUMMARY_RES);
createFooterPreference(getText(summary));
mStaticDescription = getText(summary);
} else if (arguments.containsKey(AccessibilitySettings.EXTRA_SUMMARY)) {
final CharSequence summary = arguments.getCharSequence(
AccessibilitySettings.EXTRA_SUMMARY);
createFooterPreference(summary);
mStaticDescription = summary;
}
}
private void createFooterPreference(CharSequence title) {
final PreferenceScreen preferenceScreen = getPreferenceScreen();
preferenceScreen.addPreference(new FooterPreference.Builder(getActivity()).setTitle(
title).build());
private Drawable getDrawableFromUri(Uri imageUri) {
if (mImageGetterCacheView == null) {
mImageGetterCacheView = new ImageView(getContext());
}
mImageGetterCacheView.setAdjustViewBounds(true);
mImageGetterCacheView.setImageURI(imageUri);
final Drawable drawable = mImageGetterCacheView.getDrawable().mutate();
if (drawable != null) {
drawable.setBounds(/* left= */0, /* top= */0, drawable.getIntrinsicWidth(),
drawable.getIntrinsicHeight());
}
mImageGetterCacheView.setImageURI(null);
mImageGetterCacheView.setImageDrawable(null);
return drawable;
}
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright (C) 2019 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.settings.accessibility;
import static com.google.common.truth.Truth.assertThat;
import android.content.Context;
import android.text.Editable;
import android.text.Html;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.TextView;
import androidx.preference.PreferenceViewHolder;
import com.android.settings.R;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.xml.sax.XMLReader;
import java.util.ArrayList;
import java.util.List;
/** Tests for {@link HtmlTextPreference} */
@RunWith(RobolectricTestRunner.class)
public final class HtmlTextPreferenceTest {
private HtmlTextPreference mHtmlTextPreference;
private PreferenceViewHolder mPreferenceViewHolder;
private String mHandledTag;
private final Html.TagHandler mTagHandler = new Html.TagHandler() {
@Override
public void handleTag(boolean opening, String tag, Editable editable, XMLReader xmlReader) {
mHandledTag = tag;
}
};
@Before
public void setUp() {
final Context context = RuntimeEnvironment.application;
mHtmlTextPreference = new HtmlTextPreference(context);
final LayoutInflater inflater = LayoutInflater.from(context);
final View view =
inflater.inflate(R.layout.preference_static_text, null);
mPreferenceViewHolder = PreferenceViewHolder.createInstanceForTests(view);
}
@Test
public void testUnsupportedTagList_keepRealContentWithoutTag() {
final List<String> testUnsupportedTagList = new ArrayList<>();
testUnsupportedTagList.add("testTag");
final String testStr = "<testTag>Real description</testTag>";
final String expectedStr = "Real description";
final String expectedTag = "unsupportedtag1";
mHtmlTextPreference.setUnsupportedTagList(testUnsupportedTagList);
mHtmlTextPreference.setSummary(testStr);
mHtmlTextPreference.setTagHandler(mTagHandler);
mHtmlTextPreference.onBindViewHolder(mPreferenceViewHolder);
final TextView summaryView = mPreferenceViewHolder.itemView.findViewById(
android.R.id.summary);
assertThat(summaryView.getText().toString()).isEqualTo(expectedStr);
assertThat(mHandledTag).isEqualTo(expectedTag);
}
}