TextClassifierImpl.java revision 9b4c82a83cc3c1aafac2325d7a601ba3e090b90b
1/*
2 * Copyright (C) 2017 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package android.view.textclassifier;
18
19import android.annotation.NonNull;
20import android.annotation.Nullable;
21import android.content.ComponentName;
22import android.content.Context;
23import android.content.Intent;
24import android.content.pm.PackageManager;
25import android.content.pm.ResolveInfo;
26import android.graphics.drawable.Drawable;
27import android.icu.text.BreakIterator;
28import android.net.Uri;
29import android.os.ParcelFileDescriptor;
30import android.provider.Browser;
31import android.text.Spannable;
32import android.text.TextUtils;
33import android.text.method.WordIterator;
34import android.text.style.ClickableSpan;
35import android.text.util.Linkify;
36import android.util.Log;
37import android.view.View;
38
39import com.android.internal.util.Preconditions;
40
41import java.io.FileNotFoundException;
42import java.util.ArrayList;
43import java.util.Collections;
44import java.util.Comparator;
45import java.util.LinkedHashMap;
46import java.util.LinkedList;
47import java.util.List;
48import java.util.Locale;
49import java.util.Map;
50
51/**
52 * Default implementation of the {@link TextClassifier} interface.
53 *
54 * <p>This class uses machine learning to recognize entities in text.
55 * Unless otherwise stated, methods of this class are blocking operations and should most
56 * likely not be called on the UI thread.
57 *
58 * @hide
59 */
60final class TextClassifierImpl implements TextClassifier {
61
62    private static final String LOG_TAG = "TextClassifierImpl";
63
64    private final Object mSmartSelectionLock = new Object();
65
66    private final Context mContext;
67    private final ParcelFileDescriptor mFd;
68    private SmartSelection mSmartSelection;
69
70    TextClassifierImpl(Context context, ParcelFileDescriptor fd) {
71        mContext = Preconditions.checkNotNull(context);
72        mFd = Preconditions.checkNotNull(fd);
73    }
74
75    @Override
76    public TextSelection suggestSelection(
77            @NonNull CharSequence text, int selectionStartIndex, int selectionEndIndex) {
78        validateInput(text, selectionStartIndex, selectionEndIndex);
79        try {
80            if (text.length() > 0) {
81                final String string = text.toString();
82                final int[] startEnd = getSmartSelection()
83                        .suggest(string, selectionStartIndex, selectionEndIndex);
84                final int start = startEnd[0];
85                final int end = startEnd[1];
86                if (start >= 0 && end <= string.length() && start <= end) {
87                    final String type = getSmartSelection().classifyText(string, start, end);
88                    return new TextSelection.Builder(start, end)
89                            .setEntityType(type, 1.0f)
90                            .build();
91                } else {
92                    // We can not trust the result. Log the issue and ignore the result.
93                    Log.d(LOG_TAG, "Got bad indices for input text. Ignoring result.");
94                }
95            }
96        } catch (Throwable t) {
97            // Avoid throwing from this method. Log the error.
98            Log.e(LOG_TAG,
99                    "Error suggesting selection for text. No changes to selection suggested.",
100                    t);
101        }
102        // Getting here means something went wrong, return a NO_OP result.
103        return TextClassifier.NO_OP.suggestSelection(
104                text, selectionStartIndex, selectionEndIndex);
105    }
106
107    @Override
108    public TextClassificationResult getTextClassificationResult(
109            @NonNull CharSequence text, int startIndex, int endIndex) {
110        validateInput(text, startIndex, endIndex);
111        try {
112            if (text.length() > 0) {
113                final CharSequence classified = text.subSequence(startIndex, endIndex);
114                String type = getSmartSelection()
115                        .classifyText(text.toString(), startIndex, endIndex);
116                if (!TextUtils.isEmpty(type)) {
117                    type = type.toLowerCase(Locale.ENGLISH).trim();
118                    // TODO: Added this log for debug only. Remove before release.
119                    Log.d(LOG_TAG, String.format("Classification type: %s", type));
120                    return createClassificationResult(type, classified);
121                }
122            }
123        } catch (Throwable t) {
124            // Avoid throwing from this method. Log the error.
125            Log.e(LOG_TAG, "Error getting assist info.", t);
126        }
127        // Getting here means something went wrong, return a NO_OP result.
128        return TextClassifier.NO_OP.getTextClassificationResult(text, startIndex, endIndex);
129    }
130
131    @Override
132    public LinksInfo getLinks(CharSequence text, int linkMask) {
133        Preconditions.checkArgument(text != null);
134        try {
135            return LinksInfoFactory.create(
136                    mContext, getSmartSelection(), text.toString(), linkMask);
137        } catch (Throwable t) {
138            // Avoid throwing from this method. Log the error.
139            Log.e(LOG_TAG, "Error getting links info.", t);
140        }
141        // Getting here means something went wrong, return a NO_OP result.
142        return TextClassifier.NO_OP.getLinks(text, linkMask);
143    }
144
145    private SmartSelection getSmartSelection() throws FileNotFoundException {
146        synchronized (mSmartSelectionLock) {
147            if (mSmartSelection == null) {
148                mSmartSelection = new SmartSelection(mFd.getFd());
149            }
150            return mSmartSelection;
151        }
152    }
153
154    private TextClassificationResult createClassificationResult(String type, CharSequence text) {
155        final TextClassificationResult.Builder builder = new TextClassificationResult.Builder()
156                .setText(text.toString())
157                .setEntityType(type, 1.0f /* confidence */);
158
159        final Intent intent = IntentFactory.create(mContext, type, text.toString());
160        final PackageManager pm;
161        final ResolveInfo resolveInfo;
162        if (intent != null) {
163            pm = mContext.getPackageManager();
164            resolveInfo = pm.resolveActivity(intent, 0);
165        } else {
166            pm = null;
167            resolveInfo = null;
168        }
169        if (resolveInfo != null && resolveInfo.activityInfo != null) {
170            builder.setIntent(intent)
171                    .setOnClickListener(TextClassificationResult.createStartActivityOnClickListener(
172                            mContext, intent));
173
174            final String packageName = resolveInfo.activityInfo.packageName;
175            if ("android".equals(packageName)) {
176                // Requires the chooser to find an activity to handle the intent.
177                builder.setLabel(IntentFactory.getLabel(mContext, type));
178            } else {
179                // A default activity will handle the intent.
180                intent.setComponent(new ComponentName(packageName, resolveInfo.activityInfo.name));
181                Drawable icon = resolveInfo.activityInfo.loadIcon(pm);
182                if (icon == null) {
183                    icon = resolveInfo.loadIcon(pm);
184                }
185                builder.setIcon(icon);
186                CharSequence label = resolveInfo.activityInfo.loadLabel(pm);
187                if (label == null) {
188                    label = resolveInfo.loadLabel(pm);
189                }
190                builder.setLabel(label != null ? label.toString() : null);
191            }
192        }
193        return builder.build();
194    }
195
196    /**
197     * @throws IllegalArgumentException if text is null; startIndex is negative;
198     *      endIndex is greater than text.length() or less than startIndex
199     */
200    private static void validateInput(@NonNull CharSequence text, int startIndex, int endIndex) {
201        Preconditions.checkArgument(text != null);
202        Preconditions.checkArgument(startIndex >= 0);
203        Preconditions.checkArgument(endIndex <= text.length());
204        Preconditions.checkArgument(endIndex >= startIndex);
205    }
206
207    /**
208     * Detects and creates links for specified text.
209     */
210    private static final class LinksInfoFactory {
211
212        private LinksInfoFactory() {}
213
214        public static LinksInfo create(
215                Context context, SmartSelection smartSelection, String text, int linkMask) {
216            final WordIterator wordIterator = new WordIterator();
217            wordIterator.setCharSequence(text, 0, text.length());
218            final List<SpanSpec> spans = new ArrayList<>();
219            int start = 0;
220            int end;
221            while ((end = wordIterator.nextBoundary(start)) != BreakIterator.DONE) {
222                final String token = text.substring(start, end);
223                if (TextUtils.isEmpty(token)) {
224                    continue;
225                }
226
227                final int[] selection = smartSelection.suggest(text, start, end);
228                final int selectionStart = selection[0];
229                final int selectionEnd = selection[1];
230                if (selectionStart >= 0 && selectionEnd <= text.length()
231                        && selectionStart <= selectionEnd) {
232                    final String type =
233                            smartSelection.classifyText(text, selectionStart, selectionEnd);
234                    if (matches(type, linkMask)) {
235                        final Intent intent = IntentFactory.create(
236                                context, type, text.substring(selectionStart, selectionEnd));
237                        if (hasActivityHandler(context, intent)) {
238                            final ClickableSpan span = createSpan(context, intent);
239                            spans.add(new SpanSpec(selectionStart, selectionEnd, span));
240                        }
241                    }
242                }
243                start = end;
244            }
245            return new LinksInfoImpl(text, avoidOverlaps(spans, text));
246        }
247
248        /**
249         * Returns true if the classification type matches the specified linkMask.
250         */
251        private static boolean matches(String type, int linkMask) {
252            if ((linkMask & Linkify.PHONE_NUMBERS) != 0
253                    && TextClassifier.TYPE_PHONE.equals(type)) {
254                return true;
255            }
256            if ((linkMask & Linkify.EMAIL_ADDRESSES) != 0
257                    && TextClassifier.TYPE_EMAIL.equals(type)) {
258                return true;
259            }
260            if ((linkMask & Linkify.MAP_ADDRESSES) != 0
261                    && TextClassifier.TYPE_ADDRESS.equals(type)) {
262                return true;
263            }
264            if ((linkMask & Linkify.WEB_URLS) != 0
265                    && TextClassifier.TYPE_URL.equals(type)) {
266                return true;
267            }
268            return false;
269        }
270
271        /**
272         * Trim the number of spans so that no two spans overlap.
273         *
274         * This algorithm first ensures that there is only one span per start index, then it
275         * makes sure that no two spans overlap.
276         */
277        private static List<SpanSpec> avoidOverlaps(List<SpanSpec> spans, String text) {
278            Collections.sort(spans, Comparator.comparingInt(span -> span.mStart));
279            // Group spans by start index. Take the longest span.
280            final Map<Integer, SpanSpec> reps = new LinkedHashMap<>();  // order matters.
281            final int size = spans.size();
282            for (int i = 0; i < size; i++) {
283                final SpanSpec span = spans.get(i);
284                final LinksInfoFactory.SpanSpec rep = reps.get(span.mStart);
285                if (rep == null || rep.mEnd < span.mEnd) {
286                    reps.put(span.mStart, span);
287                }
288            }
289            // Avoid span intersections. Take the longer span.
290            final LinkedList<SpanSpec> result = new LinkedList<>();
291            for (SpanSpec rep : reps.values()) {
292                if (result.isEmpty()) {
293                    result.add(rep);
294                    continue;
295                }
296
297                final SpanSpec last = result.getLast();
298                if (rep.mStart < last.mEnd) {
299                    // Spans intersect. Use the one with characters.
300                    if ((rep.mEnd - rep.mStart) > (last.mEnd - last.mStart)) {
301                        result.set(result.size() - 1, rep);
302                    }
303                } else {
304                    result.add(rep);
305                }
306            }
307            return result;
308        }
309
310        private static ClickableSpan createSpan(final Context context, final Intent intent) {
311            return new ClickableSpan() {
312                // TODO: Style this span.
313                @Override
314                public void onClick(View widget) {
315                    context.startActivity(intent);
316                }
317            };
318        }
319
320        private static boolean hasActivityHandler(Context context, @Nullable Intent intent) {
321            if (intent == null) {
322                return false;
323            }
324            final ResolveInfo resolveInfo = context.getPackageManager().resolveActivity(intent, 0);
325            return resolveInfo != null && resolveInfo.activityInfo != null;
326        }
327
328        /**
329         * Implementation of LinksInfo that adds ClickableSpans to the specified text.
330         */
331        private static final class LinksInfoImpl implements LinksInfo {
332
333            private final CharSequence mOriginalText;
334            private final List<SpanSpec> mSpans;
335
336            LinksInfoImpl(CharSequence originalText, List<SpanSpec> spans) {
337                mOriginalText = originalText;
338                mSpans = spans;
339            }
340
341            @Override
342            public boolean apply(@NonNull CharSequence text) {
343                Preconditions.checkArgument(text != null);
344                if (text instanceof Spannable && mOriginalText.toString().equals(text.toString())) {
345                    Spannable spannable = (Spannable) text;
346                    final int size = mSpans.size();
347                    for (int i = 0; i < size; i++) {
348                        final SpanSpec span = mSpans.get(i);
349                        spannable.setSpan(span.mSpan, span.mStart, span.mEnd, 0);
350                    }
351                    return true;
352                }
353                return false;
354            }
355        }
356
357        /**
358         * Span plus its start and end index.
359         */
360        private static final class SpanSpec {
361
362            private final int mStart;
363            private final int mEnd;
364            private final ClickableSpan mSpan;
365
366            SpanSpec(int start, int end, ClickableSpan span) {
367                mStart = start;
368                mEnd = end;
369                mSpan = span;
370            }
371        }
372    }
373
374    /**
375     * Creates intents based on the classification type.
376     */
377    private static final class IntentFactory {
378
379        private IntentFactory() {}
380
381        @Nullable
382        public static Intent create(Context context, String type, String text) {
383            switch (type) {
384                case TextClassifier.TYPE_EMAIL:
385                    return new Intent(Intent.ACTION_SENDTO)
386                            .setData(Uri.parse(String.format("mailto:%s", text)));
387                case TextClassifier.TYPE_PHONE:
388                    return new Intent(Intent.ACTION_DIAL)
389                            .setData(Uri.parse(String.format("tel:%s", text)));
390                case TextClassifier.TYPE_ADDRESS:
391                    return new Intent(Intent.ACTION_VIEW)
392                            .setData(Uri.parse(String.format("geo:0,0?q=%s", text)));
393                case TextClassifier.TYPE_URL:
394                    return new Intent(Intent.ACTION_VIEW, Uri.parse(text))
395                            .putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName());
396                default:
397                    return null;
398                // TODO: Add other classification types.
399            }
400        }
401
402        @Nullable
403        public static String getLabel(Context context, String type) {
404            switch (type) {
405                case TextClassifier.TYPE_EMAIL:
406                    return context.getString(com.android.internal.R.string.email);
407                case TextClassifier.TYPE_PHONE:
408                    return context.getString(com.android.internal.R.string.dial);
409                case TextClassifier.TYPE_ADDRESS:
410                    return context.getString(com.android.internal.R.string.map);
411                case TextClassifier.TYPE_URL:
412                    return context.getString(com.android.internal.R.string.browse);
413                default:
414                    return null;
415                // TODO: Add other classification types.
416            }
417        }
418    }
419}
420