• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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