• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 com.android.textclassifier;
18 
19 import android.app.Person;
20 import android.app.RemoteAction;
21 import android.content.ComponentName;
22 import android.content.Context;
23 import android.content.Intent;
24 import android.text.TextUtils;
25 import android.util.ArrayMap;
26 import android.util.Pair;
27 import android.view.textclassifier.ConversationAction;
28 import android.view.textclassifier.ConversationActions;
29 import android.view.textclassifier.ConversationActions.Message;
30 import com.android.textclassifier.common.ModelFile;
31 import com.android.textclassifier.common.base.TcLog;
32 import com.android.textclassifier.common.intent.LabeledIntent;
33 import com.android.textclassifier.common.intent.TemplateIntentFactory;
34 import com.android.textclassifier.common.logging.ResultIdUtils;
35 import com.google.android.textclassifier.ActionsSuggestionsModel;
36 import com.google.android.textclassifier.RemoteActionTemplate;
37 import com.google.common.base.Equivalence;
38 import com.google.common.base.Equivalence.Wrapper;
39 import com.google.common.base.Optional;
40 import java.util.ArrayDeque;
41 import java.util.ArrayList;
42 import java.util.Deque;
43 import java.util.List;
44 import java.util.Map;
45 import java.util.Objects;
46 import java.util.function.Function;
47 import java.util.stream.Collectors;
48 import javax.annotation.Nullable;
49 
50 /** Helper class for action suggestions. */
51 final class ActionsSuggestionsHelper {
52   private static final String TAG = "ActionsSuggestions";
53   private static final int USER_LOCAL = 0;
54   private static final int FIRST_NON_LOCAL_USER = 1;
55 
ActionsSuggestionsHelper()56   private ActionsSuggestionsHelper() {}
57 
58   /**
59    * Converts the messages to a list of native messages object that the model can understand.
60    *
61    * <p>User id encoding - local user is represented as 0, Other users are numbered according to how
62    * far before they spoke last time in the conversation. For example, considering this
63    * conversation:
64    *
65    * <ul>
66    *   <li>User A: xxx
67    *   <li>Local user: yyy
68    *   <li>User B: zzz
69    * </ul>
70    *
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, List<String>> languageDetector)73   public static ActionsSuggestionsModel.ConversationMessage[] toNativeMessages(
74       List<ConversationActions.Message> messages,
75       Function<CharSequence, List<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 =
89           message.getReferenceTime() == null
90               ? 0
91               : message.getReferenceTime().toInstant().toEpochMilli();
92       String timeZone =
93           message.getReferenceTime() == null ? null : message.getReferenceTime().getZone().getId();
94       nativeMessages.push(
95           new ActionsSuggestionsModel.ConversationMessage(
96               personEncoder.encode(message.getAuthor()),
97               message.getText().toString(),
98               referenceTime,
99               timeZone,
100               String.join(",", languageDetector.apply(message.getText()))));
101     }
102     return nativeMessages.toArray(
103         new ActionsSuggestionsModel.ConversationMessage[nativeMessages.size()]);
104   }
105 
106   /** Returns the result id for logging. */
createResultId( Context context, List<ConversationActions.Message> messages, Optional<ModelFile> actionsModel, Optional<ModelFile> annotatorModel, Optional<ModelFile> langIdModel)107   public static String createResultId(
108       Context context,
109       List<ConversationActions.Message> messages,
110       Optional<ModelFile> actionsModel,
111       Optional<ModelFile> annotatorModel,
112       Optional<ModelFile> langIdModel) {
113     int hash =
114         Objects.hash(
115             messages.stream().mapToInt(ActionsSuggestionsHelper::hashMessage),
116             context.getPackageName(),
117             System.currentTimeMillis());
118     return ResultIdUtils.createId(
119         hash, ModelFile.toModelInfos(actionsModel, annotatorModel, langIdModel));
120   }
121 
122   /** Generated labeled intent from an action suggestion and return the resolved result. */
123   @Nullable
createLabeledIntentResult( Context context, TemplateIntentFactory templateIntentFactory, ActionsSuggestionsModel.ActionSuggestion nativeSuggestion)124   public static LabeledIntent.Result createLabeledIntentResult(
125       Context context,
126       TemplateIntentFactory templateIntentFactory,
127       ActionsSuggestionsModel.ActionSuggestion nativeSuggestion) {
128     RemoteActionTemplate[] remoteActionTemplates = nativeSuggestion.getRemoteActionTemplates();
129     if (remoteActionTemplates == null) {
130       TcLog.w(
131           TAG, "createRemoteAction: Missing template for type " + nativeSuggestion.getActionType());
132       return null;
133     }
134     List<LabeledIntent> labeledIntents = templateIntentFactory.create(remoteActionTemplates);
135     if (labeledIntents.isEmpty()) {
136       return null;
137     }
138     // Given that we only support implicit intent here, we should expect there is just one
139     // intent for each action type.
140     LabeledIntent.TitleChooser titleChooser =
141         ActionsSuggestionsHelper.createTitleChooser(nativeSuggestion.getActionType());
142     return labeledIntents.get(0).resolve(context, titleChooser);
143   }
144 
145   /** Returns a {@link LabeledIntent.TitleChooser} for conversation actions use case. */
146   @Nullable
createTitleChooser(String actionType)147   public static LabeledIntent.TitleChooser createTitleChooser(String actionType) {
148     if (ConversationAction.TYPE_OPEN_URL.equals(actionType)) {
149       return (labeledIntent, resolveInfo) -> {
150         if (resolveInfo == null) {
151           return labeledIntent.titleWithEntity;
152         }
153         if (resolveInfo.handleAllWebDataURI) {
154           return labeledIntent.titleWithEntity;
155         }
156         if ("android".equals(resolveInfo.activityInfo.packageName)) {
157           return labeledIntent.titleWithEntity;
158         }
159         return labeledIntent.titleWithoutEntity;
160       };
161     }
162     return null;
163   }
164 
165   /**
166    * Returns a list of {@link ConversationAction}s that have 0 duplicates. Two actions are
167    * duplicates if they may look the same to users. This function assumes every ConversationActions
168    * with a non-null RemoteAction also have a non-null intent in the extras.
169    */
170   public static List<ConversationAction> removeActionsWithDuplicates(
171       List<ConversationAction> conversationActions) {
172     // Ideally, we should compare title and icon here, but comparing icon is expensive and thus
173     // we use the component name of the target handler as the heuristic.
174     Map<Pair<String, String>, Integer> counter = new ArrayMap<>();
175     for (ConversationAction conversationAction : conversationActions) {
176       Pair<String, String> representation = getRepresentation(conversationAction);
177       if (representation == null) {
178         continue;
179       }
180       Integer existingCount = counter.getOrDefault(representation, 0);
181       counter.put(representation, existingCount + 1);
182     }
183     List<ConversationAction> result = new ArrayList<>();
184     for (ConversationAction conversationAction : conversationActions) {
185       Pair<String, String> representation = getRepresentation(conversationAction);
186       if (representation == null || counter.getOrDefault(representation, 0) == 1) {
187         result.add(conversationAction);
188       }
189     }
190     return result;
191   }
192 
193   @Nullable
194   private static Pair<String, String> getRepresentation(ConversationAction conversationAction) {
195     RemoteAction remoteAction = conversationAction.getAction();
196     if (remoteAction == null) {
197       return null;
198     }
199     Intent actionIntent = ExtrasUtils.getActionIntent(conversationAction.getExtras());
200     ComponentName componentName = actionIntent.getComponent();
201     // Action without a component name will be considered as from the same app.
202     String packageName = componentName == null ? null : componentName.getPackageName();
203     return new Pair<>(conversationAction.getAction().getTitle().toString(), packageName);
204   }
205 
206   private static final class PersonEncoder {
207     private static final Equivalence<Person> EQUIVALENCE = new PersonEquivalence();
208     private static final Equivalence.Wrapper<Person> PERSON_USER_SELF =
209         EQUIVALENCE.wrap(Message.PERSON_USER_SELF);
210 
211     private final Map<Equivalence.Wrapper<Person>, Integer> personToUserIdMap = new ArrayMap<>();
212     private int nextUserId = FIRST_NON_LOCAL_USER;
213 
214     private int encode(Person person) {
215       Wrapper<Person> personWrapper = EQUIVALENCE.wrap(person);
216       if (PERSON_USER_SELF.equals(personWrapper)) {
217         return USER_LOCAL;
218       }
219       Integer result = personToUserIdMap.get(personWrapper);
220       if (result == null) {
221         personToUserIdMap.put(personWrapper, nextUserId);
222         result = nextUserId;
223         nextUserId++;
224       }
225       return result;
226     }
227 
228     private static final class PersonEquivalence extends Equivalence<Person> {
229 
230       @Override
231       protected boolean doEquivalent(Person a, Person b) {
232         return Objects.equals(a.getKey(), b.getKey())
233             && TextUtils.equals(a.getName(), b.getName())
234             && Objects.equals(a.getUri(), b.getUri());
235       }
236 
237       @Override
238       protected int doHash(Person person) {
239         return Objects.hash(person.getKey(), person.getName(), person.getUri());
240       }
241     }
242   }
243 
244   private static int hashMessage(ConversationActions.Message message) {
245     return Objects.hash(message.getAuthor(), message.getText(), message.getReferenceTime());
246   }
247 }
248