diff options
Diffstat (limited to 'java/com/android/dialer/calllog/datasources')
6 files changed, 638 insertions, 25 deletions
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/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<CallLogDataSource> getDataSourcesIncludingSystemCallLog(); + + List<CallLogDataSource> 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..f0384b09a 100644 --- a/java/com/android/dialer/calllog/datasources/contacts/ContactsDataSource.java +++ b/java/com/android/dialer/calllog/datasources/contacts/ContactsDataSource.java @@ -16,13 +16,14 @@ 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.datasources.CallLogDataSource; +import com.android.dialer.calllog.datasources.CallLogMutations; 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. */ @@ -36,7 +37,7 @@ public final class ContactsDataSource implements CallLogDataSource { public boolean isDirty(Context appContext) { Assert.isWorkerThread(); - // TODO: Implementation. + // TODO(zachh): Implementation. return false; } @@ -44,17 +45,26 @@ public final class ContactsDataSource implements CallLogDataSource { @Override public void fill( Context appContext, - SQLiteDatabase readableDatabase, - long lastRebuildTimeMillis, CallLogMutations mutations) { Assert.isWorkerThread(); - // TODO: Implementation. + // TODO(zachh): Implementation. + } + + @Override + public void onSuccessfulFill(Context appContext) { + // TODO(zachh): Implementation. + } + + @Override + public ContentValues coalesce(List<ContentValues> individualRowsSortedByTimestampDesc) { + // TODO(zachh): Implementation. + return new ContentValues(); } @MainThread @Override public void registerContentObservers( Context appContext, ContentObserverCallbacks contentObserverCallbacks) { - // TODO: Guard against missing permissions during callback registration. + // TODO(zachh): Guard against missing permissions during callback registration. } } diff --git a/java/com/android/dialer/calllog/datasources/systemcalllog/SystemCallLogDataSource.java b/java/com/android/dialer/calllog/datasources/systemcalllog/SystemCallLogDataSource.java index ea6663fbe..1bdbb8a1b 100644 --- a/java/com/android/dialer/calllog/datasources/systemcalllog/SystemCallLogDataSource.java +++ b/java/com/android/dialer/calllog/datasources/systemcalllog/SystemCallLogDataSource.java @@ -16,28 +16,59 @@ 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.provider.ContactsContract.CommonDataKinds.Phone; +import android.support.annotation.ColorInt; 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.telecom.PhoneAccount; +import android.telecom.PhoneAccountHandle; +import android.text.TextUtils; +import android.util.ArraySet; +import com.android.dialer.CallTypes; +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.calllogutils.PhoneAccountUtils; 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.theme.R; import com.android.dialer.util.PermissionsUtil; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +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,10 +78,13 @@ 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; } + // TODO(zachh): Need to somehow register observers if user enables permission after launch? appContext .getContentResolver() @@ -71,23 +105,328 @@ public class SystemCallLogDataSource implements CallLogDataSource { * column is unused). This means that we can't detect deletes without scanning the entire table, * which would be too slow. So, we just rely on content observers to trigger rebuilds when any * change is made to the system call log. + * + * Just return false unless the table has never been written to. */ - return false; + return !PreferenceManager.getDefaultSharedPreferences(appContext) + .contains(PREF_LAST_TIMESTAMP_PROCESSED); } @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) + .apply(); + } + } + + @Override + public ContentValues coalesce(List<ContentValues> individualRowsSortedByTimestampDesc) { + // TODO(zachh): Complete implementation. + ContentValues coalescedValues = + new RowCombiner(individualRowsSortedByTimestampDesc) + .useMostRecentLong(AnnotatedCallLog.TIMESTAMP) + .useMostRecentLong(AnnotatedCallLog.NEW) + .useMostRecentString(AnnotatedCallLog.NUMBER_TYPE_LABEL) + .useMostRecentString(AnnotatedCallLog.NAME) + .useMostRecentString(AnnotatedCallLog.FORMATTED_NUMBER) + .useMostRecentString(AnnotatedCallLog.PHOTO_URI) + .useMostRecentLong(AnnotatedCallLog.PHOTO_ID) + .useMostRecentString(AnnotatedCallLog.LOOKUP_URI) + .useMostRecentString(AnnotatedCallLog.GEOCODED_LOCATION) + .useSingleValueString(AnnotatedCallLog.PHONE_ACCOUNT_LABEL) + .useSingleValueLong(AnnotatedCallLog.PHONE_ACCOUNT_COLOR) + .combine(); + + CallTypes.Builder callTypes = CallTypes.newBuilder(); + // Store a maximum of 3 call types since that's all we show to users via icons. + for (int i = 0; i < 3 && i < individualRowsSortedByTimestampDesc.size(); i++) { + callTypes.addType( + individualRowsSortedByTimestampDesc.get(i).getAsInteger(AnnotatedCallLog.TYPE)); + } + coalescedValues.put(CoalescedAnnotatedCallLog.CALL_TYPES, callTypes.build().toByteArray()); + + return coalescedValues; + } + + @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); + + DialerPhoneNumberUtil dialerPhoneNumberUtil = + new DialerPhoneNumberUtil(PhoneNumberUtil.getInstance()); + + // TODO(zachh): 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.TYPE, + Calls.COUNTRY_ISO, + Calls.CACHED_NAME, + Calls.CACHED_FORMATTED_NUMBER, + Calls.CACHED_PHOTO_URI, + Calls.CACHED_PHOTO_ID, + Calls.CACHED_LOOKUP_URI, + Calls.CACHED_NUMBER_TYPE, + Calls.CACHED_NUMBER_LABEL, + Calls.IS_READ, + Calls.NEW, + Calls.GEOCODED_LOCATION, + Calls.PHONE_ACCOUNT_COMPONENT_NAME, + Calls.PHONE_ACCOUNT_ID, + Calls.FEATURES + }, + 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()); + + 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 typeColumn = cursor.getColumnIndexOrThrow(Calls.TYPE); + int countryIsoColumn = cursor.getColumnIndexOrThrow(Calls.COUNTRY_ISO); + int cachedNameColumn = cursor.getColumnIndexOrThrow(Calls.CACHED_NAME); + int cachedFormattedNumberColumn = + cursor.getColumnIndexOrThrow(Calls.CACHED_FORMATTED_NUMBER); + int cachedPhotoUriColumn = cursor.getColumnIndexOrThrow(Calls.CACHED_PHOTO_URI); + int cachedPhotoIdColumn = cursor.getColumnIndexOrThrow(Calls.CACHED_PHOTO_ID); + int cachedLookupUriColumn = cursor.getColumnIndexOrThrow(Calls.CACHED_LOOKUP_URI); + int cachedNumberTypeColumn = cursor.getColumnIndexOrThrow(Calls.CACHED_NUMBER_TYPE); + int cachedNumberLabelColumn = cursor.getColumnIndexOrThrow(Calls.CACHED_NUMBER_LABEL); + int isReadColumn = cursor.getColumnIndexOrThrow(Calls.IS_READ); + int newColumn = cursor.getColumnIndexOrThrow(Calls.NEW); + int geocodedLocationColumn = cursor.getColumnIndexOrThrow(Calls.GEOCODED_LOCATION); + int phoneAccountComponentColumn = + cursor.getColumnIndexOrThrow(Calls.PHONE_ACCOUNT_COMPONENT_NAME); + int phoneAccountIdColumn = cursor.getColumnIndexOrThrow(Calls.PHONE_ACCOUNT_ID); + int featuresColumn = cursor.getColumnIndexOrThrow(Calls.FEATURES); + + // 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); + long type = cursor.getInt(typeColumn); + String countryIso = cursor.getString(countryIsoColumn); + String cachedName = cursor.getString(cachedNameColumn); + String formattedNumber = cursor.getString(cachedFormattedNumberColumn); + String cachedPhotoUri = cursor.getString(cachedPhotoUriColumn); + long cachedPhotoId = cursor.getLong(cachedPhotoIdColumn); + String cachedLookupUri = cursor.getString(cachedLookupUriColumn); + int cachedNumberType = cursor.getInt(cachedNumberTypeColumn); + String cachedNumberLabel = cursor.getString(cachedNumberLabelColumn); + int isRead = cursor.getInt(isReadColumn); + int isNew = cursor.getInt(newColumn); + String geocodedLocation = cursor.getString(geocodedLocationColumn); + String phoneAccountComponentName = cursor.getString(phoneAccountComponentColumn); + String phoneAccountId = cursor.getString(phoneAccountIdColumn); + int features = cursor.getInt(featuresColumn); + + ContentValues contentValues = new ContentValues(); + contentValues.put(AnnotatedCallLog.TIMESTAMP, date); + + if (!TextUtils.isEmpty(numberAsStr)) { + byte[] numberAsProtoBytes = + dialerPhoneNumberUtil.parse(numberAsStr, countryIso).toByteArray(); + // TODO(zachh): Need to handle post-dial digits; different on N and M. + contentValues.put(AnnotatedCallLog.NUMBER, numberAsProtoBytes); + } - // TODO: Implementation. + contentValues.put(AnnotatedCallLog.TYPE, type); + contentValues.put(AnnotatedCallLog.NAME, cachedName); + contentValues.put(AnnotatedCallLog.FORMATTED_NUMBER, formattedNumber); + contentValues.put(AnnotatedCallLog.PHOTO_URI, cachedPhotoUri); + contentValues.put(AnnotatedCallLog.PHOTO_ID, cachedPhotoId); + contentValues.put(AnnotatedCallLog.LOOKUP_URI, cachedLookupUri); + + // Phone.getTypeLabel returns "Custom" if given (0, null) which is not of any use. Just + // omit setting the label if there's no information for it. + if (cachedNumberType != 0 || cachedNumberLabel != null) { + contentValues.put( + AnnotatedCallLog.NUMBER_TYPE_LABEL, + Phone.getTypeLabel(appContext.getResources(), cachedNumberType, cachedNumberLabel) + .toString()); + } + contentValues.put(AnnotatedCallLog.IS_READ, isRead); + contentValues.put(AnnotatedCallLog.NEW, isNew); + contentValues.put(AnnotatedCallLog.GEOCODED_LOCATION, geocodedLocation); + populatePhoneAccountLabelAndColor( + appContext, contentValues, phoneAccountComponentName, phoneAccountId); + contentValues.put(AnnotatedCallLog.FEATURES, features); + + if (existingAnnotatedCallLogIds.contains(id)) { + mutations.update(id, contentValues); + } else { + mutations.insert(id, contentValues); + } + } while (cursor.moveToNext()); + } // else no new results, do nothing. + } + } + + private void populatePhoneAccountLabelAndColor( + Context appContext, + ContentValues contentValues, + String phoneAccountComponentName, + String phoneAccountId) { + PhoneAccountHandle phoneAccountHandle = + PhoneAccountUtils.getAccount(phoneAccountComponentName, phoneAccountId); + if (phoneAccountHandle == null) { + return; + } + String label = PhoneAccountUtils.getAccountLabel(appContext, phoneAccountHandle); + if (TextUtils.isEmpty(label)) { + return; + } + contentValues.put(AnnotatedCallLog.PHONE_ACCOUNT_LABEL, label); + + @ColorInt int color = PhoneAccountUtils.getAccountColor(appContext, phoneAccountHandle); + if (color == PhoneAccount.NO_HIGHLIGHT_COLOR) { + color = + appContext + .getResources() + .getColor(R.color.dialer_secondary_text_color, appContext.getTheme()); + } + contentValues.put(AnnotatedCallLog.PHONE_ACCOUNT_COLOR, color); + } + + private static void handleDeletes( + Context appContext, Set<Long> existingAnnotatedCallLogIds, CallLogMutations mutations) { + Set<Long> systemCallLogIds = + getIdsFromSystemCallLogThatMatch(appContext, existingAnnotatedCallLogIds); + LogUtil.i( + "SystemCallLogDataSource.handleDeletes", + "found %d matching 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..adb7a0742 --- /dev/null +++ b/java/com/android/dialer/calllog/datasources/util/RowCombiner.java @@ -0,0 +1,73 @@ +/* + * 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; +import java.util.Objects; + +/** 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; + } + + /** Use the most recent value for the specified column. */ + public RowCombiner useMostRecentString(String columnName) { + combinedRow.put(columnName, individualRowsSortedByTimestampDesc.get(0).getAsString(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()) { + String current = iterator.next().getAsString(columnName); + Assert.checkState(Objects.equals(singleValue, current), "Values different for " + columnName); + } + combinedRow.put(columnName, singleValue); + return this; + } + + /** Asserts that all column values for the given column name are the same, and uses it. */ + public RowCombiner useSingleValueLong(String columnName) { + Iterator<ContentValues> iterator = individualRowsSortedByTimestampDesc.iterator(); + Long singleValue = iterator.next().getAsLong(columnName); + while (iterator.hasNext()) { + Long current = iterator.next().getAsLong(columnName); + Assert.checkState(Objects.equals(singleValue, current), "Values different for " + columnName); + } + combinedRow.put(columnName, singleValue); + return this; + } + + public ContentValues combine() { + return combinedRow; + } +} |