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