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