From 2f1c7586bcce334ca69022eb8dc6d8965ceb6a05 Mon Sep 17 00:00:00 2001 From: Eric Erfanian Date: Mon, 19 Jun 2017 11:26:01 -0700 Subject: Update AOSP Dialer source from internal google3 repository at cl/159428781. Test: make, treehugger This CL updates the AOSP Dialer source with all the changes that have gone into the private google3 repository. This includes all the changes from cl/152373142 (4/06/2017) to cl/159428781 (6/19/2017). This goal of these drops is to keep the AOSP source in sync with the internal google3 repository. Currently these sync are done by hand with very minor modifications to the internal source code. See the Android.mk file for list of modifications. Our current goal is to do frequent drops (daily if possible) and eventually switched to an automated process. Change-Id: Ie60a84b3936efd0ea3d95d7c86bf96d2b1663030 --- .../android/dialer/calllog/CallLogComponent.java | 2 + .../android/dialer/calllog/CallLogFramework.java | 5 +- java/com/android/dialer/calllog/CallLogModule.java | 8 +- java/com/android/dialer/calllog/DataSources.java | 31 -- .../calllog/RefreshAnnotatedCallLogWorker.java | 123 ++++---- .../dialer/calllog/database/AndroidManifest.xml | 28 ++ .../dialer/calllog/database/AnnotatedCallLog.java | 53 ---- .../database/AnnotatedCallLogContentProvider.java | 331 +++++++++++++++++++++ .../database/AnnotatedCallLogDatabaseHelper.java | 46 ++- .../calllog/database/CallLogDatabaseComponent.java | 40 +++ .../dialer/calllog/database/CallLogMutations.java | 58 ---- .../android/dialer/calllog/database/Coalescer.java | 180 +++++++++++ .../dialer/calllog/database/MutationApplier.java | 105 +++++++ .../contract/AnnotatedCallLogContract.java | 135 +++++++++ .../calllog/datasources/CallLogDataSource.java | 67 ++++- .../calllog/datasources/CallLogMutations.java | 110 +++++++ .../dialer/calllog/datasources/DataSources.java | 30 ++ .../datasources/contacts/ContactsDataSource.java | 25 +- .../systemcalllog/SystemCallLogDataSource.java | 251 +++++++++++++++- .../calllog/datasources/util/RowCombiner.java | 53 ++++ .../calllog/testing/FakeCallLogApplication.java | 53 ++++ .../android/dialer/calllog/ui/AndroidManifest.xml | 2 +- .../calllog/ui/AnnotatedCallLogCursorLoader.java | 48 --- .../dialer/calllog/ui/NewCallLogAdapter.java | 53 ++++ .../dialer/calllog/ui/NewCallLogFragment.java | 101 ++++--- .../dialer/calllog/ui/NewCallLogViewHolder.java | 43 +++ .../calllog/ui/res/layout/new_call_log_entry.xml | 16 +- .../ui/res/layout/new_call_log_fragment.xml | 7 +- 28 files changed, 1658 insertions(+), 346 deletions(-) delete mode 100644 java/com/android/dialer/calllog/DataSources.java create mode 100644 java/com/android/dialer/calllog/database/AndroidManifest.xml delete mode 100644 java/com/android/dialer/calllog/database/AnnotatedCallLog.java create mode 100644 java/com/android/dialer/calllog/database/AnnotatedCallLogContentProvider.java create mode 100644 java/com/android/dialer/calllog/database/CallLogDatabaseComponent.java delete mode 100644 java/com/android/dialer/calllog/database/CallLogMutations.java create mode 100644 java/com/android/dialer/calllog/database/Coalescer.java create mode 100644 java/com/android/dialer/calllog/database/MutationApplier.java create mode 100644 java/com/android/dialer/calllog/database/contract/AnnotatedCallLogContract.java create mode 100644 java/com/android/dialer/calllog/datasources/CallLogMutations.java create mode 100644 java/com/android/dialer/calllog/datasources/DataSources.java create mode 100644 java/com/android/dialer/calllog/datasources/util/RowCombiner.java create mode 100644 java/com/android/dialer/calllog/testing/FakeCallLogApplication.java delete mode 100644 java/com/android/dialer/calllog/ui/AnnotatedCallLogCursorLoader.java create mode 100644 java/com/android/dialer/calllog/ui/NewCallLogAdapter.java create mode 100644 java/com/android/dialer/calllog/ui/NewCallLogViewHolder.java (limited to 'java/com/android/dialer/calllog') diff --git a/java/com/android/dialer/calllog/CallLogComponent.java b/java/com/android/dialer/calllog/CallLogComponent.java index 5cdd2b4d0..c7db2a1b8 100644 --- a/java/com/android/dialer/calllog/CallLogComponent.java +++ b/java/com/android/dialer/calllog/CallLogComponent.java @@ -25,6 +25,8 @@ public abstract class CallLogComponent { public abstract CallLogFramework callLogFramework(); + public abstract RefreshAnnotatedCallLogWorker getRefreshAnnotatedCallLogWorker(); + public static CallLogComponent get(Context context) { return ((HasComponent) ((HasRootComponent) context.getApplicationContext()).component()) .callLogComponent(); diff --git a/java/com/android/dialer/calllog/CallLogFramework.java b/java/com/android/dialer/calllog/CallLogFramework.java index 508413b14..d3a2c638e 100644 --- a/java/com/android/dialer/calllog/CallLogFramework.java +++ b/java/com/android/dialer/calllog/CallLogFramework.java @@ -22,9 +22,10 @@ import android.preference.PreferenceManager; import android.support.annotation.MainThread; import android.support.annotation.Nullable; import com.android.dialer.calllog.datasources.CallLogDataSource; +import com.android.dialer.calllog.datasources.DataSources; import com.android.dialer.common.Assert; -import com.android.dialer.common.ConfigProviderBindings; import com.android.dialer.common.LogUtil; +import com.android.dialer.configprovider.ConfigProviderBindings; import javax.inject.Inject; import javax.inject.Singleton; @@ -38,7 +39,6 @@ import javax.inject.Singleton; public final class CallLogFramework implements CallLogDataSource.ContentObserverCallbacks { static final String PREF_FORCE_REBUILD = "callLogFrameworkForceRebuild"; - static final String PREF_LAST_REBUILD_TIMESTAMP_MILLIS = "callLogFrameworkLastRebuild"; private final DataSources dataSources; @@ -58,6 +58,7 @@ public final class CallLogFramework implements CallLogDataSource.ContentObserver LogUtil.enterBlock("CallLogFramework.registerContentObservers"); if (!isNewCallLogEnabled(appContext)) { + LogUtil.i("CallLogFramework.registerContentObservers", "new call log not enabled"); return; } diff --git a/java/com/android/dialer/calllog/CallLogModule.java b/java/com/android/dialer/calllog/CallLogModule.java index d7473a75e..2f2f16d5b 100644 --- a/java/com/android/dialer/calllog/CallLogModule.java +++ b/java/com/android/dialer/calllog/CallLogModule.java @@ -17,11 +17,9 @@ package com.android.dialer.calllog; import com.android.dialer.calllog.datasources.CallLogDataSource; +import com.android.dialer.calllog.datasources.DataSources; import com.android.dialer.calllog.datasources.contacts.ContactsDataSource; import com.android.dialer.calllog.datasources.systemcalllog.SystemCallLogDataSource; -import com.android.dialer.common.concurrent.DefaultDialerExecutorFactory; -import com.android.dialer.common.concurrent.DialerExecutorFactory; -import dagger.Binds; import dagger.Module; import dagger.Provides; import java.util.Arrays; @@ -32,10 +30,6 @@ import java.util.List; @Module public abstract class CallLogModule { - @Binds - abstract DialerExecutorFactory bindDialerExecutorFactory( - DefaultDialerExecutorFactory defaultDialerExecutorFactory); - @Provides static DataSources provideCallLogDataSources( SystemCallLogDataSource systemCallLogDataSource, ContactsDataSource contactsDataSource) { diff --git a/java/com/android/dialer/calllog/DataSources.java b/java/com/android/dialer/calllog/DataSources.java deleted file mode 100644 index 21d190167..000000000 --- a/java/com/android/dialer/calllog/DataSources.java +++ /dev/null @@ -1,31 +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; - -import com.android.dialer.calllog.datasources.CallLogDataSource; -import com.android.dialer.calllog.datasources.systemcalllog.SystemCallLogDataSource; -import java.util.List; - -/** Immutable lists of data sources used to populate the annotated call log. */ -interface DataSources { - - SystemCallLogDataSource getSystemCallLogDataSource(); - - List getDataSourcesIncludingSystemCallLog(); - - List getDataSourcesExcludingSystemCallLog(); -} diff --git a/java/com/android/dialer/calllog/RefreshAnnotatedCallLogWorker.java b/java/com/android/dialer/calllog/RefreshAnnotatedCallLogWorker.java index f9f0c9935..273246649 100644 --- a/java/com/android/dialer/calllog/RefreshAnnotatedCallLogWorker.java +++ b/java/com/android/dialer/calllog/RefreshAnnotatedCallLogWorker.java @@ -18,91 +18,84 @@ package com.android.dialer.calllog; import android.annotation.TargetApi; import android.content.Context; +import android.content.OperationApplicationException; import android.content.SharedPreferences; -import android.database.sqlite.SQLiteDatabase; import android.os.Build; +import android.os.RemoteException; import android.preference.PreferenceManager; import android.support.annotation.WorkerThread; -import com.android.dialer.calllog.database.AnnotatedCallLog; -import com.android.dialer.calllog.database.CallLogMutations; +import com.android.dialer.calllog.database.CallLogDatabaseComponent; import com.android.dialer.calllog.datasources.CallLogDataSource; +import com.android.dialer.calllog.datasources.CallLogMutations; +import com.android.dialer.calllog.datasources.DataSources; import com.android.dialer.common.Assert; import com.android.dialer.common.LogUtil; import com.android.dialer.common.concurrent.DialerExecutor.Worker; +import com.android.dialer.inject.ApplicationContext; import javax.inject.Inject; /** * Worker which brings the annotated call log up to date, if necessary. * - *

Accepts a boolean which indicates if the dirty check should be skipped, and returns true if - * the annotated call log was updated. + *

Accepts a boolean which indicates if the dirty check should be skipped. */ -public class RefreshAnnotatedCallLogWorker implements Worker { +public class RefreshAnnotatedCallLogWorker implements Worker { private final Context appContext; private final DataSources dataSources; @Inject - public RefreshAnnotatedCallLogWorker(Context appContext, DataSources dataSources) { + RefreshAnnotatedCallLogWorker(@ApplicationContext Context appContext, DataSources dataSources) { this.appContext = appContext; this.dataSources = dataSources; } @Override - public Boolean doInBackground(Boolean skipDirtyCheck) { - LogUtil.enterBlock("RefreshAnnotatedCallLogWorker.doInBackgroundFallible"); + public Void doInBackground(Boolean skipDirtyCheck) + throws RemoteException, OperationApplicationException { + LogUtil.enterBlock("RefreshAnnotatedCallLogWorker.doInBackground"); long startTime = System.currentTimeMillis(); - boolean annotatedCallLogUpdated = checkDirtyAndRebuildIfNecessary(appContext, skipDirtyCheck); + checkDirtyAndRebuildIfNecessary(appContext, skipDirtyCheck); LogUtil.i( - "RefreshAnnotatedCallLogWorker.doInBackgroundFallible", - "updated? %s, took %dms", - annotatedCallLogUpdated, + "RefreshAnnotatedCallLogWorker.doInBackground", + "took %dms", System.currentTimeMillis() - startTime); - return annotatedCallLogUpdated; + return null; } @WorkerThread - private boolean checkDirtyAndRebuildIfNecessary(Context appContext, boolean skipDirtyCheck) { + private void checkDirtyAndRebuildIfNecessary(Context appContext, boolean skipDirtyCheck) + throws RemoteException, OperationApplicationException { Assert.isWorkerThread(); long startTime = System.currentTimeMillis(); SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(appContext); - long lastRebuildTimeMillis = - sharedPreferences.getLong(CallLogFramework.PREF_LAST_REBUILD_TIMESTAMP_MILLIS, 0); - if (lastRebuildTimeMillis == 0) { - LogUtil.i( - "RefreshAnnotatedCallLogWorker.checkDirtyAndRebuildIfNecessary", - "annotated call log has never been built, marking it dirty"); - } + // Default to true. If the pref doesn't exist, the annotated call log hasn't been created and + // we just skip isDirty checks and force a rebuild. boolean forceRebuildPrefValue = - sharedPreferences.getBoolean(CallLogFramework.PREF_FORCE_REBUILD, false); + sharedPreferences.getBoolean(CallLogFramework.PREF_FORCE_REBUILD, true); if (forceRebuildPrefValue) { LogUtil.i( "RefreshAnnotatedCallLogWorker.checkDirtyAndRebuildIfNecessary", - "call log has been marked dirty"); + "annotated call log has been marked dirty or does not exist"); } - boolean isDirty = - lastRebuildTimeMillis == 0 - || skipDirtyCheck - || forceRebuildPrefValue - || isDirty(appContext); + boolean isDirty = skipDirtyCheck || forceRebuildPrefValue || isDirty(appContext); + LogUtil.i( "RefreshAnnotatedCallLogWorker.checkDirtyAndRebuildIfNecessary", "isDirty took: %dms", System.currentTimeMillis() - startTime); if (isDirty) { startTime = System.currentTimeMillis(); - rebuild(appContext, lastRebuildTimeMillis); + rebuild(appContext); LogUtil.i( "RefreshAnnotatedCallLogWorker.checkDirtyAndRebuildIfNecessary", "rebuild took: %dms", System.currentTimeMillis() - startTime); - return true; // Annotated call log was updated. } - return false; // Annotated call log was not updated. } @WorkerThread @@ -129,52 +122,58 @@ public class RefreshAnnotatedCallLogWorker implements Worker { @TargetApi(Build.VERSION_CODES.M) // Uses try-with-resources @WorkerThread - private void rebuild(Context appContext, long lastRebuildTimeMillis) { + private void rebuild(Context appContext) throws RemoteException, OperationApplicationException { Assert.isWorkerThread(); - // TODO: Start a transaction? - try (SQLiteDatabase database = AnnotatedCallLog.getWritableDatabase(appContext)) { + CallLogMutations mutations = new CallLogMutations(); - CallLogMutations mutations = new CallLogMutations(); + // System call log data source must go first! + CallLogDataSource systemCallLogDataSource = dataSources.getSystemCallLogDataSource(); + String dataSourceName = getName(systemCallLogDataSource); + LogUtil.i("RefreshAnnotatedCallLogWorker.rebuild", "filling %s", dataSourceName); + long startTime = System.currentTimeMillis(); + systemCallLogDataSource.fill(appContext, mutations); + LogUtil.i( + "RefreshAnnotatedCallLogWorker.rebuild", + "%s.fill took: %dms", + dataSourceName, + System.currentTimeMillis() - startTime); - // System call log data source must go first! - CallLogDataSource systemCallLogDataSource = dataSources.getSystemCallLogDataSource(); - String dataSourceName = getName(systemCallLogDataSource); + for (CallLogDataSource dataSource : dataSources.getDataSourcesExcludingSystemCallLog()) { + dataSourceName = getName(dataSource); LogUtil.i("RefreshAnnotatedCallLogWorker.rebuild", "filling %s", dataSourceName); - long startTime = System.currentTimeMillis(); - systemCallLogDataSource.fill(appContext, database, lastRebuildTimeMillis, mutations); + startTime = System.currentTimeMillis(); + dataSource.fill(appContext, mutations); LogUtil.i( - "RefreshAnnotatedCallLogWorker.rebuild", + "CallLogFramework.rebuild", "%s.fill took: %dms", dataSourceName, System.currentTimeMillis() - startTime); + } + LogUtil.i("RefreshAnnotatedCallLogWorker.rebuild", "applying mutations to database"); + startTime = System.currentTimeMillis(); + CallLogDatabaseComponent.get(appContext) + .mutationApplier() + .applyToDatabase(mutations, appContext); + LogUtil.i( + "RefreshAnnotatedCallLogWorker.rebuild", + "applyToDatabase took: %dms", + System.currentTimeMillis() - startTime); - for (CallLogDataSource dataSource : dataSources.getDataSourcesExcludingSystemCallLog()) { - dataSourceName = getName(dataSource); - LogUtil.i("RefreshAnnotatedCallLogWorker.rebuild", "filling %s", dataSourceName); - startTime = System.currentTimeMillis(); - dataSource.fill(appContext, database, lastRebuildTimeMillis, mutations); - LogUtil.i( - "CallLogFramework.rebuild", - "%s.fill took: %dms", - dataSourceName, - System.currentTimeMillis() - startTime); - } - LogUtil.i("RefreshAnnotatedCallLogWorker.rebuild", "applying mutations to database"); + for (CallLogDataSource dataSource : dataSources.getDataSourcesIncludingSystemCallLog()) { + dataSourceName = getName(dataSource); + LogUtil.i("RefreshAnnotatedCallLogWorker.rebuild", "onSuccessfulFill'ing %s", dataSourceName); startTime = System.currentTimeMillis(); - mutations.applyToDatabase(database); + dataSource.onSuccessfulFill(appContext); LogUtil.i( - "RefreshAnnotatedCallLogWorker.rebuild", - "applyToDatabase took: %dms", + "CallLogFramework.rebuild", + "%s.onSuccessfulFill took: %dms", + dataSourceName, System.currentTimeMillis() - startTime); } SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(appContext); - sharedPreferences - .edit() - .putBoolean(CallLogFramework.PREF_FORCE_REBUILD, false) - .putLong(CallLogFramework.PREF_LAST_REBUILD_TIMESTAMP_MILLIS, System.currentTimeMillis()) - .commit(); + sharedPreferences.edit().putBoolean(CallLogFramework.PREF_FORCE_REBUILD, false).apply(); } private static String getName(CallLogDataSource dataSource) { 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 @@ + + + + + + + + + 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 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} + * + *

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 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 inserts = new ArrayMap<>(); - private final ArrayMap updates = new ArrayMap<>(); - private final ArraySet 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. + * + *

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 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 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 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 operations = new ArrayList<>(); + + if (!mutations.getInserts().isEmpty()) { + LogUtil.i( + "CallLogMutations.applyToDatabase", "inserting %d rows", mutations.getInserts().size()); + for (Entry 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 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}. + * + *

When adding columns be sure to update {@link #ALL_COMMON_COLUMNS}. + */ + interface CommonColumns extends BaseColumns { + + /** + * Timestamp of the entry, in milliseconds. + * + *

Type: INTEGER (long) + */ + String TIMESTAMP = "timestamp"; + + /** + * Name to display for the entry. + * + *

Type: TEXT + */ + String CONTACT_NAME = "contact_name"; + + String[] ALL_COMMON_COLUMNS = new String[] {_ID, TIMESTAMP, CONTACT_NAME}; + } + + /** + * AnnotatedCallLog table. + * + *

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. + * + *

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. + * + *

Type: BLOB + */ + public static final String NUMBER = "number"; + } + + /** + * Coalesced view of the AnnotatedCallLog table. + * + *

This is an in-memory view of the {@link AnnotatedCallLog} with some adjacent entries + * collapsed. + * + *

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. + * + *

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. + * + *

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; + } +} diff --git a/java/com/android/dialer/calllog/datasources/CallLogDataSource.java b/java/com/android/dialer/calllog/datasources/CallLogDataSource.java index 13d0b842d..3fff3ba53 100644 --- a/java/com/android/dialer/calllog/datasources/CallLogDataSource.java +++ b/java/com/android/dialer/calllog/datasources/CallLogDataSource.java @@ -16,13 +16,39 @@ package com.android.dialer.calllog.datasources; +import android.content.ContentValues; import android.content.Context; -import android.database.sqlite.SQLiteDatabase; import android.support.annotation.MainThread; import android.support.annotation.WorkerThread; -import com.android.dialer.calllog.database.CallLogMutations; +import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract; +import java.util.List; -/** A source of data for one or more columns in the annotated call log. */ +/** + * A source of data for one or more columns in the annotated call log. + * + *

Data sources have three lifecycle operations, which are always called on the same thread and + * in the same order for a particular "checkDirtyAndRebuild" cycle. However, not all operations are + * always invoked. + * + *

    + *
  1. {@link #isDirty(Context)}: Invoked only if the framework doesn't yet know if a rebuild is + * necessary. + *
  2. {@link #fill(Context, CallLogMutations)}: Invoked only if the framework determined a + * rebuild is necessary. + *
  3. {@link #onSuccessfulFill(Context)}: Invoked if and only if fill was previously called and + * the mutations provided by the previous fill operation succeeded in being applied. + *
+ * + *

Because {@link #isDirty(Context)} is not always invoked, {@link #fill(Context, + * CallLogMutations)} shouldn't rely on any state saved during {@link #isDirty(Context)}. It + * is safe to assume that {@link #onSuccessfulFill(Context)} refers to the previous fill + * operation. + * + *

The same data source objects may be reused across multiple checkDirtyAndRebuild cycles, so + * implementors should take care to clear any internal state at the start of a new cycle. + * + *

{@link #coalesce(List)} may be called from any worker thread at any time. + */ public interface CallLogDataSource { /** @@ -35,6 +61,8 @@ public interface CallLogDataSource { *

Most implementations of this method will rely on some sort of last modified timestamp. If it * is impossible for a data source to be modified without the dialer application being notified, * this method may immediately return false. + * + * @see CallLogDataSource class doc for complete lifecyle information */ @WorkerThread boolean isDirty(Context appContext); @@ -43,16 +71,39 @@ public interface CallLogDataSource { * Computes the set of mutations necessary to update the annotated call log with respect to this * data source. * + * @see CallLogDataSource class doc for complete lifecyle information * @param mutations the set of mutations which this method should contribute to. Note that it may * contain inserts from the system call log, and these inserts should be modified by each data * source. */ @WorkerThread - void fill( - Context appContext, - SQLiteDatabase readableDatabase, - long lastRebuildTimeMillis, - CallLogMutations mutations); + void fill(Context appContext, CallLogMutations mutations); + + /** + * Called after database mutations have been applied to all data sources. This is useful for + * saving state such as the timestamp of the last row processed in an underlying database. Note + * that all mutations across all data sources are applied in a single transaction. + * + * @see CallLogDataSource class doc for complete lifecyle information + */ + @WorkerThread + void onSuccessfulFill(Context appContext); + + /** + * Combines raw annotated call log rows into a single coalesced row. + * + *

May be called by any worker thread at any time so implementations should take care to be + * threadsafe. (Ideally no state should be required to implement this.) + * + * @param individualRowsSortedByTimestampDesc group of fully populated rows from {@link + * AnnotatedCallLogContract.AnnotatedCallLog} which need to be combined for display purposes. + * This method should not modify this list. + * @return a partial {@link AnnotatedCallLogContract.CoalescedAnnotatedCallLog} row containing + * only columns which this data source is responsible for, which is the result of aggregating + * {@code individualRowsSortedByTimestampDesc}. + */ + @WorkerThread + ContentValues coalesce(List individualRowsSortedByTimestampDesc); @MainThread void registerContentObservers( diff --git a/java/com/android/dialer/calllog/datasources/CallLogMutations.java b/java/com/android/dialer/calllog/datasources/CallLogMutations.java new file mode 100644 index 000000000..148601d68 --- /dev/null +++ b/java/com/android/dialer/calllog/datasources/CallLogMutations.java @@ -0,0 +1,110 @@ +/* + * 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.datasources; + +import android.content.ContentValues; +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 inserts = new ArrayMap<>(); + private final ArrayMap updates = new ArrayMap<>(); + private final ArraySet deletes = new ArraySet<>(); + + /** + * @param contentValues an entire row not including the ID + * @throws IllegalStateException if this {@link CallLogMutations} already contains an insert, + * update, or delete with the provided id + */ + public void insert(long id, ContentValues contentValues) { + Assert.checkArgument(!inserts.containsKey(id), "Can't insert row already scheduled for insert"); + Assert.checkArgument(!updates.containsKey(id), "Can't insert row scheduled for update"); + Assert.checkArgument(!deletes.contains(id), "Can't insert row scheduled for delete"); + + inserts.put(id, contentValues); + } + + /** + * Stores a database update using the provided ID and content values. If this {@link + * CallLogMutations} object already contains an update with the specified ID, the existing content + * values are merged with the provided ones, with the provided ones overwriting the existing ones + * for values with the same key. + * + * @param contentValues the specific columns to update, not including the ID. + * @throws IllegalStateException if this {@link CallLogMutations} already contains an insert or + * delete with the provided id + */ + public void update(long id, ContentValues contentValues) { + Assert.checkArgument(!inserts.containsKey(id), "Can't update row scheduled for insert"); + Assert.checkArgument(!deletes.contains(id), "Can't delete row scheduled for delete"); + + ContentValues existingContentValues = updates.get(id); + if (existingContentValues != null) { + existingContentValues.putAll(contentValues); + } else { + updates.put(id, contentValues); + } + } + + /** + * @throws IllegalStateException if this {@link CallLogMutations} already contains an insert, + * update, or delete with the provided id + */ + public void delete(long id) { + Assert.checkArgument(!inserts.containsKey(id), "Can't delete row scheduled for insert"); + Assert.checkArgument(!updates.containsKey(id), "Can't delete row scheduled for update"); + Assert.checkArgument(!deletes.contains(id), "Can't delete row already scheduled for delete"); + + deletes.add(id); + } + + public boolean isEmpty() { + return inserts.isEmpty() && updates.isEmpty() && deletes.isEmpty(); + } + + /** + * Get the pending inserts. + * + * @return the pending inserts where the key is the annotated call log database ID and the values + * are values to be inserted (not including the ID) + */ + public ArrayMap getInserts() { + return inserts; + } + + /** + * Get the pending updates. + * + * @return the pending updates where the key is the annotated call log database ID and the values + * are values to be updated (not including the ID) + */ + public ArrayMap getUpdates() { + return updates; + } + + /** + * Get the pending deletes. + * + * @return the annotated call log database IDs corresponding to the rows to be deleted + */ + public ArraySet getDeletes() { + return deletes; + } +} diff --git a/java/com/android/dialer/calllog/datasources/DataSources.java b/java/com/android/dialer/calllog/datasources/DataSources.java new file mode 100644 index 000000000..911ca3fa3 --- /dev/null +++ b/java/com/android/dialer/calllog/datasources/DataSources.java @@ -0,0 +1,30 @@ +/* + * 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.datasources; + +import com.android.dialer.calllog.datasources.systemcalllog.SystemCallLogDataSource; +import java.util.List; + +/** Immutable lists of data sources used to populate the annotated call log. */ +public interface DataSources { + + SystemCallLogDataSource getSystemCallLogDataSource(); + + List getDataSourcesIncludingSystemCallLog(); + + List getDataSourcesExcludingSystemCallLog(); +} diff --git a/java/com/android/dialer/calllog/datasources/contacts/ContactsDataSource.java b/java/com/android/dialer/calllog/datasources/contacts/ContactsDataSource.java index 355940f6a..82a85235b 100644 --- a/java/com/android/dialer/calllog/datasources/contacts/ContactsDataSource.java +++ b/java/com/android/dialer/calllog/datasources/contacts/ContactsDataSource.java @@ -16,13 +16,16 @@ package com.android.dialer.calllog.datasources.contacts; +import android.content.ContentValues; import android.content.Context; -import android.database.sqlite.SQLiteDatabase; import android.support.annotation.MainThread; import android.support.annotation.WorkerThread; -import com.android.dialer.calllog.database.CallLogMutations; +import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.AnnotatedCallLog; import com.android.dialer.calllog.datasources.CallLogDataSource; +import com.android.dialer.calllog.datasources.CallLogMutations; +import com.android.dialer.calllog.datasources.util.RowCombiner; import com.android.dialer.common.Assert; +import java.util.List; import javax.inject.Inject; /** Responsible for maintaining the contacts related columns in the annotated call log. */ @@ -44,11 +47,25 @@ public final class ContactsDataSource implements CallLogDataSource { @Override public void fill( Context appContext, - SQLiteDatabase readableDatabase, - long lastRebuildTimeMillis, CallLogMutations mutations) { Assert.isWorkerThread(); // TODO: Implementation. + for (ContentValues contentValues : mutations.getInserts().values()) { + contentValues.put(AnnotatedCallLog.CONTACT_NAME, "Placeholder name"); + } + } + + @Override + public void onSuccessfulFill(Context appContext) { + // TODO: Implementation. + } + + @Override + public ContentValues coalesce(List individualRowsSortedByTimestampDesc) { + // TODO: Implementation. + return new RowCombiner(individualRowsSortedByTimestampDesc) + .useSingleValueString(AnnotatedCallLog.CONTACT_NAME) + .combine(); } @MainThread diff --git a/java/com/android/dialer/calllog/datasources/systemcalllog/SystemCallLogDataSource.java b/java/com/android/dialer/calllog/datasources/systemcalllog/SystemCallLogDataSource.java index ea6663fbe..f2063283f 100644 --- a/java/com/android/dialer/calllog/datasources/systemcalllog/SystemCallLogDataSource.java +++ b/java/com/android/dialer/calllog/datasources/systemcalllog/SystemCallLogDataSource.java @@ -16,28 +16,54 @@ package com.android.dialer.calllog.datasources.systemcalllog; +import android.Manifest.permission; +import android.annotation.TargetApi; +import android.content.ContentValues; import android.content.Context; import android.database.ContentObserver; -import android.database.sqlite.SQLiteDatabase; +import android.database.Cursor; import android.net.Uri; +import android.os.Build; import android.os.Handler; +import android.preference.PreferenceManager; import android.provider.CallLog; +import android.provider.CallLog.Calls; import android.support.annotation.MainThread; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; import android.support.annotation.WorkerThread; -import com.android.dialer.calllog.database.CallLogMutations; +import android.text.TextUtils; +import android.util.ArraySet; +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.CallLogMutations; +import com.android.dialer.calllog.datasources.util.RowCombiner; import com.android.dialer.common.Assert; import com.android.dialer.common.LogUtil; import com.android.dialer.common.concurrent.ThreadUtil; +import com.android.dialer.phonenumberproto.DialerPhoneNumberUtil; import com.android.dialer.util.PermissionsUtil; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.protobuf.InvalidProtocolBufferException; +import java.util.Arrays; +import java.util.List; +import java.util.Set; import javax.inject.Inject; /** * Responsible for defining the rows in the annotated call log and maintaining the columns in it * which are derived from the system call log. */ +@SuppressWarnings("MissingPermission") public class SystemCallLogDataSource implements CallLogDataSource { + @VisibleForTesting + static final String PREF_LAST_TIMESTAMP_PROCESSED = "systemCallLogLastTimestampProcessed"; + + @Nullable private Long lastTimestampProcessed; + @Inject public SystemCallLogDataSource() {} @@ -47,6 +73,8 @@ public class SystemCallLogDataSource implements CallLogDataSource { Context appContext, ContentObserverCallbacks contentObserverCallbacks) { Assert.isMainThread(); + LogUtil.enterBlock("SystemCallLogDataSource.registerContentObservers"); + if (!PermissionsUtil.hasCallLogReadPermissions(appContext)) { LogUtil.i("SystemCallLogDataSource.registerContentObservers", "no call log permissions"); return; @@ -77,17 +105,222 @@ public class SystemCallLogDataSource implements CallLogDataSource { @WorkerThread @Override - public void fill( - Context appContext, - SQLiteDatabase readableDatabase, - long lastRebuildTimeMillis, - CallLogMutations mutations) { + public void fill(Context appContext, CallLogMutations mutations) { Assert.isWorkerThread(); + lastTimestampProcessed = null; + + if (!PermissionsUtil.hasPermission(appContext, permission.READ_CALL_LOG)) { + LogUtil.i("SystemCallLogDataSource.fill", "no call log permissions"); + return; + } + // This data source should always run first so the mutations should always be empty. - Assert.checkState(mutations.isEmpty()); + Assert.checkArgument(mutations.isEmpty()); + + Set annotatedCallLogIds = getAnnotatedCallLogIds(appContext); + + LogUtil.i( + "SystemCallLogDataSource.fill", + "found %d existing annotated call log ids", + annotatedCallLogIds.size()); + + handleInsertsAndUpdates(appContext, mutations, annotatedCallLogIds); + handleDeletes(appContext, annotatedCallLogIds, mutations); + } + + @WorkerThread + @Override + public void onSuccessfulFill(Context appContext) { + // If a fill operation was a no-op, lastTimestampProcessed could still be null. + if (lastTimestampProcessed != null) { + PreferenceManager.getDefaultSharedPreferences(appContext) + .edit() + .putLong(PREF_LAST_TIMESTAMP_PROCESSED, lastTimestampProcessed) + .apply(); + } + } + + @Override + public ContentValues coalesce(List individualRowsSortedByTimestampDesc) { + // TODO: Complete implementation. + ContentValues coalescedValues = + new RowCombiner(individualRowsSortedByTimestampDesc) + .useMostRecentLong(AnnotatedCallLog.TIMESTAMP) + .combine(); + + // All phone numbers in the provided group should be equivalent (but could be formatted + // differently). Arbitrarily show the raw phone number of the most recent call. + DialerPhoneNumber mostRecentPhoneNumber = + getMostRecentPhoneNumber(individualRowsSortedByTimestampDesc); + coalescedValues.put( + CoalescedAnnotatedCallLog.FORMATTED_NUMBER, + mostRecentPhoneNumber.getRawInput().getNumber()); + return coalescedValues; + } + + private static DialerPhoneNumber getMostRecentPhoneNumber( + List individualRowsSortedByTimestampDesc) { + DialerPhoneNumber dialerPhoneNumber; + byte[] protoBytes = + individualRowsSortedByTimestampDesc.get(0).getAsByteArray(AnnotatedCallLog.NUMBER); + try { + dialerPhoneNumber = DialerPhoneNumber.parseFrom(protoBytes); + } catch (InvalidProtocolBufferException e) { + throw Assert.createAssertionFailException("couldn't parse DialerPhoneNumber", e); + } + return dialerPhoneNumber; + } + + @TargetApi(Build.VERSION_CODES.M) // Uses try-with-resources + private void handleInsertsAndUpdates( + Context appContext, CallLogMutations mutations, Set existingAnnotatedCallLogIds) { + long previousTimestampProcessed = + PreferenceManager.getDefaultSharedPreferences(appContext) + .getLong(PREF_LAST_TIMESTAMP_PROCESSED, 0L); + + DialerPhoneNumberUtil dialerPhoneNumberUtil = + new DialerPhoneNumberUtil(PhoneNumberUtil.getInstance()); + + // TODO: Really should be getting last 1000 by timestamp, not by last modified. + try (Cursor cursor = + appContext + .getContentResolver() + .query( + Calls.CONTENT_URI, // Excludes voicemail + new String[] { + Calls._ID, Calls.DATE, Calls.LAST_MODIFIED, Calls.NUMBER, Calls.COUNTRY_ISO + }, + Calls.LAST_MODIFIED + " > ?", + new String[] {String.valueOf(previousTimestampProcessed)}, + Calls.LAST_MODIFIED + " DESC LIMIT 1000")) { - // TODO: Implementation. + if (cursor == null) { + LogUtil.e("SystemCallLogDataSource.handleInsertsAndUpdates", "null cursor"); + return; + } + + LogUtil.i( + "SystemCallLogDataSource.handleInsertsAndUpdates", + "found %d entries to insert/update", + cursor.getCount()); + + if (cursor.moveToFirst()) { + int idColumn = cursor.getColumnIndexOrThrow(Calls._ID); + int dateColumn = cursor.getColumnIndexOrThrow(Calls.DATE); + int lastModifiedColumn = cursor.getColumnIndexOrThrow(Calls.LAST_MODIFIED); + int numberColumn = cursor.getColumnIndexOrThrow(Calls.NUMBER); + int countryIsoColumn = cursor.getColumnIndexOrThrow(Calls.COUNTRY_ISO); + + // The cursor orders by LAST_MODIFIED DESC, so the first result is the most recent timestamp + // processed. + lastTimestampProcessed = cursor.getLong(lastModifiedColumn); + do { + long id = cursor.getLong(idColumn); + long date = cursor.getLong(dateColumn); + String numberAsStr = cursor.getString(numberColumn); + String countryIso = cursor.getString(countryIsoColumn); + + byte[] numberAsProtoBytes = + dialerPhoneNumberUtil.parse(numberAsStr, countryIso).toByteArray(); + + ContentValues contentValues = new ContentValues(); + contentValues.put(AnnotatedCallLog.TIMESTAMP, date); + contentValues.put(AnnotatedCallLog.NUMBER, numberAsProtoBytes); + + if (existingAnnotatedCallLogIds.contains(id)) { + mutations.update(id, contentValues); + } else { + mutations.insert(id, contentValues); + } + } while (cursor.moveToNext()); + } // else no new results, do nothing. + } + } + + private static void handleDeletes( + Context appContext, Set existingAnnotatedCallLogIds, CallLogMutations mutations) { + Set systemCallLogIds = + getIdsFromSystemCallLogThatMatch(appContext, existingAnnotatedCallLogIds); + LogUtil.i( + "SystemCallLogDataSource.handleDeletes", + "found %d matching entries in system call log", + systemCallLogIds.size()); + Set idsInAnnotatedCallLogNoLongerInSystemCallLog = new ArraySet<>(); + idsInAnnotatedCallLogNoLongerInSystemCallLog.addAll(existingAnnotatedCallLogIds); + idsInAnnotatedCallLogNoLongerInSystemCallLog.removeAll(systemCallLogIds); + + LogUtil.i( + "SystemCallLogDataSource.handleDeletes", + "found %d call log entries to remove", + idsInAnnotatedCallLogNoLongerInSystemCallLog.size()); + + for (long id : idsInAnnotatedCallLogNoLongerInSystemCallLog) { + mutations.delete(id); + } + } + + @TargetApi(Build.VERSION_CODES.M) // Uses try-with-resources + private static Set getAnnotatedCallLogIds(Context appContext) { + ArraySet ids = new ArraySet<>(); + + try (Cursor cursor = + appContext + .getContentResolver() + .query( + AnnotatedCallLog.CONTENT_URI, + new String[] {AnnotatedCallLog._ID}, + null, + null, + null)) { + + if (cursor == null) { + LogUtil.e("SystemCallLogDataSource.getAnnotatedCallLogIds", "null cursor"); + return ids; + } + + if (cursor.moveToFirst()) { + int idColumn = cursor.getColumnIndexOrThrow(AnnotatedCallLog._ID); + do { + ids.add(cursor.getLong(idColumn)); + } while (cursor.moveToNext()); + } + } + return ids; + } + + @TargetApi(Build.VERSION_CODES.M) // Uses try-with-resources + private static Set getIdsFromSystemCallLogThatMatch( + Context appContext, Set matchingIds) { + ArraySet ids = new ArraySet<>(); + + String[] questionMarks = new String[matchingIds.size()]; + Arrays.fill(questionMarks, "?"); + String whereClause = (Calls._ID + " in (") + TextUtils.join(",", questionMarks) + ")"; + String[] whereArgs = new String[matchingIds.size()]; + int i = 0; + for (long id : matchingIds) { + whereArgs[i++] = String.valueOf(id); + } + + try (Cursor cursor = + appContext + .getContentResolver() + .query(Calls.CONTENT_URI, new String[] {Calls._ID}, whereClause, whereArgs, null)) { + + if (cursor == null) { + LogUtil.e("SystemCallLogDataSource.getIdsFromSystemCallLog", "null cursor"); + return ids; + } + + if (cursor.moveToFirst()) { + int idColumn = cursor.getColumnIndexOrThrow(Calls._ID); + do { + ids.add(cursor.getLong(idColumn)); + } while (cursor.moveToNext()); + } + return ids; + } } private static class CallLogObserver extends ContentObserver { diff --git a/java/com/android/dialer/calllog/datasources/util/RowCombiner.java b/java/com/android/dialer/calllog/datasources/util/RowCombiner.java new file mode 100644 index 000000000..0c7be1e27 --- /dev/null +++ b/java/com/android/dialer/calllog/datasources/util/RowCombiner.java @@ -0,0 +1,53 @@ +/* + * 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.datasources.util; + +import android.content.ContentValues; +import com.android.dialer.common.Assert; +import java.util.Iterator; +import java.util.List; + +/** Convenience class for aggregating row values. */ +public class RowCombiner { + private final List individualRowsSortedByTimestampDesc; + private final ContentValues combinedRow = new ContentValues(); + + public RowCombiner(List individualRowsSortedByTimestampDesc) { + Assert.checkArgument(!individualRowsSortedByTimestampDesc.isEmpty()); + this.individualRowsSortedByTimestampDesc = individualRowsSortedByTimestampDesc; + } + + /** Use the most recent value for the specified column. */ + public RowCombiner useMostRecentLong(String columnName) { + combinedRow.put(columnName, individualRowsSortedByTimestampDesc.get(0).getAsLong(columnName)); + return this; + } + + /** Asserts that all column values for the given column name are the same, and uses it. */ + public RowCombiner useSingleValueString(String columnName) { + Iterator iterator = individualRowsSortedByTimestampDesc.iterator(); + String singleValue = iterator.next().getAsString(columnName); + while (iterator.hasNext()) { + Assert.checkState(iterator.next().getAsString(columnName).equals(singleValue)); + } + combinedRow.put(columnName, singleValue); + return this; + } + + public ContentValues combine() { + return combinedRow; + } +} diff --git a/java/com/android/dialer/calllog/testing/FakeCallLogApplication.java b/java/com/android/dialer/calllog/testing/FakeCallLogApplication.java new file mode 100644 index 000000000..8aee7dd56 --- /dev/null +++ b/java/com/android/dialer/calllog/testing/FakeCallLogApplication.java @@ -0,0 +1,53 @@ +/* + * 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.testing; + +import android.app.Application; +import com.android.dialer.calllog.CallLogComponent; +import com.android.dialer.calllog.CallLogModule; +import com.android.dialer.calllog.database.CallLogDatabaseComponent; +import com.android.dialer.common.concurrent.DialerExecutorComponent; +import com.android.dialer.common.concurrent.testing.TestDialerExecutorModule; +import com.android.dialer.inject.ContextModule; +import com.android.dialer.inject.HasRootComponent; +import dagger.Component; +import javax.inject.Singleton; + +/** + * Fake application for call log robolectric tests which uses all real bindings but doesn't require + * tests to depend on and use all of DialerApplication. + */ +public final class FakeCallLogApplication extends Application implements HasRootComponent { + private Object rootComponent; + + @Override + public final synchronized Object component() { + if (rootComponent == null) { + rootComponent = + DaggerFakeCallLogApplication_FakeComponent.builder() + .contextModule(new ContextModule(this)) + .build(); + } + return rootComponent; + } + + @Singleton + @Component(modules = {CallLogModule.class, ContextModule.class, TestDialerExecutorModule.class}) + interface FakeComponent + extends CallLogDatabaseComponent.HasComponent, + CallLogComponent.HasComponent, + DialerExecutorComponent.HasComponent {} +} diff --git a/java/com/android/dialer/calllog/ui/AndroidManifest.xml b/java/com/android/dialer/calllog/ui/AndroidManifest.xml index 228167749..eaf71aba8 100644 --- a/java/com/android/dialer/calllog/ui/AndroidManifest.xml +++ b/java/com/android/dialer/calllog/ui/AndroidManifest.xml @@ -13,4 +13,4 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License --> - + diff --git a/java/com/android/dialer/calllog/ui/AnnotatedCallLogCursorLoader.java b/java/com/android/dialer/calllog/ui/AnnotatedCallLogCursorLoader.java deleted file mode 100644 index cd8622e80..000000000 --- a/java/com/android/dialer/calllog/ui/AnnotatedCallLogCursorLoader.java +++ /dev/null @@ -1,48 +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.ui; - -import android.annotation.TargetApi; -import android.content.Context; -import android.content.CursorLoader; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.os.Build; -import com.android.dialer.calllog.database.AnnotatedCallLog; -import com.android.dialer.calllog.database.AnnotatedCallLog.Columns; - -/** CursorLoader which reads the annotated call log. */ -class AnnotatedCallLogCursorLoader extends CursorLoader { - - AnnotatedCallLogCursorLoader(Context context) { - super(context); - } - - @TargetApi(Build.VERSION_CODES.M) // Uses try-with-resources - @Override - public Cursor loadInBackground() { - try (SQLiteDatabase readableDatabase = AnnotatedCallLog.getReadableDatabase(getContext())) { - return readableDatabase.rawQuery( - "SELECT * FROM " - + AnnotatedCallLog.TABLE_NAME - + " ORDER BY " - + Columns.TIMESTAMP - + " DESC", - null /* selectionArgs */); - } - } -} diff --git a/java/com/android/dialer/calllog/ui/NewCallLogAdapter.java b/java/com/android/dialer/calllog/ui/NewCallLogAdapter.java new file mode 100644 index 000000000..f9ab21cb3 --- /dev/null +++ b/java/com/android/dialer/calllog/ui/NewCallLogAdapter.java @@ -0,0 +1,53 @@ +/* + * 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.ui; + +import android.database.Cursor; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.ViewGroup; +import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.CoalescedAnnotatedCallLog; + +/** {@link RecyclerView.Adapter} for the new call log fragment. */ +final class NewCallLogAdapter extends RecyclerView.Adapter { + + private final Cursor cursor; + private final int timestampIndex; + + NewCallLogAdapter(Cursor cursor) { + this.cursor = cursor; + timestampIndex = cursor.getColumnIndexOrThrow(CoalescedAnnotatedCallLog.TIMESTAMP); + } + + @Override + public NewCallLogViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) { + return new NewCallLogViewHolder( + LayoutInflater.from(viewGroup.getContext()) + .inflate(R.layout.new_call_log_entry, viewGroup, false)); + } + + @Override + public void onBindViewHolder(NewCallLogViewHolder viewHolder, int position) { + cursor.moveToPosition(position); + long timestamp = cursor.getLong(timestampIndex); + viewHolder.bind(timestamp); + } + + @Override + public int getItemCount() { + return cursor.getCount(); + } +} diff --git a/java/com/android/dialer/calllog/ui/NewCallLogFragment.java b/java/com/android/dialer/calllog/ui/NewCallLogFragment.java index b8f2b1326..712f7cf25 100644 --- a/java/com/android/dialer/calllog/ui/NewCallLogFragment.java +++ b/java/com/android/dialer/calllog/ui/NewCallLogFragment.java @@ -17,30 +17,38 @@ package com.android.dialer.calllog.ui; import android.app.Fragment; import android.app.LoaderManager.LoaderCallbacks; -import android.content.Context; +import android.content.CursorLoader; import android.content.Loader; import android.database.Cursor; import android.os.Bundle; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.CursorAdapter; -import android.widget.ListView; -import android.widget.SimpleCursorAdapter; -import android.widget.TextView; import com.android.dialer.calllog.CallLogComponent; import com.android.dialer.calllog.CallLogFramework; import com.android.dialer.calllog.CallLogFramework.CallLogUi; -import com.android.dialer.calllog.database.AnnotatedCallLog.Columns; +import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.CoalescedAnnotatedCallLog; import com.android.dialer.common.LogUtil; -import java.text.SimpleDateFormat; -import java.util.Locale; +import com.android.dialer.common.concurrent.DialerExecutor; +import com.android.dialer.common.concurrent.DialerExecutorComponent; +import com.android.dialer.common.concurrent.DialerExecutorFactory; /** The "new" call log fragment implementation, which is built on top of the annotated call log. */ public final class NewCallLogFragment extends Fragment implements CallLogUi, LoaderCallbacks { - private CursorAdapter cursorAdapter; + /* + * This is a reasonable time that it might take between related call log writes, that also + * shouldn't slow down single-writes too much. For example, when populating the database using + * the simulator, using this value results in ~6 refresh cycles (on a release build) to write 120 + * call log entries. + */ + private static final long WAIT_MILLIS = 100L; + + private DialerExecutor refreshAnnotatedCallLogTask; + private RecyclerView recyclerView; public NewCallLogFragment() { LogUtil.enterBlock("NewCallLogFragment.NewCallLogFragment"); @@ -52,8 +60,27 @@ public final class NewCallLogFragment extends Fragment LogUtil.enterBlock("NewCallLogFragment.onCreate"); - CallLogFramework callLogFramework = CallLogComponent.get(getContext()).callLogFramework(); + CallLogComponent component = CallLogComponent.get(getContext()); + CallLogFramework callLogFramework = component.callLogFramework(); callLogFramework.attachUi(this); + + DialerExecutorFactory dialerExecutorFactory = + DialerExecutorComponent.get(getContext()).dialerExecutorFactory(); + + refreshAnnotatedCallLogTask = + dialerExecutorFactory + .createUiTaskBuilder( + getFragmentManager(), + "NewCallLogFragment.refreshAnnotatedCallLog", + component.getRefreshAnnotatedCallLogWorker()) + .build(); + } + + @Override + public void onStart() { + super.onStart(); + + LogUtil.enterBlock("NewCallLogFragment.onStart"); } @Override @@ -64,6 +91,9 @@ public final class NewCallLogFragment extends Fragment CallLogFramework callLogFramework = CallLogComponent.get(getContext()).callLogFramework(); callLogFramework.attachUi(this); + + // TODO: Consider doing this when fragment becomes visible. + checkAnnotatedCallLogDirtyAndRefreshIfNecessary(); } @Override @@ -82,57 +112,44 @@ public final class NewCallLogFragment extends Fragment LogUtil.enterBlock("NewCallLogFragment.onCreateView"); View view = inflater.inflate(R.layout.new_call_log_fragment, container, false); - ListView listView = (ListView) view.findViewById(R.id.list); + recyclerView = view.findViewById(R.id.new_call_log_recycler_view); - this.cursorAdapter = - new MyCursorAdapter( - getContext(), - R.layout.new_call_log_entry, - null /* cursor */, - new String[] {Columns.TIMESTAMP, Columns.CONTACT_NAME}, - new int[] {R.id.timestamp, R.id.contact_name}, - 0); - listView.setAdapter(cursorAdapter); - - getLoaderManager().initLoader(0, null, this); + getLoaderManager().restartLoader(0, null, this); return view; } + private void checkAnnotatedCallLogDirtyAndRefreshIfNecessary() { + LogUtil.enterBlock("NewCallLogFragment.checkAnnotatedCallLogDirtyAndRefreshIfNecessary"); + refreshAnnotatedCallLogTask.executeSerialWithWait(false /* skipDirtyCheck */, WAIT_MILLIS); + } + @Override public void invalidateUi() { LogUtil.enterBlock("NewCallLogFragment.invalidateUi"); - // TODO: Implementation. + refreshAnnotatedCallLogTask.executeSerialWithWait(true /* skipDirtyCheck */, WAIT_MILLIS); } @Override public Loader onCreateLoader(int id, Bundle args) { - // TODO: This is sort of weird, do we need to implement a content provider? - return new AnnotatedCallLogCursorLoader(getContext()); + LogUtil.enterBlock("NewCallLogFragment.onCreateLoader"); + // CoalescedAnnotatedCallLog requires that all params be null. + return new CursorLoader( + getContext(), CoalescedAnnotatedCallLog.CONTENT_URI, null, null, null, null); } @Override public void onLoadFinished(Loader loader, Cursor newCursor) { - cursorAdapter.swapCursor(newCursor); + LogUtil.enterBlock("NewCallLogFragment.onLoadFinished"); + + // TODO: Handle empty cursor by showing empty view. + recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + recyclerView.setAdapter(new NewCallLogAdapter(newCursor)); } @Override public void onLoaderReset(Loader loader) { - cursorAdapter.swapCursor(null); - } - - private static class MyCursorAdapter extends SimpleCursorAdapter { - - MyCursorAdapter(Context context, int layout, Cursor c, String[] from, int[] to, int flags) { - super(context, layout, c, from, to, flags); - } - - @Override - public void setViewText(TextView view, String text) { - if (view.getId() == R.id.timestamp) { - text = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).format(Long.valueOf(text)); - } - view.setText(text); - } + LogUtil.enterBlock("NewCallLogFragment.onLoaderReset"); + recyclerView.setAdapter(null); } } diff --git a/java/com/android/dialer/calllog/ui/NewCallLogViewHolder.java b/java/com/android/dialer/calllog/ui/NewCallLogViewHolder.java new file mode 100644 index 000000000..4c459e123 --- /dev/null +++ b/java/com/android/dialer/calllog/ui/NewCallLogViewHolder.java @@ -0,0 +1,43 @@ +/* + * 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.ui; + +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.widget.TextView; +import java.text.SimpleDateFormat; +import java.util.Locale; + +/** {@link RecyclerView.ViewHolder} for the new call log. */ +final class NewCallLogViewHolder extends RecyclerView.ViewHolder { + + // TODO: Format correctly using current locale. + private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US); + + private final TextView contactNameView; + private final TextView timestampView; + + NewCallLogViewHolder(View view) { + super(view); + contactNameView = view.findViewById(R.id.contact_name); + timestampView = view.findViewById(R.id.timestamp); + } + + void bind(long timestamp) { + contactNameView.setText("Contact Name Placeholder"); + timestampView.setText(dateFormat.format(timestamp)); + } +} diff --git a/java/com/android/dialer/calllog/ui/res/layout/new_call_log_entry.xml b/java/com/android/dialer/calllog/ui/res/layout/new_call_log_entry.xml index ee3efd002..99797fab4 100644 --- a/java/com/android/dialer/calllog/ui/res/layout/new_call_log_entry.xml +++ b/java/com/android/dialer/calllog/ui/res/layout/new_call_log_entry.xml @@ -18,16 +18,20 @@ + android:layout_height="wrap_content" + android:padding="8dp" + android:orientation="vertical"> + android:layout_height="wrap_content" + style="@style/PrimaryText"/> + android:layout_height="wrap_content" + style="@style/SecondaryText"/> + \ No newline at end of file diff --git a/java/com/android/dialer/calllog/ui/res/layout/new_call_log_fragment.xml b/java/com/android/dialer/calllog/ui/res/layout/new_call_log_fragment.xml index 433dbdd0f..e1d8410b6 100644 --- a/java/com/android/dialer/calllog/ui/res/layout/new_call_log_fragment.xml +++ b/java/com/android/dialer/calllog/ui/res/layout/new_call_log_fragment.xml @@ -15,8 +15,9 @@ ~ limitations under the License --> - + android:layout_height="match_parent" + android:background="@color/background_dialer_light"/> -- cgit v1.2.3