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 android.annotation.NonNull;
     20 import android.annotation.Nullable;
     21 import android.content.ComponentName;
     22 import android.content.Context;
     23 import android.content.Intent;
     24 import android.content.pm.PackageManager;
     25 import android.content.pm.ResolveInfo;
     26 import android.graphics.drawable.Drawable;
     27 import android.net.Uri;
     28 import android.os.LocaleList;
     29 import android.os.ParcelFileDescriptor;
     30 import android.provider.Browser;
     31 import android.provider.Settings;
     32 import android.text.Spannable;
     33 import android.text.TextUtils;
     34 import android.text.method.WordIterator;
     35 import android.text.style.ClickableSpan;
     36 import android.text.util.Linkify;
     37 import android.util.Log;
     38 import android.util.Patterns;
     39 import android.view.View;
     40 import android.widget.TextViewMetrics;
     41 
     42 import com.android.internal.annotations.GuardedBy;
     43 import com.android.internal.logging.MetricsLogger;
     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.text.BreakIterator;
     50 import java.util.ArrayList;
     51 import java.util.Collections;
     52 import java.util.Comparator;
     53 import java.util.HashMap;
     54 import java.util.LinkedHashMap;
     55 import java.util.LinkedList;
     56 import java.util.List;
     57 import java.util.Locale;
     58 import java.util.Map;
     59 import java.util.Objects;
     60 import java.util.regex.Matcher;
     61 import java.util.regex.Pattern;
     62 
     63 /**
     64  * Default implementation of the {@link TextClassifier} interface.
     65  *
     66  * <p>This class uses machine learning to recognize entities in text.
     67  * Unless otherwise stated, methods of this class are blocking operations and should most
     68  * likely not be called on the UI thread.
     69  *
     70  * @hide
     71  */
     72 final class TextClassifierImpl implements TextClassifier {
     73 
     74     private static final String LOG_TAG = DEFAULT_LOG_TAG;
     75     private static final String MODEL_DIR = "/etc/textclassifier/";
     76     private static final String MODEL_FILE_REGEX = "textclassifier\\.smartselection\\.(.*)\\.model";
     77     private static final String UPDATED_MODEL_FILE_PATH =
     78             "/data/misc/textclassifier/textclassifier.smartselection.model";
     79 
     80     private final Context mContext;
     81 
     82     private final MetricsLogger mMetricsLogger = new MetricsLogger();
     83 
     84     private final Object mSmartSelectionLock = new Object();
     85     @GuardedBy("mSmartSelectionLock") // Do not access outside this lock.
     86     private Map<Locale, String> mModelFilePaths;
     87     @GuardedBy("mSmartSelectionLock") // Do not access outside this lock.
     88     private Locale mLocale;
     89     @GuardedBy("mSmartSelectionLock") // Do not access outside this lock.
     90     private int mVersion;
     91     @GuardedBy("mSmartSelectionLock") // Do not access outside this lock.
     92     private SmartSelection mSmartSelection;
     93 
     94     private TextClassifierConstants mSettings;
     95 
     96     TextClassifierImpl(Context context) {
     97         mContext = Preconditions.checkNotNull(context);
     98     }
     99 
    100     @Override
    101     public TextSelection suggestSelection(
    102             @NonNull CharSequence text, int selectionStartIndex, int selectionEndIndex,
    103             @Nullable LocaleList defaultLocales) {
    104         validateInput(text, selectionStartIndex, selectionEndIndex);
    105         try {
    106             if (text.length() > 0) {
    107                 final SmartSelection smartSelection = getSmartSelection(defaultLocales);
    108                 final String string = text.toString();
    109                 final int[] startEnd = smartSelection.suggest(
    110                         string, selectionStartIndex, selectionEndIndex);
    111                 final int start = startEnd[0];
    112                 final int end = startEnd[1];
    113                 if (start <= end
    114                         && start >= 0 && end <= string.length()
    115                         && start <= selectionStartIndex && end >= selectionEndIndex) {
    116                     final TextSelection.Builder tsBuilder = new TextSelection.Builder(start, end);
    117                     final SmartSelection.ClassificationResult[] results =
    118                             smartSelection.classifyText(
    119                                     string, start, end,
    120                                     getHintFlags(string, start, end));
    121                     final int size = results.length;
    122                     for (int i = 0; i < size; i++) {
    123                         tsBuilder.setEntityType(results[i].mCollection, results[i].mScore);
    124                     }
    125                     return tsBuilder
    126                             .setLogSource(LOG_TAG)
    127                             .setVersionInfo(getVersionInfo())
    128                             .build();
    129                 } else {
    130                     // We can not trust the result. Log the issue and ignore the result.
    131                     Log.d(LOG_TAG, "Got bad indices for input text. Ignoring result.");
    132                 }
    133             }
    134         } catch (Throwable t) {
    135             // Avoid throwing from this method. Log the error.
    136             Log.e(LOG_TAG,
    137                     "Error suggesting selection for text. No changes to selection suggested.",
    138                     t);
    139         }
    140         // Getting here means something went wrong, return a NO_OP result.
    141         return TextClassifier.NO_OP.suggestSelection(
    142                 text, selectionStartIndex, selectionEndIndex, defaultLocales);
    143     }
    144 
    145     @Override
    146     public TextClassification classifyText(
    147             @NonNull CharSequence text, int startIndex, int endIndex,
    148             @Nullable LocaleList defaultLocales) {
    149         validateInput(text, startIndex, endIndex);
    150         try {
    151             if (text.length() > 0) {
    152                 final String string = text.toString();
    153                 SmartSelection.ClassificationResult[] results = getSmartSelection(defaultLocales)
    154                         .classifyText(string, startIndex, endIndex,
    155                                 getHintFlags(string, startIndex, endIndex));
    156                 if (results.length > 0) {
    157                     final TextClassification classificationResult =
    158                             createClassificationResult(
    159                                     results, string.subSequence(startIndex, endIndex));
    160                     return classificationResult;
    161                 }
    162             }
    163         } catch (Throwable t) {
    164             // Avoid throwing from this method. Log the error.
    165             Log.e(LOG_TAG, "Error getting assist info.", t);
    166         }
    167         // Getting here means something went wrong, return a NO_OP result.
    168         return TextClassifier.NO_OP.classifyText(
    169                 text, startIndex, endIndex, defaultLocales);
    170     }
    171 
    172     @Override
    173     public LinksInfo getLinks(
    174             @NonNull CharSequence text, int linkMask, @Nullable LocaleList defaultLocales) {
    175         Preconditions.checkArgument(text != null);
    176         try {
    177             return LinksInfoFactory.create(
    178                     mContext, getSmartSelection(defaultLocales), text.toString(), linkMask);
    179         } catch (Throwable t) {
    180             // Avoid throwing from this method. Log the error.
    181             Log.e(LOG_TAG, "Error getting links info.", t);
    182         }
    183         // Getting here means something went wrong, return a NO_OP result.
    184         return TextClassifier.NO_OP.getLinks(text, linkMask, defaultLocales);
    185     }
    186 
    187     @Override
    188     public void logEvent(String source, String event) {
    189         if (LOG_TAG.equals(source)) {
    190             mMetricsLogger.count(event, 1);
    191         }
    192     }
    193 
    194     @Override
    195     public TextClassifierConstants getSettings() {
    196         if (mSettings == null) {
    197             mSettings = TextClassifierConstants.loadFromString(Settings.Global.getString(
    198                     mContext.getContentResolver(), Settings.Global.TEXT_CLASSIFIER_CONSTANTS));
    199         }
    200         return mSettings;
    201     }
    202 
    203     private SmartSelection getSmartSelection(LocaleList localeList) throws FileNotFoundException {
    204         synchronized (mSmartSelectionLock) {
    205             localeList = localeList == null ? LocaleList.getEmptyLocaleList() : localeList;
    206             final Locale locale = findBestSupportedLocaleLocked(localeList);
    207             if (locale == null) {
    208                 throw new FileNotFoundException("No file for null locale");
    209             }
    210             if (mSmartSelection == null || !Objects.equals(mLocale, locale)) {
    211                 destroySmartSelectionIfExistsLocked();
    212                 final ParcelFileDescriptor fd = getFdLocked(locale);
    213                 mSmartSelection = new SmartSelection(fd.getFd());
    214                 closeAndLogError(fd);
    215                 mLocale = locale;
    216             }
    217             return mSmartSelection;
    218         }
    219     }
    220 
    221     @NonNull
    222     private String getVersionInfo() {
    223         synchronized (mSmartSelectionLock) {
    224             if (mLocale != null) {
    225                 return String.format("%s_v%d", mLocale.toLanguageTag(), mVersion);
    226             }
    227             return "";
    228         }
    229     }
    230 
    231     @GuardedBy("mSmartSelectionLock") // Do not call outside this lock.
    232     private ParcelFileDescriptor getFdLocked(Locale locale) throws FileNotFoundException {
    233         ParcelFileDescriptor updateFd;
    234         try {
    235             updateFd = ParcelFileDescriptor.open(
    236                     new File(UPDATED_MODEL_FILE_PATH), ParcelFileDescriptor.MODE_READ_ONLY);
    237         } catch (FileNotFoundException e) {
    238             updateFd = null;
    239         }
    240         ParcelFileDescriptor factoryFd;
    241         try {
    242             final String factoryModelFilePath = getFactoryModelFilePathsLocked().get(locale);
    243             if (factoryModelFilePath != null) {
    244                 factoryFd = ParcelFileDescriptor.open(
    245                         new File(factoryModelFilePath), ParcelFileDescriptor.MODE_READ_ONLY);
    246             } else {
    247                 factoryFd = null;
    248             }
    249         } catch (FileNotFoundException e) {
    250             factoryFd = null;
    251         }
    252 
    253         if (updateFd == null) {
    254             if (factoryFd != null) {
    255                 return factoryFd;
    256             } else {
    257                 throw new FileNotFoundException(
    258                         String.format("No model file found for %s", locale));
    259             }
    260         }
    261 
    262         final int updateFdInt = updateFd.getFd();
    263         final boolean localeMatches = Objects.equals(
    264                 locale.getLanguage().trim().toLowerCase(),
    265                 SmartSelection.getLanguage(updateFdInt).trim().toLowerCase());
    266         if (factoryFd == null) {
    267             if (localeMatches) {
    268                 return updateFd;
    269             } else {
    270                 closeAndLogError(updateFd);
    271                 throw new FileNotFoundException(
    272                         String.format("No model file found for %s", locale));
    273             }
    274         }
    275 
    276         if (!localeMatches) {
    277             closeAndLogError(updateFd);
    278             return factoryFd;
    279         }
    280 
    281         final int updateVersion = SmartSelection.getVersion(updateFdInt);
    282         final int factoryVersion = SmartSelection.getVersion(factoryFd.getFd());
    283         if (updateVersion > factoryVersion) {
    284             closeAndLogError(factoryFd);
    285             mVersion = updateVersion;
    286             return updateFd;
    287         } else {
    288             closeAndLogError(updateFd);
    289             mVersion = factoryVersion;
    290             return factoryFd;
    291         }
    292     }
    293 
    294     @GuardedBy("mSmartSelectionLock") // Do not call outside this lock.
    295     private void destroySmartSelectionIfExistsLocked() {
    296         if (mSmartSelection != null) {
    297             mSmartSelection.close();
    298             mSmartSelection = null;
    299         }
    300     }
    301 
    302     @GuardedBy("mSmartSelectionLock") // Do not call outside this lock.
    303     @Nullable
    304     private Locale findBestSupportedLocaleLocked(LocaleList localeList) {
    305         // Specified localeList takes priority over the system default, so it is listed first.
    306         final String languages = localeList.isEmpty()
    307                 ? LocaleList.getDefault().toLanguageTags()
    308                 : localeList.toLanguageTags() + "," + LocaleList.getDefault().toLanguageTags();
    309         final List<Locale.LanguageRange> languageRangeList = Locale.LanguageRange.parse(languages);
    310 
    311         final List<Locale> supportedLocales =
    312                 new ArrayList<>(getFactoryModelFilePathsLocked().keySet());
    313         final Locale updatedModelLocale = getUpdatedModelLocale();
    314         if (updatedModelLocale != null) {
    315             supportedLocales.add(updatedModelLocale);
    316         }
    317         return Locale.lookup(languageRangeList, supportedLocales);
    318     }
    319 
    320     @GuardedBy("mSmartSelectionLock") // Do not call outside this lock.
    321     private Map<Locale, String> getFactoryModelFilePathsLocked() {
    322         if (mModelFilePaths == null) {
    323             final Map<Locale, String> modelFilePaths = new HashMap<>();
    324             final File modelsDir = new File(MODEL_DIR);
    325             if (modelsDir.exists() && modelsDir.isDirectory()) {
    326                 final File[] models = modelsDir.listFiles();
    327                 final Pattern modelFilenamePattern = Pattern.compile(MODEL_FILE_REGEX);
    328                 final int size = models.length;
    329                 for (int i = 0; i < size; i++) {
    330                     final File modelFile = models[i];
    331                     final Matcher matcher = modelFilenamePattern.matcher(modelFile.getName());
    332                     if (matcher.matches() && modelFile.isFile()) {
    333                         final String language = matcher.group(1);
    334                         final Locale locale = Locale.forLanguageTag(language);
    335                         modelFilePaths.put(locale, modelFile.getAbsolutePath());
    336                     }
    337                 }
    338             }
    339             mModelFilePaths = modelFilePaths;
    340         }
    341         return mModelFilePaths;
    342     }
    343 
    344     @Nullable
    345     private Locale getUpdatedModelLocale() {
    346         try {
    347             final ParcelFileDescriptor updateFd = ParcelFileDescriptor.open(
    348                     new File(UPDATED_MODEL_FILE_PATH), ParcelFileDescriptor.MODE_READ_ONLY);
    349             final Locale locale = Locale.forLanguageTag(
    350                     SmartSelection.getLanguage(updateFd.getFd()));
    351             closeAndLogError(updateFd);
    352             return locale;
    353         } catch (FileNotFoundException e) {
    354             return null;
    355         }
    356     }
    357 
    358     private TextClassification createClassificationResult(
    359             SmartSelection.ClassificationResult[] classifications, CharSequence text) {
    360         final TextClassification.Builder builder = new TextClassification.Builder()
    361                 .setText(text.toString());
    362 
    363         final int size = classifications.length;
    364         for (int i = 0; i < size; i++) {
    365             builder.setEntityType(classifications[i].mCollection, classifications[i].mScore);
    366         }
    367 
    368         final String type = getHighestScoringType(classifications);
    369         builder.setLogType(IntentFactory.getLogType(type));
    370 
    371         final Intent intent = IntentFactory.create(mContext, type, text.toString());
    372         final PackageManager pm;
    373         final ResolveInfo resolveInfo;
    374         if (intent != null) {
    375             pm = mContext.getPackageManager();
    376             resolveInfo = pm.resolveActivity(intent, 0);
    377         } else {
    378             pm = null;
    379             resolveInfo = null;
    380         }
    381         if (resolveInfo != null && resolveInfo.activityInfo != null) {
    382             builder.setIntent(intent)
    383                     .setOnClickListener(TextClassification.createStartActivityOnClickListener(
    384                             mContext, intent));
    385 
    386             final String packageName = resolveInfo.activityInfo.packageName;
    387             if ("android".equals(packageName)) {
    388                 // Requires the chooser to find an activity to handle the intent.
    389                 builder.setLabel(IntentFactory.getLabel(mContext, type));
    390             } else {
    391                 // A default activity will handle the intent.
    392                 intent.setComponent(new ComponentName(packageName, resolveInfo.activityInfo.name));
    393                 Drawable icon = resolveInfo.activityInfo.loadIcon(pm);
    394                 if (icon == null) {
    395                     icon = resolveInfo.loadIcon(pm);
    396                 }
    397                 builder.setIcon(icon);
    398                 CharSequence label = resolveInfo.activityInfo.loadLabel(pm);
    399                 if (label == null) {
    400                     label = resolveInfo.loadLabel(pm);
    401                 }
    402                 builder.setLabel(label != null ? label.toString() : null);
    403             }
    404         }
    405         return builder.setVersionInfo(getVersionInfo()).build();
    406     }
    407 
    408     private static int getHintFlags(CharSequence text, int start, int end) {
    409         int flag = 0;
    410         final CharSequence subText = text.subSequence(start, end);
    411         if (Patterns.AUTOLINK_EMAIL_ADDRESS.matcher(subText).matches()) {
    412             flag |= SmartSelection.HINT_FLAG_EMAIL;
    413         }
    414         if (Patterns.AUTOLINK_WEB_URL.matcher(subText).matches()
    415                 && Linkify.sUrlMatchFilter.acceptMatch(text, start, end)) {
    416             flag |= SmartSelection.HINT_FLAG_URL;
    417         }
    418         return flag;
    419     }
    420 
    421     private static String getHighestScoringType(SmartSelection.ClassificationResult[] types) {
    422         if (types.length < 1) {
    423             return "";
    424         }
    425 
    426         String type = types[0].mCollection;
    427         float highestScore = types[0].mScore;
    428         final int size = types.length;
    429         for (int i = 1; i < size; i++) {
    430             if (types[i].mScore > highestScore) {
    431                 type = types[i].mCollection;
    432                 highestScore = types[i].mScore;
    433             }
    434         }
    435         return type;
    436     }
    437 
    438     /**
    439      * Closes the ParcelFileDescriptor and logs any errors that occur.
    440      */
    441     private static void closeAndLogError(ParcelFileDescriptor fd) {
    442         try {
    443             fd.close();
    444         } catch (IOException e) {
    445             Log.e(LOG_TAG, "Error closing file.", e);
    446         }
    447     }
    448 
    449     /**
    450      * @throws IllegalArgumentException if text is null; startIndex is negative;
    451      *      endIndex is greater than text.length() or is not greater than startIndex
    452      */
    453     private static void validateInput(@NonNull CharSequence text, int startIndex, int endIndex) {
    454         Preconditions.checkArgument(text != null);
    455         Preconditions.checkArgument(startIndex >= 0);
    456         Preconditions.checkArgument(endIndex <= text.length());
    457         Preconditions.checkArgument(endIndex > startIndex);
    458     }
    459 
    460     /**
    461      * Detects and creates links for specified text.
    462      */
    463     private static final class LinksInfoFactory {
    464 
    465         private LinksInfoFactory() {}
    466 
    467         public static LinksInfo create(
    468                 Context context, SmartSelection smartSelection, String text, int linkMask) {
    469             final WordIterator wordIterator = new WordIterator();
    470             wordIterator.setCharSequence(text, 0, text.length());
    471             final List<SpanSpec> spans = new ArrayList<>();
    472             int start = 0;
    473             int end;
    474             while ((end = wordIterator.nextBoundary(start)) != BreakIterator.DONE) {
    475                 final String token = text.substring(start, end);
    476                 if (TextUtils.isEmpty(token)) {
    477                     continue;
    478                 }
    479 
    480                 final int[] selection = smartSelection.suggest(text, start, end);
    481                 final int selectionStart = selection[0];
    482                 final int selectionEnd = selection[1];
    483                 if (selectionStart >= 0 && selectionEnd <= text.length()
    484                         && selectionStart <= selectionEnd) {
    485                     final SmartSelection.ClassificationResult[] results =
    486                             smartSelection.classifyText(
    487                                     text, selectionStart, selectionEnd,
    488                                     getHintFlags(text, selectionStart, selectionEnd));
    489                     if (results.length > 0) {
    490                         final String type = getHighestScoringType(results);
    491                         if (matches(type, linkMask)) {
    492                             final Intent intent = IntentFactory.create(
    493                                     context, type, text.substring(selectionStart, selectionEnd));
    494                             if (hasActivityHandler(context, intent)) {
    495                                 final ClickableSpan span = createSpan(context, intent);
    496                                 spans.add(new SpanSpec(selectionStart, selectionEnd, span));
    497                             }
    498                         }
    499                     }
    500                 }
    501                 start = end;
    502             }
    503             return new LinksInfoImpl(text, avoidOverlaps(spans, text));
    504         }
    505 
    506         /**
    507          * Returns true if the classification type matches the specified linkMask.
    508          */
    509         private static boolean matches(String type, int linkMask) {
    510             type = type.trim().toLowerCase(Locale.ENGLISH);
    511             if ((linkMask & Linkify.PHONE_NUMBERS) != 0
    512                     && TextClassifier.TYPE_PHONE.equals(type)) {
    513                 return true;
    514             }
    515             if ((linkMask & Linkify.EMAIL_ADDRESSES) != 0
    516                     && TextClassifier.TYPE_EMAIL.equals(type)) {
    517                 return true;
    518             }
    519             if ((linkMask & Linkify.MAP_ADDRESSES) != 0
    520                     && TextClassifier.TYPE_ADDRESS.equals(type)) {
    521                 return true;
    522             }
    523             if ((linkMask & Linkify.WEB_URLS) != 0
    524                     && TextClassifier.TYPE_URL.equals(type)) {
    525                 return true;
    526             }
    527             return false;
    528         }
    529 
    530         /**
    531          * Trim the number of spans so that no two spans overlap.
    532          *
    533          * This algorithm first ensures that there is only one span per start index, then it
    534          * makes sure that no two spans overlap.
    535          */
    536         private static List<SpanSpec> avoidOverlaps(List<SpanSpec> spans, String text) {
    537             Collections.sort(spans, Comparator.comparingInt(span -> span.mStart));
    538             // Group spans by start index. Take the longest span.
    539             final Map<Integer, SpanSpec> reps = new LinkedHashMap<>();  // order matters.
    540             final int size = spans.size();
    541             for (int i = 0; i < size; i++) {
    542                 final SpanSpec span = spans.get(i);
    543                 final LinksInfoFactory.SpanSpec rep = reps.get(span.mStart);
    544                 if (rep == null || rep.mEnd < span.mEnd) {
    545                     reps.put(span.mStart, span);
    546                 }
    547             }
    548             // Avoid span intersections. Take the longer span.
    549             final LinkedList<SpanSpec> result = new LinkedList<>();
    550             for (SpanSpec rep : reps.values()) {
    551                 if (result.isEmpty()) {
    552                     result.add(rep);
    553                     continue;
    554                 }
    555 
    556                 final SpanSpec last = result.getLast();
    557                 if (rep.mStart < last.mEnd) {
    558                     // Spans intersect. Use the one with characters.
    559                     if ((rep.mEnd - rep.mStart) > (last.mEnd - last.mStart)) {
    560                         result.set(result.size() - 1, rep);
    561                     }
    562                 } else {
    563                     result.add(rep);
    564                 }
    565             }
    566             return result;
    567         }
    568 
    569         private static ClickableSpan createSpan(final Context context, final Intent intent) {
    570             return new ClickableSpan() {
    571                 // TODO: Style this span.
    572                 @Override
    573                 public void onClick(View widget) {
    574                     context.startActivity(intent);
    575                 }
    576             };
    577         }
    578 
    579         private static boolean hasActivityHandler(Context context, @Nullable Intent intent) {
    580             if (intent == null) {
    581                 return false;
    582             }
    583             final ResolveInfo resolveInfo = context.getPackageManager().resolveActivity(intent, 0);
    584             return resolveInfo != null && resolveInfo.activityInfo != null;
    585         }
    586 
    587         /**
    588          * Implementation of LinksInfo that adds ClickableSpans to the specified text.
    589          */
    590         private static final class LinksInfoImpl implements LinksInfo {
    591 
    592             private final CharSequence mOriginalText;
    593             private final List<SpanSpec> mSpans;
    594 
    595             LinksInfoImpl(CharSequence originalText, List<SpanSpec> spans) {
    596                 mOriginalText = originalText;
    597                 mSpans = spans;
    598             }
    599 
    600             @Override
    601             public boolean apply(@NonNull CharSequence text) {
    602                 Preconditions.checkArgument(text != null);
    603                 if (text instanceof Spannable && mOriginalText.toString().equals(text.toString())) {
    604                     Spannable spannable = (Spannable) text;
    605                     final int size = mSpans.size();
    606                     for (int i = 0; i < size; i++) {
    607                         final SpanSpec span = mSpans.get(i);
    608                         spannable.setSpan(span.mSpan, span.mStart, span.mEnd, 0);
    609                     }
    610                     return true;
    611                 }
    612                 return false;
    613             }
    614         }
    615 
    616         /**
    617          * Span plus its start and end index.
    618          */
    619         private static final class SpanSpec {
    620 
    621             private final int mStart;
    622             private final int mEnd;
    623             private final ClickableSpan mSpan;
    624 
    625             SpanSpec(int start, int end, ClickableSpan span) {
    626                 mStart = start;
    627                 mEnd = end;
    628                 mSpan = span;
    629             }
    630         }
    631     }
    632 
    633     /**
    634      * Creates intents based on the classification type.
    635      */
    636     private static final class IntentFactory {
    637 
    638         private IntentFactory() {}
    639 
    640         @Nullable
    641         public static Intent create(Context context, String type, String text) {
    642             type = type.trim().toLowerCase(Locale.ENGLISH);
    643             text = text.trim();
    644             switch (type) {
    645                 case TextClassifier.TYPE_EMAIL:
    646                     return new Intent(Intent.ACTION_SENDTO)
    647                             .setData(Uri.parse(String.format("mailto:%s", text)));
    648                 case TextClassifier.TYPE_PHONE:
    649                     return new Intent(Intent.ACTION_DIAL)
    650                             .setData(Uri.parse(String.format("tel:%s", text)));
    651                 case TextClassifier.TYPE_ADDRESS:
    652                     return new Intent(Intent.ACTION_VIEW)
    653                             .setData(Uri.parse(String.format("geo:0,0?q=%s", text)));
    654                 case TextClassifier.TYPE_URL:
    655                     final String httpPrefix = "http://";
    656                     final String httpsPrefix = "https://";
    657                     if (text.toLowerCase().startsWith(httpPrefix)) {
    658                         text = httpPrefix + text.substring(httpPrefix.length());
    659                     } else if (text.toLowerCase().startsWith(httpsPrefix)) {
    660                         text = httpsPrefix + text.substring(httpsPrefix.length());
    661                     } else {
    662                         text = httpPrefix + text;
    663                     }
    664                     return new Intent(Intent.ACTION_VIEW, Uri.parse(text))
    665                             .putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName());
    666                 default:
    667                     return null;
    668             }
    669         }
    670 
    671         @Nullable
    672         public static String getLabel(Context context, String type) {
    673             type = type.trim().toLowerCase(Locale.ENGLISH);
    674             switch (type) {
    675                 case TextClassifier.TYPE_EMAIL:
    676                     return context.getString(com.android.internal.R.string.email);
    677                 case TextClassifier.TYPE_PHONE:
    678                     return context.getString(com.android.internal.R.string.dial);
    679                 case TextClassifier.TYPE_ADDRESS:
    680                     return context.getString(com.android.internal.R.string.map);
    681                 case TextClassifier.TYPE_URL:
    682                     return context.getString(com.android.internal.R.string.browse);
    683                 default:
    684                     return null;
    685             }
    686         }
    687 
    688         @Nullable
    689         public static int getLogType(String type) {
    690             type = type.trim().toLowerCase(Locale.ENGLISH);
    691             switch (type) {
    692                 case TextClassifier.TYPE_EMAIL:
    693                     return TextViewMetrics.SUBTYPE_ASSIST_MENU_ITEM_EMAIL;
    694                 case TextClassifier.TYPE_PHONE:
    695                     return TextViewMetrics.SUBTYPE_ASSIST_MENU_ITEM_PHONE;
    696                 case TextClassifier.TYPE_ADDRESS:
    697                     return TextViewMetrics.SUBTYPE_ASSIST_MENU_ITEM_ADDRESS;
    698                 case TextClassifier.TYPE_URL:
    699                     return TextViewMetrics.SUBTYPE_ASSIST_MENU_ITEM_URL;
    700                 default:
    701                     return TextViewMetrics.SUBTYPE_ASSIST_MENU_ITEM_OTHER;
    702             }
    703         }
    704     }
    705 }
    706