/* * Copyright (C) 2019 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.cellbroadcastservice; import static com.android.cellbroadcastservice.CellBroadcastMetrics.ERR_FAILED_TO_INSERT_TO_DB; import android.content.ContentProvider; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.UriMatcher; import android.content.pm.PackageManager; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.database.sqlite.SQLiteQueryBuilder; import android.net.Uri; import android.provider.Telephony; import android.provider.Telephony.CellBroadcasts; import android.text.TextUtils; import android.util.Log; import com.android.internal.annotations.VisibleForTesting; import java.util.Arrays; /** * The content provider that provides access of cell broadcast message to application. * Permission {@link com.android.cellbroadcastservice.FULL_ACCESS_CELL_BROADCAST_HISTORY} is * required for querying the cell broadcast message. Only the Cell Broadcast module should have this * permission. */ public class CellBroadcastProvider extends ContentProvider { private static final String TAG = CellBroadcastProvider.class.getSimpleName(); private static final boolean DBG = Log.isLoggable(TAG, Log.DEBUG); /** Database name. */ private static final String DATABASE_NAME = "cellbroadcasts.db"; /** Database version. */ @VisibleForTesting public static final int DATABASE_VERSION = 4; /** URI matcher for ContentProvider queries. */ private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); /** URI matcher type to get all cell broadcasts. */ private static final int ALL = 0; /** * URI matcher type for get all message history, this is used primarily for default * cellbroadcast app or messaging app to display message history. some information is not * exposed for messaging history, e.g, messages which are out of broadcast geometrics will not * be delivered to end users thus will not be returned as message history query result. */ private static final int MESSAGE_HISTORY = 1; /** * URI matcher type for update message which are being displayed to end-users. */ private static final int MESSAGE_DISPLAYED = 2; /** MIME type for the list of all cell broadcasts. */ private static final String LIST_TYPE = "vnd.android.cursor.dir/cellbroadcast"; /** Table name of cell broadcast message. */ @VisibleForTesting public static final String CELL_BROADCASTS_TABLE_NAME = "cell_broadcasts"; /** Authority string for content URIs. */ @VisibleForTesting public static final String AUTHORITY = "cellbroadcasts"; /** Content uri of this provider. */ public static final Uri CONTENT_URI = Uri.parse("content://cellbroadcasts"); /** * Local definition of the query columns for instantiating * {@link android.telephony.SmsCbMessage} objects. */ public static final String[] QUERY_COLUMNS = { CellBroadcasts._ID, CellBroadcasts.SLOT_INDEX, CellBroadcasts.SUBSCRIPTION_ID, CellBroadcasts.GEOGRAPHICAL_SCOPE, CellBroadcasts.PLMN, CellBroadcasts.LAC, CellBroadcasts.CID, CellBroadcasts.SERIAL_NUMBER, CellBroadcasts.SERVICE_CATEGORY, CellBroadcasts.LANGUAGE_CODE, CellBroadcasts.DATA_CODING_SCHEME, CellBroadcasts.MESSAGE_BODY, CellBroadcasts.MESSAGE_FORMAT, CellBroadcasts.MESSAGE_PRIORITY, CellBroadcasts.ETWS_WARNING_TYPE, CellBroadcasts.ETWS_IS_PRIMARY, CellBroadcasts.CMAS_MESSAGE_CLASS, CellBroadcasts.CMAS_CATEGORY, CellBroadcasts.CMAS_RESPONSE_TYPE, CellBroadcasts.CMAS_SEVERITY, CellBroadcasts.CMAS_URGENCY, CellBroadcasts.CMAS_CERTAINTY, CellBroadcasts.RECEIVED_TIME, CellBroadcasts.LOCATION_CHECK_TIME, CellBroadcasts.MESSAGE_BROADCASTED, CellBroadcasts.MESSAGE_DISPLAYED, CellBroadcasts.GEOMETRIES, CellBroadcasts.MAXIMUM_WAIT_TIME }; @VisibleForTesting public CellBroadcastPermissionChecker mPermissionChecker; /** The database helper for this content provider. */ @VisibleForTesting public SQLiteOpenHelper mDbHelper; static { sUriMatcher.addURI(AUTHORITY, null, ALL); sUriMatcher.addURI(AUTHORITY, "history", MESSAGE_HISTORY); sUriMatcher.addURI(AUTHORITY, "displayed", MESSAGE_DISPLAYED); } public CellBroadcastProvider() {} @VisibleForTesting public CellBroadcastProvider(CellBroadcastPermissionChecker permissionChecker) { mPermissionChecker = permissionChecker; } @Override public boolean onCreate() { mDbHelper = new CellBroadcastDatabaseHelper(getContext()); mPermissionChecker = new CellBroadcastPermissionChecker(); return true; } /** * 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 ALL: return LIST_TYPE; default: return null; } } @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { checkReadPermission(uri); if (DBG) { Log.d(TAG, "query:" + " uri = " + uri + " projection = " + Arrays.toString(projection) + " selection = " + selection + " selectionArgs = " + Arrays.toString(selectionArgs) + " sortOrder = " + sortOrder); } SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); qb.setStrict(true); // a little protection from injection attacks qb.setTables(CELL_BROADCASTS_TABLE_NAME); String orderBy; if (!TextUtils.isEmpty(sortOrder)) { orderBy = sortOrder; } else { orderBy = CellBroadcasts.RECEIVED_TIME + " DESC"; } int match = sUriMatcher.match(uri); switch (match) { case ALL: return getReadableDatabase().query( CELL_BROADCASTS_TABLE_NAME, projection, selection, selectionArgs, null /* groupBy */, null /* having */, orderBy); case MESSAGE_HISTORY: // limit projections to certain columns. limit result to broadcasted messages only. qb.appendWhere(CellBroadcasts.MESSAGE_BROADCASTED + "=1"); return qb.query(getReadableDatabase(), projection, selection, selectionArgs, null, null, orderBy); default: throw new IllegalArgumentException( "Query method doesn't support this uri = " + uri); } } @Override public Uri insert(Uri uri, ContentValues values) { checkWritePermission(); if (DBG) { Log.d(TAG, "insert:" + " uri = " + uri + " contentValue = " + values); } switch (sUriMatcher.match(uri)) { case ALL: long row = getWritableDatabase().insertOrThrow(CELL_BROADCASTS_TABLE_NAME, null, values); if (row > 0) { Uri newUri = ContentUris.withAppendedId(CONTENT_URI, row); getContext().getContentResolver() .notifyChange(CONTENT_URI, null /* observer */); return newUri; } else { String errorString = "uri=" + uri.toString() + " values=" + values; // 1000 character limit for error logs if (errorString.length() > 1000) { errorString = errorString.substring(0, 1000); } CellBroadcastServiceMetrics.getInstance().logMessageError( ERR_FAILED_TO_INSERT_TO_DB, errorString); Log.e(TAG, "Insert record failed because of unknown reason. " + errorString); return null; } default: String errorString = "Insert method doesn't support this uri=" + uri.toString() + " values=" + values; // 1000 character limit for error logs if (errorString.length() > 1000) { errorString = errorString.substring(0, 1000); } CellBroadcastServiceMetrics.getInstance().logMessageError( ERR_FAILED_TO_INSERT_TO_DB, errorString); throw new IllegalArgumentException(errorString); } } @Override public int delete(Uri uri, String selection, String[] selectionArgs) { checkWritePermission(); if (DBG) { Log.d(TAG, "delete:" + " uri = " + uri + " selection = " + selection + " selectionArgs = " + Arrays.toString(selectionArgs)); } switch (sUriMatcher.match(uri)) { case ALL: return getWritableDatabase().delete(CELL_BROADCASTS_TABLE_NAME, selection, selectionArgs); default: throw new IllegalArgumentException( "Delete method doesn't support this uri = " + uri); } } @Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { checkWritePermission(); if (DBG) { Log.d(TAG, "update:" + " uri = " + uri + " values = {" + values + "}" + " selection = " + selection + " selectionArgs = " + Arrays.toString(selectionArgs)); } int rowCount = 0; switch (sUriMatcher.match(uri)) { case ALL: rowCount = getWritableDatabase().update( CELL_BROADCASTS_TABLE_NAME, values, selection, selectionArgs); if (rowCount > 0) { getContext().getContentResolver().notifyChange(uri, null /* observer */, ContentResolver.NOTIFY_SKIP_NOTIFY_FOR_DESCENDANTS | ContentResolver.NOTIFY_SYNC_TO_NETWORK ); } return rowCount; case MESSAGE_DISPLAYED: // mark message was displayed to the end-users. values.put(Telephony.CellBroadcasts.MESSAGE_DISPLAYED, 1); rowCount = getWritableDatabase().update( CELL_BROADCASTS_TABLE_NAME, values, selection, selectionArgs); if (rowCount > 0) { // update was succeed. the row number of the updated message. try (Cursor ret = query(CellBroadcasts.CONTENT_URI, new String[]{CellBroadcasts._ID}, selection, selectionArgs, null)) { if (ret != null && ret.moveToFirst()) { int rowNumber = ret.getInt(ret.getColumnIndex(CellBroadcasts._ID)); Log.d(TAG, "notify contentObservers for the displayed message, row: " + rowNumber); getContext().getContentResolver().notifyChange( Uri.withAppendedPath(CONTENT_URI, "displayed/" + rowNumber), null, true); } } catch (Exception ex) { Log.e(TAG, "exception during update message displayed: " + ex.toString()); } } return rowCount; default: throw new IllegalArgumentException( "Update method doesn't support this uri = " + uri); } } /** * Returns a string used to create the cell broadcast table. This is exposed so the unit test * can construct its own in-memory database to match the cell broadcast db. */ @VisibleForTesting public static String getStringForCellBroadcastTableCreation(String tableName) { return "CREATE TABLE " + tableName + " (" + CellBroadcasts._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + CellBroadcasts.SUBSCRIPTION_ID + " INTEGER," + CellBroadcasts.SLOT_INDEX + " INTEGER DEFAULT 0," + CellBroadcasts.GEOGRAPHICAL_SCOPE + " INTEGER," + CellBroadcasts.PLMN + " TEXT," + CellBroadcasts.LAC + " INTEGER," + CellBroadcasts.CID + " INTEGER," + CellBroadcasts.SERIAL_NUMBER + " INTEGER," + CellBroadcasts.SERVICE_CATEGORY + " INTEGER," + CellBroadcasts.LANGUAGE_CODE + " TEXT," + CellBroadcasts.DATA_CODING_SCHEME + " INTEGER DEFAULT 0," + CellBroadcasts.MESSAGE_BODY + " TEXT," + CellBroadcasts.MESSAGE_FORMAT + " INTEGER," + CellBroadcasts.MESSAGE_PRIORITY + " INTEGER," + CellBroadcasts.ETWS_WARNING_TYPE + " INTEGER," + CellBroadcasts.ETWS_IS_PRIMARY + " BOOLEAN DEFAULT 0," + CellBroadcasts.CMAS_MESSAGE_CLASS + " INTEGER," + CellBroadcasts.CMAS_CATEGORY + " INTEGER," + CellBroadcasts.CMAS_RESPONSE_TYPE + " INTEGER," + CellBroadcasts.CMAS_SEVERITY + " INTEGER," + CellBroadcasts.CMAS_URGENCY + " INTEGER," + CellBroadcasts.CMAS_CERTAINTY + " INTEGER," + CellBroadcasts.RECEIVED_TIME + " BIGINT," + CellBroadcasts.LOCATION_CHECK_TIME + " BIGINT DEFAULT -1," + CellBroadcasts.MESSAGE_BROADCASTED + " BOOLEAN DEFAULT 0," + CellBroadcasts.MESSAGE_DISPLAYED + " BOOLEAN DEFAULT 0," + CellBroadcasts.GEOMETRIES + " TEXT," + CellBroadcasts.MAXIMUM_WAIT_TIME + " INTEGER);"; } private SQLiteDatabase getWritableDatabase() { return mDbHelper.getWritableDatabase(); } private SQLiteDatabase getReadableDatabase() { return mDbHelper.getReadableDatabase(); } private void checkWritePermission() { if (!mPermissionChecker.hasFullAccessPermission()) { throw new SecurityException( "No permission to write CellBroadcast provider"); } } private void checkReadPermission(Uri uri) { int match = sUriMatcher.match(uri); switch (match) { case ALL: if (!mPermissionChecker.hasFullAccessPermission()) { throw new SecurityException( "No permission to read CellBroadcast provider"); } break; case MESSAGE_HISTORY: // The normal read permission android.permission.READ_CELL_BROADCASTS // is defined in AndroidManifest.xml and is enfored by the platform. // So no additional check is required here. break; default: return; } } @VisibleForTesting public static class CellBroadcastDatabaseHelper extends SQLiteOpenHelper { public CellBroadcastDatabaseHelper(Context context) { super(context, DATABASE_NAME, null /* factory */, DATABASE_VERSION); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL(getStringForCellBroadcastTableCreation(CELL_BROADCASTS_TABLE_NAME)); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (DBG) { Log.d(TAG, "onUpgrade: oldV=" + oldVersion + " newV=" + newVersion); } if (oldVersion < 2) { db.execSQL("ALTER TABLE " + CELL_BROADCASTS_TABLE_NAME + " ADD COLUMN " + CellBroadcasts.SLOT_INDEX + " INTEGER DEFAULT 0;"); Log.d(TAG, "add slotIndex column"); } if (oldVersion < 3) { db.execSQL("ALTER TABLE " + CELL_BROADCASTS_TABLE_NAME + " ADD COLUMN " + CellBroadcasts.DATA_CODING_SCHEME + " INTEGER DEFAULT 0;"); db.execSQL("ALTER TABLE " + CELL_BROADCASTS_TABLE_NAME + " ADD COLUMN " + CellBroadcasts.LOCATION_CHECK_TIME + " BIGINT DEFAULT -1;"); // Specifically for upgrade, the message displayed should be true. For newly arrived // message, default should be false. db.execSQL("ALTER TABLE " + CELL_BROADCASTS_TABLE_NAME + " ADD COLUMN " + CellBroadcasts.MESSAGE_DISPLAYED + " BOOLEAN DEFAULT 1;"); Log.d(TAG, "add dcs, location check time, and message displayed column."); } if (oldVersion < 4) { db.execSQL("ALTER TABLE " + CELL_BROADCASTS_TABLE_NAME + " ADD COLUMN " + CellBroadcasts.ETWS_IS_PRIMARY + " BOOLEAN DEFAULT 0;"); Log.d(TAG, "add ETWS is_primary column."); } } } /** * Cell broadcast permission checker. */ public class CellBroadcastPermissionChecker { /** * @return {@code true} if the caller has permission to fully access the cell broadcast * provider. */ public boolean hasFullAccessPermission() { int status = getContext().checkCallingOrSelfPermission( "com.android.cellbroadcastservice.FULL_ACCESS_CELL_BROADCAST_HISTORY"); return status == PackageManager.PERMISSION_GRANTED; } } }