1 /* 2 * Copyright (C) 2022 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.server.appsearch.contactsindexer; 18 19 import android.annotation.NonNull; 20 import android.app.appsearch.GenericDocument; 21 import android.app.appsearch.util.IndentingStringBuilder; 22 import android.app.appsearch.util.LogUtil; 23 import android.util.ArrayMap; 24 import android.util.Log; 25 26 import com.android.internal.annotations.VisibleForTesting; 27 import com.android.internal.util.Preconditions; 28 import com.android.server.appsearch.contactsindexer.appsearchtypes.ContactPoint; 29 import com.android.server.appsearch.contactsindexer.appsearchtypes.Person; 30 31 import java.lang.reflect.Array; 32 import java.nio.charset.StandardCharsets; 33 import java.security.MessageDigest; 34 import java.security.NoSuchAlgorithmException; 35 import java.util.ArrayList; 36 import java.util.Arrays; 37 import java.util.List; 38 import java.util.Map; 39 import java.util.Objects; 40 41 /** 42 * Helper class to help build the {@link Person}. 43 * 44 * <p>It takes a {@link Person.Builder} with a map to help handle and aggregate {@link 45 * ContactPoint}s, and put them in the {@link Person} during the build. 46 * 47 * <p>This class is not thread safe. 48 * 49 * @hide 50 */ 51 public final class PersonBuilderHelper { 52 static final String TAG = "PersonBuilderHelper"; 53 static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; 54 static final int BASE_SCORE = 1; 55 56 // We want to store id separately even if we do have it set in the builder, since we 57 // can't get its value out of the builder, which will be used to fetch fingerprints. 58 private final String mId; 59 private final Person.Builder mBuilder; 60 private long mCreationTimestampMillis = -1; 61 private Map<String, ContactPointBuilderHelper> mContactPointBuilderHelpers = new ArrayMap<>(); 62 PersonBuilderHelper(@onNull String id, @NonNull Person.Builder builder)63 public PersonBuilderHelper(@NonNull String id, @NonNull Person.Builder builder) { 64 Objects.requireNonNull(id); 65 Objects.requireNonNull(builder); 66 mId = id; 67 mBuilder = builder; 68 } 69 70 /** 71 * Helper class to construct a {@link ContactPoint}. 72 * 73 * <p>In this helper, besides a {@link ContactPoint.Builder}, it contains a list of phone number 74 * variants, so we can append those at the end of the final phone number list in {@link 75 * #buildContactPoint()}. 76 */ 77 private static class ContactPointBuilderHelper { 78 final ContactPoint.Builder mBuilder; 79 List<String> mPhoneNumberVariants = new ArrayList<>(); 80 ContactPointBuilderHelper(@onNull ContactPoint.Builder builder)81 ContactPointBuilderHelper(@NonNull ContactPoint.Builder builder) { 82 mBuilder = Objects.requireNonNull(builder); 83 } 84 addPhoneNumberVariant(@onNull String phoneNumberVariant)85 ContactPointBuilderHelper addPhoneNumberVariant(@NonNull String phoneNumberVariant) { 86 mPhoneNumberVariants.add(Objects.requireNonNull(phoneNumberVariant)); 87 return this; 88 } 89 buildContactPoint()90 ContactPoint buildContactPoint() { 91 // Append the phone number variants at the end of phone number list. So the original 92 // phone numbers can appear first in the list. 93 for (int i = 0; i < mPhoneNumberVariants.size(); ++i) { 94 mBuilder.addPhone(mPhoneNumberVariants.get(i)); 95 } 96 return mBuilder.build(); 97 } 98 } 99 100 /** 101 * A {@link Person} is built and returned based on the current properties set in this helper. 102 * 103 * <p>A fingerprint is automatically generated and set. 104 */ 105 @NonNull buildPerson()106 public Person buildPerson() { 107 Preconditions.checkState( 108 mCreationTimestampMillis >= 0, 109 "creationTimestamp must be explicitly set in the PersonBuilderHelper."); 110 111 for (ContactPointBuilderHelper builderHelper : mContactPointBuilderHelpers.values()) { 112 // We don't need to reset it for generating fingerprint. But still set it 0 here to 113 // avoid creationTimestamp automatically generated using current time. So our testing 114 // could be easier. 115 builderHelper.mBuilder.setCreationTimestampMillis(0); 116 mBuilder.addContactPoint(builderHelper.buildContactPoint()); 117 } 118 // Set the fingerprint and creationTimestamp to 0 to calculate the actual fingerprint. 119 mBuilder.setScore(0); 120 mBuilder.setFingerprint(EMPTY_BYTE_ARRAY); 121 mBuilder.setCreationTimestampMillis(0); 122 // Build a person for generating the fingerprint. 123 Person contactForFingerPrint = mBuilder.build(); 124 try { 125 byte[] fingerprint = generateFingerprintMD5(contactForFingerPrint); 126 // This is an "a priori" document score that doesn't take any usage into account. 127 // Hence, the heuristic that's used to assign the document score is to add the 128 // presence or count of all the salient properties of the contact. 129 int score = 130 BASE_SCORE 131 + contactForFingerPrint.getContactPoints().length 132 + contactForFingerPrint.getAdditionalNames().length; 133 mBuilder.setScore(score); 134 mBuilder.setFingerprint(fingerprint); 135 mBuilder.setCreationTimestampMillis(mCreationTimestampMillis); 136 } catch (NoSuchAlgorithmException e) { 137 // debug logging here to avoid flooding the log. 138 if (LogUtil.DEBUG) { 139 Log.d( 140 TAG, 141 "Failed to generate fingerprint for contact " 142 + contactForFingerPrint.getId(), 143 e); 144 } 145 } 146 // Build a final person with fingerprint set. 147 return mBuilder.build(); 148 } 149 150 /** Gets the ID of this {@link Person}. */ 151 @NonNull getId()152 String getId() { 153 return mId; 154 } 155 156 @NonNull getPersonBuilder()157 public Person.Builder getPersonBuilder() { 158 return mBuilder; 159 } 160 161 @NonNull getOrCreateContactPointBuilderHelper(@onNull String label)162 private ContactPointBuilderHelper getOrCreateContactPointBuilderHelper(@NonNull String label) { 163 ContactPointBuilderHelper builderHelper = 164 mContactPointBuilderHelpers.get(Objects.requireNonNull(label)); 165 if (builderHelper == null) { 166 builderHelper = 167 new ContactPointBuilderHelper( 168 new ContactPoint.Builder( 169 AppSearchHelper.NAMESPACE_NAME, 170 /* id= */ "", // doesn't matter for this nested type. 171 label)); 172 mContactPointBuilderHelpers.put(label, builderHelper); 173 } 174 175 return builderHelper; 176 } 177 178 @NonNull setCreationTimestampMillis(long creationTimestampMillis)179 public PersonBuilderHelper setCreationTimestampMillis(long creationTimestampMillis) { 180 mCreationTimestampMillis = creationTimestampMillis; 181 return this; 182 } 183 184 @NonNull addAppIdToPerson(@onNull String label, @NonNull String appId)185 public PersonBuilderHelper addAppIdToPerson(@NonNull String label, @NonNull String appId) { 186 getOrCreateContactPointBuilderHelper(Objects.requireNonNull(label)) 187 .mBuilder 188 .addAppId(Objects.requireNonNull(appId)); 189 return this; 190 } 191 addEmailToPerson(@onNull String label, @NonNull String email)192 public PersonBuilderHelper addEmailToPerson(@NonNull String label, @NonNull String email) { 193 getOrCreateContactPointBuilderHelper(Objects.requireNonNull(label)) 194 .mBuilder 195 .addEmail(Objects.requireNonNull(email)); 196 return this; 197 } 198 199 @NonNull addAddressToPerson(@onNull String label, @NonNull String address)200 public PersonBuilderHelper addAddressToPerson(@NonNull String label, @NonNull String address) { 201 getOrCreateContactPointBuilderHelper(Objects.requireNonNull(label)) 202 .mBuilder 203 .addAddress(Objects.requireNonNull(address)); 204 return this; 205 } 206 207 @NonNull addPhoneToPerson(@onNull String label, @NonNull String phone)208 public PersonBuilderHelper addPhoneToPerson(@NonNull String label, @NonNull String phone) { 209 getOrCreateContactPointBuilderHelper(Objects.requireNonNull(label)) 210 .mBuilder 211 .addPhone(Objects.requireNonNull(phone)); 212 return this; 213 } 214 215 @NonNull addPhoneVariantToPerson( @onNull String label, @NonNull String phoneVariant)216 public PersonBuilderHelper addPhoneVariantToPerson( 217 @NonNull String label, @NonNull String phoneVariant) { 218 getOrCreateContactPointBuilderHelper(Objects.requireNonNull(label)) 219 .addPhoneNumberVariant(Objects.requireNonNull(phoneVariant)); 220 return this; 221 } 222 223 @NonNull generateFingerprintMD5(@onNull Person person)224 static byte[] generateFingerprintMD5(@NonNull Person person) throws NoSuchAlgorithmException { 225 Objects.requireNonNull(person); 226 227 MessageDigest md = MessageDigest.getInstance("MD5"); 228 md.update(generateFingerprintStringForPerson(person).getBytes(StandardCharsets.UTF_8)); 229 return md.digest(); 230 } 231 232 @VisibleForTesting 233 /** Returns a string presentation of {@link Person} for fingerprinting. */ generateFingerprintStringForPerson(@onNull Person person)234 static String generateFingerprintStringForPerson(@NonNull Person person) { 235 Objects.requireNonNull(person); 236 237 StringBuilder builder = new StringBuilder(); 238 appendGenericDocumentString(person, builder); 239 return builder.toString(); 240 } 241 242 /** 243 * Appends string representation of a {@link GenericDocument} to the {@link StringBuilder}. 244 * 245 * <p>This is basically same as {@link 246 * GenericDocument#appendGenericDocumentString(IndentingStringBuilder)}, but only keep the 247 * properties part and use a normal {@link StringBuilder} to skip the indentation. 248 */ appendGenericDocumentString( @onNull GenericDocument doc, @NonNull StringBuilder builder)249 private static void appendGenericDocumentString( 250 @NonNull GenericDocument doc, @NonNull StringBuilder builder) { 251 Objects.requireNonNull(doc); 252 Objects.requireNonNull(builder); 253 254 builder.append("properties: {\n"); 255 String[] sortedProperties = doc.getPropertyNames().toArray(new String[0]); 256 Arrays.sort(sortedProperties); 257 for (int i = 0; i < sortedProperties.length; i++) { 258 Object property = Objects.requireNonNull(doc.getProperty(sortedProperties[i])); 259 appendPropertyString(sortedProperties[i], property, builder); 260 if (i != sortedProperties.length - 1) { 261 builder.append(",\n"); 262 } 263 } 264 builder.append("\n"); 265 builder.append("}"); 266 } 267 268 /** 269 * Appends string representation of a {@link GenericDocument}'s property to the {@link 270 * StringBuilder}. 271 * 272 * <p>This is basically same as {@link GenericDocument#appendPropertyString(String, Object, 273 * IndentingStringBuilder)}, but use a normal {@link StringBuilder} to skip the indentation. 274 * 275 * <p>Here we still keep most of the formatting(e.g. '\n') to make sure we won't hit some 276 * possible corner cases. E.g. We will have "someProperty1: some\n Property2:..." instead of 277 * "someProperty1: someProperty2:". For latter, we can interpret it as empty string value for 278 * "someProperty1", with a different property name "someProperty2". In this case, the content is 279 * changed but fingerprint will remain same if we don't have that '\n'. 280 * 281 * <p>Plus, some basic formatting will make the testing more clear. 282 */ appendPropertyString( @onNull String propertyName, @NonNull Object property, @NonNull StringBuilder builder)283 private static void appendPropertyString( 284 @NonNull String propertyName, 285 @NonNull Object property, 286 @NonNull StringBuilder builder) { 287 Objects.requireNonNull(propertyName); 288 Objects.requireNonNull(property); 289 Objects.requireNonNull(builder); 290 291 builder.append("\"").append(propertyName).append("\": ["); 292 if (property instanceof GenericDocument[]) { 293 GenericDocument[] documentValues = (GenericDocument[]) property; 294 for (int i = 0; i < documentValues.length; ++i) { 295 builder.append("\n"); 296 appendGenericDocumentString(documentValues[i], builder); 297 if (i != documentValues.length - 1) { 298 builder.append(","); 299 } 300 builder.append("\n"); 301 } 302 builder.append("]"); 303 } else { 304 int propertyArrLength = Array.getLength(property); 305 for (int i = 0; i < propertyArrLength; i++) { 306 Object propertyElement = Array.get(property, i); 307 if (propertyElement instanceof String) { 308 builder.append("\"").append((String) propertyElement).append("\""); 309 } else if (propertyElement instanceof byte[]) { 310 builder.append(Arrays.toString((byte[]) propertyElement)); 311 } else { 312 builder.append(propertyElement.toString()); 313 } 314 if (i != propertyArrLength - 1) { 315 builder.append(", "); 316 } else { 317 builder.append("]"); 318 } 319 } 320 } 321 } 322 } 323