TextClassifierImpl.java revision 9b4c82a83cc3c1aafac2325d7a601ba3e090b90b
1/* 2 * Copyright (C) 2017 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package android.view.textclassifier; 18 19import android.annotation.NonNull; 20import android.annotation.Nullable; 21import android.content.ComponentName; 22import android.content.Context; 23import android.content.Intent; 24import android.content.pm.PackageManager; 25import android.content.pm.ResolveInfo; 26import android.graphics.drawable.Drawable; 27import android.icu.text.BreakIterator; 28import android.net.Uri; 29import android.os.ParcelFileDescriptor; 30import android.provider.Browser; 31import android.text.Spannable; 32import android.text.TextUtils; 33import android.text.method.WordIterator; 34import android.text.style.ClickableSpan; 35import android.text.util.Linkify; 36import android.util.Log; 37import android.view.View; 38 39import com.android.internal.util.Preconditions; 40 41import java.io.FileNotFoundException; 42import java.util.ArrayList; 43import java.util.Collections; 44import java.util.Comparator; 45import java.util.LinkedHashMap; 46import java.util.LinkedList; 47import java.util.List; 48import java.util.Locale; 49import java.util.Map; 50 51/** 52 * Default implementation of the {@link TextClassifier} interface. 53 * 54 * <p>This class uses machine learning to recognize entities in text. 55 * Unless otherwise stated, methods of this class are blocking operations and should most 56 * likely not be called on the UI thread. 57 * 58 * @hide 59 */ 60final class TextClassifierImpl implements TextClassifier { 61 62 private static final String LOG_TAG = "TextClassifierImpl"; 63 64 private final Object mSmartSelectionLock = new Object(); 65 66 private final Context mContext; 67 private final ParcelFileDescriptor mFd; 68 private SmartSelection mSmartSelection; 69 70 TextClassifierImpl(Context context, ParcelFileDescriptor fd) { 71 mContext = Preconditions.checkNotNull(context); 72 mFd = Preconditions.checkNotNull(fd); 73 } 74 75 @Override 76 public TextSelection suggestSelection( 77 @NonNull CharSequence text, int selectionStartIndex, int selectionEndIndex) { 78 validateInput(text, selectionStartIndex, selectionEndIndex); 79 try { 80 if (text.length() > 0) { 81 final String string = text.toString(); 82 final int[] startEnd = getSmartSelection() 83 .suggest(string, selectionStartIndex, selectionEndIndex); 84 final int start = startEnd[0]; 85 final int end = startEnd[1]; 86 if (start >= 0 && end <= string.length() && start <= end) { 87 final String type = getSmartSelection().classifyText(string, start, end); 88 return new TextSelection.Builder(start, end) 89 .setEntityType(type, 1.0f) 90 .build(); 91 } else { 92 // We can not trust the result. Log the issue and ignore the result. 93 Log.d(LOG_TAG, "Got bad indices for input text. Ignoring result."); 94 } 95 } 96 } catch (Throwable t) { 97 // Avoid throwing from this method. Log the error. 98 Log.e(LOG_TAG, 99 "Error suggesting selection for text. No changes to selection suggested.", 100 t); 101 } 102 // Getting here means something went wrong, return a NO_OP result. 103 return TextClassifier.NO_OP.suggestSelection( 104 text, selectionStartIndex, selectionEndIndex); 105 } 106 107 @Override 108 public TextClassificationResult getTextClassificationResult( 109 @NonNull CharSequence text, int startIndex, int endIndex) { 110 validateInput(text, startIndex, endIndex); 111 try { 112 if (text.length() > 0) { 113 final CharSequence classified = text.subSequence(startIndex, endIndex); 114 String type = getSmartSelection() 115 .classifyText(text.toString(), startIndex, endIndex); 116 if (!TextUtils.isEmpty(type)) { 117 type = type.toLowerCase(Locale.ENGLISH).trim(); 118 // TODO: Added this log for debug only. Remove before release. 119 Log.d(LOG_TAG, String.format("Classification type: %s", type)); 120 return createClassificationResult(type, classified); 121 } 122 } 123 } catch (Throwable t) { 124 // Avoid throwing from this method. Log the error. 125 Log.e(LOG_TAG, "Error getting assist info.", t); 126 } 127 // Getting here means something went wrong, return a NO_OP result. 128 return TextClassifier.NO_OP.getTextClassificationResult(text, startIndex, endIndex); 129 } 130 131 @Override 132 public LinksInfo getLinks(CharSequence text, int linkMask) { 133 Preconditions.checkArgument(text != null); 134 try { 135 return LinksInfoFactory.create( 136 mContext, getSmartSelection(), text.toString(), linkMask); 137 } catch (Throwable t) { 138 // Avoid throwing from this method. Log the error. 139 Log.e(LOG_TAG, "Error getting links info.", t); 140 } 141 // Getting here means something went wrong, return a NO_OP result. 142 return TextClassifier.NO_OP.getLinks(text, linkMask); 143 } 144 145 private SmartSelection getSmartSelection() throws FileNotFoundException { 146 synchronized (mSmartSelectionLock) { 147 if (mSmartSelection == null) { 148 mSmartSelection = new SmartSelection(mFd.getFd()); 149 } 150 return mSmartSelection; 151 } 152 } 153 154 private TextClassificationResult createClassificationResult(String type, CharSequence text) { 155 final TextClassificationResult.Builder builder = new TextClassificationResult.Builder() 156 .setText(text.toString()) 157 .setEntityType(type, 1.0f /* confidence */); 158 159 final Intent intent = IntentFactory.create(mContext, type, text.toString()); 160 final PackageManager pm; 161 final ResolveInfo resolveInfo; 162 if (intent != null) { 163 pm = mContext.getPackageManager(); 164 resolveInfo = pm.resolveActivity(intent, 0); 165 } else { 166 pm = null; 167 resolveInfo = null; 168 } 169 if (resolveInfo != null && resolveInfo.activityInfo != null) { 170 builder.setIntent(intent) 171 .setOnClickListener(TextClassificationResult.createStartActivityOnClickListener( 172 mContext, intent)); 173 174 final String packageName = resolveInfo.activityInfo.packageName; 175 if ("android".equals(packageName)) { 176 // Requires the chooser to find an activity to handle the intent. 177 builder.setLabel(IntentFactory.getLabel(mContext, type)); 178 } else { 179 // A default activity will handle the intent. 180 intent.setComponent(new ComponentName(packageName, resolveInfo.activityInfo.name)); 181 Drawable icon = resolveInfo.activityInfo.loadIcon(pm); 182 if (icon == null) { 183 icon = resolveInfo.loadIcon(pm); 184 } 185 builder.setIcon(icon); 186 CharSequence label = resolveInfo.activityInfo.loadLabel(pm); 187 if (label == null) { 188 label = resolveInfo.loadLabel(pm); 189 } 190 builder.setLabel(label != null ? label.toString() : null); 191 } 192 } 193 return builder.build(); 194 } 195 196 /** 197 * @throws IllegalArgumentException if text is null; startIndex is negative; 198 * endIndex is greater than text.length() or less than startIndex 199 */ 200 private static void validateInput(@NonNull CharSequence text, int startIndex, int endIndex) { 201 Preconditions.checkArgument(text != null); 202 Preconditions.checkArgument(startIndex >= 0); 203 Preconditions.checkArgument(endIndex <= text.length()); 204 Preconditions.checkArgument(endIndex >= startIndex); 205 } 206 207 /** 208 * Detects and creates links for specified text. 209 */ 210 private static final class LinksInfoFactory { 211 212 private LinksInfoFactory() {} 213 214 public static LinksInfo create( 215 Context context, SmartSelection smartSelection, String text, int linkMask) { 216 final WordIterator wordIterator = new WordIterator(); 217 wordIterator.setCharSequence(text, 0, text.length()); 218 final List<SpanSpec> spans = new ArrayList<>(); 219 int start = 0; 220 int end; 221 while ((end = wordIterator.nextBoundary(start)) != BreakIterator.DONE) { 222 final String token = text.substring(start, end); 223 if (TextUtils.isEmpty(token)) { 224 continue; 225 } 226 227 final int[] selection = smartSelection.suggest(text, start, end); 228 final int selectionStart = selection[0]; 229 final int selectionEnd = selection[1]; 230 if (selectionStart >= 0 && selectionEnd <= text.length() 231 && selectionStart <= selectionEnd) { 232 final String type = 233 smartSelection.classifyText(text, selectionStart, selectionEnd); 234 if (matches(type, linkMask)) { 235 final Intent intent = IntentFactory.create( 236 context, type, text.substring(selectionStart, selectionEnd)); 237 if (hasActivityHandler(context, intent)) { 238 final ClickableSpan span = createSpan(context, intent); 239 spans.add(new SpanSpec(selectionStart, selectionEnd, span)); 240 } 241 } 242 } 243 start = end; 244 } 245 return new LinksInfoImpl(text, avoidOverlaps(spans, text)); 246 } 247 248 /** 249 * Returns true if the classification type matches the specified linkMask. 250 */ 251 private static boolean matches(String type, int linkMask) { 252 if ((linkMask & Linkify.PHONE_NUMBERS) != 0 253 && TextClassifier.TYPE_PHONE.equals(type)) { 254 return true; 255 } 256 if ((linkMask & Linkify.EMAIL_ADDRESSES) != 0 257 && TextClassifier.TYPE_EMAIL.equals(type)) { 258 return true; 259 } 260 if ((linkMask & Linkify.MAP_ADDRESSES) != 0 261 && TextClassifier.TYPE_ADDRESS.equals(type)) { 262 return true; 263 } 264 if ((linkMask & Linkify.WEB_URLS) != 0 265 && TextClassifier.TYPE_URL.equals(type)) { 266 return true; 267 } 268 return false; 269 } 270 271 /** 272 * Trim the number of spans so that no two spans overlap. 273 * 274 * This algorithm first ensures that there is only one span per start index, then it 275 * makes sure that no two spans overlap. 276 */ 277 private static List<SpanSpec> avoidOverlaps(List<SpanSpec> spans, String text) { 278 Collections.sort(spans, Comparator.comparingInt(span -> span.mStart)); 279 // Group spans by start index. Take the longest span. 280 final Map<Integer, SpanSpec> reps = new LinkedHashMap<>(); // order matters. 281 final int size = spans.size(); 282 for (int i = 0; i < size; i++) { 283 final SpanSpec span = spans.get(i); 284 final LinksInfoFactory.SpanSpec rep = reps.get(span.mStart); 285 if (rep == null || rep.mEnd < span.mEnd) { 286 reps.put(span.mStart, span); 287 } 288 } 289 // Avoid span intersections. Take the longer span. 290 final LinkedList<SpanSpec> result = new LinkedList<>(); 291 for (SpanSpec rep : reps.values()) { 292 if (result.isEmpty()) { 293 result.add(rep); 294 continue; 295 } 296 297 final SpanSpec last = result.getLast(); 298 if (rep.mStart < last.mEnd) { 299 // Spans intersect. Use the one with characters. 300 if ((rep.mEnd - rep.mStart) > (last.mEnd - last.mStart)) { 301 result.set(result.size() - 1, rep); 302 } 303 } else { 304 result.add(rep); 305 } 306 } 307 return result; 308 } 309 310 private static ClickableSpan createSpan(final Context context, final Intent intent) { 311 return new ClickableSpan() { 312 // TODO: Style this span. 313 @Override 314 public void onClick(View widget) { 315 context.startActivity(intent); 316 } 317 }; 318 } 319 320 private static boolean hasActivityHandler(Context context, @Nullable Intent intent) { 321 if (intent == null) { 322 return false; 323 } 324 final ResolveInfo resolveInfo = context.getPackageManager().resolveActivity(intent, 0); 325 return resolveInfo != null && resolveInfo.activityInfo != null; 326 } 327 328 /** 329 * Implementation of LinksInfo that adds ClickableSpans to the specified text. 330 */ 331 private static final class LinksInfoImpl implements LinksInfo { 332 333 private final CharSequence mOriginalText; 334 private final List<SpanSpec> mSpans; 335 336 LinksInfoImpl(CharSequence originalText, List<SpanSpec> spans) { 337 mOriginalText = originalText; 338 mSpans = spans; 339 } 340 341 @Override 342 public boolean apply(@NonNull CharSequence text) { 343 Preconditions.checkArgument(text != null); 344 if (text instanceof Spannable && mOriginalText.toString().equals(text.toString())) { 345 Spannable spannable = (Spannable) text; 346 final int size = mSpans.size(); 347 for (int i = 0; i < size; i++) { 348 final SpanSpec span = mSpans.get(i); 349 spannable.setSpan(span.mSpan, span.mStart, span.mEnd, 0); 350 } 351 return true; 352 } 353 return false; 354 } 355 } 356 357 /** 358 * Span plus its start and end index. 359 */ 360 private static final class SpanSpec { 361 362 private final int mStart; 363 private final int mEnd; 364 private final ClickableSpan mSpan; 365 366 SpanSpec(int start, int end, ClickableSpan span) { 367 mStart = start; 368 mEnd = end; 369 mSpan = span; 370 } 371 } 372 } 373 374 /** 375 * Creates intents based on the classification type. 376 */ 377 private static final class IntentFactory { 378 379 private IntentFactory() {} 380 381 @Nullable 382 public static Intent create(Context context, String type, String text) { 383 switch (type) { 384 case TextClassifier.TYPE_EMAIL: 385 return new Intent(Intent.ACTION_SENDTO) 386 .setData(Uri.parse(String.format("mailto:%s", text))); 387 case TextClassifier.TYPE_PHONE: 388 return new Intent(Intent.ACTION_DIAL) 389 .setData(Uri.parse(String.format("tel:%s", text))); 390 case TextClassifier.TYPE_ADDRESS: 391 return new Intent(Intent.ACTION_VIEW) 392 .setData(Uri.parse(String.format("geo:0,0?q=%s", text))); 393 case TextClassifier.TYPE_URL: 394 return new Intent(Intent.ACTION_VIEW, Uri.parse(text)) 395 .putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName()); 396 default: 397 return null; 398 // TODO: Add other classification types. 399 } 400 } 401 402 @Nullable 403 public static String getLabel(Context context, String type) { 404 switch (type) { 405 case TextClassifier.TYPE_EMAIL: 406 return context.getString(com.android.internal.R.string.email); 407 case TextClassifier.TYPE_PHONE: 408 return context.getString(com.android.internal.R.string.dial); 409 case TextClassifier.TYPE_ADDRESS: 410 return context.getString(com.android.internal.R.string.map); 411 case TextClassifier.TYPE_URL: 412 return context.getString(com.android.internal.R.string.browse); 413 default: 414 return null; 415 // TODO: Add other classification types. 416 } 417 } 418 } 419} 420