1/*
2 * Copyright (C) 2009 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
17package com.android.providers.contacts;
18
19import static com.android.providers.contacts.util.DbQueryUtils.checkForSupportedColumns;
20import static com.android.providers.contacts.util.DbQueryUtils.getEqualityClause;
21import static com.android.providers.contacts.util.DbQueryUtils.getInequalityClause;
22
23import android.app.AppOpsManager;
24import android.content.ContentProvider;
25import android.content.ContentUris;
26import android.content.ContentValues;
27import android.content.Context;
28import android.content.UriMatcher;
29import android.database.Cursor;
30import android.database.DatabaseUtils;
31import android.database.sqlite.SQLiteDatabase;
32import android.database.sqlite.SQLiteQueryBuilder;
33import android.net.Uri;
34import android.os.UserHandle;
35import android.os.UserManager;
36import android.provider.CallLog;
37import android.provider.CallLog.Calls;
38import android.text.TextUtils;
39import android.util.Log;
40
41import com.android.providers.contacts.ContactsDatabaseHelper.DbProperties;
42import com.android.providers.contacts.ContactsDatabaseHelper.Tables;
43import com.android.providers.contacts.util.SelectionBuilder;
44import com.android.providers.contacts.util.UserUtils;
45
46import com.google.common.annotations.VisibleForTesting;
47
48import java.util.HashMap;
49import java.util.List;
50
51/**
52 * Call log content provider.
53 */
54public class CallLogProvider extends ContentProvider {
55    private static final String TAG = CallLogProvider.class.getSimpleName();
56
57    /** Selection clause for selecting all calls that were made after a certain time */
58    private static final String MORE_RECENT_THAN_SELECTION = Calls.DATE + "> ?";
59    /** Selection clause to use to exclude voicemail records.  */
60    private static final String EXCLUDE_VOICEMAIL_SELECTION = getInequalityClause(
61            Calls.TYPE, Calls.VOICEMAIL_TYPE);
62
63    @VisibleForTesting
64    static final String[] CALL_LOG_SYNC_PROJECTION = new String[] {
65        Calls.NUMBER,
66        Calls.NUMBER_PRESENTATION,
67        Calls.TYPE,
68        Calls.FEATURES,
69        Calls.DATE,
70        Calls.DURATION,
71        Calls.DATA_USAGE,
72        Calls.PHONE_ACCOUNT_COMPONENT_NAME,
73        Calls.PHONE_ACCOUNT_ID
74    };
75
76    private static final int CALLS = 1;
77
78    private static final int CALLS_ID = 2;
79
80    private static final int CALLS_FILTER = 3;
81
82    private static final UriMatcher sURIMatcher = new UriMatcher(UriMatcher.NO_MATCH);
83    static {
84        sURIMatcher.addURI(CallLog.AUTHORITY, "calls", CALLS);
85        sURIMatcher.addURI(CallLog.AUTHORITY, "calls/#", CALLS_ID);
86        sURIMatcher.addURI(CallLog.AUTHORITY, "calls/filter/*", CALLS_FILTER);
87    }
88
89    private static final HashMap<String, String> sCallsProjectionMap;
90    static {
91
92        // Calls projection map
93        sCallsProjectionMap = new HashMap<String, String>();
94        sCallsProjectionMap.put(Calls._ID, Calls._ID);
95        sCallsProjectionMap.put(Calls.NUMBER, Calls.NUMBER);
96        sCallsProjectionMap.put(Calls.NUMBER_PRESENTATION, Calls.NUMBER_PRESENTATION);
97        sCallsProjectionMap.put(Calls.DATE, Calls.DATE);
98        sCallsProjectionMap.put(Calls.DURATION, Calls.DURATION);
99        sCallsProjectionMap.put(Calls.DATA_USAGE, Calls.DATA_USAGE);
100        sCallsProjectionMap.put(Calls.TYPE, Calls.TYPE);
101        sCallsProjectionMap.put(Calls.FEATURES, Calls.FEATURES);
102        sCallsProjectionMap.put(Calls.PHONE_ACCOUNT_COMPONENT_NAME, Calls.PHONE_ACCOUNT_COMPONENT_NAME);
103        sCallsProjectionMap.put(Calls.PHONE_ACCOUNT_ID, Calls.PHONE_ACCOUNT_ID);
104        sCallsProjectionMap.put(Calls.NEW, Calls.NEW);
105        sCallsProjectionMap.put(Calls.VOICEMAIL_URI, Calls.VOICEMAIL_URI);
106        sCallsProjectionMap.put(Calls.TRANSCRIPTION, Calls.TRANSCRIPTION);
107        sCallsProjectionMap.put(Calls.IS_READ, Calls.IS_READ);
108        sCallsProjectionMap.put(Calls.CACHED_NAME, Calls.CACHED_NAME);
109        sCallsProjectionMap.put(Calls.CACHED_NUMBER_TYPE, Calls.CACHED_NUMBER_TYPE);
110        sCallsProjectionMap.put(Calls.CACHED_NUMBER_LABEL, Calls.CACHED_NUMBER_LABEL);
111        sCallsProjectionMap.put(Calls.COUNTRY_ISO, Calls.COUNTRY_ISO);
112        sCallsProjectionMap.put(Calls.GEOCODED_LOCATION, Calls.GEOCODED_LOCATION);
113        sCallsProjectionMap.put(Calls.CACHED_LOOKUP_URI, Calls.CACHED_LOOKUP_URI);
114        sCallsProjectionMap.put(Calls.CACHED_MATCHED_NUMBER, Calls.CACHED_MATCHED_NUMBER);
115        sCallsProjectionMap.put(Calls.CACHED_NORMALIZED_NUMBER, Calls.CACHED_NORMALIZED_NUMBER);
116        sCallsProjectionMap.put(Calls.CACHED_PHOTO_ID, Calls.CACHED_PHOTO_ID);
117        sCallsProjectionMap.put(Calls.CACHED_FORMATTED_NUMBER, Calls.CACHED_FORMATTED_NUMBER);
118    }
119
120    private ContactsDatabaseHelper mDbHelper;
121    private DatabaseUtils.InsertHelper mCallsInserter;
122    private boolean mUseStrictPhoneNumberComparation;
123    private VoicemailPermissions mVoicemailPermissions;
124    private CallLogInsertionHelper mCallLogInsertionHelper;
125
126    @Override
127    public boolean onCreate() {
128        setAppOps(AppOpsManager.OP_READ_CALL_LOG, AppOpsManager.OP_WRITE_CALL_LOG);
129        if (Log.isLoggable(Constants.PERFORMANCE_TAG, Log.DEBUG)) {
130            Log.d(Constants.PERFORMANCE_TAG, "CallLogProvider.onCreate start");
131        }
132        final Context context = getContext();
133        mDbHelper = getDatabaseHelper(context);
134        mUseStrictPhoneNumberComparation =
135            context.getResources().getBoolean(
136                    com.android.internal.R.bool.config_use_strict_phone_number_comparation);
137        mVoicemailPermissions = new VoicemailPermissions(context);
138        mCallLogInsertionHelper = createCallLogInsertionHelper(context);
139        final UserManager userManager = UserUtils.getUserManager(context);
140        if (userManager != null &&
141                !userManager.hasUserRestriction(UserManager.DISALLOW_OUTGOING_CALLS)) {
142            syncEntriesFromPrimaryUser(userManager);
143        }
144
145        if (Log.isLoggable(Constants.PERFORMANCE_TAG, Log.DEBUG)) {
146            Log.d(Constants.PERFORMANCE_TAG, "CallLogProvider.onCreate finish");
147        }
148        return true;
149    }
150
151    @VisibleForTesting
152    protected CallLogInsertionHelper createCallLogInsertionHelper(final Context context) {
153        return DefaultCallLogInsertionHelper.getInstance(context);
154    }
155
156    @VisibleForTesting
157    protected ContactsDatabaseHelper getDatabaseHelper(final Context context) {
158        return ContactsDatabaseHelper.getInstance(context);
159    }
160
161    @Override
162    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
163            String sortOrder) {
164        final SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
165        qb.setTables(Tables.CALLS);
166        qb.setProjectionMap(sCallsProjectionMap);
167        qb.setStrict(true);
168
169        final SelectionBuilder selectionBuilder = new SelectionBuilder(selection);
170        checkVoicemailPermissionAndAddRestriction(uri, selectionBuilder, true /*isQuery*/);
171
172        final int match = sURIMatcher.match(uri);
173        switch (match) {
174            case CALLS:
175                break;
176
177            case CALLS_ID: {
178                selectionBuilder.addClause(getEqualityClause(Calls._ID,
179                        parseCallIdFromUri(uri)));
180                break;
181            }
182
183            case CALLS_FILTER: {
184                List<String> pathSegments = uri.getPathSegments();
185                String phoneNumber = pathSegments.size() >= 2 ? pathSegments.get(2) : null;
186                if (!TextUtils.isEmpty(phoneNumber)) {
187                    qb.appendWhere("PHONE_NUMBERS_EQUAL(number, ");
188                    qb.appendWhereEscapeString(phoneNumber);
189                    qb.appendWhere(mUseStrictPhoneNumberComparation ? ", 1)" : ", 0)");
190                } else {
191                    qb.appendWhere(Calls.NUMBER_PRESENTATION + "!="
192                            + Calls.PRESENTATION_ALLOWED);
193                }
194                break;
195            }
196
197            default:
198                throw new IllegalArgumentException("Unknown URL " + uri);
199        }
200
201        final int limit = getIntParam(uri, Calls.LIMIT_PARAM_KEY, 0);
202        final int offset = getIntParam(uri, Calls.OFFSET_PARAM_KEY, 0);
203        String limitClause = null;
204        if (limit > 0) {
205            limitClause = offset + "," + limit;
206        }
207
208        final SQLiteDatabase db = mDbHelper.getReadableDatabase();
209        final Cursor c = qb.query(db, projection, selectionBuilder.build(), selectionArgs, null,
210                null, sortOrder, limitClause);
211        if (c != null) {
212            c.setNotificationUri(getContext().getContentResolver(), CallLog.CONTENT_URI);
213        }
214        return c;
215    }
216
217    /**
218     * Gets an integer query parameter from a given uri.
219     *
220     * @param uri The uri to extract the query parameter from.
221     * @param key The query parameter key.
222     * @param defaultValue A default value to return if the query parameter does not exist.
223     * @return The value from the query parameter in the Uri.  Or the default value if the parameter
224     * does not exist in the uri.
225     * @throws IllegalArgumentException when the value in the query parameter is not an integer.
226     */
227    private int getIntParam(Uri uri, String key, int defaultValue) {
228        String valueString = uri.getQueryParameter(key);
229        if (valueString == null) {
230            return defaultValue;
231        }
232
233        try {
234            return Integer.parseInt(valueString);
235        } catch (NumberFormatException e) {
236            String msg = "Integer required for " + key + " parameter but value '" + valueString +
237                    "' was found instead.";
238            throw new IllegalArgumentException(msg, e);
239        }
240    }
241
242    @Override
243    public String getType(Uri uri) {
244        int match = sURIMatcher.match(uri);
245        switch (match) {
246            case CALLS:
247                return Calls.CONTENT_TYPE;
248            case CALLS_ID:
249                return Calls.CONTENT_ITEM_TYPE;
250            case CALLS_FILTER:
251                return Calls.CONTENT_TYPE;
252            default:
253                throw new IllegalArgumentException("Unknown URI: " + uri);
254        }
255    }
256
257    @Override
258    public Uri insert(Uri uri, ContentValues values) {
259        checkForSupportedColumns(sCallsProjectionMap, values);
260        // Inserting a voicemail record through call_log requires the voicemail
261        // permission and also requires the additional voicemail param set.
262        if (hasVoicemailValue(values)) {
263            checkIsAllowVoicemailRequest(uri);
264            mVoicemailPermissions.checkCallerHasWriteAccess();
265        }
266        if (mCallsInserter == null) {
267            SQLiteDatabase db = mDbHelper.getWritableDatabase();
268            mCallsInserter = new DatabaseUtils.InsertHelper(db, Tables.CALLS);
269        }
270
271        ContentValues copiedValues = new ContentValues(values);
272
273        // Add the computed fields to the copied values.
274        mCallLogInsertionHelper.addComputedValues(copiedValues);
275
276        long rowId = getDatabaseModifier(mCallsInserter).insert(copiedValues);
277        if (rowId > 0) {
278            return ContentUris.withAppendedId(uri, rowId);
279        }
280        return null;
281    }
282
283    @Override
284    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
285        checkForSupportedColumns(sCallsProjectionMap, values);
286        // Request that involves changing record type to voicemail requires the
287        // voicemail param set in the uri.
288        if (hasVoicemailValue(values)) {
289            checkIsAllowVoicemailRequest(uri);
290        }
291
292        SelectionBuilder selectionBuilder = new SelectionBuilder(selection);
293        checkVoicemailPermissionAndAddRestriction(uri, selectionBuilder, false /*isQuery*/);
294
295        final SQLiteDatabase db = mDbHelper.getWritableDatabase();
296        final int matchedUriId = sURIMatcher.match(uri);
297        switch (matchedUriId) {
298            case CALLS:
299                break;
300
301            case CALLS_ID:
302                selectionBuilder.addClause(getEqualityClause(Calls._ID, parseCallIdFromUri(uri)));
303                break;
304
305            default:
306                throw new UnsupportedOperationException("Cannot update URL: " + uri);
307        }
308
309        return getDatabaseModifier(db).update(Tables.CALLS, values, selectionBuilder.build(),
310                selectionArgs);
311    }
312
313    @Override
314    public int delete(Uri uri, String selection, String[] selectionArgs) {
315        SelectionBuilder selectionBuilder = new SelectionBuilder(selection);
316        checkVoicemailPermissionAndAddRestriction(uri, selectionBuilder, false /*isQuery*/);
317
318        final SQLiteDatabase db = mDbHelper.getWritableDatabase();
319        final int matchedUriId = sURIMatcher.match(uri);
320        switch (matchedUriId) {
321            case CALLS:
322                return getDatabaseModifier(db).delete(Tables.CALLS,
323                        selectionBuilder.build(), selectionArgs);
324            default:
325                throw new UnsupportedOperationException("Cannot delete that URL: " + uri);
326        }
327    }
328
329    // Work around to let the test code override the context. getContext() is final so cannot be
330    // overridden.
331    protected Context context() {
332        return getContext();
333    }
334
335    /**
336     * Returns a {@link DatabaseModifier} that takes care of sending necessary notifications
337     * after the operation is performed.
338     */
339    private DatabaseModifier getDatabaseModifier(SQLiteDatabase db) {
340        return new DbModifierWithNotification(Tables.CALLS, db, context());
341    }
342
343    /**
344     * Same as {@link #getDatabaseModifier(SQLiteDatabase)} but used for insert helper operations
345     * only.
346     */
347    private DatabaseModifier getDatabaseModifier(DatabaseUtils.InsertHelper insertHelper) {
348        return new DbModifierWithNotification(Tables.CALLS, insertHelper, context());
349    }
350
351    private static final Integer VOICEMAIL_TYPE = new Integer(Calls.VOICEMAIL_TYPE);
352    private boolean hasVoicemailValue(ContentValues values) {
353        return VOICEMAIL_TYPE.equals(values.getAsInteger(Calls.TYPE));
354    }
355
356    /**
357     * Checks if the supplied uri requests to include voicemails and take appropriate
358     * action.
359     * <p> If voicemail is requested, then check for voicemail permissions. Otherwise
360     * modify the selection to restrict to non-voicemail entries only.
361     */
362    private void checkVoicemailPermissionAndAddRestriction(Uri uri,
363            SelectionBuilder selectionBuilder, boolean isQuery) {
364        if (isAllowVoicemailRequest(uri)) {
365            if (isQuery) {
366                mVoicemailPermissions.checkCallerHasReadAccess();
367            } else {
368                mVoicemailPermissions.checkCallerHasWriteAccess();
369            }
370        } else {
371            selectionBuilder.addClause(EXCLUDE_VOICEMAIL_SELECTION);
372        }
373    }
374
375    /**
376     * Determines if the supplied uri has the request to allow voicemails to be
377     * included.
378     */
379    private boolean isAllowVoicemailRequest(Uri uri) {
380        return uri.getBooleanQueryParameter(Calls.ALLOW_VOICEMAILS_PARAM_KEY, false);
381    }
382
383    /**
384     * Checks to ensure that the given uri has allow_voicemail set. Used by
385     * insert and update operations to check that ContentValues with voicemail
386     * call type must use the voicemail uri.
387     * @throws IllegalArgumentException if allow_voicemail is not set.
388     */
389    private void checkIsAllowVoicemailRequest(Uri uri) {
390        if (!isAllowVoicemailRequest(uri)) {
391            throw new IllegalArgumentException(
392                    String.format("Uri %s cannot be used for voicemail record." +
393                            " Please set '%s=true' in the uri.", uri,
394                            Calls.ALLOW_VOICEMAILS_PARAM_KEY));
395        }
396    }
397
398   /**
399    * Parses the call Id from the given uri, assuming that this is a uri that
400    * matches CALLS_ID. For other uri types the behaviour is undefined.
401    * @throws IllegalArgumentException if the id included in the Uri is not a valid long value.
402    */
403    private long parseCallIdFromUri(Uri uri) {
404        try {
405            return Long.parseLong(uri.getPathSegments().get(1));
406        } catch (NumberFormatException e) {
407            throw new IllegalArgumentException("Invalid call id in uri: " + uri, e);
408        }
409    }
410
411    /**
412     * Syncs any unique call log entries that have been inserted into the primary user's call log
413     * since the last time the last sync occurred.
414     */
415    private void syncEntriesFromPrimaryUser(UserManager userManager) {
416        final int userHandle = userManager.getUserHandle();
417        if (userHandle == UserHandle.USER_OWNER
418                || userManager.getUserInfo(userHandle).isManagedProfile()) {
419            return;
420        }
421
422        final long lastSyncTime = getLastSyncTime();
423        final Uri uri = ContentProvider.maybeAddUserId(CallLog.Calls.CONTENT_URI,
424                UserHandle.USER_OWNER);
425        final Cursor cursor = getContext().getContentResolver().query(
426                uri,
427                CALL_LOG_SYNC_PROJECTION,
428                EXCLUDE_VOICEMAIL_SELECTION + " AND " + MORE_RECENT_THAN_SELECTION,
429                new String[] {String.valueOf(lastSyncTime)},
430                Calls.DATE + " DESC");
431        if (cursor == null) {
432            return;
433        }
434        try {
435            final long lastSyncedEntryTime = copyEntriesFromCursor(cursor);
436            if (lastSyncedEntryTime > lastSyncTime) {
437                setLastTimeSynced(lastSyncedEntryTime);
438            }
439        } finally {
440            cursor.close();
441        }
442    }
443
444    /**
445     * @param cursor to copy call log entries from
446     *
447     * @return the timestamp of the last synced entry.
448     */
449    @VisibleForTesting
450    long copyEntriesFromCursor(Cursor cursor) {
451        long lastSynced = 0;
452        final ContentValues values = new ContentValues();
453        final SQLiteDatabase db = mDbHelper.getWritableDatabase();
454        db.beginTransaction();
455        try {
456            final String[] args = new String[2];
457            cursor.moveToPosition(-1);
458            while (cursor.moveToNext()) {
459                values.clear();
460                DatabaseUtils.cursorRowToContentValues(cursor, values);
461                final String startTime = values.getAsString(Calls.DATE);
462                final String number = values.getAsString(Calls.NUMBER);
463
464                if (startTime == null || number == null) {
465                    continue;
466                }
467
468                if (cursor.isLast()) {
469                    try {
470                        lastSynced = Long.valueOf(startTime);
471                    } catch (NumberFormatException e) {
472                        Log.e(TAG, "Call log entry does not contain valid start time: "
473                                + startTime);
474                    }
475                }
476
477                // Avoid duplicating an already existing entry (which is uniquely identified by
478                // the number, and the start time)
479                args[0] = startTime;
480                args[1] = number;
481                if (DatabaseUtils.queryNumEntries(db, Tables.CALLS,
482                        Calls.DATE + " = ? AND " + Calls.NUMBER + " = ?", args) > 0) {
483                    continue;
484                }
485
486                db.insert(Tables.CALLS, null, values);
487            }
488            db.setTransactionSuccessful();
489        } finally {
490            db.endTransaction();
491        }
492        return lastSynced;
493    }
494
495    private long getLastSyncTime() {
496        try {
497            return Long.valueOf(mDbHelper.getProperty(DbProperties.CALL_LOG_LAST_SYNCED, "0"));
498        } catch (NumberFormatException e) {
499            return 0;
500        }
501    }
502
503    private void setLastTimeSynced(long time) {
504        mDbHelper.setProperty(DbProperties.CALL_LOG_LAST_SYNCED, String.valueOf(time));
505    }
506}
507