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 84 SystemCallLogDataSource( 85 @BackgroundExecutor ListeningExecutorService backgroundExecutorService, 86 MarkDirtyObserver markDirtyObserver) { 87 this.backgroundExecutorService = backgroundExecutorService; 88 this.markDirtyObserver = markDirtyObserver; 89 } 90 91 @MainThread 92 @Override 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 123 public ListenableFuture<Boolean> isDirty(Context appContext) { 124 return backgroundExecutorService.submit(() -> isDirtyInternal(appContext)); 125 } 126 127 @Override 128 public ListenableFuture<Void> fill(Context appContext, CallLogMutations mutations) { 129 return backgroundExecutorService.submit(() -> fillInternal(appContext, mutations)); 130 } 131 132 @Override 133 public ListenableFuture<Void> onSuccessfulFill(Context appContext) { 134 return backgroundExecutorService.submit(() -> onSuccessfulFillInternal(appContext)); 135 } 136 137 @WorkerThread 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 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 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 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 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 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 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 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 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 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 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 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