387 lines
17 KiB
Java
387 lines
17 KiB
Java
/*
|
|
* 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.database.Cursor;
|
|
import android.database.StaleDataException;
|
|
import android.provider.CallLog.Calls;
|
|
import android.support.annotation.NonNull;
|
|
import android.support.annotation.WorkerThread;
|
|
import android.telecom.PhoneAccountHandle;
|
|
import android.text.TextUtils;
|
|
import com.android.dialer.CoalescedIds;
|
|
import com.android.dialer.DialerPhoneNumber;
|
|
import com.android.dialer.NumberAttributes;
|
|
import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.AnnotatedCallLog;
|
|
import com.android.dialer.calllog.model.CoalescedRow;
|
|
import com.android.dialer.common.Assert;
|
|
import com.android.dialer.common.concurrent.Annotations.BackgroundExecutor;
|
|
import com.android.dialer.compat.telephony.TelephonyManagerCompat;
|
|
import com.android.dialer.metrics.FutureTimer;
|
|
import com.android.dialer.metrics.Metrics;
|
|
import com.android.dialer.phonenumberproto.DialerPhoneNumberUtil;
|
|
import com.android.dialer.telecom.TelecomUtil;
|
|
import com.google.common.collect.ImmutableList;
|
|
import com.google.common.util.concurrent.ListenableFuture;
|
|
import com.google.common.util.concurrent.ListeningExecutorService;
|
|
import com.google.protobuf.InvalidProtocolBufferException;
|
|
import java.util.Objects;
|
|
import javax.inject.Inject;
|
|
|
|
/** Combines adjacent rows in {@link AnnotatedCallLog}. */
|
|
public class Coalescer {
|
|
|
|
private final FutureTimer futureTimer;
|
|
private final ListeningExecutorService backgroundExecutorService;
|
|
|
|
@Inject
|
|
Coalescer(
|
|
@BackgroundExecutor ListeningExecutorService backgroundExecutorService,
|
|
FutureTimer futureTimer) {
|
|
this.backgroundExecutorService = backgroundExecutorService;
|
|
this.futureTimer = futureTimer;
|
|
}
|
|
|
|
/**
|
|
* Given rows from {@link AnnotatedCallLog}, combine adjacent ones which should be collapsed for
|
|
* display purposes.
|
|
*
|
|
* @param allAnnotatedCallLogRowsSortedByTimestampDesc {@link AnnotatedCallLog} rows sorted in
|
|
* descending order of timestamp.
|
|
* @return a future of a list of {@link CoalescedRow coalesced rows}, which will be used to
|
|
* display call log entries.
|
|
*/
|
|
public ListenableFuture<ImmutableList<CoalescedRow>> coalesce(
|
|
@NonNull Cursor allAnnotatedCallLogRowsSortedByTimestampDesc) {
|
|
ListenableFuture<ImmutableList<CoalescedRow>> coalescingFuture =
|
|
backgroundExecutorService.submit(
|
|
() -> coalesceInternal(Assert.isNotNull(allAnnotatedCallLogRowsSortedByTimestampDesc)));
|
|
futureTimer.applyTiming(coalescingFuture, Metrics.NEW_CALL_LOG_COALESCE);
|
|
return coalescingFuture;
|
|
}
|
|
|
|
/**
|
|
* Reads the entire {@link AnnotatedCallLog} into memory from the provided cursor and then builds
|
|
* and returns a list of {@link CoalescedRow coalesced rows}, which is the result of combining
|
|
* adjacent rows which should be collapsed for display purposes.
|
|
*
|
|
* @param allAnnotatedCallLogRowsSortedByTimestampDesc {@link AnnotatedCallLog} rows sorted in
|
|
* descending order of timestamp.
|
|
* @return a list of {@link CoalescedRow coalesced rows}, which will be used to display call log
|
|
* entries.
|
|
*/
|
|
@WorkerThread
|
|
@NonNull
|
|
private ImmutableList<CoalescedRow> coalesceInternal(
|
|
Cursor allAnnotatedCallLogRowsSortedByTimestampDesc) throws ExpectedCoalescerException {
|
|
Assert.isWorkerThread();
|
|
|
|
ImmutableList.Builder<CoalescedRow> coalescedRowListBuilder = new ImmutableList.Builder<>();
|
|
|
|
try {
|
|
if (!allAnnotatedCallLogRowsSortedByTimestampDesc.moveToFirst()) {
|
|
return ImmutableList.of();
|
|
}
|
|
|
|
RowCombiner rowCombiner = new RowCombiner(allAnnotatedCallLogRowsSortedByTimestampDesc);
|
|
rowCombiner.startNewGroup();
|
|
|
|
long coalescedRowId = 0;
|
|
do {
|
|
boolean isRowMerged = rowCombiner.mergeRow(allAnnotatedCallLogRowsSortedByTimestampDesc);
|
|
|
|
if (isRowMerged) {
|
|
allAnnotatedCallLogRowsSortedByTimestampDesc.moveToNext();
|
|
}
|
|
|
|
if (!isRowMerged || allAnnotatedCallLogRowsSortedByTimestampDesc.isAfterLast()) {
|
|
coalescedRowListBuilder.add(
|
|
rowCombiner.combine().toBuilder().setId(coalescedRowId++).build());
|
|
rowCombiner.startNewGroup();
|
|
}
|
|
} while (!allAnnotatedCallLogRowsSortedByTimestampDesc.isAfterLast());
|
|
|
|
return coalescedRowListBuilder.build();
|
|
|
|
} catch (Exception exception) {
|
|
// Coalescing can fail if cursor "allAnnotatedCallLogRowsSortedByTimestampDesc" is closed by
|
|
// its loader while the work is still in progress.
|
|
//
|
|
// This can happen when the loader restarts and finishes loading data before the coalescing
|
|
// work is completed.
|
|
//
|
|
// This kind of failure doesn't have to crash the app as coalescing will be restarted on the
|
|
// latest data obtained by the loader. Therefore, we inspect the exception here and throw an
|
|
// ExpectedCoalescerException if it is the case described above.
|
|
//
|
|
// The type of expected exception depends on whether AbstractWindowedCursor#checkPosition() is
|
|
// called when the cursor is closed.
|
|
// (1) If it is called before the cursor is closed, we will get IllegalStateException thrown
|
|
// by SQLiteClosable when it attempts to acquire a reference to the database.
|
|
// (2) Otherwise, we will get StaleDataException thrown by AbstractWindowedCursor's
|
|
// checkPosition() method.
|
|
//
|
|
// Note that it would be more accurate to inspect the stack trace to locate the origin of the
|
|
// exception. However, according to the documentation on Throwable#getStackTrace, "some
|
|
// virtual machines may, under some circumstances, omit one or more stack frames from the
|
|
// stack trace". "In the extreme case, a virtual machine that has no stack trace information
|
|
// concerning this throwable is permitted to return a zero-length array from this method."
|
|
// Therefore, the best we can do is to inspect the message in the exception.
|
|
// TODO(linyuh): try to avoid the expected failure.
|
|
String message = exception.getMessage();
|
|
if (message != null
|
|
&& ((exception instanceof StaleDataException
|
|
&& message.startsWith("Attempting to access a closed CursorWindow"))
|
|
|| (exception instanceof IllegalStateException
|
|
&& message.startsWith("attempt to re-open an already-closed object")))) {
|
|
throw new ExpectedCoalescerException(exception);
|
|
}
|
|
|
|
throw exception;
|
|
}
|
|
}
|
|
|
|
/** Combines rows from {@link AnnotatedCallLog} into a {@link CoalescedRow}. */
|
|
private static final class RowCombiner {
|
|
private final CoalescedRow.Builder coalescedRowBuilder = CoalescedRow.newBuilder();
|
|
private final CoalescedIds.Builder coalescedIdsBuilder = CoalescedIds.newBuilder();
|
|
|
|
// Indexes for columns in AnnotatedCallLog
|
|
private final int idColumn;
|
|
private final int timestampColumn;
|
|
private final int numberColumn;
|
|
private final int formattedNumberColumn;
|
|
private final int numberPresentationColumn;
|
|
private final int isReadColumn;
|
|
private final int isNewColumn;
|
|
private final int geocodedLocationColumn;
|
|
private final int phoneAccountComponentNameColumn;
|
|
private final int phoneAccountIdColumn;
|
|
private final int featuresColumn;
|
|
private final int numberAttributesColumn;
|
|
private final int isVoicemailCallColumn;
|
|
private final int voicemailCallTagColumn;
|
|
private final int callTypeColumn;
|
|
|
|
// DialerPhoneNumberUtil will be created lazily as its instantiation is expensive.
|
|
private DialerPhoneNumberUtil dialerPhoneNumberUtil = null;
|
|
|
|
RowCombiner(Cursor annotatedCallLogRow) {
|
|
idColumn = annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog._ID);
|
|
timestampColumn = annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.TIMESTAMP);
|
|
numberColumn = annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.NUMBER);
|
|
formattedNumberColumn =
|
|
annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.FORMATTED_NUMBER);
|
|
numberPresentationColumn =
|
|
annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.NUMBER_PRESENTATION);
|
|
isReadColumn = annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.IS_READ);
|
|
isNewColumn = annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.NEW);
|
|
geocodedLocationColumn =
|
|
annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.GEOCODED_LOCATION);
|
|
phoneAccountComponentNameColumn =
|
|
annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.PHONE_ACCOUNT_COMPONENT_NAME);
|
|
phoneAccountIdColumn =
|
|
annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.PHONE_ACCOUNT_ID);
|
|
featuresColumn = annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.FEATURES);
|
|
numberAttributesColumn =
|
|
annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.NUMBER_ATTRIBUTES);
|
|
isVoicemailCallColumn =
|
|
annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.IS_VOICEMAIL_CALL);
|
|
voicemailCallTagColumn =
|
|
annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.VOICEMAIL_CALL_TAG);
|
|
callTypeColumn = annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.CALL_TYPE);
|
|
}
|
|
|
|
/**
|
|
* Prepares {@link RowCombiner} for building a new group of rows by clearing information on all
|
|
* previously merged rows.
|
|
*/
|
|
void startNewGroup() {
|
|
coalescedRowBuilder.clear();
|
|
coalescedIdsBuilder.clear();
|
|
}
|
|
|
|
/**
|
|
* Merge the given {@link AnnotatedCallLog} row into the current group.
|
|
*
|
|
* @return true if the given row is merged.
|
|
*/
|
|
boolean mergeRow(Cursor annotatedCallLogRow) {
|
|
Assert.checkArgument(annotatedCallLogRow.getInt(callTypeColumn) != Calls.VOICEMAIL_TYPE);
|
|
|
|
if (!canMergeRow(annotatedCallLogRow)) {
|
|
return false;
|
|
}
|
|
|
|
// Set fields that don't use the most recent value.
|
|
//
|
|
// Currently there is only one such field: "features".
|
|
// If any call in a group includes a feature (like Wifi/HD), consider the group to have
|
|
// the feature.
|
|
coalescedRowBuilder.setFeatures(
|
|
coalescedRowBuilder.getFeatures() | annotatedCallLogRow.getInt(featuresColumn));
|
|
|
|
// Set fields that use the most recent value.
|
|
// Rows passed to Coalescer are already sorted in descending order of timestamp. If the
|
|
// coalesced ID list is not empty, it means RowCombiner has merged the most recent row in a
|
|
// group and there is no need to continue as we only set fields that use the most recent value
|
|
// from this point forward.
|
|
if (!coalescedIdsBuilder.getCoalescedIdList().isEmpty()) {
|
|
coalescedIdsBuilder.addCoalescedId(annotatedCallLogRow.getInt(idColumn));
|
|
return true;
|
|
}
|
|
|
|
coalescedRowBuilder
|
|
.setTimestamp(annotatedCallLogRow.getLong(timestampColumn))
|
|
.setNumberPresentation(annotatedCallLogRow.getInt(numberPresentationColumn))
|
|
.setIsRead(annotatedCallLogRow.getInt(isReadColumn) == 1)
|
|
.setIsNew(annotatedCallLogRow.getInt(isNewColumn) == 1)
|
|
.setIsVoicemailCall(annotatedCallLogRow.getInt(isVoicemailCallColumn) == 1)
|
|
.setCallType(annotatedCallLogRow.getInt(callTypeColumn));
|
|
|
|
// Two different DialerPhoneNumbers could be combined if they are different but considered
|
|
// to be a match by libphonenumber; in this case we arbitrarily select the most recent one.
|
|
try {
|
|
coalescedRowBuilder.setNumber(
|
|
DialerPhoneNumber.parseFrom(annotatedCallLogRow.getBlob(numberColumn)));
|
|
} catch (InvalidProtocolBufferException e) {
|
|
throw Assert.createAssertionFailException("Unable to parse DialerPhoneNumber bytes", e);
|
|
}
|
|
|
|
String formattedNumber = annotatedCallLogRow.getString(formattedNumberColumn);
|
|
if (!TextUtils.isEmpty(formattedNumber)) {
|
|
coalescedRowBuilder.setFormattedNumber(formattedNumber);
|
|
}
|
|
|
|
String geocodedLocation = annotatedCallLogRow.getString(geocodedLocationColumn);
|
|
if (!TextUtils.isEmpty(geocodedLocation)) {
|
|
coalescedRowBuilder.setGeocodedLocation(geocodedLocation);
|
|
}
|
|
|
|
String phoneAccountComponentName =
|
|
annotatedCallLogRow.getString(phoneAccountComponentNameColumn);
|
|
if (!TextUtils.isEmpty(phoneAccountComponentName)) {
|
|
coalescedRowBuilder.setPhoneAccountComponentName(phoneAccountComponentName);
|
|
}
|
|
|
|
String phoneAccountId = annotatedCallLogRow.getString(phoneAccountIdColumn);
|
|
if (!TextUtils.isEmpty(phoneAccountId)) {
|
|
coalescedRowBuilder.setPhoneAccountId(phoneAccountId);
|
|
}
|
|
|
|
try {
|
|
coalescedRowBuilder.setNumberAttributes(
|
|
NumberAttributes.parseFrom(annotatedCallLogRow.getBlob(numberAttributesColumn)));
|
|
} catch (InvalidProtocolBufferException e) {
|
|
throw Assert.createAssertionFailException("Unable to parse NumberAttributes bytes", e);
|
|
}
|
|
|
|
String voicemailCallTag = annotatedCallLogRow.getString(voicemailCallTagColumn);
|
|
if (!TextUtils.isEmpty(voicemailCallTag)) {
|
|
coalescedRowBuilder.setVoicemailCallTag(voicemailCallTag);
|
|
}
|
|
|
|
coalescedIdsBuilder.addCoalescedId(annotatedCallLogRow.getInt(idColumn));
|
|
return true;
|
|
}
|
|
|
|
/** Builds a {@link CoalescedRow} based on all rows merged into the current group. */
|
|
CoalescedRow combine() {
|
|
return coalescedRowBuilder.setCoalescedIds(coalescedIdsBuilder.build()).build();
|
|
}
|
|
|
|
/**
|
|
* Returns true if the given {@link AnnotatedCallLog} row can be merged into the current group.
|
|
*/
|
|
private boolean canMergeRow(Cursor annotatedCallLogRow) {
|
|
return coalescedIdsBuilder.getCoalescedIdList().isEmpty()
|
|
|| (samePhoneAccount(annotatedCallLogRow)
|
|
&& sameNumberPresentation(annotatedCallLogRow)
|
|
&& meetsCallFeatureCriteria(annotatedCallLogRow)
|
|
&& meetsDialerPhoneNumberCriteria(annotatedCallLogRow));
|
|
}
|
|
|
|
private boolean samePhoneAccount(Cursor annotatedCallLogRow) {
|
|
PhoneAccountHandle groupPhoneAccountHandle =
|
|
TelecomUtil.composePhoneAccountHandle(
|
|
coalescedRowBuilder.getPhoneAccountComponentName(),
|
|
coalescedRowBuilder.getPhoneAccountId());
|
|
PhoneAccountHandle rowPhoneAccountHandle =
|
|
TelecomUtil.composePhoneAccountHandle(
|
|
annotatedCallLogRow.getString(phoneAccountComponentNameColumn),
|
|
annotatedCallLogRow.getString(phoneAccountIdColumn));
|
|
|
|
return Objects.equals(groupPhoneAccountHandle, rowPhoneAccountHandle);
|
|
}
|
|
|
|
private boolean sameNumberPresentation(Cursor annotatedCallLogRow) {
|
|
return coalescedRowBuilder.getNumberPresentation()
|
|
== annotatedCallLogRow.getInt(numberPresentationColumn);
|
|
}
|
|
|
|
private boolean meetsCallFeatureCriteria(Cursor annotatedCallLogRow) {
|
|
int groupFeatures = coalescedRowBuilder.getFeatures();
|
|
int rowFeatures = annotatedCallLogRow.getInt(featuresColumn);
|
|
|
|
// A row with FEATURES_ASSISTED_DIALING should not be combined with one without it.
|
|
if ((groupFeatures & TelephonyManagerCompat.FEATURES_ASSISTED_DIALING)
|
|
!= (rowFeatures & TelephonyManagerCompat.FEATURES_ASSISTED_DIALING)) {
|
|
return false;
|
|
}
|
|
|
|
// A video call should not be combined with one that is not a video call.
|
|
if ((groupFeatures & Calls.FEATURES_VIDEO) != (rowFeatures & Calls.FEATURES_VIDEO)) {
|
|
return false;
|
|
}
|
|
|
|
// A RTT call should not be combined with one that is not a RTT call.
|
|
if ((groupFeatures & Calls.FEATURES_RTT) != (rowFeatures & Calls.FEATURES_RTT)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private boolean meetsDialerPhoneNumberCriteria(Cursor annotatedCallLogRow) {
|
|
DialerPhoneNumber groupPhoneNumber = coalescedRowBuilder.getNumber();
|
|
|
|
DialerPhoneNumber rowPhoneNumber;
|
|
try {
|
|
byte[] rowPhoneNumberBytes = annotatedCallLogRow.getBlob(numberColumn);
|
|
if (rowPhoneNumberBytes == null) {
|
|
return false; // Empty numbers should not be combined.
|
|
}
|
|
rowPhoneNumber = DialerPhoneNumber.parseFrom(rowPhoneNumberBytes);
|
|
} catch (InvalidProtocolBufferException e) {
|
|
throw Assert.createAssertionFailException("Unable to parse DialerPhoneNumber bytes", e);
|
|
}
|
|
|
|
if (dialerPhoneNumberUtil == null) {
|
|
dialerPhoneNumberUtil = new DialerPhoneNumberUtil();
|
|
}
|
|
|
|
return dialerPhoneNumberUtil.isMatch(groupPhoneNumber, rowPhoneNumber);
|
|
}
|
|
}
|
|
|
|
/** A checked exception thrown when expected failure happens when coalescing is in progress. */
|
|
public static final class ExpectedCoalescerException extends Exception {
|
|
ExpectedCoalescerException(Throwable throwable) {
|
|
super("Expected coalescing exception", throwable);
|
|
}
|
|
}
|
|
}
|