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