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