1 /* 2 * Copyright (C) 2017 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License 15 */ 16 package com.android.dialer.calllog.database; 17 18 import android.database.Cursor; 19 import android.database.StaleDataException; 20 import android.provider.CallLog.Calls; 21 import android.support.annotation.NonNull; 22 import android.support.annotation.WorkerThread; 23 import android.telecom.PhoneAccountHandle; 24 import android.text.TextUtils; 25 import com.android.dialer.CoalescedIds; 26 import com.android.dialer.DialerPhoneNumber; 27 import com.android.dialer.NumberAttributes; 28 import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.AnnotatedCallLog; 29 import com.android.dialer.calllog.model.CoalescedRow; 30 import com.android.dialer.common.Assert; 31 import com.android.dialer.common.concurrent.Annotations.BackgroundExecutor; 32 import com.android.dialer.compat.telephony.TelephonyManagerCompat; 33 import com.android.dialer.metrics.FutureTimer; 34 import com.android.dialer.metrics.Metrics; 35 import com.android.dialer.phonenumberproto.DialerPhoneNumberUtil; 36 import com.android.dialer.telecom.TelecomUtil; 37 import com.google.common.collect.ImmutableList; 38 import com.google.common.util.concurrent.ListenableFuture; 39 import com.google.common.util.concurrent.ListeningExecutorService; 40 import com.google.protobuf.InvalidProtocolBufferException; 41 import java.util.Objects; 42 import javax.inject.Inject; 43 44 /** Combines adjacent rows in {@link AnnotatedCallLog}. */ 45 public class Coalescer { 46 47 private final FutureTimer futureTimer; 48 private final ListeningExecutorService backgroundExecutorService; 49 50 @Inject Coalescer( @ackgroundExecutor ListeningExecutorService backgroundExecutorService, FutureTimer futureTimer)51 Coalescer( 52 @BackgroundExecutor ListeningExecutorService backgroundExecutorService, 53 FutureTimer futureTimer) { 54 this.backgroundExecutorService = backgroundExecutorService; 55 this.futureTimer = futureTimer; 56 } 57 58 /** 59 * Given rows from {@link AnnotatedCallLog}, combine adjacent ones which should be collapsed for 60 * display purposes. 61 * 62 * @param allAnnotatedCallLogRowsSortedByTimestampDesc {@link AnnotatedCallLog} rows sorted in 63 * descending order of timestamp. 64 * @return a future of a list of {@link CoalescedRow coalesced rows}, which will be used to 65 * display call log entries. 66 */ coalesce( @onNull Cursor allAnnotatedCallLogRowsSortedByTimestampDesc)67 public ListenableFuture<ImmutableList<CoalescedRow>> coalesce( 68 @NonNull Cursor allAnnotatedCallLogRowsSortedByTimestampDesc) { 69 ListenableFuture<ImmutableList<CoalescedRow>> coalescingFuture = 70 backgroundExecutorService.submit( 71 () -> coalesceInternal(Assert.isNotNull(allAnnotatedCallLogRowsSortedByTimestampDesc))); 72 futureTimer.applyTiming(coalescingFuture, Metrics.NEW_CALL_LOG_COALESCE); 73 return coalescingFuture; 74 } 75 76 /** 77 * Reads the entire {@link AnnotatedCallLog} into memory from the provided cursor and then builds 78 * and returns a list of {@link CoalescedRow coalesced rows}, which is the result of combining 79 * adjacent rows which should be collapsed for display purposes. 80 * 81 * @param allAnnotatedCallLogRowsSortedByTimestampDesc {@link AnnotatedCallLog} rows sorted in 82 * descending order of timestamp. 83 * @return a list of {@link CoalescedRow coalesced rows}, which will be used to display call log 84 * entries. 85 */ 86 @WorkerThread 87 @NonNull coalesceInternal( Cursor allAnnotatedCallLogRowsSortedByTimestampDesc)88 private ImmutableList<CoalescedRow> coalesceInternal( 89 Cursor allAnnotatedCallLogRowsSortedByTimestampDesc) throws ExpectedCoalescerException { 90 Assert.isWorkerThread(); 91 92 ImmutableList.Builder<CoalescedRow> coalescedRowListBuilder = new ImmutableList.Builder<>(); 93 94 try { 95 if (!allAnnotatedCallLogRowsSortedByTimestampDesc.moveToFirst()) { 96 return ImmutableList.of(); 97 } 98 99 RowCombiner rowCombiner = new RowCombiner(allAnnotatedCallLogRowsSortedByTimestampDesc); 100 rowCombiner.startNewGroup(); 101 102 long coalescedRowId = 0; 103 do { 104 boolean isRowMerged = rowCombiner.mergeRow(allAnnotatedCallLogRowsSortedByTimestampDesc); 105 106 if (isRowMerged) { 107 allAnnotatedCallLogRowsSortedByTimestampDesc.moveToNext(); 108 } 109 110 if (!isRowMerged || allAnnotatedCallLogRowsSortedByTimestampDesc.isAfterLast()) { 111 coalescedRowListBuilder.add( 112 rowCombiner.combine().toBuilder().setId(coalescedRowId++).build()); 113 rowCombiner.startNewGroup(); 114 } 115 } while (!allAnnotatedCallLogRowsSortedByTimestampDesc.isAfterLast()); 116 117 return coalescedRowListBuilder.build(); 118 119 } catch (Exception exception) { 120 // Coalescing can fail if cursor "allAnnotatedCallLogRowsSortedByTimestampDesc" is closed by 121 // its loader while the work is still in progress. 122 // 123 // This can happen when the loader restarts and finishes loading data before the coalescing 124 // work is completed. 125 // 126 // This kind of failure doesn't have to crash the app as coalescing will be restarted on the 127 // latest data obtained by the loader. Therefore, we inspect the exception here and throw an 128 // ExpectedCoalescerException if it is the case described above. 129 // 130 // The type of expected exception depends on whether AbstractWindowedCursor#checkPosition() is 131 // called when the cursor is closed. 132 // (1) If it is called before the cursor is closed, we will get IllegalStateException thrown 133 // by SQLiteClosable when it attempts to acquire a reference to the database. 134 // (2) Otherwise, we will get StaleDataException thrown by AbstractWindowedCursor's 135 // checkPosition() method. 136 // 137 // Note that it would be more accurate to inspect the stack trace to locate the origin of the 138 // exception. However, according to the documentation on Throwable#getStackTrace, "some 139 // virtual machines may, under some circumstances, omit one or more stack frames from the 140 // stack trace". "In the extreme case, a virtual machine that has no stack trace information 141 // concerning this throwable is permitted to return a zero-length array from this method." 142 // Therefore, the best we can do is to inspect the message in the exception. 143 // TODO(linyuh): try to avoid the expected failure. 144 String message = exception.getMessage(); 145 if (message != null 146 && ((exception instanceof StaleDataException 147 && message.startsWith("Attempting to access a closed CursorWindow")) 148 || (exception instanceof IllegalStateException 149 && message.startsWith("attempt to re-open an already-closed object")))) { 150 throw new ExpectedCoalescerException(exception); 151 } 152 153 throw exception; 154 } 155 } 156 157 /** Combines rows from {@link AnnotatedCallLog} into a {@link CoalescedRow}. */ 158 private static final class RowCombiner { 159 private final CoalescedRow.Builder coalescedRowBuilder = CoalescedRow.newBuilder(); 160 private final CoalescedIds.Builder coalescedIdsBuilder = CoalescedIds.newBuilder(); 161 162 // Indexes for columns in AnnotatedCallLog 163 private final int idColumn; 164 private final int timestampColumn; 165 private final int numberColumn; 166 private final int formattedNumberColumn; 167 private final int numberPresentationColumn; 168 private final int isReadColumn; 169 private final int isNewColumn; 170 private final int geocodedLocationColumn; 171 private final int phoneAccountComponentNameColumn; 172 private final int phoneAccountIdColumn; 173 private final int featuresColumn; 174 private final int numberAttributesColumn; 175 private final int isVoicemailCallColumn; 176 private final int voicemailCallTagColumn; 177 private final int callTypeColumn; 178 179 // DialerPhoneNumberUtil will be created lazily as its instantiation is expensive. 180 private DialerPhoneNumberUtil dialerPhoneNumberUtil = null; 181 RowCombiner(Cursor annotatedCallLogRow)182 RowCombiner(Cursor annotatedCallLogRow) { 183 idColumn = annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog._ID); 184 timestampColumn = annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.TIMESTAMP); 185 numberColumn = annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.NUMBER); 186 formattedNumberColumn = 187 annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.FORMATTED_NUMBER); 188 numberPresentationColumn = 189 annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.NUMBER_PRESENTATION); 190 isReadColumn = annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.IS_READ); 191 isNewColumn = annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.NEW); 192 geocodedLocationColumn = 193 annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.GEOCODED_LOCATION); 194 phoneAccountComponentNameColumn = 195 annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.PHONE_ACCOUNT_COMPONENT_NAME); 196 phoneAccountIdColumn = 197 annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.PHONE_ACCOUNT_ID); 198 featuresColumn = annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.FEATURES); 199 numberAttributesColumn = 200 annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.NUMBER_ATTRIBUTES); 201 isVoicemailCallColumn = 202 annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.IS_VOICEMAIL_CALL); 203 voicemailCallTagColumn = 204 annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.VOICEMAIL_CALL_TAG); 205 callTypeColumn = annotatedCallLogRow.getColumnIndexOrThrow(AnnotatedCallLog.CALL_TYPE); 206 } 207 208 /** 209 * Prepares {@link RowCombiner} for building a new group of rows by clearing information on all 210 * previously merged rows. 211 */ startNewGroup()212 void startNewGroup() { 213 coalescedRowBuilder.clear(); 214 coalescedIdsBuilder.clear(); 215 } 216 217 /** 218 * Merge the given {@link AnnotatedCallLog} row into the current group. 219 * 220 * @return true if the given row is merged. 221 */ mergeRow(Cursor annotatedCallLogRow)222 boolean mergeRow(Cursor annotatedCallLogRow) { 223 Assert.checkArgument(annotatedCallLogRow.getInt(callTypeColumn) != Calls.VOICEMAIL_TYPE); 224 225 if (!canMergeRow(annotatedCallLogRow)) { 226 return false; 227 } 228 229 // Set fields that don't use the most recent value. 230 // 231 // Currently there is only one such field: "features". 232 // If any call in a group includes a feature (like Wifi/HD), consider the group to have 233 // the feature. 234 coalescedRowBuilder.setFeatures( 235 coalescedRowBuilder.getFeatures() | annotatedCallLogRow.getInt(featuresColumn)); 236 237 // Set fields that use the most recent value. 238 // Rows passed to Coalescer are already sorted in descending order of timestamp. If the 239 // coalesced ID list is not empty, it means RowCombiner has merged the most recent row in a 240 // group and there is no need to continue as we only set fields that use the most recent value 241 // from this point forward. 242 if (!coalescedIdsBuilder.getCoalescedIdList().isEmpty()) { 243 coalescedIdsBuilder.addCoalescedId(annotatedCallLogRow.getInt(idColumn)); 244 return true; 245 } 246 247 coalescedRowBuilder 248 .setTimestamp(annotatedCallLogRow.getLong(timestampColumn)) 249 .setNumberPresentation(annotatedCallLogRow.getInt(numberPresentationColumn)) 250 .setIsRead(annotatedCallLogRow.getInt(isReadColumn) == 1) 251 .setIsNew(annotatedCallLogRow.getInt(isNewColumn) == 1) 252 .setIsVoicemailCall(annotatedCallLogRow.getInt(isVoicemailCallColumn) == 1) 253 .setCallType(annotatedCallLogRow.getInt(callTypeColumn)); 254 255 // Two different DialerPhoneNumbers could be combined if they are different but considered 256 // to be a match by libphonenumber; in this case we arbitrarily select the most recent one. 257 try { 258 coalescedRowBuilder.setNumber( 259 DialerPhoneNumber.parseFrom(annotatedCallLogRow.getBlob(numberColumn))); 260 } catch (InvalidProtocolBufferException e) { 261 throw Assert.createAssertionFailException("Unable to parse DialerPhoneNumber bytes", e); 262 } 263 264 String formattedNumber = annotatedCallLogRow.getString(formattedNumberColumn); 265 if (!TextUtils.isEmpty(formattedNumber)) { 266 coalescedRowBuilder.setFormattedNumber(formattedNumber); 267 } 268 269 String geocodedLocation = annotatedCallLogRow.getString(geocodedLocationColumn); 270 if (!TextUtils.isEmpty(geocodedLocation)) { 271 coalescedRowBuilder.setGeocodedLocation(geocodedLocation); 272 } 273 274 String phoneAccountComponentName = 275 annotatedCallLogRow.getString(phoneAccountComponentNameColumn); 276 if (!TextUtils.isEmpty(phoneAccountComponentName)) { 277 coalescedRowBuilder.setPhoneAccountComponentName(phoneAccountComponentName); 278 } 279 280 String phoneAccountId = annotatedCallLogRow.getString(phoneAccountIdColumn); 281 if (!TextUtils.isEmpty(phoneAccountId)) { 282 coalescedRowBuilder.setPhoneAccountId(phoneAccountId); 283 } 284 285 try { 286 coalescedRowBuilder.setNumberAttributes( 287 NumberAttributes.parseFrom(annotatedCallLogRow.getBlob(numberAttributesColumn))); 288 } catch (InvalidProtocolBufferException e) { 289 throw Assert.createAssertionFailException("Unable to parse NumberAttributes bytes", e); 290 } 291 292 String voicemailCallTag = annotatedCallLogRow.getString(voicemailCallTagColumn); 293 if (!TextUtils.isEmpty(voicemailCallTag)) { 294 coalescedRowBuilder.setVoicemailCallTag(voicemailCallTag); 295 } 296 297 coalescedIdsBuilder.addCoalescedId(annotatedCallLogRow.getInt(idColumn)); 298 return true; 299 } 300 301 /** Builds a {@link CoalescedRow} based on all rows merged into the current group. */ combine()302 CoalescedRow combine() { 303 return coalescedRowBuilder.setCoalescedIds(coalescedIdsBuilder.build()).build(); 304 } 305 306 /** 307 * Returns true if the given {@link AnnotatedCallLog} row can be merged into the current group. 308 */ canMergeRow(Cursor annotatedCallLogRow)309 private boolean canMergeRow(Cursor annotatedCallLogRow) { 310 return coalescedIdsBuilder.getCoalescedIdList().isEmpty() 311 || (samePhoneAccount(annotatedCallLogRow) 312 && sameNumberPresentation(annotatedCallLogRow) 313 && meetsCallFeatureCriteria(annotatedCallLogRow) 314 && meetsDialerPhoneNumberCriteria(annotatedCallLogRow)); 315 } 316 samePhoneAccount(Cursor annotatedCallLogRow)317 private boolean samePhoneAccount(Cursor annotatedCallLogRow) { 318 PhoneAccountHandle groupPhoneAccountHandle = 319 TelecomUtil.composePhoneAccountHandle( 320 coalescedRowBuilder.getPhoneAccountComponentName(), 321 coalescedRowBuilder.getPhoneAccountId()); 322 PhoneAccountHandle rowPhoneAccountHandle = 323 TelecomUtil.composePhoneAccountHandle( 324 annotatedCallLogRow.getString(phoneAccountComponentNameColumn), 325 annotatedCallLogRow.getString(phoneAccountIdColumn)); 326 327 return Objects.equals(groupPhoneAccountHandle, rowPhoneAccountHandle); 328 } 329 sameNumberPresentation(Cursor annotatedCallLogRow)330 private boolean sameNumberPresentation(Cursor annotatedCallLogRow) { 331 return coalescedRowBuilder.getNumberPresentation() 332 == annotatedCallLogRow.getInt(numberPresentationColumn); 333 } 334 meetsCallFeatureCriteria(Cursor annotatedCallLogRow)335 private boolean meetsCallFeatureCriteria(Cursor annotatedCallLogRow) { 336 int groupFeatures = coalescedRowBuilder.getFeatures(); 337 int rowFeatures = annotatedCallLogRow.getInt(featuresColumn); 338 339 // A row with FEATURES_ASSISTED_DIALING should not be combined with one without it. 340 if ((groupFeatures & TelephonyManagerCompat.FEATURES_ASSISTED_DIALING) 341 != (rowFeatures & TelephonyManagerCompat.FEATURES_ASSISTED_DIALING)) { 342 return false; 343 } 344 345 // A video call should not be combined with one that is not a video call. 346 if ((groupFeatures & Calls.FEATURES_VIDEO) != (rowFeatures & Calls.FEATURES_VIDEO)) { 347 return false; 348 } 349 350 // A RTT call should not be combined with one that is not a RTT call. 351 if ((groupFeatures & Calls.FEATURES_RTT) != (rowFeatures & Calls.FEATURES_RTT)) { 352 return false; 353 } 354 355 return true; 356 } 357 meetsDialerPhoneNumberCriteria(Cursor annotatedCallLogRow)358 private boolean meetsDialerPhoneNumberCriteria(Cursor annotatedCallLogRow) { 359 DialerPhoneNumber groupPhoneNumber = coalescedRowBuilder.getNumber(); 360 361 DialerPhoneNumber rowPhoneNumber; 362 try { 363 byte[] rowPhoneNumberBytes = annotatedCallLogRow.getBlob(numberColumn); 364 if (rowPhoneNumberBytes == null) { 365 return false; // Empty numbers should not be combined. 366 } 367 rowPhoneNumber = DialerPhoneNumber.parseFrom(rowPhoneNumberBytes); 368 } catch (InvalidProtocolBufferException e) { 369 throw Assert.createAssertionFailException("Unable to parse DialerPhoneNumber bytes", e); 370 } 371 372 if (dialerPhoneNumberUtil == null) { 373 dialerPhoneNumberUtil = new DialerPhoneNumberUtil(); 374 } 375 376 return dialerPhoneNumberUtil.isMatch(groupPhoneNumber, rowPhoneNumber); 377 } 378 } 379 380 /** A checked exception thrown when expected failure happens when coalescing is in progress. */ 381 public static final class ExpectedCoalescerException extends Exception { ExpectedCoalescerException(Throwable throwable)382 ExpectedCoalescerException(Throwable throwable) { 383 super("Expected coalescing exception", throwable); 384 } 385 } 386 } 387