| 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); |
| } |
| } |