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