/* * Copyright (C) 2006 The Android Open Source 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.annotation.TargetApi; 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.Build.VERSION; import android.os.Build.VERSION_CODES; import android.os.Handler; import android.os.Looper; import android.os.Message; import android.provider.ContactsContract; import android.provider.ContactsContract.Directory; import android.support.annotation.MainThread; import android.support.annotation.RequiresPermission; import android.support.annotation.WorkerThread; import android.telephony.PhoneNumberUtils; import android.text.TextUtils; import com.android.contacts.common.compat.DirectoryCompat; 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 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 */ @TargetApi(VERSION_CODES.M) 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 b/27621628 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. * *

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; // check to see if these are recognized numbers, and use shortcuts if we can. if (PhoneNumberUtils.isLocalEmergencyNumber(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(contactRef); 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) { long[] directoryIds = getDirectoryIds(context); int size = directoryIds.length; if (size == 0) { 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(b/26019872): 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); } return true; } private static long[] getDirectoryIds(Context context) { ArrayList results = new ArrayList<>(); Uri uri = Directory.CONTENT_URI; if (VERSION.SDK_INT >= VERSION_CODES.N) { 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 results) { if (cursor != null) { int idIndex = cursor.getColumnIndex(Directory._ID); while (cursor.moveToNext()) { long id = cursor.getLong(idIndex); if (DirectoryCompat.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; } /* 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 mListener; private final Context mContext; // Make sure listener to be called once and only once private int mCount; private boolean mIsListenerCalled; DirectoryQueryCompleteListenerFactory( Context context, int size, OnQueryCompleteListener listener) { mCount = size; mListener = listener; mIsListenerCalled = false; mContext = context; } private void onDirectoryQueryComplete( int token, Object cookie, CallerInfo ci, long directoryId) { boolean shouldCallListener = false; synchronized (this) { mCount = mCount - 1; if (!mIsListenerCalled && (ci.contactExists || mCount == 0)) { mIsListenerCalled = true; shouldCallListener = true; } } // Don't call callback in synchronized block because mListener.onQueryComplete may // take long time to complete if (shouldCallListener && mListener != null) { addCallerInfoIntoCache(ci, directoryId); mListener.onQueryComplete(token, cookie, ci); } } private void addCallerInfoIntoCache(CallerInfo ci, long directoryId) { CachedNumberLookupService cachedNumberLookupService = PhoneNumberCache.get(mContext).getCachedNumberLookupService(); if (ci.contactExists && cachedNumberLookupService != null) { // 1. Cache caller info CachedContactInfo cachedContactInfo = CallerInfoUtils.buildCachedContactInfo(cachedNumberLookupService, ci); String directoryLabel = mContext.getString(R.string.directory_search_label); cachedContactInfo.setDirectorySource(directoryLabel, directoryId); cachedNumberLookupService.addContact(mContext, cachedContactInfo); // 2. Cache photo if (ci.contactDisplayPhotoUri != null && ci.normalizedNumber != null) { try (InputStream in = mContext.getContentResolver().openInputStream(ci.contactDisplayPhotoUri)) { if (in != null) { cachedNumberLookupService.addPhoto(mContext, 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 mDirectoryId; DirectoryQueryCompleteListener(long directoryId) { mDirectoryId = directoryId; } @Override public void onDataLoaded(int token, Object cookie, CallerInfo ci) { Log.d(LOG_TAG, "DirectoryQueryCompleteListener.onDataLoaded"); mListener.onDataLoaded(token, cookie, ci); } @Override public void onQueryComplete(int token, Object cookie, CallerInfo ci) { Log.d(LOG_TAG, "DirectoryQueryCompleteListener.onQueryComplete"); onDirectoryQueryComplete(token, cookie, ci, mDirectoryId); } } } /** 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 mQueryContext; private Uri mQueryUri; private CallerInfo mCallerInfo; /** Asynchronous query handler class for the contact / callerinfo object. */ private CallerInfoAsyncQueryHandler(Context context, Uri contactRef) { super(context.getContentResolver()); this.mQueryContext = context; this.mQueryUri = 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. * *

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().toString() + " for token: " + token + mCallerInfo); cw.listener.onQueryComplete(token, cw.cookie, mCallerInfo); } mQueryContext = null; mQueryUri = null; mCallerInfo = 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 (mCallerInfo == null) { if ((mQueryContext == null) || (mQueryUri == 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). mCallerInfo = new CallerInfo().markAsEmergency(mQueryContext); } else if (cw.event == EVENT_VOICEMAIL_NUMBER) { mCallerInfo = new CallerInfo().markAsVoiceMail(mQueryContext); } else { mCallerInfo = CallerInfo.getCallerInfo(mQueryContext, mQueryUri, cursor); Log.d(this, "==> Got mCallerInfo: " + mCallerInfo); CallerInfo newCallerInfo = CallerInfo.doSecondaryLookupIfNecessary(mQueryContext, cw.number, mCallerInfo); if (newCallerInfo != mCallerInfo) { mCallerInfo = newCallerInfo; Log.d(this, "#####async contact look up with numeric username" + mCallerInfo); } // 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(mCallerInfo.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. mCallerInfo.updateGeoDescription(mQueryContext, cw.number); } } // Use the number entered by the user for display. if (!TextUtils.isEmpty(cw.number)) { mCallerInfo.phoneNumber = cw.number; } } Log.d(this, "constructing CallerInfo object for token: " + token); if (cw.listener != null) { cw.listener.onDataLoaded(token, cw.cookie, mCallerInfo); } } } finally { // The cursor may have been closed in CallerInfo.getCallerInfo() if (cursor != null && !cursor.isClosed()) { cursor.close(); } } } /** * Our own query worker thread. * *

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. * *

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. * *

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 = mQueryContext.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(); } } } } }