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