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