Tuesday, May 3, 2011

The revenge of the SyncAdapter: Synchronizing data in Android

Introduction
Most of the applications that are interesting in some way are connected to live data on the Internet. A common scenario is then how to keep local and remote content synchronized. Android does provide an easy and straightforward mechanism to achieve this synchronization: the SyncAdapter.

What is a SyncAdapter?
Stop anything you are doing right now and watch this excellent video by Virgil Dobjanschi. If you are in a hurry forward to 44' when Virgil introduces the third pattern.


To sum up Virgil's words: a SyncAdapter is just a service that is started by the sync manager, which in turn maintains a queue of SyncAdapters. You delegate the responsibility for choosing when to sync to the system, who knows better than you what's going on and which other applications are synchronizing data. The sync manager does also schedule resyncs according to the errors reported by the SyncAdapter, making your code cleaner and eliminating the need of alarms.

How does this work?
The basic idea is to mirror the remote data in a local database and access it through a ContentProvider. You then access this local ContentProvider from you app. You fetch data from it and impact any changes on it.
The SyncAdapter will be in charge of making the remote content match your local data. It will fetch new data and push the local changes to the server.

Writing a ContentProvider
Writing a ContentProvider is outside the scope of this entry, so go read it anywhere else, like here, here or here. There are some other players that we need to take care of.

Adding an account
You need to add an account in order to use a SyncAdapter. This account will appear in the Accounts & sync section of the settings application. From this section the user is able to add, modify or remove his account, as well as choosing what content to synchronize. Your application will also play fairly when the user enables automatic synchronization.
The AccountManager is in charge of managing user credentials. The user enters his credentials just once and all the applications that have the USE_CREDENTIALS permission can query the manager to obtain an authentication token.
In order to add an account to your application you have to extend the AbstractAccountAuthenticator class. It is okay to return null values from the methods you are not going to use. The important ones are addAcount and getAuthToken.

@Override
public Bundle addAccount(AccountAuthenticatorResponse response,
String accountType, String authTokenType,
String[] requiredFeatures, Bundle options)
throws NetworkErrorException {

final Intent intent = new Intent(mContext, AuthenticatorActivity.class);
intent.putExtra(AuthenticatorActivity.PARAM_AUTHTOKEN_TYPE,
authTokenType);
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE,
response);
final Bundle bundle = new Bundle();
bundle.putParcelable(AccountManager.KEY_INTENT, intent);
return bundle;
}
You have probably noticed that there is a reference to an activity named AuthenticatorActivity. This is just an activity that asks for the user credentials. It will be prompted when the user adds a new account from settings. You can of course use the same activity than when your application is launched.
The other important method to override is getAuthToken. You will call this method from your SyncAdapter if you need to do any authenticated requests to the server. It will return the same token until it is explicitly invalidated in the account manager. Here is where the logic to get an auth token from the server will be placed.

@Override
public Bundle getAuthToken(AccountAuthenticatorResponse response,
Account account, String authTokenType, Bundle options)
throws NetworkErrorException {
if (!authTokenType.equals(AuthenticatorActivity.PARAM_AUTHTOKEN_TYPE)) {
final Bundle result = new Bundle();
result.putString(AccountManager.KEY_ERROR_MESSAGE,
"invalid authTokenType");
return result;
}
final AccountManager am = AccountManager.get(mContext);
final String password = am.getPassword(account);
if (password != null) {
boolean verified = callSomeLoginServiceThatReturnsTrueIfValid(
account.name, password);

if (verified) {
final Bundle result = new Bundle();
result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name);
result.putString(AccountManager.KEY_ACCOUNT_TYPE,
AuthenticatorActivity.PARAM_ACCOUNT_TYPE);
result.putString(AccountManager.KEY_AUTHTOKEN,
hereGoesTheReceivedToken);
return result;
}
}
// Password is missing or incorrect. Start the activity to add the missing data.
final Intent intent = new Intent(mContext, AuthenticatorActivity.class);
// ...
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE,
response);
final Bundle bundle = new Bundle();
bundle.putParcelable(AccountManager.KEY_INTENT, intent);
return bundle;
}

Android provides a handy class called AccountAuthenticatorActivity that has a method to set the authentication result back to the account authenticator. Here is the relevant code of our AuthenticatorActivity that adds the account and sets the result.
private void finishLogin(String token) {
final Account account = new Account(mUsername, ACCOUNT_TYPE);
if (mRequestNewAccount) {
mAccountManager.addAccountExplicitly(account, mPassword, null);
// Extension point. Here we will set up the auto sync for different
// services
// Example: ContentResolver.setSyncAutomatically(account,
// ContactsContract.AUTHORITY, true);
} else {
mAccountManager.setPassword(account, mPassword);
}
final Intent intent = new Intent();
intent.putExtra(AccountManager.KEY_ACCOUNT_NAME, mUsername);
intent.putExtra(AccountManager.KEY_ACCOUNT_TYPE, ACCOUNT_TYPE);
if (token != null && token.equals(AUTHTOKEN_TYPE)) {
intent.putExtra(AccountManager.KEY_AUTHTOKEN, token);
}
setAccountAuthenticatorResult(intent.getExtras());
setResult(RESULT_OK, intent);
finish();
}

There is still one step remaining. Android does not call our implementation of the AbstractAccountAuthenticator directly. It must be wrapped in a service that returns a subclass of AbstractAccountAuthenticator from its onBind method.
public class AuthenticationService extends Service {
private static final String TAG = "AuthenticationService";
private Authenticator mAuthenticator;

@Override
public void onCreate() {
if (Log.isLoggable(TAG, Log.VERBOSE)) {
Log.v(TAG, "Authentication Service started.");
}

mAuthenticator = new Authenticator(this);
}

@Override
public void onDestroy() {
if (Log.isLoggable(TAG, Log.VERBOSE)) {
Log.v(TAG, "Authentication Service stopped.");
}
}

@Override
public IBinder onBind(Intent intent) {
if (Log.isLoggable(TAG, Log.VERBOSE)) {
Log.v(TAG, "getBinder() ... returning AccountAuthenticator binder");
}

return mAuthenticator.getIBinder();
}
}

Finally, register the service in the AndroidManifest.xml file and filter the action named android.accounts.AccountAuthenticator. The permissions required for account management are GET_ACCOUNTS, USE_CREDENTIALS, MANAGE_ACCOUNTS and AUTHENTICATE_ACCOUNTS.

Writing the SyncAdapter
First of all throw a file in the res/xml folder that describes the SyncAdapter and what kind of content it is going to synchronize.
<sync-adapter   xmlns:android="http://schemas.android.com/apk/res/android"
android:contentAuthority="com.nasatrainedmonkeys.app.providers.SomeContentProvider"
android:accountType="com.nasatrainedmonkeys.account" />

In order to write a SyncAdapter you have to extend the AbstractThreadedSyncAdapter class and override its onPerformSync method. This method will be run in a newly spawned thread when no other sync operation is running. One of the parameters is an instance of the SyncResult class which is used to inform the SyncManager about any errors in the sync process. With this result the SyncManager can decide whether to reschedule the sync in the future or not.
public class SampleSyncAdapter extends AbstractThreadedSyncAdapter {
private AccountManager mAccountManager;
private ContentResolver mContentResolver;

public SampleSyncAdapter(Context context, boolean autoInitialize) {
super(context, autoInitialize);

mAccountManager = AccountManager.get(context);
mContentResolver = context.getContentResolver();
}

@Override
public void onPerformSync(Account account, Bundle extras, String authority,
ContentProviderClient provider, SyncResult syncResult) {

String authtoken = null;
try {
authtoken = mAccountManager.blockingGetAuthToken(account,
AuthenticatorActivity.PARAM_AUTHTOKEN_TYPE, true);

// Dummy sample. Do whatever you want in this method.
List data = fetchData(authtoken);

syncRemoteDeleted(data);
syncFromServerToLocalStorage(data);
syncDirtyToServer(authtoken, getDirtyList(mContentResolver));
} catch (Exception e) {
handleException(authtoken, e, syncResult);
}
}

private void handleException(String authtoken, Exception e,
SyncResult syncResult) {
if (e instanceof AuthenticatorException) {
syncResult.stats.numParseExceptions++;
Log.e(TAG, "AuthenticatorException", e);
} else if (e instanceof OperationCanceledException) {
Log.e(TAG, "OperationCanceledExcepion", e);
} else if (e instanceof IOException) {
Log.e(TAG, "IOException", e);
syncResult.stats.numIoExceptions++;
} else if (e instanceof AuthenticationException) {
mAccountManager.invalidateAuthToken(
AuthenticatorActivity.PARAM_ACCOUNT_TYPE, authtoken);
// The numAuthExceptions require user intervention and are
// considered hard errors.
// We automatically get a new hash, so let's make SyncManager retry
// automatically.
syncResult.stats.numIoExceptions++;
Log.e(TAG, "AuthenticationException", e);
} else if (e instanceof ParseException) {
syncResult.stats.numParseExceptions++;
Log.e(TAG, "ParseException", e);
} else if (e instanceof JsonParseException) {
syncResult.stats.numParseExceptions++;
Log.e(TAG, "JSONException", e);
}
}

// ...
}

The only missing step is to return the sync adapter from the onBind method of a wrapping service and register the service in the AndroidManifest.xml to filter the intents with action android.content.SyncAdapter.
public class SampleSyncService extends Service {
private static final Object sSyncAdapterLock = new Object();
private static SampleSyncAdapter sSyncAdapter = null;

@Override
public void onCreate() {
synchronized (sSyncAdapterLock) {
if (sSyncAdapter == null) {
sSyncAdapter = new SampleSyncAdapter(getApplicationContext(), true);
}
}
}

@Override
public IBinder onBind(Intent intent) {
return sSyncAdapter.getSyncAdapterBinder();
}
}
Conclusion
Use SyncAdapters. They take away the pain of manually managing alarms and awkward synchronization retries. Let the system be responsible for choosing the best time to sync your data and play nicely with the rest of the applications.

References

10 comments:

  1. Another post to crossreference is http://stackoverflow.com/questions/5253858/why-does-contentresolver-requestsync-not-trigger-a-sync/5255360#5255360 -- I explicitly go through all the steps (I think...) to get Sync going.

    ReplyDelete
  2. Cool! I'll add it explicitly to the references as soon as blogger stops breaking the code indentation while I'm editing the post.

    Thanks for all your answers!

    ReplyDelete
  3. Just curious, if we provide the SyncAdapter, we provide the way to stop synchronizing from user. Is this really good idea, to let user stop synchronizing data when we need them up to date in application?

    ReplyDelete
  4. @Marek Sebera:

    Ideally you give the user the chance to enable or disable auto-sync, but you can always force an update and ignore that setting.

    ReplyDelete
  5. Hi, Thank you for the tutorial.
    I have a question. How should I do on the server side? Can I use PHP to check whether the log in username and password are correct?

    ReplyDelete
  6. @Han:

    The server implementation is completely independent from your android application, so you can use any technology you like :)

    ReplyDelete
  7. Adding to Han's question, what is the server side data supposed to look like? Does it have to be data in json or xml? Does the server data have to be restful? From the sample, http://samplesyncadapter2.appspot.com/
    is this a vanilla restful service or what. I'm almost there, have my content provider, have my 'account' and I'm working on the sync adapter implementation. But at some point I need to understand what the server side requirements are.

    ReplyDelete
  8. @fedup:

    The mention to RESTful services is because you can easily mirror the complete set of operations in your database (INSERT, UPDATE, DELETE) to the webservice using HTTP verbs. But it's definitely not a requirement.

    About the data format: JSON or XML are the most used ones. You should have some specific layer or module in your application that knows how to parse server responses and build a model object from it, so you always deal with Java objects regardless of the data format your server is using.

    The tendency is using JSON everywhere, so you are safe with it :)

    ReplyDelete
  9. Thanks for the really useful post! Especially thanks for the link to J.C. Wenger's answers on Stack Overflow. They are the best resource I found on this topic.

    ReplyDelete
  10. Another good resource for sync adapters, giving an actual step-by-step guide to build one is here:

    http://udinic.wordpress.com/2013/07/24/write-your-own-android-sync-adapter/

    ReplyDelete