From fc039c36abc808d2d24bdd77c06a65e04f386e9b Mon Sep 17 00:00:00 2001 From: Tony Mak Date: Thu, 17 Jan 2019 19:32:08 +0000 Subject: [PATCH] Update IntentFactory to construct intents using RemoteActionTemplate... objects that are returned by the model 1. TemplateIntentFactory is the intent generator. It reads from the templates that are returned from the model, and construct the intents accordingly. If template is missing, we fallback to use LegacyIntentFactory. 2. LegacyIntentFactory is the old(existing) intent generator. 3. Added a flag to allow us to switch between them. Test: atest TemplateIntentFactoryTest.java Test: atest LegacyIntentFactoryTest.java Change-Id: I7bdcc73321f5a0160c5ff0edf1a2095119f4dcb1 --- core/java/android/provider/Settings.java | 1 + .../view/textclassifier/IntentFactory.java | 56 ++++ .../textclassifier/LegacyIntentFactory.java | 260 ++++++++++++++++ .../textclassifier/TemplateIntentFactory.java | 167 +++++++++++ .../TextClassificationConstants.java | 11 + .../textclassifier/TextClassifierImpl.java | 278 ++---------------- ...Test.java => LegacyIntentFactoryTest.java} | 47 ++- .../TemplateIntentFactoryTest.java | 218 ++++++++++++++ 8 files changed, 783 insertions(+), 255 deletions(-) create mode 100644 core/java/android/view/textclassifier/IntentFactory.java create mode 100644 core/java/android/view/textclassifier/LegacyIntentFactory.java create mode 100644 core/java/android/view/textclassifier/TemplateIntentFactory.java rename core/tests/coretests/src/android/view/textclassifier/{IntentFactoryTest.java => LegacyIntentFactoryTest.java} (56%) create mode 100644 core/tests/coretests/src/android/view/textclassifier/TemplateIntentFactoryTest.java diff --git a/core/java/android/provider/Settings.java b/core/java/android/provider/Settings.java index 195e72c71e557..52a87b9f7aaeb 100644 --- a/core/java/android/provider/Settings.java +++ b/core/java/android/provider/Settings.java @@ -11724,6 +11724,7 @@ public final class Settings { * entity_list_not_editable (String[]) * entity_list_editable (String[]) * lang_id_threshold_override (float) + * template_intent_factory_enabled (boolean) * * *

diff --git a/core/java/android/view/textclassifier/IntentFactory.java b/core/java/android/view/textclassifier/IntentFactory.java new file mode 100644 index 0000000000000..d9c03c858f7a1 --- /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 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 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 0000000000000..b6e5b3e26b16e --- /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 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 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 createForEmail(Context context, String text) { + final List 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 createForPhone(Context context, String text) { + final List 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 createForAddress(Context context, String text) { + final List 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 createForUrl(Context context, String text) { + if (Uri.parse(text).getScheme() == null) { + text = "http://" + text; + } + final List 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 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 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 createForFlight(Context context, String text) { + final List 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 createForDictionary(Context context, String text) { + final List 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 0000000000000..97e11bb702be5 --- /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 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 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 createFromRemoteActionTemplates( + RemoteActionTemplate[] remoteActionTemplates) { + final List 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 7f928f74da192..ee9e04e5329a9 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) * * *

@@ -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 mInAppConversationActionTypesDefault; private final List 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 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 7782079213e74..c297928ae5f67 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 sourceIntents = new ArrayList<>(); - for (LabeledIntent labeledIntent : IntentFactory.create( - mContext, classifiedText, isForeignText(classifiedText, foreignTextThreshold), - referenceTime, highestScoringResult)) { + List 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 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 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 createForEmail(Context context, String text) { - final List 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 createForPhone(Context context, String text) { - final List 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 createForAddress(Context context, String text) { - final List 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 createForUrl(Context context, String text) { - if (Uri.parse(text).getScheme() == null) { - text = "http://" + text; - } - final List 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 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 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 createForFlight(Context context, String text) { - final List 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 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 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 similarity index 56% rename from core/tests/coretests/src/android/view/textclassifier/IntentFactoryTest.java rename to core/tests/coretests/src/android/view/textclassifier/LegacyIntentFactoryTest.java index 3fc8e4c2eecdf..73d3eec734d77 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 intents = TextClassifierImpl.IntentFactory.create( + List 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 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 0000000000000..0fcf359708c14 --- /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 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 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 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(); + } +}