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