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