blob: dbc56ba8618dba51810145ca50d8fba4e84beb74 [file] [log] [blame]
/*
* 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.Binder;
import android.os.Bundle;
import android.util.Log;
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.
*/
protected abstract 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.
*/
protected abstract 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
*/
protected abstract 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 <string, string>
* @return the converted projection
*/
protected String[] convertProjection(String[] projection, Map<String, String> 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.
*/
protected abstract 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
*/
protected abstract 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.
*/
protected abstract 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
*/
protected abstract 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.
*/
protected abstract 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
*/
protected abstract 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 <btKey, emailKey>
* @return a new ContentValues object with the keys replaced as specified in the
* keyMap
*/
protected ContentValues createContentValues(Set<Entry<String, Object>> valueSet,
Map<String, String> keyMap) {
ContentValues values = new ContentValues(valueSet.size());
for (Entry<String, Object> 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
*/
protected abstract 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
*/
protected abstract 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
*/
protected abstract 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<String> segments = uri.getPathSegments();
if (segments.size() < 1) {
throw new IllegalArgumentException("No AccountId pressent in URI: " + uri);
}
return segments.get(0);
}
}