• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 
17 package com.android.dialer.calllog.datasources.systemcalllog;
18 
19 import android.Manifest.permission;
20 import android.annotation.TargetApi;
21 import android.content.ContentValues;
22 import android.content.Context;
23 import android.database.Cursor;
24 import android.os.Build;
25 import android.os.Build.VERSION;
26 import android.os.Build.VERSION_CODES;
27 import android.provider.CallLog;
28 import android.provider.CallLog.Calls;
29 import android.provider.VoicemailContract;
30 import android.provider.VoicemailContract.Voicemails;
31 import android.support.annotation.ColorInt;
32 import android.support.annotation.MainThread;
33 import android.support.annotation.Nullable;
34 import android.support.annotation.RequiresApi;
35 import android.support.annotation.VisibleForTesting;
36 import android.support.annotation.WorkerThread;
37 import android.telecom.PhoneAccount;
38 import android.telecom.PhoneAccountHandle;
39 import android.telephony.PhoneNumberUtils;
40 import android.text.TextUtils;
41 import android.util.ArraySet;
42 import com.android.dialer.DialerPhoneNumber;
43 import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.AnnotatedCallLog;
44 import com.android.dialer.calllog.datasources.CallLogDataSource;
45 import com.android.dialer.calllog.datasources.CallLogMutations;
46 import com.android.dialer.calllog.datasources.util.RowCombiner;
47 import com.android.dialer.calllog.observer.MarkDirtyObserver;
48 import com.android.dialer.calllogutils.PhoneAccountUtils;
49 import com.android.dialer.common.Assert;
50 import com.android.dialer.common.LogUtil;
51 import com.android.dialer.common.concurrent.Annotations.BackgroundExecutor;
52 import com.android.dialer.compat.android.provider.VoicemailCompat;
53 import com.android.dialer.phonenumberproto.DialerPhoneNumberUtil;
54 import com.android.dialer.storage.StorageComponent;
55 import com.android.dialer.telecom.TelecomUtil;
56 import com.android.dialer.theme.R;
57 import com.android.dialer.util.PermissionsUtil;
58 import com.google.common.collect.Iterables;
59 import com.google.common.util.concurrent.ListenableFuture;
60 import com.google.common.util.concurrent.ListeningExecutorService;
61 import com.google.i18n.phonenumbers.PhoneNumberUtil;
62 import java.util.ArrayList;
63 import java.util.Arrays;
64 import java.util.List;
65 import java.util.Set;
66 import javax.inject.Inject;
67 
68 /**
69  * Responsible for defining the rows in the annotated call log and maintaining the columns in it
70  * which are derived from the system call log.
71  */
72 @SuppressWarnings("MissingPermission")
73 public class SystemCallLogDataSource implements CallLogDataSource {
74 
75   @VisibleForTesting
76   static final String PREF_LAST_TIMESTAMP_PROCESSED = "systemCallLogLastTimestampProcessed";
77 
78   private final ListeningExecutorService backgroundExecutorService;
79   private final MarkDirtyObserver markDirtyObserver;
80 
81   @Nullable private Long lastTimestampProcessed;
82 
83   @Inject
SystemCallLogDataSource( @ackgroundExecutor ListeningExecutorService backgroundExecutorService, MarkDirtyObserver markDirtyObserver)84   SystemCallLogDataSource(
85       @BackgroundExecutor ListeningExecutorService backgroundExecutorService,
86       MarkDirtyObserver markDirtyObserver) {
87     this.backgroundExecutorService = backgroundExecutorService;
88     this.markDirtyObserver = markDirtyObserver;
89   }
90 
91   @MainThread
92   @Override
registerContentObservers(Context appContext)93   public void registerContentObservers(Context appContext) {
94     Assert.isMainThread();
95 
96     LogUtil.enterBlock("SystemCallLogDataSource.registerContentObservers");
97 
98     if (!PermissionsUtil.hasCallLogReadPermissions(appContext)) {
99       LogUtil.i("SystemCallLogDataSource.registerContentObservers", "no call log permissions");
100       return;
101     }
102     // TODO(zachh): Need to somehow register observers if user enables permission after launch?
103 
104     // The system call log has a last updated timestamp, but deletes are physical (the "deleted"
105     // column is unused). This means that we can't detect deletes without scanning the entire table,
106     // which would be too slow. So, we just rely on content observers to trigger rebuilds when any
107     // change is made to the system call log.
108     appContext
109         .getContentResolver()
110         .registerContentObserver(CallLog.Calls.CONTENT_URI_WITH_VOICEMAIL, true, markDirtyObserver);
111 
112     if (!PermissionsUtil.hasAddVoicemailPermissions(appContext)) {
113       LogUtil.i("SystemCallLogDataSource.registerContentObservers", "no add voicemail permissions");
114       return;
115     }
116     // TODO(uabdullah): Need to somehow register observers if user enables permission after launch?
117     appContext
118         .getContentResolver()
119         .registerContentObserver(VoicemailContract.Status.CONTENT_URI, true, markDirtyObserver);
120   }
121 
122   @Override
isDirty(Context appContext)123   public ListenableFuture<Boolean> isDirty(Context appContext) {
124     return backgroundExecutorService.submit(() -> isDirtyInternal(appContext));
125   }
126 
127   @Override
fill(Context appContext, CallLogMutations mutations)128   public ListenableFuture<Void> fill(Context appContext, CallLogMutations mutations) {
129     return backgroundExecutorService.submit(() -> fillInternal(appContext, mutations));
130   }
131 
132   @Override
onSuccessfulFill(Context appContext)133   public ListenableFuture<Void> onSuccessfulFill(Context appContext) {
134     return backgroundExecutorService.submit(() -> onSuccessfulFillInternal(appContext));
135   }
136 
137   @WorkerThread
isDirtyInternal(Context appContext)138   private boolean isDirtyInternal(Context appContext) {
139     Assert.isWorkerThread();
140 
141     /*
142      * The system call log has a last updated timestamp, but deletes are physical (the "deleted"
143      * column is unused). This means that we can't detect deletes without scanning the entire table,
144      * which would be too slow. So, we just rely on content observers to trigger rebuilds when any
145      * change is made to the system call log.
146      *
147      * Just return false unless the table has never been written to.
148      */
149     return !StorageComponent.get(appContext)
150         .unencryptedSharedPrefs()
151         .contains(PREF_LAST_TIMESTAMP_PROCESSED);
152   }
153 
154   @WorkerThread
fillInternal(Context appContext, CallLogMutations mutations)155   private Void fillInternal(Context appContext, CallLogMutations mutations) {
156     Assert.isWorkerThread();
157 
158     lastTimestampProcessed = null;
159 
160     if (!PermissionsUtil.hasPermission(appContext, permission.READ_CALL_LOG)) {
161       LogUtil.i("SystemCallLogDataSource.fill", "no call log permissions");
162       return null;
163     }
164 
165     // This data source should always run first so the mutations should always be empty.
166     Assert.checkArgument(mutations.isEmpty());
167 
168     Set<Long> annotatedCallLogIds = getAnnotatedCallLogIds(appContext);
169 
170     LogUtil.i(
171         "SystemCallLogDataSource.fill",
172         "found %d existing annotated call log ids",
173         annotatedCallLogIds.size());
174 
175     handleInsertsAndUpdates(appContext, mutations, annotatedCallLogIds);
176     handleDeletes(appContext, annotatedCallLogIds, mutations);
177     return null;
178   }
179 
180   @WorkerThread
onSuccessfulFillInternal(Context appContext)181   private Void onSuccessfulFillInternal(Context appContext) {
182     // If a fill operation was a no-op, lastTimestampProcessed could still be null.
183     if (lastTimestampProcessed != null) {
184       StorageComponent.get(appContext)
185           .unencryptedSharedPrefs()
186           .edit()
187           .putLong(PREF_LAST_TIMESTAMP_PROCESSED, lastTimestampProcessed)
188           .apply();
189     }
190     return null;
191   }
192 
193   @Override
coalesce(List<ContentValues> individualRowsSortedByTimestampDesc)194   public ContentValues coalesce(List<ContentValues> individualRowsSortedByTimestampDesc) {
195     assertNoVoicemailsInRows(individualRowsSortedByTimestampDesc);
196 
197     return new RowCombiner(individualRowsSortedByTimestampDesc)
198         .useMostRecentLong(AnnotatedCallLog.TIMESTAMP)
199         .useMostRecentLong(AnnotatedCallLog.NEW)
200         // Two different DialerPhoneNumbers could be combined if they are different but considered
201         // to be an "exact match" by libphonenumber; in this case we arbitrarily select the most
202         // recent one.
203         .useMostRecentBlob(AnnotatedCallLog.NUMBER)
204         .useMostRecentString(AnnotatedCallLog.FORMATTED_NUMBER)
205         .useSingleValueInt(AnnotatedCallLog.NUMBER_PRESENTATION)
206         .useMostRecentString(AnnotatedCallLog.GEOCODED_LOCATION)
207         .useSingleValueString(AnnotatedCallLog.PHONE_ACCOUNT_COMPONENT_NAME)
208         .useSingleValueString(AnnotatedCallLog.PHONE_ACCOUNT_ID)
209         .useSingleValueString(AnnotatedCallLog.PHONE_ACCOUNT_LABEL)
210         .useSingleValueLong(AnnotatedCallLog.PHONE_ACCOUNT_COLOR)
211         .useMostRecentLong(AnnotatedCallLog.CALL_TYPE)
212         // If any call in a group includes a feature (like Wifi/HD), consider the group to have the
213         // feature.
214         .bitwiseOr(AnnotatedCallLog.FEATURES)
215         .combine();
216   }
217 
assertNoVoicemailsInRows(List<ContentValues> individualRowsSortedByTimestampDesc)218   private void assertNoVoicemailsInRows(List<ContentValues> individualRowsSortedByTimestampDesc) {
219     for (ContentValues contentValue : individualRowsSortedByTimestampDesc) {
220       if (contentValue.getAsLong(AnnotatedCallLog.CALL_TYPE) != null) {
221         Assert.checkArgument(
222             contentValue.getAsLong(AnnotatedCallLog.CALL_TYPE) != Calls.VOICEMAIL_TYPE);
223       }
224     }
225   }
226 
227   @TargetApi(Build.VERSION_CODES.M) // Uses try-with-resources
handleInsertsAndUpdates( Context appContext, CallLogMutations mutations, Set<Long> existingAnnotatedCallLogIds)228   private void handleInsertsAndUpdates(
229       Context appContext, CallLogMutations mutations, Set<Long> existingAnnotatedCallLogIds) {
230     long previousTimestampProcessed =
231         StorageComponent.get(appContext)
232             .unencryptedSharedPrefs()
233             .getLong(PREF_LAST_TIMESTAMP_PROCESSED, 0L);
234 
235     DialerPhoneNumberUtil dialerPhoneNumberUtil =
236         new DialerPhoneNumberUtil(PhoneNumberUtil.getInstance());
237 
238     // TODO(zachh): Really should be getting last 1000 by timestamp, not by last modified.
239     try (Cursor cursor =
240         appContext
241             .getContentResolver()
242             .query(
243                 Calls.CONTENT_URI_WITH_VOICEMAIL,
244                 getProjection(),
245                 // TODO(a bug): LAST_MODIFIED not available on M
246                 Calls.LAST_MODIFIED + " > ? AND " + Voicemails.DELETED + " = 0",
247                 new String[] {String.valueOf(previousTimestampProcessed)},
248                 Calls.LAST_MODIFIED + " DESC LIMIT 1000")) {
249 
250       if (cursor == null) {
251         LogUtil.e("SystemCallLogDataSource.handleInsertsAndUpdates", "null cursor");
252         return;
253       }
254 
255       LogUtil.i(
256           "SystemCallLogDataSource.handleInsertsAndUpdates",
257           "found %d entries to insert/update",
258           cursor.getCount());
259 
260       if (cursor.moveToFirst()) {
261         int idColumn = cursor.getColumnIndexOrThrow(Calls._ID);
262         int dateColumn = cursor.getColumnIndexOrThrow(Calls.DATE);
263         int lastModifiedColumn = cursor.getColumnIndexOrThrow(Calls.LAST_MODIFIED);
264         int numberColumn = cursor.getColumnIndexOrThrow(Calls.NUMBER);
265         int presentationColumn = cursor.getColumnIndexOrThrow(Calls.NUMBER_PRESENTATION);
266         int typeColumn = cursor.getColumnIndexOrThrow(Calls.TYPE);
267         int countryIsoColumn = cursor.getColumnIndexOrThrow(Calls.COUNTRY_ISO);
268         int durationsColumn = cursor.getColumnIndexOrThrow(Calls.DURATION);
269         int dataUsageColumn = cursor.getColumnIndexOrThrow(Calls.DATA_USAGE);
270         int transcriptionColumn = cursor.getColumnIndexOrThrow(Calls.TRANSCRIPTION);
271         int voicemailUriColumn = cursor.getColumnIndexOrThrow(Calls.VOICEMAIL_URI);
272         int isReadColumn = cursor.getColumnIndexOrThrow(Calls.IS_READ);
273         int newColumn = cursor.getColumnIndexOrThrow(Calls.NEW);
274         int geocodedLocationColumn = cursor.getColumnIndexOrThrow(Calls.GEOCODED_LOCATION);
275         int phoneAccountComponentColumn =
276             cursor.getColumnIndexOrThrow(Calls.PHONE_ACCOUNT_COMPONENT_NAME);
277         int phoneAccountIdColumn = cursor.getColumnIndexOrThrow(Calls.PHONE_ACCOUNT_ID);
278         int featuresColumn = cursor.getColumnIndexOrThrow(Calls.FEATURES);
279         int postDialDigitsColumn = cursor.getColumnIndexOrThrow(Calls.POST_DIAL_DIGITS);
280 
281         // The cursor orders by LAST_MODIFIED DESC, so the first result is the most recent timestamp
282         // processed.
283         lastTimestampProcessed = cursor.getLong(lastModifiedColumn);
284         do {
285           long id = cursor.getLong(idColumn);
286           long date = cursor.getLong(dateColumn);
287           String numberAsStr = cursor.getString(numberColumn);
288           int type;
289           if (cursor.isNull(typeColumn) || (type = cursor.getInt(typeColumn)) == 0) {
290             // CallLog.Calls#TYPE lists the allowed values, which are non-null and non-zero.
291             throw new IllegalStateException("call type is missing");
292           }
293           int presentation;
294           if (cursor.isNull(presentationColumn)
295               || (presentation = cursor.getInt(presentationColumn)) == 0) {
296             // CallLog.Calls#NUMBER_PRESENTATION lists the allowed values, which are non-null and
297             // non-zero.
298             throw new IllegalStateException("presentation is missing");
299           }
300           String countryIso = cursor.getString(countryIsoColumn);
301           int duration = cursor.getInt(durationsColumn);
302           int dataUsage = cursor.getInt(dataUsageColumn);
303           String transcription = cursor.getString(transcriptionColumn);
304           String voicemailUri = cursor.getString(voicemailUriColumn);
305           int isRead = cursor.getInt(isReadColumn);
306           int isNew = cursor.getInt(newColumn);
307           String geocodedLocation = cursor.getString(geocodedLocationColumn);
308           String phoneAccountComponentName = cursor.getString(phoneAccountComponentColumn);
309           String phoneAccountId = cursor.getString(phoneAccountIdColumn);
310           int features = cursor.getInt(featuresColumn);
311           String postDialDigits = cursor.getString(postDialDigitsColumn);
312 
313           ContentValues contentValues = new ContentValues();
314           contentValues.put(AnnotatedCallLog.TIMESTAMP, date);
315 
316           if (!TextUtils.isEmpty(numberAsStr)) {
317             String numberWithPostDialDigits =
318                 postDialDigits == null ? numberAsStr : numberAsStr + postDialDigits;
319             DialerPhoneNumber dialerPhoneNumber =
320                 dialerPhoneNumberUtil.parse(numberWithPostDialDigits, countryIso);
321 
322             contentValues.put(AnnotatedCallLog.NUMBER, dialerPhoneNumber.toByteArray());
323             String formattedNumber =
324                 PhoneNumberUtils.formatNumber(numberWithPostDialDigits, countryIso);
325             if (formattedNumber == null) {
326               formattedNumber = numberWithPostDialDigits;
327             }
328             contentValues.put(AnnotatedCallLog.FORMATTED_NUMBER, formattedNumber);
329           } else {
330             contentValues.put(
331                 AnnotatedCallLog.NUMBER, DialerPhoneNumber.getDefaultInstance().toByteArray());
332           }
333           contentValues.put(AnnotatedCallLog.NUMBER_PRESENTATION, presentation);
334           contentValues.put(AnnotatedCallLog.CALL_TYPE, type);
335           contentValues.put(AnnotatedCallLog.IS_READ, isRead);
336           contentValues.put(AnnotatedCallLog.NEW, isNew);
337           contentValues.put(AnnotatedCallLog.GEOCODED_LOCATION, geocodedLocation);
338           contentValues.put(
339               AnnotatedCallLog.PHONE_ACCOUNT_COMPONENT_NAME, phoneAccountComponentName);
340           contentValues.put(AnnotatedCallLog.PHONE_ACCOUNT_ID, phoneAccountId);
341           populatePhoneAccountLabelAndColor(
342               appContext, contentValues, phoneAccountComponentName, phoneAccountId);
343           contentValues.put(AnnotatedCallLog.FEATURES, features);
344           contentValues.put(AnnotatedCallLog.DURATION, duration);
345           contentValues.put(AnnotatedCallLog.DATA_USAGE, dataUsage);
346           contentValues.put(AnnotatedCallLog.TRANSCRIPTION, transcription);
347           contentValues.put(AnnotatedCallLog.VOICEMAIL_URI, voicemailUri);
348           setTranscriptionState(cursor, contentValues);
349 
350           if (existingAnnotatedCallLogIds.contains(id)) {
351             mutations.update(id, contentValues);
352           } else {
353             mutations.insert(id, contentValues);
354           }
355         } while (cursor.moveToNext());
356       } // else no new results, do nothing.
357     }
358   }
359 
setTranscriptionState(Cursor cursor, ContentValues contentValues)360   private void setTranscriptionState(Cursor cursor, ContentValues contentValues) {
361     if (VERSION.SDK_INT >= VERSION_CODES.O) {
362       int transcriptionStateColumn =
363           cursor.getColumnIndexOrThrow(VoicemailCompat.TRANSCRIPTION_STATE);
364       int transcriptionState = cursor.getInt(transcriptionStateColumn);
365       contentValues.put(VoicemailCompat.TRANSCRIPTION_STATE, transcriptionState);
366     }
367   }
368 
369   private static final String[] PROJECTION_PRE_O =
370       new String[] {
371         Calls._ID,
372         Calls.DATE,
373         Calls.LAST_MODIFIED, // TODO(a bug): Not available in M
374         Calls.NUMBER,
375         Calls.NUMBER_PRESENTATION,
376         Calls.TYPE,
377         Calls.COUNTRY_ISO,
378         Calls.DURATION,
379         Calls.DATA_USAGE,
380         Calls.TRANSCRIPTION,
381         Calls.VOICEMAIL_URI,
382         Calls.IS_READ,
383         Calls.NEW,
384         Calls.GEOCODED_LOCATION,
385         Calls.PHONE_ACCOUNT_COMPONENT_NAME,
386         Calls.PHONE_ACCOUNT_ID,
387         Calls.FEATURES,
388         Calls.POST_DIAL_DIGITS // TODO(a bug): Not available in M
389       };
390 
391   @RequiresApi(VERSION_CODES.O)
392   private static final String[] PROJECTION_O_AND_LATER;
393 
394   static {
395     List<String> projectionList = new ArrayList<>(Arrays.asList(PROJECTION_PRE_O));
396     projectionList.add(VoicemailCompat.TRANSCRIPTION_STATE);
397     PROJECTION_O_AND_LATER = projectionList.toArray(new String[projectionList.size()]);
398   }
399 
getProjection()400   private String[] getProjection() {
401     if (VERSION.SDK_INT >= VERSION_CODES.O) {
402       return PROJECTION_O_AND_LATER;
403     }
404     return PROJECTION_PRE_O;
405   }
406 
populatePhoneAccountLabelAndColor( Context appContext, ContentValues contentValues, String phoneAccountComponentName, String phoneAccountId)407   private void populatePhoneAccountLabelAndColor(
408       Context appContext,
409       ContentValues contentValues,
410       String phoneAccountComponentName,
411       String phoneAccountId) {
412     PhoneAccountHandle phoneAccountHandle =
413         TelecomUtil.composePhoneAccountHandle(phoneAccountComponentName, phoneAccountId);
414     if (phoneAccountHandle == null) {
415       return;
416     }
417     String label = PhoneAccountUtils.getAccountLabel(appContext, phoneAccountHandle);
418     if (TextUtils.isEmpty(label)) {
419       return;
420     }
421     contentValues.put(AnnotatedCallLog.PHONE_ACCOUNT_LABEL, label);
422 
423     @ColorInt int color = PhoneAccountUtils.getAccountColor(appContext, phoneAccountHandle);
424     if (color == PhoneAccount.NO_HIGHLIGHT_COLOR) {
425       color =
426           appContext
427               .getResources()
428               .getColor(R.color.dialer_secondary_text_color, appContext.getTheme());
429     }
430     contentValues.put(AnnotatedCallLog.PHONE_ACCOUNT_COLOR, color);
431   }
432 
handleDeletes( Context appContext, Set<Long> existingAnnotatedCallLogIds, CallLogMutations mutations)433   private static void handleDeletes(
434       Context appContext, Set<Long> existingAnnotatedCallLogIds, CallLogMutations mutations) {
435     Set<Long> systemCallLogIds =
436         getIdsFromSystemCallLogThatMatch(appContext, existingAnnotatedCallLogIds);
437     LogUtil.i(
438         "SystemCallLogDataSource.handleDeletes",
439         "found %d matching entries in system call log",
440         systemCallLogIds.size());
441     Set<Long> idsInAnnotatedCallLogNoLongerInSystemCallLog = new ArraySet<>();
442     idsInAnnotatedCallLogNoLongerInSystemCallLog.addAll(existingAnnotatedCallLogIds);
443     idsInAnnotatedCallLogNoLongerInSystemCallLog.removeAll(systemCallLogIds);
444 
445     LogUtil.i(
446         "SystemCallLogDataSource.handleDeletes",
447         "found %d call log entries to remove",
448         idsInAnnotatedCallLogNoLongerInSystemCallLog.size());
449 
450     for (long id : idsInAnnotatedCallLogNoLongerInSystemCallLog) {
451       mutations.delete(id);
452     }
453   }
454 
455   @TargetApi(Build.VERSION_CODES.M) // Uses try-with-resources
getAnnotatedCallLogIds(Context appContext)456   private static Set<Long> getAnnotatedCallLogIds(Context appContext) {
457     ArraySet<Long> ids = new ArraySet<>();
458 
459     try (Cursor cursor =
460         appContext
461             .getContentResolver()
462             .query(
463                 AnnotatedCallLog.CONTENT_URI,
464                 new String[] {AnnotatedCallLog._ID},
465                 null,
466                 null,
467                 null)) {
468 
469       if (cursor == null) {
470         LogUtil.e("SystemCallLogDataSource.getAnnotatedCallLogIds", "null cursor");
471         return ids;
472       }
473 
474       if (cursor.moveToFirst()) {
475         int idColumn = cursor.getColumnIndexOrThrow(AnnotatedCallLog._ID);
476         do {
477           ids.add(cursor.getLong(idColumn));
478         } while (cursor.moveToNext());
479       }
480     }
481     return ids;
482   }
483 
484   @TargetApi(Build.VERSION_CODES.M) // Uses try-with-resources
getIdsFromSystemCallLogThatMatch( Context appContext, Set<Long> matchingIds)485   private static Set<Long> getIdsFromSystemCallLogThatMatch(
486       Context appContext, Set<Long> matchingIds) {
487     ArraySet<Long> ids = new ArraySet<>();
488 
489     // Batch the select statements into chunks of 999, the maximum size for SQLite selection args.
490     Iterable<List<Long>> batches = Iterables.partition(matchingIds, 999);
491     for (List<Long> idsInBatch : batches) {
492       String[] questionMarks = new String[idsInBatch.size()];
493       Arrays.fill(questionMarks, "?");
494 
495       String whereClause = (Calls._ID + " in (") + TextUtils.join(",", questionMarks) + ")";
496       String[] whereArgs = new String[idsInBatch.size()];
497       int i = 0;
498       for (long id : idsInBatch) {
499         whereArgs[i++] = String.valueOf(id);
500       }
501 
502       try (Cursor cursor =
503           appContext
504               .getContentResolver()
505               .query(
506                   Calls.CONTENT_URI_WITH_VOICEMAIL,
507                   new String[] {Calls._ID},
508                   whereClause,
509                   whereArgs,
510                   null)) {
511 
512         if (cursor == null) {
513           LogUtil.e("SystemCallLogDataSource.getIdsFromSystemCallLog", "null cursor");
514           return ids;
515         }
516 
517         if (cursor.moveToFirst()) {
518           int idColumn = cursor.getColumnIndexOrThrow(Calls._ID);
519           do {
520             ids.add(cursor.getLong(idColumn));
521           } while (cursor.moveToNext());
522         }
523       }
524     }
525     return ids;
526   }
527 }
528