diff options
Diffstat (limited to 'java/com/android/dialer/calllog')
27 files changed, 1445 insertions, 315 deletions
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..55ef15b47 100644 --- a/java/com/android/dialer/calllog/CallLogFramework.java +++ b/java/com/android/dialer/calllog/CallLogFramework.java @@ -22,6 +22,7 @@ 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; @@ -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/RefreshAnnotatedCallLogWorker.java b/java/com/android/dialer/calllog/RefreshAnnotatedCallLogWorker.java index f9f0c9935..d25ec5e65 100644 --- a/java/com/android/dialer/calllog/RefreshAnnotatedCallLogWorker.java +++ b/java/com/android/dialer/calllog/RefreshAnnotatedCallLogWorker.java @@ -18,14 +18,16 @@ 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; @@ -34,75 +36,65 @@ import javax.inject.Inject; /** * Worker which brings the annotated call log up to date, if necessary. * - * <p>Accepts a boolean which indicates if the dirty check should be skipped, and returns true if - * the annotated call log was updated. + * <p>Accepts a boolean which indicates if the dirty check should be skipped. */ -public class RefreshAnnotatedCallLogWorker implements Worker<Boolean, Boolean> { +public class RefreshAnnotatedCallLogWorker implements Worker<Boolean, Void> { private final Context appContext; private final DataSources dataSources; @Inject - public RefreshAnnotatedCallLogWorker(Context appContext, DataSources dataSources) { + RefreshAnnotatedCallLogWorker(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,51 +121,48 @@ public class RefreshAnnotatedCallLogWorker implements Worker<Boolean, Boolean> { @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); - - 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"); - startTime = System.currentTimeMillis(); - mutations.applyToDatabase(database); - LogUtil.i( - "RefreshAnnotatedCallLogWorker.rebuild", - "applyToDatabase took: %dms", - 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); SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(appContext); sharedPreferences .edit() .putBoolean(CallLogFramework.PREF_FORCE_REBUILD, false) - .putLong(CallLogFramework.PREF_LAST_REBUILD_TIMESTAMP_MILLIS, System.currentTimeMillis()) .commit(); } diff --git a/java/com/android/dialer/calllog/database/AndroidManifest.xml b/java/com/android/dialer/calllog/database/AndroidManifest.xml new file mode 100644 index 000000000..396a6d9a1 --- /dev/null +++ b/java/com/android/dialer/calllog/database/AndroidManifest.xml @@ -0,0 +1,28 @@ +<!-- + ~ Copyright (C) 2017 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + --> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.android.dialer.calllog.database"> + + <application> + + <provider + android:authorities="com.android.dialer.annotatedcalllog" + android:exported="false" + android:multiprocess="false" + android:name=".AnnotatedCallLogContentProvider"/> + + </application> +</manifest> diff --git a/java/com/android/dialer/calllog/database/AnnotatedCallLog.java b/java/com/android/dialer/calllog/database/AnnotatedCallLog.java deleted file mode 100644 index 7dca44a60..000000000 --- a/java/com/android/dialer/calllog/database/AnnotatedCallLog.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License - */ - -package com.android.dialer.calllog.database; - -import android.content.Context; -import android.database.sqlite.SQLiteDatabase; -import android.support.annotation.WorkerThread; -import com.android.dialer.common.Assert; - -/** Static methods and constants for interacting with the annotated call log table. */ -public final class AnnotatedCallLog { - - private static final String DATABASE_NAME = "annotated_call_log.db"; - - public static final String TABLE_NAME = "AnnotatedCallLog"; - - /** Column names for the annotated call log table. */ - public static final class Columns { - public static final String ID = "_id"; - public static final String TIMESTAMP = "timestamp"; - public static final String CONTACT_NAME = "contact_name"; - } - - private AnnotatedCallLog() {} - - @WorkerThread - public static SQLiteDatabase getWritableDatabase(Context appContext) { - Assert.isWorkerThread(); - - return new AnnotatedCallLogDatabaseHelper(appContext, DATABASE_NAME).getWritableDatabase(); - } - - @WorkerThread - public static SQLiteDatabase getReadableDatabase(Context appContext) { - Assert.isWorkerThread(); - - return new AnnotatedCallLogDatabaseHelper(appContext, DATABASE_NAME).getReadableDatabase(); - } -} diff --git a/java/com/android/dialer/calllog/database/AnnotatedCallLogContentProvider.java b/java/com/android/dialer/calllog/database/AnnotatedCallLogContentProvider.java new file mode 100644 index 000000000..a9c0d36b0 --- /dev/null +++ b/java/com/android/dialer/calllog/database/AnnotatedCallLogContentProvider.java @@ -0,0 +1,310 @@ +/* + * 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 { + + private static final int ANNOTATED_CALL_LOG_TABLE_CODE = 1; + private static final int ANNOTATED_CALL_LOG_TABLE_ID_CODE = 2; + private static final int COALESCED_ANNOTATED_CALL_LOG_TABLE_CODE = 3; + + private static final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + + static { + uriMatcher.addURI( + AnnotatedCallLogContract.AUTHORITY, AnnotatedCallLog.TABLE, ANNOTATED_CALL_LOG_TABLE_CODE); + uriMatcher.addURI( + AnnotatedCallLogContract.AUTHORITY, + AnnotatedCallLog.TABLE + "/#", + ANNOTATED_CALL_LOG_TABLE_ID_CODE); + uriMatcher.addURI( + AnnotatedCallLogContract.AUTHORITY, + CoalescedAnnotatedCallLog.TABLE, + COALESCED_ANNOTATED_CALL_LOG_TABLE_CODE); + } + + private AnnotatedCallLogDatabaseHelper databaseHelper; + private Coalescer coalescer; + + private final ThreadLocal<Boolean> applyingBatch = new ThreadLocal<>(); + + /** Ensures that only a single notification is generated from {@link #applyBatch(ArrayList)}. */ + private boolean isApplyingBatch() { + return applyingBatch.get() != null && applyingBatch.get(); + } + + @Override + public boolean onCreate() { + databaseHelper = new AnnotatedCallLogDatabaseHelper(getContext()); + coalescer = CallLogDatabaseComponent.get(getContext()).coalescer(); + return true; + } + + @TargetApi(Build.VERSION_CODES.M) // Uses try-with-resources + @Nullable + @Override + public Cursor query( + @NonNull Uri uri, + @Nullable String[] projection, + @Nullable String selection, + @Nullable String[] selectionArgs, + @Nullable String sortOrder) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder(); + queryBuilder.setTables(AnnotatedCallLog.TABLE); + int match = uriMatcher.match(uri); + switch (match) { + case ANNOTATED_CALL_LOG_TABLE_ID_CODE: + queryBuilder.appendWhere(AnnotatedCallLog._ID + "=" + ContentUris.parseId(uri)); + // fall through + case ANNOTATED_CALL_LOG_TABLE_CODE: + Cursor cursor = + queryBuilder.query(db, projection, selection, selectionArgs, null, null, sortOrder); + if (cursor != null) { + cursor.setNotificationUri( + getContext().getContentResolver(), AnnotatedCallLog.CONTENT_URI); + } else { + LogUtil.w("AnnotatedCallLogContentProvider.query", "cursor was null"); + } + return cursor; + case COALESCED_ANNOTATED_CALL_LOG_TABLE_CODE: + Assert.checkArgument(projection == null, "projection not supported for coalesced call log"); + Assert.checkArgument(selection == null, "selection not supported for coalesced call log"); + Assert.checkArgument( + selectionArgs == null, "selection args not supported for coalesced call log"); + Assert.checkArgument(sortOrder == null, "sort order not supported for coalesced call log"); + try (Cursor allAnnotatedCallLogRows = + queryBuilder.query( + db, null, null, null, null, null, AnnotatedCallLog.TIMESTAMP + " DESC")) { + Cursor coalescedRows = coalescer.coalesce(allAnnotatedCallLogRows); + coalescedRows.setNotificationUri( + getContext().getContentResolver(), CoalescedAnnotatedCallLog.CONTENT_URI); + return coalescedRows; + } + default: + throw new IllegalArgumentException("Unknown uri: " + uri); + } + } + + @Nullable + @Override + public String getType(@NonNull Uri uri) { + return AnnotatedCallLog.CONTENT_ITEM_TYPE; + } + + @Nullable + @Override + public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) { + // Javadoc states values is not nullable, even though it is annotated as such (b/38123194)! + Assert.checkArgument(values != null); + + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + int match = uriMatcher.match(uri); + switch (match) { + case ANNOTATED_CALL_LOG_TABLE_CODE: + Assert.checkArgument( + values.get(AnnotatedCallLog._ID) != null, "You must specify an _ID when inserting"); + break; + case ANNOTATED_CALL_LOG_TABLE_ID_CODE: + Long idFromUri = ContentUris.parseId(uri); + Long idFromValues = values.getAsLong(AnnotatedCallLog._ID); + Assert.checkArgument( + idFromValues == null || idFromValues.equals(idFromUri), + "_ID from values %d does not match ID from URI: %s", + idFromValues, + uri); + if (idFromValues == null) { + values.put(AnnotatedCallLog._ID, idFromUri); + } + break; + case COALESCED_ANNOTATED_CALL_LOG_TABLE_CODE: + throw new UnsupportedOperationException("coalesced call log does not support inserting"); + default: + throw new IllegalArgumentException("Unknown uri: " + uri); + } + long id = database.insert(AnnotatedCallLog.TABLE, null, values); + if (id < 0) { + LogUtil.w( + "AnnotatedCallLogContentProvider.insert", + "error inserting row with id: %d", + values.get(AnnotatedCallLog._ID)); + return null; + } + Uri insertedUri = ContentUris.withAppendedId(AnnotatedCallLog.CONTENT_URI, id); + if (!isApplyingBatch()) { + notifyChange(insertedUri); + } + return insertedUri; + } + + @Override + public int delete( + @NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + final int match = uriMatcher.match(uri); + switch (match) { + case ANNOTATED_CALL_LOG_TABLE_CODE: + break; + case ANNOTATED_CALL_LOG_TABLE_ID_CODE: + Assert.checkArgument(selection == null, "Do not specify selection when deleting by ID"); + Assert.checkArgument( + selectionArgs == null, "Do not specify selection args when deleting by ID"); + long id = ContentUris.parseId(uri); + Assert.checkArgument(id != -1, "error parsing id from uri %s", uri); + selection = getSelectionWithId(id); + break; + case COALESCED_ANNOTATED_CALL_LOG_TABLE_CODE: + throw new UnsupportedOperationException("coalesced call log does not support deleting"); + default: + throw new IllegalArgumentException("Unknown uri: " + uri); + } + int rows = database.delete(AnnotatedCallLog.TABLE, selection, selectionArgs); + if (rows > 0) { + if (!isApplyingBatch()) { + notifyChange(uri); + } + } else { + LogUtil.w("AnnotatedCallLogContentProvider.delete", "no rows deleted"); + } + return rows; + } + + @Override + public int update( + @NonNull Uri uri, + @Nullable ContentValues values, + @Nullable String selection, + @Nullable String[] selectionArgs) { + // Javadoc states values is not nullable, even though it is annotated as such (b/38123194)! + Assert.checkArgument(values != null); + + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + int match = uriMatcher.match(uri); + switch (match) { + case ANNOTATED_CALL_LOG_TABLE_CODE: + break; + case ANNOTATED_CALL_LOG_TABLE_ID_CODE: + Assert.checkArgument( + !values.containsKey(AnnotatedCallLog._ID), "Do not specify _ID when updating by ID"); + Assert.checkArgument(selection == null, "Do not specify selection when updating by ID"); + Assert.checkArgument( + selectionArgs == null, "Do not specify selection args when updating by ID"); + selection = getSelectionWithId(ContentUris.parseId(uri)); + break; + case COALESCED_ANNOTATED_CALL_LOG_TABLE_CODE: + throw new UnsupportedOperationException("coalesced call log does not support updating"); + default: + throw new IllegalArgumentException("Unknown uri: " + uri); + } + int rows = database.update(AnnotatedCallLog.TABLE, values, selection, selectionArgs); + if (rows > 0) { + if (!isApplyingBatch()) { + notifyChange(uri); + } + } else { + LogUtil.w("AnnotatedCallLogContentProvider.update", "no rows updated"); + } + return rows; + } + + /** + * {@inheritDoc} + * + * <p>Note: When applyBatch is used with the AnnotatedCallLog, only a single notification for the + * content URI is generated, not individual notifications for each affected URI. + */ + @NonNull + @Override + public ContentProviderResult[] applyBatch(@NonNull ArrayList<ContentProviderOperation> operations) + throws OperationApplicationException { + ContentProviderResult[] results = new ContentProviderResult[operations.size()]; + if (operations.isEmpty()) { + return results; + } + + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + try { + applyingBatch.set(true); + database.beginTransaction(); + for (int i = 0; i < operations.size(); i++) { + ContentProviderOperation operation = operations.get(i); + int match = uriMatcher.match(operation.getUri()); + switch (match) { + case ANNOTATED_CALL_LOG_TABLE_CODE: + case ANNOTATED_CALL_LOG_TABLE_ID_CODE: + // These are allowed values, continue. + break; + case COALESCED_ANNOTATED_CALL_LOG_TABLE_CODE: + throw new UnsupportedOperationException( + "coalesced call log does not support applyBatch"); + default: + throw new IllegalArgumentException("Unknown uri: " + operation.getUri()); + } + ContentProviderResult result = operation.apply(this, results, i); + if (operations.get(i).isInsert()) { + if (result.uri == null) { + throw new OperationApplicationException("error inserting row"); + } + } else if (result.count == 0) { + throw new OperationApplicationException("error updating or deleting rows"); + } + 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..3cca639ff 100644 --- a/java/com/android/dialer/calllog/database/AnnotatedCallLogDatabaseHelper.java +++ b/java/com/android/dialer/calllog/database/AnnotatedCallLogDatabaseHelper.java @@ -16,28 +16,25 @@ 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; /** {@link SQLiteOpenHelper} for the AnnotatedCallLog database. */ class AnnotatedCallLogDatabaseHelper extends SQLiteOpenHelper { - AnnotatedCallLogDatabaseHelper(Context appContext, String databaseName) { - super(appContext, databaseName, null, 1); + AnnotatedCallLogDatabaseHelper(Context appContext) { + super(appContext, "annotated_call_log.db", null, 1); } private static final String CREATE_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(");") .toString(); diff --git a/java/com/android/dialer/calllog/database/CallLogDatabaseComponent.java b/java/com/android/dialer/calllog/database/CallLogDatabaseComponent.java new file mode 100644 index 000000000..ede46911c --- /dev/null +++ b/java/com/android/dialer/calllog/database/CallLogDatabaseComponent.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +package com.android.dialer.calllog.database; + +import android.content.Context; +import com.android.dialer.inject.HasRootComponent; +import dagger.Subcomponent; + +/** Dagger component for database package. */ +@Subcomponent +public abstract class CallLogDatabaseComponent { + + public abstract Coalescer coalescer(); + + public abstract MutationApplier mutationApplier(); + + public static CallLogDatabaseComponent get(Context context) { + return ((CallLogDatabaseComponent.HasComponent) + ((HasRootComponent) context.getApplicationContext()).component()) + .callLogDatabaseComponent(); + } + + /** Used to refer to the root application component. */ + public interface HasComponent { + CallLogDatabaseComponent callLogDatabaseComponent(); + } +} diff --git a/java/com/android/dialer/calllog/database/CallLogMutations.java b/java/com/android/dialer/calllog/database/CallLogMutations.java deleted file mode 100644 index ec020c6af..000000000 --- a/java/com/android/dialer/calllog/database/CallLogMutations.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License - */ - -package com.android.dialer.calllog.database; - -import android.content.ContentValues; -import android.database.sqlite.SQLiteDatabase; -import android.support.annotation.WorkerThread; -import android.util.ArrayMap; -import android.util.ArraySet; -import com.android.dialer.common.Assert; - -/** A collection of mutations to the annotated call log. */ -public final class CallLogMutations { - - private final ArrayMap<Integer, ContentValues> inserts = new ArrayMap<>(); - private final ArrayMap<Integer, ContentValues> updates = new ArrayMap<>(); - private final ArraySet<Integer> deletes = new ArraySet<>(); - - /** @param contentValues an entire row not including the ID */ - public void insert(int id, ContentValues contentValues) { - inserts.put(id, contentValues); - } - - /** @param contentValues the specific columns to update, not including the ID. */ - public void update(int id, ContentValues contentValues) { - // TODO: Consider merging automatically. - updates.put(id, contentValues); - } - - public void delete(int id) { - deletes.add(id); - } - - public boolean isEmpty() { - return inserts.isEmpty() && updates.isEmpty() && deletes.isEmpty(); - } - - @WorkerThread - public void applyToDatabase(SQLiteDatabase writableDatabase) { - Assert.isWorkerThread(); - - // TODO: Implementation. - } -} diff --git a/java/com/android/dialer/calllog/database/Coalescer.java b/java/com/android/dialer/calllog/database/Coalescer.java new file mode 100644 index 000000000..e3dfb7ece --- /dev/null +++ b/java/com/android/dialer/calllog/database/Coalescer.java @@ -0,0 +1,142 @@ +/* + * 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.DatabaseUtils; +import android.database.MatrixCursor; +import android.support.annotation.NonNull; +import android.support.annotation.WorkerThread; +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 java.util.ArrayList; +import java.util.List; +import java.util.Map; +import javax.inject.Inject; + +/** + * Coalesces call log rows by combining some adjacent rows. + * + * <p>Applies the business which logic which determines which adjacent rows should be coalasced, and + * then delegates to each data source to determine how individual columns should be aggregated. + */ +public class Coalescer { + + private final DataSources dataSources; + + @Inject + Coalescer(DataSources dataSources) { + this.dataSources = dataSources; + } + + /** + * Reads the entire {@link AnnotatedCallLog} database into memory from the provided {@code + * allAnnotatedCallLog} parameter and then builds and returns a new {@link MatrixCursor} which is + * the result of combining adjacent rows which should be collapsed for display purposes. + * + * @param allAnnotatedCallLogRowsSortedByTimestampDesc all {@link AnnotatedCallLog} rows, sorted + * by timestamp descending + * @return a new {@link MatrixCursor} containing the {@link CoalescedAnnotatedCallLog} rows to + * display + */ + @WorkerThread + @NonNull + Cursor coalesce(@NonNull Cursor allAnnotatedCallLogRowsSortedByTimestampDesc) { + Assert.isWorkerThread(); + + // Note: This method relies on rowsShouldBeCombined to determine which rows should be combined, + // but delegates to data sources to actually aggregate column values. + + MatrixCursor allCoalescedRowsMatrixCursor = + new MatrixCursor( + CoalescedAnnotatedCallLog.ALL_COLUMNS, + Assert.isNotNull(allAnnotatedCallLogRowsSortedByTimestampDesc).getCount()); + + if (allAnnotatedCallLogRowsSortedByTimestampDesc.moveToFirst()) { + int coalescedRowId = 0; + + List<ContentValues> currentRowGroup = new ArrayList<>(); + + do { + ContentValues currentRow = new ContentValues(); + DatabaseUtils.cursorRowToContentValues( + allAnnotatedCallLogRowsSortedByTimestampDesc, currentRow); + + if (currentRowGroup.isEmpty()) { + currentRowGroup.add(currentRow); + continue; + } + + ContentValues previousRow = currentRowGroup.get(currentRowGroup.size() - 1); + + if (!rowsShouldBeCombined(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; + } + + /** + * @param row1 a row from {@link AnnotatedCallLog} + * @param row2 a row from {@link AnnotatedCallLog} + */ + private static boolean rowsShouldBeCombined(ContentValues row1, ContentValues row2) { + // TODO: Real implementation. + return row1.get(AnnotatedCallLog.TIMESTAMP).equals(row2.get(AnnotatedCallLog.TIMESTAMP)); + } + + /** + * Delegates to data sources to aggregate individual columns to create a new coalesced row. + * + * @param individualRows {@link AnnotatedCallLog} rows sorted by timestamp descending + * @return a {@link CoalescedAnnotatedCallLog} row + */ + private ContentValues coalesceRowsForAllDataSources(List<ContentValues> individualRows) { + ContentValues coalescedValues = new ContentValues(); + for (CallLogDataSource dataSource : dataSources.getDataSourcesIncludingSystemCallLog()) { + coalescedValues.putAll(dataSource.coalesce(individualRows)); + } + return coalescedValues; + } + + /** + * @param contentValues a {@link CoalescedAnnotatedCallLog} row + * @param matrixCursor represents {@link CoalescedAnnotatedCallLog} + */ + private static void addContentValuesToMatrixCursor( + ContentValues contentValues, MatrixCursor matrixCursor, int rowId) { + MatrixCursor.RowBuilder rowBuilder = matrixCursor.newRow(); + rowBuilder.add(CoalescedAnnotatedCallLog._ID, rowId); + for (Map.Entry<String, Object> entry : contentValues.valueSet()) { + rowBuilder.add(entry.getKey(), entry.getValue()); + } + } +} diff --git a/java/com/android/dialer/calllog/database/MutationApplier.java b/java/com/android/dialer/calllog/database/MutationApplier.java new file mode 100644 index 000000000..21c8a507d --- /dev/null +++ b/java/com/android/dialer/calllog/database/MutationApplier.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +package com.android.dialer.calllog.database; + +import android.content.ContentProviderOperation; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.OperationApplicationException; +import android.os.RemoteException; +import android.support.annotation.WorkerThread; +import android.text.TextUtils; +import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract; +import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.AnnotatedCallLog; +import com.android.dialer.calllog.datasources.CallLogMutations; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Map.Entry; +import javax.inject.Inject; + +/** Applies {@link CallLogMutations} to the annotated call log. */ +public class MutationApplier { + + @Inject + MutationApplier() {} + + /** Applies the provided {@link CallLogMutations} to the annotated call log. */ + @WorkerThread + public void applyToDatabase(CallLogMutations mutations, Context appContext) + throws RemoteException, OperationApplicationException { + Assert.isWorkerThread(); + + if (mutations.isEmpty()) { + return; + } + + ArrayList<ContentProviderOperation> operations = new ArrayList<>(); + + if (!mutations.getInserts().isEmpty()) { + LogUtil.i( + "CallLogMutations.applyToDatabase", "inserting %d rows", mutations.getInserts().size()); + for (Entry<Long, ContentValues> entry : mutations.getInserts().entrySet()) { + long id = entry.getKey(); + ContentValues contentValues = entry.getValue(); + operations.add( + ContentProviderOperation.newInsert( + ContentUris.withAppendedId(AnnotatedCallLog.CONTENT_URI, id)) + .withValues(contentValues) + .build()); + } + } + + if (!mutations.getUpdates().isEmpty()) { + LogUtil.i( + "CallLogMutations.applyToDatabase", "updating %d rows", mutations.getUpdates().size()); + for (Entry<Long, ContentValues> entry : mutations.getUpdates().entrySet()) { + long id = entry.getKey(); + ContentValues contentValues = entry.getValue(); + operations.add( + ContentProviderOperation.newUpdate( + ContentUris.withAppendedId(AnnotatedCallLog.CONTENT_URI, id)) + .withValues(contentValues) + .build()); + } + } + + if (!mutations.getDeletes().isEmpty()) { + LogUtil.i( + "CallLogMutations.applyToDatabase", "deleting %d rows", mutations.getDeletes().size()); + String[] questionMarks = new String[mutations.getDeletes().size()]; + Arrays.fill(questionMarks, "?"); + + String whereClause = + (AnnotatedCallLog._ID + " in (") + TextUtils.join(",", questionMarks) + ")"; + + String[] whereArgs = new String[mutations.getDeletes().size()]; + int i = 0; + for (long id : mutations.getDeletes()) { + whereArgs[i++] = String.valueOf(id); + } + + operations.add( + ContentProviderOperation.newDelete(AnnotatedCallLog.CONTENT_URI) + .withSelection(whereClause, whereArgs) + .build()); + } + + appContext.getContentResolver().applyBatch(AnnotatedCallLogContract.AUTHORITY, operations); + } +} diff --git a/java/com/android/dialer/calllog/database/contract/AnnotatedCallLogContract.java b/java/com/android/dialer/calllog/database/contract/AnnotatedCallLogContract.java new file mode 100644 index 000000000..8b3b0a852 --- /dev/null +++ b/java/com/android/dialer/calllog/database/contract/AnnotatedCallLogContract.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.dialer.calllog.database.contract; + +import android.net.Uri; +import android.provider.BaseColumns; +import com.android.dialer.constants.Constants; +import java.util.Arrays; + +/** Contract for the AnnotatedCallLog content provider. */ +public class AnnotatedCallLogContract { + public static final String AUTHORITY = Constants.get().getAnnotatedCallLogProviderAuthority(); + + public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY); + + /** + * Columns shared by {@link AnnotatedCallLog} and {@link CoalescedAnnotatedCallLog}. + * + * <p>When adding columns be sure to update {@link #ALL_COMMON_COLUMNS}. + */ + interface CommonColumns extends BaseColumns { + + /** + * Timestamp of the entry, in milliseconds. + * + * <p>Type: INTEGER (long) + */ + String TIMESTAMP = "timestamp"; + + /** + * Name to display for the entry. + * + * <p>Type: TEXT + */ + String CONTACT_NAME = "contact_name"; + + String[] ALL_COMMON_COLUMNS = new String[] {_ID, TIMESTAMP, CONTACT_NAME}; + } + + /** + * AnnotatedCallLog table. + * + * <p>This contains all of the non-coalesced call log entries. + */ + public static final class AnnotatedCallLog implements CommonColumns { + + public static final String TABLE = "AnnotatedCallLog"; + + /** The content URI for this table. */ + public static final Uri CONTENT_URI = + Uri.withAppendedPath(AnnotatedCallLogContract.CONTENT_URI, TABLE); + + /** The MIME type of a {@link android.content.ContentProvider#getType(Uri)} single entry. */ + public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/annotated_call_log"; + } + + /** + * Coalesced view of the AnnotatedCallLog table. + * + * <p>This is an in-memory view of the {@link AnnotatedCallLog} with some adjacent entries + * collapsed. + * + * <p>When adding columns be sure to update {@link #COLUMNS_ONLY_IN_COALESCED_CALL_LOG}. + */ + public static final class CoalescedAnnotatedCallLog implements CommonColumns { + + public static final String TABLE = "CoalescedAnnotatedCallLog"; + + /** The content URI for this table. */ + public static final Uri CONTENT_URI = + Uri.withAppendedPath(AnnotatedCallLogContract.CONTENT_URI, TABLE); + + /** The MIME type of a {@link android.content.ContentProvider#getType(Uri)} single entry. */ + public static final String CONTENT_ITEM_TYPE = + "vnd.android.cursor.item/coalesced_annotated_call_log"; + + /** + * Number of AnnotatedCallLog rows represented by this CoalescedAnnotatedCallLog row. + * + * <p>Type: INTEGER + */ + public static final String NUMBER_CALLS = "number_calls"; + + /** + * 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}; + + /** 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. + * + * <p>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. + * + * <ol> + * <li>{@link #isDirty(Context)}: Invoked only if the framework doesn't yet know if a rebuild is + * necessary. + * <li>{@link #fill(Context, CallLogMutations)}: Invoked only if the framework determined a + * rebuild is necessary. + * <li>{@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. + * </ol> + * + * <p>Because {@link #isDirty(Context)} is not always invoked, {@link #fill(Context, + * CallLogMutations)} shouldn't rely on any state saved during {@link #isDirty(Context)}. It + * <em>is</em> safe to assume that {@link #onSuccessfulFill(Context)} refers to the previous fill + * operation. + * + * <p>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. + * + * <p>{@link #coalesce(List)} may be called from any worker thread at any time. + */ public interface CallLogDataSource { /** @@ -35,6 +61,8 @@ public interface CallLogDataSource { * <p>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. + * + * <p>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<ContentValues> 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<Long, ContentValues> inserts = new ArrayMap<>(); + private final ArrayMap<Long, ContentValues> updates = new ArrayMap<>(); + private final ArraySet<Long> 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<Long, ContentValues> 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<Long, ContentValues> getUpdates() { + return updates; + } + + /** + * Get the pending deletes. + * + * @return the annotated call log database IDs corresponding to the rows to be deleted + */ + public ArraySet<Long> getDeletes() { + return deletes; + } +} diff --git a/java/com/android/dialer/calllog/DataSources.java b/java/com/android/dialer/calllog/datasources/DataSources.java index 21d190167..911ca3fa3 100644 --- a/java/com/android/dialer/calllog/DataSources.java +++ b/java/com/android/dialer/calllog/datasources/DataSources.java @@ -14,14 +14,13 @@ * limitations under the License */ -package com.android.dialer.calllog; +package com.android.dialer.calllog.datasources; -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 { +public interface DataSources { SystemCallLogDataSource getSystemCallLogDataSource(); diff --git a/java/com/android/dialer/calllog/datasources/contacts/ContactsDataSource.java b/java/com/android/dialer/calllog/datasources/contacts/ContactsDataSource.java index 355940f6a..e9538daab 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,13 +47,24 @@ public final class ContactsDataSource implements CallLogDataSource { @Override public void fill( Context appContext, - SQLiteDatabase readableDatabase, - long lastRebuildTimeMillis, CallLogMutations mutations) { Assert.isWorkerThread(); // TODO: Implementation. } + @Override + public void onSuccessfulFill(Context appContext) { + // TODO: Implementation. + } + + @Override + public ContentValues coalesce(List<ContentValues> individualRowsSortedByTimestampDesc) { + // TODO: Implementation. + return new RowCombiner(individualRowsSortedByTimestampDesc) + .useSingleValueString(AnnotatedCallLog.CONTACT_NAME) + .combine(); + } + @MainThread @Override public void registerContentObservers( diff --git a/java/com/android/dialer/calllog/datasources/systemcalllog/SystemCallLogDataSource.java b/java/com/android/dialer/calllog/datasources/systemcalllog/SystemCallLogDataSource.java index ea6663fbe..be2df6043 100644 --- a/java/com/android/dialer/calllog/datasources/systemcalllog/SystemCallLogDataSource.java +++ b/java/com/android/dialer/calllog/datasources/systemcalllog/SystemCallLogDataSource.java @@ -16,28 +16,49 @@ 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.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 com.android.dialer.common.LogUtil; import com.android.dialer.common.concurrent.ThreadUtil; import com.android.dialer.util.PermissionsUtil; +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 +68,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 +100,185 @@ 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<Long> 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) + .commit(); + } + } + + @Override + public ContentValues coalesce(List<ContentValues> individualRowsSortedByTimestampDesc) { + // TODO: Complete implementation. + return new RowCombiner(individualRowsSortedByTimestampDesc) + .useMostRecentLong(AnnotatedCallLog.TIMESTAMP) + .combine(); + } + + @TargetApi(Build.VERSION_CODES.M) // Uses try-with-resources + private void handleInsertsAndUpdates( + Context appContext, CallLogMutations mutations, Set<Long> existingAnnotatedCallLogIds) { + long previousTimestampProcessed = + PreferenceManager.getDefaultSharedPreferences(appContext) + .getLong(PREF_LAST_TIMESTAMP_PROCESSED, 0L); + + try (Cursor cursor = + appContext + .getContentResolver() + .query( + Calls.CONTENT_URI, // Excludes voicemail + new String[] {Calls._ID, Calls.DATE, Calls.LAST_MODIFIED}, + Calls.LAST_MODIFIED + " > ?", + new String[] {String.valueOf(previousTimestampProcessed)}, + Calls.LAST_MODIFIED + " DESC LIMIT 1000")) { + + if (cursor == null) { + LogUtil.e("SystemCallLogDataSource.handleInsertsAndUpdates", "null cursor"); + return; + } + + LogUtil.i( + "SystemCallLogDataSource.handleInsertsAndUpdates", + "found %d entries to insert/update", + cursor.getCount()); - // TODO: Implementation. + if (cursor.moveToFirst()) { + int idColumn = cursor.getColumnIndexOrThrow(Calls._ID); + int dateColumn = cursor.getColumnIndexOrThrow(Calls.DATE); + int lastModifiedColumn = cursor.getColumnIndexOrThrow(Calls.LAST_MODIFIED); + + // 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); + + ContentValues contentValues = new ContentValues(); + contentValues.put(AnnotatedCallLog.TIMESTAMP, date); + + 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<Long> existingAnnotatedCallLogIds, CallLogMutations mutations) { + Set<Long> systemCallLogIds = + getIdsFromSystemCallLogThatMatch(appContext, existingAnnotatedCallLogIds); + LogUtil.i( + "SystemCallLogDataSource.handleDeletes", + "found %d entries in system call log", + systemCallLogIds.size()); + Set<Long> 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<Long> getAnnotatedCallLogIds(Context appContext) { + ArraySet<Long> 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<Long> getIdsFromSystemCallLogThatMatch( + Context appContext, Set<Long> matchingIds) { + ArraySet<Long> 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<ContentValues> individualRowsSortedByTimestampDesc; + private final ContentValues combinedRow = new ContentValues(); + + public RowCombiner(List<ContentValues> 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<ContentValues> 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..cb2240539 --- /dev/null +++ b/java/com/android/dialer/calllog/testing/FakeCallLogApplication.java @@ -0,0 +1,39 @@ +/* + * 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.CallLogModule; +import com.android.dialer.calllog.database.CallLogDatabaseComponent; +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 { + + @Override + public Object component() { + return DaggerFakeCallLogApplication_FakeComponent.create(); + } + + @Singleton + @Component(modules = CallLogModule.class) + interface FakeComponent extends CallLogDatabaseComponent.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 --> -<manifest package="com.android.dialer.calllog"/> +<manifest package="com.android.dialer.calllog.ui"/> 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<NewCallLogViewHolder> { + + 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..89ed52fd7 100644 --- a/java/com/android/dialer/calllog/ui/NewCallLogFragment.java +++ b/java/com/android/dialer/calllog/ui/NewCallLogFragment.java @@ -17,30 +17,30 @@ 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<Cursor> { - private CursorAdapter cursorAdapter; + private DialerExecutor<Boolean> refreshAnnotatedCallLogTask; + private RecyclerView recyclerView; public NewCallLogFragment() { LogUtil.enterBlock("NewCallLogFragment.NewCallLogFragment"); @@ -52,8 +52,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 +83,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 +104,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.executeSerial(false /* skipDirtyCheck */); + } + @Override public void invalidateUi() { LogUtil.enterBlock("NewCallLogFragment.invalidateUi"); - // TODO: Implementation. + refreshAnnotatedCallLogTask.executeSerial(true /* skipDirtyCheck */); } @Override public Loader<Cursor> 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<Cursor> 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<Cursor> 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 @@ <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="horizontal"> + android:layout_height="wrap_content" + android:padding="8dp" + android:orientation="vertical"> <TextView - android:id="@+id/timestamp" + android:id="@+id/contact_name" android:layout_width="wrap_content" - android:layout_height="wrap_content"/> + android:layout_height="wrap_content" + style="@style/PrimaryText"/> <TextView - android:id="@+id/contact_name" + android:id="@+id/timestamp" android:layout_width="wrap_content" - android:layout_height="wrap_content"/> + android:layout_height="wrap_content" + style="@style/SecondaryText"/> + </LinearLayout>
\ 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 --> -<ListView +<android.support.v7.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android" - android:id="@+id/list" + android:id="@+id/new_call_log_recycler_view" android:layout_width="match_parent" - android:layout_height="match_parent"/> + android:layout_height="match_parent" + android:background="@color/background_dialer_light"/> |