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