diff --git a/core/java/android/view/textclassifier/TextClassifierImpl.java b/core/java/android/view/textclassifier/TextClassifierImpl.java index 72796cf160d05..0657067d992f0 100644 --- a/core/java/android/view/textclassifier/TextClassifierImpl.java +++ b/core/java/android/view/textclassifier/TextClassifierImpl.java @@ -17,20 +17,34 @@ package android.view.textclassifier; import android.annotation.NonNull; +import android.annotation.Nullable; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.graphics.drawable.Drawable; +import android.icu.text.BreakIterator; import android.net.Uri; import android.os.ParcelFileDescriptor; import android.text.SmartSelection; +import android.text.Spannable; import android.text.TextUtils; +import android.text.method.WordIterator; +import android.text.style.ClickableSpan; +import android.text.util.Linkify; import android.util.Log; +import android.view.View; import com.android.internal.util.Preconditions; import java.io.FileNotFoundException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; /** * Default implementation of the {@link TextClassifier} interface. @@ -99,29 +113,7 @@ final class TextClassifierImpl implements TextClassifier { type = type.toLowerCase().trim(); // TODO: Added this log for debug only. Remove before release. Log.d(LOG_TAG, String.format("Classification type: %s", type)); - final Intent intent; - final String title; - switch (type) { - case TextClassifier.TYPE_EMAIL: - intent = new Intent(Intent.ACTION_SENDTO); - intent.setData(Uri.parse(String.format("mailto:%s", text))); - title = mContext.getString(com.android.internal.R.string.email); - return createClassificationResult(classified, type, intent, title); - case TextClassifier.TYPE_PHONE: - intent = new Intent(Intent.ACTION_DIAL); - intent.setData(Uri.parse(String.format("tel:%s", text))); - title = mContext.getString(com.android.internal.R.string.dial); - return createClassificationResult(classified, type, intent, title); - case TextClassifier.TYPE_ADDRESS: - intent = new Intent(Intent.ACTION_VIEW); - intent.setData(Uri.parse(String.format("geo:0,0?q=%s", text))); - title = mContext.getString(com.android.internal.R.string.map); - return createClassificationResult(classified, type, intent, title); - default: - // No classification type found. Return a no-op result. - break; - // TODO: Add other classification types. - } + return createClassificationResult(type, classified); } } } catch (Throwable t) { @@ -132,9 +124,18 @@ final class TextClassifierImpl implements TextClassifier { return TextClassifier.NO_OP.getTextClassificationResult(text, startIndex, endIndex); } + @Override - public LinksInfo getLinks(@NonNull CharSequence text, int linkMask) { - // TODO: Implement + public LinksInfo getLinks(CharSequence text, int linkMask) { + Preconditions.checkArgument(text != null); + try { + return LinksInfoFactory.create( + mContext, getSmartSelection(), text.toString(), linkMask); + } catch (Throwable t) { + // Avoid throwing from this method. Log the error. + Log.e(LOG_TAG, "Error getting links info.", t); + } + // Getting here means something went wrong, return a NO_OP result. return TextClassifier.NO_OP.getLinks(text, linkMask); } @@ -145,19 +146,23 @@ final class TextClassifierImpl implements TextClassifier { return mSmartSelection; } - private TextClassificationResult createClassificationResult( - CharSequence text, String type, Intent intent, String label) { - TextClassificationResult.Builder builder = new TextClassificationResult.Builder() + private TextClassificationResult createClassificationResult(String type, CharSequence text) { + final Intent intent = IntentFactory.create(type, text.toString()); + if (intent == null) { + return TextClassificationResult.EMPTY; + } + + final TextClassificationResult.Builder builder = new TextClassificationResult.Builder() .setText(text.toString()) .setEntityType(type, 1.0f /* confidence */) .setIntent(intent) .setOnClickListener(TextClassificationResult.createStartActivityOnClick( mContext, intent)) - .setLabel(label); - PackageManager pm = mContext.getPackageManager(); - ResolveInfo resolveInfo = pm.resolveActivity(intent, 0); - // TODO: If the resolveInfo is the "chooser", do not set the package name and use a - // default icon for this classification type. + .setLabel(IntentFactory.getLabel(mContext, type)); + final PackageManager pm = mContext.getPackageManager(); + final ResolveInfo resolveInfo = pm.resolveActivity(intent, 0); + // TODO: If the resolveInfo is the "chooser", do not set the package name and use a default + // icon for this classification type. intent.setPackage(resolveInfo.activityInfo.packageName); Drawable icon = resolveInfo.activityInfo.loadIcon(pm); if (icon == null) { @@ -177,4 +182,198 @@ final class TextClassifierImpl implements TextClassifier { Preconditions.checkArgument(endIndex <= text.length()); Preconditions.checkArgument(endIndex >= startIndex); } + + /** + * Detects and creates links for specified text. + */ + private static final class LinksInfoFactory { + + private LinksInfoFactory() {} + + public static LinksInfo create( + Context context, SmartSelection smartSelection, String text, int linkMask) { + final WordIterator wordIterator = new WordIterator(); + wordIterator.setCharSequence(text, 0, text.length()); + final List spans = new ArrayList<>(); + int start = 0; + int end; + while ((end = wordIterator.nextBoundary(start)) != BreakIterator.DONE) { + final String token = text.substring(start, end); + if (TextUtils.isEmpty(token)) { + continue; + } + + final int[] selection = smartSelection.suggest(text, start, end); + final int selectionStart = selection[0]; + final int selectionEnd = selection[1]; + if (selectionStart >= 0 && selectionEnd <= text.length() + && selectionStart <= selectionEnd) { + final String type = + smartSelection.classifyText(text, selectionStart, selectionEnd); + if (matches(type, linkMask)) { + final ClickableSpan span = createSpan( + context, type, text.substring(selectionStart, selectionEnd)); + spans.add(new SpanSpec(selectionStart, selectionEnd, span)); + } + } + start = end; + } + return new LinksInfoImpl(text, avoidOverlaps(spans, text)); + } + + /** + * Returns true if the classification type matches the specified linkMask. + */ + private static boolean matches(String type, int linkMask) { + if ((linkMask & Linkify.PHONE_NUMBERS) != 0 + && TextClassifier.TYPE_PHONE.equals(type)) { + return true; + } + if ((linkMask & Linkify.EMAIL_ADDRESSES) != 0 + && TextClassifier.TYPE_EMAIL.equals(type)) { + return true; + } + if ((linkMask & Linkify.MAP_ADDRESSES) != 0 + && TextClassifier.TYPE_ADDRESS.equals(type)) { + return true; + } + return false; + } + + /** + * Trim the number of spans so that no two spans overlap. + * + * This algorithm first ensures that there is only one span per start index, then it + * makes sure that no two spans overlap. + */ + private static List avoidOverlaps(List spans, String text) { + Collections.sort(spans, Comparator.comparingInt(span -> span.mStart)); + // Group spans by start index. Take the longest span. + final Map reps = new LinkedHashMap<>(); // order matters. + final int size = spans.size(); + for (int i = 0; i < size; i++) { + final SpanSpec span = spans.get(i); + final LinksInfoFactory.SpanSpec rep = reps.get(span.mStart); + if (rep == null || rep.mEnd < span.mEnd) { + reps.put(span.mStart, span); + } + } + // Avoid span intersections. Take the longer span. + final LinkedList result = new LinkedList<>(); + for (SpanSpec rep : reps.values()) { + if (result.isEmpty()) { + result.add(rep); + continue; + } + + final SpanSpec last = result.getLast(); + if (rep.mStart < last.mEnd) { + // Spans intersect. Use the one with characters. + if ((rep.mEnd - rep.mStart) > (last.mEnd - last.mStart)) { + result.set(result.size() - 1, rep); + } + } else { + result.add(rep); + } + } + return result; + } + + private static ClickableSpan createSpan( + final Context context, final String type, final String text) { + return new ClickableSpan() { + // TODO: Style this span. + @Override + public void onClick(View widget) { + context.startActivity(IntentFactory.create(type, text)); + } + }; + } + + /** + * Implementation of LinksInfo that adds ClickableSpans to the specified text. + */ + private static final class LinksInfoImpl implements LinksInfo { + + private final CharSequence mOriginalText; + private final List mSpans; + + LinksInfoImpl(CharSequence originalText, List spans) { + mOriginalText = originalText; + mSpans = spans; + } + + @Override + public boolean apply(@NonNull CharSequence text) { + Preconditions.checkArgument(text != null); + if (text instanceof Spannable && mOriginalText.toString().equals(text.toString())) { + Spannable spannable = (Spannable) text; + final int size = mSpans.size(); + for (int i = 0; i < size; i++) { + final SpanSpec span = mSpans.get(i); + spannable.setSpan(span.mSpan, span.mStart, span.mEnd, 0); + } + return true; + } + return false; + } + } + + /** + * Span plus its start and end index. + */ + private static final class SpanSpec { + + private final int mStart; + private final int mEnd; + private final ClickableSpan mSpan; + + SpanSpec(int start, int end, ClickableSpan span) { + mStart = start; + mEnd = end; + mSpan = span; + } + } + } + + /** + * Creates intents based on the classification type. + */ + private static final class IntentFactory { + + private IntentFactory() {} + + @Nullable + public static Intent create(String type, String text) { + switch (type) { + case TextClassifier.TYPE_EMAIL: + return new Intent(Intent.ACTION_SENDTO) + .setData(Uri.parse(String.format("mailto:%s", text))); + case TextClassifier.TYPE_PHONE: + return new Intent(Intent.ACTION_DIAL) + .setData(Uri.parse(String.format("tel:%s", text))); + case TextClassifier.TYPE_ADDRESS: + return new Intent(Intent.ACTION_VIEW) + .setData(Uri.parse(String.format("geo:0,0?q=%s", text))); + default: + return null; + // TODO: Add other classification types. + } + } + + @Nullable + public static String getLabel(Context context, String type) { + switch (type) { + case TextClassifier.TYPE_EMAIL: + return context.getString(com.android.internal.R.string.email); + case TextClassifier.TYPE_PHONE: + return context.getString(com.android.internal.R.string.dial); + case TextClassifier.TYPE_ADDRESS: + return context.getString(com.android.internal.R.string.map); + default: + return null; + // TODO: Add other classification types. + } + } + } }