One of the great new user-facing features of Android 2.0 is the is the new Facebook app, which brings your Facebook contacts and statuses into your Android contacts database:

So, how exactly does my Nexus One know that Chris is excited about the upcoming launch of his new mobile apps? The answer is a Contacts sync provider in the Facebook app. Read on to learn how to create your own!

Sync Providers

Sync providers are services that allow an Account to synchronize data on the device on a regular basis. Not quite sure how to create an Account? Read part one first! To implement a Contacts sync provider, we’ll need a service, some xml files, and the following permissions added to the AndroidManifest.xml:

AndroidManifest.xml snippet

The Service

Similar to our Account Authenticator service, our Contacts Sync Provider service will return a subclass of AbstractThreadedSyncAdapter from the onBind method.

ContactsSyncAdapterService.java

public class ContactsSyncAdapterService extends Service { private static final String TAG = "ContactsSyncAdapterService"; private static SyncAdapterImpl sSyncAdapter = null; private static ContentResolver mContentResolver = null; public ContactsSyncAdapterService() { super(); } private static class SyncAdapterImpl extends AbstractThreadedSyncAdapter { private Context mContext; public SyncAdapterImpl(Context context) { super(context, true); mContext = context; } @Override public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) { try { ContactsSyncAdapterService.performSync(mContext, account, extras, authority, provider, syncResult); } catch (OperationCanceledException e) { } } } @Override public IBinder onBind(Intent intent) { IBinder ret = null; ret = getSyncAdapter().getSyncAdapterBinder(); return ret; } private SyncAdapterImpl getSyncAdapter() { if (sSyncAdapter == null) sSyncAdapter = new SyncAdapterImpl(this); return sSyncAdapter; } private static void performSync(Context context, Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) throws OperationCanceledException { mContentResolver = context.getContentResolver(); Log.i(TAG, "performSync: " + account.toString()); //This is where the magic will happen! } }

The service is defined in AndroidManifest.xml like so:

AndroidManifest.xml snippet

Finally, we need an xml file to let Android know that our sync provider handles Contacts for the Account type we defined in part 1.

sync_contacts.xml

At this point we have a sync provider that doesn’t do anything, but also shouldn’t crash the Android system. Just in case, lets test it in Dev Tools first. Start the Android emulator and launch the “Dev Tools” app, then scroll down to “Sync Tester”.

The drop-down should confirm that com.android.contacts can be synced with the account type you’ve created. Select the entry for your account type and press the “Bind” button to connect to your sync service. If all goes well, Sync Tester will say it’s connected to your service. Now click “Start Sync” and select your account from the popup. Sync Tester will let you know that the sync succeeded (even though we didn’t actually do anything). At this point it should be safe to enable contact syncing from the Accounts & Sync settings screen without Android crashing and rebooting.

Contacts

Lets take a moment to discuss how Contacts on Android work. Each sync account, such as Google or Facebook, creates its own set of RawContacts which the Android system then aggregates into the single list of contacts you see in the Dialer. The RawContacts table contains several fields that Sync Providers can use for whatever they like, and in this implementation we will use the SYNC1 field to store the RawContact’s Last.fm username.

Contact Data

Data, such as name, phone number, email address, etc. is stored in a table that references a RawContact ID. The Data table can contain anything you like, and there are several predefined MIME-types available for phone numbers, email addresses, names, etc. Android will automatically try to combine RawContacts that contain the same name, email address, etc. into a single Contact, and the user can also combine and split Contacts manually from the contact edit screen.

Custom Data Types

A sync provider can store additional data about a RawContact in the Data table, and provide an xml file to tell the Contacts app how to format this row. To create a custom MIME type, we need to add an additional meta-data tag to our service entry in AndroidManifest.xml to reference a new ContactsSource XML file:

AndroidManifest.xml snippet

This ContactsSource xml file will tell the Contacts app how to format our custom MIME-types. In the example below, we specify an icon, and tell the Contacts app to use the DATA2 and DATA3 columns to render the fields. Note that this file’s format doesn’t appear to be documented outside of the source code for the Contacts app.

contacts.xml

Here’s how Facebook’s custom “Facebook Profile” field looks when rendered by the Contacts app:

Creating a RawContact

Now lets put all the above information together to create a RawContact for a Last.fm user. Our contacts will display only a name and a custom field that links to the user’s Last.fm profile. We store the user’s username in the RawContact’s SYNC1 field so we can easily look it up later. We will batch together the creation of the RawContact and the insertion of the Data, as Android will run an aggregation pass after each batch completes.

addContact method

private static void addContact(Account account, String name, String username) { Log.i(TAG, "Adding contact: " + name); ArrayList operationList = new ArrayList(); //Create our RawContact ContentProviderOperation.Builder builder = ContentProviderOperation.newInsert(RawContacts.CONTENT_URI); builder.withValue(RawContacts.ACCOUNT_NAME, account.name); builder.withValue(RawContacts.ACCOUNT_TYPE, account.type); builder.withValue(RawContacts.SYNC1, username); operationList.add(builder.build()); //Create a Data record of common type 'StructuredName' for our RawContact builder = ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI); builder.withValueBackReference(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID, 0); builder.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE); builder.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, name); operationList.add(builder.build()); //Create a Data record of custom type "vnd.android.cursor.item/vnd.fm.last.android.profile" to display a link to the Last.fm profile builder = ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI); builder.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0); builder.withValue(ContactsContract.Data.MIMETYPE, "vnd.android.cursor.item/vnd.fm.last.android.profile"); builder.withValue(ContactsContract.Data.DATA1, username); builder.withValue(ContactsContract.Data.DATA2, "Last.fm Profile"); builder.withValue(ContactsContract.Data.DATA3, "View profile"); operationList.add(builder.build()); try { mContentResolver.applyBatch(ContactsContract.AUTHORITY, operationList); } catch (Exception e) { Log.e(TAG, "Something went wrong during creation! " + e); e.printStackTrace(); } }

Social status updates

Android keeps another table for social networking status updates. Inserting a record into this table will replace any previous status if the timestamp of the insert is newer than the previous timestamp, otherwise the previous record will remain and the new insert will be discarded. A status update record is associated with a Data record, in our implementation we will associate it with our Last.fm profile record. Status records contain the status text, a package name where resources are located, an icon resource, and a label resource. Below is a function that will insert a status update, as well as updating our Last.fm Profile Data record to display the last track the user listened to. Note that for efficiency purposes, we will send all the updates in a single batch, so Android will only run a single aggregation pass at the end.

updateContactStatus method

private static void updateContactStatus(ArrayList operationList, long rawContactId, Track track) { Uri rawContactUri = ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId); Uri entityUri = Uri.withAppendedPath(rawContactUri, Entity.CONTENT_DIRECTORY); Cursor c = mContentResolver.query(entityUri, new String[] { RawContacts.SOURCE_ID, Entity.DATA_ID, Entity.MIMETYPE, Entity.DATA1 }, null, null, null); try { while (c.moveToNext()) { if (!c.isNull(1)) { String mimeType = c.getString(2); String status = ""; if (track.getNowPlaying() != null && track.getNowPlaying().equals("true")) status = "Listening to " + track.getName() + " by " + track.getArtist(); else status = "Listened to " + track.getName() + " by " + track.getArtist(); if (mimeType.equals("vnd.android.cursor.item/vnd.fm.last.android.profile")) { ContentProviderOperation.Builder builder = ContentProviderOperation.newInsert(ContactsContract.StatusUpdates.CONTENT_URI); builder.withValue(ContactsContract.StatusUpdates.DATA_ID, c.getLong(1)); builder.withValue(ContactsContract.StatusUpdates.STATUS, status); builder.withValue(ContactsContract.StatusUpdates.STATUS_RES_PACKAGE, "fm.last.android"); builder.withValue(ContactsContract.StatusUpdates.STATUS_LABEL, R.string.app_name); builder.withValue(ContactsContract.StatusUpdates.STATUS_ICON, R.drawable.icon); if (track.getDate() != null) { long date = Long.parseLong(track.getDate()) * 1000; builder.withValue(ContactsContract.StatusUpdates.STATUS_TIMESTAMP, date); } operationList.add(builder.build()); builder = ContentProviderOperation.newUpdate(ContactsContract.Data.CONTENT_URI); builder.withSelection(BaseColumns._ID + " = '" + c.getLong(1) + "'", null); builder.withValue(ContactsContract.Data.DATA3, status); operationList.add(builder.build()); } } } } finally { c.close(); } }

Here’s how the contact record looks after we’ve inserted a status update record and updated our data record:

Putting It All Together

Now that we have our utility functions written, we can fill in the body of our performSync method. We’re going to take a very simple approach to syncing: we’ll grab the list of RawContact IDs currently associated with Last.fm usernames, and we’ll create a RawContact for any Last.fm friend that doesn’t currently have one. If a RawContact already exists, we’ll fetch their most recent track and post a status update. Note that this is a one-way sync, we’re not dealing with local deletions or changes.

performSync method

private static void performSync(Context context, Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) throws OperationCanceledException { HashMap localContacts = new HashMap(); mContentResolver = context.getContentResolver(); Log.i(TAG, "performSync: " + account.toString()); // Load the local Last.fm contacts Uri rawContactUri = RawContacts.CONTENT_URI.buildUpon().appendQueryParameter(RawContacts.ACCOUNT_NAME, account.name).appendQueryParameter( RawContacts.ACCOUNT_TYPE, account.type).build(); Cursor c1 = mContentResolver.query(rawContactUri, new String[] { BaseColumns._ID, RawContacts.SYNC1 }, null, null, null); while (c1.moveToNext()) { localContacts.put(c1.getString(1), c1.getLong(0)); } ArrayList operationList = new ArrayList(); LastFmServer server = AndroidLastFmServerFactory.getServer(); try { Friends friends = server.getFriends(account.name, "", "50"); for (User user : friends.getFriends()) { if (!localContacts.containsKey(user.getName())) { if (user.getRealName().length() > 0) addContact(account, user.getRealName(), user.getName()); else addContact(account, user.getName(), user.getName()); } else { Track[] tracks = server.getUserRecentTracks(user.getName(), "true", 1); if (tracks.length > 0) { updateContactStatus(operationList, localContacts.get(user.getName()), tracks[0]); } } } if(operationList.size() > 0) mContentResolver.applyBatch(ContactsContract.AUTHORITY, operationList); } catch (Exception e1) { // TODO Auto-generated catch block e1.printStackTrace(); } }

Contact visibility

By default, new contacts that you create from your sync provider are not shown by the Contacts app. Instead, it will show only contacts it has aggregated with other visible sources. To enable the display of contacts that only exist in your provider, press the Menu button and select “Display options”, then expand the entry for your account and tap the checkbox next to “All Contacts”. This is a good default, as I don’t really want my address book to be littered with hundreds of twitter contacts!

Final Thoughts

The Android platform is awesome. The lack of documentation, however, is quite disappointing. While it’s great that I can dig through the system source code while troubleshooting and figuring things out, I shouldn’t have to!

Sync providers require Android 2.0, which is currently only available on 2 devices. The Account modifications I made to our login activity will need to be reimplemented using class introspection in order to keep compatibility with Android 1.5.

This sync provider is far from complete — I still need to figure out how to control the sync interval, set my sync provider as read-only since local changes are ignored, I might grab the user’s avatar if they don’t currently have an icon, and add a fancy “Do you want to sync your contacts?” popup to the login process similar to Facebook’s.

Hopefully this will help other developers out there struggling with the lack of documentation, as I’m looking forward to seeing twitter status updates in the contacts app in the future!

The source code for the implementation referenced here is available in my Last.fm github project under the terms of the GNU General Public License. A standalone sample project is also available here under the terms of the Apache License 2.0. Google has also released their own sample sync provider on the Android developer portal that’s a bit more complete than mine.