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 final private String mId; 59 final private 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(mCreationTimestampMillis >= 0, 108 "creationTimestamp must be explicitly set in the PersonBuilderHelper."); 109 110 for (ContactPointBuilderHelper builderHelper : mContactPointBuilderHelpers.values()) { 111 // We don't need to reset it for generating fingerprint. But still set it 0 here to 112 // avoid creationTimestamp automatically generated using current time. So our testing 113 // could be easier. 114 builderHelper.mBuilder.setCreationTimestampMillis(0); 115 mBuilder.addContactPoint(builderHelper.buildContactPoint()); 116 } 117 // Set the fingerprint and creationTimestamp to 0 to calculate the actual fingerprint. 118 mBuilder.setScore(0); 119 mBuilder.setFingerprint(EMPTY_BYTE_ARRAY); 120 mBuilder.setCreationTimestampMillis(0); 121 // Build a person for generating the fingerprint. 122 Person contactForFingerPrint = mBuilder.build(); 123 try { 124 byte[] fingerprint = generateFingerprintMD5(contactForFingerPrint); 125 // This is an "a priori" document score that doesn't take any usage into account. 126 // Hence, the heuristic that's used to assign the document score is to add the 127 // presence or count of all the salient properties of the contact. 128 int score = BASE_SCORE + contactForFingerPrint.getContactPoints().length 129 + contactForFingerPrint.getAdditionalNames().length; 130 mBuilder.setScore(score); 131 mBuilder.setFingerprint(fingerprint); 132 mBuilder.setCreationTimestampMillis(mCreationTimestampMillis); 133 } catch (NoSuchAlgorithmException e) { 134 // debug logging here to avoid flooding the log. 135 if (LogUtil.DEBUG) { 136 Log.d(TAG, 137 "Failed to generate fingerprint for contact " 138 + contactForFingerPrint.getId(), 139 e); 140 } 141 } 142 // Build a final person with fingerprint set. 143 return mBuilder.build(); 144 } 145 146 /** Gets the ID of this {@link Person}. */ 147 @NonNull getId()148 String getId() { 149 return mId; 150 } 151 152 @NonNull getPersonBuilder()153 public Person.Builder getPersonBuilder() { 154 return mBuilder; 155 } 156 157 @NonNull getOrCreateContactPointBuilderHelper(@onNull String label)158 private ContactPointBuilderHelper getOrCreateContactPointBuilderHelper(@NonNull String label) { 159 ContactPointBuilderHelper builderHelper = mContactPointBuilderHelpers.get( 160 Objects.requireNonNull(label)); 161 if (builderHelper == null) { 162 builderHelper = new ContactPointBuilderHelper( 163 new ContactPoint.Builder(AppSearchHelper.NAMESPACE_NAME, 164 /*id=*/"", // doesn't matter for this nested type. 165 label)); 166 mContactPointBuilderHelpers.put(label, builderHelper); 167 } 168 169 return builderHelper; 170 } 171 172 @NonNull setCreationTimestampMillis(long creationTimestampMillis)173 public PersonBuilderHelper setCreationTimestampMillis(long creationTimestampMillis) { 174 mCreationTimestampMillis = creationTimestampMillis; 175 return this; 176 } 177 178 @NonNull addAppIdToPerson(@onNull String label, @NonNull String appId)179 public PersonBuilderHelper addAppIdToPerson(@NonNull String label, @NonNull String appId) { 180 getOrCreateContactPointBuilderHelper(Objects.requireNonNull(label)).mBuilder 181 .addAppId(Objects.requireNonNull(appId)); 182 return this; 183 } 184 addEmailToPerson(@onNull String label, @NonNull String email)185 public PersonBuilderHelper addEmailToPerson(@NonNull String label, @NonNull String email) { 186 getOrCreateContactPointBuilderHelper(Objects.requireNonNull(label)).mBuilder 187 .addEmail(Objects.requireNonNull(email)); 188 return this; 189 } 190 191 @NonNull addAddressToPerson(@onNull String label, @NonNull String address)192 public PersonBuilderHelper addAddressToPerson(@NonNull String label, @NonNull String address) { 193 getOrCreateContactPointBuilderHelper(Objects.requireNonNull(label)).mBuilder 194 .addAddress(Objects.requireNonNull(address)); 195 return this; 196 } 197 198 @NonNull addPhoneToPerson(@onNull String label, @NonNull String phone)199 public PersonBuilderHelper addPhoneToPerson(@NonNull String label, @NonNull String phone) { 200 getOrCreateContactPointBuilderHelper(Objects.requireNonNull(label)).mBuilder 201 .addPhone(Objects.requireNonNull(phone)); 202 return this; 203 } 204 205 @NonNull addPhoneVariantToPerson(@onNull String label, @NonNull String phoneVariant)206 public PersonBuilderHelper addPhoneVariantToPerson(@NonNull String label, 207 @NonNull String phoneVariant) { 208 getOrCreateContactPointBuilderHelper(Objects.requireNonNull(label)) 209 .addPhoneNumberVariant(Objects.requireNonNull(phoneVariant)); 210 return this; 211 } 212 213 @NonNull generateFingerprintMD5(@onNull Person person)214 static byte[] generateFingerprintMD5(@NonNull Person person) throws NoSuchAlgorithmException { 215 Objects.requireNonNull(person); 216 217 MessageDigest md = MessageDigest.getInstance("MD5"); 218 md.update(generateFingerprintStringForPerson(person).getBytes(StandardCharsets.UTF_8)); 219 return md.digest(); 220 } 221 222 @VisibleForTesting 223 /** Returns a string presentation of {@link Person} for fingerprinting. */ generateFingerprintStringForPerson(@onNull Person person)224 static String generateFingerprintStringForPerson(@NonNull Person person) { 225 Objects.requireNonNull(person); 226 227 StringBuilder builder = new StringBuilder(); 228 appendGenericDocumentString(person, builder); 229 return builder.toString(); 230 } 231 232 /** 233 * Appends string representation of a {@link GenericDocument} to the {@link StringBuilder}. 234 * 235 * <p>This is basically same as 236 * {@link GenericDocument#appendGenericDocumentString(IndentingStringBuilder)}, but only keep 237 * the properties part and use a normal {@link StringBuilder} to skip the indentation. 238 */ appendGenericDocumentString(@onNull GenericDocument doc, @NonNull StringBuilder builder)239 private static void appendGenericDocumentString(@NonNull GenericDocument doc, 240 @NonNull StringBuilder builder) { 241 Objects.requireNonNull(doc); 242 Objects.requireNonNull(builder); 243 244 builder.append("properties: {\n"); 245 String[] sortedProperties = doc.getPropertyNames().toArray(new String[0]); 246 Arrays.sort(sortedProperties); 247 for (int i = 0; i < sortedProperties.length; i++) { 248 Object property = Objects.requireNonNull(doc.getProperty(sortedProperties[i])); 249 appendPropertyString(sortedProperties[i], property, builder); 250 if (i != sortedProperties.length - 1) { 251 builder.append(",\n"); 252 } 253 } 254 builder.append("\n"); 255 builder.append("}"); 256 } 257 258 /** 259 * Appends string representation of a {@link GenericDocument}'s property to the 260 * {@link StringBuilder}. 261 * 262 * <p>This is basically same as 263 * {@link GenericDocument#appendPropertyString(String, Object, IndentingStringBuilder)}, but 264 * use a normal {@link StringBuilder} to skip the indentation. 265 * 266 * <p>Here we still keep most of the formatting(e.g. '\n') to make sure we won't hit some 267 * possible corner cases. E.g. We will have "someProperty1: some\n Property2:..." instead of 268 * "someProperty1: someProperty2:". For latter, we can interpret it as empty string value for 269 * "someProperty1", with a different property name "someProperty2". In this case, the content is 270 * changed but fingerprint will remain same if we don't have that '\n'. 271 * 272 * <p>Plus, some basic formatting will make the testing more clear. 273 */ appendPropertyString( @onNull String propertyName, @NonNull Object property, @NonNull StringBuilder builder)274 private static void appendPropertyString( 275 @NonNull String propertyName, 276 @NonNull Object property, 277 @NonNull StringBuilder builder) { 278 Objects.requireNonNull(propertyName); 279 Objects.requireNonNull(property); 280 Objects.requireNonNull(builder); 281 282 builder.append("\"").append(propertyName).append("\": ["); 283 if (property instanceof GenericDocument[]) { 284 GenericDocument[] documentValues = (GenericDocument[]) property; 285 for (int i = 0; i < documentValues.length; ++i) { 286 builder.append("\n"); 287 appendGenericDocumentString(documentValues[i], builder); 288 if (i != documentValues.length - 1) { 289 builder.append(","); 290 } 291 builder.append("\n"); 292 } 293 builder.append("]"); 294 } else { 295 int propertyArrLength = Array.getLength(property); 296 for (int i = 0; i < propertyArrLength; i++) { 297 Object propertyElement = Array.get(property, i); 298 if (propertyElement instanceof String) { 299 builder.append("\"").append((String) propertyElement).append("\""); 300 } else if (propertyElement instanceof byte[]) { 301 builder.append(Arrays.toString((byte[]) propertyElement)); 302 } else { 303 builder.append(propertyElement.toString()); 304 } 305 if (i != propertyArrLength - 1) { 306 builder.append(", "); 307 } else { 308 builder.append("]"); 309 } 310 } 311 } 312 } 313 } 314