1 /*
2  * Copyright (C) 2023 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.transformer;
18 
19 import static android.provider.ContactsContract.AUTHORITY_URI;
20 
21 import android.annotation.NonNull;
22 import android.annotation.Nullable;
23 import android.app.appsearch.GenericDocument;
24 import android.app.appsearch.SearchSpec;
25 import android.content.ContentUris;
26 import android.net.Uri;
27 import android.provider.ContactsContract;
28 import android.provider.ContactsContract.Contacts;
29 import android.util.ArraySet;
30 import android.util.Log;
31 
32 import com.android.internal.annotations.VisibleForTesting;
33 import com.android.server.appsearch.contactsindexer.AppSearchHelper;
34 import com.android.server.appsearch.contactsindexer.appsearchtypes.ContactPoint;
35 import com.android.server.appsearch.contactsindexer.appsearchtypes.Person;
36 
37 import java.util.ArrayList;
38 import java.util.List;
39 import java.util.Map;
40 import java.util.Objects;
41 import java.util.Set;
42 
43 /** Contains various transforms for {@link Person} enterprise access. */
44 final class PersonEnterpriseTransformer {
45     private static final String TAG = "AppSearchPersonEnterpri";
46 
47     // These constants are hidden in ContactsContract.Contacts
48     private static final Uri CORP_CONTENT_URI =
49             Uri.withAppendedPath(AUTHORITY_URI, "contacts_corp");
50     private static final long ENTERPRISE_CONTACT_ID_BASE = 1000000000;
51     private static final String ENTERPRISE_CONTACT_LOOKUP_PREFIX = "c-";
52 
53     // Person externalUri should begin with "content://com.android.contacts/contacts/lookup"
54     private static final String CONTACTS_LOOKUP_URI_PREFIX = Contacts.CONTENT_LOOKUP_URI.toString();
55 
56     private static final List<String> PERSON_ACCESSIBLE_PROPERTIES =
57             List.of(
58                     Person.PERSON_PROPERTY_NAME,
59                     Person.PERSON_PROPERTY_GIVEN_NAME,
60                     Person.PERSON_PROPERTY_MIDDLE_NAME,
61                     Person.PERSON_PROPERTY_FAMILY_NAME,
62                     Person.PERSON_PROPERTY_EXTERNAL_URI,
63                     Person.PERSON_PROPERTY_ADDITIONAL_NAME_TYPES,
64                     Person.PERSON_PROPERTY_ADDITIONAL_NAMES,
65                     Person.PERSON_PROPERTY_IMAGE_URI,
66                     Person.PERSON_PROPERTY_CONTACT_POINTS
67                             + "."
68                             + ContactPoint.CONTACT_POINT_PROPERTY_LABEL,
69                     Person.PERSON_PROPERTY_CONTACT_POINTS
70                             + "."
71                             + ContactPoint.CONTACT_POINT_PROPERTY_EMAIL,
72                     Person.PERSON_PROPERTY_CONTACT_POINTS
73                             + "."
74                             + ContactPoint.CONTACT_POINT_PROPERTY_TELEPHONE);
75 
76     @VisibleForTesting
77     static final Set<String> PERSON_ACCESSIBLE_PROPERTIES_SET =
78             new ArraySet<>(PERSON_ACCESSIBLE_PROPERTIES);
79 
PersonEnterpriseTransformer()80     private PersonEnterpriseTransformer() {}
81 
82     /**
83      * Returns whether or not a document of the given package, database, and schema type combination
84      * should be transformed for enterprise.
85      */
shouldTransform( @onNull String packageName, @NonNull String databaseName, @NonNull String schemaType)86     static boolean shouldTransform(
87             @NonNull String packageName, @NonNull String databaseName, @NonNull String schemaType) {
88         return schemaType.equals(Person.SCHEMA_TYPE)
89                 && packageName.equals("android")
90                 && databaseName.equals(AppSearchHelper.DATABASE_NAME);
91     }
92 
93     /**
94      * Transforms the imageUri and externalUri properties of a Person document to their enterprise
95      * versions which are the corp thumbnail uri and corp lookup uri respectively.
96      *
97      * <p>When contacts are accessed through CP2's enterprise uri, CP2 replaces the contact id with
98      * an enterprise contact id (the original contact id plus a base enterprise id {@link
99      * ContactsContract.Contacts#ENTERPRISE_CONTACT_ID_BASE}). The corp thumbnail uri keeps the
100      * original contact id, but the corp lookup uri uses the enterprise contact id.
101      *
102      * <p>In this method, we only transform the imageUri and externalUri properties, and we leave
103      * the document id untouched, since changing the document id would interfere with retrieving
104      * documents by id.
105      */
106     @NonNull
transformDocument(@onNull GenericDocument originalDocument)107     static GenericDocument transformDocument(@NonNull GenericDocument originalDocument) {
108         Objects.requireNonNull(originalDocument);
109         String imageUri = originalDocument.getPropertyString(Person.PERSON_PROPERTY_IMAGE_URI);
110         String externalUri =
111                 originalDocument.getPropertyString(Person.PERSON_PROPERTY_EXTERNAL_URI);
112         // Only transform the properties if they're present in the document. If neither property is
113         // present, just return the original document
114         if (imageUri == null && externalUri == null) {
115             return originalDocument;
116         }
117         GenericDocument.Builder<GenericDocument.Builder<?>> transformedDocumentBuilder =
118                 new GenericDocument.Builder<>(originalDocument);
119         if (imageUri != null) {
120             try {
121                 long contactId = Long.parseLong(originalDocument.getId());
122                 transformedDocumentBuilder.setPropertyString(
123                         Person.PERSON_PROPERTY_IMAGE_URI, getCorpImageUri(contactId));
124             } catch (NumberFormatException e) {
125                 Log.w(TAG, "Failed to set imageUri property", e);
126             }
127         }
128         if (externalUri != null) {
129             transformedDocumentBuilder.setPropertyString(
130                     Person.PERSON_PROPERTY_EXTERNAL_URI, getCorpLookupUri(externalUri));
131         }
132         return transformedDocumentBuilder.build();
133     }
134 
135     /**
136      * Returns the corp thumbnail uri for the given contact id. Note, the generated uri should
137      * include the original contact id as opposed to the enterprise contact id returned by CP2 which
138      * has {@link Contacts#ENTERPRISE_CONTACT_ID_BASE} added to it.
139      */
140     @VisibleForTesting
141     @NonNull
getCorpImageUri(long contactId)142     static String getCorpImageUri(long contactId) {
143         // https://cs.android.com/android/platform/superproject/main/+/main:packages/providers/ContactsProvider/src/com/android/providers/contacts/enterprise/EnterpriseContactsCursorWrapper.java;l=178;drc=a9d2c06a03a653954629ff10070ebbe4ea87d526
144         return ContentUris.appendId(CORP_CONTENT_URI.buildUpon(), contactId)
145                 .appendPath(Contacts.Photo.CONTENT_DIRECTORY)
146                 .toString();
147     }
148 
149     /**
150      * Transforms the given lookup uri to a corp lookup uri. This prepends an enterprise-specific
151      * prefix to the lookup key segment and transforms the contact id segment (if present) to an
152      * enterprise contact id, e.g. "content://com.android.contacts/contacts/lookup/key/123" would
153      * become "content://com.android.contacts/contacts/lookup/c-key/1000000123".
154      *
155      * <p>Note, if the given lookup uri does not match a CP2 lookup uri, this just returns the
156      * original string.
157      */
158     @VisibleForTesting
159     @NonNull
getCorpLookupUri(@onNull String lookupUri)160     static String getCorpLookupUri(@NonNull String lookupUri) {
161         // https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/provider/ContactsContract.java;l=2269;drc=9b279fbc71a1908311c32c24b4c65e967598b288
162         if (!lookupUri.startsWith(CONTACTS_LOOKUP_URI_PREFIX)) {
163             return lookupUri;
164         }
165         // The indexed uri has four segments: "contacts", "lookup", the lookup key, and the contact
166         // id (contact id is optional)
167         List<String> pathSegments = Uri.parse(lookupUri).getPathSegments();
168         if (pathSegments.size() < 3) {
169             return lookupUri;
170         }
171         if (pathSegments.size() > 3) {
172             try {
173                 long contactId = Long.parseLong(pathSegments.get(3));
174                 return getCorpLookupUriFromLookupKey(pathSegments.get(2), contactId);
175             } catch (NumberFormatException e) {
176                 Log.w(TAG, "Failed to get contact id from lookup uri", e);
177             }
178         }
179         return getCorpLookupUriFromLookupKey(pathSegments.get(2));
180     }
181 
182     @NonNull
getCorpLookupUriFromLookupKey(@onNull String lookupKey, long contactId)183     private static String getCorpLookupUriFromLookupKey(@NonNull String lookupKey, long contactId) {
184         return ContentUris.withAppendedId(
185                         Uri.withAppendedPath(
186                                 Contacts.CONTENT_LOOKUP_URI,
187                                 ENTERPRISE_CONTACT_LOOKUP_PREFIX + lookupKey),
188                         ENTERPRISE_CONTACT_ID_BASE + contactId)
189                 .toString();
190     }
191 
192     @NonNull
getCorpLookupUriFromLookupKey(@onNull String lookupKey)193     private static String getCorpLookupUriFromLookupKey(@NonNull String lookupKey) {
194         return Uri.withAppendedPath(
195                         Contacts.CONTENT_LOOKUP_URI, ENTERPRISE_CONTACT_LOOKUP_PREFIX + lookupKey)
196                 .toString();
197     }
198 
199     /**
200      * Transforms a {@link SearchSpec} through its builder, adding property filters and projections
201      * that restrict the allowed properties for the {@link Person} schema type.
202      */
transformSearchSpec( @onNull SearchSpec searchSpec, @NonNull SearchSpec.Builder builder)203     static void transformSearchSpec(
204             @NonNull SearchSpec searchSpec, @NonNull SearchSpec.Builder builder) {
205         Map<String, List<String>> projections = searchSpec.getProjections();
206         Map<String, List<String>> filterProperties = searchSpec.getFilterProperties();
207         builder.addProjection(
208                 Person.SCHEMA_TYPE, getAccessibleProperties(projections.get(Person.SCHEMA_TYPE)));
209         builder.addFilterProperties(
210                 Person.SCHEMA_TYPE,
211                 getAccessibleProperties(filterProperties.get(Person.SCHEMA_TYPE)));
212     }
213 
214     /**
215      * Adds allowed properties to the map for each {@link Person} schema type. If properties already
216      * exist in the map for {@link Person}, removes the unallowed properties, leaving an
217      * intersection of the original properties and the allowed properties.
218      */
transformPropertiesMap(@onNull Map<String, List<String>> propertiesMap)219     static void transformPropertiesMap(@NonNull Map<String, List<String>> propertiesMap) {
220         propertiesMap.put(
221                 Person.SCHEMA_TYPE, getAccessibleProperties(propertiesMap.get(Person.SCHEMA_TYPE)));
222     }
223 
224     /**
225      * If properties is non-null, returns the intersection of properties with the enterprise
226      * accessible properties for {@link Person}; otherwise simply returns the enterprise accessible
227      * properties for {@link Person}.
228      */
getAccessibleProperties(@ullable List<String> properties)229     private static List<String> getAccessibleProperties(@Nullable List<String> properties) {
230         if (properties == null) {
231             return PERSON_ACCESSIBLE_PROPERTIES;
232         }
233         List<String> filteredProperties = new ArrayList<>();
234         for (int i = 0; i < properties.size(); i++) {
235             if (PERSON_ACCESSIBLE_PROPERTIES_SET.contains(properties.get(i))) {
236                 filteredProperties.add(properties.get(i));
237             }
238         }
239         return filteredProperties;
240     }
241 }
242