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