/* * Copyright (C) 2015 Samsung System LSI * 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.bluetooth.mapapi; import android.content.ContentProvider; import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; import android.content.UriMatcher; import android.content.pm.ProviderInfo; import android.database.Cursor; import android.net.Uri; import android.os.AsyncTask; import android.os.Binder; import android.os.Bundle; import android.os.ParcelFileDescriptor; import android.util.Log; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; /** * A base implementation of the BluetoothMapContract. * A base class for a ContentProvider that allows access to Instant messages from a Bluetooth * device through the Message Access Profile. */ public abstract class BluetoothMapIMProvider extends ContentProvider { private static final String TAG = "BluetoothMapIMProvider"; private static final boolean D = true; private static final int MATCH_ACCOUNT = 1; private static final int MATCH_MESSAGE = 3; private static final int MATCH_CONVERSATION = 4; private static final int MATCH_CONVOCONTACT = 5; protected ContentResolver mResolver; private Uri CONTENT_URI = null; private String mAuthority; private UriMatcher mMatcher; /** * @return the CONTENT_URI exposed. This will be used to send out notifications. */ abstract protected Uri getContentUri(); /** * Implementation is provided by the parent class. */ @Override public void attachInfo(Context context, ProviderInfo info) { mAuthority = info.authority; mMatcher = new UriMatcher(UriMatcher.NO_MATCH); mMatcher.addURI(mAuthority, BluetoothMapContract.TABLE_ACCOUNT, MATCH_ACCOUNT); mMatcher.addURI(mAuthority, "#/"+ BluetoothMapContract.TABLE_MESSAGE, MATCH_MESSAGE); mMatcher.addURI(mAuthority, "#/"+ BluetoothMapContract.TABLE_CONVERSATION, MATCH_CONVERSATION); mMatcher.addURI(mAuthority, "#/"+ BluetoothMapContract.TABLE_CONVOCONTACT, MATCH_CONVOCONTACT); // Sanity check our setup if (!info.exported) { throw new SecurityException("Provider must be exported"); } // Enforce correct permissions are used if (!android.Manifest.permission.BLUETOOTH_MAP.equals(info.writePermission)){ throw new SecurityException("Provider must be protected by " + android.Manifest.permission.BLUETOOTH_MAP); } if(D) Log.d(TAG,"attachInfo() mAuthority = " + mAuthority); mResolver = context.getContentResolver(); super.attachInfo(context, info); } /** * This function shall be called when any Account database content have changed * to Notify any attached observers. * @param accountId the ID of the account that changed. Null is a valid value, * if accountId is unknown or multiple accounts changed. */ protected void onAccountChanged(String accountId) { Uri newUri = null; if(mAuthority == null){ return; } if(accountId == null){ newUri = BluetoothMapContract.buildAccountUri(mAuthority); } else { newUri = BluetoothMapContract.buildAccountUriwithId(mAuthority, accountId); } if(D) Log.d(TAG,"onAccountChanged() accountId = " + accountId + " URI: " + newUri); mResolver.notifyChange(newUri, null); } /** * This function shall be called when any Message database content have changed * to notify any attached observers. * @param accountId Null is a valid value, if accountId is unknown, but * recommended for increased performance. * @param messageId Null is a valid value, if multiple messages changed or the * messageId is unknown, but recommended for increased performance. */ protected void onMessageChanged(String accountId, String messageId) { Uri newUri = null; if(mAuthority == null){ return; } if(accountId == null){ newUri = BluetoothMapContract.buildMessageUri(mAuthority); } else { if(messageId == null) { newUri = BluetoothMapContract.buildMessageUri(mAuthority,accountId); } else { newUri = BluetoothMapContract.buildMessageUriWithId(mAuthority,accountId, messageId); } } if(D) Log.d(TAG,"onMessageChanged() accountId = " + accountId + " messageId = " + messageId + " URI: " + newUri); mResolver.notifyChange(newUri, null); } /** * This function shall be called when any Message database content have changed * to notify any attached observers. * @param accountId Null is a valid value, if accountId is unknown, but * recommended for increased performance. * @param contactId Null is a valid value, if multiple contacts changed or the * contactId is unknown, but recommended for increased performance. */ protected void onContactChanged(String accountId, String contactId) { Uri newUri = null; if(mAuthority == null){ return; } if(accountId == null){ newUri = BluetoothMapContract.buildConvoContactsUri(mAuthority); } else { if(contactId == null) { newUri = BluetoothMapContract.buildConvoContactsUri(mAuthority,accountId); } else { newUri = BluetoothMapContract.buildConvoContactsUriWithId(mAuthority, accountId, contactId); } } if(D) Log.d(TAG,"onContactChanged() accountId = " + accountId + " contactId = " + contactId + " URI: " + newUri); mResolver.notifyChange(newUri, null); } /** * Not used, this is just a dummy implementation. * TODO: We might need to something intelligent here after introducing IM */ @Override public String getType(Uri uri) { return "InstantMessage"; } /** * The MAP specification states that a delete request from MAP client is a folder shift to the * 'deleted' folder. * Only use case of delete() is when transparency is requested for push messages, then * message should not remain in sent folder and therefore must be deleted */ @Override public int delete(Uri uri, String where, String[] selectionArgs) { if (D) Log.d(TAG, "delete(): uri=" + uri.toString() ); int result = 0; String table = uri.getPathSegments().get(1); if(table == null) throw new IllegalArgumentException("Table missing in URI"); // the id of the entry to be deleted from the database String messageId = uri.getLastPathSegment(); if (messageId == null) throw new IllegalArgumentException("Message ID missing in update values!"); String accountId = getAccountId(uri); if (accountId == null) throw new IllegalArgumentException("Account ID missing in update values!"); long callingId = Binder.clearCallingIdentity(); try { if(table.equals(BluetoothMapContract.TABLE_MESSAGE)) { return deleteMessage(accountId, messageId); } else { if (D) Log.w(TAG, "Unknown table name: " + table); return result; } } finally { Binder.restoreCallingIdentity(callingId); } } /** * This function deletes a message. * @param accountId the ID of the Account * @param messageId the ID of the message to delete. * @return the number of messages deleted - 0 if the message was not found. */ abstract protected int deleteMessage(String accountId, String messageId); /** * Insert is used to add new messages to the data base. * Insert message approach: * - Insert an empty message to get an _id with only a folder_id * - Open the _id for write * - Write the message content * (When the writer completes, this provider should do an update of the message) */ @Override public Uri insert(Uri uri, ContentValues values) { String table = uri.getLastPathSegment(); if(table == null) throw new IllegalArgumentException("Table missing in URI"); String accountId = getAccountId(uri); if (accountId == null) throw new IllegalArgumentException("Account ID missing in URI"); // TODO: validate values? String id; // the id of the entry inserted into the database long callingId = Binder.clearCallingIdentity(); Log.d(TAG, "insert(): uri=" + uri.toString() + " - getLastPathSegment() = " + uri.getLastPathSegment()); try { if(table.equals(BluetoothMapContract.TABLE_MESSAGE)) { id = insertMessage(accountId, values); if(D) Log.i(TAG, "insert() ID: " + id); return Uri.parse(uri.toString() + "/" + id); } else { Log.w(TAG, "Unknown table name: " + table); return null; } } finally { Binder.restoreCallingIdentity(callingId); } } /** * Inserts an empty message into the Message data base in the specified folder. * This is done before the actual message content is written by fileIO. * @param accountId the ID of the account * @param folderId the ID of the folder to create a new message in. * @return the message id as a string */ abstract protected String insertMessage(String accountId, ContentValues values); /** * Utility function to build a projection based on a projectionMap. * * "btColumnName" -> "imColumnName as btColumnName" for each entry. * * This supports SQL statements in the column name entry. * @param projection * @param projectionMap * @return the converted projection */ protected String[] convertProjection(String[] projection, Map projectionMap) { String[] newProjection = new String[projection.length]; for(int i = 0; i < projection.length; i++) { newProjection[i] = projectionMap.get(projection[i]) + " as " + projection[i]; } return newProjection; } /** * This query needs to map from the data used in the e-mail client to * BluetoothMapContract type of data. */ @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { long callingId = Binder.clearCallingIdentity(); try { String accountId = null; if(D)Log.w(TAG, "query(): uri =" + mAuthority + " uri=" + uri.toString()); switch (mMatcher.match(uri)) { case MATCH_ACCOUNT: return queryAccount(projection, selection, selectionArgs, sortOrder); case MATCH_MESSAGE: // TODO: Extract account from URI accountId = getAccountId(uri); return queryMessage(accountId, projection, selection, selectionArgs, sortOrder); case MATCH_CONVERSATION: accountId = getAccountId(uri); String value; String searchString = uri.getQueryParameter(BluetoothMapContract.FILTER_ORIGINATOR_SUBSTRING); Long periodBegin = null; value = uri.getQueryParameter(BluetoothMapContract.FILTER_PERIOD_BEGIN); if(value != null) { periodBegin = Long.parseLong(value); } Long periodEnd = null; value = uri.getQueryParameter(BluetoothMapContract.FILTER_PERIOD_END); if(value != null) { periodEnd = Long.parseLong(value); } Boolean read = null; value = uri.getQueryParameter(BluetoothMapContract.FILTER_READ_STATUS); if(value != null) { read = value.equalsIgnoreCase("true"); } Long threadId = null; value = uri.getQueryParameter(BluetoothMapContract.FILTER_THREAD_ID); if(value != null) { threadId = Long.parseLong(value); } return queryConversation(accountId, threadId, read, periodEnd, periodBegin, searchString, projection, sortOrder); case MATCH_CONVOCONTACT: accountId = getAccountId(uri); long contactId = 0; return queryConvoContact(accountId, contactId, projection, selection, selectionArgs, sortOrder); default: throw new UnsupportedOperationException("Unsupported Uri " + uri); } } finally { Binder.restoreCallingIdentity(callingId); } } /** * Query account information. * This function shall return only exposable e-mail accounts. Hence shall not * return accounts that has policies suggesting not to be shared them. * @param projection * @param selection * @param selectionArgs * @param sortOrder * @return a cursor to the accounts that are subject to exposure over BT. */ abstract protected Cursor queryAccount(String[] projection, String selection, String[] selectionArgs, String sortOrder); /** * For the message table the selection (where clause) can only include the following columns: * date: less than, greater than and equals * flagRead: = 1 or = 0 * flagPriority: = 1 or = 0 * folder_id: the ID of the folder only equals * toList: partial name/address search * fromList: partial name/address search * Additionally the COUNT and OFFSET shall be supported. * @param accountId the ID of the account * @param projection * @param selection * @param selectionArgs * @param sortOrder * @return a cursor to query result */ abstract protected Cursor queryMessage(String accountId, String[] projection, String selection, String[] selectionArgs, String sortOrder); /** * For the Conversation table the selection (where clause) can only include * the following columns: * _id: the ID of the conversation only equals * name: partial name search * last_activity: less than, greater than and equals * version_counter: updated IDs are regenerated * Additionally the COUNT and OFFSET shall be supported. * @param accountId the ID of the account * @param threadId the ID of the conversation * @param projection * @param selection * @param selectionArgs * @param sortOrder * @return a cursor to query result */ // abstract protected Cursor queryConversation(Long threadId, String[] projection, // String selection, String[] selectionArgs, String sortOrder); /** * Query for conversations with contact information. The expected result is a cursor pointing * to one row for each contact in a conversation. * E.g.: * ThreadId | ThreadName | ... | ContactName | ContactPrecence | ... | * 1 | "Bowling" | ... | Hans | 1 | ... | * 1 | "Bowling" | ... | Peter | 2 | ... | * 2 | "" | ... | Peter | 2 | ... | * 3 | "" | ... | Hans | 1 | ... | * * @param accountId the ID of the account * @param threadId filter on a single threadId - null if no filtering is needed. * @param read filter on a read status: * null: no filtering on read is needed. * true: return only threads that has NO unread messages. * false: return only threads that has unread messages. * @param periodEnd last_activity time stamp of the the newest thread to include in the * result. * @param periodBegin last_activity time stamp of the the oldest thread to include in the * result. * @param searchString if not null, include only threads that has contacts that matches the * searchString as part of the contact name or nickName. * @param projection A list of the columns that is needed in the result * @param sortOrder the sort order * @return a Cursor representing the query result. */ abstract protected Cursor queryConversation(String accountId, Long threadId, Boolean read, Long periodEnd, Long periodBegin, String searchString, String[] projection, String sortOrder); /** * For the ConvoContact table the selection (where clause) can only include the * following columns: * _id: the ID of the contact only equals * convo_id: id of conversation contact is part of * name: partial name search * x_bt_uid: the ID of the bt uid only equals * chat_state: active, inactive, gone, composing, paused * last_active: less than, greater than and equals * presence_state: online, do_not_disturb, away, offline * priority: level of priority 0 - 100 * last_online: less than, greater than and equals * @param accountId the ID of the account * @param contactId the ID of the contact * @param projection * @param selection * @param selectionArgs * @param sortOrder * @return a cursor to query result */ abstract protected Cursor queryConvoContact(String accountId, Long contactId, String[] projection, String selection, String[] selectionArgs, String sortOrder); /** * update() * Messages can be modified in the following cases: * - the folder_key of a message - hence the message can be moved to a new folder, * but the content cannot be modified. * - the FLAG_READ state can be changed. * Conversations can be modified in the following cases: * - the read status - changing between read, unread * - the last activity - the time stamp of last message sent of received in the conversation * ConvoContacts can be modified in the following cases: * - the chat_state - chat status of the contact in conversation * - the last_active - the time stamp of last action in the conversation * - the presence_state - the time stamp of last time contact online * - the status - the status text of the contact available in a conversation * - the last_online - the time stamp of last time contact online * The selection statement will always be selection of a message ID, when updating a message, * hence this function will be called multiple times if multiple messages must be updated * due to the nature of the Bluetooth Message Access profile. */ @Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { String table = uri.getLastPathSegment(); if(table == null){ throw new IllegalArgumentException("Table missing in URI"); } if(selection != null) { throw new IllegalArgumentException("selection shall not be used, ContentValues " + "shall contain the data"); } long callingId = Binder.clearCallingIdentity(); if(D)Log.w(TAG, "update(): uri=" + uri.toString() + " - getLastPathSegment() = " + uri.getLastPathSegment()); try { if(table.equals(BluetoothMapContract.TABLE_ACCOUNT)) { String accountId = values.getAsString(BluetoothMapContract.AccountColumns._ID); if(accountId == null) { throw new IllegalArgumentException("Account ID missing in update values!"); } Integer exposeFlag = values.getAsInteger( BluetoothMapContract.AccountColumns.FLAG_EXPOSE); if(exposeFlag == null){ throw new IllegalArgumentException("Expose flag missing in update values!"); } return updateAccount(accountId, exposeFlag); } else if(table.equals(BluetoothMapContract.TABLE_FOLDER)) { return 0; // We do not support changing folders } else if(table.equals(BluetoothMapContract.TABLE_MESSAGE)) { String accountId = getAccountId(uri); if(accountId == null) { throw new IllegalArgumentException("Account ID missing in update values!"); } Long messageId = values.getAsLong(BluetoothMapContract.MessageColumns._ID); if(messageId == null) { throw new IllegalArgumentException("Message ID missing in update values!"); } Long folderId = values.getAsLong(BluetoothMapContract.MessageColumns.FOLDER_ID); Boolean flagRead = values.getAsBoolean( BluetoothMapContract.MessageColumns.FLAG_READ); return updateMessage(accountId, messageId, folderId, flagRead); } else if(table.equals(BluetoothMapContract.TABLE_CONVERSATION)) { return 0; // We do not support changing conversation } else if(table.equals(BluetoothMapContract.TABLE_CONVOCONTACT)) { return 0; // We do not support changing contacts } else { if(D)Log.w(TAG, "Unknown table name: " + table); return 0; } } finally { Binder.restoreCallingIdentity(callingId); } } /** * Update an entry in the account table. Only the expose flag will be * changed through this interface. * @param accountId the ID of the account to change. * @param flagExpose the updated value. * @return the number of entries changed - 0 if account not found or value cannot be changed. */ abstract protected int updateAccount(String accountId, Integer flagExpose); /** * Update an entry in the message table. * @param accountId ID of the account to which the messageId relates * @param messageId the ID of the message to update * @param folderId the new folder ID value to set - ignore if null. * @param flagRead the new flagRead value to set - ignore if null. * @return */ abstract protected int updateMessage(String accountId, Long messageId, Long folderId, Boolean flagRead); /** * Utility function to Creates a ContentValues object based on a modified valuesSet. * To be used after changing the keys and optionally values of a valueSet obtained * from a ContentValues object received in update(). * @param valueSet the values as received in the contentProvider * @param keyMap the key map * @return a new ContentValues object with the keys replaced as specified in the * keyMap */ protected ContentValues createContentValues(Set> valueSet, Map keyMap) { ContentValues values = new ContentValues(valueSet.size()); for(Entry ent : valueSet) { String key = keyMap.get(ent.getKey()); // Convert the key name Object value = ent.getValue(); if(value == null) { values.putNull(key); } else if(ent.getValue() instanceof Boolean) { values.put(key, (Boolean) value); } else if(ent.getValue() instanceof Byte) { values.put(key, (Byte) value); } else if(ent.getValue() instanceof byte[]) { values.put(key, (byte[]) value); } else if(ent.getValue() instanceof Double) { values.put(key, (Double) value); } else if(ent.getValue() instanceof Float) { values.put(key, (Float) value); } else if(ent.getValue() instanceof Integer) { values.put(key, (Integer) value); } else if(ent.getValue() instanceof Long) { values.put(key, (Long) value); } else if(ent.getValue() instanceof Short) { values.put(key, (Short) value); } else if(ent.getValue() instanceof String) { values.put(key, (String) value); } else { throw new IllegalArgumentException("Unknown data type in content value"); } } return values; } @Override public Bundle call(String method, String arg, Bundle extras) { long callingId = Binder.clearCallingIdentity(); if(D)Log.w(TAG, "call(): method=" + method + " arg=" + arg + "ThreadId: " + Thread.currentThread().getId()); int ret = -1; try { if(method.equals(BluetoothMapContract.METHOD_UPDATE_FOLDER)) { long accountId = extras.getLong(BluetoothMapContract.EXTRA_UPDATE_ACCOUNT_ID, -1); if(accountId == -1) { Log.w(TAG, "No account ID in CALL"); return null; } long folderId = extras.getLong(BluetoothMapContract.EXTRA_UPDATE_FOLDER_ID, -1); if(folderId == -1) { Log.w(TAG, "No folder ID in CALL"); return null; } ret = syncFolder(accountId, folderId); } else if (method.equals(BluetoothMapContract.METHOD_SET_OWNER_STATUS)) { int presenceState = extras.getInt(BluetoothMapContract.EXTRA_PRESENCE_STATE); String presenceStatus = extras.getString( BluetoothMapContract.EXTRA_PRESENCE_STATUS); long lastActive = extras.getLong(BluetoothMapContract.EXTRA_LAST_ACTIVE); int chatState = extras.getInt(BluetoothMapContract.EXTRA_CHAT_STATE); String convoId = extras.getString(BluetoothMapContract.EXTRA_CONVERSATION_ID); ret = setOwnerStatus(presenceState, presenceStatus, lastActive, chatState, convoId); } else if (method.equals(BluetoothMapContract.METHOD_SET_BLUETOOTH_STATE)) { boolean bluetoothState = extras.getBoolean( BluetoothMapContract.EXTRA_BLUETOOTH_STATE); ret = setBluetoothStatus(bluetoothState); } } finally { Binder.restoreCallingIdentity(callingId); } if(ret == 0) { return new Bundle(); } return null; } /** * Trigger a sync of the specified folder. * @param accountId the ID of the account that owns the folder * @param folderId the ID of the folder. * @return 0 at success */ abstract protected int syncFolder(long accountId, long folderId); /** * Set the properties that should change presence or chat state of owner * e.g. when the owner is active on a BT client device but not on the BT server device * where the IM application is installed, it should still be possible to show an active status. * @param presenceState should follow the contract specified values * @param presenceStatus string the owners current status * @param lastActive time stamp of the owners last activity * @param chatState should follow the contract specified values * @param convoId ID to the conversation to change * @return 0 at success */ abstract protected int setOwnerStatus(int presenceState, String presenceStatus, long lastActive, int chatState, String convoId); /** * Notify the application of the Bluetooth state * @param bluetoothState 'on' of 'off' * @return 0 at success */ abstract protected int setBluetoothStatus(boolean bluetoothState); /** * Need this to suppress warning in unit tests. */ @Override public void shutdown() { // Don't call super.shutdown(), which emits a warning... } /** * Extract the BluetoothMapContract.AccountColumns._ID from the given URI. */ public static String getAccountId(Uri uri) { final List segments = uri.getPathSegments(); if (segments.size() < 1) { throw new IllegalArgumentException("No AccountId pressent in URI: " + uri); } return segments.get(0); } }