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