blob: 719492e693b14a439a23436e3d46c0fabacb4055 [file] [log] [blame]
/*
* Copyright (C) 2013 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.dialer.database;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.database.sqlite.SQLiteOpenHelper;
import android.database.sqlite.SQLiteStatement;
import android.net.Uri;
import android.provider.BaseColumns;
import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds.Phone;
import android.provider.ContactsContract.Contacts;
import android.provider.ContactsContract.Data;
import android.provider.ContactsContract.Directory;
import android.support.annotation.VisibleForTesting;
import android.support.annotation.WorkerThread;
import android.text.TextUtils;
import com.android.contacts.common.util.StopWatch;
import com.android.dialer.common.LogUtil;
import com.android.dialer.common.concurrent.DefaultFutureCallback;
import com.android.dialer.common.concurrent.DialerExecutorComponent;
import com.android.dialer.common.concurrent.DialerFutureSerializer;
import com.android.dialer.common.database.Selection;
import com.android.dialer.configprovider.ConfigProviderComponent;
import com.android.dialer.contacts.resources.R;
import com.android.dialer.database.FilteredNumberContract.FilteredNumberColumns;
import com.android.dialer.smartdial.util.SmartDialNameMatcher;
import com.android.dialer.smartdial.util.SmartDialPrefix;
import com.android.dialer.util.PermissionsUtil;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.MoreExecutors;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
/**
* Database helper for smart dial. Designed as a singleton to make sure there is only one access
* point to the database. Provides methods to maintain, update, and query the database.
*/
public class DialerDatabaseHelper extends SQLiteOpenHelper {
/**
* SmartDial DB version ranges:
*
* <pre>
* 0-98 KitKat
* </pre>
*/
public static final int DATABASE_VERSION = 10;
public static final String DATABASE_NAME = "dialer.db";
public static final String ACTION_SMART_DIAL_UPDATED =
"com.android.dialer.database.ACTION_SMART_DIAL_UPDATED";
private static final String TAG = "DialerDatabaseHelper";
private static final boolean DEBUG = false;
/** Saves the last update time of smart dial databases to shared preferences. */
private static final String DATABASE_LAST_CREATED_SHARED_PREF = "com.android.dialer";
private static final String LAST_UPDATED_MILLIS = "last_updated_millis";
@VisibleForTesting
static final String DEFAULT_LAST_UPDATED_CONFIG_KEY = "smart_dial_default_last_update_millis";
private static final String DATABASE_VERSION_PROPERTY = "database_version";
private static final int MAX_ENTRIES = 20;
private final Context context;
private final DialerFutureSerializer dialerFutureSerializer = new DialerFutureSerializer();
private boolean isTestInstance = false;
protected DialerDatabaseHelper(Context context, String databaseName, int dbVersion) {
super(context, databaseName, null, dbVersion);
this.context = Objects.requireNonNull(context, "Context must not be null");
}
public void setIsTestInstance(boolean isTestInstance) {
this.isTestInstance = isTestInstance;
}
/**
* Creates tables in the database when database is created for the first time.
*
* @param db The database.
*/
@Override
public void onCreate(SQLiteDatabase db) {
setupTables(db);
}
private void setupTables(SQLiteDatabase db) {
dropTables(db);
db.execSQL(
"CREATE TABLE "
+ Tables.SMARTDIAL_TABLE
+ " ("
+ SmartDialDbColumns._ID
+ " INTEGER PRIMARY KEY AUTOINCREMENT,"
+ SmartDialDbColumns.DATA_ID
+ " INTEGER, "
+ SmartDialDbColumns.NUMBER
+ " TEXT,"
+ SmartDialDbColumns.CONTACT_ID
+ " INTEGER,"
+ SmartDialDbColumns.LOOKUP_KEY
+ " TEXT,"
+ SmartDialDbColumns.DISPLAY_NAME_PRIMARY
+ " TEXT, "
+ SmartDialDbColumns.PHOTO_ID
+ " INTEGER, "
+ SmartDialDbColumns.LAST_SMARTDIAL_UPDATE_TIME
+ " LONG, "
+ SmartDialDbColumns.LAST_TIME_USED
+ " LONG, "
+ SmartDialDbColumns.TIMES_USED
+ " INTEGER, "
+ SmartDialDbColumns.STARRED
+ " INTEGER, "
+ SmartDialDbColumns.IS_SUPER_PRIMARY
+ " INTEGER, "
+ SmartDialDbColumns.IN_VISIBLE_GROUP
+ " INTEGER, "
+ SmartDialDbColumns.IS_PRIMARY
+ " INTEGER, "
+ SmartDialDbColumns.CARRIER_PRESENCE
+ " INTEGER NOT NULL DEFAULT 0"
+ ");");
db.execSQL(
"CREATE TABLE "
+ Tables.PREFIX_TABLE
+ " ("
+ PrefixColumns._ID
+ " INTEGER PRIMARY KEY AUTOINCREMENT,"
+ PrefixColumns.PREFIX
+ " TEXT COLLATE NOCASE, "
+ PrefixColumns.CONTACT_ID
+ " INTEGER"
+ ");");
db.execSQL(
"CREATE TABLE "
+ Tables.PROPERTIES
+ " ("
+ PropertiesColumns.PROPERTY_KEY
+ " TEXT PRIMARY KEY, "
+ PropertiesColumns.PROPERTY_VALUE
+ " TEXT "
+ ");");
// This will need to also be updated in setupTablesForFilteredNumberTest and onUpgrade.
// Hardcoded so we know on glance what columns are updated in setupTables,
// and to be able to guarantee the state of the DB at each upgrade step.
db.execSQL(
"CREATE TABLE "
+ Tables.FILTERED_NUMBER_TABLE
+ " ("
+ FilteredNumberColumns._ID
+ " INTEGER PRIMARY KEY AUTOINCREMENT,"
+ FilteredNumberColumns.NORMALIZED_NUMBER
+ " TEXT UNIQUE,"
+ FilteredNumberColumns.NUMBER
+ " TEXT,"
+ FilteredNumberColumns.COUNTRY_ISO
+ " TEXT,"
+ FilteredNumberColumns.TIMES_FILTERED
+ " INTEGER,"
+ FilteredNumberColumns.LAST_TIME_FILTERED
+ " LONG,"
+ FilteredNumberColumns.CREATION_TIME
+ " LONG,"
+ FilteredNumberColumns.TYPE
+ " INTEGER,"
+ FilteredNumberColumns.SOURCE
+ " INTEGER"
+ ");");
setProperty(db, DATABASE_VERSION_PROPERTY, String.valueOf(DATABASE_VERSION));
if (!isTestInstance) {
resetSmartDialLastUpdatedTime();
}
}
public void dropTables(SQLiteDatabase db) {
db.execSQL("DROP TABLE IF EXISTS " + Tables.PREFIX_TABLE);
db.execSQL("DROP TABLE IF EXISTS " + Tables.SMARTDIAL_TABLE);
db.execSQL("DROP TABLE IF EXISTS " + Tables.PROPERTIES);
db.execSQL("DROP TABLE IF EXISTS " + Tables.FILTERED_NUMBER_TABLE);
db.execSQL("DROP TABLE IF EXISTS " + Tables.VOICEMAIL_ARCHIVE_TABLE);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldNumber, int newNumber) {
// Disregard the old version and new versions provided by SQLiteOpenHelper, we will read
// our own from the database.
int oldVersion;
oldVersion = getPropertyAsInt(db, DATABASE_VERSION_PROPERTY, 0);
if (oldVersion == 0) {
LogUtil.e(
"DialerDatabaseHelper.onUpgrade", "malformed database version..recreating database");
}
if (oldVersion < 4) {
setupTables(db);
return;
}
if (oldVersion < 7) {
db.execSQL("DROP TABLE IF EXISTS " + Tables.FILTERED_NUMBER_TABLE);
db.execSQL(
"CREATE TABLE "
+ Tables.FILTERED_NUMBER_TABLE
+ " ("
+ FilteredNumberColumns._ID
+ " INTEGER PRIMARY KEY AUTOINCREMENT,"
+ FilteredNumberColumns.NORMALIZED_NUMBER
+ " TEXT UNIQUE,"
+ FilteredNumberColumns.NUMBER
+ " TEXT,"
+ FilteredNumberColumns.COUNTRY_ISO
+ " TEXT,"
+ FilteredNumberColumns.TIMES_FILTERED
+ " INTEGER,"
+ FilteredNumberColumns.LAST_TIME_FILTERED
+ " LONG,"
+ FilteredNumberColumns.CREATION_TIME
+ " LONG,"
+ FilteredNumberColumns.TYPE
+ " INTEGER,"
+ FilteredNumberColumns.SOURCE
+ " INTEGER"
+ ");");
oldVersion = 7;
}
if (oldVersion < 8) {
upgradeToVersion8(db);
oldVersion = 8;
}
if (oldVersion < 10) {
db.execSQL("DROP TABLE IF EXISTS " + Tables.VOICEMAIL_ARCHIVE_TABLE);
oldVersion = 10;
}
if (oldVersion != DATABASE_VERSION) {
throw new IllegalStateException(
"error upgrading the database to version " + DATABASE_VERSION);
}
setProperty(db, DATABASE_VERSION_PROPERTY, String.valueOf(DATABASE_VERSION));
}
public void upgradeToVersion8(SQLiteDatabase db) {
db.execSQL("ALTER TABLE smartdial_table ADD carrier_presence INTEGER NOT NULL DEFAULT 0");
}
/** Stores a key-value pair in the {@link Tables#PROPERTIES} table. */
public void setProperty(String key, String value) {
setProperty(getWritableDatabase(), key, value);
}
public void setProperty(SQLiteDatabase db, String key, String value) {
final ContentValues values = new ContentValues();
values.put(PropertiesColumns.PROPERTY_KEY, key);
values.put(PropertiesColumns.PROPERTY_VALUE, value);
db.replace(Tables.PROPERTIES, null, values);
}
/** Returns the value from the {@link Tables#PROPERTIES} table. */
public String getProperty(String key, String defaultValue) {
return getProperty(getReadableDatabase(), key, defaultValue);
}
public String getProperty(SQLiteDatabase db, String key, String defaultValue) {
try {
String value = null;
final Cursor cursor =
db.query(
Tables.PROPERTIES,
new String[] {PropertiesColumns.PROPERTY_VALUE},
PropertiesColumns.PROPERTY_KEY + "=?",
new String[] {key},
null,
null,
null);
if (cursor != null) {
try {
if (cursor.moveToFirst()) {
value = cursor.getString(0);
}
} finally {
cursor.close();
}
}
return value != null ? value : defaultValue;
} catch (SQLiteException e) {
return defaultValue;
}
}
public int getPropertyAsInt(SQLiteDatabase db, String key, int defaultValue) {
final String stored = getProperty(db, key, "");
try {
return Integer.parseInt(stored);
} catch (NumberFormatException e) {
return defaultValue;
}
}
private void resetSmartDialLastUpdatedTime() {
final SharedPreferences databaseLastUpdateSharedPref =
context.getSharedPreferences(DATABASE_LAST_CREATED_SHARED_PREF, Context.MODE_PRIVATE);
final SharedPreferences.Editor editor = databaseLastUpdateSharedPref.edit();
editor.putLong(LAST_UPDATED_MILLIS, 0);
editor.apply();
}
/**
* Starts the database upgrade process in the background.
*
* @see #updateSmartDialDatabase(boolean) for the usage of {@code forceUpdate}.
*/
public void startSmartDialUpdateThread(boolean forceUpdate) {
if (PermissionsUtil.hasContactsReadPermissions(context)) {
Futures.addCallback(
// Serialize calls to updateSmartDialDatabase. Use FutureSerializer instead of
// synchronizing on the method to prevent deadlocking thread pool. FutureSerializer
// provides the guarantee that the next AsyncCallable won't even be submitted until the
// ListenableFuture returned by the previous one completes. See a bug.
dialerFutureSerializer.submit(
() -> {
updateSmartDialDatabase(forceUpdate);
return null;
},
DialerExecutorComponent.get(context).backgroundExecutor()),
new DefaultFutureCallback<>(),
MoreExecutors.directExecutor());
}
}
/**
* Removes rows in the smartdial database that matches the contacts that have been deleted by
* other apps since last update.
*
* @param db Database to operate on.
* @param lastUpdatedTimeMillis the last time at which an update to the smart dial database was
* run.
*/
private void removeDeletedContacts(SQLiteDatabase db, String lastUpdatedTimeMillis) {
Cursor deletedContactCursor = getDeletedContactCursor(lastUpdatedTimeMillis);
if (deletedContactCursor == null) {
return;
}
db.beginTransaction();
try {
if (!deletedContactCursor.moveToFirst()) {
return;
}
do {
if (deletedContactCursor.isNull(DeleteContactQuery.DELETED_CONTACT_ID)) {
LogUtil.i(
"DialerDatabaseHelper.removeDeletedContacts",
"contact_id column null. Row was deleted during iteration, skipping");
continue;
}
long deleteContactId = deletedContactCursor.getLong(DeleteContactQuery.DELETED_CONTACT_ID);
Selection smartDialSelection =
Selection.column(SmartDialDbColumns.CONTACT_ID).is("=", deleteContactId);
db.delete(
Tables.SMARTDIAL_TABLE,
smartDialSelection.getSelection(),
smartDialSelection.getSelectionArgs());
Selection prefixSelection =
Selection.column(PrefixColumns.CONTACT_ID).is("=", deleteContactId);
db.delete(
Tables.PREFIX_TABLE,
prefixSelection.getSelection(),
prefixSelection.getSelectionArgs());
} while (deletedContactCursor.moveToNext());
db.setTransactionSuccessful();
} finally {
deletedContactCursor.close();
db.endTransaction();
}
}
private Cursor getDeletedContactCursor(String lastUpdateMillis) {
return context
.getContentResolver()
.query(
DeleteContactQuery.URI,
DeleteContactQuery.PROJECTION,
DeleteContactQuery.SELECT_UPDATED_CLAUSE,
new String[] {lastUpdateMillis},
null);
}
/**
* Removes potentially corrupted entries in the database. These contacts may be added before the
* previous instance of the dialer was destroyed for some reason. For data integrity, we delete
* all of them.
*
* @param db Database pointer to the dialer database.
* @param last_update_time Time stamp of last successful update of the dialer database.
*/
private void removePotentiallyCorruptedContacts(SQLiteDatabase db, String last_update_time) {
db.delete(
Tables.PREFIX_TABLE,
PrefixColumns.CONTACT_ID
+ " IN "
+ "(SELECT "
+ SmartDialDbColumns.CONTACT_ID
+ " FROM "
+ Tables.SMARTDIAL_TABLE
+ " WHERE "
+ SmartDialDbColumns.LAST_SMARTDIAL_UPDATE_TIME
+ " > "
+ last_update_time
+ ")",
null);
db.delete(
Tables.SMARTDIAL_TABLE,
SmartDialDbColumns.LAST_SMARTDIAL_UPDATE_TIME + " > " + last_update_time,
null);
}
/**
* Removes rows in the smartdial database that matches updated contacts.
*
* @param db Database pointer to the smartdial database
* @param updatedContactCursor Cursor pointing to the list of recently updated contacts.
*/
@VisibleForTesting
void removeUpdatedContacts(SQLiteDatabase db, Cursor updatedContactCursor) {
db.beginTransaction();
try {
updatedContactCursor.moveToPosition(-1);
while (updatedContactCursor.moveToNext()) {
if (updatedContactCursor.isNull(UpdatedContactQuery.UPDATED_CONTACT_ID)) {
LogUtil.i(
"DialerDatabaseHelper.removeUpdatedContacts",
"contact_id column null. Row was deleted during iteration, skipping");
continue;
}
final Long contactId = updatedContactCursor.getLong(UpdatedContactQuery.UPDATED_CONTACT_ID);
db.delete(Tables.SMARTDIAL_TABLE, SmartDialDbColumns.CONTACT_ID + "=" + contactId, null);
db.delete(Tables.PREFIX_TABLE, PrefixColumns.CONTACT_ID + "=" + contactId, null);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
/**
* Inserts updated contacts as rows to the smartdial table.
*
* @param db Database pointer to the smartdial database.
* @param updatedContactCursor Cursor pointing to the list of recently updated contacts.
* @param currentMillis Current time to be recorded in the smartdial table as update timestamp.
*/
@VisibleForTesting
protected void insertUpdatedContactsAndNumberPrefix(
SQLiteDatabase db, Cursor updatedContactCursor, Long currentMillis) {
db.beginTransaction();
try {
final String sqlInsert =
"INSERT INTO "
+ Tables.SMARTDIAL_TABLE
+ " ("
+ SmartDialDbColumns.DATA_ID
+ ", "
+ SmartDialDbColumns.NUMBER
+ ", "
+ SmartDialDbColumns.CONTACT_ID
+ ", "
+ SmartDialDbColumns.LOOKUP_KEY
+ ", "
+ SmartDialDbColumns.DISPLAY_NAME_PRIMARY
+ ", "
+ SmartDialDbColumns.PHOTO_ID
+ ", "
+ SmartDialDbColumns.LAST_TIME_USED
+ ", "
+ SmartDialDbColumns.TIMES_USED
+ ", "
+ SmartDialDbColumns.STARRED
+ ", "
+ SmartDialDbColumns.IS_SUPER_PRIMARY
+ ", "
+ SmartDialDbColumns.IN_VISIBLE_GROUP
+ ", "
+ SmartDialDbColumns.IS_PRIMARY
+ ", "
+ SmartDialDbColumns.CARRIER_PRESENCE
+ ", "
+ SmartDialDbColumns.LAST_SMARTDIAL_UPDATE_TIME
+ ") "
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
final SQLiteStatement insert = db.compileStatement(sqlInsert);
final String numberSqlInsert =
"INSERT INTO "
+ Tables.PREFIX_TABLE
+ " ("
+ PrefixColumns.CONTACT_ID
+ ", "
+ PrefixColumns.PREFIX
+ ") "
+ " VALUES (?, ?)";
final SQLiteStatement numberInsert = db.compileStatement(numberSqlInsert);
updatedContactCursor.moveToPosition(-1);
while (updatedContactCursor.moveToNext()) {
insert.clearBindings();
if (updatedContactCursor.isNull(PhoneQuery.PHONE_ID)) {
LogUtil.i(
"DialerDatabaseHelper.insertUpdatedContactsAndNumberPrefix",
"_id column null. Row was deleted during iteration, skipping");
continue;
}
// Handle string columns which can possibly be null first. In the case of certain
// null columns (due to malformed rows possibly inserted by third-party apps
// or sync adapters), skip the phone number row.
final String number = updatedContactCursor.getString(PhoneQuery.PHONE_NUMBER);
if (TextUtils.isEmpty(number)) {
continue;
} else {
insert.bindString(2, number);
}
final String lookupKey = updatedContactCursor.getString(PhoneQuery.PHONE_LOOKUP_KEY);
if (TextUtils.isEmpty(lookupKey)) {
continue;
} else {
insert.bindString(4, lookupKey);
}
final String displayName = updatedContactCursor.getString(PhoneQuery.PHONE_DISPLAY_NAME);
if (displayName == null) {
insert.bindString(5, context.getResources().getString(R.string.missing_name));
} else {
insert.bindString(5, displayName);
}
insert.bindLong(1, updatedContactCursor.getLong(PhoneQuery.PHONE_ID));
insert.bindLong(3, updatedContactCursor.getLong(PhoneQuery.PHONE_CONTACT_ID));
insert.bindLong(6, updatedContactCursor.getLong(PhoneQuery.PHONE_PHOTO_ID));
insert.bindLong(7, updatedContactCursor.getLong(PhoneQuery.PHONE_LAST_TIME_USED));
insert.bindLong(8, updatedContactCursor.getInt(PhoneQuery.PHONE_TIMES_USED));
insert.bindLong(9, updatedContactCursor.getInt(PhoneQuery.PHONE_STARRED));
insert.bindLong(10, updatedContactCursor.getInt(PhoneQuery.PHONE_IS_SUPER_PRIMARY));
insert.bindLong(11, updatedContactCursor.getInt(PhoneQuery.PHONE_IN_VISIBLE_GROUP));
insert.bindLong(12, updatedContactCursor.getInt(PhoneQuery.PHONE_IS_PRIMARY));
insert.bindLong(13, updatedContactCursor.getInt(PhoneQuery.PHONE_CARRIER_PRESENCE));
insert.bindLong(14, currentMillis);
insert.executeInsert();
final String contactPhoneNumber = updatedContactCursor.getString(PhoneQuery.PHONE_NUMBER);
final ArrayList<String> numberPrefixes =
SmartDialPrefix.parseToNumberTokens(context, contactPhoneNumber);
for (String numberPrefix : numberPrefixes) {
numberInsert.bindLong(1, updatedContactCursor.getLong(PhoneQuery.PHONE_CONTACT_ID));
numberInsert.bindString(2, numberPrefix);
numberInsert.executeInsert();
numberInsert.clearBindings();
}
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
/**
* Inserts prefixes of contact names to the prefix table.
*
* @param db Database pointer to the smartdial database.
* @param nameCursor Cursor pointing to the list of distinct updated contacts.
*/
@VisibleForTesting
void insertNamePrefixes(SQLiteDatabase db, Cursor nameCursor) {
final int columnIndexName = nameCursor.getColumnIndex(SmartDialDbColumns.DISPLAY_NAME_PRIMARY);
final int columnIndexContactId = nameCursor.getColumnIndex(SmartDialDbColumns.CONTACT_ID);
db.beginTransaction();
try {
final String sqlInsert =
"INSERT INTO "
+ Tables.PREFIX_TABLE
+ " ("
+ PrefixColumns.CONTACT_ID
+ ", "
+ PrefixColumns.PREFIX
+ ") "
+ " VALUES (?, ?)";
final SQLiteStatement insert = db.compileStatement(sqlInsert);
while (nameCursor.moveToNext()) {
if (nameCursor.isNull(columnIndexContactId)) {
LogUtil.i(
"DialerDatabaseHelper.insertNamePrefixes",
"contact_id column null. Row was deleted during iteration, skipping");
continue;
}
/** Computes a list of prefixes of a given contact name. */
final ArrayList<String> namePrefixes =
SmartDialPrefix.generateNamePrefixes(context, nameCursor.getString(columnIndexName));
for (String namePrefix : namePrefixes) {
insert.bindLong(1, nameCursor.getLong(columnIndexContactId));
insert.bindString(2, namePrefix);
insert.executeInsert();
insert.clearBindings();
}
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
/**
* Updates the smart dial and prefix database. This method queries the Delta API to get changed
* contacts since last update, and updates the records in smartdial database and prefix database
* accordingly. It also queries the deleted contact database to remove newly deleted contacts
* since last update.
*
* @param forceUpdate If set to true, update the database by reloading all contacts.
*/
@WorkerThread
public void updateSmartDialDatabase(boolean forceUpdate) {
LogUtil.enterBlock("DialerDatabaseHelper.updateSmartDialDatabase");
final SQLiteDatabase db = getWritableDatabase();
LogUtil.v("DialerDatabaseHelper.updateSmartDialDatabase", "starting to update database");
final StopWatch stopWatch = DEBUG ? StopWatch.start("Updating databases") : null;
/** Gets the last update time on the database. */
final SharedPreferences databaseLastUpdateSharedPref =
context.getSharedPreferences(DATABASE_LAST_CREATED_SHARED_PREF, Context.MODE_PRIVATE);
long defaultLastUpdateMillis =
ConfigProviderComponent.get(context)
.getConfigProvider()
.getLong(DEFAULT_LAST_UPDATED_CONFIG_KEY, 0);
long sharedPrefLastUpdateMillis =
databaseLastUpdateSharedPref.getLong(LAST_UPDATED_MILLIS, defaultLastUpdateMillis);
final String lastUpdateMillis = String.valueOf(forceUpdate ? 0 : sharedPrefLastUpdateMillis);
LogUtil.i(
"DialerDatabaseHelper.updateSmartDialDatabase", "last updated at %s", lastUpdateMillis);
/** Sets the time after querying the database as the current update time. */
final Long currentMillis = System.currentTimeMillis();
if (DEBUG) {
stopWatch.lap("Queried the Contacts database");
}
/** Removes contacts that have been deleted. */
removeDeletedContacts(db, lastUpdateMillis);
removePotentiallyCorruptedContacts(db, lastUpdateMillis);
if (DEBUG) {
stopWatch.lap("Finished deleting deleted entries");
}
/**
* If the database did not exist before, jump through deletion as there is nothing to delete.
*/
if (!lastUpdateMillis.equals("0")) {
/**
* Removes contacts that have been updated. Updated contact information will be inserted
* later. Note that this has to use a separate result set from updatePhoneCursor, since it is
* possible for a contact to be updated (e.g. phone number deleted), but have no results show
* up in updatedPhoneCursor (since all of its phone numbers have been deleted).
*/
final Cursor updatedContactCursor =
context
.getContentResolver()
.query(
UpdatedContactQuery.URI,
UpdatedContactQuery.PROJECTION,
UpdatedContactQuery.SELECT_UPDATED_CLAUSE,
new String[] {lastUpdateMillis},
null);
if (updatedContactCursor == null) {
LogUtil.e(
"DialerDatabaseHelper.updateSmartDialDatabase",
"smartDial query received null for cursor");
return;
}
try {
removeUpdatedContacts(db, updatedContactCursor);
} finally {
updatedContactCursor.close();
}
if (DEBUG) {
stopWatch.lap("Finished deleting entries belonging to updated contacts");
}
}
/**
* Queries the contact database to get all phone numbers that have been updated since the last
* update time.
*/
final Cursor updatedPhoneCursor =
context
.getContentResolver()
.query(
PhoneQuery.URI,
PhoneQuery.PROJECTION,
PhoneQuery.SELECTION,
new String[] {lastUpdateMillis},
null);
if (updatedPhoneCursor == null) {
LogUtil.e(
"DialerDatabaseHelper.updateSmartDialDatabase",
"smartDial query received null for cursor");
return;
}
try {
/** Inserts recently updated phone numbers to the smartdial database. */
insertUpdatedContactsAndNumberPrefix(db, updatedPhoneCursor, currentMillis);
if (DEBUG) {
stopWatch.lap("Finished building the smart dial table");
}
} finally {
updatedPhoneCursor.close();
}
/**
* Gets a list of distinct contacts which have been updated, and adds the name prefixes of these
* contacts to the prefix table.
*/
final Cursor nameCursor =
db.rawQuery(
"SELECT DISTINCT "
+ SmartDialDbColumns.DISPLAY_NAME_PRIMARY
+ ", "
+ SmartDialDbColumns.CONTACT_ID
+ " FROM "
+ Tables.SMARTDIAL_TABLE
+ " WHERE "
+ SmartDialDbColumns.LAST_SMARTDIAL_UPDATE_TIME
+ " = "
+ currentMillis,
new String[] {});
if (nameCursor != null) {
try {
if (DEBUG) {
stopWatch.lap("Queried the smart dial table for contact names");
}
/** Inserts prefixes of names into the prefix table. */
insertNamePrefixes(db, nameCursor);
if (DEBUG) {
stopWatch.lap("Finished building the name prefix table");
}
} finally {
nameCursor.close();
}
}
/** Creates index on contact_id for fast JOIN operation. */
db.execSQL(
"CREATE INDEX IF NOT EXISTS smartdial_contact_id_index ON "
+ Tables.SMARTDIAL_TABLE
+ " ("
+ SmartDialDbColumns.CONTACT_ID
+ ");");
/** Creates index on last_smartdial_update_time for fast SELECT operation. */
db.execSQL(
"CREATE INDEX IF NOT EXISTS smartdial_last_update_index ON "
+ Tables.SMARTDIAL_TABLE
+ " ("
+ SmartDialDbColumns.LAST_SMARTDIAL_UPDATE_TIME
+ ");");
/** Creates index on sorting fields for fast sort operation. */
db.execSQL(
"CREATE INDEX IF NOT EXISTS smartdial_sort_index ON "
+ Tables.SMARTDIAL_TABLE
+ " ("
+ SmartDialDbColumns.STARRED
+ ", "
+ SmartDialDbColumns.IS_SUPER_PRIMARY
+ ", "
+ SmartDialDbColumns.LAST_TIME_USED
+ ", "
+ SmartDialDbColumns.TIMES_USED
+ ", "
+ SmartDialDbColumns.IN_VISIBLE_GROUP
+ ", "
+ SmartDialDbColumns.DISPLAY_NAME_PRIMARY
+ ", "
+ SmartDialDbColumns.CONTACT_ID
+ ", "
+ SmartDialDbColumns.IS_PRIMARY
+ ");");
/** Creates index on prefix for fast SELECT operation. */
db.execSQL(
"CREATE INDEX IF NOT EXISTS nameprefix_index ON "
+ Tables.PREFIX_TABLE
+ " ("
+ PrefixColumns.PREFIX
+ ");");
/** Creates index on contact_id for fast JOIN operation. */
db.execSQL(
"CREATE INDEX IF NOT EXISTS nameprefix_contact_id_index ON "
+ Tables.PREFIX_TABLE
+ " ("
+ PrefixColumns.CONTACT_ID
+ ");");
if (DEBUG) {
stopWatch.lap(TAG + "Finished recreating index");
}
/** Updates the database index statistics. */
db.execSQL("ANALYZE " + Tables.SMARTDIAL_TABLE);
db.execSQL("ANALYZE " + Tables.PREFIX_TABLE);
db.execSQL("ANALYZE smartdial_contact_id_index");
db.execSQL("ANALYZE smartdial_last_update_index");
db.execSQL("ANALYZE nameprefix_index");
db.execSQL("ANALYZE nameprefix_contact_id_index");
if (DEBUG) {
stopWatch.stopAndLog(TAG + "Finished updating index stats", 0);
}
final SharedPreferences.Editor editor = databaseLastUpdateSharedPref.edit();
editor.putLong(LAST_UPDATED_MILLIS, currentMillis);
editor.apply();
LogUtil.i("DialerDatabaseHelper.updateSmartDialDatabase", "broadcasting smart dial update");
// Notify content observers that smart dial database has been updated.
Intent intent = new Intent(ACTION_SMART_DIAL_UPDATED);
intent.setPackage(context.getPackageName());
context.sendBroadcast(intent);
}
/**
* Returns a list of candidate contacts where the query is a prefix of the dialpad index of the
* contact's name or phone number.
*
* @param query The prefix of a contact's dialpad index.
* @return A list of top candidate contacts that will be suggested to user to match their input.
*/
@WorkerThread
public synchronized ArrayList<ContactNumber> getLooseMatches(
String query, SmartDialNameMatcher nameMatcher) {
final SQLiteDatabase db = getReadableDatabase();
/** Uses SQL query wildcard '%' to represent prefix matching. */
final String looseQuery = query + "%";
final ArrayList<ContactNumber> result = new ArrayList<>();
final StopWatch stopWatch = DEBUG ? StopWatch.start(":Name Prefix query") : null;
final String currentTimeStamp = Long.toString(System.currentTimeMillis());
/** Queries the database to find contacts that have an index matching the query prefix. */
final Cursor cursor =
db.rawQuery(
"SELECT "
+ SmartDialDbColumns.DATA_ID
+ ", "
+ SmartDialDbColumns.DISPLAY_NAME_PRIMARY
+ ", "
+ SmartDialDbColumns.PHOTO_ID
+ ", "
+ SmartDialDbColumns.NUMBER
+ ", "
+ SmartDialDbColumns.CONTACT_ID
+ ", "
+ SmartDialDbColumns.LOOKUP_KEY
+ ", "
+ SmartDialDbColumns.CARRIER_PRESENCE
+ " FROM "
+ Tables.SMARTDIAL_TABLE
+ " WHERE "
+ SmartDialDbColumns.CONTACT_ID
+ " IN "
+ " (SELECT "
+ PrefixColumns.CONTACT_ID
+ " FROM "
+ Tables.PREFIX_TABLE
+ " WHERE "
+ Tables.PREFIX_TABLE
+ "."
+ PrefixColumns.PREFIX
+ " LIKE '"
+ looseQuery
+ "')"
+ " ORDER BY "
+ SmartDialSortingOrder.SORT_ORDER,
new String[] {currentTimeStamp});
if (cursor == null) {
return result;
}
try {
if (DEBUG) {
stopWatch.lap("Prefix query completed");
}
/** Gets the column ID from the cursor. */
final int columnDataId = 0;
final int columnDisplayNamePrimary = 1;
final int columnPhotoId = 2;
final int columnNumber = 3;
final int columnId = 4;
final int columnLookupKey = 5;
final int columnCarrierPresence = 6;
if (DEBUG) {
stopWatch.lap("Found column IDs");
}
final Set<ContactMatch> duplicates = new HashSet<>();
int counter = 0;
if (DEBUG) {
stopWatch.lap("Moved cursor to start");
}
/** Iterates the cursor to find top contact suggestions without duplication. */
while ((cursor.moveToNext()) && (counter < MAX_ENTRIES)) {
if (cursor.isNull(columnDataId)) {
LogUtil.i(
"DialerDatabaseHelper.getLooseMatches",
"_id column null. Row was deleted during iteration, skipping");
continue;
}
final long dataID = cursor.getLong(columnDataId);
final String displayName = cursor.getString(columnDisplayNamePrimary);
final String phoneNumber = cursor.getString(columnNumber);
final long id = cursor.getLong(columnId);
final long photoId = cursor.getLong(columnPhotoId);
final String lookupKey = cursor.getString(columnLookupKey);
final int carrierPresence = cursor.getInt(columnCarrierPresence);
/**
* If a contact already exists and another phone number of the contact is being processed,
* skip the second instance.
*/
final ContactMatch contactMatch = new ContactMatch(lookupKey, id);
if (duplicates.contains(contactMatch)) {
continue;
}
/**
* If the contact has either the name or number that matches the query, add to the result.
*/
final boolean nameMatches = nameMatcher.matches(context, displayName);
final boolean numberMatches =
(nameMatcher.matchesNumber(context, phoneNumber, query) != null);
if (nameMatches || numberMatches) {
/** If a contact has not been added, add it to the result and the hash set. */
duplicates.add(contactMatch);
result.add(
new ContactNumber(
id, dataID, displayName, phoneNumber, lookupKey, photoId, carrierPresence));
counter++;
if (DEBUG) {
stopWatch.lap("Added one result: Name: " + displayName);
}
}
}
if (DEBUG) {
stopWatch.stopAndLog(TAG + "Finished loading cursor", 0);
}
} finally {
cursor.close();
}
return result;
}
public interface Tables {
/** Saves a list of numbers to be blocked. */
String FILTERED_NUMBER_TABLE = "filtered_numbers_table";
/** Saves the necessary smart dial information of all contacts. */
String SMARTDIAL_TABLE = "smartdial_table";
/** Saves all possible prefixes to refer to a contacts. */
String PREFIX_TABLE = "prefix_table";
/** Saves all archived voicemail information. */
String VOICEMAIL_ARCHIVE_TABLE = "voicemail_archive_table";
/** Database properties for internal use */
String PROPERTIES = "properties";
}
public interface SmartDialDbColumns {
String _ID = "id";
String DATA_ID = "data_id";
String NUMBER = "phone_number";
String CONTACT_ID = "contact_id";
String LOOKUP_KEY = "lookup_key";
String DISPLAY_NAME_PRIMARY = "display_name";
String PHOTO_ID = "photo_id";
String LAST_TIME_USED = "last_time_used";
String TIMES_USED = "times_used";
String STARRED = "starred";
String IS_SUPER_PRIMARY = "is_super_primary";
String IN_VISIBLE_GROUP = "in_visible_group";
String IS_PRIMARY = "is_primary";
String CARRIER_PRESENCE = "carrier_presence";
String LAST_SMARTDIAL_UPDATE_TIME = "last_smartdial_update_time";
}
public interface PrefixColumns extends BaseColumns {
String PREFIX = "prefix";
String CONTACT_ID = "contact_id";
}
public interface PropertiesColumns {
String PROPERTY_KEY = "property_key";
String PROPERTY_VALUE = "property_value";
}
/** Query options for querying the contact database. */
public interface PhoneQuery {
Uri URI =
Phone.CONTENT_URI
.buildUpon()
.appendQueryParameter(
ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT))
.appendQueryParameter(ContactsContract.REMOVE_DUPLICATE_ENTRIES, "true")
.build();
String[] PROJECTION =
new String[] {
Phone._ID, // 0
Phone.TYPE, // 1
Phone.LABEL, // 2
Phone.NUMBER, // 3
Phone.CONTACT_ID, // 4
Phone.LOOKUP_KEY, // 5
Phone.DISPLAY_NAME_PRIMARY, // 6
Phone.PHOTO_ID, // 7
Data.LAST_TIME_USED, // 8
Data.TIMES_USED, // 9
Contacts.STARRED, // 10
Data.IS_SUPER_PRIMARY, // 11
Contacts.IN_VISIBLE_GROUP, // 12
Data.IS_PRIMARY, // 13
Data.CARRIER_PRESENCE, // 14
};
int PHONE_ID = 0;
int PHONE_TYPE = 1;
int PHONE_LABEL = 2;
int PHONE_NUMBER = 3;
int PHONE_CONTACT_ID = 4;
int PHONE_LOOKUP_KEY = 5;
int PHONE_DISPLAY_NAME = 6;
int PHONE_PHOTO_ID = 7;
int PHONE_LAST_TIME_USED = 8;
int PHONE_TIMES_USED = 9;
int PHONE_STARRED = 10;
int PHONE_IS_SUPER_PRIMARY = 11;
int PHONE_IN_VISIBLE_GROUP = 12;
int PHONE_IS_PRIMARY = 13;
int PHONE_CARRIER_PRESENCE = 14;
/** Selects only rows that have been updated after a certain time stamp. */
String SELECT_UPDATED_CLAUSE = Phone.CONTACT_LAST_UPDATED_TIMESTAMP + " > ?";
/**
* Ignores contacts that have an unreasonably long lookup key. These are likely to be the result
* of multiple (> 50) merged raw contacts, and are likely to cause OutOfMemoryExceptions within
* SQLite, or cause memory allocation problems later on when iterating through the cursor set
* (see a bug)
*/
String SELECT_IGNORE_LOOKUP_KEY_TOO_LONG_CLAUSE = "length(" + Phone.LOOKUP_KEY + ") < 1000";
String SELECTION = SELECT_UPDATED_CLAUSE + " AND " + SELECT_IGNORE_LOOKUP_KEY_TOO_LONG_CLAUSE;
}
/**
* Query for all contacts that have been updated since the last time the smart dial database was
* updated.
*/
public interface UpdatedContactQuery {
Uri URI = ContactsContract.Contacts.CONTENT_URI;
String[] PROJECTION =
new String[] {
ContactsContract.Contacts._ID // 0
};
int UPDATED_CONTACT_ID = 0;
String SELECT_UPDATED_CLAUSE =
ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP + " > ?";
}
/** Query options for querying the deleted contact database. */
public interface DeleteContactQuery {
Uri URI = ContactsContract.DeletedContacts.CONTENT_URI;
String[] PROJECTION =
new String[] {
ContactsContract.DeletedContacts.CONTACT_ID, // 0
ContactsContract.DeletedContacts.CONTACT_DELETED_TIMESTAMP, // 1
};
int DELETED_CONTACT_ID = 0;
int DELETED_TIMESTAMP = 1;
/** Selects only rows that have been deleted after a certain time stamp. */
String SELECT_UPDATED_CLAUSE =
ContactsContract.DeletedContacts.CONTACT_DELETED_TIMESTAMP + " > ?";
}
/**
* Gets the sorting order for the smartdial table. This computes a SQL "ORDER BY" argument by
* composing contact status and recent contact details together.
*/
private interface SmartDialSortingOrder {
/** Current contacts - those contacted within the last 3 days (in milliseconds) */
long LAST_TIME_USED_CURRENT_MS = 3L * 24 * 60 * 60 * 1000;
/** Recent contacts - those contacted within the last 30 days (in milliseconds) */
long LAST_TIME_USED_RECENT_MS = 30L * 24 * 60 * 60 * 1000;
/** Time since last contact. */
String TIME_SINCE_LAST_USED_MS =
"( ?1 - " + Tables.SMARTDIAL_TABLE + "." + SmartDialDbColumns.LAST_TIME_USED + ")";
/**
* Contacts that have been used in the past 3 days rank higher than contacts that have been used
* in the past 30 days, which rank higher than contacts that have not been used in recent 30
* days.
*/
String SORT_BY_DATA_USAGE =
"(CASE WHEN "
+ TIME_SINCE_LAST_USED_MS
+ " < "
+ LAST_TIME_USED_CURRENT_MS
+ " THEN 0 "
+ " WHEN "
+ TIME_SINCE_LAST_USED_MS
+ " < "
+ LAST_TIME_USED_RECENT_MS
+ " THEN 1 "
+ " ELSE 2 END)";
/**
* This sort order is similar to that used by the ContactsProvider when returning a list of
* frequently called contacts.
*/
String SORT_ORDER =
Tables.SMARTDIAL_TABLE
+ "."
+ SmartDialDbColumns.STARRED
+ " DESC, "
+ Tables.SMARTDIAL_TABLE
+ "."
+ SmartDialDbColumns.IS_SUPER_PRIMARY
+ " DESC, "
+ SORT_BY_DATA_USAGE
+ ", "
+ Tables.SMARTDIAL_TABLE
+ "."
+ SmartDialDbColumns.TIMES_USED
+ " DESC, "
+ Tables.SMARTDIAL_TABLE
+ "."
+ SmartDialDbColumns.IN_VISIBLE_GROUP
+ " DESC, "
+ Tables.SMARTDIAL_TABLE
+ "."
+ SmartDialDbColumns.DISPLAY_NAME_PRIMARY
+ ", "
+ Tables.SMARTDIAL_TABLE
+ "."
+ SmartDialDbColumns.CONTACT_ID
+ ", "
+ Tables.SMARTDIAL_TABLE
+ "."
+ SmartDialDbColumns.IS_PRIMARY
+ " DESC";
}
/**
* Simple data format for a contact, containing only information needed for showing up in smart
* dial interface.
*/
public static class ContactNumber {
public final long id;
public final long dataId;
public final String displayName;
public final String phoneNumber;
public final String lookupKey;
public final long photoId;
public final int carrierPresence;
public ContactNumber(
long id,
long dataID,
String displayName,
String phoneNumber,
String lookupKey,
long photoId,
int carrierPresence) {
this.dataId = dataID;
this.id = id;
this.displayName = displayName;
this.phoneNumber = phoneNumber;
this.lookupKey = lookupKey;
this.photoId = photoId;
this.carrierPresence = carrierPresence;
}
@Override
public int hashCode() {
return Objects.hash(
id, dataId, displayName, phoneNumber, lookupKey, photoId, carrierPresence);
}
@Override
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (object instanceof ContactNumber) {
final ContactNumber that = (ContactNumber) object;
return Objects.equals(this.id, that.id)
&& Objects.equals(this.dataId, that.dataId)
&& Objects.equals(this.displayName, that.displayName)
&& Objects.equals(this.phoneNumber, that.phoneNumber)
&& Objects.equals(this.lookupKey, that.lookupKey)
&& Objects.equals(this.photoId, that.photoId)
&& Objects.equals(this.carrierPresence, that.carrierPresence);
}
return false;
}
}
/** Data format for finding duplicated contacts. */
private static class ContactMatch {
private final String lookupKey;
private final long id;
public ContactMatch(String lookupKey, long id) {
this.lookupKey = lookupKey;
this.id = id;
}
@Override
public int hashCode() {
return Objects.hash(lookupKey, id);
}
@Override
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (object instanceof ContactMatch) {
final ContactMatch that = (ContactMatch) object;
return Objects.equals(this.lookupKey, that.lookupKey) && Objects.equals(this.id, that.id);
}
return false;
}
}
}