blob: eba87150e4e7e94dc0b921260f44c1888141a043 [file] [log] [blame]
package android.content;
import android.database.sqlite.SQLiteOpenHelper;
import android.database.sqlite.SQLiteDatabase;
import android.database.Cursor;
import android.net.Uri;
import android.accounts.OnAccountsUpdateListener;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.provider.SyncConstValue;
import android.util.Config;
import android.util.Log;
import android.os.Bundle;
import android.text.TextUtils;
import java.util.Collections;
import java.util.Map;
import java.util.Vector;
import java.util.ArrayList;
import java.util.Set;
import java.util.HashSet;
import com.google.android.collect.Maps;
/**
* A specialization of the ContentProvider that centralizes functionality
* used by ContentProviders that are syncable. It also wraps calls to the ContentProvider
* inside of database transactions.
*
* @hide
*/
public abstract class AbstractSyncableContentProvider extends SyncableContentProvider {
private static final String TAG = "SyncableContentProvider";
protected SQLiteOpenHelper mOpenHelper;
protected SQLiteDatabase mDb;
private final String mDatabaseName;
private final int mDatabaseVersion;
private final Uri mContentUri;
/** the account set in the last call to onSyncStart() */
private Account mSyncingAccount;
private SyncStateContentProviderHelper mSyncState = null;
private static final String[] sAccountProjection =
new String[] {SyncConstValue._SYNC_ACCOUNT, SyncConstValue._SYNC_ACCOUNT_TYPE};
private boolean mIsTemporary;
private AbstractTableMerger mCurrentMerger = null;
private boolean mIsMergeCancelled = false;
private static final String SYNC_ACCOUNT_WHERE_CLAUSE =
SyncConstValue._SYNC_ACCOUNT + "=? AND " + SyncConstValue._SYNC_ACCOUNT_TYPE + "=?";
protected boolean isTemporary() {
return mIsTemporary;
}
private final ThreadLocal<Boolean> mApplyingBatch = new ThreadLocal<Boolean>();
private final ThreadLocal<Set<Uri>> mPendingBatchNotifications = new ThreadLocal<Set<Uri>>();
/**
* Indicates whether or not this ContentProvider contains a full
* set of data or just diffs. This knowledge comes in handy when
* determining how to incorporate the contents of a temporary
* provider into a real provider.
*/
private boolean mContainsDiffs;
/**
* Initializes the AbstractSyncableContentProvider
* @param dbName the filename of the database
* @param dbVersion the current version of the database schema
* @param contentUri The base Uri of the syncable content in this provider
*/
public AbstractSyncableContentProvider(String dbName, int dbVersion, Uri contentUri) {
super();
mDatabaseName = dbName;
mDatabaseVersion = dbVersion;
mContentUri = contentUri;
mIsTemporary = false;
setContainsDiffs(false);
if (Config.LOGV) {
Log.v(TAG, "created SyncableContentProvider " + this);
}
}
/**
* Close resources that must be closed. You must call this to properly release
* the resources used by the AbstractSyncableContentProvider.
*/
public void close() {
if (mOpenHelper != null) {
mOpenHelper.close(); // OK to call .close() repeatedly.
}
}
/**
* Override to create your schema and do anything else you need to do with a new database.
* This is run inside a transaction (so you don't need to use one).
* This method may not use getDatabase(), or call content provider methods, it must only
* use the database handle passed to it.
*/
protected void bootstrapDatabase(SQLiteDatabase db) {}
/**
* Override to upgrade your database from an old version to the version you specified.
* Don't set the DB version; this will automatically be done after the method returns.
* This method may not use getDatabase(), or call content provider methods, it must only
* use the database handle passed to it.
*
* @param oldVersion version of the existing database
* @param newVersion current version to upgrade to
* @return true if the upgrade was lossless, false if it was lossy
*/
protected abstract boolean upgradeDatabase(SQLiteDatabase db, int oldVersion, int newVersion);
/**
* Override to do anything (like cleanups or checks) you need to do after opening a database.
* Does nothing by default. This is run inside a transaction (so you don't need to use one).
* This method may not use getDatabase(), or call content provider methods, it must only
* use the database handle passed to it.
*/
protected void onDatabaseOpened(SQLiteDatabase db) {}
private class DatabaseHelper extends SQLiteOpenHelper {
DatabaseHelper(Context context, String name) {
// Note: context and name may be null for temp providers
super(context, name, null, mDatabaseVersion);
}
@Override
public void onCreate(SQLiteDatabase db) {
bootstrapDatabase(db);
mSyncState.createDatabase(db);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
if (!upgradeDatabase(db, oldVersion, newVersion)) {
mSyncState.discardSyncData(db, null /* all accounts */);
ContentResolver.requestSync(null /* all accounts */,
mContentUri.getAuthority(), new Bundle());
}
}
@Override
public void onOpen(SQLiteDatabase db) {
onDatabaseOpened(db);
mSyncState.onDatabaseOpened(db);
}
}
@Override
public boolean onCreate() {
if (isTemporary()) throw new IllegalStateException("onCreate() called for temp provider");
mOpenHelper = new AbstractSyncableContentProvider.DatabaseHelper(getContext(),
mDatabaseName);
mSyncState = new SyncStateContentProviderHelper(mOpenHelper);
AccountManager.get(getContext()).addOnAccountsUpdatedListener(
new OnAccountsUpdateListener() {
public void onAccountsUpdated(Account[] accounts) {
// Some providers override onAccountsChanged(); give them a database to
// work with.
mDb = mOpenHelper.getWritableDatabase();
// Only call onAccountsChanged on GAIA accounts; otherwise, the contacts and
// calendar providers will choke as they try to sync unknown accounts with
// AbstractGDataSyncAdapter, which will put acore into a crash loop
ArrayList<Account> gaiaAccounts = new ArrayList<Account>();
for (Account acct: accounts) {
if (acct.type.equals("com.google")) {
gaiaAccounts.add(acct);
}
}
accounts = new Account[gaiaAccounts.size()];
int i = 0;
for (Account acct: gaiaAccounts) {
accounts[i++] = acct;
}
onAccountsChanged(accounts);
TempProviderSyncAdapter syncAdapter = getTempProviderSyncAdapter();
if (syncAdapter != null) {
syncAdapter.onAccountsChanged(accounts);
}
}
}, null /* handler */, true /* updateImmediately */);
return true;
}
/**
* Get a non-persistent instance of this content provider.
* You must call {@link #close} on the returned
* SyncableContentProvider when you are done with it.
*
* @return a non-persistent content provider with the same layout as this
* provider.
*/
public AbstractSyncableContentProvider getTemporaryInstance() {
AbstractSyncableContentProvider temp;
try {
temp = getClass().newInstance();
} catch (InstantiationException e) {
throw new RuntimeException("unable to instantiate class, "
+ "this should never happen", e);
} catch (IllegalAccessException e) {
throw new RuntimeException(
"IllegalAccess while instantiating class, "
+ "this should never happen", e);
}
// Note: onCreate() isn't run for the temp provider, and it has no Context.
temp.mIsTemporary = true;
temp.setContainsDiffs(true);
temp.mOpenHelper = temp.new DatabaseHelper(null, null);
temp.mSyncState = new SyncStateContentProviderHelper(temp.mOpenHelper);
if (!isTemporary()) {
mSyncState.copySyncState(
mOpenHelper.getReadableDatabase(),
temp.mOpenHelper.getWritableDatabase(),
getSyncingAccount());
}
return temp;
}
public SQLiteDatabase getDatabase() {
if (mDb == null) mDb = mOpenHelper.getWritableDatabase();
return mDb;
}
public boolean getContainsDiffs() {
return mContainsDiffs;
}
public void setContainsDiffs(boolean containsDiffs) {
if (containsDiffs && !isTemporary()) {
throw new IllegalStateException(
"only a temporary provider can contain diffs");
}
mContainsDiffs = containsDiffs;
}
/**
* Each subclass of this class should define a subclass of {@link
* android.content.AbstractTableMerger} for each table they wish to merge. It
* should then override this method and return one instance of
* each merger, in sequence. Their {@link
* android.content.AbstractTableMerger#merge merge} methods will be called, one at a
* time, in the order supplied.
*
* <p>The default implementation returns an empty list, so that no
* merging will occur.
* @return A sequence of subclasses of {@link
* android.content.AbstractTableMerger}, one for each table that should be merged.
*/
protected Iterable<? extends AbstractTableMerger> getMergers() {
return Collections.emptyList();
}
@Override
public final int update(final Uri url, final ContentValues values,
final String selection, final String[] selectionArgs) {
mDb = mOpenHelper.getWritableDatabase();
final boolean notApplyingBatch = !applyingBatch();
if (notApplyingBatch) {
mDb.beginTransaction();
}
try {
if (isTemporary() && mSyncState.matches(url)) {
int numRows = mSyncState.asContentProvider().update(
url, values, selection, selectionArgs);
if (notApplyingBatch) {
mDb.setTransactionSuccessful();
}
return numRows;
}
int result = updateInternal(url, values, selection, selectionArgs);
if (notApplyingBatch) {
mDb.setTransactionSuccessful();
}
if (!isTemporary() && result > 0) {
if (notApplyingBatch) {
getContext().getContentResolver().notifyChange(url, null /* observer */,
changeRequiresLocalSync(url));
} else {
mPendingBatchNotifications.get().add(url);
}
}
return result;
} finally {
if (notApplyingBatch) {
mDb.endTransaction();
}
}
}
@Override
public final int delete(final Uri url, final String selection,
final String[] selectionArgs) {
mDb = mOpenHelper.getWritableDatabase();
final boolean notApplyingBatch = !applyingBatch();
if (notApplyingBatch) {
mDb.beginTransaction();
}
try {
if (isTemporary() && mSyncState.matches(url)) {
int numRows = mSyncState.asContentProvider().delete(url, selection, selectionArgs);
if (notApplyingBatch) {
mDb.setTransactionSuccessful();
}
return numRows;
}
int result = deleteInternal(url, selection, selectionArgs);
if (notApplyingBatch) {
mDb.setTransactionSuccessful();
}
if (!isTemporary() && result > 0) {
if (notApplyingBatch) {
getContext().getContentResolver().notifyChange(url, null /* observer */,
changeRequiresLocalSync(url));
} else {
mPendingBatchNotifications.get().add(url);
}
}
return result;
} finally {
if (notApplyingBatch) {
mDb.endTransaction();
}
}
}
private boolean applyingBatch() {
return mApplyingBatch.get() != null && mApplyingBatch.get();
}
@Override
public final Uri insert(final Uri url, final ContentValues values) {
mDb = mOpenHelper.getWritableDatabase();
final boolean notApplyingBatch = !applyingBatch();
if (notApplyingBatch) {
mDb.beginTransaction();
}
try {
if (isTemporary() && mSyncState.matches(url)) {
Uri result = mSyncState.asContentProvider().insert(url, values);
if (notApplyingBatch) {
mDb.setTransactionSuccessful();
}
return result;
}
Uri result = insertInternal(url, values);
if (notApplyingBatch) {
mDb.setTransactionSuccessful();
}
if (!isTemporary() && result != null) {
if (notApplyingBatch) {
getContext().getContentResolver().notifyChange(url, null /* observer */,
changeRequiresLocalSync(url));
} else {
mPendingBatchNotifications.get().add(url);
}
}
return result;
} finally {
if (notApplyingBatch) {
mDb.endTransaction();
}
}
}
@Override
public final int bulkInsert(final Uri uri, final ContentValues[] values) {
int size = values.length;
int completed = 0;
final boolean isSyncStateUri = mSyncState.matches(uri);
mDb = mOpenHelper.getWritableDatabase();
mDb.beginTransaction();
try {
for (int i = 0; i < size; i++) {
Uri result;
if (isTemporary() && isSyncStateUri) {
result = mSyncState.asContentProvider().insert(uri, values[i]);
} else {
result = insertInternal(uri, values[i]);
mDb.yieldIfContended();
}
if (result != null) {
completed++;
}
}
mDb.setTransactionSuccessful();
} finally {
mDb.endTransaction();
}
if (!isTemporary() && completed == size) {
getContext().getContentResolver().notifyChange(uri, null /* observer */,
changeRequiresLocalSync(uri));
}
return completed;
}
/**
* <p>
* Start batch transaction. {@link #endTransaction} MUST be called after
* calling this method. Those methods should be used like this:
* </p>
*
* <pre class="prettyprint">
* boolean successful = false;
* beginBatch()
* try {
* // Do something related to mDb
* successful = true;
* return ret;
* } finally {
* endBatch(successful);
* }
* </pre>
*
* @hide This method should be used only when {@link ContentProvider#applyBatch} is not enough and must be
* used with {@link #endBatch}.
* e.g. If returned value has to be used during one transaction, this method might be useful.
*/
public final void beginBatch() {
// initialize if this is the first time this thread has applied a batch
if (mApplyingBatch.get() == null) {
mApplyingBatch.set(false);
mPendingBatchNotifications.set(new HashSet<Uri>());
}
if (applyingBatch()) {
throw new IllegalStateException(
"applyBatch is not reentrant but mApplyingBatch is already set");
}
SQLiteDatabase db = getDatabase();
db.beginTransaction();
boolean successful = false;
try {
mApplyingBatch.set(true);
successful = true;
} finally {
if (!successful) {
// Something unexpected happened. We must call endTransaction() at least.
db.endTransaction();
}
}
}
/**
* <p>
* Finish batch transaction. If "successful" is true, try to call
* mDb.setTransactionSuccessful() before calling mDb.endTransaction().
* This method MUST be used with {@link #beginBatch()}.
* </p>
*
* @hide This method must be used with {@link #beginTransaction}
*/
public final void endBatch(boolean successful) {
try {
if (successful) {
// setTransactionSuccessful() must be called just once during opening the
// transaction.
mDb.setTransactionSuccessful();
}
} finally {
mApplyingBatch.set(false);
getDatabase().endTransaction();
for (Uri url : mPendingBatchNotifications.get()) {
getContext().getContentResolver().notifyChange(url, null /* observer */,
changeRequiresLocalSync(url));
}
}
}
public ContentProviderResult[] applyBatch(ArrayList<ContentProviderOperation> operations)
throws OperationApplicationException {
boolean successful = false;
beginBatch();
try {
ContentProviderResult[] results = super.applyBatch(operations);
successful = true;
return results;
} finally {
endBatch(successful);
}
}
/**
* Check if changes to this URI can be syncable changes.
* @param uri the URI of the resource that was changed
* @return true if changes to this URI can be syncable changes, false otherwise
*/
public boolean changeRequiresLocalSync(Uri uri) {
return true;
}
@Override
public final Cursor query(final Uri url, final String[] projection,
final String selection, final String[] selectionArgs,
final String sortOrder) {
mDb = mOpenHelper.getReadableDatabase();
if (isTemporary() && mSyncState.matches(url)) {
return mSyncState.asContentProvider().query(
url, projection, selection, selectionArgs, sortOrder);
}
return queryInternal(url, projection, selection, selectionArgs, sortOrder);
}
/**
* Called right before a sync is started.
*
* @param context the sync context for the operation
* @param account
*/
public void onSyncStart(SyncContext context, Account account) {
if (account == null) {
throw new IllegalArgumentException("you passed in an empty account");
}
mSyncingAccount = account;
}
/**
* Called right after a sync is completed
*
* @param context the sync context for the operation
* @param success true if the sync succeeded, false if an error occurred
*/
public void onSyncStop(SyncContext context, boolean success) {
}
/**
* The account of the most recent call to onSyncStart()
* @return the account
*/
public Account getSyncingAccount() {
return mSyncingAccount;
}
/**
* Merge diffs from a sync source with this content provider.
*
* @param context the SyncContext within which this merge is taking place
* @param diffs A temporary content provider containing diffs from a sync
* source.
* @param result a MergeResult that contains information about the merge, including
* a temporary content provider with the same layout as this provider containing
* @param syncResult
*/
public void merge(SyncContext context, SyncableContentProvider diffs,
TempProviderSyncResult result, SyncResult syncResult) {
SQLiteDatabase db = mOpenHelper.getWritableDatabase();
db.beginTransaction();
try {
synchronized(this) {
mIsMergeCancelled = false;
}
Iterable<? extends AbstractTableMerger> mergers = getMergers();
try {
for (AbstractTableMerger merger : mergers) {
synchronized(this) {
if (mIsMergeCancelled) break;
mCurrentMerger = merger;
}
merger.merge(context, getSyncingAccount(), diffs, result, syncResult, this);
}
if (mIsMergeCancelled) return;
if (diffs != null) {
mSyncState.copySyncState(
((AbstractSyncableContentProvider)diffs).mOpenHelper.getReadableDatabase(),
mOpenHelper.getWritableDatabase(),
getSyncingAccount());
}
} finally {
synchronized (this) {
mCurrentMerger = null;
}
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
/**
* Invoked when the active sync has been canceled. Sets the sync state of this provider and
* its merger to canceled.
*/
public void onSyncCanceled() {
synchronized (this) {
mIsMergeCancelled = true;
if (mCurrentMerger != null) {
mCurrentMerger.onMergeCancelled();
}
}
}
public boolean isMergeCancelled() {
return mIsMergeCancelled;
}
/**
* Subclasses should override this instead of update(). See update()
* for details.
*
* <p> This method is called within a acquireDbLock()/releaseDbLock() block,
* which means a database transaction will be active during the call;
*/
protected abstract int updateInternal(Uri url, ContentValues values,
String selection, String[] selectionArgs);
/**
* Subclasses should override this instead of delete(). See delete()
* for details.
*
* <p> This method is called within a acquireDbLock()/releaseDbLock() block,
* which means a database transaction will be active during the call;
*/
protected abstract int deleteInternal(Uri url, String selection, String[] selectionArgs);
/**
* Subclasses should override this instead of insert(). See insert()
* for details.
*
* <p> This method is called within a acquireDbLock()/releaseDbLock() block,
* which means a database transaction will be active during the call;
*/
protected abstract Uri insertInternal(Uri url, ContentValues values);
/**
* Subclasses should override this instead of query(). See query()
* for details.
*
* <p> This method is *not* called within a acquireDbLock()/releaseDbLock()
* block for performance reasons. If an implementation needs atomic access
* to the database the lock can be acquired then.
*/
protected abstract Cursor queryInternal(Uri url, String[] projection,
String selection, String[] selectionArgs, String sortOrder);
/**
* Make sure that there are no entries for accounts that no longer exist
* @param accountsArray the array of currently-existing accounts
*/
protected void onAccountsChanged(Account[] accountsArray) {
Map<Account, Boolean> accounts = Maps.newHashMap();
for (Account account : accountsArray) {
accounts.put(account, false);
}
SQLiteDatabase db = mOpenHelper.getWritableDatabase();
Map<String, String> tableMap = db.getSyncedTables();
Vector<String> tables = new Vector<String>();
tables.addAll(tableMap.keySet());
tables.addAll(tableMap.values());
db.beginTransaction();
try {
mSyncState.onAccountsChanged(accountsArray);
for (String table : tables) {
deleteRowsForRemovedAccounts(accounts, table);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
/**
* A helper method to delete all rows whose account is not in the accounts
* map. The accountColumnName is the name of the column that is expected
* to hold the account. If a row has an empty account it is never deleted.
*
* @param accounts a map of existing accounts
* @param table the table to delete from
*/
protected void deleteRowsForRemovedAccounts(Map<Account, Boolean> accounts, String table) {
SQLiteDatabase db = mOpenHelper.getWritableDatabase();
Cursor c = db.query(table, sAccountProjection, null, null,
"_sync_account, _sync_account_type", null, null);
try {
while (c.moveToNext()) {
String accountName = c.getString(0);
String accountType = c.getString(1);
if (TextUtils.isEmpty(accountName)) {
continue;
}
Account account = new Account(accountName, accountType);
if (!accounts.containsKey(account)) {
int numDeleted;
numDeleted = db.delete(table, "_sync_account=? AND _sync_account_type=?",
new String[]{account.name, account.type});
if (Config.LOGV) {
Log.v(TAG, "deleted " + numDeleted
+ " records from table " + table
+ " for account " + account);
}
}
}
} finally {
c.close();
}
}
/**
* Called when the sync system determines that this provider should no longer
* contain records for the specified account.
*/
public void wipeAccount(Account account) {
SQLiteDatabase db = mOpenHelper.getWritableDatabase();
Map<String, String> tableMap = db.getSyncedTables();
ArrayList<String> tables = new ArrayList<String>();
tables.addAll(tableMap.keySet());
tables.addAll(tableMap.values());
db.beginTransaction();
try {
// remove the SyncState data
mSyncState.discardSyncData(db, account);
// remove the data in the synced tables
for (String table : tables) {
db.delete(table, SYNC_ACCOUNT_WHERE_CLAUSE,
new String[]{account.name, account.type});
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
/**
* Retrieves the SyncData bytes for the given account. The byte array returned may be null.
*/
public byte[] readSyncDataBytes(Account account) {
return mSyncState.readSyncDataBytes(mOpenHelper.getReadableDatabase(), account);
}
/**
* Sets the SyncData bytes for the given account. The byte array may be null.
*/
public void writeSyncDataBytes(Account account, byte[] data) {
mSyncState.writeSyncDataBytes(mOpenHelper.getWritableDatabase(), account, data);
}
}