1 /* 2 * Copyright (C) 2013 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.app.calllog; 18 19 import android.Manifest; 20 import android.content.ContentResolver; 21 import android.content.ContentUris; 22 import android.content.ContentValues; 23 import android.content.Context; 24 import android.database.Cursor; 25 import android.net.Uri; 26 import android.os.Build; 27 import android.provider.CallLog.Calls; 28 import android.provider.VoicemailContract.Voicemails; 29 import android.support.annotation.NonNull; 30 import android.support.annotation.Nullable; 31 import android.support.annotation.VisibleForTesting; 32 import android.support.annotation.WorkerThread; 33 import android.support.v4.os.UserManagerCompat; 34 import android.telephony.PhoneNumberUtils; 35 import android.text.TextUtils; 36 import com.android.dialer.app.R; 37 import com.android.dialer.calllogutils.PhoneNumberDisplayUtil; 38 import com.android.dialer.common.LogUtil; 39 import com.android.dialer.common.database.Selection; 40 import com.android.dialer.compat.android.provider.VoicemailCompat; 41 import com.android.dialer.configprovider.ConfigProviderComponent; 42 import com.android.dialer.location.GeoUtil; 43 import com.android.dialer.phonenumbercache.ContactInfo; 44 import com.android.dialer.phonenumbercache.ContactInfoHelper; 45 import com.android.dialer.phonenumberutil.PhoneNumberHelper; 46 import com.android.dialer.util.PermissionsUtil; 47 import java.util.ArrayList; 48 import java.util.Arrays; 49 import java.util.List; 50 import java.util.concurrent.TimeUnit; 51 52 /** Helper class operating on call log notifications. */ 53 public class CallLogNotificationsQueryHelper { 54 55 @VisibleForTesting 56 static final String CONFIG_NEW_VOICEMAIL_NOTIFICATION_THRESHOLD_OFFSET = 57 "new_voicemail_notification_threshold"; 58 59 private final Context context; 60 private final NewCallsQuery newCallsQuery; 61 private final ContactInfoHelper contactInfoHelper; 62 private final String currentCountryIso; 63 CallLogNotificationsQueryHelper( Context context, NewCallsQuery newCallsQuery, ContactInfoHelper contactInfoHelper, String countryIso)64 CallLogNotificationsQueryHelper( 65 Context context, 66 NewCallsQuery newCallsQuery, 67 ContactInfoHelper contactInfoHelper, 68 String countryIso) { 69 this.context = context; 70 this.newCallsQuery = newCallsQuery; 71 this.contactInfoHelper = contactInfoHelper; 72 currentCountryIso = countryIso; 73 } 74 75 /** Returns an instance of {@link CallLogNotificationsQueryHelper}. */ getInstance(Context context)76 public static CallLogNotificationsQueryHelper getInstance(Context context) { 77 ContentResolver contentResolver = context.getContentResolver(); 78 String countryIso = GeoUtil.getCurrentCountryIso(context); 79 return new CallLogNotificationsQueryHelper( 80 context, 81 createNewCallsQuery(context, contentResolver), 82 new ContactInfoHelper(context, countryIso), 83 countryIso); 84 } 85 markAllMissedCallsInCallLogAsRead(@onNull Context context)86 public static void markAllMissedCallsInCallLogAsRead(@NonNull Context context) { 87 markMissedCallsInCallLogAsRead(context, null); 88 } 89 markSingleMissedCallInCallLogAsRead( @onNull Context context, @Nullable Uri callUri)90 public static void markSingleMissedCallInCallLogAsRead( 91 @NonNull Context context, @Nullable Uri callUri) { 92 if (callUri == null) { 93 LogUtil.e( 94 "CallLogNotificationsQueryHelper.markSingleMissedCallInCallLogAsRead", 95 "call URI is null, unable to mark call as read"); 96 } else { 97 markMissedCallsInCallLogAsRead(context, callUri); 98 } 99 } 100 101 /** 102 * If callUri is null then calls with a matching callUri are marked as read, otherwise all calls 103 * are marked as read. 104 */ 105 @WorkerThread markMissedCallsInCallLogAsRead(Context context, @Nullable Uri callUri)106 private static void markMissedCallsInCallLogAsRead(Context context, @Nullable Uri callUri) { 107 if (!UserManagerCompat.isUserUnlocked(context)) { 108 LogUtil.e("CallLogNotificationsQueryHelper.markMissedCallsInCallLogAsRead", "locked"); 109 return; 110 } 111 if (!PermissionsUtil.hasPhonePermissions(context)) { 112 LogUtil.e( 113 "CallLogNotificationsQueryHelper.markMissedCallsInCallLogAsRead", "no phone permission"); 114 return; 115 } 116 if (!PermissionsUtil.hasCallLogWritePermissions(context)) { 117 LogUtil.e( 118 "CallLogNotificationsQueryHelper.markMissedCallsInCallLogAsRead", 119 "no call log write permission"); 120 return; 121 } 122 123 ContentValues values = new ContentValues(); 124 values.put(Calls.NEW, 0); 125 values.put(Calls.IS_READ, 1); 126 StringBuilder where = new StringBuilder(); 127 where.append(Calls.NEW); 128 where.append(" = 1 AND "); 129 where.append(Calls.TYPE); 130 where.append(" = ?"); 131 try { 132 context 133 .getContentResolver() 134 .update( 135 callUri == null ? Calls.CONTENT_URI : callUri, 136 values, 137 where.toString(), 138 new String[] {Integer.toString(Calls.MISSED_TYPE)}); 139 } catch (IllegalArgumentException e) { 140 LogUtil.e( 141 "CallLogNotificationsQueryHelper.markMissedCallsInCallLogAsRead", 142 "contacts provider update command failed", 143 e); 144 } 145 } 146 147 /** Create a new instance of {@link NewCallsQuery}. */ createNewCallsQuery( Context context, ContentResolver contentResolver)148 public static NewCallsQuery createNewCallsQuery( 149 Context context, ContentResolver contentResolver) { 150 151 return new DefaultNewCallsQuery(context.getApplicationContext(), contentResolver); 152 } 153 getNewCallsQuery()154 NewCallsQuery getNewCallsQuery() { 155 return newCallsQuery; 156 } 157 158 /** 159 * Get all voicemails with the "new" flag set to 1. 160 * 161 * @return A list of NewCall objects where each object represents a new voicemail. 162 */ 163 @Nullable getNewVoicemails()164 public List<NewCall> getNewVoicemails() { 165 return newCallsQuery.query( 166 Calls.VOICEMAIL_TYPE, 167 System.currentTimeMillis() 168 - ConfigProviderComponent.get(context) 169 .getConfigProvider() 170 .getLong( 171 CONFIG_NEW_VOICEMAIL_NOTIFICATION_THRESHOLD_OFFSET, TimeUnit.DAYS.toMillis(7))); 172 } 173 174 /** 175 * Get all missed calls with the "new" flag set to 1. 176 * 177 * @return A list of NewCall objects where each object represents a new missed call. 178 */ 179 @Nullable getNewMissedCalls()180 public List<NewCall> getNewMissedCalls() { 181 return newCallsQuery.query(Calls.MISSED_TYPE); 182 } 183 184 /** 185 * Given a number and number information (presentation and country ISO), get the best name for 186 * display. If the name is empty but we have a special presentation, display that. Otherwise 187 * attempt to look it up in the database or the cache. If that fails, fall back to displaying the 188 * number. 189 */ getName( @ullable String number, int numberPresentation, @Nullable String countryIso)190 public String getName( 191 @Nullable String number, int numberPresentation, @Nullable String countryIso) { 192 return getContactInfo(number, numberPresentation, countryIso).name; 193 } 194 195 /** 196 * Given a number and number information (presentation and country ISO), get {@link ContactInfo}. 197 * If the name is empty but we have a special presentation, display that. Otherwise attempt to 198 * look it up in the cache. If that fails, fall back to displaying the number. 199 */ getContactInfo( @ullable String number, int numberPresentation, @Nullable String countryIso)200 public ContactInfo getContactInfo( 201 @Nullable String number, int numberPresentation, @Nullable String countryIso) { 202 if (countryIso == null) { 203 countryIso = currentCountryIso; 204 } 205 206 number = (number == null) ? "" : number; 207 ContactInfo contactInfo = new ContactInfo(); 208 contactInfo.number = number; 209 contactInfo.formattedNumber = PhoneNumberHelper.formatNumber(context, number, countryIso); 210 // contactInfo.normalizedNumber is not PhoneNumberUtils.normalizeNumber. Read ContactInfo. 211 contactInfo.normalizedNumber = PhoneNumberUtils.formatNumberToE164(number, countryIso); 212 213 // 1. Special number representation. 214 contactInfo.name = 215 PhoneNumberDisplayUtil.getDisplayName(context, number, numberPresentation, false) 216 .toString(); 217 if (!TextUtils.isEmpty(contactInfo.name)) { 218 return contactInfo; 219 } 220 221 // 2. Look it up in the cache. 222 ContactInfo cachedContactInfo = contactInfoHelper.lookupNumber(number, countryIso); 223 224 if (cachedContactInfo != null && !TextUtils.isEmpty(cachedContactInfo.name)) { 225 return cachedContactInfo; 226 } 227 228 if (!TextUtils.isEmpty(contactInfo.formattedNumber)) { 229 // 3. If we cannot lookup the contact, use the formatted number instead. 230 contactInfo.name = contactInfo.formattedNumber; 231 } else if (!TextUtils.isEmpty(number)) { 232 // 4. If number can't be formatted, use number. 233 contactInfo.name = number; 234 } else { 235 // 5. Otherwise, it's unknown number. 236 contactInfo.name = context.getResources().getString(R.string.unknown); 237 } 238 return contactInfo; 239 } 240 241 /** Allows determining the new calls for which a notification should be generated. */ 242 public interface NewCallsQuery { 243 244 long NO_THRESHOLD = Long.MAX_VALUE; 245 246 /** Returns the new calls of a certain type for which a notification should be generated. */ 247 @Nullable query(int type)248 List<NewCall> query(int type); 249 250 /** 251 * Returns the new calls of a certain type for which a notification should be generated. 252 * 253 * @param thresholdMillis New calls added before this timestamp will be considered old, or 254 * {@link #NO_THRESHOLD} if threshold is not checked. 255 */ 256 @Nullable query(int type, long thresholdMillis)257 List<NewCall> query(int type, long thresholdMillis); 258 259 /** Returns a {@link NewCall} pointed by the {@code callsUri} */ 260 @Nullable queryUnreadVoicemail(Uri callsUri)261 NewCall queryUnreadVoicemail(Uri callsUri); 262 } 263 264 /** Information about a new voicemail. */ 265 public static final class NewCall { 266 267 public final Uri callsUri; 268 @Nullable public final Uri voicemailUri; 269 public final String number; 270 public final int numberPresentation; 271 public final String accountComponentName; 272 public final String accountId; 273 public final String transcription; 274 public final String countryIso; 275 public final long dateMs; 276 public final int transcriptionState; 277 NewCall( Uri callsUri, @Nullable Uri voicemailUri, String number, int numberPresentation, String accountComponentName, String accountId, String transcription, String countryIso, long dateMs, int transcriptionState)278 public NewCall( 279 Uri callsUri, 280 @Nullable Uri voicemailUri, 281 String number, 282 int numberPresentation, 283 String accountComponentName, 284 String accountId, 285 String transcription, 286 String countryIso, 287 long dateMs, 288 int transcriptionState) { 289 this.callsUri = callsUri; 290 this.voicemailUri = voicemailUri; 291 this.number = number; 292 this.numberPresentation = numberPresentation; 293 this.accountComponentName = accountComponentName; 294 this.accountId = accountId; 295 this.transcription = transcription; 296 this.countryIso = countryIso; 297 this.dateMs = dateMs; 298 this.transcriptionState = transcriptionState; 299 } 300 } 301 302 /** 303 * Default implementation of {@link NewCallsQuery} that looks up the list of new calls to notify 304 * about in the call log. 305 */ 306 private static final class DefaultNewCallsQuery implements NewCallsQuery { 307 308 private static final String[] PROJECTION = { 309 Calls._ID, 310 Calls.NUMBER, 311 Calls.VOICEMAIL_URI, 312 Calls.NUMBER_PRESENTATION, 313 Calls.PHONE_ACCOUNT_COMPONENT_NAME, 314 Calls.PHONE_ACCOUNT_ID, 315 Calls.TRANSCRIPTION, 316 Calls.COUNTRY_ISO, 317 Calls.DATE 318 }; 319 320 private static final String[] PROJECTION_O; 321 322 static { 323 List<String> list = new ArrayList<>(); Arrays.asList(PROJECTION)324 list.addAll(Arrays.asList(PROJECTION)); 325 list.add(VoicemailCompat.TRANSCRIPTION_STATE); 326 PROJECTION_O = list.toArray(new String[list.size()]); 327 } 328 329 private static final int ID_COLUMN_INDEX = 0; 330 private static final int NUMBER_COLUMN_INDEX = 1; 331 private static final int VOICEMAIL_URI_COLUMN_INDEX = 2; 332 private static final int NUMBER_PRESENTATION_COLUMN_INDEX = 3; 333 private static final int PHONE_ACCOUNT_COMPONENT_NAME_COLUMN_INDEX = 4; 334 private static final int PHONE_ACCOUNT_ID_COLUMN_INDEX = 5; 335 private static final int TRANSCRIPTION_COLUMN_INDEX = 6; 336 private static final int COUNTRY_ISO_COLUMN_INDEX = 7; 337 private static final int DATE_COLUMN_INDEX = 8; 338 private static final int TRANSCRIPTION_STATE_COLUMN_INDEX = 9; 339 340 private final ContentResolver contentResolver; 341 private final Context context; 342 DefaultNewCallsQuery(Context context, ContentResolver contentResolver)343 private DefaultNewCallsQuery(Context context, ContentResolver contentResolver) { 344 this.context = context; 345 this.contentResolver = contentResolver; 346 } 347 348 @Override 349 @Nullable query(int type)350 public List<NewCall> query(int type) { 351 return query(type, NO_THRESHOLD); 352 } 353 354 @Override 355 @Nullable 356 @SuppressWarnings("MissingPermission") query(int type, long thresholdMillis)357 public List<NewCall> query(int type, long thresholdMillis) { 358 if (!PermissionsUtil.hasPermission(context, Manifest.permission.READ_CALL_LOG)) { 359 LogUtil.w( 360 "CallLogNotificationsQueryHelper.DefaultNewCallsQuery.query", 361 "no READ_CALL_LOG permission, returning null for calls lookup."); 362 return null; 363 } 364 // A call is "new" when: 365 // NEW is 1. usually set when a new row is inserted 366 // TYPE matches the query type. 367 // IS_READ is not 1. A call might be backed up and restored, so it will be "new" to the 368 // call log, but the user has already read it on another device. 369 Selection.Builder selectionBuilder = 370 Selection.builder() 371 .and(Selection.column(Calls.NEW).is("= 1")) 372 .and(Selection.column(Calls.TYPE).is("=", type)) 373 .and(Selection.column(Calls.IS_READ).is("IS NOT 1")); 374 375 if (type == Calls.VOICEMAIL_TYPE) { 376 selectionBuilder.and(Selection.column(Voicemails.DELETED).is(" = 0")); 377 } 378 379 if (thresholdMillis != NO_THRESHOLD) { 380 selectionBuilder = 381 selectionBuilder.and( 382 Selection.column(Calls.DATE) 383 .is("IS NULL") 384 .buildUpon() 385 .or(Selection.column(Calls.DATE).is(">=", thresholdMillis)) 386 .build()); 387 } 388 Selection selection = selectionBuilder.build(); 389 try (Cursor cursor = 390 contentResolver.query( 391 Calls.CONTENT_URI_WITH_VOICEMAIL, 392 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ? PROJECTION_O : PROJECTION, 393 selection.getSelection(), 394 selection.getSelectionArgs(), 395 Calls.DEFAULT_SORT_ORDER)) { 396 if (cursor == null) { 397 return null; 398 } 399 List<NewCall> newCalls = new ArrayList<>(); 400 while (cursor.moveToNext()) { 401 newCalls.add(createNewCallsFromCursor(cursor)); 402 } 403 return newCalls; 404 } catch (RuntimeException e) { 405 LogUtil.w( 406 "CallLogNotificationsQueryHelper.DefaultNewCallsQuery.query", 407 "exception when querying Contacts Provider for calls lookup"); 408 return null; 409 } 410 } 411 412 @Nullable 413 @Override 414 @SuppressWarnings("missingPermission") queryUnreadVoicemail(Uri voicemailUri)415 public NewCall queryUnreadVoicemail(Uri voicemailUri) { 416 if (!PermissionsUtil.hasPermission(context, Manifest.permission.READ_CALL_LOG)) { 417 LogUtil.w( 418 "CallLogNotificationsQueryHelper.DefaultNewCallsQuery.query", 419 "No READ_CALL_LOG permission, returning null for calls lookup."); 420 return null; 421 } 422 Selection selection = 423 Selection.column(Calls.VOICEMAIL_URI) 424 .is("=", voicemailUri) 425 .buildUpon() 426 .and(Selection.column(Calls.IS_READ).is("IS NOT", 1)) 427 .build(); 428 try (Cursor cursor = 429 contentResolver.query( 430 Calls.CONTENT_URI_WITH_VOICEMAIL, 431 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ? PROJECTION_O : PROJECTION, 432 selection.getSelection(), 433 selection.getSelectionArgs(), 434 null)) { 435 if (cursor == null) { 436 return null; 437 } 438 if (!cursor.moveToFirst()) { 439 return null; 440 } 441 return createNewCallsFromCursor(cursor); 442 } 443 } 444 445 /** Returns an instance of {@link NewCall} created by using the values of the cursor. */ createNewCallsFromCursor(Cursor cursor)446 private NewCall createNewCallsFromCursor(Cursor cursor) { 447 String voicemailUriString = cursor.getString(VOICEMAIL_URI_COLUMN_INDEX); 448 Uri callsUri = 449 ContentUris.withAppendedId( 450 Calls.CONTENT_URI_WITH_VOICEMAIL, cursor.getLong(ID_COLUMN_INDEX)); 451 Uri voicemailUri = voicemailUriString == null ? null : Uri.parse(voicemailUriString); 452 return new NewCall( 453 callsUri, 454 voicemailUri, 455 cursor.getString(NUMBER_COLUMN_INDEX), 456 cursor.getInt(NUMBER_PRESENTATION_COLUMN_INDEX), 457 cursor.getString(PHONE_ACCOUNT_COMPONENT_NAME_COLUMN_INDEX), 458 cursor.getString(PHONE_ACCOUNT_ID_COLUMN_INDEX), 459 cursor.getString(TRANSCRIPTION_COLUMN_INDEX), 460 cursor.getString(COUNTRY_ISO_COLUMN_INDEX), 461 cursor.getLong(DATE_COLUMN_INDEX), 462 Build.VERSION.SDK_INT >= Build.VERSION_CODES.O 463 ? cursor.getInt(TRANSCRIPTION_STATE_COLUMN_INDEX) 464 : VoicemailCompat.TRANSCRIPTION_NOT_STARTED); 465 } 466 } 467 } 468