1 /*
2  * Copyright (C) 2012 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.cellbroadcastreceiver;
18 
19 import static com.android.cellbroadcastservice.CellBroadcastMetrics.ERRSRC_CBR;
20 import static com.android.cellbroadcastservice.CellBroadcastMetrics.ERRTYPE_PROVIDERINIT;
21 
22 import android.annotation.NonNull;
23 import android.content.ContentProvider;
24 import android.content.ContentProviderClient;
25 import android.content.ContentResolver;
26 import android.content.ContentValues;
27 import android.content.Context;
28 import android.content.UriMatcher;
29 import android.database.Cursor;
30 import android.database.sqlite.SQLiteDatabase;
31 import android.database.sqlite.SQLiteQueryBuilder;
32 import android.net.Uri;
33 import android.os.AsyncTask;
34 import android.os.Bundle;
35 import android.os.UserManager;
36 import android.provider.Telephony;
37 import android.telephony.SmsCbCmasInfo;
38 import android.telephony.SmsCbEtwsInfo;
39 import android.telephony.SmsCbLocation;
40 import android.telephony.SmsCbMessage;
41 import android.text.TextUtils;
42 import android.util.Log;
43 
44 import com.android.internal.annotations.VisibleForTesting;
45 
46 import java.util.concurrent.CountDownLatch;
47 
48 /**
49  * ContentProvider for the database of received cell broadcasts.
50  */
51 public class CellBroadcastContentProvider extends ContentProvider {
52     private static final String TAG = "CellBroadcastContentProvider";
53 
54     /** URI matcher for ContentProvider queries. */
55     private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
56 
57     /** Authority string for content URIs. */
58     @VisibleForTesting
59     public static final String CB_AUTHORITY = "cellbroadcasts-app";
60 
61     /** Content URI for notifying observers. */
62     static final Uri CONTENT_URI = Uri.parse("content://cellbroadcasts-app/");
63 
64     /** URI matcher type to get all cell broadcasts. */
65     private static final int CB_ALL = 0;
66 
67     /** URI matcher type to get a cell broadcast by ID. */
68     private static final int CB_ALL_ID = 1;
69 
70     /** MIME type for the list of all cell broadcasts. */
71     private static final String CB_LIST_TYPE = "vnd.android.cursor.dir/cellbroadcast";
72 
73     /** MIME type for an individual cell broadcast. */
74     private static final String CB_TYPE = "vnd.android.cursor.item/cellbroadcast";
75 
76     public static final String CALL_MIGRATION_METHOD = "migrate-legacy-data";
77 
78     static {
sUriMatcher.addURI(CB_AUTHORITY, null, CB_ALL)79         sUriMatcher.addURI(CB_AUTHORITY, null, CB_ALL);
sUriMatcher.addURI(CB_AUTHORITY, "#", CB_ALL_ID)80         sUriMatcher.addURI(CB_AUTHORITY, "#", CB_ALL_ID);
81     }
82 
83     /**
84      * The database for this content provider. Before using this we need to wait on
85      * mInitializedLatch, which counts down once initialization finishes in a background thread
86      */
87 
88     @VisibleForTesting
89     public CellBroadcastDatabaseHelper mOpenHelper;
90 
91     // Latch which counts down from 1 when initialization in CellBroadcastOpenHelper.tryToMigrateV13
92     // is finished
93     private final CountDownLatch mInitializedLatch = new CountDownLatch(1);
94 
95     /**
96      * Initialize content provider.
97      * @return true if the provider was successfully loaded, false otherwise
98      */
99     @Override
onCreate()100     public boolean onCreate() {
101         mOpenHelper = new CellBroadcastDatabaseHelper(getContext(), false);
102         // trigger this to create database explicitly. Otherwise the db will be created only after
103         // the first query/update/insertion. Data migration is done inside db creation and we want
104         // to migrate data from cellbroadcast-legacy immediately when upgrade to the mainline module
105         // rather than migrate after the first emergency alert.
106         // getReadable database will also call tryToMigrateV13 which copies the DB file to allow
107         // for safe rollbacks.
108         // This is done in a background thread to avoid triggering an ANR if the disk operations are
109         // too slow, and all other database uses should wait for the latch.
110         new Thread(() -> {
111             mOpenHelper.getReadableDatabase();
112             mInitializedLatch.countDown();
113         }).start();
114         return true;
115     }
116 
awaitInitAndGetWritableDatabase()117     protected SQLiteDatabase awaitInitAndGetWritableDatabase() {
118         while (mInitializedLatch.getCount() != 0) {
119             try {
120                 mInitializedLatch.await();
121             } catch (InterruptedException e) {
122                 CellBroadcastReceiverMetrics.getInstance().logModuleError(
123                         ERRSRC_CBR, ERRTYPE_PROVIDERINIT);
124                 Log.e(TAG, "Interrupted while waiting for db initialization. e=" + e);
125             }
126         }
127         return mOpenHelper.getWritableDatabase();
128     }
129 
awaitInitAndGetReadableDatabase()130     protected SQLiteDatabase awaitInitAndGetReadableDatabase() {
131         while (mInitializedLatch.getCount() != 0) {
132             try {
133                 mInitializedLatch.await();
134             } catch (InterruptedException e) {
135                 CellBroadcastReceiverMetrics.getInstance().logModuleError(
136                         ERRSRC_CBR, ERRTYPE_PROVIDERINIT);
137                 Log.e(TAG, "Interrupted while waiting for db initialization. e=" + e);
138             }
139         }
140         return mOpenHelper.getReadableDatabase();
141     }
142 
143     /**
144      * Return a cursor for the cell broadcast table.
145      * @param uri the URI to query.
146      * @param projection the list of columns to put into the cursor, or null.
147      * @param selection the selection criteria to apply when filtering rows, or null.
148      * @param selectionArgs values to replace ?s in selection string.
149      * @param sortOrder how the rows in the cursor should be sorted, or null to sort from most
150      *  recently received to least recently received.
151      * @return a Cursor or null.
152      */
153     @Override
query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder)154     public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
155             String sortOrder) {
156         SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
157         qb.setTables(CellBroadcastDatabaseHelper.TABLE_NAME);
158 
159         int match = sUriMatcher.match(uri);
160         switch (match) {
161             case CB_ALL:
162                 // get all broadcasts
163                 break;
164 
165             case CB_ALL_ID:
166                 // get broadcast by ID
167                 qb.appendWhere("(_id=" + uri.getPathSegments().get(0) + ')');
168                 break;
169 
170             default:
171                 Log.e(TAG, "Invalid query: " + uri);
172                 throw new IllegalArgumentException("Unknown URI: " + uri);
173         }
174 
175         String orderBy;
176         if (!TextUtils.isEmpty(sortOrder)) {
177             orderBy = sortOrder;
178         } else {
179             orderBy = Telephony.CellBroadcasts.DEFAULT_SORT_ORDER;
180         }
181 
182         SQLiteDatabase db = awaitInitAndGetReadableDatabase();
183         Cursor c = qb.query(db, projection, selection, selectionArgs, null, null, orderBy);
184         if (c != null) {
185             c.setNotificationUri(getContext().getContentResolver(), CONTENT_URI);
186         }
187         return c;
188     }
189 
190     /**
191      * Return the MIME type of the data at the specified URI.
192      * @param uri the URI to query.
193      * @return a MIME type string, or null if there is no type.
194      */
195     @Override
getType(Uri uri)196     public String getType(Uri uri) {
197         int match = sUriMatcher.match(uri);
198         switch (match) {
199             case CB_ALL:
200                 return CB_LIST_TYPE;
201 
202             case CB_ALL_ID:
203                 return CB_TYPE;
204 
205             default:
206                 return null;
207         }
208     }
209 
210     /**
211      * Insert a new row. This throws an exception, as the database can only be modified by
212      * calling custom methods in this class, and not via the ContentProvider interface.
213      * @param uri the content:// URI of the insertion request.
214      * @param values a set of column_name/value pairs to add to the database.
215      * @return the URI for the newly inserted item.
216      */
217     @Override
insert(Uri uri, ContentValues values)218     public Uri insert(Uri uri, ContentValues values) {
219         throw new UnsupportedOperationException("insert not supported");
220     }
221 
222     /**
223      * Delete one or more rows. This throws an exception, as the database can only be modified by
224      * calling custom methods in this class, and not via the ContentProvider interface.
225      * @param uri the full URI to query, including a row ID (if a specific record is requested).
226      * @param selection an optional restriction to apply to rows when deleting.
227      * @return the number of rows affected.
228      */
229     @Override
delete(Uri uri, String selection, String[] selectionArgs)230     public int delete(Uri uri, String selection, String[] selectionArgs) {
231         throw new UnsupportedOperationException("delete not supported");
232     }
233 
234     /**
235      * Update one or more rows. This throws an exception, as the database can only be modified by
236      * calling custom methods in this class, and not via the ContentProvider interface.
237      * @param uri the URI to query, potentially including the row ID.
238      * @param values a Bundle mapping from column names to new column values.
239      * @param selection an optional filter to match rows to update.
240      * @return the number of rows affected.
241      */
242     @Override
update(Uri uri, ContentValues values, String selection, String[] selectionArgs)243     public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
244         throw new UnsupportedOperationException("update not supported");
245     }
246 
247     @Override
call(String method, String name, Bundle args)248     public Bundle call(String method, String name, Bundle args) {
249         Log.d(TAG, "call:"
250                 + " method=" + method
251                 + " name=" + name
252                 + " args=" + args);
253         // this is to handle a content-provider defined method: migration
254         if (CALL_MIGRATION_METHOD.equals(method)) {
255             mOpenHelper.migrateFromLegacyIfNeeded(awaitInitAndGetReadableDatabase());
256         }
257         return null;
258     }
259 
getContentValues(SmsCbMessage message)260     private ContentValues getContentValues(SmsCbMessage message) {
261         ContentValues cv = new ContentValues();
262         cv.put(Telephony.CellBroadcasts.SLOT_INDEX, message.getSlotIndex());
263         cv.put(Telephony.CellBroadcasts.GEOGRAPHICAL_SCOPE, message.getGeographicalScope());
264         SmsCbLocation location = message.getLocation();
265         cv.put(Telephony.CellBroadcasts.PLMN, location.getPlmn());
266         if (location.getLac() != -1) {
267             cv.put(Telephony.CellBroadcasts.LAC, location.getLac());
268         }
269         if (location.getCid() != -1) {
270             cv.put(Telephony.CellBroadcasts.CID, location.getCid());
271         }
272         cv.put(Telephony.CellBroadcasts.SERIAL_NUMBER, message.getSerialNumber());
273         cv.put(Telephony.CellBroadcasts.SERVICE_CATEGORY, message.getServiceCategory());
274         cv.put(Telephony.CellBroadcasts.LANGUAGE_CODE, message.getLanguageCode());
275         cv.put(Telephony.CellBroadcasts.MESSAGE_BODY, message.getMessageBody());
276         cv.put(Telephony.CellBroadcasts.DELIVERY_TIME, message.getReceivedTime());
277         cv.put(Telephony.CellBroadcasts.MESSAGE_FORMAT, message.getMessageFormat());
278         cv.put(Telephony.CellBroadcasts.MESSAGE_PRIORITY, message.getMessagePriority());
279 
280         SmsCbEtwsInfo etwsInfo = message.getEtwsWarningInfo();
281         if (etwsInfo != null) {
282             cv.put(Telephony.CellBroadcasts.ETWS_WARNING_TYPE, etwsInfo.getWarningType());
283         }
284 
285         SmsCbCmasInfo cmasInfo = message.getCmasWarningInfo();
286         if (cmasInfo != null) {
287             cv.put(Telephony.CellBroadcasts.CMAS_MESSAGE_CLASS, cmasInfo.getMessageClass());
288             cv.put(Telephony.CellBroadcasts.CMAS_CATEGORY, cmasInfo.getCategory());
289             cv.put(Telephony.CellBroadcasts.CMAS_RESPONSE_TYPE, cmasInfo.getResponseType());
290             cv.put(Telephony.CellBroadcasts.CMAS_SEVERITY, cmasInfo.getSeverity());
291             cv.put(Telephony.CellBroadcasts.CMAS_URGENCY, cmasInfo.getUrgency());
292             cv.put(Telephony.CellBroadcasts.CMAS_CERTAINTY, cmasInfo.getCertainty());
293         }
294 
295         return cv;
296     }
297 
298     /**
299      * Internal method to insert a new Cell Broadcast into the database and notify observers.
300      * @param message the message to insert
301      * @return true if the broadcast is new, false if it's a duplicate broadcast.
302      */
303     @VisibleForTesting
insertNewBroadcast(SmsCbMessage message)304     public boolean insertNewBroadcast(SmsCbMessage message) {
305         SQLiteDatabase db = awaitInitAndGetWritableDatabase();
306         ContentValues cv = getContentValues(message);
307 
308         // Note: this method previously queried the database for duplicate message IDs, but this
309         // is not compatible with CMAS carrier requirements and could also cause other emergency
310         // alerts, e.g. ETWS, to not display if the database is filled with old messages.
311         // Use duplicate message ID detection in CellBroadcastAlertService instead of DB query.
312         long rowId = db.insert(CellBroadcastDatabaseHelper.TABLE_NAME, null, cv);
313         if (rowId == -1) {
314             Log.e(TAG, "failed to insert new broadcast into database");
315             // Return true on DB write failure because we still want to notify the user.
316             // The SmsCbMessage will be passed with the intent, so the message will be
317             // displayed in the emergency alert dialog, or the dialog that is displayed when
318             // the user selects the notification for a non-emergency broadcast, even if the
319             // broadcast could not be written to the database.
320         }
321         return true;    // broadcast is not a duplicate
322     }
323 
324     /**
325      * Internal method to delete a cell broadcast by row ID and notify observers.
326      * @param rowId the row ID of the broadcast to delete
327      * @return true if the database was updated, false otherwise
328      */
329     @VisibleForTesting
deleteBroadcast(long rowId)330     public boolean deleteBroadcast(long rowId) {
331         SQLiteDatabase db = awaitInitAndGetWritableDatabase();
332 
333         int rowCount = db.delete(CellBroadcastDatabaseHelper.TABLE_NAME,
334                 Telephony.CellBroadcasts._ID + "=?",
335                 new String[]{Long.toString(rowId)});
336         if (rowCount != 0) {
337             return true;
338         } else {
339             Log.e(TAG, "failed to delete broadcast at row " + rowId);
340             return false;
341         }
342     }
343 
344     /**
345      * Internal method to delete all cell broadcasts and notify observers.
346      * @return true if the database was updated, false otherwise
347      */
348     @VisibleForTesting
deleteAllBroadcasts()349     public boolean deleteAllBroadcasts() {
350         SQLiteDatabase db = awaitInitAndGetWritableDatabase();
351 
352         int rowCount = db.delete(CellBroadcastDatabaseHelper.TABLE_NAME, null, null);
353         if (rowCount != 0) {
354             return true;
355         } else {
356             Log.e(TAG, "failed to delete all broadcasts");
357             return false;
358         }
359     }
360 
361     /**
362      * Internal method to mark a broadcast as read and notify observers. The broadcast can be
363      * identified by delivery time (for new alerts) or by row ID. The caller is responsible for
364      * decrementing the unread non-emergency alert count, if necessary.
365      *
366      * @param columnName the column name to query (ID or delivery time)
367      * @param columnValue the ID or delivery time of the broadcast to mark read
368      * @return true if the database was updated, false otherwise
369      */
markBroadcastRead(String columnName, long columnValue)370     boolean markBroadcastRead(String columnName, long columnValue) {
371         SQLiteDatabase db = awaitInitAndGetWritableDatabase();
372 
373         ContentValues cv = new ContentValues(1);
374         cv.put(Telephony.CellBroadcasts.MESSAGE_READ, 1);
375 
376         String whereClause = columnName + "=?";
377         String[] whereArgs = new String[]{Long.toString(columnValue)};
378 
379         int rowCount = db.update(CellBroadcastDatabaseHelper.TABLE_NAME, cv, whereClause, whereArgs);
380         if (rowCount != 0) {
381             return true;
382         } else {
383             Log.e(TAG, "failed to mark broadcast read: " + columnName + " = " + columnValue);
384             return false;
385         }
386     }
387 
388     /**
389      * Internal method to mark a broadcast received in direct boot mode. After user unlocks, mark
390      * all messages not in direct boot mode.
391      *
392      * @param columnName the column name to query (ID or delivery time)
393      * @param columnValue the ID or delivery time of the broadcast to mark read
394      * @param isSmsSyncPending whether the message was pending for SMS inbox synchronization
395      * @return true if the database was updated, false otherwise
396      */
397     @VisibleForTesting
markBroadcastSmsSyncPending(String columnName, long columnValue, boolean isSmsSyncPending)398     public boolean markBroadcastSmsSyncPending(String columnName, long columnValue,
399             boolean isSmsSyncPending) {
400         SQLiteDatabase db = awaitInitAndGetWritableDatabase();
401 
402         ContentValues cv = new ContentValues(1);
403         cv.put(CellBroadcastDatabaseHelper.SMS_SYNC_PENDING, isSmsSyncPending ? 1 : 0);
404 
405         String whereClause = columnName + "=?";
406         String[] whereArgs = new String[]{Long.toString(columnValue)};
407 
408         int rowCount = db.update(CellBroadcastDatabaseHelper.TABLE_NAME, cv, whereClause,
409                 whereArgs);
410         if (rowCount != 0) {
411             return true;
412         } else {
413             Log.e(TAG, "failed to mark broadcast pending for sms inbox sync:  " + isSmsSyncPending
414                     + " where: " + columnName + " = " + columnValue);
415             return false;
416         }
417     }
418 
419     /**
420      * Write message to sms inbox if pending. e.g, when receive alerts in direct boot mode, we
421      * might need to sync message to sms inbox after user unlock.
422      * @param context
423      */
424 
425     @VisibleForTesting
resyncToSmsInbox(@onNull Context context)426     public void resyncToSmsInbox(@NonNull Context context) {
427         // query all messages currently marked as sms inbox sync pending
428         try (Cursor cursor = query(
429                 CellBroadcastContentProvider.CONTENT_URI,
430                 CellBroadcastDatabaseHelper.QUERY_COLUMNS,
431                 CellBroadcastDatabaseHelper.SMS_SYNC_PENDING + "=1",
432                 null, null)) {
433             if (cursor != null) {
434                 while (cursor.moveToNext()) {
435                     SmsCbMessage message = CellBroadcastCursorAdapter
436                             .createFromCursor(context, cursor);
437                     if (message != null) {
438                         Log.d(TAG, "handling message received pending for sms sync: "
439                                 + message.toString());
440                         writeMessageToSmsInbox(message, context);
441                         // mark message received in direct mode was handled
442                         markBroadcastSmsSyncPending(
443                                 Telephony.CellBroadcasts.DELIVERY_TIME,
444                                 message.getReceivedTime(), false);
445                     }
446                 }
447             }
448         }
449     }
450 
451     /**
452      * Write displayed cellbroadcast messages to sms inbox
453      *
454      * @param message The cell broadcast message.
455      */
456     @VisibleForTesting
writeMessageToSmsInbox(@onNull SmsCbMessage message, @NonNull Context context)457     public void writeMessageToSmsInbox(@NonNull SmsCbMessage message, @NonNull Context context) {
458         UserManager userManager = (UserManager) context.getSystemService(Context.USER_SERVICE);
459         if (!userManager.isSystemUser()) {
460             // SMS database is single-user mode, discard non-system users to avoid inserting twice.
461             Log.d(TAG, "ignoring writeMessageToSmsInbox due to non-system user");
462             return;
463         }
464         // Note SMS database is not direct boot aware for privacy reasons, we should only interact
465         // with sms db until users has unlocked.
466         if (!userManager.isUserUnlocked()) {
467             Log.d(TAG, "ignoring writeMessageToSmsInbox due to direct boot mode");
468             // need to retry after user unlock
469             markBroadcastSmsSyncPending(Telephony.CellBroadcasts.DELIVERY_TIME,
470                         message.getReceivedTime(), true);
471             return;
472         }
473         // composing SMS
474         ContentValues cv = new ContentValues();
475         cv.put(Telephony.Sms.Inbox.BODY, message.getMessageBody());
476         cv.put(Telephony.Sms.Inbox.DATE, message.getReceivedTime());
477         cv.put(Telephony.Sms.Inbox.SUBSCRIPTION_ID, message.getSubscriptionId());
478         cv.put(Telephony.Sms.Inbox.SUBJECT, context.getString(
479                 CellBroadcastResources.getDialogTitleResource(context, message)));
480         cv.put(Telephony.Sms.Inbox.ADDRESS,
481                 CellBroadcastResources.getSmsSenderAddressResourceEnglishString(context, message));
482         cv.put(Telephony.Sms.Inbox.THREAD_ID, Telephony.Threads.getOrCreateThreadId(context,
483                 CellBroadcastResources.getSmsSenderAddressResourceEnglishString(context, message)));
484         if (CellBroadcastSettings.getResourcesByOperator(context, message.getSubscriptionId(),
485                         CellBroadcastReceiver.getRoamingOperatorSupported(context))
486                 .getBoolean(R.bool.always_mark_sms_read)) {
487             // Always mark SMS message READ. End users expect when they read new CBS messages,
488             // the unread alert count in the notification should be decreased, as they thought it
489             // was coming from SMS. Now we are marking those SMS as read (SMS now serve as a message
490             // history purpose) and that should give clear messages to end-users that alerts are not
491             // from the SMS app but CellBroadcast and they should tap the notification to read alert
492             // in order to see decreased unread message count.
493             cv.put(Telephony.Sms.Inbox.READ, 1);
494         }
495         Uri uri = context.getContentResolver().insert(Telephony.Sms.Inbox.CONTENT_URI, cv);
496         if (uri == null) {
497             Log.e(TAG, "writeMessageToSmsInbox: failed");
498         } else {
499             Log.d(TAG, "writeMessageToSmsInbox: succeed uri = " + uri);
500         }
501     }
502 
503     /** Callback for users of AsyncCellBroadcastOperation. */
504     interface CellBroadcastOperation {
505         /**
506          * Perform an operation using the specified provider.
507          * @param provider the CellBroadcastContentProvider to use
508          * @return true if any rows were changed, false otherwise
509          */
execute(CellBroadcastContentProvider provider)510         boolean execute(CellBroadcastContentProvider provider);
511     }
512 
513     /**
514      * Async task to call this content provider's internal methods on a background thread.
515      * The caller supplies the CellBroadcastOperation object to call for this provider.
516      */
517     static class AsyncCellBroadcastTask extends AsyncTask<CellBroadcastOperation, Void, Void> {
518         /** Reference to this app's content resolver. */
519         private ContentResolver mContentResolver;
520 
AsyncCellBroadcastTask(ContentResolver contentResolver)521         AsyncCellBroadcastTask(ContentResolver contentResolver) {
522             mContentResolver = contentResolver;
523         }
524 
525         /**
526          * Perform a generic operation on the CellBroadcastContentProvider.
527          * @param params the CellBroadcastOperation object to call for this provider
528          * @return void
529          */
530         @Override
doInBackground(CellBroadcastOperation... params)531         protected Void doInBackground(CellBroadcastOperation... params) {
532             ContentProviderClient cpc = mContentResolver.acquireContentProviderClient(
533                     CellBroadcastContentProvider.CB_AUTHORITY);
534             CellBroadcastContentProvider provider = (CellBroadcastContentProvider)
535                     cpc.getLocalContentProvider();
536 
537             if (provider != null) {
538                 try {
539                     boolean changed = params[0].execute(provider);
540                     if (changed) {
541                         Log.d(TAG, "database changed: notifying observers...");
542                         mContentResolver.notifyChange(CONTENT_URI, null, false);
543                     }
544                 } finally {
545                     cpc.release();
546                 }
547             } else {
548                 Log.e(TAG, "getLocalContentProvider() returned null");
549             }
550 
551             mContentResolver = null;    // free reference to content resolver
552             return null;
553         }
554     }
555 }
556