1/*
2 * Copyright (C) 2016 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 */
16package com.android.contacts.database;
17
18import android.annotation.TargetApi;
19import android.content.ContentProviderOperation;
20import android.content.ContentProviderResult;
21import android.content.ContentResolver;
22import android.content.Context;
23import android.content.OperationApplicationException;
24import android.content.pm.PackageManager;
25import android.database.Cursor;
26import android.net.Uri;
27import android.os.Build;
28import android.os.RemoteException;
29import android.provider.BaseColumns;
30import android.provider.ContactsContract;
31import android.provider.ContactsContract.CommonDataKinds.Phone;
32import android.provider.ContactsContract.CommonDataKinds.StructuredName;
33import android.provider.ContactsContract.Data;
34import android.provider.ContactsContract.RawContacts;
35import android.support.annotation.VisibleForTesting;
36import android.support.v4.util.ArrayMap;
37import android.telephony.SubscriptionInfo;
38import android.telephony.SubscriptionManager;
39import android.telephony.TelephonyManager;
40import android.text.TextUtils;
41import android.util.SparseArray;
42
43import com.android.contacts.R;
44import com.android.contacts.compat.CompatUtils;
45import com.android.contacts.model.SimCard;
46import com.android.contacts.model.SimContact;
47import com.android.contacts.model.account.AccountWithDataSet;
48import com.android.contacts.util.PermissionsUtil;
49import com.android.contacts.util.SharedPreferenceUtil;
50import com.google.common.base.Joiner;
51
52import java.util.ArrayList;
53import java.util.Arrays;
54import java.util.Collections;
55import java.util.HashMap;
56import java.util.HashSet;
57import java.util.List;
58import java.util.Map;
59import java.util.Set;
60
61/**
62 * Provides data access methods for loading contacts from a SIM card and and migrating these
63 * SIM contacts to a CP2 account.
64 */
65public class SimContactDaoImpl extends SimContactDao {
66    private static final String TAG = "SimContactDao";
67
68    // Maximum number of SIM contacts to import in a single ContentResolver.applyBatch call.
69    // This is necessary to avoid TransactionTooLargeException when there are a large number of
70    // contacts. This has been tested on Nexus 6 NME70B and is probably be conservative enough
71    // to work on any phone.
72    private static final int IMPORT_MAX_BATCH_SIZE = 300;
73
74    // How many SIM contacts to consider in a single query. This prevents hitting the SQLite
75    // query parameter limit.
76    static final int QUERY_MAX_BATCH_SIZE = 100;
77
78    @VisibleForTesting
79    public static final Uri ICC_CONTENT_URI = Uri.parse("content://icc/adn");
80
81    public static String _ID = BaseColumns._ID;
82    public static String NAME = "name";
83    public static String NUMBER = "number";
84    public static String EMAILS = "emails";
85
86    private final Context mContext;
87    private final ContentResolver mResolver;
88    private final TelephonyManager mTelephonyManager;
89
90    public SimContactDaoImpl(Context context) {
91        this(context, context.getContentResolver(),
92                (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE));
93    }
94
95    public SimContactDaoImpl(Context context, ContentResolver resolver,
96            TelephonyManager telephonyManager) {
97        mContext = context;
98        mResolver = resolver;
99        mTelephonyManager = telephonyManager;
100    }
101
102    public Context getContext() {
103        return mContext;
104    }
105
106    @Override
107    public boolean canReadSimContacts() {
108        // Require SIM_STATE_READY because the TelephonyManager methods related to SIM require
109        // this state
110        return hasTelephony() && hasPermissions() &&
111                mTelephonyManager.getSimState() == TelephonyManager.SIM_STATE_READY;
112    }
113
114    @Override
115    public List<SimCard> getSimCards() {
116        if (!canReadSimContacts()) {
117            return Collections.emptyList();
118        }
119        final List<SimCard> sims = CompatUtils.isMSIMCompatible() ?
120                getSimCardsFromSubscriptions() :
121                Collections.singletonList(SimCard.create(mTelephonyManager,
122                        mContext.getString(R.string.single_sim_display_label)));
123        return SharedPreferenceUtil.restoreSimStates(mContext, sims);
124    }
125
126    @Override
127    public ArrayList<SimContact> loadContactsForSim(SimCard sim) {
128        if (sim.hasValidSubscriptionId()) {
129            return loadSimContacts(sim.getSubscriptionId());
130        }
131        return loadSimContacts();
132    }
133
134    public ArrayList<SimContact> loadSimContacts(int subscriptionId) {
135        return loadFrom(ICC_CONTENT_URI.buildUpon()
136                .appendPath("subId")
137                .appendPath(String.valueOf(subscriptionId))
138                .build());
139    }
140
141    public ArrayList<SimContact> loadSimContacts() {
142        return loadFrom(ICC_CONTENT_URI);
143    }
144
145    @Override
146    public ContentProviderResult[] importContacts(List<SimContact> contacts,
147            AccountWithDataSet targetAccount)
148            throws RemoteException, OperationApplicationException {
149        if (contacts.size() < IMPORT_MAX_BATCH_SIZE) {
150            return importBatch(contacts, targetAccount);
151        }
152        final List<ContentProviderResult> results = new ArrayList<>();
153        for (int i = 0; i < contacts.size(); i += IMPORT_MAX_BATCH_SIZE) {
154            results.addAll(Arrays.asList(importBatch(
155                    contacts.subList(i, Math.min(contacts.size(), i + IMPORT_MAX_BATCH_SIZE)),
156                    targetAccount)));
157        }
158        return results.toArray(new ContentProviderResult[results.size()]);
159    }
160
161    public void persistSimState(SimCard sim) {
162        SharedPreferenceUtil.persistSimStates(mContext, Collections.singletonList(sim));
163    }
164
165    @Override
166    public void persistSimStates(List<SimCard> simCards) {
167        SharedPreferenceUtil.persistSimStates(mContext, simCards);
168    }
169
170    @Override
171    public SimCard getSimBySubscriptionId(int subscriptionId) {
172        final List<SimCard> sims = SharedPreferenceUtil.restoreSimStates(mContext, getSimCards());
173        if (subscriptionId == SimCard.NO_SUBSCRIPTION_ID && !sims.isEmpty()) {
174            return sims.get(0);
175        }
176        for (SimCard sim : getSimCards()) {
177            if (sim.getSubscriptionId() == subscriptionId) {
178                return sim;
179            }
180        }
181        return null;
182    }
183
184    /**
185     * Finds SIM contacts that exist in CP2 and associates the account of the CP2 contact with
186     * the SIM contact
187     */
188    public Map<AccountWithDataSet, Set<SimContact>> findAccountsOfExistingSimContacts(
189            List<SimContact> contacts) {
190        final Map<AccountWithDataSet, Set<SimContact>> result = new ArrayMap<>();
191        for (int i = 0; i < contacts.size(); i += QUERY_MAX_BATCH_SIZE) {
192            findAccountsOfExistingSimContacts(
193                    contacts.subList(i, Math.min(contacts.size(), i + QUERY_MAX_BATCH_SIZE)),
194                    result);
195        }
196        return result;
197    }
198
199    private void findAccountsOfExistingSimContacts(List<SimContact> contacts,
200            Map<AccountWithDataSet, Set<SimContact>> result) {
201        final Map<Long, List<SimContact>> rawContactToSimContact = new HashMap<>();
202        Collections.sort(contacts, SimContact.compareByPhoneThenName());
203
204        final Cursor dataCursor = queryRawContactsForSimContacts(contacts);
205
206        try {
207            while (dataCursor.moveToNext()) {
208                final String number = DataQuery.getPhoneNumber(dataCursor);
209                final String name = DataQuery.getDisplayName(dataCursor);
210
211                final int index = SimContact.findByPhoneAndName(contacts, number, name);
212                if (index < 0) {
213                    continue;
214                }
215                final SimContact contact = contacts.get(index);
216                final long id = DataQuery.getRawContactId(dataCursor);
217                if (!rawContactToSimContact.containsKey(id)) {
218                    rawContactToSimContact.put(id, new ArrayList<SimContact>());
219                }
220                rawContactToSimContact.get(id).add(contact);
221            }
222        } finally {
223            dataCursor.close();
224        }
225
226        final Cursor accountsCursor = queryAccountsOfRawContacts(rawContactToSimContact.keySet());
227        try {
228            while (accountsCursor.moveToNext()) {
229                final AccountWithDataSet account = AccountQuery.getAccount(accountsCursor);
230                final long id = AccountQuery.getId(accountsCursor);
231                if (!result.containsKey(account)) {
232                    result.put(account, new HashSet<SimContact>());
233                }
234                for (SimContact contact : rawContactToSimContact.get(id)) {
235                    result.get(account).add(contact);
236                }
237            }
238        } finally {
239            accountsCursor.close();
240        }
241    }
242
243
244    private ContentProviderResult[] importBatch(List<SimContact> contacts,
245            AccountWithDataSet targetAccount)
246            throws RemoteException, OperationApplicationException {
247        final ArrayList<ContentProviderOperation> ops =
248                createImportOperations(contacts, targetAccount);
249        return mResolver.applyBatch(ContactsContract.AUTHORITY, ops);
250    }
251
252    @TargetApi(Build.VERSION_CODES.LOLLIPOP_MR1)
253    private List<SimCard> getSimCardsFromSubscriptions() {
254        final SubscriptionManager subscriptionManager = (SubscriptionManager)
255                mContext.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE);
256        final List<SubscriptionInfo> subscriptions = subscriptionManager
257                .getActiveSubscriptionInfoList();
258        final ArrayList<SimCard> result = new ArrayList<>();
259        for (SubscriptionInfo subscriptionInfo : subscriptions) {
260            result.add(SimCard.create(subscriptionInfo));
261        }
262        return result;
263    }
264
265    private List<SimContact> getContactsForSim(SimCard sim) {
266        final List<SimContact> contacts = sim.getContacts();
267        return contacts != null ? contacts : loadContactsForSim(sim);
268    }
269
270    // See b/32831092
271    // Sometimes the SIM contacts provider seems to get stuck if read from multiple threads
272    // concurrently. So we just have a global lock around it to prevent potential issues.
273    private static final Object SIM_READ_LOCK = new Object();
274    private ArrayList<SimContact> loadFrom(Uri uri) {
275        synchronized (SIM_READ_LOCK) {
276            final Cursor cursor = mResolver.query(uri, null, null, null, null);
277            if (cursor == null) {
278                // Assume null means there are no SIM contacts.
279                return new ArrayList<>(0);
280            }
281
282            try {
283                return loadFromCursor(cursor);
284            } finally {
285                cursor.close();
286            }
287        }
288    }
289
290    private ArrayList<SimContact> loadFromCursor(Cursor cursor) {
291        final int colId = cursor.getColumnIndex(_ID);
292        final int colName = cursor.getColumnIndex(NAME);
293        final int colNumber = cursor.getColumnIndex(NUMBER);
294        final int colEmails = cursor.getColumnIndex(EMAILS);
295
296        final ArrayList<SimContact> result = new ArrayList<>();
297
298        while (cursor.moveToNext()) {
299            final long id = cursor.getLong(colId);
300            final String name = cursor.getString(colName);
301            final String number = cursor.getString(colNumber);
302            final String emails = cursor.getString(colEmails);
303
304            final SimContact contact = new SimContact(id, name, number, parseEmails(emails));
305            // Only include contact if it has some useful data
306            if (contact.hasName() || contact.hasPhone() || contact.hasEmails()) {
307                result.add(contact);
308            }
309        }
310        return result;
311    }
312
313    private Cursor queryRawContactsForSimContacts(List<SimContact> contacts) {
314        final StringBuilder selectionBuilder = new StringBuilder();
315
316        int phoneCount = 0;
317        int nameCount = 0;
318        for (SimContact contact : contacts) {
319            if (contact.hasPhone()) {
320                phoneCount++;
321            } else if (contact.hasName()) {
322                nameCount++;
323            }
324        }
325        List<String> selectionArgs = new ArrayList<>(phoneCount + 1);
326
327        selectionBuilder.append('(');
328        selectionBuilder.append(Data.MIMETYPE).append("=? AND ");
329        selectionArgs.add(Phone.CONTENT_ITEM_TYPE);
330
331        selectionBuilder.append(Phone.NUMBER).append(" IN (")
332                .append(Joiner.on(',').join(Collections.nCopies(phoneCount, '?')))
333                .append(')');
334        for (SimContact contact : contacts) {
335            if (contact.hasPhone()) {
336                selectionArgs.add(contact.getPhone());
337            }
338        }
339        selectionBuilder.append(')');
340
341        if (nameCount > 0) {
342            selectionBuilder.append(" OR (");
343
344            selectionBuilder.append(Data.MIMETYPE).append("=? AND ");
345            selectionArgs.add(StructuredName.CONTENT_ITEM_TYPE);
346
347            selectionBuilder.append(Data.DISPLAY_NAME).append(" IN (")
348                    .append(Joiner.on(',').join(Collections.nCopies(nameCount, '?')))
349                    .append(')');
350            for (SimContact contact : contacts) {
351                if (!contact.hasPhone() && contact.hasName()) {
352                    selectionArgs.add(contact.getName());
353                }
354            }
355            selectionBuilder.append(')');
356        }
357
358        return mResolver.query(Data.CONTENT_URI.buildUpon()
359                        .appendQueryParameter(Data.VISIBLE_CONTACTS_ONLY, "true")
360                        .build(),
361                DataQuery.PROJECTION,
362                selectionBuilder.toString(),
363                selectionArgs.toArray(new String[selectionArgs.size()]),
364                null);
365    }
366
367    private Cursor queryAccountsOfRawContacts(Set<Long> ids) {
368        final StringBuilder selectionBuilder = new StringBuilder();
369
370        final String[] args = new String[ids.size()];
371
372        selectionBuilder.append(RawContacts._ID).append(" IN (")
373                .append(Joiner.on(',').join(Collections.nCopies(args.length, '?')))
374                .append(")");
375        int i = 0;
376        for (long id : ids) {
377            args[i++] = String.valueOf(id);
378        }
379        return mResolver.query(RawContacts.CONTENT_URI,
380                AccountQuery.PROJECTION,
381                selectionBuilder.toString(),
382                args,
383                null);
384    }
385
386    private ArrayList<ContentProviderOperation> createImportOperations(List<SimContact> contacts,
387            AccountWithDataSet targetAccount) {
388        final ArrayList<ContentProviderOperation> ops = new ArrayList<>();
389        for (SimContact contact : contacts) {
390            contact.appendCreateContactOperations(ops, targetAccount);
391        }
392        return ops;
393    }
394
395    private String[] parseEmails(String emails) {
396        return !TextUtils.isEmpty(emails) ? emails.split(",") : null;
397    }
398
399    private boolean hasTelephony() {
400        return mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEPHONY);
401    }
402
403    private boolean hasPermissions() {
404        return PermissionsUtil.hasContactsPermissions(mContext) &&
405                PermissionsUtil.hasPhonePermissions(mContext);
406    }
407
408    // TODO remove this class and the USE_FAKE_INSTANCE flag once this code is not under
409    // active development or anytime after 3/1/2017
410    public static class DebugImpl extends SimContactDaoImpl {
411
412        private List<SimCard> mSimCards = new ArrayList<>();
413        private SparseArray<SimCard> mCardsBySubscription = new SparseArray<>();
414
415        public DebugImpl(Context context) {
416            super(context);
417        }
418
419        public DebugImpl addSimCard(SimCard sim) {
420            mSimCards.add(sim);
421            mCardsBySubscription.put(sim.getSubscriptionId(), sim);
422            return this;
423        }
424
425        @Override
426        public List<SimCard> getSimCards() {
427            return SharedPreferenceUtil.restoreSimStates(getContext(), mSimCards);
428        }
429
430        @Override
431        public ArrayList<SimContact> loadContactsForSim(SimCard card) {
432            return new ArrayList<>(card.getContacts());
433        }
434
435        @Override
436        public boolean canReadSimContacts() {
437            return true;
438        }
439    }
440
441    // Query used for detecting existing contacts that may match a SimContact.
442    private static final class DataQuery {
443
444        public static final String[] PROJECTION = new String[] {
445                Data.RAW_CONTACT_ID, Phone.NUMBER, Data.DISPLAY_NAME, Data.MIMETYPE
446        };
447
448        public static final int RAW_CONTACT_ID = 0;
449        public static final int PHONE_NUMBER = 1;
450        public static final int DISPLAY_NAME = 2;
451        public static final int MIMETYPE = 3;
452
453        public static long getRawContactId(Cursor cursor) {
454            return cursor.getLong(RAW_CONTACT_ID);
455        }
456
457        public static String getPhoneNumber(Cursor cursor) {
458            return isPhoneNumber(cursor) ? cursor.getString(PHONE_NUMBER) : null;
459        }
460
461        public static String getDisplayName(Cursor cursor) {
462            return cursor.getString(DISPLAY_NAME);
463        }
464
465        public static boolean isPhoneNumber(Cursor cursor) {
466            return Phone.CONTENT_ITEM_TYPE.equals(cursor.getString(MIMETYPE));
467        }
468    }
469
470    private static final class AccountQuery {
471        public static final String[] PROJECTION = new String[] {
472                RawContacts._ID, RawContacts.ACCOUNT_NAME, RawContacts.ACCOUNT_TYPE,
473                RawContacts.DATA_SET
474        };
475
476        public static long getId(Cursor cursor) {
477            return cursor.getLong(0);
478        }
479
480        public static AccountWithDataSet getAccount(Cursor cursor) {
481            return new AccountWithDataSet(cursor.getString(1), cursor.getString(2),
482                    cursor.getString(3));
483        }
484    }
485}
486