Android Data Sync

by
Tags: , ,
Category:

If you have an Android app that 1) reads and/or writes data from a SQLite database and 2) needs to update that data periodically from another source, say a RESTful web service then one approach you can take is to hook into the Android Sync Service. I recently created an Android project with a Sync Adapter to consume and publish data to a simple Go RESTful web service and I’d like to share what I’ve learned.

Android source: https://github.com/snyderep/TeamKarmaAndroid

Go source: https://github.com/snyderep/TeamKarmaServer

Relevant parts of the Android project:

  1. Content Provider
  2. Authenticator and Authenticator Service
  3. Sync Adapter and Sync Service

Content Provider

Content providers are the standard Android way of providing access to data in one process from another process. I didn’t need a provider since I did not intend to share data with other applications, but I decided that the provider abstraction was a nice clean one and it was worth using regardless. While a content provider isn’t strictly necessary for use within a sync adapter, I felt it better to do so. A provider is required for the app to provide custom search suggestions which I may incorporate at a later time.

Android docs on content providers: http://developer.android.com/guide/topics/providers/content-providers.html

The easiest way to create a Content Provider is to extend android.content.ContentProvider and fill in the relevant CRUD methods: query, insert, update and delete as well as getType(). Content providers specify data types (tables) via URIs. For example the URI “content://com.sportsteamkarma.provider/team” may represent a list of sports teams while “content://com.sportsteamkarma.provider/team/1” may represent a specific team. getType returns the MIME type associated with a content URI.

Example partial content provider:

public class DataContentProvider extends ContentProvider {
  private static final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
  private static final int TEAM_ID = 6;
  private static final String AUTHORITY = "com.sportsteamkarma.provider";
  static {
    uriMatcher.addURI(AUTHORITY, "league/#/team/#", TEAM_ID);
  }
  private DbHelper dbHelper;
  @Override
  public boolean onCreate() {
    dbHelper = new DbHelper(getContext());
    return true;
  }
  @Override
  public Cursor query(Uri uri, String[] columns, String selection,
                      String[] selectionArgs, String sortOrder) {
    List segments;
    segments = uri.getPathSegments();
    SQLiteDatabase db = dbHelper.getWritableDatabase();
    switch (uriMatcher.match(uri)) {
      case TEAM_ID:
        return db.query(
          DataContract.Team.TABLE_NAME,
          columns,
          buildSelection(
            DataContract.Team.COLUMN_NAME_LEAGUE_ID + "=? AND “ +
            DataContract.Team._ID + "=?", selection),
          buildSelectionArgs(
            new String[] {segments.get(1), segments.get(3)},
            selectionArgs),
          null, null, sortOrder);
      default:
        throw new RuntimeException("No content provider URI match.");
    }
}
  @Override
  public String getType(Uri uri) {
    switch (uriMatcher.match(uri)) {
      case TEAM_ID:
        return "vnd.android.cursor.item/vnd.com.sportsteamkarma.provider.team";
      default:
        throw new RuntimeException("No content provider URI match.");
    }
  }
  private String buildSelection(String baseSelection, String selection) {
    if (TextUtils.isEmpty(selection)) {
      return baseSelection;
    }
    return TextUtils.concat(baseSelection,
                            " AND (",
                            selection, ")").toString();
  }
  private String[] buildSelectionArgs(String[] baseArgs,
                                      String[] selectionArgs) {
    if (selectionArgs == null || selectionArgs.length == 0) {
      return baseArgs;
    }
    return ArrayUtils.addAll(baseArgs, selectionArgs);
  }
...
}

A content provider must also be registered in the manifest. A snippet like this one must be added within the application element.

<provider
    android:name=".data.provider.DataContentProvider"
    android:authorities="com.sportsteamkarma.provider"
    android:exported="false"
    android:syncable=“true">
</provider>

Authenticator and Authenticator Service

The sync framework requires an Authenticator. An Authenticator plugs into the Android accounts and authentication framework. Unfortunately even if an app doesn’t use accounts, as my app does not yet use accounts, an authenticator component is still required. Fortunately it’s not difficult to provide a no-op authenticator component, also called a Stub Authenticator in the Android docs.

Android docs on creating a Stub Authenticator: http://developer.android.com/training/sync-adapters/creating-authenticator.html

The authenticator service is necessary for the sync framework to access the authenticator. Here is a sample authenticator service:

public class AuthenticatorService extends Service {
  private Authenticator authenticator;
  @Override
  public void onCreate() {
    authenticator = new Authenticator(this);
  }
  /*
  * When the system binds to this Service to make the RPC call
  * return the authenticator’s IBinder.
  */
  @Override
  public IBinder onBind(Intent intent) {
    return authenticator.getIBinder();
  }
}

Android development generally requires a fair amount of xml to wire the bits and pieces together. This is no exception, we need xml for the authenticator metadata and we need to point to that metadata in the manifest as we register the authenticator service.

The authenticator metadata:

<?xml version="1.0" encoding="utf-8"?>
<account-authenticator
  xmlns:android="http://schemas.android.com/apk/res/android"
  android:accountType="com.sportsteamkarma"
  android:icon="@drawable/ic_launcher"
  android:smallIcon="@drawable/ic_launcher"
  android:label="@string/app_name”/>

Even if the app does not use accounts, accountType is required and should be a domain that is under your control.

And the addition of the authenticator service to the app manifest, the service element belongs within the application:

<service
  android:name="com.sportsteamkarma.service.datasync.AuthenticatorService">
  <intent-filter>
    <action android:name="android.accounts.AccountAuthenticator"/>
  </intent-filter>
  <meta-data
    android:name="android.accounts.AccountAuthenticator"
    android:resource="@xml/authenticator" />
</service>

Android will trigger the intent action android.accounts.AccountAuthenticator which will cause the AuthenticatorService to be started.

Sync Adapter and Sync Service

Now for the fun part. 2 more major pieces are needed, the sync adapter which does the work of syncing the data between the server and the local database and the sync service which is the service that ties the sync adapter into the Android sync framework.

Android docs for creating a sync adapter: http://developer.android.com/training/sync-adapters/creating-sync-adapter.html

When a sync happens the sync adapter’s onPerformSync method is called by the framework and within the scope of that method anything that is permitted by the framework can be done to sync the data. In the case of my app I built a simple REST api in Go and standard calls are made to retrieve and parse JSON. Alternatives include making SOAP calls or using web sockets or downloading flat files. Once the data is retrieved from the external source, persisting it is done via a content provider client, this is where the content provider comes into play. Again, a content provider isn’t strictly necessary, within the sync adapter you can certainly open and update a local database directly.

Here’s a sync adapter example:

public class SyncAdapter extends AbstractThreadedSyncAdapter {
  private static final String AUTHORITY = "com.sportsteamkarma.provider";
  private static final String PREFIX = "content://" + AUTHORITY + "/";
  public SyncAdapter(Context context, boolean autoInitialize) {
    super(context, autoInitialize);
  }
  @Override
  public void onPerformSync(Account account, Bundle extras, String authority,
                            ContentProviderClient contentProviderClient, SyncResult syncResult) {
    // naive implementation, delete and replace everything
    SyncResult result = new SyncResult();
    try {
      deleteSports(contentProviderClient);
      insertSports(contentProviderClient);
    } catch (RemoteException | IOException e) {
      syncResult.hasHardError();
    }
  }
  private void deleteSports(ContentProviderClient contentProviderClient)
    throws RemoteException {
    // Execute a query against the content provider.
    Cursor cursor = contentProviderClient.query(
      // The URI “content://com.sportsteamkarma.provider/sport”
      // will be recognized by the content provider.
      Uri.parse(PREFIX + "/sport”),
      // specify that we only want the _id column.
      new String[] {DataContract.Sport._ID}, null, null, null);
    if (cursor.moveToFirst()) {
      do {
        long sportId = cursor.getLong(0);
        contentProviderClient.delete(
          Uri.parse(PREFIX + "/sport/" + sportId),
          null, null);
      } while (cursor.moveToNext());
    }
  }
  /**
   * Fetch data from the remote server and populate the local database
   * via the content provider.
   */
  private void insertSports(ContentProviderClient contentProviderClient)
    throws RemoteException, IOException {
    URL url = new URL("http", "192.168.117.16", 3000, "/api/sports");
    URLConnection conn = url.openConnection();
    try (
      BufferedReader bufReader = new BufferedReader(
        new InputStreamReader(conn.getInputStream(), "UTF-8”));
      JsonReader reader = new JsonReader(bufReader)
    ) {
      reader.beginArray();
      Long id = null;
      String sportName = null;
      while (reader.hasNext()) {
        reader.beginObject();
        while (reader.hasNext()) {
          String name = reader.nextName();
          if (name.equals("id")) {
            id = reader.nextLong();
          } else if (name.equals("name")) {
            sportName = reader.nextString();
          }
        }
        reader.endObject();
        ContentValues contentValues = new ContentValues();
        contentValues.put(
          DataContract.Sport.COLUMN_NAME_SPORT_NAME, sportName);
        contentProviderClient.insert(
          Uri.parse(PREFIX + "/sport/" + id), contentValues);
      }
      reader.endArray();
    }
  }
}

To be able to trigger the adapter from the Android sync framework a Service is needed:

public class SyncService extends Service {
  private static SyncAdapter syncAdapter = null;
  // Object to use as a thread-safe lock
  private static final Object syncAdapterLock = new Object();
  @Override
  public void onCreate() {
    super.onCreate();
    /*
     * Create the sync adapter as a singleton.
     * Set the sync adapter as syncable
     * Disallow parallel syncs
     */
    synchronized (syncAdapterLock) {
      if (syncAdapter == null) {
        syncAdapter = new SyncAdapter(getApplicationContext(), true);
      }
    }
  }
  /**
   * Return an object that allows the system to invoke
   * the sync adapter.
   */
  @Override
  public IBinder onBind(Intent intent) {
    /*
     * Get the object that allows external processes
     * to call onPerformSync(). The object is created
     * in the base class code when the SyncAdapter
     * constructors call super()
     */
    return syncAdapter.getSyncAdapterBinder();
  }
}

And of course more XML is needed. xml metadata is needed for the sync adapter and also the sync service must be registered in the manifest.

syncadapter.xml – belongs in res/xml:

<sync-adapter
  xmlns:android="http://schemas.android.com/apk/res/android"
  android:contentAuthority="com.sportsteamkarma.provider"
  android:accountType="com.sportsteamkarma"
  android:userVisible="false"
  android:supportsUploading="false"
  android:allowParallelSyncs="false"
  android:isAlwaysSyncable="true"/>

Note that the accountType must match the authenticator.xml accountType.
The service entry in the app manifest (within the application element):

<service
  android:name="com.sportsteamkarma.service.datasync.SyncService"
  android:exported="true"
  android:process=":sync">
  <intent-filter>
    <action android:name="android.content.SyncAdapter"/>
  </intent-filter>
  <meta-data
    android:name="android.content.SyncAdapter"
    android:resource="@xml/syncadapter" />
</service>

Final Steps – Add Account & Start The Sync

In the scope of the application’s main activity an account must be added even if your app doesn’t use accounts. It’s a requirement of the sync framework. Fortunately a dummy account can be used. In my main activity in onCreate I have:

  // Create the account type and default account
  Account newAccount = new Account("dummyaccount", "com.sportsteamkarma");
  AccountManager accountManager = (AccountManager) this.getSystemService(ACCOUNT_SERVICE);
  // If the account already exists no harm is done but
  // a warning will be logged.
  accountManager.addAccountExplicitly(newAccount, null, null);

Finally, you can either let the sync happen periodically as Android deems it necessary or you can force a sync to happen.

To just enable the sync (not kick it off) call setSyncAutomatically on ContentResolver. An account is needed but it can be a dummy account.

ContentResolver.setSyncAutomatically(
  newAccount, "com.sportsteamkarma.provider", true);

To manually force a sync, call requestSync. The last parameter, the bundle of ‘extras’ will be passed through to the sync adapter. If there are any special conditions that the sync adapter needs to be aware of they should be passed as flags or strings here in a Bundle.

ContentResolver.requestSync(
  newAccount,"com.sportsteamkarma.provider", Bundle.EMPTY);

See other possibly interesting posts at Code Highway To Somewhere