diff options
| author | 2019-01-29 22:03:03 +0000 | |
|---|---|---|
| committer | 2019-01-29 22:03:03 +0000 | |
| commit | 577c93bd2eba5e451d69df1e873b7838fbd1da2a (patch) | |
| tree | 16c8ef03b78f397846115f5e4ef4e762ba280136 | |
| parent | 45fd11ca0ea32100d7d578ce2cbda44ad876a381 (diff) | |
| parent | fc039c36abc808d2d24bdd77c06a65e04f386e9b (diff) | |
Merge "Update IntentFactory to construct intents using RemoteActionTemplate... objects that are returned by the model"
| -rw-r--r-- | core/java/android/provider/Settings.java | 1 | ||||
| -rw-r--r-- | core/java/android/view/textclassifier/IntentFactory.java | 56 | ||||
| -rw-r--r-- | core/java/android/view/textclassifier/LegacyIntentFactory.java | 260 | ||||
| -rw-r--r-- | core/java/android/view/textclassifier/TemplateIntentFactory.java | 167 | ||||
| -rw-r--r-- | core/java/android/view/textclassifier/TextClassificationConstants.java | 11 | ||||
| -rw-r--r-- | core/java/android/view/textclassifier/TextClassifierImpl.java | 278 | ||||
| -rw-r--r-- | core/tests/coretests/src/android/view/textclassifier/LegacyIntentFactoryTest.java (renamed from core/tests/coretests/src/android/view/textclassifier/IntentFactoryTest.java) | 47 | ||||
| -rw-r--r-- | core/tests/coretests/src/android/view/textclassifier/TemplateIntentFactoryTest.java | 218 |
8 files changed, 783 insertions, 255 deletions
diff --git a/core/java/android/provider/Settings.java b/core/java/android/provider/Settings.java index 32d139fd49b6..9721ab1aeb09 100644 --- a/core/java/android/provider/Settings.java +++ b/core/java/android/provider/Settings.java @@ -11766,6 +11766,7 @@ public final class Settings { * entity_list_not_editable (String[]) * entity_list_editable (String[]) * lang_id_threshold_override (float) + * template_intent_factory_enabled (boolean) * </pre> * * <p> diff --git a/core/java/android/view/textclassifier/IntentFactory.java b/core/java/android/view/textclassifier/IntentFactory.java new file mode 100644 index 000000000000..d9c03c858f7a --- /dev/null +++ b/core/java/android/view/textclassifier/IntentFactory.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2019 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 android.view.textclassifier; + +import android.annotation.Nullable; +import android.content.Context; +import android.content.Intent; + +import com.google.android.textclassifier.AnnotatorModel; + +import java.time.Instant; +import java.util.List; + +/** + * @hide + */ +public interface IntentFactory { + + /** + * Return a list of LabeledIntent from the classification result. + */ + List<TextClassifierImpl.LabeledIntent> create( + Context context, + String text, + boolean foreignText, + @Nullable Instant referenceTime, + @Nullable AnnotatorModel.ClassificationResult classification); + + /** + * Inserts translate action to the list if it is a foreign text. + */ + static void insertTranslateAction( + List<TextClassifierImpl.LabeledIntent> actions, Context context, String text) { + actions.add(new TextClassifierImpl.LabeledIntent( + context.getString(com.android.internal.R.string.translate), + context.getString(com.android.internal.R.string.translate_desc), + new Intent(Intent.ACTION_TRANSLATE) + // TODO: Probably better to introduce a "translate" scheme instead of + // using EXTRA_TEXT. + .putExtra(Intent.EXTRA_TEXT, text), + text.hashCode())); + } +} diff --git a/core/java/android/view/textclassifier/LegacyIntentFactory.java b/core/java/android/view/textclassifier/LegacyIntentFactory.java new file mode 100644 index 000000000000..b6e5b3e26b16 --- /dev/null +++ b/core/java/android/view/textclassifier/LegacyIntentFactory.java @@ -0,0 +1,260 @@ +/* + * Copyright (C) 2019 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 android.view.textclassifier; + +import static java.time.temporal.ChronoUnit.MILLIS; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.SearchManager; +import android.content.ContentUris; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.os.UserManager; +import android.provider.Browser; +import android.provider.CalendarContract; +import android.provider.ContactsContract; +import android.view.textclassifier.TextClassifierImpl.LabeledIntent; + +import com.google.android.textclassifier.AnnotatorModel; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +/** + * Creates intents based on the classification type. + * @hide + */ +public final class LegacyIntentFactory implements IntentFactory { + + private static final String TAG = "LegacyIntentFactory"; + private static final long MIN_EVENT_FUTURE_MILLIS = TimeUnit.MINUTES.toMillis(5); + private static final long DEFAULT_EVENT_DURATION = TimeUnit.HOURS.toMillis(1); + + public LegacyIntentFactory() {} + + @NonNull + @Override + public List<LabeledIntent> create(Context context, String text, boolean foreignText, + @Nullable Instant referenceTime, + AnnotatorModel.ClassificationResult classification) { + final String type = classification != null + ? classification.getCollection().trim().toLowerCase(Locale.ENGLISH) + : ""; + text = text.trim(); + final List<LabeledIntent> actions; + switch (type) { + case TextClassifier.TYPE_EMAIL: + actions = createForEmail(context, text); + break; + case TextClassifier.TYPE_PHONE: + actions = createForPhone(context, text); + break; + case TextClassifier.TYPE_ADDRESS: + actions = createForAddress(context, text); + break; + case TextClassifier.TYPE_URL: + actions = createForUrl(context, text); + break; + case TextClassifier.TYPE_DATE: // fall through + case TextClassifier.TYPE_DATE_TIME: + if (classification.getDatetimeResult() != null) { + final Instant parsedTime = Instant.ofEpochMilli( + classification.getDatetimeResult().getTimeMsUtc()); + actions = createForDatetime(context, type, referenceTime, parsedTime); + } else { + actions = new ArrayList<>(); + } + break; + case TextClassifier.TYPE_FLIGHT_NUMBER: + actions = createForFlight(context, text); + break; + case TextClassifier.TYPE_DICTIONARY: + actions = createForDictionary(context, text); + break; + default: + actions = new ArrayList<>(); + break; + } + if (foreignText) { + IntentFactory.insertTranslateAction(actions, context, text); + } + actions.forEach( + action -> action.getIntent() + .putExtra(TextClassifier.EXTRA_FROM_TEXT_CLASSIFIER, true)); + return actions; + } + + @NonNull + private static List<LabeledIntent> createForEmail(Context context, String text) { + final List<LabeledIntent> actions = new ArrayList<>(); + actions.add(new LabeledIntent( + context.getString(com.android.internal.R.string.email), + context.getString(com.android.internal.R.string.email_desc), + new Intent(Intent.ACTION_SENDTO) + .setData(Uri.parse(String.format("mailto:%s", text))), + LabeledIntent.DEFAULT_REQUEST_CODE)); + actions.add(new LabeledIntent( + context.getString(com.android.internal.R.string.add_contact), + context.getString(com.android.internal.R.string.add_contact_desc), + new Intent(Intent.ACTION_INSERT_OR_EDIT) + .setType(ContactsContract.Contacts.CONTENT_ITEM_TYPE) + .putExtra(ContactsContract.Intents.Insert.EMAIL, text), + text.hashCode())); + return actions; + } + + @NonNull + private static List<LabeledIntent> createForPhone(Context context, String text) { + final List<LabeledIntent> actions = new ArrayList<>(); + final UserManager userManager = context.getSystemService(UserManager.class); + final Bundle userRestrictions = userManager != null + ? userManager.getUserRestrictions() : new Bundle(); + if (!userRestrictions.getBoolean(UserManager.DISALLOW_OUTGOING_CALLS, false)) { + actions.add(new LabeledIntent( + context.getString(com.android.internal.R.string.dial), + context.getString(com.android.internal.R.string.dial_desc), + new Intent(Intent.ACTION_DIAL).setData( + Uri.parse(String.format("tel:%s", text))), + LabeledIntent.DEFAULT_REQUEST_CODE)); + } + actions.add(new LabeledIntent( + context.getString(com.android.internal.R.string.add_contact), + context.getString(com.android.internal.R.string.add_contact_desc), + new Intent(Intent.ACTION_INSERT_OR_EDIT) + .setType(ContactsContract.Contacts.CONTENT_ITEM_TYPE) + .putExtra(ContactsContract.Intents.Insert.PHONE, text), + text.hashCode())); + if (!userRestrictions.getBoolean(UserManager.DISALLOW_SMS, false)) { + actions.add(new LabeledIntent( + context.getString(com.android.internal.R.string.sms), + context.getString(com.android.internal.R.string.sms_desc), + new Intent(Intent.ACTION_SENDTO) + .setData(Uri.parse(String.format("smsto:%s", text))), + LabeledIntent.DEFAULT_REQUEST_CODE)); + } + return actions; + } + + @NonNull + private static List<LabeledIntent> createForAddress(Context context, String text) { + final List<LabeledIntent> actions = new ArrayList<>(); + try { + final String encText = URLEncoder.encode(text, "UTF-8"); + actions.add(new LabeledIntent( + context.getString(com.android.internal.R.string.map), + context.getString(com.android.internal.R.string.map_desc), + new Intent(Intent.ACTION_VIEW) + .setData(Uri.parse(String.format("geo:0,0?q=%s", encText))), + LabeledIntent.DEFAULT_REQUEST_CODE)); + } catch (UnsupportedEncodingException e) { + Log.e(TAG, "Could not encode address", e); + } + return actions; + } + + @NonNull + private static List<LabeledIntent> createForUrl(Context context, String text) { + if (Uri.parse(text).getScheme() == null) { + text = "http://" + text; + } + final List<LabeledIntent> actions = new ArrayList<>(); + actions.add(new LabeledIntent( + context.getString(com.android.internal.R.string.browse), + context.getString(com.android.internal.R.string.browse_desc), + new Intent(Intent.ACTION_VIEW, Uri.parse(text)) + .putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName()), + LabeledIntent.DEFAULT_REQUEST_CODE)); + return actions; + } + + @NonNull + private static List<LabeledIntent> createForDatetime( + Context context, String type, @Nullable Instant referenceTime, + Instant parsedTime) { + if (referenceTime == null) { + // If no reference time was given, use now. + referenceTime = Instant.now(); + } + List<LabeledIntent> actions = new ArrayList<>(); + actions.add(createCalendarViewIntent(context, parsedTime)); + final long millisUntilEvent = referenceTime.until(parsedTime, MILLIS); + if (millisUntilEvent > MIN_EVENT_FUTURE_MILLIS) { + actions.add(createCalendarCreateEventIntent(context, parsedTime, type)); + } + return actions; + } + + @NonNull + private static List<LabeledIntent> createForFlight(Context context, String text) { + final List<LabeledIntent> actions = new ArrayList<>(); + actions.add(new LabeledIntent( + context.getString(com.android.internal.R.string.view_flight), + context.getString(com.android.internal.R.string.view_flight_desc), + new Intent(Intent.ACTION_WEB_SEARCH) + .putExtra(SearchManager.QUERY, text), + text.hashCode())); + return actions; + } + + @NonNull + private static LabeledIntent createCalendarViewIntent(Context context, Instant parsedTime) { + Uri.Builder builder = CalendarContract.CONTENT_URI.buildUpon(); + builder.appendPath("time"); + ContentUris.appendId(builder, parsedTime.toEpochMilli()); + return new LabeledIntent( + context.getString(com.android.internal.R.string.view_calendar), + context.getString(com.android.internal.R.string.view_calendar_desc), + new Intent(Intent.ACTION_VIEW).setData(builder.build()), + LabeledIntent.DEFAULT_REQUEST_CODE); + } + + @NonNull + private static LabeledIntent createCalendarCreateEventIntent( + Context context, Instant parsedTime, @TextClassifier.EntityType String type) { + final boolean isAllDay = TextClassifier.TYPE_DATE.equals(type); + return new LabeledIntent( + context.getString(com.android.internal.R.string.add_calendar_event), + context.getString(com.android.internal.R.string.add_calendar_event_desc), + new Intent(Intent.ACTION_INSERT) + .setData(CalendarContract.Events.CONTENT_URI) + .putExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, isAllDay) + .putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, + parsedTime.toEpochMilli()) + .putExtra(CalendarContract.EXTRA_EVENT_END_TIME, + parsedTime.toEpochMilli() + DEFAULT_EVENT_DURATION), + parsedTime.hashCode()); + } + + @NonNull + private static List<LabeledIntent> createForDictionary(Context context, String text) { + final List<LabeledIntent> actions = new ArrayList<>(); + actions.add(new LabeledIntent( + context.getString(com.android.internal.R.string.define), + context.getString(com.android.internal.R.string.define_desc), + new Intent(Intent.ACTION_DEFINE) + .putExtra(Intent.EXTRA_TEXT, text), + text.hashCode())); + return actions; + } +} diff --git a/core/java/android/view/textclassifier/TemplateIntentFactory.java b/core/java/android/view/textclassifier/TemplateIntentFactory.java new file mode 100644 index 000000000000..97e11bb702be --- /dev/null +++ b/core/java/android/view/textclassifier/TemplateIntentFactory.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2019 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 android.view.textclassifier; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.text.TextUtils; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.util.ArrayUtils; +import com.android.internal.util.Preconditions; + +import com.google.android.textclassifier.AnnotatorModel; +import com.google.android.textclassifier.NamedVariant; +import com.google.android.textclassifier.RemoteActionTemplate; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Creates intents based on {@link RemoteActionTemplate} objects. + * @hide + */ +@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) +public final class TemplateIntentFactory implements IntentFactory { + private static final String TAG = TextClassifier.DEFAULT_LOG_TAG; + private final IntentFactory mFallback; + + public TemplateIntentFactory(IntentFactory fallback) { + mFallback = Preconditions.checkNotNull(fallback); + } + + /** + * Returns a list of {@link android.view.textclassifier.TextClassifierImpl.LabeledIntent} + * that are constructed from the classification result. + */ + @NonNull + @Override + public List<TextClassifierImpl.LabeledIntent> create( + Context context, + String text, + boolean foreignText, + @Nullable Instant referenceTime, + @Nullable AnnotatorModel.ClassificationResult classification) { + if (classification == null) { + return Collections.emptyList(); + } + RemoteActionTemplate[] remoteActionTemplates = classification.getRemoteActionTemplates(); + if (ArrayUtils.isEmpty(remoteActionTemplates)) { + // RemoteActionTemplate is missing, fallback. + Log.w(TAG, "RemoteActionTemplate is missing, fallback to LegacyIntentFactory."); + return mFallback.create(context, text, foreignText, referenceTime, classification); + } + final List<TextClassifierImpl.LabeledIntent> labeledIntents = + new ArrayList<>(createFromRemoteActionTemplates(remoteActionTemplates)); + if (foreignText) { + IntentFactory.insertTranslateAction(labeledIntents, context, text.trim()); + } + labeledIntents.forEach( + action -> action.getIntent() + .putExtra(TextClassifier.EXTRA_FROM_TEXT_CLASSIFIER, true)); + return labeledIntents; + } + + private static List<TextClassifierImpl.LabeledIntent> createFromRemoteActionTemplates( + RemoteActionTemplate[] remoteActionTemplates) { + final List<TextClassifierImpl.LabeledIntent> labeledIntents = new ArrayList<>(); + for (RemoteActionTemplate remoteActionTemplate : remoteActionTemplates) { + Intent intent = createIntent(remoteActionTemplate); + if (intent == null) { + continue; + } + TextClassifierImpl.LabeledIntent + labeledIntent = new TextClassifierImpl.LabeledIntent( + remoteActionTemplate.title, + remoteActionTemplate.description, + intent, + remoteActionTemplate.requestCode == null + ? TextClassifierImpl.LabeledIntent.DEFAULT_REQUEST_CODE + : remoteActionTemplate.requestCode + ); + labeledIntents.add(labeledIntent); + } + return labeledIntents; + } + + @Nullable + private static Intent createIntent(RemoteActionTemplate remoteActionTemplate) { + Intent intent = new Intent(); + if (!TextUtils.isEmpty(remoteActionTemplate.packageName)) { + Log.w(TAG, "A RemoteActionTemplate is skipped as package name is set."); + return null; + } + if (!TextUtils.isEmpty(remoteActionTemplate.action)) { + intent.setAction(remoteActionTemplate.action); + } + Uri data = null; + if (!TextUtils.isEmpty(remoteActionTemplate.data)) { + data = Uri.parse(remoteActionTemplate.data); + } + if (data != null || !TextUtils.isEmpty(remoteActionTemplate.type)) { + intent.setDataAndType(data, remoteActionTemplate.type); + } + if (remoteActionTemplate.flags != null) { + intent.setFlags(remoteActionTemplate.flags); + } + if (remoteActionTemplate.category != null) { + for (String category : remoteActionTemplate.category) { + intent.addCategory(category); + } + } + intent.putExtras(createExtras(remoteActionTemplate.extras)); + return intent; + } + + private static Bundle createExtras(NamedVariant[] namedVariants) { + if (namedVariants == null) { + return Bundle.EMPTY; + } + Bundle bundle = new Bundle(); + for (NamedVariant namedVariant : namedVariants) { + switch (namedVariant.getType()) { + case NamedVariant.TYPE_INT: + bundle.putInt(namedVariant.getName(), namedVariant.getInt()); + break; + case NamedVariant.TYPE_LONG: + bundle.putLong(namedVariant.getName(), namedVariant.getLong()); + break; + case NamedVariant.TYPE_FLOAT: + bundle.putFloat(namedVariant.getName(), namedVariant.getFloat()); + break; + case NamedVariant.TYPE_DOUBLE: + bundle.putDouble(namedVariant.getName(), namedVariant.getDouble()); + break; + case NamedVariant.TYPE_BOOL: + bundle.putBoolean(namedVariant.getName(), namedVariant.getBool()); + break; + case NamedVariant.TYPE_STRING: + bundle.putString(namedVariant.getName(), namedVariant.getString()); + break; + default: + Log.w(TAG, + "Unsupported type found in createExtras : " + namedVariant.getType()); + } + } + return bundle; + } +} diff --git a/core/java/android/view/textclassifier/TextClassificationConstants.java b/core/java/android/view/textclassifier/TextClassificationConstants.java index 7f928f74da19..ee9e04e5329a 100644 --- a/core/java/android/view/textclassifier/TextClassificationConstants.java +++ b/core/java/android/view/textclassifier/TextClassificationConstants.java @@ -47,6 +47,7 @@ import java.util.StringJoiner; * entity_list_not_editable (String[]) * entity_list_editable (String[]) * lang_id_threshold_override (float) + * template_intent_factory_enabled (boolean) * </pre> * * <p> @@ -97,6 +98,7 @@ public final class TextClassificationConstants { "notification_conversation_action_types_default"; private static final String LANG_ID_THRESHOLD_OVERRIDE = "lang_id_threshold_override"; + private static final String TEMPLATE_INTENT_FACTORY_ENABLED = "template_intent_factory_enabled"; private static final boolean LOCAL_TEXT_CLASSIFIER_ENABLED_DEFAULT = true; private static final boolean SYSTEM_TEXT_CLASSIFIER_ENABLED_DEFAULT = true; @@ -137,6 +139,7 @@ public final class TextClassificationConstants { * @see EntityConfidence */ private static final float LANG_ID_THRESHOLD_OVERRIDE_DEFAULT = -1f; + private static final boolean TEMPLATE_INTENT_FACTORY_ENABLED_DEFAULT = true; private final boolean mSystemTextClassifierEnabled; private final boolean mLocalTextClassifierEnabled; @@ -155,6 +158,7 @@ public final class TextClassificationConstants { private final List<String> mInAppConversationActionTypesDefault; private final List<String> mNotificationConversationActionTypesDefault; private final float mLangIdThresholdOverride; + private final boolean mTemplateIntentFactoryEnabled; private TextClassificationConstants(@Nullable String settings) { final KeyValueListParser parser = new KeyValueListParser(','); @@ -215,6 +219,8 @@ public final class TextClassificationConstants { mLangIdThresholdOverride = parser.getFloat( LANG_ID_THRESHOLD_OVERRIDE, LANG_ID_THRESHOLD_OVERRIDE_DEFAULT); + mTemplateIntentFactoryEnabled = parser.getBoolean( + TEMPLATE_INTENT_FACTORY_ENABLED, TEMPLATE_INTENT_FACTORY_ENABLED_DEFAULT); } /** Load from a settings string. */ @@ -290,6 +296,10 @@ public final class TextClassificationConstants { return mLangIdThresholdOverride; } + public boolean isTemplateIntentFactoryEnabled() { + return mTemplateIntentFactoryEnabled; + } + private static List<String> parseStringList(String listStr) { return Collections.unmodifiableList(Arrays.asList(listStr.split(STRING_LIST_DELIMITER))); } @@ -315,6 +325,7 @@ public final class TextClassificationConstants { pw.printPair("getNotificationConversationActionTypes", mNotificationConversationActionTypesDefault); pw.printPair("getLangIdThresholdOverride", mLangIdThresholdOverride); + pw.printPair("isTemplateIntentFactoryEnabled", mTemplateIntentFactoryEnabled); pw.decreaseIndent(); pw.println(); } diff --git a/core/java/android/view/textclassifier/TextClassifierImpl.java b/core/java/android/view/textclassifier/TextClassifierImpl.java index 7782079213e7..c297928ae5f6 100644 --- a/core/java/android/view/textclassifier/TextClassifierImpl.java +++ b/core/java/android/view/textclassifier/TextClassifierImpl.java @@ -16,30 +16,21 @@ package android.view.textclassifier; -import static java.time.temporal.ChronoUnit.MILLIS; - import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.WorkerThread; import android.app.PendingIntent; import android.app.RemoteAction; -import android.app.SearchManager; import android.content.ComponentName; -import android.content.ContentUris; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.graphics.drawable.Icon; import android.icu.util.ULocale; -import android.net.Uri; import android.os.Bundle; import android.os.LocaleList; import android.os.ParcelFileDescriptor; -import android.os.UserManager; -import android.provider.Browser; -import android.provider.CalendarContract; -import android.provider.ContactsContract; import com.android.internal.annotations.GuardedBy; import com.android.internal.annotations.VisibleForTesting; @@ -53,19 +44,15 @@ import com.google.android.textclassifier.LangIdModel; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.net.URLEncoder; import java.time.Instant; import java.time.ZonedDateTime; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; -import java.util.concurrent.TimeUnit; /** * Default implementation of the {@link TextClassifier} interface. @@ -128,6 +115,8 @@ public final class TextClassifierImpl implements TextClassifier { private final ModelFileManager mLangIdModelFileManager; private final ModelFileManager mActionsModelFileManager; + private final IntentFactory mIntentFactory; + public TextClassifierImpl( Context context, TextClassificationConstants settings, TextClassifier fallback) { mContext = Preconditions.checkNotNull(context); @@ -155,6 +144,10 @@ public final class TextClassifierImpl implements TextClassifier { UPDATED_ACTIONS_MODEL, ActionsSuggestionsModel::getVersion, ActionsSuggestionsModel::getLocales)); + + mIntentFactory = mSettings.isTemplateIntentFactoryEnabled() + ? new TemplateIntentFactory(new LegacyIntentFactory()) + : new LegacyIntentFactory(); } public TextClassifierImpl(Context context, TextClassificationConstants settings) { @@ -198,7 +191,8 @@ public final class TextClassifierImpl implements TextClassifier { new AnnotatorModel.ClassificationOptions( refTime.toInstant().toEpochMilli(), refTime.getZone().getId(), - localesString)); + localesString), + mContext); final int size = results.length; for (int i = 0; i < size; i++) { tsBuilder.setEntityType(results[i].getCollection(), results[i].getScore()); @@ -241,7 +235,8 @@ public final class TextClassifierImpl implements TextClassifier { new AnnotatorModel.ClassificationOptions( refTime.toInstant().toEpochMilli(), refTime.getZone().getId(), - localesString)); + localesString), + mContext); if (results.length > 0) { return createClassificationResult( results, string, @@ -560,8 +555,9 @@ public final class TextClassifierImpl implements TextClassifier { AnnotatorModel.ClassificationResult highestScoringResult = typeCount > 0 ? classifications[0] : null; for (int i = 0; i < typeCount; i++) { - builder.setEntityType(classifications[i].getCollection(), - classifications[i].getScore()); + builder.setEntityType( + classifications[i].getCollection(), + classifications[i].getScore()); if (classifications[i].getScore() > highestScoringResult.getScore()) { highestScoringResult = classifications[i]; } @@ -572,9 +568,13 @@ public final class TextClassifierImpl implements TextClassifier { : 0.5f /* TODO: Load this from the langId model. */; boolean isPrimaryAction = true; final ArrayList<Intent> sourceIntents = new ArrayList<>(); - for (LabeledIntent labeledIntent : IntentFactory.create( - mContext, classifiedText, isForeignText(classifiedText, foreignTextThreshold), - referenceTime, highestScoringResult)) { + List<LabeledIntent> labeledIntents = mIntentFactory.create( + mContext, + classifiedText, + isForeignText(classifiedText, foreignTextThreshold), + referenceTime, + highestScoringResult); + for (LabeledIntent labeledIntent : labeledIntents) { final RemoteAction action = labeledIntent.asRemoteAction(mContext); if (action == null) { continue; @@ -720,11 +720,13 @@ public final class TextClassifierImpl implements TextClassifier { mRequestCode = requestCode; } - String getTitle() { + @VisibleForTesting + public String getTitle() { return mTitle; } - String getDescription() { + @VisibleForTesting + public String getDescription() { return mDescription; } @@ -733,7 +735,8 @@ public final class TextClassifierImpl implements TextClassifier { return mIntent; } - int getRequestCode() { + @VisibleForTesting + public int getRequestCode() { return mRequestCode; } @@ -769,233 +772,4 @@ public final class TextClassifierImpl implements TextClassifier { return action; } } - - /** - * Creates intents based on the classification type. - */ - @VisibleForTesting - public static final class IntentFactory { - - private static final long MIN_EVENT_FUTURE_MILLIS = TimeUnit.MINUTES.toMillis(5); - private static final long DEFAULT_EVENT_DURATION = TimeUnit.HOURS.toMillis(1); - - private IntentFactory() {} - - @NonNull - public static List<LabeledIntent> create( - Context context, - String text, - boolean foreignText, - @Nullable Instant referenceTime, - @Nullable AnnotatorModel.ClassificationResult classification) { - final String type = classification != null - ? classification.getCollection().trim().toLowerCase(Locale.ENGLISH) - : ""; - text = text.trim(); - final List<LabeledIntent> actions; - switch (type) { - case TextClassifier.TYPE_EMAIL: - actions = createForEmail(context, text); - break; - case TextClassifier.TYPE_PHONE: - actions = createForPhone(context, text); - break; - case TextClassifier.TYPE_ADDRESS: - actions = createForAddress(context, text); - break; - case TextClassifier.TYPE_URL: - actions = createForUrl(context, text); - break; - case TextClassifier.TYPE_DATE: // fall through - case TextClassifier.TYPE_DATE_TIME: - if (classification.getDatetimeResult() != null) { - final Instant parsedTime = Instant.ofEpochMilli( - classification.getDatetimeResult().getTimeMsUtc()); - actions = createForDatetime(context, type, referenceTime, parsedTime); - } else { - actions = new ArrayList<>(); - } - break; - case TextClassifier.TYPE_FLIGHT_NUMBER: - actions = createForFlight(context, text); - break; - case TextClassifier.TYPE_DICTIONARY: - actions = createForDictionary(context, text); - break; - default: - actions = new ArrayList<>(); - break; - } - if (foreignText) { - insertTranslateAction(actions, context, text); - } - actions.forEach( - action -> action.getIntent() - .putExtra(TextClassifier.EXTRA_FROM_TEXT_CLASSIFIER, true)); - return actions; - } - - @NonNull - private static List<LabeledIntent> createForEmail(Context context, String text) { - final List<LabeledIntent> actions = new ArrayList<>(); - actions.add(new LabeledIntent( - context.getString(com.android.internal.R.string.email), - context.getString(com.android.internal.R.string.email_desc), - new Intent(Intent.ACTION_SENDTO) - .setData(Uri.parse(String.format("mailto:%s", text))), - LabeledIntent.DEFAULT_REQUEST_CODE)); - actions.add(new LabeledIntent( - context.getString(com.android.internal.R.string.add_contact), - context.getString(com.android.internal.R.string.add_contact_desc), - new Intent(Intent.ACTION_INSERT_OR_EDIT) - .setType(ContactsContract.Contacts.CONTENT_ITEM_TYPE) - .putExtra(ContactsContract.Intents.Insert.EMAIL, text), - text.hashCode())); - return actions; - } - - @NonNull - private static List<LabeledIntent> createForPhone(Context context, String text) { - final List<LabeledIntent> actions = new ArrayList<>(); - final UserManager userManager = context.getSystemService(UserManager.class); - final Bundle userRestrictions = userManager != null - ? userManager.getUserRestrictions() : new Bundle(); - if (!userRestrictions.getBoolean(UserManager.DISALLOW_OUTGOING_CALLS, false)) { - actions.add(new LabeledIntent( - context.getString(com.android.internal.R.string.dial), - context.getString(com.android.internal.R.string.dial_desc), - new Intent(Intent.ACTION_DIAL).setData( - Uri.parse(String.format("tel:%s", text))), - LabeledIntent.DEFAULT_REQUEST_CODE)); - } - actions.add(new LabeledIntent( - context.getString(com.android.internal.R.string.add_contact), - context.getString(com.android.internal.R.string.add_contact_desc), - new Intent(Intent.ACTION_INSERT_OR_EDIT) - .setType(ContactsContract.Contacts.CONTENT_ITEM_TYPE) - .putExtra(ContactsContract.Intents.Insert.PHONE, text), - text.hashCode())); - if (!userRestrictions.getBoolean(UserManager.DISALLOW_SMS, false)) { - actions.add(new LabeledIntent( - context.getString(com.android.internal.R.string.sms), - context.getString(com.android.internal.R.string.sms_desc), - new Intent(Intent.ACTION_SENDTO) - .setData(Uri.parse(String.format("smsto:%s", text))), - LabeledIntent.DEFAULT_REQUEST_CODE)); - } - return actions; - } - - @NonNull - private static List<LabeledIntent> createForAddress(Context context, String text) { - final List<LabeledIntent> actions = new ArrayList<>(); - try { - final String encText = URLEncoder.encode(text, "UTF-8"); - actions.add(new LabeledIntent( - context.getString(com.android.internal.R.string.map), - context.getString(com.android.internal.R.string.map_desc), - new Intent(Intent.ACTION_VIEW) - .setData(Uri.parse(String.format("geo:0,0?q=%s", encText))), - LabeledIntent.DEFAULT_REQUEST_CODE)); - } catch (UnsupportedEncodingException e) { - Log.e(LOG_TAG, "Could not encode address", e); - } - return actions; - } - - @NonNull - private static List<LabeledIntent> createForUrl(Context context, String text) { - if (Uri.parse(text).getScheme() == null) { - text = "http://" + text; - } - final List<LabeledIntent> actions = new ArrayList<>(); - actions.add(new LabeledIntent( - context.getString(com.android.internal.R.string.browse), - context.getString(com.android.internal.R.string.browse_desc), - new Intent(Intent.ACTION_VIEW, Uri.parse(text)) - .putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName()), - LabeledIntent.DEFAULT_REQUEST_CODE)); - return actions; - } - - @NonNull - private static List<LabeledIntent> createForDatetime( - Context context, String type, @Nullable Instant referenceTime, - Instant parsedTime) { - if (referenceTime == null) { - // If no reference time was given, use now. - referenceTime = Instant.now(); - } - List<LabeledIntent> actions = new ArrayList<>(); - actions.add(createCalendarViewIntent(context, parsedTime)); - final long millisUntilEvent = referenceTime.until(parsedTime, MILLIS); - if (millisUntilEvent > MIN_EVENT_FUTURE_MILLIS) { - actions.add(createCalendarCreateEventIntent(context, parsedTime, type)); - } - return actions; - } - - @NonNull - private static List<LabeledIntent> createForFlight(Context context, String text) { - final List<LabeledIntent> actions = new ArrayList<>(); - actions.add(new LabeledIntent( - context.getString(com.android.internal.R.string.view_flight), - context.getString(com.android.internal.R.string.view_flight_desc), - new Intent(Intent.ACTION_WEB_SEARCH) - .putExtra(SearchManager.QUERY, text), - text.hashCode())); - return actions; - } - - @NonNull - private static LabeledIntent createCalendarViewIntent(Context context, Instant parsedTime) { - Uri.Builder builder = CalendarContract.CONTENT_URI.buildUpon(); - builder.appendPath("time"); - ContentUris.appendId(builder, parsedTime.toEpochMilli()); - return new LabeledIntent( - context.getString(com.android.internal.R.string.view_calendar), - context.getString(com.android.internal.R.string.view_calendar_desc), - new Intent(Intent.ACTION_VIEW).setData(builder.build()), - LabeledIntent.DEFAULT_REQUEST_CODE); - } - - @NonNull - private static LabeledIntent createCalendarCreateEventIntent( - Context context, Instant parsedTime, @EntityType String type) { - final boolean isAllDay = TextClassifier.TYPE_DATE.equals(type); - return new LabeledIntent( - context.getString(com.android.internal.R.string.add_calendar_event), - context.getString(com.android.internal.R.string.add_calendar_event_desc), - new Intent(Intent.ACTION_INSERT) - .setData(CalendarContract.Events.CONTENT_URI) - .putExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, isAllDay) - .putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, - parsedTime.toEpochMilli()) - .putExtra(CalendarContract.EXTRA_EVENT_END_TIME, - parsedTime.toEpochMilli() + DEFAULT_EVENT_DURATION), - parsedTime.hashCode()); - } - - private static void insertTranslateAction( - List<LabeledIntent> actions, Context context, String text) { - actions.add(new LabeledIntent( - context.getString(com.android.internal.R.string.translate), - context.getString(com.android.internal.R.string.translate_desc), - new Intent(Intent.ACTION_TRANSLATE) - // TODO: Probably better to introduce a "translate" scheme instead of - // using EXTRA_TEXT. - .putExtra(Intent.EXTRA_TEXT, text), - text.hashCode())); - } - - @NonNull - private static List<LabeledIntent> createForDictionary(Context context, String text) { - return Arrays.asList(new LabeledIntent( - context.getString(com.android.internal.R.string.define), - context.getString(com.android.internal.R.string.define_desc), - new Intent(Intent.ACTION_DEFINE) - .putExtra(Intent.EXTRA_TEXT, text), - text.hashCode())); - } - } } diff --git a/core/tests/coretests/src/android/view/textclassifier/IntentFactoryTest.java b/core/tests/coretests/src/android/view/textclassifier/LegacyIntentFactoryTest.java index 3fc8e4c2eecd..73d3eec734d7 100644 --- a/core/tests/coretests/src/android/view/textclassifier/IntentFactoryTest.java +++ b/core/tests/coretests/src/android/view/textclassifier/LegacyIntentFactoryTest.java @@ -26,6 +26,7 @@ import androidx.test.runner.AndroidJUnit4; import com.google.android.textclassifier.AnnotatorModel; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -33,10 +34,17 @@ import java.util.List; @SmallTest @RunWith(AndroidJUnit4.class) -public class IntentFactoryTest { +public class LegacyIntentFactoryTest { private static final String TEXT = "text"; + private LegacyIntentFactory mLegacyIntentFactory; + + @Before + public void setup() { + mLegacyIntentFactory = new LegacyIntentFactory(); + } + @Test public void create_typeDictionary() { AnnotatorModel.ClassificationResult classificationResult = @@ -44,12 +52,18 @@ public class IntentFactoryTest { TextClassifier.TYPE_DICTIONARY, 1.0f, null, + null, + null, + null, + null, + null, + null, null); - List<TextClassifierImpl.LabeledIntent> intents = TextClassifierImpl.IntentFactory.create( + List<TextClassifierImpl.LabeledIntent> intents = mLegacyIntentFactory.create( InstrumentationRegistry.getContext(), TEXT, - false, + /* foreignText */ false, null, classificationResult); @@ -61,4 +75,31 @@ public class IntentFactoryTest { assertThat( intent.getBooleanExtra(TextClassifier.EXTRA_FROM_TEXT_CLASSIFIER, false)).isTrue(); } + + @Test + public void create_translateAndDictionary() { + AnnotatorModel.ClassificationResult classificationResult = + new AnnotatorModel.ClassificationResult( + TextClassifier.TYPE_DICTIONARY, + 1.0f, + null, + null, + null, + null, + null, + null, + null, + null); + + List<TextClassifierImpl.LabeledIntent> intents = mLegacyIntentFactory.create( + InstrumentationRegistry.getContext(), + TEXT, + /* foreignText */ true, + null, + classificationResult); + + assertThat(intents).hasSize(2); + assertThat(intents.get(0).getIntent().getAction()).isEqualTo(Intent.ACTION_DEFINE); + assertThat(intents.get(1).getIntent().getAction()).isEqualTo(Intent.ACTION_TRANSLATE); + } } diff --git a/core/tests/coretests/src/android/view/textclassifier/TemplateIntentFactoryTest.java b/core/tests/coretests/src/android/view/textclassifier/TemplateIntentFactoryTest.java new file mode 100644 index 000000000000..0fcf359708c1 --- /dev/null +++ b/core/tests/coretests/src/android/view/textclassifier/TemplateIntentFactoryTest.java @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2019 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 android.view.textclassifier; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Intent; +import android.net.Uri; + +import androidx.test.InstrumentationRegistry; +import androidx.test.filters.SmallTest; +import androidx.test.runner.AndroidJUnit4; + +import com.google.android.textclassifier.AnnotatorModel; +import com.google.android.textclassifier.NamedVariant; +import com.google.android.textclassifier.RemoteActionTemplate; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.List; + +@SmallTest +@RunWith(AndroidJUnit4.class) +public class TemplateIntentFactoryTest { + + private static final String TEXT = "text"; + private static final String TITLE = "Map"; + private static final String DESCRIPTION = "Check the map"; + private static final String ACTION = Intent.ACTION_VIEW; + private static final String DATA = Uri.parse("http://www.android.com").toString(); + private static final String TYPE = "text/html"; + private static final Integer FLAG = Intent.FLAG_ACTIVITY_NEW_TASK; + private static final String[] CATEGORY = + new String[]{Intent.CATEGORY_DEFAULT, Intent.CATEGORY_APP_BROWSER}; + private static final String PACKAGE_NAME = "pkg.name"; + private static final String KEY_ONE = "key1"; + private static final String VALUE_ONE = "value1"; + private static final String KEY_TWO = "key2"; + private static final int VALUE_TWO = 42; + + private static final NamedVariant[] NAMED_VARIANTS = new NamedVariant[]{ + new NamedVariant(KEY_ONE, VALUE_ONE), + new NamedVariant(KEY_TWO, VALUE_TWO) + }; + private static final Integer REQUEST_CODE = 10; + + @Mock + private IntentFactory mFallback; + private TemplateIntentFactory mTemplateIntentFactory; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + mTemplateIntentFactory = new TemplateIntentFactory(mFallback); + } + + @Test + public void create_full() { + RemoteActionTemplate remoteActionTemplate = new RemoteActionTemplate( + TITLE, + DESCRIPTION, + ACTION, + DATA, + TYPE, + FLAG, + CATEGORY, + /* packageName */ null, + NAMED_VARIANTS, + REQUEST_CODE + ); + + AnnotatorModel.ClassificationResult classificationResult = + new AnnotatorModel.ClassificationResult( + TextClassifier.TYPE_ADDRESS, + 1.0f, + null, + null, + null, + null, + null, + null, + null, + new RemoteActionTemplate[]{remoteActionTemplate}); + + List<TextClassifierImpl.LabeledIntent> intents = mTemplateIntentFactory.create( + InstrumentationRegistry.getContext(), + TEXT, + false, + null, + classificationResult); + + assertThat(intents).hasSize(1); + TextClassifierImpl.LabeledIntent labeledIntent = intents.get(0); + assertThat(labeledIntent.getTitle()).isEqualTo(TITLE); + assertThat(labeledIntent.getDescription()).isEqualTo(DESCRIPTION); + assertThat(labeledIntent.getRequestCode()).isEqualTo(REQUEST_CODE); + Intent intent = labeledIntent.getIntent(); + assertThat(intent.getAction()).isEqualTo(ACTION); + assertThat(intent.getData().toString()).isEqualTo(DATA); + assertThat(intent.getType()).isEqualTo(TYPE); + assertThat(intent.getFlags()).isEqualTo(FLAG); + assertThat(intent.getCategories()).containsExactly((Object[]) CATEGORY); + assertThat(intent.getPackage()).isNull(); + assertThat( + intent.getStringExtra(KEY_ONE)).isEqualTo(VALUE_ONE); + assertThat(intent.getIntExtra(KEY_TWO, 0)).isEqualTo(VALUE_TWO); + assertThat( + intent.getBooleanExtra(TextClassifier.EXTRA_FROM_TEXT_CLASSIFIER, false)).isTrue(); + } + + @Test + public void create_pacakgeIsNotNull() { + RemoteActionTemplate remoteActionTemplate = new RemoteActionTemplate( + TITLE, + DESCRIPTION, + ACTION, + DATA, + TYPE, + FLAG, + CATEGORY, + PACKAGE_NAME, + NAMED_VARIANTS, + REQUEST_CODE + ); + + AnnotatorModel.ClassificationResult classificationResult = + new AnnotatorModel.ClassificationResult( + TextClassifier.TYPE_ADDRESS, + 1.0f, + null, + null, + null, + null, + null, + null, + null, + new RemoteActionTemplate[]{remoteActionTemplate}); + + List<TextClassifierImpl.LabeledIntent> intents = mTemplateIntentFactory.create( + InstrumentationRegistry.getContext(), + TEXT, + false, + null, + classificationResult); + + assertThat(intents).hasSize(0); + } + + @Test + public void create_minimal() { + RemoteActionTemplate remoteActionTemplate = new RemoteActionTemplate( + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ); + + AnnotatorModel.ClassificationResult classificationResult = + new AnnotatorModel.ClassificationResult( + TextClassifier.TYPE_ADDRESS, + 1.0f, + null, + null, + null, + null, + null, + null, + null, + new RemoteActionTemplate[]{remoteActionTemplate}); + + List<TextClassifierImpl.LabeledIntent> intents = mTemplateIntentFactory.create( + InstrumentationRegistry.getContext(), + TEXT, + false, + null, + classificationResult); + + assertThat(intents).hasSize(1); + TextClassifierImpl.LabeledIntent labeledIntent = intents.get(0); + assertThat(labeledIntent.getTitle()).isNull(); + assertThat(labeledIntent.getDescription()).isNull(); + assertThat(labeledIntent.getRequestCode()).isEqualTo( + TextClassifierImpl.LabeledIntent.DEFAULT_REQUEST_CODE); + Intent intent = labeledIntent.getIntent(); + assertThat(intent.getAction()).isNull(); + assertThat(intent.getData()).isNull(); + assertThat(intent.getType()).isNull(); + assertThat(intent.getFlags()).isEqualTo(0); + assertThat(intent.getCategories()).isNull(); + assertThat(intent.getPackage()).isNull(); + assertThat( + intent.getBooleanExtra(TextClassifier.EXTRA_FROM_TEXT_CLASSIFIER, false)).isTrue(); + } +} |