blob: d4339b03a3bca9fd5b08c0ab2a4f523875ab09b7 [file] [log] [blame]
/*
* Copyright (C) 2009 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/
package com.android.providers.contacts;
import static com.android.providers.contacts.util.DbQueryUtils.checkForSupportedColumns;
import static com.android.providers.contacts.util.DbQueryUtils.getEqualityClause;
import static com.android.providers.contacts.util.DbQueryUtils.getInequalityClause;
import android.app.AppOpsManager;
import android.content.ContentProvider;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteQueryBuilder;
import android.net.Uri;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Message;
import android.os.Process;
import android.os.UserHandle;
import android.os.UserManager;
import android.provider.CallLog;
import android.provider.CallLog.Calls;
import android.text.TextUtils;
import android.util.Log;
import com.android.providers.contacts.ContactsDatabaseHelper.DbProperties;
import com.android.providers.contacts.ContactsDatabaseHelper.Tables;
import com.android.providers.contacts.util.SelectionBuilder;
import com.android.providers.contacts.util.UserUtils;
import com.google.common.annotations.VisibleForTesting;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.CountDownLatch;
/**
* Call log content provider.
*/
public class CallLogProvider extends ContentProvider {
private static final String TAG = CallLogProvider.class.getSimpleName();
private static final int BACKGROUND_TASK_INITIALIZE = 0;
/** Selection clause for selecting all calls that were made after a certain time */
private static final String MORE_RECENT_THAN_SELECTION = Calls.DATE + "> ?";
/** Selection clause to use to exclude voicemail records. */
private static final String EXCLUDE_VOICEMAIL_SELECTION = getInequalityClause(
Calls.TYPE, Calls.VOICEMAIL_TYPE);
@VisibleForTesting
static final String[] CALL_LOG_SYNC_PROJECTION = new String[] {
Calls.NUMBER,
Calls.NUMBER_PRESENTATION,
Calls.TYPE,
Calls.FEATURES,
Calls.DATE,
Calls.DURATION,
Calls.DATA_USAGE,
Calls.PHONE_ACCOUNT_COMPONENT_NAME,
Calls.PHONE_ACCOUNT_ID
};
private static final int CALLS = 1;
private static final int CALLS_ID = 2;
private static final int CALLS_FILTER = 3;
private static final UriMatcher sURIMatcher = new UriMatcher(UriMatcher.NO_MATCH);
static {
sURIMatcher.addURI(CallLog.AUTHORITY, "calls", CALLS);
sURIMatcher.addURI(CallLog.AUTHORITY, "calls/#", CALLS_ID);
sURIMatcher.addURI(CallLog.AUTHORITY, "calls/filter/*", CALLS_FILTER);
}
private static final HashMap<String, String> sCallsProjectionMap;
static {
// Calls projection map
sCallsProjectionMap = new HashMap<String, String>();
sCallsProjectionMap.put(Calls._ID, Calls._ID);
sCallsProjectionMap.put(Calls.NUMBER, Calls.NUMBER);
sCallsProjectionMap.put(Calls.NUMBER_PRESENTATION, Calls.NUMBER_PRESENTATION);
sCallsProjectionMap.put(Calls.DATE, Calls.DATE);
sCallsProjectionMap.put(Calls.DURATION, Calls.DURATION);
sCallsProjectionMap.put(Calls.DATA_USAGE, Calls.DATA_USAGE);
sCallsProjectionMap.put(Calls.TYPE, Calls.TYPE);
sCallsProjectionMap.put(Calls.FEATURES, Calls.FEATURES);
sCallsProjectionMap.put(Calls.PHONE_ACCOUNT_COMPONENT_NAME, Calls.PHONE_ACCOUNT_COMPONENT_NAME);
sCallsProjectionMap.put(Calls.PHONE_ACCOUNT_ID, Calls.PHONE_ACCOUNT_ID);
sCallsProjectionMap.put(Calls.NEW, Calls.NEW);
sCallsProjectionMap.put(Calls.VOICEMAIL_URI, Calls.VOICEMAIL_URI);
sCallsProjectionMap.put(Calls.TRANSCRIPTION, Calls.TRANSCRIPTION);
sCallsProjectionMap.put(Calls.IS_READ, Calls.IS_READ);
sCallsProjectionMap.put(Calls.CACHED_NAME, Calls.CACHED_NAME);
sCallsProjectionMap.put(Calls.CACHED_NUMBER_TYPE, Calls.CACHED_NUMBER_TYPE);
sCallsProjectionMap.put(Calls.CACHED_NUMBER_LABEL, Calls.CACHED_NUMBER_LABEL);
sCallsProjectionMap.put(Calls.COUNTRY_ISO, Calls.COUNTRY_ISO);
sCallsProjectionMap.put(Calls.GEOCODED_LOCATION, Calls.GEOCODED_LOCATION);
sCallsProjectionMap.put(Calls.CACHED_LOOKUP_URI, Calls.CACHED_LOOKUP_URI);
sCallsProjectionMap.put(Calls.CACHED_MATCHED_NUMBER, Calls.CACHED_MATCHED_NUMBER);
sCallsProjectionMap.put(Calls.CACHED_NORMALIZED_NUMBER, Calls.CACHED_NORMALIZED_NUMBER);
sCallsProjectionMap.put(Calls.CACHED_PHOTO_ID, Calls.CACHED_PHOTO_ID);
sCallsProjectionMap.put(Calls.CACHED_FORMATTED_NUMBER, Calls.CACHED_FORMATTED_NUMBER);
}
private HandlerThread mBackgroundThread;
private Handler mBackgroundHandler;
private volatile CountDownLatch mReadAccessLatch;
private ContactsDatabaseHelper mDbHelper;
private DatabaseUtils.InsertHelper mCallsInserter;
private boolean mUseStrictPhoneNumberComparation;
private VoicemailPermissions mVoicemailPermissions;
private CallLogInsertionHelper mCallLogInsertionHelper;
@Override
public boolean onCreate() {
setAppOps(AppOpsManager.OP_READ_CALL_LOG, AppOpsManager.OP_WRITE_CALL_LOG);
if (Log.isLoggable(Constants.PERFORMANCE_TAG, Log.DEBUG)) {
Log.d(Constants.PERFORMANCE_TAG, "CallLogProvider.onCreate start");
}
final Context context = getContext();
mDbHelper = getDatabaseHelper(context);
mUseStrictPhoneNumberComparation =
context.getResources().getBoolean(
com.android.internal.R.bool.config_use_strict_phone_number_comparation);
mVoicemailPermissions = new VoicemailPermissions(context);
mCallLogInsertionHelper = createCallLogInsertionHelper(context);
mBackgroundThread = new HandlerThread("CallLogProviderWorker",
Process.THREAD_PRIORITY_BACKGROUND);
mBackgroundThread.start();
mBackgroundHandler = new Handler(mBackgroundThread.getLooper()) {
@Override
public void handleMessage(Message msg) {
performBackgroundTask(msg.what);
}
};
mReadAccessLatch = new CountDownLatch(1);
scheduleBackgroundTask(BACKGROUND_TASK_INITIALIZE);
if (Log.isLoggable(Constants.PERFORMANCE_TAG, Log.DEBUG)) {
Log.d(Constants.PERFORMANCE_TAG, "CallLogProvider.onCreate finish");
}
return true;
}
@VisibleForTesting
protected CallLogInsertionHelper createCallLogInsertionHelper(final Context context) {
return DefaultCallLogInsertionHelper.getInstance(context);
}
@VisibleForTesting
protected ContactsDatabaseHelper getDatabaseHelper(final Context context) {
return ContactsDatabaseHelper.getInstance(context);
}
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
String sortOrder) {
waitForAccess(mReadAccessLatch);
final SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
qb.setTables(Tables.CALLS);
qb.setProjectionMap(sCallsProjectionMap);
qb.setStrict(true);
final SelectionBuilder selectionBuilder = new SelectionBuilder(selection);
checkVoicemailPermissionAndAddRestriction(uri, selectionBuilder, true /*isQuery*/);
final int match = sURIMatcher.match(uri);
switch (match) {
case CALLS:
break;
case CALLS_ID: {
selectionBuilder.addClause(getEqualityClause(Calls._ID,
parseCallIdFromUri(uri)));
break;
}
case CALLS_FILTER: {
List<String> pathSegments = uri.getPathSegments();
String phoneNumber = pathSegments.size() >= 2 ? pathSegments.get(2) : null;
if (!TextUtils.isEmpty(phoneNumber)) {
qb.appendWhere("PHONE_NUMBERS_EQUAL(number, ");
qb.appendWhereEscapeString(phoneNumber);
qb.appendWhere(mUseStrictPhoneNumberComparation ? ", 1)" : ", 0)");
} else {
qb.appendWhere(Calls.NUMBER_PRESENTATION + "!="
+ Calls.PRESENTATION_ALLOWED);
}
break;
}
default:
throw new IllegalArgumentException("Unknown URL " + uri);
}
final int limit = getIntParam(uri, Calls.LIMIT_PARAM_KEY, 0);
final int offset = getIntParam(uri, Calls.OFFSET_PARAM_KEY, 0);
String limitClause = null;
if (limit > 0) {
limitClause = offset + "," + limit;
}
final SQLiteDatabase db = mDbHelper.getReadableDatabase();
final Cursor c = qb.query(db, projection, selectionBuilder.build(), selectionArgs, null,
null, sortOrder, limitClause);
if (c != null) {
c.setNotificationUri(getContext().getContentResolver(), CallLog.CONTENT_URI);
}
return c;
}
/**
* Gets an integer query parameter from a given uri.
*
* @param uri The uri to extract the query parameter from.
* @param key The query parameter key.
* @param defaultValue A default value to return if the query parameter does not exist.
* @return The value from the query parameter in the Uri. Or the default value if the parameter
* does not exist in the uri.
* @throws IllegalArgumentException when the value in the query parameter is not an integer.
*/
private int getIntParam(Uri uri, String key, int defaultValue) {
String valueString = uri.getQueryParameter(key);
if (valueString == null) {
return defaultValue;
}
try {
return Integer.parseInt(valueString);
} catch (NumberFormatException e) {
String msg = "Integer required for " + key + " parameter but value '" + valueString +
"' was found instead.";
throw new IllegalArgumentException(msg, e);
}
}
@Override
public String getType(Uri uri) {
int match = sURIMatcher.match(uri);
switch (match) {
case CALLS:
return Calls.CONTENT_TYPE;
case CALLS_ID:
return Calls.CONTENT_ITEM_TYPE;
case CALLS_FILTER:
return Calls.CONTENT_TYPE;
default:
throw new IllegalArgumentException("Unknown URI: " + uri);
}
}
@Override
public Uri insert(Uri uri, ContentValues values) {
waitForAccess(mReadAccessLatch);
checkForSupportedColumns(sCallsProjectionMap, values);
// Inserting a voicemail record through call_log requires the voicemail
// permission and also requires the additional voicemail param set.
if (hasVoicemailValue(values)) {
checkIsAllowVoicemailRequest(uri);
mVoicemailPermissions.checkCallerHasWriteAccess();
}
if (mCallsInserter == null) {
SQLiteDatabase db = mDbHelper.getWritableDatabase();
mCallsInserter = new DatabaseUtils.InsertHelper(db, Tables.CALLS);
}
ContentValues copiedValues = new ContentValues(values);
// Add the computed fields to the copied values.
mCallLogInsertionHelper.addComputedValues(copiedValues);
long rowId = getDatabaseModifier(mCallsInserter).insert(copiedValues);
if (rowId > 0) {
return ContentUris.withAppendedId(uri, rowId);
}
return null;
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
waitForAccess(mReadAccessLatch);
checkForSupportedColumns(sCallsProjectionMap, values);
// Request that involves changing record type to voicemail requires the
// voicemail param set in the uri.
if (hasVoicemailValue(values)) {
checkIsAllowVoicemailRequest(uri);
}
SelectionBuilder selectionBuilder = new SelectionBuilder(selection);
checkVoicemailPermissionAndAddRestriction(uri, selectionBuilder, false /*isQuery*/);
final SQLiteDatabase db = mDbHelper.getWritableDatabase();
final int matchedUriId = sURIMatcher.match(uri);
switch (matchedUriId) {
case CALLS:
break;
case CALLS_ID:
selectionBuilder.addClause(getEqualityClause(Calls._ID, parseCallIdFromUri(uri)));
break;
default:
throw new UnsupportedOperationException("Cannot update URL: " + uri);
}
return getDatabaseModifier(db).update(Tables.CALLS, values, selectionBuilder.build(),
selectionArgs);
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
waitForAccess(mReadAccessLatch);
SelectionBuilder selectionBuilder = new SelectionBuilder(selection);
checkVoicemailPermissionAndAddRestriction(uri, selectionBuilder, false /*isQuery*/);
final SQLiteDatabase db = mDbHelper.getWritableDatabase();
final int matchedUriId = sURIMatcher.match(uri);
switch (matchedUriId) {
case CALLS:
return getDatabaseModifier(db).delete(Tables.CALLS,
selectionBuilder.build(), selectionArgs);
default:
throw new UnsupportedOperationException("Cannot delete that URL: " + uri);
}
}
// Work around to let the test code override the context. getContext() is final so cannot be
// overridden.
protected Context context() {
return getContext();
}
/**
* Returns a {@link DatabaseModifier} that takes care of sending necessary notifications
* after the operation is performed.
*/
private DatabaseModifier getDatabaseModifier(SQLiteDatabase db) {
return new DbModifierWithNotification(Tables.CALLS, db, context());
}
/**
* Same as {@link #getDatabaseModifier(SQLiteDatabase)} but used for insert helper operations
* only.
*/
private DatabaseModifier getDatabaseModifier(DatabaseUtils.InsertHelper insertHelper) {
return new DbModifierWithNotification(Tables.CALLS, insertHelper, context());
}
private static final Integer VOICEMAIL_TYPE = new Integer(Calls.VOICEMAIL_TYPE);
private boolean hasVoicemailValue(ContentValues values) {
return VOICEMAIL_TYPE.equals(values.getAsInteger(Calls.TYPE));
}
/**
* Checks if the supplied uri requests to include voicemails and take appropriate
* action.
* <p> If voicemail is requested, then check for voicemail permissions. Otherwise
* modify the selection to restrict to non-voicemail entries only.
*/
private void checkVoicemailPermissionAndAddRestriction(Uri uri,
SelectionBuilder selectionBuilder, boolean isQuery) {
if (isAllowVoicemailRequest(uri)) {
if (isQuery) {
mVoicemailPermissions.checkCallerHasReadAccess();
} else {
mVoicemailPermissions.checkCallerHasWriteAccess();
}
} else {
selectionBuilder.addClause(EXCLUDE_VOICEMAIL_SELECTION);
}
}
/**
* Determines if the supplied uri has the request to allow voicemails to be
* included.
*/
private boolean isAllowVoicemailRequest(Uri uri) {
return uri.getBooleanQueryParameter(Calls.ALLOW_VOICEMAILS_PARAM_KEY, false);
}
/**
* Checks to ensure that the given uri has allow_voicemail set. Used by
* insert and update operations to check that ContentValues with voicemail
* call type must use the voicemail uri.
* @throws IllegalArgumentException if allow_voicemail is not set.
*/
private void checkIsAllowVoicemailRequest(Uri uri) {
if (!isAllowVoicemailRequest(uri)) {
throw new IllegalArgumentException(
String.format("Uri %s cannot be used for voicemail record." +
" Please set '%s=true' in the uri.", uri,
Calls.ALLOW_VOICEMAILS_PARAM_KEY));
}
}
/**
* Parses the call Id from the given uri, assuming that this is a uri that
* matches CALLS_ID. For other uri types the behaviour is undefined.
* @throws IllegalArgumentException if the id included in the Uri is not a valid long value.
*/
private long parseCallIdFromUri(Uri uri) {
try {
return Long.parseLong(uri.getPathSegments().get(1));
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid call id in uri: " + uri, e);
}
}
/**
* Syncs any unique call log entries that have been inserted into the primary user's call log
* since the last time the last sync occurred.
*/
private void syncEntriesFromPrimaryUser(UserManager userManager) {
final int userHandle = userManager.getUserHandle();
if (userHandle == UserHandle.USER_OWNER
|| userManager.getUserInfo(userHandle).isManagedProfile()) {
return;
}
final long lastSyncTime = getLastSyncTime();
final Uri uri = ContentProvider.maybeAddUserId(CallLog.Calls.CONTENT_URI,
UserHandle.USER_OWNER);
final Cursor cursor = getContext().getContentResolver().query(
uri,
CALL_LOG_SYNC_PROJECTION,
EXCLUDE_VOICEMAIL_SELECTION + " AND " + MORE_RECENT_THAN_SELECTION,
new String[] {String.valueOf(lastSyncTime)},
Calls.DATE + " DESC");
if (cursor == null) {
return;
}
try {
final long lastSyncedEntryTime = copyEntriesFromCursor(cursor);
if (lastSyncedEntryTime > lastSyncTime) {
setLastTimeSynced(lastSyncedEntryTime);
}
} finally {
cursor.close();
}
}
/**
* @param cursor to copy call log entries from
*
* @return the timestamp of the last synced entry.
*/
@VisibleForTesting
long copyEntriesFromCursor(Cursor cursor) {
long lastSynced = 0;
final ContentValues values = new ContentValues();
final SQLiteDatabase db = mDbHelper.getWritableDatabase();
db.beginTransaction();
try {
final String[] args = new String[2];
cursor.moveToPosition(-1);
while (cursor.moveToNext()) {
values.clear();
DatabaseUtils.cursorRowToContentValues(cursor, values);
final String startTime = values.getAsString(Calls.DATE);
final String number = values.getAsString(Calls.NUMBER);
if (startTime == null || number == null) {
continue;
}
if (cursor.isLast()) {
try {
lastSynced = Long.valueOf(startTime);
} catch (NumberFormatException e) {
Log.e(TAG, "Call log entry does not contain valid start time: "
+ startTime);
}
}
// Avoid duplicating an already existing entry (which is uniquely identified by
// the number, and the start time)
args[0] = startTime;
args[1] = number;
if (DatabaseUtils.queryNumEntries(db, Tables.CALLS,
Calls.DATE + " = ? AND " + Calls.NUMBER + " = ?", args) > 0) {
continue;
}
db.insert(Tables.CALLS, null, values);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
return lastSynced;
}
private long getLastSyncTime() {
try {
return Long.valueOf(mDbHelper.getProperty(DbProperties.CALL_LOG_LAST_SYNCED, "0"));
} catch (NumberFormatException e) {
return 0;
}
}
private void setLastTimeSynced(long time) {
mDbHelper.setProperty(DbProperties.CALL_LOG_LAST_SYNCED, String.valueOf(time));
}
private static void waitForAccess(CountDownLatch latch) {
if (latch == null) {
return;
}
while (true) {
try {
latch.await();
return;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
private void scheduleBackgroundTask(int task) {
mBackgroundHandler.sendEmptyMessage(task);
}
private void performBackgroundTask(int task) {
if (task == BACKGROUND_TASK_INITIALIZE) {
try {
final Context context = getContext();
if (context != null) {
final UserManager userManager = UserUtils.getUserManager(context);
if (userManager != null &&
!userManager.hasUserRestriction(UserManager.DISALLOW_OUTGOING_CALLS)) {
syncEntriesFromPrimaryUser(userManager);
}
}
} finally {
mReadAccessLatch.countDown();
mReadAccessLatch = null;
}
}
}
}