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