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