+ * So 25k gives good performance and a reasonable data footprint. Sounds good to me.
+ */
+ private static final int MAX_SMALL_MESSAGE_SIZE = (25 * 1024);
+
+ private static final String PENDING_COMMAND_TRASH =
+ "com.android.email.MessagingController.trash";
+ private static final String PENDING_COMMAND_MARK_READ =
+ "com.android.email.MessagingController.markRead";
+ private static final String PENDING_COMMAND_APPEND =
+ "com.android.email.MessagingController.append";
+
+ private static MessagingController inst = null;
+ private BlockingQueue mCommands = new LinkedBlockingQueue();
+ private Thread mThread;
+ private HashSet mListeners = new HashSet();
+ private boolean mBusy;
+ private Application mApplication;
+
+ private MessagingController(Application application) {
+ mApplication = application;
+ mThread = new Thread(this);
+ mThread.start();
+ }
+
+ /**
+ * Gets or creates the singleton instance of MessagingController. Application is used to
+ * provide a Context to classes that need it.
+ * @param application
+ * @return
+ */
+ public synchronized static MessagingController getInstance(Application application) {
+ if (inst == null) {
+ inst = new MessagingController(application);
+ }
+ return inst;
+ }
+
+ public boolean isBusy() {
+ return mBusy;
+ }
+
+ public void run() {
+ Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
+ while (true) {
+ try {
+ Command command = mCommands.take();
+ if (command.listener == null || mListeners.contains(command.listener)) {
+ mBusy = true;
+ command.runnable.run();
+ for (MessagingListener l : mListeners) {
+ l.controllerCommandCompleted(mCommands.size() > 0);
+ }
+ }
+ }
+ catch (Exception e) {
+ if (Config.LOGV) {
+ Log.v(Email.LOG_TAG, "Error running command", e);
+ }
+ }
+ mBusy = false;
+ }
+ }
+
+ private void put(String description, MessagingListener listener, Runnable runnable) {
+ try {
+ Command command = new Command();
+ command.listener = listener;
+ command.runnable = runnable;
+ command.description = description;
+ mCommands.put(command);
+ }
+ catch (InterruptedException ie) {
+ throw new Error(ie);
+ }
+ }
+
+ public void addListener(MessagingListener listener) {
+ mListeners.add(listener);
+ }
+
+ public void removeListener(MessagingListener listener) {
+ mListeners.remove(listener);
+ }
+
+ /**
+ * Lists folders that are available locally and remotely. This method calls
+ * listFoldersCallback for local folders before it returns, and then for
+ * remote folders at some later point. If there are no local folders
+ * includeRemote is forced by this method. This method should be called from
+ * a Thread as it may take several seconds to list the local folders.
+ * TODO this needs to cache the remote folder list
+ *
+ * @param account
+ * @param includeRemote
+ * @param listener
+ * @throws MessagingException
+ */
+ public void listFolders(
+ final Account account,
+ boolean refreshRemote,
+ MessagingListener listener) {
+ for (MessagingListener l : mListeners) {
+ l.listFoldersStarted(account);
+ }
+ try {
+ Store localStore = Store.getInstance(account.getLocalStoreUri(), mApplication);
+ Folder[] localFolders = localStore.getPersonalNamespaces();
+
+ if ( localFolders == null || localFolders.length == 0) {
+ doRefreshRemote(account, listener);
+ return;
+ }
+
+ for (MessagingListener l : mListeners) {
+ l.listFolders(account, localFolders);
+ }
+ }
+ catch (Exception e) {
+ for (MessagingListener l : mListeners) {
+ l.listFoldersFailed(account, e.getMessage());
+ return;
+ }
+ }
+ for (MessagingListener l : mListeners) {
+ l.listFoldersFinished(account);
+ }
+ }
+
+ private void doRefreshRemote (final Account account, MessagingListener listener) {
+ put("listFolders", listener, new Runnable() {
+ public void run() {
+ try {
+ Store store = Store.getInstance(account.getStoreUri(), mApplication);
+
+ Folder[] remoteFolders = store.getPersonalNamespaces();
+
+ Store localStore = Store.getInstance(
+ account.getLocalStoreUri(),
+ mApplication);
+ HashSet remoteFolderNames = new HashSet();
+ for (int i = 0, count = remoteFolders.length; i < count; i++) {
+ Folder localFolder = localStore.getFolder(remoteFolders[i].getName());
+ if (!localFolder.exists()) {
+
+ localFolder.create(FolderType.HOLDS_MESSAGES, account.getDisplayCount());
+ }
+ remoteFolderNames.add(remoteFolders[i].getName());
+ }
+
+ Folder[] localFolders = localStore.getPersonalNamespaces();
+
+ /*
+ * Clear out any folders that are no longer on the remote store.
+ */
+ for (Folder localFolder : localFolders) {
+ String localFolderName = localFolder.getName();
+ if (localFolderName.equalsIgnoreCase(Email.INBOX) ||
+ localFolderName.equals(account.getTrashFolderName()) ||
+ localFolderName.equals(account.getOutboxFolderName()) ||
+ localFolderName.equals(account.getDraftsFolderName()) ||
+ localFolderName.equals(account.getSentFolderName())) {
+ continue;
+ }
+ if (!remoteFolderNames.contains(localFolder.getName())) {
+ localFolder.delete(false);
+ }
+ }
+
+ localFolders = localStore.getPersonalNamespaces();
+
+ for (MessagingListener l : mListeners) {
+ l.listFolders(account, localFolders);
+ }
+ for (MessagingListener l : mListeners) {
+ l.listFoldersFinished(account);
+ }
+ }
+ catch (Exception e) {
+ for (MessagingListener l : mListeners) {
+ l.listFoldersFailed(account, "");
+ }
+ }
+ }
+ });
+ }
+
+
+
+ /**
+ * List the local message store for the given folder. This work is done
+ * synchronously.
+ *
+ * @param account
+ * @param folder
+ * @param listener
+ * @throws MessagingException
+ */
+ public void listLocalMessages(final Account account, final String folder,
+ MessagingListener listener) {
+ for (MessagingListener l : mListeners) {
+ l.listLocalMessagesStarted(account, folder);
+ }
+
+ try {
+ Store localStore = Store.getInstance(account.getLocalStoreUri(), mApplication);
+ Folder localFolder = localStore.getFolder(folder);
+ localFolder.open(OpenMode.READ_WRITE);
+ Message[] localMessages = localFolder.getMessages(null);
+ ArrayList messages = new ArrayList();
+ for (Message message : localMessages) {
+ if (!message.isSet(Flag.DELETED)) {
+ messages.add(message);
+ }
+ }
+ for (MessagingListener l : mListeners) {
+ l.listLocalMessages(account, folder, messages.toArray(new Message[0]));
+ }
+ for (MessagingListener l : mListeners) {
+ l.listLocalMessagesFinished(account, folder);
+ }
+ }
+ catch (Exception e) {
+ for (MessagingListener l : mListeners) {
+ l.listLocalMessagesFailed(account, folder, e.getMessage());
+ }
+ }
+ }
+
+ public void loadMoreMessages(Account account, String folder, MessagingListener listener) {
+ try {
+ LocalStore localStore = (LocalStore) Store.getInstance(
+ account.getLocalStoreUri(),
+ mApplication);
+ LocalFolder localFolder = (LocalFolder) localStore.getFolder(folder);
+ localFolder.setVisibleLimit(localFolder.getVisibleLimit()
+ + account.getDisplayCount());
+ synchronizeMailbox(account, folder, listener);
+ }
+ catch (MessagingException me) {
+ throw new RuntimeException("Unable to set visible limit on folder", me);
+ }
+ }
+
+ public void resetVisibleLimits(Account[] accounts) {
+ for (Account account : accounts) {
+ try {
+ LocalStore localStore =
+ (LocalStore) Store.getInstance(account.getLocalStoreUri(), mApplication);
+ localStore.resetVisibleLimits(account.getDisplayCount());
+ }
+ catch (MessagingException e) {
+ Log.e(Email.LOG_TAG, "Unable to reset visible limits", e);
+ }
+ }
+ }
+
+ /**
+ * Start background synchronization of the specified folder.
+ * @param account
+ * @param folder
+ * @param numNewestMessagesToKeep Specifies the number of messages that should be
+ * considered as part of the window of available messages. This number effectively limits
+ * the user's view into the mailbox to the newest (numNewestMessagesToKeep) messages.
+ * @param listener
+ */
+ public void synchronizeMailbox(final Account account, final String folder,
+ MessagingListener listener) {
+ /*
+ * We don't ever sync the Outbox.
+ */
+ if (folder.equals(account.getOutboxFolderName())) {
+ return;
+ }
+ for (MessagingListener l : mListeners) {
+ l.synchronizeMailboxStarted(account, folder);
+ }
+ put("synchronizeMailbox", listener, new Runnable() {
+ public void run() {
+ synchronizeMailboxSynchronous(account, folder);
+ }
+ });
+ }
+
+ /**
+ * Start foreground synchronization of the specified folder. This is generally only called
+ * by synchronizeMailbox.
+ * @param account
+ * @param folder
+ * @param numNewestMessagesToKeep Specifies the number of messages that should be
+ * considered as part of the window of available messages. This number effectively limits
+ * the user's view into the mailbox to the newest (numNewestMessagesToKeep) messages.
+ * @param listener
+ *
+ * TODO Break this method up into smaller chunks.
+ */
+ public void synchronizeMailboxSynchronous(final Account account, final String folder) {
+ for (MessagingListener l : mListeners) {
+ l.synchronizeMailboxStarted(account, folder);
+ }
+ try {
+ processPendingCommandsSynchronous(account);
+
+ /*
+ * Get the message list from the local store and create an index of
+ * the uids within the list.
+ */
+ final LocalStore localStore =
+ (LocalStore) Store.getInstance(account.getLocalStoreUri(), mApplication);
+ final LocalFolder localFolder = (LocalFolder) localStore.getFolder(folder);
+ localFolder.open(OpenMode.READ_WRITE);
+ Message[] localMessages = localFolder.getMessages(null);
+ HashMap localUidMap = new HashMap();
+ for (Message message : localMessages) {
+ localUidMap.put(message.getUid(), message);
+ }
+
+ Store remoteStore = Store.getInstance(account.getStoreUri(), mApplication);
+ Folder remoteFolder = remoteStore.getFolder(folder);
+
+ /*
+ * If the folder is a "special" folder we need to see if it exists
+ * on the remote server. It if does not exist we'll try to create it. If we
+ * can't create we'll abort. This will happen on every single Pop3 folder as
+ * designed and on Imap folders during error conditions. This allows us
+ * to treat Pop3 and Imap the same in this code.
+ */
+ if (folder.equals(account.getTrashFolderName()) ||
+ folder.equals(account.getSentFolderName()) ||
+ folder.equals(account.getDraftsFolderName())) {
+ if (!remoteFolder.exists()) {
+ if (!remoteFolder.create(FolderType.HOLDS_MESSAGES)) {
+ for (MessagingListener l : mListeners) {
+ l.synchronizeMailboxFinished(account, folder, 0, 0);
+ }
+ return;
+ }
+ }
+ }
+
+ /*
+ * Synchronization process:
+ Open the folder
+ Upload any local messages that are marked as PENDING_UPLOAD (Drafts, Sent, Trash)
+ Get the message count
+ Get the list of the newest Email.DEFAULT_VISIBLE_LIMIT messages
+ getMessages(messageCount - Email.DEFAULT_VISIBLE_LIMIT, messageCount)
+ See if we have each message locally, if not fetch it's flags and envelope
+ Get and update the unread count for the folder
+ Update the remote flags of any messages we have locally with an internal date
+ newer than the remote message.
+ Get the current flags for any messages we have locally but did not just download
+ Update local flags
+ For any message we have locally but not remotely, delete the local message to keep
+ cache clean.
+ Download larger parts of any new messages.
+ (Optional) Download small attachments in the background.
+ */
+
+ /*
+ * Open the remote folder. This pre-loads certain metadata like message count.
+ */
+ remoteFolder.open(OpenMode.READ_WRITE);
+
+
+ /*
+ * Get the remote message count.
+ */
+ int remoteMessageCount = remoteFolder.getMessageCount();
+
+ int visibleLimit = localFolder.getVisibleLimit();
+
+ Message[] remoteMessages = new Message[0];
+ final ArrayList unsyncedMessages = new ArrayList();
+ HashMap remoteUidMap = new HashMap();
+
+ if (remoteMessageCount > 0) {
+ /*
+ * Message numbers start at 1.
+ */
+ int remoteStart = Math.max(0, remoteMessageCount - visibleLimit) + 1;
+ int remoteEnd = remoteMessageCount;
+ remoteMessages = remoteFolder.getMessages(remoteStart, remoteEnd, null);
+ for (Message message : remoteMessages) {
+ remoteUidMap.put(message.getUid(), message);
+ }
+
+
+
+
+
+
+
+ /*
+ * Get a list of the messages that are in the remote list but not on the
+ * local store, or messages that are in the local store but failed to download
+ * on the last sync. These are the new messages that we will download.
+ */
+ for (Message message : remoteMessages) {
+ Message localMessage = localUidMap.get(message.getUid());
+ if (localMessage == null ||
+ (!localMessage.isSet(Flag.DELETED) &&
+ !localMessage.isSet(Flag.X_DOWNLOADED_FULL) &&
+ !localMessage.isSet(Flag.X_DOWNLOADED_PARTIAL))) {
+ unsyncedMessages.add(message);
+ }
+ }
+ }
+
+
+ /*
+ * Trash any remote messages that are marked as trashed locally.
+ */
+ for (Message message : localMessages) {
+ Message remoteMessage = remoteUidMap.get(message.getUid());
+ // skip things deleted on the server side
+ if (remoteMessage != null && message.isSet(Flag.DELETED)) {
+ remoteMessage.setFlag(Flag.DELETED, true);
+ }
+
+ }
+
+
+ /*
+ * A list of messages that were downloaded and which did not have the Seen flag set.
+ * This will serve to indicate the true "new" message count that will be reported to
+ * the user via notification.
+ */
+ final ArrayList newMessages = new ArrayList();
+
+ /*
+ * Fetch the flags and envelope only of the new messages. This is intended to get us
+s * critical data as fast as possible, and then we'll fill in the details.
+ */
+ if (unsyncedMessages.size() > 0) {
+
+ /*
+ * Reverse the order of the messages. Depending on the server this may get us
+ * fetch results for newest to oldest. If not, no harm done.
+ */
+ Collections.reverse(unsyncedMessages);
+
+ FetchProfile fp = new FetchProfile();
+ if (remoteFolder.supportsFetchingFlags()) {
+ fp.add(FetchProfile.Item.FLAGS);
+ }
+ fp.add(FetchProfile.Item.ENVELOPE);
+ remoteFolder.fetch(unsyncedMessages.toArray(new Message[0]), fp,
+ new MessageRetrievalListener() {
+ public void messageFinished(Message message, int number, int ofTotal) {
+ try {
+ // Store the new message locally
+ localFolder.appendMessages(new Message[] {
+ message
+ });
+
+ // And include it in the view
+ if (message.getSubject() != null &&
+ message.getFrom() != null) {
+ /*
+ * We check to make sure that we got something worth
+ * showing (subject and from) because some protocols
+ * (POP) may not be able to give us headers for
+ * ENVELOPE, only size.
+ */
+ for (MessagingListener l : mListeners) {
+ l.synchronizeMailboxNewMessage(account, folder,
+ localFolder.getMessage(message.getUid()));
+ }
+ }
+
+ if (!message.isSet(Flag.SEEN)) {
+ newMessages.add(message);
+ }
+ }
+ catch (Exception e) {
+ Log.e(Email.LOG_TAG,
+ "Error while storing downloaded message.",
+ e);
+ }
+ }
+
+ public void messageStarted(String uid, int number, int ofTotal) {
+ }
+ });
+ }
+
+ FetchProfile fp;
+
+ /*
+ * Refresh the flags for any messages in the local store that we didn't just
+ * download.
+ */
+ if (remoteFolder.supportsFetchingFlags()) {
+ fp = new FetchProfile();
+ fp.add(FetchProfile.Item.FLAGS);
+ remoteFolder.fetch(remoteMessages, fp, null);
+ for (Message remoteMessage : remoteMessages) {
+ Message localMessage = localFolder.getMessage(remoteMessage.getUid());
+ if (localMessage == null) {
+ continue;
+ }
+ if (remoteMessage.isSet(Flag.SEEN) != localMessage.isSet(Flag.SEEN)) {
+ localMessage.setFlag(Flag.SEEN, remoteMessage.isSet(Flag.SEEN));
+ for (MessagingListener l : mListeners) {
+ l.synchronizeMailboxNewMessage(account, folder, localMessage);
+ }
+ }
+ }
+ }
+
+ /*
+ * Get and store the unread message count.
+ */
+ int remoteUnreadMessageCount = remoteFolder.getUnreadMessageCount();
+ if (remoteUnreadMessageCount == -1) {
+ localFolder.setUnreadMessageCount(localFolder.getUnreadMessageCount()
+ + newMessages.size());
+ }
+ else {
+ localFolder.setUnreadMessageCount(remoteUnreadMessageCount);
+ }
+
+ /*
+ * Remove any messages that are in the local store but no longer on the remote store.
+ */
+ for (Message localMessage : localMessages) {
+ if (remoteUidMap.get(localMessage.getUid()) == null) {
+ localMessage.setFlag(Flag.X_DESTROYED, true);
+ for (MessagingListener l : mListeners) {
+ l.synchronizeMailboxRemovedMessage(account, folder, localMessage);
+ }
+ }
+ }
+
+ /*
+ * Now we download the actual content of messages.
+ */
+ ArrayList largeMessages = new ArrayList();
+ ArrayList smallMessages = new ArrayList();
+ for (Message message : unsyncedMessages) {
+ /*
+ * Sort the messages into two buckets, small and large. Small messages will be
+ * downloaded fully and large messages will be downloaded in parts. By sorting
+ * into two buckets we can pipeline the commands for each set of messages
+ * into a single command to the server saving lots of round trips.
+ */
+ if (message.getSize() > (MAX_SMALL_MESSAGE_SIZE)) {
+ largeMessages.add(message);
+ } else {
+ smallMessages.add(message);
+ }
+ }
+ /*
+ * Grab the content of the small messages first. This is going to
+ * be very fast and at very worst will be a single up of a few bytes and a single
+ * download of 625k.
+ */
+ fp = new FetchProfile();
+ fp.add(FetchProfile.Item.BODY);
+ remoteFolder.fetch(smallMessages.toArray(new Message[smallMessages.size()]),
+ fp, new MessageRetrievalListener() {
+ public void messageFinished(Message message, int number, int ofTotal) {
+ try {
+ // Store the updated message locally
+ localFolder.appendMessages(new Message[] {
+ message
+ });
+
+ Message localMessage = localFolder.getMessage(message.getUid());
+
+ // Set a flag indicating this message has now be fully downloaded
+ localMessage.setFlag(Flag.X_DOWNLOADED_FULL, true);
+
+ // Update the listener with what we've found
+ for (MessagingListener l : mListeners) {
+ l.synchronizeMailboxNewMessage(
+ account,
+ folder,
+ localMessage);
+ }
+ }
+ catch (MessagingException me) {
+
+ }
+ }
+
+ public void messageStarted(String uid, int number, int ofTotal) {
+ }
+ });
+
+ /*
+ * Now do the large messages that require more round trips.
+ */
+ fp.clear();
+ fp.add(FetchProfile.Item.STRUCTURE);
+ remoteFolder.fetch(largeMessages.toArray(new Message[largeMessages.size()]),
+ fp, null);
+ for (Message message : largeMessages) {
+ if (message.getBody() == null) {
+ /*
+ * The provider was unable to get the structure of the message, so
+ * we'll download a reasonable portion of the messge and mark it as
+ * incomplete so the entire thing can be downloaded later if the user
+ * wishes to download it.
+ */
+ fp.clear();
+ fp.add(FetchProfile.Item.BODY_SANE);
+ /*
+ * TODO a good optimization here would be to make sure that all Stores set
+ * the proper size after this fetch and compare the before and after size. If
+ * they equal we can mark this SYNCHRONIZED instead of PARTIALLY_SYNCHRONIZED
+ */
+
+ remoteFolder.fetch(new Message[] { message }, fp, null);
+ // Store the updated message locally
+ localFolder.appendMessages(new Message[] {
+ message
+ });
+
+ Message localMessage = localFolder.getMessage(message.getUid());
+
+ // Set a flag indicating that the message has been partially downloaded and
+ // is ready for view.
+ localMessage.setFlag(Flag.X_DOWNLOADED_PARTIAL, true);
+ } else {
+ /*
+ * We have a structure to deal with, from which
+ * we can pull down the parts we want to actually store.
+ * Build a list of parts we are interested in. Text parts will be downloaded
+ * right now, attachments will be left for later.
+ */
+
+ ArrayList viewables = new ArrayList();
+ ArrayList attachments = new ArrayList();
+ MimeUtility.collectParts(message, viewables, attachments);
+
+ /*
+ * Now download the parts we're interested in storing.
+ */
+ for (Part part : viewables) {
+ fp.clear();
+ fp.add(part);
+ // TODO what happens if the network connection dies? We've got partial
+ // messages with incorrect status stored.
+ remoteFolder.fetch(new Message[] { message }, fp, null);
+ }
+ // Store the updated message locally
+ localFolder.appendMessages(new Message[] {
+ message
+ });
+
+ Message localMessage = localFolder.getMessage(message.getUid());
+
+ // Set a flag indicating this message has been fully downloaded and can be
+ // viewed.
+ localMessage.setFlag(Flag.X_DOWNLOADED_FULL, true);
+ }
+
+ // Update the listener with what we've found
+ for (MessagingListener l : mListeners) {
+ l.synchronizeMailboxNewMessage(
+ account,
+ folder,
+ localFolder.getMessage(message.getUid()));
+ }
+ }
+
+
+ /*
+ * Notify listeners that we're finally done.
+ */
+ for (MessagingListener l : mListeners) {
+ l.synchronizeMailboxFinished(
+ account,
+ folder,
+ remoteFolder.getMessageCount(), newMessages.size());
+ }
+
+ remoteFolder.close(false);
+ localFolder.close(false);
+ }
+ catch (Exception e) {
+ if (Config.LOGV) {
+ Log.v(Email.LOG_TAG, "synchronizeMailbox", e);
+ }
+ for (MessagingListener l : mListeners) {
+ l.synchronizeMailboxFailed(
+ account,
+ folder,
+ e.getMessage());
+ }
+ }
+ }
+
+ private void queuePendingCommand(Account account, PendingCommand command) {
+ try {
+ LocalStore localStore = (LocalStore) Store.getInstance(
+ account.getLocalStoreUri(),
+ mApplication);
+ localStore.addPendingCommand(command);
+ }
+ catch (Exception e) {
+ throw new RuntimeException("Unable to enqueue pending command", e);
+ }
+ }
+
+ private void processPendingCommands(final Account account) {
+ put("processPendingCommands", null, new Runnable() {
+ public void run() {
+ try {
+ processPendingCommandsSynchronous(account);
+ }
+ catch (MessagingException me) {
+ if (Config.LOGV) {
+ Log.v(Email.LOG_TAG, "processPendingCommands", me);
+ }
+ /*
+ * Ignore any exceptions from the commands. Commands will be processed
+ * on the next round.
+ */
+ }
+ }
+ });
+ }
+
+ private void processPendingCommandsSynchronous(Account account) throws MessagingException {
+ LocalStore localStore = (LocalStore) Store.getInstance(
+ account.getLocalStoreUri(),
+ mApplication);
+ ArrayList commands = localStore.getPendingCommands();
+ for (PendingCommand command : commands) {
+ /*
+ * We specifically do not catch any exceptions here. If a command fails it is
+ * most likely due to a server or IO error and it must be retried before any
+ * other command processes. This maintains the order of the commands.
+ */
+ if (PENDING_COMMAND_APPEND.equals(command.command)) {
+ processPendingAppend(command, account);
+ }
+ else if (PENDING_COMMAND_MARK_READ.equals(command.command)) {
+ processPendingMarkRead(command, account);
+ }
+ else if (PENDING_COMMAND_TRASH.equals(command.command)) {
+ processPendingTrash(command, account);
+ }
+ localStore.removePendingCommand(command);
+ }
+ }
+
+ /**
+ * Process a pending append message command. This command uploads a local message to the
+ * server, first checking to be sure that the server message is not newer than
+ * the local message. Once the local message is successfully processed it is deleted so
+ * that the server message will be synchronized down without an additional copy being
+ * created.
+ * TODO update the local message UID instead of deleteing it
+ *
+ * @param command arguments = (String folder, String uid)
+ * @param account
+ * @throws MessagingException
+ */
+ private void processPendingAppend(PendingCommand command, Account account)
+ throws MessagingException {
+ String folder = command.arguments[0];
+ String uid = command.arguments[1];
+
+ LocalStore localStore = (LocalStore) Store.getInstance(
+ account.getLocalStoreUri(),
+ mApplication);
+ LocalFolder localFolder = (LocalFolder) localStore.getFolder(folder);
+ LocalMessage localMessage = (LocalMessage) localFolder.getMessage(uid);
+
+ if (localMessage == null) {
+ return;
+ }
+
+ Store remoteStore = Store.getInstance(account.getStoreUri(), mApplication);
+ Folder remoteFolder = remoteStore.getFolder(folder);
+ if (!remoteFolder.exists()) {
+ if (!remoteFolder.create(FolderType.HOLDS_MESSAGES)) {
+ return;
+ }
+ }
+ remoteFolder.open(OpenMode.READ_WRITE);
+ if (remoteFolder.getMode() != OpenMode.READ_WRITE) {
+ return;
+ }
+
+ Message remoteMessage = null;
+ if (!localMessage.getUid().startsWith("Local")
+ && !localMessage.getUid().contains("-")) {
+ remoteMessage = remoteFolder.getMessage(localMessage.getUid());
+ }
+
+ if (remoteMessage == null) {
+ /*
+ * If the message does not exist remotely we just upload it and then
+ * update our local copy with the new uid.
+ */
+ FetchProfile fp = new FetchProfile();
+ fp.add(FetchProfile.Item.BODY);
+ localFolder.fetch(new Message[] { localMessage }, fp, null);
+ String oldUid = localMessage.getUid();
+ remoteFolder.appendMessages(new Message[] { localMessage });
+ localFolder.changeUid(localMessage);
+ for (MessagingListener l : mListeners) {
+ l.messageUidChanged(account, folder, oldUid, localMessage.getUid());
+ }
+ }
+ else {
+ /*
+ * If the remote message exists we need to determine which copy to keep.
+ */
+ /*
+ * See if the remote message is newer than ours.
+ */
+ FetchProfile fp = new FetchProfile();
+ fp.add(FetchProfile.Item.ENVELOPE);
+ remoteFolder.fetch(new Message[] { remoteMessage }, fp, null);
+ Date localDate = localMessage.getInternalDate();
+ Date remoteDate = remoteMessage.getInternalDate();
+ if (remoteDate.compareTo(localDate) > 0) {
+ /*
+ * If the remote message is newer than ours we'll just
+ * delete ours and move on. A sync will get the server message
+ * if we need to be able to see it.
+ */
+ localMessage.setFlag(Flag.DELETED, true);
+ }
+ else {
+ /*
+ * Otherwise we'll upload our message and then delete the remote message.
+ */
+ fp.clear();
+ fp = new FetchProfile();
+ fp.add(FetchProfile.Item.BODY);
+ localFolder.fetch(new Message[] { localMessage }, fp, null);
+ String oldUid = localMessage.getUid();
+ remoteFolder.appendMessages(new Message[] { localMessage });
+ localFolder.changeUid(localMessage);
+ for (MessagingListener l : mListeners) {
+ l.messageUidChanged(account, folder, oldUid, localMessage.getUid());
+ }
+ remoteMessage.setFlag(Flag.DELETED, true);
+ }
+ }
+ }
+
+ /**
+ * Process a pending trash message command.
+ *
+ * @param command arguments = (String folder, String uid)
+ * @param account
+ * @throws MessagingException
+ */
+ private void processPendingTrash(PendingCommand command, Account account)
+ throws MessagingException {
+ String folder = command.arguments[0];
+ String uid = command.arguments[1];
+
+ Store remoteStore = Store.getInstance(account.getStoreUri(), mApplication);
+ Folder remoteFolder = remoteStore.getFolder(folder);
+ if (!remoteFolder.exists()) {
+ return;
+ }
+ remoteFolder.open(OpenMode.READ_WRITE);
+ if (remoteFolder.getMode() != OpenMode.READ_WRITE) {
+ return;
+ }
+
+ Message remoteMessage = null;
+ if (!uid.startsWith("Local")
+ && !uid.contains("-")) {
+ remoteMessage = remoteFolder.getMessage(uid);
+ }
+ if (remoteMessage == null) {
+ return;
+ }
+
+ Folder remoteTrashFolder = remoteStore.getFolder(account.getTrashFolderName());
+ /*
+ * Attempt to copy the remote message to the remote trash folder.
+ */
+ if (!remoteTrashFolder.exists()) {
+ /*
+ * If the remote trash folder doesn't exist we try to create it.
+ */
+ remoteTrashFolder.create(FolderType.HOLDS_MESSAGES);
+ }
+
+ if (remoteTrashFolder.exists()) {
+ remoteFolder.copyMessages(new Message[] { remoteMessage }, remoteTrashFolder);
+ }
+
+ remoteMessage.setFlag(Flag.DELETED, true);
+ remoteFolder.expunge();
+ }
+
+ /**
+ * Processes a pending mark read or unread command.
+ *
+ * @param command arguments = (String folder, String uid, boolean read)
+ * @param account
+ */
+ private void processPendingMarkRead(PendingCommand command, Account account)
+ throws MessagingException {
+ String folder = command.arguments[0];
+ String uid = command.arguments[1];
+ boolean read = Boolean.parseBoolean(command.arguments[2]);
+
+ Store remoteStore = Store.getInstance(account.getStoreUri(), mApplication);
+ Folder remoteFolder = remoteStore.getFolder(folder);
+ if (!remoteFolder.exists()) {
+ return;
+ }
+ remoteFolder.open(OpenMode.READ_WRITE);
+ if (remoteFolder.getMode() != OpenMode.READ_WRITE) {
+ return;
+ }
+ Message remoteMessage = null;
+ if (!uid.startsWith("Local")
+ && !uid.contains("-")) {
+ remoteMessage = remoteFolder.getMessage(uid);
+ }
+ if (remoteMessage == null) {
+ return;
+ }
+ remoteMessage.setFlag(Flag.SEEN, read);
+ }
+
+ /**
+ * Mark the message with the given account, folder and uid either Seen or not Seen.
+ * @param account
+ * @param folder
+ * @param uid
+ * @param seen
+ */
+ public void markMessageRead(
+ final Account account,
+ final String folder,
+ final String uid,
+ final boolean seen) {
+ try {
+ Store localStore = Store.getInstance(account.getLocalStoreUri(), mApplication);
+ Folder localFolder = localStore.getFolder(folder);
+ localFolder.open(OpenMode.READ_WRITE);
+
+ Message message = localFolder.getMessage(uid);
+ message.setFlag(Flag.SEEN, seen);
+ PendingCommand command = new PendingCommand();
+ command.command = PENDING_COMMAND_MARK_READ;
+ command.arguments = new String[] { folder, uid, Boolean.toString(seen) };
+ queuePendingCommand(account, command);
+ processPendingCommands(account);
+ }
+ catch (MessagingException me) {
+ throw new RuntimeException(me);
+ }
+ }
+
+ private void loadMessageForViewRemote(final Account account, final String folder,
+ final String uid, MessagingListener listener) {
+ put("loadMessageForViewRemote", listener, new Runnable() {
+ public void run() {
+ try {
+ Store localStore = Store.getInstance(account.getLocalStoreUri(), mApplication);
+ LocalFolder localFolder = (LocalFolder) localStore.getFolder(folder);
+ localFolder.open(OpenMode.READ_WRITE);
+
+ Message message = localFolder.getMessage(uid);
+
+ if (message.isSet(Flag.X_DOWNLOADED_FULL)) {
+ /*
+ * If the message has been synchronized since we were called we'll
+ * just hand it back cause it's ready to go.
+ */
+ FetchProfile fp = new FetchProfile();
+ fp.add(FetchProfile.Item.ENVELOPE);
+ fp.add(FetchProfile.Item.BODY);
+ localFolder.fetch(new Message[] { message }, fp, null);
+
+ for (MessagingListener l : mListeners) {
+ l.loadMessageForViewBodyAvailable(account, folder, uid, message);
+ }
+ for (MessagingListener l : mListeners) {
+ l.loadMessageForViewFinished(account, folder, uid, message);
+ }
+ localFolder.close(false);
+ return;
+ }
+
+ /*
+ * At this point the message is not available, so we need to download it
+ * fully if possible.
+ */
+
+ Store remoteStore = Store.getInstance(account.getStoreUri(), mApplication);
+ Folder remoteFolder = remoteStore.getFolder(folder);
+ remoteFolder.open(OpenMode.READ_WRITE);
+
+ // Get the remote message and fully download it
+ Message remoteMessage = remoteFolder.getMessage(uid);
+ FetchProfile fp = new FetchProfile();
+ fp.add(FetchProfile.Item.BODY);
+ remoteFolder.fetch(new Message[] { remoteMessage }, fp, null);
+
+ // Store the message locally and load the stored message into memory
+ localFolder.appendMessages(new Message[] { remoteMessage });
+ message = localFolder.getMessage(uid);
+ localFolder.fetch(new Message[] { message }, fp, null);
+
+ // This is a view message request, so mark it read
+ if (!message.isSet(Flag.SEEN)) {
+ markMessageRead(account, folder, uid, true);
+ }
+
+ // Mark that this message is now fully synched
+ message.setFlag(Flag.X_DOWNLOADED_FULL, true);
+
+ for (MessagingListener l : mListeners) {
+ l.loadMessageForViewBodyAvailable(account, folder, uid, message);
+ }
+ for (MessagingListener l : mListeners) {
+ l.loadMessageForViewFinished(account, folder, uid, message);
+ }
+ remoteFolder.close(false);
+ localFolder.close(false);
+ }
+ catch (Exception e) {
+ for (MessagingListener l : mListeners) {
+ l.loadMessageForViewFailed(account, folder, uid, e.getMessage());
+ }
+ }
+ }
+ });
+ }
+
+ public void loadMessageForView(final Account account, final String folder, final String uid,
+ MessagingListener listener) {
+ for (MessagingListener l : mListeners) {
+ l.loadMessageForViewStarted(account, folder, uid);
+ }
+ try {
+ Store localStore = Store.getInstance(account.getLocalStoreUri(), mApplication);
+ LocalFolder localFolder = (LocalFolder) localStore.getFolder(folder);
+ localFolder.open(OpenMode.READ_WRITE);
+
+ Message message = localFolder.getMessage(uid);
+
+ for (MessagingListener l : mListeners) {
+ l.loadMessageForViewHeadersAvailable(account, folder, uid, message);
+ }
+
+ if (!message.isSet(Flag.X_DOWNLOADED_FULL)) {
+ loadMessageForViewRemote(account, folder, uid, listener);
+ localFolder.close(false);
+ return;
+ }
+
+ if (!message.isSet(Flag.SEEN)) {
+ markMessageRead(account, folder, uid, true);
+ }
+
+ FetchProfile fp = new FetchProfile();
+ fp.add(FetchProfile.Item.ENVELOPE);
+ fp.add(FetchProfile.Item.BODY);
+ localFolder.fetch(new Message[] {
+ message
+ }, fp, null);
+
+ for (MessagingListener l : mListeners) {
+ l.loadMessageForViewBodyAvailable(account, folder, uid, message);
+ }
+
+ for (MessagingListener l : mListeners) {
+ l.loadMessageForViewFinished(account, folder, uid, message);
+ }
+ localFolder.close(false);
+ }
+ catch (Exception e) {
+ for (MessagingListener l : mListeners) {
+ l.loadMessageForViewFailed(account, folder, uid, e.getMessage());
+ }
+ }
+ }
+
+ /**
+ * Attempts to load the attachment specified by part from the given account and message.
+ * @param account
+ * @param message
+ * @param part
+ * @param listener
+ */
+ public void loadAttachment(
+ final Account account,
+ final Message message,
+ final Part part,
+ final Object tag,
+ MessagingListener listener) {
+ /*
+ * Check if the attachment has already been downloaded. If it has there's no reason to
+ * download it, so we just tell the listener that it's ready to go.
+ */
+ try {
+ if (part.getBody() != null) {
+ for (MessagingListener l : mListeners) {
+ l.loadAttachmentStarted(account, message, part, tag, false);
+ }
+
+ for (MessagingListener l : mListeners) {
+ l.loadAttachmentFinished(account, message, part, tag);
+ }
+ return;
+ }
+ }
+ catch (MessagingException me) {
+ /*
+ * If the header isn't there the attachment isn't downloaded yet, so just continue
+ * on.
+ */
+ }
+
+ for (MessagingListener l : mListeners) {
+ l.loadAttachmentStarted(account, message, part, tag, true);
+ }
+
+ put("loadAttachment", listener, new Runnable() {
+ public void run() {
+ try {
+ LocalStore localStore =
+ (LocalStore) Store.getInstance(account.getLocalStoreUri(), mApplication);
+ /*
+ * We clear out any attachments already cached in the entire store and then
+ * we update the passed in message to reflect that there are no cached
+ * attachments. This is in support of limiting the account to having one
+ * attachment downloaded at a time.
+ */
+ localStore.pruneCachedAttachments();
+ ArrayList viewables = new ArrayList();
+ ArrayList attachments = new ArrayList();
+ MimeUtility.collectParts(message, viewables, attachments);
+ for (Part attachment : attachments) {
+ attachment.setBody(null);
+ }
+ Store remoteStore = Store.getInstance(account.getStoreUri(), mApplication);
+ LocalFolder localFolder =
+ (LocalFolder) localStore.getFolder(message.getFolder().getName());
+ Folder remoteFolder = remoteStore.getFolder(message.getFolder().getName());
+ remoteFolder.open(OpenMode.READ_WRITE);
+
+ FetchProfile fp = new FetchProfile();
+ fp.add(part);
+ remoteFolder.fetch(new Message[] { message }, fp, null);
+ localFolder.updateMessage((LocalMessage)message);
+ localFolder.close(false);
+ for (MessagingListener l : mListeners) {
+ l.loadAttachmentFinished(account, message, part, tag);
+ }
+ }
+ catch (MessagingException me) {
+ if (Config.LOGV) {
+ Log.v(Email.LOG_TAG, "", me);
+ }
+ for (MessagingListener l : mListeners) {
+ l.loadAttachmentFailed(account, message, part, tag, me.getMessage());
+ }
+ }
+ }
+ });
+ }
+
+ /**
+ * Stores the given message in the Outbox and starts a sendPendingMessages command to
+ * attempt to send the message.
+ * @param account
+ * @param message
+ * @param listener
+ */
+ public void sendMessage(final Account account,
+ final Message message,
+ MessagingListener listener) {
+ try {
+ Store localStore = Store.getInstance(account.getLocalStoreUri(), mApplication);
+ LocalFolder localFolder =
+ (LocalFolder) localStore.getFolder(account.getOutboxFolderName());
+ localFolder.open(OpenMode.READ_WRITE);
+ localFolder.appendMessages(new Message[] {
+ message
+ });
+ Message localMessage = localFolder.getMessage(message.getUid());
+ localMessage.setFlag(Flag.X_DOWNLOADED_FULL, true);
+ localFolder.close(false);
+ sendPendingMessages(account, null);
+ }
+ catch (Exception e) {
+ for (MessagingListener l : mListeners) {
+ // TODO general failed
+ }
+ }
+ }
+
+ /**
+ * Attempt to send any messages that are sitting in the Outbox.
+ * @param account
+ * @param listener
+ */
+ public void sendPendingMessages(final Account account,
+ MessagingListener listener) {
+ put("sendPendingMessages", listener, new Runnable() {
+ public void run() {
+ sendPendingMessagesSynchronous(account);
+ }
+ });
+ }
+
+ /**
+ * Attempt to send any messages that are sitting in the Outbox.
+ * @param account
+ * @param listener
+ */
+ public void sendPendingMessagesSynchronous(final Account account) {
+ try {
+ Store localStore = Store.getInstance(
+ account.getLocalStoreUri(),
+ mApplication);
+ Folder localFolder = localStore.getFolder(
+ account.getOutboxFolderName());
+ if (!localFolder.exists()) {
+ return;
+ }
+ localFolder.open(OpenMode.READ_WRITE);
+
+ Message[] localMessages = localFolder.getMessages(null);
+
+ /*
+ * The profile we will use to pull all of the content
+ * for a given local message into memory for sending.
+ */
+ FetchProfile fp = new FetchProfile();
+ fp.add(FetchProfile.Item.ENVELOPE);
+ fp.add(FetchProfile.Item.BODY);
+
+ LocalFolder localSentFolder =
+ (LocalFolder) localStore.getFolder(
+ account.getSentFolderName());
+
+ Transport transport = Transport.getInstance(account.getTransportUri());
+ for (Message message : localMessages) {
+ try {
+ localFolder.fetch(new Message[] { message }, fp, null);
+ try {
+ message.setFlag(Flag.X_SEND_IN_PROGRESS, true);
+ transport.sendMessage(message);
+ message.setFlag(Flag.X_SEND_IN_PROGRESS, false);
+ localFolder.copyMessages(
+ new Message[] { message },
+ localSentFolder);
+
+ PendingCommand command = new PendingCommand();
+ command.command = PENDING_COMMAND_APPEND;
+ command.arguments =
+ new String[] {
+ localSentFolder.getName(),
+ message.getUid() };
+ queuePendingCommand(account, command);
+ processPendingCommands(account);
+ message.setFlag(Flag.X_DESTROYED, true);
+ }
+ catch (Exception e) {
+ message.setFlag(Flag.X_SEND_FAILED, true);
+ }
+ }
+ catch (Exception e) {
+ /*
+ * We ignore this exception because a future refresh will retry this
+ * message.
+ */
+ }
+ }
+ localFolder.expunge();
+ if (localFolder.getMessageCount() == 0) {
+ localFolder.delete(false);
+ }
+ for (MessagingListener l : mListeners) {
+ l.sendPendingMessagesCompleted(account);
+ }
+ }
+ catch (Exception e) {
+ for (MessagingListener l : mListeners) {
+ // TODO general failed
+ }
+ }
+ }
+
+ /**
+ * We do the local portion of this synchronously because other activities may have to make
+ * updates based on what happens here
+ * @param account
+ * @param folder
+ * @param message
+ * @param listener
+ */
+ public void deleteMessage(final Account account, final String folder, final Message message,
+ MessagingListener listener) {
+ if (folder.equals(account.getTrashFolderName())) {
+ return;
+ }
+ try {
+ Store localStore = Store.getInstance(account.getLocalStoreUri(), mApplication);
+ Folder localFolder = localStore.getFolder(folder);
+ Folder localTrashFolder = localStore.getFolder(account.getTrashFolderName());
+
+ localFolder.copyMessages(new Message[] { message }, localTrashFolder);
+ message.setFlag(Flag.DELETED, true);
+
+ if (account.getDeletePolicy() == Account.DELETE_POLICY_ON_DELETE) {
+ PendingCommand command = new PendingCommand();
+ command.command = PENDING_COMMAND_TRASH;
+ command.arguments = new String[] { folder, message.getUid() };
+ queuePendingCommand(account, command);
+ processPendingCommands(account);
+ }
+ }
+ catch (MessagingException me) {
+ throw new RuntimeException("Error deleting message from local store.", me);
+ }
+ }
+
+ public void emptyTrash(final Account account, MessagingListener listener) {
+ put("emptyTrash", listener, new Runnable() {
+ public void run() {
+ // TODO IMAP
+ try {
+ Store localStore = Store.getInstance(account.getLocalStoreUri(), mApplication);
+ Folder localFolder = localStore.getFolder(account.getTrashFolderName());
+ localFolder.open(OpenMode.READ_WRITE);
+ Message[] messages = localFolder.getMessages(null);
+ localFolder.setFlags(messages, new Flag[] {
+ Flag.DELETED
+ }, true);
+ localFolder.close(true);
+ for (MessagingListener l : mListeners) {
+ l.emptyTrashCompleted(account);
+ }
+ }
+ catch (Exception e) {
+ // TODO
+ if (Config.LOGV) {
+ Log.v(Email.LOG_TAG, "emptyTrash");
+ }
+ }
+ }
+ });
+ }
+
+ /**
+ * Checks mail for one or multiple accounts. If account is null all accounts
+ * are checked.
+ *
+ * @param context
+ * @param account
+ * @param listener
+ */
+ public void checkMail(final Context context, final Account account,
+ final MessagingListener listener) {
+ for (MessagingListener l : mListeners) {
+ l.checkMailStarted(context, account);
+ }
+ put("checkMail", listener, new Runnable() {
+ public void run() {
+ Account[] accounts;
+ if (account != null) {
+ accounts = new Account[] {
+ account
+ };
+ } else {
+ accounts = Preferences.getPreferences(context).getAccounts();
+ }
+ for (Account account : accounts) {
+ //We do the math in seconds and not millis
+ //since timers are not that accurate
+ long now = (long)Math.floor(System.currentTimeMillis() / 1000);
+ long autoCheckIntervalTime = account.getAutomaticCheckIntervalMinutes() * 60;
+ long lastAutoCheckTime = (long)Math.ceil(account.getLastAutomaticCheckTime() / 1000);
+ if (autoCheckIntervalTime>0
+ && (now-lastAutoCheckTime)>autoCheckIntervalTime) {
+ sendPendingMessagesSynchronous(account);
+ synchronizeMailboxSynchronous(account, Email.INBOX);
+ //This saves the last auto check time even if sync fails
+ //TODO: Listen for both send and sync success and failures
+ //and only save last auto check time is not errors
+ account.setLastAutomaticCheckTime(now*1000);
+ account.save(Preferences.getPreferences(context));
+ }
+ }
+ for (MessagingListener l : mListeners) {
+ l.checkMailFinished(context, account);
+ }
+ }
+ });
+ }
+
+ public void saveDraft(final Account account, final Message message) {
+ try {
+ Store localStore = Store.getInstance(account.getLocalStoreUri(), mApplication);
+ LocalFolder localFolder =
+ (LocalFolder) localStore.getFolder(account.getDraftsFolderName());
+ localFolder.open(OpenMode.READ_WRITE);
+ localFolder.appendMessages(new Message[] {
+ message
+ });
+ Message localMessage = localFolder.getMessage(message.getUid());
+ localMessage.setFlag(Flag.X_DOWNLOADED_FULL, true);
+
+ PendingCommand command = new PendingCommand();
+ command.command = PENDING_COMMAND_APPEND;
+ command.arguments = new String[] {
+ localFolder.getName(),
+ localMessage.getUid() };
+ queuePendingCommand(account, command);
+ processPendingCommands(account);
+ }
+ catch (MessagingException e) {
+ Log.e(Email.LOG_TAG, "Unable to save message as draft.", e);
+ }
+ }
+
+ class Command {
+ public Runnable runnable;
+
+ public MessagingListener listener;
+
+ public String description;
+ }
+}
diff --git a/src/com/android/email/MessagingListener.java b/src/com/android/email/MessagingListener.java
new file mode 100644
index 000000000..f367c6e0b
--- /dev/null
+++ b/src/com/android/email/MessagingListener.java
@@ -0,0 +1,132 @@
+
+package com.android.email;
+
+import android.content.Context;
+
+import com.android.email.mail.Folder;
+import com.android.email.mail.Message;
+import com.android.email.mail.Part;
+
+/**
+ * Defines the interface that MessagingController will use to callback to requesters. This class
+ * is defined as non-abstract so that someone who wants to receive only a few messages can
+ * do so without implementing the entire interface. It is highly recommended that users of
+ * this interface use the @Override annotation in their implementations to avoid being caught by
+ * changes in this class.
+ */
+public class MessagingListener {
+ public void listFoldersStarted(Account account) {
+ }
+
+ public void listFolders(Account account, Folder[] folders) {
+ }
+
+ public void listFoldersFailed(Account account, String message) {
+ }
+
+ public void listFoldersFinished(Account account) {
+ }
+
+ public void listLocalMessagesStarted(Account account, String folder) {
+ }
+
+ public void listLocalMessages(Account account, String folder, Message[] messages) {
+ }
+
+ public void listLocalMessagesFailed(Account account, String folder, String message) {
+ }
+
+ public void listLocalMessagesFinished(Account account, String folder) {
+ }
+
+ public void synchronizeMailboxStarted(Account account, String folder) {
+ }
+
+ public void synchronizeMailboxNewMessage(Account account, String folder, Message message) {
+ }
+
+ public void synchronizeMailboxRemovedMessage(Account account, String folder,Message message) {
+ }
+
+ public void synchronizeMailboxFinished(Account account, String folder,
+ int totalMessagesInMailbox, int numNewMessages) {
+ }
+
+ public void synchronizeMailboxFailed(Account account, String folder,
+ String message) {
+ }
+
+ public void loadMessageForViewStarted(Account account, String folder, String uid) {
+ }
+
+ public void loadMessageForViewHeadersAvailable(Account account, String folder, String uid,
+ Message message) {
+ }
+
+ public void loadMessageForViewBodyAvailable(Account account, String folder, String uid,
+ Message message) {
+ }
+
+ public void loadMessageForViewFinished(Account account, String folder, String uid,
+ Message message) {
+ }
+
+ public void loadMessageForViewFailed(Account account, String folder, String uid, String message) {
+ }
+
+ public void checkMailStarted(Context context, Account account) {
+ }
+
+ public void checkMailFinished(Context context, Account account) {
+ }
+
+ public void checkMailFailed(Context context, Account account, String reason) {
+ }
+
+ public void sendPendingMessagesCompleted(Account account) {
+ }
+
+ public void emptyTrashCompleted(Account account) {
+ }
+
+ public void messageUidChanged(Account account, String folder, String oldUid, String newUid) {
+
+ }
+
+ public void loadAttachmentStarted(
+ Account account,
+ Message message,
+ Part part,
+ Object tag,
+ boolean requiresDownload)
+ {
+ }
+
+ public void loadAttachmentFinished(
+ Account account,
+ Message message,
+ Part part,
+ Object tag)
+ {
+ }
+
+ public void loadAttachmentFailed(
+ Account account,
+ Message message,
+ Part part,
+ Object tag,
+ String reason)
+ {
+ }
+
+ /**
+ * General notification messages subclasses can override to be notified that the controller
+ * has completed a command. This is useful for turning off progress indicators that may have
+ * been left over from previous commands.
+ * @param moreCommandsToRun True if the controller will continue on to another command
+ * immediately.
+ */
+ public void controllerCommandCompleted(boolean moreCommandsToRun) {
+
+ }
+}
diff --git a/src/com/android/email/PeekableInputStream.java b/src/com/android/email/PeekableInputStream.java
new file mode 100644
index 000000000..1ee269f21
--- /dev/null
+++ b/src/com/android/email/PeekableInputStream.java
@@ -0,0 +1,64 @@
+
+package com.android.email;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * A filtering InputStream that allows single byte "peeks" without consuming the byte. The
+ * client of this stream can call peek() to see the next available byte in the stream
+ * and a subsequent read will still return the peeked byte.
+ */
+public class PeekableInputStream extends InputStream {
+ private InputStream mIn;
+ private boolean mPeeked;
+ private int mPeekedByte;
+
+ public PeekableInputStream(InputStream in) {
+ this.mIn = in;
+ }
+
+ @Override
+ public int read() throws IOException {
+ if (!mPeeked) {
+ return mIn.read();
+ } else {
+ mPeeked = false;
+ return mPeekedByte;
+ }
+ }
+
+ public int peek() throws IOException {
+ if (!mPeeked) {
+ mPeekedByte = read();
+ mPeeked = true;
+ }
+ return mPeekedByte;
+ }
+
+ @Override
+ public int read(byte[] b, int offset, int length) throws IOException {
+ if (!mPeeked) {
+ return mIn.read(b, offset, length);
+ } else {
+ b[0] = (byte)mPeekedByte;
+ mPeeked = false;
+ int r = mIn.read(b, offset + 1, length - 1);
+ if (r == -1) {
+ return 1;
+ } else {
+ return r + 1;
+ }
+ }
+ }
+
+ @Override
+ public int read(byte[] b) throws IOException {
+ return read(b, 0, b.length);
+ }
+
+ public String toString() {
+ return String.format("PeekableInputStream(in=%s, peeked=%b, peekedByte=%d)",
+ mIn.toString(), mPeeked, mPeekedByte);
+ }
+}
diff --git a/src/com/android/email/Preferences.java b/src/com/android/email/Preferences.java
new file mode 100644
index 000000000..24fa17ee0
--- /dev/null
+++ b/src/com/android/email/Preferences.java
@@ -0,0 +1,123 @@
+
+package com.android.email;
+
+import java.util.Arrays;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.util.Config;
+import android.util.Log;
+
+public class Preferences {
+ private static Preferences preferences;
+
+ SharedPreferences mSharedPreferences;
+
+ private Preferences(Context context) {
+ mSharedPreferences = context.getSharedPreferences("AndroidMail.Main", Context.MODE_PRIVATE);
+ }
+
+ /**
+ * TODO need to think about what happens if this gets GCed along with the
+ * Activity that initialized it. Do we lose ability to read Preferences in
+ * further Activities? Maybe this should be stored in the Application
+ * context.
+ *
+ * @return
+ */
+ public static synchronized Preferences getPreferences(Context context) {
+ if (preferences == null) {
+ preferences = new Preferences(context);
+ }
+ return preferences;
+ }
+
+ /**
+ * Returns an array of the accounts on the system. If no accounts are
+ * registered the method returns an empty array.
+ *
+ * @return
+ */
+ public Account[] getAccounts() {
+ String accountUuids = mSharedPreferences.getString("accountUuids", null);
+ if (accountUuids == null || accountUuids.length() == 0) {
+ return new Account[] {};
+ }
+ String[] uuids = accountUuids.split(",");
+ Account[] accounts = new Account[uuids.length];
+ for (int i = 0, length = uuids.length; i < length; i++) {
+ accounts[i] = new Account(this, uuids[i]);
+ }
+ return accounts;
+ }
+
+ public Account getAccountByContentUri(Uri uri) {
+ return new Account(this, uri.getPath().substring(1));
+ }
+
+ /**
+ * Returns the Account marked as default. If no account is marked as default
+ * the first account in the list is marked as default and then returned. If
+ * there are no accounts on the system the method returns null.
+ *
+ * @return
+ */
+ public Account getDefaultAccount() {
+ String defaultAccountUuid = mSharedPreferences.getString("defaultAccountUuid", null);
+ Account defaultAccount = null;
+ Account[] accounts = getAccounts();
+ if (defaultAccountUuid != null) {
+ for (Account account : accounts) {
+ if (account.getUuid().equals(defaultAccountUuid)) {
+ defaultAccount = account;
+ break;
+ }
+ }
+ }
+
+ if (defaultAccount == null) {
+ if (accounts.length > 0) {
+ defaultAccount = accounts[0];
+ setDefaultAccount(defaultAccount);
+ }
+ }
+
+ return defaultAccount;
+ }
+
+ public void setDefaultAccount(Account account) {
+ mSharedPreferences.edit().putString("defaultAccountUuid", account.getUuid()).commit();
+ }
+
+ public void setEnableDebugLogging(boolean value) {
+ mSharedPreferences.edit().putBoolean("enableDebugLogging", value).commit();
+ }
+
+ public boolean geteEnableDebugLogging() {
+ return mSharedPreferences.getBoolean("enableDebugLogging", false);
+ }
+
+ public void setEnableSensitiveLogging(boolean value) {
+ mSharedPreferences.edit().putBoolean("enableSensitiveLogging", value).commit();
+ }
+
+ public boolean getEnableSensitiveLogging() {
+ return mSharedPreferences.getBoolean("enableSensitiveLogging", false);
+ }
+
+ public void save() {
+ }
+
+ public void clear() {
+ mSharedPreferences.edit().clear().commit();
+ }
+
+ public void dump() {
+ if (Config.LOGV) {
+ for (String key : mSharedPreferences.getAll().keySet()) {
+ Log.v(Email.LOG_TAG, key + " = " + mSharedPreferences.getAll().get(key));
+ }
+ }
+ }
+}
diff --git a/src/com/fsck/k9/Utility.java b/src/com/android/email/Utility.java
similarity index 98%
rename from src/com/fsck/k9/Utility.java
rename to src/com/android/email/Utility.java
index 70ca3ac37..3b5302e81 100644
--- a/src/com/fsck/k9/Utility.java
+++ b/src/com/android/email/Utility.java
@@ -1,5 +1,5 @@
-package com.fsck.k9;
+package com.android.email;
import java.io.IOException;
import java.io.InputStream;
@@ -7,7 +7,7 @@ import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.util.Date;
-import com.fsck.k9.codec.binary.Base64;
+import com.android.email.codec.binary.Base64;
import android.text.Editable;
import android.widget.EditText;
diff --git a/src/com/fsck/k9/activity/Accounts.java b/src/com/android/email/activity/Accounts.java
similarity index 94%
rename from src/com/fsck/k9/activity/Accounts.java
rename to src/com/android/email/activity/Accounts.java
index 4411ab628..5701c6c6e 100644
--- a/src/com/fsck/k9/activity/Accounts.java
+++ b/src/com/android/email/activity/Accounts.java
@@ -1,5 +1,5 @@
-package com.fsck.k9.activity;
+package com.android.email.activity;
import android.app.AlertDialog;
import android.app.Dialog;
@@ -29,18 +29,18 @@ import android.widget.TextView;
import android.widget.AdapterView.AdapterContextMenuInfo;
import android.widget.AdapterView.OnItemClickListener;
-import com.fsck.k9.Account;
-import com.fsck.k9.k9;
-import com.fsck.k9.MessagingController;
-import com.fsck.k9.Preferences;
-import com.fsck.k9.R;
-import com.fsck.k9.activity.setup.AccountSettings;
-import com.fsck.k9.activity.setup.AccountSetupBasics;
-import com.fsck.k9.activity.setup.AccountSetupCheckSettings;
-import com.fsck.k9.mail.MessagingException;
-import com.fsck.k9.mail.Store;
-import com.fsck.k9.mail.store.LocalStore;
-import com.fsck.k9.mail.store.LocalStore.LocalFolder;
+import com.android.email.Account;
+import com.android.email.Email;
+import com.android.email.MessagingController;
+import com.android.email.Preferences;
+import com.android.email.R;
+import com.android.email.activity.setup.AccountSettings;
+import com.android.email.activity.setup.AccountSetupBasics;
+import com.android.email.activity.setup.AccountSetupCheckSettings;
+import com.android.email.mail.MessagingException;
+import com.android.email.mail.Store;
+import com.android.email.mail.store.LocalStore;
+import com.android.email.mail.store.LocalStore.LocalFolder;
public class Accounts extends ListActivity implements OnItemClickListener, OnClickListener {
private static final int DIALOG_REMOVE_ACCOUNT = 1;
@@ -158,7 +158,7 @@ public class Accounts extends ListActivity implements OnItemClickListener, OnCli
// Ignore
}
mSelectedContextAccount.delete(Preferences.getPreferences(Accounts.this));
- k9.setServicesEnabled(Accounts.this);
+ Email.setServicesEnabled(Accounts.this);
refresh();
}
})
@@ -320,7 +320,7 @@ getPackageManager().getPackageInfo(getPackageName(), 0);
LocalStore localStore = (LocalStore) Store.getInstance(
account.getLocalStoreUri(),
getApplication());
- LocalFolder localFolder = (LocalFolder) localStore.getFolder(k9.INBOX);
+ LocalFolder localFolder = (LocalFolder) localStore.getFolder(Email.INBOX);
if (localFolder.exists()) {
unreadMessageCount = localFolder.getUnreadMessageCount();
}
diff --git a/src/com/android/email/activity/Debug.java b/src/com/android/email/activity/Debug.java
new file mode 100644
index 000000000..555b64953
--- /dev/null
+++ b/src/com/android/email/activity/Debug.java
@@ -0,0 +1,73 @@
+
+package com.android.email.activity;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.widget.CheckBox;
+import android.widget.CompoundButton;
+import android.widget.TextView;
+import android.widget.CompoundButton.OnCheckedChangeListener;
+
+import com.android.email.Email;
+import com.android.email.Preferences;
+import com.android.email.R;
+
+public class Debug extends Activity implements OnCheckedChangeListener {
+ private TextView mVersionView;
+ private CheckBox mEnableDebugLoggingView;
+ private CheckBox mEnableSensitiveLoggingView;
+
+ private Preferences mPreferences;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.debug);
+
+ mPreferences = Preferences.getPreferences(this);
+
+ mVersionView = (TextView)findViewById(R.id.version);
+ mEnableDebugLoggingView = (CheckBox)findViewById(R.id.debug_logging);
+ mEnableSensitiveLoggingView = (CheckBox)findViewById(R.id.sensitive_logging);
+
+ mEnableDebugLoggingView.setOnCheckedChangeListener(this);
+ mEnableSensitiveLoggingView.setOnCheckedChangeListener(this);
+
+ mVersionView.setText(String.format(getString(R.string.debug_version_fmt).toString(),
+ getString(R.string.build_number)));
+
+ mEnableDebugLoggingView.setChecked(Email.DEBUG);
+ mEnableSensitiveLoggingView.setChecked(Email.DEBUG_SENSITIVE);
+ }
+
+ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
+ if (buttonView.getId() == R.id.debug_logging) {
+ Email.DEBUG = isChecked;
+ mPreferences.setEnableDebugLogging(Email.DEBUG);
+ } else if (buttonView.getId() == R.id.sensitive_logging) {
+ Email.DEBUG_SENSITIVE = isChecked;
+ mPreferences.setEnableSensitiveLogging(Email.DEBUG_SENSITIVE);
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ int id = item.getItemId();
+ if (id == R.id.dump_settings) {
+ Preferences.getPreferences(this).dump();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ getMenuInflater().inflate(R.menu.debug_option, menu);
+ return true;
+ }
+
+}
diff --git a/src/com/android/email/activity/FolderMessageList.java b/src/com/android/email/activity/FolderMessageList.java
new file mode 100644
index 000000000..0c76be204
--- /dev/null
+++ b/src/com/android/email/activity/FolderMessageList.java
@@ -0,0 +1,1308 @@
+package com.android.email.activity;
+
+import java.text.DateFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+
+import android.app.ExpandableListActivity;
+import android.app.NotificationManager;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Typeface;
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Process;
+import android.util.Config;
+import android.util.Log;
+import android.view.ContextMenu;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.ContextMenu.ContextMenuInfo;
+import android.widget.BaseExpandableListAdapter;
+import android.widget.ExpandableListView;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+import android.widget.Toast;
+import android.widget.ExpandableListView.ExpandableListContextMenuInfo;
+
+import com.android.email.Account;
+import com.android.email.Email;
+import com.android.email.MessagingController;
+import com.android.email.MessagingListener;
+import com.android.email.R;
+import com.android.email.Utility;
+import com.android.email.Preferences;
+import com.android.email.activity.FolderMessageList.FolderMessageListAdapter.FolderInfoHolder;
+import com.android.email.activity.FolderMessageList.FolderMessageListAdapter.MessageInfoHolder;
+import com.android.email.activity.setup.AccountSettings;
+import com.android.email.mail.Address;
+import com.android.email.mail.Flag;
+import com.android.email.mail.Folder;
+import com.android.email.mail.Message;
+import com.android.email.mail.MessagingException;
+import com.android.email.mail.Message.RecipientType;
+import com.android.email.mail.store.LocalStore.LocalMessage;
+import com.android.email.mail.store.LocalStore;
+
+/**
+ * FolderMessageList is the primary user interface for the program. This Activity shows
+ * a two level list of the Account's folders and each folder's messages. From this
+ * Activity the user can perform all standard message operations.
+ *
+ *
+ * TODO some things that are slowing us down:
+ * Need a way to remove state such as progress bar and per folder progress on
+ * resume if the command has completed.
+ *
+ * TODO
+ * Break out seperate functions for:
+ * refresh local folders
+ * refresh remote folders
+ * refresh open folder local messages
+ * refresh open folder remote messages
+ *
+ * And don't refresh remote folders ever unless the user runs a refresh. Maybe not even then.
+ */
+public class FolderMessageList extends ExpandableListActivity {
+ private static final String EXTRA_ACCOUNT = "account";
+ private static final String EXTRA_CLEAR_NOTIFICATION = "clearNotification";
+ private static final String EXTRA_INITIAL_FOLDER = "initialFolder";
+
+ private static final String STATE_KEY_LIST =
+ "com.android.email.activity.folderlist_expandableListState";
+ private static final String STATE_KEY_EXPANDED_GROUP =
+ "com.android.email.activity.folderlist_expandedGroup";
+ private static final String STATE_KEY_EXPANDED_GROUP_SELECTION =
+ "com.android.email.activity.folderlist_expandedGroupSelection";
+
+ private static final int UPDATE_FOLDER_ON_EXPAND_INTERVAL_MS = (1000 * 60 * 3);
+
+ private static final int[] colorChipResIds = new int[] {
+ R.drawable.appointment_indicator_leftside_1,
+ R.drawable.appointment_indicator_leftside_2,
+ R.drawable.appointment_indicator_leftside_3,
+ R.drawable.appointment_indicator_leftside_4,
+ R.drawable.appointment_indicator_leftside_5,
+ R.drawable.appointment_indicator_leftside_6,
+ R.drawable.appointment_indicator_leftside_7,
+ R.drawable.appointment_indicator_leftside_8,
+ R.drawable.appointment_indicator_leftside_9,
+ R.drawable.appointment_indicator_leftside_10,
+ R.drawable.appointment_indicator_leftside_11,
+ R.drawable.appointment_indicator_leftside_12,
+ R.drawable.appointment_indicator_leftside_13,
+ R.drawable.appointment_indicator_leftside_14,
+ R.drawable.appointment_indicator_leftside_15,
+ R.drawable.appointment_indicator_leftside_16,
+ R.drawable.appointment_indicator_leftside_17,
+ R.drawable.appointment_indicator_leftside_18,
+ R.drawable.appointment_indicator_leftside_19,
+ R.drawable.appointment_indicator_leftside_20,
+ R.drawable.appointment_indicator_leftside_21,
+ };
+
+ private ExpandableListView mListView;
+ private int colorChipResId;
+
+ private FolderMessageListAdapter mAdapter;
+ private LayoutInflater mInflater;
+ private Account mAccount;
+ /**
+ * Stores the name of the folder that we want to open as soon as possible after load. It is
+ * set to null once the folder has been opened once.
+ */
+ private String mInitialFolder;
+
+ private DateFormat mDateFormat = DateFormat.getDateInstance(DateFormat.SHORT);
+ private DateFormat mTimeFormat = DateFormat.getTimeInstance(DateFormat.SHORT);
+
+ private int mExpandedGroup = -1;
+ private boolean mRestoringState;
+
+ private boolean mRefreshRemote;
+
+ private FolderMessageListHandler mHandler = new FolderMessageListHandler();
+
+ class FolderMessageListHandler extends Handler {
+ private static final int MSG_PROGRESS = 2;
+ private static final int MSG_DATA_CHANGED = 3;
+ private static final int MSG_EXPAND_GROUP = 5;
+ private static final int MSG_FOLDER_LOADING = 7;
+ private static final int MSG_REMOVE_MESSAGE = 11;
+ private static final int MSG_SYNC_MESSAGES = 13;
+ private static final int MSG_FOLDER_STATUS = 17;
+
+ @Override
+ public void handleMessage(android.os.Message msg) {
+ switch (msg.what) {
+ case MSG_PROGRESS:
+ setProgressBarIndeterminateVisibility(msg.arg1 != 0);
+ break;
+ case MSG_DATA_CHANGED:
+ mAdapter.notifyDataSetChanged();
+ break;
+ case MSG_EXPAND_GROUP:
+ mListView.expandGroup(msg.arg1);
+ break;
+ /*
+ * The following functions modify the state of the adapter's underlying list and
+ * must be run here, in the main thread, so that notifyDataSetChanged is run
+ * before any further requests are made to the adapter.
+ */
+ case MSG_FOLDER_LOADING: {
+ FolderInfoHolder folder = mAdapter.getFolder((String) msg.obj);
+ if (folder != null) {
+ folder.loading = msg.arg1 != 0;
+ mAdapter.notifyDataSetChanged();
+ }
+ break;
+ }
+ case MSG_REMOVE_MESSAGE: {
+ FolderInfoHolder folder = (FolderInfoHolder) ((Object[]) msg.obj)[0];
+ MessageInfoHolder message = (MessageInfoHolder) ((Object[]) msg.obj)[1];
+ folder.messages.remove(message);
+ mAdapter.notifyDataSetChanged();
+ break;
+ }
+ case MSG_SYNC_MESSAGES: {
+ FolderInfoHolder folder = (FolderInfoHolder) ((Object[]) msg.obj)[0];
+ Message[] messages = (Message[]) ((Object[]) msg.obj)[1];
+ folder.messages.clear();
+ for (Message message : messages) {
+ mAdapter.addOrUpdateMessage(folder, message, false, false);
+ }
+ Collections.sort(folder.messages);
+ mAdapter.notifyDataSetChanged();
+ break;
+ }
+ case MSG_FOLDER_STATUS: {
+ String folderName = (String) ((Object[]) msg.obj)[0];
+ String status = (String) ((Object[]) msg.obj)[1];
+ FolderInfoHolder folder = mAdapter.getFolder(folderName);
+ if (folder != null) {
+ folder.status = status;
+ mAdapter.notifyDataSetChanged();
+ }
+ break;
+ }
+ default:
+ super.handleMessage(msg);
+ }
+ }
+
+ public void synchronizeMessages(FolderInfoHolder folder, Message[] messages) {
+ android.os.Message msg = new android.os.Message();
+ msg.what = MSG_SYNC_MESSAGES;
+ msg.obj = new Object[] { folder, messages };
+ sendMessage(msg);
+ }
+
+ public void removeMessage(FolderInfoHolder folder, MessageInfoHolder message) {
+ android.os.Message msg = new android.os.Message();
+ msg.what = MSG_REMOVE_MESSAGE;
+ msg.obj = new Object[] { folder, message };
+ sendMessage(msg);
+ }
+
+ public void folderLoading(String folder, boolean loading) {
+ android.os.Message msg = new android.os.Message();
+ msg.what = MSG_FOLDER_LOADING;
+ msg.arg1 = loading ? 1 : 0;
+ msg.obj = folder;
+ sendMessage(msg);
+ }
+
+ public void progress(boolean progress) {
+ android.os.Message msg = new android.os.Message();
+ msg.what = MSG_PROGRESS;
+ msg.arg1 = progress ? 1 : 0;
+ sendMessage(msg);
+ }
+
+ public void dataChanged() {
+ sendEmptyMessage(MSG_DATA_CHANGED);
+ }
+
+ public void expandGroup(int groupPosition) {
+ android.os.Message msg = new android.os.Message();
+ msg.what = MSG_EXPAND_GROUP;
+ msg.arg1 = groupPosition;
+ sendMessage(msg);
+ }
+
+ public void folderStatus(String folder, String status) {
+ android.os.Message msg = new android.os.Message();
+ msg.what = MSG_FOLDER_STATUS;
+ msg.obj = new String[] { folder, status };
+ sendMessage(msg);
+ }
+ }
+
+ /**
+ * This class is responsible for reloading the list of local messages for a given folder,
+ * notifying the adapter that the message have been loaded and queueing up a remote
+ * update of the folder.
+ */
+ class FolderUpdateWorker implements Runnable {
+ String mFolder;
+ boolean mSynchronizeRemote;
+
+ /**
+ * Create a worker for the given folder and specifying whether the
+ * worker should synchronize the remote folder or just the local one.
+ * @param folder
+ * @param synchronizeRemote
+ */
+ public FolderUpdateWorker(String folder, boolean synchronizeRemote) {
+ mFolder = folder;
+ mSynchronizeRemote = synchronizeRemote;
+ }
+
+ public void run() {
+ // Lower our priority
+ Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
+ // Synchronously load the list of local messages
+ MessagingController.getInstance(getApplication()).listLocalMessages(
+ mAccount,
+ mFolder,
+ mAdapter.mListener);
+ if (mSynchronizeRemote) {
+ // Tell the MessagingController to run a remote update of this folder
+ // at it's leisure
+ MessagingController.getInstance(getApplication()).synchronizeMailbox(
+ mAccount,
+ mFolder,
+ mAdapter.mListener);
+ }
+ }
+ }
+
+ public static void actionHandleAccount(Context context, Account account, String initialFolder) {
+ Intent intent = new Intent(context, FolderMessageList.class);
+ intent.putExtra(EXTRA_ACCOUNT, account);
+ if (initialFolder != null) {
+ intent.putExtra(EXTRA_INITIAL_FOLDER, initialFolder);
+ }
+ context.startActivity(intent);
+ }
+
+ public static void actionHandleAccount(Context context, Account account) {
+ actionHandleAccount(context, account, null);
+ }
+
+ public static Intent actionHandleAccountIntent(Context context, Account account, String initialFolder) {
+ Intent intent = new Intent(context, FolderMessageList.class);
+ intent.putExtra(EXTRA_ACCOUNT, account);
+ intent.putExtra(EXTRA_CLEAR_NOTIFICATION, true);
+ if (initialFolder != null) {
+ intent.putExtra(EXTRA_INITIAL_FOLDER, initialFolder);
+ }
+ return intent;
+ }
+
+ public static Intent actionHandleAccountIntent(Context context, Account account) {
+ return actionHandleAccountIntent(context, account, null);
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
+
+ mListView = getExpandableListView();
+ mListView.setScrollBarStyle(View.SCROLLBARS_OUTSIDE_INSET);
+ mListView.setLongClickable(true);
+ registerForContextMenu(mListView);
+
+ /*
+ * We manually save and restore the list's state because our adapter is slow.
+ */
+ mListView.setSaveEnabled(false);
+
+ getExpandableListView().setGroupIndicator(
+ getResources().getDrawable(R.drawable.expander_ic_folder));
+
+ mInflater = getLayoutInflater();
+
+ Intent intent = getIntent();
+ mAccount = (Account)intent.getSerializableExtra(EXTRA_ACCOUNT);
+
+ // Take the initial folder into account only if we are *not* restoring the activity already
+ if (savedInstanceState == null) {
+ mInitialFolder = intent.getStringExtra(EXTRA_INITIAL_FOLDER);
+ }
+
+ /*
+ * Since the color chip is always the same color for a given account we just cache the id
+ * of the chip right here.
+ */
+ colorChipResId = colorChipResIds[mAccount.getAccountNumber() % colorChipResIds.length];
+
+ mAdapter = new FolderMessageListAdapter();
+
+ final Object previousData = getLastNonConfigurationInstance();
+ if (previousData != null) {
+ //noinspection unchecked
+ mAdapter.mFolders = (ArrayList) previousData;
+ }
+
+ setListAdapter(mAdapter);
+
+ if (savedInstanceState != null) {
+ mRestoringState = true;
+ onRestoreListState(savedInstanceState);
+ mRestoringState = false;
+ }
+
+ setTitle(mAccount.getDescription());
+ }
+
+ private void onRestoreListState(Bundle savedInstanceState) {
+ final int expandedGroup = savedInstanceState.getInt(STATE_KEY_EXPANDED_GROUP, -1);
+ if (expandedGroup >= 0 && mAdapter.getGroupCount() > expandedGroup) {
+ mListView.expandGroup(expandedGroup);
+ long selectedChild = savedInstanceState.getLong(STATE_KEY_EXPANDED_GROUP_SELECTION, -1);
+ if (selectedChild != ExpandableListView.PACKED_POSITION_VALUE_NULL) {
+ mListView.setSelection(mListView.getFlatListPosition(selectedChild));
+ }
+ }
+ mListView.onRestoreInstanceState(savedInstanceState.getParcelable(STATE_KEY_LIST));
+ }
+
+ @Override
+ public Object onRetainNonConfigurationInstance() {
+ return mAdapter.mFolders;
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ MessagingController.getInstance(getApplication()).removeListener(mAdapter.mListener);
+ }
+
+ /**
+ * On resume we refresh the folder list (in the background) and we refresh the messages
+ * for any folder that is currently open. This guarantees that things like unread message
+ * count and read status are updated.
+ */
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ NotificationManager notifMgr = (NotificationManager)
+ getSystemService(Context.NOTIFICATION_SERVICE);
+ notifMgr.cancel(1);
+
+ MessagingController.getInstance(getApplication()).addListener(mAdapter.mListener);
+ mAccount.refresh(Preferences.getPreferences(this));
+ onRefresh(false);
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putParcelable(STATE_KEY_LIST, mListView.onSaveInstanceState());
+ outState.putInt(STATE_KEY_EXPANDED_GROUP, mExpandedGroup);
+ outState.putLong(STATE_KEY_EXPANDED_GROUP_SELECTION, mListView.getSelectedPosition());
+ }
+
+ @Override
+ public void onGroupCollapse(int groupPosition) {
+ super.onGroupCollapse(groupPosition);
+ mExpandedGroup = -1;
+ }
+
+ @Override
+ public void onGroupExpand(int groupPosition) {
+ super.onGroupExpand(groupPosition);
+ if (mExpandedGroup != -1) {
+ mListView.collapseGroup(mExpandedGroup);
+ }
+ mExpandedGroup = groupPosition;
+
+ if (!mRestoringState) {
+ /*
+ * Scroll the selected item to the top of the screen.
+ */
+ int position = mListView.getFlatListPosition(
+ ExpandableListView.getPackedPositionForGroup(groupPosition));
+ mListView.setSelectionFromTop(position, 0);
+ }
+
+ final FolderInfoHolder folder = (FolderInfoHolder) mAdapter.getGroup(groupPosition);
+ /*
+ * We'll only do a hard refresh of a particular folder every 3 minutes or if the user
+ * specifically asks for a refresh.
+ */
+ if (System.currentTimeMillis() - folder.lastChecked
+ > UPDATE_FOLDER_ON_EXPAND_INTERVAL_MS) {
+ folder.lastChecked = System.currentTimeMillis();
+ // TODO: If the previous thread is already running, we should cancel it
+ new Thread(new FolderUpdateWorker(folder.name, true)).start();
+ }
+ }
+
+
+ @Override
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
+ //Shortcuts that work no matter what is selected
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_C: { onCompose(); return true;}
+ case KeyEvent.KEYCODE_Q: { onAccounts(); return true; }
+ case KeyEvent.KEYCODE_S: { onEditAccount(); return true; }
+ case KeyEvent.KEYCODE_L: {
+ long lastAutoCheckTime = mAccount.getLastAutomaticCheckTime();
+ Toast.makeText(this, (new Date(lastAutoCheckTime)).toString(), Toast.LENGTH_LONG).show();
+ return true;
+ }
+ }//switch
+
+ //Shortcuts that only work when a message is selected
+ int group = mListView.getPackedPositionGroup(mListView.getSelectedId());
+ int item =(mListView.getSelectedItemPosition() -1 );
+ // Guard against hitting delete on group names
+ try {
+ MessageInfoHolder message = (MessageInfoHolder) mAdapter.getChild(group, item);
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_DEL: { onDelete(message); return true;}
+ case KeyEvent.KEYCODE_D: { onDelete(message); return true;}
+ case KeyEvent.KEYCODE_F: { onForward(message); return true;}
+ case KeyEvent.KEYCODE_A: { onReplyAll(message); return true; }
+ case KeyEvent.KEYCODE_R: { onReply(message); return true; }
+ }
+ }
+ finally {
+ return super.onKeyDown(keyCode, event);
+ }
+ }//onKeyDown
+
+
+ @Override
+ public boolean onChildClick(ExpandableListView parent, View v, int groupPosition,
+ int childPosition, long id) {
+ FolderInfoHolder folder = (FolderInfoHolder) mAdapter.getGroup(groupPosition);
+ if (folder.outbox) {
+ return false;
+ }
+ if (childPosition == folder.messages.size() && !folder.loading) {
+ if (folder.status == null) {
+ MessagingController.getInstance(getApplication()).loadMoreMessages(
+ mAccount,
+ folder.name,
+ mAdapter.mListener);
+ return false;
+ }
+ else {
+ MessagingController.getInstance(getApplication()).synchronizeMailbox(
+ mAccount,
+ folder.name,
+ mAdapter.mListener);
+ return false;
+ }
+ }
+ else if (childPosition >= folder.messages.size()) {
+ return false;
+ }
+ MessageInfoHolder message = (MessageInfoHolder) mAdapter.getChild(groupPosition, childPosition);
+
+ onOpenMessage(folder, message);
+
+ return true;
+ }
+
+ private void onRefresh(final boolean forceRemote) {
+ if (forceRemote) {
+ mRefreshRemote = true;
+ }
+ new Thread() {
+ public void run() {
+ Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
+ MessagingController.getInstance(getApplication()).listFolders(
+ mAccount,
+ forceRemote,
+ mAdapter.mListener);
+ if (forceRemote) {
+ MessagingController.getInstance(getApplication()).sendPendingMessages(
+ mAccount,
+ null);
+ }
+ }
+ }.start();
+ }
+
+ private void onOpenMessage(FolderInfoHolder folder, MessageInfoHolder message) {
+ /*
+ * We set read=true here for UI performance reasons. The actual value will get picked up
+ * on the refresh when the Activity is resumed but that may take a second or so and we
+ * don't want this to show and then go away.
+ * I've gone back and forth on this, and this gives a better UI experience, so I am
+ * putting it back in.
+ */
+ if (!message.read) {
+ message.read = true;
+ mHandler.dataChanged();
+ }
+
+ if (folder.name.equals(mAccount.getDraftsFolderName())) {
+ MessageCompose.actionEditDraft(this, mAccount, message.message);
+ }
+ else {
+ ArrayList folderUids = new ArrayList();
+ for (MessageInfoHolder holder : folder.messages) {
+ folderUids.add(holder.uid);
+ }
+ MessageView.actionView(this, mAccount, folder.name, message.uid, folderUids);
+ }
+ }
+
+ private void onEditAccount() {
+ AccountSettings.actionSettings(this, mAccount);
+ }
+
+ private void onAccounts() {
+ startActivity(new Intent(this, Accounts.class));
+ finish();
+ }
+
+ private void onCompose() {
+ MessageCompose.actionCompose(this, mAccount);
+ }
+
+ private void onDelete(MessageInfoHolder holder) {
+ MessagingController.getInstance(getApplication()).deleteMessage(
+ mAccount,
+ holder.message.getFolder().getName(),
+ holder.message,
+ null);
+ mAdapter.removeMessage(holder.message.getFolder().getName(), holder.uid);
+ Toast.makeText(this, R.string.message_deleted_toast, Toast.LENGTH_SHORT).show();
+ }
+
+ private void onReply(MessageInfoHolder holder) {
+ MessageCompose.actionReply(this, mAccount, holder.message, false);
+ }
+
+ private void onReplyAll(MessageInfoHolder holder) {
+ MessageCompose.actionReply(this, mAccount, holder.message, true);
+ }
+
+ private void onForward(MessageInfoHolder holder) {
+ MessageCompose.actionForward(this, mAccount, holder.message);
+ }
+
+ private void onToggleRead(MessageInfoHolder holder) {
+ MessagingController.getInstance(getApplication()).markMessageRead(
+ mAccount,
+ holder.message.getFolder().getName(),
+ holder.uid,
+ !holder.read);
+ holder.read = !holder.read;
+ onRefresh(false);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.refresh:
+ onRefresh(true);
+ return true;
+ case R.id.accounts:
+ onAccounts();
+ return true;
+ case R.id.compose:
+ onCompose();
+ return true;
+ case R.id.account_settings:
+ onEditAccount();
+ return true;
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ getMenuInflater().inflate(R.menu.folder_message_list_option, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem item) {
+ ExpandableListContextMenuInfo info =
+ (ExpandableListContextMenuInfo) item.getMenuInfo();
+ int groupPosition =
+ ExpandableListView.getPackedPositionGroup(info.packedPosition);
+ int childPosition =
+ ExpandableListView.getPackedPositionChild(info.packedPosition);
+ FolderInfoHolder folder = (FolderInfoHolder) mAdapter.getGroup(groupPosition);
+ if (childPosition < mAdapter.getChildrenCount(groupPosition)) {
+ MessageInfoHolder holder =
+ (MessageInfoHolder) mAdapter.getChild(groupPosition, childPosition);
+ switch (item.getItemId()) {
+ case R.id.open:
+ onOpenMessage(folder, holder);
+ break;
+ case R.id.delete:
+ onDelete(holder);
+ break;
+ case R.id.reply:
+ onReply(holder);
+ break;
+ case R.id.reply_all:
+ onReplyAll(holder);
+ break;
+ case R.id.forward:
+ onForward(holder);
+ break;
+ case R.id.mark_as_read:
+ onToggleRead(holder);
+ break;
+ }
+ }
+ return super.onContextItemSelected(item);
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
+ super.onCreateContextMenu(menu, v, menuInfo);
+ ExpandableListContextMenuInfo info = (ExpandableListContextMenuInfo) menuInfo;
+ if (ExpandableListView.getPackedPositionType(info.packedPosition) ==
+ ExpandableListView.PACKED_POSITION_TYPE_CHILD) {
+ long packedPosition = info.packedPosition;
+ int groupPosition = ExpandableListView.getPackedPositionGroup(packedPosition);
+ int childPosition = ExpandableListView.getPackedPositionChild(packedPosition);
+ FolderInfoHolder folder = (FolderInfoHolder) mAdapter.getGroup(groupPosition);
+ if (folder.outbox) {
+ return;
+ }
+ if (childPosition < folder.messages.size()) {
+ getMenuInflater().inflate(R.menu.folder_message_list_context, menu);
+ MessageInfoHolder message =
+ (MessageInfoHolder) mAdapter.getChild(groupPosition, childPosition);
+ if (message.read) {
+ menu.findItem(R.id.mark_as_read).setTitle(R.string.mark_as_unread_action);
+ }
+ }
+ }
+ }
+
+ class FolderMessageListAdapter extends BaseExpandableListAdapter {
+ private ArrayList mFolders = new ArrayList();
+
+ private MessagingListener mListener = new MessagingListener() {
+ @Override
+ public void listFoldersStarted(Account account) {
+ if (!account.equals(mAccount)) {
+ return;
+ }
+ mHandler.progress(true);
+ }
+
+ @Override
+ public void listFoldersFailed(Account account, String message) {
+ if (!account.equals(mAccount)) {
+ return;
+ }
+ mHandler.progress(false);
+ if (Config.LOGV) {
+ Log.v(Email.LOG_TAG, "listFoldersFailed " + message);
+ }
+ }
+
+ @Override
+ public void listFoldersFinished(Account account) {
+ if (!account.equals(mAccount)) {
+ return;
+ }
+ mHandler.progress(false);
+ if (mInitialFolder != null) {
+ int groupPosition = getFolderPosition(mInitialFolder);
+ mInitialFolder = null;
+ if (groupPosition != -1) {
+ mHandler.expandGroup(groupPosition);
+ }
+ }
+ }
+
+ @Override
+ public void listFolders(Account account, Folder[] folders) {
+ if (!account.equals(mAccount)) {
+ return;
+ }
+ for (Folder folder : folders) {
+ FolderInfoHolder holder = getFolder(folder.getName());
+ if (holder == null) {
+ holder = new FolderInfoHolder();
+ mFolders.add(holder);
+ }
+ holder.name = folder.getName();
+ if (holder.name.equalsIgnoreCase(Email.INBOX)) {
+ holder.displayName = getString(R.string.special_mailbox_name_inbox);
+ // XXX TOOD nuke when we do this for all folders
+ try {
+ holder.unreadMessageCount = folder.getUnreadMessageCount();
+ }
+ catch (MessagingException me) {
+ Log.e(Email.LOG_TAG, "Folder.getUnreadMessageCount() failed", me);
+ }
+
+ }
+ else {
+ holder.displayName = folder.getName();
+ }
+ if (holder.name.equals(mAccount.getOutboxFolderName())) {
+ holder.outbox = true;
+ }
+ if (holder.messages == null) {
+ holder.messages = new ArrayList();
+ }
+ /* TODO - once we're in a position to asynchronously list off
+ * unread message counts quckly, start doing this again.
+ * right now, they're not even displayed
+
+ try {
+ holder.unreadMessageCount = folder.getUnreadMessageCount();
+ }
+ catch (MessagingException me) {
+ Log.e(Email.LOG_TAG, "Folder.getUnreadMessageCount() failed", me);
+ }
+
+ */
+ }
+
+ Collections.sort(mFolders);
+ mHandler.dataChanged();
+
+
+ /*
+ * We will do this eventually. This restores the state of the list in the
+ * case of a killed Activity but we have some message sync issues to take care of.
+ */
+// if (mRestoredState != null) {
+// if (Config.LOGV) {
+// Log.v(Email.LOG_TAG, "Attempting to restore list state");
+// }
+// Parcelable listViewState =
+// mListView.onRestoreInstanceState(mListViewState);
+// mListViewState = null;
+// }
+
+ /*
+ * Now we need to refresh any folders that are currently expanded. We do this
+ * in case the status or number of messages has changed.
+ */
+ for (int i = 0, count = getGroupCount(); i < count; i++) {
+ if (mListView.isGroupExpanded(i)) {
+ final FolderInfoHolder folder = (FolderInfoHolder) mAdapter.getGroup(i);
+ new Thread(new FolderUpdateWorker(folder.name, mRefreshRemote)).start();
+ }
+ }
+ mRefreshRemote = false;
+ }
+
+ @Override
+ public void listLocalMessagesStarted(Account account, String folder) {
+ if (!account.equals(mAccount)) {
+ return;
+ }
+ mHandler.progress(true);
+ mHandler.folderLoading(folder, true);
+ }
+
+ @Override
+ public void listLocalMessagesFailed(Account account, String folder, String message) {
+ if (!account.equals(mAccount)) {
+ return;
+ }
+ mHandler.progress(false);
+ mHandler.folderLoading(folder, false);
+ }
+
+ @Override
+ public void listLocalMessagesFinished(Account account, String folder) {
+ if (!account.equals(mAccount)) {
+ return;
+ }
+ mHandler.progress(false);
+ mHandler.folderLoading(folder, false);
+ }
+
+ @Override
+ public void listLocalMessages(Account account, String folder, Message[] messages) {
+ if (!account.equals(mAccount)) {
+ return;
+ }
+ synchronizeMessages(folder, messages);
+ }
+
+ @Override
+ public void synchronizeMailboxStarted(
+ Account account,
+ String folder) {
+ if (!account.equals(mAccount)) {
+ return;
+ }
+ mHandler.progress(true);
+ mHandler.folderLoading(folder, true);
+ mHandler.folderStatus(folder, null);
+ }
+
+ @Override
+ public void synchronizeMailboxFinished(
+ Account account,
+ String folder,
+ int totalMessagesInMailbox,
+ int numNewMessages) {
+ if (!account.equals(mAccount)) {
+ return;
+ }
+ mHandler.progress(false);
+ mHandler.folderLoading(folder, false);
+ mHandler.folderStatus(folder, null);
+ onRefresh(false);
+ }
+
+ @Override
+ public void synchronizeMailboxFailed(
+ Account account,
+ String folder,
+ String message) {
+ if (!account.equals(mAccount)) {
+ return;
+ }
+ mHandler.progress(false);
+ mHandler.folderLoading(folder, false);
+ mHandler.folderStatus(folder, getString(R.string.status_network_error));
+ FolderInfoHolder holder = getFolder(folder);
+ if (holder != null) {
+ /*
+ * Reset the last checked time to 0 so that the next expand will attempt to
+ * refresh this folder.
+ */
+ holder.lastChecked = 0;
+ }
+ }
+
+ @Override
+ public void synchronizeMailboxNewMessage(
+ Account account,
+ String folder,
+ Message message) {
+ if (!account.equals(mAccount)) {
+ return;
+ }
+ addOrUpdateMessage(folder, message);
+ }
+
+ @Override
+ public void synchronizeMailboxRemovedMessage(
+ Account account,
+ String folder,
+ Message message) {
+ if (!account.equals(mAccount)) {
+ return;
+ }
+ removeMessage(folder, message.getUid());
+ }
+
+ @Override
+ public void emptyTrashCompleted(Account account) {
+ if (!account.equals(mAccount)) {
+ return;
+ }
+ onRefresh(false);
+ }
+
+ @Override
+ public void sendPendingMessagesCompleted(Account account) {
+ if (!account.equals(mAccount)) {
+ return;
+ }
+ onRefresh(false);
+ }
+
+ @Override
+ public void messageUidChanged(
+ Account account,
+ String folder,
+ String oldUid,
+ String newUid) {
+ if (mAccount.equals(account)) {
+ FolderInfoHolder holder = getFolder(folder);
+ if (folder != null) {
+ for (MessageInfoHolder message : holder.messages) {
+ if (message.uid.equals(oldUid)) {
+ message.uid = newUid;
+ message.message.setUid(newUid);
+ }
+ }
+ }
+ }
+ }
+ };
+
+ private Drawable mAttachmentIcon;
+
+ FolderMessageListAdapter() {
+ mAttachmentIcon = getResources().getDrawable(R.drawable.ic_mms_attachment_small);
+ }
+
+ public void removeMessage(String folder, String messageUid) {
+ FolderInfoHolder f = getFolder(folder);
+ if (f == null) {
+ return;
+ }
+ MessageInfoHolder m = getMessage(f, messageUid);
+ if (m == null) {
+ return;
+ }
+ mHandler.removeMessage(f, m);
+ }
+
+ public void synchronizeMessages(String folder, Message[] messages) {
+ FolderInfoHolder f = getFolder(folder);
+ if (f == null) {
+ return;
+ }
+ mHandler.synchronizeMessages(f, messages);
+ }
+
+ public void addOrUpdateMessage(String folder, Message message) {
+ addOrUpdateMessage(folder, message, true, true);
+ }
+
+ private void addOrUpdateMessage(FolderInfoHolder folder, Message message,
+ boolean sort, boolean notify) {
+ MessageInfoHolder m = getMessage(folder, message.getUid());
+ if (m == null) {
+ m = new MessageInfoHolder(message, folder);
+ folder.messages.add(m);
+ }
+ else {
+ m.populate(message, folder);
+ }
+ if (sort) {
+ Collections.sort(folder.messages);
+ }
+ if (notify) {
+ mHandler.dataChanged();
+ }
+ }
+
+ private void addOrUpdateMessage(String folder, Message message,
+ boolean sort, boolean notify) {
+ FolderInfoHolder f = getFolder(folder);
+ if (f == null) {
+ return;
+ }
+ addOrUpdateMessage(f, message, sort, notify);
+ }
+
+ public MessageInfoHolder getMessage(FolderInfoHolder folder, String messageUid) {
+ for (MessageInfoHolder message : folder.messages) {
+ if (message.uid.equals(messageUid)) {
+ return message;
+ }
+ }
+ return null;
+ }
+
+ public int getGroupCount() {
+ return mFolders.size();
+ }
+
+ public long getGroupId(int groupPosition) {
+ return groupPosition;
+ }
+
+ public Object getGroup(int groupPosition) {
+ return mFolders.get(groupPosition);
+ }
+
+ public FolderInfoHolder getFolder(String folder) {
+ FolderInfoHolder folderHolder = null;
+ for (int i = 0, count = getGroupCount(); i < count; i++) {
+ FolderInfoHolder holder = (FolderInfoHolder) getGroup(i);
+ if (holder.name.equals(folder)) {
+ folderHolder = holder;
+ }
+ }
+ return folderHolder;
+ }
+
+ /**
+ * Gets the group position of the given folder or returns -1 if the folder is not
+ * found.
+ * @param folder
+ * @return
+ */
+ public int getFolderPosition(String folder) {
+ for (int i = 0, count = getGroupCount(); i < count; i++) {
+ FolderInfoHolder holder = (FolderInfoHolder) getGroup(i);
+ if (holder.name.equals(folder)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ public View getGroupView(int groupPosition, boolean isExpanded, View convertView,
+ ViewGroup parent) {
+ FolderInfoHolder folder = (FolderInfoHolder) getGroup(groupPosition);
+ View view;
+ if (convertView != null) {
+ view = convertView;
+ } else {
+ view = mInflater.inflate(R.layout.folder_message_list_group, parent, false);
+ }
+ FolderViewHolder holder = (FolderViewHolder) view.getTag();
+ if (holder == null) {
+ holder = new FolderViewHolder();
+ holder.folderName = (TextView) view.findViewById(R.id.folder_name);
+ holder.newMessageCount = (TextView) view.findViewById(R.id.new_message_count);
+ holder.folderStatus = (TextView) view.findViewById(R.id.folder_status);
+ view.setTag(holder);
+ }
+ holder.folderName.setText(folder.displayName);
+
+ if (folder.status == null) {
+ holder.folderStatus.setVisibility(View.GONE);
+ }
+ else {
+ holder.folderStatus.setText(folder.status);
+ holder.folderStatus.setVisibility(View.VISIBLE);
+ }
+
+ if (folder.unreadMessageCount != 0) {
+ holder.newMessageCount.setText(Integer.toString(folder.unreadMessageCount));
+ holder.newMessageCount.setVisibility(View.VISIBLE);
+ }
+ else {
+ holder.newMessageCount.setVisibility(View.GONE);
+ }
+ return view;
+ }
+
+ public int getChildrenCount(int groupPosition) {
+ FolderInfoHolder folder = (FolderInfoHolder) getGroup(groupPosition);
+ return folder.messages.size() + 1;
+ }
+
+ public long getChildId(int groupPosition, int childPosition) {
+ FolderInfoHolder folder = (FolderInfoHolder) getGroup(groupPosition);
+ if (childPosition < folder.messages.size()) {
+ MessageInfoHolder holder = folder.messages.get(childPosition);
+ return ((LocalStore.LocalMessage) holder.message).getId();
+ } else {
+ return -1;
+ }
+ }
+
+ public Object getChild(int groupPosition, int childPosition) {
+ FolderInfoHolder folder = (FolderInfoHolder) getGroup(groupPosition);
+ return folder.messages.get(childPosition);
+ }
+
+ public View getChildView(int groupPosition, int childPosition, boolean isLastChild,
+ View convertView, ViewGroup parent) {
+ FolderInfoHolder folder = (FolderInfoHolder) getGroup(groupPosition);
+ if (isLastChild) {
+ View view;
+ if ((convertView != null)
+ && (convertView.getId()
+ == R.layout.folder_message_list_child_footer)) {
+ view = convertView;
+ }
+ else {
+ view = mInflater.inflate(R.layout.folder_message_list_child_footer,
+ parent, false);
+ view.setId(R.layout.folder_message_list_child_footer);
+ }
+ FooterViewHolder holder = (FooterViewHolder) view.getTag();
+ if (holder == null) {
+ holder = new FooterViewHolder();
+ holder.progress = (ProgressBar) view.findViewById(R.id.progress);
+ holder.main = (TextView) view.findViewById(R.id.main_text);
+ view.setTag(holder);
+ }
+ if (folder.loading) {
+ holder.main.setText(getString(R.string.status_loading_more));
+ holder.progress.setVisibility(View.VISIBLE);
+ }
+ else {
+ if (folder.status == null) {
+ // holder.main.setText(getString(R.string.message_list_load_more_messages_action));
+ // holder.main.setText("Load up to " + Email.VISIBLE_LIMIT_INCREMENT + " more");
+ holder.main.setText("Load up to " + mAccount.getDisplayCount() + " more");
+ }
+ else {
+ holder.main.setText(getString(R.string.status_loading_more_failed));
+ }
+ holder.progress.setVisibility(View.GONE);
+ }
+ return view;
+ }
+ else {
+ MessageInfoHolder message =
+ (MessageInfoHolder) getChild(groupPosition, childPosition);
+ View view;
+ if ((convertView != null)
+ && (convertView.getId() != R.layout.folder_message_list_child_footer)) {
+ view = convertView;
+ } else {
+ view = mInflater.inflate(R.layout.folder_message_list_child, parent, false);
+ }
+ MessageViewHolder holder = (MessageViewHolder) view.getTag();
+ if (holder == null) {
+ holder = new MessageViewHolder();
+ holder.subject = (TextView) view.findViewById(R.id.subject);
+ holder.from = (TextView) view.findViewById(R.id.from);
+ holder.date = (TextView) view.findViewById(R.id.date);
+ holder.chip = view.findViewById(R.id.chip);
+ /*
+ * TODO
+ * The line below and the commented lines a bit further down are work
+ * in progress for outbox status. They should not be removed.
+ */
+// holder.status = (TextView) view.findViewById(R.id.status);
+
+ /*
+ * This will need to move to below if we ever convert this whole thing
+ * to a combined inbox.
+ */
+ holder.chip.setBackgroundResource(colorChipResId);
+
+ view.setTag(holder);
+ }
+ holder.chip.getBackground().setAlpha(message.read ? 0 : 255);
+ holder.subject.setText(message.subject);
+ holder.subject.setTypeface(null, message.read ? Typeface.NORMAL : Typeface.BOLD);
+ holder.from.setText(message.sender);
+ holder.from.setTypeface(null, message.read ? Typeface.NORMAL : Typeface.BOLD);
+ holder.date.setText(message.date);
+ holder.subject.setCompoundDrawablesWithIntrinsicBounds(null, null,
+ message.hasAttachments ? mAttachmentIcon : null, null);
+// if (folder.outbox) {
+// holder.status.setText("Sending");
+// }
+// else {
+// holder.status.setText("");
+// }
+ return view;
+ }
+ }
+
+ public boolean hasStableIds() {
+ return true;
+ }
+
+ public boolean isChildSelectable(int groupPosition, int childPosition) {
+ return childPosition < getChildrenCount(groupPosition);
+ }
+
+ public class FolderInfoHolder implements Comparable {
+ public String name;
+ public String displayName;
+ public ArrayList messages;
+ public long lastChecked;
+ public int unreadMessageCount;
+ public boolean loading;
+ public String status;
+ public boolean lastCheckFailed;
+
+ /**
+ * Outbox is handled differently from any other folder.
+ */
+ public boolean outbox;
+
+ public int compareTo(FolderInfoHolder o) {
+ String s1 = this.name;
+ String s2 = o.name;
+ if (Email.INBOX.equalsIgnoreCase(s1)) {
+ return -1;
+ } else if (Email.INBOX.equalsIgnoreCase(s2)) {
+ return 1;
+ } else
+ return s1.compareToIgnoreCase(s2);
+ }
+ }
+
+ public class MessageInfoHolder implements Comparable {
+ public String subject;
+ public String date;
+ public Date compareDate;
+ public String sender;
+ public boolean hasAttachments;
+ public String uid;
+ public boolean read;
+ public Message message;
+
+ public MessageInfoHolder(Message m, FolderInfoHolder folder) {
+ populate(m, folder);
+ }
+
+ public void populate(Message m, FolderInfoHolder folder) {
+ try {
+ LocalMessage message = (LocalMessage) m;
+ Date date = message.getSentDate();
+ this.compareDate = date;
+ if (Utility.isDateToday(date)) {
+ this.date = mTimeFormat.format(date);
+ }
+ else {
+ this.date = mDateFormat.format(date);
+ }
+ this.hasAttachments = message.getAttachmentCount() > 0;
+ this.read = message.isSet(Flag.SEEN);
+ if (folder.outbox) {
+ this.sender = Address.toFriendly(
+ message.getRecipients(RecipientType.TO));
+ }
+ else {
+ this.sender = Address.toFriendly(message.getFrom());
+ }
+ this.subject = message.getSubject();
+ this.uid = message.getUid();
+ this.message = m;
+ }
+ catch (MessagingException me) {
+ if (Config.LOGV) {
+ Log.v(Email.LOG_TAG, "Unable to load message info", me);
+ }
+ }
+ }
+
+ public int compareTo(MessageInfoHolder o) {
+ return this.compareDate.compareTo(o.compareDate) * -1;
+ }
+ }
+
+ class FolderViewHolder {
+ public TextView folderName;
+ public TextView folderStatus;
+ public TextView newMessageCount;
+ }
+
+ class MessageViewHolder {
+ public TextView subject;
+ public TextView preview;
+ public TextView from;
+ public TextView date;
+ public View chip;
+ }
+
+ class FooterViewHolder {
+ public ProgressBar progress;
+ public TextView main;
+ }
+ }
+}
diff --git a/src/com/android/email/activity/MessageCompose.java b/src/com/android/email/activity/MessageCompose.java
new file mode 100644
index 000000000..80c39c198
--- /dev/null
+++ b/src/com/android/email/activity/MessageCompose.java
@@ -0,0 +1,1053 @@
+
+package com.android.email.activity;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Date;
+
+import android.app.Activity;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Parcelable;
+import android.provider.OpenableColumns;
+import android.text.TextWatcher;
+import android.text.util.Rfc822Tokenizer;
+import android.util.Config;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.Window;
+import android.view.View.OnClickListener;
+import android.view.View.OnFocusChangeListener;
+import android.webkit.WebView;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.ImageButton;
+import android.widget.LinearLayout;
+import android.widget.MultiAutoCompleteTextView;
+import android.widget.TextView;
+import android.widget.Toast;
+import android.widget.AutoCompleteTextView.Validator;
+
+import com.android.email.Account;
+import com.android.email.Email;
+import com.android.email.EmailAddressAdapter;
+import com.android.email.EmailAddressValidator;
+import com.android.email.MessagingController;
+import com.android.email.MessagingListener;
+import com.android.email.Preferences;
+import com.android.email.R;
+import com.android.email.Utility;
+import com.android.email.mail.Address;
+import com.android.email.mail.Body;
+import com.android.email.mail.Message;
+import com.android.email.mail.MessagingException;
+import com.android.email.mail.Multipart;
+import com.android.email.mail.Part;
+import com.android.email.mail.Message.RecipientType;
+import com.android.email.mail.internet.MimeBodyPart;
+import com.android.email.mail.internet.MimeHeader;
+import com.android.email.mail.internet.MimeMessage;
+import com.android.email.mail.internet.MimeMultipart;
+import com.android.email.mail.internet.MimeUtility;
+import com.android.email.mail.internet.TextBody;
+import com.android.email.mail.store.LocalStore;
+import com.android.email.mail.store.LocalStore.LocalAttachmentBody;
+
+public class MessageCompose extends Activity implements OnClickListener, OnFocusChangeListener {
+ private static final String ACTION_REPLY = "com.android.email.intent.action.REPLY";
+ private static final String ACTION_REPLY_ALL = "com.android.email.intent.action.REPLY_ALL";
+ private static final String ACTION_FORWARD = "com.android.email.intent.action.FORWARD";
+ private static final String ACTION_EDIT_DRAFT = "com.android.email.intent.action.EDIT_DRAFT";
+
+ private static final String EXTRA_ACCOUNT = "account";
+ private static final String EXTRA_FOLDER = "folder";
+ private static final String EXTRA_MESSAGE = "message";
+
+ private static final String STATE_KEY_ATTACHMENTS =
+ "com.android.email.activity.MessageCompose.attachments";
+ private static final String STATE_KEY_CC_SHOWN =
+ "com.android.email.activity.MessageCompose.ccShown";
+ private static final String STATE_KEY_BCC_SHOWN =
+ "com.android.email.activity.MessageCompose.bccShown";
+ private static final String STATE_KEY_QUOTED_TEXT_SHOWN =
+ "com.android.email.activity.MessageCompose.quotedTextShown";
+ private static final String STATE_KEY_SOURCE_MESSAGE_PROCED =
+ "com.android.email.activity.MessageCompose.stateKeySourceMessageProced";
+ private static final String STATE_KEY_DRAFT_UID =
+ "com.android.email.activity.MessageCompose.draftUid";
+
+ private static final int MSG_PROGRESS_ON = 1;
+ private static final int MSG_PROGRESS_OFF = 2;
+ private static final int MSG_UPDATE_TITLE = 3;
+ private static final int MSG_SKIPPED_ATTACHMENTS = 4;
+ private static final int MSG_SAVED_DRAFT = 5;
+ private static final int MSG_DISCARDED_DRAFT = 6;
+
+ private static final int ACTIVITY_REQUEST_PICK_ATTACHMENT = 1;
+
+ private Account mAccount;
+ private String mFolder;
+ private String mSourceMessageUid;
+ private Message mSourceMessage;
+ /**
+ * Indicates that the source message has been processed at least once and should not
+ * be processed on any subsequent loads. This protects us from adding attachments that
+ * have already been added from the restore of the view state.
+ */
+ private boolean mSourceMessageProcessed = false;
+
+ private MultiAutoCompleteTextView mToView;
+ private MultiAutoCompleteTextView mCcView;
+ private MultiAutoCompleteTextView mBccView;
+ private EditText mSubjectView;
+ private EditText mMessageContentView;
+ private Button mSendButton;
+ private Button mDiscardButton;
+ private Button mSaveButton;
+ private LinearLayout mAttachments;
+ private View mQuotedTextBar;
+ private ImageButton mQuotedTextDelete;
+ private WebView mQuotedText;
+
+ private boolean mDraftNeedsSaving = false;
+
+ /**
+ * The draft uid of this message. This is used when saving drafts so that the same draft is
+ * overwritten instead of being created anew. This property is null until the first save.
+ */
+ private String mDraftUid;
+
+ private Handler mHandler = new Handler() {
+ @Override
+ public void handleMessage(android.os.Message msg) {
+ switch (msg.what) {
+ case MSG_PROGRESS_ON:
+ setProgressBarIndeterminateVisibility(true);
+ break;
+ case MSG_PROGRESS_OFF:
+ setProgressBarIndeterminateVisibility(false);
+ break;
+ case MSG_UPDATE_TITLE:
+ updateTitle();
+ break;
+ case MSG_SKIPPED_ATTACHMENTS:
+ Toast.makeText(
+ MessageCompose.this,
+ getString(R.string.message_compose_attachments_skipped_toast),
+ Toast.LENGTH_LONG).show();
+ break;
+ case MSG_SAVED_DRAFT:
+ Toast.makeText(
+ MessageCompose.this,
+ getString(R.string.message_saved_toast),
+ Toast.LENGTH_LONG).show();
+ break;
+ case MSG_DISCARDED_DRAFT:
+ Toast.makeText(
+ MessageCompose.this,
+ getString(R.string.message_discarded_toast),
+ Toast.LENGTH_LONG).show();
+ break;
+ default:
+ super.handleMessage(msg);
+ break;
+ }
+ }
+ };
+
+ private Listener mListener = new Listener();
+ private EmailAddressAdapter mAddressAdapter;
+ private Validator mAddressValidator;
+
+
+ class Attachment implements Serializable {
+ public String name;
+ public String contentType;
+ public long size;
+ public Uri uri;
+ }
+
+ /**
+ * Compose a new message using the given account. If account is null the default account
+ * will be used.
+ * @param context
+ * @param account
+ */
+ public static void actionCompose(Context context, Account account) {
+ Intent i = new Intent(context, MessageCompose.class);
+ i.putExtra(EXTRA_ACCOUNT, account);
+ context.startActivity(i);
+ }
+
+ /**
+ * Compose a new message as a reply to the given message. If replyAll is true the function
+ * is reply all instead of simply reply.
+ * @param context
+ * @param account
+ * @param message
+ * @param replyAll
+ */
+ public static void actionReply(
+ Context context,
+ Account account,
+ Message message,
+ boolean replyAll) {
+ Intent i = new Intent(context, MessageCompose.class);
+ i.putExtra(EXTRA_ACCOUNT, account);
+ i.putExtra(EXTRA_FOLDER, message.getFolder().getName());
+ i.putExtra(EXTRA_MESSAGE, message.getUid());
+ if (replyAll) {
+ i.setAction(ACTION_REPLY_ALL);
+ }
+ else {
+ i.setAction(ACTION_REPLY);
+ }
+ context.startActivity(i);
+ }
+
+ /**
+ * Compose a new message as a forward of the given message.
+ * @param context
+ * @param account
+ * @param message
+ */
+ public static void actionForward(Context context, Account account, Message message) {
+ Intent i = new Intent(context, MessageCompose.class);
+ i.putExtra(EXTRA_ACCOUNT, account);
+ i.putExtra(EXTRA_FOLDER, message.getFolder().getName());
+ i.putExtra(EXTRA_MESSAGE, message.getUid());
+ i.setAction(ACTION_FORWARD);
+ context.startActivity(i);
+ }
+
+ /**
+ * Continue composition of the given message. This action modifies the way this Activity
+ * handles certain actions.
+ * Save will attempt to replace the message in the given folder with the updated version.
+ * Discard will delete the message from the given folder.
+ * @param context
+ * @param account
+ * @param folder
+ * @param message
+ */
+ public static void actionEditDraft(Context context, Account account, Message message) {
+ Intent i = new Intent(context, MessageCompose.class);
+ i.putExtra(EXTRA_ACCOUNT, account);
+ i.putExtra(EXTRA_FOLDER, message.getFolder().getName());
+ i.putExtra(EXTRA_MESSAGE, message.getUid());
+ i.setAction(ACTION_EDIT_DRAFT);
+ context.startActivity(i);
+ }
+
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
+
+ setContentView(R.layout.message_compose);
+
+ mAddressAdapter = new EmailAddressAdapter(this);
+ mAddressValidator = new EmailAddressValidator();
+
+ mToView = (MultiAutoCompleteTextView)findViewById(R.id.to);
+ mCcView = (MultiAutoCompleteTextView)findViewById(R.id.cc);
+ mBccView = (MultiAutoCompleteTextView)findViewById(R.id.bcc);
+ mSubjectView = (EditText)findViewById(R.id.subject);
+ mMessageContentView = (EditText)findViewById(R.id.message_content);
+ mAttachments = (LinearLayout)findViewById(R.id.attachments);
+ mQuotedTextBar = findViewById(R.id.quoted_text_bar);
+ mQuotedTextDelete = (ImageButton)findViewById(R.id.quoted_text_delete);
+ mQuotedText = (WebView)findViewById(R.id.quoted_text);
+
+ TextWatcher watcher = new TextWatcher() {
+ public void beforeTextChanged(CharSequence s, int start,
+ int before, int after) { }
+
+ public void onTextChanged(CharSequence s, int start,
+ int before, int count) {
+ mDraftNeedsSaving = true;
+ }
+
+ public void afterTextChanged(android.text.Editable s) { }
+ };
+
+ mToView.addTextChangedListener(watcher);
+ mCcView.addTextChangedListener(watcher);
+ mBccView.addTextChangedListener(watcher);
+ mSubjectView.addTextChangedListener(watcher);
+ mMessageContentView.addTextChangedListener(watcher);
+
+ /*
+ * We set this to invisible by default. Other methods will turn it back on if it's
+ * needed.
+ */
+ mQuotedTextBar.setVisibility(View.GONE);
+ mQuotedText.setVisibility(View.GONE);
+
+ mQuotedTextDelete.setOnClickListener(this);
+
+ mToView.setAdapter(mAddressAdapter);
+ mToView.setTokenizer(new Rfc822Tokenizer());
+ mToView.setValidator(mAddressValidator);
+
+ mCcView.setAdapter(mAddressAdapter);
+ mCcView.setTokenizer(new Rfc822Tokenizer());
+ mCcView.setValidator(mAddressValidator);
+
+ mBccView.setAdapter(mAddressAdapter);
+ mBccView.setTokenizer(new Rfc822Tokenizer());
+ mBccView.setValidator(mAddressValidator);
+
+
+ mSubjectView.setOnFocusChangeListener(this);
+
+ if (savedInstanceState != null) {
+ /*
+ * This data gets used in onCreate, so grab it here instead of onRestoreIntstanceState
+ */
+ mSourceMessageProcessed =
+ savedInstanceState.getBoolean(STATE_KEY_SOURCE_MESSAGE_PROCED, false);
+ }
+
+ Intent intent = getIntent();
+
+ String action = intent.getAction();
+
+ if (Intent.ACTION_VIEW.equals(action) || Intent.ACTION_SENDTO.equals(action)) {
+ /*
+ * Someone has clicked a mailto: link. The address is in the URI.
+ */
+ mAccount = Preferences.getPreferences(this).getDefaultAccount();
+ if (mAccount == null) {
+ /*
+ * There are no accounts set up. This should not have happened. Prompt the
+ * user to set up an account as an acceptable bailout.
+ */
+ startActivity(new Intent(this, Accounts.class));
+ mDraftNeedsSaving = false;
+ finish();
+ return;
+ }
+ if (intent.getData() != null) {
+ Uri uri = intent.getData();
+ try {
+ if (uri.getScheme().equalsIgnoreCase("mailto")) {
+ Address[] addresses = Address.parse(uri.getSchemeSpecificPart());
+ addAddresses(mToView, addresses);
+ }
+ }
+ catch (Exception e) {
+ /*
+ * If we can't extract any information from the URI it's okay. They can
+ * still compose a message.
+ */
+ }
+ }
+ }
+ else if (Intent.ACTION_SEND.equals(action)) {
+ /*
+ * Someone is trying to compose an email with an attachment, probably Pictures.
+ * The Intent should contain an EXTRA_STREAM with the data to attach.
+ */
+
+ mAccount = Preferences.getPreferences(this).getDefaultAccount();
+ if (mAccount == null) {
+ /*
+ * There are no accounts set up. This should not have happened. Prompt the
+ * user to set up an account as an acceptable bailout.
+ */
+ startActivity(new Intent(this, Accounts.class));
+ mDraftNeedsSaving = false;
+ finish();
+ return;
+ }
+
+ String type = intent.getType();
+ Uri stream = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM);
+ if (stream != null && type != null) {
+ if (MimeUtility.mimeTypeMatches(type, Email.ACCEPTABLE_ATTACHMENT_SEND_TYPES)) {
+ addAttachment(stream);
+ }
+ }
+ }
+ else {
+ mAccount = (Account) intent.getSerializableExtra(EXTRA_ACCOUNT);
+ mFolder = (String) intent.getStringExtra(EXTRA_FOLDER);
+ mSourceMessageUid = (String) intent.getStringExtra(EXTRA_MESSAGE);
+ }
+
+ if (ACTION_REPLY.equals(action) || ACTION_REPLY_ALL.equals(action) ||
+ ACTION_FORWARD.equals(action) || ACTION_EDIT_DRAFT.equals(action)) {
+ /*
+ * If we need to load the message we add ourself as a message listener here
+ * so we can kick it off. Normally we add in onResume but we don't
+ * want to reload the message every time the activity is resumed.
+ * There is no harm in adding twice.
+ */
+ MessagingController.getInstance(getApplication()).addListener(mListener);
+ MessagingController.getInstance(getApplication()).loadMessageForView(
+ mAccount,
+ mFolder,
+ mSourceMessageUid,
+ mListener);
+ }
+
+ addAddress(mBccView, new Address(mAccount.getAlwaysBcc(), ""));
+ updateTitle();
+ }
+
+ public void onResume() {
+ super.onResume();
+ MessagingController.getInstance(getApplication()).addListener(mListener);
+ }
+
+ public void onPause() {
+ super.onPause();
+ saveIfNeeded();
+ MessagingController.getInstance(getApplication()).removeListener(mListener);
+ }
+
+ /**
+ * The framework handles most of the fields, but we need to handle stuff that we
+ * dynamically show and hide:
+ * Attachment list,
+ * Cc field,
+ * Bcc field,
+ * Quoted text,
+ */
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ saveIfNeeded();
+ ArrayList attachments = new ArrayList();
+ for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
+ View view = mAttachments.getChildAt(i);
+ Attachment attachment = (Attachment) view.getTag();
+ attachments.add(attachment.uri);
+ }
+ outState.putParcelableArrayList(STATE_KEY_ATTACHMENTS, attachments);
+ outState.putBoolean(STATE_KEY_CC_SHOWN, mCcView.getVisibility() == View.VISIBLE);
+ outState.putBoolean(STATE_KEY_BCC_SHOWN, mBccView.getVisibility() == View.VISIBLE);
+ outState.putBoolean(STATE_KEY_QUOTED_TEXT_SHOWN,
+ mQuotedTextBar.getVisibility() == View.VISIBLE);
+ outState.putBoolean(STATE_KEY_SOURCE_MESSAGE_PROCED, mSourceMessageProcessed);
+ outState.putString(STATE_KEY_DRAFT_UID, mDraftUid);
+ }
+
+ @Override
+ protected void onRestoreInstanceState(Bundle savedInstanceState) {
+ super.onRestoreInstanceState(savedInstanceState);
+ ArrayList attachments = (ArrayList)
+ savedInstanceState.getParcelableArrayList(STATE_KEY_ATTACHMENTS);
+ mAttachments.removeAllViews();
+ for (Parcelable p : attachments) {
+ Uri uri = (Uri) p;
+ addAttachment(uri);
+ }
+
+ mCcView.setVisibility(savedInstanceState.getBoolean(STATE_KEY_CC_SHOWN) ?
+ View.VISIBLE : View.GONE);
+ mBccView.setVisibility(savedInstanceState.getBoolean(STATE_KEY_BCC_SHOWN) ?
+ View.VISIBLE : View.GONE);
+ mQuotedTextBar.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN) ?
+ View.VISIBLE : View.GONE);
+ mQuotedText.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN) ?
+ View.VISIBLE : View.GONE);
+ mDraftUid = savedInstanceState.getString(STATE_KEY_DRAFT_UID);
+ mDraftNeedsSaving = false;
+ }
+
+ private void updateTitle() {
+ if (mSubjectView.getText().length() == 0) {
+ setTitle(R.string.compose_title);
+ } else {
+ setTitle(mSubjectView.getText().toString());
+ }
+ }
+
+ public void onFocusChange(View view, boolean focused) {
+ if (!focused) {
+ updateTitle();
+ }
+ }
+
+ private void addAddresses(MultiAutoCompleteTextView view, Address[] addresses) {
+ if (addresses == null) {
+ return;
+ }
+ for (Address address : addresses) {
+ addAddress(view, address);
+ }
+ }
+
+ private void addAddress(MultiAutoCompleteTextView view, Address address) {
+ view.append(address + ", ");
+ }
+
+ private Address[] getAddresses(MultiAutoCompleteTextView view) {
+ Address[] addresses = Address.parse(view.getText().toString().trim());
+ return addresses;
+ }
+
+ private MimeMessage createMessage() throws MessagingException {
+ MimeMessage message = new MimeMessage();
+ message.setSentDate(new Date());
+ Address from = new Address(mAccount.getEmail(), mAccount.getName());
+ message.setFrom(from);
+ message.setRecipients(RecipientType.TO, getAddresses(mToView));
+ message.setRecipients(RecipientType.CC, getAddresses(mCcView));
+ message.setRecipients(RecipientType.BCC, getAddresses(mBccView));
+ message.setSubject(mSubjectView.getText().toString());
+ // XXX TODO - not sure why this won't add header
+ // message.setHeader("X-User-Agent", getString(R.string.message_header_mua));
+
+
+
+ /*
+ * Build the Body that will contain the text of the message. We'll decide where to
+ * include it later.
+ */
+
+ String text = mMessageContentView.getText().toString();
+
+ if (mQuotedTextBar.getVisibility() == View.VISIBLE) {
+ String action = getIntent().getAction();
+ String quotedText = null;
+ Part part = MimeUtility.findFirstPartByMimeType(mSourceMessage,
+ "text/plain");
+ if (part != null) {
+ quotedText = MimeUtility.getTextFromPart(part);
+ }
+ if (ACTION_REPLY.equals(action) || ACTION_REPLY_ALL.equals(action)) {
+ text += String.format(
+ getString(R.string.message_compose_reply_header_fmt),
+ Address.toString(mSourceMessage.getFrom()));
+ if (quotedText != null) {
+ text += quotedText.replaceAll("(?m)^", ">");
+ }
+ }
+ else if (ACTION_FORWARD.equals(action)) {
+ text += String.format(
+ getString(R.string.message_compose_fwd_header_fmt),
+ mSourceMessage.getSubject(),
+ Address.toString(mSourceMessage.getFrom()),
+ Address.toString(
+ mSourceMessage.getRecipients(RecipientType.TO)),
+ Address.toString(
+ mSourceMessage.getRecipients(RecipientType.CC)));
+ if (quotedText != null) {
+ text += quotedText;
+ }
+ }
+ }
+
+
+
+ text = appendSignature(text);
+
+
+ TextBody body = new TextBody(text);
+
+ if (mAttachments.getChildCount() > 0) {
+ /*
+ * The message has attachments that need to be included. First we add the part
+ * containing the text that will be sent and then we include each attachment.
+ */
+
+ MimeMultipart mp;
+
+ mp = new MimeMultipart();
+ mp.addBodyPart(new MimeBodyPart(body, "text/plain"));
+
+ for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
+ Attachment attachment = (Attachment) mAttachments.getChildAt(i).getTag();
+ MimeBodyPart bp = new MimeBodyPart(
+ new LocalStore.LocalAttachmentBody(attachment.uri, getApplication()));
+ bp.setHeader(MimeHeader.HEADER_CONTENT_TYPE, String.format("%s;\n name=\"%s\"",
+ attachment.contentType,
+ attachment.name));
+ bp.setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, "base64");
+ bp.setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION,
+ String.format("attachment;\n filename=\"%s\"",
+ attachment.name));
+ mp.addBodyPart(bp);
+ }
+
+ message.setBody(mp);
+ }
+ else {
+ /*
+ * No attachments to include, just stick the text body in the message and call
+ * it good.
+ */
+ message.setBody(body);
+ }
+
+ return message;
+ }
+
+ private String appendSignature (String text) {
+ String mSignature;
+ mSignature = mAccount.getSignature();
+
+ if (mSignature != null && ! mSignature.contentEquals("")){
+ text += "\n-- \n" + mAccount.getSignature();
+ }
+
+ return text;
+ }
+
+
+ private void sendOrSaveMessage(boolean save) {
+ /*
+ * Create the message from all the data the user has entered.
+ */
+ MimeMessage message;
+ try {
+ message = createMessage();
+ }
+ catch (MessagingException me) {
+ Log.e(Email.LOG_TAG, "Failed to create new message for send or save.", me);
+ throw new RuntimeException("Failed to create a new message for send or save.", me);
+ }
+
+ if (save) {
+ /*
+ * Save a draft
+ */
+ if (mDraftUid != null) {
+ message.setUid(mDraftUid);
+ }
+ else if (ACTION_EDIT_DRAFT.equals(getIntent().getAction())) {
+ /*
+ * We're saving a previously saved draft, so update the new message's uid
+ * to the old message's uid.
+ */
+ message.setUid(mSourceMessageUid);
+ }
+ MessagingController.getInstance(getApplication()).saveDraft(mAccount, message);
+ mDraftUid = message.getUid();
+
+ // Don't display the toast if the user is just changing the orientation
+ if ((getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) {
+ mHandler.sendEmptyMessage(MSG_SAVED_DRAFT);
+ }
+ }
+ else {
+ /*
+ * Send the message
+ * TODO Is it possible for us to be editing a draft with a null source message? Don't
+ * think so. Could probably remove below check.
+ */
+ if (ACTION_EDIT_DRAFT.equals(getIntent().getAction())
+ && mSourceMessageUid != null) {
+ /*
+ * We're sending a previously saved draft, so delete the old draft first.
+ */
+ MessagingController.getInstance(getApplication()).deleteMessage(
+ mAccount,
+ mFolder,
+ mSourceMessage,
+ null);
+ }
+ MessagingController.getInstance(getApplication()).sendMessage(mAccount, message, null);
+ }
+ }
+
+ private void saveIfNeeded() {
+ if (!mDraftNeedsSaving) {
+ return;
+ }
+ mDraftNeedsSaving = false;
+ sendOrSaveMessage(true);
+ }
+
+ private void onSend() {
+ if (getAddresses(mToView).length == 0 &&
+ getAddresses(mCcView).length == 0 &&
+ getAddresses(mBccView).length == 0) {
+ mToView.setError(getString(R.string.message_compose_error_no_recipients));
+ Toast.makeText(this, getString(R.string.message_compose_error_no_recipients),
+ Toast.LENGTH_LONG).show();
+ return;
+ }
+ sendOrSaveMessage(false);
+ mDraftNeedsSaving = false;
+ finish();
+ }
+
+ private void onDiscard() {
+ if (mSourceMessageUid != null) {
+ if (ACTION_EDIT_DRAFT.equals(getIntent().getAction()) && mSourceMessageUid != null) {
+ MessagingController.getInstance(getApplication()).deleteMessage(
+ mAccount,
+ mFolder,
+ mSourceMessage,
+ null);
+ }
+ }
+ mHandler.sendEmptyMessage(MSG_DISCARDED_DRAFT);
+ mDraftNeedsSaving = false;
+ finish();
+ }
+
+ private void onSave() {
+ saveIfNeeded();
+ finish();
+ }
+
+ private void onAddCcBcc() {
+ mCcView.setVisibility(View.VISIBLE);
+ mBccView.setVisibility(View.VISIBLE);
+ }
+
+ /**
+ * Kick off a picker for whatever kind of MIME types we'll accept and let Android take over.
+ */
+ private void onAddAttachment() {
+ Intent i = new Intent(Intent.ACTION_GET_CONTENT);
+ i.addCategory(Intent.CATEGORY_OPENABLE);
+ i.setType(Email.ACCEPTABLE_ATTACHMENT_SEND_TYPES[0]);
+ startActivityForResult(Intent.createChooser(i, null), ACTIVITY_REQUEST_PICK_ATTACHMENT);
+ }
+
+ private void addAttachment(Uri uri) {
+ addAttachment(uri, -1, null);
+ }
+
+ private void addAttachment(Uri uri, int size, String name) {
+ ContentResolver contentResolver = getContentResolver();
+
+ String contentType = contentResolver.getType(uri);
+
+ if (contentType == null) {
+ contentType = "";
+ }
+
+ Attachment attachment = new Attachment();
+ attachment.name = name;
+ attachment.contentType = contentType;
+ attachment.size = size;
+ attachment.uri = uri;
+
+ if (attachment.size == -1 || attachment.name == null) {
+ Cursor metadataCursor = contentResolver.query(
+ uri,
+ new String[]{ OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE },
+ null,
+ null,
+ null);
+ if (metadataCursor != null) {
+ try {
+ if (metadataCursor.moveToFirst()) {
+ if (attachment.name == null) {
+ attachment.name = metadataCursor.getString(0);
+ }
+ if (attachment.size == -1) {
+ attachment.size = metadataCursor.getInt(1);
+ }
+ }
+ } finally {
+ metadataCursor.close();
+ }
+ }
+ }
+
+ if (attachment.name == null) {
+ attachment.name = uri.getLastPathSegment();
+ }
+
+ View view = getLayoutInflater().inflate(
+ R.layout.message_compose_attachment,
+ mAttachments,
+ false);
+ TextView nameView = (TextView)view.findViewById(R.id.attachment_name);
+ ImageButton delete = (ImageButton)view.findViewById(R.id.attachment_delete);
+ nameView.setText(attachment.name);
+ delete.setOnClickListener(this);
+ delete.setTag(view);
+ view.setTag(attachment);
+ mAttachments.addView(view);
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ if (data == null) {
+ return;
+ }
+ addAttachment(data.getData());
+ mDraftNeedsSaving = true;
+ }
+
+ public void onClick(View view) {
+ switch (view.getId()) {
+ case R.id.attachment_delete:
+ /*
+ * The view is the delete button, and we have previously set the tag of
+ * the delete button to the view that owns it. We don't use parent because the
+ * view is very complex and could change in the future.
+ */
+ mAttachments.removeView((View) view.getTag());
+ mDraftNeedsSaving = true;
+ break;
+ case R.id.quoted_text_delete:
+ mQuotedTextBar.setVisibility(View.GONE);
+ mQuotedText.setVisibility(View.GONE);
+ mDraftNeedsSaving = true;
+ break;
+ }
+ }
+
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.send:
+ onSend();
+ break;
+ case R.id.save:
+ onSave();
+ break;
+ case R.id.discard:
+ onDiscard();
+ break;
+ case R.id.add_cc_bcc:
+ onAddCcBcc();
+ break;
+ case R.id.add_attachment:
+ onAddAttachment();
+ break;
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ return true;
+ }
+
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ getMenuInflater().inflate(R.menu.message_compose_option, menu);
+ return true;
+ }
+
+ /**
+ * Returns true if all attachments were able to be attached, otherwise returns false.
+ */
+ private boolean loadAttachments(Part part, int depth) throws MessagingException {
+ if (part.getBody() instanceof Multipart) {
+ Multipart mp = (Multipart) part.getBody();
+ boolean ret = true;
+ for (int i = 0, count = mp.getCount(); i < count; i++) {
+ if (!loadAttachments(mp.getBodyPart(i), depth + 1)) {
+ ret = false;
+ }
+ }
+ return ret;
+ } else {
+ String contentType = MimeUtility.unfoldAndDecode(part.getContentType());
+ String name = MimeUtility.getHeaderParameter(contentType, "name");
+ if (name != null) {
+ Body body = part.getBody();
+ if (body != null && body instanceof LocalAttachmentBody) {
+ final Uri uri = ((LocalAttachmentBody) body).getContentUri();
+ mHandler.post(new Runnable() {
+ public void run() {
+ addAttachment(uri);
+ }
+ });
+ }
+ else {
+ return false;
+ }
+ }
+ return true;
+ }
+ }
+
+ /**
+ * Pull out the parts of the now loaded source message and apply them to the new message
+ * depending on the type of message being composed.
+ * @param message
+ */
+ private void processSourceMessage(Message message) {
+ String action = getIntent().getAction();
+ if (ACTION_REPLY.equals(action) || ACTION_REPLY_ALL.equals(action)) {
+ try {
+ if (message.getSubject() != null &&
+ !message.getSubject().toLowerCase().startsWith("re:")) {
+ mSubjectView.setText("Re: " + message.getSubject());
+ }
+ else {
+ mSubjectView.setText(message.getSubject());
+ }
+ /*
+ * If a reply-to was included with the message use that, otherwise use the from
+ * or sender address.
+ */
+ Address[] replyToAddresses;
+ if (message.getReplyTo().length > 0) {
+ addAddresses(mToView, replyToAddresses = message.getReplyTo());
+ }
+ else {
+ addAddresses(mToView, replyToAddresses = message.getFrom());
+ }
+ if (ACTION_REPLY_ALL.equals(action)) {
+ for (Address address : message.getRecipients(RecipientType.TO)) {
+ if (!address.getAddress().equalsIgnoreCase(mAccount.getEmail())) {
+ addAddress(mToView, address);
+ }
+ }
+ if (message.getRecipients(RecipientType.CC).length > 0) {
+ for (Address address : message.getRecipients(RecipientType.CC)) {
+ if (!Utility.arrayContains(replyToAddresses, address)) {
+ addAddress(mCcView, address);
+ }
+ }
+ mCcView.setVisibility(View.VISIBLE);
+ }
+ }
+
+ Part part = MimeUtility.findFirstPartByMimeType(message, "text/plain");
+ if (part == null) {
+ part = MimeUtility.findFirstPartByMimeType(message, "text/html");
+ }
+ if (part != null) {
+ String text = MimeUtility.getTextFromPart(part);
+ if (text != null) {
+ mQuotedTextBar.setVisibility(View.VISIBLE);
+ mQuotedText.setVisibility(View.VISIBLE);
+ mQuotedText.loadDataWithBaseURL("email://", text, part.getMimeType(),
+ "utf-8", null);
+ }
+ }
+ }
+ catch (MessagingException me) {
+ /*
+ * This really should not happen at this point but if it does it's okay.
+ * The user can continue composing their message.
+ */
+ }
+ }
+ else if (ACTION_FORWARD.equals(action)) {
+ try {
+ if (message.getSubject() != null &&
+ !message.getSubject().toLowerCase().startsWith("fwd:")) {
+ mSubjectView.setText("Fwd: " + message.getSubject());
+ }
+ else {
+ mSubjectView.setText(message.getSubject());
+ }
+
+ Part part = MimeUtility.findFirstPartByMimeType(message, "text/plain");
+ if (part == null) {
+ part = MimeUtility.findFirstPartByMimeType(message, "text/html");
+ }
+ if (part != null) {
+ String text = MimeUtility.getTextFromPart(part);
+ if (text != null) {
+ mQuotedTextBar.setVisibility(View.VISIBLE);
+ mQuotedText.setVisibility(View.VISIBLE);
+ mQuotedText.loadDataWithBaseURL("email://", text, part.getMimeType(),
+ "utf-8", null);
+ }
+ }
+ if (!mSourceMessageProcessed) {
+ if (!loadAttachments(message, 0)) {
+ mHandler.sendEmptyMessage(MSG_SKIPPED_ATTACHMENTS);
+ }
+ }
+ }
+ catch (MessagingException me) {
+ /*
+ * This really should not happen at this point but if it does it's okay.
+ * The user can continue composing their message.
+ */
+ }
+ }
+ else if (ACTION_EDIT_DRAFT.equals(action)) {
+ try {
+ mSubjectView.setText(message.getSubject());
+ addAddresses(mToView, message.getRecipients(RecipientType.TO));
+ if (message.getRecipients(RecipientType.CC).length > 0) {
+ addAddresses(mCcView, message.getRecipients(RecipientType.CC));
+ mCcView.setVisibility(View.VISIBLE);
+ }
+ if (message.getRecipients(RecipientType.BCC).length > 0) {
+ addAddresses(mBccView, message.getRecipients(RecipientType.BCC));
+ mBccView.setVisibility(View.VISIBLE);
+ }
+ Part part = MimeUtility.findFirstPartByMimeType(message, "text/plain");
+ if (part != null) {
+ String text = MimeUtility.getTextFromPart(part);
+ mMessageContentView.setText(text);
+ }
+ if (!mSourceMessageProcessed) {
+ loadAttachments(message, 0);
+ }
+ }
+ catch (MessagingException me) {
+ // TODO
+ }
+ }
+ mSourceMessageProcessed = true;
+ mDraftNeedsSaving = false;
+ }
+
+ class Listener extends MessagingListener {
+ @Override
+ public void loadMessageForViewStarted(Account account, String folder, String uid) {
+ mHandler.sendEmptyMessage(MSG_PROGRESS_ON);
+ }
+
+ @Override
+ public void loadMessageForViewFinished(Account account, String folder, String uid,
+ Message message) {
+ mHandler.sendEmptyMessage(MSG_PROGRESS_OFF);
+ }
+
+ @Override
+ public void loadMessageForViewBodyAvailable(Account account, String folder, String uid,
+ final Message message) {
+ mSourceMessage = message;
+ runOnUiThread(new Runnable() {
+ public void run() {
+ processSourceMessage(message);
+ }
+ });
+ }
+
+ @Override
+ public void loadMessageForViewFailed(Account account, String folder, String uid,
+ final String message) {
+ mHandler.sendEmptyMessage(MSG_PROGRESS_OFF);
+ // TODO show network error
+ }
+
+ @Override
+ public void messageUidChanged(
+ Account account,
+ String folder,
+ String oldUid,
+ String newUid) {
+ if (account.equals(mAccount)
+ && (folder.equals(mFolder)
+ || (mFolder == null
+ && folder.equals(mAccount.getDraftsFolderName())))) {
+ if (oldUid.equals(mDraftUid)) {
+ mDraftUid = newUid;
+ }
+ if (oldUid.equals(mSourceMessageUid)) {
+ mSourceMessageUid = newUid;
+ }
+ if (mSourceMessage != null && (oldUid.equals(mSourceMessage.getUid()))) {
+ mSourceMessage.setUid(newUid);
+ }
+ }
+ }
+ }
+}
diff --git a/src/com/android/email/activity/MessageView.java b/src/com/android/email/activity/MessageView.java
new file mode 100644
index 000000000..9cdee2d1d
--- /dev/null
+++ b/src/com/android/email/activity/MessageView.java
@@ -0,0 +1,916 @@
+
+package com.android.email.activity;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.text.DateFormat;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.regex.Matcher;
+
+import org.apache.commons.io.IOUtils;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.media.MediaScannerConnection;
+import android.media.MediaScannerConnection.MediaScannerConnectionClient;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Environment;
+import android.os.Handler;
+import android.os.Process;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.util.Regex;
+import android.text.util.Linkify;
+import android.util.Config;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.Window;
+import android.view.View.OnClickListener;
+import android.webkit.CacheManager;
+import android.webkit.UrlInterceptHandler;
+import android.webkit.WebView;
+import android.webkit.CacheManager.CacheResult;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.android.email.Account;
+import com.android.email.Email;
+import com.android.email.MessagingController;
+import com.android.email.MessagingListener;
+import com.android.email.R;
+import com.android.email.Utility;
+import com.android.email.mail.Address;
+import com.android.email.mail.Message;
+import com.android.email.mail.MessagingException;
+import com.android.email.mail.Multipart;
+import com.android.email.mail.Part;
+import com.android.email.mail.Message.RecipientType;
+import com.android.email.mail.internet.MimeHeader;
+import com.android.email.mail.internet.MimeUtility;
+import com.android.email.mail.store.LocalStore.LocalAttachmentBody;
+import com.android.email.mail.store.LocalStore.LocalAttachmentBodyPart;
+import com.android.email.mail.store.LocalStore.LocalMessage;
+import com.android.email.provider.AttachmentProvider;
+
+public class MessageView extends Activity
+ implements UrlInterceptHandler, OnClickListener {
+ private static final String EXTRA_ACCOUNT = "com.android.email.MessageView_account";
+ private static final String EXTRA_FOLDER = "com.android.email.MessageView_folder";
+ private static final String EXTRA_MESSAGE = "com.android.email.MessageView_message";
+ private static final String EXTRA_FOLDER_UIDS = "com.android.email.MessageView_folderUids";
+ private static final String EXTRA_NEXT = "com.android.email.MessageView_next";
+
+ private TextView mFromView;
+ private TextView mDateView;
+ private TextView mToView;
+ private TextView mSubjectView;
+ private WebView mMessageContentView;
+ private LinearLayout mAttachments;
+ private View mAttachmentIcon;
+ private View mShowPicturesSection;
+
+ private Account mAccount;
+ private String mFolder;
+ private String mMessageUid;
+ private ArrayList mFolderUids;
+
+ private Message mMessage;
+ private String mNextMessageUid = null;
+ private String mPreviousMessageUid = null;
+
+ private DateFormat mDateTimeFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT);
+ private DateFormat mTimeFormat = DateFormat.getTimeInstance(DateFormat.SHORT);
+
+ private Listener mListener = new Listener();
+ private MessageViewHandler mHandler = new MessageViewHandler();
+
+
+
+
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_DEL: { onDelete(); return true;}
+ case KeyEvent.KEYCODE_D: { onDelete(); return true;}
+ case KeyEvent.KEYCODE_F: { onForward(); return true;}
+ case KeyEvent.KEYCODE_A: { onReplyAll(); return true; }
+ case KeyEvent.KEYCODE_R: { onReply(); return true; }
+ case KeyEvent.KEYCODE_J: { onPrevious(); return true; }
+ case KeyEvent.KEYCODE_K: { onNext(); return true; }
+ case KeyEvent.KEYCODE_Z: { if (event.isShiftPressed()) {
+ mMessageContentView.zoomIn();
+ } else {
+ mMessageContentView.zoomOut();
+ }
+ return true; }
+
+
+ }
+ return super.onKeyDown(keyCode, event);
+ }
+
+
+
+ class MessageViewHandler extends Handler {
+ private static final int MSG_PROGRESS = 2;
+ private static final int MSG_ADD_ATTACHMENT = 3;
+ private static final int MSG_SET_ATTACHMENTS_ENABLED = 4;
+ private static final int MSG_SET_HEADERS = 5;
+ private static final int MSG_NETWORK_ERROR = 6;
+ private static final int MSG_ATTACHMENT_SAVED = 7;
+ private static final int MSG_ATTACHMENT_NOT_SAVED = 8;
+ private static final int MSG_SHOW_SHOW_PICTURES = 9;
+ private static final int MSG_FETCHING_ATTACHMENT = 10;
+
+ @Override
+ public void handleMessage(android.os.Message msg) {
+ switch (msg.what) {
+ case MSG_PROGRESS:
+ setProgressBarIndeterminateVisibility(msg.arg1 != 0);
+ break;
+ case MSG_ADD_ATTACHMENT:
+ mAttachments.addView((View) msg.obj);
+ mAttachments.setVisibility(View.VISIBLE);
+ break;
+ case MSG_SET_ATTACHMENTS_ENABLED:
+ for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
+ Attachment attachment = (Attachment) mAttachments.getChildAt(i).getTag();
+ attachment.viewButton.setEnabled(msg.arg1 == 1);
+ attachment.downloadButton.setEnabled(msg.arg1 == 1);
+ }
+ break;
+ case MSG_SET_HEADERS:
+ String[] values = (String[]) msg.obj;
+ setTitle(values[0]);
+ mSubjectView.setText(values[0]);
+ mFromView.setText(values[1]);
+ mDateView.setText(values[2]);
+ mToView.setText(values[3]);
+ mAttachmentIcon.setVisibility(msg.arg1 == 1 ? View.VISIBLE : View.GONE);
+ break;
+ case MSG_NETWORK_ERROR:
+ Toast.makeText(MessageView.this,
+ R.string.status_network_error, Toast.LENGTH_LONG).show();
+ break;
+ case MSG_ATTACHMENT_SAVED:
+ Toast.makeText(MessageView.this, String.format(
+ getString(R.string.message_view_status_attachment_saved), msg.obj),
+ Toast.LENGTH_LONG).show();
+ break;
+ case MSG_ATTACHMENT_NOT_SAVED:
+ Toast.makeText(MessageView.this,
+ getString(R.string.message_view_status_attachment_not_saved),
+ Toast.LENGTH_LONG).show();
+ break;
+ case MSG_SHOW_SHOW_PICTURES:
+ mShowPicturesSection.setVisibility(msg.arg1 == 1 ? View.VISIBLE : View.GONE);
+ break;
+ case MSG_FETCHING_ATTACHMENT:
+ Toast.makeText(MessageView.this,
+ getString(R.string.message_view_fetching_attachment_toast),
+ Toast.LENGTH_SHORT).show();
+ break;
+ default:
+ super.handleMessage(msg);
+ }
+ }
+
+ public void progress(boolean progress) {
+ android.os.Message msg = new android.os.Message();
+ msg.what = MSG_PROGRESS;
+ msg.arg1 = progress ? 1 : 0;
+ sendMessage(msg);
+ }
+
+ public void addAttachment(View attachmentView) {
+ android.os.Message msg = new android.os.Message();
+ msg.what = MSG_ADD_ATTACHMENT;
+ msg.obj = attachmentView;
+ sendMessage(msg);
+ }
+
+ public void setAttachmentsEnabled(boolean enabled) {
+ android.os.Message msg = new android.os.Message();
+ msg.what = MSG_SET_ATTACHMENTS_ENABLED;
+ msg.arg1 = enabled ? 1 : 0;
+ sendMessage(msg);
+ }
+
+ public void setHeaders(
+ String subject,
+ String from,
+ String date,
+ String to,
+ boolean hasAttachments) {
+ android.os.Message msg = new android.os.Message();
+ msg.what = MSG_SET_HEADERS;
+ msg.arg1 = hasAttachments ? 1 : 0;
+ msg.obj = new String[] { subject, from, date, to };
+ sendMessage(msg);
+ }
+
+ public void networkError() {
+ sendEmptyMessage(MSG_NETWORK_ERROR);
+ }
+
+ public void attachmentSaved(String filename) {
+ android.os.Message msg = new android.os.Message();
+ msg.what = MSG_ATTACHMENT_SAVED;
+ msg.obj = filename;
+ sendMessage(msg);
+ }
+
+ public void attachmentNotSaved() {
+ sendEmptyMessage(MSG_ATTACHMENT_NOT_SAVED);
+ }
+
+ public void fetchingAttachment() {
+ sendEmptyMessage(MSG_FETCHING_ATTACHMENT);
+ }
+
+ public void showShowPictures(boolean show) {
+ android.os.Message msg = new android.os.Message();
+ msg.what = MSG_SHOW_SHOW_PICTURES;
+ msg.arg1 = show ? 1 : 0;
+ sendMessage(msg);
+ }
+
+
+
+ }
+
+ class Attachment {
+ public String name;
+ public String contentType;
+ public long size;
+ public LocalAttachmentBodyPart part;
+ public Button viewButton;
+ public Button downloadButton;
+ public ImageView iconView;
+ }
+
+ public static void actionView(Context context, Account account,
+ String folder, String messageUid, ArrayList folderUids) {
+ actionView(context, account, folder, messageUid, folderUids, null);
+ }
+
+ public static void actionView(Context context, Account account,
+ String folder, String messageUid, ArrayList folderUids, Bundle extras) {
+ Intent i = new Intent(context, MessageView.class);
+ i.putExtra(EXTRA_ACCOUNT, account);
+ i.putExtra(EXTRA_FOLDER, folder);
+ i.putExtra(EXTRA_MESSAGE, messageUid);
+ i.putExtra(EXTRA_FOLDER_UIDS, folderUids);
+ if (extras != null) {
+ i.putExtras(extras);
+ }
+ context.startActivity(i);
+ }
+
+ public void onCreate(Bundle icicle) {
+ super.onCreate(icicle);
+
+ requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
+
+ setContentView(R.layout.message_view);
+
+ mFromView = (TextView)findViewById(R.id.from);
+ mToView = (TextView)findViewById(R.id.to);
+ mSubjectView = (TextView)findViewById(R.id.subject);
+ mDateView = (TextView)findViewById(R.id.date);
+ mMessageContentView = (WebView)findViewById(R.id.message_content);
+ mAttachments = (LinearLayout)findViewById(R.id.attachments);
+ mAttachmentIcon = findViewById(R.id.attachment);
+ mShowPicturesSection = findViewById(R.id.show_pictures_section);
+
+ mMessageContentView.setVerticalScrollBarEnabled(false);
+ mAttachments.setVisibility(View.GONE);
+ mAttachmentIcon.setVisibility(View.GONE);
+
+ findViewById(R.id.reply).setOnClickListener(this);
+ findViewById(R.id.reply_all).setOnClickListener(this);
+ findViewById(R.id.delete).setOnClickListener(this);
+ findViewById(R.id.show_pictures).setOnClickListener(this);
+
+ // UrlInterceptRegistry.registerHandler(this);
+
+ mMessageContentView.getSettings().setBlockNetworkImage(true);
+ mMessageContentView.getSettings().setSupportZoom(true);
+
+ setTitle("");
+
+ Intent intent = getIntent();
+ mAccount = (Account) intent.getSerializableExtra(EXTRA_ACCOUNT);
+ mFolder = intent.getStringExtra(EXTRA_FOLDER);
+ mMessageUid = intent.getStringExtra(EXTRA_MESSAGE);
+ mFolderUids = intent.getStringArrayListExtra(EXTRA_FOLDER_UIDS);
+
+ View next = findViewById(R.id.next);
+ View previous = findViewById(R.id.previous);
+ /*
+ * Next and Previous Message are not shown in landscape mode, so
+ * we need to check before we use them.
+ */
+ if (next != null && previous != null) {
+ next.setOnClickListener(this);
+ previous.setOnClickListener(this);
+
+ findSurroundingMessagesUid();
+
+ previous.setVisibility(mPreviousMessageUid != null ? View.VISIBLE : View.GONE);
+ next.setVisibility(mNextMessageUid != null ? View.VISIBLE : View.GONE);
+
+ boolean goNext = intent.getBooleanExtra(EXTRA_NEXT, false);
+ if (goNext) {
+ next.requestFocus();
+ }
+ }
+
+ MessagingController.getInstance(getApplication()).addListener(mListener);
+ new Thread() {
+ public void run() {
+ // TODO this is a spot that should be eventually handled by a MessagingController
+ // thread pool. We want it in a thread but it can't be blocked by the normal
+ // synchronization stuff in MC.
+ Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
+ MessagingController.getInstance(getApplication()).loadMessageForView(
+ mAccount,
+ mFolder,
+ mMessageUid,
+ mListener);
+ }
+ }.start();
+ }
+
+ private void findSurroundingMessagesUid() {
+ for (int i = 0, count = mFolderUids.size(); i < count; i++) {
+ String messageUid = mFolderUids.get(i);
+ if (messageUid.equals(mMessageUid)) {
+ if (i != 0) {
+ mPreviousMessageUid = mFolderUids.get(i - 1);
+ }
+
+ if (i != count - 1) {
+ mNextMessageUid = mFolderUids.get(i + 1);
+ }
+ break;
+ }
+ }
+ }
+
+ public void onResume() {
+ super.onResume();
+ MessagingController.getInstance(getApplication()).addListener(mListener);
+ }
+
+ public void onPause() {
+ super.onPause();
+ MessagingController.getInstance(getApplication()).removeListener(mListener);
+ }
+
+ private void onDelete() {
+ if (mMessage != null) {
+ MessagingController.getInstance(getApplication()).deleteMessage(
+ mAccount,
+ mFolder,
+ mMessage,
+ null);
+ Toast.makeText(this, R.string.message_deleted_toast, Toast.LENGTH_SHORT).show();
+
+ // Remove this message's Uid locally
+ mFolderUids.remove(mMessage.getUid());
+ // Check if we have previous/next messages available before choosing
+ // which one to display
+ findSurroundingMessagesUid();
+
+ if (mPreviousMessageUid != null) {
+ onPrevious();
+ } else if (mNextMessageUid != null) {
+ onNext();
+ } else {
+ finish();
+ }
+ }
+ }
+
+ private void onReply() {
+ if (mMessage != null) {
+ MessageCompose.actionReply(this, mAccount, mMessage, false);
+ finish();
+ }
+ }
+
+ private void onReplyAll() {
+ if (mMessage != null) {
+ MessageCompose.actionReply(this, mAccount, mMessage, true);
+ finish();
+ }
+ }
+
+ private void onForward() {
+ if (mMessage != null) {
+ MessageCompose.actionForward(this, mAccount, mMessage);
+ finish();
+ }
+ }
+
+ private void onNext() {
+ Bundle extras = new Bundle(1);
+ extras.putBoolean(EXTRA_NEXT, true);
+ MessageView.actionView(this, mAccount, mFolder, mNextMessageUid, mFolderUids, extras);
+ finish();
+ }
+
+ private void onPrevious() {
+ MessageView.actionView(this, mAccount, mFolder, mPreviousMessageUid, mFolderUids);
+ finish();
+ }
+
+ private void onMarkAsUnread() {
+ MessagingController.getInstance(getApplication()).markMessageRead(
+ mAccount,
+ mFolder,
+ mMessage.getUid(),
+ false);
+ }
+
+ /**
+ * Creates a unique file in the given directory by appending a hyphen
+ * and a number to the given filename.
+ * @param directory
+ * @param filename
+ * @return
+ */
+ private File createUniqueFile(File directory, String filename) {
+ File file = new File(directory, filename);
+ if (!file.exists()) {
+ return file;
+ }
+ // Get the extension of the file, if any.
+ int index = filename.lastIndexOf('.');
+ String format;
+ if (index != -1) {
+ String name = filename.substring(0, index);
+ String extension = filename.substring(index);
+ format = name + "-%d" + extension;
+ }
+ else {
+ format = filename + "-%d";
+ }
+ for (int i = 2; i < Integer.MAX_VALUE; i++) {
+ file = new File(directory, String.format(format, i));
+ if (!file.exists()) {
+ return file;
+ }
+ }
+ return null;
+ }
+
+ private void onDownloadAttachment(Attachment attachment) {
+ if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
+ /*
+ * Abort early if there's no place to save the attachment. We don't want to spend
+ * the time downloading it and then abort.
+ */
+ Toast.makeText(this,
+ getString(R.string.message_view_status_attachment_not_saved),
+ Toast.LENGTH_SHORT).show();
+ return;
+ }
+ MessagingController.getInstance(getApplication()).loadAttachment(
+ mAccount,
+ mMessage,
+ attachment.part,
+ new Object[] { true, attachment },
+ mListener);
+ }
+
+ private void onViewAttachment(Attachment attachment) {
+ MessagingController.getInstance(getApplication()).loadAttachment(
+ mAccount,
+ mMessage,
+ attachment.part,
+ new Object[] { false, attachment },
+ mListener);
+ }
+
+ private void onShowPictures() {
+ mMessageContentView.getSettings().setBlockNetworkImage(false);
+ mShowPicturesSection.setVisibility(View.GONE);
+ }
+
+ public void onClick(View view) {
+ switch (view.getId()) {
+ case R.id.reply:
+ onReply();
+ break;
+ case R.id.reply_all:
+ onReplyAll();
+ break;
+ case R.id.delete:
+ onDelete();
+ break;
+ case R.id.next:
+ onNext();
+ break;
+ case R.id.previous:
+ onPrevious();
+ break;
+ case R.id.download:
+ onDownloadAttachment((Attachment) view.getTag());
+ break;
+ case R.id.view:
+ onViewAttachment((Attachment) view.getTag());
+ break;
+ case R.id.show_pictures:
+ onShowPictures();
+ break;
+ }
+ }
+
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.delete:
+ onDelete();
+ break;
+ case R.id.reply:
+ onReply();
+ break;
+ case R.id.reply_all:
+ onReplyAll();
+ break;
+ case R.id.forward:
+ onForward();
+ break;
+ case R.id.mark_as_unread:
+ onMarkAsUnread();
+ break;
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ return true;
+ }
+
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ getMenuInflater().inflate(R.menu.message_view_option, menu);
+ return true;
+ }
+
+ public CacheResult service(String url, Map headers) {
+ String prefix = "http://cid/";
+ if (url.startsWith(prefix)) {
+ try {
+ String contentId = url.substring(prefix.length());
+ final Part part = MimeUtility.findPartByContentId(mMessage, "<" + contentId + ">");
+ if (part != null) {
+ CacheResult cr = new CacheManager.CacheResult();
+ // TODO looks fixed in Mainline, cr.setInputStream
+ // part.getBody().writeTo(cr.getStream());
+ return cr;
+ }
+ }
+ catch (Exception e) {
+ // TODO
+ }
+ }
+ return null;
+ }
+
+ private Bitmap getPreviewIcon(Attachment attachment) throws MessagingException {
+ try {
+ return BitmapFactory.decodeStream(
+ getContentResolver().openInputStream(
+ AttachmentProvider.getAttachmentThumbnailUri(mAccount,
+ attachment.part.getAttachmentId(),
+ 62,
+ 62)));
+ }
+ catch (Exception e) {
+ /*
+ * We don't care what happened, we just return null for the preview icon.
+ */
+ return null;
+ }
+ }
+
+ /*
+ * Formats the given size as a String in bytes, kB, MB or GB with a single digit
+ * of precision. Ex: 12,315,000 = 12.3 MB
+ */
+ public static String formatSize(float size) {
+ long kb = 1024;
+ long mb = (kb * 1024);
+ long gb = (mb * 1024);
+ if (size < kb) {
+ return String.format("%d bytes", (int) size);
+ }
+ else if (size < mb) {
+ return String.format("%.1f kB", size / kb);
+ }
+ else if (size < gb) {
+ return String.format("%.1f MB", size / mb);
+ }
+ else {
+ return String.format("%.1f GB", size / gb);
+ }
+ }
+
+ private void renderAttachments(Part part, int depth) throws MessagingException {
+ String contentType = MimeUtility.unfoldAndDecode(part.getContentType());
+ String name = MimeUtility.getHeaderParameter(contentType, "name");
+ if (name != null) {
+ /*
+ * We're guaranteed size because LocalStore.fetch puts it there.
+ */
+ String contentDisposition = MimeUtility.unfoldAndDecode(part.getDisposition());
+ int size = Integer.parseInt(MimeUtility.getHeaderParameter(contentDisposition, "size"));
+
+ Attachment attachment = new Attachment();
+ attachment.size = size;
+ attachment.contentType = part.getMimeType();
+ attachment.name = name;
+ attachment.part = (LocalAttachmentBodyPart) part;
+
+ LayoutInflater inflater = getLayoutInflater();
+ View view = inflater.inflate(R.layout.message_view_attachment, null);
+
+ TextView attachmentName = (TextView)view.findViewById(R.id.attachment_name);
+ TextView attachmentInfo = (TextView)view.findViewById(R.id.attachment_info);
+ ImageView attachmentIcon = (ImageView)view.findViewById(R.id.attachment_icon);
+ Button attachmentView = (Button)view.findViewById(R.id.view);
+ Button attachmentDownload = (Button)view.findViewById(R.id.download);
+
+ if ((!MimeUtility.mimeTypeMatches(attachment.contentType,
+ Email.ACCEPTABLE_ATTACHMENT_VIEW_TYPES))
+ || (MimeUtility.mimeTypeMatches(attachment.contentType,
+ Email.UNACCEPTABLE_ATTACHMENT_VIEW_TYPES))) {
+ attachmentView.setVisibility(View.GONE);
+ }
+ if ((!MimeUtility.mimeTypeMatches(attachment.contentType,
+ Email.ACCEPTABLE_ATTACHMENT_DOWNLOAD_TYPES))
+ || (MimeUtility.mimeTypeMatches(attachment.contentType,
+ Email.UNACCEPTABLE_ATTACHMENT_DOWNLOAD_TYPES))) {
+ attachmentDownload.setVisibility(View.GONE);
+ }
+
+ if (attachment.size > Email.MAX_ATTACHMENT_DOWNLOAD_SIZE) {
+ attachmentView.setVisibility(View.GONE);
+ attachmentDownload.setVisibility(View.GONE);
+ }
+
+ attachment.viewButton = attachmentView;
+ attachment.downloadButton = attachmentDownload;
+ attachment.iconView = attachmentIcon;
+
+ view.setTag(attachment);
+ attachmentView.setOnClickListener(this);
+ attachmentView.setTag(attachment);
+ attachmentDownload.setOnClickListener(this);
+ attachmentDownload.setTag(attachment);
+
+ attachmentName.setText(name);
+ attachmentInfo.setText(formatSize(size));
+
+ Bitmap previewIcon = getPreviewIcon(attachment);
+ if (previewIcon != null) {
+ attachmentIcon.setImageBitmap(previewIcon);
+ }
+
+ mHandler.addAttachment(view);
+ }
+
+ if (part.getBody() instanceof Multipart) {
+ Multipart mp = (Multipart)part.getBody();
+ for (int i = 0; i < mp.getCount(); i++) {
+ renderAttachments(mp.getBodyPart(i), depth + 1);
+ }
+ }
+ }
+
+ class Listener extends MessagingListener {
+
+ @Override
+ public void loadMessageForViewHeadersAvailable(Account account, String folder, String uid,
+ final Message message) {
+ MessageView.this.mMessage = message;
+ try {
+ String subjectText = message.getSubject();
+ String fromText = Address.toFriendly(message.getFrom());
+ String dateText = Utility.isDateToday(message.getSentDate()) ?
+ mTimeFormat.format(message.getSentDate()) :
+ mDateTimeFormat.format(message.getSentDate());
+ String toText = Address.toFriendly(message.getRecipients(RecipientType.TO));
+ boolean hasAttachments = ((LocalMessage) message).getAttachmentCount() > 0;
+ mHandler.setHeaders(subjectText,
+ fromText,
+ dateText,
+ toText,
+ hasAttachments);
+ }
+ catch (MessagingException me) {
+ if (Config.LOGV) {
+ Log.v(Email.LOG_TAG, "loadMessageForViewHeadersAvailable", me);
+ }
+ }
+ }
+
+ @Override
+ public void loadMessageForViewBodyAvailable(Account account, String folder, String uid,
+ Message message) {
+ SpannableString markup;
+ MessageView.this.mMessage = message;
+ try {
+ Part part = MimeUtility.findFirstPartByMimeType(mMessage, "text/html");
+ if (part == null) {
+ part = MimeUtility.findFirstPartByMimeType(mMessage, "text/plain");
+ }
+ if (part != null) {
+ String text = MimeUtility.getTextFromPart(part);
+ if (part.getMimeType().equalsIgnoreCase("text/html")) {
+ text = text.replaceAll("cid:", "http://cid/");
+ } else {
+ Matcher m = Regex.WEB_URL_PATTERN.matcher(text);
+ StringBuffer sb = new StringBuffer();
+ while (m.find()) {
+ int start = m.start();
+ if (start != 0 && text.charAt(start - 1) != '@') {
+ m.appendReplacement(sb, "$0");
+ }
+ else {
+ m.appendReplacement(sb, "$0");
+ }
+ }
+ m.appendTail(sb);
+
+/*
+ * Convert plain text to HTML by replacing
+ * \r?\n with and adding a html/body wrapper.
+ */
+ text = sb.toString().replaceAll("\r?\n", " ");
+
+
+
+ text = "" + text + "";
+
+ }
+
+
+
+ /*
+ * TODO this should be smarter, change to regex for img, but consider how to
+ * get backgroung images and a million other things that HTML allows.
+ */
+ if (text.contains(" 0) {
+ mDefaultView.setVisibility(View.VISIBLE);
+ }
+
+ if (savedInstanceState != null && savedInstanceState.containsKey(EXTRA_ACCOUNT)) {
+ mAccount = (Account)savedInstanceState.getSerializable(EXTRA_ACCOUNT);
+ }
+
+ if (savedInstanceState != null && savedInstanceState.containsKey(STATE_KEY_PROVIDER)) {
+ mProvider = (Provider)savedInstanceState.getSerializable(STATE_KEY_PROVIDER);
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ validateFields();
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putSerializable(EXTRA_ACCOUNT, mAccount);
+ if (mProvider != null) {
+ outState.putSerializable(STATE_KEY_PROVIDER, mProvider);
+ }
+ }
+
+ public void afterTextChanged(Editable s) {
+ validateFields();
+ }
+
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+
+ private void validateFields() {
+ boolean valid = Utility.requiredFieldValid(mEmailView)
+ && Utility.requiredFieldValid(mPasswordView)
+ && mEmailValidator.isValid(mEmailView.getText().toString());
+ mNextButton.setEnabled(valid);
+ mManualSetupButton.setEnabled(valid);
+ /*
+ * Dim the next button's icon to 50% if the button is disabled.
+ * TODO this can probably be done with a stateful drawable. Check into it.
+ * android:state_enabled
+ */
+ Utility.setCompoundDrawablesAlpha(mNextButton, mNextButton.isEnabled() ? 255 : 128);
+ }
+
+ private String getOwnerName() {
+ String name = null;
+ String projection[] = {
+ ContactMethods.NAME
+ };
+ Cursor c = getContentResolver().query(
+ Uri.withAppendedPath(Contacts.People.CONTENT_URI, "owner"), projection, null, null,
+ null);
+ if (c.getCount() > 0) {
+ c.moveToFirst();
+ name = c.getString(0);
+ c.close();
+ }
+
+ if (name == null || name.length() == 0) {
+ Account account = Preferences.getPreferences(this).getDefaultAccount();
+ if (account != null) {
+ name = account.getName();
+ }
+ }
+ return name;
+ }
+
+ @Override
+ public Dialog onCreateDialog(int id) {
+ if (id == DIALOG_NOTE) {
+ if (mProvider != null && mProvider.note != null) {
+ return new AlertDialog.Builder(this)
+ .setMessage(mProvider.note)
+ .setPositiveButton(
+ getString(R.string.okay_action),
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int which) {
+ finishAutoSetup();
+ }
+ })
+ .setNegativeButton(
+ getString(R.string.cancel_action),
+ null)
+ .create();
+ }
+ }
+ return null;
+ }
+
+ private void finishAutoSetup() {
+ String email = mEmailView.getText().toString();
+ String password = mPasswordView.getText().toString();
+ String[] emailParts = email.split("@");
+ String user = emailParts[0];
+ String domain = emailParts[1];
+ URI incomingUri = null;
+ URI outgoingUri = null;
+ try {
+ String incomingUsername = mProvider.incomingUsernameTemplate;
+ incomingUsername = incomingUsername.replaceAll("\\$email", email);
+ incomingUsername = incomingUsername.replaceAll("\\$user", user);
+ incomingUsername = incomingUsername.replaceAll("\\$domain", domain);
+
+ URI incomingUriTemplate = mProvider.incomingUriTemplate;
+ incomingUri = new URI(incomingUriTemplate.getScheme(), incomingUsername + ":"
+ + password, incomingUriTemplate.getHost(), incomingUriTemplate.getPort(), null,
+ null, null);
+
+ String outgoingUsername = mProvider.outgoingUsernameTemplate;
+ outgoingUsername = outgoingUsername.replaceAll("\\$email", email);
+ outgoingUsername = outgoingUsername.replaceAll("\\$user", user);
+ outgoingUsername = outgoingUsername.replaceAll("\\$domain", domain);
+
+ URI outgoingUriTemplate = mProvider.outgoingUriTemplate;
+ outgoingUri = new URI(outgoingUriTemplate.getScheme(), outgoingUsername + ":"
+ + password, outgoingUriTemplate.getHost(), outgoingUriTemplate.getPort(), null,
+ null, null);
+ } catch (URISyntaxException use) {
+ /*
+ * If there is some problem with the URI we give up and go on to
+ * manual setup.
+ */
+ onManualSetup();
+ return;
+ }
+
+ mAccount = new Account(this);
+ mAccount.setName(getOwnerName());
+ mAccount.setEmail(email);
+ mAccount.setStoreUri(incomingUri.toString());
+ mAccount.setTransportUri(outgoingUri.toString());
+ mAccount.setDraftsFolderName(getString(R.string.special_mailbox_name_drafts));
+ mAccount.setTrashFolderName(getString(R.string.special_mailbox_name_trash));
+ mAccount.setOutboxFolderName(getString(R.string.special_mailbox_name_outbox));
+ mAccount.setSentFolderName(getString(R.string.special_mailbox_name_sent));
+ if (incomingUri.toString().startsWith("imap")) {
+ mAccount.setDeletePolicy(Account.DELETE_POLICY_ON_DELETE);
+ }
+ AccountSetupCheckSettings.actionCheckSettings(this, mAccount, true, true);
+ }
+
+ private void onNext() {
+ String email = mEmailView.getText().toString();
+ String password = mPasswordView.getText().toString();
+ String[] emailParts = email.split("@");
+ String user = emailParts[0];
+ String domain = emailParts[1];
+ mProvider = findProviderForDomain(domain);
+ if (mProvider == null) {
+ /*
+ * We don't have default settings for this account, start the manual
+ * setup process.
+ */
+ onManualSetup();
+ return;
+ }
+
+ if (mProvider.note != null) {
+ showDialog(DIALOG_NOTE);
+ }
+ else {
+ finishAutoSetup();
+ }
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ if (resultCode == RESULT_OK) {
+ mAccount.setDescription(mAccount.getEmail());
+ mAccount.save(Preferences.getPreferences(this));
+ if (mDefaultView.isChecked()) {
+ Preferences.getPreferences(this).setDefaultAccount(mAccount);
+ }
+ Email.setServicesEnabled(this);
+ AccountSetupNames.actionSetNames(this, mAccount);
+ finish();
+ }
+ }
+
+ private void onManualSetup() {
+ String email = mEmailView.getText().toString();
+ String password = mPasswordView.getText().toString();
+ String[] emailParts = email.split("@");
+ String user = emailParts[0];
+ String domain = emailParts[1];
+
+ mAccount = new Account(this);
+ mAccount.setName(getOwnerName());
+ mAccount.setEmail(email);
+ try {
+ URI uri = new URI("placeholder", user + ":" + password, "mail." + domain, -1, null,
+ null, null);
+ mAccount.setStoreUri(uri.toString());
+ mAccount.setTransportUri(uri.toString());
+ } catch (URISyntaxException use) {
+ /*
+ * If we can't set up the URL we just continue. It's only for
+ * convenience.
+ */
+ }
+ mAccount.setDraftsFolderName(getString(R.string.special_mailbox_name_drafts));
+ mAccount.setTrashFolderName(getString(R.string.special_mailbox_name_trash));
+ mAccount.setOutboxFolderName(getString(R.string.special_mailbox_name_outbox));
+ mAccount.setSentFolderName(getString(R.string.special_mailbox_name_sent));
+
+ AccountSetupAccountType.actionSelectAccountType(this, mAccount, mDefaultView.isChecked());
+ finish();
+ }
+
+ public void onClick(View v) {
+ switch (v.getId()) {
+ case R.id.next:
+ onNext();
+ break;
+ case R.id.manual_setup:
+ onManualSetup();
+ break;
+ }
+ }
+
+ /**
+ * Attempts to get the given attribute as a String resource first, and if it fails
+ * returns the attribute as a simple String value.
+ * @param xml
+ * @param name
+ * @return
+ */
+ private String getXmlAttribute(XmlResourceParser xml, String name) {
+ int resId = xml.getAttributeResourceValue(null, name, 0);
+ if (resId == 0) {
+ return xml.getAttributeValue(null, name);
+ }
+ else {
+ return getString(resId);
+ }
+ }
+
+ private Provider findProviderForDomain(String domain) {
+ try {
+ XmlResourceParser xml = getResources().getXml(R.xml.providers);
+ int xmlEventType;
+ Provider provider = null;
+ while ((xmlEventType = xml.next()) != XmlResourceParser.END_DOCUMENT) {
+ if (xmlEventType == XmlResourceParser.START_TAG
+ && "provider".equals(xml.getName())
+ && domain.equalsIgnoreCase(getXmlAttribute(xml, "domain"))) {
+ provider = new Provider();
+ provider.id = getXmlAttribute(xml, "id");
+ provider.label = getXmlAttribute(xml, "label");
+ provider.domain = getXmlAttribute(xml, "domain");
+ provider.note = getXmlAttribute(xml, "note");
+ }
+ else if (xmlEventType == XmlResourceParser.START_TAG
+ && "incoming".equals(xml.getName())
+ && provider != null) {
+ provider.incomingUriTemplate = new URI(getXmlAttribute(xml, "uri"));
+ provider.incomingUsernameTemplate = getXmlAttribute(xml, "username");
+ }
+ else if (xmlEventType == XmlResourceParser.START_TAG
+ && "outgoing".equals(xml.getName())
+ && provider != null) {
+ provider.outgoingUriTemplate = new URI(getXmlAttribute(xml, "uri"));
+ provider.outgoingUsernameTemplate = getXmlAttribute(xml, "username");
+ }
+ else if (xmlEventType == XmlResourceParser.END_TAG
+ && "provider".equals(xml.getName())
+ && provider != null) {
+ return provider;
+ }
+ }
+ }
+ catch (Exception e) {
+ Log.e(Email.LOG_TAG, "Error while trying to load provider settings.", e);
+ }
+ return null;
+ }
+
+ static class Provider implements Serializable {
+ private static final long serialVersionUID = 8511656164616538989L;
+
+ public String id;
+
+ public String label;
+
+ public String domain;
+
+ public URI incomingUriTemplate;
+
+ public String incomingUsernameTemplate;
+
+ public URI outgoingUriTemplate;
+
+ public String outgoingUsernameTemplate;
+
+ public String note;
+ }
+}
diff --git a/src/com/android/email/activity/setup/AccountSetupCheckSettings.java b/src/com/android/email/activity/setup/AccountSetupCheckSettings.java
new file mode 100644
index 000000000..2f3dc06fd
--- /dev/null
+++ b/src/com/android/email/activity/setup/AccountSetupCheckSettings.java
@@ -0,0 +1,274 @@
+
+package com.android.email.activity.setup;
+
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Process;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.Button;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import com.android.email.Account;
+import com.android.email.R;
+import com.android.email.mail.AuthenticationFailedException;
+import com.android.email.mail.MessagingException;
+import com.android.email.mail.Store;
+import com.android.email.mail.Transport;
+import com.android.email.mail.CertificateValidationException;
+import com.android.email.mail.store.TrustManagerFactory;
+
+/**
+ * Checks the given settings to make sure that they can be used to send and
+ * receive mail.
+ *
+ * XXX NOTE: The manifest for this app has it ignore config changes, because
+ * it doesn't correctly deal with restarting while its thread is running.
+ */
+public class AccountSetupCheckSettings extends Activity implements OnClickListener {
+ private static final String EXTRA_ACCOUNT = "account";
+
+ private static final String EXTRA_CHECK_INCOMING = "checkIncoming";
+
+ private static final String EXTRA_CHECK_OUTGOING = "checkOutgoing";
+
+ private Handler mHandler = new Handler();
+
+ private ProgressBar mProgressBar;
+
+ private TextView mMessageView;
+
+ private Account mAccount;
+
+ private boolean mCheckIncoming;
+
+ private boolean mCheckOutgoing;
+
+ private boolean mCanceled;
+
+ private boolean mDestroyed;
+
+ public static void actionCheckSettings(Activity context, Account account,
+ boolean checkIncoming, boolean checkOutgoing) {
+ Intent i = new Intent(context, AccountSetupCheckSettings.class);
+ i.putExtra(EXTRA_ACCOUNT, account);
+ i.putExtra(EXTRA_CHECK_INCOMING, checkIncoming);
+ i.putExtra(EXTRA_CHECK_OUTGOING, checkOutgoing);
+ context.startActivityForResult(i, 1);
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.account_setup_check_settings);
+ mMessageView = (TextView)findViewById(R.id.message);
+ mProgressBar = (ProgressBar)findViewById(R.id.progress);
+ ((Button)findViewById(R.id.cancel)).setOnClickListener(this);
+
+ setMessage(R.string.account_setup_check_settings_retr_info_msg);
+ mProgressBar.setIndeterminate(true);
+
+ mAccount = (Account)getIntent().getSerializableExtra(EXTRA_ACCOUNT);
+ mCheckIncoming = (boolean)getIntent().getBooleanExtra(EXTRA_CHECK_INCOMING, false);
+ mCheckOutgoing = (boolean)getIntent().getBooleanExtra(EXTRA_CHECK_OUTGOING, false);
+
+ new Thread() {
+ public void run() {
+ Store store = null;
+ Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
+ try {
+ if (mDestroyed) {
+ return;
+ }
+ if (mCanceled) {
+ finish();
+ return;
+ }
+ if (mCheckIncoming) {
+ setMessage(R.string.account_setup_check_settings_check_incoming_msg);
+ store = Store.getInstance(mAccount.getStoreUri(), getApplication());
+ store.checkSettings();
+ }
+ if (mDestroyed) {
+ return;
+ }
+ if (mCanceled) {
+ finish();
+ return;
+ }
+ if (mCheckOutgoing) {
+ setMessage(R.string.account_setup_check_settings_check_outgoing_msg);
+ Transport transport = Transport.getInstance(mAccount.getTransportUri());
+ transport.close();
+ transport.open();
+ transport.close();
+ }
+ if (mDestroyed) {
+ return;
+ }
+ if (mCanceled) {
+ finish();
+ return;
+ }
+ setResult(RESULT_OK);
+ finish();
+ } catch (final AuthenticationFailedException afe) {
+ showErrorDialog(
+ R.string.account_setup_failed_dlg_auth_message_fmt,
+ afe.getMessage() == null ? "" : afe.getMessage());
+ } catch (final CertificateValidationException cve) {
+ acceptKeyDialog(
+ R.string.account_setup_failed_dlg_certificate_message_fmt,
+ cve);
+ //cve.getMessage() == null ? "" : cve.getMessage());
+ } catch (final MessagingException me) {
+ showErrorDialog(
+ R.string.account_setup_failed_dlg_server_message_fmt,
+ me.getMessage() == null ? "" : me.getMessage());
+ }
+ }
+
+ }.start();
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ mDestroyed = true;
+ mCanceled = true;
+ }
+
+ private void setMessage(final int resId) {
+ mHandler.post(new Runnable() {
+ public void run() {
+ if (mDestroyed) {
+ return;
+ }
+ mMessageView.setText(getString(resId));
+ }
+ });
+ }
+
+ private void showErrorDialog(final int msgResId, final Object... args) {
+ mHandler.post(new Runnable() {
+ public void run() {
+ if (mDestroyed) {
+ return;
+ }
+ mProgressBar.setIndeterminate(false);
+ new AlertDialog.Builder(AccountSetupCheckSettings.this)
+ .setTitle(getString(R.string.account_setup_failed_dlg_title))
+ .setMessage(getString(msgResId, args))
+ .setCancelable(true)
+ .setPositiveButton(
+ getString(R.string.account_setup_failed_dlg_edit_details_action),
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int which) {
+ finish();
+ }
+ })
+ .show();
+ }
+ });
+ }
+ private void acceptKeyDialog(final int msgResId, final Object... args) {
+ mHandler.post(new Runnable() {
+ public void run() {
+ if (mDestroyed) {
+ return;
+ }
+ final X509Certificate[] chain = TrustManagerFactory.getLastCertChain();
+ String exMessage = "Unknown Error";
+
+ Exception ex = ((Exception)args[0]);
+ if (ex != null) {
+ if (ex.getCause() != null) {
+ if (ex.getCause().getCause() != null) {
+ exMessage = ex.getCause().getCause().getMessage();
+
+ } else {
+ exMessage = ex.getCause().getMessage();
+ }
+ } else {
+ exMessage = ex.getMessage();
+ }
+ }
+
+ mProgressBar.setIndeterminate(false);
+ StringBuffer chainInfo = new StringBuffer(100);
+ for (int i = 0; i < chain.length; i++)
+ {
+ // display certificate chain information
+ chainInfo.append("Certificate chain[" + i + "]:\n");
+ chainInfo.append("Subject: " + chain[i].getSubjectDN().toString() + "\n");
+ chainInfo.append("Issuer: " + chain[i].getIssuerDN().toString() + "\n");
+ }
+
+ new AlertDialog.Builder(AccountSetupCheckSettings.this)
+ .setTitle(getString(R.string.account_setup_failed_dlg_invalid_certificate_title))
+ //.setMessage(getString(R.string.account_setup_failed_dlg_invalid_certificate)
+ .setMessage(getString(msgResId,exMessage)
+ + " " + chainInfo.toString()
+ )
+ .setCancelable(true)
+ .setPositiveButton(
+ getString(R.string.account_setup_failed_dlg_invalid_certificate_accept),
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int which) {
+ try {
+ String alias = mAccount.getUuid();
+ if (mCheckIncoming) {
+ alias = alias + ".incoming";
+ }
+ if (mCheckOutgoing) {
+ alias = alias + ".outgoing";
+ }
+ TrustManagerFactory.addCertificateChain(alias, chain);
+ } catch (CertificateException e) {
+ showErrorDialog(
+ R.string.account_setup_failed_dlg_certificate_message_fmt,
+ e.getMessage() == null ? "" : e.getMessage());
+ }
+ AccountSetupCheckSettings.actionCheckSettings(AccountSetupCheckSettings.this, mAccount,
+ mCheckIncoming, mCheckOutgoing);
+ }
+ })
+ .setNegativeButton(
+ getString(R.string.account_setup_failed_dlg_invalid_certificate_reject),
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int which) {
+ finish();
+ }
+ })
+ .show();
+ }
+ });
+ }
+
+ public void onActivityResult(int reqCode, int resCode, Intent data) {
+ setResult(resCode);
+ finish();
+ }
+
+
+ private void onCancel() {
+ mCanceled = true;
+ setMessage(R.string.account_setup_check_settings_canceling_msg);
+ }
+
+ public void onClick(View v) {
+ switch (v.getId()) {
+ case R.id.cancel:
+ onCancel();
+ break;
+ }
+ }
+}
diff --git a/src/com/android/email/activity/setup/AccountSetupComposition.java b/src/com/android/email/activity/setup/AccountSetupComposition.java
new file mode 100644
index 000000000..080267c24
--- /dev/null
+++ b/src/com/android/email/activity/setup/AccountSetupComposition.java
@@ -0,0 +1,117 @@
+package com.android.email.activity.setup;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.util.Log;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.view.View;
+import android.view.KeyEvent;
+import android.widget.AdapterView;
+import android.widget.EditText;
+import android.widget.TextView;
+
+import com.android.email.Account;
+import com.android.email.Preferences;
+import com.android.email.R;
+import com.android.email.Email;
+import com.android.email.Utility;
+
+public class AccountSetupComposition extends Activity {
+
+ private static final String EXTRA_ACCOUNT = "account";
+
+ private Account mAccount;
+
+ private EditText mAccountSignature;
+ private EditText mAccountEmail;
+ private EditText mAccountAlwaysBcc;
+ private EditText mAccountName;
+ private EditText mAccountSentItems;
+ private EditText mAccountDeletedItems;
+
+
+ public static void actionEditCompositionSettings(Activity context, Account account) {
+ Intent i = new Intent(context, AccountSetupComposition.class);
+ i.setAction(Intent.ACTION_EDIT);
+ i.putExtra(EXTRA_ACCOUNT, account);
+ context.startActivity(i);
+ }
+
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ mAccount = (Account)getIntent().getSerializableExtra(EXTRA_ACCOUNT);
+
+ setContentView(R.layout.account_setup_composition);
+
+ /*
+ * If we're being reloaded we override the original account with the one
+ * we saved
+ */
+ if (savedInstanceState != null && savedInstanceState.containsKey(EXTRA_ACCOUNT)) {
+ mAccount = (Account)savedInstanceState.getSerializable(EXTRA_ACCOUNT);
+ }
+
+ mAccountName = (EditText)findViewById(R.id.account_name);
+ mAccountName.setText(mAccount.getName());
+
+ mAccountEmail = (EditText)findViewById(R.id.account_email);
+ mAccountEmail.setText(mAccount.getEmail());
+
+ mAccountAlwaysBcc = (EditText)findViewById(R.id.account_always_bcc);
+ mAccountAlwaysBcc.setText(mAccount.getAlwaysBcc());
+
+ mAccountSignature = (EditText)findViewById(R.id.account_signature);
+ mAccountSignature.setText(mAccount.getSignature());
+
+ mAccountSentItems = (EditText)findViewById(R.id.account_sent_items);
+ mAccountSentItems.setText(mAccount.getSentFolderName());
+
+ mAccountDeletedItems = (EditText)findViewById(R.id.account_deleted_items);
+ mAccountDeletedItems.setText(mAccount.getTrashFolderName());
+
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ mAccount.refresh(Preferences.getPreferences(this));
+ }
+
+ private void saveSettings() {
+ mAccount.setEmail(mAccountEmail.getText().toString());
+ mAccount.setAlwaysBcc(mAccountAlwaysBcc.getText().toString());
+ mAccount.setName(mAccountName.getText().toString());
+ mAccount.setSignature(mAccountSignature.getText().toString());
+ mAccount.setSentFolderName(mAccountSentItems.getText().toString());
+ mAccount.setTrashFolderName(mAccountDeletedItems.getText().toString());
+
+ mAccount.save(Preferences.getPreferences(this));
+
+ }
+
+ @Override
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
+ if (keyCode == KeyEvent.KEYCODE_BACK) {
+ saveSettings();
+ }
+ return super.onKeyDown(keyCode, event);
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putSerializable(EXTRA_ACCOUNT, mAccount);
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ mAccount.save(Preferences.getPreferences(this));
+ finish();
+ }
+}
diff --git a/src/com/fsck/k9/activity/setup/AccountSetupIncoming.java b/src/com/android/email/activity/setup/AccountSetupIncoming.java
similarity index 98%
rename from src/com/fsck/k9/activity/setup/AccountSetupIncoming.java
rename to src/com/android/email/activity/setup/AccountSetupIncoming.java
index db378ec33..f1ee9ac93 100644
--- a/src/com/fsck/k9/activity/setup/AccountSetupIncoming.java
+++ b/src/com/android/email/activity/setup/AccountSetupIncoming.java
@@ -1,5 +1,5 @@
-package com.fsck.k9.activity.setup;
+package com.android.email.activity.setup;
import java.net.URI;
import java.net.URISyntaxException;
@@ -21,10 +21,10 @@ import android.widget.EditText;
import android.widget.Spinner;
import android.widget.TextView;
-import com.fsck.k9.Account;
-import com.fsck.k9.Preferences;
-import com.fsck.k9.R;
-import com.fsck.k9.Utility;
+import com.android.email.Account;
+import com.android.email.Preferences;
+import com.android.email.R;
+import com.android.email.Utility;
public class AccountSetupIncoming extends Activity implements OnClickListener {
private static final String EXTRA_ACCOUNT = "account";
diff --git a/src/com/android/email/activity/setup/AccountSetupNames.java b/src/com/android/email/activity/setup/AccountSetupNames.java
new file mode 100644
index 000000000..5ff61a22f
--- /dev/null
+++ b/src/com/android/email/activity/setup/AccountSetupNames.java
@@ -0,0 +1,103 @@
+
+package com.android.email.activity.setup;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.text.method.TextKeyListener;
+import android.text.method.TextKeyListener.Capitalize;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.Button;
+import android.widget.EditText;
+
+import com.android.email.Account;
+import com.android.email.Email;
+import com.android.email.Preferences;
+import com.android.email.R;
+import com.android.email.Utility;
+import com.android.email.activity.FolderMessageList;
+
+public class AccountSetupNames extends Activity implements OnClickListener {
+ private static final String EXTRA_ACCOUNT = "account";
+
+ private EditText mDescription;
+
+ private EditText mName;
+
+ private Account mAccount;
+
+ private Button mDoneButton;
+
+ public static void actionSetNames(Context context, Account account) {
+ Intent i = new Intent(context, AccountSetupNames.class);
+ i.putExtra(EXTRA_ACCOUNT, account);
+ context.startActivity(i);
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.account_setup_names);
+ mDescription = (EditText)findViewById(R.id.account_description);
+ mName = (EditText)findViewById(R.id.account_name);
+ mDoneButton = (Button)findViewById(R.id.done);
+ mDoneButton.setOnClickListener(this);
+
+ TextWatcher validationTextWatcher = new TextWatcher() {
+ public void afterTextChanged(Editable s) {
+ validateFields();
+ }
+
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+ };
+ mName.addTextChangedListener(validationTextWatcher);
+
+ mName.setKeyListener(TextKeyListener.getInstance(false, Capitalize.WORDS));
+
+ mAccount = (Account)getIntent().getSerializableExtra(EXTRA_ACCOUNT);
+
+ /*
+ * Since this field is considered optional, we don't set this here. If
+ * the user fills in a value we'll reset the current value, otherwise we
+ * just leave the saved value alone.
+ */
+ // mDescription.setText(mAccount.getDescription());
+ if (mAccount.getName() != null) {
+ mName.setText(mAccount.getName());
+ }
+ if (!Utility.requiredFieldValid(mName)) {
+ mDoneButton.setEnabled(false);
+ }
+ }
+
+ private void validateFields() {
+ mDoneButton.setEnabled(Utility.requiredFieldValid(mName));
+ Utility.setCompoundDrawablesAlpha(mDoneButton, mDoneButton.isEnabled() ? 255 : 128);
+ }
+
+ private void onNext() {
+ if (Utility.requiredFieldValid(mDescription)) {
+ mAccount.setDescription(mDescription.getText().toString());
+ }
+ mAccount.setName(mName.getText().toString());
+ mAccount.save(Preferences.getPreferences(this));
+ FolderMessageList.actionHandleAccount(this, mAccount, Email.INBOX);
+ finish();
+ }
+
+ public void onClick(View v) {
+ switch (v.getId()) {
+ case R.id.done:
+ onNext();
+ break;
+ }
+ }
+}
diff --git a/src/com/android/email/activity/setup/AccountSetupOptions.java b/src/com/android/email/activity/setup/AccountSetupOptions.java
new file mode 100644
index 000000000..70bf5512c
--- /dev/null
+++ b/src/com/android/email/activity/setup/AccountSetupOptions.java
@@ -0,0 +1,130 @@
+
+package com.android.email.activity.setup;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.ArrayAdapter;
+import android.widget.CheckBox;
+import android.widget.Spinner;
+
+import com.android.email.Account;
+import com.android.email.Email;
+import com.android.email.Preferences;
+import com.android.email.R;
+
+public class AccountSetupOptions extends Activity implements OnClickListener {
+ private static final String EXTRA_ACCOUNT = "account";
+
+ private static final String EXTRA_MAKE_DEFAULT = "makeDefault";
+
+ private Spinner mCheckFrequencyView;
+
+ private Spinner mDisplayCountView;
+
+ private CheckBox mDefaultView;
+
+ private CheckBox mNotifyView;
+ private CheckBox mNotifyRingtoneView;
+
+ private Account mAccount;
+
+ public static void actionOptions(Context context, Account account, boolean makeDefault) {
+ Intent i = new Intent(context, AccountSetupOptions.class);
+ i.putExtra(EXTRA_ACCOUNT, account);
+ i.putExtra(EXTRA_MAKE_DEFAULT, makeDefault);
+ context.startActivity(i);
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.account_setup_options);
+
+ mCheckFrequencyView = (Spinner)findViewById(R.id.account_check_frequency);
+ mDisplayCountView = (Spinner)findViewById(R.id.account_display_count);
+ mDefaultView = (CheckBox)findViewById(R.id.account_default);
+ mNotifyView = (CheckBox)findViewById(R.id.account_notify);
+ mNotifyRingtoneView = (CheckBox)findViewById(R.id.account_notify_ringtone);
+
+ findViewById(R.id.next).setOnClickListener(this);
+
+ SpinnerOption checkFrequencies[] = {
+ new SpinnerOption(-1,
+ getString(R.string.account_setup_options_mail_check_frequency_never)),
+ new SpinnerOption(5,
+ getString(R.string.account_setup_options_mail_check_frequency_5min)),
+ new SpinnerOption(10,
+ getString(R.string.account_setup_options_mail_check_frequency_10min)),
+ new SpinnerOption(15,
+ getString(R.string.account_setup_options_mail_check_frequency_15min)),
+ new SpinnerOption(30,
+ getString(R.string.account_setup_options_mail_check_frequency_30min)),
+ new SpinnerOption(60,
+ getString(R.string.account_setup_options_mail_check_frequency_1hour)),
+ };
+
+ ArrayAdapter checkFrequenciesAdapter = new ArrayAdapter(this,
+ android.R.layout.simple_spinner_item, checkFrequencies);
+ checkFrequenciesAdapter
+ .setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ mCheckFrequencyView.setAdapter(checkFrequenciesAdapter);
+
+ SpinnerOption displayCounts[] = {
+ new SpinnerOption(10,
+ getString(R.string.account_setup_options_mail_display_count_10)),
+ new SpinnerOption(25,
+ getString(R.string.account_setup_options_mail_display_count_25)),
+ new SpinnerOption(50,
+ getString(R.string.account_setup_options_mail_display_count_50)),
+ new SpinnerOption(100,
+ getString(R.string.account_setup_options_mail_display_count_100)),
+ };
+
+ ArrayAdapter displayCountsAdapter = new ArrayAdapter(this,
+ android.R.layout.simple_spinner_item, displayCounts);
+ displayCountsAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ mDisplayCountView.setAdapter(displayCountsAdapter);
+
+ mAccount = (Account)getIntent().getSerializableExtra(EXTRA_ACCOUNT);
+ boolean makeDefault = getIntent().getBooleanExtra(EXTRA_MAKE_DEFAULT, false);
+
+ if (mAccount.equals(Preferences.getPreferences(this).getDefaultAccount()) || makeDefault) {
+ mDefaultView.setChecked(true);
+ }
+ mNotifyView.setChecked(mAccount.isNotifyNewMail());
+ mNotifyRingtoneView.setChecked(mAccount.isNotifyRingtone());
+ SpinnerOption.setSpinnerOptionValue(mCheckFrequencyView, mAccount
+ .getAutomaticCheckIntervalMinutes());
+ SpinnerOption.setSpinnerOptionValue(mDisplayCountView, mAccount
+ .getDisplayCount());
+ }
+
+ private void onDone() {
+ mAccount.setDescription(mAccount.getEmail());
+ mAccount.setNotifyNewMail(mNotifyView.isChecked());
+ mAccount.setNotifyRingtone(mNotifyRingtoneView.isChecked());
+ mAccount.setAutomaticCheckIntervalMinutes((Integer)((SpinnerOption)mCheckFrequencyView
+ .getSelectedItem()).value);
+ mAccount.setDisplayCount((Integer)((SpinnerOption)mDisplayCountView
+ .getSelectedItem()).value);
+ mAccount.save(Preferences.getPreferences(this));
+ if (mDefaultView.isChecked()) {
+ Preferences.getPreferences(this).setDefaultAccount(mAccount);
+ }
+ Email.setServicesEnabled(this);
+ AccountSetupNames.actionSetNames(this, mAccount);
+ finish();
+ }
+
+ public void onClick(View v) {
+ switch (v.getId()) {
+ case R.id.next:
+ onDone();
+ break;
+ }
+ }
+}
diff --git a/src/com/android/email/activity/setup/AccountSetupOutgoing.java b/src/com/android/email/activity/setup/AccountSetupOutgoing.java
new file mode 100644
index 000000000..7abb815de
--- /dev/null
+++ b/src/com/android/email/activity/setup/AccountSetupOutgoing.java
@@ -0,0 +1,285 @@
+
+package com.android.email.activity.setup;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.text.method.DigitsKeyListener;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.View.OnClickListener;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.CompoundButton;
+import android.widget.EditText;
+import android.widget.Spinner;
+import android.widget.CompoundButton.OnCheckedChangeListener;
+
+import com.android.email.Account;
+import com.android.email.Preferences;
+import com.android.email.R;
+import com.android.email.Utility;
+
+public class AccountSetupOutgoing extends Activity implements OnClickListener,
+ OnCheckedChangeListener {
+ private static final String EXTRA_ACCOUNT = "account";
+
+ private static final String EXTRA_MAKE_DEFAULT = "makeDefault";
+
+ private static final int smtpPorts[] = {
+ 25, 465, 465, 25, 25
+ };
+
+ private static final String smtpSchemes[] = {
+ "smtp", "smtp+ssl", "smtp+ssl+", "smtp+tls", "smtp+tls+"
+ };
+ private static final int webdavPorts[] = {
+ 80, 443, 443, 443, 443
+ };
+ private static final String webdavSchemes[] = {
+ "webdav", "webdav+ssl", "webdav+ssl+", "webdav+tls", "webdav+tls+"
+ };
+
+ private EditText mUsernameView;
+ private EditText mPasswordView;
+ private EditText mServerView;
+ private EditText mPortView;
+ private CheckBox mRequireLoginView;
+ private ViewGroup mRequireLoginSettingsView;
+ private Spinner mSecurityTypeView;
+ private Button mNextButton;
+ private Account mAccount;
+ private boolean mMakeDefault;
+
+ public static void actionOutgoingSettings(Context context, Account account, boolean makeDefault) {
+ Intent i = new Intent(context, AccountSetupOutgoing.class);
+ i.putExtra(EXTRA_ACCOUNT, account);
+ i.putExtra(EXTRA_MAKE_DEFAULT, makeDefault);
+ context.startActivity(i);
+ }
+
+ public static void actionEditOutgoingSettings(Context context, Account account) {
+ Intent i = new Intent(context, AccountSetupOutgoing.class);
+ i.setAction(Intent.ACTION_EDIT);
+ i.putExtra(EXTRA_ACCOUNT, account);
+ context.startActivity(i);
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.account_setup_outgoing);
+
+ mAccount = (Account)getIntent().getSerializableExtra(EXTRA_ACCOUNT);
+
+ try {
+ if (new URI(mAccount.getStoreUri()).getScheme().startsWith("webdav")) {
+ mAccount.setTransportUri(mAccount.getStoreUri());
+ AccountSetupCheckSettings.actionCheckSettings(this, mAccount, false, true);
+ }
+ } catch (URISyntaxException e) {
+ // TODO Auto-generated catch block
+ e.printStackTrace();
+ }
+
+
+ mUsernameView = (EditText)findViewById(R.id.account_username);
+ mPasswordView = (EditText)findViewById(R.id.account_password);
+ mServerView = (EditText)findViewById(R.id.account_server);
+ mPortView = (EditText)findViewById(R.id.account_port);
+ mRequireLoginView = (CheckBox)findViewById(R.id.account_require_login);
+ mRequireLoginSettingsView = (ViewGroup)findViewById(R.id.account_require_login_settings);
+ mSecurityTypeView = (Spinner)findViewById(R.id.account_security_type);
+ mNextButton = (Button)findViewById(R.id.next);
+
+ mNextButton.setOnClickListener(this);
+ mRequireLoginView.setOnCheckedChangeListener(this);
+
+ SpinnerOption securityTypes[] = {
+ new SpinnerOption(0, getString(R.string.account_setup_incoming_security_none_label)),
+ new SpinnerOption(1,
+ getString(R.string.account_setup_incoming_security_ssl_optional_label)),
+ new SpinnerOption(2, getString(R.string.account_setup_incoming_security_ssl_label)),
+ new SpinnerOption(3,
+ getString(R.string.account_setup_incoming_security_tls_optional_label)),
+ new SpinnerOption(4, getString(R.string.account_setup_incoming_security_tls_label)),
+ };
+
+ ArrayAdapter securityTypesAdapter = new ArrayAdapter(this,
+ android.R.layout.simple_spinner_item, securityTypes);
+ securityTypesAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ mSecurityTypeView.setAdapter(securityTypesAdapter);
+
+ /*
+ * Updates the port when the user changes the security type. This allows
+ * us to show a reasonable default which the user can change.
+ */
+ mSecurityTypeView.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ public void onItemSelected(AdapterView arg0, View arg1, int arg2, long arg3) {
+ updatePortFromSecurityType();
+ }
+
+ public void onNothingSelected(AdapterView> arg0) {
+ }
+ });
+
+ /*
+ * Calls validateFields() which enables or disables the Next button
+ * based on the fields' validity.
+ */
+ TextWatcher validationTextWatcher = new TextWatcher() {
+ public void afterTextChanged(Editable s) {
+ validateFields();
+ }
+
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+ };
+ mUsernameView.addTextChangedListener(validationTextWatcher);
+ mPasswordView.addTextChangedListener(validationTextWatcher);
+ mServerView.addTextChangedListener(validationTextWatcher);
+ mPortView.addTextChangedListener(validationTextWatcher);
+
+ /*
+ * Only allow digits in the port field.
+ */
+ mPortView.setKeyListener(DigitsKeyListener.getInstance("0123456789"));
+
+ mAccount = (Account)getIntent().getSerializableExtra(EXTRA_ACCOUNT);
+ mMakeDefault = (boolean)getIntent().getBooleanExtra(EXTRA_MAKE_DEFAULT, false);
+
+ /*
+ * If we're being reloaded we override the original account with the one
+ * we saved
+ */
+ if (savedInstanceState != null && savedInstanceState.containsKey(EXTRA_ACCOUNT)) {
+ mAccount = (Account)savedInstanceState.getSerializable(EXTRA_ACCOUNT);
+ }
+
+ try {
+ URI uri = new URI(mAccount.getTransportUri());
+ String username = null;
+ String password = null;
+ if (uri.getUserInfo() != null) {
+ String[] userInfoParts = uri.getUserInfo().split(":", 2);
+ username = userInfoParts[0];
+ if (userInfoParts.length > 1) {
+ password = userInfoParts[1];
+ }
+ }
+
+ if (username != null) {
+ mUsernameView.setText(username);
+ mRequireLoginView.setChecked(true);
+ }
+
+ if (password != null) {
+ mPasswordView.setText(password);
+ }
+
+ for (int i = 0; i < smtpSchemes.length; i++) {
+ if (smtpSchemes[i].equals(uri.getScheme())) {
+ SpinnerOption.setSpinnerOptionValue(mSecurityTypeView, i);
+ }
+ }
+
+ if (uri.getHost() != null) {
+ mServerView.setText(uri.getHost());
+ }
+
+ if (uri.getPort() != -1) {
+ mPortView.setText(Integer.toString(uri.getPort()));
+ } else {
+ updatePortFromSecurityType();
+ }
+ } catch (URISyntaxException use) {
+ /*
+ * We should always be able to parse our own settings.
+ */
+ throw new Error(use);
+ }
+
+ validateFields();
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putSerializable(EXTRA_ACCOUNT, mAccount);
+ }
+
+ private void validateFields() {
+ mNextButton
+ .setEnabled(
+ Utility.domainFieldValid(mServerView) &&
+ Utility.requiredFieldValid(mPortView) &&
+ (!mRequireLoginView.isChecked() ||
+ (Utility.requiredFieldValid(mUsernameView) &&
+ Utility.requiredFieldValid(mPasswordView))));
+ Utility.setCompoundDrawablesAlpha(mNextButton, mNextButton.isEnabled() ? 255 : 128);
+ }
+
+ private void updatePortFromSecurityType() {
+ int securityType = (Integer)((SpinnerOption)mSecurityTypeView.getSelectedItem()).value;
+ mPortView.setText(Integer.toString(smtpPorts[securityType]));
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ if (resultCode == RESULT_OK) {
+ if (Intent.ACTION_EDIT.equals(getIntent().getAction())) {
+ mAccount.save(Preferences.getPreferences(this));
+ finish();
+ } else {
+ AccountSetupOptions.actionOptions(this, mAccount, mMakeDefault);
+ finish();
+ }
+ }
+ }
+
+ private void onNext() {
+ int securityType = (Integer)((SpinnerOption)mSecurityTypeView.getSelectedItem()).value;
+ URI uri;
+ try {
+ String userInfo = null;
+ if (mRequireLoginView.isChecked()) {
+ userInfo = mUsernameView.getText().toString() + ":"
+ + mPasswordView.getText().toString();
+ }
+ uri = new URI(smtpSchemes[securityType], userInfo, mServerView.getText().toString(),
+ Integer.parseInt(mPortView.getText().toString()), null, null, null);
+ mAccount.setTransportUri(uri.toString());
+ } catch (URISyntaxException use) {
+ /*
+ * It's unrecoverable if we cannot create a URI from components that
+ * we validated to be safe.
+ */
+ throw new Error(use);
+ }
+ AccountSetupCheckSettings.actionCheckSettings(this, mAccount, false, true);
+ }
+
+ public void onClick(View v) {
+ switch (v.getId()) {
+ case R.id.next:
+ onNext();
+ break;
+ }
+ }
+
+ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
+ mRequireLoginSettingsView.setVisibility(isChecked ? View.VISIBLE : View.GONE);
+ validateFields();
+ }
+}
diff --git a/src/com/android/email/activity/setup/SpinnerOption.java b/src/com/android/email/activity/setup/SpinnerOption.java
new file mode 100644
index 000000000..14067b8f8
--- /dev/null
+++ b/src/com/android/email/activity/setup/SpinnerOption.java
@@ -0,0 +1,33 @@
+/**
+ *
+ */
+
+package com.android.email.activity.setup;
+
+import android.widget.Spinner;
+
+public class SpinnerOption {
+ public Object value;
+
+ public String label;
+
+ public static void setSpinnerOptionValue(Spinner spinner, Object value) {
+ for (int i = 0, count = spinner.getCount(); i < count; i++) {
+ SpinnerOption so = (SpinnerOption)spinner.getItemAtPosition(i);
+ if (so.value.equals(value)) {
+ spinner.setSelection(i, true);
+ return;
+ }
+ }
+ }
+
+ public SpinnerOption(Object value, String label) {
+ this.value = value;
+ this.label = label;
+ }
+
+ @Override
+ public String toString() {
+ return label;
+ }
+}
diff --git a/src/com/android/email/codec/binary/Base64.java b/src/com/android/email/codec/binary/Base64.java
new file mode 100644
index 000000000..9f6b74d69
--- /dev/null
+++ b/src/com/android/email/codec/binary/Base64.java
@@ -0,0 +1,788 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.email.codec.binary;
+
+import org.apache.commons.codec.BinaryDecoder;
+import org.apache.commons.codec.BinaryEncoder;
+import org.apache.commons.codec.DecoderException;
+import org.apache.commons.codec.EncoderException;
+
+import java.io.UnsupportedEncodingException;
+import java.math.BigInteger;
+
+/**
+ * Provides Base64 encoding and decoding as defined by RFC 2045.
+ *
+ *
+ * This class implements section 6.8. Base64 Content-Transfer-Encoding from RFC 2045 Multipurpose
+ * Internet Mail Extensions (MIME) Part One: Format of Internet Message Bodies by Freed and Borenstein.
+ *
+ * The {@value} character limit does not count the trailing CRLF, but counts all other characters, including any
+ * equal signs.
+ *
+ *
+ * @see RFC 2045 section 6.8
+ */
+ static final int CHUNK_SIZE = 76;
+
+ /**
+ * Chunk separator per RFC 2045 section 2.1.
+ *
+ * @see RFC 2045 section 2.1
+ */
+ static final byte[] CHUNK_SEPARATOR = {'\r','\n'};
+
+ /**
+ * This array is a lookup table that translates 6-bit positive integer
+ * index values into their "Base64 Alphabet" equivalents as specified
+ * in Table 1 of RFC 2045.
+ *
+ * Thanks to "commons" project in ws.apache.org for this code.
+ * http://svn.apache.org/repos/asf/webservices/commons/trunk/modules/util/
+ */
+ private static final byte[] intToBase64 = {
+ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
+ 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
+ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
+ 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
+ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', '/'
+ };
+
+ /**
+ * Byte used to pad output.
+ */
+ private static final byte PAD = '=';
+
+ /**
+ * This array is a lookup table that translates unicode characters
+ * drawn from the "Base64 Alphabet" (as specified in Table 1 of RFC 2045)
+ * into their 6-bit positive integer equivalents. Characters that
+ * are not in the Base64 alphabet but fall within the bounds of the
+ * array are translated to -1.
+ *
+ * Thanks to "commons" project in ws.apache.org for this code.
+ * http://svn.apache.org/repos/asf/webservices/commons/trunk/modules/util/
+ */
+ private static final byte[] base64ToInt = {
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63, 52, 53, 54,
+ 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4,
+ 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23,
+ 24, 25, -1, -1, -1, -1, -1, -1, 26, 27, 28, 29, 30, 31, 32, 33, 34,
+ 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51
+ };
+
+ /** Mask used to extract 6 bits, used when encoding */
+ private static final int MASK_6BITS = 0x3f;
+
+ /** Mask used to extract 8 bits, used in decoding base64 bytes */
+ private static final int MASK_8BITS = 0xff;
+
+ // The static final fields above are used for the original static byte[] methods on Base64.
+ // The private member fields below are used with the new streaming approach, which requires
+ // some state be preserved between calls of encode() and decode().
+
+
+ /**
+ * Line length for encoding. Not used when decoding. A value of zero or less implies
+ * no chunking of the base64 encoded data.
+ */
+ private final int lineLength;
+
+ /**
+ * Line separator for encoding. Not used when decoding. Only used if lineLength > 0.
+ */
+ private final byte[] lineSeparator;
+
+ /**
+ * Convenience variable to help us determine when our buffer is going to run out of
+ * room and needs resizing. decodeSize = 3 + lineSeparator.length;
+ */
+ private final int decodeSize;
+
+ /**
+ * Convenience variable to help us determine when our buffer is going to run out of
+ * room and needs resizing. encodeSize = 4 + lineSeparator.length;
+ */
+ private final int encodeSize;
+
+ /**
+ * Buffer for streaming.
+ */
+ private byte[] buf;
+
+ /**
+ * Position where next character should be written in the buffer.
+ */
+ private int pos;
+
+ /**
+ * Position where next character should be read from the buffer.
+ */
+ private int readPos;
+
+ /**
+ * Variable tracks how many characters have been written to the current line.
+ * Only used when encoding. We use it to make sure each encoded line never
+ * goes beyond lineLength (if lineLength > 0).
+ */
+ private int currentLinePos;
+
+ /**
+ * Writes to the buffer only occur after every 3 reads when encoding, an
+ * every 4 reads when decoding. This variable helps track that.
+ */
+ private int modulus;
+
+ /**
+ * Boolean flag to indicate the EOF has been reached. Once EOF has been
+ * reached, this Base64 object becomes useless, and must be thrown away.
+ */
+ private boolean eof;
+
+ /**
+ * Place holder for the 3 bytes we're dealing with for our base64 logic.
+ * Bitwise operations store and extract the base64 encoding or decoding from
+ * this variable.
+ */
+ private int x;
+
+ /**
+ * Default constructor: lineLength is 76, and the lineSeparator is CRLF
+ * when encoding, and all forms can be decoded.
+ */
+ public Base64() {
+ this(CHUNK_SIZE, CHUNK_SEPARATOR);
+ }
+
+ /**
+ *
+ * Consumer can use this constructor to choose a different lineLength
+ * when encoding (lineSeparator is still CRLF). All forms of data can
+ * be decoded.
+ *
+ * Note: lineLengths that aren't multiples of 4 will still essentially
+ * end up being multiples of 4 in the encoded data.
+ *
+ *
+ * @param lineLength each line of encoded data will be at most this long
+ * (rounded up to nearest multiple of 4).
+ * If lineLength <= 0, then the output will not be divided into lines (chunks).
+ * Ignored when decoding.
+ */
+ public Base64(int lineLength) {
+ this(lineLength, CHUNK_SEPARATOR);
+ }
+
+ /**
+ *
+ * Consumer can use this constructor to choose a different lineLength
+ * and lineSeparator when encoding. All forms of data can
+ * be decoded.
+ *
+ * Note: lineLengths that aren't multiples of 4 will still essentially
+ * end up being multiples of 4 in the encoded data.
+ *
+ * @param lineLength Each line of encoded data will be at most this long
+ * (rounded up to nearest multiple of 4). Ignored when decoding.
+ * If <= 0, then output will not be divided into lines (chunks).
+ * @param lineSeparator Each line of encoded data will end with this
+ * sequence of bytes.
+ * If lineLength <= 0, then the lineSeparator is not used.
+ * @throws IllegalArgumentException The provided lineSeparator included
+ * some base64 characters. That's not going to work!
+ */
+ public Base64(int lineLength, byte[] lineSeparator) {
+ this.lineLength = lineLength;
+ this.lineSeparator = new byte[lineSeparator.length];
+ System.arraycopy(lineSeparator, 0, this.lineSeparator, 0, lineSeparator.length);
+ if (lineLength > 0) {
+ this.encodeSize = 4 + lineSeparator.length;
+ } else {
+ this.encodeSize = 4;
+ }
+ this.decodeSize = encodeSize - 1;
+ if (containsBase64Byte(lineSeparator)) {
+ String sep;
+ try {
+ sep = new String(lineSeparator, "UTF-8");
+ } catch (UnsupportedEncodingException uee) {
+ sep = new String(lineSeparator);
+ }
+ throw new IllegalArgumentException("lineSeperator must not contain base64 characters: [" + sep + "]");
+ }
+ }
+
+ /**
+ * Returns true if this Base64 object has buffered data for reading.
+ *
+ * @return true if there is Base64 object still available for reading.
+ */
+ boolean hasData() { return buf != null; }
+
+ /**
+ * Returns the amount of buffered data available for reading.
+ *
+ * @return The amount of buffered data available for reading.
+ */
+ int avail() { return buf != null ? pos - readPos : 0; }
+
+ /** Doubles our buffer. */
+ private void resizeBuf() {
+ if (buf == null) {
+ buf = new byte[8192];
+ pos = 0;
+ readPos = 0;
+ } else {
+ byte[] b = new byte[buf.length * 2];
+ System.arraycopy(buf, 0, b, 0, buf.length);
+ buf = b;
+ }
+ }
+
+ /**
+ * Extracts buffered data into the provided byte[] array, starting
+ * at position bPos, up to a maximum of bAvail bytes. Returns how
+ * many bytes were actually extracted.
+ *
+ * @param b byte[] array to extract the buffered data into.
+ * @param bPos position in byte[] array to start extraction at.
+ * @param bAvail amount of bytes we're allowed to extract. We may extract
+ * fewer (if fewer are available).
+ * @return The number of bytes successfully extracted into the provided
+ * byte[] array.
+ */
+ int readResults(byte[] b, int bPos, int bAvail) {
+ if (buf != null) {
+ int len = Math.min(avail(), bAvail);
+ if (buf != b) {
+ System.arraycopy(buf, readPos, b, bPos, len);
+ readPos += len;
+ if (readPos >= pos) {
+ buf = null;
+ }
+ } else {
+ // Re-using the original consumer's output array is only
+ // allowed for one round.
+ buf = null;
+ }
+ return len;
+ } else {
+ return eof ? -1 : 0;
+ }
+ }
+
+ /**
+ * Small optimization where we try to buffer directly to the consumer's
+ * output array for one round (if consumer calls this method first!) instead
+ * of starting our own buffer.
+ *
+ * @param out byte[] array to buffer directly to.
+ * @param outPos Position to start buffering into.
+ * @param outAvail Amount of bytes available for direct buffering.
+ */
+ void setInitialBuffer(byte[] out, int outPos, int outAvail) {
+ // We can re-use consumer's original output array under
+ // special circumstances, saving on some System.arraycopy().
+ if (out != null && out.length == outAvail) {
+ buf = out;
+ pos = outPos;
+ readPos = outPos;
+ }
+ }
+
+ /**
+ *
+ * Encodes all of the provided data, starting at inPos, for inAvail bytes.
+ * Must be called at least twice: once with the data to encode, and once
+ * with inAvail set to "-1" to alert encoder that EOF has been reached,
+ * so flush last remaining bytes (if not multiple of 3).
+ *
+ * Thanks to "commons" project in ws.apache.org for the bitwise operations,
+ * and general approach.
+ * http://svn.apache.org/repos/asf/webservices/commons/trunk/modules/util/
+ *
+ *
+ * @param in byte[] array of binary data to base64 encode.
+ * @param inPos Position to start reading data from.
+ * @param inAvail Amount of bytes available from input for encoding.
+ */
+ void encode(byte[] in, int inPos, int inAvail) {
+ if (eof) {
+ return;
+ }
+
+ // inAvail < 0 is how we're informed of EOF in the underlying data we're
+ // encoding.
+ if (inAvail < 0) {
+ eof = true;
+ if (buf == null || buf.length - pos < encodeSize) {
+ resizeBuf();
+ }
+ switch (modulus) {
+ case 1:
+ buf[pos++] = intToBase64[(x >> 2) & MASK_6BITS];
+ buf[pos++] = intToBase64[(x << 4) & MASK_6BITS];
+ buf[pos++] = PAD;
+ buf[pos++] = PAD;
+ break;
+
+ case 2:
+ buf[pos++] = intToBase64[(x >> 10) & MASK_6BITS];
+ buf[pos++] = intToBase64[(x >> 4) & MASK_6BITS];
+ buf[pos++] = intToBase64[(x << 2) & MASK_6BITS];
+ buf[pos++] = PAD;
+ break;
+ }
+ if (lineLength > 0) {
+ System.arraycopy(lineSeparator, 0, buf, pos, lineSeparator.length);
+ pos += lineSeparator.length;
+ }
+ } else {
+ for (int i = 0; i < inAvail; i++) {
+ if (buf == null || buf.length - pos < encodeSize) {
+ resizeBuf();
+ }
+ modulus = (++modulus) % 3;
+ int b = in[inPos++];
+ if (b < 0) { b += 256; }
+ x = (x << 8) + b;
+ if (0 == modulus) {
+ buf[pos++] = intToBase64[(x >> 18) & MASK_6BITS];
+ buf[pos++] = intToBase64[(x >> 12) & MASK_6BITS];
+ buf[pos++] = intToBase64[(x >> 6) & MASK_6BITS];
+ buf[pos++] = intToBase64[x & MASK_6BITS];
+ currentLinePos += 4;
+ if (lineLength > 0 && lineLength <= currentLinePos) {
+ System.arraycopy(lineSeparator, 0, buf, pos, lineSeparator.length);
+ pos += lineSeparator.length;
+ currentLinePos = 0;
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ *
+ * Decodes all of the provided data, starting at inPos, for inAvail bytes.
+ * Should be called at least twice: once with the data to decode, and once
+ * with inAvail set to "-1" to alert decoder that EOF has been reached.
+ * The "-1" call is not necessary when decoding, but it doesn't hurt, either.
+ *
+ * Ignores all non-base64 characters. This is how chunked (e.g. 76 character)
+ * data is handled, since CR and LF are silently ignored, but has implications
+ * for other bytes, too. This method subscribes to the garbage-in, garbage-out
+ * philosophy: it will not check the provided data for validity.
+ *
+ * Thanks to "commons" project in ws.apache.org for the bitwise operations,
+ * and general approach.
+ * http://svn.apache.org/repos/asf/webservices/commons/trunk/modules/util/
+ *
+
+ * @param in byte[] array of ascii data to base64 decode.
+ * @param inPos Position to start reading data from.
+ * @param inAvail Amount of bytes available from input for encoding.
+ */
+ void decode(byte[] in, int inPos, int inAvail) {
+ if (eof) {
+ return;
+ }
+ if (inAvail < 0) {
+ eof = true;
+ }
+ for (int i = 0; i < inAvail; i++) {
+ if (buf == null || buf.length - pos < decodeSize) {
+ resizeBuf();
+ }
+ byte b = in[inPos++];
+ if (b == PAD) {
+ x = x << 6;
+ switch (modulus) {
+ case 2:
+ x = x << 6;
+ buf[pos++] = (byte) ((x >> 16) & MASK_8BITS);
+ break;
+ case 3:
+ buf[pos++] = (byte) ((x >> 16) & MASK_8BITS);
+ buf[pos++] = (byte) ((x >> 8) & MASK_8BITS);
+ break;
+ }
+ // WE'RE DONE!!!!
+ eof = true;
+ return;
+ } else {
+ if (b >= 0 && b < base64ToInt.length) {
+ int result = base64ToInt[b];
+ if (result >= 0) {
+ modulus = (++modulus) % 4;
+ x = (x << 6) + result;
+ if (modulus == 0) {
+ buf[pos++] = (byte) ((x >> 16) & MASK_8BITS);
+ buf[pos++] = (byte) ((x >> 8) & MASK_8BITS);
+ buf[pos++] = (byte) (x & MASK_8BITS);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Returns whether or not the octet is in the base 64 alphabet.
+ *
+ * @param octet
+ * The value to test
+ * @return true if the value is defined in the the base 64 alphabet, false otherwise.
+ */
+ public static boolean isBase64(byte octet) {
+ return octet == PAD || (octet >= 0 && octet < base64ToInt.length && base64ToInt[octet] != -1);
+ }
+
+ /**
+ * Tests a given byte array to see if it contains only valid characters within the Base64 alphabet.
+ * Currently the method treats whitespace as valid.
+ *
+ * @param arrayOctet
+ * byte array to test
+ * @return true if all bytes are valid characters in the Base64 alphabet or if the byte array is
+ * empty; false, otherwise
+ */
+ public static boolean isArrayByteBase64(byte[] arrayOctet) {
+ for (int i = 0; i < arrayOctet.length; i++) {
+ if (!isBase64(arrayOctet[i]) && !isWhiteSpace(arrayOctet[i])) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /*
+ * Tests a given byte array to see if it contains only valid characters within the Base64 alphabet.
+ *
+ * @param arrayOctet
+ * byte array to test
+ * @return true if any byte is a valid character in the Base64 alphabet; false herwise
+ */
+ private static boolean containsBase64Byte(byte[] arrayOctet) {
+ for (int i = 0; i < arrayOctet.length; i++) {
+ if (isBase64(arrayOctet[i])) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Encodes binary data using the base64 algorithm but does not chunk the output.
+ *
+ * @param binaryData
+ * binary data to encode
+ * @return Base64 characters
+ */
+ public static byte[] encodeBase64(byte[] binaryData) {
+ return encodeBase64(binaryData, false);
+ }
+
+ /**
+ * Encodes binary data using the base64 algorithm and chunks the encoded output into 76 character blocks
+ *
+ * @param binaryData
+ * binary data to encode
+ * @return Base64 characters chunked in 76 character blocks
+ */
+ public static byte[] encodeBase64Chunked(byte[] binaryData) {
+ return encodeBase64(binaryData, true);
+ }
+
+ /**
+ * Decodes an Object using the base64 algorithm. This method is provided in order to satisfy the requirements of the
+ * Decoder interface, and will throw a DecoderException if the supplied object is not of type byte[].
+ *
+ * @param pObject
+ * Object to decode
+ * @return An object (of type byte[]) containing the binary data which corresponds to the byte[] supplied.
+ * @throws DecoderException
+ * if the parameter supplied is not of type byte[]
+ */
+ public Object decode(Object pObject) throws DecoderException {
+ if (!(pObject instanceof byte[])) {
+ throw new DecoderException("Parameter supplied to Base64 decode is not a byte[]");
+ }
+ return decode((byte[]) pObject);
+ }
+
+ /**
+ * Decodes a byte[] containing containing characters in the Base64 alphabet.
+ *
+ * @param pArray
+ * A byte array containing Base64 character data
+ * @return a byte array containing binary data
+ */
+ public byte[] decode(byte[] pArray) {
+ return decodeBase64(pArray);
+ }
+
+ /**
+ * Encodes binary data using the base64 algorithm, optionally chunking the output into 76 character blocks.
+ *
+ * @param binaryData
+ * Array containing binary data to encode.
+ * @param isChunked
+ * if true this encoder will chunk the base64 output into 76 character blocks
+ * @return Base64-encoded data.
+ * @throws IllegalArgumentException
+ * Thrown when the input array needs an output array bigger than {@link Integer#MAX_VALUE}
+ */
+ public static byte[] encodeBase64(byte[] binaryData, boolean isChunked) {
+ if (binaryData == null || binaryData.length == 0) {
+ return binaryData;
+ }
+ Base64 b64 = isChunked ? new Base64() : new Base64(0);
+
+ long len = (binaryData.length * 4) / 3;
+ long mod = len % 4;
+ if (mod != 0) {
+ len += 4 - mod;
+ }
+ if (isChunked) {
+ len += (1 + (len / CHUNK_SIZE)) * CHUNK_SEPARATOR.length;
+ }
+
+ if (len > Integer.MAX_VALUE) {
+ throw new IllegalArgumentException(
+ "Input array too big, output array would be bigger than Integer.MAX_VALUE=" + Integer.MAX_VALUE);
+ }
+ byte[] buf = new byte[(int) len];
+ b64.setInitialBuffer(buf, 0, buf.length);
+ b64.encode(binaryData, 0, binaryData.length);
+ b64.encode(binaryData, 0, -1); // Notify encoder of EOF.
+
+ // Encoder might have resized, even though it was unnecessary.
+ if (b64.buf != buf) {
+ b64.readResults(buf, 0, buf.length);
+ }
+ return buf;
+ }
+
+ /**
+ * Decodes Base64 data into octets
+ *
+ * @param base64Data Byte array containing Base64 data
+ * @return Array containing decoded data.
+ */
+ public static byte[] decodeBase64(byte[] base64Data) {
+ if (base64Data == null || base64Data.length == 0) {
+ return base64Data;
+ }
+ Base64 b64 = new Base64();
+
+ long len = (base64Data.length * 3) / 4;
+ byte[] buf = new byte[(int) len];
+ b64.setInitialBuffer(buf, 0, buf.length);
+ b64.decode(base64Data, 0, base64Data.length);
+ b64.decode(base64Data, 0, -1); // Notify decoder of EOF.
+
+ // We have no idea what the line-length was, so we
+ // cannot know how much of our array wasn't used.
+ byte[] result = new byte[b64.pos];
+ b64.readResults(result, 0, result.length);
+ return result;
+ }
+
+ /**
+ * Discards any whitespace from a base-64 encoded block.
+ *
+ * @param data
+ * The base-64 encoded data to discard the whitespace from.
+ * @return The data, less whitespace (see RFC 2045).
+ * @deprecated This method is no longer needed
+ */
+ static byte[] discardWhitespace(byte[] data) {
+ byte groomedData[] = new byte[data.length];
+ int bytesCopied = 0;
+
+ for (int i = 0; i < data.length; i++) {
+ switch (data[i]) {
+ case ' ' :
+ case '\n' :
+ case '\r' :
+ case '\t' :
+ break;
+ default :
+ groomedData[bytesCopied++] = data[i];
+ }
+ }
+
+ byte packedData[] = new byte[bytesCopied];
+
+ System.arraycopy(groomedData, 0, packedData, 0, bytesCopied);
+
+ return packedData;
+ }
+
+
+ /**
+ * Check if a byte value is whitespace or not.
+ *
+ * @param byteToCheck the byte to check
+ * @return true if byte is whitespace, false otherwise
+ */
+ private static boolean isWhiteSpace(byte byteToCheck){
+ switch (byteToCheck) {
+ case ' ' :
+ case '\n' :
+ case '\r' :
+ case '\t' :
+ return true;
+ default :
+ return false;
+ }
+ }
+
+ /**
+ * Discards any characters outside of the base64 alphabet, per the requirements on page 25 of RFC 2045 - "Any
+ * characters outside of the base64 alphabet are to be ignored in base64 encoded data."
+ *
+ * @param data
+ * The base-64 encoded data to groom
+ * @return The data, less non-base64 characters (see RFC 2045).
+ */
+ static byte[] discardNonBase64(byte[] data) {
+ byte groomedData[] = new byte[data.length];
+ int bytesCopied = 0;
+
+ for (int i = 0; i < data.length; i++) {
+ if (isBase64(data[i])) {
+ groomedData[bytesCopied++] = data[i];
+ }
+ }
+
+ byte packedData[] = new byte[bytesCopied];
+
+ System.arraycopy(groomedData, 0, packedData, 0, bytesCopied);
+
+ return packedData;
+ }
+
+ // Implementation of the Encoder Interface
+
+ /**
+ * Encodes an Object using the base64 algorithm. This method is provided in order to satisfy the requirements of the
+ * Encoder interface, and will throw an EncoderException if the supplied object is not of type byte[].
+ *
+ * @param pObject
+ * Object to encode
+ * @return An object (of type byte[]) containing the base64 encoded data which corresponds to the byte[] supplied.
+ * @throws EncoderException
+ * if the parameter supplied is not of type byte[]
+ */
+ public Object encode(Object pObject) throws EncoderException {
+ if (!(pObject instanceof byte[])) {
+ throw new EncoderException("Parameter supplied to Base64 encode is not a byte[]");
+ }
+ return encode((byte[]) pObject);
+ }
+
+ /**
+ * Encodes a byte[] containing binary data, into a byte[] containing characters in the Base64 alphabet.
+ *
+ * @param pArray
+ * a byte array containing binary data
+ * @return A byte array containing only Base64 character data
+ */
+ public byte[] encode(byte[] pArray) {
+ return encodeBase64(pArray, false);
+ }
+
+ // Implementation of integer encoding used for crypto
+ /**
+ * Decode a byte64-encoded integer according to crypto
+ * standards such as W3C's XML-Signature
+ *
+ * @param pArray a byte array containing base64 character data
+ * @return A BigInteger
+ */
+ public static BigInteger decodeInteger(byte[] pArray) {
+ return new BigInteger(1, decodeBase64(pArray));
+ }
+
+ /**
+ * Encode to a byte64-encoded integer according to crypto
+ * standards such as W3C's XML-Signature
+ *
+ * @param bigInt a BigInteger
+ * @return A byte array containing base64 character data
+ * @throws NullPointerException if null is passed in
+ */
+ public static byte[] encodeInteger(BigInteger bigInt) {
+ if(bigInt == null) {
+ throw new NullPointerException("encodeInteger called with null parameter");
+ }
+
+ return encodeBase64(toIntegerBytes(bigInt), false);
+ }
+
+ /**
+ * Returns a byte-array representation of a BigInteger
+ * without sign bit.
+ *
+ * @param bigInt BigInteger to be converted
+ * @return a byte array representation of the BigInteger parameter
+ */
+ static byte[] toIntegerBytes(BigInteger bigInt) {
+ int bitlen = bigInt.bitLength();
+ // round bitlen
+ bitlen = ((bitlen + 7) >> 3) << 3;
+ byte[] bigBytes = bigInt.toByteArray();
+
+ if(((bigInt.bitLength() % 8) != 0) &&
+ (((bigInt.bitLength() / 8) + 1) == (bitlen / 8))) {
+ return bigBytes;
+ }
+
+ // set up params for copying everything but sign bit
+ int startSrc = 0;
+ int len = bigBytes.length;
+
+ // if bigInt is exactly byte-aligned, just skip signbit in copy
+ if((bigInt.bitLength() % 8) == 0) {
+ startSrc = 1;
+ len--;
+ }
+
+ int startDst = bitlen / 8 - len; // to pad w/ nulls as per spec
+ byte[] resizedBytes = new byte[bitlen / 8];
+
+ System.arraycopy(bigBytes, startSrc, resizedBytes, startDst, len);
+
+ return resizedBytes;
+ }
+}
diff --git a/src/com/android/email/codec/binary/Base64OutputStream.java b/src/com/android/email/codec/binary/Base64OutputStream.java
new file mode 100644
index 000000000..ba59c3c95
--- /dev/null
+++ b/src/com/android/email/codec/binary/Base64OutputStream.java
@@ -0,0 +1,179 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.email.codec.binary;
+
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * Provides Base64 encoding and decoding in a streaming fashion (unlimited size).
+ * When encoding the default lineLength is 76 characters and the default
+ * lineEnding is CRLF, but these can be overridden by using the appropriate
+ * constructor.
+ *
+ * The default behaviour of the Base64OutputStream is to ENCODE, whereas the
+ * default behaviour of the Base64InputStream is to DECODE. But this behaviour
+ * can be overridden by using a different constructor.
+ *
+ * This class implements section 6.8. Base64 Content-Transfer-Encoding from RFC 2045 Multipurpose
+ * Internet Mail Extensions (MIME) Part One: Format of Internet Message Bodies by Freed and Borenstein.
+ *
+ *
+ * @author Apache Software Foundation
+ * @version $Id $
+ * @see RFC 2045
+ * @since 1.0-dev
+ */
+public class Base64OutputStream extends FilterOutputStream {
+ private final boolean doEncode;
+ private final Base64 base64;
+ private final byte[] singleByte = new byte[1];
+
+ /**
+ * Creates a Base64OutputStream such that all data written is Base64-encoded
+ * to the original provided OutputStream.
+ *
+ * @param out OutputStream to wrap.
+ */
+ public Base64OutputStream(OutputStream out) {
+ this(out, true);
+ }
+
+ /**
+ * Creates a Base64OutputStream such that all data written is either
+ * Base64-encoded or Base64-decoded to the original provided OutputStream.
+ *
+ * @param out OutputStream to wrap.
+ * @param doEncode true if we should encode all data written to us,
+ * false if we should decode.
+ */
+ public Base64OutputStream(OutputStream out, boolean doEncode) {
+ super(out);
+ this.doEncode = doEncode;
+ this.base64 = new Base64();
+ }
+
+ /**
+ * Creates a Base64OutputStream such that all data written is either
+ * Base64-encoded or Base64-decoded to the original provided OutputStream.
+ *
+ * @param out OutputStream to wrap.
+ * @param doEncode true if we should encode all data written to us,
+ * false if we should decode.
+ * @param lineLength If doEncode is true, each line of encoded
+ * data will contain lineLength characters.
+ * If lineLength <=0, the encoded data is not divided into lines.
+ * If doEncode is false, lineLength is ignored.
+ * @param lineSeparator If doEncode is true, each line of encoded
+ * data will be terminated with this byte sequence (e.g. \r\n).
+ * If lineLength <= 0, the lineSeparator is not used.
+ * If doEncode is false lineSeparator is ignored.
+ */
+ public Base64OutputStream(OutputStream out, boolean doEncode, int lineLength, byte[] lineSeparator) {
+ super(out);
+ this.doEncode = doEncode;
+ this.base64 = new Base64(lineLength, lineSeparator);
+ }
+
+ /**
+ * Writes the specified byte to this output stream.
+ */
+ public void write(int i) throws IOException {
+ singleByte[0] = (byte) i;
+ write(singleByte, 0, 1);
+ }
+
+ /**
+ * Writes len bytes from the specified
+ * b array starting at offset to
+ * this output stream.
+ *
+ * @param b source byte array
+ * @param offset where to start reading the bytes
+ * @param len maximum number of bytes to write
+ *
+ * @throws IOException if an I/O error occurs.
+ * @throws NullPointerException if the byte array parameter is null
+ * @throws IndexOutOfBoundsException if offset, len or buffer size are invalid
+ */
+ public void write(byte b[], int offset, int len) throws IOException {
+ if (b == null) {
+ throw new NullPointerException();
+ } else if (offset < 0 || len < 0 || offset + len < 0) {
+ throw new IndexOutOfBoundsException();
+ } else if (offset > b.length || offset + len > b.length) {
+ throw new IndexOutOfBoundsException();
+ } else if (len > 0) {
+ if (doEncode) {
+ base64.encode(b, offset, len);
+ } else {
+ base64.decode(b, offset, len);
+ }
+ flush(false);
+ }
+ }
+
+ /**
+ * Flushes this output stream and forces any buffered output bytes
+ * to be written out to the stream. If propogate is true, the wrapped
+ * stream will also be flushed.
+ *
+ * @param propogate boolean flag to indicate whether the wrapped
+ * OutputStream should also be flushed.
+ * @throws IOException if an I/O error occurs.
+ */
+ private void flush(boolean propogate) throws IOException {
+ int avail = base64.avail();
+ if (avail > 0) {
+ byte[] buf = new byte[avail];
+ int c = base64.readResults(buf, 0, avail);
+ if (c > 0) {
+ out.write(buf, 0, c);
+ }
+ }
+ if (propogate) {
+ out.flush();
+ }
+ }
+
+ /**
+ * Flushes this output stream and forces any buffered output bytes
+ * to be written out to the stream.
+ *
+ * @throws IOException if an I/O error occurs.
+ */
+ public void flush() throws IOException {
+ flush(true);
+ }
+
+ /**
+ * Closes this output stream, flushing any remaining bytes that must be encoded. The
+ * underlying stream is flushed but not closed.
+ */
+ public void close() throws IOException {
+ // Notify encoder of EOF (-1).
+ if (doEncode) {
+ base64.encode(singleByte, 0, -1);
+ } else {
+ base64.decode(singleByte, 0, -1);
+ }
+ flush();
+ }
+
+}
diff --git a/src/com/android/email/mail/Address.java b/src/com/android/email/mail/Address.java
new file mode 100644
index 000000000..f5a4ee450
--- /dev/null
+++ b/src/com/android/email/mail/Address.java
@@ -0,0 +1,215 @@
+
+package com.android.email.mail;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.util.ArrayList;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.james.mime4j.field.address.AddressList;
+import org.apache.james.mime4j.field.address.Mailbox;
+import org.apache.james.mime4j.field.address.MailboxList;
+import org.apache.james.mime4j.field.address.NamedMailbox;
+import org.apache.james.mime4j.field.address.parser.ParseException;
+
+import android.util.Config;
+import android.util.Log;
+
+import com.android.email.Email;
+import com.android.email.Utility;
+import com.android.email.mail.internet.MimeUtility;
+
+public class Address {
+ String mAddress;
+
+ String mPersonal;
+
+ public Address(String address, String personal) {
+ this.mAddress = address;
+ this.mPersonal = personal;
+ }
+
+ public Address(String address) {
+ this.mAddress = address;
+ }
+
+ public String getAddress() {
+ return mAddress;
+ }
+
+ public void setAddress(String address) {
+ this.mAddress = address;
+ }
+
+ public String getPersonal() {
+ return mPersonal;
+ }
+
+ public void setPersonal(String personal) {
+ this.mPersonal = personal;
+ }
+
+ /**
+ * Parse a comma separated list of addresses in RFC-822 format and return an
+ * array of Address objects.
+ *
+ * @param addressList
+ * @return An array of 0 or more Addresses.
+ */
+ public static Address[] parse(String addressList) {
+ ArrayList addresses = new ArrayList();
+ if (addressList == null) {
+ return new Address[] {};
+ }
+ try {
+ MailboxList parsedList = AddressList.parse(addressList).flatten();
+ for (int i = 0, count = parsedList.size(); i < count; i++) {
+ org.apache.james.mime4j.field.address.Address address = parsedList.get(i);
+ if (address instanceof NamedMailbox) {
+ NamedMailbox namedMailbox = (NamedMailbox)address;
+ addresses.add(new Address(namedMailbox.getLocalPart() + "@"
+ + namedMailbox.getDomain(), namedMailbox.getName()));
+ } else if (address instanceof Mailbox) {
+ Mailbox mailbox = (Mailbox)address;
+ addresses.add(new Address(mailbox.getLocalPart() + "@" + mailbox.getDomain()));
+ } else {
+ Log.e(Email.LOG_TAG, "Unknown address type from Mime4J: "
+ + address.getClass().toString());
+ }
+
+ }
+ } catch (ParseException pe) {
+ }
+ return addresses.toArray(new Address[] {});
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o instanceof Address) {
+ return getAddress().equals(((Address) o).getAddress());
+ }
+ return super.equals(o);
+ }
+
+ public String toString() {
+ if (mPersonal != null) {
+ if (mPersonal.matches(".*[\\(\\)<>@,;:\\\\\".\\[\\]].*")) {
+ return Utility.quoteString(mPersonal) + " <" + mAddress + ">";
+ } else {
+ return mPersonal + " <" + mAddress + ">";
+ }
+ } else {
+ return mAddress;
+ }
+ }
+
+ public static String toString(Address[] addresses) {
+ if (addresses == null) {
+ return null;
+ }
+ StringBuffer sb = new StringBuffer();
+ for (int i = 0; i < addresses.length; i++) {
+ sb.append(addresses[i].toString());
+ if (i < addresses.length - 1) {
+ sb.append(',');
+ }
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Returns either the personal portion of the Address or the address portion if the personal
+ * is not available.
+ * @return
+ */
+ public String toFriendly() {
+ if (mPersonal != null && mPersonal.length() > 0) {
+ return mPersonal;
+ }
+ else {
+ return mAddress;
+ }
+ }
+
+ public static String toFriendly(Address[] addresses) {
+ if (addresses == null) {
+ return null;
+ }
+ StringBuffer sb = new StringBuffer();
+ for (int i = 0; i < addresses.length; i++) {
+ sb.append(addresses[i].toFriendly());
+ if (i < addresses.length - 1) {
+ sb.append(',');
+ }
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Unpacks an address list previously packed with packAddressList()
+ * @param list
+ * @return
+ */
+ public static Address[] unpack(String addressList) {
+ if (addressList == null) {
+ return new Address[] { };
+ }
+ ArrayList addresses = new ArrayList();
+ int length = addressList.length();
+ int pairStartIndex = 0;
+ int pairEndIndex = 0;
+ int addressEndIndex = 0;
+ while (pairStartIndex < length) {
+ pairEndIndex = addressList.indexOf(',', pairStartIndex);
+ if (pairEndIndex == -1) {
+ pairEndIndex = length;
+ }
+ addressEndIndex = addressList.indexOf(';', pairStartIndex);
+ String address = null;
+ String personal = null;
+ if (addressEndIndex == -1 || addressEndIndex > pairEndIndex) {
+ address = Utility.fastUrlDecode(addressList.substring(pairStartIndex, pairEndIndex));
+ }
+ else {
+ address = Utility.fastUrlDecode(addressList.substring(pairStartIndex, addressEndIndex));
+ personal = Utility.fastUrlDecode(addressList.substring(addressEndIndex + 1, pairEndIndex));
+ }
+ addresses.add(new Address(address, personal));
+ pairStartIndex = pairEndIndex + 1;
+ }
+ return addresses.toArray(new Address[] { });
+ }
+
+ /**
+ * Packs an address list into a String that is very quick to read
+ * and parse. Packed lists can be unpacked with unpackAddressList()
+ * The packed list is a comma seperated list of:
+ * URLENCODE(address)[;URLENCODE(personal)]
+ * @param list
+ * @return
+ */
+ public static String pack(Address[] addresses) {
+ if (addresses == null) {
+ return null;
+ }
+ StringBuffer sb = new StringBuffer();
+ for (int i = 0, count = addresses.length; i < count; i++) {
+ Address address = addresses[i];
+ try {
+ sb.append(URLEncoder.encode(address.getAddress(), "UTF-8"));
+ if (address.getPersonal() != null) {
+ sb.append(';');
+ sb.append(URLEncoder.encode(address.getPersonal(), "UTF-8"));
+ }
+ if (i < count - 1) {
+ sb.append(',');
+ }
+ }
+ catch (UnsupportedEncodingException uee) {
+ return null;
+ }
+ }
+ return sb.toString();
+ }
+}
diff --git a/src/com/android/email/mail/AuthenticationFailedException.java b/src/com/android/email/mail/AuthenticationFailedException.java
new file mode 100644
index 000000000..dc79f6c79
--- /dev/null
+++ b/src/com/android/email/mail/AuthenticationFailedException.java
@@ -0,0 +1,14 @@
+
+package com.android.email.mail;
+
+public class AuthenticationFailedException extends MessagingException {
+ public static final long serialVersionUID = -1;
+
+ public AuthenticationFailedException(String message) {
+ super(message);
+ }
+
+ public AuthenticationFailedException(String message, Throwable throwable) {
+ super(message, throwable);
+ }
+}
diff --git a/src/com/android/email/mail/Body.java b/src/com/android/email/mail/Body.java
new file mode 100644
index 000000000..6dacfa5c1
--- /dev/null
+++ b/src/com/android/email/mail/Body.java
@@ -0,0 +1,11 @@
+
+package com.android.email.mail;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+public interface Body {
+ public InputStream getInputStream() throws MessagingException;
+ public void writeTo(OutputStream out) throws IOException, MessagingException;
+}
diff --git a/src/com/android/email/mail/BodyPart.java b/src/com/android/email/mail/BodyPart.java
new file mode 100644
index 000000000..ff7c8d62b
--- /dev/null
+++ b/src/com/android/email/mail/BodyPart.java
@@ -0,0 +1,10 @@
+
+package com.android.email.mail;
+
+public abstract class BodyPart implements Part {
+ protected Multipart mParent;
+
+ public Multipart getParent() {
+ return mParent;
+ }
+}
diff --git a/src/com/android/email/mail/CertificateValidationException.java b/src/com/android/email/mail/CertificateValidationException.java
new file mode 100644
index 000000000..17bba50cc
--- /dev/null
+++ b/src/com/android/email/mail/CertificateValidationException.java
@@ -0,0 +1,14 @@
+
+package com.android.email.mail;
+
+public class CertificateValidationException extends MessagingException {
+ public static final long serialVersionUID = -1;
+
+ public CertificateValidationException(String message) {
+ super(message);
+ }
+
+ public CertificateValidationException(String message, Throwable throwable) {
+ super(message, throwable);
+ }
+}
\ No newline at end of file
diff --git a/src/com/android/email/mail/FetchProfile.java b/src/com/android/email/mail/FetchProfile.java
new file mode 100644
index 000000000..558fec8bc
--- /dev/null
+++ b/src/com/android/email/mail/FetchProfile.java
@@ -0,0 +1,57 @@
+
+package com.android.email.mail;
+
+import java.util.ArrayList;
+
+/**
+ *
+ * A FetchProfile is a list of items that should be downloaded in bulk for a set of messages.
+ * FetchProfile can contain the following objects:
+ * FetchProfile.Item: Described below.
+ * Message: Indicates that the body of the entire message should be fetched.
+ * Synonymous with FetchProfile.Item.BODY.
+ * Part: Indicates that the given Part should be fetched. The provider
+ * is expected have previously created the given BodyPart and stored
+ * any information it needs to download the content.
+ *
+ */
+public class FetchProfile extends ArrayList {
+ /**
+ * Default items available for pre-fetching. It should be expected that any
+ * item fetched by using these items could potentially include all of the
+ * previous items.
+ */
+ public enum Item {
+ /**
+ * Download the flags of the message.
+ */
+ FLAGS,
+
+ /**
+ * Download the envelope of the message. This should include at minimum
+ * the size and the following headers: date, subject, from, content-type, to, cc
+ */
+ ENVELOPE,
+
+ /**
+ * Download the structure of the message. This maps directly to IMAP's BODYSTRUCTURE
+ * and may map to other providers.
+ * The provider should, if possible, fill in a properly formatted MIME structure in
+ * the message without actually downloading any message data. If the provider is not
+ * capable of this operation it should specifically set the body of the message to null
+ * so that upper levels can detect that a full body download is needed.
+ */
+ STRUCTURE,
+
+ /**
+ * A sane portion of the entire message, cut off at a provider determined limit.
+ * This should generaly be around 50kB.
+ */
+ BODY_SANE,
+
+ /**
+ * The entire message.
+ */
+ BODY,
+ }
+}
diff --git a/src/com/android/email/mail/Flag.java b/src/com/android/email/mail/Flag.java
new file mode 100644
index 000000000..71f299645
--- /dev/null
+++ b/src/com/android/email/mail/Flag.java
@@ -0,0 +1,48 @@
+
+package com.android.email.mail;
+
+/**
+ * Flags that can be applied to Messages.
+ */
+public enum Flag {
+ DELETED,
+ SEEN,
+ ANSWERED,
+ FLAGGED,
+ DRAFT,
+ RECENT,
+
+ /*
+ * The following flags are for internal library use only.
+ * TODO Eventually we should creates a Flags class that extends ArrayList that allows
+ * these flags and Strings to represent user defined flags. At that point the below
+ * flags should become user defined flags.
+ */
+ /**
+ * Delete and remove from the LocalStore immediately.
+ */
+ X_DESTROYED,
+
+ /**
+ * Sending of an unsent message failed. It will be retried. Used to show status.
+ */
+ X_SEND_FAILED,
+
+ /**
+ * Sending of an unsent message is in progress.
+ */
+ X_SEND_IN_PROGRESS,
+
+ /**
+ * Indicates that a message is fully downloaded from the server and can be viewed normally.
+ * This does not include attachments, which are never downloaded fully.
+ */
+ X_DOWNLOADED_FULL,
+
+ /**
+ * Indicates that a message is partially downloaded from the server and can be viewed but
+ * more content is available on the server.
+ * This does not include attachments, which are never downloaded fully.
+ */
+ X_DOWNLOADED_PARTIAL,
+}
diff --git a/src/com/android/email/mail/Folder.java b/src/com/android/email/mail/Folder.java
new file mode 100644
index 000000000..853efc4ab
--- /dev/null
+++ b/src/com/android/email/mail/Folder.java
@@ -0,0 +1,107 @@
+package com.android.email.mail;
+
+
+public abstract class Folder {
+ public enum OpenMode {
+ READ_WRITE, READ_ONLY,
+ }
+
+ public enum FolderType {
+ HOLDS_FOLDERS, HOLDS_MESSAGES,
+ }
+
+ /**
+ * Forces an open of the MailProvider. If the provider is already open this
+ * function returns without doing anything.
+ *
+ * @param mode READ_ONLY or READ_WRITE
+ */
+ public abstract void open(OpenMode mode) throws MessagingException;
+
+ /**
+ * Forces a close of the MailProvider. Any further access will attempt to
+ * reopen the MailProvider.
+ *
+ * @param expunge If true all deleted messages will be expunged.
+ */
+ public abstract void close(boolean expunge) throws MessagingException;
+
+ /**
+ * @return True if further commands are not expected to have to open the
+ * connection.
+ */
+ public abstract boolean isOpen();
+
+ /**
+ * Get the mode the folder was opened with. This may be different than the mode the open
+ * was requested with.
+ * @return
+ */
+ public abstract OpenMode getMode() throws MessagingException;
+
+ public abstract boolean create(FolderType type) throws MessagingException;
+
+ /**
+ * Create a new folder with a specified display limit. Not abstract to allow
+ * remote folders to not override or worry about this call if they don't care to.
+ */
+ public boolean create(FolderType type, int displayLimit) throws MessagingException {
+ return create(type);
+ }
+
+ public abstract boolean exists() throws MessagingException;
+
+ /**
+ * @return A count of the messages in the selected folder.
+ */
+ public abstract int getMessageCount() throws MessagingException;
+
+ public abstract int getUnreadMessageCount() throws MessagingException;
+
+ public abstract Message getMessage(String uid) throws MessagingException;
+
+ public abstract Message[] getMessages(int start, int end, MessageRetrievalListener listener)
+ throws MessagingException;
+
+ /**
+ * Fetches the given list of messages. The specified listener is notified as
+ * each fetch completes. Messages are downloaded as (as) lightweight (as
+ * possible) objects to be filled in with later requests. In most cases this
+ * means that only the UID is downloaded.
+ *
+ * @param uids
+ * @param listener
+ */
+ public abstract Message[] getMessages(MessageRetrievalListener listener)
+ throws MessagingException;
+
+ public abstract Message[] getMessages(String[] uids, MessageRetrievalListener listener)
+ throws MessagingException;
+
+ public abstract void appendMessages(Message[] messages) throws MessagingException;
+
+ public abstract void copyMessages(Message[] msgs, Folder folder) throws MessagingException;
+
+ public abstract void setFlags(Message[] messages, Flag[] flags, boolean value)
+ throws MessagingException;
+
+ public abstract Message[] expunge() throws MessagingException;
+
+ public abstract void fetch(Message[] messages, FetchProfile fp,
+ MessageRetrievalListener listener) throws MessagingException;
+
+ public abstract void delete(boolean recurse) throws MessagingException;
+
+ public abstract String getName();
+
+ public abstract Flag[] getPermanentFlags() throws MessagingException;
+
+ public boolean supportsFetchingFlags() {
+ return true;
+ }//isFlagSupported
+
+ @Override
+ public String toString() {
+ return getName();
+ }
+}
diff --git a/src/com/android/email/mail/Message.java b/src/com/android/email/mail/Message.java
new file mode 100644
index 000000000..5f13c33ff
--- /dev/null
+++ b/src/com/android/email/mail/Message.java
@@ -0,0 +1,118 @@
+
+package com.android.email.mail;
+
+import java.util.Date;
+import java.util.HashSet;
+
+public abstract class Message implements Part, Body {
+ public enum RecipientType {
+ TO, CC, BCC,
+ }
+
+ protected String mUid;
+
+ protected HashSet mFlags = new HashSet();
+
+ protected Date mInternalDate;
+
+ protected Folder mFolder;
+
+ public String getUid() {
+ return mUid;
+ }
+
+ public void setUid(String uid) {
+ this.mUid = uid;
+ }
+
+ public Folder getFolder() {
+ return mFolder;
+ }
+
+ public abstract String getSubject() throws MessagingException;
+
+ public abstract void setSubject(String subject) throws MessagingException;
+
+ public Date getInternalDate() {
+ return mInternalDate;
+ }
+
+ public void setInternalDate(Date internalDate) {
+ this.mInternalDate = internalDate;
+ }
+
+ public abstract Date getReceivedDate() throws MessagingException;
+
+ public abstract Date getSentDate() throws MessagingException;
+
+ public abstract void setSentDate(Date sentDate) throws MessagingException;
+
+ public abstract Address[] getRecipients(RecipientType type) throws MessagingException;
+
+ public abstract void setRecipients(RecipientType type, Address[] addresses)
+ throws MessagingException;
+
+ public void setRecipient(RecipientType type, Address address) throws MessagingException {
+ setRecipients(type, new Address[] {
+ address
+ });
+ }
+
+ public abstract Address[] getFrom() throws MessagingException;
+
+ public abstract void setFrom(Address from) throws MessagingException;
+
+ public abstract Address[] getReplyTo() throws MessagingException;
+
+ public abstract void setReplyTo(Address[] from) throws MessagingException;
+
+ public abstract Body getBody() throws MessagingException;
+
+ public abstract String getContentType() throws MessagingException;
+
+ public abstract void addHeader(String name, String value) throws MessagingException;
+
+ public abstract void setHeader(String name, String value) throws MessagingException;
+
+ public abstract String[] getHeader(String name) throws MessagingException;
+
+ public abstract void removeHeader(String name) throws MessagingException;
+
+ public abstract void setBody(Body body) throws MessagingException;
+
+ public boolean isMimeType(String mimeType) throws MessagingException {
+ return getContentType().startsWith(mimeType);
+ }
+
+ /*
+ * TODO Refactor Flags at some point to be able to store user defined flags.
+ */
+ public Flag[] getFlags() {
+ return mFlags.toArray(new Flag[] {});
+ }
+
+ public void setFlag(Flag flag, boolean set) throws MessagingException {
+ if (set) {
+ mFlags.add(flag);
+ } else {
+ mFlags.remove(flag);
+ }
+ }
+
+ /**
+ * This method calls setFlag(Flag, boolean)
+ * @param flags
+ * @param set
+ */
+ public void setFlags(Flag[] flags, boolean set) throws MessagingException {
+ for (Flag flag : flags) {
+ setFlag(flag, set);
+ }
+ }
+
+ public boolean isSet(Flag flag) {
+ return mFlags.contains(flag);
+ }
+
+ public abstract void saveChanges() throws MessagingException;
+}
diff --git a/src/com/android/email/mail/MessageDateComparator.java b/src/com/android/email/mail/MessageDateComparator.java
new file mode 100644
index 000000000..67b273aa3
--- /dev/null
+++ b/src/com/android/email/mail/MessageDateComparator.java
@@ -0,0 +1,19 @@
+
+package com.android.email.mail;
+
+import java.util.Comparator;
+
+public class MessageDateComparator implements Comparator {
+ public int compare(Message o1, Message o2) {
+ try {
+ if (o1.getSentDate() == null) {
+ return 1;
+ } else if (o2.getSentDate() == null) {
+ return -1;
+ } else
+ return o2.getSentDate().compareTo(o1.getSentDate());
+ } catch (Exception e) {
+ return 0;
+ }
+ }
+}
diff --git a/src/com/android/email/mail/MessageRetrievalListener.java b/src/com/android/email/mail/MessageRetrievalListener.java
new file mode 100644
index 000000000..fd070521d
--- /dev/null
+++ b/src/com/android/email/mail/MessageRetrievalListener.java
@@ -0,0 +1,8 @@
+
+package com.android.email.mail;
+
+public interface MessageRetrievalListener {
+ public void messageStarted(String uid, int number, int ofTotal);
+
+ public void messageFinished(Message message, int number, int ofTotal);
+}
diff --git a/src/com/android/email/mail/MessagingException.java b/src/com/android/email/mail/MessagingException.java
new file mode 100644
index 000000000..8ccd8473e
--- /dev/null
+++ b/src/com/android/email/mail/MessagingException.java
@@ -0,0 +1,14 @@
+
+package com.android.email.mail;
+
+public class MessagingException extends Exception {
+ public static final long serialVersionUID = -1;
+
+ public MessagingException(String message) {
+ super(message);
+ }
+
+ public MessagingException(String message, Throwable throwable) {
+ super(message, throwable);
+ }
+}
diff --git a/src/com/android/email/mail/Multipart.java b/src/com/android/email/mail/Multipart.java
new file mode 100644
index 000000000..1e59a73b0
--- /dev/null
+++ b/src/com/android/email/mail/Multipart.java
@@ -0,0 +1,48 @@
+
+package com.android.email.mail;
+
+import java.util.ArrayList;
+
+public abstract class Multipart implements Body {
+ protected Part mParent;
+
+ protected ArrayList mParts = new ArrayList();
+
+ protected String mContentType;
+
+ public void addBodyPart(BodyPart part) throws MessagingException {
+ mParts.add(part);
+ }
+
+ public void addBodyPart(BodyPart part, int index) throws MessagingException {
+ mParts.add(index, part);
+ }
+
+ public BodyPart getBodyPart(int index) throws MessagingException {
+ return mParts.get(index);
+ }
+
+ public String getContentType() throws MessagingException {
+ return mContentType;
+ }
+
+ public int getCount() throws MessagingException {
+ return mParts.size();
+ }
+
+ public boolean removeBodyPart(BodyPart part) throws MessagingException {
+ return mParts.remove(part);
+ }
+
+ public void removeBodyPart(int index) throws MessagingException {
+ mParts.remove(index);
+ }
+
+ public Part getParent() throws MessagingException {
+ return mParent;
+ }
+
+ public void setParent(Part parent) throws MessagingException {
+ this.mParent = parent;
+ }
+}
diff --git a/src/com/android/email/mail/NoSuchProviderException.java b/src/com/android/email/mail/NoSuchProviderException.java
new file mode 100644
index 000000000..68d663a44
--- /dev/null
+++ b/src/com/android/email/mail/NoSuchProviderException.java
@@ -0,0 +1,14 @@
+
+package com.android.email.mail;
+
+public class NoSuchProviderException extends MessagingException {
+ public static final long serialVersionUID = -1;
+
+ public NoSuchProviderException(String message) {
+ super(message);
+ }
+
+ public NoSuchProviderException(String message, Throwable throwable) {
+ super(message, throwable);
+ }
+}
diff --git a/src/com/android/email/mail/Part.java b/src/com/android/email/mail/Part.java
new file mode 100644
index 000000000..45c38c223
--- /dev/null
+++ b/src/com/android/email/mail/Part.java
@@ -0,0 +1,31 @@
+
+package com.android.email.mail;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+public interface Part {
+ public void addHeader(String name, String value) throws MessagingException;
+
+ public void removeHeader(String name) throws MessagingException;
+
+ public void setHeader(String name, String value) throws MessagingException;
+
+ public Body getBody() throws MessagingException;
+
+ public String getContentType() throws MessagingException;
+
+ public String getDisposition() throws MessagingException;
+
+ public String[] getHeader(String name) throws MessagingException;
+
+ public int getSize() throws MessagingException;
+
+ public boolean isMimeType(String mimeType) throws MessagingException;
+
+ public String getMimeType() throws MessagingException;
+
+ public void setBody(Body body) throws MessagingException;
+
+ public void writeTo(OutputStream out) throws IOException, MessagingException;
+}
diff --git a/src/com/android/email/mail/Store.java b/src/com/android/email/mail/Store.java
new file mode 100644
index 000000000..18afe5781
--- /dev/null
+++ b/src/com/android/email/mail/Store.java
@@ -0,0 +1,80 @@
+
+package com.android.email.mail;
+
+import java.util.HashMap;
+
+import android.app.Application;
+
+import com.android.email.mail.store.ImapStore;
+import com.android.email.mail.store.LocalStore;
+import com.android.email.mail.store.Pop3Store;
+import com.android.email.mail.store.WebDavStore;
+
+/**
+ * Store is the access point for an email message store. It's location can be
+ * local or remote and no specific protocol is defined. Store is intended to
+ * loosely model in combination the JavaMail classes javax.mail.Store and
+ * javax.mail.Folder along with some additional functionality to improve
+ * performance on mobile devices. Implementations of this class should focus on
+ * making as few network connections as possible.
+ */
+public abstract class Store {
+ /**
+ * A global suggestion to Store implementors on how much of the body
+ * should be returned on FetchProfile.Item.BODY_SANE requests.
+ */
+ public static final int FETCH_BODY_SANE_SUGGESTED_SIZE = (50 * 1024);
+
+ protected static final int SOCKET_CONNECT_TIMEOUT = 10000;
+ protected static final int SOCKET_READ_TIMEOUT = 60000;
+
+ private static HashMap mStores = new HashMap();
+
+ /**
+ * Get an instance of a mail store. The URI is parsed as a standard URI and
+ * the scheme is used to determine which protocol will be used. The
+ * following schemes are currently recognized: imap - IMAP with no
+ * connection security. Ex: imap://username:password@host/ imap+tls - IMAP
+ * with TLS connection security, if the server supports it. Ex:
+ * imap+tls://username:password@host imap+tls+ - IMAP with required TLS
+ * connection security. Connection fails if TLS is not available. Ex:
+ * imap+tls+://username:password@host imap+ssl+ - IMAP with required SSL
+ * connection security. Connection fails if SSL is not available. Ex:
+ * imap+ssl+://username:password@host
+ *
+ * @param uri The URI of the store.
+ * @return
+ * @throws MessagingException
+ */
+ public synchronized static Store getInstance(String uri, Application application) throws MessagingException {
+ Store store = mStores.get(uri);
+ if (store == null) {
+ if (uri.startsWith("imap")) {
+ store = new ImapStore(uri);
+ } else if (uri.startsWith("pop3")) {
+ store = new Pop3Store(uri);
+ } else if (uri.startsWith("local")) {
+ store = new LocalStore(uri, application);
+ } else if (uri.startsWith("webdav")) {
+ store = new WebDavStore(uri);
+ }
+
+
+ if (store != null) {
+ mStores.put(uri, store);
+ }
+ }
+
+ if (store == null) {
+ throw new MessagingException("Unable to locate an applicable Store for " + uri);
+ }
+
+ return store;
+ }
+
+ public abstract Folder getFolder(String name) throws MessagingException;
+
+ public abstract Folder[] getPersonalNamespaces() throws MessagingException;
+
+ public abstract void checkSettings() throws MessagingException;
+}
diff --git a/src/com/android/email/mail/Transport.java b/src/com/android/email/mail/Transport.java
new file mode 100644
index 000000000..2aba0991a
--- /dev/null
+++ b/src/com/android/email/mail/Transport.java
@@ -0,0 +1,25 @@
+
+package com.android.email.mail;
+
+import com.android.email.mail.transport.SmtpTransport;
+import com.android.email.mail.transport.WebDavTransport;
+
+public abstract class Transport {
+ protected static final int SOCKET_CONNECT_TIMEOUT = 10000;
+
+ public synchronized static Transport getInstance(String uri) throws MessagingException {
+ if (uri.startsWith("smtp")) {
+ return new SmtpTransport(uri);
+ } else if (uri.startsWith("webdav")) {
+ return new WebDavTransport(uri);
+ } else {
+ throw new MessagingException("Unable to locate an applicable Transport for " + uri);
+ }
+ }
+
+ public abstract void open() throws MessagingException;
+
+ public abstract void sendMessage(Message message) throws MessagingException;
+
+ public abstract void close() throws MessagingException;
+}
diff --git a/src/com/android/email/mail/internet/BinaryTempFileBody.java b/src/com/android/email/mail/internet/BinaryTempFileBody.java
new file mode 100644
index 000000000..76b120d5b
--- /dev/null
+++ b/src/com/android/email/mail/internet/BinaryTempFileBody.java
@@ -0,0 +1,77 @@
+package com.android.email.mail.internet;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import org.apache.commons.io.IOUtils;
+
+import android.util.Config;
+import android.util.Log;
+
+import com.android.email.Email;
+import com.android.email.codec.binary.Base64OutputStream;
+import com.android.email.mail.Body;
+import com.android.email.mail.MessagingException;
+
+/**
+ * A Body that is backed by a temp file. The Body exposes a getOutputStream method that allows
+ * the user to write to the temp file. After the write the body is available via getInputStream
+ * and writeTo one time. After writeTo is called, or the InputStream returned from
+ * getInputStream is closed the file is deleted and the Body should be considered disposed of.
+ */
+public class BinaryTempFileBody implements Body {
+ private static File mTempDirectory;
+
+ private File mFile;
+
+ public static void setTempDirectory(File tempDirectory) {
+ mTempDirectory = tempDirectory;
+ }
+
+ public BinaryTempFileBody() throws IOException {
+ if (mTempDirectory == null) {
+ throw new
+ RuntimeException("setTempDirectory has not been called on BinaryTempFileBody!");
+ }
+ }
+
+ public OutputStream getOutputStream() throws IOException {
+ mFile = File.createTempFile("body", null, mTempDirectory);
+ mFile.deleteOnExit();
+ return new FileOutputStream(mFile);
+ }
+
+ public InputStream getInputStream() throws MessagingException {
+ try {
+ return new BinaryTempFileBodyInputStream(new FileInputStream(mFile));
+ }
+ catch (IOException ioe) {
+ throw new MessagingException("Unable to open body", ioe);
+ }
+ }
+
+ public void writeTo(OutputStream out) throws IOException, MessagingException {
+ InputStream in = getInputStream();
+ Base64OutputStream base64Out = new Base64OutputStream(out);
+ IOUtils.copy(in, base64Out);
+ base64Out.close();
+ mFile.delete();
+ }
+
+ class BinaryTempFileBodyInputStream extends FilterInputStream {
+ public BinaryTempFileBodyInputStream(InputStream in) {
+ super(in);
+ }
+
+ @Override
+ public void close() throws IOException {
+ super.close();
+ mFile.delete();
+ }
+ }
+}
diff --git a/src/com/android/email/mail/internet/MimeBodyPart.java b/src/com/android/email/mail/internet/MimeBodyPart.java
new file mode 100644
index 000000000..f41caedde
--- /dev/null
+++ b/src/com/android/email/mail/internet/MimeBodyPart.java
@@ -0,0 +1,121 @@
+
+package com.android.email.mail.internet;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+
+import com.android.email.mail.Body;
+import com.android.email.mail.BodyPart;
+import com.android.email.mail.MessagingException;
+
+/**
+ * TODO this is a close approximation of Message, need to update along with
+ * Message.
+ */
+public class MimeBodyPart extends BodyPart {
+ protected MimeHeader mHeader = new MimeHeader();
+ protected Body mBody;
+ protected int mSize;
+
+ public MimeBodyPart() throws MessagingException {
+ this(null);
+ }
+
+ public MimeBodyPart(Body body) throws MessagingException {
+ this(body, null);
+ }
+
+ public MimeBodyPart(Body body, String mimeType) throws MessagingException {
+ if (mimeType != null) {
+ setHeader(MimeHeader.HEADER_CONTENT_TYPE, mimeType);
+ }
+ setBody(body);
+ }
+
+ protected String getFirstHeader(String name) throws MessagingException {
+ return mHeader.getFirstHeader(name);
+ }
+
+ public void addHeader(String name, String value) throws MessagingException {
+ mHeader.addHeader(name, value);
+ }
+
+ public void setHeader(String name, String value) throws MessagingException {
+ mHeader.setHeader(name, value);
+ }
+
+ public String[] getHeader(String name) throws MessagingException {
+ return mHeader.getHeader(name);
+ }
+
+ public void removeHeader(String name) throws MessagingException {
+ mHeader.removeHeader(name);
+ }
+
+ public Body getBody() throws MessagingException {
+ return mBody;
+ }
+
+ public void setBody(Body body) throws MessagingException {
+ this.mBody = body;
+ if (body instanceof com.android.email.mail.Multipart) {
+ com.android.email.mail.Multipart multipart = ((com.android.email.mail.Multipart)body);
+ multipart.setParent(this);
+ setHeader(MimeHeader.HEADER_CONTENT_TYPE, multipart.getContentType());
+ }
+ else if (body instanceof TextBody) {
+ String contentType = String.format("%s;\n charset=utf-8", getMimeType());
+ String name = MimeUtility.getHeaderParameter(getContentType(), "name");
+ if (name != null) {
+ contentType += String.format(";\n name=\"%s\"", name);
+ }
+ setHeader(MimeHeader.HEADER_CONTENT_TYPE, contentType);
+ setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, "base64");
+ }
+ }
+
+ public String getContentType() throws MessagingException {
+ String contentType = getFirstHeader(MimeHeader.HEADER_CONTENT_TYPE);
+ if (contentType == null) {
+ return "text/plain";
+ } else {
+ return contentType;
+ }
+ }
+
+ public String getDisposition() throws MessagingException {
+ String contentDisposition = getFirstHeader(MimeHeader.HEADER_CONTENT_DISPOSITION);
+ if (contentDisposition == null) {
+ return null;
+ } else {
+ return contentDisposition;
+ }
+ }
+
+ public String getMimeType() throws MessagingException {
+ return MimeUtility.getHeaderParameter(getContentType(), null);
+ }
+
+ public boolean isMimeType(String mimeType) throws MessagingException {
+ return getMimeType().equals(mimeType);
+ }
+
+ public int getSize() throws MessagingException {
+ return mSize;
+ }
+
+ /**
+ * Write the MimeMessage out in MIME format.
+ */
+ public void writeTo(OutputStream out) throws IOException, MessagingException {
+ BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out), 1024);
+ mHeader.writeTo(out);
+ writer.write("\r\n");
+ writer.flush();
+ if (mBody != null) {
+ mBody.writeTo(out);
+ }
+ }
+}
diff --git a/src/com/android/email/mail/internet/MimeHeader.java b/src/com/android/email/mail/internet/MimeHeader.java
new file mode 100644
index 000000000..7a233d054
--- /dev/null
+++ b/src/com/android/email/mail/internet/MimeHeader.java
@@ -0,0 +1,105 @@
+
+package com.android.email.mail.internet;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.util.ArrayList;
+
+import com.android.email.Utility;
+import com.android.email.mail.MessagingException;
+
+public class MimeHeader {
+ /**
+ * Application specific header that contains Store specific information about an attachment.
+ * In IMAP this contains the IMAP BODYSTRUCTURE part id so that the ImapStore can later
+ * retrieve the attachment at will from the server.
+ * The info is recorded from this header on LocalStore.appendMessages and is put back
+ * into the MIME data by LocalStore.fetch.
+ */
+ public static final String HEADER_ANDROID_ATTACHMENT_STORE_DATA = "X-Android-Attachment-StoreData";
+
+ public static final String HEADER_CONTENT_TYPE = "Content-Type";
+ public static final String HEADER_CONTENT_TRANSFER_ENCODING = "Content-Transfer-Encoding";
+ public static final String HEADER_CONTENT_DISPOSITION = "Content-Disposition";
+
+ /**
+ * Fields that should be omitted when writing the header using writeTo()
+ */
+ private static final String[] writeOmitFields = {
+// HEADER_ANDROID_ATTACHMENT_DOWNLOADED,
+// HEADER_ANDROID_ATTACHMENT_ID,
+ HEADER_ANDROID_ATTACHMENT_STORE_DATA
+ };
+
+ protected ArrayList mFields = new ArrayList();
+
+ public void clear() {
+ mFields.clear();
+ }
+
+ public String getFirstHeader(String name) throws MessagingException {
+ String[] header = getHeader(name);
+ if (header == null) {
+ return null;
+ }
+ return header[0];
+ }
+
+ public void addHeader(String name, String value) throws MessagingException {
+ mFields.add(new Field(name, MimeUtility.foldAndEncode(value)));
+ }
+
+ public void setHeader(String name, String value) throws MessagingException {
+ if (name == null || value == null) {
+ return;
+ }
+ removeHeader(name);
+ addHeader(name, value);
+ }
+
+ public String[] getHeader(String name) throws MessagingException {
+ ArrayList values = new ArrayList();
+ for (Field field : mFields) {
+ if (field.name.equalsIgnoreCase(name)) {
+ values.add(field.value);
+ }
+ }
+ if (values.size() == 0) {
+ return null;
+ }
+ return values.toArray(new String[] {});
+ }
+
+ public void removeHeader(String name) throws MessagingException {
+ ArrayList removeFields = new ArrayList();
+ for (Field field : mFields) {
+ if (field.name.equalsIgnoreCase(name)) {
+ removeFields.add(field);
+ }
+ }
+ mFields.removeAll(removeFields);
+ }
+
+ public void writeTo(OutputStream out) throws IOException, MessagingException {
+ BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out), 1024);
+ for (Field field : mFields) {
+ if (!Utility.arrayContains(writeOmitFields, field.name)) {
+ writer.write(field.name + ": " + field.value + "\r\n");
+ }
+ }
+ writer.flush();
+ }
+
+ class Field {
+ String name;
+
+ String value;
+
+ public Field(String name, String value) {
+ this.name = name;
+ this.value = value;
+ }
+ }
+}
diff --git a/src/com/android/email/mail/internet/MimeMessage.java b/src/com/android/email/mail/internet/MimeMessage.java
new file mode 100644
index 000000000..a1a474f15
--- /dev/null
+++ b/src/com/android/email/mail/internet/MimeMessage.java
@@ -0,0 +1,424 @@
+
+package com.android.email.mail.internet;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Stack;
+
+import org.apache.james.mime4j.BodyDescriptor;
+import org.apache.james.mime4j.ContentHandler;
+import org.apache.james.mime4j.EOLConvertingInputStream;
+import org.apache.james.mime4j.MimeStreamParser;
+import org.apache.james.mime4j.field.DateTimeField;
+import org.apache.james.mime4j.field.Field;
+
+import com.android.email.mail.Address;
+import com.android.email.mail.Body;
+import com.android.email.mail.BodyPart;
+import com.android.email.mail.Message;
+import com.android.email.mail.MessagingException;
+import com.android.email.mail.Part;
+
+/**
+ * An implementation of Message that stores all of it's metadata in RFC 822 and
+ * RFC 2045 style headers.
+ */
+public class MimeMessage extends Message {
+ protected MimeHeader mHeader = new MimeHeader();
+ protected Address[] mFrom;
+ protected Address[] mTo;
+ protected Address[] mCc;
+ protected Address[] mBcc;
+ protected Address[] mReplyTo;
+ protected Date mSentDate;
+ protected SimpleDateFormat mDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z");
+ protected Body mBody;
+ protected int mSize;
+
+ public MimeMessage() {
+ /*
+ * Every new messages gets a Message-ID
+ */
+ try {
+ setHeader("Message-ID", generateMessageId());
+ }
+ catch (MessagingException me) {
+ throw new RuntimeException("Unable to create MimeMessage", me);
+ }
+ }
+
+ private String generateMessageId() {
+ StringBuffer sb = new StringBuffer();
+ sb.append("<");
+ for (int i = 0; i < 24; i++) {
+ sb.append(Integer.toString((int)(Math.random() * 35), 36));
+ }
+ sb.append(".");
+ sb.append(Long.toString(System.currentTimeMillis()));
+ sb.append("@email.android.com>");
+ return sb.toString();
+ }
+
+ /**
+ * Parse the given InputStream using Apache Mime4J to build a MimeMessage.
+ *
+ * @param in
+ * @throws IOException
+ * @throws MessagingException
+ */
+ public MimeMessage(InputStream in) throws IOException, MessagingException {
+ parse(in);
+ }
+
+ protected void parse(InputStream in) throws IOException, MessagingException {
+ mHeader.clear();
+ mBody = null;
+ mBcc = null;
+ mTo = null;
+ mFrom = null;
+ mSentDate = null;
+
+ MimeStreamParser parser = new MimeStreamParser();
+ parser.setContentHandler(new MimeMessageBuilder());
+ parser.parse(new EOLConvertingInputStream(in));
+ }
+
+ public Date getReceivedDate() throws MessagingException {
+ return null;
+ }
+
+ public Date getSentDate() throws MessagingException {
+ if (mSentDate == null) {
+ try {
+ DateTimeField field = (DateTimeField)Field.parse("Date: "
+ + MimeUtility.unfoldAndDecode(getFirstHeader("Date")));
+ mSentDate = field.getDate();
+ } catch (Exception e) {
+
+ }
+ }
+ return mSentDate;
+ }
+
+ public void setSentDate(Date sentDate) throws MessagingException {
+ setHeader("Date", mDateFormat.format(sentDate));
+ this.mSentDate = sentDate;
+ }
+
+ public String getContentType() throws MessagingException {
+ String contentType = getFirstHeader(MimeHeader.HEADER_CONTENT_TYPE);
+ if (contentType == null) {
+ return "text/plain";
+ } else {
+ return contentType;
+ }
+ }
+
+ public String getDisposition() throws MessagingException {
+ String contentDisposition = getFirstHeader(MimeHeader.HEADER_CONTENT_DISPOSITION);
+ if (contentDisposition == null) {
+ return null;
+ } else {
+ return contentDisposition;
+ }
+ }
+
+ public String getMimeType() throws MessagingException {
+ return MimeUtility.getHeaderParameter(getContentType(), null);
+ }
+
+ public int getSize() throws MessagingException {
+ return mSize;
+ }
+
+ /**
+ * Returns a list of the given recipient type from this message. If no addresses are
+ * found the method returns an empty array.
+ */
+ public Address[] getRecipients(RecipientType type) throws MessagingException {
+ if (type == RecipientType.TO) {
+ if (mTo == null) {
+ mTo = Address.parse(MimeUtility.unfold(getFirstHeader("To")));
+ }
+ return mTo;
+ } else if (type == RecipientType.CC) {
+ if (mCc == null) {
+ mCc = Address.parse(MimeUtility.unfold(getFirstHeader("CC")));
+ }
+ return mCc;
+ } else if (type == RecipientType.BCC) {
+ if (mBcc == null) {
+ mBcc = Address.parse(MimeUtility.unfold(getFirstHeader("BCC")));
+ }
+ return mBcc;
+ } else {
+ throw new MessagingException("Unrecognized recipient type.");
+ }
+ }
+
+ public void setRecipients(RecipientType type, Address[] addresses) throws MessagingException {
+ if (type == RecipientType.TO) {
+ if (addresses == null || addresses.length == 0) {
+ removeHeader("To");
+ this.mTo = null;
+ } else {
+ setHeader("To", Address.toString(addresses));
+ this.mTo = addresses;
+ }
+ } else if (type == RecipientType.CC) {
+ if (addresses == null || addresses.length == 0) {
+ removeHeader("CC");
+ this.mCc = null;
+ } else {
+ setHeader("CC", Address.toString(addresses));
+ this.mCc = addresses;
+ }
+ } else if (type == RecipientType.BCC) {
+ if (addresses == null || addresses.length == 0) {
+ removeHeader("BCC");
+ this.mBcc = null;
+ } else {
+ setHeader("BCC", Address.toString(addresses));
+ this.mBcc = addresses;
+ }
+ } else {
+ throw new MessagingException("Unrecognized recipient type.");
+ }
+ }
+
+ /**
+ * Returns the unfolded, decoded value of the Subject header.
+ */
+ public String getSubject() throws MessagingException {
+ return MimeUtility.unfoldAndDecode(getFirstHeader("Subject"));
+ }
+
+ public void setSubject(String subject) throws MessagingException {
+ setHeader("Subject", subject);
+ }
+
+ public Address[] getFrom() throws MessagingException {
+ if (mFrom == null) {
+ String list = MimeUtility.unfold(getFirstHeader("From"));
+ if (list == null || list.length() == 0) {
+ list = MimeUtility.unfold(getFirstHeader("Sender"));
+ }
+ mFrom = Address.parse(list);
+ }
+ return mFrom;
+ }
+
+ public void setFrom(Address from) throws MessagingException {
+ if (from != null) {
+ setHeader("From", from.toString());
+ this.mFrom = new Address[] {
+ from
+ };
+ } else {
+ this.mFrom = null;
+ }
+ }
+
+ public Address[] getReplyTo() throws MessagingException {
+ if (mReplyTo == null) {
+ mReplyTo = Address.parse(MimeUtility.unfold(getFirstHeader("Reply-to")));
+ }
+ return mReplyTo;
+ }
+
+ public void setReplyTo(Address[] replyTo) throws MessagingException {
+ if (replyTo == null || replyTo.length == 0) {
+ removeHeader("Reply-to");
+ mReplyTo = null;
+ } else {
+ setHeader("Reply-to", Address.toString(replyTo));
+ mReplyTo = replyTo;
+ }
+ }
+
+ public void saveChanges() throws MessagingException {
+ throw new MessagingException("saveChanges not yet implemented");
+ }
+
+ public Body getBody() throws MessagingException {
+ return mBody;
+ }
+
+ public void setBody(Body body) throws MessagingException {
+ this.mBody = body;
+ if (body instanceof com.android.email.mail.Multipart) {
+ com.android.email.mail.Multipart multipart = ((com.android.email.mail.Multipart)body);
+ multipart.setParent(this);
+ setHeader(MimeHeader.HEADER_CONTENT_TYPE, multipart.getContentType());
+ setHeader("MIME-Version", "1.0");
+ }
+ else if (body instanceof TextBody) {
+ setHeader(MimeHeader.HEADER_CONTENT_TYPE, String.format("%s;\n charset=utf-8",
+ getMimeType()));
+ setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, "base64");
+ }
+ }
+
+ protected String getFirstHeader(String name) throws MessagingException {
+ return mHeader.getFirstHeader(name);
+ }
+
+ public void addHeader(String name, String value) throws MessagingException {
+ mHeader.addHeader(name, value);
+ }
+
+ public void setHeader(String name, String value) throws MessagingException {
+ mHeader.setHeader(name, value);
+ }
+
+ public String[] getHeader(String name) throws MessagingException {
+ return mHeader.getHeader(name);
+ }
+
+ public void removeHeader(String name) throws MessagingException {
+ mHeader.removeHeader(name);
+ }
+
+ public void writeTo(OutputStream out) throws IOException, MessagingException {
+ BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out), 1024);
+ mHeader.writeTo(out);
+ writer.write("\r\n");
+ writer.flush();
+ if (mBody != null) {
+ mBody.writeTo(out);
+ }
+ }
+
+ public InputStream getInputStream() throws MessagingException {
+ return null;
+ }
+
+ class MimeMessageBuilder implements ContentHandler {
+ private Stack stack = new Stack();
+
+ public MimeMessageBuilder() {
+ }
+
+ private void expect(Class c) {
+ if (!c.isInstance(stack.peek())) {
+ throw new IllegalStateException("Internal stack error: " + "Expected '"
+ + c.getName() + "' found '" + stack.peek().getClass().getName() + "'");
+ }
+ }
+
+ public void startMessage() {
+ if (stack.isEmpty()) {
+ stack.push(MimeMessage.this);
+ } else {
+ expect(Part.class);
+ try {
+ MimeMessage m = new MimeMessage();
+ ((Part)stack.peek()).setBody(m);
+ stack.push(m);
+ } catch (MessagingException me) {
+ throw new Error(me);
+ }
+ }
+ }
+
+ public void endMessage() {
+ expect(MimeMessage.class);
+ stack.pop();
+ }
+
+ public void startHeader() {
+ expect(Part.class);
+ }
+
+ public void field(String fieldData) {
+ expect(Part.class);
+ try {
+ String[] tokens = fieldData.split(":", 2);
+ ((Part)stack.peek()).addHeader(tokens[0], tokens[1].trim());
+ } catch (MessagingException me) {
+ throw new Error(me);
+ }
+ }
+
+ public void endHeader() {
+ expect(Part.class);
+ }
+
+ public void startMultipart(BodyDescriptor bd) {
+ expect(Part.class);
+
+ Part e = (Part)stack.peek();
+ try {
+ MimeMultipart multiPart = new MimeMultipart(e.getContentType());
+ e.setBody(multiPart);
+ stack.push(multiPart);
+ } catch (MessagingException me) {
+ throw new Error(me);
+ }
+ }
+
+ public void body(BodyDescriptor bd, InputStream in) throws IOException {
+ expect(Part.class);
+ Body body = MimeUtility.decodeBody(in, bd.getTransferEncoding());
+ try {
+ ((Part)stack.peek()).setBody(body);
+ } catch (MessagingException me) {
+ throw new Error(me);
+ }
+ }
+
+ public void endMultipart() {
+ stack.pop();
+ }
+
+ public void startBodyPart() {
+ expect(MimeMultipart.class);
+
+ try {
+ MimeBodyPart bodyPart = new MimeBodyPart();
+ ((MimeMultipart)stack.peek()).addBodyPart(bodyPart);
+ stack.push(bodyPart);
+ } catch (MessagingException me) {
+ throw new Error(me);
+ }
+ }
+
+ public void endBodyPart() {
+ expect(BodyPart.class);
+ stack.pop();
+ }
+
+ public void epilogue(InputStream is) throws IOException {
+ expect(MimeMultipart.class);
+ StringBuffer sb = new StringBuffer();
+ int b;
+ while ((b = is.read()) != -1) {
+ sb.append((char)b);
+ }
+ // ((Multipart) stack.peek()).setEpilogue(sb.toString());
+ }
+
+ public void preamble(InputStream is) throws IOException {
+ expect(MimeMultipart.class);
+ StringBuffer sb = new StringBuffer();
+ int b;
+ while ((b = is.read()) != -1) {
+ sb.append((char)b);
+ }
+ try {
+ ((MimeMultipart)stack.peek()).setPreamble(sb.toString());
+ } catch (MessagingException me) {
+ throw new Error(me);
+ }
+ }
+
+ public void raw(InputStream is) throws IOException {
+ throw new UnsupportedOperationException("Not supported");
+ }
+ }
+}
diff --git a/src/com/android/email/mail/internet/MimeMultipart.java b/src/com/android/email/mail/internet/MimeMultipart.java
new file mode 100644
index 000000000..47fe0d82d
--- /dev/null
+++ b/src/com/android/email/mail/internet/MimeMultipart.java
@@ -0,0 +1,95 @@
+
+package com.android.email.mail.internet;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+
+import com.android.email.mail.BodyPart;
+import com.android.email.mail.MessagingException;
+import com.android.email.mail.Multipart;
+
+public class MimeMultipart extends Multipart {
+ protected String mPreamble;
+
+ protected String mContentType;
+
+ protected String mBoundary;
+
+ protected String mSubType;
+
+ public MimeMultipart() throws MessagingException {
+ mBoundary = generateBoundary();
+ setSubType("mixed");
+ }
+
+ public MimeMultipart(String contentType) throws MessagingException {
+ this.mContentType = contentType;
+ try {
+ mSubType = MimeUtility.getHeaderParameter(contentType, null).split("/")[1];
+ mBoundary = MimeUtility.getHeaderParameter(contentType, "boundary");
+ if (mBoundary == null) {
+ throw new MessagingException("MultiPart does not contain boundary: " + contentType);
+ }
+ } catch (Exception e) {
+ throw new MessagingException(
+ "Invalid MultiPart Content-Type; must contain subtype and boundary. ("
+ + contentType + ")", e);
+ }
+ }
+
+ public String generateBoundary() {
+ StringBuffer sb = new StringBuffer();
+ sb.append("----");
+ for (int i = 0; i < 30; i++) {
+ sb.append(Integer.toString((int)(Math.random() * 35), 36));
+ }
+ return sb.toString().toUpperCase();
+ }
+
+ public String getPreamble() throws MessagingException {
+ return mPreamble;
+ }
+
+ public void setPreamble(String preamble) throws MessagingException {
+ this.mPreamble = preamble;
+ }
+
+ public String getContentType() throws MessagingException {
+ return mContentType;
+ }
+
+ public void setSubType(String subType) throws MessagingException {
+ this.mSubType = subType;
+ mContentType = String.format("multipart/%s; boundary=\"%s\"", subType, mBoundary);
+ }
+
+ public void writeTo(OutputStream out) throws IOException, MessagingException {
+ BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out), 1024);
+
+ if (mPreamble != null) {
+ writer.write(mPreamble + "\r\n");
+ }
+
+ if(mParts.size() == 0){
+ writer.write("--" + mBoundary + "\r\n");
+ }
+
+ for (int i = 0, count = mParts.size(); i < count; i++) {
+ BodyPart bodyPart = (BodyPart)mParts.get(i);
+ writer.write("--" + mBoundary + "\r\n");
+ writer.flush();
+ bodyPart.writeTo(out);
+ writer.write("\r\n");
+ }
+
+ writer.write("--" + mBoundary + "--\r\n");
+ writer.flush();
+ }
+
+ public InputStream getInputStream() throws MessagingException {
+ return null;
+ }
+}
diff --git a/src/com/android/email/mail/internet/MimeUtility.java b/src/com/android/email/mail/internet/MimeUtility.java
new file mode 100644
index 000000000..9ee711014
--- /dev/null
+++ b/src/com/android/email/mail/internet/MimeUtility.java
@@ -0,0 +1,311 @@
+
+package com.android.email.mail.internet;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.nio.charset.Charset;
+
+
+
+import org.apache.commons.io.IOUtils;
+import org.apache.james.mime4j.decoder.Base64InputStream;
+import org.apache.james.mime4j.decoder.DecoderUtil;
+import org.apache.james.mime4j.decoder.QuotedPrintableInputStream;
+
+import android.util.Log;
+
+import com.android.email.Email;
+import com.android.email.mail.Body;
+import com.android.email.mail.BodyPart;
+import com.android.email.mail.Message;
+import com.android.email.mail.MessagingException;
+import com.android.email.mail.Multipart;
+import com.android.email.mail.Part;
+
+public class MimeUtility {
+
+
+ public static String unfold(String s) {
+ if (s == null) {
+ return null;
+ }
+ return s.replaceAll("\r|\n", "");
+ }
+
+ public static String decode(String s) {
+ if (s == null) {
+ return null;
+ }
+ return DecoderUtil.decodeEncodedWords(s);
+ }
+
+ public static String unfoldAndDecode(String s) {
+ return decode(unfold(s));
+ }
+
+ // TODO implement proper foldAndEncode
+ public static String foldAndEncode(String s) {
+ return s;
+ }
+
+ /**
+ * Returns the named parameter of a header field. If name is null the first
+ * parameter is returned, or if there are no additional parameters in the
+ * field the entire field is returned. Otherwise the named parameter is
+ * searched for in a case insensitive fashion and returned. If the parameter
+ * cannot be found the method returns null.
+ *
+ * @param header
+ * @param name
+ * @return
+ */
+ public static String getHeaderParameter(String header, String name) {
+ if (header == null) {
+ return null;
+ }
+ header = header.replaceAll("\r|\n", "");
+ String[] parts = header.split(";");
+ if (name == null) {
+ return parts[0];
+ }
+ for (String part : parts) {
+ if (part.trim().toLowerCase().startsWith(name.toLowerCase())) {
+ String parameter = part.split("=", 2)[1].trim();
+ if (parameter.startsWith("\"") && parameter.endsWith("\"")) {
+ return parameter.substring(1, parameter.length() - 1);
+ }
+ else {
+ return parameter;
+ }
+ }
+ }
+ return null;
+ }
+
+ public static Part findFirstPartByMimeType(Part part, String mimeType)
+ throws MessagingException {
+ if (part.getBody() instanceof Multipart) {
+ Multipart multipart = (Multipart)part.getBody();
+ for (int i = 0, count = multipart.getCount(); i < count; i++) {
+ BodyPart bodyPart = multipart.getBodyPart(i);
+ Part ret = findFirstPartByMimeType(bodyPart, mimeType);
+ if (ret != null) {
+ return ret;
+ }
+ }
+ }
+ else if (part.getMimeType().equalsIgnoreCase(mimeType)) {
+ return part;
+ }
+ return null;
+ }
+
+ public static Part findPartByContentId(Part part, String contentId) throws Exception {
+ if (part.getBody() instanceof Multipart) {
+ Multipart multipart = (Multipart)part.getBody();
+ for (int i = 0, count = multipart.getCount(); i < count; i++) {
+ BodyPart bodyPart = multipart.getBodyPart(i);
+ Part ret = findPartByContentId(bodyPart, contentId);
+ if (ret != null) {
+ return ret;
+ }
+ }
+ }
+ String[] header = part.getHeader("Content-ID");
+ if (header != null) {
+ for (String s : header) {
+ if (s.equals(contentId)) {
+ return part;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Reads the Part's body and returns a String based on any charset conversion that needed
+ * to be done.
+ * @param part
+ * @return
+ * @throws IOException
+ */
+ public static String getTextFromPart(Part part) {
+ Charset mCharsetConverter;
+
+ try {
+ if (part != null && part.getBody() != null) {
+ InputStream in = part.getBody().getInputStream();
+ String mimeType = part.getMimeType();
+ if (mimeType != null && MimeUtility.mimeTypeMatches(mimeType, "text/*")) {
+ /*
+ * Now we read the part into a buffer for further processing. Because
+ * the stream is now wrapped we'll remove any transfer encoding at this point.
+ */
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ IOUtils.copy(in, out);
+
+ byte[] bytes = out.toByteArray();
+ in.close();
+ out.close();
+
+ String charset = getHeaderParameter(part.getContentType(), "charset");
+ /*
+ * We've got a text part, so let's see if it needs to be processed further.
+ */
+ if (charset != null) {
+ /*
+ * See if there is conversion from the MIME charset to the Java one.
+ */
+ mCharsetConverter = Charset.forName(charset);
+ charset = mCharsetConverter.name();
+ }
+ if (charset != null) {
+ /*
+ * We've got a charset encoding, so decode using it.
+ */
+ return new String(bytes, 0, bytes.length, charset);
+ }
+ else {
+ /*
+ * No encoding, so use us-ascii, which is the standard.
+ */
+ return new String(bytes, 0, bytes.length, "ASCII");
+ }
+ }
+ }
+
+ }
+ catch (Exception e) {
+ /*
+ * If we are not able to process the body there's nothing we can do about it. Return
+ * null and let the upper layers handle the missing content.
+ */
+ Log.e(Email.LOG_TAG, "Unable to getTextFromPart", e);
+ }
+ return null;
+ }
+
+ /**
+ * Returns true if the given mimeType matches the matchAgainst specification.
+ * @param mimeType A MIME type to check.
+ * @param matchAgainst A MIME type to check against. May include wildcards such as image/* or
+ * * /*.
+ * @return
+ */
+ public static boolean mimeTypeMatches(String mimeType, String matchAgainst) {
+ return mimeType.matches(matchAgainst.replaceAll("\\*", "\\.\\*"));
+ }
+
+ /**
+ * Returns true if the given mimeType matches any of the matchAgainst specifications.
+ * @param mimeType A MIME type to check.
+ * @param matchAgainst An array of MIME types to check against. May include wildcards such
+ * as image/* or * /*.
+ * @return
+ */
+ public static boolean mimeTypeMatches(String mimeType, String[] matchAgainst) {
+ for (String matchType : matchAgainst) {
+ if (mimeType.matches(matchType.replaceAll("\\*", "\\.\\*"))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Removes any content transfer encoding from the stream and returns a Body.
+ */
+ public static Body decodeBody(InputStream in, String contentTransferEncoding)
+ throws IOException {
+ /*
+ * We'll remove any transfer encoding by wrapping the stream.
+ */
+ if (contentTransferEncoding != null) {
+ contentTransferEncoding =
+ MimeUtility.getHeaderParameter(contentTransferEncoding, null);
+ if ("quoted-printable".equalsIgnoreCase(contentTransferEncoding)) {
+ in = new QuotedPrintableInputStream(in);
+ }
+ else if ("base64".equalsIgnoreCase(contentTransferEncoding)) {
+ in = new Base64InputStream(in);
+ }
+ }
+
+ BinaryTempFileBody tempBody = new BinaryTempFileBody();
+ OutputStream out = tempBody.getOutputStream();
+ IOUtils.copy(in, out);
+ out.close();
+ return tempBody;
+ }
+
+ /**
+ * An unfortunately named method that makes decisions about a Part (usually a Message)
+ * as to which of it's children will be "viewable" and which will be attachments.
+ * The method recursively sorts the viewables and attachments into seperate
+ * lists for further processing.
+ * @param part
+ * @param viewables
+ * @param attachments
+ * @throws MessagingException
+ */
+ public static void collectParts(Part part, ArrayList viewables,
+ ArrayList attachments) throws MessagingException {
+ String disposition = part.getDisposition();
+ String dispositionType = null;
+ String dispositionFilename = null;
+ if (disposition != null) {
+ dispositionType = MimeUtility.getHeaderParameter(disposition, null);
+ dispositionFilename = MimeUtility.getHeaderParameter(disposition, "filename");
+ }
+
+ /*
+ * A best guess that this part is intended to be an attachment and not inline.
+ */
+ boolean attachment = ("attachment".equalsIgnoreCase(dispositionType))
+ || (dispositionFilename != null)
+ && (!"inline".equalsIgnoreCase(dispositionType));
+
+ /*
+ * If the part is Multipart but not alternative it's either mixed or
+ * something we don't know about, which means we treat it as mixed
+ * per the spec. We just process it's pieces recursively.
+ */
+ if (part.getBody() instanceof Multipart) {
+ Multipart mp = (Multipart)part.getBody();
+ for (int i = 0; i < mp.getCount(); i++) {
+ collectParts(mp.getBodyPart(i), viewables, attachments);
+ }
+ }
+ /*
+ * If the part is an embedded message we just continue to process
+ * it, pulling any viewables or attachments into the running list.
+ */
+ else if (part.getBody() instanceof Message) {
+ Message message = (Message)part.getBody();
+ collectParts(message, viewables, attachments);
+ }
+ /*
+ * If the part is HTML and it got this far it's part of a mixed (et
+ * al) and should be rendered inline.
+ */
+ else if ((!attachment) && (part.getMimeType().equalsIgnoreCase("text/html"))) {
+ viewables.add(part);
+ }
+ /*
+ * If the part is plain text and it got this far it's part of a
+ * mixed (et al) and should be rendered inline.
+ */
+ else if ((!attachment) && (part.getMimeType().equalsIgnoreCase("text/plain"))) {
+ viewables.add(part);
+ }
+ /*
+ * Finally, if it's nothing else we will include it as an attachment.
+ */
+ else {
+ attachments.add(part);
+ }
+ }
+}
diff --git a/src/com/android/email/mail/internet/TextBody.java b/src/com/android/email/mail/internet/TextBody.java
new file mode 100644
index 000000000..c1755922c
--- /dev/null
+++ b/src/com/android/email/mail/internet/TextBody.java
@@ -0,0 +1,47 @@
+
+package com.android.email.mail.internet;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+
+
+import com.android.email.codec.binary.Base64;
+import com.android.email.mail.Body;
+import com.android.email.mail.MessagingException;
+
+public class TextBody implements Body {
+ String mBody;
+
+ public TextBody(String body) {
+ this.mBody = body;
+ }
+
+ public void writeTo(OutputStream out) throws IOException, MessagingException {
+ byte[] bytes = mBody.getBytes("UTF-8");
+ out.write(Base64.encodeBase64Chunked(bytes));
+ }
+
+ /**
+ * Get the text of the body in it's unencoded format.
+ * @return
+ */
+ public String getText() {
+ return mBody;
+ }
+
+ /**
+ * Returns an InputStream that reads this body's text in UTF-8 format.
+ */
+ public InputStream getInputStream() throws MessagingException {
+ try {
+ byte[] b = mBody.getBytes("UTF-8");
+ return new ByteArrayInputStream(b);
+ }
+ catch (UnsupportedEncodingException usee) {
+ return null;
+ }
+ }
+}
diff --git a/src/com/android/email/mail/store/ImapResponseParser.java b/src/com/android/email/mail/store/ImapResponseParser.java
new file mode 100644
index 000000000..e51742295
--- /dev/null
+++ b/src/com/android/email/mail/store/ImapResponseParser.java
@@ -0,0 +1,356 @@
+/**
+ *
+ */
+
+package com.android.email.mail.store;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+
+import android.util.Config;
+import android.util.Log;
+
+import com.android.email.Email;
+import com.android.email.FixedLengthInputStream;
+import com.android.email.PeekableInputStream;
+import com.android.email.mail.MessagingException;
+
+public class ImapResponseParser {
+ SimpleDateFormat mDateTimeFormat = new SimpleDateFormat("dd-MMM-yyyy HH:mm:ss Z");
+ PeekableInputStream mIn;
+ InputStream mActiveLiteral;
+
+ public ImapResponseParser(PeekableInputStream in) {
+ this.mIn = in;
+ }
+
+ /**
+ * Reads the next response available on the stream and returns an
+ * ImapResponse object that represents it.
+ *
+ * @return
+ * @throws IOException
+ */
+ public ImapResponse readResponse() throws IOException {
+ ImapResponse response = new ImapResponse();
+ if (mActiveLiteral != null) {
+ while (mActiveLiteral.read() != -1)
+ ;
+ mActiveLiteral = null;
+ }
+ int ch = mIn.peek();
+ if (ch == '*') {
+ parseUntaggedResponse();
+ readTokens(response);
+ } else if (ch == '+') {
+ response.mCommandContinuationRequested =
+ parseCommandContinuationRequest();
+ readTokens(response);
+ } else {
+ response.mTag = parseTaggedResponse();
+ readTokens(response);
+ }
+ if (Config.LOGD) {
+ if (Email.DEBUG) {
+ Log.d(Email.LOG_TAG, "<<< " + response.toString());
+ }
+ }
+ return response;
+ }
+
+ private void readTokens(ImapResponse response) throws IOException {
+ response.clear();
+ Object token;
+ while ((token = readToken()) != null) {
+ if (response != null) {
+ response.add(token);
+ }
+ if (mActiveLiteral != null) {
+ break;
+ }
+ }
+ response.mCompleted = token == null;
+ }
+
+ /**
+ * Reads the next token of the response. The token can be one of: String -
+ * for NIL, QUOTED, NUMBER, ATOM. InputStream - for LITERAL.
+ * InputStream.available() returns the total length of the stream.
+ * ImapResponseList - for PARENTHESIZED LIST. Can contain any of the above
+ * elements including List.
+ *
+ * @return The next token in the response or null if there are no more
+ * tokens.
+ * @throws IOException
+ */
+ public Object readToken() throws IOException {
+ while (true) {
+ Object token = parseToken();
+ if (token == null || !token.equals(")")) {
+ return token;
+ }
+ }
+ }
+
+ private Object parseToken() throws IOException {
+ if (mActiveLiteral != null) {
+ while (mActiveLiteral.read() != -1)
+ ;
+ mActiveLiteral = null;
+ }
+ while (true) {
+ int ch = mIn.peek();
+ if (ch == '(') {
+ return parseList();
+ } else if (ch == ')') {
+ expect(')');
+ return ")";
+ } else if (ch == '"') {
+ return parseQuoted();
+ } else if (ch == '{') {
+ mActiveLiteral = parseLiteral();
+ return mActiveLiteral;
+ } else if (ch == ' ') {
+ expect(' ');
+ } else if (ch == '\r') {
+ expect('\r');
+ expect('\n');
+ return null;
+ } else if (ch == '\n') {
+ expect('\n');
+ return null;
+ } else if (ch == '\t') {
+ expect('\t');
+ } else {
+ return parseAtom();
+ }
+ }
+ }
+
+ private boolean parseCommandContinuationRequest() throws IOException {
+ expect('+');
+ expect(' ');
+ return true;
+ }
+
+ // * OK [UIDNEXT 175] Predicted next UID
+ private void parseUntaggedResponse() throws IOException {
+ expect('*');
+ expect(' ');
+ }
+
+ // 3 OK [READ-WRITE] Select completed.
+ private String parseTaggedResponse() throws IOException {
+ String tag = readStringUntil(' ');
+ return tag;
+ }
+
+ private ImapList parseList() throws IOException {
+ expect('(');
+ ImapList list = new ImapList();
+ Object token;
+ while (true) {
+ token = parseToken();
+ if (token == null) {
+ break;
+ } else if (token instanceof InputStream) {
+ list.add(token);
+ break;
+ } else if (token.equals(")")) {
+ break;
+ } else {
+ list.add(token);
+ }
+ }
+ return list;
+ }
+
+ private String parseAtom() throws IOException {
+ StringBuffer sb = new StringBuffer();
+ int ch;
+ while (true) {
+ ch = mIn.peek();
+ if (ch == -1) {
+ throw new IOException("parseAtom(): end of stream reached");
+ } else if (ch == '(' || ch == ')' || ch == '{' || ch == ' ' ||
+ // docs claim that flags are \ atom but atom isn't supposed to
+ // contain
+ // * and some falgs contain *
+ // ch == '%' || ch == '*' ||
+ ch == '%' ||
+ // TODO probably should not allow \ and should recognize
+ // it as a flag instead
+ // ch == '"' || ch == '\' ||
+ ch == '"' || (ch >= 0x00 && ch <= 0x1f) || ch == 0x7f) {
+ if (sb.length() == 0) {
+ throw new IOException(String.format("parseAtom(): (%04x %c)", (int)ch, ch));
+ }
+ return sb.toString();
+ } else {
+ sb.append((char)mIn.read());
+ }
+ }
+ }
+
+ /**
+ * A { has been read, read the rest of the size string, the space and then
+ * notify the listener with an InputStream.
+ *
+ * @param mListener
+ * @throws IOException
+ */
+ private InputStream parseLiteral() throws IOException {
+ expect('{');
+ int size = Integer.parseInt(readStringUntil('}'));
+ expect('\r');
+ expect('\n');
+ FixedLengthInputStream fixed = new FixedLengthInputStream(mIn, size);
+ return fixed;
+ }
+
+ /**
+ * A " has been read, read to the end of the quoted string and notify the
+ * listener.
+ *
+ * @param mListener
+ * @throws IOException
+ */
+ private String parseQuoted() throws IOException {
+ expect('"');
+ return readStringUntil('"');
+ }
+
+ private String readStringUntil(char end) throws IOException {
+ StringBuffer sb = new StringBuffer();
+ int ch;
+ while ((ch = mIn.read()) != -1) {
+ if (ch == end) {
+ return sb.toString();
+ } else {
+ sb.append((char)ch);
+ }
+ }
+ throw new IOException("readQuotedString(): end of stream reached");
+ }
+
+ private int expect(char ch) throws IOException {
+ int d;
+ if ((d = mIn.read()) != ch) {
+ throw new IOException(String.format("Expected %04x (%c) but got %04x (%c)", (int)ch,
+ ch, d, (char)d));
+ }
+ return d;
+ }
+
+ /**
+ * Represents an IMAP LIST response and is also the base class for the
+ * ImapResponse.
+ */
+ public class ImapList extends ArrayList