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 static android.app.appsearch.testutil.AppSearchTestUtils.checkIsBatchResultSuccess; 20 import static android.app.appsearch.testutil.AppSearchTestUtils.convertSearchResultsToDocuments; 21 22 import static com.google.common.truth.Truth.assertThat; 23 24 import android.app.appsearch.AppSearchManager; 25 import android.app.appsearch.AppSearchResult; 26 import android.app.appsearch.AppSearchSession; 27 import android.app.appsearch.AppSearchSessionShim; 28 import android.app.appsearch.GenericDocument; 29 import android.app.appsearch.GetSchemaResponse; 30 import android.app.appsearch.PutDocumentsRequest; 31 import android.app.appsearch.SearchResultsShim; 32 import android.app.appsearch.SearchSpec; 33 import android.app.appsearch.SetSchemaRequest; 34 import android.app.appsearch.testutil.AppSearchSessionShimImpl; 35 import android.content.Context; 36 37 import androidx.test.core.app.ApplicationProvider; 38 39 import com.android.modules.utils.testing.TestableDeviceConfig; 40 import com.android.server.appsearch.contactsindexer.appsearchtypes.ContactPoint; 41 import com.android.server.appsearch.contactsindexer.appsearchtypes.Person; 42 43 import com.google.common.collect.ImmutableSet; 44 45 import org.junit.After; 46 import org.junit.Before; 47 import org.junit.Rule; 48 import org.junit.Test; 49 50 import java.util.ArrayList; 51 import java.util.List; 52 import java.util.concurrent.CompletableFuture; 53 import java.util.concurrent.Executor; 54 import java.util.concurrent.Executors; 55 56 // Since AppSearchHelper mainly just calls AppSearch's api to index/remove files, we shouldn't 57 // worry too much about it since AppSearch has good test coverage. Here just add some simple checks. 58 public class AppSearchHelperTest { 59 private final Executor mSingleThreadedExecutor = Executors.newSingleThreadExecutor(); 60 61 private Context mContext; 62 private AppSearchHelper mAppSearchHelper; 63 private ContactsUpdateStats mUpdateStats; 64 65 private AppSearchSessionShim mDb; 66 private ContactsIndexerConfig mConfigForTest = new TestContactsIndexerConfig(); 67 68 @Before setUp()69 public void setUp() throws Exception { 70 mContext = ApplicationProvider.getApplicationContext(); 71 mUpdateStats = new ContactsUpdateStats(); 72 73 // b/258968096 74 // Internally AppSearchHelper.createAppSearchHelper will set Person and 75 // ContactPoint schema for AppSearch. 76 // 77 // Since everything is async, the fact we didn't wait until it finish is making 78 // testCreateAppSearchHelper_incompatibleSchemaChange flaky: 79 // - In that test, it uses an AppSearchSessionShim to set 80 // CONTACT_POINT_SCHEMA_WITH_LABEL_REPEATED 81 // - Then, the test will create another AppSearchHelper 82 // - For this local AppSearchHelper in the test, we are expecting an incompatible 83 // schema change. 84 // - But if mAppSearchHelper doesn't finish setting its schema, and 85 // CONTACT_POINT_SCHEMA_WITH_LABEL_REPEATED is set first, mAppSearchHelper will get an 86 // incompatible schema change, and the one created later for the test won't since it 87 // will set the same schemas as mAppSearchHelper. 88 // 89 // To fix the flakiness, we need to wait until mAppSearchHelper finishes initialization. 90 // We choose to do it in the setup to make sure it won't create such flakiness in the 91 // future tests. 92 // 93 mAppSearchHelper = AppSearchHelper.createAppSearchHelper(mContext, mSingleThreadedExecutor, 94 mConfigForTest); 95 // TODO(b/237115318) we need to revisit this once the contact indexer is refactored. 96 // getSession here will call get() on the future for AppSearchSession to make sure it has 97 // been initialized. 98 AppSearchSession unused = mAppSearchHelper.getSession(); 99 AppSearchManager.SearchContext searchContext = 100 new AppSearchManager.SearchContext.Builder(AppSearchHelper.DATABASE_NAME).build(); 101 mDb = AppSearchSessionShimImpl.createSearchSessionAsync( 102 searchContext).get(); 103 } 104 105 @After tearDown()106 public void tearDown() throws Exception { 107 // Wipe the data in AppSearchHelper.DATABASE_NAME. 108 SetSchemaRequest setSchemaRequest = new SetSchemaRequest.Builder() 109 .setForceOverride(true).build(); 110 mDb.setSchemaAsync(setSchemaRequest).get(); 111 } 112 113 @Test testAppSearchHelper_permissionIsSetCorrectlyForPerson()114 public void testAppSearchHelper_permissionIsSetCorrectlyForPerson() throws Exception { 115 // TODO(b/203605504) We can create AppSearchHelper in the test itself so make things more 116 // clear. 117 AppSearchSession session = mAppSearchHelper.getSession(); 118 CompletableFuture<AppSearchResult<GetSchemaResponse>> responseFuture = 119 new CompletableFuture<>(); 120 121 // TODO(b/203605504) Considering using AppSearchShim, which is our test utility that 122 // glues AppSearchSession to the Future API 123 session.getSchema(mSingleThreadedExecutor, responseFuture::complete); 124 125 AppSearchResult<GetSchemaResponse> result = responseFuture.get(); 126 assertThat(result.isSuccess()).isTrue(); 127 GetSchemaResponse response = result.getResultValue(); 128 assertThat(response.getRequiredPermissionsForSchemaTypeVisibility()).hasSize(2); 129 assertThat(response.getRequiredPermissionsForSchemaTypeVisibility()).containsKey( 130 ContactPoint.SCHEMA_TYPE); 131 assertThat(response.getRequiredPermissionsForSchemaTypeVisibility()).containsEntry( 132 Person.SCHEMA_TYPE, 133 ImmutableSet.of(ImmutableSet.of(SetSchemaRequest.READ_CONTACTS))); 134 } 135 136 @Test testIndexContacts()137 public void testIndexContacts() throws Exception { 138 mAppSearchHelper.indexContactsAsync(generatePersonData(50), mUpdateStats).get(); 139 140 List<String> appsearchIds = mAppSearchHelper.getAllContactIdsAsync().get(); 141 assertThat(appsearchIds.size()).isEqualTo(50); 142 } 143 144 @Test testIndexContacts_clearAfterIndex()145 public void testIndexContacts_clearAfterIndex() throws Exception { 146 List<Person> contacts = generatePersonData(50); 147 148 CompletableFuture<Void> indexContactsFuture = mAppSearchHelper.indexContactsAsync(contacts, 149 mUpdateStats); 150 contacts.clear(); 151 indexContactsFuture.get(); 152 153 List<String> appsearchIds = mAppSearchHelper.getAllContactIdsAsync().get(); 154 assertThat(appsearchIds.size()).isEqualTo(50); 155 } 156 157 @Test testAppSearchHelper_removeContacts()158 public void testAppSearchHelper_removeContacts() throws Exception { 159 mAppSearchHelper.indexContactsAsync(generatePersonData(50), mUpdateStats).get(); 160 List<String> indexedIds = mAppSearchHelper.getAllContactIdsAsync().get(); 161 162 List<String> deletedIds = new ArrayList<>(); 163 for (int i = 0; i < 50; i += 5) { 164 deletedIds.add(String.valueOf(i)); 165 } 166 mAppSearchHelper.removeContactsByIdAsync(deletedIds, mUpdateStats).get(); 167 168 assertThat(indexedIds.size()).isEqualTo(50); 169 List<String> appsearchIds = mAppSearchHelper.getAllContactIdsAsync().get(); 170 assertThat(appsearchIds).containsNoneIn(deletedIds); 171 } 172 173 @Test testCreateAppSearchHelper_compatibleSchemaChange()174 public void testCreateAppSearchHelper_compatibleSchemaChange() throws Exception { 175 AppSearchHelper appSearchHelper = AppSearchHelper.createAppSearchHelper(mContext, 176 mSingleThreadedExecutor, mConfigForTest); 177 178 assertThat(appSearchHelper).isNotNull(); 179 assertThat(appSearchHelper.isDataLikelyWipedDuringInitAsync().get()).isFalse(); 180 } 181 182 @Test testCreateAppSearchHelper_compatibleSchemaChange2()183 public void testCreateAppSearchHelper_compatibleSchemaChange2() throws Exception { 184 SetSchemaRequest setSchemaRequest = new SetSchemaRequest.Builder() 185 .addSchemas(TestUtils.CONTACT_POINT_SCHEMA_WITH_APP_IDS_OPTIONAL) 186 .setForceOverride(true).build(); 187 mDb.setSchemaAsync(setSchemaRequest).get(); 188 189 // APP_IDS changed from optional to repeated, which is a compatible change. 190 AppSearchHelper appSearchHelper = 191 AppSearchHelper.createAppSearchHelper(mContext, mSingleThreadedExecutor, 192 mConfigForTest); 193 194 assertThat(appSearchHelper).isNotNull(); 195 assertThat(appSearchHelper.isDataLikelyWipedDuringInitAsync().get()).isFalse(); 196 } 197 198 @Test testCreateAppSearchHelper_incompatibleSchemaChange()199 public void testCreateAppSearchHelper_incompatibleSchemaChange() throws Exception { 200 SetSchemaRequest setSchemaRequest = new SetSchemaRequest.Builder() 201 .addSchemas(TestUtils.CONTACT_POINT_SCHEMA_WITH_LABEL_REPEATED) 202 .setForceOverride(true).build(); 203 mDb.setSchemaAsync(setSchemaRequest).get(); 204 205 // LABEL changed from repeated to optional, which is an incompatible change. 206 AppSearchHelper appSearchHelper = 207 AppSearchHelper.createAppSearchHelper(mContext, mSingleThreadedExecutor, 208 mConfigForTest); 209 210 assertThat(appSearchHelper).isNotNull(); 211 assertThat(appSearchHelper.isDataLikelyWipedDuringInitAsync().get()).isTrue(); 212 } 213 214 @Test testGetAllContactIds()215 public void testGetAllContactIds() throws Exception { 216 indexContactsInBatchesAsync(generatePersonData(200)).get(); 217 218 List<String> appSearchContactIds = mAppSearchHelper.getAllContactIdsAsync().get(); 219 220 assertThat(appSearchContactIds.size()).isEqualTo(200); 221 } 222 indexContactsInBatchesAsync(List<Person> contacts)223 private CompletableFuture<Void> indexContactsInBatchesAsync(List<Person> contacts) { 224 CompletableFuture<Void> indexContactsInBatchesFuture = 225 CompletableFuture.completedFuture(null); 226 int startIndex = 0; 227 while (startIndex < contacts.size()) { 228 int batchEndIndex = Math.min( 229 startIndex + ContactsIndexerImpl.NUM_UPDATED_CONTACTS_PER_BATCH_FOR_APPSEARCH, 230 contacts.size()); 231 List<Person> batchedContacts = contacts.subList(startIndex, batchEndIndex); 232 indexContactsInBatchesFuture = indexContactsInBatchesFuture 233 .thenCompose(x -> mAppSearchHelper.indexContactsAsync(batchedContacts, 234 mUpdateStats)); 235 startIndex = batchEndIndex; 236 } 237 return indexContactsInBatchesFuture; 238 } 239 240 @Test testPersonSchema_indexFirstMiddleAndLastNames()241 public void testPersonSchema_indexFirstMiddleAndLastNames() throws Exception { 242 // Override test config to index first, middle and last names. 243 ContactsIndexerConfig config = new TestContactsIndexerConfig() { 244 @Override 245 public boolean shouldIndexFirstMiddleAndLastNames() { 246 return true; 247 } 248 }; 249 SetSchemaRequest setSchemaRequest = new SetSchemaRequest.Builder() 250 .addSchemas(ContactPoint.SCHEMA, Person.getSchema(config)) 251 .setForceOverride(true).build(); 252 mDb.setSchemaAsync(setSchemaRequest).get(); 253 // Index document 254 GenericDocument doc1 = 255 new GenericDocument.Builder<>("namespace", "id1", Person.SCHEMA_TYPE) 256 .setPropertyString(Person.PERSON_PROPERTY_NAME, "新中野") 257 .setPropertyString(Person.PERSON_PROPERTY_FAMILY_NAME, "新") 258 .setPropertyString(Person.PERSON_PROPERTY_GIVEN_NAME, "野") 259 .setPropertyString(Person.PERSON_PROPERTY_MIDDLE_NAME, "中") 260 .build(); 261 checkIsBatchResultSuccess( 262 mDb.putAsync( 263 new PutDocumentsRequest.Builder().addGenericDocuments(doc1).build())); 264 265 SearchSpec spec = new SearchSpec.Builder() 266 .setTermMatch(SearchSpec.TERM_MATCH_PREFIX) 267 .build(); 268 269 // Searching by full name returns document 270 SearchResultsShim searchResults = mDb.search("新中野", spec); 271 List<GenericDocument> documents = convertSearchResultsToDocuments(searchResults); 272 assertThat(documents).containsExactly(doc1); 273 274 // Searching by last name returns document 275 searchResults = mDb.search("新", spec); 276 documents = convertSearchResultsToDocuments(searchResults); 277 assertThat(documents).containsExactly(doc1); 278 279 // Searching by middle name returns document 280 searchResults = mDb.search("中", spec); 281 documents = convertSearchResultsToDocuments(searchResults); 282 assertThat(documents).containsExactly(doc1); 283 284 // Searching by first name returns document 285 searchResults = mDb.search("野", spec); 286 documents = convertSearchResultsToDocuments(searchResults); 287 assertThat(documents).containsExactly(doc1); 288 } 289 290 // Index document only using the full name. This helps understand why first, middle 291 // and last names need to be indexed in order to be able to search some Chinese names 292 // efficiently. This can also potentially alert us of any future ICU tokenization changes. 293 // For e.g., if "新中野" is segmented to "新","中" and "野" in the future (as compared to only 294 // a single token "新中野" currently), the third and fourth asserts in ths test will start 295 // failing. This documents current behavior, but doesn't endorse it. Ideally, all of the below 296 // queries would be considered matches even when only the full name is indexed. 297 @Test testPersonSchema_indexFullNameOnly()298 public void testPersonSchema_indexFullNameOnly() throws Exception { 299 SetSchemaRequest setSchemaRequest = new SetSchemaRequest.Builder() 300 .addSchemas(ContactPoint.SCHEMA, Person.getSchema(mConfigForTest)) 301 .setForceOverride(true).build(); 302 mDb.setSchemaAsync(setSchemaRequest).get(); 303 GenericDocument doc1 = 304 new GenericDocument.Builder<>("namespace", "id1", Person.SCHEMA_TYPE) 305 .setPropertyString(Person.PERSON_PROPERTY_NAME, "新中野") 306 .setPropertyString(Person.PERSON_PROPERTY_FAMILY_NAME, "新") 307 .setPropertyString(Person.PERSON_PROPERTY_GIVEN_NAME, "野") 308 .setPropertyString(Person.PERSON_PROPERTY_MIDDLE_NAME, "中") 309 .build(); 310 checkIsBatchResultSuccess( 311 mDb.putAsync( 312 new PutDocumentsRequest.Builder().addGenericDocuments(doc1).build())); 313 314 // Searching by full name returns the document 315 SearchResultsShim searchResults = 316 mDb.search( 317 "新中野", 318 new SearchSpec.Builder() 319 .setTermMatch(SearchSpec.TERM_MATCH_PREFIX) 320 .build()); 321 List<GenericDocument> documents = convertSearchResultsToDocuments(searchResults); 322 assertThat(documents).containsExactly(doc1); 323 324 SearchSpec spec = new SearchSpec.Builder() 325 .setTermMatch(SearchSpec.TERM_MATCH_PREFIX) 326 .build(); 327 328 // Searching by last name returns the document 329 searchResults = mDb.search("新", spec); 330 documents = convertSearchResultsToDocuments(searchResults); 331 assertThat(documents).containsExactly(doc1); 332 333 // Searching by middle name doesn't return the document 334 searchResults = mDb.search("中", spec); 335 documents = convertSearchResultsToDocuments(searchResults); 336 assertThat(documents).isEmpty(); 337 338 // Searching by first name doesn't return the document 339 searchResults = mDb.search("野", spec); 340 documents = convertSearchResultsToDocuments(searchResults); 341 assertThat(documents).isEmpty(); 342 } 343 generatePersonData(int numContacts)344 List<Person> generatePersonData(int numContacts) { 345 List<Person> personList = new ArrayList<>(); 346 for (int i = 0; i < numContacts; i++) { 347 personList.add( 348 new Person.Builder(/*namespace=*/ "", String.valueOf(i), "name" + i).build()); 349 } 350 return personList; 351 } 352 }