1 /*
2  * Copyright (C) 2018 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.view.textclassifier;
18 
19 import android.annotation.Nullable;
20 import android.app.Person;
21 import android.app.RemoteAction;
22 import android.content.ComponentName;
23 import android.content.Context;
24 import android.content.Intent;
25 import android.text.TextUtils;
26 import android.util.ArrayMap;
27 import android.util.Pair;
28 import android.view.textclassifier.intent.LabeledIntent;
29 import android.view.textclassifier.intent.TemplateIntentFactory;
30 
31 import com.android.internal.annotations.VisibleForTesting;
32 
33 import com.google.android.textclassifier.ActionsSuggestionsModel;
34 import com.google.android.textclassifier.RemoteActionTemplate;
35 
36 import java.util.ArrayDeque;
37 import java.util.ArrayList;
38 import java.util.Deque;
39 import java.util.List;
40 import java.util.Locale;
41 import java.util.Map;
42 import java.util.Objects;
43 import java.util.StringJoiner;
44 import java.util.function.Function;
45 import java.util.stream.Collectors;
46 
47 /**
48  * Helper class for action suggestions.
49  *
50  * @hide
51  */
52 @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
53 public final class ActionsSuggestionsHelper {
54     private static final String TAG = "ActionsSuggestions";
55     private static final int USER_LOCAL = 0;
56     private static final int FIRST_NON_LOCAL_USER = 1;
57 
ActionsSuggestionsHelper()58     private ActionsSuggestionsHelper() {}
59 
60     /**
61      * Converts the messages to a list of native messages object that the model can understand.
62      * <p>
63      * User id encoding - local user is represented as 0, Other users are numbered according to
64      * how far before they spoke last time in the conversation. For example, considering this
65      * conversation:
66      * <ul>
67      * <li> User A: xxx
68      * <li> Local user: yyy
69      * <li> User B: zzz
70      * </ul>
71      * User A will be encoded as 2, user B will be encoded as 1 and local user will be encoded as 0.
72      */
toNativeMessages( List<ConversationActions.Message> messages, Function<CharSequence, String> languageDetector)73     public static ActionsSuggestionsModel.ConversationMessage[] toNativeMessages(
74             List<ConversationActions.Message> messages,
75             Function<CharSequence, String> languageDetector) {
76         List<ConversationActions.Message> messagesWithText =
77                 messages.stream()
78                         .filter(message -> !TextUtils.isEmpty(message.getText()))
79                         .collect(Collectors.toCollection(ArrayList::new));
80         if (messagesWithText.isEmpty()) {
81             return new ActionsSuggestionsModel.ConversationMessage[0];
82         }
83         Deque<ActionsSuggestionsModel.ConversationMessage> nativeMessages = new ArrayDeque<>();
84         PersonEncoder personEncoder = new PersonEncoder();
85         int size = messagesWithText.size();
86         for (int i = size - 1; i >= 0; i--) {
87             ConversationActions.Message message = messagesWithText.get(i);
88             long referenceTime = message.getReferenceTime() == null
89                     ? 0
90                     : message.getReferenceTime().toInstant().toEpochMilli();
91             String timeZone = message.getReferenceTime() == null
92                     ? null
93                     : message.getReferenceTime().getZone().getId();
94             nativeMessages.push(new ActionsSuggestionsModel.ConversationMessage(
95                     personEncoder.encode(message.getAuthor()),
96                     message.getText().toString(), referenceTime, timeZone,
97                     languageDetector.apply(message.getText())));
98         }
99         return nativeMessages.toArray(
100                 new ActionsSuggestionsModel.ConversationMessage[nativeMessages.size()]);
101     }
102 
103     /**
104      * Returns the result id for logging.
105      */
createResultId( Context context, List<ConversationActions.Message> messages, int modelVersion, List<Locale> modelLocales)106     public static String createResultId(
107             Context context,
108             List<ConversationActions.Message> messages,
109             int modelVersion,
110             List<Locale> modelLocales) {
111         final StringJoiner localesJoiner = new StringJoiner(",");
112         for (Locale locale : modelLocales) {
113             localesJoiner.add(locale.toLanguageTag());
114         }
115         final String modelName = String.format(
116                 Locale.US, "%s_v%d", localesJoiner.toString(), modelVersion);
117         final int hash = Objects.hash(
118                 messages.stream().mapToInt(ActionsSuggestionsHelper::hashMessage),
119                 context.getPackageName(),
120                 System.currentTimeMillis());
121         return SelectionSessionLogger.SignatureParser.createSignature(
122                 SelectionSessionLogger.CLASSIFIER_ID, modelName, hash);
123     }
124 
125     /**
126      * Generated labeled intent from an action suggestion and return the resolved result.
127      */
128     @Nullable
createLabeledIntentResult( Context context, TemplateIntentFactory templateIntentFactory, ActionsSuggestionsModel.ActionSuggestion nativeSuggestion)129     public static LabeledIntent.Result createLabeledIntentResult(
130             Context context,
131             TemplateIntentFactory templateIntentFactory,
132             ActionsSuggestionsModel.ActionSuggestion nativeSuggestion) {
133         RemoteActionTemplate[] remoteActionTemplates =
134                 nativeSuggestion.getRemoteActionTemplates();
135         if (remoteActionTemplates == null) {
136             Log.w(TAG, "createRemoteAction: Missing template for type "
137                     + nativeSuggestion.getActionType());
138             return null;
139         }
140         List<LabeledIntent> labeledIntents = templateIntentFactory.create(remoteActionTemplates);
141         if (labeledIntents.isEmpty()) {
142             return null;
143         }
144         // Given that we only support implicit intent here, we should expect there is just one
145         // intent for each action type.
146         LabeledIntent.TitleChooser titleChooser =
147                 ActionsSuggestionsHelper.createTitleChooser(nativeSuggestion.getActionType());
148         return labeledIntents.get(0).resolve(context, titleChooser, null);
149     }
150 
151     /**
152      * Returns a {@link LabeledIntent.TitleChooser} for conversation actions use case.
153      */
154     @Nullable
createTitleChooser(String actionType)155     public static LabeledIntent.TitleChooser createTitleChooser(String actionType) {
156         if (ConversationAction.TYPE_OPEN_URL.equals(actionType)) {
157             return (labeledIntent, resolveInfo) -> {
158                 if (resolveInfo.handleAllWebDataURI) {
159                     return labeledIntent.titleWithEntity;
160                 }
161                 if ("android".equals(resolveInfo.activityInfo.packageName)) {
162                     return labeledIntent.titleWithEntity;
163                 }
164                 return labeledIntent.titleWithoutEntity;
165             };
166         }
167         return null;
168     }
169 
170     /**
171      * Returns a list of {@link ConversationAction}s that have 0 duplicates. Two actions are
172      * duplicates if they may look the same to users. This function assumes every
173      * ConversationActions with a non-null RemoteAction also have a non-null intent in the extras.
174      */
175     public static List<ConversationAction> removeActionsWithDuplicates(
176             List<ConversationAction> conversationActions) {
177         // Ideally, we should compare title and icon here, but comparing icon is expensive and thus
178         // we use the component name of the target handler as the heuristic.
179         Map<Pair<String, String>, Integer> counter = new ArrayMap<>();
180         for (ConversationAction conversationAction : conversationActions) {
181             Pair<String, String> representation = getRepresentation(conversationAction);
182             if (representation == null) {
183                 continue;
184             }
185             Integer existingCount = counter.getOrDefault(representation, 0);
186             counter.put(representation, existingCount + 1);
187         }
188         List<ConversationAction> result = new ArrayList<>();
189         for (ConversationAction conversationAction : conversationActions) {
190             Pair<String, String> representation = getRepresentation(conversationAction);
191             if (representation == null || counter.getOrDefault(representation, 0) == 1) {
192                 result.add(conversationAction);
193             }
194         }
195         return result;
196     }
197 
198     @Nullable
199     private static Pair<String, String> getRepresentation(
200             ConversationAction conversationAction) {
201         RemoteAction remoteAction = conversationAction.getAction();
202         if (remoteAction == null) {
203             return null;
204         }
205         Intent actionIntent = ExtrasUtils.getActionIntent(conversationAction.getExtras());
206         ComponentName componentName = actionIntent.getComponent();
207         // Action without a component name will be considered as from the same app.
208         String packageName = componentName == null ? null : componentName.getPackageName();
209         return new Pair<>(
210                 conversationAction.getAction().getTitle().toString(), packageName);
211     }
212 
213     private static final class PersonEncoder {
214         private final Map<Person, Integer> mMapping = new ArrayMap<>();
215         private int mNextUserId = FIRST_NON_LOCAL_USER;
216 
217         private int encode(Person person) {
218             if (ConversationActions.Message.PERSON_USER_SELF.equals(person)) {
219                 return USER_LOCAL;
220             }
221             Integer result = mMapping.get(person);
222             if (result == null) {
223                 mMapping.put(person, mNextUserId);
224                 result = mNextUserId;
225                 mNextUserId++;
226             }
227             return result;
228         }
229     }
230 
231     private static int hashMessage(ConversationActions.Message message) {
232         return Objects.hash(message.getAuthor(), message.getText(), message.getReferenceTime());
233     }
234 }
235