| /* |
| * Copyright (C) 2006 The Android Open Source Project |
| * Copyright (C) 2023 The LineageOS Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.incallui; |
| |
| import android.Manifest; |
| import android.content.AsyncQueryHandler; |
| import android.content.ContentResolver; |
| import android.content.Context; |
| import android.database.Cursor; |
| import android.database.SQLException; |
| import android.net.Uri; |
| import android.os.Handler; |
| import android.os.Looper; |
| import android.os.Message; |
| import android.os.Trace; |
| import android.provider.ContactsContract; |
| import android.provider.ContactsContract.Directory; |
| import android.text.TextUtils; |
| |
| import androidx.annotation.MainThread; |
| import androidx.annotation.RequiresPermission; |
| import androidx.annotation.WorkerThread; |
| |
| import com.android.dialer.R; |
| import com.android.dialer.phonenumbercache.CachedNumberLookupService; |
| import com.android.dialer.phonenumbercache.CachedNumberLookupService.CachedContactInfo; |
| import com.android.dialer.phonenumbercache.ContactInfoHelper; |
| import com.android.dialer.phonenumbercache.PhoneNumberCache; |
| import com.android.dialer.phonenumberutil.PhoneNumberHelper; |
| |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| |
| /** |
| * Helper class to make it easier to run asynchronous caller-id lookup queries. |
| * |
| * @see CallerInfo |
| */ |
| public class CallerInfoAsyncQuery { |
| |
| /** Interface for a CallerInfoAsyncQueryHandler result return. */ |
| interface OnQueryCompleteListener { |
| |
| /** Called when the query is complete. */ |
| @MainThread |
| void onQueryComplete(int token, Object cookie, CallerInfo ci); |
| |
| /** Called when data is loaded. Must be called in worker thread. */ |
| @WorkerThread |
| void onDataLoaded(int token, Object cookie, CallerInfo ci); |
| } |
| |
| private static final boolean DBG = false; |
| private static final String LOG_TAG = "CallerInfoAsyncQuery"; |
| |
| private static final int EVENT_NEW_QUERY = 1; |
| private static final int EVENT_ADD_LISTENER = 2; |
| private static final int EVENT_EMERGENCY_NUMBER = 3; |
| private static final int EVENT_VOICEMAIL_NUMBER = 4; |
| // If the CallerInfo query finds no contacts, should we use the |
| // PhoneNumberOfflineGeocoder to look up a "geo description"? |
| // (TODO: This could become a flag in config.xml if it ever needs to be |
| // configured on a per-product basis.) |
| private static final boolean ENABLE_UNKNOWN_NUMBER_GEO_DESCRIPTION = true; |
| /* Directory lookup related code - START */ |
| private static final String[] DIRECTORY_PROJECTION = new String[] {Directory._ID}; |
| |
| /** Private constructor for factory methods. */ |
| private CallerInfoAsyncQuery() {} |
| |
| @RequiresPermission(Manifest.permission.READ_CONTACTS) |
| static void startQuery( |
| final int token, |
| final Context context, |
| final CallerInfo info, |
| final OnQueryCompleteListener listener, |
| final Object cookie) { |
| Log.d(LOG_TAG, "##### CallerInfoAsyncQuery startContactProviderQuery()... #####"); |
| Log.d(LOG_TAG, "- number: " + info.phoneNumber); |
| Log.d(LOG_TAG, "- cookie: " + cookie); |
| |
| OnQueryCompleteListener contactsProviderQueryCompleteListener = |
| new OnQueryCompleteListener() { |
| @Override |
| public void onQueryComplete(int token, Object cookie, CallerInfo ci) { |
| Log.d(LOG_TAG, "contactsProviderQueryCompleteListener onQueryComplete"); |
| // If there are no other directory queries, make sure that the listener is |
| // notified of this result. see a bug |
| if ((ci != null && ci.contactExists) |
| || !startOtherDirectoriesQuery(token, context, info, listener, cookie)) { |
| if (listener != null && ci != null) { |
| listener.onQueryComplete(token, cookie, ci); |
| } |
| } |
| } |
| |
| @Override |
| public void onDataLoaded(int token, Object cookie, CallerInfo ci) { |
| Log.d(LOG_TAG, "contactsProviderQueryCompleteListener onDataLoaded"); |
| listener.onDataLoaded(token, cookie, ci); |
| } |
| }; |
| startDefaultDirectoryQuery(token, context, info, contactsProviderQueryCompleteListener, cookie); |
| } |
| |
| // Private methods |
| private static void startDefaultDirectoryQuery( |
| int token, |
| Context context, |
| CallerInfo info, |
| OnQueryCompleteListener listener, |
| Object cookie) { |
| // Construct the URI object and query params, and start the query. |
| Uri uri = ContactInfoHelper.getContactInfoLookupUri(info.phoneNumber); |
| startQueryInternal(token, context, info, listener, cookie, uri); |
| } |
| |
| /** |
| * Factory method to start the query based on a CallerInfo object. |
| * |
| * <p>Note: if the number contains an "@" character we treat it as a SIP address, and look it up |
| * directly in the Data table rather than using the PhoneLookup table. TODO: But eventually we |
| * should expose two separate methods, one for numbers and one for SIP addresses, and then have |
| * PhoneUtils.startGetCallerInfo() decide which one to call based on the phone type of the |
| * incoming connection. |
| */ |
| private static void startQueryInternal( |
| int token, |
| Context context, |
| CallerInfo info, |
| OnQueryCompleteListener listener, |
| Object cookie, |
| Uri contactRef) { |
| if (DBG) { |
| Log.d(LOG_TAG, "==> contactRef: " + sanitizeUriToString(contactRef)); |
| } |
| |
| if ((context == null) || (contactRef == null)) { |
| throw new QueryPoolException("Bad context or query uri."); |
| } |
| CallerInfoAsyncQueryHandler handler = new CallerInfoAsyncQueryHandler(context, contactRef); |
| |
| //create cookieWrapper, start query |
| CookieWrapper cw = new CookieWrapper(); |
| cw.listener = listener; |
| cw.cookie = cookie; |
| cw.number = info.phoneNumber; |
| cw.countryIso = info.countryIso; |
| |
| // check to see if these are recognized numbers, and use shortcuts if we can. |
| if (PhoneNumberHelper.isEmergencyNumber(context, info.phoneNumber)) { |
| cw.event = EVENT_EMERGENCY_NUMBER; |
| } else if (info.isVoiceMailNumber()) { |
| cw.event = EVENT_VOICEMAIL_NUMBER; |
| } else { |
| cw.event = EVENT_NEW_QUERY; |
| } |
| |
| String[] proejection = CallerInfo.getDefaultPhoneLookupProjection(); |
| handler.startQuery( |
| token, |
| cw, // cookie |
| contactRef, // uri |
| proejection, // projection |
| null, // selection |
| null, // selectionArgs |
| null); // orderBy |
| } |
| |
| // Return value indicates if listener was notified. |
| private static boolean startOtherDirectoriesQuery( |
| int token, |
| Context context, |
| CallerInfo info, |
| OnQueryCompleteListener listener, |
| Object cookie) { |
| Trace.beginSection("CallerInfoAsyncQuery.startOtherDirectoriesQuery"); |
| long[] directoryIds = getDirectoryIds(context); |
| int size = directoryIds.length; |
| if (size == 0) { |
| Trace.endSection(); |
| return false; |
| } |
| |
| DirectoryQueryCompleteListenerFactory listenerFactory = |
| new DirectoryQueryCompleteListenerFactory(context, size, listener); |
| |
| // The current implementation of multiple async query runs in single handler thread |
| // in AsyncQueryHandler. |
| // intermediateListener.onQueryComplete is also called from the same caller thread. |
| // TODO(a bug): use thread pool instead of single thread. |
| for (int i = 0; i < size; i++) { |
| long directoryId = directoryIds[i]; |
| Uri uri = ContactInfoHelper.getContactInfoLookupUri(info.phoneNumber, directoryId); |
| if (DBG) { |
| Log.d(LOG_TAG, "directoryId: " + directoryId + " uri: " + uri); |
| } |
| OnQueryCompleteListener intermediateListener = listenerFactory.newListener(directoryId); |
| startQueryInternal(token, context, info, intermediateListener, cookie, uri); |
| } |
| Trace.endSection(); |
| return true; |
| } |
| |
| private static long[] getDirectoryIds(Context context) { |
| ArrayList<Long> results = new ArrayList<>(); |
| |
| Uri uri = Uri.withAppendedPath(ContactsContract.AUTHORITY_URI, "directories_enterprise"); |
| |
| ContentResolver cr = context.getContentResolver(); |
| Cursor cursor = cr.query(uri, DIRECTORY_PROJECTION, null, null, null); |
| addDirectoryIdsFromCursor(cursor, results); |
| |
| long[] result = new long[results.size()]; |
| for (int i = 0; i < results.size(); i++) { |
| result[i] = results.get(i); |
| } |
| return result; |
| } |
| |
| private static void addDirectoryIdsFromCursor(Cursor cursor, ArrayList<Long> results) { |
| if (cursor != null) { |
| int idIndex = cursor.getColumnIndex(Directory._ID); |
| while (cursor.moveToNext()) { |
| long id = cursor.getLong(idIndex); |
| if (Directory.isRemoteDirectoryId(id)) { |
| results.add(id); |
| } |
| } |
| cursor.close(); |
| } |
| } |
| |
| private static String sanitizeUriToString(Uri uri) { |
| if (uri != null) { |
| String uriString = uri.toString(); |
| int indexOfLastSlash = uriString.lastIndexOf('/'); |
| if (indexOfLastSlash > 0) { |
| return uriString.substring(0, indexOfLastSlash) + "/xxxxxxx"; |
| } else { |
| return uriString; |
| } |
| } else { |
| return ""; |
| } |
| } |
| |
| /** Wrap the cookie from the WorkerArgs with additional information needed by our classes. */ |
| private static final class CookieWrapper { |
| |
| public OnQueryCompleteListener listener; |
| public Object cookie; |
| public int event; |
| public String number; |
| public String countryIso; |
| } |
| /* Directory lookup related code - END */ |
| |
| /** Simple exception used to communicate problems with the query pool. */ |
| private static class QueryPoolException extends SQLException { |
| |
| QueryPoolException(String error) { |
| super(error); |
| } |
| } |
| |
| private static final class DirectoryQueryCompleteListenerFactory { |
| |
| private final OnQueryCompleteListener listener; |
| private final Context context; |
| // Make sure listener to be called once and only once |
| private int count; |
| private boolean isListenerCalled; |
| |
| DirectoryQueryCompleteListenerFactory( |
| Context context, int size, OnQueryCompleteListener listener) { |
| count = size; |
| this.listener = listener; |
| isListenerCalled = false; |
| this.context = context; |
| } |
| |
| private void onDirectoryQueryComplete( |
| int token, Object cookie, CallerInfo ci, long directoryId) { |
| boolean shouldCallListener = false; |
| synchronized (this) { |
| count = count - 1; |
| if (!isListenerCalled && (ci.contactExists || count == 0)) { |
| isListenerCalled = true; |
| shouldCallListener = true; |
| } |
| } |
| |
| // Don't call callback in synchronized block because mListener.onQueryComplete may |
| // take long time to complete |
| if (shouldCallListener && listener != null) { |
| addCallerInfoIntoCache(ci, directoryId); |
| listener.onQueryComplete(token, cookie, ci); |
| } |
| } |
| |
| private void addCallerInfoIntoCache(CallerInfo ci, long directoryId) { |
| CachedNumberLookupService cachedNumberLookupService = |
| PhoneNumberCache.get(context).getCachedNumberLookupService(); |
| if (ci.contactExists && cachedNumberLookupService != null) { |
| // 1. Cache caller info |
| CachedContactInfo cachedContactInfo = |
| CallerInfoUtils.buildCachedContactInfo(cachedNumberLookupService, ci); |
| String directoryLabel = context.getString(R.string.directory_search_label); |
| cachedContactInfo.setDirectorySource(directoryLabel, directoryId); |
| cachedNumberLookupService.addContact(context, cachedContactInfo); |
| |
| // 2. Cache photo |
| if (ci.contactDisplayPhotoUri != null && ci.normalizedNumber != null) { |
| try (InputStream in = |
| context.getContentResolver().openInputStream(ci.contactDisplayPhotoUri)) { |
| if (in != null) { |
| cachedNumberLookupService.addPhoto(context, ci.normalizedNumber, in); |
| } |
| } catch (IOException e) { |
| Log.e(LOG_TAG, "failed to fetch directory contact photo", e); |
| } |
| } |
| } |
| } |
| |
| OnQueryCompleteListener newListener(long directoryId) { |
| return new DirectoryQueryCompleteListener(directoryId); |
| } |
| |
| private class DirectoryQueryCompleteListener implements OnQueryCompleteListener { |
| |
| private final long directoryId; |
| |
| DirectoryQueryCompleteListener(long directoryId) { |
| this.directoryId = directoryId; |
| } |
| |
| @Override |
| public void onDataLoaded(int token, Object cookie, CallerInfo ci) { |
| Log.d(LOG_TAG, "DirectoryQueryCompleteListener.onDataLoaded"); |
| listener.onDataLoaded(token, cookie, ci); |
| } |
| |
| @Override |
| public void onQueryComplete(int token, Object cookie, CallerInfo ci) { |
| Log.d(LOG_TAG, "DirectoryQueryCompleteListener.onQueryComplete"); |
| onDirectoryQueryComplete(token, cookie, ci, directoryId); |
| } |
| } |
| } |
| |
| /** Our own implementation of the AsyncQueryHandler. */ |
| private static class CallerInfoAsyncQueryHandler extends AsyncQueryHandler { |
| |
| /** |
| * The information relevant to each CallerInfo query. Each query may have multiple listeners, so |
| * each AsyncCursorInfo is associated with 2 or more CookieWrapper objects in the queue (one |
| * with a new query event, and one with a end event, with 0 or more additional listeners in |
| * between). |
| */ |
| private Context queryContext; |
| |
| private Uri queryUri; |
| private CallerInfo callerInfo; |
| |
| /** Asynchronous query handler class for the contact / callerinfo object. */ |
| private CallerInfoAsyncQueryHandler(Context context, Uri contactRef) { |
| super(context.getContentResolver()); |
| this.queryContext = context; |
| this.queryUri = contactRef; |
| } |
| |
| @Override |
| public void startQuery( |
| int token, |
| Object cookie, |
| Uri uri, |
| String[] projection, |
| String selection, |
| String[] selectionArgs, |
| String orderBy) { |
| if (DBG) { |
| // Show stack trace with the arguments. |
| Log.d( |
| LOG_TAG, |
| "InCall: startQuery: url=" |
| + uri |
| + " projection=[" |
| + Arrays.toString(projection) |
| + "]" |
| + " selection=" |
| + selection |
| + " " |
| + " args=[" |
| + Arrays.toString(selectionArgs) |
| + "]", |
| new RuntimeException("STACKTRACE")); |
| } |
| super.startQuery(token, cookie, uri, projection, selection, selectionArgs, orderBy); |
| } |
| |
| @Override |
| protected Handler createHandler(Looper looper) { |
| return new CallerInfoWorkerHandler(looper); |
| } |
| |
| /** |
| * Overrides onQueryComplete from AsyncQueryHandler. |
| * |
| * <p>This method takes into account the state of this class; we construct the CallerInfo object |
| * only once for each set of listeners. When the query thread has done its work and calls this |
| * method, we inform the remaining listeners in the queue, until we're out of listeners. Once we |
| * get the message indicating that we should expect no new listeners for this CallerInfo object, |
| * we release the AsyncCursorInfo back into the pool. |
| */ |
| @Override |
| protected void onQueryComplete(int token, Object cookie, Cursor cursor) { |
| Log.d(this, "##### onQueryComplete() ##### query complete for token: " + token); |
| |
| CookieWrapper cw = (CookieWrapper) cookie; |
| |
| if (cw.listener != null) { |
| Log.d( |
| this, |
| "notifying listener: " |
| + cw.listener.getClass() |
| + " for token: " |
| + token |
| + callerInfo); |
| cw.listener.onQueryComplete(token, cw.cookie, callerInfo); |
| } |
| queryContext = null; |
| queryUri = null; |
| callerInfo = null; |
| } |
| |
| void updateData(int token, Object cookie, Cursor cursor) { |
| try { |
| Log.d(this, "##### updateData() ##### for token: " + token); |
| |
| //get the cookie and notify the listener. |
| CookieWrapper cw = (CookieWrapper) cookie; |
| if (cw == null) { |
| // Normally, this should never be the case for calls originating |
| // from within this code. |
| // However, if there is any code that calls this method, we should |
| // check the parameters to make sure they're viable. |
| Log.d(this, "Cookie is null, ignoring onQueryComplete() request."); |
| return; |
| } |
| |
| // check the token and if needed, create the callerinfo object. |
| if (callerInfo == null) { |
| if ((queryContext == null) || (queryUri == null)) { |
| throw new QueryPoolException( |
| "Bad context or query uri, or CallerInfoAsyncQuery already released."); |
| } |
| |
| // adjust the callerInfo data as needed, and only if it was set from the |
| // initial query request. |
| // Change the callerInfo number ONLY if it is an emergency number or the |
| // voicemail number, and adjust other data (including photoResource) |
| // accordingly. |
| if (cw.event == EVENT_EMERGENCY_NUMBER) { |
| // Note we're setting the phone number here (refer to javadoc |
| // comments at the top of CallerInfo class). |
| callerInfo = new CallerInfo().markAsEmergency(queryContext); |
| } else if (cw.event == EVENT_VOICEMAIL_NUMBER) { |
| callerInfo = new CallerInfo().markAsVoiceMail(queryContext); |
| } else { |
| callerInfo = CallerInfo.getCallerInfo(queryContext, queryUri, cursor); |
| Log.d(this, "==> Got mCallerInfo: " + callerInfo); |
| |
| CallerInfo newCallerInfo = |
| CallerInfo.doSecondaryLookupIfNecessary(queryContext, cw.number, callerInfo); |
| if (newCallerInfo != callerInfo) { |
| callerInfo = newCallerInfo; |
| Log.d(this, "#####async contact look up with numeric username" + callerInfo); |
| } |
| callerInfo.countryIso = cw.countryIso; |
| |
| // Final step: look up the geocoded description. |
| if (ENABLE_UNKNOWN_NUMBER_GEO_DESCRIPTION) { |
| // Note we do this only if we *don't* have a valid name (i.e. if |
| // no contacts matched the phone number of the incoming call), |
| // since that's the only case where the incoming-call UI cares |
| // about this field. |
| // |
| // (TODO: But if we ever want the UI to show the geoDescription |
| // even when we *do* match a contact, we'll need to either call |
| // updateGeoDescription() unconditionally here, or possibly add a |
| // new parameter to CallerInfoAsyncQuery.startQuery() to force |
| // the geoDescription field to be populated.) |
| |
| if (TextUtils.isEmpty(callerInfo.name)) { |
| // Actually when no contacts match the incoming phone number, |
| // the CallerInfo object is totally blank here (i.e. no name |
| // *or* phoneNumber). So we need to pass in cw.number as |
| // a fallback number. |
| callerInfo.updateGeoDescription(queryContext, cw.number); |
| } |
| } |
| |
| // Use the number entered by the user for display. |
| if (!TextUtils.isEmpty(cw.number)) { |
| callerInfo.phoneNumber = cw.number; |
| } |
| } |
| |
| Log.d(this, "constructing CallerInfo object for token: " + token); |
| |
| if (cw.listener != null) { |
| cw.listener.onDataLoaded(token, cw.cookie, callerInfo); |
| } |
| } |
| |
| } finally { |
| // The cursor may have been closed in CallerInfo.getCallerInfo() |
| if (cursor != null && !cursor.isClosed()) { |
| cursor.close(); |
| } |
| } |
| } |
| |
| /** |
| * Our own query worker thread. |
| * |
| * <p>This thread handles the messages enqueued in the looper. The normal sequence of events is |
| * that a new query shows up in the looper queue, followed by 0 or more add listener requests, |
| * and then an end request. Of course, these requests can be interlaced with requests from other |
| * tokens, but is irrelevant to this handler since the handler has no state. |
| * |
| * <p>Note that we depend on the queue to keep things in order; in other words, the looper queue |
| * must be FIFO with respect to input from the synchronous startQuery calls and output to this |
| * handleMessage call. |
| * |
| * <p>This use of the queue is required because CallerInfo objects may be accessed multiple |
| * times before the query is complete. All accesses (listeners) must be queued up and informed |
| * in order when the query is complete. |
| */ |
| class CallerInfoWorkerHandler extends WorkerHandler { |
| |
| CallerInfoWorkerHandler(Looper looper) { |
| super(looper); |
| } |
| |
| @Override |
| public void handleMessage(Message msg) { |
| WorkerArgs args = (WorkerArgs) msg.obj; |
| CookieWrapper cw = (CookieWrapper) args.cookie; |
| |
| if (cw == null) { |
| // Normally, this should never be the case for calls originating |
| // from within this code. |
| // However, if there is any code that this Handler calls (such as in |
| // super.handleMessage) that DOES place unexpected messages on the |
| // queue, then we need pass these messages on. |
| Log.d( |
| this, |
| "Unexpected command (CookieWrapper is null): " |
| + msg.what |
| + " ignored by CallerInfoWorkerHandler, passing onto parent."); |
| |
| super.handleMessage(msg); |
| } else { |
| Log.d( |
| this, |
| "Processing event: " |
| + cw.event |
| + " token (arg1): " |
| + msg.arg1 |
| + " command: " |
| + msg.what |
| + " query URI: " |
| + sanitizeUriToString(args.uri)); |
| |
| switch (cw.event) { |
| case EVENT_NEW_QUERY: |
| final ContentResolver resolver = queryContext.getContentResolver(); |
| |
| // This should never happen. |
| if (resolver == null) { |
| Log.e(this, "Content Resolver is null!"); |
| return; |
| } |
| // start the sql command. |
| Cursor cursor; |
| try { |
| cursor = |
| resolver.query( |
| args.uri, |
| args.projection, |
| args.selection, |
| args.selectionArgs, |
| args.orderBy); |
| // Calling getCount() causes the cursor window to be filled, |
| // which will make the first access on the main thread a lot faster. |
| if (cursor != null) { |
| cursor.getCount(); |
| } |
| } catch (Exception e) { |
| Log.e(this, "Exception thrown during handling EVENT_ARG_QUERY", e); |
| cursor = null; |
| } |
| |
| args.result = cursor; |
| updateData(msg.arg1, cw, cursor); |
| break; |
| |
| // shortcuts to avoid query for recognized numbers. |
| case EVENT_EMERGENCY_NUMBER: |
| case EVENT_VOICEMAIL_NUMBER: |
| case EVENT_ADD_LISTENER: |
| updateData(msg.arg1, cw, (Cursor) args.result); |
| break; |
| default: // fall out |
| } |
| Message reply = args.handler.obtainMessage(msg.what); |
| reply.obj = args; |
| reply.arg1 = msg.arg1; |
| |
| reply.sendToTarget(); |
| } |
| } |
| } |
| } |
| } |