Add support for attachments in EmailProvider and (preliminary) EAS

* EmailProvider now saves Attachment records atomically with Message (and Body,
  of course) if an ArrayList is stored in mAttachments
* Update EAS code to support attachment discovery
* Update EAS code to support attachment download via service API (preliminary)
* Add test for atomic attachment save
* Add test for unique file creation (external)
This commit is contained in:
Marc Blank 2009-07-15 15:08:53 -07:00
parent c5f783f022
commit 976f92908d
8 changed files with 324 additions and 53 deletions

View File

@ -26,10 +26,12 @@ import android.content.Context;
import android.content.OperationApplicationException;
import android.database.Cursor;
import android.net.Uri;
import android.os.Environment;
import android.os.Parcel;
import android.os.Parcelable;
import android.os.RemoteException;
import java.io.File;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
@ -638,7 +640,7 @@ public abstract class EmailContent {
public void addSaveOps(ArrayList<ContentProviderOperation> ops) {
// First, save the message
ContentProviderOperation.Builder b = getSaveOrUpdateBuilder(true, mBaseUri, mId);
ContentProviderOperation.Builder b = getSaveOrUpdateBuilder(true, mBaseUri, -1);
ops.add(b.withValues(toContentValues()).build());
// Create and save the body
@ -652,8 +654,19 @@ public abstract class EmailContent {
b = getSaveOrUpdateBuilder(true, Body.CONTENT_URI, 0);
b.withValues(cv);
ContentValues backValues = new ContentValues();
backValues.put(Body.MESSAGE_KEY, ops.size() - 1);
int messageBackValue = ops.size() - 1;
backValues.put(Body.MESSAGE_KEY, messageBackValue);
ops.add(b.withValueBackReferences(backValues).build());
// Create the attaachments, if any
if (mAttachments != null) {
for (Attachment att: mAttachments) {
ops.add(getSaveOrUpdateBuilder(true, Attachment.CONTENT_URI, -1)
.withValues(att.toContentValues())
.withValueBackReference(Attachment.MESSAGE_KEY, messageBackValue)
.build());
}
}
}
// Text and Html information are stored as <location>;<encoding>;<charset>;<length>
@ -1482,6 +1495,39 @@ public abstract class EmailContent {
}
}
/**
* Creates a unique file in the external store by appending a hyphen
* and a number to the given filename.
* @param filename
* @return a new File object, or null if one could not be created
*/
public static File createUniqueFile(String filename) {
// TODO Handle internal storage, as required
if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
File directory = Environment.getExternalStorageDirectory();
File file = new File(directory, filename);
if (!file.exists()) {
return file;
}
// Get the extension of the file, if any.
int index = filename.lastIndexOf('.');
String name = filename;
String extension = "";
if (index != -1) {
name = filename.substring(0, index);
extension = filename.substring(index);
}
for (int i = 2; i < Integer.MAX_VALUE; i++) {
file = new File(directory, name + '-' + i + extension);
if (!file.exists()) {
return file;
}
}
return null;
}
return null;
}
@Override
@SuppressWarnings("unchecked")
public EmailContent.Attachment restore(Cursor cursor) {

View File

@ -69,8 +69,8 @@ public abstract class AbstractSyncService implements Runnable {
protected String mMailboxName;
public Account mAccount;
protected Context mContext;
protected long mRequestTime = 0;
protected volatile long mRequestTime = 0;
protected ArrayList<PartRequest> mPartRequests = new ArrayList<PartRequest>();
protected PartRequest mPendingPartRequest = null;
@ -285,6 +285,7 @@ public abstract class AbstractSyncService implements Runnable {
public void addPartRequest(PartRequest req) {
synchronized (mPartRequests) {
mPartRequests.add(req);
mRequestTime = System.currentTimeMillis();
}
}

View File

@ -66,6 +66,7 @@ import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.os.RemoteException;
import android.util.Log;
public class EasSyncService extends InteractiveSyncService {
@ -76,8 +77,11 @@ public class EasSyncService extends InteractiveSyncService {
private static final String WHERE_SYNC_FREQUENCY_PING =
Mailbox.SYNC_FREQUENCY + '=' + Account.CHECK_INTERVAL_PING;
private static final String SYNC_FREQUENCY_PING =
MailboxColumns.SYNC_FREQUENCY + '=' + Account.CHECK_INTERVAL_PING;
MailboxColumns.SYNC_FREQUENCY + " IN (" + Account.CHECK_INTERVAL_PING +
',' + Account.CHECK_INTERVAL_PUSH + ')';
static private final int CHUNK_SIZE = 16 * 1024;
// Reasonable default
String mProtocolVersion = "2.5";
static String mDeviceId = null;
@ -195,7 +199,7 @@ public class EasSyncService extends InteractiveSyncService {
// TODO Auto-generated method stub
}
protected HttpURLConnection sendEASPostCommand(String cmd, String data) throws IOException {
protected HttpURLConnection sendEASPostCommand(String cmd, String data) throws IOException {
HttpURLConnection uc = setupEASCommand("POST", cmd);
if (uc != null) {
uc.setRequestProperty("Content-Length", Integer.toString(data.length() + 2));
@ -208,11 +212,36 @@ public class EasSyncService extends InteractiveSyncService {
return uc;
}
static private final int CHUNK_SIZE = 16 * 1024;
private void doStatusCallback(IEmailServiceCallback callback, int status) {
try {
callback.status(status, 0);
} catch (RemoteException e2) {
// No danger if the client is no longer around
}
}
protected void getAttachment(PartRequest req) throws IOException {
private void doProgressCallback(IEmailServiceCallback callback, int progress) {
try {
callback.status(EmailServiceStatus.IN_PROGRESS, progress);
} catch (RemoteException e2) {
// No danger if the client is no longer around
}
}
/**
* Loads an attachment, based on the PartRequest passed in. The PartRequest is basically our
* wrapper for Attachment
* @param req the part (attachment) to be retrieved
* @param external whether the attachment should be loaded to external storage
* @throws IOException
*/
protected void getAttachment(PartRequest req, boolean external) throws IOException {
// TODO Implement internal storage as required
IEmailServiceCallback callback = req.callback;
Attachment att = req.att;
doProgressCallback(callback, 0);
DefaultHttpClient client = new DefaultHttpClient();
String us = makeUriString("GetAttachment", "&AttachmentName=" + req.att.mLocation);
String us = makeUriString("GetAttachment", "&AttachmentName=" + att.mLocation);
HttpPost method = new HttpPost(URI.create(us));
method.setHeader("Authorization", mAuthString);
@ -226,8 +255,7 @@ public class EasSyncService extends InteractiveSyncService {
Log.v(TAG, "Attachment code: " + status + ", Length: " + len + ", Type: " + type);
}
InputStream is = res.getEntity().getContent();
// TODO Use the request data, when it's defined. For now, stubbed out
File f = null; // Attachment.openAttachmentFile(req);
File f = Attachment.createUniqueFile(att.mFileName);
if (f != null) {
FileOutputStream os = new FileOutputStream(f);
if (len > 0) {
@ -241,10 +269,8 @@ public class EasSyncService extends InteractiveSyncService {
int read = is.read(bytes, 0, n);
os.write(bytes, 0, read);
len -= read;
if (req.handler != null) {
long pct = ((length - len) * 100 / length);
req.handler.sendEmptyMessage((int)pct);
}
int pct = ((length - len) * 100 / length);
doProgressCallback(callback, pct);
}
} finally {
mPendingPartRequest = null;
@ -254,12 +280,17 @@ public class EasSyncService extends InteractiveSyncService {
os.flush();
os.close();
ContentValues cv = new ContentValues();
cv.put(AttachmentColumns.CONTENT_URI, f.getAbsolutePath());
cv.put(AttachmentColumns.MIME_TYPE, type);
req.att.update(mContext, cv);
// TODO Inform UI that we're done
// EmailProvider will throw an exception if we try to update an unsaved attachment
if (att.isSaved()) {
ContentValues cv = new ContentValues();
cv.put(AttachmentColumns.CONTENT_URI, f.getAbsolutePath());
cv.put(AttachmentColumns.MIME_TYPE, type);
att.update(mContext, cv);
doStatusCallback(callback, EmailServiceStatus.SUCCESS);
}
}
} else {
doStatusCallback(callback, EmailServiceStatus.MESSAGE_NOT_FOUND);
}
}
@ -408,11 +439,14 @@ public class EasSyncService extends InteractiveSyncService {
}
// Wait for push notifications.
String threadName = Thread.currentThread().getName();
try {
runPingLoop();
} catch (StaleFolderListException e) {
// We break out if we get told about a stale folder list
userLog("Ping interrupted; folder list requires sync...");
} finally {
Thread.currentThread().setName(threadName);
}
}
}
@ -471,6 +505,7 @@ public class EasSyncService extends InteractiveSyncService {
// If we have some number that are ready for push, send Ping to the server
s.end("PingFolders").end("Ping").end();
uc = sendEASPostCommand("Ping", s.toString());
Thread.currentThread().setName(mAccount.mDisplayName + ": Ping");
userLog("Sending ping, timeout: " + uc.getReadTimeout() / 1000 + "s");
code = uc.getResponseCode();
userLog("Ping response: " + code);
@ -521,7 +556,7 @@ public class EasSyncService extends InteractiveSyncService {
mBindArguments[0] = Long.toString(mAccount.mId);
ArrayList<String> syncList = pp.getSyncList();
for (String serverId: syncList) {
mBindArguments[1] = serverId;
mBindArguments[1] = serverId;
Cursor c = cr.query(Mailbox.CONTENT_URI, Mailbox.CONTENT_PROJECTION,
WHERE_ACCOUNT_KEY_AND_SERVER_ID, mBindArguments, null);
try {
@ -626,6 +661,21 @@ public class EasSyncService extends InteractiveSyncService {
runAwake();
waitForConnectivity();
while (true) {
PartRequest req = null;
synchronized (mPartRequests) {
if (mPartRequests.isEmpty()) {
break;
} else {
req = mPartRequests.get(0);
}
}
getAttachment(req, true);
synchronized(mPartRequests) {
mPartRequests.remove(req);
}
}
EasSerializer s = new EasSerializer();
if (mailbox.mSyncKey == null) {
userLog("Mailbox syncKey RESET");

View File

@ -13,15 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.exchange;
/**
* This is a local copy of com.android.email.EmailProvider
*
* Last copied from com.android.email.EmailProvider on 7/15/09
* Last copied from com.android.email.EmailProvider on 7/16/09
*/
package com.android.exchange;
import com.android.email.R;
import com.android.email.provider.EmailProvider;
@ -33,10 +32,12 @@ import android.content.Context;
import android.content.OperationApplicationException;
import android.database.Cursor;
import android.net.Uri;
import android.os.Environment;
import android.os.Parcel;
import android.os.Parcelable;
import android.os.RemoteException;
import java.io.File;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
@ -647,10 +648,10 @@ public abstract class EmailContent {
}
public void addSaveOps(ArrayList<ContentProviderOperation> ops) {
// First, save the message
ContentProviderOperation.Builder b = getSaveOrUpdateBuilder(true, mBaseUri, mId);
// First, save the message
ContentProviderOperation.Builder b = getSaveOrUpdateBuilder(true, mBaseUri, -1);
ops.add(b.withValues(toContentValues()).build());
// Create and save the body
ContentValues cv = new ContentValues();
if (mText != null) {
@ -662,9 +663,20 @@ public abstract class EmailContent {
b = getSaveOrUpdateBuilder(true, Body.CONTENT_URI, 0);
b.withValues(cv);
ContentValues backValues = new ContentValues();
backValues.put(Body.MESSAGE_KEY, ops.size() - 1);
int messageBackValue = ops.size() - 1;
backValues.put(Body.MESSAGE_KEY, messageBackValue);
ops.add(b.withValueBackReferences(backValues).build());
}
// Create the attaachments, if any
if (mAttachments != null) {
for (Attachment att: mAttachments) {
ops.add(getSaveOrUpdateBuilder(true, Attachment.CONTENT_URI, -1)
.withValues(att.toContentValues())
.withValueBackReference(Attachment.MESSAGE_KEY, messageBackValue)
.build());
}
}
}
// Text and Html information are stored as <location>;<encoding>;<charset>;<length>
// charset: U = us-ascii; 8 = utf-8; I = iso-8559-1; others literally (e.g. KOI8-R)
@ -1492,6 +1504,38 @@ public abstract class EmailContent {
}
}
/**
* Creates a unique file in the external store by appending a hyphen
* and a number to the given filename.
* @param filename
* @return a new File object, or null if one could not be created
*/
public static File createUniqueFile(String filename) {
if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
File directory = Environment.getExternalStorageDirectory();
File file = new File(directory, filename);
if (!file.exists()) {
return file;
}
// Get the extension of the file, if any.
int index = filename.lastIndexOf('.');
String name = filename;
String extension = "";
if (index != -1) {
name = filename.substring(0, index);
extension = filename.substring(index);
}
for (int i = 2; i < Integer.MAX_VALUE; i++) {
file = new File(directory, name + '-' + i + extension);
if (!file.exists()) {
return file;
}
}
return null;
}
return null;
}
@Override
@SuppressWarnings("unchecked")
public EmailContent.Attachment restore(Cursor cursor) {

View File

@ -17,30 +17,51 @@
package com.android.exchange;
import com.android.email.provider.EmailContent;
import android.os.IBinder;
import android.os.RemoteException;
import android.util.Log;
import android.os.Handler;
import com.android.exchange.EmailContent.Attachment;
/**
* PartRequest is the EAS wrapper for attachment loading requests. In addition to information about
* the attachment to be loaded, it also contains the callback to be used for status/progress
* updates to the UI.
*/
public class PartRequest {
public long id;
public long timeStamp;
public long emailId;
public EmailContent.Attachment att;
public Attachment att;
public String loc;
public long size;
public long loaded;
public Handler handler;
public IEmailServiceCallback callback;
public PartRequest (long _emailId, EmailContent.Attachment _att) {
id = System.currentTimeMillis();
static IEmailServiceCallback sCallback = new IEmailServiceCallback () {
/* (non-Javadoc)
* @see com.android.exchange.IEmailServiceCallback#status(int, int)
*/
public void status(int statusCode, int progress) throws RemoteException {
// This is a placeholder, so that all PartRequests have a callback (prevents a lot of
// useless checking in the sync service). When debugging, logs the status and progress
// of the download.
if (Eas.TEST_DEBUG) {
Log.d("Status: ", "Code = " + statusCode + ", progress = " + progress);
}
}
public IBinder asBinder() { return null; }
};
public PartRequest(long _emailId, Attachment _att) {
timeStamp = System.currentTimeMillis();
emailId = _emailId;
att = _att;
loc = att.mLocation;
size = att.mSize;
loaded = 0;
callback = sCallback;
}
public PartRequest (long _emailId, EmailContent.Attachment _att, Handler _handler) {
public PartRequest(long _emailId, Attachment _att, IEmailServiceCallback _callback) {
this(_emailId, _att);
handler = _handler;
callback = _callback;
}
}

View File

@ -568,9 +568,10 @@ public class SyncManager extends Service implements Runnable {
}
public void run() {
log("Running");
mStop = false;
//Debug.waitForDebugger(); // DON'T CHECK IN WITH THIS
runAwake(-1);
ContentResolver resolver = getContentResolver();
@ -762,7 +763,6 @@ public class SyncManager extends Service implements Runnable {
}
if (service != null) {
service.mRequestTime = System.currentTimeMillis();
service.addPartRequest(req);
kick();
}
@ -776,7 +776,6 @@ public class SyncManager extends Service implements Runnable {
long mailboxId = msg.mMailboxKey;
AbstractSyncService service = INSTANCE.mServiceMap.get(mailboxId);
if (service != null) {
service.mRequestTime = System.currentTimeMillis();
return service.hasPartRequest(emailId, part);
}
return null;
@ -790,7 +789,6 @@ public class SyncManager extends Service implements Runnable {
long mailboxId = msg.mMailboxKey;
AbstractSyncService service = INSTANCE.mServiceMap.get(mailboxId);
if (service != null) {
service.mRequestTime = System.currentTimeMillis();
service.cancelPartRequest(emailId, part);
}
}

View File

@ -103,9 +103,7 @@ public class EasEmailSyncAdapter extends EasSyncAdapter {
while (nextTag(EasTags.SYNC_APPLICATION_DATA) != END) {
switch (tag) {
case EasTags.EMAIL_ATTACHMENTS:
break;
case EasTags.EMAIL_ATTACHMENT:
attachmentParser(atts, msg);
attachmentsParser(atts, msg);
break;
case EasTags.EMAIL_TO:
to = getValue();
@ -194,7 +192,7 @@ public class EasEmailSyncAdapter extends EasSyncAdapter {
public void attachmentParser(ArrayList<Attachment> atts, Message msg) throws IOException {
String fileName = null;
String length = null;
String lvl = null;
String location = null;
while (nextTag(EasTags.EMAIL_ATTACHMENT) != END) {
switch (tag) {
@ -202,7 +200,7 @@ public class EasEmailSyncAdapter extends EasSyncAdapter {
fileName = getValue();
break;
case EasTags.EMAIL_ATT_NAME:
lvl = getValue();
location = getValue();
break;
case EasTags.EMAIL_ATT_SIZE:
length = getValue();
@ -212,16 +210,29 @@ public class EasEmailSyncAdapter extends EasSyncAdapter {
}
}
if (fileName != null && length != null && lvl != null) {
if (fileName != null && length != null && location != null) {
Attachment att = new Attachment();
att.mEncoding = "base64";
att.mSize = Long.parseLong(length);
att.mFileName = fileName;
att.mLocation = location;
atts.add(att);
msg.mFlagAttachment = true;
}
}
public void attachmentsParser(ArrayList<Attachment> atts, Message msg) throws IOException {
while (nextTag(EasTags.EMAIL_ATTACHMENTS) != END) {
switch (tag) {
case EasTags.EMAIL_ATTACHMENT:
attachmentParser(atts, msg);
break;
default:
skipTag();
}
}
}
private Cursor getServerIdCursor(String serverId, String[] projection) {
bindArguments[0] = serverId;
bindArguments[1] = mMailboxIdAsString;

View File

@ -16,7 +16,13 @@
package com.android.email.provider;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import com.android.email.provider.EmailContent.Account;
import com.android.email.provider.EmailContent.Attachment;
import com.android.email.provider.EmailContent.Body;
import com.android.email.provider.EmailContent.Mailbox;
import com.android.email.provider.EmailContent.Message;
@ -28,6 +34,7 @@ import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Environment;
import android.test.ProviderTestCase2;
/**
@ -87,10 +94,22 @@ public class ProviderTests extends ProviderTestCase2<EmailProvider> {
ProviderTestUtils.assertMailboxEqual("testMailboxSave", box1, box2);
}
private static Attachment setupAttachment(String fileName, long length) {
Attachment att = new Attachment();
att.mFileName = fileName;
att.mLocation = "location" + length;
att.mSize = length;
return att;
}
public static String[] expectedAttachmentNames =
new String[] {"attachment1.doc", "attachment2.xls", "attachment3"};
// The lengths need to be kept in ascending order
public static long[] expectedAttachmentSizes = new long[] {31415L, 97701L, 151213L};
/**
* Test simple message save/retrieve
*
* TODO: attachments
* TODO: serverId vs. serverIntId
*/
public void testMessageSave() {
@ -137,7 +156,45 @@ public class ProviderTests extends ProviderTestCase2<EmailProvider> {
assertEquals("body text", text2, body2.mTextContent);
assertEquals("body html", html2, body2.mHtmlContent);
} finally {
if (c != null) c.close();
c.close();
}
Message message3 = ProviderTestUtils.setupMessage("message3", account1Id, box1Id, true,
false, mMockContext);
ArrayList<Attachment> atts = new ArrayList<Attachment>();
for (int i = 0; i < 3; i++) {
atts.add(setupAttachment(expectedAttachmentNames[i], expectedAttachmentSizes[i]));
}
message3.mAttachments = atts;
message3.saveOrUpdate(mMockContext);
long message3Id = message3.mId;
// Now check the attachments; there should be three and they should match name and size
c = null;
try {
// Note that there is NO guarantee of the order of returned records in the general case,
// so we specifically ask for ordering by size. The expectedAttachmentSizes array must
// be kept sorted by size (ascending) for this test to work properly
c = mMockContext.getContentResolver().query(
Attachment.CONTENT_URI,
Attachment.CONTENT_PROJECTION,
Attachment.MESSAGE_KEY + "=?",
new String[] {
String.valueOf(message3Id)
},
Attachment.SIZE);
int numAtts = c.getCount();
assertEquals(3, numAtts);
int i = 0;
while (c.moveToNext()) {
assertEquals(expectedAttachmentNames[i],
c.getString(Attachment.CONTENT_FILENAME_COLUMN));
assertEquals(expectedAttachmentSizes[i],
c.getLong(Attachment.CONTENT_SIZE_COLUMN));
i++;
}
} finally {
c.close();
}
}
@ -487,4 +544,47 @@ public class ProviderTests extends ProviderTestCase2<EmailProvider> {
* TODO: attachments
*/
/**
* Test that our unique file name algorithm works as expected. Since this test requires an
* SD card, we check the environment first, and return immediately if none is mounted.
* @throws IOException
*/
public void testCreateUniqueFile() throws IOException {
// Delete existing files, if they exist
if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
return;
}
try {
String fileName = "A11achm3n1.doc";
File uniqueFile = Attachment.createUniqueFile(fileName);
assertEquals(fileName, uniqueFile.getName());
if (uniqueFile.createNewFile()) {
uniqueFile = Attachment.createUniqueFile(fileName);
assertEquals("A11achm3n1-2.doc", uniqueFile.getName());
if (uniqueFile.createNewFile()) {
uniqueFile = Attachment.createUniqueFile(fileName);
assertEquals("A11achm3n1-3.doc", uniqueFile.getName());
}
}
fileName = "A11achm3n1";
uniqueFile = Attachment.createUniqueFile(fileName);
assertEquals(fileName, uniqueFile.getName());
if (uniqueFile.createNewFile()) {
uniqueFile = Attachment.createUniqueFile(fileName);
assertEquals("A11achm3n1-2", uniqueFile.getName());
}
} finally {
File directory = Environment.getExternalStorageDirectory();
// These are the files that should be created earlier in the test. Make sure
// they are deleted for the next go-around
String[] fileNames = new String[] {"A11achm3n1.doc", "A11achm3n1-2.doc", "A11achm3n1"};
int length = fileNames.length;
for (int i = 0; i < length; i++) {
File file = new File(directory, fileNames[i]);
if (file.exists()) {
file.delete();
}
}
}
}
}