Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 6b766752 authored by Abodunrinwa Toki's avatar Abodunrinwa Toki
Browse files

Implement TextClassifierImpl.getLinks

This is a simple implementation of getLinks that works. I plan to
improve on the algorithm later.

Bug: 34661057
Test: See: Ic2a5eceeaec4cd2943c6c753084df46d30511fee
Change-Id: Icb9d4678e19a72ff89556dcaef5940ffab5d95d5
parent c755fb64
Loading
Loading
Loading
Loading
+232 −33
Original line number Diff line number Diff line
@@ -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<SpanSpec> 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<SpanSpec> avoidOverlaps(List<SpanSpec> spans, String text) {
            Collections.sort(spans, Comparator.comparingInt(span -> span.mStart));
            // Group spans by start index. Take the longest span.
            final Map<Integer, SpanSpec> 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<SpanSpec> 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<SpanSpec> mSpans;

            LinksInfoImpl(CharSequence originalText, List<SpanSpec> 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.
            }
        }
    }
}