Legacy account migration

* Create new activity to encapsulate account upgrade
* Populate it with a list of legacy accounts, and progress bars for each
* Sidestep Welcome when there are legacy accounts to convert
* Super lightweight account migration:
  - Account login info only
  - no folders, messages, or attachments
* Scrub out old data
* Return to Welcome screen

As noted, the copies working (useable) POP & IMAP accounts, but does
not try to deal with folders, messages, or attachments.

Bug: 2065528
This commit is contained in:
Andrew Stadler 2010-02-10 23:17:55 -08:00
parent a5b8b084ff
commit 842ac04828
9 changed files with 639 additions and 7 deletions

View File

@ -55,14 +55,20 @@
<application android:icon="@drawable/icon" android:label="@string/app_name"
android:name="Email">
<activity android:name=".activity.Welcome">
<activity
android:name=".activity.Welcome">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".activity.UpgradeAccounts"
android:label="@string/upgrade_accounts_title"
android:theme="@android:style/Theme.NoTitleBar"
android:configChanges="keyboardHidden|orientation" >
</activity>
<!-- Must be exported in order for the AccountManager to launch it -->
<activity
android:name=".activity.setup.AccountSetupBasics"

View File

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2010 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.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@*android:drawable/title_bar_medium">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="@string/upgrade_accounts_title"
android:gravity="center"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textColor="?android:attr/textColorPrimary"
android:shadowColor="?android:attr/colorBackground"
android:shadowRadius="2" />
</LinearLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0px"
android:layout_weight="1"
android:paddingTop="10dip"
android:paddingBottom="10dip">
<ListView android:id="@android:id/list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:drawSelectorOnTop="false"
android:fastScrollEnabled="true" />
</FrameLayout>
<LinearLayout style="@android:style/ButtonBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<View
android:layout_width="0dip"
android:layout_height="0dip"
android:layout_weight="1" />
<Button android:id="@+id/action_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/okay_action" />
<View
android:layout_width="0dip"
android:layout_height="0dip"
android:layout_weight="1" />
</LinearLayout>
</LinearLayout>

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
/*
** Copyright 2010, 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.
*/
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?android:attr/listPreferredItemHeight"
android:orientation="vertical"
android:paddingRight="6dip"
android:paddingLeft="6dip"
android:gravity="fill" >
<TextView android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textStyle="bold"
android:singleLine="true"
android:ellipsize="marquee"
android:layout_marginBottom="2dip" />
<ProgressBar android:id="@+id/progress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="100" />
<TextView android:id="@+id/error"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:ellipsize="marquee"
android:layout_marginBottom="2dip" />
</LinearLayout>

View File

@ -573,6 +573,10 @@
<!-- Message of Remove account confirmation dialog box -->
<string name="account_delete_dlg_instructions_fmt">The account \"<xliff:g id="account">%s</xliff:g>\" will be removed from Email.</string>
<!-- Title of Upgrade Accounts activity -->
<string name="upgrade_accounts_title">Upgrade accounts</string>
<string name="upgrade_accounts_error">Unable to upgrade account</string>
<!-- Message that appears when user adds a Yahoo mail account. This alert has no title. -->
<string name="provider_note_yahoo">Mailbox access is not supported for some types of
Yahoo! mail accounts. If you have trouble connecting, visit yahoo.com for more

View File

@ -450,6 +450,14 @@ public class Account {
mSyncWindow = window;
}
public int getBackupFlags() {
return mBackupFlags;
}
public void setBackupFlags(int flags) {
mBackupFlags = flags;
}
@Override
public boolean equals(Object o) {
if (o instanceof Account) {

View File

@ -579,7 +579,7 @@ public class LegacyConversions {
* @param fromAccount the legacy account to convert to modern format
* @return an Account ready to be committed to provider
*/
/* package */ static EmailContent.Account makeAccount(Context context, Account fromAccount) {
public static EmailContent.Account makeAccount(Context context, Account fromAccount) {
EmailContent.Account result = new EmailContent.Account();

View File

@ -0,0 +1,430 @@
/*
* Copyright (C) 2008 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.activity;
import com.android.email.Account;
import com.android.email.Email;
import com.android.email.LegacyConversions;
import com.android.email.Preferences;
import com.android.email.R;
import com.android.email.mail.Folder;
import com.android.email.mail.MessagingException;
import com.android.email.mail.Store;
import com.android.email.mail.store.LocalStore;
import com.android.email.provider.EmailContent;
import com.android.email.provider.EmailContent.AccountColumns;
import android.app.Activity;
import android.app.ListActivity;
import android.content.Context;
import android.content.Intent;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Handler;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.View.OnClickListener;
import android.widget.BaseAdapter;
import android.widget.Button;
import android.widget.ListView;
import android.widget.ProgressBar;
import android.widget.TextView;
/**
* This activity will be used whenever we have a large/slow bulk upgrade operation.
*
* Note: It's preferable to check for "accounts needing upgrade" before launching this
* activity, so as to not waste time before every launch.
*
* TODO: Disable orientation changes, to keep the activity from restarting on rotation. This is
* set in the manifest but for some reason it's not working.
* TODO: More work on actual conversions
*/
public class UpgradeAccounts extends ListActivity implements OnClickListener {
private AccountInfo[] mLegacyAccounts;
private UIHandler mHandler = new UIHandler();
private AccountsAdapter mAdapter;
private ListView mListView;
private Button mProceedButton;
private ConversionTask mConversionTask;
/** This projection is for looking up accounts by their legacy UUID */
private static final String WHERE_ACCOUNT_UUID_IS = AccountColumns.COMPATIBILITY_UUID + "=?";
public static void actionStart(Activity fromActivity) {
Intent i = new Intent(fromActivity, UpgradeAccounts.class);
fromActivity.startActivity(i);
}
@Override
public void onCreate(Bundle icicle) {
super.onCreate(icicle);
Preferences p = Preferences.getPreferences(this);
loadAccountInfoArray(p.getAccounts());
Log.d(Email.LOG_TAG, "*** Preparing to upgrade " +
Integer.toString(mLegacyAccounts.length) + " accounts");
setContentView(R.layout.upgrade_accounts);
mListView = getListView();
mProceedButton = (Button) findViewById(R.id.action_button);
mProceedButton.setEnabled(false);
mProceedButton.setOnClickListener(this);
}
@Override
protected void onResume() {
super.onResume();
updateList();
// Start the big conversion engine
mConversionTask = new ConversionTask(mLegacyAccounts);
mConversionTask.execute();
}
@Override
protected void onDestroy() {
super.onDestroy();
if (mConversionTask != null &&
mConversionTask.getStatus() != ConversionTask.Status.FINISHED) {
mConversionTask.cancel(true);
mConversionTask = null;
}
}
public void onClick(View v) {
switch (v.getId()) {
case R.id.action_button:
onClickOk();
break;
}
}
private void onClickOk() {
Welcome.actionStart(UpgradeAccounts.this);
finish();
}
private void updateList() {
mAdapter = new AccountsAdapter();
getListView().setAdapter(mAdapter);
}
private static class AccountInfo {
Account account;
int maxProgress;
int progress;
String error;
}
private void loadAccountInfoArray(Account[] legacyAccounts) {
mLegacyAccounts = new AccountInfo[legacyAccounts.length];
for (int i = 0; i < legacyAccounts.length; i++) {
AccountInfo ai = new AccountInfo();
ai.account = legacyAccounts[i];
ai.maxProgress = 0;
ai.progress = 0;
ai.error = null;
mLegacyAccounts[i] = ai;
}
}
private static class ViewHolder {
TextView displayName;
ProgressBar progress;
TextView errorReport;
}
class AccountsAdapter extends BaseAdapter {
final LayoutInflater mInflater;
AccountsAdapter() {
mInflater = (LayoutInflater)getSystemService(Context.LAYOUT_INFLATER_SERVICE);
}
@Override
public boolean hasStableIds() {
return true;
}
public int getCount() {
return mLegacyAccounts.length;
}
public Object getItem(int position) {
return mLegacyAccounts[position];
}
public long getItemId(int position) {
return position;
}
public View getView(int position, View convertView, ViewGroup parent) {
View v;
if (convertView == null) {
v = newView(parent);
} else {
v = convertView;
}
bindView(v, position);
return v;
}
public View newView(ViewGroup parent) {
View v = mInflater.inflate(R.layout.upgrade_accounts_item, parent, false);
ViewHolder h = new ViewHolder();
h.displayName = (TextView) v.findViewById(R.id.name);
h.progress = (ProgressBar) v.findViewById(R.id.progress);
h.errorReport = (TextView) v.findViewById(R.id.error);
v.setTag(h);
return v;
}
public void bindView(View view, int position) {
ViewHolder vh = (ViewHolder) view.getTag();
AccountInfo ai = mLegacyAccounts[position];
vh.displayName.setText(ai.account.getDescription());
if (ai.error == null) {
vh.errorReport.setVisibility(View.GONE);
vh.progress.setVisibility(View.VISIBLE);
vh.progress.setMax(ai.maxProgress);
vh.progress.setProgress(ai.progress);
} else {
vh.progress.setVisibility(View.GONE);
vh.errorReport.setVisibility(View.VISIBLE);
vh.errorReport.setText(ai.error);
}
}
}
/**
* Handler for updating UI from async workers
*
* TODO: I don't know the right paradigm for updating a progress bar in a ListView. I'd
* like to be able to say, "update it if it's visible, skip it if it's not visible."
*/
class UIHandler extends Handler {
private static final int MSG_SET_MAX = 1;
private static final int MSG_SET_PROGRESS = 2;
private static final int MSG_INC_PROGRESS = 3;
private static final int MSG_ERROR = 4;
@Override
public void handleMessage(android.os.Message msg) {
switch (msg.what) {
case MSG_SET_MAX:
mLegacyAccounts[msg.arg1].maxProgress = msg.arg2;
mListView.invalidateViews(); // find a less annoying way to do that
break;
case MSG_SET_PROGRESS:
mLegacyAccounts[msg.arg1].progress = msg.arg2;
mListView.invalidateViews(); // find a less annoying way to do that
break;
case MSG_INC_PROGRESS:
mLegacyAccounts[msg.arg1].progress++;
mListView.invalidateViews(); // find a less annoying way to do that
break;
case MSG_ERROR:
mLegacyAccounts[msg.arg1].error = (String) msg.obj;
mListView.invalidateViews(); // find a less annoying way to do that
mProceedButton.setEnabled(true);
break;
default:
super.handleMessage(msg);
}
}
public void setMaxProgress(int accountNum, int max) {
android.os.Message msg = android.os.Message.obtain();
msg.what = MSG_SET_MAX;
msg.arg1 = accountNum;
msg.arg2 = max;
sendMessage(msg);
}
public void setProgress(int accountNum, int progress) {
android.os.Message msg = android.os.Message.obtain();
msg.what = MSG_SET_PROGRESS;
msg.arg1 = accountNum;
msg.arg2 = progress;
sendMessage(msg);
}
public void incProgress(int accountNum) {
android.os.Message msg = android.os.Message.obtain();
msg.what = MSG_INC_PROGRESS;
msg.arg1 = accountNum;
sendMessage(msg);
}
// Note: also enables the "OK" button, so we pause when complete
public void error(String error) {
android.os.Message msg = android.os.Message.obtain();
msg.what = MSG_ERROR;
msg.obj = error;
sendMessage(msg);
}
}
/**
* Everything above was UI plumbing. This is the meat of this class - a conversion
* engine to rebuild accounts from the "LocalStore" (pre Android 2.0) format to the
* "Provider" (2.0 and beyond) format.
*/
private class ConversionTask extends AsyncTask<Void, Void, Void> {
UpgradeAccounts.AccountInfo[] mAccountInfo;
final Context mContext;
final Preferences mPreferences;
public ConversionTask(UpgradeAccounts.AccountInfo[] accountInfo) {
// TODO: should I copy this?
mAccountInfo = accountInfo;
mContext = UpgradeAccounts.this;
mPreferences = Preferences.getPreferences(mContext);
}
@Override
protected Void doInBackground(Void... params) {
UIHandler handler = UpgradeAccounts.this.mHandler;
// Step 1: Analyze accounts and generate progress max values
for (int i = 0; i < mAccountInfo.length; i++) {
int estimate = UpgradeAccounts.estimateWork(mContext, mAccountInfo[i].account);
UpgradeAccounts.this.mHandler.setMaxProgress(i, estimate);
}
// Step 2: Clean out IMAP accounts
for (int i = 0; i < mAccountInfo.length; i++) {
if (mAccountInfo[i].error == null) {
cleanImapAccount(mContext, mAccountInfo[i].account, i, handler);
}
}
// Step 3: Copy accounts (and delete old accounts)
for (int i = 0; i < mAccountInfo.length; i++) {
if (mAccountInfo[i].error == null) {
copyAccount(mContext, mAccountInfo[i].account, i, handler);
}
deleteAccountStore(mContext, mAccountInfo[i].account, handler);
mAccountInfo[i].account.delete(mPreferences);
// reset the progress indicator to mark account "complete" (in case est was wrong)
UpgradeAccounts.this.mHandler.setMaxProgress(i, 100);
UpgradeAccounts.this.mHandler.setProgress(i, 100);
}
return null;
}
@Override
protected void onPostExecute(Void result) {
if (!isCancelled()) {
// if there were no errors, we never enabled the OK button, but
// we'll just proceed through anyway and return to the Welcome activity
if (!mProceedButton.isEnabled()) {
onClickOk();
}
}
}
}
/**
* Estimate the work required to convert an account.
* 1 (account) + # folders + # messages + # attachments
*/
/* package */ static int estimateWork(Context context, Account account) {
int estimate = 1; // account
try {
Store store = LocalStore.newInstance(account.getLocalStoreUri(), context, null);
Folder[] folders = store.getPersonalNamespaces();
estimate += folders.length;
for (int i = 0; i < folders.length; i++) {
Folder folder = folders[i];
folder.open(Folder.OpenMode.READ_ONLY, null);
estimate += folder.getMessageCount();
}
estimate += ((LocalStore)store).getStoredAttachmentCount();
} catch (MessagingException e) {
Log.d(Email.LOG_TAG, "Exception while estimating account size " + e);
}
return estimate;
}
/**
* Clean out an IMAP account. Anything we can reload from server, we delete. This seems
* drastic, but it greatly reduces the risk of running out of disk space by copying everything.
*/
/* package */ void cleanImapAccount(Context context, Account account, int accountNum,
UIHandler handler) {
String storeUri = account.getStoreUri();
if (!storeUri.startsWith(Store.STORE_SCHEME_IMAP)) {
return;
}
if (handler != null) {
handler.incProgress(accountNum);
}
}
/**
* Copy an account.
*/
/* package */ void copyAccount(Context context, Account account, int accountNum,
UIHandler handler) {
// If already exists- just skip it
int existCount = EmailContent.count(context, EmailContent.Account.CONTENT_URI,
WHERE_ACCOUNT_UUID_IS, new String[] { account.getUuid() });
if (existCount > 0) {
Log.d(Email.LOG_TAG, "No conversion, account exists: " + account.getDescription());
if (handler != null) {
handler.error(context.getString(R.string.upgrade_accounts_error));
}
return;
}
// Create the new account and write it
EmailContent.Account newAccount = LegacyConversions.makeAccount(context, account);
newAccount.save(context);
if (handler != null) {
handler.incProgress(accountNum);
}
// TODO folders
// TODO messages
// TODO attachments
}
/**
* Delete an account
*/
/* package */ void deleteAccountStore(Context context, Account account, UIHandler handler) {
try {
Store store = LocalStore.newInstance(account.getLocalStoreUri(), context, null);
store.delete();
} catch (MessagingException e) {
Log.d(Email.LOG_TAG, "Exception while deleting account " + e);
if (handler != null) {
handler.error(context.getString(R.string.upgrade_accounts_error));
}
}
}
}

View File

@ -16,13 +16,16 @@
package com.android.email.activity;
import com.android.email.Account;
import com.android.email.AccountBackupRestore;
import com.android.email.ExchangeUtils;
import com.android.email.Preferences;
import com.android.email.activity.setup.AccountSetupBasics;
import com.android.email.provider.EmailContent.Account;
import com.android.email.provider.EmailContent;
import com.android.email.provider.EmailContent.Mailbox;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.os.Bundle;
@ -39,6 +42,9 @@ import android.os.Bundle;
*/
public class Welcome extends Activity {
/** DO NOT CHECK IN AS 'TRUE' - DEVELOPMENT ONLY */
private static final boolean DEBUG_FORCE_UPGRADES = false;
public static void actionStart(Activity fromActivity) {
Intent i = new Intent(fromActivity, Welcome.class);
fromActivity.startActivity(i);
@ -48,6 +54,14 @@ public class Welcome extends Activity {
public void onCreate(Bundle icicle) {
super.onCreate(icicle);
// Quickly check for bulk upgrades (from older app versions) and switch to the
// upgrade activity if necessary
if (bulkUpgradesRequired(this, Preferences.getPreferences(this))) {
UpgradeAccounts.actionStart(this);
finish();
return;
}
// Restore accounts, if it has not happened already
// NOTE: This is blocking, which it should not be (in the UI thread)
// We're going to live with this for the short term and replace with something
@ -65,8 +79,8 @@ public class Welcome extends Activity {
Cursor c = null;
try {
c = getContentResolver().query(
Account.CONTENT_URI,
Account.ID_PROJECTION,
EmailContent.Account.CONTENT_URI,
EmailContent.Account.ID_PROJECTION,
null, null, null);
switch (c.getCount()) {
case 0:
@ -74,7 +88,7 @@ public class Welcome extends Activity {
break;
case 1:
c.moveToFirst();
long accountId = c.getLong(Account.CONTENT_ID_COLUMN);
long accountId = c.getLong(EmailContent.Account.CONTENT_ID_COLUMN);
MessageList.actionHandleAccount(this, accountId, Mailbox.TYPE_INBOX);
break;
default:
@ -90,4 +104,42 @@ public class Welcome extends Activity {
// In all cases, do not return to this activity
finish();
}
/**
* Test for bulk upgrades and return true if necessary
*
* TODO should be in an AsyncTask since it has DB ops
*
* @return true if upgrades required (old accounts exit). false otherwise.
*/
/* package */ boolean bulkUpgradesRequired(Context context, Preferences preferences) {
if (DEBUG_FORCE_UPGRADES) {
// build at least one fake account
Account fake = new Account(this);
fake.setDescription("Fake Account");
fake.setEmail("user@gmail.com");
fake.setName("First Last");
fake.setSenderUri("smtp://user:password@smtp.gmail.com");
fake.setStoreUri("imap://user:password@imap.gmail.com");
fake.save(preferences);
return true;
}
// 1. Get list of legacy accounts and look for any non-backup entries
Account[] legacyAccounts = preferences.getAccounts();
if (legacyAccounts.length == 0) {
return false;
}
// 2. Look at the first legacy account and decide what to do
// We only need to look at the first: If it's not a backup account, then it's a true
// legacy account, and there are one or more accounts needing upgrade. If it is a backup
// account, then we know for sure that there are no legacy accounts (backup deletes all
// old accounts, and indicates that "modern" code has already run on this device.)
if (0 != (legacyAccounts[0].getBackupFlags() & Account.BACKUP_FLAGS_IS_BACKUP)) {
return false;
} else {
return true;
}
}
}

View File

@ -365,6 +365,20 @@ public class LocalStore extends Store implements PersistentDataCallbacks {
}
}
/**
* Report # of attachments (for migration estimates only - catches all exceptions and
* just returns zero)
*/
public int getStoredAttachmentCount() {
try{
File[] attachments = mAttachmentsDir.listFiles();
return attachments.length;
}
catch (Exception e) {
return 0;
}
}
/**
* Deletes all cached attachments for the entire store.
*/