TextClassifierImpl.java revision 253827f207be31399a21c390f90ce3ffe4b020c0
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.PendingIntent;
25import android.app.RemoteAction;
26import android.app.SearchManager;
27import android.content.ComponentName;
28import android.content.ContentUris;
29import android.content.Context;
30import android.content.Intent;
31import android.content.pm.PackageManager;
32import android.content.pm.ResolveInfo;
33import android.graphics.drawable.Icon;
34import android.net.Uri;
35import android.os.Bundle;
36import android.os.LocaleList;
37import android.os.ParcelFileDescriptor;
38import android.os.UserManager;
39import android.provider.Browser;
40import android.provider.CalendarContract;
41import android.provider.ContactsContract;
42
43import com.android.internal.annotations.GuardedBy;
44import com.android.internal.util.Preconditions;
45
46import java.io.File;
47import java.io.FileNotFoundException;
48import java.io.IOException;
49import java.io.UnsupportedEncodingException;
50import java.net.URLEncoder;
51import java.time.Instant;
52import java.time.ZonedDateTime;
53import java.util.ArrayList;
54import java.util.Arrays;
55import java.util.Collection;
56import java.util.Collections;
57import java.util.HashMap;
58import java.util.List;
59import java.util.Locale;
60import java.util.Map;
61import java.util.Objects;
62import java.util.StringJoiner;
63import java.util.concurrent.TimeUnit;
64import java.util.regex.Matcher;
65import 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 */
76public 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            final String httpPrefix = "http://";
759            final String httpsPrefix = "https://";
760            if (text.toLowerCase().startsWith(httpPrefix)) {
761                text = httpPrefix + text.substring(httpPrefix.length());
762            } else if (text.toLowerCase().startsWith(httpsPrefix)) {
763                text = httpsPrefix + text.substring(httpsPrefix.length());
764            } else {
765                text = httpPrefix + text;
766            }
767            return Arrays.asList(new LabeledIntent(
768                    context.getString(com.android.internal.R.string.browse),
769                    context.getString(com.android.internal.R.string.browse_desc),
770                    new Intent(Intent.ACTION_VIEW, Uri.parse(text))
771                            .putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName()),
772                    LabeledIntent.DEFAULT_REQUEST_CODE));
773        }
774
775        @NonNull
776        private static List<LabeledIntent> createForDatetime(
777                Context context, String type, @Nullable Instant referenceTime,
778                Instant parsedTime) {
779            if (referenceTime == null) {
780                // If no reference time was given, use now.
781                referenceTime = Instant.now();
782            }
783            List<LabeledIntent> actions = new ArrayList<>();
784            actions.add(createCalendarViewIntent(context, parsedTime));
785            final long millisUntilEvent = referenceTime.until(parsedTime, MILLIS);
786            if (millisUntilEvent > MIN_EVENT_FUTURE_MILLIS) {
787                actions.add(createCalendarCreateEventIntent(context, parsedTime, type));
788            }
789            return actions;
790        }
791
792        @NonNull
793        private static List<LabeledIntent> createForFlight(Context context, String text) {
794            return Arrays.asList(new LabeledIntent(
795                    context.getString(com.android.internal.R.string.view_flight),
796                    context.getString(com.android.internal.R.string.view_flight_desc),
797                    new Intent(Intent.ACTION_WEB_SEARCH)
798                            .putExtra(SearchManager.QUERY, text),
799                    text.hashCode()));
800        }
801
802        @NonNull
803        private static LabeledIntent createCalendarViewIntent(Context context, Instant parsedTime) {
804            Uri.Builder builder = CalendarContract.CONTENT_URI.buildUpon();
805            builder.appendPath("time");
806            ContentUris.appendId(builder, parsedTime.toEpochMilli());
807            return new LabeledIntent(
808                    context.getString(com.android.internal.R.string.view_calendar),
809                    context.getString(com.android.internal.R.string.view_calendar_desc),
810                    new Intent(Intent.ACTION_VIEW).setData(builder.build()),
811                    LabeledIntent.DEFAULT_REQUEST_CODE);
812        }
813
814        @NonNull
815        private static LabeledIntent createCalendarCreateEventIntent(
816                Context context, Instant parsedTime, @EntityType String type) {
817            final boolean isAllDay = TextClassifier.TYPE_DATE.equals(type);
818            return new LabeledIntent(
819                    context.getString(com.android.internal.R.string.add_calendar_event),
820                    context.getString(com.android.internal.R.string.add_calendar_event_desc),
821                    new Intent(Intent.ACTION_INSERT)
822                            .setData(CalendarContract.Events.CONTENT_URI)
823                            .putExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, isAllDay)
824                            .putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME,
825                                    parsedTime.toEpochMilli())
826                            .putExtra(CalendarContract.EXTRA_EVENT_END_TIME,
827                                    parsedTime.toEpochMilli() + DEFAULT_EVENT_DURATION),
828                    parsedTime.hashCode());
829        }
830    }
831}
832