1 /*
2  * Copyright (C) 2020 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.ims.rcs.uce.eab;
18 
19 import android.content.ContentValues;
20 import android.content.Context;
21 import android.content.SharedPreferences;
22 import android.database.Cursor;
23 import android.net.Uri;
24 import android.preference.PreferenceManager;
25 import android.provider.ContactsContract;
26 import android.telephony.TelephonyManager;
27 import android.util.Log;
28 
29 import com.android.i18n.phonenumbers.NumberParseException;
30 import com.android.i18n.phonenumbers.PhoneNumberUtil;
31 import com.android.i18n.phonenumbers.Phonenumber;
32 import com.android.internal.annotations.VisibleForTesting;
33 
34 import java.util.ArrayList;
35 import java.util.HashMap;
36 import java.util.List;
37 import java.util.Map;
38 import java.util.stream.Collectors;
39 
40 /**
41  * Sync the contacts from Contact Provider to EAB Provider
42  */
43 public class EabContactSyncController {
44     private final String TAG = this.getClass().getSimpleName();
45 
46     private static final int NOT_INIT_LAST_UPDATED_TIME = -1;
47     private static final String LAST_UPDATED_TIME_KEY = "eab_last_updated_time";
48 
49     /**
50      * Sync contact from Contact provider to EAB provider. There are 4 kinds of cases need to be
51      * handled when received the contact db changed:
52      *
53      * 1. Contact deleted
54      * 2. Delete the phone number in the contact
55      * 3. Update the phone number
56      * 4. Add a new contact and add phone number
57      *
58      * @return The contacts that need to refresh
59      */
60     @VisibleForTesting
syncContactToEabProvider(Context context)61     public List<Uri> syncContactToEabProvider(Context context) {
62         Log.d(TAG, "syncContactToEabProvider");
63         List<Uri> refreshContacts = null;
64         StringBuilder selection = new StringBuilder();
65         String[] selectionArgs = null;
66 
67         // Get the last update timestamp from shared preference.
68         long lastUpdatedTimeStamp = getLastUpdatedTime(context);
69         if (lastUpdatedTimeStamp != -1) {
70             selection.append(ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP + ">?");
71             selectionArgs = new String[]{String.valueOf(lastUpdatedTimeStamp)};
72         }
73 
74         // Contact deleted cases (case 1)
75         handleContactDeletedCase(context, lastUpdatedTimeStamp);
76 
77         // Query the contacts that have not been synchronized to eab contact table.
78         Cursor updatedContact = context.getContentResolver().query(
79                 ContactsContract.Data.CONTENT_URI,
80                 null,
81                 selection.toString(),
82                 selectionArgs,
83                 null);
84 
85         if (updatedContact != null) {
86             Log.d(TAG, "Contact changed count: " + updatedContact.getCount());
87 
88             if (updatedContact.getCount() == 0) {
89                 return new ArrayList<>();
90             }
91 
92             // Delete the EAB phone number that not in contact provider (case 2). Updated phone
93             // number(case 3) also delete in here and re-insert in next step.
94             handlePhoneNumberDeletedCase(context, updatedContact);
95 
96             // Insert the phone number that not in EAB provider (case 3 and case 4)
97             refreshContacts = handlePhoneNumberInsertedCase(context, updatedContact);
98 
99             // Update the last update time in shared preference
100             if (updatedContact.getCount() > 0) {
101                 long maxTimestamp = findMaxTimestamp(updatedContact);
102                 if (maxTimestamp != Long.MIN_VALUE) {
103                     setLastUpdatedTime(context, maxTimestamp);
104                 }
105             }
106             updatedContact.close();
107         } else {
108             Log.e(TAG, "Cursor is null.");
109         }
110         return refreshContacts;
111     }
112 
113     /**
114      * Delete the phone numbers that contact has been deleted in contact provider. Query based on
115      * {@link ContactsContract.DeletedContacts#CONTENT_URI} to know which contact has been removed.
116      *
117      * @param timeStamp last updated timestamp
118      */
handleContactDeletedCase(Context context, long timeStamp)119     private void handleContactDeletedCase(Context context, long timeStamp) {
120         String selection = "";
121         if (timeStamp != -1) {
122             selection =
123                     ContactsContract.DeletedContacts.CONTACT_DELETED_TIMESTAMP + ">" + timeStamp;
124         }
125 
126         Cursor cursor = context.getContentResolver().query(
127                 ContactsContract.DeletedContacts.CONTENT_URI,
128                 new String[]{ContactsContract.DeletedContacts.CONTACT_ID,
129                         ContactsContract.DeletedContacts.CONTACT_DELETED_TIMESTAMP},
130                 selection,
131                 null,
132                 null);
133 
134         if (cursor == null) {
135             Log.d(TAG, "handleContactDeletedCase() cursor is null.");
136             return;
137         }
138 
139         Log.d(TAG, "(Case 1) The count of contact that need to be deleted: "
140                 + cursor.getCount());
141 
142         StringBuilder deleteClause = new StringBuilder();
143         while (cursor.moveToNext()) {
144             if (deleteClause.length() > 0) {
145                 deleteClause.append(" OR ");
146             }
147 
148             String contactId = cursor.getString(cursor.getColumnIndex(
149                     ContactsContract.DeletedContacts.CONTACT_ID));
150             deleteClause.append(EabProvider.ContactColumns.CONTACT_ID + "=" + contactId);
151         }
152 
153         if (deleteClause.toString().length() > 0) {
154             int number = context.getContentResolver().delete(
155                     EabProvider.CONTACT_URI,
156                     deleteClause.toString(),
157                     null);
158             Log.d(TAG, "(Case 1) Deleted contact count=" + number);
159         }
160     }
161 
162     /**
163      * Delete phone numbers that have been deleted in the contact provider. There is no API to get
164      * deleted phone numbers easily, so check all updated contact's phone number and delete the
165      * phone number. It will also delete the phone number that has been changed.
166      */
handlePhoneNumberDeletedCase(Context context, Cursor cursor)167     private void handlePhoneNumberDeletedCase(Context context, Cursor cursor) {
168         // The map represent which contacts have which numbers.
169         Map<String, List<String>> phoneNumberMap = new HashMap<>();
170         cursor.moveToPosition(-1);
171         while (cursor.moveToNext()) {
172             String mimeType = cursor.getString(
173                     cursor.getColumnIndex(ContactsContract.Data.MIMETYPE));
174             if (!mimeType.equals(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)) {
175                 continue;
176             }
177 
178             String rawContactId = cursor.getString(
179                     cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID));
180             String number = formatNumber(context, cursor.getString(
181                     cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)));
182 
183             if (phoneNumberMap.containsKey(rawContactId)) {
184                 phoneNumberMap.get(rawContactId).add(number);
185             } else {
186                 List<String> phoneNumberList = new ArrayList<>();
187                 phoneNumberList.add(number);
188                 phoneNumberMap.put(rawContactId, phoneNumberList);
189             }
190         }
191 
192         // Build a SQL statement that delete the phone number not exist in contact provider.
193         // For example:
194         // raw_contact_id = 1 AND phone_number NOT IN (12345, 23456)
195         StringBuilder deleteClause = new StringBuilder();
196         List<String> deleteClauseArgs = new ArrayList<>();
197         for (Map.Entry<String, List<String>> entry : phoneNumberMap.entrySet()) {
198             String rawContactId = entry.getKey();
199             List<String> phoneNumberList = entry.getValue();
200 
201             if (deleteClause.length() > 0) {
202                 deleteClause.append(" OR ");
203             }
204 
205             deleteClause.append("(" + EabProvider.ContactColumns.RAW_CONTACT_ID + "=? ");
206             deleteClauseArgs.add(rawContactId);
207 
208             if (phoneNumberList.size() > 0) {
209                 String argsList = phoneNumberList.stream()
210                         .map(s -> "?")
211                         .collect(Collectors.joining(", "));
212                 deleteClause.append(" AND "
213                         + EabProvider.ContactColumns.PHONE_NUMBER
214                         + " NOT IN (" + argsList + "))");
215                 deleteClauseArgs.addAll(phoneNumberList);
216             } else {
217                 deleteClause.append(")");
218             }
219         }
220 
221         int number = context.getContentResolver().delete(
222                 EabProvider.CONTACT_URI,
223                 deleteClause.toString(),
224                 deleteClauseArgs.toArray(new String[0]));
225         Log.d(TAG, "(Case 2, 3) handlePhoneNumberDeletedCase number count= " + number);
226     }
227 
228     /**
229      * Insert new phone number.
230      *
231      * @param contactCursor the result of updated contact
232      * @return the contacts that need to refresh
233      */
handlePhoneNumberInsertedCase(Context context, Cursor contactCursor)234     private List<Uri> handlePhoneNumberInsertedCase(Context context,
235             Cursor contactCursor) {
236         List<Uri> refreshContacts = new ArrayList<>();
237         List<ContentValues> allContactData = new ArrayList<>();
238         contactCursor.moveToPosition(-1);
239 
240         // Query all of contacts that store in eab provider
241         Cursor eabContact = context.getContentResolver().query(
242                 EabProvider.CONTACT_URI,
243                 null,
244                 EabProvider.ContactColumns.DATA_ID + " IS NOT NULL",
245                 null,
246                 EabProvider.ContactColumns.DATA_ID);
247 
248         while (contactCursor.moveToNext()) {
249             String contactId = contactCursor.getString(contactCursor.getColumnIndex(
250                     ContactsContract.Data.CONTACT_ID));
251             String rawContactId = contactCursor.getString(contactCursor.getColumnIndex(
252                     ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID));
253             String dataId = contactCursor.getString(
254                     contactCursor.getColumnIndex(ContactsContract.Data._ID));
255             String mimeType = contactCursor.getString(
256                     contactCursor.getColumnIndex(ContactsContract.Data.MIMETYPE));
257 
258             if (!mimeType.equals(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)) {
259                 continue;
260             }
261 
262             String number = formatNumber(context, contactCursor.getString(
263                     contactCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)));
264 
265             int index = searchDataIdIndex(eabContact, Integer.parseInt(dataId));
266             if (index == -1) {
267                 Log.d(TAG, "Data id does not exist. Insert phone number into EAB db.");
268                 refreshContacts.add(Uri.parse(number));
269                 ContentValues data = new ContentValues();
270                 data.put(EabProvider.ContactColumns.CONTACT_ID, contactId);
271                 data.put(EabProvider.ContactColumns.DATA_ID, dataId);
272                 data.put(EabProvider.ContactColumns.RAW_CONTACT_ID, rawContactId);
273                 data.put(EabProvider.ContactColumns.PHONE_NUMBER, number);
274                 allContactData.add(data);
275             }
276         }
277 
278         // Insert contacts at once
279         int result = context.getContentResolver().bulkInsert(
280                 EabProvider.CONTACT_URI,
281                 allContactData.toArray(new ContentValues[0]));
282         Log.d(TAG, "(Case 3, 4) Phone number insert count: " + result);
283         return refreshContacts;
284     }
285 
286     /**
287      * Binary search the target data_id in the cursor.
288      *
289      * @param cursor       EabProvider contact which sorted by
290      *                     {@link EabProvider.ContactColumns#DATA_ID}
291      * @param targetDataId the data_id to search for
292      * @return the index of cursor
293      */
searchDataIdIndex(Cursor cursor, int targetDataId)294     private int searchDataIdIndex(Cursor cursor, int targetDataId) {
295         int start = 0;
296         int end = cursor.getCount() - 1;
297 
298         while (start <= end) {
299             int position = (start + end) >>> 1;
300             cursor.moveToPosition(position);
301             int dataId = cursor.getInt(cursor.getColumnIndex(EabProvider.ContactColumns.DATA_ID));
302 
303             if (dataId > targetDataId) {
304                 end = position - 1;
305             } else if (dataId < targetDataId) {
306                 start = position + 1;
307             } else {
308                 return position;
309             }
310         }
311         return -1;
312     }
313 
314 
findMaxTimestamp(Cursor cursor)315     private long findMaxTimestamp(Cursor cursor) {
316         long maxTimestamp = Long.MIN_VALUE;
317         cursor.moveToPosition(-1);
318         while(cursor.moveToNext()) {
319             long lastUpdatedTimeStamp = cursor.getLong(cursor.getColumnIndex(
320                     ContactsContract.CommonDataKinds.Phone.CONTACT_LAST_UPDATED_TIMESTAMP));
321             Log.d(TAG, lastUpdatedTimeStamp + " " + maxTimestamp);
322             if (lastUpdatedTimeStamp > maxTimestamp) {
323                 maxTimestamp = lastUpdatedTimeStamp;
324             }
325         }
326         return maxTimestamp;
327     }
328 
setLastUpdatedTime(Context context, long timestamp)329     private void setLastUpdatedTime(Context context, long timestamp) {
330         Log.d(TAG, "setLastUpdatedTime: " + timestamp);
331         SharedPreferences sharedPreferences =
332                 PreferenceManager.getDefaultSharedPreferences(context);
333         sharedPreferences.edit().putLong(LAST_UPDATED_TIME_KEY, timestamp).apply();
334     }
335 
getLastUpdatedTime(Context context)336     private long getLastUpdatedTime(Context context) {
337         SharedPreferences sharedPreferences =
338                 PreferenceManager.getDefaultSharedPreferences(context);
339         return sharedPreferences.getLong(LAST_UPDATED_TIME_KEY, NOT_INIT_LAST_UPDATED_TIME);
340     }
341 
formatNumber(Context context, String number)342     private String formatNumber(Context context, String number) {
343         TelephonyManager manager = context.getSystemService(TelephonyManager.class);
344         String simCountryIso = manager.getSimCountryIso();
345         if (simCountryIso != null) {
346             simCountryIso = simCountryIso.toUpperCase();
347             PhoneNumberUtil util = PhoneNumberUtil.getInstance();
348             try {
349                 Phonenumber.PhoneNumber phoneNumber = util.parse(number, simCountryIso);
350                 return util.format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164);
351             } catch (NumberParseException e) {
352                 Log.w(TAG, "formatNumber: could not format " + number + ", error: " + e);
353             }
354         }
355         return number;
356     }
357 }
358