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