| /* |
| * Copyright (C) 2022 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.server.appsearch.contactsindexer; |
| |
| import android.annotation.NonNull; |
| import android.app.appsearch.GenericDocument; |
| import android.app.appsearch.util.IndentingStringBuilder; |
| import android.app.appsearch.util.LogUtil; |
| import android.util.ArrayMap; |
| import android.util.Log; |
| |
| import com.android.internal.annotations.VisibleForTesting; |
| import com.android.internal.util.Preconditions; |
| import com.android.server.appsearch.contactsindexer.appsearchtypes.ContactPoint; |
| import com.android.server.appsearch.contactsindexer.appsearchtypes.Person; |
| |
| import java.lang.reflect.Array; |
| import java.nio.charset.StandardCharsets; |
| import java.security.MessageDigest; |
| import java.security.NoSuchAlgorithmException; |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Objects; |
| |
| /** |
| * Helper class to help build the {@link Person}. |
| * |
| * <p>It takes a {@link Person.Builder} with a map to help handle and aggregate {@link |
| * ContactPoint}s, and put them in the {@link Person} during the build. |
| * |
| * <p>This class is not thread safe. |
| * |
| * @hide |
| */ |
| public final class PersonBuilderHelper { |
| static final String TAG = "PersonBuilderHelper"; |
| static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; |
| static final int BASE_SCORE = 1; |
| |
| // We want to store id separately even if we do have it set in the builder, since we |
| // can't get its value out of the builder, which will be used to fetch fingerprints. |
| final private String mId; |
| final private Person.Builder mBuilder; |
| private long mCreationTimestampMillis = -1; |
| private Map<String, ContactPointBuilderHelper> mContactPointBuilderHelpers = new ArrayMap<>(); |
| |
| public PersonBuilderHelper(@NonNull String id, @NonNull Person.Builder builder) { |
| Objects.requireNonNull(id); |
| Objects.requireNonNull(builder); |
| mId = id; |
| mBuilder = builder; |
| } |
| |
| /** |
| * Helper class to construct a {@link ContactPoint}. |
| * |
| * <p>In this helper, besides a {@link ContactPoint.Builder}, it contains a list of phone number |
| * variants, so we can append those at the end of the final phone number list in {@link |
| * #buildContactPoint()}. |
| */ |
| private static class ContactPointBuilderHelper { |
| final ContactPoint.Builder mBuilder; |
| List<String> mPhoneNumberVariants = new ArrayList<>(); |
| |
| ContactPointBuilderHelper(@NonNull ContactPoint.Builder builder) { |
| mBuilder = Objects.requireNonNull(builder); |
| } |
| |
| ContactPointBuilderHelper addPhoneNumberVariant(@NonNull String phoneNumberVariant) { |
| mPhoneNumberVariants.add(Objects.requireNonNull(phoneNumberVariant)); |
| return this; |
| } |
| |
| ContactPoint buildContactPoint() { |
| // Append the phone number variants at the end of phone number list. So the original |
| // phone numbers can appear first in the list. |
| for (int i = 0; i < mPhoneNumberVariants.size(); ++i) { |
| mBuilder.addPhone(mPhoneNumberVariants.get(i)); |
| } |
| return mBuilder.build(); |
| } |
| } |
| |
| /** |
| * A {@link Person} is built and returned based on the current properties set in this helper. |
| * |
| * <p>A fingerprint is automatically generated and set. |
| */ |
| @NonNull |
| public Person buildPerson() { |
| Preconditions.checkState(mCreationTimestampMillis >= 0, |
| "creationTimestamp must be explicitly set in the PersonBuilderHelper."); |
| |
| for (ContactPointBuilderHelper builderHelper : mContactPointBuilderHelpers.values()) { |
| // We don't need to reset it for generating fingerprint. But still set it 0 here to |
| // avoid creationTimestamp automatically generated using current time. So our testing |
| // could be easier. |
| builderHelper.mBuilder.setCreationTimestampMillis(0); |
| mBuilder.addContactPoint(builderHelper.buildContactPoint()); |
| } |
| // Set the fingerprint and creationTimestamp to 0 to calculate the actual fingerprint. |
| mBuilder.setScore(0); |
| mBuilder.setFingerprint(EMPTY_BYTE_ARRAY); |
| mBuilder.setCreationTimestampMillis(0); |
| // Build a person for generating the fingerprint. |
| Person contactForFingerPrint = mBuilder.build(); |
| try { |
| byte[] fingerprint = generateFingerprintMD5(contactForFingerPrint); |
| // This is an "a priori" document score that doesn't take any usage into account. |
| // Hence, the heuristic that's used to assign the document score is to add the |
| // presence or count of all the salient properties of the contact. |
| int score = BASE_SCORE + contactForFingerPrint.getContactPoints().length |
| + contactForFingerPrint.getAdditionalNames().length; |
| mBuilder.setScore(score); |
| mBuilder.setFingerprint(fingerprint); |
| mBuilder.setCreationTimestampMillis(mCreationTimestampMillis); |
| } catch (NoSuchAlgorithmException e) { |
| // debug logging here to avoid flooding the log. |
| if (LogUtil.DEBUG) { |
| Log.d(TAG, |
| "Failed to generate fingerprint for contact " |
| + contactForFingerPrint.getId(), |
| e); |
| } |
| } |
| // Build a final person with fingerprint set. |
| return mBuilder.build(); |
| } |
| |
| /** Gets the ID of this {@link Person}. */ |
| @NonNull |
| String getId() { |
| return mId; |
| } |
| |
| @NonNull |
| public Person.Builder getPersonBuilder() { |
| return mBuilder; |
| } |
| |
| @NonNull |
| private ContactPointBuilderHelper getOrCreateContactPointBuilderHelper(@NonNull String label) { |
| ContactPointBuilderHelper builderHelper = mContactPointBuilderHelpers.get( |
| Objects.requireNonNull(label)); |
| if (builderHelper == null) { |
| builderHelper = new ContactPointBuilderHelper( |
| new ContactPoint.Builder(AppSearchHelper.NAMESPACE_NAME, |
| /*id=*/"", // doesn't matter for this nested type. |
| label)); |
| mContactPointBuilderHelpers.put(label, builderHelper); |
| } |
| |
| return builderHelper; |
| } |
| |
| @NonNull |
| public PersonBuilderHelper setCreationTimestampMillis(long creationTimestampMillis) { |
| mCreationTimestampMillis = creationTimestampMillis; |
| return this; |
| } |
| |
| @NonNull |
| public PersonBuilderHelper addAppIdToPerson(@NonNull String label, @NonNull String appId) { |
| getOrCreateContactPointBuilderHelper(Objects.requireNonNull(label)).mBuilder |
| .addAppId(Objects.requireNonNull(appId)); |
| return this; |
| } |
| |
| public PersonBuilderHelper addEmailToPerson(@NonNull String label, @NonNull String email) { |
| getOrCreateContactPointBuilderHelper(Objects.requireNonNull(label)).mBuilder |
| .addEmail(Objects.requireNonNull(email)); |
| return this; |
| } |
| |
| @NonNull |
| public PersonBuilderHelper addAddressToPerson(@NonNull String label, @NonNull String address) { |
| getOrCreateContactPointBuilderHelper(Objects.requireNonNull(label)).mBuilder |
| .addAddress(Objects.requireNonNull(address)); |
| return this; |
| } |
| |
| @NonNull |
| public PersonBuilderHelper addPhoneToPerson(@NonNull String label, @NonNull String phone) { |
| getOrCreateContactPointBuilderHelper(Objects.requireNonNull(label)).mBuilder |
| .addPhone(Objects.requireNonNull(phone)); |
| return this; |
| } |
| |
| @NonNull |
| public PersonBuilderHelper addPhoneVariantToPerson(@NonNull String label, |
| @NonNull String phoneVariant) { |
| getOrCreateContactPointBuilderHelper(Objects.requireNonNull(label)) |
| .addPhoneNumberVariant(Objects.requireNonNull(phoneVariant)); |
| return this; |
| } |
| |
| @NonNull |
| static byte[] generateFingerprintMD5(@NonNull Person person) throws NoSuchAlgorithmException { |
| Objects.requireNonNull(person); |
| |
| MessageDigest md = MessageDigest.getInstance("MD5"); |
| md.update(generateFingerprintStringForPerson(person).getBytes(StandardCharsets.UTF_8)); |
| return md.digest(); |
| } |
| |
| @VisibleForTesting |
| /** Returns a string presentation of {@link Person} for fingerprinting. */ |
| static String generateFingerprintStringForPerson(@NonNull Person person) { |
| Objects.requireNonNull(person); |
| |
| StringBuilder builder = new StringBuilder(); |
| appendGenericDocumentString(person, builder); |
| return builder.toString(); |
| } |
| |
| /** |
| * Appends string representation of a {@link GenericDocument} to the {@link StringBuilder}. |
| * |
| * <p>This is basically same as |
| * {@link GenericDocument#appendGenericDocumentString(IndentingStringBuilder)}, but only keep |
| * the properties part and use a normal {@link StringBuilder} to skip the indentation. |
| */ |
| private static void appendGenericDocumentString(@NonNull GenericDocument doc, |
| @NonNull StringBuilder builder) { |
| Objects.requireNonNull(doc); |
| Objects.requireNonNull(builder); |
| |
| builder.append("properties: {\n"); |
| String[] sortedProperties = doc.getPropertyNames().toArray(new String[0]); |
| Arrays.sort(sortedProperties); |
| for (int i = 0; i < sortedProperties.length; i++) { |
| Object property = Objects.requireNonNull(doc.getProperty(sortedProperties[i])); |
| appendPropertyString(sortedProperties[i], property, builder); |
| if (i != sortedProperties.length - 1) { |
| builder.append(",\n"); |
| } |
| } |
| builder.append("\n"); |
| builder.append("}"); |
| } |
| |
| /** |
| * Appends string representation of a {@link GenericDocument}'s property to the |
| * {@link StringBuilder}. |
| * |
| * <p>This is basically same as |
| * {@link GenericDocument#appendPropertyString(String, Object, IndentingStringBuilder)}, but |
| * use a normal {@link StringBuilder} to skip the indentation. |
| * |
| * <p>Here we still keep most of the formatting(e.g. '\n') to make sure we won't hit some |
| * possible corner cases. E.g. We will have "someProperty1: some\n Property2:..." instead of |
| * "someProperty1: someProperty2:". For latter, we can interpret it as empty string value for |
| * "someProperty1", with a different property name "someProperty2". In this case, the content is |
| * changed but fingerprint will remain same if we don't have that '\n'. |
| * |
| * <p>Plus, some basic formatting will make the testing more clear. |
| */ |
| private static void appendPropertyString( |
| @NonNull String propertyName, |
| @NonNull Object property, |
| @NonNull StringBuilder builder) { |
| Objects.requireNonNull(propertyName); |
| Objects.requireNonNull(property); |
| Objects.requireNonNull(builder); |
| |
| builder.append("\"").append(propertyName).append("\": ["); |
| if (property instanceof GenericDocument[]) { |
| GenericDocument[] documentValues = (GenericDocument[]) property; |
| for (int i = 0; i < documentValues.length; ++i) { |
| builder.append("\n"); |
| appendGenericDocumentString(documentValues[i], builder); |
| if (i != documentValues.length - 1) { |
| builder.append(","); |
| } |
| builder.append("\n"); |
| } |
| builder.append("]"); |
| } else { |
| int propertyArrLength = Array.getLength(property); |
| for (int i = 0; i < propertyArrLength; i++) { |
| Object propertyElement = Array.get(property, i); |
| if (propertyElement instanceof String) { |
| builder.append("\"").append((String) propertyElement).append("\""); |
| } else if (propertyElement instanceof byte[]) { |
| builder.append(Arrays.toString((byte[]) propertyElement)); |
| } else { |
| builder.append(propertyElement.toString()); |
| } |
| if (i != propertyArrLength - 1) { |
| builder.append(", "); |
| } else { |
| builder.append("]"); |
| } |
| } |
| } |
| } |
| } |