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