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