/*
* Copyright 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.textclassifier;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.UserManager;
import android.provider.Browser;
import android.provider.ContactsContract;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.style.URLSpan;
import android.text.util.Linkify;
import android.util.Log;
import android.util.Patterns;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
import androidx.core.app.RemoteActionCompat;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.core.text.util.LinkifyCompat;
import androidx.core.util.Preconditions;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
/**
* Provides limited text classifier feature by using the legacy {@link LinkifyCompat} API.
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY)
final class LegacyTextClassifier extends TextClassifier {
private static final String LOG_TAG = "LegacyTextClassifier";
private static final List<String> DEFAULT_ENTITY_TYPES = Collections.unmodifiableList(
Arrays.asList(TextClassifier.TYPE_URL,
TextClassifier.TYPE_EMAIL,
TextClassifier.TYPE_PHONE));
private static final int NOT_LINKIFY = 0;
private static LegacyTextClassifier sInstance;
private final MatchMaker mMatchMaker;
@VisibleForTesting()
LegacyTextClassifier(MatchMaker matchMaker) {
mMatchMaker = Preconditions.checkNotNull(matchMaker);
}
public static LegacyTextClassifier of(Context context) {
if (sInstance == null) {
sInstance = new LegacyTextClassifier(
new MatchMakerImpl(context.getApplicationContext()));
}
return sInstance;
}
@WorkerThread
@Override
@NonNull
/** @inheritDoc */
public TextClassification classifyText(@NonNull TextClassification.Request request) {
final String requestText = request.getText().toString()
.substring(request.getStartIndex(), request.getEndIndex());
if (Patterns.WEB_URL.matcher(requestText).matches()) {
return createTextClassification(requestText, TextClassifier.TYPE_URL);
} else if (Patterns.EMAIL_ADDRESS.matcher(requestText).matches()) {
return createTextClassification(requestText, TextClassifier.TYPE_EMAIL);
} else if (Patterns.PHONE.matcher(requestText).matches()) {
return createTextClassification(requestText, TextClassifier.TYPE_PHONE);
} else {
return TextClassification.EMPTY;
}
}
private TextClassification createTextClassification(
String text, @EntityType String entityType) {
final TextClassification.Builder builder = new TextClassification.Builder()
.setText(text)
.setEntityType(entityType, 1f);
for (RemoteActionCompat action : mMatchMaker.getActions(entityType, text)) {
builder.addAction(action);
}
return builder.build();
}
@WorkerThread
@Override
@NonNull
/** @inheritDoc */
public TextLinks generateLinks(@NonNull TextLinks.Request request) {
final Collection<String> entityTypes = request.getEntityConfig()
.resolveTypes(DEFAULT_ENTITY_TYPES);
final String requestText = request.getText().toString();
final TextLinks.Builder builder = new TextLinks.Builder(requestText);
for (String entityType : entityTypes) {
addLinks(builder, requestText, entityType);
}
return builder.build();
}
private static void addLinks(
TextLinks.Builder builder, String string, @EntityType String entityType) {
final int linkifyMask = entityTypeToLinkifyMask(entityType);
if (linkifyMask == NOT_LINKIFY) {
return;
}
final Spannable spannable = new SpannableString(string);
if (LinkifyCompat.addLinks(spannable, linkifyMask)) {
final URLSpan[] spans = spannable.getSpans(0, spannable.length(), URLSpan.class);
for (URLSpan urlSpan : spans) {
builder.addLink(
spannable.getSpanStart(urlSpan),
spannable.getSpanEnd(urlSpan),
Collections.singletonMap(entityType, 1.0f));
}
}
}
@LinkifyCompat.LinkifyMask
private static int entityTypeToLinkifyMask(@EntityType String entityType) {
switch (entityType) {
case TextClassifier.TYPE_URL:
return Linkify.WEB_URLS;
case TextClassifier.TYPE_PHONE:
return Linkify.PHONE_NUMBERS;
case TextClassifier.TYPE_EMAIL:
return Linkify.EMAIL_ADDRESSES;
default:
// NOTE: Do not support MAP_ADDRESSES. Legacy version does not work well.
return NOT_LINKIFY;
}
}
/**
* Default MatchMaker implementation for the LegacyTextClassifier.
*/
// TODO: Write unit tests for MatchMakerImpl.
// Will involve faking/mocking out system internals such as context, package manager, etc.
@VisibleForTesting
static final class MatchMakerImpl implements MatchMaker {
// RemoteAction requires that there be an icon.
// Use this when no icon is required. Use with RemoteAction.setShouldShowIcon(false).
private static final IconCompat NO_ICON = IconCompat.createWithData(new byte[0], 0, 0);
private final Context mContext;
private final PackageManager mPackageManager;
private final Bundle mUserRestrictions;
private final PermissionsChecker mPermissionsChecker;
@SuppressWarnings("WeakerAccess") /* synthetic access */
MatchMakerImpl(final Context context) {
this(context,
context.getPackageManager(),
createUserRestrictions(context),
createPermissionsChecker(context));
}
MatchMakerImpl(
Context context,
PackageManager packageManager,
Bundle userRestrictions,
PermissionsChecker permissionsChecker) {
mContext = Preconditions.checkNotNull(context);
mPackageManager = Preconditions.checkNotNull(packageManager);
mUserRestrictions = Preconditions.checkNotNull(userRestrictions);
mPermissionsChecker = Preconditions.checkNotNull(permissionsChecker);
}
private static Bundle createUserRestrictions(Context context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) {
return Bundle.EMPTY;
}
final Object userManager = context.getSystemService(Context.USER_SERVICE);
return userManager instanceof UserManager
? ((UserManager) userManager).getUserRestrictions() : new Bundle();
}
private static PermissionsChecker createPermissionsChecker(final Context context) {
return new PermissionsChecker() {
@Override
public boolean hasPermission(ActivityInfo info) {
if (context.getPackageName().equals(info.packageName)) {
return true;
}
if (!info.exported) {
return false;
}
if (info.permission == null) {
return true;
}
return ContextCompat.checkSelfPermission(context, info.permission)
== PackageManager.PERMISSION_GRANTED;
}
};
}
@Override
public List<RemoteActionCompat> getActions(String entityType, CharSequence text) {
switch (entityType) {
case TextClassifier.TYPE_URL:
return createForUrl(text.toString());
case TextClassifier.TYPE_EMAIL:
return createForEmail(text.toString());
case TextClassifier.TYPE_PHONE:
return createForPhone(text.toString());
default:
return Collections.emptyList();
}
}
private List<RemoteActionCompat> createForUrl(String text) {
if (Uri.parse(text).getScheme() == null) {
text = "http://" + text;
}
final RemoteActionCompat browserAction = createRemoteAction(
new Intent(Intent.ACTION_VIEW, Uri.parse(text))
.putExtra(Browser.EXTRA_APPLICATION_ID, mContext.getPackageName()),
mContext.getString(R.string.browse),
mContext.getString(R.string.browse_desc),
0);
if (browserAction != null) {
return Collections.unmodifiableList(Arrays.asList(browserAction));
}
return Collections.emptyList();
}
private List<RemoteActionCompat> createForEmail(String text) {
final List<RemoteActionCompat> actions = new ArrayList<>();
final RemoteActionCompat emailAction = createRemoteAction(
new Intent(Intent.ACTION_SENDTO)
.setData(Uri.parse(String.format("mailto:%s", text))),
mContext.getString(R.string.email),
mContext.getString(R.string.email_desc),
0);
if (emailAction != null) {
actions.add(emailAction);
}
final RemoteActionCompat contactsAction = createRemoteAction(
new Intent(Intent.ACTION_INSERT_OR_EDIT)
.setType(ContactsContract.Contacts.CONTENT_ITEM_TYPE)
.putExtra(ContactsContract.Intents.Insert.EMAIL, text),
mContext.getString(R.string.add_contact),
mContext.getString(R.string.add_contact_desc),
0);
if (contactsAction != null) {
actions.add(contactsAction);
}
return immutableList(actions);
}
private List<RemoteActionCompat> createForPhone(String text) {
final List<RemoteActionCompat> actions = new ArrayList<>();
if (!mUserRestrictions.getBoolean(UserManager.DISALLOW_OUTGOING_CALLS, false)) {
final RemoteActionCompat dialAction = createRemoteAction(
new Intent(Intent.ACTION_DIAL)
.setData(Uri.parse(String.format("tel:%s", text))),
mContext.getString(R.string.dial),
mContext.getString(R.string.dial_desc),
0);
if (dialAction != null) {
actions.add(dialAction);
}
}
final RemoteActionCompat contactsAction = createRemoteAction(
new Intent(Intent.ACTION_INSERT_OR_EDIT)
.setType(ContactsContract.Contacts.CONTENT_ITEM_TYPE)
.putExtra(ContactsContract.Intents.Insert.PHONE, text),
mContext.getString(R.string.add_contact),
mContext.getString(R.string.add_contact_desc),
text.hashCode());
if (contactsAction != null) {
actions.add(contactsAction);
}
if (!mUserRestrictions.getBoolean(UserManager.DISALLOW_SMS, false)) {
final RemoteActionCompat smsAction = createRemoteAction(
new Intent(Intent.ACTION_SENDTO)
.setData(Uri.parse(String.format("smsto:%s", text))),
mContext.getString(R.string.sms),
mContext.getString(R.string.sms_desc),
0);
if (smsAction != null) {
actions.add(smsAction);
}
}
if (!actions.isEmpty()) {
return Collections.unmodifiableList(actions);
}
return Collections.emptyList();
}
private List<RemoteActionCompat> immutableList(List<RemoteActionCompat> actions) {
if (!actions.isEmpty()) {
return Collections.unmodifiableList(actions);
}
return Collections.emptyList();
}
@Nullable
private RemoteActionCompat createRemoteAction(
Intent intent, String title, String description, int requestCode) {
final ResolveInfo resolveInfo = mPackageManager.resolveActivity(intent, 0);
if (resolveInfo == null || resolveInfo.activityInfo == null) {
return null;
}
IconCompat icon = NO_ICON;
boolean shouldShowIcon = false;
final String packageName = resolveInfo.activityInfo.packageName;
if (!"android".equals(packageName)) {
intent.setClassName(packageName, resolveInfo.activityInfo.name);
if (resolveInfo.activityInfo.getIconResource() != 0) {
try {
icon = IconCompat.createWithResource(
mPackageManager.getResourcesForApplication(packageName),
packageName,
resolveInfo.activityInfo.getIconResource());
shouldShowIcon = true;
} catch (PackageManager.NameNotFoundException e) {
Log.e(LOG_TAG, "Icon resource error", e);
}
}
}
final PendingIntent pendingIntent = createPendingIntent(intent, requestCode);
if (pendingIntent == null) {
return null;
}
final RemoteActionCompat action =
new RemoteActionCompat(icon, title, description, pendingIntent);
action.setShouldShowIcon(shouldShowIcon);
return action;
}
@Nullable
private PendingIntent createPendingIntent(Intent intent, int requestCode) {
final ResolveInfo resolveInfo = mPackageManager.resolveActivity(intent, 0);
final int flags = PendingIntent.FLAG_UPDATE_CURRENT;
if (resolveInfo != null && resolveInfo.activityInfo != null
&& mPermissionsChecker.hasPermission(resolveInfo.activityInfo)) {
return PendingIntent.getActivity(mContext, requestCode, intent, flags);
}
return null;
}
interface PermissionsChecker {
boolean hasPermission(ActivityInfo info);
}
}
}