blob: 629a10d9f076f10f7066e0397f55ca9c1c848b2a [file] [log] [blame]
/*
* Copyright (C) 2013 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.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;
/**
* A base implementation of the BluetoothMapEmailContract.
* A base class for a ContentProvider that allows access to Email messages from a Bluetooth
* device through the Message Access Profile.
*/
public abstract class BluetoothMapEmailProvider extends ContentProvider {
private static final String TAG = "BluetoothMapEmailProvider";
private static final boolean D = true;
private static final int MATCH_ACCOUNT = 1;
private static final int MATCH_MESSAGE = 2;
private static final int MATCH_FOLDER = 3;
protected ContentResolver mResolver;
private Uri CONTENT_URI = null;
private String mAuthority;
private UriMatcher mMatcher;
private PipeReader mPipeReader = new PipeReader();
private PipeWriter mPipeWriter = new PipeWriter();
/**
* Write the content of a message to a stream as MIME encoded RFC-2822 data.
* @param accountId the ID of the account to which the message belong
* @param messageId the ID of the message to write to the stream
* @param includeAttachment true if attachments should be included
* @param download true if any missing part of the message shall be downloaded
* before written to the stream. The download flag will determine
* whether or not attachments shall be downloaded or only the message content.
* @param out the FileOurputStream to write to.
* @throws IOException
*/
protected abstract void WriteMessageToStream(long accountId, long messageId,
boolean includeAttachment, boolean download, FileOutputStream out) throws IOException;
/**
* @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_FOLDER, MATCH_FOLDER);
mMatcher.addURI(mAuthority, "#/" + BluetoothMapContract.TABLE_MESSAGE, MATCH_MESSAGE);
// 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);
}
mResolver = context.getContentResolver();
super.attachInfo(context, info);
}
/**
* Interface to write a stream of data to a pipe. Use with
* {@link ContentProvider#openPipeHelper}.
*/
public interface PipeDataReader<T> {
/**
* Called from a background thread to stream data from a pipe.
* Note that the pipe is blocking, so this thread can block on
* reads for an arbitrary amount of time if the client is slow
* at writing.
*
* @param input The pipe where data should be read. This will be
* closed for you upon returning from this function.
* @param uri The URI whose data is to be written.
* @param mimeType The desired type of data to be written.
* @param opts Options supplied by caller.
* @param args Your own custom arguments.
*/
void readDataFromPipe(ParcelFileDescriptor input, Uri uri, String mimeType, Bundle opts,
T args);
}
public class PipeReader implements PipeDataReader<Cursor> {
/**
* Read the data from the pipe and generate a message.
* Use the message to do an update of the message specified by the URI.
*/
@Override
public void readDataFromPipe(ParcelFileDescriptor input, Uri uri, String mimeType,
Bundle opts, Cursor args) {
Log.v(TAG, "readDataFromPipe(): uri=" + uri.toString());
FileInputStream fIn = null;
try {
fIn = new FileInputStream(input.getFileDescriptor());
long messageId = Long.valueOf(uri.getLastPathSegment());
long accountId = Long.valueOf(getAccountId(uri));
UpdateMimeMessageFromStream(fIn, accountId, messageId);
} catch (IOException e) {
Log.w(TAG, "IOException: ", e);
/* TODO: How to signal the error to the calling entity? Had expected
readDataFromPipe
* to throw IOException?
*/
} finally {
try {
if (fIn != null) {
fIn.close();
}
} catch (IOException e) {
Log.w(TAG, e);
}
}
}
}
/**
* Read a MIME encoded RFC-2822 fileStream and update the message content.
* The Date and/or From headers may not be present in the MIME encoded
* message, and this function shall add appropriate values if the headers
* are missing. From should be set to the owner of the account.
*
* @param input the file stream to read data from
* @param accountId the accountId
* @param messageId ID of the message to update
*/
protected abstract void UpdateMimeMessageFromStream(FileInputStream input, long accountId,
long messageId) throws IOException;
public class PipeWriter implements PipeDataWriter<Cursor> {
/**
* Generate a message based on the cursor, and write the encoded data to the stream.
*/
@Override
public void writeDataToPipe(ParcelFileDescriptor output, Uri uri, String mimeType,
Bundle opts, Cursor c) {
if (D) {
Log.d(TAG, "writeDataToPipe(): uri=" + uri.toString() + " - getLastPathSegment() = "
+ uri.getLastPathSegment());
}
FileOutputStream fout = null;
try {
fout = new FileOutputStream(output.getFileDescriptor());
boolean includeAttachments = true;
boolean download = false;
List<String> segments = uri.getPathSegments();
long messageId = Long.parseLong(segments.get(2));
long accountId = Long.parseLong(getAccountId(uri));
if (segments.size() >= 4) {
String format = segments.get(3);
if (format.equalsIgnoreCase(BluetoothMapContract.FILE_MSG_NO_ATTACHMENTS)) {
includeAttachments = false;
} else if (format.equalsIgnoreCase(
BluetoothMapContract.FILE_MSG_DOWNLOAD_NO_ATTACHMENTS)) {
includeAttachments = false;
download = true;
} else if (format.equalsIgnoreCase(BluetoothMapContract.FILE_MSG_DOWNLOAD)) {
download = true;
}
}
WriteMessageToStream(accountId, messageId, includeAttachments, download, fout);
} catch (IOException e) {
Log.w(TAG, e);
/* TODO: How to signal the error to the calling entity? Had expected writeDataToPipe
* to throw IOException?
*/
} finally {
try {
fout.flush();
} catch (IOException e) {
Log.w(TAG, "IOException: ", e);
}
try {
fout.close();
} catch (IOException e) {
Log.w(TAG, "IOException: ", e);
}
}
}
}
/**
* 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);
}
/**
* Not used, this is just a dummy implementation.
*/
@Override
public String getType(Uri uri) {
return "Email";
}
/**
* Open a file descriptor to a message.
* Two modes supported for read: With and without attachments.
* One mode exist for write and the actual content will be with or without
* attachments.
*
* Mode will be "r" or "w".
*
* URI format:
* The URI scheme is as follows.
* For messages with attachments:
* content://com.android.mail.bluetoothprovider/Messages/msgId#
*
* For messages without attachments:
* content://com.android.mail.bluetoothprovider/Messages/msgId#/NO_ATTACHMENTS
*
* UPDATE: For write.
* First create a message in the DB using insert into the message DB
* Then open a file handle to the #id
* write the data to a stream created from the fileHandle.
*
* @param uri the URI to open. ../Messages/#id
* @param mode the mode to use. The following modes exist: - UPDATE do not work - use URI
* - "read_with_attachments" - to read an e-mail including any attachments
* - "read_no_attachments" - to read an e-mail excluding any attachments
* - "write" - to add a mime encoded message to the database. This write
* should not trigger the message to be send.
* @return the ParcelFileDescriptor
* @throws FileNotFoundException
*/
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
long callingId = Binder.clearCallingIdentity();
if (D) {
Log.d(TAG, "openFile(): uri=" + uri.toString() + " - getLastPathSegment() = "
+ uri.getLastPathSegment());
}
try {
/* To be able to do abstraction of the file IO, we simply ignore the URI at this
* point and let the read/write function implementations parse the URI. */
if (mode.equals("w")) {
return openInversePipeHelper(uri, null, null, null, mPipeReader);
} else {
return openPipeHelper(uri, null, null, null, mPipeWriter);
}
} catch (IOException e) {
Log.w(TAG, e);
} finally {
Binder.restoreCallingIdentity(callingId);
}
return null;
}
/**
* A helper function for implementing {@link #openFile}, for
* creating a data pipe and background thread allowing you to stream
* data back from the client. This function returns a new
* ParcelFileDescriptor that should be returned to the caller (the caller
* is responsible for closing it).
*
* @param uri The URI whose data is to be written.
* @param mimeType The desired type of data to be written.
* @param opts Options supplied by caller.
* @param args Your own custom arguments.
* @param func Interface implementing the function that will actually
* stream the data.
* @return Returns a new ParcelFileDescriptor holding the read side of
* the pipe. This should be returned to the caller for reading; the caller
* is responsible for closing it when done.
*/
private <T> ParcelFileDescriptor openInversePipeHelper(final Uri uri, final String mimeType,
final Bundle opts, final T args, final PipeDataReader<T> func)
throws FileNotFoundException {
try {
final ParcelFileDescriptor[] fds = ParcelFileDescriptor.createPipe();
AsyncTask<Object, Object, Object> task = new AsyncTask<Object, Object, Object>() {
@Override
protected Object doInBackground(Object... params) {
func.readDataFromPipe(fds[0], uri, mimeType, opts, args);
try {
fds[0].close();
} catch (IOException e) {
Log.w(TAG, "Failure closing pipe", e);
}
return null;
}
};
task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, (Object[]) null);
return fds[1];
} catch (IOException e) {
throw new FileNotFoundException("failure making pipe");
}
}
/**
* 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);
Long folderId = values.getAsLong(BluetoothMapContract.MessageColumns.FOLDER_ID);
if (folderId == null) {
throw new IllegalArgumentException("FolderId missing in ContentValues");
}
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, folderId.toString());
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, String folderId);
/**
* Utility function to build a projection based on a projectionMap.
*
* "btColumnName" -> "emailColumnName as btColumnName" for each entry.
*
* This supports SQL statements in the emailColumnName entry.
* @param projection
* @param projectionMap <btColumnName, emailColumnName>
* @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;
switch (mMatcher.match(uri)) {
case MATCH_ACCOUNT:
return queryAccount(projection, selection, selectionArgs, sortOrder);
case MATCH_FOLDER:
accountId = getAccountId(uri);
return queryFolder(accountId, projection, selection, selectionArgs, sortOrder);
case MATCH_MESSAGE:
accountId = getAccountId(uri);
return queryMessage(accountId, 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);
/**
* Filter out the non usable folders and ensure to name the mandatory folders
* inbox, outbox, sent, deleted and draft.
* @param accountId
* @param projection
* @param selection
* @param selectionArgs
* @param sortOrder
* @return
*/
protected abstract Cursor queryFolder(String accountId, 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
* ccList: partial name/address search
* bccList: 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);
/**
* 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.
* 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);
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 (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, int 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);
@Override
public Bundle call(String method, String arg, Bundle extras) {
long callingId = Binder.clearCallingIdentity();
if (D) {
Log.d(TAG, "call(): method=" + method + " arg=" + arg + "ThreadId: "
+ Thread.currentThread().getId());
}
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;
}
int ret = syncFolder(accountId, folderId);
if (ret == 0) {
return new Bundle();
}
return null;
}
} finally {
Binder.restoreCallingIdentity(callingId);
}
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);
/**
* 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);
}
}