diff options
Diffstat (limited to 'java/com/android/dialer/calllog/database')
9 files changed, 853 insertions, 123 deletions
diff --git a/java/com/android/dialer/calllog/database/AndroidManifest.xml b/java/com/android/dialer/calllog/database/AndroidManifest.xml new file mode 100644 index 000000000..396a6d9a1 --- /dev/null +++ b/java/com/android/dialer/calllog/database/AndroidManifest.xml @@ -0,0 +1,28 @@ +<!-- + ~ Copyright (C) 2017 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 + --> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.android.dialer.calllog.database"> + + <application> + + <provider + android:authorities="com.android.dialer.annotatedcalllog" + android:exported="false" + android:multiprocess="false" + android:name=".AnnotatedCallLogContentProvider"/> + + </application> +</manifest> diff --git a/java/com/android/dialer/calllog/database/AnnotatedCallLog.java b/java/com/android/dialer/calllog/database/AnnotatedCallLog.java deleted file mode 100644 index 7dca44a60..000000000 --- a/java/com/android/dialer/calllog/database/AnnotatedCallLog.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (C) 2017 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.calllog.database; - -import android.content.Context; -import android.database.sqlite.SQLiteDatabase; -import android.support.annotation.WorkerThread; -import com.android.dialer.common.Assert; - -/** Static methods and constants for interacting with the annotated call log table. */ -public final class AnnotatedCallLog { - - private static final String DATABASE_NAME = "annotated_call_log.db"; - - public static final String TABLE_NAME = "AnnotatedCallLog"; - - /** Column names for the annotated call log table. */ - public static final class Columns { - public static final String ID = "_id"; - public static final String TIMESTAMP = "timestamp"; - public static final String CONTACT_NAME = "contact_name"; - } - - private AnnotatedCallLog() {} - - @WorkerThread - public static SQLiteDatabase getWritableDatabase(Context appContext) { - Assert.isWorkerThread(); - - return new AnnotatedCallLogDatabaseHelper(appContext, DATABASE_NAME).getWritableDatabase(); - } - - @WorkerThread - public static SQLiteDatabase getReadableDatabase(Context appContext) { - Assert.isWorkerThread(); - - return new AnnotatedCallLogDatabaseHelper(appContext, DATABASE_NAME).getReadableDatabase(); - } -} diff --git a/java/com/android/dialer/calllog/database/AnnotatedCallLogContentProvider.java b/java/com/android/dialer/calllog/database/AnnotatedCallLogContentProvider.java new file mode 100644 index 000000000..30aa2bff5 --- /dev/null +++ b/java/com/android/dialer/calllog/database/AnnotatedCallLogContentProvider.java @@ -0,0 +1,331 @@ +/* + * Copyright (C) 2017 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.calllog.database; + +import android.annotation.TargetApi; +import android.content.ContentProvider; +import android.content.ContentProviderOperation; +import android.content.ContentProviderResult; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.OperationApplicationException; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteQueryBuilder; +import android.net.Uri; +import android.os.Build; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract; +import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.AnnotatedCallLog; +import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.CoalescedAnnotatedCallLog; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; +import java.util.ArrayList; + +/** {@link ContentProvider} for the annotated call log. */ +public class AnnotatedCallLogContentProvider extends ContentProvider { + + /** + * We sometimes run queries where we potentially pass every ID into a where clause using the + * (?,?,?,...) syntax. The maximum number of host parameters is 999, so that's the maximum size + * this table can be. See https://www.sqlite.org/limits.html for more details. + */ + private static final int MAX_ROWS = 999; + + private static final int ANNOTATED_CALL_LOG_TABLE_CODE = 1; + private static final int ANNOTATED_CALL_LOG_TABLE_ID_CODE = 2; + private static final int COALESCED_ANNOTATED_CALL_LOG_TABLE_CODE = 3; + + private static final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + + static { + uriMatcher.addURI( + AnnotatedCallLogContract.AUTHORITY, AnnotatedCallLog.TABLE, ANNOTATED_CALL_LOG_TABLE_CODE); + uriMatcher.addURI( + AnnotatedCallLogContract.AUTHORITY, + AnnotatedCallLog.TABLE + "/#", + ANNOTATED_CALL_LOG_TABLE_ID_CODE); + uriMatcher.addURI( + AnnotatedCallLogContract.AUTHORITY, + CoalescedAnnotatedCallLog.TABLE, + COALESCED_ANNOTATED_CALL_LOG_TABLE_CODE); + } + + private AnnotatedCallLogDatabaseHelper databaseHelper; + private Coalescer coalescer; + + private final ThreadLocal<Boolean> applyingBatch = new ThreadLocal<>(); + + /** Ensures that only a single notification is generated from {@link #applyBatch(ArrayList)}. */ + private boolean isApplyingBatch() { + return applyingBatch.get() != null && applyingBatch.get(); + } + + @Override + public boolean onCreate() { + databaseHelper = new AnnotatedCallLogDatabaseHelper(getContext(), MAX_ROWS); + coalescer = CallLogDatabaseComponent.get(getContext()).coalescer(); + return true; + } + + @TargetApi(Build.VERSION_CODES.M) // Uses try-with-resources + @Nullable + @Override + public Cursor query( + @NonNull Uri uri, + @Nullable String[] projection, + @Nullable String selection, + @Nullable String[] selectionArgs, + @Nullable String sortOrder) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder(); + queryBuilder.setTables(AnnotatedCallLog.TABLE); + int match = uriMatcher.match(uri); + switch (match) { + case ANNOTATED_CALL_LOG_TABLE_ID_CODE: + queryBuilder.appendWhere(AnnotatedCallLog._ID + "=" + ContentUris.parseId(uri)); + // fall through + case ANNOTATED_CALL_LOG_TABLE_CODE: + Cursor cursor = + queryBuilder.query(db, projection, selection, selectionArgs, null, null, sortOrder); + if (cursor != null) { + cursor.setNotificationUri( + getContext().getContentResolver(), AnnotatedCallLog.CONTENT_URI); + } else { + LogUtil.w("AnnotatedCallLogContentProvider.query", "cursor was null"); + } + return cursor; + case COALESCED_ANNOTATED_CALL_LOG_TABLE_CODE: + Assert.checkArgument(projection == null, "projection not supported for coalesced call log"); + Assert.checkArgument(selection == null, "selection not supported for coalesced call log"); + Assert.checkArgument( + selectionArgs == null, "selection args not supported for coalesced call log"); + Assert.checkArgument(sortOrder == null, "sort order not supported for coalesced call log"); + try (Cursor allAnnotatedCallLogRows = + queryBuilder.query( + db, null, null, null, null, null, AnnotatedCallLog.TIMESTAMP + " DESC")) { + Cursor coalescedRows = coalescer.coalesce(allAnnotatedCallLogRows); + coalescedRows.setNotificationUri( + getContext().getContentResolver(), CoalescedAnnotatedCallLog.CONTENT_URI); + return coalescedRows; + } + default: + throw new IllegalArgumentException("Unknown uri: " + uri); + } + } + + @Nullable + @Override + public String getType(@NonNull Uri uri) { + return AnnotatedCallLog.CONTENT_ITEM_TYPE; + } + + @Nullable + @Override + public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) { + // Javadoc states values is not nullable, even though it is annotated as such (b/38123194)! + Assert.checkArgument(values != null); + + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + int match = uriMatcher.match(uri); + switch (match) { + case ANNOTATED_CALL_LOG_TABLE_CODE: + Assert.checkArgument( + values.get(AnnotatedCallLog._ID) != null, "You must specify an _ID when inserting"); + break; + case ANNOTATED_CALL_LOG_TABLE_ID_CODE: + Long idFromUri = ContentUris.parseId(uri); + Long idFromValues = values.getAsLong(AnnotatedCallLog._ID); + Assert.checkArgument( + idFromValues == null || idFromValues.equals(idFromUri), + "_ID from values %d does not match ID from URI: %s", + idFromValues, + uri); + if (idFromValues == null) { + values.put(AnnotatedCallLog._ID, idFromUri); + } + break; + case COALESCED_ANNOTATED_CALL_LOG_TABLE_CODE: + throw new UnsupportedOperationException("coalesced call log does not support inserting"); + default: + throw new IllegalArgumentException("Unknown uri: " + uri); + } + long id = database.insert(AnnotatedCallLog.TABLE, null, values); + if (id < 0) { + LogUtil.w( + "AnnotatedCallLogContentProvider.insert", + "error inserting row with id: %d", + values.get(AnnotatedCallLog._ID)); + return null; + } + Uri insertedUri = ContentUris.withAppendedId(AnnotatedCallLog.CONTENT_URI, id); + if (!isApplyingBatch()) { + notifyChange(insertedUri); + } + return insertedUri; + } + + @Override + public int delete( + @NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + final int match = uriMatcher.match(uri); + switch (match) { + case ANNOTATED_CALL_LOG_TABLE_CODE: + break; + case ANNOTATED_CALL_LOG_TABLE_ID_CODE: + Assert.checkArgument(selection == null, "Do not specify selection when deleting by ID"); + Assert.checkArgument( + selectionArgs == null, "Do not specify selection args when deleting by ID"); + long id = ContentUris.parseId(uri); + Assert.checkArgument(id != -1, "error parsing id from uri %s", uri); + selection = getSelectionWithId(id); + break; + case COALESCED_ANNOTATED_CALL_LOG_TABLE_CODE: + throw new UnsupportedOperationException("coalesced call log does not support deleting"); + default: + throw new IllegalArgumentException("Unknown uri: " + uri); + } + int rows = database.delete(AnnotatedCallLog.TABLE, selection, selectionArgs); + if (rows > 0) { + if (!isApplyingBatch()) { + notifyChange(uri); + } + } else { + LogUtil.w("AnnotatedCallLogContentProvider.delete", "no rows deleted"); + } + return rows; + } + + @Override + public int update( + @NonNull Uri uri, + @Nullable ContentValues values, + @Nullable String selection, + @Nullable String[] selectionArgs) { + // Javadoc states values is not nullable, even though it is annotated as such (b/38123194)! + Assert.checkArgument(values != null); + + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + int match = uriMatcher.match(uri); + switch (match) { + case ANNOTATED_CALL_LOG_TABLE_CODE: + break; + case ANNOTATED_CALL_LOG_TABLE_ID_CODE: + Assert.checkArgument( + !values.containsKey(AnnotatedCallLog._ID), "Do not specify _ID when updating by ID"); + Assert.checkArgument(selection == null, "Do not specify selection when updating by ID"); + Assert.checkArgument( + selectionArgs == null, "Do not specify selection args when updating by ID"); + selection = getSelectionWithId(ContentUris.parseId(uri)); + break; + case COALESCED_ANNOTATED_CALL_LOG_TABLE_CODE: + throw new UnsupportedOperationException("coalesced call log does not support updating"); + default: + throw new IllegalArgumentException("Unknown uri: " + uri); + } + int rows = database.update(AnnotatedCallLog.TABLE, values, selection, selectionArgs); + if (rows > 0) { + if (!isApplyingBatch()) { + notifyChange(uri); + } + } else { + LogUtil.w("AnnotatedCallLogContentProvider.update", "no rows updated"); + } + return rows; + } + + /** + * {@inheritDoc} + * + * <p>Note: When applyBatch is used with the AnnotatedCallLog, only a single notification for the + * content URI is generated, not individual notifications for each affected URI. + */ + @NonNull + @Override + public ContentProviderResult[] applyBatch(@NonNull ArrayList<ContentProviderOperation> operations) + throws OperationApplicationException { + ContentProviderResult[] results = new ContentProviderResult[operations.size()]; + if (operations.isEmpty()) { + return results; + } + + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + try { + applyingBatch.set(true); + database.beginTransaction(); + for (int i = 0; i < operations.size(); i++) { + ContentProviderOperation operation = operations.get(i); + int match = uriMatcher.match(operation.getUri()); + switch (match) { + case ANNOTATED_CALL_LOG_TABLE_CODE: + case ANNOTATED_CALL_LOG_TABLE_ID_CODE: + // These are allowed values, continue. + break; + case COALESCED_ANNOTATED_CALL_LOG_TABLE_CODE: + throw new UnsupportedOperationException( + "coalesced call log does not support applyBatch"); + default: + throw new IllegalArgumentException("Unknown uri: " + operation.getUri()); + } + ContentProviderResult result = operation.apply(this, results, i); + if (operations.get(i).isInsert()) { + if (result.uri == null) { + throw new OperationApplicationException("error inserting row"); + } + } else if (result.count == 0) { + /* + * The batches built by MutationApplier happen to contain operations in order of: + * + * 1. Inserts + * 2. Updates + * 3. Deletes + * + * Let's say the last row in the table is row Z, and MutationApplier wishes to update it, + * as well as insert row A. When row A gets inserted, row Z will be deleted via the + * trigger if the table is full. Then later, when we try to process the update for row Z, + * it won't exist. + */ + LogUtil.w( + "AnnotatedCallLogContentProvider.applyBatch", + "update or delete failed, possibly because row got cleaned up"); + } + results[i] = result; + } + database.setTransactionSuccessful(); + } finally { + applyingBatch.set(false); + database.endTransaction(); + } + notifyChange(AnnotatedCallLog.CONTENT_URI); + return results; + } + + private String getSelectionWithId(long id) { + return AnnotatedCallLog._ID + "=" + id; + } + + private void notifyChange(Uri uri) { + getContext().getContentResolver().notifyChange(uri, null); + // Any time the annotated call log changes, we need to also notify observers of the + // CoalescedAnnotatedCallLog, since that is just a massaged in-memory view of the real annotated + // call log table. + getContext().getContentResolver().notifyChange(CoalescedAnnotatedCallLog.CONTENT_URI, null); + } +} diff --git a/java/com/android/dialer/calllog/database/AnnotatedCallLogDatabaseHelper.java b/java/com/android/dialer/calllog/database/AnnotatedCallLogDatabaseHelper.java index 7b28e5505..887dfcbb6 100644 --- a/java/com/android/dialer/calllog/database/AnnotatedCallLogDatabaseHelper.java +++ b/java/com/android/dialer/calllog/database/AnnotatedCallLogDatabaseHelper.java @@ -16,36 +16,58 @@ package com.android.dialer.calllog.database; -import static com.android.dialer.calllog.database.AnnotatedCallLog.Columns.CONTACT_NAME; -import static com.android.dialer.calllog.database.AnnotatedCallLog.Columns.ID; -import static com.android.dialer.calllog.database.AnnotatedCallLog.Columns.TIMESTAMP; - import android.content.Context; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; +import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.AnnotatedCallLog; import com.android.dialer.common.LogUtil; +import java.util.Locale; /** {@link SQLiteOpenHelper} for the AnnotatedCallLog database. */ class AnnotatedCallLogDatabaseHelper extends SQLiteOpenHelper { + private final int maxRows; - AnnotatedCallLogDatabaseHelper(Context appContext, String databaseName) { - super(appContext, databaseName, null, 1); + AnnotatedCallLogDatabaseHelper(Context appContext, int maxRows) { + super(appContext, "annotated_call_log.db", null, 1); + this.maxRows = maxRows; } - private static final String CREATE_SQL = + private static final String CREATE_TABLE_SQL = new StringBuilder() - .append("create table if not exists " + AnnotatedCallLog.TABLE_NAME + " (") - .append(ID + " integer primary key, ") - .append(TIMESTAMP + " integer, ") - .append(CONTACT_NAME + " string") + .append("create table if not exists " + AnnotatedCallLog.TABLE + " (") + .append(AnnotatedCallLog._ID + " integer primary key, ") + .append(AnnotatedCallLog.TIMESTAMP + " integer, ") + .append(AnnotatedCallLog.CONTACT_NAME + " string, ") + .append(AnnotatedCallLog.NUMBER + " blob") .append(");") .toString(); + /** Deletes all but the first maxRows rows (by timestamp) to keep the table a manageable size. */ + private static final String CREATE_TRIGGER_SQL = + "create trigger delete_old_rows after insert on " + + AnnotatedCallLog.TABLE + + " when (select count(*) from " + + AnnotatedCallLog.TABLE + + ") > %d" + + " begin delete from " + + AnnotatedCallLog.TABLE + + " where " + + AnnotatedCallLog._ID + + " in (select " + + AnnotatedCallLog._ID + + " from " + + AnnotatedCallLog.TABLE + + " order by timestamp limit (select count(*)-%d" + + " from " + + AnnotatedCallLog.TABLE + + " )); end;"; + @Override public void onCreate(SQLiteDatabase db) { LogUtil.enterBlock("AnnotatedCallLogDatabaseHelper.onCreate"); long startTime = System.currentTimeMillis(); - db.execSQL(CREATE_SQL); + db.execSQL(CREATE_TABLE_SQL); + db.execSQL(String.format(Locale.US, CREATE_TRIGGER_SQL, maxRows, maxRows)); // TODO: Consider logging impression. LogUtil.i( "AnnotatedCallLogDatabaseHelper.onCreate", diff --git a/java/com/android/dialer/calllog/database/CallLogDatabaseComponent.java b/java/com/android/dialer/calllog/database/CallLogDatabaseComponent.java new file mode 100644 index 000000000..ede46911c --- /dev/null +++ b/java/com/android/dialer/calllog/database/CallLogDatabaseComponent.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2017 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.calllog.database; + +import android.content.Context; +import com.android.dialer.inject.HasRootComponent; +import dagger.Subcomponent; + +/** Dagger component for database package. */ +@Subcomponent +public abstract class CallLogDatabaseComponent { + + public abstract Coalescer coalescer(); + + public abstract MutationApplier mutationApplier(); + + public static CallLogDatabaseComponent get(Context context) { + return ((CallLogDatabaseComponent.HasComponent) + ((HasRootComponent) context.getApplicationContext()).component()) + .callLogDatabaseComponent(); + } + + /** Used to refer to the root application component. */ + public interface HasComponent { + CallLogDatabaseComponent callLogDatabaseComponent(); + } +} diff --git a/java/com/android/dialer/calllog/database/CallLogMutations.java b/java/com/android/dialer/calllog/database/CallLogMutations.java deleted file mode 100644 index ec020c6af..000000000 --- a/java/com/android/dialer/calllog/database/CallLogMutations.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright (C) 2017 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.calllog.database; - -import android.content.ContentValues; -import android.database.sqlite.SQLiteDatabase; -import android.support.annotation.WorkerThread; -import android.util.ArrayMap; -import android.util.ArraySet; -import com.android.dialer.common.Assert; - -/** A collection of mutations to the annotated call log. */ -public final class CallLogMutations { - - private final ArrayMap<Integer, ContentValues> inserts = new ArrayMap<>(); - private final ArrayMap<Integer, ContentValues> updates = new ArrayMap<>(); - private final ArraySet<Integer> deletes = new ArraySet<>(); - - /** @param contentValues an entire row not including the ID */ - public void insert(int id, ContentValues contentValues) { - inserts.put(id, contentValues); - } - - /** @param contentValues the specific columns to update, not including the ID. */ - public void update(int id, ContentValues contentValues) { - // TODO: Consider merging automatically. - updates.put(id, contentValues); - } - - public void delete(int id) { - deletes.add(id); - } - - public boolean isEmpty() { - return inserts.isEmpty() && updates.isEmpty() && deletes.isEmpty(); - } - - @WorkerThread - public void applyToDatabase(SQLiteDatabase writableDatabase) { - Assert.isWorkerThread(); - - // TODO: Implementation. - } -} diff --git a/java/com/android/dialer/calllog/database/Coalescer.java b/java/com/android/dialer/calllog/database/Coalescer.java new file mode 100644 index 000000000..5683687fd --- /dev/null +++ b/java/com/android/dialer/calllog/database/Coalescer.java @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2017 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.calllog.database; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.support.annotation.NonNull; +import android.support.annotation.WorkerThread; +import com.android.dialer.DialerPhoneNumber; +import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.AnnotatedCallLog; +import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.CoalescedAnnotatedCallLog; +import com.android.dialer.calllog.datasources.CallLogDataSource; +import com.android.dialer.calllog.datasources.DataSources; +import com.android.dialer.common.Assert; +import com.android.dialer.phonenumberproto.DialerPhoneNumberUtil; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.protobuf.InvalidProtocolBufferException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import javax.inject.Inject; + +/** + * Coalesces call log rows by combining some adjacent rows. + * + * <p>Applies the business which logic which determines which adjacent rows should be coalasced, and + * then delegates to each data source to determine how individual columns should be aggregated. + */ +public class Coalescer { + + private final DataSources dataSources; + + @Inject + Coalescer(DataSources dataSources) { + this.dataSources = dataSources; + } + + /** + * Reads the entire {@link AnnotatedCallLog} database into memory from the provided {@code + * allAnnotatedCallLog} parameter and then builds and returns a new {@link MatrixCursor} which is + * the result of combining adjacent rows which should be collapsed for display purposes. + * + * @param allAnnotatedCallLogRowsSortedByTimestampDesc all {@link AnnotatedCallLog} rows, sorted + * by timestamp descending + * @return a new {@link MatrixCursor} containing the {@link CoalescedAnnotatedCallLog} rows to + * display + */ + @WorkerThread + @NonNull + Cursor coalesce(@NonNull Cursor allAnnotatedCallLogRowsSortedByTimestampDesc) { + Assert.isWorkerThread(); + + // Note: This method relies on rowsShouldBeCombined to determine which rows should be combined, + // but delegates to data sources to actually aggregate column values. + + DialerPhoneNumberUtil dialerPhoneNumberUtil = + new DialerPhoneNumberUtil(PhoneNumberUtil.getInstance()); + + MatrixCursor allCoalescedRowsMatrixCursor = + new MatrixCursor( + CoalescedAnnotatedCallLog.ALL_COLUMNS, + Assert.isNotNull(allAnnotatedCallLogRowsSortedByTimestampDesc).getCount()); + + if (allAnnotatedCallLogRowsSortedByTimestampDesc.moveToFirst()) { + int coalescedRowId = 0; + + List<ContentValues> currentRowGroup = new ArrayList<>(); + + do { + ContentValues currentRow = + cursorRowToContentValues(allAnnotatedCallLogRowsSortedByTimestampDesc); + + if (currentRowGroup.isEmpty()) { + currentRowGroup.add(currentRow); + continue; + } + + ContentValues previousRow = currentRowGroup.get(currentRowGroup.size() - 1); + + if (!rowsShouldBeCombined(dialerPhoneNumberUtil, previousRow, currentRow)) { + ContentValues coalescedRow = coalesceRowsForAllDataSources(currentRowGroup); + coalescedRow.put(CoalescedAnnotatedCallLog.NUMBER_CALLS, currentRowGroup.size()); + addContentValuesToMatrixCursor( + coalescedRow, allCoalescedRowsMatrixCursor, coalescedRowId++); + currentRowGroup.clear(); + } + currentRowGroup.add(currentRow); + } while (allAnnotatedCallLogRowsSortedByTimestampDesc.moveToNext()); + + // Deal with leftover rows. + ContentValues coalescedRow = coalesceRowsForAllDataSources(currentRowGroup); + coalescedRow.put(CoalescedAnnotatedCallLog.NUMBER_CALLS, currentRowGroup.size()); + addContentValuesToMatrixCursor(coalescedRow, allCoalescedRowsMatrixCursor, coalescedRowId); + } + return allCoalescedRowsMatrixCursor; + } + + private static ContentValues cursorRowToContentValues(Cursor cursor) { + ContentValues values = new ContentValues(); + String[] columns = cursor.getColumnNames(); + int length = columns.length; + for (int i = 0; i < length; i++) { + if (cursor.getType(i) == Cursor.FIELD_TYPE_BLOB) { + values.put(columns[i], cursor.getBlob(i)); + } else { + values.put(columns[i], cursor.getString(i)); + } + } + return values; + } + + /** + * @param row1 a row from {@link AnnotatedCallLog} + * @param row2 a row from {@link AnnotatedCallLog} + */ + private static boolean rowsShouldBeCombined( + DialerPhoneNumberUtil dialerPhoneNumberUtil, ContentValues row1, ContentValues row2) { + // TODO: Real implementation. + DialerPhoneNumber number1; + DialerPhoneNumber number2; + try { + number1 = DialerPhoneNumber.parseFrom(row1.getAsByteArray(AnnotatedCallLog.NUMBER)); + number2 = DialerPhoneNumber.parseFrom(row2.getAsByteArray(AnnotatedCallLog.NUMBER)); + } catch (InvalidProtocolBufferException e) { + throw Assert.createAssertionFailException("error parsing DialerPhoneNumber proto", e); + } + + if (!number1.hasDialerInternalPhoneNumber() && !number2.hasDialerInternalPhoneNumber()) { + // Empty numbers should not be combined. + return false; + } + + if (!number1.hasDialerInternalPhoneNumber() || !number2.hasDialerInternalPhoneNumber()) { + // An empty number should not be combined with a non-empty number. + return false; + } + return dialerPhoneNumberUtil.isExactMatch(number1, number2); + } + + /** + * Delegates to data sources to aggregate individual columns to create a new coalesced row. + * + * @param individualRows {@link AnnotatedCallLog} rows sorted by timestamp descending + * @return a {@link CoalescedAnnotatedCallLog} row + */ + private ContentValues coalesceRowsForAllDataSources(List<ContentValues> individualRows) { + ContentValues coalescedValues = new ContentValues(); + for (CallLogDataSource dataSource : dataSources.getDataSourcesIncludingSystemCallLog()) { + coalescedValues.putAll(dataSource.coalesce(individualRows)); + } + return coalescedValues; + } + + /** + * @param contentValues a {@link CoalescedAnnotatedCallLog} row + * @param matrixCursor represents {@link CoalescedAnnotatedCallLog} + */ + private static void addContentValuesToMatrixCursor( + ContentValues contentValues, MatrixCursor matrixCursor, int rowId) { + MatrixCursor.RowBuilder rowBuilder = matrixCursor.newRow(); + rowBuilder.add(CoalescedAnnotatedCallLog._ID, rowId); + for (Map.Entry<String, Object> entry : contentValues.valueSet()) { + rowBuilder.add(entry.getKey(), entry.getValue()); + } + } +} diff --git a/java/com/android/dialer/calllog/database/MutationApplier.java b/java/com/android/dialer/calllog/database/MutationApplier.java new file mode 100644 index 000000000..21c8a507d --- /dev/null +++ b/java/com/android/dialer/calllog/database/MutationApplier.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2017 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.calllog.database; + +import android.content.ContentProviderOperation; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.OperationApplicationException; +import android.os.RemoteException; +import android.support.annotation.WorkerThread; +import android.text.TextUtils; +import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract; +import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.AnnotatedCallLog; +import com.android.dialer.calllog.datasources.CallLogMutations; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Map.Entry; +import javax.inject.Inject; + +/** Applies {@link CallLogMutations} to the annotated call log. */ +public class MutationApplier { + + @Inject + MutationApplier() {} + + /** Applies the provided {@link CallLogMutations} to the annotated call log. */ + @WorkerThread + public void applyToDatabase(CallLogMutations mutations, Context appContext) + throws RemoteException, OperationApplicationException { + Assert.isWorkerThread(); + + if (mutations.isEmpty()) { + return; + } + + ArrayList<ContentProviderOperation> operations = new ArrayList<>(); + + if (!mutations.getInserts().isEmpty()) { + LogUtil.i( + "CallLogMutations.applyToDatabase", "inserting %d rows", mutations.getInserts().size()); + for (Entry<Long, ContentValues> entry : mutations.getInserts().entrySet()) { + long id = entry.getKey(); + ContentValues contentValues = entry.getValue(); + operations.add( + ContentProviderOperation.newInsert( + ContentUris.withAppendedId(AnnotatedCallLog.CONTENT_URI, id)) + .withValues(contentValues) + .build()); + } + } + + if (!mutations.getUpdates().isEmpty()) { + LogUtil.i( + "CallLogMutations.applyToDatabase", "updating %d rows", mutations.getUpdates().size()); + for (Entry<Long, ContentValues> entry : mutations.getUpdates().entrySet()) { + long id = entry.getKey(); + ContentValues contentValues = entry.getValue(); + operations.add( + ContentProviderOperation.newUpdate( + ContentUris.withAppendedId(AnnotatedCallLog.CONTENT_URI, id)) + .withValues(contentValues) + .build()); + } + } + + if (!mutations.getDeletes().isEmpty()) { + LogUtil.i( + "CallLogMutations.applyToDatabase", "deleting %d rows", mutations.getDeletes().size()); + String[] questionMarks = new String[mutations.getDeletes().size()]; + Arrays.fill(questionMarks, "?"); + + String whereClause = + (AnnotatedCallLog._ID + " in (") + TextUtils.join(",", questionMarks) + ")"; + + String[] whereArgs = new String[mutations.getDeletes().size()]; + int i = 0; + for (long id : mutations.getDeletes()) { + whereArgs[i++] = String.valueOf(id); + } + + operations.add( + ContentProviderOperation.newDelete(AnnotatedCallLog.CONTENT_URI) + .withSelection(whereClause, whereArgs) + .build()); + } + + appContext.getContentResolver().applyBatch(AnnotatedCallLogContract.AUTHORITY, operations); + } +} diff --git a/java/com/android/dialer/calllog/database/contract/AnnotatedCallLogContract.java b/java/com/android/dialer/calllog/database/contract/AnnotatedCallLogContract.java new file mode 100644 index 000000000..7f314e37c --- /dev/null +++ b/java/com/android/dialer/calllog/database/contract/AnnotatedCallLogContract.java @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2017 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.calllog.database.contract; + +import android.net.Uri; +import android.provider.BaseColumns; +import com.android.dialer.constants.Constants; +import java.util.Arrays; + +/** Contract for the AnnotatedCallLog content provider. */ +public class AnnotatedCallLogContract { + public static final String AUTHORITY = Constants.get().getAnnotatedCallLogProviderAuthority(); + + public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY); + + /** + * Columns shared by {@link AnnotatedCallLog} and {@link CoalescedAnnotatedCallLog}. + * + * <p>When adding columns be sure to update {@link #ALL_COMMON_COLUMNS}. + */ + interface CommonColumns extends BaseColumns { + + /** + * Timestamp of the entry, in milliseconds. + * + * <p>Type: INTEGER (long) + */ + String TIMESTAMP = "timestamp"; + + /** + * Name to display for the entry. + * + * <p>Type: TEXT + */ + String CONTACT_NAME = "contact_name"; + + String[] ALL_COMMON_COLUMNS = new String[] {_ID, TIMESTAMP, CONTACT_NAME}; + } + + /** + * AnnotatedCallLog table. + * + * <p>This contains all of the non-coalesced call log entries. + */ + public static final class AnnotatedCallLog implements CommonColumns { + + public static final String TABLE = "AnnotatedCallLog"; + + /** The content URI for this table. */ + public static final Uri CONTENT_URI = + Uri.withAppendedPath(AnnotatedCallLogContract.CONTENT_URI, TABLE); + + /** The MIME type of a {@link android.content.ContentProvider#getType(Uri)} single entry. */ + public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/annotated_call_log"; + + /** + * The phone number called or number the call came from, encoded as a {@link + * com.android.dialer.DialerPhoneNumber} proto. The number may be empty if it was an incoming + * call and the number was unknown. + * + * <p>This column is only present in the annotated call log, and not the coalesced annotated + * call log. The coalesced version uses a formatted number string rather than proto bytes. + * + * <p>Type: BLOB + */ + public static final String NUMBER = "number"; + } + + /** + * Coalesced view of the AnnotatedCallLog table. + * + * <p>This is an in-memory view of the {@link AnnotatedCallLog} with some adjacent entries + * collapsed. + * + * <p>When adding columns be sure to update {@link #COLUMNS_ONLY_IN_COALESCED_CALL_LOG}. + */ + public static final class CoalescedAnnotatedCallLog implements CommonColumns { + + public static final String TABLE = "CoalescedAnnotatedCallLog"; + + /** The content URI for this table. */ + public static final Uri CONTENT_URI = + Uri.withAppendedPath(AnnotatedCallLogContract.CONTENT_URI, TABLE); + + /** The MIME type of a {@link android.content.ContentProvider#getType(Uri)} single entry. */ + public static final String CONTENT_ITEM_TYPE = + "vnd.android.cursor.item/coalesced_annotated_call_log"; + + /** + * Number of AnnotatedCallLog rows represented by this CoalescedAnnotatedCallLog row. + * + * <p>Type: INTEGER + */ + public static final String NUMBER_CALLS = "number_calls"; + + /** + * The phone number formatted in a way suitable for display to the user. This value is generated + * on the fly when the {@link CoalescedAnnotatedCallLog} is generated. + * + * <p>Type: TEXT + */ + public static final String FORMATTED_NUMBER = "formatted_number"; + + /** + * Columns that are only in the {@link CoalescedAnnotatedCallLog} but not the {@link + * AnnotatedCallLog}. + */ + private static final String[] COLUMNS_ONLY_IN_COALESCED_CALL_LOG = + new String[] {NUMBER_CALLS, FORMATTED_NUMBER}; + + /** All columns in the {@link CoalescedAnnotatedCallLog}. */ + public static final String[] ALL_COLUMNS = + concat(ALL_COMMON_COLUMNS, COLUMNS_ONLY_IN_COALESCED_CALL_LOG); + } + + private static String[] concat(String[] first, String[] second) { + String[] result = Arrays.copyOf(first, first.length + second.length); + System.arraycopy(second, 0, result, first.length, second.length); + return result; + } +} |