diff options
author | Maurice Lam <yukl@google.com> | 2016-02-25 10:46:15 -0800 |
---|---|---|
committer | Maurice Lam <yukl@google.com> | 2016-03-28 19:53:32 -0700 |
commit | d832154e333a3a45b5faecd518b664ddd297183c (patch) | |
tree | 742dd5c07865bf712557efed9875f7dadb8de440 | |
parent | eb85a47ae17061a4ed20d5a5fba4c61e24f17013 (diff) | |
download | setupwizard-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
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 <annotation> tags in strings to become their respective types. Currently 2 + * types are supported: + * <ol> + * <li><annotation link="foobar"> will create a + * {@link com.android.setupwizardlib.span.LinkSpan} that broadcasts with the key + * "foobar"</li> + * <li><annotation textAppearance="TextAppearance.FooBar"> 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]); + } +} |