TextClassifierImpl.java revision 20d346eafec9404fb6f5b8eeb9a18ad794b4ca9a
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.annotation.WorkerThread;
22import android.app.RemoteAction;
23import android.app.SearchManager;
24import android.content.ComponentName;
25import android.content.ContentUris;
26import android.content.Context;
27import android.content.Intent;
28import android.content.pm.PackageManager;
29import android.content.pm.ResolveInfo;
30import android.graphics.drawable.Icon;
31import android.net.Uri;
32import android.os.Bundle;
33import android.os.LocaleList;
34import android.os.ParcelFileDescriptor;
35import android.os.UserManager;
36import android.provider.Browser;
37import android.provider.CalendarContract;
38import android.provider.ContactsContract;
39
40import com.android.internal.annotations.GuardedBy;
41import com.android.internal.util.Preconditions;
42
43import java.io.File;
44import java.io.FileNotFoundException;
45import java.io.IOException;
46import java.io.UnsupportedEncodingException;
47import java.net.URLEncoder;
48import java.util.ArrayList;
49import java.util.Arrays;
50import java.util.Calendar;
51import java.util.Collection;
52import java.util.Collections;
53import java.util.HashMap;
54import java.util.List;
55import java.util.Locale;
56import java.util.Map;
57import java.util.Objects;
58import java.util.StringJoiner;
59import java.util.concurrent.TimeUnit;
60import java.util.regex.Matcher;
61import java.util.regex.Pattern;
62
63/**
64 * Default implementation of the {@link TextClassifier} interface.
65 *
66 * <p>This class uses machine learning to recognize entities in text.
67 * Unless otherwise stated, methods of this class are blocking operations and should most
68 * likely not be called on the UI thread.
69 *
70 * @hide
71 */
72public final class TextClassifierImpl implements TextClassifier {
73
74    private static final String LOG_TAG = DEFAULT_LOG_TAG;
75    private static final String MODEL_DIR = "/etc/textclassifier/";
76    private static final String MODEL_FILE_REGEX = "textclassifier\\.(.*)\\.model";
77    private static final String UPDATED_MODEL_FILE_PATH =
78            "/data/misc/textclassifier/textclassifier.model";
79
80    private final Context mContext;
81    private final TextClassifier mFallback;
82    private final GenerateLinksLogger mGenerateLinksLogger;
83
84    private final Object mLock = new Object();
85    @GuardedBy("mLock") // Do not access outside this lock.
86    private List<ModelFile> mAllModelFiles;
87    @GuardedBy("mLock") // Do not access outside this lock.
88    private ModelFile mModel;
89    @GuardedBy("mLock") // Do not access outside this lock.
90    private TextClassifierImplNative mNative;
91
92    private final Object mLoggerLock = new Object();
93    @GuardedBy("mLoggerLock") // Do not access outside this lock.
94    private Logger.Config mLoggerConfig;
95    @GuardedBy("mLoggerLock") // Do not access outside this lock.
96    private Logger mLogger;
97
98    private final TextClassificationConstants mSettings;
99
100    public TextClassifierImpl(Context context, TextClassificationConstants settings) {
101        mContext = Preconditions.checkNotNull(context);
102        mFallback = TextClassifier.NO_OP;
103        mSettings = Preconditions.checkNotNull(settings);
104        mGenerateLinksLogger = new GenerateLinksLogger(mSettings.getGenerateLinksLogSampleRate());
105    }
106
107    /** @inheritDoc */
108    @Override
109    @WorkerThread
110    public TextSelection suggestSelection(
111            @NonNull CharSequence text, int selectionStartIndex, int selectionEndIndex,
112            @Nullable TextSelection.Options options) {
113        Utils.validate(text, selectionStartIndex, selectionEndIndex, false /* allowInMainThread */);
114        try {
115            final int rangeLength = selectionEndIndex - selectionStartIndex;
116            if (text.length() > 0
117                    && rangeLength <= mSettings.getSuggestSelectionMaxRangeLength()) {
118                final LocaleList locales = (options == null) ? null : options.getDefaultLocales();
119                final String localesString = concatenateLocales(locales);
120                final Calendar refTime = Calendar.getInstance();
121                final boolean darkLaunchAllowed = options != null && options.isDarkLaunchAllowed();
122                final TextClassifierImplNative nativeImpl = getNative(locales);
123                final String string = text.toString();
124                final int start;
125                final int end;
126                if (mSettings.isModelDarkLaunchEnabled() && !darkLaunchAllowed) {
127                    start = selectionStartIndex;
128                    end = selectionEndIndex;
129                } else {
130                    final int[] startEnd = nativeImpl.suggestSelection(
131                            string, selectionStartIndex, selectionEndIndex,
132                            new TextClassifierImplNative.SelectionOptions(localesString));
133                    start = startEnd[0];
134                    end = startEnd[1];
135                }
136                if (start < end
137                        && start >= 0 && end <= string.length()
138                        && start <= selectionStartIndex && end >= selectionEndIndex) {
139                    final TextSelection.Builder tsBuilder = new TextSelection.Builder(start, end);
140                    final TextClassifierImplNative.ClassificationResult[] results =
141                            nativeImpl.classifyText(
142                                    string, start, end,
143                                    new TextClassifierImplNative.ClassificationOptions(
144                                            refTime.getTimeInMillis(),
145                                            refTime.getTimeZone().getID(),
146                                            localesString));
147                    final int size = results.length;
148                    for (int i = 0; i < size; i++) {
149                        tsBuilder.setEntityType(results[i].getCollection(), results[i].getScore());
150                    }
151                    return tsBuilder
152                            .setSignature(
153                                    getSignature(string, selectionStartIndex, selectionEndIndex))
154                            .build();
155                } else {
156                    // We can not trust the result. Log the issue and ignore the result.
157                    Log.d(LOG_TAG, "Got bad indices for input text. Ignoring result.");
158                }
159            }
160        } catch (Throwable t) {
161            // Avoid throwing from this method. Log the error.
162            Log.e(LOG_TAG,
163                    "Error suggesting selection for text. No changes to selection suggested.",
164                    t);
165        }
166        // Getting here means something went wrong, return a NO_OP result.
167        return mFallback.suggestSelection(
168                text, selectionStartIndex, selectionEndIndex, options);
169    }
170
171    /** @inheritDoc */
172    @Override
173    @WorkerThread
174    public TextClassification classifyText(
175            @NonNull CharSequence text, int startIndex, int endIndex,
176            @Nullable TextClassification.Options options) {
177        Utils.validate(text, startIndex, endIndex, false /* allowInMainThread */);
178        try {
179            final int rangeLength = endIndex - startIndex;
180            if (text.length() > 0 && rangeLength <= mSettings.getClassifyTextMaxRangeLength()) {
181                final String string = text.toString();
182                final LocaleList locales = (options == null) ? null : options.getDefaultLocales();
183                final String localesString = concatenateLocales(locales);
184                final Calendar refTime = (options != null && options.getReferenceTime() != null)
185                        ? options.getReferenceTime() : Calendar.getInstance();
186
187                final TextClassifierImplNative.ClassificationResult[] results =
188                        getNative(locales)
189                                .classifyText(string, startIndex, endIndex,
190                                        new TextClassifierImplNative.ClassificationOptions(
191                                                refTime.getTimeInMillis(),
192                                                refTime.getTimeZone().getID(),
193                                                localesString));
194                if (results.length > 0) {
195                    return createClassificationResult(
196                            results, string, startIndex, endIndex, refTime);
197                }
198            }
199        } catch (Throwable t) {
200            // Avoid throwing from this method. Log the error.
201            Log.e(LOG_TAG, "Error getting text classification info.", t);
202        }
203        // Getting here means something went wrong, return a NO_OP result.
204        return mFallback.classifyText(text, startIndex, endIndex, options);
205    }
206
207    /** @inheritDoc */
208    @Override
209    @WorkerThread
210    public TextLinks generateLinks(
211            @NonNull CharSequence text, @Nullable TextLinks.Options options) {
212        Utils.validate(text, getMaxGenerateLinksTextLength(), false /* allowInMainThread */);
213
214        final boolean legacyFallback = options != null && options.isLegacyFallback();
215        if (!mSettings.isSmartLinkifyEnabled() && legacyFallback) {
216            return Utils.generateLegacyLinks(text, options);
217        }
218
219        final String textString = text.toString();
220        final TextLinks.Builder builder = new TextLinks.Builder(textString);
221
222        try {
223            final long startTimeMs = System.currentTimeMillis();
224            final LocaleList defaultLocales = options != null ? options.getDefaultLocales() : null;
225            final Calendar refTime = Calendar.getInstance();
226            final Collection<String> entitiesToIdentify =
227                    options != null && options.getEntityConfig() != null
228                            ? options.getEntityConfig().resolveEntityListModifications(
229                                    getEntitiesForHints(options.getEntityConfig().getHints()))
230                            : mSettings.getEntityListDefault();
231            final TextClassifierImplNative nativeImpl =
232                    getNative(defaultLocales);
233            final TextClassifierImplNative.AnnotatedSpan[] annotations =
234                    nativeImpl.annotate(
235                        textString,
236                        new TextClassifierImplNative.AnnotationOptions(
237                                refTime.getTimeInMillis(),
238                                refTime.getTimeZone().getID(),
239                                concatenateLocales(defaultLocales)));
240            for (TextClassifierImplNative.AnnotatedSpan span : annotations) {
241                final TextClassifierImplNative.ClassificationResult[] results =
242                        span.getClassification();
243                if (results.length == 0
244                        || !entitiesToIdentify.contains(results[0].getCollection())) {
245                    continue;
246                }
247                final Map<String, Float> entityScores = new HashMap<>();
248                for (int i = 0; i < results.length; i++) {
249                    entityScores.put(results[i].getCollection(), results[i].getScore());
250                }
251                builder.addLink(span.getStartIndex(), span.getEndIndex(), entityScores);
252            }
253            final TextLinks links = builder.build();
254            final long endTimeMs = System.currentTimeMillis();
255            final String callingPackageName =
256                    options == null || options.getCallingPackageName() == null
257                            ? mContext.getPackageName()  // local (in process) TC.
258                            : options.getCallingPackageName();
259            mGenerateLinksLogger.logGenerateLinks(
260                    text, links, callingPackageName, endTimeMs - startTimeMs);
261            return links;
262        } catch (Throwable t) {
263            // Avoid throwing from this method. Log the error.
264            Log.e(LOG_TAG, "Error getting links info.", t);
265        }
266        return mFallback.generateLinks(text, options);
267    }
268
269    /** @inheritDoc */
270    @Override
271    public int getMaxGenerateLinksTextLength() {
272        return mSettings.getGenerateLinksMaxTextLength();
273    }
274
275    private Collection<String> getEntitiesForHints(Collection<String> hints) {
276        final boolean editable = hints.contains(HINT_TEXT_IS_EDITABLE);
277        final boolean notEditable = hints.contains(HINT_TEXT_IS_NOT_EDITABLE);
278
279        // Use the default if there is no hint, or conflicting ones.
280        final boolean useDefault = editable == notEditable;
281        if (useDefault) {
282            return mSettings.getEntityListDefault();
283        } else if (editable) {
284            return mSettings.getEntityListEditable();
285        } else {  // notEditable
286            return mSettings.getEntityListNotEditable();
287        }
288    }
289
290    /** @inheritDoc */
291    @Override
292    public Logger getLogger(@NonNull Logger.Config config) {
293        Preconditions.checkNotNull(config);
294        synchronized (mLoggerLock) {
295            if (mLogger == null || !config.equals(mLoggerConfig)) {
296                mLoggerConfig = config;
297                mLogger = new DefaultLogger(config);
298            }
299        }
300        return mLogger;
301    }
302
303    private TextClassifierImplNative getNative(LocaleList localeList)
304            throws FileNotFoundException {
305        synchronized (mLock) {
306            localeList = localeList == null ? LocaleList.getEmptyLocaleList() : localeList;
307            final ModelFile bestModel = findBestModelLocked(localeList);
308            if (bestModel == null) {
309                throw new FileNotFoundException("No model for " + localeList.toLanguageTags());
310            }
311            if (mNative == null || !Objects.equals(mModel, bestModel)) {
312                Log.d(DEFAULT_LOG_TAG, "Loading " + bestModel);
313                destroyNativeIfExistsLocked();
314                final ParcelFileDescriptor fd = ParcelFileDescriptor.open(
315                        new File(bestModel.getPath()), ParcelFileDescriptor.MODE_READ_ONLY);
316                mNative = new TextClassifierImplNative(fd.getFd());
317                closeAndLogError(fd);
318                mModel = bestModel;
319            }
320            return mNative;
321        }
322    }
323
324    private String getSignature(String text, int start, int end) {
325        synchronized (mLock) {
326            return DefaultLogger.createSignature(text, start, end, mContext, mModel.getVersion(),
327                    mModel.getSupportedLocales());
328        }
329    }
330
331    @GuardedBy("mLock") // Do not call outside this lock.
332    private void destroyNativeIfExistsLocked() {
333        if (mNative != null) {
334            mNative.close();
335            mNative = null;
336        }
337    }
338
339    private static String concatenateLocales(@Nullable LocaleList locales) {
340        return (locales == null) ? "" : locales.toLanguageTags();
341    }
342
343    /**
344     * Finds the most appropriate model to use for the given target locale list.
345     *
346     * The basic logic is: we ignore all models that don't support any of the target locales. For
347     * the remaining candidates, we take the update model unless its version number is lower than
348     * the factory version. It's assumed that factory models do not have overlapping locale ranges
349     * and conflict resolution between these models hence doesn't matter.
350     */
351    @GuardedBy("mLock") // Do not call outside this lock.
352    @Nullable
353    private ModelFile findBestModelLocked(LocaleList localeList) {
354        // Specified localeList takes priority over the system default, so it is listed first.
355        final String languages = localeList.isEmpty()
356                ? LocaleList.getDefault().toLanguageTags()
357                : localeList.toLanguageTags() + "," + LocaleList.getDefault().toLanguageTags();
358        final List<Locale.LanguageRange> languageRangeList = Locale.LanguageRange.parse(languages);
359
360        ModelFile bestModel = null;
361        for (ModelFile model : listAllModelsLocked()) {
362            if (model.isAnyLanguageSupported(languageRangeList)) {
363                if (model.isPreferredTo(bestModel)) {
364                    bestModel = model;
365                }
366            }
367        }
368        return bestModel;
369    }
370
371    /** Returns a list of all model files available, in order of precedence. */
372    @GuardedBy("mLock") // Do not call outside this lock.
373    private List<ModelFile> listAllModelsLocked() {
374        if (mAllModelFiles == null) {
375            final List<ModelFile> allModels = new ArrayList<>();
376            // The update model has the highest precedence.
377            if (new File(UPDATED_MODEL_FILE_PATH).exists()) {
378                final ModelFile updatedModel = ModelFile.fromPath(UPDATED_MODEL_FILE_PATH);
379                if (updatedModel != null) {
380                    allModels.add(updatedModel);
381                }
382            }
383            // Factory models should never have overlapping locales, so the order doesn't matter.
384            final File modelsDir = new File(MODEL_DIR);
385            if (modelsDir.exists() && modelsDir.isDirectory()) {
386                final File[] modelFiles = modelsDir.listFiles();
387                final Pattern modelFilenamePattern = Pattern.compile(MODEL_FILE_REGEX);
388                for (File modelFile : modelFiles) {
389                    final Matcher matcher = modelFilenamePattern.matcher(modelFile.getName());
390                    if (matcher.matches() && modelFile.isFile()) {
391                        final ModelFile model = ModelFile.fromPath(modelFile.getAbsolutePath());
392                        if (model != null) {
393                            allModels.add(model);
394                        }
395                    }
396                }
397            }
398            mAllModelFiles = allModels;
399        }
400        return mAllModelFiles;
401    }
402
403    private TextClassification createClassificationResult(
404            TextClassifierImplNative.ClassificationResult[] classifications,
405            String text, int start, int end, @Nullable Calendar referenceTime) {
406        final String classifiedText = text.substring(start, end);
407        final TextClassification.Builder builder = new TextClassification.Builder()
408                .setText(classifiedText);
409
410        final int size = classifications.length;
411        TextClassifierImplNative.ClassificationResult highestScoringResult = null;
412        float highestScore = Float.MIN_VALUE;
413        for (int i = 0; i < size; i++) {
414            builder.setEntityType(classifications[i].getCollection(),
415                                  classifications[i].getScore());
416            if (classifications[i].getScore() > highestScore) {
417                highestScoringResult = classifications[i];
418                highestScore = classifications[i].getScore();
419            }
420        }
421
422        boolean isPrimaryAction = true;
423        for (LabeledIntent labeledIntent : IntentFactory.create(
424                mContext, referenceTime, highestScoringResult, classifiedText)) {
425            RemoteAction action = labeledIntent.asRemoteAction(mContext);
426            if (isPrimaryAction) {
427                // For O backwards compatibility, the first RemoteAction is also written to the
428                // legacy API fields.
429                builder.setIcon(action.getIcon().loadDrawable(mContext));
430                builder.setLabel(action.getTitle().toString());
431                builder.setIntent(labeledIntent.getIntent());
432                builder.setOnClickListener(TextClassification.createIntentOnClickListener(
433                        TextClassification.createPendingIntent(mContext,
434                                labeledIntent.getIntent())));
435                isPrimaryAction = false;
436            }
437            builder.addAction(action);
438        }
439
440        return builder.setSignature(getSignature(text, start, end)).build();
441    }
442
443    /**
444     * Closes the ParcelFileDescriptor and logs any errors that occur.
445     */
446    private static void closeAndLogError(ParcelFileDescriptor fd) {
447        try {
448            fd.close();
449        } catch (IOException e) {
450            Log.e(LOG_TAG, "Error closing file.", e);
451        }
452    }
453
454    /**
455     * Describes TextClassifier model files on disk.
456     */
457    private static final class ModelFile {
458
459        private final String mPath;
460        private final String mName;
461        private final int mVersion;
462        private final List<Locale> mSupportedLocales;
463        private final boolean mLanguageIndependent;
464
465        /** Returns null if the path did not point to a compatible model. */
466        static @Nullable ModelFile fromPath(String path) {
467            final File file = new File(path);
468            try {
469                final ParcelFileDescriptor modelFd = ParcelFileDescriptor.open(
470                        file, ParcelFileDescriptor.MODE_READ_ONLY);
471                final int version = TextClassifierImplNative.getVersion(modelFd.getFd());
472                final String supportedLocalesStr =
473                        TextClassifierImplNative.getLocales(modelFd.getFd());
474                if (supportedLocalesStr.isEmpty()) {
475                    Log.d(DEFAULT_LOG_TAG, "Ignoring " + file.getAbsolutePath());
476                    return null;
477                }
478                final boolean languageIndependent = supportedLocalesStr.equals("*");
479                final List<Locale> supportedLocales = new ArrayList<>();
480                for (String langTag : supportedLocalesStr.split(",")) {
481                    supportedLocales.add(Locale.forLanguageTag(langTag));
482                }
483                closeAndLogError(modelFd);
484                return new ModelFile(path, file.getName(), version, supportedLocales,
485                                     languageIndependent);
486            } catch (FileNotFoundException e) {
487                Log.e(DEFAULT_LOG_TAG, "Failed to peek " + file.getAbsolutePath(), e);
488                return null;
489            }
490        }
491
492        /** The absolute path to the model file. */
493        String getPath() {
494            return mPath;
495        }
496
497        /** A name to use for signature generation. Effectively the name of the model file. */
498        String getName() {
499            return mName;
500        }
501
502        /** Returns the version tag in the model's metadata. */
503        int getVersion() {
504            return mVersion;
505        }
506
507        /** Returns whether the language supports any language in the given ranges. */
508        boolean isAnyLanguageSupported(List<Locale.LanguageRange> languageRanges) {
509            return mLanguageIndependent || Locale.lookup(languageRanges, mSupportedLocales) != null;
510        }
511
512        /** All locales supported by the model. */
513        List<Locale> getSupportedLocales() {
514            return Collections.unmodifiableList(mSupportedLocales);
515        }
516
517        public boolean isPreferredTo(ModelFile model) {
518            // A model is preferred to no model.
519            if (model == null) {
520                return true;
521            }
522
523            // A language-specific model is preferred to a language independent
524            // model.
525            if (!mLanguageIndependent && model.mLanguageIndependent) {
526                return true;
527            }
528
529            // A higher-version model is preferred.
530            if (getVersion() > model.getVersion()) {
531                return true;
532            }
533            return false;
534        }
535
536        @Override
537        public boolean equals(Object other) {
538            if (this == other) {
539                return true;
540            } else if (other == null || !ModelFile.class.isAssignableFrom(other.getClass())) {
541                return false;
542            } else {
543                final ModelFile otherModel = (ModelFile) other;
544                return mPath.equals(otherModel.mPath);
545            }
546        }
547
548        @Override
549        public String toString() {
550            final StringJoiner localesJoiner = new StringJoiner(",");
551            for (Locale locale : mSupportedLocales) {
552                localesJoiner.add(locale.toLanguageTag());
553            }
554            return String.format(Locale.US, "ModelFile { path=%s name=%s version=%d locales=%s }",
555                    mPath, mName, mVersion, localesJoiner.toString());
556        }
557
558        private ModelFile(String path, String name, int version, List<Locale> supportedLocales,
559                          boolean languageIndependent) {
560            mPath = path;
561            mName = name;
562            mVersion = version;
563            mSupportedLocales = supportedLocales;
564            mLanguageIndependent = languageIndependent;
565        }
566    }
567
568    /**
569     * Helper class to store the information from which RemoteActions are built.
570     */
571    private static final class LabeledIntent {
572        private String mTitle;
573        private String mDescription;
574        private Intent mIntent;
575
576        LabeledIntent(String title, String description, Intent intent) {
577            mTitle = title;
578            mDescription = description;
579            mIntent = intent;
580        }
581
582        String getTitle() {
583            return mTitle;
584        }
585
586        String getDescription() {
587            return mDescription;
588        }
589
590        Intent getIntent() {
591            return mIntent;
592        }
593
594        RemoteAction asRemoteAction(Context context) {
595            final PackageManager pm = context.getPackageManager();
596            final ResolveInfo resolveInfo = pm.resolveActivity(mIntent, 0);
597            final String packageName = resolveInfo != null && resolveInfo.activityInfo != null
598                    ? resolveInfo.activityInfo.packageName : null;
599            Icon icon = null;
600            boolean shouldShowIcon = false;
601            if (packageName != null && !"android".equals(packageName)) {
602                // There is a default activity handling the intent.
603                mIntent.setComponent(new ComponentName(packageName, resolveInfo.activityInfo.name));
604                if (resolveInfo.activityInfo.getIconResource() != 0) {
605                    icon = Icon.createWithResource(
606                            packageName, resolveInfo.activityInfo.getIconResource());
607                    shouldShowIcon = true;
608                }
609            }
610            if (icon == null) {
611                // RemoteAction requires that there be an icon.
612                icon = Icon.createWithResource("android",
613                        com.android.internal.R.drawable.ic_more_items);
614            }
615            RemoteAction action = new RemoteAction(icon, mTitle, mDescription,
616                    TextClassification.createPendingIntent(context, mIntent));
617            action.setShouldShowIcon(shouldShowIcon);
618            return action;
619        }
620    }
621
622    /**
623     * Creates intents based on the classification type.
624     */
625    static final class IntentFactory {
626
627        private static final long MIN_EVENT_FUTURE_MILLIS = TimeUnit.MINUTES.toMillis(5);
628        private static final long DEFAULT_EVENT_DURATION = TimeUnit.HOURS.toMillis(1);
629
630        private IntentFactory() {}
631
632        @NonNull
633        public static List<LabeledIntent> create(
634                Context context,
635                @Nullable Calendar referenceTime,
636                TextClassifierImplNative.ClassificationResult classification,
637                String text) {
638            final String type = classification.getCollection().trim().toLowerCase(Locale.ENGLISH);
639            text = text.trim();
640            switch (type) {
641                case TextClassifier.TYPE_EMAIL:
642                    return createForEmail(context, text);
643                case TextClassifier.TYPE_PHONE:
644                    return createForPhone(context, text);
645                case TextClassifier.TYPE_ADDRESS:
646                    return createForAddress(context, text);
647                case TextClassifier.TYPE_URL:
648                    return createForUrl(context, text);
649                case TextClassifier.TYPE_DATE:
650                case TextClassifier.TYPE_DATE_TIME:
651                    if (classification.getDatetimeResult() != null) {
652                        Calendar eventTime = Calendar.getInstance();
653                        eventTime.setTimeInMillis(
654                                classification.getDatetimeResult().getTimeMsUtc());
655                        return createForDatetime(context, type, referenceTime, eventTime);
656                    } else {
657                        return new ArrayList<>();
658                    }
659                case TextClassifier.TYPE_FLIGHT_NUMBER:
660                    return createForFlight(context, text);
661                default:
662                    return new ArrayList<>();
663            }
664        }
665
666        @NonNull
667        private static List<LabeledIntent> createForEmail(Context context, String text) {
668            return Arrays.asList(
669                    new LabeledIntent(
670                            context.getString(com.android.internal.R.string.email),
671                            context.getString(com.android.internal.R.string.email_desc),
672                            new Intent(Intent.ACTION_SENDTO)
673                                    .setData(Uri.parse(String.format("mailto:%s", text)))),
674                    new LabeledIntent(
675                            context.getString(com.android.internal.R.string.add_contact),
676                            context.getString(com.android.internal.R.string.add_contact_desc),
677                            new Intent(Intent.ACTION_INSERT_OR_EDIT)
678                                    .setType(ContactsContract.Contacts.CONTENT_ITEM_TYPE)
679                                    .putExtra(ContactsContract.Intents.Insert.EMAIL, text)));
680        }
681
682        @NonNull
683        private static List<LabeledIntent> createForPhone(Context context, String text) {
684            final List<LabeledIntent> actions = new ArrayList<>();
685            final UserManager userManager = context.getSystemService(UserManager.class);
686            final Bundle userRestrictions = userManager != null
687                    ? userManager.getUserRestrictions() : new Bundle();
688            if (!userRestrictions.getBoolean(UserManager.DISALLOW_OUTGOING_CALLS, false)) {
689                actions.add(new LabeledIntent(
690                        context.getString(com.android.internal.R.string.dial),
691                        context.getString(com.android.internal.R.string.dial_desc),
692                        new Intent(Intent.ACTION_DIAL).setData(
693                                Uri.parse(String.format("tel:%s", text)))));
694            }
695            actions.add(new LabeledIntent(
696                    context.getString(com.android.internal.R.string.add_contact),
697                    context.getString(com.android.internal.R.string.add_contact_desc),
698                    new Intent(Intent.ACTION_INSERT_OR_EDIT)
699                            .setType(ContactsContract.Contacts.CONTENT_ITEM_TYPE)
700                            .putExtra(ContactsContract.Intents.Insert.PHONE, text)));
701            if (!userRestrictions.getBoolean(UserManager.DISALLOW_SMS, false)) {
702                actions.add(new LabeledIntent(
703                        context.getString(com.android.internal.R.string.sms),
704                        context.getString(com.android.internal.R.string.sms_desc),
705                        new Intent(Intent.ACTION_SENDTO)
706                                .setData(Uri.parse(String.format("smsto:%s", text)))));
707            }
708            return actions;
709        }
710
711        @NonNull
712        private static List<LabeledIntent> createForAddress(Context context, String text) {
713            final List<LabeledIntent> actions = new ArrayList<>();
714            try {
715                final String encText = URLEncoder.encode(text, "UTF-8");
716                actions.add(new LabeledIntent(
717                        context.getString(com.android.internal.R.string.map),
718                        context.getString(com.android.internal.R.string.map_desc),
719                        new Intent(Intent.ACTION_VIEW)
720                                .setData(Uri.parse(String.format("geo:0,0?q=%s", encText)))));
721            } catch (UnsupportedEncodingException e) {
722                Log.e(LOG_TAG, "Could not encode address", e);
723            }
724            return actions;
725        }
726
727        @NonNull
728        private static List<LabeledIntent> createForUrl(Context context, String text) {
729            final String httpPrefix = "http://";
730            final String httpsPrefix = "https://";
731            if (text.toLowerCase().startsWith(httpPrefix)) {
732                text = httpPrefix + text.substring(httpPrefix.length());
733            } else if (text.toLowerCase().startsWith(httpsPrefix)) {
734                text = httpsPrefix + text.substring(httpsPrefix.length());
735            } else {
736                text = httpPrefix + text;
737            }
738            return Arrays.asList(new LabeledIntent(
739                    context.getString(com.android.internal.R.string.browse),
740                    context.getString(com.android.internal.R.string.browse_desc),
741                    new Intent(Intent.ACTION_VIEW, Uri.parse(text))
742                            .putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName())));
743        }
744
745        @NonNull
746        private static List<LabeledIntent> createForDatetime(
747                Context context, String type, @Nullable Calendar referenceTime,
748                Calendar eventTime) {
749            if (referenceTime == null) {
750                // If no reference time was given, use now.
751                referenceTime = Calendar.getInstance();
752            }
753            List<LabeledIntent> actions = new ArrayList<>();
754            actions.add(createCalendarViewIntent(context, eventTime));
755            final long millisSinceReference =
756                    eventTime.getTimeInMillis() - referenceTime.getTimeInMillis();
757            if (millisSinceReference > MIN_EVENT_FUTURE_MILLIS) {
758                actions.add(createCalendarCreateEventIntent(context, eventTime, type));
759            }
760            return actions;
761        }
762
763        @NonNull
764        private static List<LabeledIntent> createForFlight(Context context, String text) {
765            return Arrays.asList(new LabeledIntent(
766                    context.getString(com.android.internal.R.string.view_flight),
767                    context.getString(com.android.internal.R.string.view_flight_desc),
768                    new Intent(Intent.ACTION_WEB_SEARCH)
769                            .putExtra(SearchManager.QUERY, text)));
770        }
771
772        @NonNull
773        private static LabeledIntent createCalendarViewIntent(Context context, Calendar eventTime) {
774            Uri.Builder builder = CalendarContract.CONTENT_URI.buildUpon();
775            builder.appendPath("time");
776            ContentUris.appendId(builder, eventTime.getTimeInMillis());
777            return new LabeledIntent(
778                    context.getString(com.android.internal.R.string.view_calendar),
779                    context.getString(com.android.internal.R.string.view_calendar_desc),
780                    new Intent(Intent.ACTION_VIEW).setData(builder.build()));
781        }
782
783        @NonNull
784        private static LabeledIntent createCalendarCreateEventIntent(
785                Context context, Calendar eventTime, @EntityType String type) {
786            final boolean isAllDay = TextClassifier.TYPE_DATE.equals(type);
787            return new LabeledIntent(
788                    context.getString(com.android.internal.R.string.add_calendar_event),
789                    context.getString(com.android.internal.R.string.add_calendar_event_desc),
790                    new Intent(Intent.ACTION_INSERT)
791                            .setData(CalendarContract.Events.CONTENT_URI)
792                            .putExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, isAllDay)
793                            .putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME,
794                                    eventTime.getTimeInMillis())
795                            .putExtra(CalendarContract.EXTRA_EVENT_END_TIME,
796                                    eventTime.getTimeInMillis() + DEFAULT_EVENT_DURATION));
797        }
798    }
799}
800