diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 53cadc3a1..913debde2 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1,24 +1,21 @@ + android:versionName="0.22" package="com.fsck.k9"> - - - - + + + @@ -27,70 +24,70 @@ + android:name="com.android.email.activity.FolderMessageList"> + android:name="com.android.email.activity.MessageView"> @@ -111,7 +108,7 @@ - @@ -125,16 +122,16 @@ diff --git a/build.xml b/build.xml index 320a5ef71..5f30b7951 100644 --- a/build.xml +++ b/build.xml @@ -7,7 +7,7 @@ - + @@ -126,9 +126,14 @@ - - - + + + + + + + + diff --git a/src/com/android/email/Account.java b/src/com/android/email/Account.java new file mode 100644 index 000000000..08d5fa102 --- /dev/null +++ b/src/com/android/email/Account.java @@ -0,0 +1,408 @@ + +package com.android.email; + +import java.io.Serializable; +import java.util.Arrays; +import java.util.UUID; + +import android.content.Context; +import android.content.SharedPreferences; +import android.net.Uri; + +/** + * Account stores all of the settings for a single account defined by the user. It is able to save + * and delete itself given a Preferences to work with. Each account is defined by a UUID. + */ +public class Account implements Serializable { + public static final int DELETE_POLICY_NEVER = 0; + public static final int DELETE_POLICY_7DAYS = 1; + public static final int DELETE_POLICY_ON_DELETE = 2; + + private static final long serialVersionUID = 2975156672298625121L; + + String mUuid; + String mStoreUri; + String mLocalStoreUri; + String mTransportUri; + String mDescription; + String mName; + String mEmail; + String mSignature; + String mAlwaysBcc; + int mAutomaticCheckIntervalMinutes; + int mDisplayCount; + long mLastAutomaticCheckTime; + boolean mNotifyNewMail; + boolean mNotifyRingtone; + String mDraftsFolderName; + String mSentFolderName; + String mTrashFolderName; + String mOutboxFolderName; + int mAccountNumber; + boolean mVibrate; + String mRingtoneUri; + + /** + *
+     * 0 Never 
+     * 1 After 7 days 
+     * 2 When I delete from inbox
+     * 
+ */ + int mDeletePolicy; + + public Account(Context context) { + // TODO Change local store path to something readable / recognizable + mUuid = UUID.randomUUID().toString(); + mLocalStoreUri = "local://localhost/" + context.getDatabasePath(mUuid + ".db"); + mAutomaticCheckIntervalMinutes = -1; + mDisplayCount = -1; + mAccountNumber = -1; + mNotifyNewMail = true; + mNotifyRingtone = false; + mSignature = "Sent from my Android phone with K-9. Please excuse my brevity."; + mVibrate = false; + mRingtoneUri = "content://settings/system/notification_sound"; + } + + Account(Preferences preferences, String uuid) { + this.mUuid = uuid; + refresh(preferences); + } + + /** + * Refresh the account from the stored settings. + */ + public void refresh(Preferences preferences) { + mStoreUri = Utility.base64Decode(preferences.mSharedPreferences.getString(mUuid + + ".storeUri", null)); + mLocalStoreUri = preferences.mSharedPreferences.getString(mUuid + ".localStoreUri", null); + mTransportUri = Utility.base64Decode(preferences.mSharedPreferences.getString(mUuid + + ".transportUri", null)); + mDescription = preferences.mSharedPreferences.getString(mUuid + ".description", null); + mAlwaysBcc = preferences.mSharedPreferences.getString(mUuid + ".alwaysBcc", mAlwaysBcc); + mName = preferences.mSharedPreferences.getString(mUuid + ".name", mName); + mEmail = preferences.mSharedPreferences.getString(mUuid + ".email", mEmail); + mSignature = preferences.mSharedPreferences.getString(mUuid + ".signature", mSignature); + mAutomaticCheckIntervalMinutes = preferences.mSharedPreferences.getInt(mUuid + + ".automaticCheckIntervalMinutes", -1); + mDisplayCount = preferences.mSharedPreferences.getInt(mUuid + ".displayCount", -1); + mLastAutomaticCheckTime = preferences.mSharedPreferences.getLong(mUuid + + ".lastAutomaticCheckTime", 0); + mNotifyNewMail = preferences.mSharedPreferences.getBoolean(mUuid + ".notifyNewMail", + false); + mNotifyRingtone = preferences.mSharedPreferences.getBoolean(mUuid + ".notifyRingtone", + false); + mDeletePolicy = preferences.mSharedPreferences.getInt(mUuid + ".deletePolicy", 0); + mDraftsFolderName = preferences.mSharedPreferences.getString(mUuid + ".draftsFolderName", + "Drafts"); + mSentFolderName = preferences.mSharedPreferences.getString(mUuid + ".sentFolderName", + "Sent"); + mTrashFolderName = preferences.mSharedPreferences.getString(mUuid + ".trashFolderName", + "Trash"); + mOutboxFolderName = preferences.mSharedPreferences.getString(mUuid + ".outboxFolderName", + "Outbox"); + mAccountNumber = preferences.mSharedPreferences.getInt(mUuid + ".accountNumber", 0); + mVibrate = preferences.mSharedPreferences.getBoolean(mUuid + ".vibrate", false); + mRingtoneUri = preferences.mSharedPreferences.getString(mUuid + ".ringtone", + "content://settings/system/notification_sound"); + } + + public String getUuid() { + return mUuid; + } + + public String getStoreUri() { + return mStoreUri; + } + + public void setStoreUri(String storeUri) { + this.mStoreUri = storeUri; + } + + public String getTransportUri() { + return mTransportUri; + } + + public void setTransportUri(String transportUri) { + this.mTransportUri = transportUri; + } + + public String getDescription() { + return mDescription; + } + + public void setDescription(String description) { + this.mDescription = description; + } + + public String getName() { + return mName; + } + + public void setName(String name) { + this.mName = name; + } + + public String getSignature() { + return mSignature; + } + + public void setSignature(String signature) { + this.mSignature = signature; + } + + public String getEmail() { + return mEmail; + } + + public void setEmail(String email) { + this.mEmail = email; + } + + public String getAlwaysBcc() { + return mAlwaysBcc; + } + + public void setAlwaysBcc(String alwaysBcc) { + this.mAlwaysBcc = alwaysBcc; + } + + + public boolean isVibrate() { + return mVibrate; + } + + public void setVibrate(boolean vibrate) { + mVibrate = vibrate; + } + + public String getRingtone() { + return mRingtoneUri; + } + + public void setRingtone(String ringtoneUri) { + mRingtoneUri = ringtoneUri; + } + + public void delete(Preferences preferences) { + String[] uuids = preferences.mSharedPreferences.getString("accountUuids", "").split(","); + StringBuffer sb = new StringBuffer(); + for (int i = 0, length = uuids.length; i < length; i++) { + if (!uuids[i].equals(mUuid)) { + if (sb.length() > 0) { + sb.append(','); + } + sb.append(uuids[i]); + } + } + String accountUuids = sb.toString(); + SharedPreferences.Editor editor = preferences.mSharedPreferences.edit(); + editor.putString("accountUuids", accountUuids); + + editor.remove(mUuid + ".storeUri"); + editor.remove(mUuid + ".localStoreUri"); + editor.remove(mUuid + ".transportUri"); + editor.remove(mUuid + ".description"); + editor.remove(mUuid + ".name"); + editor.remove(mUuid + ".email"); + editor.remove(mUuid + ".alwaysBcc"); + editor.remove(mUuid + ".automaticCheckIntervalMinutes"); + editor.remove(mUuid + ".lastAutomaticCheckTime"); + editor.remove(mUuid + ".notifyNewMail"); + editor.remove(mUuid + ".deletePolicy"); + editor.remove(mUuid + ".draftsFolderName"); + editor.remove(mUuid + ".sentFolderName"); + editor.remove(mUuid + ".trashFolderName"); + editor.remove(mUuid + ".outboxFolderName"); + editor.remove(mUuid + ".accountNumber"); + editor.remove(mUuid + ".vibrate"); + editor.remove(mUuid + ".ringtone"); + editor.commit(); + } + + public void save(Preferences preferences) { + if (!preferences.mSharedPreferences.getString("accountUuids", "").contains(mUuid)) { + /* + * When the account is first created we assign it a unique account number. The + * account number will be unique to that account for the lifetime of the account. + * So, we get all the existing account numbers, sort them ascending, loop through + * the list and check if the number is greater than 1 + the previous number. If so + * we use the previous number + 1 as the account number. This refills gaps. + * mAccountNumber starts as -1 on a newly created account. It must be -1 for this + * algorithm to work. + * + * I bet there is a much smarter way to do this. Anyone like to suggest it? + */ + Account[] accounts = preferences.getAccounts(); + int[] accountNumbers = new int[accounts.length]; + for (int i = 0; i < accounts.length; i++) { + accountNumbers[i] = accounts[i].getAccountNumber(); + } + Arrays.sort(accountNumbers); + for (int accountNumber : accountNumbers) { + if (accountNumber > mAccountNumber + 1) { + break; + } + mAccountNumber = accountNumber; + } + mAccountNumber++; + + String accountUuids = preferences.mSharedPreferences.getString("accountUuids", ""); + accountUuids += (accountUuids.length() != 0 ? "," : "") + mUuid; + SharedPreferences.Editor editor = preferences.mSharedPreferences.edit(); + editor.putString("accountUuids", accountUuids); + editor.commit(); + } + + SharedPreferences.Editor editor = preferences.mSharedPreferences.edit(); + + editor.putString(mUuid + ".storeUri", Utility.base64Encode(mStoreUri)); + editor.putString(mUuid + ".localStoreUri", mLocalStoreUri); + editor.putString(mUuid + ".transportUri", Utility.base64Encode(mTransportUri)); + editor.putString(mUuid + ".description", mDescription); + editor.putString(mUuid + ".name", mName); + editor.putString(mUuid + ".email", mEmail); + editor.putString(mUuid + ".signature", mSignature); + editor.putString(mUuid + ".alwaysBcc", mAlwaysBcc); + editor.putInt(mUuid + ".automaticCheckIntervalMinutes", mAutomaticCheckIntervalMinutes); + editor.putInt(mUuid + ".displayCount", mDisplayCount); + editor.putLong(mUuid + ".lastAutomaticCheckTime", mLastAutomaticCheckTime); + editor.putBoolean(mUuid + ".notifyNewMail", mNotifyNewMail); + editor.putBoolean(mUuid + ".notifyRingtone", mNotifyRingtone); + editor.putInt(mUuid + ".deletePolicy", mDeletePolicy); + editor.putString(mUuid + ".draftsFolderName", mDraftsFolderName); + editor.putString(mUuid + ".sentFolderName", mSentFolderName); + editor.putString(mUuid + ".trashFolderName", mTrashFolderName); + editor.putString(mUuid + ".outboxFolderName", mOutboxFolderName); + editor.putInt(mUuid + ".accountNumber", mAccountNumber); + editor.putBoolean(mUuid + ".vibrate", mVibrate); + editor.putString(mUuid + ".ringtone", mRingtoneUri); + editor.commit(); + } + + public String toString() { + return mDescription; + } + + public Uri getContentUri() { + return Uri.parse("content://accounts/" + getUuid()); + } + + public String getLocalStoreUri() { + return mLocalStoreUri; + } + + public void setLocalStoreUri(String localStoreUri) { + this.mLocalStoreUri = localStoreUri; + } + + /** + * Returns -1 for never. + */ + public int getAutomaticCheckIntervalMinutes() { + return mAutomaticCheckIntervalMinutes; + } + + public int getDisplayCount() { + if (mDisplayCount == -1) { + this.mDisplayCount = Email.DEFAULT_VISIBLE_LIMIT; + } + return mDisplayCount; + } + + /** + * @param automaticCheckIntervalMinutes or -1 for never. + */ + public void setAutomaticCheckIntervalMinutes(int automaticCheckIntervalMinutes) { + this.mAutomaticCheckIntervalMinutes = automaticCheckIntervalMinutes; + } + + /** + * @param displayCount + */ + public void setDisplayCount(int displayCount) { + if (displayCount != -1) { + this.mDisplayCount = displayCount; + } else { + this.mDisplayCount = Email.DEFAULT_VISIBLE_LIMIT; + } + } + + public long getLastAutomaticCheckTime() { + return mLastAutomaticCheckTime; + } + + public void setLastAutomaticCheckTime(long lastAutomaticCheckTime) { + this.mLastAutomaticCheckTime = lastAutomaticCheckTime; + } + + public boolean isNotifyRingtone() { + return mNotifyRingtone; + } + + public void setNotifyRingtone(boolean notifyRingtone) { + this.mNotifyRingtone = notifyRingtone; + } + + + public boolean isNotifyNewMail() { + return mNotifyNewMail; + } + + public void setNotifyNewMail(boolean notifyNewMail) { + this.mNotifyNewMail = notifyNewMail; + } + + public int getDeletePolicy() { + return mDeletePolicy; + } + + public void setDeletePolicy(int deletePolicy) { + this.mDeletePolicy = deletePolicy; + } + + public String getDraftsFolderName() { + return mDraftsFolderName; + } + + public void setDraftsFolderName(String draftsFolderName) { + mDraftsFolderName = draftsFolderName; + } + + public String getSentFolderName() { + return mSentFolderName; + } + + public void setSentFolderName(String sentFolderName) { + mSentFolderName = sentFolderName; + } + + public String getTrashFolderName() { + return mTrashFolderName; + } + + public void setTrashFolderName(String trashFolderName) { + mTrashFolderName = trashFolderName; + } + + public String getOutboxFolderName() { + return mOutboxFolderName; + } + + public void setOutboxFolderName(String outboxFolderName) { + mOutboxFolderName = outboxFolderName; + } + + public int getAccountNumber() { + return mAccountNumber; + } + + @Override + public boolean equals(Object o) { + if (o instanceof Account) { + return ((Account)o).mUuid.equals(mUuid); + } + return super.equals(o); + } +} diff --git a/src/com/android/email/Email.java b/src/com/android/email/Email.java new file mode 100644 index 000000000..f303962f1 --- /dev/null +++ b/src/com/android/email/Email.java @@ -0,0 +1,177 @@ + +package com.android.email; + +import java.io.File; + +import android.app.Application; +import android.content.ComponentName; +import android.content.Context; +import android.content.pm.PackageManager; +import android.util.Config; +import android.util.Log; + +import com.android.email.activity.MessageCompose; +import com.android.email.mail.internet.BinaryTempFileBody; +import com.android.email.mail.internet.MimeMessage; +import com.android.email.service.BootReceiver; +import com.android.email.service.MailService; + +public class Email extends Application { + public static Application app = null; + public static File tempDirectory; + public static final String LOG_TAG = "k9"; + + /** + * If this is enabled there will be additional logging information sent to + * Log.d, including protocol dumps. + */ + public static boolean DEBUG = false; + + /** + * If this is enabled than logging that normally hides sensitive information + * like passwords will show that information. + */ + public static boolean DEBUG_SENSITIVE = false; + + + /** + * The MIME type(s) of attachments we're willing to send. At the moment it is not possible + * to open a chooser with a list of filter types, so the chooser is only opened with the first + * item in the list. The entire list will be used to filter down attachments that are added + * with Intent.ACTION_SEND. + */ + public static final String[] ACCEPTABLE_ATTACHMENT_SEND_TYPES = new String[] { + "*/*", + }; + + /** + * The MIME type(s) of attachments we're willing to view. + */ + public static final String[] ACCEPTABLE_ATTACHMENT_VIEW_TYPES = new String[] { + "image/*", + "audio/*", + "text/*", + }; + + /** + * The MIME type(s) of attachments we're not willing to view. + */ + public static final String[] UNACCEPTABLE_ATTACHMENT_VIEW_TYPES = new String[] { + "image/gif", + }; + + /** + * The MIME type(s) of attachments we're willing to download to SD. + */ + public static final String[] ACCEPTABLE_ATTACHMENT_DOWNLOAD_TYPES = new String[] { + "*/*", + }; + + /** + * The MIME type(s) of attachments we're not willing to download to SD. + */ + public static final String[] UNACCEPTABLE_ATTACHMENT_DOWNLOAD_TYPES = new String[] { + "image/gif", + }; + + /** + * The special name "INBOX" is used throughout the application to mean "Whatever folder + * the server refers to as the user's Inbox. Placed here to ease use. + */ + public static final String INBOX = "INBOX"; + + /** + * Specifies how many messages will be shown in a folder by default. This number is set + * on each new folder and can be incremented with "Load more messages..." by the + * VISIBLE_LIMIT_INCREMENT + */ + public static int DEFAULT_VISIBLE_LIMIT = 25; + + /** + * Number of additioanl messages to load when a user selectes "Load more messages..." + */ + public static int VISIBLE_LIMIT_INCREMENT = 25; + + /** + * The maximum size of an attachment we're willing to download (either View or Save) + * Attachments that are base64 encoded (most) will be about 1.375x their actual size + * so we should probably factor that in. A 5MB attachment will generally be around + * 6.8MB downloaded but only 5MB saved. + */ + public static final int MAX_ATTACHMENT_DOWNLOAD_SIZE = (5 * 1024 * 1024); + + /** + * Called throughout the application when the number of accounts has changed. This method + * enables or disables the Compose activity, the boot receiver and the service based on + * whether any accounts are configured. + */ + public static void setServicesEnabled(Context context) { + setServicesEnabled(context, Preferences.getPreferences(context).getAccounts().length > 0); + } + + public static void setServicesEnabled(Context context, boolean enabled) { + PackageManager pm = context.getPackageManager(); + if (!enabled && pm.getComponentEnabledSetting(new ComponentName(context, MailService.class)) == + PackageManager.COMPONENT_ENABLED_STATE_ENABLED) { + /* + * If no accounts now exist but the service is still enabled we're about to disable it + * so we'll reschedule to kill off any existing alarms. + */ + MailService.actionReschedule(context); + } + pm.setComponentEnabledSetting( + new ComponentName(context, MessageCompose.class), + enabled ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED : + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP); + pm.setComponentEnabledSetting( + new ComponentName(context, BootReceiver.class), + enabled ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED : + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP); + pm.setComponentEnabledSetting( + new ComponentName(context, MailService.class), + enabled ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED : + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP); + if (enabled && pm.getComponentEnabledSetting(new ComponentName(context, MailService.class)) == + PackageManager.COMPONENT_ENABLED_STATE_ENABLED) { + /* + * And now if accounts do exist then we've just enabled the service and we want to + * schedule alarms for the new accounts. + */ + MailService.actionReschedule(context); + } + } + + @Override + public void onCreate() { + super.onCreate(); + app = this; + Preferences prefs = Preferences.getPreferences(this); + DEBUG = prefs.geteEnableDebugLogging(); + DEBUG_SENSITIVE = prefs.getEnableSensitiveLogging(); + MessagingController.getInstance(this).resetVisibleLimits(prefs.getAccounts()); + + /* + * We have to give MimeMessage a temp directory because File.createTempFile(String, String) + * doesn't work in Android and MimeMessage does not have access to a Context. + */ + BinaryTempFileBody.setTempDirectory(getCacheDir()); + + /* + * Enable background sync of messages + */ + + setServicesEnabled(this); + + } +} + + + + + + + + diff --git a/src/com/android/email/EmailAddressAdapter.java b/src/com/android/email/EmailAddressAdapter.java new file mode 100644 index 000000000..45636c0b9 --- /dev/null +++ b/src/com/android/email/EmailAddressAdapter.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email; + +import static android.provider.Contacts.ContactMethods.CONTENT_EMAIL_URI; +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.provider.Contacts.ContactMethods; +import android.provider.Contacts.People; +import android.view.View; +import android.widget.ResourceCursorAdapter; +import android.widget.TextView; + +import com.android.email.mail.Address; + +public class EmailAddressAdapter extends ResourceCursorAdapter { + public static final int NAME_INDEX = 1; + + public static final int DATA_INDEX = 2; + + private static final String SORT_ORDER = People.TIMES_CONTACTED + " DESC, " + People.NAME; + + private ContentResolver mContentResolver; + + private static final String[] PROJECTION = { + ContactMethods._ID, // 0 + ContactMethods.NAME, // 1 + ContactMethods.DATA + // 2 + }; + + public EmailAddressAdapter(Context context) { + super(context, R.layout.recipient_dropdown_item, null); + mContentResolver = context.getContentResolver(); + } + + @Override + public final String convertToString(Cursor cursor) { + String name = cursor.getString(NAME_INDEX); + String address = cursor.getString(DATA_INDEX); + + return new Address(address, name).toString(); + } + + @Override + public final void bindView(View view, Context context, Cursor cursor) { + TextView text1 = (TextView)view.findViewById(R.id.text1); + TextView text2 = (TextView)view.findViewById(R.id.text2); + text1.setText(cursor.getString(NAME_INDEX)); + text2.setText(cursor.getString(DATA_INDEX)); + } + + @Override + public Cursor runQueryOnBackgroundThread(CharSequence constraint) { + String where = null; + + if (constraint != null) { + String filter = DatabaseUtils.sqlEscapeString(constraint.toString() + '%'); + + StringBuilder s = new StringBuilder(); + s.append("(people.name LIKE "); + s.append(filter); + s.append(") OR (contact_methods.data LIKE "); + s.append(filter); + s.append(")"); + + where = s.toString(); + } + + return mContentResolver.query(CONTENT_EMAIL_URI, PROJECTION, where, null, SORT_ORDER); + } +} diff --git a/src/com/android/email/EmailAddressValidator.java b/src/com/android/email/EmailAddressValidator.java new file mode 100644 index 000000000..2a0b65dc0 --- /dev/null +++ b/src/com/android/email/EmailAddressValidator.java @@ -0,0 +1,18 @@ + +package com.android.email; + +import com.android.email.mail.Address; + +import android.util.Config; +import android.util.Log; +import android.widget.AutoCompleteTextView.Validator; + +public class EmailAddressValidator implements Validator { + public CharSequence fixText(CharSequence invalidText) { + return ""; + } + + public boolean isValid(CharSequence text) { + return Address.parse(text.toString()).length > 0; + } +} diff --git a/src/com/android/email/FixedLengthInputStream.java b/src/com/android/email/FixedLengthInputStream.java new file mode 100644 index 000000000..ac24641d0 --- /dev/null +++ b/src/com/android/email/FixedLengthInputStream.java @@ -0,0 +1,60 @@ + +package com.android.email; + +import java.io.IOException; +import java.io.InputStream; + +/** + * A filtering InputStream that stops allowing reads after the given length has been read. This + * is used to allow a client to read directly from an underlying protocol stream without reading + * past where the protocol handler intended the client to read. + */ +public class FixedLengthInputStream extends InputStream { + private InputStream mIn; + private int mLength; + private int mCount; + + public FixedLengthInputStream(InputStream in, int length) { + this.mIn = in; + this.mLength = length; + } + + @Override + public int available() throws IOException { + return mLength - mCount; + } + + @Override + public int read() throws IOException { + if (mCount < mLength) { + mCount++; + return mIn.read(); + } else { + return -1; + } + } + + @Override + public int read(byte[] b, int offset, int length) throws IOException { + if (mCount < mLength) { + int d = mIn.read(b, offset, Math.min(mLength - mCount, length)); + if (d == -1) { + return -1; + } else { + mCount += d; + return d; + } + } else { + return -1; + } + } + + @Override + public int read(byte[] b) throws IOException { + return read(b, 0, b.length); + } + + public String toString() { + return String.format("FixedLengthInputStream(in=%s, length=%d)", mIn.toString(), mLength); + } +} diff --git a/src/com/android/email/Manifest.java b/src/com/android/email/Manifest.java new file mode 100644 index 000000000..fbb008584 --- /dev/null +++ b/src/com/android/email/Manifest.java @@ -0,0 +1,14 @@ +/* AUTO-GENERATED FILE. DO NOT MODIFY. + * + * This class was automatically generated by the + * aapt tool from the resource data it found. It + * should not be modified by hand. + */ + +package com.android.email; + +public final class Manifest { + public static final class permission { + public static final String READ_ATTACHMENT="com.android.email.permission.READ_ATTACHMENT"; + } +} diff --git a/src/com/android/email/MessagingController.java b/src/com/android/email/MessagingController.java new file mode 100644 index 000000000..bbbdebf77 --- /dev/null +++ b/src/com/android/email/MessagingController.java @@ -0,0 +1,1499 @@ + +package com.android.email; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import android.app.Application; +import android.content.Context; +import android.os.Process; +import android.util.Config; +import android.util.Log; + +import com.android.email.mail.FetchProfile; +import com.android.email.mail.Flag; +import com.android.email.mail.Folder; +import com.android.email.mail.Message; +import com.android.email.mail.MessageRetrievalListener; +import com.android.email.mail.MessagingException; +import com.android.email.mail.Part; +import com.android.email.mail.Store; +import com.android.email.mail.Transport; +import com.android.email.mail.Folder.FolderType; +import com.android.email.mail.Folder.OpenMode; +import com.android.email.mail.internet.MimeHeader; +import com.android.email.mail.internet.MimeUtility; +import com.android.email.mail.store.LocalStore; +import com.android.email.mail.store.LocalStore.LocalFolder; +import com.android.email.mail.store.LocalStore.LocalMessage; +import com.android.email.mail.store.LocalStore.PendingCommand; + +/** + * Starts a long running (application) Thread that will run through commands + * that require remote mailbox access. This class is used to serialize and + * prioritize these commands. Each method that will submit a command requires a + * MessagingListener instance to be provided. It is expected that that listener + * has also been added as a registered listener using addListener(). When a + * command is to be executed, if the listener that was provided with the command + * is no longer registered the command is skipped. The design idea for the above + * is that when an Activity starts it registers as a listener. When it is paused + * it removes itself. Thus, any commands that that activity submitted are + * removed from the queue once the activity is no longer active. + */ +public class MessagingController implements Runnable { + /** + * The maximum message size that we'll consider to be "small". A small message is downloaded + * in full immediately instead of in pieces. Anything over this size will be downloaded in + * pieces with attachments being left off completely and downloaded on demand. + * + * + * 25k for a "small" message was picked by educated trial and error. + * http://answers.google.com/answers/threadview?id=312463 claims that the + * average size of an email is 59k, which I feel is too large for our + * blind download. The following tests were performed on a download of + * 25 random messages. + *
+     * 5k - 61 seconds,
+     * 25k - 51 seconds,
+     * 55k - 53 seconds,
+     * 
+ * 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. + *

+ * + * @see RFC 2045 + * @author Apache Software Foundation + * @since 1.0-dev + * @version $Id$ + */ +public class Base64 implements BinaryEncoder, BinaryDecoder { + /** + * Chunk size per RFC 2045 section 6.8. + * + *

+ * 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 { + public ImapList getList(int index) { + return (ImapList)get(index); + } + + public String getString(int index) { + return (String)get(index); + } + + public InputStream getLiteral(int index) { + return (InputStream)get(index); + } + + public int getNumber(int index) { + return Integer.parseInt(getString(index)); + } + + public Date getDate(int index) throws MessagingException { + try { + return mDateTimeFormat.parse(getString(index)); + } catch (ParseException pe) { + throw new MessagingException("Unable to parse IMAP datetime", pe); + } + } + + public Object getKeyedValue(Object key) { + for (int i = 0, count = size(); i < count; i++) { + if (get(i).equals(key)) { + return get(i + 1); + } + } + return null; + } + + public ImapList getKeyedList(Object key) { + return (ImapList)getKeyedValue(key); + } + + public String getKeyedString(Object key) { + return (String)getKeyedValue(key); + } + + public InputStream getKeyedLiteral(Object key) { + return (InputStream)getKeyedValue(key); + } + + public int getKeyedNumber(Object key) { + return Integer.parseInt(getKeyedString(key)); + } + + public Date getKeyedDate(Object key) throws MessagingException { + try { + String value = getKeyedString(key); + if (value == null) { + return null; + } + return mDateTimeFormat.parse(value); + } catch (ParseException pe) { + throw new MessagingException("Unable to parse IMAP datetime", pe); + } + } + } + + /** + * Represents a single response from the IMAP server. Tagged responses will + * have a non-null tag. Untagged responses will have a null tag. The object + * will contain all of the available tokens at the time the response is + * received. In general, it will either contain all of the tokens of the + * response or all of the tokens up until the first LITERAL. If the object + * does not contain the entire response the caller must call more() to + * continue reading the response until more returns false. + */ + public class ImapResponse extends ImapList { + private boolean mCompleted; + + boolean mCommandContinuationRequested; + String mTag; + + public boolean more() throws IOException { + if (mCompleted) { + return false; + } + readTokens(this); + return true; + } + + public String getAlertText() { + if (size() > 1 && "[ALERT]".equals(getString(1))) { + StringBuffer sb = new StringBuffer(); + for (int i = 2, count = size(); i < count; i++) { + sb.append(get(i).toString()); + sb.append(' '); + } + return sb.toString(); + } else { + return null; + } + } + + public String toString() { + return "#" + mTag + "# " + super.toString(); + } + } +} diff --git a/src/com/android/email/mail/store/ImapStore.java b/src/com/android/email/mail/store/ImapStore.java new file mode 100644 index 000000000..7312bbc8e --- /dev/null +++ b/src/com/android/email/mail/store/ImapStore.java @@ -0,0 +1,1318 @@ + +package com.android.email.mail.store; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.Charset; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.SSLException; + +import android.util.Config; +import android.util.Log; + +import com.android.email.Email; +import com.android.email.PeekableInputStream; +import com.android.email.Utility; +import com.android.email.mail.AuthenticationFailedException; +import com.android.email.mail.FetchProfile; +import com.android.email.mail.Flag; +import com.android.email.mail.Folder; +import com.android.email.mail.Message; +import com.android.email.mail.MessageRetrievalListener; +import com.android.email.mail.MessagingException; +import com.android.email.mail.Part; +import com.android.email.mail.Store; +import com.android.email.mail.CertificateValidationException; +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.store.ImapResponseParser.ImapList; +import com.android.email.mail.store.ImapResponseParser.ImapResponse; +import com.android.email.mail.transport.CountingOutputStream; +import com.android.email.mail.transport.EOLConvertingOutputStream; +import com.beetstra.jutf7.CharsetProvider; + +/** + *
+ * TODO Need to start keeping track of UIDVALIDITY
+ * TODO Need a default response handler for things like folder updates
+ * TODO In fetch(), if we need a ImapMessage and were given
+ * something else we can try to do a pre-fetch first.
+ *
+ * ftp://ftp.isi.edu/in-notes/rfc2683.txt When a client asks for
+ * certain information in a FETCH command, the server may return the requested
+ * information in any order, not necessarily in the order that it was requested.
+ * Further, the server may return the information in separate FETCH responses
+ * and may also return information that was not explicitly requested (to reflect
+ * to the client changes in the state of the subject message).
+ * 
+ */ +public class ImapStore extends Store { + public static final int CONNECTION_SECURITY_NONE = 0; + public static final int CONNECTION_SECURITY_TLS_OPTIONAL = 1; + public static final int CONNECTION_SECURITY_TLS_REQUIRED = 2; + public static final int CONNECTION_SECURITY_SSL_REQUIRED = 3; + public static final int CONNECTION_SECURITY_SSL_OPTIONAL = 4; + + private static final Flag[] PERMANENT_FLAGS = { Flag.DELETED, Flag.SEEN }; + + private String mHost; + private int mPort; + private String mUsername; + private String mPassword; + private int mConnectionSecurity; + private String mPathPrefix; + private String mPathDelimeter; + + private LinkedList mConnections = + new LinkedList(); + + /** + * Charset used for converting folder names to and from UTF-7 as defined by RFC 3501. + */ + private Charset mModifiedUtf7Charset; + + /** + * Cache of ImapFolder objects. ImapFolders are attached to a given folder on the server + * and as long as their associated connection remains open they are reusable between + * requests. This cache lets us make sure we always reuse, if possible, for a given + * folder name. + */ + private HashMap mFolderCache = new HashMap(); + + /** + * imap://user:password@server:port CONNECTION_SECURITY_NONE + * imap+tls://user:password@server:port CONNECTION_SECURITY_TLS_OPTIONAL + * imap+tls+://user:password@server:port CONNECTION_SECURITY_TLS_REQUIRED + * imap+ssl+://user:password@server:port CONNECTION_SECURITY_SSL_REQUIRED + * imap+ssl://user:password@server:port CONNECTION_SECURITY_SSL_OPTIONAL + * + * @param _uri + */ + public ImapStore(String _uri) throws MessagingException { + URI uri; + try { + uri = new URI(_uri); + } catch (URISyntaxException use) { + throw new MessagingException("Invalid ImapStore URI", use); + } + + String scheme = uri.getScheme(); + if (scheme.equals("imap")) { + mConnectionSecurity = CONNECTION_SECURITY_NONE; + mPort = 143; + } else if (scheme.equals("imap+tls")) { + mConnectionSecurity = CONNECTION_SECURITY_TLS_OPTIONAL; + mPort = 143; + } else if (scheme.equals("imap+tls+")) { + mConnectionSecurity = CONNECTION_SECURITY_TLS_REQUIRED; + mPort = 143; + } else if (scheme.equals("imap+ssl+")) { + mConnectionSecurity = CONNECTION_SECURITY_SSL_REQUIRED; + mPort = 993; + } else if (scheme.equals("imap+ssl")) { + mConnectionSecurity = CONNECTION_SECURITY_SSL_OPTIONAL; + mPort = 993; + } else { + throw new MessagingException("Unsupported protocol"); + } + + mHost = uri.getHost(); + + if (uri.getPort() != -1) { + mPort = uri.getPort(); + } + + if (uri.getUserInfo() != null) { + String[] userInfoParts = uri.getUserInfo().split(":", 2); + mUsername = userInfoParts[0]; + if (userInfoParts.length > 1) { + mPassword = userInfoParts[1]; + } + } + + if ((uri.getPath() != null) && (uri.getPath().length() > 0)) { + mPathPrefix = uri.getPath().substring(1); + } + + mModifiedUtf7Charset = new CharsetProvider().charsetForName("X-RFC-3501"); + } + + @Override + public Folder getFolder(String name) throws MessagingException { + ImapFolder folder; + synchronized (mFolderCache) { + folder = mFolderCache.get(name); + if (folder == null) { + folder = new ImapFolder(name); + mFolderCache.put(name, folder); + } + } + return folder; + } + + + @Override + public Folder[] getPersonalNamespaces() throws MessagingException { + ImapConnection connection = getConnection(); + try { + ArrayList folders = new ArrayList(); + if(mPathPrefix == null){ mPathPrefix = ""; } + List responses = + connection.executeSimpleCommand(String.format("LIST \"\" \"%s*\"", + mPathPrefix)); + + for (ImapResponse response : responses) { + if (response.get(0).equals("LIST")) { + boolean includeFolder = true; + String folder = decodeFolderName(response.getString(3)); + + if(mPathDelimeter == null){ mPathDelimeter = response.getString(2); } + + if (folder.equalsIgnoreCase(Email.INBOX)) { + continue; + }else{ + if(mPathPrefix.length() > 0){ + if(folder.length() >= mPathPrefix.length() + 1){ + folder = folder.substring(mPathPrefix.length() + 1); + } + if(!decodeFolderName(response.getString(3)).equals(mPathPrefix + mPathDelimeter + folder)){ + includeFolder = false; + } + } + } + + ImapList attributes = response.getList(1); + for (int i = 0, count = attributes.size(); i < count; i++) { + String attribute = attributes.getString(i); + if (attribute.equalsIgnoreCase("\\NoSelect")) { + includeFolder = false; + } + } + if (includeFolder) { + folders.add(getFolder(folder)); + } + } + } + folders.add(getFolder("INBOX")); + return folders.toArray(new Folder[] {}); + } catch (IOException ioe) { + connection.close(); + throw new MessagingException("Unable to get folder list.", ioe); + } finally { + releaseConnection(connection); + } + } + + @Override + public void checkSettings() throws MessagingException { + try { + ImapConnection connection = new ImapConnection(); + connection.open(); + connection.close(); + } + catch (IOException ioe) { + throw new MessagingException("Unable to connect.", ioe); + } + } + + /** + * Gets a connection if one is available for reuse, or creates a new one if not. + * @return + */ + private ImapConnection getConnection() throws MessagingException { + synchronized (mConnections) { + ImapConnection connection = null; + while ((connection = mConnections.poll()) != null) { + try { + connection.executeSimpleCommand("NOOP"); + break; + } + catch (IOException ioe) { + connection.close(); + } + } + if (connection == null) { + connection = new ImapConnection(); + } + return connection; + } + } + + private void releaseConnection(ImapConnection connection) { + mConnections.offer(connection); + } + + private String encodeFolderName(String name) { + try { + ByteBuffer bb = mModifiedUtf7Charset.encode(name); + byte[] b = new byte[bb.limit()]; + bb.get(b); + return new String(b, "US-ASCII"); + } + catch (UnsupportedEncodingException uee) { + /* + * The only thing that can throw this is getBytes("US-ASCII") and if US-ASCII doesn't + * exist we're totally screwed. + */ + throw new RuntimeException("Unable to encode folder name: " + name, uee); + } + } + + private String decodeFolderName(String name) { + /* + * Convert the encoded name to US-ASCII, then pass it through the modified UTF-7 + * decoder and return the Unicode String. + */ + try { + byte[] encoded = name.getBytes("US-ASCII"); + CharBuffer cb = mModifiedUtf7Charset.decode(ByteBuffer.wrap(encoded)); + return cb.toString(); + } + catch (UnsupportedEncodingException uee) { + /* + * The only thing that can throw this is getBytes("US-ASCII") and if US-ASCII doesn't + * exist we're totally screwed. + */ + throw new RuntimeException("Unable to decode folder name: " + name, uee); + } + } + + class ImapFolder extends Folder { + private String mName; + private int mMessageCount = -1; + private ImapConnection mConnection; + private OpenMode mMode; + private boolean mExists; + + public ImapFolder(String name) { + this.mName = name; + } + + public String getPrefixedName() { + String prefixedName = ""; + if(mPathPrefix.length() > 0 && !mName.equalsIgnoreCase(Email.INBOX)){ + prefixedName += mPathPrefix + mPathDelimeter; + } + + prefixedName += mName; + return prefixedName; + } + + public void open(OpenMode mode) throws MessagingException { + if (isOpen() && mMode == mode) { + // Make sure the connection is valid. If it's not we'll close it down and continue + // on to get a new one. + try { + mConnection.executeSimpleCommand("NOOP"); + return; + } + catch (IOException ioe) { + ioExceptionHandler(mConnection, ioe); + } + } + synchronized (this) { + mConnection = getConnection(); + } + // * FLAGS (\Answered \Flagged \Deleted \Seen \Draft NonJunk + // $MDNSent) + // * OK [PERMANENTFLAGS (\Answered \Flagged \Deleted \Seen \Draft + // NonJunk $MDNSent \*)] Flags permitted. + // * 23 EXISTS + // * 0 RECENT + // * OK [UIDVALIDITY 1125022061] UIDs valid + // * OK [UIDNEXT 57576] Predicted next UID + // 2 OK [READ-WRITE] Select completed. + try { + + if(mPathDelimeter == null){ + List nameResponses = + mConnection.executeSimpleCommand(String.format("LIST \"\" \"*%s\"", encodeFolderName(mName))); + if(nameResponses.size() > 0){ + mPathDelimeter = nameResponses.get(0).getString(2); + } + } + List responses = mConnection.executeSimpleCommand( + String.format("SELECT \"%s\"", + encodeFolderName(getPrefixedName()))); + + /* + * If the command succeeds we expect the folder has been opened read-write + * unless we are notified otherwise in the responses. + */ + mMode = OpenMode.READ_WRITE; + + for (ImapResponse response : responses) { + if (response.mTag == null && response.get(1).equals("EXISTS")) { + mMessageCount = response.getNumber(0); + } + else if (response.mTag != null && response.size() >= 2) { + if ("[READ-ONLY]".equalsIgnoreCase(response.getString(1))) { + mMode = OpenMode.READ_ONLY; + } + else if ("[READ-WRITE]".equalsIgnoreCase(response.getString(1))) { + mMode = OpenMode.READ_WRITE; + } + } + } + + if (mMessageCount == -1) { + throw new MessagingException( + "Did not find message count during select"); + } + mExists = true; + + } catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } + } + + public boolean isOpen() { + return mConnection != null; + } + + @Override + public OpenMode getMode() throws MessagingException { + return mMode; + } + + public void close(boolean expunge) { + if (!isOpen()) { + return; + } + // TODO implement expunge + mMessageCount = -1; + synchronized (this) { + releaseConnection(mConnection); + mConnection = null; + } + } + + public String getName() { + return mName; + } + + public boolean exists() throws MessagingException { + if (mExists) { + return true; + } + /* + * This method needs to operate in the unselected mode as well as the selected mode + * so we must get the connection ourselves if it's not there. We are specifically + * not calling checkOpen() since we don't care if the folder is open. + */ + ImapConnection connection = null; + synchronized(this) { + if (mConnection == null) { + connection = getConnection(); + } + else { + connection = mConnection; + } + } + try { + connection.executeSimpleCommand(String.format("STATUS \"%s\" (UIDVALIDITY)", + encodeFolderName(getPrefixedName()))); + mExists = true; + return true; + } + catch (MessagingException me) { + return false; + } + catch (IOException ioe) { + throw ioExceptionHandler(connection, ioe); + } + finally { + if (mConnection == null) { + releaseConnection(connection); + } + } + } + + public boolean create(FolderType type) throws MessagingException { + /* + * This method needs to operate in the unselected mode as well as the selected mode + * so we must get the connection ourselves if it's not there. We are specifically + * not calling checkOpen() since we don't care if the folder is open. + */ + ImapConnection connection = null; + synchronized(this) { + if (mConnection == null) { + connection = getConnection(); + } + else { + connection = mConnection; + } + } + try { + connection.executeSimpleCommand(String.format("CREATE \"%s\"", + encodeFolderName(getPrefixedName()))); + return true; + } + catch (MessagingException me) { + return false; + } + catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } + finally { + if (mConnection == null) { + releaseConnection(connection); + } + } + } + + @Override + public void copyMessages(Message[] messages, Folder folder) throws MessagingException { + checkOpen(); + String[] uids = new String[messages.length]; + for (int i = 0, count = messages.length; i < count; i++) { + uids[i] = messages[i].getUid(); + } + try { + mConnection.executeSimpleCommand(String.format("UID COPY %s \"%s\"", + Utility.combine(uids, ','), + encodeFolderName(folder.getName()))); + } + catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } + } + + @Override + public int getMessageCount() { + return mMessageCount; + } + + @Override + public int getUnreadMessageCount() throws MessagingException { + checkOpen(); + try { + int unreadMessageCount = 0; + List responses = mConnection.executeSimpleCommand( + String.format("STATUS \"%s\" (UNSEEN)", + encodeFolderName(getPrefixedName()))); + for (ImapResponse response : responses) { + if (response.mTag == null && response.get(0).equals("STATUS")) { + ImapList status = response.getList(2); + unreadMessageCount = status.getKeyedNumber("UNSEEN"); + } + } + return unreadMessageCount; + } + catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } + } + + @Override + public void delete(boolean recurse) throws MessagingException { + throw new Error("ImapStore.delete() not yet implemented"); + } + + @Override + public Message getMessage(String uid) throws MessagingException { + checkOpen(); + + try { + try { + List responses = + mConnection.executeSimpleCommand(String.format("UID SEARCH UID %S", uid)); + for (ImapResponse response : responses) { + if (response.mTag == null && response.get(0).equals("SEARCH")) { + for (int i = 1, count = response.size(); i < count; i++) { + if (uid.equals(response.get(i))) { + return new ImapMessage(uid, this); + } + } + } + } + } + catch (MessagingException me) { + return null; + } + } + catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } + return null; + } + + @Override + public Message[] getMessages(int start, int end, MessageRetrievalListener listener) + throws MessagingException { + if (start < 1 || end < 1 || end < start) { + throw new MessagingException( + String.format("Invalid message set %d %d", + start, end)); + } + checkOpen(); + ArrayList messages = new ArrayList(); + try { + ArrayList uids = new ArrayList(); + List responses = mConnection + .executeSimpleCommand(String.format("UID SEARCH %d:%d NOT DELETED", start, end)); + for (ImapResponse response : responses) { + if (response.get(0).equals("SEARCH")) { + for (int i = 1, count = response.size(); i < count; i++) { + uids.add(response.getString(i)); + } + } + } + for (int i = 0, count = uids.size(); i < count; i++) { + if (listener != null) { + listener.messageStarted(uids.get(i), i, count); + } + ImapMessage message = new ImapMessage(uids.get(i), this); + messages.add(message); + if (listener != null) { + listener.messageFinished(message, i, count); + } + } + } catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } + return messages.toArray(new Message[] {}); + } + + public Message[] getMessages(MessageRetrievalListener listener) throws MessagingException { + return getMessages(null, listener); + } + + public Message[] getMessages(String[] uids, MessageRetrievalListener listener) + throws MessagingException { + checkOpen(); + ArrayList messages = new ArrayList(); + try { + if (uids == null) { + List responses = mConnection + .executeSimpleCommand("UID SEARCH 1:* NOT DELETED"); + ArrayList tempUids = new ArrayList(); + for (ImapResponse response : responses) { + if (response.get(0).equals("SEARCH")) { + for (int i = 1, count = response.size(); i < count; i++) { + tempUids.add(response.getString(i)); + } + } + } + uids = tempUids.toArray(new String[] {}); + } + for (int i = 0, count = uids.length; i < count; i++) { + if (listener != null) { + listener.messageStarted(uids[i], i, count); + } + ImapMessage message = new ImapMessage(uids[i], this); + messages.add(message); + if (listener != null) { + listener.messageFinished(message, i, count); + } + } + } catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } + return messages.toArray(new Message[] {}); + } + + public void fetch(Message[] messages, FetchProfile fp, MessageRetrievalListener listener) + throws MessagingException { + if (messages == null || messages.length == 0) { + return; + } + checkOpen(); + String[] uids = new String[messages.length]; + HashMap messageMap = new HashMap(); + for (int i = 0, count = messages.length; i < count; i++) { + uids[i] = messages[i].getUid(); + messageMap.put(uids[i], messages[i]); + } + + /* + * Figure out what command we are going to run: + * Flags - UID FETCH (FLAGS) + * Envelope - UID FETCH ([FLAGS] INTERNALDATE UID RFC822.SIZE FLAGS BODY.PEEK[HEADER.FIELDS (date subject from content-type to cc)]) + * + */ + LinkedHashSet fetchFields = new LinkedHashSet(); + fetchFields.add("UID"); + if (fp.contains(FetchProfile.Item.FLAGS)) { + fetchFields.add("FLAGS"); + } + if (fp.contains(FetchProfile.Item.ENVELOPE)) { + fetchFields.add("INTERNALDATE"); + fetchFields.add("RFC822.SIZE"); + fetchFields.add("BODY.PEEK[HEADER.FIELDS (date subject from content-type to cc)]"); + } + if (fp.contains(FetchProfile.Item.STRUCTURE)) { + fetchFields.add("BODYSTRUCTURE"); + } + if (fp.contains(FetchProfile.Item.BODY_SANE)) { + fetchFields.add(String.format("BODY.PEEK[]<0.%d>", FETCH_BODY_SANE_SUGGESTED_SIZE)); + } + if (fp.contains(FetchProfile.Item.BODY)) { + fetchFields.add("BODY.PEEK[]"); + } + for (Object o : fp) { + if (o instanceof Part) { + Part part = (Part) o; + String partId = part.getHeader(MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA)[0]; + fetchFields.add("BODY.PEEK[" + partId + "]"); + } + } + + try { + String tag = mConnection.sendCommand(String.format("UID FETCH %s (%s)", + Utility.combine(uids, ','), + Utility.combine(fetchFields.toArray(new String[fetchFields.size()]), ' ') + ), false); + ImapResponse response; + int messageNumber = 0; + do { + response = mConnection.readResponse(); + + if (response.mTag == null && response.get(1).equals("FETCH")) { + ImapList fetchList = (ImapList)response.getKeyedValue("FETCH"); + String uid = fetchList.getKeyedString("UID"); + + Message message = messageMap.get(uid); + + if (listener != null) { + listener.messageStarted(uid, messageNumber++, messageMap.size()); + } + + if (fp.contains(FetchProfile.Item.FLAGS)) { + ImapList flags = fetchList.getKeyedList("FLAGS"); + ImapMessage imapMessage = (ImapMessage) message; + if (flags != null) { + for (int i = 0, count = flags.size(); i < count; i++) { + String flag = flags.getString(i); + if (flag.equals("\\Deleted")) { + imapMessage.setFlagInternal(Flag.DELETED, true); + } + else if (flag.equals("\\Answered")) { + imapMessage.setFlagInternal(Flag.ANSWERED, true); + } + else if (flag.equals("\\Seen")) { + imapMessage.setFlagInternal(Flag.SEEN, true); + } + else if (flag.equals("\\Flagged")) { + imapMessage.setFlagInternal(Flag.FLAGGED, true); + } + } + } + } + if (fp.contains(FetchProfile.Item.ENVELOPE)) { + Date internalDate = fetchList.getKeyedDate("INTERNALDATE"); + int size = fetchList.getKeyedNumber("RFC822.SIZE"); + InputStream headerStream = fetchList.getLiteral(fetchList.size() - 1); + + ImapMessage imapMessage = (ImapMessage) message; + + message.setInternalDate(internalDate); + imapMessage.setSize(size); + imapMessage.parse(headerStream); + } + if (fp.contains(FetchProfile.Item.STRUCTURE)) { + ImapList bs = fetchList.getKeyedList("BODYSTRUCTURE"); + if (bs != null) { + try { + parseBodyStructure(bs, message, "TEXT"); + } + catch (MessagingException e) { + if (Config.LOGV) { + Log.v(Email.LOG_TAG, "Error handling message", e); + } + message.setBody(null); + } + } + } + if (fp.contains(FetchProfile.Item.BODY)) { + InputStream bodyStream = fetchList.getLiteral(fetchList.size() - 1); + ImapMessage imapMessage = (ImapMessage) message; + imapMessage.parse(bodyStream); + } + if (fp.contains(FetchProfile.Item.BODY_SANE)) { + InputStream bodyStream = fetchList.getLiteral(fetchList.size() - 1); + ImapMessage imapMessage = (ImapMessage) message; + imapMessage.parse(bodyStream); + } + for (Object o : fp) { + if (o instanceof Part) { + Part part = (Part) o; + InputStream bodyStream = fetchList.getLiteral(fetchList.size() - 1); + String contentType = part.getContentType(); + String contentTransferEncoding = part.getHeader( + MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING)[0]; + part.setBody(MimeUtility.decodeBody( + bodyStream, + contentTransferEncoding)); + } + } + + if (listener != null) { + listener.messageFinished(message, messageNumber, messageMap.size()); + } + } + + while (response.more()); + + } while (response.mTag == null); + } + catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } + } + + @Override + public Flag[] getPermanentFlags() throws MessagingException { + return PERMANENT_FLAGS; + } + + /** + * Handle any untagged responses that the caller doesn't care to handle themselves. + * @param responses + */ + private void handleUntaggedResponses(List responses) { + for (ImapResponse response : responses) { + handleUntaggedResponse(response); + } + } + + /** + * Handle an untagged response that the caller doesn't care to handle themselves. + * @param response + */ + private void handleUntaggedResponse(ImapResponse response) { + if (response.mTag == null && response.get(1).equals("EXISTS")) { + mMessageCount = response.getNumber(0); + } + } + + private void parseBodyStructure(ImapList bs, Part part, String id) + throws MessagingException { + if (bs.get(0) instanceof ImapList) { + /* + * This is a multipart/* + */ + MimeMultipart mp = new MimeMultipart(); + for (int i = 0, count = bs.size(); i < count; i++) { + if (bs.get(i) instanceof ImapList) { + /* + * For each part in the message we're going to add a new BodyPart and parse + * into it. + */ + ImapBodyPart bp = new ImapBodyPart(); + if (id.equals("TEXT")) { + parseBodyStructure(bs.getList(i), bp, Integer.toString(i + 1)); + } + else { + parseBodyStructure(bs.getList(i), bp, id + "." + (i + 1)); + } + mp.addBodyPart(bp); + } + else { + /* + * We've got to the end of the children of the part, so now we can find out + * what type it is and bail out. + */ + String subType = bs.getString(i); + mp.setSubType(subType.toLowerCase()); + break; + } + } + part.setBody(mp); + } + else{ + /* + * This is a body. We need to add as much information as we can find out about + * it to the Part. + */ + + /* + body type + body subtype + body parameter parenthesized list + body id + body description + body encoding + body size + */ + + + String type = bs.getString(0); + String subType = bs.getString(1); + String mimeType = (type + "/" + subType).toLowerCase(); + + ImapList bodyParams = null; + if (bs.get(2) instanceof ImapList) { + bodyParams = bs.getList(2); + } + String encoding = bs.getString(5); + int size = bs.getNumber(6); + + if (MimeUtility.mimeTypeMatches(mimeType, "message/rfc822")) { +// A body type of type MESSAGE and subtype RFC822 +// contains, immediately after the basic fields, the +// envelope structure, body structure, and size in +// text lines of the encapsulated message. +// [MESSAGE, RFC822, [NAME, Fwd: [#HTR-517941]: update plans at 1am Friday - Memory allocation - displayware.eml], NIL, NIL, 7BIT, 5974, NIL, [INLINE, [FILENAME*0, Fwd: [#HTR-517941]: update plans at 1am Friday - Memory all, FILENAME*1, ocation - displayware.eml]], NIL] + /* + * This will be caught by fetch and handled appropriately. + */ + throw new MessagingException("BODYSTRUCTURE message/rfc822 not yet supported."); + } + + /* + * Set the content type with as much information as we know right now. + */ + String contentType = String.format("%s", mimeType); + + if (bodyParams != null) { + /* + * If there are body params we might be able to get some more information out + * of them. + */ + for (int i = 0, count = bodyParams.size(); i < count; i += 2) { + contentType += String.format(";\n %s=\"%s\"", + bodyParams.getString(i), + bodyParams.getString(i + 1)); + } + } + + part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, contentType); + + // Extension items + ImapList bodyDisposition = null; + if (("text".equalsIgnoreCase(type)) + && (bs.size() > 8) + && (bs.get(9) instanceof ImapList)) { + bodyDisposition = bs.getList(9); + } + else if (!("text".equalsIgnoreCase(type)) + && (bs.size() > 7) + && (bs.get(8) instanceof ImapList)) { + bodyDisposition = bs.getList(8); + } + + String contentDisposition = ""; + + if (bodyDisposition != null && bodyDisposition.size() > 0) { + if (!"NIL".equalsIgnoreCase(bodyDisposition.getString(0))) { + contentDisposition = bodyDisposition.getString(0).toLowerCase(); + } + + if ((bodyDisposition.size() > 1) + && (bodyDisposition.get(1) instanceof ImapList)) { + ImapList bodyDispositionParams = bodyDisposition.getList(1); + /* + * If there is body disposition information we can pull some more information + * about the attachment out. + */ + for (int i = 0, count = bodyDispositionParams.size(); i < count; i += 2) { + contentDisposition += String.format(";\n %s=\"%s\"", + bodyDispositionParams.getString(i).toLowerCase(), + bodyDispositionParams.getString(i + 1)); + } + } + } + + if (MimeUtility.getHeaderParameter(contentDisposition, "size") == null) { + contentDisposition += String.format(";\n size=%d", size); + } + + /* + * Set the content disposition containing at least the size. Attachment + * handling code will use this down the road. + */ + part.setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, contentDisposition); + + + /* + * Set the Content-Transfer-Encoding header. Attachment code will use this + * to parse the body. + */ + part.setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, encoding); + + if (part instanceof ImapMessage) { + ((ImapMessage) part).setSize(size); + } + else if (part instanceof ImapBodyPart) { + ((ImapBodyPart) part).setSize(size); + } + else { + throw new MessagingException("Unknown part type " + part.toString()); + } + part.setHeader(MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA, id); + } + + } + + /** + * Appends the given messages to the selected folder. This implementation also determines + * the new UID of the given message on the IMAP server and sets the Message's UID to the + * new server UID. + */ + public void appendMessages(Message[] messages) throws MessagingException { + checkOpen(); + try { + for (Message message : messages) { + CountingOutputStream out = new CountingOutputStream(); + EOLConvertingOutputStream eolOut = new EOLConvertingOutputStream(out); + message.writeTo(eolOut); + eolOut.flush(); + mConnection.sendCommand( + String.format("APPEND \"%s\" {%d}", + encodeFolderName(getPrefixedName()), + out.getCount()), false); + ImapResponse response; + do { + response = mConnection.readResponse(); + if (response.mCommandContinuationRequested) { + eolOut = new EOLConvertingOutputStream(mConnection.mOut); + message.writeTo(eolOut); + eolOut.write('\r'); + eolOut.write('\n'); + eolOut.flush(); + } + else if (response.mTag == null) { + handleUntaggedResponse(response); + } + while (response.more()); + } while(response.mTag == null); + + /* + * Try to find the UID of the message we just appended using the + * Message-ID header. + */ + String[] messageIdHeader = message.getHeader("Message-ID"); + if (messageIdHeader == null || messageIdHeader.length == 0) { + continue; + } + String messageId = messageIdHeader[0]; + List responses = + mConnection.executeSimpleCommand( + String.format("UID SEARCH (HEADER MESSAGE-ID %s)", messageId)); + for (ImapResponse response1 : responses) { + if (response1.mTag == null && response1.get(0).equals("SEARCH") + && response1.size() > 1) { + message.setUid(response1.getString(1)); + } + } + + } + } + catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } + } + + public Message[] expunge() throws MessagingException { + checkOpen(); + try { + handleUntaggedResponses(mConnection.executeSimpleCommand("EXPUNGE")); + } catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } + return null; + } + + public void setFlags(Message[] messages, Flag[] flags, boolean value) + throws MessagingException { + checkOpen(); + String[] uids = new String[messages.length]; + for (int i = 0, count = messages.length; i < count; i++) { + uids[i] = messages[i].getUid(); + } + ArrayList flagNames = new ArrayList(); + for (int i = 0, count = flags.length; i < count; i++) { + Flag flag = flags[i]; + if (flag == Flag.SEEN) { + flagNames.add("\\Seen"); + } + else if (flag == Flag.DELETED) { + flagNames.add("\\Deleted"); + } + } + try { + mConnection.executeSimpleCommand(String.format("UID STORE %s %sFLAGS.SILENT (%s)", + Utility.combine(uids, ','), + value ? "+" : "-", + Utility.combine(flagNames.toArray(new String[flagNames.size()]), ' '))); + } + catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } + } + + private void checkOpen() throws MessagingException { + if (!isOpen()) { + throw new MessagingException("Folder " + getPrefixedName() + " is not open."); + } + } + + private MessagingException ioExceptionHandler(ImapConnection connection, IOException ioe) + throws MessagingException { + connection.close(); + close(false); + return new MessagingException("IO Error", ioe); + } + + @Override + public boolean equals(Object o) { + if (o instanceof ImapFolder) { + return ((ImapFolder)o).getPrefixedName().equals(getPrefixedName()); + } + return super.equals(o); + } + } + + /** + * A cacheable class that stores the details for a single IMAP connection. + */ + class ImapConnection { + private Socket mSocket; + private PeekableInputStream mIn; + private OutputStream mOut; + private ImapResponseParser mParser; + private int mNextCommandTag; + + public void open() throws IOException, MessagingException { + if (isOpen()) { + return; + } + + mNextCommandTag = 1; + + try { + SocketAddress socketAddress = new InetSocketAddress(mHost, mPort); + if (mConnectionSecurity == CONNECTION_SECURITY_SSL_REQUIRED || + mConnectionSecurity == CONNECTION_SECURITY_SSL_OPTIONAL) { + SSLContext sslContext = SSLContext.getInstance("TLS"); + final boolean secure = mConnectionSecurity == CONNECTION_SECURITY_SSL_REQUIRED; + sslContext.init(null, new TrustManager[] { + TrustManagerFactory.get(mHost, secure) + }, new SecureRandom()); + mSocket = sslContext.getSocketFactory().createSocket(); + mSocket.connect(socketAddress, SOCKET_CONNECT_TIMEOUT); + } else { + mSocket = new Socket(); + mSocket.connect(socketAddress, SOCKET_CONNECT_TIMEOUT); + } + + mSocket.setSoTimeout(Store.SOCKET_READ_TIMEOUT); + + mIn = new PeekableInputStream(new BufferedInputStream(mSocket.getInputStream(), + 1024)); + mParser = new ImapResponseParser(mIn); + mOut = mSocket.getOutputStream(); + + // BANNER + mParser.readResponse(); + + if (mConnectionSecurity == CONNECTION_SECURITY_TLS_OPTIONAL + || mConnectionSecurity == CONNECTION_SECURITY_TLS_REQUIRED) { + // CAPABILITY + List responses = executeSimpleCommand("CAPABILITY"); + if (responses.size() != 2) { + throw new MessagingException("Invalid CAPABILITY response received"); + } + if (responses.get(0).contains("STARTTLS")) { + // STARTTLS + executeSimpleCommand("STARTTLS"); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + boolean secure = mConnectionSecurity == CONNECTION_SECURITY_TLS_REQUIRED; + sslContext.init(null, new TrustManager[] { + TrustManagerFactory.get(mHost, secure) + }, new SecureRandom()); + mSocket = sslContext.getSocketFactory().createSocket(mSocket, mHost, mPort, + true); + mSocket.setSoTimeout(Store.SOCKET_READ_TIMEOUT); + mIn = new PeekableInputStream(new BufferedInputStream(mSocket + .getInputStream(), 1024)); + mParser = new ImapResponseParser(mIn); + mOut = mSocket.getOutputStream(); + } else if (mConnectionSecurity == CONNECTION_SECURITY_TLS_REQUIRED) { + throw new MessagingException("TLS not supported but required"); + } + } + + mOut = new BufferedOutputStream(mOut); + + try { + // TODO eventually we need to add additional authentication + // options such as SASL + executeSimpleCommand("LOGIN " + mUsername + " " + mPassword, true); + } catch (ImapException ie) { + throw new AuthenticationFailedException(ie.getAlertText(), ie); + + } catch (MessagingException me) { + throw new AuthenticationFailedException(null, me); + } + } catch (SSLException e) { + throw new CertificateValidationException(e.getMessage(), e); + } catch (GeneralSecurityException gse) { + throw new MessagingException( + "Unable to open connection to IMAP server due to security error.", gse); + } + } + + public boolean isOpen() { + return (mIn != null && mOut != null && mSocket != null && mSocket.isConnected() && !mSocket + .isClosed()); + } + + public void close() { +// if (isOpen()) { +// try { +// executeSimpleCommand("LOGOUT"); +// } catch (Exception e) { +// +// } +// } + try { + mIn.close(); + } catch (Exception e) { + + } + try { + mOut.close(); + } catch (Exception e) { + + } + try { + mSocket.close(); + } catch (Exception e) { + + } + mIn = null; + mOut = null; + mSocket = null; + } + + public ImapResponse readResponse() throws IOException, MessagingException { + return mParser.readResponse(); + } + + public String sendCommand(String command, boolean sensitive) + throws MessagingException, IOException { + open(); + String tag = Integer.toString(mNextCommandTag++); + String commandToSend = tag + " " + command; + mOut.write(commandToSend.getBytes()); + mOut.write('\r'); + mOut.write('\n'); + mOut.flush(); + if (Config.LOGD) { + if (Email.DEBUG) { + if (sensitive && !Email.DEBUG_SENSITIVE) { + Log.d(Email.LOG_TAG, ">>> " + + "[Command Hidden, Enable Sensitive Debug Logging To Show]"); + } else { + Log.d(Email.LOG_TAG, ">>> " + commandToSend); + } + } + } + return tag; + } + + public List executeSimpleCommand(String command) throws IOException, + ImapException, MessagingException { + return executeSimpleCommand(command, false); + } + + public List executeSimpleCommand(String command, boolean sensitive) + throws IOException, ImapException, MessagingException { + String tag = sendCommand(command, sensitive); + ArrayList responses = new ArrayList(); + ImapResponse response; + do { + response = mParser.readResponse(); + responses.add(response); + } while (response.mTag == null); + if (response.size() < 1 || !response.get(0).equals("OK")) { + throw new ImapException(response.toString(), response.getAlertText()); + } + return responses; + } + } + + class ImapMessage extends MimeMessage { + ImapMessage(String uid, Folder folder) throws MessagingException { + this.mUid = uid; + this.mFolder = folder; + } + + public void setSize(int size) { + this.mSize = size; + } + + public void parse(InputStream in) throws IOException, MessagingException { + super.parse(in); + } + + public void setFlagInternal(Flag flag, boolean set) throws MessagingException { + super.setFlag(flag, set); + } + + @Override + public void setFlag(Flag flag, boolean set) throws MessagingException { + super.setFlag(flag, set); + mFolder.setFlags(new Message[] { this }, new Flag[] { flag }, set); + } + } + + class ImapBodyPart extends MimeBodyPart { + public ImapBodyPart() throws MessagingException { + super(); + } + + public void setSize(int size) { + this.mSize = size; + } + } + + class ImapException extends MessagingException { + String mAlertText; + + public ImapException(String message, String alertText, Throwable throwable) { + super(message, throwable); + this.mAlertText = alertText; + } + + public ImapException(String message, String alertText) { + super(message); + this.mAlertText = alertText; + } + + public String getAlertText() { + return mAlertText; + } + + public void setAlertText(String alertText) { + mAlertText = alertText; + } + } +} diff --git a/src/com/android/email/mail/store/LocalStore.java b/src/com/android/email/mail/store/LocalStore.java new file mode 100644 index 000000000..2b37bc493 --- /dev/null +++ b/src/com/android/email/mail/store/LocalStore.java @@ -0,0 +1,1220 @@ + +package com.android.email.mail.store; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Date; +import java.util.UUID; + +import org.apache.commons.io.IOUtils; + +import android.app.Application; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.util.Config; +import android.util.Log; + +import com.android.email.Email; +import com.android.email.Utility; +import com.android.email.codec.binary.Base64OutputStream; +import com.android.email.mail.Address; +import com.android.email.mail.Body; +import com.android.email.mail.FetchProfile; +import com.android.email.mail.Flag; +import com.android.email.mail.Folder; +import com.android.email.mail.Message; +import com.android.email.mail.MessageRetrievalListener; +import com.android.email.mail.MessagingException; +import com.android.email.mail.Part; +import com.android.email.mail.Store; +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.provider.AttachmentProvider; + +/** + *
+ * Implements a SQLite database backed local store for Messages.
+ * 
+ */ +public class LocalStore extends Store { + private static final int DB_VERSION = 18; + private static final Flag[] PERMANENT_FLAGS = { Flag.DELETED, Flag.X_DESTROYED, Flag.SEEN }; + + private String mPath; + private SQLiteDatabase mDb; + private File mAttachmentsDir; + private Application mApplication; + + /** + * @param uri local://localhost/path/to/database/uuid.db + */ + public LocalStore(String _uri, Application application) throws MessagingException { + mApplication = application; + URI uri = null; + try { + uri = new URI(_uri); + } catch (Exception e) { + throw new MessagingException("Invalid uri for LocalStore"); + } + if (!uri.getScheme().equals("local")) { + throw new MessagingException("Invalid scheme"); + } + mPath = uri.getPath(); + + File parentDir = new File(mPath).getParentFile(); + if (!parentDir.exists()) { + parentDir.mkdirs(); + } + mDb = SQLiteDatabase.openOrCreateDatabase(mPath, null); + if (mDb.getVersion() != DB_VERSION) { + doDbUpgrade(mDb); + } + + + mAttachmentsDir = new File(mPath + "_att"); + if (!mAttachmentsDir.exists()) { + mAttachmentsDir.mkdirs(); + } + } + + + private void doDbUpgrade ( SQLiteDatabase mDb) { + + if (mDb.getVersion() < 18) { + if (Config.LOGV) { + Log.v(Email.LOG_TAG, String.format("Upgrading database from %d to %d", mDb + .getVersion(), 18)); + } + mDb.execSQL("DROP TABLE IF EXISTS folders"); + mDb.execSQL("CREATE TABLE folders (id INTEGER PRIMARY KEY, name TEXT, " + + "last_updated INTEGER, unread_count INTEGER, visible_limit INTEGER)"); + + mDb.execSQL("DROP TABLE IF EXISTS messages"); + mDb.execSQL("CREATE TABLE messages (id INTEGER PRIMARY KEY, folder_id INTEGER, uid TEXT, subject TEXT, " + + "date INTEGER, flags TEXT, sender_list TEXT, to_list TEXT, cc_list TEXT, bcc_list TEXT, reply_to_list TEXT, " + + "html_content TEXT, text_content TEXT, attachment_count INTEGER, internal_date INTEGER)"); + + mDb.execSQL("DROP TABLE IF EXISTS attachments"); + mDb.execSQL("CREATE TABLE attachments (id INTEGER PRIMARY KEY, message_id INTEGER," + + "store_data TEXT, content_uri TEXT, size INTEGER, name TEXT," + + "mime_type TEXT)"); + + mDb.execSQL("DROP TABLE IF EXISTS pending_commands"); + mDb.execSQL("CREATE TABLE pending_commands " + + "(id INTEGER PRIMARY KEY, command TEXT, arguments TEXT)"); + + mDb.execSQL("DROP TRIGGER IF EXISTS delete_folder"); + mDb.execSQL("CREATE TRIGGER delete_folder BEFORE DELETE ON folders BEGIN DELETE FROM messages WHERE old.id = folder_id; END;"); + + mDb.execSQL("DROP TRIGGER IF EXISTS delete_message"); + mDb.execSQL("CREATE TRIGGER delete_message BEFORE DELETE ON messages BEGIN DELETE FROM attachments WHERE old.id = message_id; END;"); + mDb.setVersion(18); + } + if (mDb.getVersion() != DB_VERSION) { + throw new Error("Database upgrade failed!"); + } + } + + @Override + public Folder getFolder(String name) throws MessagingException { + return new LocalFolder(name); + } + + // TODO this takes about 260-300ms, seems slow. + @Override + public Folder[] getPersonalNamespaces() throws MessagingException { + ArrayList folders = new ArrayList(); + Cursor cursor = null; + + + try { + cursor = mDb.rawQuery("SELECT name FROM folders", null); + while (cursor.moveToNext()) { + folders.add(new LocalFolder(cursor.getString(0))); + } + } + finally { + if (cursor != null) { + cursor.close(); + } + } + return folders.toArray(new Folder[] {}); + } + + @Override + public void checkSettings() throws MessagingException { + } + + /** + * Delete the entire Store and it's backing database. + */ + public void delete() { + try { + mDb.close(); + } catch (Exception e) { + + } + try{ + File[] attachments = mAttachmentsDir.listFiles(); + for (File attachment : attachments) { + if (attachment.exists()) { + attachment.delete(); + } + } + if (mAttachmentsDir.exists()) { + mAttachmentsDir.delete(); + } + } + catch (Exception e) { + } + try { + new File(mPath).delete(); + } + catch (Exception e) { + + } + } + + /** + * Deletes all cached attachments for the entire store. + */ + public void pruneCachedAttachments() throws MessagingException { + File[] files = mAttachmentsDir.listFiles(); + for (File file : files) { + if (file.exists()) { + try { + Cursor cursor = null; + try { + cursor = mDb.query( + "attachments", + new String[] { "store_data" }, + "id = ?", + new String[] { file.getName() }, + null, + null, + null); + if (cursor.moveToNext()) { + if (cursor.getString(0) == null) { + /* + * If the attachment has no store data it is not recoverable, so + * we won't delete it. + */ + continue; + } + } + } + finally { + if (cursor != null) { + cursor.close(); + } + } + ContentValues cv = new ContentValues(); + cv.putNull("content_uri"); + mDb.update("attachments", cv, "id = ?", new String[] { file.getName() }); + } + catch (Exception e) { + /* + * If the row has gone away before we got to mark it not-downloaded that's + * okay. + */ + } + if (!file.delete()) { + file.deleteOnExit(); + } + } + } + } + + public void resetVisibleLimits() { + resetVisibleLimits(Email.DEFAULT_VISIBLE_LIMIT); + } + + public void resetVisibleLimits(int visibleLimit) { + ContentValues cv = new ContentValues(); + cv.put("visible_limit", Integer.toString(visibleLimit)); + mDb.update("folders", cv, null, null); + } + + public ArrayList getPendingCommands() { + Cursor cursor = null; + try { + cursor = mDb.query("pending_commands", + new String[] { "id", "command", "arguments" }, + null, + null, + null, + null, + "id ASC"); + ArrayList commands = new ArrayList(); + while (cursor.moveToNext()) { + PendingCommand command = new PendingCommand(); + command.mId = cursor.getLong(0); + command.command = cursor.getString(1); + String arguments = cursor.getString(2); + command.arguments = arguments.split(","); + for (int i = 0; i < command.arguments.length; i++) { + command.arguments[i] = Utility.fastUrlDecode(command.arguments[i]); + } + commands.add(command); + } + return commands; + } + finally { + if (cursor != null) { + cursor.close(); + } + } + } + + public void addPendingCommand(PendingCommand command) { + try { + for (int i = 0; i < command.arguments.length; i++) { + command.arguments[i] = URLEncoder.encode(command.arguments[i], "UTF-8"); + } + ContentValues cv = new ContentValues(); + cv.put("command", command.command); + cv.put("arguments", Utility.combine(command.arguments, ',')); + mDb.insert("pending_commands", "command", cv); + } + catch (UnsupportedEncodingException usee) { + throw new Error("Aparently UTF-8 has been lost to the annals of history."); + } + } + + public void removePendingCommand(PendingCommand command) { + mDb.delete("pending_commands", "id = ?", new String[] { Long.toString(command.mId) }); + } + + public static class PendingCommand { + private long mId; + public String command; + public String[] arguments; + + @Override + public String toString() { + StringBuffer sb = new StringBuffer(); + sb.append(command); + sb.append("\n"); + for (String argument : arguments) { + sb.append(" "); + sb.append(argument); + sb.append("\n"); + } + return sb.toString(); + } + } + + public class LocalFolder extends Folder { + private String mName; + private long mFolderId = -1; + private int mUnreadMessageCount = -1; + private int mVisibleLimit = -1; + + public LocalFolder(String name) { + this.mName = name; + } + + public long getId() { + return mFolderId; + } + + @Override + public void open(OpenMode mode) throws MessagingException { + if (isOpen()) { + return; + } + Cursor cursor = null; + try { + cursor = mDb.rawQuery( + "SELECT id, unread_count, visible_limit FROM folders " + + "where folders.name = ?", + new String[] { mName }); + if (cursor.moveToFirst()) { + mFolderId = cursor.getInt(0); + mUnreadMessageCount = cursor.getInt(1); + mVisibleLimit = cursor.getInt(2); + } else { + // Calling exists on open is a little expensive. Instead, + // just handle it when we don't find it. + create(FolderType.HOLDS_MESSAGES); + open(mode); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + @Override + public boolean isOpen() { + return mFolderId != -1; + } + + @Override + public OpenMode getMode() throws MessagingException { + return OpenMode.READ_WRITE; + } + + @Override + public String getName() { + return mName; + } + + @Override + public boolean exists() throws MessagingException { + Cursor cursor = null; + try { + cursor = mDb.rawQuery("SELECT id FROM folders " + + "where folders.name = ?", new String[] { this + .getName() }); + if (cursor.moveToFirst()) { + int folderId = cursor.getInt(0); + return (folderId > 0) ? true : false; + } else { + return false; + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + @Override + public boolean create(FolderType type) throws MessagingException { + if (exists()) { + throw new MessagingException("Folder " + mName + " already exists."); + } + mDb.execSQL("INSERT INTO folders (name, visible_limit) VALUES (?, ?)", new Object[] { + mName, + Email.DEFAULT_VISIBLE_LIMIT + }); + return true; + } + + public boolean create(FolderType type, int visibleLimit) throws MessagingException { + if (exists()) { + throw new MessagingException("Folder " + mName + " already exists."); + } + mDb.execSQL("INSERT INTO folders (name, visible_limit) VALUES (?, ?)", new Object[] { + mName, + visibleLimit + }); + return true; + } + + @Override + public void close(boolean expunge) throws MessagingException { + if (expunge) { + expunge(); + } + mFolderId = -1; + } + + @Override + public int getMessageCount() throws MessagingException { + open(OpenMode.READ_WRITE); + Cursor cursor = null; + try { + cursor = mDb.rawQuery("SELECT COUNT(*) FROM messages WHERE messages.folder_id = ?", + new String[] { + Long.toString(mFolderId) + }); + cursor.moveToFirst(); + int messageCount = cursor.getInt(0); + return messageCount; + } + finally { + if (cursor != null) { + cursor.close(); + } + } + } + + @Override + public int getUnreadMessageCount() throws MessagingException { + open(OpenMode.READ_WRITE); + return mUnreadMessageCount; + } + + + public void setUnreadMessageCount(int unreadMessageCount) throws MessagingException { + open(OpenMode.READ_WRITE); + mUnreadMessageCount = Math.max(0, unreadMessageCount); + mDb.execSQL("UPDATE folders SET unread_count = ? WHERE id = ?", + new Object[] { mUnreadMessageCount, mFolderId }); + } + + public int getVisibleLimit() throws MessagingException { + open(OpenMode.READ_WRITE); + return mVisibleLimit; + } + + + public void setVisibleLimit(int visibleLimit) throws MessagingException { + open(OpenMode.READ_WRITE); + mVisibleLimit = visibleLimit; + mDb.execSQL("UPDATE folders SET visible_limit = ? WHERE id = ?", + new Object[] { mVisibleLimit, mFolderId }); + } + + + @Override + public void fetch(Message[] messages, FetchProfile fp, MessageRetrievalListener listener) + throws MessagingException { + open(OpenMode.READ_WRITE); + if (fp.contains(FetchProfile.Item.BODY)) { + for (Message message : messages) { + LocalMessage localMessage = (LocalMessage)message; + Cursor cursor = null; + localMessage.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "multipart/mixed"); + MimeMultipart mp = new MimeMultipart(); + mp.setSubType("mixed"); + localMessage.setBody(mp); + try { + cursor = mDb.rawQuery("SELECT html_content, text_content FROM messages " + + "WHERE id = ?", + new String[] { Long.toString(localMessage.mId) }); + cursor.moveToNext(); + String htmlContent = cursor.getString(0); + String textContent = cursor.getString(1); + + if (htmlContent != null) { + TextBody body = new TextBody(htmlContent); + MimeBodyPart bp = new MimeBodyPart(body, "text/html"); + mp.addBodyPart(bp); + } + + if (textContent != null) { + TextBody body = new TextBody(textContent); + MimeBodyPart bp = new MimeBodyPart(body, "text/plain"); + mp.addBodyPart(bp); + } + } + finally { + if (cursor != null) { + cursor.close(); + } + } + + try { + cursor = mDb.query( + "attachments", + new String[] { + "id", + "size", + "name", + "mime_type", + "store_data", + "content_uri" }, + "message_id = ?", + new String[] { Long.toString(localMessage.mId) }, + null, + null, + null); + + while (cursor.moveToNext()) { + long id = cursor.getLong(0); + int size = cursor.getInt(1); + String name = cursor.getString(2); + String type = cursor.getString(3); + String storeData = cursor.getString(4); + String contentUri = cursor.getString(5); + Body body = null; + if (contentUri != null) { + body = new LocalAttachmentBody(Uri.parse(contentUri), mApplication); + } + MimeBodyPart bp = new LocalAttachmentBodyPart(body, id); + bp.setHeader(MimeHeader.HEADER_CONTENT_TYPE, + String.format("%s;\n name=\"%s\"", + type, + name)); + bp.setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, "base64"); + bp.setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, + String.format("attachment;\n filename=\"%s\";\n size=%d", + name, + size)); + + /* + * HEADER_ANDROID_ATTACHMENT_STORE_DATA is a custom header we add to that + * we can later pull the attachment from the remote store if neccesary. + */ + bp.setHeader(MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA, storeData); + + mp.addBodyPart(bp); + } + } + finally { + if (cursor != null) { + cursor.close(); + } + } + } + } + } + + private void populateMessageFromGetMessageCursor(LocalMessage message, Cursor cursor) + throws MessagingException{ + message.setSubject(cursor.getString(0) == null ? "" : cursor.getString(0)); + Address[] from = Address.unpack(cursor.getString(1)); + if (from.length > 0) { + message.setFrom(from[0]); + } + message.setSentDate(new Date(cursor.getLong(2))); + message.setUid(cursor.getString(3)); + String flagList = cursor.getString(4); + if (flagList != null && flagList.length() > 0) { + String[] flags = flagList.split(","); + try { + for (String flag : flags) { + message.setFlagInternal(Flag.valueOf(flag.toUpperCase()), true); + } + } catch (Exception e) { + } + } + message.mId = cursor.getLong(5); + message.setRecipients(RecipientType.TO, Address.unpack(cursor.getString(6))); + message.setRecipients(RecipientType.CC, Address.unpack(cursor.getString(7))); + message.setRecipients(RecipientType.BCC, Address.unpack(cursor.getString(8))); + message.setReplyTo(Address.unpack(cursor.getString(9))); + message.mAttachmentCount = cursor.getInt(10); + message.setInternalDate(new Date(cursor.getLong(11))); + } + + @Override + public Message[] getMessages(int start, int end, MessageRetrievalListener listener) + throws MessagingException { + open(OpenMode.READ_WRITE); + throw new MessagingException( + "LocalStore.getMessages(int, int, MessageRetrievalListener) not yet implemented"); + } + + @Override + public Message getMessage(String uid) throws MessagingException { + open(OpenMode.READ_WRITE); + LocalMessage message = new LocalMessage(uid, this); + Cursor cursor = null; + try { + cursor = mDb.rawQuery( + "SELECT subject, sender_list, date, uid, flags, id, to_list, cc_list, " + + "bcc_list, reply_to_list, attachment_count, internal_date " + + "FROM messages " + "WHERE uid = ? " + "AND folder_id = ?", + new String[] { + message.getUid(), Long.toString(mFolderId) + }); + if (!cursor.moveToNext()) { + return null; + } + populateMessageFromGetMessageCursor(message, cursor); + } + finally { + if (cursor != null) { + cursor.close(); + } + } + return message; + } + + @Override + public Message[] getMessages(MessageRetrievalListener listener) throws MessagingException { + open(OpenMode.READ_WRITE); + ArrayList messages = new ArrayList(); + Cursor cursor = null; + try { + cursor = mDb.rawQuery( + "SELECT subject, sender_list, date, uid, flags, id, to_list, cc_list, " + + "bcc_list, reply_to_list, attachment_count, internal_date " + + "FROM messages " + "WHERE folder_id = ?", new String[] { + Long.toString(mFolderId) + }); + + while (cursor.moveToNext()) { + LocalMessage message = new LocalMessage(null, this); + populateMessageFromGetMessageCursor(message, cursor); + messages.add(message); + } + } + finally { + if (cursor != null) { + cursor.close(); + } + } + + return messages.toArray(new Message[] {}); + } + + @Override + public Message[] getMessages(String[] uids, MessageRetrievalListener listener) + throws MessagingException { + open(OpenMode.READ_WRITE); + if (uids == null) { + return getMessages(listener); + } + ArrayList messages = new ArrayList(); + for (String uid : uids) { + messages.add(getMessage(uid)); + } + return messages.toArray(new Message[] {}); + } + + @Override + public void copyMessages(Message[] msgs, Folder folder) throws MessagingException { + if (!(folder instanceof LocalFolder)) { + throw new MessagingException("copyMessages called with incorrect Folder"); + } + ((LocalFolder) folder).appendMessages(msgs, true); + } + + /** + * The method differs slightly from the contract; If an incoming message already has a uid + * assigned and it matches the uid of an existing message then this message will replace the + * old message. It is implemented as a delete/insert. This functionality is used in saving + * of drafts and re-synchronization of updated server messages. + */ + @Override + public void appendMessages(Message[] messages) throws MessagingException { + appendMessages(messages, false); + } + + /** + * The method differs slightly from the contract; If an incoming message already has a uid + * assigned and it matches the uid of an existing message then this message will replace the + * old message. It is implemented as a delete/insert. This functionality is used in saving + * of drafts and re-synchronization of updated server messages. + */ + public void appendMessages(Message[] messages, boolean copy) throws MessagingException { + open(OpenMode.READ_WRITE); + for (Message message : messages) { + if (!(message instanceof MimeMessage)) { + throw new Error("LocalStore can only store Messages that extend MimeMessage"); + } + + String uid = message.getUid(); + if (uid == null) { + message.setUid("Local" + UUID.randomUUID().toString()); + } + else { + /* + * The message may already exist in this Folder, so delete it first. + */ + deleteAttachments(message.getUid()); + mDb.execSQL("DELETE FROM messages WHERE folder_id = ? AND uid = ?", + new Object[] { mFolderId, message.getUid() }); + } + + ArrayList viewables = new ArrayList(); + ArrayList attachments = new ArrayList(); + MimeUtility.collectParts(message, viewables, attachments); + + StringBuffer sbHtml = new StringBuffer(); + StringBuffer sbText = new StringBuffer(); + for (Part viewable : viewables) { + try { + String text = MimeUtility.getTextFromPart(viewable); + /* + * Anything with MIME type text/html will be stored as such. Anything + * else will be stored as text/plain. + */ + if (viewable.getMimeType().equalsIgnoreCase("text/html")) { + sbHtml.append(text); + } + else { + sbText.append(text); + } + } catch (Exception e) { + throw new MessagingException("Unable to get text for message part", e); + } + } + + try { + ContentValues cv = new ContentValues(); + cv.put("uid", message.getUid()); + cv.put("subject", message.getSubject()); + cv.put("sender_list", Address.pack(message.getFrom())); + cv.put("date", message.getSentDate() == null + ? System.currentTimeMillis() : message.getSentDate().getTime()); + cv.put("flags", Utility.combine(message.getFlags(), ',').toUpperCase()); + cv.put("folder_id", mFolderId); + cv.put("to_list", Address.pack(message.getRecipients(RecipientType.TO))); + cv.put("cc_list", Address.pack(message.getRecipients(RecipientType.CC))); + cv.put("bcc_list", Address.pack(message.getRecipients(RecipientType.BCC))); + cv.put("html_content", sbHtml.length() > 0 ? sbHtml.toString() : null); + cv.put("text_content", sbText.length() > 0 ? sbText.toString() : null); + cv.put("reply_to_list", Address.pack(message.getReplyTo())); + cv.put("attachment_count", attachments.size()); + cv.put("internal_date", message.getInternalDate() == null + ? System.currentTimeMillis() : message.getInternalDate().getTime()); + long messageId = mDb.insert("messages", "uid", cv); + for (Part attachment : attachments) { + saveAttachment(messageId, attachment, copy); + } + } catch (Exception e) { + throw new MessagingException("Error appending message", e); + } + } + } + + /** + * Update the given message in the LocalStore without first deleting the existing + * message (contrast with appendMessages). This method is used to store changes + * to the given message while updating attachments and not removing existing + * attachment data. + * TODO In the future this method should be combined with appendMessages since the Message + * contains enough data to decide what to do. + * @param message + * @throws MessagingException + */ + public void updateMessage(LocalMessage message) throws MessagingException { + open(OpenMode.READ_WRITE); + ArrayList viewables = new ArrayList(); + ArrayList attachments = new ArrayList(); + MimeUtility.collectParts(message, viewables, attachments); + + StringBuffer sbHtml = new StringBuffer(); + StringBuffer sbText = new StringBuffer(); + for (int i = 0, count = viewables.size(); i < count; i++) { + Part viewable = viewables.get(i); + try { + String text = MimeUtility.getTextFromPart(viewable); + /* + * Anything with MIME type text/html will be stored as such. Anything + * else will be stored as text/plain. + */ + if (viewable.getMimeType().equalsIgnoreCase("text/html")) { + sbHtml.append(text); + } + else { + sbText.append(text); + } + } catch (Exception e) { + throw new MessagingException("Unable to get text for message part", e); + } + } + + try { + mDb.execSQL("UPDATE messages SET " + + "uid = ?, subject = ?, sender_list = ?, date = ?, flags = ?, " + + "folder_id = ?, to_list = ?, cc_list = ?, bcc_list = ?, " + + "html_content = ?, text_content = ?, reply_to_list = ?, " + + "attachment_count = ? WHERE id = ?", + new Object[] { + message.getUid(), + message.getSubject(), + Address.pack(message.getFrom()), + message.getSentDate() == null ? System + .currentTimeMillis() : message.getSentDate() + .getTime(), + Utility.combine(message.getFlags(), ',').toUpperCase(), + mFolderId, + Address.pack(message + .getRecipients(RecipientType.TO)), + Address.pack(message + .getRecipients(RecipientType.CC)), + Address.pack(message + .getRecipients(RecipientType.BCC)), + sbHtml.length() > 0 ? sbHtml.toString() : null, + sbText.length() > 0 ? sbText.toString() : null, + Address.pack(message.getReplyTo()), + attachments.size(), + message.mId + }); + + for (int i = 0, count = attachments.size(); i < count; i++) { + Part attachment = attachments.get(i); + saveAttachment(message.mId, attachment, false); + } + } catch (Exception e) { + throw new MessagingException("Error appending message", e); + } + } + + /** + * @param messageId + * @param attachment + * @param attachmentId -1 to create a new attachment or >= 0 to update an existing + * @throws IOException + * @throws MessagingException + */ + private void saveAttachment(long messageId, Part attachment, boolean saveAsNew) + throws IOException, MessagingException { + long attachmentId = -1; + Uri contentUri = null; + int size = -1; + File tempAttachmentFile = null; + + if ((!saveAsNew) && (attachment instanceof LocalAttachmentBodyPart)) { + attachmentId = ((LocalAttachmentBodyPart) attachment).getAttachmentId(); + } + + if (attachment.getBody() != null) { + Body body = attachment.getBody(); + if (body instanceof LocalAttachmentBody) { + contentUri = ((LocalAttachmentBody) body).getContentUri(); + } + else { + /* + * If the attachment has a body we're expected to save it into the local store + * so we copy the data into a cached attachment file. + */ + InputStream in = attachment.getBody().getInputStream(); + tempAttachmentFile = File.createTempFile("att", null, mAttachmentsDir); + FileOutputStream out = new FileOutputStream(tempAttachmentFile); + size = IOUtils.copy(in, out); + in.close(); + out.close(); + } + } + + if (size == -1) { + /* + * If the attachment is not yet downloaded see if we can pull a size + * off the Content-Disposition. + */ + String disposition = attachment.getDisposition(); + if (disposition != null) { + String s = MimeUtility.getHeaderParameter(disposition, "size"); + if (s != null) { + size = Integer.parseInt(s); + } + } + } + if (size == -1) { + size = 0; + } + + String storeData = + Utility.combine(attachment.getHeader( + MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA), ','); + + String name = MimeUtility.getHeaderParameter(attachment.getContentType(), "name"); + + if (attachmentId == -1) { + ContentValues cv = new ContentValues(); + cv.put("message_id", messageId); + cv.put("content_uri", contentUri != null ? contentUri.toString() : null); + cv.put("store_data", storeData); + cv.put("size", size); + cv.put("name", name); + cv.put("mime_type", attachment.getMimeType()); + + attachmentId = mDb.insert("attachments", "message_id", cv); + } + else { + ContentValues cv = new ContentValues(); + cv.put("content_uri", contentUri != null ? contentUri.toString() : null); + cv.put("size", size); + mDb.update( + "attachments", + cv, + "id = ?", + new String[] { Long.toString(attachmentId) }); + } + + if (tempAttachmentFile != null) { + File attachmentFile = new File(mAttachmentsDir, Long.toString(attachmentId)); + tempAttachmentFile.renameTo(attachmentFile); + contentUri = AttachmentProvider.getAttachmentUri( + new File(mPath).getName(), + attachmentId); + attachment.setBody(new LocalAttachmentBody(contentUri, mApplication)); + ContentValues cv = new ContentValues(); + cv.put("content_uri", contentUri != null ? contentUri.toString() : null); + mDb.update( + "attachments", + cv, + "id = ?", + new String[] { Long.toString(attachmentId) }); + } + + if (attachment instanceof LocalAttachmentBodyPart) { + ((LocalAttachmentBodyPart) attachment).setAttachmentId(attachmentId); + } + } + + /** + * Changes the stored uid of the given message (using it's internal id as a key) to + * the uid in the message. + * @param message + */ + public void changeUid(LocalMessage message) throws MessagingException { + open(OpenMode.READ_WRITE); + ContentValues cv = new ContentValues(); + cv.put("uid", message.getUid()); + mDb.update("messages", cv, "id = ?", new String[] { Long.toString(message.mId) }); + } + + @Override + public void setFlags(Message[] messages, Flag[] flags, boolean value) + throws MessagingException { + open(OpenMode.READ_WRITE); + for (Message message : messages) { + message.setFlags(flags, value); + } + } + + @Override + public Message[] expunge() throws MessagingException { + open(OpenMode.READ_WRITE); + ArrayList expungedMessages = new ArrayList(); + /* + * epunge() doesn't do anything because deleted messages are saved for their uids + * and really, really deleted messages are "Destroyed" and removed immediately. + */ + return expungedMessages.toArray(new Message[] {}); + } + + @Override + public void delete(boolean recurse) throws MessagingException { + // We need to open the folder first to make sure we've got it's id + open(OpenMode.READ_ONLY); + Message[] messages = getMessages(null); + for (Message message : messages) { + deleteAttachments(message.getUid()); + } + mDb.execSQL("DELETE FROM folders WHERE id = ?", new Object[] { + Long.toString(mFolderId), + }); + } + + @Override + public boolean equals(Object o) { + if (o instanceof LocalFolder) { + return ((LocalFolder)o).mName.equals(mName); + } + return super.equals(o); + } + + @Override + public Flag[] getPermanentFlags() throws MessagingException { + return PERMANENT_FLAGS; + } + + private void deleteAttachments(String uid) throws MessagingException { + open(OpenMode.READ_WRITE); + Cursor messagesCursor = null; + try { + messagesCursor = mDb.query( + "messages", + new String[] { "id" }, + "folder_id = ? AND uid = ?", + new String[] { Long.toString(mFolderId), uid }, + null, + null, + null); + while (messagesCursor.moveToNext()) { + long messageId = messagesCursor.getLong(0); + Cursor attachmentsCursor = null; + try { + attachmentsCursor = mDb.query( + "attachments", + new String[] { "id" }, + "message_id = ?", + new String[] { Long.toString(messageId) }, + null, + null, + null); + while (attachmentsCursor.moveToNext()) { + long attachmentId = attachmentsCursor.getLong(0); + try{ + File file = new File(mAttachmentsDir, Long.toString(attachmentId)); + if (file.exists()) { + file.delete(); + } + } + catch (Exception e) { + + } + } + } + finally { + if (attachmentsCursor != null) { + attachmentsCursor.close(); + } + } + } + } + finally { + if (messagesCursor != null) { + messagesCursor.close(); + } + } + } + } + + public class LocalMessage extends MimeMessage { + private long mId; + private int mAttachmentCount; + + LocalMessage(String uid, Folder folder) throws MessagingException { + this.mUid = uid; + this.mFolder = folder; + } + + public int getAttachmentCount() { + return mAttachmentCount; + } + + public void parse(InputStream in) throws IOException, MessagingException { + super.parse(in); + } + + public void setFlagInternal(Flag flag, boolean set) throws MessagingException { + super.setFlag(flag, set); + } + + public long getId() { + return mId; + } + + public void setFlag(Flag flag, boolean set) throws MessagingException { + if (flag == Flag.DELETED && set) { + /* + * If a message is being marked as deleted we want to clear out it's content + * and attachments as well. Delete will not actually remove the row since we need + * to retain the uid for synchronization purposes. + */ + + /* + * Delete all of the messages' content to save space. + */ + mDb.execSQL( + "UPDATE messages SET " + + "subject = NULL, " + + "sender_list = NULL, " + + "date = NULL, " + + "to_list = NULL, " + + "cc_list = NULL, " + + "bcc_list = NULL, " + + "html_content = NULL, " + + "text_content = NULL, " + + "reply_to_list = NULL " + + "WHERE id = ?", + new Object[] { + mId + }); + + ((LocalFolder) mFolder).deleteAttachments(getUid()); + + /* + * Delete all of the messages' attachments to save space. + */ + mDb.execSQL("DELETE FROM attachments WHERE id = ?", + new Object[] { + mId + }); + } + else if (flag == Flag.X_DESTROYED && set) { + ((LocalFolder) mFolder).deleteAttachments(getUid()); + mDb.execSQL("DELETE FROM messages WHERE id = ?", + new Object[] { mId }); + } + + /* + * Update the unread count on the folder. + */ + try { + if (flag == Flag.DELETED || flag == Flag.X_DESTROYED || flag == Flag.SEEN) { + LocalFolder folder = (LocalFolder)mFolder; + if (set && !isSet(Flag.SEEN)) { + folder.setUnreadMessageCount(folder.getUnreadMessageCount() - 1); + } + else if (!set && isSet(Flag.SEEN)) { + folder.setUnreadMessageCount(folder.getUnreadMessageCount() + 1); + } + } + } + catch (MessagingException me) { + Log.e(Email.LOG_TAG, "Unable to update LocalStore unread message count", + me); + throw new RuntimeException(me); + } + + super.setFlag(flag, set); + /* + * Set the flags on the message. + */ + mDb.execSQL("UPDATE messages " + "SET flags = ? " + "WHERE id = ?", new Object[] { + Utility.combine(getFlags(), ',').toUpperCase(), mId + }); + } + } + + public class LocalAttachmentBodyPart extends MimeBodyPart { + private long mAttachmentId = -1; + + public LocalAttachmentBodyPart(Body body, long attachmentId) throws MessagingException { + super(body); + mAttachmentId = attachmentId; + } + + /** + * Returns the local attachment id of this body, or -1 if it is not stored. + * @return + */ + public long getAttachmentId() { + return mAttachmentId; + } + + public void setAttachmentId(long attachmentId) { + mAttachmentId = attachmentId; + } + + public String toString() { + return "" + mAttachmentId; + } + } + + public static class LocalAttachmentBody implements Body { + private Application mApplication; + private Uri mUri; + + public LocalAttachmentBody(Uri uri, Application application) { + mApplication = application; + mUri = uri; + } + + public InputStream getInputStream() throws MessagingException { + try { + return mApplication.getContentResolver().openInputStream(mUri); + } + catch (FileNotFoundException fnfe) { + /* + * Since it's completely normal for us to try to serve up attachments that + * have been blown away, we just return an empty stream. + */ + return new ByteArrayInputStream(new byte[0]); + } + catch (IOException ioe) { + throw new MessagingException("Invalid attachment.", ioe); + } + } + + public void writeTo(OutputStream out) throws IOException, MessagingException { + InputStream in = getInputStream(); + Base64OutputStream base64Out = new Base64OutputStream(out); + IOUtils.copy(in, base64Out); + base64Out.close(); + } + + public Uri getContentUri() { + return mUri; + } + } +} diff --git a/src/com/android/email/mail/store/Pop3Store.java b/src/com/android/email/mail/store/Pop3Store.java new file mode 100644 index 000000000..4d28cda2c --- /dev/null +++ b/src/com/android/email/mail/store/Pop3Store.java @@ -0,0 +1,897 @@ + +package com.android.email.mail.store; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.SSLException; + +import android.util.Config; +import android.util.Log; + +import com.android.email.Email; +import com.android.email.Utility; +import com.android.email.mail.AuthenticationFailedException; +import com.android.email.mail.FetchProfile; +import com.android.email.mail.Flag; +import com.android.email.mail.Folder; +import com.android.email.mail.Message; +import com.android.email.mail.MessageRetrievalListener; +import com.android.email.mail.MessagingException; +import com.android.email.mail.Store; +import com.android.email.mail.CertificateValidationException; +import com.android.email.mail.Folder.OpenMode; +import com.android.email.mail.internet.MimeMessage; + +public class Pop3Store extends Store { + public static final int CONNECTION_SECURITY_NONE = 0; + public static final int CONNECTION_SECURITY_TLS_OPTIONAL = 1; + public static final int CONNECTION_SECURITY_TLS_REQUIRED = 2; + public static final int CONNECTION_SECURITY_SSL_REQUIRED = 3; + public static final int CONNECTION_SECURITY_SSL_OPTIONAL = 4; + + private static final Flag[] PERMANENT_FLAGS = { Flag.DELETED }; + + private String mHost; + private int mPort; + private String mUsername; + private String mPassword; + private int mConnectionSecurity; + private HashMap mFolders = new HashMap(); + private Pop3Capabilities mCapabilities; + +// /** +// * Detected latency, used for usage scaling. +// * Usage scaling occurs when it is neccesary to get information about +// * messages that could result in large data loads. This value allows +// * the code that loads this data to decide between using large downloads +// * (high latency) or multiple round trips (low latency) to accomplish +// * the same thing. +// * Default is Integer.MAX_VALUE implying massive latency so that the large +// * download method is used by default until latency data is collected. +// */ +// private int mLatencyMs = Integer.MAX_VALUE; +// +// /** +// * Detected throughput, used for usage scaling. +// * Usage scaling occurs when it is neccesary to get information about +// * messages that could result in large data loads. This value allows +// * the code that loads this data to decide between using large downloads +// * (high latency) or multiple round trips (low latency) to accomplish +// * the same thing. +// * Default is Integer.MAX_VALUE implying massive bandwidth so that the +// * large download method is used by default until latency data is +// * collected. +// */ +// private int mThroughputKbS = Integer.MAX_VALUE; + + /** + * pop3://user:password@server:port CONNECTION_SECURITY_NONE + * pop3+tls://user:password@server:port CONNECTION_SECURITY_TLS_OPTIONAL + * pop3+tls+://user:password@server:port CONNECTION_SECURITY_TLS_REQUIRED + * pop3+ssl+://user:password@server:port CONNECTION_SECURITY_SSL_REQUIRED + * pop3+ssl://user:password@server:port CONNECTION_SECURITY_SSL_OPTIONAL + * + * @param _uri + */ + public Pop3Store(String _uri) throws MessagingException { + URI uri; + try { + uri = new URI(_uri); + } catch (URISyntaxException use) { + throw new MessagingException("Invalid Pop3Store URI", use); + } + + String scheme = uri.getScheme(); + if (scheme.equals("pop3")) { + mConnectionSecurity = CONNECTION_SECURITY_NONE; + mPort = 110; + } else if (scheme.equals("pop3+tls")) { + mConnectionSecurity = CONNECTION_SECURITY_TLS_OPTIONAL; + mPort = 110; + } else if (scheme.equals("pop3+tls+")) { + mConnectionSecurity = CONNECTION_SECURITY_TLS_REQUIRED; + mPort = 110; + } else if (scheme.equals("pop3+ssl+")) { + mConnectionSecurity = CONNECTION_SECURITY_SSL_REQUIRED; + mPort = 995; + } else if (scheme.equals("pop3+ssl")) { + mConnectionSecurity = CONNECTION_SECURITY_SSL_OPTIONAL; + mPort = 995; + } else { + throw new MessagingException("Unsupported protocol"); + } + + mHost = uri.getHost(); + + if (uri.getPort() != -1) { + mPort = uri.getPort(); + } + + if (uri.getUserInfo() != null) { + String[] userInfoParts = uri.getUserInfo().split(":", 2); + mUsername = userInfoParts[0]; + if (userInfoParts.length > 1) { + mPassword = userInfoParts[1]; + } + } + } + + @Override + public Folder getFolder(String name) throws MessagingException { + Folder folder = mFolders.get(name); + if (folder == null) { + folder = new Pop3Folder(name); + mFolders.put(folder.getName(), folder); + } + return folder; + } + + @Override + public Folder[] getPersonalNamespaces() throws MessagingException { + return new Folder[] { + getFolder("INBOX"), + }; + } + + @Override + public void checkSettings() throws MessagingException { + Pop3Folder folder = new Pop3Folder("INBOX"); + folder.open(OpenMode.READ_WRITE); + if (!mCapabilities.uidl) { + /* + * Run an additional test to see if UIDL is supported on the server. If it's not we + * can't service this account. + */ + try{ + /* + * If the server doesn't support UIDL it will return a - response, which causes + * executeSimpleCommand to throw a MessagingException, exiting this method. + */ + folder.executeSimpleCommand("UIDL"); + } + catch (IOException ioe) { + throw new MessagingException(null, ioe); + } + } + folder.close(false); + } + + class Pop3Folder extends Folder { + private Socket mSocket; + private InputStream mIn; + private OutputStream mOut; + private HashMap mUidToMsgMap = new HashMap(); + private HashMap mMsgNumToMsgMap = new HashMap(); + private HashMap mUidToMsgNumMap = new HashMap(); + private String mName; + private int mMessageCount; + + public Pop3Folder(String name) { + this.mName = name; + if (mName.equalsIgnoreCase("INBOX")) { + mName = "INBOX"; + } + } + + @Override + public synchronized void open(OpenMode mode) throws MessagingException { + if (isOpen()) { + return; + } + + if (!mName.equalsIgnoreCase("INBOX")) { + throw new MessagingException("Folder does not exist"); + } + + try { + SocketAddress socketAddress = new InetSocketAddress(mHost, mPort); + if (mConnectionSecurity == CONNECTION_SECURITY_SSL_REQUIRED || + mConnectionSecurity == CONNECTION_SECURITY_SSL_OPTIONAL) { + SSLContext sslContext = SSLContext.getInstance("TLS"); + final boolean secure = mConnectionSecurity == CONNECTION_SECURITY_SSL_REQUIRED; + sslContext.init(null, new TrustManager[] { + TrustManagerFactory.get(mHost, secure) + }, new SecureRandom()); + mSocket = sslContext.getSocketFactory().createSocket(); + mSocket.connect(socketAddress, SOCKET_CONNECT_TIMEOUT); + mIn = new BufferedInputStream(mSocket.getInputStream(), 1024); + mOut = new BufferedOutputStream(mSocket.getOutputStream(), 512); + } else { + mSocket = new Socket(); + mSocket.connect(socketAddress, SOCKET_CONNECT_TIMEOUT); + mIn = new BufferedInputStream(mSocket.getInputStream(), 1024); + mOut = new BufferedOutputStream(mSocket.getOutputStream(), 512); + } + + mSocket.setSoTimeout(Store.SOCKET_READ_TIMEOUT); + + + // Eat the banner + executeSimpleCommand(null); + + mCapabilities = getCapabilities(); + + if (mConnectionSecurity == CONNECTION_SECURITY_TLS_OPTIONAL + || mConnectionSecurity == CONNECTION_SECURITY_TLS_REQUIRED) { + if (mCapabilities.stls) { + writeLine("STLS"); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + boolean secure = mConnectionSecurity == CONNECTION_SECURITY_TLS_REQUIRED; + sslContext.init(null, new TrustManager[] { + TrustManagerFactory.get(mHost, secure) + }, new SecureRandom()); + mSocket = sslContext.getSocketFactory().createSocket(mSocket, mHost, mPort, + true); + mSocket.setSoTimeout(Store.SOCKET_READ_TIMEOUT); + mIn = new BufferedInputStream(mSocket.getInputStream(), 1024); + mOut = new BufferedOutputStream(mSocket.getOutputStream(), 512); + } else if (mConnectionSecurity == CONNECTION_SECURITY_TLS_REQUIRED) { + throw new MessagingException("TLS not supported but required"); + } + } + + try { + executeSimpleCommand("USER " + mUsername); + executeSimpleCommand("PASS " + mPassword); + } catch (MessagingException me) { + throw new AuthenticationFailedException(null, me); + } + } catch (SSLException e) { + throw new CertificateValidationException(e.getMessage(), e); + } catch (GeneralSecurityException gse) { + throw new MessagingException( + "Unable to open connection to POP server due to security error.", gse); + } catch (IOException ioe) { + throw new MessagingException("Unable to open connection to POP server.", ioe); + } + + try { + String response = executeSimpleCommand("STAT"); + String[] parts = response.split(" "); + mMessageCount = Integer.parseInt(parts[1]); + } + catch (IOException ioe) { + throw new MessagingException("Unable to STAT folder.", ioe); + } + mUidToMsgMap.clear(); + mMsgNumToMsgMap.clear(); + mUidToMsgNumMap.clear(); + } + + public boolean isOpen() { + return (mIn != null && mOut != null && mSocket != null && mSocket.isConnected() && !mSocket + .isClosed()); + } + + @Override + public OpenMode getMode() throws MessagingException { + return OpenMode.READ_ONLY; + } + + @Override + public void close(boolean expunge) { + try { + executeSimpleCommand("QUIT"); + } + catch (Exception e) { + /* + * QUIT may fail if the connection is already closed. We don't care. It's just + * being friendly. + */ + } + + closeIO(); + } + + private void closeIO() { + try { + mIn.close(); + } catch (Exception e) { + /* + * May fail if the connection is already closed. + */ + } + try { + mOut.close(); + } catch (Exception e) { + /* + * May fail if the connection is already closed. + */ + } + try { + mSocket.close(); + } catch (Exception e) { + /* + * May fail if the connection is already closed. + */ + } + mIn = null; + mOut = null; + mSocket = null; + } + + @Override + public String getName() { + return mName; + } + + @Override + public boolean create(FolderType type) throws MessagingException { + return false; + } + + @Override + public boolean exists() throws MessagingException { + return mName.equalsIgnoreCase("INBOX"); + } + + @Override + public int getMessageCount() { + return mMessageCount; + } + + @Override + public int getUnreadMessageCount() throws MessagingException { + return -1; + } + + @Override + public Message getMessage(String uid) throws MessagingException { + Pop3Message message = mUidToMsgMap.get(uid); + if (message == null) { + message = new Pop3Message(uid, this); + } + return message; + } + + @Override + public Message[] getMessages(int start, int end, MessageRetrievalListener listener) + throws MessagingException { + if (start < 1 || end < 1 || end < start) { + throw new MessagingException(String.format("Invalid message set %d %d", + start, end)); + } + try { + indexMsgNums(start, end); + } catch (IOException ioe) { + throw new MessagingException("getMessages", ioe); + } + ArrayList messages = new ArrayList(); + int i = 0; + for (int msgNum = start; msgNum <= end; msgNum++) { + Pop3Message message = mMsgNumToMsgMap.get(msgNum); + if (listener != null) { + listener.messageStarted(message.getUid(), i++, (end - start) + 1); + } + messages.add(message); + if (listener != null) { + listener.messageFinished(message, i++, (end - start) + 1); + } + } + return messages.toArray(new Message[messages.size()]); + } + + /** + * Ensures that the given message set (from start to end inclusive) + * has been queried so that uids are available in the local cache. + * @param start + * @param end + * @throws MessagingException + * @throws IOException + */ + private void indexMsgNums(int start, int end) + throws MessagingException, IOException { + int unindexedMessageCount = 0; + for (int msgNum = start; msgNum <= end; msgNum++) { + if (mMsgNumToMsgMap.get(msgNum) == null) { + unindexedMessageCount++; + } + } + if (unindexedMessageCount == 0) { + return; + } + if (unindexedMessageCount < 50 && mMessageCount > 5000) { + /* + * In extreme cases we'll do a UIDL command per message instead of a bulk + * download. + */ + for (int msgNum = start; msgNum <= end; msgNum++) { + Pop3Message message = mMsgNumToMsgMap.get(msgNum); + if (message == null) { + String response = executeSimpleCommand("UIDL " + msgNum); + int uidIndex = response.lastIndexOf(' '); + String msgUid = response.substring(uidIndex + 1); + message = new Pop3Message(msgUid, this); + indexMessage(msgNum, message); + } + } + } + else { + String response = executeSimpleCommand("UIDL"); + while ((response = readLine()) != null) { + if (response.equals(".")) { + break; + } + String[] uidParts = response.split(" "); + Integer msgNum = Integer.valueOf(uidParts[0]); + String msgUid = uidParts[1]; + if (msgNum >= start && msgNum <= end) { + Pop3Message message = mMsgNumToMsgMap.get(msgNum); + if (message == null) { + message = new Pop3Message(msgUid, this); + indexMessage(msgNum, message); + } + } + } + } + } + + private void indexUids(ArrayList uids) + throws MessagingException, IOException { + HashSet unindexedUids = new HashSet(); + for (String uid : uids) { + if (mUidToMsgMap.get(uid) == null) { + unindexedUids.add(uid); + } + } + if (unindexedUids.size() == 0) { + return; + } + /* + * If we are missing uids in the cache the only sure way to + * get them is to do a full UIDL list. A possible optimization + * would be trying UIDL for the latest X messages and praying. + */ + String response = executeSimpleCommand("UIDL"); + while ((response = readLine()) != null) { + if (response.equals(".")) { + break; + } + String[] uidParts = response.split(" "); + Integer msgNum = Integer.valueOf(uidParts[0]); + String msgUid = uidParts[1]; + if (unindexedUids.contains(msgUid)) { + if (Config.LOGD) { + Pop3Message message = mUidToMsgMap.get(msgUid); + if (message == null) { + message = new Pop3Message(msgUid, this); + } + indexMessage(msgNum, message); + } + } + } + } + + private void indexMessage(int msgNum, Pop3Message message) { + mMsgNumToMsgMap.put(msgNum, message); + mUidToMsgMap.put(message.getUid(), message); + mUidToMsgNumMap.put(message.getUid(), msgNum); + } + + @Override + public Message[] getMessages(MessageRetrievalListener listener) throws MessagingException { + throw new UnsupportedOperationException("Pop3Folder.getMessage(MessageRetrievalListener)"); + } + + @Override + public Message[] getMessages(String[] uids, MessageRetrievalListener listener) + throws MessagingException { + throw new UnsupportedOperationException("Pop3Folder.getMessage(MessageRetrievalListener)"); + } + + /** + * Fetch the items contained in the FetchProfile into the given set of + * Messages in as efficient a manner as possible. + * @param messages + * @param fp + * @throws MessagingException + */ + public void fetch(Message[] messages, FetchProfile fp, MessageRetrievalListener listener) + throws MessagingException { + if (messages == null || messages.length == 0) { + return; + } + ArrayList uids = new ArrayList(); + for (Message message : messages) { + uids.add(message.getUid()); + } + try { + indexUids(uids); + } + catch (IOException ioe) { + throw new MessagingException("fetch", ioe); + } + try { + if (fp.contains(FetchProfile.Item.ENVELOPE)) { + /* + * We pass the listener only if there are other things to do in the + * FetchProfile. Since fetchEnvelop works in bulk and eveything else + * works one at a time if we let fetchEnvelope send events the + * event would get sent twice. + */ + fetchEnvelope(messages, fp.size() == 1 ? listener : null); + } + } + catch (IOException ioe) { + throw new MessagingException("fetch", ioe); + } + for (int i = 0, count = messages.length; i < count; i++) { + Message message = messages[i]; + if (!(message instanceof Pop3Message)) { + throw new MessagingException("Pop3Store.fetch called with non-Pop3 Message"); + } + Pop3Message pop3Message = (Pop3Message)message; + try { + if (listener != null && !fp.contains(FetchProfile.Item.ENVELOPE)) { + listener.messageStarted(pop3Message.getUid(), i, count); + } + if (fp.contains(FetchProfile.Item.BODY)) { + fetchBody(pop3Message, -1); + } + else if (fp.contains(FetchProfile.Item.BODY_SANE)) { + /* + * To convert the suggested download size we take the size + * divided by the maximum line size (76). + */ + fetchBody(pop3Message, + FETCH_BODY_SANE_SUGGESTED_SIZE / 76); + } + else if (fp.contains(FetchProfile.Item.STRUCTURE)) { + /* + * If the user is requesting STRUCTURE we are required to set the body + * to null since we do not support the function. + */ + pop3Message.setBody(null); + } + if (listener != null && !fp.contains(FetchProfile.Item.ENVELOPE)) { + listener.messageFinished(message, i, count); + } + } catch (IOException ioe) { + throw new MessagingException("Unable to fetch message", ioe); + } + } + } + + private void fetchEnvelope(Message[] messages, + MessageRetrievalListener listener) throws IOException, MessagingException { + int unsizedMessages = 0; + for (Message message : messages) { + if (message.getSize() == -1) { + unsizedMessages++; + } + } + if (unsizedMessages == 0) { + return; + } + if (unsizedMessages < 50 && mMessageCount > 5000) { + /* + * In extreme cases we'll do a command per message instead of a bulk request + * to hopefully save some time and bandwidth. + */ + for (int i = 0, count = messages.length; i < count; i++) { + Message message = messages[i]; + if (!(message instanceof Pop3Message)) { + throw new MessagingException("Pop3Store.fetch called with non-Pop3 Message"); + } + Pop3Message pop3Message = (Pop3Message)message; + if (listener != null) { + listener.messageStarted(pop3Message.getUid(), i, count); + } + String response = executeSimpleCommand(String.format("LIST %d", + mUidToMsgNumMap.get(pop3Message.getUid()))); + String[] listParts = response.split(" "); + int msgNum = Integer.parseInt(listParts[1]); + int msgSize = Integer.parseInt(listParts[2]); + pop3Message.setSize(msgSize); + if (listener != null) { + listener.messageFinished(pop3Message, i, count); + } + } + } + else { + HashSet msgUidIndex = new HashSet(); + for (Message message : messages) { + msgUidIndex.add(message.getUid()); + } + int i = 0, count = messages.length; + String response = executeSimpleCommand("LIST"); + while ((response = readLine()) != null) { + if (response.equals(".")) { + break; + } + String[] listParts = response.split(" "); + int msgNum = Integer.parseInt(listParts[0]); + int msgSize = Integer.parseInt(listParts[1]); + Pop3Message pop3Message = mMsgNumToMsgMap.get(msgNum); + if (pop3Message != null && msgUidIndex.contains(pop3Message.getUid())) { + if (listener != null) { + listener.messageStarted(pop3Message.getUid(), i, count); + } + pop3Message.setSize(msgSize); + if (listener != null) { + listener.messageFinished(pop3Message, i, count); + } + i++; + } + } + } + } + + /** + * Fetches the body of the given message, limiting the stored data + * to the specified number of lines. If lines is -1 the entire message + * is fetched. This is implemented with RETR for lines = -1 or TOP + * for any other value. If the server does not support TOP it is + * emulated with RETR and extra lines are thrown away. + * @param message + * @param lines + */ + private void fetchBody(Pop3Message message, int lines) + throws IOException, MessagingException { + String response = null; + if (lines == -1 || !mCapabilities.top) { + response = executeSimpleCommand(String.format("RETR %d", + mUidToMsgNumMap.get(message.getUid()))); + } + else { + response = executeSimpleCommand(String.format("TOP %d %d", + mUidToMsgNumMap.get(message.getUid()), + lines)); + } + if (response != null) { + try { + message.parse(new Pop3ResponseInputStream(mIn)); + } + catch (MessagingException me) { + /* + * If we're only downloading headers it's possible + * we'll get a broken MIME message which we're not + * real worried about. If we've downloaded the body + * and can't parse it we need to let the user know. + */ + if (lines == -1) { + throw me; + } + } + } + } + + @Override + public Flag[] getPermanentFlags() throws MessagingException { + return PERMANENT_FLAGS; + } + + public void appendMessages(Message[] messages) throws MessagingException { + } + + public void delete(boolean recurse) throws MessagingException { + } + + public Message[] expunge() throws MessagingException { + return null; + } + + public void setFlags(Message[] messages, Flag[] flags, boolean value) + throws MessagingException { + if (!value || !Utility.arrayContains(flags, Flag.DELETED)) { + /* + * The only flagging we support is setting the Deleted flag. + */ + return; + } + try { + for (Message message : messages) { + executeSimpleCommand(String.format("DELE %s", + mUidToMsgNumMap.get(message.getUid()))); + } + } + catch (IOException ioe) { + throw new MessagingException("setFlags()", ioe); + } + } + + @Override + public void copyMessages(Message[] msgs, Folder folder) throws MessagingException { + throw new UnsupportedOperationException("copyMessages is not supported in POP3"); + } + +// private boolean isRoundTripModeSuggested() { +// long roundTripMethodMs = +// (uncachedMessageCount * 2 * mLatencyMs); +// long bulkMethodMs = +// (mMessageCount * 58) / (mThroughputKbS * 1024 / 8) * 1000; +// } + + private String readLine() throws IOException { + StringBuffer sb = new StringBuffer(); + int d = mIn.read(); + if (d == -1) { + throw new IOException("End of stream reached while trying to read line."); + } + do { + if (((char)d) == '\r') { + continue; + } else if (((char)d) == '\n') { + break; + } else { + sb.append((char)d); + } + } while ((d = mIn.read()) != -1); + String ret = sb.toString(); + if (Config.LOGD) { + if (Email.DEBUG) { + Log.d(Email.LOG_TAG, "<<< " + ret); + } + } + return ret; + } + + private void writeLine(String s) throws IOException { + if (Config.LOGD) { + if (Email.DEBUG) { + Log.d(Email.LOG_TAG, ">>> " + s); + } + } + mOut.write(s.getBytes()); + mOut.write('\r'); + mOut.write('\n'); + mOut.flush(); + } + + private Pop3Capabilities getCapabilities() throws IOException, MessagingException { + Pop3Capabilities capabilities = new Pop3Capabilities(); + try { + String response = executeSimpleCommand("CAPA"); + while ((response = readLine()) != null) { + if (response.equals(".")) { + break; + } + if (response.equalsIgnoreCase("STLS")){ + capabilities.stls = true; + } + else if (response.equalsIgnoreCase("UIDL")) { + capabilities.uidl = true; + } + else if (response.equalsIgnoreCase("PIPELINING")) { + capabilities.pipelining = true; + } + else if (response.equalsIgnoreCase("USER")) { + capabilities.user = true; + } + else if (response.equalsIgnoreCase("TOP")) { + capabilities.top = true; + } + } + } + catch (MessagingException me) { + /* + * The server may not support the CAPA command, so we just eat this Exception + * and allow the empty capabilities object to be returned. + */ + } + return capabilities; + } + + private String executeSimpleCommand(String command) throws IOException, MessagingException { + try { + open(OpenMode.READ_WRITE); + + if (command != null) { + writeLine(command); + } + + String response = readLine(); + + if (response.length() > 1 && response.charAt(0) == '-') { + throw new MessagingException(response); + } + + return response; + } + catch (IOException e) { + closeIO(); + throw e; + } + } + + @Override + public boolean supportsFetchingFlags() { + return false; + }//isFlagSupported + + @Override + public boolean equals(Object o) { + if (o instanceof Pop3Folder) { + return ((Pop3Folder) o).mName.equals(mName); + } + return super.equals(o); + } + + }//Pop3Folder + + class Pop3Message extends MimeMessage { + public Pop3Message(String uid, Pop3Folder folder) throws MessagingException { + mUid = uid; + mFolder = folder; + mSize = -1; + } + + public void setSize(int size) { + mSize = size; + } + + protected void parse(InputStream in) throws IOException, MessagingException { + super.parse(in); + } + + @Override + public void setFlag(Flag flag, boolean set) throws MessagingException { + super.setFlag(flag, set); + mFolder.setFlags(new Message[] { this }, new Flag[] { flag }, set); + } + } + + class Pop3Capabilities { + public boolean stls; + public boolean top; + public boolean user; + public boolean uidl; + public boolean pipelining; + + public String toString() { + return String.format("STLS %b, TOP %b, USER %b, UIDL %b, PIPELINING %b", + stls, + top, + user, + uidl, + pipelining); + } + } + + class Pop3ResponseInputStream extends InputStream { + InputStream mIn; + boolean mStartOfLine = true; + boolean mFinished; + + public Pop3ResponseInputStream(InputStream in) { + mIn = in; + } + + @Override + public int read() throws IOException { + if (mFinished) { + return -1; + } + int d = mIn.read(); + if (mStartOfLine && d == '.') { + d = mIn.read(); + if (d == '\r') { + mFinished = true; + mIn.read(); + return -1; + } + } + + mStartOfLine = (d == '\n'); + + return d; + } + } +} diff --git a/src/com/android/email/mail/store/TrustManagerFactory.java b/src/com/android/email/mail/store/TrustManagerFactory.java new file mode 100644 index 000000000..acd61750d --- /dev/null +++ b/src/com/android/email/mail/store/TrustManagerFactory.java @@ -0,0 +1,213 @@ + +package com.android.email.mail.store; + +import android.util.Log; +import android.app.Application; +import android.content.Context; +import android.net.http.DomainNameChecker; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.security.KeyStore; +import java.security.NoSuchAlgorithmException; +import java.security.KeyStoreException; +import java.security.cert.X509Certificate; +import java.security.cert.CertificateException; + +import javax.net.ssl.X509TrustManager; +import javax.net.ssl.TrustManager; + +import com.android.email.Email; + +public final class TrustManagerFactory { + private static final String LOG_TAG = "TrustManagerFactory"; + + private static X509TrustManager defaultTrustManager; + private static X509TrustManager unsecureTrustManager; + private static X509TrustManager localTrustManager; + + private static SecureX509TrustManager secureTrustManager; + + private static X509Certificate[] lastCertChain = null; + + private static File keyStoreFile; + private static KeyStore keyStore; + + + private static class SimpleX509TrustManager implements X509TrustManager { + public void checkClientTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + } + + public void checkServerTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + } + + public X509Certificate[] getAcceptedIssuers() { + return null; + } + } + + private static class SecureX509TrustManager implements X509TrustManager { + private static String mHost; + private static SecureX509TrustManager me; + + private SecureX509TrustManager() { + } + + public static X509TrustManager getInstance(String host) { + mHost = host; + if (me == null) { + me = new SecureX509TrustManager(); + } + return me; + } + + public static void setHost(String host){ + mHost = host; + } + + public void checkClientTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + defaultTrustManager.checkClientTrusted(chain, authType); + } + + public void checkServerTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + TrustManagerFactory.setLastCertChain(chain); + try { + defaultTrustManager.checkServerTrusted(chain, authType); + } catch (CertificateException e) { + localTrustManager.checkServerTrusted(new X509Certificate[] {chain[0]}, authType); + } + if (!DomainNameChecker.match(chain[0], mHost)) { + try { + String dn = chain[0].getSubjectDN().toString(); + if ((dn != null) && (dn.equalsIgnoreCase(keyStore.getCertificateAlias(chain[0])))) { + return; + } + } catch (KeyStoreException e) { + throw new CertificateException("Certificate cannot be verified; KeyStore Exception: " + e); + } + throw new CertificateException("Certificate domain name does not match " + + mHost); + } + } + + public X509Certificate[] getAcceptedIssuers() { + return defaultTrustManager.getAcceptedIssuers(); + } + + } + + static { + try { + javax.net.ssl.TrustManagerFactory tmf = javax.net.ssl.TrustManagerFactory.getInstance("X509"); + Application app = Email.app; + keyStoreFile = new File(app.getDir("KeyStore", Context.MODE_PRIVATE) + File.separator + "KeyStore.bks"); + keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + //TODO: read store from disk. + java.io.FileInputStream fis; + try { + fis = new java.io.FileInputStream(keyStoreFile); + } catch (FileNotFoundException e1) { + fis = null; + } + try { + keyStore.load(fis, "".toCharArray()); + } catch (IOException e) { + Log.e(LOG_TAG, "KeyStore IOException while initializing TrustManagerFactory ", e); + keyStore = null; + } catch (CertificateException e) { + Log.e(LOG_TAG, "KeyStore CertificateException while initializing TrustManagerFactory ", e); + keyStore = null; + } + tmf.init(keyStore); + TrustManager[] tms = tmf.getTrustManagers(); + if (tms != null) { + for (TrustManager tm : tms) { + if (tm instanceof X509TrustManager) { + localTrustManager = (X509TrustManager)tm; + break; + } + } + } + tmf = javax.net.ssl.TrustManagerFactory.getInstance("X509"); + tmf.init((KeyStore)null); + tms = tmf.getTrustManagers(); + if (tms != null) { + for (TrustManager tm : tms) { + if (tm instanceof X509TrustManager) { + defaultTrustManager = (X509TrustManager) tm; + break; + } + } + } + + } catch (NoSuchAlgorithmException e) { + Log.e(LOG_TAG, "Unable to get X509 Trust Manager ", e); + } catch (KeyStoreException e) { + Log.e(LOG_TAG, "Key Store exception while initializing TrustManagerFactory ", e); + } + unsecureTrustManager = new SimpleX509TrustManager(); + } + + private TrustManagerFactory() { + } + + public static X509TrustManager get(String host, boolean secure) { + return secure ? SecureX509TrustManager.getInstance(host) : + unsecureTrustManager; + } + + public static KeyStore getKeyStore() { + return keyStore; + } + + public static void setLastCertChain(X509Certificate[] chain) { + lastCertChain = chain; + } + public static X509Certificate[] getLastCertChain() { + return lastCertChain; + } + + public static void addCertificateChain(String alias, X509Certificate[] chain) throws CertificateException { + try { + javax.net.ssl.TrustManagerFactory tmf = javax.net.ssl.TrustManagerFactory.getInstance("X509"); + for (int i = 0; i < chain.length; i++) + { + keyStore.setCertificateEntry + (chain[i].getSubjectDN().toString(), chain[i]); + } + + tmf.init(keyStore); + TrustManager[] tms = tmf.getTrustManagers(); + if (tms != null) { + for (TrustManager tm : tms) { + if (tm instanceof X509TrustManager) { + localTrustManager = (X509TrustManager) tm; + break; + } + } + } + java.io.FileOutputStream keyStoreStream; + try { + keyStoreStream = new java.io.FileOutputStream(keyStoreFile); + keyStore.store(keyStoreStream, "".toCharArray()); + keyStoreStream.close(); + } catch (FileNotFoundException e) { + throw new CertificateException("Unable to write KeyStore: " + e.getMessage()); + } catch (CertificateException e) { + throw new CertificateException("Unable to write KeyStore: " + e.getMessage()); + } catch (IOException e) { + throw new CertificateException("Unable to write KeyStore: " + e.getMessage()); + } + + } catch (NoSuchAlgorithmException e) { + Log.e(LOG_TAG, "Unable to get X509 Trust Manager ", e); + } catch (KeyStoreException e) { + Log.e(LOG_TAG, "Key Store exception while initializing TrustManagerFactory ", e); + } + } +} diff --git a/src/com/android/email/mail/store/WebDavStore.java b/src/com/android/email/mail/store/WebDavStore.java new file mode 100644 index 000000000..fea3f821d --- /dev/null +++ b/src/com/android/email/mail/store/WebDavStore.java @@ -0,0 +1,1846 @@ +package com.android.email.mail.store; + +import java.io.BufferedReader; +import java.io.InputStreamReader; + +import android.util.Log; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URISyntaxException; +import java.text.DateFormat; +import java.text.ParsePosition; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.Stack; + +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; +import javax.xml.parsers.ParserConfigurationException; + +import com.android.email.Email; +import com.android.email.mail.FetchProfile; +import com.android.email.mail.Flag; +import com.android.email.mail.Folder; +import com.android.email.mail.Message; +import com.android.email.mail.MessageRetrievalListener; +import com.android.email.mail.MessagingException; +import com.android.email.mail.Store; +import com.android.email.mail.internet.MimeBodyPart; +import com.android.email.mail.internet.MimeMessage; +import com.android.email.mail.internet.TextBody; + +import org.apache.http.HttpResponse; +import org.apache.http.HttpEntity; +import org.apache.http.client.CookieStore; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.DefaultHttpClient; +import org.apache.http.message.BasicNameValuePair; + +import org.xml.sax.Attributes; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; +import org.xml.sax.XMLReader; +import org.xml.sax.helpers.DefaultHandler; + +/** + *
+ * Uses WebDAV formatted HTTP calls to an MS Exchange server to fetch emails
+ * and email information.  This has only been tested on an MS Exchange
+ * Server 2003.  It uses Form-Based authentication and requires that
+ * Outlook Web Access be enabled on the server.
+ * 
+ */ +public class WebDavStore extends Store { + public static final int CONNECTION_SECURITY_NONE = 0; + public static final int CONNECTION_SECURITY_TLS_OPTIONAL = 1; + public static final int CONNECTION_SECURITY_TLS_REQUIRED = 2; + public static final int CONNECTION_SECURITY_SSL_REQUIRED = 3; + public static final int CONNECTION_SECURITY_SSL_OPTIONAL = 4; + + private static final Flag[] PERMANENT_FLAGS = { Flag.DELETED, Flag.SEEN, Flag.ANSWERED }; + + private int mConnectionSecurity; + private String mUsername; /* Stores the username for authentications */ + private String alias; + private String mPassword; /* Stores the password for authentications */ + private String mUrl; /* Stores the base URL for the server */ + + private CookieStore mAuthCookies; /* Stores cookies from authentication */ + private boolean mAuthenticated = false; /* Stores authentication state */ + private long mLastAuth = -1; /* Stores the timestamp of last auth */ + private long mAuthTimeout = 5 * 60; + + private HashMap mFolderList = new HashMap(); + /** + * webdav://user:password@server:port CONNECTION_SECURITY_NONE + * webdav+tls://user:password@server:port CONNECTION_SECURITY_TLS_OPTIONAL + * webdav+tls+://user:password@server:port CONNECTION_SECURITY_TLS_REQUIRED + * webdav+ssl+://user:password@server:port CONNECTION_SECURITY_SSL_REQUIRED + * webdav+ssl://user:password@server:port CONNECTION_SECURITY_SSL_OPTIONAL + * + * @param _uri + */ + public WebDavStore(String _uri) throws MessagingException { + URI uri; + + try { + uri = new URI(_uri); + } catch (URISyntaxException use) { + throw new MessagingException("Invalid WebDavStore URI", use); + } + String scheme = uri.getScheme(); + if (scheme.equals("webdav")) { + mConnectionSecurity = CONNECTION_SECURITY_NONE; + } else if (scheme.equals("webdav+ssl")) { + mConnectionSecurity = CONNECTION_SECURITY_SSL_OPTIONAL; + } else if (scheme.equals("webdav+ssl+")) { + mConnectionSecurity = CONNECTION_SECURITY_SSL_REQUIRED; + } else if (scheme.equals("webdav+tls")) { + mConnectionSecurity = CONNECTION_SECURITY_TLS_OPTIONAL; + } else if (scheme.equals("webdav+tls+")) { + mConnectionSecurity = CONNECTION_SECURITY_TLS_REQUIRED; + } else { + throw new MessagingException("Unsupported protocol"); + } + + String host = uri.getHost(); + + if (host.startsWith("http")) { + String[] hostParts = host.split("://", 2); + if (hostParts.length > 1) { + host = hostParts[1]; + } + } + + if (mConnectionSecurity == CONNECTION_SECURITY_TLS_REQUIRED || + mConnectionSecurity == CONNECTION_SECURITY_SSL_REQUIRED || + mConnectionSecurity == CONNECTION_SECURITY_TLS_OPTIONAL || + mConnectionSecurity == CONNECTION_SECURITY_SSL_OPTIONAL) { + this.mUrl = "https://" + host; + } else { + this.mUrl = "http://" + host; + } + + if (uri.getUserInfo() != null) { + String[] userInfoParts = uri.getUserInfo().split(":", 2); + mUsername = userInfoParts[0]; + String userParts[] = mUsername.split("/", 2); + + if (userParts.length > 1) { + alias = userParts[1]; + } else { + alias = mUsername; + } + if (userInfoParts.length > 1) { + mPassword = userInfoParts[1]; + } + } + } + + + @Override + public void checkSettings() throws MessagingException { + Log.e(Email.LOG_TAG, "WebDavStore.checkSettings() not implemented"); + } + + @Override + public Folder[] getPersonalNamespaces() throws MessagingException { + ArrayList folderList = new ArrayList(); + DefaultHttpClient httpclient = new DefaultHttpClient(); + HttpEntity responseEntity; + HttpGeneric httpmethod; + HttpResponse response; + StringEntity messageEntity; + String messageBody; + int status_code; + + if (needAuth()) { + authenticate(); + } + + if (this.mAuthenticated == false || + this.mAuthCookies == null) { + return folderList.toArray(new Folder[] {}); + } + + try { + /** Set up and execute the request */ + httpclient.setCookieStore(this.mAuthCookies); + messageBody = getFolderListXml(); + messageEntity = new StringEntity(messageBody); + messageEntity.setContentType("text/xml"); + + httpmethod = new HttpGeneric(this.mUrl);// + "/Exchange/" + this.mUsername); + httpmethod.setMethod("SEARCH"); + httpmethod.setEntity(messageEntity); + httpmethod.setHeader("Brief", "t"); + + response = httpclient.execute(httpmethod); + status_code = response.getStatusLine().getStatusCode(); + + if (status_code < 200 || + status_code > 300) { + throw new IOException("Error getting folder listing"); + } + + responseEntity = response.getEntity(); + + if (responseEntity != null) { + /** Parse the returned data */ + try { + InputStream istream = responseEntity.getContent(); + SAXParserFactory spf = SAXParserFactory.newInstance(); + SAXParser sp = spf.newSAXParser(); + + XMLReader xr = sp.getXMLReader(); + + WebDavHandler myHandler = new WebDavHandler(); + xr.setContentHandler(myHandler); + + xr.parse(new InputSource(istream)); + + ParsedDataSet dataset = myHandler.getDataSet(); + + String[] folderUrls = dataset.getHrefs(); + int urlLength = folderUrls.length; + + for (int i = 0; i < urlLength; i++) { + String[] urlParts = folderUrls[i].split("/"); + String folderName = urlParts[urlParts.length - 1]; + String fullPathName = ""; + WebDavFolder wdFolder; + + if (folderName.equalsIgnoreCase(Email.INBOX)) { + folderName = "INBOX"; + } else { + for (int j = 5, count = urlParts.length; j < count; j++) { + if (j != 5) { + fullPathName = fullPathName + "/" + urlParts[j]; + } else { + fullPathName = urlParts[j]; + } + } + folderName = java.net.URLDecoder.decode(fullPathName, "UTF-8"); + } + + wdFolder = new WebDavFolder(folderName); + wdFolder.setUrl(folderUrls[i]); + folderList.add(wdFolder); + this.mFolderList.put(folderName, wdFolder); + //folderList.add(getFolder(java.net.URLDecoder.decode(folderName, "UTF-8"))); + } + } catch (SAXException se) { + Log.e(Email.LOG_TAG, "Error with SAXParser " + se); + } catch (ParserConfigurationException pce) { + Log.e(Email.LOG_TAG, "Error with SAXParser " + pce); + } + } + } catch (UnsupportedEncodingException uee) { + Log.e(Email.LOG_TAG, "Error with encoding " + uee); + } catch (IOException ioe) { + Log.e(Email.LOG_TAG, "IOException " + ioe); + } + + return folderList.toArray(new WebDavFolder[] {}); + } + + @Override + public Folder getFolder(String name) throws MessagingException { + WebDavFolder folder; + + if ((folder = this.mFolderList.get(name)) == null) { + folder = new WebDavFolder(name); + } + + return folder; + } + + /*************************************************************** + * WebDAV XML Request body retrieval functions + */ + + private String getFolderListXml() { + StringBuffer buffer = new StringBuffer(200); + buffer.append(""); + buffer.append("\r\n"); + buffer.append("SELECT \"DAV:ishidden\"\r\n"); + // buffer.append(" FROM \"\"\r\n"); + buffer.append(" FROM SCOPE('deep traversal of \""+this.mUrl+"\"')\r\n"); + buffer.append(" WHERE \"DAV:ishidden\"=False AND \"DAV:isfolder\"=True\r\n"); + buffer.append("\r\n"); + + return buffer.toString(); + + } + + private String getMessageCountXml(String messageState) { + StringBuffer buffer = new StringBuffer(200); + buffer.append(""); + buffer.append("\r\n"); + buffer.append("SELECT \"DAV:visiblecount\"\r\n"); + buffer.append(" FROM \"\"\r\n"); + buffer.append(" WHERE \"DAV:ishidden\"=False AND \"DAV:isfolder\"=False AND \"urn:schemas:httpmail:read\"="+messageState+"\r\n"); + buffer.append(" GROUP BY \"DAV:ishidden\"\r\n"); + buffer.append("\r\n"); + return buffer.toString(); + } + + private String getMessageEnvelopeXml(String[] uids) { + StringBuffer buffer = new StringBuffer(200); + buffer.append(""); + buffer.append("\r\n"); + buffer.append("SELECT \"DAV:uid\", \"DAV:getcontentlength\","); + buffer.append(" \"urn:schemas:mailheader:received\","); + buffer.append(" \"urn:schemas:mailheader:mime-version\","); + buffer.append(" \"urn:schemas:mailheader:content-type\","); + buffer.append(" \"urn:schemas:mailheader:subject\","); + buffer.append(" \"urn:schemas:mailheader:date\","); + buffer.append(" \"urn:schemas:mailheader:thread-topic\","); + buffer.append(" \"urn:schemas:mailheader:thread-index\","); + buffer.append(" \"urn:schemas:mailheader:from\","); + buffer.append(" \"urn:schemas:mailheader:to\","); + buffer.append(" \"urn:schemas:mailheader:in-reply-to\","); + buffer.append(" \"urn:schemas:mailheader:return-path\","); + buffer.append(" \"urn:schemas:mailheader:cc\","); + buffer.append(" \"urn:schemas:mailheader:references\","); + buffer.append(" \"urn:schemas:httpmail:read\""); + buffer.append(" \r\n"); + buffer.append(" FROM \"\"\r\n"); + buffer.append(" WHERE \"DAV:ishidden\"=False AND \"DAV:isfolder\"=False AND "); + for (int i = 0, count = uids.length; i < count; i++) { + if (i != 0) { + buffer.append(" OR "); + } + buffer.append(" \"DAV:uid\"='"+uids[i]+"' "); + } + buffer.append("\r\n"); + buffer.append("\r\n"); + return buffer.toString(); + } + + private String getMessagesXml() { + StringBuffer buffer = new StringBuffer(200); + buffer.append(""); + buffer.append("\r\n"); + buffer.append("SELECT \"DAV:uid\"\r\n"); + buffer.append(" FROM \"\"\r\n"); + buffer.append(" WHERE \"DAV:ishidden\"=False AND \"DAV:isfolder\"=False\r\n"); + buffer.append("\r\n"); + return buffer.toString(); + } + + private String getMessageUrlsXml(String[] uids) { + StringBuffer buffer = new StringBuffer(600); + buffer.append(""); + buffer.append("\r\n"); + buffer.append("SELECT \"urn:schemas:httpmail:read\", \"DAV:uid\"\r\n"); + buffer.append(" FROM \"\"\r\n"); + buffer.append(" WHERE \"DAV:ishidden\"=False AND \"DAV:isfolder\"=False AND "); + for (int i = 0, count = uids.length; i < count; i++) { + if (i != 0) { + buffer.append(" OR "); + } + + buffer.append(" \"DAV:uid\"='"+uids[i]+"' "); + + } + buffer.append("\r\n"); + buffer.append("\r\n"); + return buffer.toString(); + } + + private String getMessageFlagsXml(String[] uids) throws MessagingException { + if (uids.length == 0) { + throw new MessagingException("Attempt to get flags on 0 length array for uids"); + } + + StringBuffer buffer = new StringBuffer(200); + buffer.append(""); + buffer.append("\r\n"); + buffer.append("SELECT \"urn:schemas:httpmail:read\", \"DAV:uid\"\r\n"); + buffer.append(" FROM \"\"\r\n"); + buffer.append(" WHERE \"DAV:ishidden\"=False AND \"DAV:isfolder\"=False AND "); + + for (int i = 0, count = uids.length; i < count; i++) { + if (i != 0) { + buffer.append(" OR "); + } + buffer.append(" \"DAV:uid\"='"+uids[i]+"' "); + } + buffer.append("\r\n"); + buffer.append("\r\n"); + return buffer.toString(); + } + + private String getMarkMessagesReadXml(String[] urls) { + StringBuffer buffer = new StringBuffer(600); + buffer.append("\r\n"); + buffer.append("\r\n"); + buffer.append("\r\n"); + for (int i = 0, count = urls.length; i < count; i++) { + buffer.append(" "+urls[i].substring(urls[i].lastIndexOf('/') + 1)+"\r\n"); + } + buffer.append("\r\n"); + buffer.append("\r\n"); + buffer.append(" \r\n"); + buffer.append(" 1\r\n"); + buffer.append(" \r\n"); + buffer.append("\r\n"); + buffer.append("\r\n"); + return buffer.toString(); + } + + /*************************************************************** + * Authentication related methods + */ + + /** + * Performs Form Based authentication regardless of the current + * authentication state + */ + public void authenticate() { + try { + this.mAuthCookies = doAuthentication(this.mUsername, this.mPassword, this.mUrl); + } catch (IOException ioe) { + Log.e(Email.LOG_TAG, "Error during authentication: " + ioe); + this.mAuthCookies = null; + } + + if (this.mAuthCookies == null) { + this.mAuthenticated = false; + } else { + this.mAuthenticated = true; + this.mLastAuth = System.currentTimeMillis()/1000; + } + } + + /** + * Determines if a new authentication is needed. + * Returns true if new authentication is needed. + */ + public boolean needAuth() { + boolean status = false; + long currentTime = -1; + if (this.mAuthenticated == false) { + status = true; + } + + currentTime = System.currentTimeMillis()/1000; + if ((currentTime - this.mLastAuth) > (this.mAuthTimeout)) { + status = true; + } + return status; + } + + /** + * Performs the Form Based Authentication + * Returns the CookieStore object for later use or null + */ + public CookieStore doAuthentication(String username, String password, + String url) throws IOException { + String authPath = "/exchweb/bin/auth/owaauth.dll"; + CookieStore cookies = null; + String[] urlParts = url.split("/"); + String finalUrl = ""; + + for (int i = 0; i <= 2; i++) { + if (i != 0) { + finalUrl = finalUrl + "/" + urlParts[i]; + } else { + finalUrl = urlParts[i]; + } + } + + /* Browser Client */ + DefaultHttpClient httpclient = new DefaultHttpClient(); + + /* Post Method */ + HttpPost httppost = new HttpPost(finalUrl + authPath); + + /** Build the POST data to use */ + ArrayList pairs = new ArrayList(); + pairs.add(new BasicNameValuePair("username", username)); + pairs.add(new BasicNameValuePair("password", password)); + pairs.add(new BasicNameValuePair("destination", finalUrl + "/Exchange/")); + pairs.add(new BasicNameValuePair("flags", "0")); + pairs.add(new BasicNameValuePair("SubmitCreds", "Log+On")); + pairs.add(new BasicNameValuePair("forcedownlevel", "0")); + pairs.add(new BasicNameValuePair("trusted", "0")); + + try { + UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(pairs); + + httppost.setEntity(formEntity); + + /** Perform the actual POST */ + HttpResponse response = httpclient.execute(httppost); + HttpEntity entity = response.getEntity(); + int status_code = response.getStatusLine().getStatusCode(); + + /** Verify success */ + if (status_code > 300 || + status_code < 200) { + throw new IOException("Error during authentication: "+status_code); + } + + cookies = httpclient.getCookieStore(); + if (cookies == null) { + throw new IOException("Error during authentication: No Cookies"); + } + + /** Get the URL for the mailbox and set it for the store */ + if (entity != null) { + InputStream istream = entity.getContent(); + + BufferedReader reader = new BufferedReader(new InputStreamReader(istream), 8192); + String tempText = ""; + + while ((tempText = reader.readLine()) != null) { + if (tempText.indexOf("BASE href") >= 0) { + String[] tagParts = tempText.split("\""); + this.mUrl = tagParts[1]; + } + } + } + + } catch (UnsupportedEncodingException uee) { + Log.e(Email.LOG_TAG, "Error encoding POST data for authencation"); + } + return cookies; + } + + public CookieStore getAuthCookies() { + return mAuthCookies; + } + public String getAlias() { + return alias; + } + public String getUrl() { + return mUrl; + } + + /************************************************************************* + * Helper and Inner classes + */ + + /** + * A WebDav Folder + */ + class WebDavFolder extends Folder { + private String mName; + private String mLocalUsername; + private String mFolderUrl; + private boolean mIsOpen = false; + private int mMessageCount = 0; + private int mUnreadMessageCount = 0; + + public WebDavFolder(String name) { + String[] userParts; + String encodedName = new String(); + try { + encodedName = java.net.URLEncoder.encode(name, "UTF-8"); + } catch (UnsupportedEncodingException uee) { + Log.e(Email.LOG_TAG, "UnsupportedEncodingException URLEncoding folder name, skipping encoded"); + encodedName = name; + } + + encodedName = encodedName.replaceAll("\\+", "%20"); + this.mName = name; + userParts = WebDavStore.this.mUsername.split("/", 2); + + if (userParts.length > 1) { + this.mLocalUsername = userParts[1]; + } else { + this.mLocalUsername = WebDavStore.this.mUsername; + } + + + //this.mFolderUrl = WebDavStore.this.mUrl + "/Exchange/" + this.mLocalUsername + "/" + encodedName; + this.mFolderUrl = WebDavStore.this.mUrl + encodedName; + } + + public void setUrl(String url) { + if (url != null) { + this.mFolderUrl = url; + } + } + + @Override + public void open(OpenMode mode) throws MessagingException { + if (needAuth()) { + authenticate(); + } + + if (WebDavStore.this.mAuthCookies == null) { + return; + } + + this.mIsOpen = true; + } + + private int getMessageCount(boolean read, CookieStore authCookies) { + String isRead; + int messageCount = 0; + + DefaultHttpClient httpclient = new DefaultHttpClient(); + HttpGeneric httpmethod; + HttpResponse response; + HttpEntity responseEntity; + StringEntity bodyEntity; + String messageBody; + int statusCode; + + if (read) { + isRead = new String("True"); + } else { + isRead = new String("False"); + } + + httpclient.setCookieStore(authCookies); + + messageBody = getMessageCountXml(isRead); + + try { + bodyEntity = new StringEntity(messageBody); + bodyEntity.setContentType("text/xml"); + + httpmethod = new HttpGeneric(this.mFolderUrl); + httpmethod.setMethod("SEARCH"); + httpmethod.setEntity(bodyEntity); + httpmethod.setHeader("Brief", "t"); + + response = httpclient.execute(httpmethod); + statusCode = response.getStatusLine().getStatusCode(); + + if (statusCode < 200 || + statusCode > 300) { + throw new IOException("Error getting message count, status code was " + statusCode); + } + + responseEntity = response.getEntity(); + + if (responseEntity != null) { + try { + ParsedDataSet dataset = new ParsedDataSet(); + InputStream istream = responseEntity.getContent(); + SAXParserFactory spf = SAXParserFactory.newInstance(); + SAXParser sp = spf.newSAXParser(); + + XMLReader xr = sp.getXMLReader(); + WebDavHandler myHandler = new WebDavHandler(); + xr.setContentHandler(myHandler); + + xr.parse(new InputSource(istream)); + + dataset = myHandler.getDataSet(); + messageCount = dataset.getMessageCount(); + + istream.close(); + } catch (SAXException se) { + Log.e(Email.LOG_TAG, "SAXException in getMessageCount " + se); + } catch (ParserConfigurationException pce) { + Log.e(Email.LOG_TAG, "ParserConfigurationException in getMessageCount " + pce); + } + } + } catch (UnsupportedEncodingException uee) { + Log.e(Email.LOG_TAG, "UnsupportedEncodingException in getMessageCount() " + uee); + } catch (IOException ioe) { + Log.e(Email.LOG_TAG, "IOException in getMessageCount() " + ioe); + } + return messageCount; + } + + @Override + public int getMessageCount() throws MessagingException { + open(OpenMode.READ_WRITE); + this.mMessageCount = getMessageCount(true, WebDavStore.this.mAuthCookies); + + return this.mMessageCount; + } + + @Override + public int getUnreadMessageCount() throws MessagingException { + open(OpenMode.READ_WRITE); + this.mUnreadMessageCount = getMessageCount(false, WebDavStore.this.mAuthCookies); + + return this.mUnreadMessageCount; + } + + @Override + public boolean isOpen() { + return this.mIsOpen; + } + + @Override + public OpenMode getMode() throws MessagingException { + return OpenMode.READ_WRITE; + } + + @Override + public String getName() { + return this.mName; + } + + @Override + public boolean exists() { + return true; + } + + @Override + public void close(boolean expunge) throws MessagingException { + this.mMessageCount = 0; + this.mUnreadMessageCount = 0; + + this.mIsOpen = false; + } + + @Override + public boolean create(FolderType type) throws MessagingException { + return true; + } + + @Override + public void delete(boolean recursive) throws MessagingException { + throw new Error("WebDavFolder.delete() not implemeneted"); + } + + @Override + public Message getMessage(String uid) throws MessagingException { + return new WebDavMessage(uid, this); + } + + @Override + public Message[] getMessages(int start, int end, MessageRetrievalListener listener) + throws MessagingException { + DefaultHttpClient httpclient = new DefaultHttpClient(); + ArrayList messages = new ArrayList(); + String[] uids; + + String messageBody; + int prevStart = start; + + /** Reverse the message range since 0 index is newest */ + start = this.mMessageCount - end; + end = this.mMessageCount - prevStart; + + if (start < 0 || end < 0 || end < start) { + throw new MessagingException(String.format("Invalid message set %d %d", start, end)); + } + + /** Verify authentication */ + if (needAuth()) { + authenticate(); + } + + if (WebDavStore.this.mAuthenticated == false || + WebDavStore.this.mAuthCookies == null) { + return messages.toArray(new Message[] {}); + } + + /** Retrieve and parse the XML entity for our messages */ + httpclient.setCookieStore(WebDavStore.this.mAuthCookies); + messageBody = getMessagesXml(); + + try { + int status_code = -1; + StringEntity messageEntity = new StringEntity(messageBody); + HttpGeneric httpmethod = new HttpGeneric(this.mFolderUrl); + HttpResponse response; + HttpEntity entity; + + messageEntity.setContentType("text/xml"); + httpmethod.setMethod("SEARCH"); + httpmethod.setEntity(messageEntity); + httpmethod.setHeader("Brief", "t"); + httpmethod.setHeader("Range", "rows=" + start + "-" + end); + + response = httpclient.execute(httpmethod); + status_code = response.getStatusLine().getStatusCode(); + + if (status_code < 200 || + status_code > 300) { + throw new IOException("Error getting messages, returned HTTP Response code " + status_code); + } + + entity = response.getEntity(); + + if (entity != null) { + try { + InputStream istream = entity.getContent(); + SAXParserFactory spf = SAXParserFactory.newInstance(); + SAXParser sp = spf.newSAXParser(); + XMLReader xr = sp.getXMLReader(); + WebDavHandler myHandler = new WebDavHandler(); + ParsedDataSet dataset; + int uidsLength = 0; + int urlsLength = 0; + + xr.setContentHandler(myHandler); + xr.parse(new InputSource(istream)); + + dataset = myHandler.getDataSet(); + + uids = dataset.getUids(); + HashMap uidToUrl = dataset.getUidToUrl(); + uidsLength = uids.length; + + for (int i = 0; i < uidsLength; i++) { + if (listener != null) { + listener.messageStarted(uids[i], i, uidsLength); + } + WebDavMessage message = new WebDavMessage(uids[i], this); + message.setUrl(uidToUrl.get(uids[i])); + messages.add(message); + + if (listener != null) { + listener.messageFinished(message, i, uidsLength); + } + } + } catch (SAXException se) { + Log.e(Email.LOG_TAG, "SAXException in getMessages() " + se); + } catch (ParserConfigurationException pce) { + Log.e(Email.LOG_TAG, "ParserConfigurationException in getMessages() " + pce); + } + } + } catch (UnsupportedEncodingException uee) { + Log.e(Email.LOG_TAG, "UnsupportedEncodingException: " + uee); + } catch (IOException ioe) { + Log.e(Email.LOG_TAG, "IOException: " + ioe); + } + + return messages.toArray(new Message[] {}); + } + + @Override + public Message[] getMessages(MessageRetrievalListener listener) throws MessagingException { + return getMessages(null, listener); + } + + @Override + public Message[] getMessages(String[] uids, MessageRetrievalListener listener) throws MessagingException { + ArrayList messageList = new ArrayList(); + Message[] messages; + + if (uids == null || + uids.length == 0) { + return messageList.toArray(new Message[] {}); + } + + for (int i = 0, count = uids.length; i < count; i++) { + if (listener != null) { + listener.messageStarted(uids[i], i, count); + } + + WebDavMessage message = new WebDavMessage(uids[i], this); + messageList.add(message); + + if (listener != null) { + listener.messageFinished(message, i, count); + } + } + messages = messageList.toArray(new Message[] {}); + + return messages; + } + + private HashMap getMessageUrls(String[] uids) { + HashMap uidToUrl = new HashMap(); + DefaultHttpClient httpclient = new DefaultHttpClient(); + String messageBody; + + /** Verify authentication */ + if (needAuth()) { + authenticate(); + } + + if (WebDavStore.this.mAuthenticated == false || + WebDavStore.this.mAuthCookies == null) { + return uidToUrl; + } + + /** Retrieve and parse the XML entity for our messages */ + httpclient.setCookieStore(WebDavStore.this.mAuthCookies); + messageBody = getMessageUrlsXml(uids); + + try { + int status_code = -1; + StringEntity messageEntity = new StringEntity(messageBody); + HttpGeneric httpmethod = new HttpGeneric(this.mFolderUrl); + HttpResponse response; + HttpEntity entity; + + messageEntity.setContentType("text/xml"); + httpmethod.setMethod("SEARCH"); + httpmethod.setEntity(messageEntity); + httpmethod.setHeader("Brief", "t"); + + response = httpclient.execute(httpmethod); + status_code = response.getStatusLine().getStatusCode(); + + if (status_code < 200 || + status_code > 300) { + throw new IOException("Error getting messages, returned HTTP Response code " + status_code); + } + + entity = response.getEntity(); + + if (entity != null) { + try { + InputStream istream = entity.getContent(); + SAXParserFactory spf = SAXParserFactory.newInstance(); + SAXParser sp = spf.newSAXParser(); + XMLReader xr = sp.getXMLReader(); + WebDavHandler myHandler = new WebDavHandler(); + ParsedDataSet dataset; + int uidsLength = 0; + int urlsLength = 0; + + xr.setContentHandler(myHandler); + xr.parse(new InputSource(istream)); + + dataset = myHandler.getDataSet(); + uidToUrl = dataset.getUidToUrl(); + } catch (SAXException se) { + Log.e(Email.LOG_TAG, "SAXException in getMessages() " + se); + } catch (ParserConfigurationException pce) { + Log.e(Email.LOG_TAG, "ParserConfigurationException in getMessages() " + pce); + } + } + } catch (UnsupportedEncodingException uee) { + Log.e(Email.LOG_TAG, "UnsupportedEncodingException: " + uee); + } catch (IOException ioe) { + Log.e(Email.LOG_TAG, "IOException: " + ioe); + } + + return uidToUrl; + } + + @Override + public void fetch(Message[] messages, FetchProfile fp, MessageRetrievalListener listener) + throws MessagingException { + HashMap uidToReadStatus = new HashMap(); + + if (messages == null || + messages.length == 0) { + return; + } + + if (needAuth()) { + authenticate(); + } + + if (WebDavStore.this.mAuthenticated == false || + WebDavStore.this.mAuthCookies == null) { + return; + } + + /** + * Fetch message flag info for the array + */ + if (fp.contains(FetchProfile.Item.FLAGS)) { + fetchFlags(messages, listener); + } + + /** + * Fetch message envelope information for the array + */ + if (fp.contains(FetchProfile.Item.ENVELOPE)) { + fetchEnvelope(messages, listener); + } + + for (int i = 0, count = messages.length; i < count; i++) { + if (!(messages[i] instanceof WebDavMessage)) { + throw new MessagingException("WebDavStore fetch called with non-WebDavMessage"); + } + WebDavMessage wdMessage = (WebDavMessage) messages[i]; + + if (listener != null) { + listener.messageStarted(wdMessage.getUid(), i, count); + } + + /** + * Set the body to null if it's asking for the structure because + * we don't support it yet. + */ + if (fp.contains(FetchProfile.Item.STRUCTURE)) { + wdMessage.setBody(null); + } + + /** + * Message fetching that we can pull as a stream + */ + if (fp.contains(FetchProfile.Item.BODY) || + fp.contains(FetchProfile.Item.BODY_SANE)) { + + DefaultHttpClient httpclient = new DefaultHttpClient(); + InputStream istream = null; + InputStream resultStream = null; + HttpGet httpget; + HttpEntity entity; + HttpResponse response; + int statusCode = 0; + + try { + httpclient.setCookieStore(WebDavStore.this.mAuthCookies); + + /** + * If fetch is called outside of the initial list (ie, a locally stored + * stored message), it may not have a URL associated. Verify and fix that + */ + if (wdMessage.getUrl().equals("")) { + wdMessage.setUrl(getMessageUrls(new String[] {wdMessage.getUid()}).get(wdMessage.getUid())); + if (wdMessage.getUrl().equals("")) { + throw new MessagingException("Unable to get URL for message"); + } + } + + httpget = new HttpGet(new URI(wdMessage.getUrl())); + httpget.setHeader("translate", "f"); + + response = httpclient.execute(httpget); + + statusCode = response.getStatusLine().getStatusCode(); + + if (statusCode < 200 || + statusCode > 300) { + throw new IOException("Status Code in invalid range"); + } + + entity = response.getEntity(); + if (entity != null) { + StringBuffer buffer = new StringBuffer(); + String tempText = new String(); + String resultText = new String(); + String bodyBoundary = ""; + BufferedReader reader; + int totalLines = FETCH_BODY_SANE_SUGGESTED_SIZE / 76; + int lines = 0; + + istream = entity.getContent(); + + if (fp.contains(FetchProfile.Item.BODY_SANE)) { + reader = new BufferedReader(new InputStreamReader(istream), 8192); + + while ((tempText = reader.readLine()) != null && + (lines < totalLines)) { + buffer.append(tempText+"\r\n"); + lines++; + } + + istream.close(); + resultText = buffer.toString(); + istream = new ByteArrayInputStream(resultText.getBytes("UTF-8")); + } + + wdMessage.parse(istream); + + } + + } catch (IllegalArgumentException iae) { + Log.e(Email.LOG_TAG, "IllegalArgumentException caught " + iae); + } catch (URISyntaxException use) { + Log.e(Email.LOG_TAG, "URISyntaxException caught " + use); + } catch (IOException ioe) { + Log.e(Email.LOG_TAG, "Non-success response code loading message, response code was " + statusCode); + } + } + + if (listener != null) { + listener.messageFinished(wdMessage, i, count); + } + } + } + + /** + * Fetches and sets the message flags for the supplied messages. + * The idea is to have this be recursive so that we do a series of medium calls + * instead of one large massive call or a large number of smaller calls. + */ + private void fetchFlags(Message[] startMessages, MessageRetrievalListener listener) throws MessagingException { + HashMap uidToReadStatus = new HashMap(); + DefaultHttpClient httpclient = new DefaultHttpClient(); + String messageBody = new String(); + Message[] messages = new Message[20]; + String[] uids; + + + if (startMessages == null || + startMessages.length == 0) { + return; + } + + if (startMessages.length > 20) { + Message[] newMessages = new Message[startMessages.length - 20]; + for (int i = 0, count = startMessages.length; i < count; i++) { + if (i < 20) { + messages[i] = startMessages[i]; + } else { + newMessages[i - 20] = startMessages[i]; + } + } + + fetchFlags(newMessages, listener); + } else { + messages = startMessages; + } + + uids = new String[messages.length]; + + for (int i = 0, count = messages.length; i < count; i++) { + uids[i] = messages[i].getUid(); + } + + httpclient.setCookieStore(WebDavStore.this.mAuthCookies); + messageBody = getMessageFlagsXml(uids); + + try { + int status_code = -1; + StringEntity messageEntity = new StringEntity(messageBody); + HttpGeneric httpmethod = new HttpGeneric(this.mFolderUrl); + HttpResponse response; + HttpEntity entity; + + messageEntity.setContentType("text/xml"); + httpmethod.setMethod("SEARCH"); + httpmethod.setEntity(messageEntity); + httpmethod.setHeader("Brief", "t"); + + response = httpclient.execute(httpmethod); + status_code = response.getStatusLine().getStatusCode(); + + if (status_code < 200 || + status_code > 300) { + throw new IOException("Error getting message flags, returned HTTP Response code " + status_code); + } + + entity = response.getEntity(); + + if (entity != null) { + try { + InputStream istream = entity.getContent(); + SAXParserFactory spf = SAXParserFactory.newInstance(); + SAXParser sp = spf.newSAXParser(); + XMLReader xr = sp.getXMLReader(); + WebDavHandler myHandler = new WebDavHandler(); + ParsedDataSet dataset; + + xr.setContentHandler(myHandler); + xr.parse(new InputSource(istream)); + + dataset = myHandler.getDataSet(); + uidToReadStatus = dataset.getUidToRead(); + } catch (SAXException se) { + Log.e(Email.LOG_TAG, "SAXException in fetch() " + se); + } catch (ParserConfigurationException pce) { + Log.e(Email.LOG_TAG, "ParserConfigurationException in fetch() " + pce); + } + } + } catch (UnsupportedEncodingException uee) { + Log.e(Email.LOG_TAG, "UnsupportedEncodingException: " + uee); + } catch (IOException ioe) { + Log.e(Email.LOG_TAG, "IOException: " + ioe); + } + + for (int i = 0, count = messages.length; i < count; i++) { + if (!(messages[i] instanceof WebDavMessage)) { + throw new MessagingException("WebDavStore fetch called with non-WebDavMessage"); + } + WebDavMessage wdMessage = (WebDavMessage) messages[i]; + + if (listener != null) { + listener.messageStarted(messages[i].getUid(), i, count); + } + + wdMessage.setFlagInternal(Flag.SEEN, uidToReadStatus.get(wdMessage.getUid())); + + if (listener != null) { + listener.messageFinished(messages[i], i, count); + } + } + } + + /** + * Fetches and parses the message envelopes for the supplied messages. + * The idea is to have this be recursive so that we do a series of medium calls + * instead of one large massive call or a large number of smaller calls. + * Call it a happy balance + */ + private void fetchEnvelope(Message[] startMessages, MessageRetrievalListener listener) throws MessagingException { + HashMap envelopes = new HashMap(); + Message[] messages = new Message[10]; + if (startMessages == null || + startMessages.length == 0) { + return; + } + + if (startMessages.length > 10) { + Message[] newMessages = new Message[startMessages.length - 10]; + for (int i = 0, count = startMessages.length; i < count; i++) { + if (i < 10) { + messages[i] = startMessages[i]; + } else { + newMessages[i - 10] = startMessages[i]; + } + } + /** System.arraycopy(startMessages, 0, messages, 0, 10); + System.arraycopy(startMessages, 10, newMessages, 0, startMessages.length - 10);*/ + fetchEnvelope(newMessages, listener); + } else { + messages = startMessages; + } + + DefaultHttpClient httpclient = new DefaultHttpClient(); + String messageBody = new String(); + String[] uids = new String[messages.length]; + + for (int i = 0, count = messages.length; i < count; i++) { + uids[i] = messages[i].getUid(); + } + + httpclient.setCookieStore(WebDavStore.this.mAuthCookies); + messageBody = getMessageEnvelopeXml(uids); + + try { + int status_code = -1; + StringEntity messageEntity = new StringEntity(messageBody); + HttpGeneric httpmethod = new HttpGeneric(this.mFolderUrl); + HttpResponse response; + HttpEntity entity; + + messageEntity.setContentType("text/xml"); + httpmethod.setMethod("SEARCH"); + httpmethod.setEntity(messageEntity); + httpmethod.setHeader("Brief", "t"); + + response = httpclient.execute(httpmethod); + status_code = response.getStatusLine().getStatusCode(); + + if (status_code < 200 || + status_code > 300) { + throw new IOException("Error getting message flags, returned HTTP Response code " + status_code); + } + + entity = response.getEntity(); + + if (entity != null) { + try { + InputStream istream = entity.getContent(); + SAXParserFactory spf = SAXParserFactory.newInstance(); + SAXParser sp = spf.newSAXParser(); + XMLReader xr = sp.getXMLReader(); + WebDavHandler myHandler = new WebDavHandler(); + ParsedDataSet dataset; + + xr.setContentHandler(myHandler); + xr.parse(new InputSource(istream)); + + dataset = myHandler.getDataSet(); + envelopes = dataset.getMessageEnvelopes(); + } catch (SAXException se) { + Log.e(Email.LOG_TAG, "SAXException in fetch() " + se); + } catch (ParserConfigurationException pce) { + Log.e(Email.LOG_TAG, "ParserConfigurationException in fetch() " + pce); + } + } + } catch (UnsupportedEncodingException uee) { + Log.e(Email.LOG_TAG, "UnsupportedEncodingException: " + uee); + } catch (IOException ioe) { + Log.e(Email.LOG_TAG, "IOException: " + ioe); + } + + int count = messages.length; + for (int i = messages.length - 1; i >= 0; i--) { + /* for (int i = 0, count = messages.length; i < count; i++) {*/ + if (!(messages[i] instanceof WebDavMessage)) { + throw new MessagingException("WebDavStore fetch called with non-WebDavMessage"); + } + WebDavMessage wdMessage = (WebDavMessage) messages[i]; + + if (listener != null) { + listener.messageStarted(messages[i].getUid(), i, count); + } + + wdMessage.setNewHeaders(envelopes.get(wdMessage.getUid())); + wdMessage.setFlagInternal(Flag.SEEN, envelopes.get(wdMessage.getUid()).getReadStatus()); + + if (listener != null) { + listener.messageFinished(messages[i], i, count); + } + } + } + + @Override + public Flag[] getPermanentFlags() throws MessagingException { + return PERMANENT_FLAGS; + } + + @Override + public void setFlags(Message[] messages, Flag[] flags, boolean value) + throws MessagingException { + String[] uids = new String[messages.length]; + + if (needAuth()) { + authenticate(); + } + + if (WebDavStore.this.mAuthenticated == false || + WebDavStore.this.mAuthCookies == null) { + return; + } + + for (int i = 0, count = messages.length; i < count; i++) { + uids[i] = messages[i].getUid(); + } + + for (int i = 0, count = flags.length; i < count; i++) { + Flag flag = flags[i]; + + if (flag == Flag.SEEN) { + markServerMessagesRead(uids); + } else if (flag == Flag.DELETED) { + deleteServerMessages(uids); + } + } + } + + private void markServerMessagesRead(String[] uids) throws MessagingException { + DefaultHttpClient httpclient = new DefaultHttpClient(); + String messageBody = new String(); + HashMap uidToUrl = getMessageUrls(uids); + String[] urls = new String[uids.length]; + + for (int i = 0, count = uids.length; i < count; i++) { + urls[i] = uidToUrl.get(uids[i]); + } + + httpclient.setCookieStore(WebDavStore.this.mAuthCookies); + messageBody = getMarkMessagesReadXml(urls); + + try { + int status_code = -1; + StringEntity messageEntity = new StringEntity(messageBody); + HttpGeneric httpmethod = new HttpGeneric(this.mFolderUrl + "/"); + HttpResponse response; + HttpEntity entity; + + messageEntity.setContentType("text/xml"); + httpmethod.setMethod("BPROPPATCH"); + httpmethod.setEntity(messageEntity); + httpmethod.setHeader("Brief", "t"); + httpmethod.setHeader("If-Match", "*"); + + response = httpclient.execute(httpmethod); + status_code = response.getStatusLine().getStatusCode(); + + if (status_code < 200 || + status_code > 300) { + throw new IOException("Error marking messages as read, returned HTTP Response code " + status_code); + } + + entity = response.getEntity(); + } catch (UnsupportedEncodingException uee) { + Log.e(Email.LOG_TAG, "UnsupportedEncodingException: " + uee); + } catch (IOException ioe) { + Log.e(Email.LOG_TAG, "IOException: " + ioe); + } + } + + private void deleteServerMessages(String[] uids) throws MessagingException { + DefaultHttpClient httpclient = new DefaultHttpClient(); + HashMap uidToUrl = getMessageUrls(uids); + String[] urls = new String[uids.length]; + + httpclient.setCookieStore(WebDavStore.this.mAuthCookies); + + for (int i = 0, count = uids.length; i < count; i++) { + try { + int status_code = -1; + HttpGeneric httpmethod = new HttpGeneric(uidToUrl.get(uids[i])); + HttpResponse response; + HttpEntity entity; + + httpmethod.setMethod("DELETE"); + httpmethod.setHeader("Brief", "t"); + + response = httpclient.execute(httpmethod); + status_code = response.getStatusLine().getStatusCode(); + + if (status_code < 200 || + status_code > 300) { + throw new IOException("Error deleting message url: "+urls[i]+" \nResponse Code: "+status_code); + } + } catch (UnsupportedEncodingException uee) { + Log.e(Email.LOG_TAG, "UnsupportedEncodingException: " + uee); + } catch (IOException ioe) { + Log.e(Email.LOG_TAG, "IOException: " + ioe); + } + } + } + + @Override + public void appendMessages(Message[] messages) throws MessagingException { + appendMessages(messages, false); + } + + public void appendMessages(Message[] messages, boolean copy) throws MessagingException { + + } + + @Override + public void copyMessages(Message[] msgs, Folder folder) throws MessagingException { + + } + + @Override + public Message[] expunge() throws MessagingException { + return null; + } + + @Override + public boolean equals(Object o) { + return false; + } + } + + /** + * A WebDav Message + */ + class WebDavMessage extends MimeMessage { + private String mUrl = new String(); + + WebDavMessage(String uid, Folder folder) throws MessagingException { + this.mUid = uid; + this.mFolder = folder; + } + + public void setUrl(String url) { + String[] urlParts = url.split("/"); + int length = urlParts.length; + String end = urlParts[length - 1]; + + this.mUrl = new String(); + url = new String(); + + /** + * We have to decode, then encode the URL because Exchange likes to + * not properly encode all characters + */ + try { + end = java.net.URLDecoder.decode(end, "UTF-8"); + end = java.net.URLEncoder.encode(end, "UTF-8"); + end = end.replaceAll("\\+", "%20"); + } catch (UnsupportedEncodingException uee) { + Log.e(Email.LOG_TAG, "UnsupportedEncodingException caught in setUrl"); + } catch (IllegalArgumentException iae) { + Log.e(Email.LOG_TAG, "IllegalArgumentException caught in setUrl"); + } + + for (int i = 0; i < length - 1; i++) { + if (i != 0) { + url = url + "/" + urlParts[i]; + } else { + url = urlParts[i]; + } + } + + url = url + "/" + end; + + this.mUrl = url; + } + + public String getUrl() { + return this.mUrl; + } + + public void setSize(int size) { + this.mSize = size; + } + + public void parse(InputStream in) throws IOException, MessagingException { + super.parse(in); + } + + public void setFlagInternal(Flag flag, boolean set) throws MessagingException { + super.setFlag(flag, set); + } + + public void setNewHeaders(ParsedMessageEnvelope envelope) throws MessagingException { + String[] headers = envelope.getHeaderList(); + HashMap messageHeaders = envelope.getMessageHeaders(); + + for (int i = 0, count = headers.length; i < count; i++) { + if (headers[i].equals("Content-Length")) { + this.setSize(new Integer(messageHeaders.get(headers[i])).intValue()); + } + this.addHeader(headers[i], messageHeaders.get(headers[i])); + } + } + + @Override + public void setFlag(Flag flag, boolean set) throws MessagingException { + super.setFlag(flag, set); + mFolder.setFlags(new Message[] { this }, new Flag[] { flag }, set); + } + } + + /** + * XML Parsing Handler + * Can handle all XML handling needs + */ + public class WebDavHandler extends DefaultHandler { + private ParsedDataSet mDataSet = new ParsedDataSet(); + private Stack mOpenTags = new Stack(); + + public ParsedDataSet getDataSet() { + return this.mDataSet; + } + + @Override + public void startDocument() throws SAXException { + this.mDataSet = new ParsedDataSet(); + } + + @Override + public void endDocument() throws SAXException { + /* Do nothing */ + } + + @Override + public void startElement(String namespaceURI, String localName, + String qName, Attributes atts) throws SAXException { + mOpenTags.push(localName); + } + + @Override + public void endElement(String namespaceURI, String localName, String qName) { + mOpenTags.pop(); + + /** Reset the hash temp variables */ + if (localName.equals("response")) { + this.mDataSet.addEnvelope(); + this.mDataSet.clearTempData(); + } + } + + @Override + public void characters(char ch[], int start, int length) { + String value = new String(ch, start, length); + mDataSet.addValue(value, mOpenTags.peek()); + } + } + + /** + * Data set for a single E-Mail message's required headers (the envelope) + * Only provides accessor methods to the stored data. All processing should be + * done elsewhere. This is done rather than having multiple hashmaps + * associating UIDs to values + */ + public class ParsedMessageEnvelope { + private boolean mReadStatus = false; + private String mUid = new String(); + private HashMap mMessageHeaders = new HashMap(); + private ArrayList mHeaders = new ArrayList(); + + public void addHeader(String field, String value) { + this.mMessageHeaders.put(field, value); + this.mHeaders.add(field); + } + + public HashMap getMessageHeaders() { + return this.mMessageHeaders; + } + + public String[] getHeaderList() { + return this.mHeaders.toArray(new String[] {}); + } + + public void setReadStatus(boolean status) { + this.mReadStatus = status; + } + + public boolean getReadStatus() { + return this.mReadStatus; + } + + public void setUid(String uid) { + if (uid != null) { + this.mUid = uid; + } + } + + public String getUid() { + return this.mUid; + } + } + + + /** + * Data set for handling all XML Parses + */ + public class ParsedDataSet { + private ArrayList mHrefs = new ArrayList(); + private ArrayList mUids = new ArrayList(); + private ArrayList mReads = new ArrayList(); + private HashMap mUidUrls = new HashMap(); + private HashMap mUidRead = new HashMap(); + private HashMap mEnvelopes = new HashMap(); + private int mMessageCount = 0; + private String mTempUid = ""; + private String mTempUrl = ""; + private String mFrom = ""; + private String mTo = ""; + private String mCc = ""; + private String mReceived = ""; + private Boolean mTempRead; + private ParsedMessageEnvelope mEnvelope = new ParsedMessageEnvelope(); + private boolean mRead; + + public void addValue(String value, String tagName) { + if (tagName.equals("href")) { + this.mHrefs.add(value); + this.mTempUrl = value; + } else if (tagName.equals("visiblecount")) { + this.mMessageCount = new Integer(value).intValue(); + } else if (tagName.equals("uid")) { + this.mUids.add(value); + this.mEnvelope.setUid(value); + this.mTempUid = value; + } else if (tagName.equals("read")) { + if (value.equals("0")) { + this.mReads.add(false); + this.mEnvelope.setReadStatus(false); + this.mTempRead = false; + } else { + this.mReads.add(true); + this.mEnvelope.setReadStatus(true); + this.mTempRead = true; + } + } else if (tagName.equals("received")) { + this.mReceived = this.mReceived + value; + } else if (tagName.equals("mime-version")) { + this.mEnvelope.addHeader("MIME-Version", value); + } else if (tagName.equals("content-type")) { + this.mEnvelope.addHeader("Content-Type", value); + } else if (tagName.equals("subject")) { + this.mEnvelope.addHeader("Subject", value); + } else if (tagName.equals("date")) { + value = value.replaceAll("T", " "); + String[] valueBreak = value.split("\\."); + value = valueBreak[0]; + + DateFormat dfInput = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + DateFormat dfOutput = new SimpleDateFormat("EEE, d MMM yy HH:mm:ss Z"); + String tempDate = ""; + + try { + Date parsedDate = dfInput.parse(value); + tempDate = dfOutput.format(parsedDate); + } catch (java.text.ParseException pe) { + Log.e(Email.LOG_TAG, "Error parsing date: "+ pe); + } + + this.mEnvelope.addHeader("Date", tempDate); + } else if (tagName.equals("thread-topic")) { + this.mEnvelope.addHeader("Thread-Topic", value); + } else if (tagName.equals("thread-index")) { + this.mEnvelope.addHeader("Thread-Index", value); + } else if (tagName.equals("from")) { + this.mFrom = this.mFrom + value; + } else if (tagName.equals("to")) { + this.mTo = this.mTo + value; + } else if (tagName.equals("in-reply-to")) { + this.mEnvelope.addHeader("In-Reply-To", value); + } else if (tagName.equals("return-path")) { + this.mEnvelope.addHeader("Return-Path", value); + } else if (tagName.equals("cc")) { + this.mCc = this.mCc + value; + } else if (tagName.equals("references")) { + this.mEnvelope.addHeader("References", value); + } else if (tagName.equals("getcontentlength")) { + this.mEnvelope.addHeader("Content-Length", value); + } + + + if (!this.mTempUid.equals("") && + this.mTempRead != null) { + if (this.mTempRead) { + this.mUidRead.put(this.mTempUid, true); + } else { + this.mUidRead.put(this.mTempUid, false); + } + } + + if (!this.mTempUid.equals("") && + !this.mTempUrl.equals("")) { + this.mUidUrls.put(this.mTempUid, this.mTempUrl); + } + } + + /** + * Clears the temp variables + */ + public void clearTempData() { + this.mTempUid = ""; + this.mTempUrl = ""; + this.mFrom = ""; + this.mEnvelope = new ParsedMessageEnvelope(); + } + + public void addEnvelope() { + this.mEnvelope.addHeader("From", this.mFrom); + this.mEnvelope.addHeader("To", this.mTo); + this.mEnvelope.addHeader("Cc", this.mCc); + this.mEnvelope.addHeader("Received", this.mReceived); + this.mEnvelopes.put(this.mEnvelope.getUid(), this.mEnvelope); + } + + /** + * Returns an array of the set of message envelope objects + */ + public HashMap getMessageEnvelopes() { + return this.mEnvelopes; + } + + /** + * Returns the Uid to Url hashmap + */ + public HashMap getUidToUrl() { + return this.mUidUrls; + } + + /** + * Returns the Uid to Read hashmap + */ + public HashMap getUidToRead() { + return this.mUidRead; + } + + /** + * Get all stored Hrefs + */ + public String[] getHrefs() { + return this.mHrefs.toArray(new String[] {}); + } + + /** + * Get the first stored Href + */ + public String getHref() { + String[] hrefs = this.mHrefs.toArray(new String[] {}); + return hrefs[0]; + } + + /** + * Get all stored Uids + */ + public String[] getUids() { + return this.mUids.toArray(new String[] {}); + } + + /** + * Get the first stored Uid + */ + public String getUid() { + String[] uids = this.mUids.toArray(new String[] {}); + return uids[0]; + } + + /** + * Get message count + */ + public int getMessageCount() { + return this.mMessageCount; + } + + /** + * Get all stored read statuses + */ + public Boolean[] getReadArray() { + Boolean[] readStatus = this.mReads.toArray(new Boolean[] {}); + return readStatus; + } + + /** + * Get the first stored read status + */ + public boolean getRead() { + return this.mRead; + } + } + + /** + * New HTTP Method that allows changing of the method and generic handling + * Needed for WebDAV custom methods such as SEARCH and PROPFIND + */ + public class HttpGeneric extends HttpEntityEnclosingRequestBase { + public String METHOD_NAME = "POST"; + + public HttpGeneric() { + super(); + } + + public HttpGeneric(final URI uri) { + super(); + setURI(uri); + } + + /** + * @throws IllegalArgumentException if the uri is invalid. + */ + public HttpGeneric(final String uri) { + super(); + + String[] urlParts = uri.split("/"); + int length = urlParts.length; + String end = urlParts[length - 1]; + String url = new String(); + + /** + * We have to decode, then encode the URL because Exchange likes to + * not properly encode all characters + */ + try { + end = java.net.URLDecoder.decode(end, "UTF-8"); + end = java.net.URLEncoder.encode(end, "UTF-8"); + end = end.replaceAll("\\+", "%20"); + } catch (UnsupportedEncodingException uee) { + Log.e(Email.LOG_TAG, "UnsupportedEncodingException caught in HttpGeneric(String uri)"); + } catch (IllegalArgumentException iae) { + Log.e(Email.LOG_TAG, "IllegalArgumentException caught in HttpGeneric(String uri)"); + } + + for (int i = 0; i < length - 1; i++) { + if (i != 0) { + url = url + "/" + urlParts[i]; + } else { + url = urlParts[i]; + } + } + + url = url + "/" + end; + + setURI(URI.create(url)); + } + + @Override + public String getMethod() { + return METHOD_NAME; + } + + public void setMethod(String method) { + if (method != null) { + METHOD_NAME = method; + } + } + } +} diff --git a/src/com/android/email/mail/transport/CountingOutputStream.java b/src/com/android/email/mail/transport/CountingOutputStream.java new file mode 100644 index 000000000..424aede5c --- /dev/null +++ b/src/com/android/email/mail/transport/CountingOutputStream.java @@ -0,0 +1,24 @@ +package com.android.email.mail.transport; + +import java.io.IOException; +import java.io.OutputStream; + +/** + * A simple OutputStream that does nothing but count how many bytes are written to it and + * makes that count available to callers. + */ +public class CountingOutputStream extends OutputStream { + private long mCount; + + public CountingOutputStream() { + } + + public long getCount() { + return mCount; + } + + @Override + public void write(int oneByte) throws IOException { + mCount++; + } +} diff --git a/src/com/android/email/mail/transport/EOLConvertingOutputStream.java b/src/com/android/email/mail/transport/EOLConvertingOutputStream.java new file mode 100644 index 000000000..719959a80 --- /dev/null +++ b/src/com/android/email/mail/transport/EOLConvertingOutputStream.java @@ -0,0 +1,33 @@ +package com.android.email.mail.transport; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +public class EOLConvertingOutputStream extends FilterOutputStream { + int lastChar; + + public EOLConvertingOutputStream(OutputStream out) { + super(out); + } + + @Override + public void write(int oneByte) throws IOException { + if (oneByte == '\n') { + if (lastChar != '\r') { + super.write('\r'); + } + } + super.write(oneByte); + lastChar = oneByte; + } + + @Override + public void flush() throws IOException { + if (lastChar == '\r') { + super.write('\n'); + lastChar = '\n'; + } + super.flush(); + } +} diff --git a/src/com/android/email/mail/transport/SmtpTransport.java b/src/com/android/email/mail/transport/SmtpTransport.java new file mode 100644 index 000000000..e5e704889 --- /dev/null +++ b/src/com/android/email/mail/transport/SmtpTransport.java @@ -0,0 +1,376 @@ + +package com.android.email.mail.transport; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.SSLException; + +import android.util.Config; +import android.util.Log; + +import com.android.email.Email; +import com.android.email.PeekableInputStream; +import com.android.email.codec.binary.Base64; +import com.android.email.mail.Address; +import com.android.email.mail.AuthenticationFailedException; +import com.android.email.mail.Message; +import com.android.email.mail.MessagingException; +import com.android.email.mail.Transport; +import com.android.email.mail.CertificateValidationException; +import com.android.email.mail.Message.RecipientType; +import com.android.email.mail.store.TrustManagerFactory; + +public class SmtpTransport extends Transport { + public static final int CONNECTION_SECURITY_NONE = 0; + + public static final int CONNECTION_SECURITY_TLS_OPTIONAL = 1; + + public static final int CONNECTION_SECURITY_TLS_REQUIRED = 2; + + public static final int CONNECTION_SECURITY_SSL_REQUIRED = 3; + + public static final int CONNECTION_SECURITY_SSL_OPTIONAL = 4; + + String mHost; + + int mPort; + + String mUsername; + + String mPassword; + + int mConnectionSecurity; + + boolean mSecure; + + Socket mSocket; + + PeekableInputStream mIn; + + OutputStream mOut; + + /** + * smtp://user:password@server:port CONNECTION_SECURITY_NONE + * smtp+tls://user:password@server:port CONNECTION_SECURITY_TLS_OPTIONAL + * smtp+tls+://user:password@server:port CONNECTION_SECURITY_TLS_REQUIRED + * smtp+ssl+://user:password@server:port CONNECTION_SECURITY_SSL_REQUIRED + * smtp+ssl://user:password@server:port CONNECTION_SECURITY_SSL_OPTIONAL + * + * @param _uri + */ + public SmtpTransport(String _uri) throws MessagingException { + URI uri; + try { + uri = new URI(_uri); + } catch (URISyntaxException use) { + throw new MessagingException("Invalid SmtpTransport URI", use); + } + + String scheme = uri.getScheme(); + if (scheme.equals("smtp")) { + mConnectionSecurity = CONNECTION_SECURITY_NONE; + mPort = 25; + } else if (scheme.equals("smtp+tls")) { + mConnectionSecurity = CONNECTION_SECURITY_TLS_OPTIONAL; + mPort = 25; + } else if (scheme.equals("smtp+tls+")) { + mConnectionSecurity = CONNECTION_SECURITY_TLS_REQUIRED; + mPort = 25; + } else if (scheme.equals("smtp+ssl+")) { + mConnectionSecurity = CONNECTION_SECURITY_SSL_REQUIRED; + mPort = 465; + } else if (scheme.equals("smtp+ssl")) { + mConnectionSecurity = CONNECTION_SECURITY_SSL_OPTIONAL; + mPort = 465; + } else { + throw new MessagingException("Unsupported protocol"); + } + + mHost = uri.getHost(); + + if (uri.getPort() != -1) { + mPort = uri.getPort(); + } + + if (uri.getUserInfo() != null) { + String[] userInfoParts = uri.getUserInfo().split(":", 2); + mUsername = userInfoParts[0]; + if (userInfoParts.length > 1) { + mPassword = userInfoParts[1]; + } + } + } + + public void open() throws MessagingException { + try { + SocketAddress socketAddress = new InetSocketAddress(mHost, mPort); + if (mConnectionSecurity == CONNECTION_SECURITY_SSL_REQUIRED || + mConnectionSecurity == CONNECTION_SECURITY_SSL_OPTIONAL) { + SSLContext sslContext = SSLContext.getInstance("TLS"); + boolean secure = mConnectionSecurity == CONNECTION_SECURITY_SSL_REQUIRED; + sslContext.init(null, new TrustManager[] { + TrustManagerFactory.get(mHost, secure) + }, new SecureRandom()); + mSocket = sslContext.getSocketFactory().createSocket(); + mSocket.connect(socketAddress, SOCKET_CONNECT_TIMEOUT); + mSecure = true; + } else { + mSocket = new Socket(); + mSocket.connect(socketAddress, SOCKET_CONNECT_TIMEOUT); + } + + mIn = new PeekableInputStream(new BufferedInputStream(mSocket.getInputStream(), 1024)); + mOut = mSocket.getOutputStream(); + + // Eat the banner + executeSimpleCommand(null); + + String localHost = "localhost.localdomain"; + try { + InetAddress localAddress = InetAddress.getLocalHost(); + if (! localAddress.isLoopbackAddress()) { + // The loopback address will resolve to 'localhost' + // some mail servers only accept qualified hostnames, so make sure + // never to override "localhost.localdomain" with "localhost" + // TODO - this is a hack. but a better hack than what was there before + localHost = localAddress.getHostName(); + } + } catch (Exception e) { + if (Config.LOGD) { + if (Email.DEBUG) { + Log.d(Email.LOG_TAG, "Unable to look up localhost"); + } + } + } + + String result = executeSimpleCommand("EHLO " + localHost); + + /* + * TODO may need to add code to fall back to HELO I switched it from + * using HELO on non STARTTLS connections because of AOL's mail + * server. It won't let you use AUTH without EHLO. + * We should really be paying more attention to the capabilities + * and only attempting auth if it's available, and warning the user + * if not. + */ + if (mConnectionSecurity == CONNECTION_SECURITY_TLS_OPTIONAL + || mConnectionSecurity == CONNECTION_SECURITY_TLS_REQUIRED) { + if (result.contains("-STARTTLS")) { + executeSimpleCommand("STARTTLS"); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + boolean secure = mConnectionSecurity == CONNECTION_SECURITY_TLS_REQUIRED; + sslContext.init(null, new TrustManager[] { + TrustManagerFactory.get(mHost, secure) + }, new SecureRandom()); + mSocket = sslContext.getSocketFactory().createSocket(mSocket, mHost, mPort, + true); + mIn = new PeekableInputStream(new BufferedInputStream(mSocket.getInputStream(), + 1024)); + mOut = mSocket.getOutputStream(); + mSecure = true; + /* + * Now resend the EHLO. Required by RFC2487 Sec. 5.2, and more specifically, + * Exim. + */ + result = executeSimpleCommand("EHLO " + localHost); + } else if (mConnectionSecurity == CONNECTION_SECURITY_TLS_REQUIRED) { + throw new MessagingException("TLS not supported but required"); + } + } + + /* + * result contains the results of the EHLO in concatenated form + */ + boolean authLoginSupported = result.matches(".*AUTH.*LOGIN.*$"); + boolean authPlainSupported = result.matches(".*AUTH.*PLAIN.*$"); + + if (mUsername != null && mUsername.length() > 0 && mPassword != null + && mPassword.length() > 0) { + if (authPlainSupported) { + saslAuthPlain(mUsername, mPassword); + } + else if (authLoginSupported) { + saslAuthLogin(mUsername, mPassword); + } + else { + throw new MessagingException("No valid authentication mechanism found."); + } + } + } catch (SSLException e) { + throw new CertificateValidationException(e.getMessage(), e); + } catch (GeneralSecurityException gse) { + throw new MessagingException( + "Unable to open connection to SMTP server due to security error.", gse); + } catch (IOException ioe) { + throw new MessagingException("Unable to open connection to SMTP server.", ioe); + } + } + + public void sendMessage(Message message) throws MessagingException { + close(); + open(); + Address[] from = message.getFrom(); + + try { + executeSimpleCommand("MAIL FROM: " + "<" + from[0].getAddress() + ">"); + for (Address address : message.getRecipients(RecipientType.TO)) { + executeSimpleCommand("RCPT TO: " + "<" + address.getAddress() + ">"); + } + for (Address address : message.getRecipients(RecipientType.CC)) { + executeSimpleCommand("RCPT TO: " + "<" + address.getAddress() + ">"); + } + for (Address address : message.getRecipients(RecipientType.BCC)) { + executeSimpleCommand("RCPT TO: " + "<" + address.getAddress() + ">"); + } + message.setRecipients(RecipientType.BCC, null); + executeSimpleCommand("DATA"); + // TODO byte stuffing + message.writeTo( + new EOLConvertingOutputStream( + new BufferedOutputStream(mOut, 1024))); + executeSimpleCommand("\r\n."); + } catch (IOException ioe) { + throw new MessagingException("Unable to send message", ioe); + } + } + + public void close() { + try { + mIn.close(); + } catch (Exception e) { + + } + try { + mOut.close(); + } catch (Exception e) { + + } + try { + mSocket.close(); + } catch (Exception e) { + + } + mIn = null; + mOut = null; + mSocket = null; + } + + private String readLine() throws IOException { + StringBuffer sb = new StringBuffer(); + int d; + while ((d = mIn.read()) != -1) { + if (((char)d) == '\r') { + continue; + } else if (((char)d) == '\n') { + break; + } else { + sb.append((char)d); + } + } + String ret = sb.toString(); + if (Config.LOGD) { + if (Email.DEBUG) { + Log.d(Email.LOG_TAG, "<<< " + ret); + } + } + return ret; + } + + private void writeLine(String s) throws IOException { + if (Config.LOGD) { + if (Email.DEBUG) { + Log.d(Email.LOG_TAG, ">>> " + s); + } + } + mOut.write(s.getBytes()); + mOut.write('\r'); + mOut.write('\n'); + mOut.flush(); + } + + private String executeSimpleCommand(String command) throws IOException, MessagingException { + if (command != null) { + writeLine(command); + } + + String line = readLine(); + + String result = line; + + while (line.length() >= 4 && line.charAt(3) == '-') { + line = readLine(); + result += line.substring(3); + } + + char c = result.charAt(0); + if ((c == '4') || (c == '5')) { + throw new MessagingException(result); + } + + return result; + } + + +// C: AUTH LOGIN +// S: 334 VXNlcm5hbWU6 +// C: d2VsZG9u +// S: 334 UGFzc3dvcmQ6 +// C: dzNsZDBu +// S: 235 2.0.0 OK Authenticated +// +// Lines 2-5 of the conversation contain base64-encoded information. The same conversation, with base64 strings decoded, reads: +// +// +// C: AUTH LOGIN +// S: 334 Username: +// C: weldon +// S: 334 Password: +// C: w3ld0n +// S: 235 2.0.0 OK Authenticated + + private void saslAuthLogin(String username, String password) throws MessagingException, + AuthenticationFailedException, IOException { + try { + executeSimpleCommand("AUTH LOGIN"); + executeSimpleCommand(new String(Base64.encodeBase64(username.getBytes()))); + executeSimpleCommand(new String(Base64.encodeBase64(password.getBytes()))); + } + catch (MessagingException me) { + if (me.getMessage().length() > 1 && me.getMessage().charAt(1) == '3') { + throw new AuthenticationFailedException("AUTH LOGIN failed (" + me.getMessage() + + ")"); + } + throw me; + } + } + + private void saslAuthPlain(String username, String password) throws MessagingException, + AuthenticationFailedException, IOException { + byte[] data = ("\000" + username + "\000" + password).getBytes(); + data = new Base64().encode(data); + try { + executeSimpleCommand("AUTH PLAIN " + new String(data)); + } + catch (MessagingException me) { + if (me.getMessage().length() > 1 && me.getMessage().charAt(1) == '3') { + throw new AuthenticationFailedException("AUTH PLAIN failed (" + me.getMessage() + + ")"); + } + throw me; + } + } +} diff --git a/src/com/android/email/mail/transport/StatusOutputStream.java b/src/com/android/email/mail/transport/StatusOutputStream.java new file mode 100644 index 000000000..4e51569b6 --- /dev/null +++ b/src/com/android/email/mail/transport/StatusOutputStream.java @@ -0,0 +1,29 @@ +package com.android.email.mail.transport; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +import com.android.email.Email; + +import android.util.Config; +import android.util.Log; + +public class StatusOutputStream extends FilterOutputStream { + private long mCount = 0; + + public StatusOutputStream(OutputStream out) { + super(out); + } + + @Override + public void write(int oneByte) throws IOException { + super.write(oneByte); + mCount++; + if (Config.LOGV) { + if (mCount % 1024 == 0) { + Log.v(Email.LOG_TAG, "# " + mCount); + } + } + } +} diff --git a/src/com/android/email/mail/transport/WebDavTransport.java b/src/com/android/email/mail/transport/WebDavTransport.java new file mode 100644 index 000000000..3735678fe --- /dev/null +++ b/src/com/android/email/mail/transport/WebDavTransport.java @@ -0,0 +1,189 @@ + +package com.android.email.mail.transport; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.SSLException; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; + +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.client.CookieStore; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.DefaultHttpClient; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; +import org.xml.sax.XMLReader; + +import android.util.Config; +import android.util.Log; + +import com.android.email.Email; +import com.android.email.PeekableInputStream; +import com.android.email.codec.binary.Base64; +import com.android.email.mail.Address; +import com.android.email.mail.AuthenticationFailedException; +import com.android.email.mail.Folder; +import com.android.email.mail.Message; +import com.android.email.mail.MessagingException; +import com.android.email.mail.Transport; +import com.android.email.mail.CertificateValidationException; +import com.android.email.mail.Message.RecipientType; +import com.android.email.mail.store.TrustManagerFactory; +import com.android.email.mail.store.WebDavStore; +import com.android.email.mail.store.WebDavStore.HttpGeneric; +import com.android.email.mail.store.WebDavStore.ParsedDataSet; +import com.android.email.mail.store.WebDavStore.WebDavHandler; + +public class WebDavTransport extends Transport { + public static final int CONNECTION_SECURITY_NONE = 0; + public static final int CONNECTION_SECURITY_TLS_OPTIONAL = 1; + public static final int CONNECTION_SECURITY_TLS_REQUIRED = 2; + public static final int CONNECTION_SECURITY_SSL_REQUIRED = 3; + public static final int CONNECTION_SECURITY_SSL_OPTIONAL = 4; + + String host; + int mPort; + private int mConnectionSecurity; + private String mUsername; /* Stores the username for authentications */ + private String mPassword; /* Stores the password for authentications */ + private String mUrl; /* Stores the base URL for the server */ + + boolean mSecure; + Socket mSocket; + PeekableInputStream mIn; + OutputStream mOut; + private WebDavStore store; + + /** + * webdav://user:password@server:port CONNECTION_SECURITY_NONE + * webdav+tls://user:password@server:port CONNECTION_SECURITY_TLS_OPTIONAL + * webdav+tls+://user:password@server:port CONNECTION_SECURITY_TLS_REQUIRED + * webdav+ssl+://user:password@server:port CONNECTION_SECURITY_SSL_REQUIRED + * webdav+ssl://user:password@server:port CONNECTION_SECURITY_SSL_OPTIONAL + * + * @param _uri + */ + public WebDavTransport(String _uri) throws MessagingException { + store = new WebDavStore(_uri); + Log.d(Email.LOG_TAG, ">>> New WebDavTransport creation complete"); + } + + public void open() throws MessagingException { + Log.d(Email.LOG_TAG, ">>> open called on WebDavTransport "); + if (store.needAuth()) { + store.authenticate(); + } + + if (store.getAuthCookies() == null) { + return; + } + } + +// public void sendMessage(Message message) throws MessagingException { +// Address[] from = message.getFrom(); +// +// } + + public void close() { + } + + public String generateTempURI(String subject) { + String encodedSubject = URLEncoder.encode(subject); + return store.getUrl() + "/Exchange/" + store.getAlias() + "/drafts/" + encodedSubject + ".eml"; + } + public String generateSendURI() { + return store.getUrl() + "/Exchange/" + store.getAlias() + "/##DavMailSubmissionURI##/"; + } + + public void sendMessage(Message message) throws MessagingException { + Log.d(Email.LOG_TAG, ">>> sendMessage called."); + + DefaultHttpClient httpclient = new DefaultHttpClient(); + HttpGeneric httpmethod; + HttpResponse response; + HttpEntity responseEntity; + StringEntity bodyEntity; + int statusCode; + String subject; + ByteArrayOutputStream out; + try { + try { + subject = message.getSubject(); + } catch (MessagingException e) { + Log.e(Email.LOG_TAG, "MessagingException while retrieving Subject: " + e); + subject = ""; + } + try { + out = new ByteArrayOutputStream(message.getSize()); + } catch (MessagingException e) { + Log.e(Email.LOG_TAG, "MessagingException while getting size of message: " + e); + out = new ByteArrayOutputStream(); + } + open(); + message.writeTo( + new EOLConvertingOutputStream( + new BufferedOutputStream(out, 1024))); + httpclient.setCookieStore(store.getAuthCookies()); + + bodyEntity = new StringEntity(out.toString(), "UTF-8"); + bodyEntity.setContentType("message/rfc822"); + + httpmethod = store.new HttpGeneric(generateTempURI(subject)); + httpmethod.setMethod("PUT"); + httpmethod.setEntity(bodyEntity); + + response = httpclient.execute(httpmethod); + statusCode = response.getStatusLine().getStatusCode(); + + if (statusCode < 200 || + statusCode > 300) { + throw new IOException("Error sending message, status code was " + statusCode); + } + + //responseEntity = response.getEntity(); + //DefaultHttpClient movehttpclient = new DefaultHttpClient(); + //HttpGeneric movehttpmethod; + //HttpResponse moveresponse; + //HttpEntity moveresponseEntity; + httpmethod = store.new HttpGeneric(generateTempURI(subject)); + httpmethod.setMethod("MOVE"); + httpmethod.setHeader("Destination", generateSendURI()); + + response = httpclient.execute(httpmethod); + statusCode = response.getStatusLine().getStatusCode(); + + if (statusCode < 200 || + statusCode > 300) { + throw new IOException("Error sending message, status code was " + statusCode); + } + + } catch (UnsupportedEncodingException uee) { + Log.e(Email.LOG_TAG, "UnsupportedEncodingException in getMessageCount() " + uee); + } catch (IOException ioe) { + Log.e(Email.LOG_TAG, "IOException in getMessageCount() " + ioe); + throw new MessagingException("Unable to send message", ioe); + } + Log.d(Email.LOG_TAG, ">>> getMessageCount finished"); + } + +} diff --git a/src/com/android/email/provider/AttachmentProvider.java b/src/com/android/email/provider/AttachmentProvider.java new file mode 100644 index 000000000..95136fa10 --- /dev/null +++ b/src/com/android/email/provider/AttachmentProvider.java @@ -0,0 +1,272 @@ +package com.android.email.provider; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.sqlite.SQLiteDatabase; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.OpenableColumns; +import android.util.Config; +import android.util.Log; + +import com.android.email.Account; +import com.android.email.Email; +import com.android.email.Utility; +import com.android.email.mail.internet.MimeUtility; + +/* + * A simple ContentProvider that allows file access to Email's attachments. + */ +public class AttachmentProvider extends ContentProvider { + public static final Uri CONTENT_URI = Uri.parse( "content://com.android.email.attachmentprovider"); + + private static final String FORMAT_RAW = "RAW"; + private static final String FORMAT_THUMBNAIL = "THUMBNAIL"; + + public static class AttachmentProviderColumns { + public static final String _ID = "_id"; + public static final String DATA = "_data"; + public static final String DISPLAY_NAME = "_display_name"; + public static final String SIZE = "_size"; + } + + public static Uri getAttachmentUri(Account account, long id) { + return CONTENT_URI.buildUpon() + .appendPath(account.getUuid() + ".db") + .appendPath(Long.toString(id)) + .appendPath(FORMAT_RAW) + .build(); + } + + public static Uri getAttachmentThumbnailUri(Account account, long id, int width, int height) { + return CONTENT_URI.buildUpon() + .appendPath(account.getUuid() + ".db") + .appendPath(Long.toString(id)) + .appendPath(FORMAT_THUMBNAIL) + .appendPath(Integer.toString(width)) + .appendPath(Integer.toString(height)) + .build(); + } + + public static Uri getAttachmentUri(String db, long id) { + return CONTENT_URI.buildUpon() + .appendPath(db) + .appendPath(Long.toString(id)) + .appendPath(FORMAT_RAW) + .build(); + } + + @Override + public boolean onCreate() { + /* + * We use the cache dir as a temporary directory (since Android doesn't give us one) so + * on startup we'll clean up any .tmp files from the last run. + */ + File[] files = getContext().getCacheDir().listFiles(); + for (File file : files) { + if (file.getName().endsWith(".tmp")) { + file.delete(); + } + } + return true; + } + + @Override + public String getType(Uri uri) { + List segments = uri.getPathSegments(); + String dbName = segments.get(0); + String id = segments.get(1); + String format = segments.get(2); + if (FORMAT_THUMBNAIL.equals(format)) { + return "image/png"; + } + else { + String path = getContext().getDatabasePath(dbName).getAbsolutePath(); + SQLiteDatabase db = null; + Cursor cursor = null; + try { + db = SQLiteDatabase.openDatabase(path, null, 0); + cursor = db.query( + "attachments", + new String[] { "mime_type" }, + "id = ?", + new String[] { id }, + null, + null, + null); + cursor.moveToFirst(); + String type = cursor.getString(0); + cursor.close(); + db.close(); + return type; + + } + finally { + if (cursor != null) { + cursor.close(); + } + if (db != null) { + db.close(); + } + + } + } + } + + @Override + public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { + List segments = uri.getPathSegments(); + String dbName = segments.get(0); + String id = segments.get(1); + String format = segments.get(2); + if (FORMAT_THUMBNAIL.equals(format)) { + int width = Integer.parseInt(segments.get(3)); + int height = Integer.parseInt(segments.get(4)); + String filename = "thmb_" + dbName + "_" + id; + File dir = getContext().getCacheDir(); + File file = new File(dir, filename); + if (!file.exists()) { + Uri attachmentUri = getAttachmentUri(dbName, Long.parseLong(id)); + String type = getType(attachmentUri); + try { + FileInputStream in = new FileInputStream( + new File(getContext().getDatabasePath(dbName + "_att"), id)); + Bitmap thumbnail = createThumbnail(type, in); + thumbnail = thumbnail.createScaledBitmap(thumbnail, width, height, true); + FileOutputStream out = new FileOutputStream(file); + thumbnail.compress(Bitmap.CompressFormat.PNG, 100, out); + out.close(); + in.close(); + } + catch (IOException ioe) { + return null; + } + } + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); + } + else { + return ParcelFileDescriptor.open( + new File(getContext().getDatabasePath(dbName + "_att"), id), + ParcelFileDescriptor.MODE_READ_ONLY); + } + } + + @Override + public int delete(Uri uri, String arg1, String[] arg2) { + return 0; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + return null; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, + String sortOrder) { + if (projection == null) { + projection = + new String[] { + AttachmentProviderColumns._ID, + AttachmentProviderColumns.DATA, + }; + } + + List segments = uri.getPathSegments(); + String dbName = segments.get(0); + String id = segments.get(1); + String format = segments.get(2); + String path = getContext().getDatabasePath(dbName).getAbsolutePath(); + String name = null; + int size = -1; + SQLiteDatabase db = null; + Cursor cursor = null; + try { + db = SQLiteDatabase.openDatabase(path, null, 0); + cursor = db.query( + "attachments", + new String[] { "name", "size" }, + "id = ?", + new String[] { id }, + null, + null, + null); + if (!cursor.moveToFirst()) { + return null; + } + name = cursor.getString(0); + size = cursor.getInt(1); + } + finally { + if (cursor != null) { + cursor.close(); + } + if (db != null) { + db.close(); + } + } + + MatrixCursor ret = new MatrixCursor(projection); + Object[] values = new Object[projection.length]; + for (int i = 0, count = projection.length; i < count; i++) { + String column = projection[i]; + if (AttachmentProviderColumns._ID.equals(column)) { + values[i] = id; + } + else if (AttachmentProviderColumns.DATA.equals(column)) { + values[i] = uri.toString(); + } + else if (AttachmentProviderColumns.DISPLAY_NAME.equals(column)) { + values[i] = name; + } + else if (AttachmentProviderColumns.SIZE.equals(column)) { + values[i] = size; + } + } + ret.addRow(values); + return ret; + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + return 0; + } + + private Bitmap createThumbnail(String type, InputStream data) { + if(MimeUtility.mimeTypeMatches(type, "image/*")) { + return createImageThumbnail(data); + } + return null; + } + + private Bitmap createImageThumbnail(InputStream data) { + try { + Bitmap bitmap = BitmapFactory.decodeStream(data); + return bitmap; + } + catch (OutOfMemoryError oome) { + /* + * Improperly downloaded images, corrupt bitmaps and the like can commonly + * cause OOME due to invalid allocation sizes. We're happy with a null bitmap in + * that case. If the system is really out of memory we'll know about it soon + * enough. + */ + return null; + } + catch (Exception e) { + return null; + } + } +} diff --git a/src/com/android/email/service/BootReceiver.java b/src/com/android/email/service/BootReceiver.java new file mode 100644 index 000000000..3867eb574 --- /dev/null +++ b/src/com/android/email/service/BootReceiver.java @@ -0,0 +1,22 @@ + +package com.android.email.service; + +import com.android.email.MessagingController; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +public class BootReceiver extends BroadcastReceiver { + public void onReceive(Context context, Intent intent) { + if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) { + MailService.actionReschedule(context); + } + else if (Intent.ACTION_DEVICE_STORAGE_LOW.equals(intent.getAction())) { + MailService.actionCancel(context); + } + else if (Intent.ACTION_DEVICE_STORAGE_OK.equals(intent.getAction())) { + MailService.actionReschedule(context); + } + } +} diff --git a/src/com/android/email/service/MailService.java b/src/com/android/email/service/MailService.java new file mode 100644 index 000000000..99da9f1a8 --- /dev/null +++ b/src/com/android/email/service/MailService.java @@ -0,0 +1,193 @@ + +package com.android.email.service; + +import java.util.HashMap; + +import android.app.AlarmManager; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.IBinder; +import android.os.SystemClock; +import android.util.Config; +import android.util.Log; +import android.text.TextUtils; +import android.net.Uri; + +import com.android.email.Account; +import com.android.email.Email; +import com.android.email.MessagingController; +import com.android.email.MessagingListener; +import com.android.email.Preferences; +import com.android.email.R; +import com.android.email.activity.Accounts; +import com.android.email.activity.FolderMessageList; + +/** + */ +public class MailService extends Service { + private static final String ACTION_CHECK_MAIL = "com.android.email.intent.action.MAIL_SERVICE_WAKEUP"; + private static final String ACTION_RESCHEDULE = "com.android.email.intent.action.MAIL_SERVICE_RESCHEDULE"; + private static final String ACTION_CANCEL = "com.android.email.intent.action.MAIL_SERVICE_CANCEL"; + + private Listener mListener = new Listener(); + + private int mStartId; + + public static void actionReschedule(Context context) { + Intent i = new Intent(); + i.setClass(context, MailService.class); + i.setAction(MailService.ACTION_RESCHEDULE); + context.startService(i); + } + + public static void actionCancel(Context context) { + Intent i = new Intent(); + i.setClass(context, MailService.class); + i.setAction(MailService.ACTION_CANCEL); + context.startService(i); + } + + @Override + public void onStart(Intent intent, int startId) { + super.onStart(intent, startId); + this.mStartId = startId; + + MessagingController.getInstance(getApplication()).addListener(mListener); + if (ACTION_CHECK_MAIL.equals(intent.getAction())) { + if (Config.LOGV) { + Log.v(Email.LOG_TAG, "***** MailService *****: checking mail"); + } + MessagingController.getInstance(getApplication()).checkMail(this, null, mListener); + } + else if (ACTION_CANCEL.equals(intent.getAction())) { + if (Config.LOGV) { + Log.v(Email.LOG_TAG, "***** MailService *****: cancel"); + } + cancel(); + stopSelf(startId); + } + else if (ACTION_RESCHEDULE.equals(intent.getAction())) { + if (Config.LOGV) { + Log.v(Email.LOG_TAG, "***** MailService *****: reschedule"); + } + reschedule(); + stopSelf(startId); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + MessagingController.getInstance(getApplication()).removeListener(mListener); + } + + private void cancel() { + AlarmManager alarmMgr = (AlarmManager)getSystemService(Context.ALARM_SERVICE); + Intent i = new Intent(); + i.setClassName("com.android.email", "com.android.email.service.MailService"); + i.setAction(ACTION_CHECK_MAIL); + PendingIntent pi = PendingIntent.getService(this, 0, i, 0); + alarmMgr.cancel(pi); + } + + private void reschedule() { + AlarmManager alarmMgr = (AlarmManager)getSystemService(Context.ALARM_SERVICE); + Intent i = new Intent(); + i.setClassName("com.android.email", "com.android.email.service.MailService"); + i.setAction(ACTION_CHECK_MAIL); + PendingIntent pi = PendingIntent.getService(this, 0, i, 0); + + int shortestInterval = -1; + for (Account account : Preferences.getPreferences(this).getAccounts()) { + if (account.getAutomaticCheckIntervalMinutes() != -1 + && (account.getAutomaticCheckIntervalMinutes() < shortestInterval || shortestInterval == -1)) { + shortestInterval = account.getAutomaticCheckIntervalMinutes(); + } + } + + if (shortestInterval == -1) { + alarmMgr.cancel(pi); + } + else { + alarmMgr.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + + (shortestInterval * (60 * 1000)), pi); + } + } + + public IBinder onBind(Intent intent) { + return null; + } + + class Listener extends MessagingListener { + HashMap accountsWithNewMail = new HashMap(); + + @Override + public void checkMailStarted(Context context, Account account) { + accountsWithNewMail.clear(); + } + + @Override + public void checkMailFailed(Context context, Account account, String reason) { + reschedule(); + stopSelf(mStartId); + } + + @Override + public void synchronizeMailboxFinished( + Account account, + String folder, + int totalMessagesInMailbox, + int numNewMessages) { + if (account.isNotifyNewMail() && numNewMessages > 0) { + accountsWithNewMail.put(account, numNewMessages); + } + } + + @Override + public void checkMailFinished(Context context, Account account) { + NotificationManager notifMgr = (NotificationManager)context + .getSystemService(Context.NOTIFICATION_SERVICE); + + if (accountsWithNewMail.size() > 0) { + Notification notif = new Notification(R.drawable.stat_notify_email_generic, + getString(R.string.notification_new_title), System.currentTimeMillis()); + boolean vibrate = false; + String ringtone = null; + if (accountsWithNewMail.size() > 1) { + for (Account account1 : accountsWithNewMail.keySet()) { + if (account1.isVibrate()) vibrate = true; + if (account1.isNotifyRingtone()) ringtone = account1.getRingtone(); + } + Intent i = new Intent(context, Accounts.class); + PendingIntent pi = PendingIntent.getActivity(context, 0, i, 0); + notif.setLatestEventInfo(context, getString(R.string.notification_new_title), + getString(R.string.notification_new_multi_account_fmt, + accountsWithNewMail.size()), pi); + } else { + Account account1 = accountsWithNewMail.keySet().iterator().next(); + int totalNewMails = accountsWithNewMail.get(account1); + Intent i = FolderMessageList.actionHandleAccountIntent(context, account1, Email.INBOX); + PendingIntent pi = PendingIntent.getActivity(context, 0, i, 0); + notif.setLatestEventInfo(context, getString(R.string.notification_new_title), + getString(R.string.notification_new_one_account_fmt, totalNewMails, + account1.getDescription()), pi); + vibrate = account1.isVibrate(); + if (account1.isNotifyRingtone()) ringtone = account1.getRingtone(); + } + notif.defaults = Notification.DEFAULT_LIGHTS; + notif.sound = TextUtils.isEmpty(ringtone) ? null : Uri.parse(ringtone); + if (vibrate) { + notif.defaults |= Notification.DEFAULT_VIBRATE; + } + notifMgr.notify(1, notif); + } + + reschedule(); + stopSelf(mStartId); + } + } +} diff --git a/src/com/fsck/k9/Account.java b/src/com/android/k9/Account.java similarity index 100% rename from src/com/fsck/k9/Account.java rename to src/com/android/k9/Account.java diff --git a/src/com/fsck/k9/EmailAddressAdapter.java b/src/com/android/k9/EmailAddressAdapter.java similarity index 100% rename from src/com/fsck/k9/EmailAddressAdapter.java rename to src/com/android/k9/EmailAddressAdapter.java diff --git a/src/com/fsck/k9/EmailAddressValidator.java b/src/com/android/k9/EmailAddressValidator.java similarity index 100% rename from src/com/fsck/k9/EmailAddressValidator.java rename to src/com/android/k9/EmailAddressValidator.java diff --git a/src/com/fsck/k9/FixedLengthInputStream.java b/src/com/android/k9/FixedLengthInputStream.java similarity index 100% rename from src/com/fsck/k9/FixedLengthInputStream.java rename to src/com/android/k9/FixedLengthInputStream.java diff --git a/src/com/fsck/k9/Manifest.java b/src/com/android/k9/Manifest.java similarity index 100% rename from src/com/fsck/k9/Manifest.java rename to src/com/android/k9/Manifest.java diff --git a/src/com/fsck/k9/MessagingController.java b/src/com/android/k9/MessagingController.java similarity index 100% rename from src/com/fsck/k9/MessagingController.java rename to src/com/android/k9/MessagingController.java diff --git a/src/com/fsck/k9/MessagingListener.java b/src/com/android/k9/MessagingListener.java similarity index 100% rename from src/com/fsck/k9/MessagingListener.java rename to src/com/android/k9/MessagingListener.java diff --git a/src/com/fsck/k9/PeekableInputStream.java b/src/com/android/k9/PeekableInputStream.java similarity index 100% rename from src/com/fsck/k9/PeekableInputStream.java rename to src/com/android/k9/PeekableInputStream.java diff --git a/src/com/fsck/k9/Preferences.java b/src/com/android/k9/Preferences.java similarity index 100% rename from src/com/fsck/k9/Preferences.java rename to src/com/android/k9/Preferences.java diff --git a/src/com/android/k9/Utility.java b/src/com/android/k9/Utility.java new file mode 100644 index 000000000..08f643f3b --- /dev/null +++ b/src/com/android/k9/Utility.java @@ -0,0 +1,176 @@ + +package com.fsck.k9; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UnsupportedEncodingException; +import java.util.Date; + +import com.fsck.k9.codec.binary.Base64; + +import android.text.Editable; +import android.widget.TextView; + +public class Utility { + public final static String readInputStream(InputStream in, String encoding) throws IOException { + InputStreamReader reader = new InputStreamReader(in, encoding); + StringBuffer sb = new StringBuffer(); + int count; + char[] buf = new char[512]; + while ((count = reader.read(buf)) != -1) { + sb.append(buf, 0, count); + } + return sb.toString(); + } + + public final static boolean arrayContains(Object[] a, Object o) { + for (int i = 0, count = a.length; i < count; i++) { + if (a[i].equals(o)) { + return true; + } + } + return false; + } + + /** + * Combines the given array of Objects into a single string using the + * seperator character and each Object's toString() method. between each + * part. + * + * @param parts + * @param seperator + * @return + */ + public static String combine(Object[] parts, char seperator) { + if (parts == null) { + return null; + } + StringBuffer sb = new StringBuffer(); + for (int i = 0; i < parts.length; i++) { + sb.append(parts[i].toString()); + if (i < parts.length - 1) { + sb.append(seperator); + } + } + return sb.toString(); + } + + public static String base64Decode(String encoded) { + if (encoded == null) { + return null; + } + byte[] decoded = new Base64().decode(encoded.getBytes()); + return new String(decoded); + } + + public static String base64Encode(String s) { + if (s == null) { + return s; + } + byte[] encoded = new Base64().encode(s.getBytes()); + return new String(encoded); + } + + public static boolean requiredFieldValid(TextView view) { + return view.getText() != null && view.getText().length() > 0; + } + + public static boolean requiredFieldValid(Editable s) { + return s != null && s.length() > 0; + } + + /** + * Ensures that the given string starts and ends with the double quote character. The string is not modified in any way except to add the + * double quote character to start and end if it's not already there. + * sample -> "sample" + * "sample" -> "sample" + * ""sample"" -> "sample" + * "sample"" -> "sample" + * sa"mp"le -> "sa"mp"le" + * "sa"mp"le" -> "sa"mp"le" + * (empty string) -> "" + * " -> "" + * @param s + * @return + */ + public static String quoteString(String s) { + if (s == null) { + return null; + } + if (!s.matches("^\".*\"$")) { + return "\"" + s + "\""; + } + else { + return s; + } + } + + /** + * A fast version of URLDecoder.decode() that works only with UTF-8 and does only two + * allocations. This version is around 3x as fast as the standard one and I'm using it + * hundreds of times in places that slow down the UI, so it helps. + */ + public static String fastUrlDecode(String s) { + try { + byte[] bytes = s.getBytes("UTF-8"); + byte ch; + int length = 0; + for (int i = 0, count = bytes.length; i < count; i++) { + ch = bytes[i]; + if (ch == '%') { + int h = (bytes[i + 1] - '0'); + int l = (bytes[i + 2] - '0'); + if (h > 9) { + h -= 7; + } + if (l > 9) { + l -= 7; + } + bytes[length] = (byte) ((h << 4) | l); + i += 2; + } + else if (ch == '+') { + bytes[length] = ' '; + } + else { + bytes[length] = bytes[i]; + } + length++; + } + return new String(bytes, 0, length, "UTF-8"); + } + catch (UnsupportedEncodingException uee) { + return null; + } + } + + /** + * Returns true if the specified date is within today. Returns false otherwise. + * @param date + * @return + */ + public static boolean isDateToday(Date date) { + // TODO But Calendar is so slowwwwwww.... + Date today = new Date(); + if (date.getYear() == today.getYear() && + date.getMonth() == today.getMonth() && + date.getDate() == today.getDate()) { + return true; + } + return false; + } + + /* + * TODO disabled this method globally. It is used in all the settings screens but I just + * noticed that an unrelated icon was dimmed. Android must share drawables internally. + */ + public static void setCompoundDrawablesAlpha(TextView view, int alpha) { +// Drawable[] drawables = view.getCompoundDrawables(); +// for (Drawable drawable : drawables) { +// if (drawable != null) { +// drawable.setAlpha(alpha); +// } +// } + } +} diff --git a/src/com/android/k9/activity/Accounts.java b/src/com/android/k9/activity/Accounts.java new file mode 100644 index 000000000..3010eaf20 --- /dev/null +++ b/src/com/android/k9/activity/Accounts.java @@ -0,0 +1,296 @@ + +package com.fsck.k9.activity; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.ListActivity; +import android.app.NotificationManager; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Bundle; +import android.view.ContextMenu; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.ContextMenu.ContextMenuInfo; +import android.view.View.OnClickListener; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.ListView; +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.mail.MessagingException; +import com.fsck.k9.mail.Store; +import com.fsck.k9.mail.store.LocalStore; +import com.fsck.k9.mail.store.LocalStore.LocalFolder; + +public class Accounts extends ListActivity implements OnItemClickListener, OnClickListener { + private static final int DIALOG_REMOVE_ACCOUNT = 1; + /** + * Key codes used to open a debug settings screen. + */ + private static int[] secretKeyCodes = { + KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_U, + KeyEvent.KEYCODE_G + }; + + private int mSecretKeyCodeIndex = 0; + private Account mSelectedContextAccount; + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + setContentView(R.layout.accounts); + ListView listView = getListView(); + listView.setOnItemClickListener(this); + listView.setItemsCanFocus(false); + listView.setEmptyView(findViewById(R.id.empty)); + findViewById(R.id.add_new_account).setOnClickListener(this); + registerForContextMenu(listView); + + if (icicle != null && icicle.containsKey("selectedContextAccount")) { + mSelectedContextAccount = (Account) icicle.getSerializable("selectedContextAccount"); + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + if (mSelectedContextAccount != null) { + outState.putSerializable("selectedContextAccount", mSelectedContextAccount); + } + } + + @Override + public void onResume() { + super.onResume(); + + NotificationManager notifMgr = (NotificationManager) + getSystemService(Context.NOTIFICATION_SERVICE); + notifMgr.cancel(1); + + refresh(); + } + + private void refresh() { + Account[] accounts = Preferences.getPreferences(this).getAccounts(); + getListView().setAdapter(new AccountsAdapter(accounts)); + } + + private void onAddNewAccount() { + AccountSetupBasics.actionNewAccount(this); + } + + private void onEditAccount(Account account) { + AccountSettings.actionSettings(this, account); + } + + private void onRefresh() { + MessagingController.getInstance(getApplication()).checkMail(this, null, null); + } + + private void onCompose() { + Account defaultAccount = + Preferences.getPreferences(this).getDefaultAccount(); + if (defaultAccount != null) { + MessageCompose.actionCompose(this, defaultAccount); + } + else { + onAddNewAccount(); + } + } + + private void onOpenAccount(Account account) { + FolderMessageList.actionHandleAccount(this, account); + } + + public void onClick(View view) { + if (view.getId() == R.id.add_new_account) { + onAddNewAccount(); + } + } + + private void onDeleteAccount(Account account) { + mSelectedContextAccount = account; + showDialog(DIALOG_REMOVE_ACCOUNT); + } + + @Override + public Dialog onCreateDialog(int id) { + switch (id) { + case DIALOG_REMOVE_ACCOUNT: + return createRemoveAccountDialog(); + } + return super.onCreateDialog(id); + } + + private Dialog createRemoveAccountDialog() { + return new AlertDialog.Builder(this) + .setTitle(R.string.account_delete_dlg_title) + .setMessage(getString(R.string.account_delete_dlg_instructions_fmt, + mSelectedContextAccount.getDescription())) + .setPositiveButton(R.string.okay_action, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int whichButton) { + dismissDialog(DIALOG_REMOVE_ACCOUNT); + try { + ((LocalStore)Store.getInstance( + mSelectedContextAccount.getLocalStoreUri(), + getApplication())).delete(); + } catch (Exception e) { + // Ignore + } + mSelectedContextAccount.delete(Preferences.getPreferences(Accounts.this)); + k9.setServicesEnabled(Accounts.this); + refresh(); + } + }) + .setNegativeButton(R.string.cancel_action, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int whichButton) { + dismissDialog(DIALOG_REMOVE_ACCOUNT); + } + }) + .create(); + } + + public boolean onContextItemSelected(MenuItem item) { + AdapterContextMenuInfo menuInfo = (AdapterContextMenuInfo)item.getMenuInfo(); + Account account = (Account)getListView().getItemAtPosition(menuInfo.position); + switch (item.getItemId()) { + case R.id.delete_account: + onDeleteAccount(account); + break; + case R.id.edit_account: + onEditAccount(account); + break; + case R.id.open: + onOpenAccount(account); + break; + } + return true; + } + + public void onItemClick(AdapterView parent, View view, int position, long id) { + Account account = (Account)parent.getItemAtPosition(position); + onOpenAccount(account); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.add_new_account: + onAddNewAccount(); + break; + case R.id.check_mail: + onRefresh(); + break; + case R.id.compose: + onCompose(); + break; + default: + return super.onOptionsItemSelected(item); + } + return true; + } + + public boolean onItemLongClick(AdapterView parent, View view, int position, long id) { + return true; + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + getMenuInflater().inflate(R.menu.accounts_option, menu); + return true; + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, v, menuInfo); + menu.setHeaderTitle(R.string.accounts_context_menu_title); + getMenuInflater().inflate(R.menu.accounts_context, menu); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (event.getKeyCode() == secretKeyCodes[mSecretKeyCodeIndex]) { + mSecretKeyCodeIndex++; + if (mSecretKeyCodeIndex == secretKeyCodes.length) { + mSecretKeyCodeIndex = 0; + startActivity(new Intent(this, Debug.class)); + } + } else { + mSecretKeyCodeIndex = 0; + } + return super.onKeyDown(keyCode, event); + } + + class AccountsAdapter extends ArrayAdapter { + public AccountsAdapter(Account[] accounts) { + super(Accounts.this, 0, accounts); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + Account account = getItem(position); + View view; + if (convertView != null) { + view = convertView; + } + else { + view = getLayoutInflater().inflate(R.layout.accounts_item, parent, false); + } + AccountViewHolder holder = (AccountViewHolder) view.getTag(); + if (holder == null) { + holder = new AccountViewHolder(); + holder.description = (TextView) view.findViewById(R.id.description); + holder.email = (TextView) view.findViewById(R.id.email); + holder.newMessageCount = (TextView) view.findViewById(R.id.new_message_count); + view.setTag(holder); + } + holder.description.setText(account.getDescription()); + holder.email.setText(account.getEmail()); + if (account.getEmail().equals(account.getDescription())) { + holder.email.setVisibility(View.GONE); + } + int unreadMessageCount = 0; + try { + LocalStore localStore = (LocalStore) Store.getInstance( + account.getLocalStoreUri(), + getApplication()); + LocalFolder localFolder = (LocalFolder) localStore.getFolder(k9.INBOX); + if (localFolder.exists()) { + unreadMessageCount = localFolder.getUnreadMessageCount(); + } + } + catch (MessagingException me) { + /* + * This is not expected to fail under normal circumstances. + */ + throw new RuntimeException("Unable to get unread count from local store.", me); + } + holder.newMessageCount.setText(Integer.toString(unreadMessageCount)); + holder.newMessageCount.setVisibility(unreadMessageCount > 0 ? View.VISIBLE : View.GONE); + return view; + } + + class AccountViewHolder { + public TextView description; + public TextView email; + public TextView newMessageCount; + } + } +} + + diff --git a/src/com/fsck/k9/activity/Debug.java b/src/com/android/k9/activity/Debug.java similarity index 100% rename from src/com/fsck/k9/activity/Debug.java rename to src/com/android/k9/activity/Debug.java diff --git a/src/com/fsck/k9/activity/FolderMessageList.java b/src/com/android/k9/activity/FolderMessageList.java similarity index 100% rename from src/com/fsck/k9/activity/FolderMessageList.java rename to src/com/android/k9/activity/FolderMessageList.java diff --git a/src/com/fsck/k9/activity/MessageCompose.java b/src/com/android/k9/activity/MessageCompose.java similarity index 100% rename from src/com/fsck/k9/activity/MessageCompose.java rename to src/com/android/k9/activity/MessageCompose.java diff --git a/src/com/fsck/k9/activity/MessageView.java b/src/com/android/k9/activity/MessageView.java similarity index 100% rename from src/com/fsck/k9/activity/MessageView.java rename to src/com/android/k9/activity/MessageView.java diff --git a/src/com/fsck/k9/activity/ProgressListener.java b/src/com/android/k9/activity/ProgressListener.java similarity index 100% rename from src/com/fsck/k9/activity/ProgressListener.java rename to src/com/android/k9/activity/ProgressListener.java diff --git a/src/com/fsck/k9/activity/Welcome.java b/src/com/android/k9/activity/Welcome.java similarity index 100% rename from src/com/fsck/k9/activity/Welcome.java rename to src/com/android/k9/activity/Welcome.java diff --git a/src/com/fsck/k9/activity/setup/AccountSettings.java b/src/com/android/k9/activity/setup/AccountSettings.java similarity index 100% rename from src/com/fsck/k9/activity/setup/AccountSettings.java rename to src/com/android/k9/activity/setup/AccountSettings.java diff --git a/src/com/fsck/k9/activity/setup/AccountSetupAccountType.java b/src/com/android/k9/activity/setup/AccountSetupAccountType.java similarity index 100% rename from src/com/fsck/k9/activity/setup/AccountSetupAccountType.java rename to src/com/android/k9/activity/setup/AccountSetupAccountType.java diff --git a/src/com/fsck/k9/activity/setup/AccountSetupBasics.java b/src/com/android/k9/activity/setup/AccountSetupBasics.java similarity index 100% rename from src/com/fsck/k9/activity/setup/AccountSetupBasics.java rename to src/com/android/k9/activity/setup/AccountSetupBasics.java diff --git a/src/com/fsck/k9/activity/setup/AccountSetupCheckSettings.java b/src/com/android/k9/activity/setup/AccountSetupCheckSettings.java similarity index 100% rename from src/com/fsck/k9/activity/setup/AccountSetupCheckSettings.java rename to src/com/android/k9/activity/setup/AccountSetupCheckSettings.java diff --git a/src/com/fsck/k9/activity/setup/AccountSetupComposition.java b/src/com/android/k9/activity/setup/AccountSetupComposition.java similarity index 100% rename from src/com/fsck/k9/activity/setup/AccountSetupComposition.java rename to src/com/android/k9/activity/setup/AccountSetupComposition.java diff --git a/src/com/android/k9/activity/setup/AccountSetupIncoming.java b/src/com/android/k9/activity/setup/AccountSetupIncoming.java new file mode 100644 index 000000000..919ef1caf --- /dev/null +++ b/src/com/android/k9/activity/setup/AccountSetupIncoming.java @@ -0,0 +1,338 @@ + +package com.fsck.k9.activity.setup; + +import java.net.URI; +import java.net.URISyntaxException; + +import android.app.Activity; +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.View.OnClickListener; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Button; +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; + +public class AccountSetupIncoming extends Activity implements OnClickListener { + private static final String EXTRA_ACCOUNT = "account"; + private static final String EXTRA_MAKE_DEFAULT = "makeDefault"; + + private static final int popPorts[] = { + 110, 995, 995, 110, 110 + }; + private static final String popSchemes[] = { + "pop3", "pop3+ssl", "pop3+ssl+", "pop3+tls", "pop3+tls+" + }; + private static final int imapPorts[] = { + 143, 993, 993, 143, 143 + }; + private static final String imapSchemes[] = { + "imap", "imap+ssl", "imap+ssl+", "imap+tls", "imap+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 int mAccountPorts[]; + private String mAccountSchemes[]; + private EditText mUsernameView; + private EditText mPasswordView; + private EditText mServerView; + private EditText mPortView; + private Spinner mSecurityTypeView; + private Spinner mDeletePolicyView; + private EditText mImapPathPrefixView; + private Button mNextButton; + private Account mAccount; + private boolean mMakeDefault; + + public static void actionIncomingSettings(Activity context, Account account, boolean makeDefault) { + Intent i = new Intent(context, AccountSetupIncoming.class); + i.putExtra(EXTRA_ACCOUNT, account); + i.putExtra(EXTRA_MAKE_DEFAULT, makeDefault); + context.startActivity(i); + } + + public static void actionEditIncomingSettings(Activity context, Account account) { + Intent i = new Intent(context, AccountSetupIncoming.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_incoming); + + mUsernameView = (EditText)findViewById(R.id.account_username); + mPasswordView = (EditText)findViewById(R.id.account_password); + TextView serverLabelView = (TextView) findViewById(R.id.account_server_label); + mServerView = (EditText)findViewById(R.id.account_server); + mPortView = (EditText)findViewById(R.id.account_port); + mSecurityTypeView = (Spinner)findViewById(R.id.account_security_type); + mDeletePolicyView = (Spinner)findViewById(R.id.account_delete_policy); + mImapPathPrefixView = (EditText)findViewById(R.id.imap_path_prefix); + mNextButton = (Button)findViewById(R.id.next); + + mNextButton.setOnClickListener(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)), + }; + + SpinnerOption deletePolicies[] = { + new SpinnerOption(0, + getString(R.string.account_setup_incoming_delete_policy_never_label)), + new SpinnerOption(1, + getString(R.string.account_setup_incoming_delete_policy_7days_label)), + new SpinnerOption(2, + getString(R.string.account_setup_incoming_delete_policy_delete_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); + + ArrayAdapter deletePoliciesAdapter = new ArrayAdapter(this, + android.R.layout.simple_spinner_item, deletePolicies); + deletePoliciesAdapter + .setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + mDeletePolicyView.setAdapter(deletePoliciesAdapter); + + /* + * 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.getStoreUri()); + 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); + } + + if (password != null) { + mPasswordView.setText(password); + } + + if (uri.getScheme().startsWith("pop3")) { + serverLabelView.setText(R.string.account_setup_incoming_pop_server_label); + mAccountPorts = popPorts; + mAccountSchemes = popSchemes; + + findViewById(R.id.imap_path_prefix_section).setVisibility(View.GONE); + } else if (uri.getScheme().startsWith("imap")) { + serverLabelView.setText(R.string.account_setup_incoming_imap_server_label); + mAccountPorts = imapPorts; + mAccountSchemes = imapSchemes; + + findViewById(R.id.account_delete_policy_label).setVisibility(View.GONE); + mDeletePolicyView.setVisibility(View.GONE); + if (uri.getPath() != null && uri.getPath().length() > 0) { + mImapPathPrefixView.setText(uri.getPath().substring(1)); + } + } else if (uri.getScheme().startsWith("webdav")) { + serverLabelView.setText(R.string.account_setup_incoming_webdav_server_label); + mAccountPorts = webdavPorts; + mAccountSchemes = webdavSchemes; + + /** Hide the unnecessary fields */ + findViewById(R.id.imap_path_prefix_section).setVisibility(View.GONE); + } else { + throw new Error("Unknown account type: " + mAccount.getStoreUri()); + } + + for (int i = 0; i < mAccountSchemes.length; i++) { + if (mAccountSchemes[i].equals(uri.getScheme())) { + SpinnerOption.setSpinnerOptionValue(mSecurityTypeView, i); + } + } + + SpinnerOption.setSpinnerOptionValue(mDeletePolicyView, mAccount.getDeletePolicy()); + + 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.requiredFieldValid(mUsernameView) + && Utility.requiredFieldValid(mPasswordView) + && Utility.requiredFieldValid(mServerView) + && Utility.requiredFieldValid(mPortView)); + Utility.setCompoundDrawablesAlpha(mNextButton, mNextButton.isEnabled() ? 255 : 128); + } + + private void updatePortFromSecurityType() { + int securityType = (Integer)((SpinnerOption)mSecurityTypeView.getSelectedItem()).value; + mPortView.setText(Integer.toString(mAccountPorts[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 { + /* + * Set the username and password for the outgoing settings to the username and + * password the user just set for incoming. + */ + try { + URI oldUri = new URI(mAccount.getTransportUri()); + URI uri = new URI( + oldUri.getScheme(), + mUsernameView.getText() + ":" + mPasswordView.getText(), + oldUri.getHost(), + oldUri.getPort(), + null, + null, + null); + mAccount.setTransportUri(uri.toString()); + } catch (URISyntaxException use) { + /* + * If we can't set up the URL we just continue. It's only for + * convenience. + */ + } + + + AccountSetupOutgoing.actionOutgoingSettings(this, mAccount, mMakeDefault); + finish(); + } + } + } + + private void onNext() { + int securityType = (Integer)((SpinnerOption)mSecurityTypeView.getSelectedItem()).value; + try { + String path = null; + if (mAccountSchemes[securityType].startsWith("imap")) { + path = "/" + mImapPathPrefixView.getText(); + } + URI uri = new URI( + mAccountSchemes[securityType], + mUsernameView.getText() + ":" + mPasswordView.getText(), + mServerView.getText().toString(), + Integer.parseInt(mPortView.getText().toString()), + path, // path + null, // query + null); + mAccount.setStoreUri(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); + } + + mAccount.setDeletePolicy((Integer)((SpinnerOption)mDeletePolicyView.getSelectedItem()).value); + AccountSetupCheckSettings.actionCheckSettings(this, mAccount, true, false); + } + + public void onClick(View v) { + switch (v.getId()) { + case R.id.next: + onNext(); + break; + } + } +} diff --git a/src/com/fsck/k9/activity/setup/AccountSetupNames.java b/src/com/android/k9/activity/setup/AccountSetupNames.java similarity index 100% rename from src/com/fsck/k9/activity/setup/AccountSetupNames.java rename to src/com/android/k9/activity/setup/AccountSetupNames.java diff --git a/src/com/fsck/k9/activity/setup/AccountSetupOptions.java b/src/com/android/k9/activity/setup/AccountSetupOptions.java similarity index 100% rename from src/com/fsck/k9/activity/setup/AccountSetupOptions.java rename to src/com/android/k9/activity/setup/AccountSetupOptions.java diff --git a/src/com/fsck/k9/activity/setup/AccountSetupOutgoing.java b/src/com/android/k9/activity/setup/AccountSetupOutgoing.java similarity index 99% rename from src/com/fsck/k9/activity/setup/AccountSetupOutgoing.java rename to src/com/android/k9/activity/setup/AccountSetupOutgoing.java index 2b64c01c9..6ddaf36dc 100644 --- a/src/com/fsck/k9/activity/setup/AccountSetupOutgoing.java +++ b/src/com/android/k9/activity/setup/AccountSetupOutgoing.java @@ -222,7 +222,7 @@ public class AccountSetupOutgoing extends Activity implements OnClickListener, private void validateFields() { mNextButton .setEnabled( - Utility.domainFieldValid(mServerView) && + Utility.requiredFieldValid(mServerView) && Utility.requiredFieldValid(mPortView) && (!mRequireLoginView.isChecked() || (Utility.requiredFieldValid(mUsernameView) && diff --git a/src/com/fsck/k9/activity/setup/SpinnerOption.java b/src/com/android/k9/activity/setup/SpinnerOption.java similarity index 100% rename from src/com/fsck/k9/activity/setup/SpinnerOption.java rename to src/com/android/k9/activity/setup/SpinnerOption.java diff --git a/src/com/fsck/k9/codec/binary/Base64.java b/src/com/android/k9/codec/binary/Base64.java similarity index 100% rename from src/com/fsck/k9/codec/binary/Base64.java rename to src/com/android/k9/codec/binary/Base64.java diff --git a/src/com/fsck/k9/codec/binary/Base64OutputStream.java b/src/com/android/k9/codec/binary/Base64OutputStream.java similarity index 100% rename from src/com/fsck/k9/codec/binary/Base64OutputStream.java rename to src/com/android/k9/codec/binary/Base64OutputStream.java diff --git a/src/com/fsck/k9/k9.java b/src/com/android/k9/k9.java similarity index 100% rename from src/com/fsck/k9/k9.java rename to src/com/android/k9/k9.java diff --git a/src/com/fsck/k9/mail/Address.java b/src/com/android/k9/mail/Address.java similarity index 100% rename from src/com/fsck/k9/mail/Address.java rename to src/com/android/k9/mail/Address.java diff --git a/src/com/fsck/k9/mail/AuthenticationFailedException.java b/src/com/android/k9/mail/AuthenticationFailedException.java similarity index 100% rename from src/com/fsck/k9/mail/AuthenticationFailedException.java rename to src/com/android/k9/mail/AuthenticationFailedException.java diff --git a/src/com/fsck/k9/mail/Body.java b/src/com/android/k9/mail/Body.java similarity index 100% rename from src/com/fsck/k9/mail/Body.java rename to src/com/android/k9/mail/Body.java diff --git a/src/com/fsck/k9/mail/BodyPart.java b/src/com/android/k9/mail/BodyPart.java similarity index 100% rename from src/com/fsck/k9/mail/BodyPart.java rename to src/com/android/k9/mail/BodyPart.java diff --git a/src/com/fsck/k9/mail/CertificateValidationException.java b/src/com/android/k9/mail/CertificateValidationException.java similarity index 100% rename from src/com/fsck/k9/mail/CertificateValidationException.java rename to src/com/android/k9/mail/CertificateValidationException.java diff --git a/src/com/fsck/k9/mail/FetchProfile.java b/src/com/android/k9/mail/FetchProfile.java similarity index 100% rename from src/com/fsck/k9/mail/FetchProfile.java rename to src/com/android/k9/mail/FetchProfile.java diff --git a/src/com/fsck/k9/mail/Flag.java b/src/com/android/k9/mail/Flag.java similarity index 100% rename from src/com/fsck/k9/mail/Flag.java rename to src/com/android/k9/mail/Flag.java diff --git a/src/com/fsck/k9/mail/Folder.java b/src/com/android/k9/mail/Folder.java similarity index 100% rename from src/com/fsck/k9/mail/Folder.java rename to src/com/android/k9/mail/Folder.java diff --git a/src/com/fsck/k9/mail/Message.java b/src/com/android/k9/mail/Message.java similarity index 100% rename from src/com/fsck/k9/mail/Message.java rename to src/com/android/k9/mail/Message.java diff --git a/src/com/fsck/k9/mail/MessageDateComparator.java b/src/com/android/k9/mail/MessageDateComparator.java similarity index 100% rename from src/com/fsck/k9/mail/MessageDateComparator.java rename to src/com/android/k9/mail/MessageDateComparator.java diff --git a/src/com/fsck/k9/mail/MessageRetrievalListener.java b/src/com/android/k9/mail/MessageRetrievalListener.java similarity index 100% rename from src/com/fsck/k9/mail/MessageRetrievalListener.java rename to src/com/android/k9/mail/MessageRetrievalListener.java diff --git a/src/com/fsck/k9/mail/MessagingException.java b/src/com/android/k9/mail/MessagingException.java similarity index 100% rename from src/com/fsck/k9/mail/MessagingException.java rename to src/com/android/k9/mail/MessagingException.java diff --git a/src/com/fsck/k9/mail/Multipart.java b/src/com/android/k9/mail/Multipart.java similarity index 100% rename from src/com/fsck/k9/mail/Multipart.java rename to src/com/android/k9/mail/Multipart.java diff --git a/src/com/fsck/k9/mail/NoSuchProviderException.java b/src/com/android/k9/mail/NoSuchProviderException.java similarity index 100% rename from src/com/fsck/k9/mail/NoSuchProviderException.java rename to src/com/android/k9/mail/NoSuchProviderException.java diff --git a/src/com/fsck/k9/mail/Part.java b/src/com/android/k9/mail/Part.java similarity index 100% rename from src/com/fsck/k9/mail/Part.java rename to src/com/android/k9/mail/Part.java diff --git a/src/com/fsck/k9/mail/Store.java b/src/com/android/k9/mail/Store.java similarity index 100% rename from src/com/fsck/k9/mail/Store.java rename to src/com/android/k9/mail/Store.java diff --git a/src/com/fsck/k9/mail/Transport.java b/src/com/android/k9/mail/Transport.java similarity index 100% rename from src/com/fsck/k9/mail/Transport.java rename to src/com/android/k9/mail/Transport.java diff --git a/src/com/fsck/k9/mail/internet/BinaryTempFileBody.java b/src/com/android/k9/mail/internet/BinaryTempFileBody.java similarity index 100% rename from src/com/fsck/k9/mail/internet/BinaryTempFileBody.java rename to src/com/android/k9/mail/internet/BinaryTempFileBody.java diff --git a/src/com/fsck/k9/mail/internet/MimeBodyPart.java b/src/com/android/k9/mail/internet/MimeBodyPart.java similarity index 100% rename from src/com/fsck/k9/mail/internet/MimeBodyPart.java rename to src/com/android/k9/mail/internet/MimeBodyPart.java diff --git a/src/com/fsck/k9/mail/internet/MimeHeader.java b/src/com/android/k9/mail/internet/MimeHeader.java similarity index 100% rename from src/com/fsck/k9/mail/internet/MimeHeader.java rename to src/com/android/k9/mail/internet/MimeHeader.java diff --git a/src/com/fsck/k9/mail/internet/MimeMessage.java b/src/com/android/k9/mail/internet/MimeMessage.java similarity index 100% rename from src/com/fsck/k9/mail/internet/MimeMessage.java rename to src/com/android/k9/mail/internet/MimeMessage.java diff --git a/src/com/fsck/k9/mail/internet/MimeMultipart.java b/src/com/android/k9/mail/internet/MimeMultipart.java similarity index 100% rename from src/com/fsck/k9/mail/internet/MimeMultipart.java rename to src/com/android/k9/mail/internet/MimeMultipart.java diff --git a/src/com/fsck/k9/mail/internet/MimeUtility.java b/src/com/android/k9/mail/internet/MimeUtility.java similarity index 100% rename from src/com/fsck/k9/mail/internet/MimeUtility.java rename to src/com/android/k9/mail/internet/MimeUtility.java diff --git a/src/com/fsck/k9/mail/internet/TextBody.java b/src/com/android/k9/mail/internet/TextBody.java similarity index 100% rename from src/com/fsck/k9/mail/internet/TextBody.java rename to src/com/android/k9/mail/internet/TextBody.java diff --git a/src/com/fsck/k9/mail/store/ImapResponseParser.java b/src/com/android/k9/mail/store/ImapResponseParser.java similarity index 100% rename from src/com/fsck/k9/mail/store/ImapResponseParser.java rename to src/com/android/k9/mail/store/ImapResponseParser.java diff --git a/src/com/fsck/k9/mail/store/ImapStore.java b/src/com/android/k9/mail/store/ImapStore.java similarity index 100% rename from src/com/fsck/k9/mail/store/ImapStore.java rename to src/com/android/k9/mail/store/ImapStore.java diff --git a/src/com/fsck/k9/mail/store/LocalStore.java b/src/com/android/k9/mail/store/LocalStore.java similarity index 100% rename from src/com/fsck/k9/mail/store/LocalStore.java rename to src/com/android/k9/mail/store/LocalStore.java diff --git a/src/com/fsck/k9/mail/store/Pop3Store.java b/src/com/android/k9/mail/store/Pop3Store.java similarity index 100% rename from src/com/fsck/k9/mail/store/Pop3Store.java rename to src/com/android/k9/mail/store/Pop3Store.java diff --git a/src/com/fsck/k9/mail/store/TrustManagerFactory.java b/src/com/android/k9/mail/store/TrustManagerFactory.java similarity index 100% rename from src/com/fsck/k9/mail/store/TrustManagerFactory.java rename to src/com/android/k9/mail/store/TrustManagerFactory.java diff --git a/src/com/fsck/k9/mail/store/WebDavStore.java b/src/com/android/k9/mail/store/WebDavStore.java similarity index 100% rename from src/com/fsck/k9/mail/store/WebDavStore.java rename to src/com/android/k9/mail/store/WebDavStore.java diff --git a/src/com/fsck/k9/mail/transport/CountingOutputStream.java b/src/com/android/k9/mail/transport/CountingOutputStream.java similarity index 100% rename from src/com/fsck/k9/mail/transport/CountingOutputStream.java rename to src/com/android/k9/mail/transport/CountingOutputStream.java diff --git a/src/com/fsck/k9/mail/transport/EOLConvertingOutputStream.java b/src/com/android/k9/mail/transport/EOLConvertingOutputStream.java similarity index 100% rename from src/com/fsck/k9/mail/transport/EOLConvertingOutputStream.java rename to src/com/android/k9/mail/transport/EOLConvertingOutputStream.java diff --git a/src/com/fsck/k9/mail/transport/SmtpTransport.java b/src/com/android/k9/mail/transport/SmtpTransport.java similarity index 100% rename from src/com/fsck/k9/mail/transport/SmtpTransport.java rename to src/com/android/k9/mail/transport/SmtpTransport.java diff --git a/src/com/fsck/k9/mail/transport/StatusOutputStream.java b/src/com/android/k9/mail/transport/StatusOutputStream.java similarity index 100% rename from src/com/fsck/k9/mail/transport/StatusOutputStream.java rename to src/com/android/k9/mail/transport/StatusOutputStream.java diff --git a/src/com/fsck/k9/mail/transport/WebDavTransport.java b/src/com/android/k9/mail/transport/WebDavTransport.java similarity index 100% rename from src/com/fsck/k9/mail/transport/WebDavTransport.java rename to src/com/android/k9/mail/transport/WebDavTransport.java diff --git a/src/com/fsck/k9/provider/AttachmentProvider.java b/src/com/android/k9/provider/AttachmentProvider.java similarity index 100% rename from src/com/fsck/k9/provider/AttachmentProvider.java rename to src/com/android/k9/provider/AttachmentProvider.java diff --git a/src/com/fsck/k9/service/BootReceiver.java b/src/com/android/k9/service/BootReceiver.java similarity index 100% rename from src/com/fsck/k9/service/BootReceiver.java rename to src/com/android/k9/service/BootReceiver.java diff --git a/src/com/fsck/k9/service/MailService.java b/src/com/android/k9/service/MailService.java similarity index 100% rename from src/com/fsck/k9/service/MailService.java rename to src/com/android/k9/service/MailService.java diff --git a/src/com/fsck/k9/K9.java b/src/com/fsck/k9/K9.java new file mode 100644 index 000000000..a12bb3ac8 --- /dev/null +++ b/src/com/fsck/k9/K9.java @@ -0,0 +1,6 @@ +package com.fsck.k9; + +import com.android.email.Email; + +public class K9 extends Email { +}