Introduce Translate intent.

Action: ACTION_TRANSLATE
Text: EXTRA_TEXT

TODO: Consider introducing a "translate" schema to pass the text to
translate instead of using a string extra to define this.

Bug: 116020587
Test: manually tested by selecting foreign language text and watching
      for a "Translate" action.
Change-Id: I046157f4b3e1aa60afaf3f123fdeecf406f712dc
diff --git a/api/current.txt b/api/current.txt
index aa400cd..39f6795 100644
--- a/api/current.txt
+++ b/api/current.txt
@@ -10116,6 +10116,7 @@
     field public static final java.lang.String ACTION_TIMEZONE_CHANGED = "android.intent.action.TIMEZONE_CHANGED";
     field public static final java.lang.String ACTION_TIME_CHANGED = "android.intent.action.TIME_SET";
     field public static final java.lang.String ACTION_TIME_TICK = "android.intent.action.TIME_TICK";
+    field public static final java.lang.String ACTION_TRANSLATE = "android.intent.action.TRANSLATE";
     field public static final java.lang.String ACTION_UID_REMOVED = "android.intent.action.UID_REMOVED";
     field public static final deprecated java.lang.String ACTION_UMS_CONNECTED = "android.intent.action.UMS_CONNECTED";
     field public static final deprecated java.lang.String ACTION_UMS_DISCONNECTED = "android.intent.action.UMS_DISCONNECTED";
diff --git a/core/java/android/content/Intent.java b/core/java/android/content/Intent.java
index 10c6bc6..de19bc0 100644
--- a/core/java/android/content/Intent.java
+++ b/core/java/android/content/Intent.java
@@ -3586,6 +3586,17 @@
     public static final String
             ACTION_OPEN_DOCUMENT_TREE = "android.intent.action.OPEN_DOCUMENT_TREE";
 
+
+    /**
+     * Activity Action: Perform text translation.
+     * <p>
+     * Input: {@link #EXTRA_TEXT getCharSequence(EXTRA_TEXT)} is the text to translate.
+     * <p>
+     * Output: nothing.
+     */
+    @SdkConstant(SdkConstantType.ACTIVITY_INTENT_ACTION)
+    public static final String ACTION_TRANSLATE = "android.intent.action.TRANSLATE";
+
     /**
      * Broadcast Action: List of dynamic sensor is changed due to new sensor being connected or
      * exisiting sensor being disconnected.
diff --git a/core/java/android/view/textclassifier/TextClassifierImpl.java b/core/java/android/view/textclassifier/TextClassifierImpl.java
index 798a820..3e57e1d 100644
--- a/core/java/android/view/textclassifier/TextClassifierImpl.java
+++ b/core/java/android/view/textclassifier/TextClassifierImpl.java
@@ -58,7 +58,6 @@
 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;
@@ -523,10 +522,10 @@
         final TextClassification.Builder builder = new TextClassification.Builder()
                 .setText(classifiedText);
 
-        final int size = classifications.length;
+        final int typeCount = classifications.length;
         AnnotatorModel.ClassificationResult highestScoringResult =
-                size > 0 ? classifications[0] : null;
-        for (int i = 0; i < size; i++) {
+                typeCount > 0 ? classifications[0] : null;
+        for (int i = 0; i < typeCount; i++) {
             builder.setEntityType(classifications[i].getCollection(),
                                   classifications[i].getScore());
             if (classifications[i].getScore() > highestScoringResult.getScore()) {
@@ -534,9 +533,12 @@
             }
         }
 
+        // TODO: Make this configurable.
+        final float foreignTextThreshold = typeCount == 0 ? 0.5f : 0.7f;
         boolean isPrimaryAction = true;
         for (LabeledIntent labeledIntent : IntentFactory.create(
-                mContext, classifiedText, referenceTime, highestScoringResult)) {
+                mContext, classifiedText, isForeignText(classifiedText, foreignTextThreshold),
+                referenceTime, highestScoringResult)) {
             final RemoteAction action = labeledIntent.asRemoteAction(mContext);
             if (action == null) {
                 continue;
@@ -558,6 +560,42 @@
         return builder.setId(createId(text, start, end)).build();
     }
 
+    private boolean isForeignText(String text, float threshold) {
+        // TODO: Revisit this algorithm.
+        try {
+            final LangIdModel.LanguageResult[] langResults = getLangIdImpl().detectLanguages(text);
+            if (langResults.length <= 0) {
+                return false;
+            }
+
+            LangIdModel.LanguageResult highestScoringResult = langResults[0];
+            for (int i = 1; i < langResults.length; i++) {
+                if (langResults[i].getScore() > highestScoringResult.getScore()) {
+                    highestScoringResult = langResults[i];
+                }
+            }
+            if (highestScoringResult.getScore() < threshold) {
+                return false;
+            }
+            // TODO: Remove
+            Log.d(LOG_TAG, String.format("Language detected: <%s:%s>",
+                    highestScoringResult.getLanguage(), highestScoringResult.getScore()));
+
+            final Locale detected = new Locale(highestScoringResult.getLanguage());
+            final LocaleList deviceLocales = LocaleList.getDefault();
+            final int size = deviceLocales.size();
+            for (int i = 0; i < size; i++) {
+                if (deviceLocales.get(i).getLanguage().equals(detected.getLanguage())) {
+                    return false;
+                }
+            }
+            return true;
+        } catch (Throwable t) {
+            Log.e(LOG_TAG, "Error detecting foreign text. Ignored.", t);
+        }
+        return false;
+    }
+
     @Override
     public void dump(@NonNull IndentingPrintWriter printWriter) {
         synchronized (mLock) {
@@ -698,53 +736,67 @@
         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)
-                    : null;
+                    : "";
             text = text.trim();
+            final List<LabeledIntent> actions;
             switch (type) {
                 case TextClassifier.TYPE_EMAIL:
-                    return createForEmail(context, text);
+                    actions = createForEmail(context, text);
+                    break;
                 case TextClassifier.TYPE_PHONE:
-                    return createForPhone(context, text);
+                    actions = createForPhone(context, text);
+                    break;
                 case TextClassifier.TYPE_ADDRESS:
-                    return createForAddress(context, text);
+                    actions = createForAddress(context, text);
+                    break;
                 case TextClassifier.TYPE_URL:
-                    return createForUrl(context, text);
-                case TextClassifier.TYPE_DATE:
+                    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());
-                        return createForDatetime(context, type, referenceTime, parsedTime);
+                        actions = createForDatetime(context, type, referenceTime, parsedTime);
                     } else {
-                        return new ArrayList<>();
+                        actions = new ArrayList<>();
                     }
+                    break;
                 case TextClassifier.TYPE_FLIGHT_NUMBER:
-                    return createForFlight(context, text);
+                    actions = createForFlight(context, text);
+                    break;
                 default:
-                    return new ArrayList<>();
+                    actions = new ArrayList<>();
+                    break;
             }
+            if (foreignText) {
+                insertTranslateAction(actions, context, text);
+            }
+            return actions;
         }
 
         @NonNull
         private static List<LabeledIntent> createForEmail(Context context, String text) {
-            return Arrays.asList(
-                    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),
-                    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()));
+            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
@@ -801,12 +853,14 @@
             if (Uri.parse(text).getScheme() == null) {
                 text = "http://" + text;
             }
-            return Arrays.asList(new LabeledIntent(
+            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
@@ -828,12 +882,14 @@
 
         @NonNull
         private static List<LabeledIntent> createForFlight(Context context, String text) {
-            return Arrays.asList(new LabeledIntent(
+            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
@@ -864,5 +920,17 @@
                                     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()));
+        }
     }
 }
diff --git a/core/res/res/values/strings.xml b/core/res/res/values/strings.xml
index 200c35d..9204ee4 100644
--- a/core/res/res/values/strings.xml
+++ b/core/res/res/values/strings.xml
@@ -2989,6 +2989,12 @@
     <!-- Accessibility description for an item in the text selection menu to track a flight [CHAR LIMIT=NONE] -->
     <string name="view_flight_desc">Track selected flight</string>
 
+    <!-- Label for item in the text selection menu to translate selected text with a translation app. Should be a verb. [CHAR LIMIT=30] -->
+    <string name="translate">Translate</string>
+
+    <!-- Accessibility description for an item in the text selection menu to translate selected text with a translation app. [CHAR LIMIT=NONE] -->
+    <string name="translate_desc">Translate selected text</string>
+
     <!-- If the device is getting low on internal storage, a notification is shown to the user.  This is the title of that notification. -->
     <string name="low_internal_storage_view_title">Storage space running out</string>
     <!-- If the device is getting low on internal storage, a notification is shown to the user.  This is the message of that notification. -->
diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml
index 82c9ff3..7a78327 100644
--- a/core/res/res/values/symbols.xml
+++ b/core/res/res/values/symbols.xml
@@ -574,6 +574,8 @@
   <java-symbol type="string" name="add_calendar_event_desc" />
   <java-symbol type="string" name="view_flight" />
   <java-symbol type="string" name="view_flight_desc" />
+  <java-symbol type="string" name="translate" />
+  <java-symbol type="string" name="translate_desc" />
   <java-symbol type="string" name="textSelectionCABTitle" />
   <java-symbol type="string" name="BaMmi" />
   <java-symbol type="string" name="CLIRDefaultOffNextCallOff" />