summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMaurice Lam <yukl@google.com>2016-02-25 10:46:15 -0800
committerMaurice Lam <yukl@google.com>2016-03-28 19:53:32 -0700
commitd832154e333a3a45b5faecd518b664ddd297183c (patch)
tree742dd5c07865bf712557efed9875f7dadb8de440
parenteb85a47ae17061a4ed20d5a5fba4c61e24f17013 (diff)
downloadsetupwizard-d832154e333a3a45b5faecd518b664ddd297183c.tar.gz
[SuwLib] Upstream LinkSpan and AnnotatedTextView
Upstream LinkSpan and AnnotatedTextView to easily create accessibility-friendly rich text TextViews with links. Bug: 27886391 Change-Id: I20137fb454c4b9d820263c8ce326380c1db2ef20
-rw-r--r--library/eclair-mr1/src/com/android/setupwizardlib/util/LinkAccessibilityHelper.java224
-rw-r--r--library/eclair-mr1/src/com/android/setupwizardlib/view/RichTextView.java118
-rw-r--r--library/eclair-mr1/test/src/com/android/setupwizardlib/test/LinkAccessibilityHelperTest.java150
-rw-r--r--library/eclair-mr1/test/src/com/android/setupwizardlib/test/RichTextViewTest.java75
-rw-r--r--library/main/src/com/android/setupwizardlib/span/LinkSpan.java87
-rw-r--r--library/main/src/com/android/setupwizardlib/span/SpanHelper.java36
-rw-r--r--library/test/src/com/android/setupwizardlib/test/LinkSpanTest.java88
-rw-r--r--library/test/src/com/android/setupwizardlib/test/SpanHelperTest.java41
8 files changed, 819 insertions, 0 deletions
diff --git a/library/eclair-mr1/src/com/android/setupwizardlib/util/LinkAccessibilityHelper.java b/library/eclair-mr1/src/com/android/setupwizardlib/util/LinkAccessibilityHelper.java
new file mode 100644
index 0000000..2c53ee7
--- /dev/null
+++ b/library/eclair-mr1/src/com/android/setupwizardlib/util/LinkAccessibilityHelper.java
@@ -0,0 +1,224 @@
+/*
+ * Copyright (C) 2016 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.setupwizardlib.util;
+
+import android.graphics.Rect;
+import android.os.Bundle;
+import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
+import android.support.v4.widget.ExploreByTouchHelper;
+import android.text.Layout;
+import android.text.Spanned;
+import android.text.style.ClickableSpan;
+import android.util.Log;
+import android.view.accessibility.AccessibilityEvent;
+import android.widget.TextView;
+
+import java.util.List;
+
+/**
+ * An accessibility delegate that allows {@link android.text.style.ClickableSpan} to be focused and
+ * clicked by accessibility services.
+ *
+ * <p />Sample usage:
+ * <pre>
+ * LinkAccessibilityHelper mAccessibilityHelper;
+ *
+ * private void init() {
+ * mAccessibilityHelper = new LinkAccessibilityHelper(myTextView);
+ * ViewCompat.setAccessibilityDelegate(myTextView, mLinkHelper);
+ * }
+ *
+ * {@literal @}Override
+ * protected boolean dispatchHoverEvent({@literal @}NonNull MotionEvent event) {
+ * if (mAccessibilityHelper != null && mAccessibilityHelper.dispatchHoverEvent(event)) {
+ * return true;
+ * }
+ * return super.dispatchHoverEvent(event);
+ * }
+ * </pre>
+ *
+ * @see com.android.setupwizardlib.view.RichTextView
+ * @see android.support.v4.widget.ExploreByTouchHelper
+ */
+public class LinkAccessibilityHelper extends ExploreByTouchHelper {
+
+ private static final String TAG = "LinkAccessibilityHelper";
+
+ private final TextView mView;
+ private final Rect mTempRect = new Rect();
+
+ public LinkAccessibilityHelper(TextView view) {
+ super(view);
+ mView = view;
+ }
+
+ @Override
+ protected int getVirtualViewAt(float x, float y) {
+ final CharSequence text = mView.getText();
+ if (text instanceof Spanned) {
+ final Spanned spannedText = (Spanned) text;
+ final int offset = getOffsetForPosition(mView, x, y);
+ ClickableSpan[] linkSpans = spannedText.getSpans(offset, offset, ClickableSpan.class);
+ if (linkSpans.length == 1) {
+ ClickableSpan linkSpan = linkSpans[0];
+ return spannedText.getSpanStart(linkSpan);
+ }
+ }
+ return INVALID_ID;
+ }
+
+ @Override
+ protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {
+ final CharSequence text = mView.getText();
+ if (text instanceof Spanned) {
+ final Spanned spannedText = (Spanned) text;
+ ClickableSpan[] linkSpans = spannedText.getSpans(0, spannedText.length(),
+ ClickableSpan.class);
+ for (ClickableSpan span : linkSpans) {
+ virtualViewIds.add(spannedText.getSpanStart(span));
+ }
+ }
+ }
+
+ @Override
+ protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) {
+ final ClickableSpan span = getSpanForOffset(virtualViewId);
+ if (span != null) {
+ event.setContentDescription(getTextForSpan(span));
+ } else {
+ Log.e(TAG, "LinkSpan is null for offset: " + virtualViewId);
+ event.setContentDescription(mView.getText());
+ }
+ }
+
+ @Override
+ protected void onPopulateNodeForVirtualView(int virtualViewId,
+ AccessibilityNodeInfoCompat info) {
+ final ClickableSpan span = getSpanForOffset(virtualViewId);
+ if (span != null) {
+ info.setContentDescription(getTextForSpan(span));
+ } else {
+ Log.e(TAG, "LinkSpan is null for offset: " + virtualViewId);
+ info.setContentDescription(mView.getText());
+ }
+ info.setFocusable(true);
+ info.setClickable(true);
+ getBoundsForSpan(span, mTempRect);
+ if (!mTempRect.isEmpty()) {
+ info.setBoundsInParent(getBoundsForSpan(span, mTempRect));
+ } else {
+ Log.e(TAG, "LinkSpan bounds is empty for: " + virtualViewId);
+ mTempRect.set(0, 0, 1, 1);
+ info.setBoundsInParent(mTempRect);
+ }
+ info.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK);
+ }
+
+ @Override
+ protected boolean onPerformActionForVirtualView(int virtualViewId, int action,
+ Bundle arguments) {
+ if (action == AccessibilityNodeInfoCompat.ACTION_CLICK) {
+ ClickableSpan span = getSpanForOffset(virtualViewId);
+ if (span != null) {
+ span.onClick(mView);
+ return true;
+ } else {
+ Log.e(TAG, "LinkSpan is null for offset: " + virtualViewId);
+ }
+ }
+ return false;
+ }
+
+ private ClickableSpan getSpanForOffset(int offset) {
+ CharSequence text = mView.getText();
+ if (text instanceof Spanned) {
+ Spanned spannedText = (Spanned) text;
+ ClickableSpan[] spans = spannedText.getSpans(offset, offset, ClickableSpan.class);
+ if (spans.length == 1) {
+ return spans[0];
+ }
+ }
+ return null;
+ }
+
+ private CharSequence getTextForSpan(ClickableSpan span) {
+ CharSequence text = mView.getText();
+ if (text instanceof Spanned) {
+ Spanned spannedText = (Spanned) text;
+ return spannedText.subSequence(spannedText.getSpanStart(span),
+ spannedText.getSpanEnd(span));
+ }
+ return text;
+ }
+
+ // Find the bounds of a span. If it spans multiple lines, it will only return the bounds for the
+ // section on the first line.
+ private Rect getBoundsForSpan(ClickableSpan span, Rect outRect) {
+ CharSequence text = mView.getText();
+ outRect.setEmpty();
+ if (text instanceof Spanned) {
+ Spanned spannedText = (Spanned) text;
+ final int spanStart = spannedText.getSpanStart(span);
+ final int spanEnd = spannedText.getSpanEnd(span);
+ final Layout layout = mView.getLayout();
+ final float xStart = layout.getPrimaryHorizontal(spanStart);
+ final float xEnd = layout.getPrimaryHorizontal(spanEnd);
+ final int lineStart = layout.getLineForOffset(spanStart);
+ final int lineEnd = layout.getLineForOffset(spanEnd);
+ layout.getLineBounds(lineStart, outRect);
+ outRect.left = (int) xStart;
+ if (lineEnd == lineStart) {
+ outRect.right = (int) xEnd;
+ } // otherwise just leave it at the end of the start line
+
+ // Offset for padding
+ outRect.offset(mView.getTotalPaddingLeft(), mView.getTotalPaddingTop());
+ }
+ return outRect;
+ }
+
+ // Compat implementation of TextView#getOffsetForPosition().
+
+ private static int getOffsetForPosition(TextView view, float x, float y) {
+ if (view.getLayout() == null) return -1;
+ final int line = getLineAtCoordinate(view, y);
+ return getOffsetAtCoordinate(view, line, x);
+ }
+
+ private static float convertToLocalHorizontalCoordinate(TextView view, float x) {
+ x -= view.getTotalPaddingLeft();
+ // Clamp the position to inside of the view.
+ x = Math.max(0.0f, x);
+ x = Math.min(view.getWidth() - view.getTotalPaddingRight() - 1, x);
+ x += view.getScrollX();
+ return x;
+ }
+
+ private static int getLineAtCoordinate(TextView view, float y) {
+ y -= view.getTotalPaddingTop();
+ // Clamp the position to inside of the view.
+ y = Math.max(0.0f, y);
+ y = Math.min(view.getHeight() - view.getTotalPaddingBottom() - 1, y);
+ y += view.getScrollY();
+ return view.getLayout().getLineForVertical((int) y);
+ }
+
+ private static int getOffsetAtCoordinate(TextView view, int line, float x) {
+ x = convertToLocalHorizontalCoordinate(view, x);
+ return view.getLayout().getOffsetForHorizontal(line, x);
+ }
+}
diff --git a/library/eclair-mr1/src/com/android/setupwizardlib/view/RichTextView.java b/library/eclair-mr1/src/com/android/setupwizardlib/view/RichTextView.java
new file mode 100644
index 0000000..d79d149
--- /dev/null
+++ b/library/eclair-mr1/src/com/android/setupwizardlib/view/RichTextView.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2016 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.setupwizardlib.view;
+
+import android.content.Context;
+import android.support.v4.view.ViewCompat;
+import android.text.Annotation;
+import android.text.SpannableString;
+import android.text.Spanned;
+import android.text.method.LinkMovementMethod;
+import android.text.style.TextAppearanceSpan;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.MotionEvent;
+import android.widget.TextView;
+
+import com.android.setupwizardlib.span.LinkSpan;
+import com.android.setupwizardlib.span.SpanHelper;
+import com.android.setupwizardlib.util.LinkAccessibilityHelper;
+
+/**
+ * An extension of TextView that automatically replaces the annotation tags as specified in
+ * {@link SpanHelper#replaceSpan(android.text.Spannable, Object, Object)}
+ */
+public class RichTextView extends TextView {
+
+ /* static section */
+
+ private static final String TAG = "RichTextView";
+
+ private static final String ANNOTATION_LINK = "link";
+ private static final String ANNOTATION_TEXT_APPEARANCE = "textAppearance";
+
+ /**
+ * Replace &lt;annotation&gt; tags in strings to become their respective types. Currently 2
+ * types are supported:
+ * <ol>
+ * <li>&lt;annotation link="foobar"&gt; will create a
+ * {@link com.android.setupwizardlib.span.LinkSpan} that broadcasts with the key
+ * "foobar"</li>
+ * <li>&lt;annotation textAppearance="TextAppearance.FooBar"&gt; will create a
+ * {@link android.text.style.TextAppearanceSpan} with @style/TextAppearance.FooBar</li>
+ * </ol>
+ */
+ public static CharSequence getRichText(Context context, CharSequence text) {
+ if (text instanceof Spanned) {
+ final SpannableString spannable = new SpannableString(text);
+ final Annotation[] spans = spannable.getSpans(0, spannable.length(), Annotation.class);
+ for (Annotation span : spans) {
+ final String key = span.getKey();
+ if (ANNOTATION_TEXT_APPEARANCE.equals(key)) {
+ String textAppearance = span.getValue();
+ final int style = context.getResources()
+ .getIdentifier(textAppearance, "style", context.getPackageName());
+ if (style == 0) {
+ Log.w(TAG, "Cannot find resource: " + style);
+ }
+ final TextAppearanceSpan textAppearanceSpan =
+ new TextAppearanceSpan(context, style);
+ SpanHelper.replaceSpan(spannable, span, textAppearanceSpan);
+ } else if (ANNOTATION_LINK.equals(key)) {
+ LinkSpan link = new LinkSpan(span.getValue());
+ SpanHelper.replaceSpan(spannable, span, link);
+ }
+ }
+ return spannable;
+ }
+ return text;
+ }
+
+ /* non-static section */
+
+ private LinkAccessibilityHelper mAccessibilityHelper;
+
+ public RichTextView(Context context) {
+ super(context);
+ init();
+ }
+
+ public RichTextView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ private void init() {
+ mAccessibilityHelper = new LinkAccessibilityHelper(this);
+ ViewCompat.setAccessibilityDelegate(this, mAccessibilityHelper);
+ setMovementMethod(LinkMovementMethod.getInstance());
+ }
+
+ @Override
+ public void setText(CharSequence text, BufferType type) {
+ text = getRichText(getContext(), text);
+ super.setText(text, type);
+ }
+
+ @Override
+ protected boolean dispatchHoverEvent(MotionEvent event) {
+ if (mAccessibilityHelper != null && mAccessibilityHelper.dispatchHoverEvent(event)) {
+ return true;
+ }
+ return super.dispatchHoverEvent(event);
+ }
+}
diff --git a/library/eclair-mr1/test/src/com/android/setupwizardlib/test/LinkAccessibilityHelperTest.java b/library/eclair-mr1/test/src/com/android/setupwizardlib/test/LinkAccessibilityHelperTest.java
new file mode 100644
index 0000000..aec6b41
--- /dev/null
+++ b/library/eclair-mr1/test/src/com/android/setupwizardlib/test/LinkAccessibilityHelperTest.java
@@ -0,0 +1,150 @@
+/*
+ * Copyright (C) 2016 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.setupwizardlib.test;
+
+import android.graphics.Rect;
+import android.os.Bundle;
+import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
+import android.support.v4.widget.ExploreByTouchHelper;
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.SmallTest;
+import android.text.SpannableStringBuilder;
+import android.view.accessibility.AccessibilityEvent;
+import android.widget.TextView;
+
+import com.android.setupwizardlib.span.LinkSpan;
+import com.android.setupwizardlib.util.LinkAccessibilityHelper;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class LinkAccessibilityHelperTest extends AndroidTestCase {
+
+ private TextView mTextView;
+ private TestLinkAccessibilityHelper mHelper;
+ private LinkSpan mSpan;
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+ mSpan = new LinkSpan("foobar");
+ SpannableStringBuilder ssb = new SpannableStringBuilder("Hello world");
+ ssb.setSpan(mSpan, 1, 2, 0 /* flags */);
+ mTextView = new TextView(getContext());
+ mTextView.setText(ssb);
+ mHelper = new TestLinkAccessibilityHelper(mTextView);
+
+ mTextView.measure(500, 500);
+ mTextView.layout(0, 0, 500, 500);
+ }
+
+ @SmallTest
+ public void testGetVirtualViewAt() {
+ final int virtualViewId = mHelper.getVirtualViewAt(15, 10);
+ assertEquals("Virtual view ID should be 1", 1, virtualViewId);
+ }
+
+ @SmallTest
+ public void testGetVirtualViewAtHost() {
+ final int virtualViewId = mHelper.getVirtualViewAt(100, 100);
+ assertEquals("Virtual view ID should be INVALID_ID",
+ ExploreByTouchHelper.INVALID_ID, virtualViewId);
+ }
+
+ @SmallTest
+ public void testGetVisibleVirtualViews() {
+ List<Integer> virtualViewIds = new ArrayList<>();
+ mHelper.getVisibleVirtualViews(virtualViewIds);
+
+ assertEquals("VisibleVirtualViews should be [1]",
+ Collections.singletonList(1), virtualViewIds);
+ }
+
+ @SmallTest
+ public void testOnPopulateEventForVirtualView() {
+ AccessibilityEvent event = AccessibilityEvent.obtain();
+ mHelper.onPopulateEventForVirtualView(1, event);
+
+ // LinkSpan is set on substring(1, 2) of "Hello world" --> "e"
+ assertEquals("LinkSpan description should be \"e\"",
+ "e", event.getContentDescription().toString());
+
+ event.recycle();
+ }
+
+ @SmallTest
+ public void testOnPopulateEventForVirtualViewHost() {
+ AccessibilityEvent event = AccessibilityEvent.obtain();
+ mHelper.onPopulateEventForVirtualView(ExploreByTouchHelper.INVALID_ID, event);
+
+ assertEquals("Host view description should be \"Hello world\"", "Hello world",
+ event.getContentDescription().toString());
+
+ event.recycle();
+ }
+
+ @SmallTest
+ public void testOnPopulateNodeForVirtualView() {
+ AccessibilityNodeInfoCompat info = AccessibilityNodeInfoCompat.obtain();
+ mHelper.onPopulateNodeForVirtualView(1, info);
+
+ assertEquals("LinkSpan description should be \"e\"",
+ "e", info.getContentDescription().toString());
+ assertTrue("LinkSpan should be focusable", info.isFocusable());
+ assertTrue("LinkSpan should be clickable", info.isClickable());
+ Rect bounds = new Rect();
+ info.getBoundsInParent(bounds);
+ assertEquals("LinkSpan bounds should be (20, 0, 35, 38)", new Rect(20, 0, 35, 38), bounds);
+
+ info.recycle();
+ }
+
+ private static class TestLinkAccessibilityHelper extends LinkAccessibilityHelper {
+
+ public TestLinkAccessibilityHelper(TextView view) {
+ super(view);
+ }
+
+ @Override
+ public int getVirtualViewAt(float x, float y) {
+ return super.getVirtualViewAt(x, y);
+ }
+
+ @Override
+ public void getVisibleVirtualViews(List<Integer> virtualViewIds) {
+ super.getVisibleVirtualViews(virtualViewIds);
+ }
+
+ @Override
+ public void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) {
+ super.onPopulateEventForVirtualView(virtualViewId, event);
+ }
+
+ @Override
+ public void onPopulateNodeForVirtualView(int virtualViewId,
+ AccessibilityNodeInfoCompat info) {
+ super.onPopulateNodeForVirtualView(virtualViewId, info);
+ }
+
+ @Override
+ public boolean onPerformActionForVirtualView(int virtualViewId, int action,
+ Bundle arguments) {
+ return super.onPerformActionForVirtualView(virtualViewId, action, arguments);
+ }
+ }
+}
diff --git a/library/eclair-mr1/test/src/com/android/setupwizardlib/test/RichTextViewTest.java b/library/eclair-mr1/test/src/com/android/setupwizardlib/test/RichTextViewTest.java
new file mode 100644
index 0000000..c591580
--- /dev/null
+++ b/library/eclair-mr1/test/src/com/android/setupwizardlib/test/RichTextViewTest.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2016 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.setupwizardlib.test;
+
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.SmallTest;
+import android.text.Annotation;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.style.TextAppearanceSpan;
+
+import com.android.setupwizardlib.span.LinkSpan;
+import com.android.setupwizardlib.view.RichTextView;
+
+import java.util.Arrays;
+
+public class RichTextViewTest extends AndroidTestCase {
+
+ @SmallTest
+ public void testLinkAnnotation() {
+ Annotation link = new Annotation("link", "foobar");
+ SpannableStringBuilder ssb = new SpannableStringBuilder("Hello world");
+ ssb.setSpan(link, 1, 2, 0 /* flags */);
+
+ RichTextView textView = new RichTextView(getContext());
+ textView.setText(ssb);
+
+ final CharSequence text = textView.getText();
+ assertTrue("Text should be spanned", text instanceof Spanned);
+
+ Object[] spans = ((Spanned) text).getSpans(0, text.length(), Annotation.class);
+ assertEquals("Annotation should be removed " + Arrays.toString(spans), 0, spans.length);
+
+ spans = ((Spanned) text).getSpans(0, text.length(), LinkSpan.class);
+ assertEquals("There should be one span " + Arrays.toString(spans), 1, spans.length);
+ assertTrue("The span should be a LinkSpan", spans[0] instanceof LinkSpan);
+ assertEquals("The LinkSpan should have id \"foobar\"",
+ "foobar", ((LinkSpan) spans[0]).getId());
+ }
+
+ @SmallTest
+ public void testTextStyle() {
+ Annotation link = new Annotation("textAppearance", "foobar");
+ SpannableStringBuilder ssb = new SpannableStringBuilder("Hello world");
+ ssb.setSpan(link, 1, 2, 0 /* flags */);
+
+ RichTextView textView = new RichTextView(getContext());
+ textView.setText(ssb);
+
+ final CharSequence text = textView.getText();
+ assertTrue("Text should be spanned", text instanceof Spanned);
+
+ Object[] spans = ((Spanned) text).getSpans(0, text.length(), Annotation.class);
+ assertEquals("Annotation should be removed " + Arrays.toString(spans), 0, spans.length);
+
+ spans = ((Spanned) text).getSpans(0, text.length(), TextAppearanceSpan.class);
+ assertEquals("There should be one span " + Arrays.toString(spans), 1, spans.length);
+ assertTrue("The span should be a TextAppearanceSpan",
+ spans[0] instanceof TextAppearanceSpan);
+ }
+}
diff --git a/library/main/src/com/android/setupwizardlib/span/LinkSpan.java b/library/main/src/com/android/setupwizardlib/span/LinkSpan.java
new file mode 100644
index 0000000..e4f9854
--- /dev/null
+++ b/library/main/src/com/android/setupwizardlib/span/LinkSpan.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2016 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.setupwizardlib.span;
+
+import android.content.Context;
+import android.graphics.Typeface;
+import android.os.Build;
+import android.text.TextPaint;
+import android.text.style.ClickableSpan;
+import android.util.Log;
+import android.view.View;
+
+/**
+ * A clickable span that will listen for click events and send it back to the context. To use this
+ * class, implement {@link com.android.setupwizardlib.span.LinkSpan.OnClickListener} in your
+ * context (typically your Activity).
+ *
+ * <p />Note on accessibility: For TalkBack to be able to traverse and interact with the links, you
+ * should use {@code LinkAccessibilityHelper} in your {@code TextView} subclass. Optionally you can
+ * also use {@code RichTextView}, which includes link support.
+ */
+public class LinkSpan extends ClickableSpan {
+
+ /*
+ * Implementation note: When the orientation changes, TextView retains a reference to this span
+ * instead of writing it to a parcel (ClickableSpan is not Parcelable). If this class has any
+ * reference to the containing Activity (i.e. the activity context, or any views in the
+ * activity), it will cause memory leak.
+ */
+
+ /* static section */
+
+ private static final String TAG = "LinkSpan";
+
+ private static final Typeface TYPEFACE_MEDIUM =
+ Typeface.create("sans-serif-medium", Typeface.NORMAL);
+
+ public interface OnClickListener {
+ void onClick(LinkSpan span);
+ }
+
+ /* non-static section */
+
+ private final String mId;
+
+ public LinkSpan(String id) {
+ mId = id;
+ }
+
+ @Override
+ public void onClick(View view) {
+ final Context context = view.getContext();
+ if (context instanceof OnClickListener) {
+ ((OnClickListener) context).onClick(this);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+ view.cancelPendingInputEvents();
+ }
+ } else {
+ Log.w(TAG, "Dropping click event. No listener attached.");
+ }
+ }
+
+ @Override
+ public void updateDrawState(TextPaint drawState) {
+ super.updateDrawState(drawState);
+ drawState.setUnderlineText(false);
+ drawState.setTypeface(TYPEFACE_MEDIUM);
+ }
+
+ public String getId() {
+ return mId;
+ }
+}
diff --git a/library/main/src/com/android/setupwizardlib/span/SpanHelper.java b/library/main/src/com/android/setupwizardlib/span/SpanHelper.java
new file mode 100644
index 0000000..d75e338
--- /dev/null
+++ b/library/main/src/com/android/setupwizardlib/span/SpanHelper.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2016 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.setupwizardlib.span;
+
+import android.text.Spannable;
+
+/**
+ * Contains helper methods for dealing with text spans, e.g. the ones in {@code android.text.style}.
+ */
+public class SpanHelper {
+
+ /**
+ * Add {@code newSpan} at the same start and end indices as {@code oldSpan} and remove
+ * {@code oldSpan} from the {@code spannable}.
+ */
+ public static void replaceSpan(Spannable spannable, Object oldSpan, Object newSpan) {
+ final int spanStart = spannable.getSpanStart(oldSpan);
+ final int spanEnd = spannable.getSpanEnd(oldSpan);
+ spannable.removeSpan(oldSpan);
+ spannable.setSpan(newSpan, spanStart, spanEnd, 0);
+ }
+}
diff --git a/library/test/src/com/android/setupwizardlib/test/LinkSpanTest.java b/library/test/src/com/android/setupwizardlib/test/LinkSpanTest.java
new file mode 100644
index 0000000..0cf8464
--- /dev/null
+++ b/library/test/src/com/android/setupwizardlib/test/LinkSpanTest.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2016 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.setupwizardlib.test;
+
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.os.Parcelable;
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.SmallTest;
+import android.widget.TextView;
+
+import com.android.setupwizardlib.span.LinkSpan;
+
+import java.lang.ref.ReferenceQueue;
+import java.lang.ref.WeakReference;
+
+public class LinkSpanTest extends AndroidTestCase {
+
+ @SmallTest
+ public void testOnClick() {
+ final TestContext context = new TestContext(getContext());
+ final TextView textView = new TextView(context);
+ final LinkSpan linkSpan = new LinkSpan("test_id");
+
+ linkSpan.onClick(textView);
+
+ assertSame("Clicked LinkSpan should be passed to setup", linkSpan, context.clickedSpan);
+ }
+
+ @SmallTest
+ public void testNonImplementingContext() {
+ final Context context = getContext();
+ final TextView textView = new TextView(context);
+ final LinkSpan linkSpan = new LinkSpan("test_id");
+
+ linkSpan.onClick(textView);
+
+ // This would be no-op, because the context doesn't implement LinkSpan.OnClickListener.
+ // Just check that no uncaught exception here.
+ }
+
+ @SmallTest
+ public void testNoContextLeak() {
+ // Use a context wrapper so this doesn't share a reference with the test case
+ Context context = new ContextWrapper(getContext());
+
+ ReferenceQueue<Context> queue = new ReferenceQueue<>();
+ WeakReference<Context> ref = new WeakReference<>(context, queue);
+
+ TextView textView = new TextView(context);
+ final Parcelable parcelable = textView.onSaveInstanceState();
+
+ textView = null;
+ context = null;
+
+ System.gc();
+
+ assertTrue("Reference to context should be GC'd", ref.isEnqueued());
+ }
+
+ private static class TestContext extends ContextWrapper implements LinkSpan.OnClickListener {
+
+ public LinkSpan clickedSpan = null;
+
+ public TestContext(Context base) {
+ super(base);
+ }
+
+ @Override
+ public void onClick(LinkSpan span) {
+ clickedSpan = span;
+ }
+ }
+}
diff --git a/library/test/src/com/android/setupwizardlib/test/SpanHelperTest.java b/library/test/src/com/android/setupwizardlib/test/SpanHelperTest.java
new file mode 100644
index 0000000..819e969
--- /dev/null
+++ b/library/test/src/com/android/setupwizardlib/test/SpanHelperTest.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2016 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.setupwizardlib.test;
+
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.SmallTest;
+import android.text.Annotation;
+import android.text.SpannableStringBuilder;
+
+import com.android.setupwizardlib.span.SpanHelper;
+
+public class SpanHelperTest extends AndroidTestCase {
+
+ @SmallTest
+ public void testReplaceSpan() {
+ SpannableStringBuilder ssb = new SpannableStringBuilder("Hello world");
+ Annotation oldSpan = new Annotation("key", "value");
+ Annotation newSpan = new Annotation("newkey", "newvalue");
+ ssb.setSpan(oldSpan, 2, 5, 0 /* flags */);
+
+ SpanHelper.replaceSpan(ssb, oldSpan, newSpan);
+
+ final Object[] spans = ssb.getSpans(0, ssb.length(), Object.class);
+ assertEquals("There should be one span in the builder", 1, spans.length);
+ assertSame("The span should be newSpan", newSpan, spans[0]);
+ }
+}