diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 1772e9d20..a67c1f54c 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -421,5 +421,13 @@ otherwise it would make K-9 start at the wrong time + + + + diff --git a/res/layout/upgrade_databases.xml b/res/layout/upgrade_databases.xml new file mode 100644 index 000000000..fdc90ae9f --- /dev/null +++ b/res/layout/upgrade_databases.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/res/values/strings.xml b/res/values/strings.xml index 3ddb0419a..cfdaa4627 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -1093,4 +1093,8 @@ http://k9mail.googlecode.com/ Threaded view Group messages by conversation + + Upgrading databases + Upgrading databases… + Upgrading database of account \"%s\" diff --git a/src/com/fsck/k9/K9.java b/src/com/fsck/k9/K9.java index 46403e109..12948202b 100644 --- a/src/com/fsck/k9/K9.java +++ b/src/com/fsck/k9/K9.java @@ -15,6 +15,7 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; @@ -27,12 +28,14 @@ import android.util.Log; import com.fsck.k9.Account.SortType; import com.fsck.k9.activity.MessageCompose; +import com.fsck.k9.activity.UpgradeDatabases; import com.fsck.k9.controller.MessagingController; import com.fsck.k9.controller.MessagingListener; import com.fsck.k9.mail.Address; import com.fsck.k9.mail.Message; import com.fsck.k9.mail.MessagingException; import com.fsck.k9.mail.internet.BinaryTempFileBody; +import com.fsck.k9.mail.store.LocalStore; import com.fsck.k9.provider.UnreadWidgetProvider; import com.fsck.k9.service.BootReceiver; import com.fsck.k9.service.MailService; @@ -64,6 +67,23 @@ public class K9 extends Application { public static File tempDirectory; public static final String LOG_TAG = "k9"; + /** + * Name of the {@link SharedPreferences} file used to store the last known version of the + * accounts' databases. + * + *

+ * See {@link UpgradeDatabases} for a detailed explanation of the database upgrade process. + *

+ */ + private static final String DATABASE_VERSION_CACHE = "database_version_cache"; + + /** + * Key used to store the last known database version of the accounts' databases. + * + * @see #DATABASE_VERSION_CACHE + */ + private static final String KEY_LAST_ACCOUNT_DATABASE_VERSION = "last_account_database_version"; + /** * Components that are interested in knowing when the K9 instance is * available and ready. @@ -150,6 +170,16 @@ public class K9 extends Application { public static boolean ENABLE_ERROR_FOLDER = true; public static String ERROR_FOLDER_NAME = "K9mail-errors"; + /** + * A reference to the {@link SharedPreferences} used for caching the last known database + * version. + * + * @see #checkCachedDatabaseVersion() + * @see #setDatabasesUpToDate(boolean) + */ + private static SharedPreferences sDatabaseVersionCache; + + private static boolean mAnimations = true; private static boolean mConfirmDelete = false; @@ -208,6 +238,11 @@ public class K9 extends Application { private static boolean sUseBackgroundAsUnreadIndicator = true; private static boolean sThreadedViewEnabled = true; + /** + * @see #areDatabasesUpToDate() + */ + private static boolean sDatabasesUpToDate = false; + /** * The MIME type(s) of attachments we're willing to view. @@ -487,6 +522,8 @@ public class K9 extends Application { galleryBuggy = checkForBuggyGallery(); + checkCachedDatabaseVersion(); + Preferences prefs = Preferences.getPreferences(this); loadPrefs(prefs); @@ -581,6 +618,31 @@ public class K9 extends Application { notifyObservers(); } + /** + * Loads the last known database version of the accounts' databases from a + * {@link SharedPreference}. + * + *

+ * If the stored version matches {@link LocalStore#DB_VERSION} we know that the databases are + * up to date.
+ * Using {@code SharedPreferences} should be a lot faster than opening all SQLite databases to + * get the current database version. + *

+ * See {@link UpgradeDatabases} for a detailed explanation of the database upgrade process. + *

+ * + * @see #areDatabasesUpToDate() + */ + public void checkCachedDatabaseVersion() { + sDatabaseVersionCache = getSharedPreferences(DATABASE_VERSION_CACHE, MODE_PRIVATE); + + int cachedVersion = sDatabaseVersionCache.getInt(KEY_LAST_ACCOUNT_DATABASE_VERSION, 0); + + if (cachedVersion < LocalStore.DB_VERSION) { + K9.setDatabasesUpToDate(false); + } + } + public static void loadPrefs(Preferences prefs) { SharedPreferences sprefs = prefs.getPreferences(); DEBUG = sprefs.getBoolean("enableDebugLogging", false); @@ -1150,4 +1212,38 @@ public class K9 extends Application { public static synchronized void setThreadedViewEnabled(boolean enable) { sThreadedViewEnabled = enable; } + + /** + * Check if we already know whether all databases are using the current database schema. + * + *

+ * This method is only used for optimizations. If it returns {@code true} we can be certain that + * getting a {@link LocalStore} instance won't trigger a schema upgrade. + *

+ * + * @return {@code true}, if we know that all databases are using the current database schema. + * {@code false}, otherwise. + */ + public static synchronized boolean areDatabasesUpToDate() { + return sDatabasesUpToDate; + } + + /** + * Remember that all account databases are using the most recent database schema. + * + * @param save + * Whether or not to write the current database version to the + * {@code SharedPreferences} {@link #DATABASE_VERSION_CACHE}. + * + * @see #areDatabasesUpToDate() + */ + public static synchronized void setDatabasesUpToDate(boolean save) { + sDatabasesUpToDate = true; + + if (save) { + Editor editor = sDatabaseVersionCache.edit(); + editor.putInt(KEY_LAST_ACCOUNT_DATABASE_VERSION, LocalStore.DB_VERSION); + editor.commit(); + } + } } diff --git a/src/com/fsck/k9/activity/Accounts.java b/src/com/fsck/k9/activity/Accounts.java index 5e04a6ede..34fc85dd8 100644 --- a/src/com/fsck/k9/activity/Accounts.java +++ b/src/com/fsck/k9/activity/Accounts.java @@ -371,6 +371,12 @@ public class Accounts extends K9ListActivity implements OnItemClickListener { if (accounts.length < 1) { WelcomeMessage.showWelcomeMessage(this); finish(); + return; + } + + if (UpgradeDatabases.actionUpgradeDatabases(this, intent)) { + finish(); + return; } boolean startup = intent.getBooleanExtra(EXTRA_STARTUP, true); diff --git a/src/com/fsck/k9/activity/UpgradeDatabases.java b/src/com/fsck/k9/activity/UpgradeDatabases.java new file mode 100644 index 000000000..3fd46723d --- /dev/null +++ b/src/com/fsck/k9/activity/UpgradeDatabases.java @@ -0,0 +1,220 @@ +package com.fsck.k9.activity; + +import com.fsck.k9.Account; +import com.fsck.k9.K9; +import com.fsck.k9.Preferences; +import com.fsck.k9.R; +import com.fsck.k9.mail.Store; +import com.fsck.k9.service.DatabaseUpgradeService; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.support.v4.content.LocalBroadcastManager; +import android.widget.TextView; + + +/** + * This activity triggers a database upgrade if necessary and displays the current upgrade progress. + * + *

+ * The current upgrade process works as follows: + *

    + *
  1. Activities that access an account's database call + * {@link #actionUpgradeDatabases(Context, Intent)} in their {@link Activity#onCreate(Bundle)} + * method.
  2. + *
  3. {@link #actionUpgradeDatabases(Context, Intent)} will call {@link K9#areDatabasesUpToDate()} + * to check if we already know whether the databases have been upgraded.
  4. + *
  5. {@link K9#areDatabasesUpToDate()} will compare the last known database version stored in a + * {@link SharedPreferences} file to {@link com.fsck.k9.mail.store.LocalStore#DB_VERSION}. This + * is done as an optimization because it's faster than opening all of the accounts' databases + * one by one.
  6. + *
  7. If there was an error reading the cached database version or if it shows the databases need + * upgrading this activity ({@code UpgradeDatabases}) is started.
  8. + *
  9. This activity will display a spinning progress indicator and start + * {@link DatabaseUpgradeService}.
  10. + *
  11. {@link DatabaseUpgradeService} will acquire a partial wake lock (with a 10 minute timeout), + * start a background thread to perform the database upgrades, and report the progress using + * {@link LocalBroadcastManager} to this activity which will update the UI accordingly.
  12. + *
  13. Once the upgrade is complete {@link DatabaseUpgradeService} will notify this activity, + * release the wake lock, and stop itself.
  14. + *
  15. This activity will start the original activity using the intent supplied when calling + * {@link #actionUpgradeDatabases(Context, Intent)}.
  16. + *
+ *

+ * Currently we make no attempts to stop the background code (e.g. {@link MessagingController}) from + * opening the accounts' databases. If this happens the upgrade is performed in one of the + * background threads and not by {@link DatabaseUpgradeService}. But this is not a problem. Due to + * the locking in {@link Store#getLocalInstance(Account, android.app.Application)} the upgrade + * service will block in the {@link Account#getLocalStore()} call and from the outside (especially + * for this activity) it will appear as if {@link DatabaseUpgradeService} is performing the upgrade. + *

+ */ +public class UpgradeDatabases extends K9Activity { + private static final String ACTION_UPGRADE_DATABASES = "upgrade_databases"; + private static final String EXTRA_START_INTENT = "start_intent"; + + + /** + * Start the {@link UpgradeDatabases} activity if necessary. + * + * @param context + * The {@link Context} used to start the activity. + * @param startIntent + * After the database upgrade is complete an activity is started using this intent. + * Usually this is the intent that was used to start the calling activity. + * Never {@code null}. + * + * @return {@code true}, if the {@code UpgradeDatabases} activity was started. In this case the + * calling activity is expected to finish itself.
+ * {@code false}, if the account databases don't need upgrading. + */ + public static boolean actionUpgradeDatabases(Context context, Intent startIntent) { + if (K9.areDatabasesUpToDate()) { + return false; + } + + Intent intent = new Intent(context, UpgradeDatabases.class); + intent.setAction(ACTION_UPGRADE_DATABASES); + intent.putExtra(EXTRA_START_INTENT, startIntent); + + // Make sure this activity is only running once + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP); + + context.startActivity(intent); + return true; + } + + + private Intent mStartIntent; + + private TextView mUpgradeText; + + private LocalBroadcastManager mLocalBroadcastManager; + private UpgradeDatabaseBroadcastReceiver mBroadcastReceiver; + private IntentFilter mIntentFilter; + private Preferences mPreferences; + + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // If the databases have already been upgraded there's no point in displaying this activity. + if (K9.areDatabasesUpToDate()) { + launchOriginalActivity(); + return; + } + + mPreferences = Preferences.getPreferences(getApplicationContext()); + + initializeLayout(); + + decodeExtras(); + + setupBroadcastReceiver(); + } + + /** + * Initialize the activity's layout + */ + private void initializeLayout() { + setContentView(R.layout.upgrade_databases); + + mUpgradeText = (TextView) findViewById(R.id.databaseUpgradeText); + } + + /** + * Decode extras in the intent used to start this activity. + */ + private void decodeExtras() { + Intent intent = getIntent(); + mStartIntent = intent.getParcelableExtra(EXTRA_START_INTENT); + } + + /** + * Setup the broadcast receiver used to receive progress updates from + * {@link DatabaseUpgradeService}. + */ + private void setupBroadcastReceiver() { + mLocalBroadcastManager = LocalBroadcastManager.getInstance(this); + mBroadcastReceiver = new UpgradeDatabaseBroadcastReceiver(); + + mIntentFilter = new IntentFilter(DatabaseUpgradeService.ACTION_UPGRADE_PROGRESS); + mIntentFilter.addAction(DatabaseUpgradeService.ACTION_UPGRADE_COMPLETE); + } + + @Override + public void onResume() { + super.onResume(); + + // Check if the upgrade was completed while the activity was paused. + if (K9.areDatabasesUpToDate()) { + launchOriginalActivity(); + return; + } + + // Register the broadcast receiver to listen for progress reports from + // DatabaseUpgradeService. + mLocalBroadcastManager.registerReceiver(mBroadcastReceiver, mIntentFilter); + + // Now that the broadcast receiver was registered start DatabaseUpgradeService. + DatabaseUpgradeService.startService(this); + } + + @Override + public void onPause() { + // The activity is being paused, so there's no point in listening to the progress of the + // database upgrade service. + mLocalBroadcastManager.unregisterReceiver(mBroadcastReceiver); + + super.onPause(); + } + + /** + * Finish this activity and launch the original activity using the supplied intent. + */ + private void launchOriginalActivity() { + finish(); + startActivity(mStartIntent); + } + + /** + * Receiver for broadcasts send by {@link DatabaseUpgradeService}. + */ + class UpgradeDatabaseBroadcastReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, final Intent intent) { + String action = intent.getAction(); + + if (DatabaseUpgradeService.ACTION_UPGRADE_PROGRESS.equals(action)) { + /* + * Information on the current upgrade progress + */ + + String accountUuid = intent.getStringExtra( + DatabaseUpgradeService.EXTRA_ACCOUNT_UUID); + + Account account = mPreferences.getAccount(accountUuid); + + if (account != null) { + String formatString = getString(R.string.upgrade_database_format); + String upgradeStatus = String.format(formatString, account.getDescription()); + mUpgradeText.setText(upgradeStatus); + } + + } else if (DatabaseUpgradeService.ACTION_UPGRADE_COMPLETE.equals(action)) { + /* + * Upgrade complete + */ + + launchOriginalActivity(); + } + } + } +} diff --git a/src/com/fsck/k9/mail/Store.java b/src/com/fsck/k9/mail/Store.java index b41a6a0cf..d3a67989d 100644 --- a/src/com/fsck/k9/mail/Store.java +++ b/src/com/fsck/k9/mail/Store.java @@ -3,6 +3,7 @@ package com.fsck.k9.mail; import java.util.HashMap; import java.util.List; +import java.util.concurrent.ConcurrentHashMap; import android.app.Application; import android.content.Context; @@ -33,10 +34,16 @@ public abstract class Store { private static HashMap sStores = new HashMap(); /** - * Local stores indexed by UUid because the Uri may change due to migration to/from SD-card. + * Local stores indexed by UUID because the Uri may change due to migration to/from SD-card. */ - private static HashMap sLocalStores = new HashMap(); + private static ConcurrentHashMap sLocalStores = new ConcurrentHashMap(); + /** + * Lock objects indexed by account UUID. + * + * @see #getLocalInstance(Account, Application) + */ + private static ConcurrentHashMap sAccountLocks = new ConcurrentHashMap(); /** * Get an instance of a remote mail store. @@ -72,16 +79,36 @@ public abstract class Store { /** * Get an instance of a local mail store. - * @throws UnavailableStorageException if not {@link StorageProvider#isReady(Context)} + * + * @throws UnavailableStorageException + * if not {@link StorageProvider#isReady(Context)} */ - public synchronized static LocalStore getLocalInstance(Account account, Application application) throws MessagingException { - Store store = sLocalStores.get(account.getUuid()); - if (store == null) { - store = new LocalStore(account, application); - sLocalStores.put(account.getUuid(), store); - } + public static LocalStore getLocalInstance(Account account, Application application) + throws MessagingException { - return (LocalStore) store; + String accountUuid = account.getUuid(); + + // Create new per-account lock object if necessary + sAccountLocks.putIfAbsent(accountUuid, new Object()); + + // Get the account's lock object + Object lock = sAccountLocks.get(accountUuid); + + // Use per-account locks so DatabaseUpgradeService always knows which account database is + // currently upgraded. + synchronized (lock) { + Store store = sLocalStores.get(accountUuid); + + if (store == null) { + // Creating a LocalStore instance will create or upgrade the database if + // necessary. This could take some time. + store = new LocalStore(account, application); + + sLocalStores.put(accountUuid, store); + } + + return (LocalStore) store; + } } /** diff --git a/src/com/fsck/k9/mail/store/LocalStore.java b/src/com/fsck/k9/mail/store/LocalStore.java index af6548915..f31523164 100644 --- a/src/com/fsck/k9/mail/store/LocalStore.java +++ b/src/com/fsck/k9/mail/store/LocalStore.java @@ -111,7 +111,7 @@ public class LocalStore extends Store implements Serializable { */ private static final int UID_CHECK_BATCH_SIZE = 500; - protected static final int DB_VERSION = 45; + public static final int DB_VERSION = 45; protected String uUid = null;