1 /*
2  * Copyright (C) 2006 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.incallui;
18 
19 import com.google.common.primitives.Longs;
20 
21 import android.Manifest;
22 import android.content.AsyncQueryHandler;
23 import android.content.ContentResolver;
24 import android.content.Context;
25 import android.database.Cursor;
26 import android.database.SQLException;
27 import android.net.Uri;
28 import android.os.Handler;
29 import android.os.Looper;
30 import android.os.Message;
31 import android.provider.ContactsContract;
32 import android.provider.ContactsContract.Directory;
33 import android.telephony.PhoneNumberUtils;
34 import android.text.TextUtils;
35 
36 import com.android.contacts.common.ContactsUtils;
37 import com.android.contacts.common.compat.DirectoryCompat;
38 import com.android.contacts.common.util.PermissionsUtil;
39 import com.android.contacts.common.util.TelephonyManagerUtils;
40 import com.android.dialer.R;
41 import com.android.dialer.calllog.ContactInfoHelper;
42 import com.android.dialer.service.CachedNumberLookupService;
43 import com.android.dialer.service.CachedNumberLookupService.CachedContactInfo;
44 import com.android.dialerbind.ObjectFactory;
45 
46 import java.io.IOException;
47 import java.io.InputStream;
48 import java.util.ArrayList;
49 import java.util.Arrays;
50 import java.util.Locale;
51 
52 /**
53  * Helper class to make it easier to run asynchronous caller-id lookup queries.
54  * @see CallerInfo
55  *
56  */
57 public class CallerInfoAsyncQuery {
58     private static final boolean DBG = false;
59     private static final String LOG_TAG = "CallerInfoAsyncQuery";
60 
61     private static final int EVENT_NEW_QUERY = 1;
62     private static final int EVENT_ADD_LISTENER = 2;
63     private static final int EVENT_END_OF_QUEUE = 3;
64     private static final int EVENT_EMERGENCY_NUMBER = 4;
65     private static final int EVENT_VOICEMAIL_NUMBER = 5;
66 
67     private CallerInfoAsyncQueryHandler mHandler;
68 
69     // If the CallerInfo query finds no contacts, should we use the
70     // PhoneNumberOfflineGeocoder to look up a "geo description"?
71     // (TODO: This could become a flag in config.xml if it ever needs to be
72     // configured on a per-product basis.)
73     private static final boolean ENABLE_UNKNOWN_NUMBER_GEO_DESCRIPTION = true;
74 
75     /**
76      * Interface for a CallerInfoAsyncQueryHandler result return.
77      */
78     public interface OnQueryCompleteListener {
79         /**
80          * Called when the query is complete.
81          */
onQueryComplete(int token, Object cookie, CallerInfo ci)82         public void onQueryComplete(int token, Object cookie, CallerInfo ci);
83     }
84 
85 
86     /**
87      * Wrap the cookie from the WorkerArgs with additional information needed by our
88      * classes.
89      */
90     private static final class CookieWrapper {
91         public OnQueryCompleteListener listener;
92         public Object cookie;
93         public int event;
94         public String number;
95     }
96 
97     /**
98      * Simple exception used to communicate problems with the query pool.
99      */
100     public static class QueryPoolException extends SQLException {
QueryPoolException(String error)101         public QueryPoolException(String error) {
102             super(error);
103         }
104     }
105 
106     /**
107      * Our own implementation of the AsyncQueryHandler.
108      */
109     private class CallerInfoAsyncQueryHandler extends AsyncQueryHandler {
110 
111         @Override
startQuery(int token, Object cookie, Uri uri, String[] projection, String selection, String[] selectionArgs, String orderBy)112         public void startQuery(int token, Object cookie, Uri uri, String[] projection,
113                 String selection, String[] selectionArgs, String orderBy) {
114             if (DBG) {
115                 // Show stack trace with the arguments.
116                 android.util.Log.d(LOG_TAG, "InCall: startQuery: url=" + uri +
117                                 " projection=[" + Arrays.toString(projection) + "]" +
118                                 " selection=" + selection + " " +
119                                 " args=[" + Arrays.toString(selectionArgs) + "]",
120                         new RuntimeException("STACKTRACE"));
121             }
122             super.startQuery(token, cookie, uri, projection, selection, selectionArgs, orderBy);
123         }
124 
125         /**
126          * The information relevant to each CallerInfo query.  Each query may have multiple
127          * listeners, so each AsyncCursorInfo is associated with 2 or more CookieWrapper
128          * objects in the queue (one with a new query event, and one with a end event, with
129          * 0 or more additional listeners in between).
130          */
131         private Context mQueryContext;
132         private Uri mQueryUri;
133         private CallerInfo mCallerInfo;
134 
135         /**
136          * Our own query worker thread.
137          *
138          * This thread handles the messages enqueued in the looper.  The normal sequence
139          * of events is that a new query shows up in the looper queue, followed by 0 or
140          * more add listener requests, and then an end request.  Of course, these requests
141          * can be interlaced with requests from other tokens, but is irrelevant to this
142          * handler since the handler has no state.
143          *
144          * Note that we depend on the queue to keep things in order; in other words, the
145          * looper queue must be FIFO with respect to input from the synchronous startQuery
146          * calls and output to this handleMessage call.
147          *
148          * This use of the queue is required because CallerInfo objects may be accessed
149          * multiple times before the query is complete.  All accesses (listeners) must be
150          * queued up and informed in order when the query is complete.
151          */
152         protected class CallerInfoWorkerHandler extends WorkerHandler {
CallerInfoWorkerHandler(Looper looper)153             public CallerInfoWorkerHandler(Looper looper) {
154                 super(looper);
155             }
156 
157             @Override
handleMessage(Message msg)158             public void handleMessage(Message msg) {
159                 WorkerArgs args = (WorkerArgs) msg.obj;
160                 CookieWrapper cw = (CookieWrapper) args.cookie;
161 
162                 if (cw == null) {
163                     // Normally, this should never be the case for calls originating
164                     // from within this code.
165                     // However, if there is any code that this Handler calls (such as in
166                     // super.handleMessage) that DOES place unexpected messages on the
167                     // queue, then we need pass these messages on.
168                     Log.d(this, "Unexpected command (CookieWrapper is null): " + msg.what +
169                             " ignored by CallerInfoWorkerHandler, passing onto parent.");
170 
171                     super.handleMessage(msg);
172                 } else {
173                     Log.d(this, "Processing event: " + cw.event + " token (arg1): " + msg.arg1 +
174                             " command: " + msg.what + " query URI: " +
175                             sanitizeUriToString(args.uri));
176 
177                     switch (cw.event) {
178                         case EVENT_NEW_QUERY:
179                             //start the sql command.
180                             super.handleMessage(msg);
181                             break;
182 
183                         // shortcuts to avoid query for recognized numbers.
184                         case EVENT_EMERGENCY_NUMBER:
185                         case EVENT_VOICEMAIL_NUMBER:
186 
187                         case EVENT_ADD_LISTENER:
188                         case EVENT_END_OF_QUEUE:
189                             // query was already completed, so just send the reply.
190                             // passing the original token value back to the caller
191                             // on top of the event values in arg1.
192                             Message reply = args.handler.obtainMessage(msg.what);
193                             reply.obj = args;
194                             reply.arg1 = msg.arg1;
195 
196                             reply.sendToTarget();
197 
198                             break;
199                         default:
200                     }
201                 }
202             }
203         }
204 
205 
206         /**
207          * Asynchronous query handler class for the contact / callerinfo object.
208          */
CallerInfoAsyncQueryHandler(Context context)209         private CallerInfoAsyncQueryHandler(Context context) {
210             super(context.getContentResolver());
211         }
212 
213         @Override
createHandler(Looper looper)214         protected Handler createHandler(Looper looper) {
215             return new CallerInfoWorkerHandler(looper);
216         }
217 
218         /**
219          * Overrides onQueryComplete from AsyncQueryHandler.
220          *
221          * This method takes into account the state of this class; we construct the CallerInfo
222          * object only once for each set of listeners. When the query thread has done its work
223          * and calls this method, we inform the remaining listeners in the queue, until we're
224          * out of listeners.  Once we get the message indicating that we should expect no new
225          * listeners for this CallerInfo object, we release the AsyncCursorInfo back into the
226          * pool.
227          */
228         @Override
onQueryComplete(int token, Object cookie, Cursor cursor)229         protected void onQueryComplete(int token, Object cookie, Cursor cursor) {
230             try {
231                 Log.d(this, "##### onQueryComplete() #####   query complete for token: " + token);
232 
233                 //get the cookie and notify the listener.
234                 CookieWrapper cw = (CookieWrapper) cookie;
235                 if (cw == null) {
236                     // Normally, this should never be the case for calls originating
237                     // from within this code.
238                     // However, if there is any code that calls this method, we should
239                     // check the parameters to make sure they're viable.
240                     Log.d(this, "Cookie is null, ignoring onQueryComplete() request.");
241                     return;
242                 }
243 
244                 if (cw.event == EVENT_END_OF_QUEUE) {
245                     release();
246                     return;
247                 }
248 
249                 // check the token and if needed, create the callerinfo object.
250                 if (mCallerInfo == null) {
251                     if ((mQueryContext == null) || (mQueryUri == null)) {
252                         throw new QueryPoolException
253                             ("Bad context or query uri, or CallerInfoAsyncQuery already released.");
254                     }
255 
256                     // adjust the callerInfo data as needed, and only if it was set from the
257                     // initial query request.
258                     // Change the callerInfo number ONLY if it is an emergency number or the
259                     // voicemail number, and adjust other data (including photoResource)
260                     // accordingly.
261                     if (cw.event == EVENT_EMERGENCY_NUMBER) {
262                         // Note we're setting the phone number here (refer to javadoc
263                         // comments at the top of CallerInfo class).
264                         mCallerInfo = new CallerInfo().markAsEmergency(mQueryContext);
265                     } else if (cw.event == EVENT_VOICEMAIL_NUMBER) {
266                         mCallerInfo = new CallerInfo().markAsVoiceMail(mQueryContext);
267                     } else {
268                         mCallerInfo = CallerInfo.getCallerInfo(mQueryContext, mQueryUri, cursor);
269                         Log.d(this, "==> Got mCallerInfo: " + mCallerInfo);
270 
271                         CallerInfo newCallerInfo = CallerInfo.doSecondaryLookupIfNecessary(
272                                 mQueryContext, cw.number, mCallerInfo);
273                         if (newCallerInfo != mCallerInfo) {
274                             mCallerInfo = newCallerInfo;
275                             Log.d(this, "#####async contact look up with numeric username"
276                                     + mCallerInfo);
277                         }
278 
279                         // Final step: look up the geocoded description.
280                         if (ENABLE_UNKNOWN_NUMBER_GEO_DESCRIPTION) {
281                             // Note we do this only if we *don't* have a valid name (i.e. if
282                             // no contacts matched the phone number of the incoming call),
283                             // since that's the only case where the incoming-call UI cares
284                             // about this field.
285                             //
286                             // (TODO: But if we ever want the UI to show the geoDescription
287                             // even when we *do* match a contact, we'll need to either call
288                             // updateGeoDescription() unconditionally here, or possibly add a
289                             // new parameter to CallerInfoAsyncQuery.startQuery() to force
290                             // the geoDescription field to be populated.)
291 
292                             if (TextUtils.isEmpty(mCallerInfo.name)) {
293                                 // Actually when no contacts match the incoming phone number,
294                                 // the CallerInfo object is totally blank here (i.e. no name
295                                 // *or* phoneNumber).  So we need to pass in cw.number as
296                                 // a fallback number.
297                                 mCallerInfo.updateGeoDescription(mQueryContext, cw.number);
298                             }
299                         }
300 
301                         // Use the number entered by the user for display.
302                         if (!TextUtils.isEmpty(cw.number)) {
303                             mCallerInfo.phoneNumber = PhoneNumberUtils.formatNumber(cw.number,
304                                     mCallerInfo.normalizedNumber,
305                                     TelephonyManagerUtils.getCurrentCountryIso(mQueryContext,
306                                             Locale.getDefault()));
307                         }
308                     }
309 
310                     Log.d(this, "constructing CallerInfo object for token: " + token);
311 
312                     //notify that we can clean up the queue after this.
313                     CookieWrapper endMarker = new CookieWrapper();
314                     endMarker.event = EVENT_END_OF_QUEUE;
315                     startQuery(token, endMarker, null, null, null, null, null);
316                 }
317 
318                 //notify the listener that the query is complete.
319                 if (cw.listener != null) {
320                     Log.d(this, "notifying listener: " + cw.listener.getClass().toString() +
321                             " for token: " + token + mCallerInfo);
322                     cw.listener.onQueryComplete(token, cw.cookie, mCallerInfo);
323                 }
324             } finally {
325                 // The cursor may have been closed in CallerInfo.getCallerInfo()
326                 if (cursor != null && !cursor.isClosed()) {
327                     cursor.close();
328                 }
329             }
330         }
331     }
332 
333     /**
334      * Private constructor for factory methods.
335      */
CallerInfoAsyncQuery()336     private CallerInfoAsyncQuery() {
337     }
338 
startQuery(final int token, final Context context, final CallerInfo info, final OnQueryCompleteListener listener, final Object cookie)339     public static void startQuery(final int token, final Context context, final CallerInfo info,
340             final OnQueryCompleteListener listener, final Object cookie) {
341         Log.d(LOG_TAG, "##### CallerInfoAsyncQuery startContactProviderQuery()... #####");
342         Log.d(LOG_TAG, "- number: " + info.phoneNumber);
343         Log.d(LOG_TAG, "- cookie: " + cookie);
344         if (!PermissionsUtil.hasPermission(context, Manifest.permission.READ_CONTACTS)) {
345             Log.w(LOG_TAG, "Dialer doesn't have permission to read contacts.");
346             listener.onQueryComplete(token, cookie, info);
347             return;
348         }
349 
350         OnQueryCompleteListener contactsProviderQueryCompleteListener =
351                 new OnQueryCompleteListener() {
352                     @Override
353                     public void onQueryComplete(int token, Object cookie, CallerInfo ci) {
354                         Log.d(LOG_TAG, "contactsProviderQueryCompleteListener done");
355                         // If there are no other directory queries, make sure that the listener is
356                         // notified of this result.  see b/27621628
357                         if ((ci != null && ci.contactExists) ||
358                             !startOtherDirectoriesQuery(token, context, info, listener, cookie)) {
359                             if (listener != null && ci != null) {
360                                 listener.onQueryComplete(token, cookie, ci);
361                             }
362                         }
363                     }
364                 };
365         startDefaultDirectoryQuery(token, context, info, contactsProviderQueryCompleteListener,
366                 cookie);
367     }
368 
369     // Private methods
startDefaultDirectoryQuery(int token, Context context, CallerInfo info, OnQueryCompleteListener listener, Object cookie)370     private static CallerInfoAsyncQuery startDefaultDirectoryQuery(int token, Context context,
371             CallerInfo info, OnQueryCompleteListener listener, Object cookie) {
372         // Construct the URI object and query params, and start the query.
373         Uri uri = ContactInfoHelper.getContactInfoLookupUri(info.phoneNumber);
374         return startQueryInternal(token, context, info, listener, cookie, uri);
375     }
376 
377     /**
378      * Factory method to start the query based on a CallerInfo object.
379      *
380      * Note: if the number contains an "@" character we treat it
381      * as a SIP address, and look it up directly in the Data table
382      * rather than using the PhoneLookup table.
383      * TODO: But eventually we should expose two separate methods, one for
384      * numbers and one for SIP addresses, and then have
385      * PhoneUtils.startGetCallerInfo() decide which one to call based on
386      * the phone type of the incoming connection.
387      */
startQueryInternal(int token, Context context, CallerInfo info, OnQueryCompleteListener listener, Object cookie, Uri contactRef)388     private static CallerInfoAsyncQuery startQueryInternal(int token, Context context,
389             CallerInfo info, OnQueryCompleteListener listener, Object cookie, Uri contactRef) {
390         if (DBG) {
391             Log.d(LOG_TAG, "==> contactRef: " + sanitizeUriToString(contactRef));
392         }
393 
394         CallerInfoAsyncQuery c = new CallerInfoAsyncQuery();
395         c.allocate(context, contactRef);
396 
397         //create cookieWrapper, start query
398         CookieWrapper cw = new CookieWrapper();
399         cw.listener = listener;
400         cw.cookie = cookie;
401         cw.number = info.phoneNumber;
402 
403         // check to see if these are recognized numbers, and use shortcuts if we can.
404         if (PhoneNumberUtils.isLocalEmergencyNumber(context, info.phoneNumber)) {
405             cw.event = EVENT_EMERGENCY_NUMBER;
406         } else if (info.isVoiceMailNumber()) {
407             cw.event = EVENT_VOICEMAIL_NUMBER;
408         } else {
409             cw.event = EVENT_NEW_QUERY;
410         }
411 
412 
413         String[] proejection = CallerInfo.getDefaultPhoneLookupProjection(contactRef);
414         c.mHandler.startQuery(token,
415                 cw,  // cookie
416                 contactRef,  // uri
417                 proejection, // projection
418                 null,  // selection
419                 null,  // selectionArgs
420                 null);  // orderBy
421         return c;
422     }
423 
424     // Return value indicates if listener was notified.
startOtherDirectoriesQuery(int token, Context context, CallerInfo info, OnQueryCompleteListener listener, Object cookie)425     private static boolean startOtherDirectoriesQuery(int token, Context context, CallerInfo info,
426             OnQueryCompleteListener listener, Object cookie) {
427         long[] directoryIds = getDirectoryIds(context);
428         int size = directoryIds.length;
429         if (size == 0) {
430             return false;
431         }
432 
433         DirectoryQueryCompleteListenerFactory listenerFactory =
434                 new DirectoryQueryCompleteListenerFactory(context, size, listener);
435 
436         // The current implementation of multiple async query runs in single handler thread
437         // in AsyncQueryHandler.
438         // intermediateListener.onQueryComplete is also called from the same caller thread.
439         // TODO(b/26019872): use thread pool instead of single thread.
440         for (int i = 0; i < size; i++) {
441             long directoryId = directoryIds[i];
442             Uri uri = ContactInfoHelper.getContactInfoLookupUri(info.phoneNumber, directoryId);
443             if (DBG) {
444                 Log.d(LOG_TAG, "directoryId: " + directoryId + " uri: " + uri);
445             }
446             OnQueryCompleteListener intermediateListener =
447                     listenerFactory.newListener(directoryId);
448             startQueryInternal(token, context, info, intermediateListener, cookie, uri);
449         }
450         return true;
451     }
452 
453     /* Directory lookup related code - START */
454     private static final String[] DIRECTORY_PROJECTION = new String[] {Directory._ID};
455 
getDirectoryIds(Context context)456     private static long[] getDirectoryIds(Context context) {
457         ArrayList<Long> results = new ArrayList<>();
458 
459         Uri uri = Directory.CONTENT_URI;
460         if (ContactsUtils.FLAG_N_FEATURE) {
461             uri = Uri.withAppendedPath(ContactsContract.AUTHORITY_URI, "directories_enterprise");
462         }
463 
464         ContentResolver cr = context.getContentResolver();
465         Cursor cursor = cr.query(uri, DIRECTORY_PROJECTION, null, null, null);
466         addDirectoryIdsFromCursor(cursor, results);
467 
468         return Longs.toArray(results);
469     }
470 
addDirectoryIdsFromCursor(Cursor cursor, ArrayList<Long> results)471     private static void addDirectoryIdsFromCursor(Cursor cursor, ArrayList<Long> results) {
472         if (cursor != null) {
473             int idIndex = cursor.getColumnIndex(Directory._ID);
474             while (cursor.moveToNext()) {
475                 long id = cursor.getLong(idIndex);
476                 if (DirectoryCompat.isRemoteDirectoryId(id)) {
477                     results.add(id);
478                 }
479             }
480             cursor.close();
481         }
482     }
483 
484     private static final class DirectoryQueryCompleteListenerFactory {
485         // Make sure listener to be called once and only once
486         private int mCount;
487         private boolean mIsListenerCalled;
488         private final OnQueryCompleteListener mListener;
489         private final Context mContext;
490         private final CachedNumberLookupService mCachedNumberLookupService =
491                 ObjectFactory.newCachedNumberLookupService();
492 
493         private class DirectoryQueryCompleteListener implements OnQueryCompleteListener {
494             private final long mDirectoryId;
495 
DirectoryQueryCompleteListener(long directoryId)496             DirectoryQueryCompleteListener(long directoryId) {
497                 mDirectoryId = directoryId;
498             }
499 
500             @Override
onQueryComplete(int token, Object cookie, CallerInfo ci)501             public void onQueryComplete(int token, Object cookie, CallerInfo ci) {
502                 onDirectoryQueryComplete(token, cookie, ci, mDirectoryId);
503             }
504         }
505 
DirectoryQueryCompleteListenerFactory(Context context, int size, OnQueryCompleteListener listener)506         DirectoryQueryCompleteListenerFactory(Context context, int size,
507                 OnQueryCompleteListener listener) {
508             mCount = size;
509             mListener = listener;
510             mIsListenerCalled = false;
511             mContext = context;
512         }
513 
onDirectoryQueryComplete(int token, Object cookie, CallerInfo ci, long directoryId)514         private void onDirectoryQueryComplete(int token, Object cookie, CallerInfo ci,
515                 long directoryId) {
516             boolean shouldCallListener = false;
517             synchronized (this) {
518                 mCount = mCount - 1;
519                 if (!mIsListenerCalled && (ci.contactExists || mCount == 0)) {
520                     mIsListenerCalled = true;
521                     shouldCallListener = true;
522                 }
523             }
524 
525             // Don't call callback in synchronized block because mListener.onQueryComplete may
526             // take long time to complete
527             if (shouldCallListener && mListener != null) {
528                 addCallerInfoIntoCache(ci, directoryId);
529                 mListener.onQueryComplete(token, cookie, ci);
530             }
531         }
532 
addCallerInfoIntoCache(CallerInfo ci, long directoryId)533         private void addCallerInfoIntoCache(CallerInfo ci, long directoryId) {
534             if (ci.contactExists && mCachedNumberLookupService != null) {
535                 // 1. Cache caller info
536                 CachedContactInfo cachedContactInfo = CallerInfoUtils
537                         .buildCachedContactInfo(mCachedNumberLookupService, ci);
538                 String directoryLabel = mContext.getString(R.string.directory_search_label);
539                 cachedContactInfo.setDirectorySource(directoryLabel, directoryId);
540                 mCachedNumberLookupService.addContact(mContext, cachedContactInfo);
541 
542                 // 2. Cache photo
543                 if (ci.contactDisplayPhotoUri != null && ci.normalizedNumber != null) {
544                     try (InputStream in = mContext.getContentResolver()
545                             .openInputStream(ci.contactDisplayPhotoUri)) {
546                         if (in != null) {
547                             mCachedNumberLookupService.addPhoto(mContext, ci.normalizedNumber, in);
548                         }
549                     } catch (IOException e) {
550                         Log.e(LOG_TAG, "failed to fetch directory contact photo", e);
551                     }
552 
553                 }
554             }
555         }
556 
newListener(long directoryId)557         public OnQueryCompleteListener newListener(long directoryId) {
558             return new DirectoryQueryCompleteListener(directoryId);
559         }
560     }
561     /* Directory lookup related code - END */
562 
563     /**
564      * Method to create a new CallerInfoAsyncQueryHandler object, ensuring correct
565      * state of context and uri.
566      */
allocate(Context context, Uri contactRef)567     private void allocate(Context context, Uri contactRef) {
568         if ((context == null) || (contactRef == null)){
569             throw new QueryPoolException("Bad context or query uri.");
570         }
571         mHandler = new CallerInfoAsyncQueryHandler(context);
572         mHandler.mQueryContext = context;
573         mHandler.mQueryUri = contactRef;
574     }
575 
576     /**
577      * Releases the relevant data.
578      */
release()579     private void release() {
580         mHandler.mQueryContext = null;
581         mHandler.mQueryUri = null;
582         mHandler.mCallerInfo = null;
583         mHandler = null;
584     }
585 
sanitizeUriToString(Uri uri)586     private static String sanitizeUriToString(Uri uri) {
587         if (uri != null) {
588             String uriString = uri.toString();
589             int indexOfLastSlash = uriString.lastIndexOf('/');
590             if (indexOfLastSlash > 0) {
591                 return uriString.substring(0, indexOfLastSlash) + "/xxxxxxx";
592             } else {
593                 return uriString;
594             }
595         } else {
596             return "";
597         }
598     }
599 }
600