/* * Copyright (C) 2012 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.cellbroadcastreceiver; import android.annotation.NonNull; import android.content.ContentProvider; import android.content.ContentProviderClient; import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; import android.content.UriMatcher; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteQueryBuilder; import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; import android.os.UserManager; import android.provider.Telephony; import android.telephony.SmsCbCmasInfo; import android.telephony.SmsCbEtwsInfo; import android.telephony.SmsCbLocation; import android.telephony.SmsCbMessage; import android.text.TextUtils; import android.util.Log; import com.android.internal.annotations.VisibleForTesting; import java.util.concurrent.CountDownLatch; /** * ContentProvider for the database of received cell broadcasts. */ public class CellBroadcastContentProvider extends ContentProvider { private static final String TAG = "CellBroadcastContentProvider"; /** URI matcher for ContentProvider queries. */ private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); /** Authority string for content URIs. */ @VisibleForTesting public static final String CB_AUTHORITY = "cellbroadcasts-app"; /** Content URI for notifying observers. */ static final Uri CONTENT_URI = Uri.parse("content://cellbroadcasts-app/"); /** URI matcher type to get all cell broadcasts. */ private static final int CB_ALL = 0; /** URI matcher type to get a cell broadcast by ID. */ private static final int CB_ALL_ID = 1; /** MIME type for the list of all cell broadcasts. */ private static final String CB_LIST_TYPE = "vnd.android.cursor.dir/cellbroadcast"; /** MIME type for an individual cell broadcast. */ private static final String CB_TYPE = "vnd.android.cursor.item/cellbroadcast"; public static final String CALL_MIGRATION_METHOD = "migrate-legacy-data"; static { sUriMatcher.addURI(CB_AUTHORITY, null, CB_ALL); sUriMatcher.addURI(CB_AUTHORITY, "#", CB_ALL_ID); } /** * The database for this content provider. Before using this we need to wait on * mInitializedLatch, which counts down once initialization finishes in a background thread */ @VisibleForTesting public CellBroadcastDatabaseHelper mOpenHelper; // Latch which counts down from 1 when initialization in CellBroadcastOpenHelper.tryToMigrateV13 // is finished private final CountDownLatch mInitializedLatch = new CountDownLatch(1); /** * Initialize content provider. * @return true if the provider was successfully loaded, false otherwise */ @Override public boolean onCreate() { mOpenHelper = new CellBroadcastDatabaseHelper(getContext(), false); // trigger this to create database explicitly. Otherwise the db will be created only after // the first query/update/insertion. Data migration is done inside db creation and we want // to migrate data from cellbroadcast-legacy immediately when upgrade to the mainline module // rather than migrate after the first emergency alert. // getReadable database will also call tryToMigrateV13 which copies the DB file to allow // for safe rollbacks. // This is done in a background thread to avoid triggering an ANR if the disk operations are // too slow, and all other database uses should wait for the latch. new Thread(() -> { mOpenHelper.getReadableDatabase(); mInitializedLatch.countDown(); }).start(); return true; } protected SQLiteDatabase awaitInitAndGetWritableDatabase() { while (mInitializedLatch.getCount() != 0) { try { mInitializedLatch.await(); } catch (InterruptedException e) { Log.e(TAG, "Interrupted while waiting for db initialization. e=" + e); } } return mOpenHelper.getWritableDatabase(); } protected SQLiteDatabase awaitInitAndGetReadableDatabase() { while (mInitializedLatch.getCount() != 0) { try { mInitializedLatch.await(); } catch (InterruptedException e) { Log.e(TAG, "Interrupted while waiting for db initialization. e=" + e); } } return mOpenHelper.getReadableDatabase(); } /** * Return a cursor for the cell broadcast table. * @param uri the URI to query. * @param projection the list of columns to put into the cursor, or null. * @param selection the selection criteria to apply when filtering rows, or null. * @param selectionArgs values to replace ?s in selection string. * @param sortOrder how the rows in the cursor should be sorted, or null to sort from most * recently received to least recently received. * @return a Cursor or null. */ @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); qb.setTables(CellBroadcastDatabaseHelper.TABLE_NAME); int match = sUriMatcher.match(uri); switch (match) { case CB_ALL: // get all broadcasts break; case CB_ALL_ID: // get broadcast by ID qb.appendWhere("(_id=" + uri.getPathSegments().get(0) + ')'); break; default: Log.e(TAG, "Invalid query: " + uri); throw new IllegalArgumentException("Unknown URI: " + uri); } String orderBy; if (!TextUtils.isEmpty(sortOrder)) { orderBy = sortOrder; } else { orderBy = Telephony.CellBroadcasts.DEFAULT_SORT_ORDER; } SQLiteDatabase db = awaitInitAndGetReadableDatabase(); Cursor c = qb.query(db, projection, selection, selectionArgs, null, null, orderBy); if (c != null) { c.setNotificationUri(getContext().getContentResolver(), CONTENT_URI); } return c; } /** * Return the MIME type of the data at the specified URI. * @param uri the URI to query. * @return a MIME type string, or null if there is no type. */ @Override public String getType(Uri uri) { int match = sUriMatcher.match(uri); switch (match) { case CB_ALL: return CB_LIST_TYPE; case CB_ALL_ID: return CB_TYPE; default: return null; } } /** * Insert a new row. This throws an exception, as the database can only be modified by * calling custom methods in this class, and not via the ContentProvider interface. * @param uri the content:// URI of the insertion request. * @param values a set of column_name/value pairs to add to the database. * @return the URI for the newly inserted item. */ @Override public Uri insert(Uri uri, ContentValues values) { throw new UnsupportedOperationException("insert not supported"); } /** * Delete one or more rows. This throws an exception, as the database can only be modified by * calling custom methods in this class, and not via the ContentProvider interface. * @param uri the full URI to query, including a row ID (if a specific record is requested). * @param selection an optional restriction to apply to rows when deleting. * @return the number of rows affected. */ @Override public int delete(Uri uri, String selection, String[] selectionArgs) { throw new UnsupportedOperationException("delete not supported"); } /** * Update one or more rows. This throws an exception, as the database can only be modified by * calling custom methods in this class, and not via the ContentProvider interface. * @param uri the URI to query, potentially including the row ID. * @param values a Bundle mapping from column names to new column values. * @param selection an optional filter to match rows to update. * @return the number of rows affected. */ @Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { throw new UnsupportedOperationException("update not supported"); } @Override public Bundle call(String method, String name, Bundle args) { Log.d(TAG, "call:" + " method=" + method + " name=" + name + " args=" + args); // this is to handle a content-provider defined method: migration if (CALL_MIGRATION_METHOD.equals(method)) { mOpenHelper.migrateFromLegacyIfNeeded(awaitInitAndGetReadableDatabase()); } return null; } private ContentValues getContentValues(SmsCbMessage message) { ContentValues cv = new ContentValues(); cv.put(Telephony.CellBroadcasts.SLOT_INDEX, message.getSlotIndex()); cv.put(Telephony.CellBroadcasts.GEOGRAPHICAL_SCOPE, message.getGeographicalScope()); SmsCbLocation location = message.getLocation(); cv.put(Telephony.CellBroadcasts.PLMN, location.getPlmn()); if (location.getLac() != -1) { cv.put(Telephony.CellBroadcasts.LAC, location.getLac()); } if (location.getCid() != -1) { cv.put(Telephony.CellBroadcasts.CID, location.getCid()); } cv.put(Telephony.CellBroadcasts.SERIAL_NUMBER, message.getSerialNumber()); cv.put(Telephony.CellBroadcasts.SERVICE_CATEGORY, message.getServiceCategory()); cv.put(Telephony.CellBroadcasts.LANGUAGE_CODE, message.getLanguageCode()); cv.put(Telephony.CellBroadcasts.MESSAGE_BODY, message.getMessageBody()); cv.put(Telephony.CellBroadcasts.DELIVERY_TIME, message.getReceivedTime()); cv.put(Telephony.CellBroadcasts.MESSAGE_FORMAT, message.getMessageFormat()); cv.put(Telephony.CellBroadcasts.MESSAGE_PRIORITY, message.getMessagePriority()); SmsCbEtwsInfo etwsInfo = message.getEtwsWarningInfo(); if (etwsInfo != null) { cv.put(Telephony.CellBroadcasts.ETWS_WARNING_TYPE, etwsInfo.getWarningType()); } SmsCbCmasInfo cmasInfo = message.getCmasWarningInfo(); if (cmasInfo != null) { cv.put(Telephony.CellBroadcasts.CMAS_MESSAGE_CLASS, cmasInfo.getMessageClass()); cv.put(Telephony.CellBroadcasts.CMAS_CATEGORY, cmasInfo.getCategory()); cv.put(Telephony.CellBroadcasts.CMAS_RESPONSE_TYPE, cmasInfo.getResponseType()); cv.put(Telephony.CellBroadcasts.CMAS_SEVERITY, cmasInfo.getSeverity()); cv.put(Telephony.CellBroadcasts.CMAS_URGENCY, cmasInfo.getUrgency()); cv.put(Telephony.CellBroadcasts.CMAS_CERTAINTY, cmasInfo.getCertainty()); } return cv; } /** * Internal method to insert a new Cell Broadcast into the database and notify observers. * @param message the message to insert * @return true if the broadcast is new, false if it's a duplicate broadcast. */ @VisibleForTesting public boolean insertNewBroadcast(SmsCbMessage message) { SQLiteDatabase db = awaitInitAndGetWritableDatabase(); ContentValues cv = getContentValues(message); // Note: this method previously queried the database for duplicate message IDs, but this // is not compatible with CMAS carrier requirements and could also cause other emergency // alerts, e.g. ETWS, to not display if the database is filled with old messages. // Use duplicate message ID detection in CellBroadcastAlertService instead of DB query. long rowId = db.insert(CellBroadcastDatabaseHelper.TABLE_NAME, null, cv); if (rowId == -1) { Log.e(TAG, "failed to insert new broadcast into database"); // Return true on DB write failure because we still want to notify the user. // The SmsCbMessage will be passed with the intent, so the message will be // displayed in the emergency alert dialog, or the dialog that is displayed when // the user selects the notification for a non-emergency broadcast, even if the // broadcast could not be written to the database. } return true; // broadcast is not a duplicate } /** * Internal method to delete a cell broadcast by row ID and notify observers. * @param rowId the row ID of the broadcast to delete * @return true if the database was updated, false otherwise */ @VisibleForTesting public boolean deleteBroadcast(long rowId) { SQLiteDatabase db = awaitInitAndGetWritableDatabase(); int rowCount = db.delete(CellBroadcastDatabaseHelper.TABLE_NAME, Telephony.CellBroadcasts._ID + "=?", new String[]{Long.toString(rowId)}); if (rowCount != 0) { return true; } else { Log.e(TAG, "failed to delete broadcast at row " + rowId); return false; } } /** * Internal method to delete all cell broadcasts and notify observers. * @return true if the database was updated, false otherwise */ @VisibleForTesting public boolean deleteAllBroadcasts() { SQLiteDatabase db = awaitInitAndGetWritableDatabase(); int rowCount = db.delete(CellBroadcastDatabaseHelper.TABLE_NAME, null, null); if (rowCount != 0) { return true; } else { Log.e(TAG, "failed to delete all broadcasts"); return false; } } /** * Internal method to mark a broadcast as read and notify observers. The broadcast can be * identified by delivery time (for new alerts) or by row ID. The caller is responsible for * decrementing the unread non-emergency alert count, if necessary. * * @param columnName the column name to query (ID or delivery time) * @param columnValue the ID or delivery time of the broadcast to mark read * @return true if the database was updated, false otherwise */ boolean markBroadcastRead(String columnName, long columnValue) { SQLiteDatabase db = awaitInitAndGetWritableDatabase(); ContentValues cv = new ContentValues(1); cv.put(Telephony.CellBroadcasts.MESSAGE_READ, 1); String whereClause = columnName + "=?"; String[] whereArgs = new String[]{Long.toString(columnValue)}; int rowCount = db.update(CellBroadcastDatabaseHelper.TABLE_NAME, cv, whereClause, whereArgs); if (rowCount != 0) { return true; } else { Log.e(TAG, "failed to mark broadcast read: " + columnName + " = " + columnValue); return false; } } /** * Internal method to mark a broadcast received in direct boot mode. After user unlocks, mark * all messages not in direct boot mode. * * @param columnName the column name to query (ID or delivery time) * @param columnValue the ID or delivery time of the broadcast to mark read * @param isSmsSyncPending whether the message was pending for SMS inbox synchronization * @return true if the database was updated, false otherwise */ @VisibleForTesting public boolean markBroadcastSmsSyncPending(String columnName, long columnValue, boolean isSmsSyncPending) { SQLiteDatabase db = awaitInitAndGetWritableDatabase(); ContentValues cv = new ContentValues(1); cv.put(CellBroadcastDatabaseHelper.SMS_SYNC_PENDING, isSmsSyncPending ? 1 : 0); String whereClause = columnName + "=?"; String[] whereArgs = new String[]{Long.toString(columnValue)}; int rowCount = db.update(CellBroadcastDatabaseHelper.TABLE_NAME, cv, whereClause, whereArgs); if (rowCount != 0) { return true; } else { Log.e(TAG, "failed to mark broadcast pending for sms inbox sync: " + isSmsSyncPending + " where: " + columnName + " = " + columnValue); return false; } } /** * Write message to sms inbox if pending. e.g, when receive alerts in direct boot mode, we * might need to sync message to sms inbox after user unlock. * @param context */ @VisibleForTesting public void resyncToSmsInbox(@NonNull Context context) { // query all messages currently marked as sms inbox sync pending try (Cursor cursor = query( CellBroadcastContentProvider.CONTENT_URI, CellBroadcastDatabaseHelper.QUERY_COLUMNS, CellBroadcastDatabaseHelper.SMS_SYNC_PENDING + "=1", null, null)) { if (cursor != null) { while (cursor.moveToNext()) { SmsCbMessage message = CellBroadcastCursorAdapter .createFromCursor(context, cursor); if (message != null) { Log.d(TAG, "handling message received pending for sms sync: " + message.toString()); writeMessageToSmsInbox(message, context); // mark message received in direct mode was handled markBroadcastSmsSyncPending( Telephony.CellBroadcasts.DELIVERY_TIME, message.getReceivedTime(), false); } } } } } /** * Write displayed cellbroadcast messages to sms inbox * * @param message The cell broadcast message. */ @VisibleForTesting public void writeMessageToSmsInbox(@NonNull SmsCbMessage message, @NonNull Context context) { UserManager userManager = (UserManager) context.getSystemService(Context.USER_SERVICE); if (!userManager.isSystemUser()) { // SMS database is single-user mode, discard non-system users to avoid inserting twice. Log.d(TAG, "ignoring writeMessageToSmsInbox due to non-system user"); return; } // Note SMS database is not direct boot aware for privacy reasons, we should only interact // with sms db until users has unlocked. if (!userManager.isUserUnlocked()) { Log.d(TAG, "ignoring writeMessageToSmsInbox due to direct boot mode"); // need to retry after user unlock markBroadcastSmsSyncPending(Telephony.CellBroadcasts.DELIVERY_TIME, message.getReceivedTime(), true); return; } // composing SMS ContentValues cv = new ContentValues(); cv.put(Telephony.Sms.Inbox.BODY, message.getMessageBody()); cv.put(Telephony.Sms.Inbox.DATE, message.getReceivedTime()); cv.put(Telephony.Sms.Inbox.SUBSCRIPTION_ID, message.getSubscriptionId()); cv.put(Telephony.Sms.Inbox.SUBJECT, context.getString( CellBroadcastResources.getDialogTitleResource(context, message))); cv.put(Telephony.Sms.Inbox.ADDRESS, CellBroadcastResources.getSmsSenderAddressResourceEnglishString(context, message)); cv.put(Telephony.Sms.Inbox.THREAD_ID, Telephony.Threads.getOrCreateThreadId(context, CellBroadcastResources.getSmsSenderAddressResourceEnglishString(context, message))); if (CellBroadcastSettings.getResources(context, message.getSubscriptionId()) .getBoolean(R.bool.always_mark_sms_read)) { // Always mark SMS message READ. End users expect when they read new CBS messages, // the unread alert count in the notification should be decreased, as they thought it // was coming from SMS. Now we are marking those SMS as read (SMS now serve as a message // history purpose) and that should give clear messages to end-users that alerts are not // from the SMS app but CellBroadcast and they should tap the notification to read alert // in order to see decreased unread message count. cv.put(Telephony.Sms.Inbox.READ, 1); } Uri uri = context.getContentResolver().insert(Telephony.Sms.Inbox.CONTENT_URI, cv); if (uri == null) { Log.e(TAG, "writeMessageToSmsInbox: failed"); } else { Log.d(TAG, "writeMessageToSmsInbox: succeed uri = " + uri); } } /** Callback for users of AsyncCellBroadcastOperation. */ interface CellBroadcastOperation { /** * Perform an operation using the specified provider. * @param provider the CellBroadcastContentProvider to use * @return true if any rows were changed, false otherwise */ boolean execute(CellBroadcastContentProvider provider); } /** * Async task to call this content provider's internal methods on a background thread. * The caller supplies the CellBroadcastOperation object to call for this provider. */ static class AsyncCellBroadcastTask extends AsyncTask { /** Reference to this app's content resolver. */ private ContentResolver mContentResolver; AsyncCellBroadcastTask(ContentResolver contentResolver) { mContentResolver = contentResolver; } /** * Perform a generic operation on the CellBroadcastContentProvider. * @param params the CellBroadcastOperation object to call for this provider * @return void */ @Override protected Void doInBackground(CellBroadcastOperation... params) { ContentProviderClient cpc = mContentResolver.acquireContentProviderClient( CellBroadcastContentProvider.CB_AUTHORITY); CellBroadcastContentProvider provider = (CellBroadcastContentProvider) cpc.getLocalContentProvider(); if (provider != null) { try { boolean changed = params[0].execute(provider); if (changed) { Log.d(TAG, "database changed: notifying observers..."); mContentResolver.notifyChange(CONTENT_URI, null, false); } } finally { cpc.release(); } } else { Log.e(TAG, "getLocalContentProvider() returned null"); } mContentResolver = null; // free reference to content resolver return null; } } }