Android Content Providers and Synchronisation

Last time we looked at creating accounts for our own custom purposes on an Android device. Now we’ve got an account, we’d like to do something with it. This series of articles will cover using an account to regularly synchronise some state on an upstream server with state held on the Android device.

Implementing synchronisation opens up a new can of worms for us. Synchronisation is done in tandem with Android’s ContentProvider subsystem. We’ll have to implement one first before we can start on a synchroniser.

Each content provider is registered with the system under a unique name. We tell the system about our provider by adding it to the manifest.

    <provider android:name=".Provider"
        android:exported="false"
        android:authorities="@string/provider_authority">
    </provider>

As usual we’ll need this unique name in our code as well, so we’ll use a string reference. I’ve marked it as non-exported here as I don’t want to offer the data stored in it outside this app.

A real-world provider implementation will most likely use Android’s built in SQLite. I’d like to keep things uncomplicated here and avoid having to describe databases as well. Instead I’m going to make as simple a provider as I can, and store a single field, say a street address. However, in being simple we’ll see a facility that’s hard to find described – non-table-based providers.

Let’s begin then. A content provider uses URIs as the means of communicating the particular piece of data wanted. They are always of this form:

content://PROVIDER_AUTHORITY/per_provider_path

All providers use the “content://” schema. PROVIDER_AUTHORITY is the unique string we’ve already seen, and is what Android uses to direct provider requests on the client side to the appropriate implementation on the server side. The path is, for the most part, the provider’s choice.

All content providers derive from the ContentProvider class. Most of the URI decoding work is done by a convenience class from Android, UriMatcher.

public class Provider extends ContentProvider {
    public static final String ADDRESS_FILENAME = "address.json"
    public static final String PROVIDER_AUTHORITY = "uk.co.fussylogic.exampleprovider";
    public static final Uri PROVIDER_BASE_URI =
        Uri.parse(ContentResolver.SCHEME_CONTENT + "://" + PROVIDER_AUTHORITY);
    public static final Uri ADDRESS_URI = Uri.withAppendedPath(PROVIDER_URI, "address");

    // UriMatcher codes
    private static final int URIMATCH_ID_ADDRESS = 1;

    private static final UriMatcher sURIMatcher = new UriMatcher(UriMatcher.NO_MATCH);
    static {
        sURIMatcher.addURI(Constants.PROVIDER_AUTHORITY, "address", URIMATCH_ID_ADDRESS);
    }

    @Override
    public String getType(Uri uri) {
        // Pattern match the URI
        int uriType = sURIMatcher.match(uri);

        switch (uriType) {
        case URIMATCH_ID_ADDRESS:
            return "application/json";
        default:
            throw new IllegalArgumentException("Unknown URI: " + uri);        
        }
    }

    @Override
    public Cursor query(Uri uri, String[] projection, String selection,
            String[] selectionArgs, String sortOrder) {
        throw new IllegalArgumentException("Unknown URI: " + uri);
    }

    @Override
    public Uri insert(Uri uri, ContentValues values) {
        throw new IllegalArgumentException("Unknown/unqueryable URI: " + uri);
    }

    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {
        throw new IllegalArgumentException("Unknown URI: " + uri);
    }

    @Override
    public int update(Uri uri, ContentValues values, String selection,
            String[] selectionArgs) {
        throw new IllegalArgumentException("Unknown URI: " + uri);
    }

    @Override
    public ParcelFileDescriptor openFile(Uri uri, String mode)
            throws FileNotFoundException {
        File fileName;
        int uriType = sURIMatcher.match(uri);
        switch (uriType) {
        case URIMATCH_ID_ADDRESS:
            fileName = new File(getContext().getFilesDir(), ADDRESS_FILENAME);
            break;
        default:
            throw new IllegalArgumentException("Non-file-based URI: " + uri);
        }

        int imode = 0;
        if (mode.contains("w")) imode |= ParcelFileDescriptor.MODE_WRITE_ONLY
                | ParcelFileDescriptor.MODE_CREATE;
        if (mode.contains("r")) imode |= ParcelFileDescriptor.MODE_READ_ONLY;
        if (mode.contains("+")) imode |= ParcelFileDescriptor.MODE_APPEND;

        if( (imode & ParcelFileDescriptor.MODE_READ_ONLY) != 0 && !fileName.exists()) {
            Log.d(TAG, fileName + " doesn't exist (" + imode + ")");
        }

        return ParcelFileDescriptor.open(fileName, imode);
    }
}

Normally ContentProvider child classes wouldn’t be so bare, and they certainly wouldn’t leave all the key methods, query(), insert(), delete(), and update(), throwing exceptions. We’re also only supporting a single URI so using UriMatcher is overkill.

This should be enough to let us read and write a file under the /address content URI; which is, in turn, enough to let us write a synchronisation service.

As with our provider, we inform Android about the synchronisation service in the manifest. It’s very similar to how we added an authenticator:

    <service android:name=".SyncService"
        android:exported="false"
        android:label="@string/app_name" >
        <intent-filter>
            <action android:name="android.content.SyncAdapter" />
        </intent-filter>

        <meta-data
            android:name="android.content.SyncAdapter"
            android:resource="@xml/syncadapter" />
    </service>

The presence of the SyncAdapter intent filter makes Android go looking for the android.content.SyncAdapter meta-data, which tells which XML file to find the synchroniser information.

<?xml version="1.0" encoding="utf-8"?>
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
    android:contentAuthority="@string/provider_authority"
    android:accountType="@string/authenticator_account_type"
    android:userVisible="true"
    android:supportsUploading="true"
    android:allowParallelSyncs="false"
    android:isAlwaysSyncable="true"/>

We’ve already seen the setting for contentAuthority above, and accountType is the account we’re syncing for – this should match what we set in authenticator definition. The other fields are self-explanatory or easily looked up in the documents.

The definition of our service goes along very similar lines to the authentication service.

public class SyncService extends Service {
    // We keep one sync adapter for the service
    private static final Object sSyncAdapterLock = new Object();
    private static SyncAdapter sSyncAdapter = null;

    @Override
    public void onCreate() {
        // The race condition we have to worry about is if multiple
        // SyncService's get created at the same time, and we create multiple
        // SyncAdapters.
        synchronized (sSyncAdapterLock) {
            if (sSyncAdapter == null) {
                sSyncAdapter = new SyncAdapter(getApplicationContext(), true);
            }
        }
    }

    @Override
    public IBinder onBind(Intent intent) {
        // What if onCreate() from the first thread is still running, but
        // hasn't created the SyncAdapter yet?  We should be locked here
        // too.
        synchronized (sSyncAdapterLock) {
            return sSyncAdapter.getSyncAdapterBinder();
        }
    }

    // -------------------------------

    public class SyncAdapter extends AbstractThreadedSyncAdapter {
        protected final AccountManager mAccountManager;

        public SyncAdapter(Context context, boolean autoInitialize) {
            super(context, autoInitialize);
            mContext = context;
            mAccountManager = AccountManager.get(context);
        }

        @Override
        public void onPerformSync(Account account, Bundle extras,
                String authority, ContentProviderClient provider,
                SyncResult syncResult) {
            try {
                performSync(account, extras, authority, provider, syncResult);
            } catch (final AuthenticatorException e) {
                Log.e(TAG, "Synchronise failed ", e);
                syncResult.stats.numParseExceptions++;
            } catch (final OperationCanceledException e) {
                Log.e(TAG, "Synchronise failed ", e);
            } catch (final IOException e) {
                Log.e(TAG, "Synchronise failed ", e);
                syncResult.stats.numIoExceptions++;
            } catch (final AuthenticationException e) {
                Log.e(TAG, "Synchronise failed ", e);
                syncResult.stats.numAuthExceptions++;
            } catch (final JSONException e) {
                Log.e(TAG, "Synchronise failed ", e);
                syncResult.stats.numParseExceptions++;
            } catch (final RuntimeException e) {
                Log.e(TAG, "Synchronise failed ", e);
                // Treat runtime exception as an I/O error
                syncResult.stats.numIoExceptions++;
            }
        }

        private void performSync(Account account, Bundle extras,
                String authority, ContentProviderClient provider,
                SyncResult syncResult) throws AuthenticatorException,
                OperationCanceledException, IOException,
                AuthenticationException, ParseException, JSONException {

            // Use the account manager to request the AuthToken we'll need
            // to talk to our sample server.  If we don't have an AuthToken
            // yet, this could involve a round-trip to the server to request
            // an AuthToken.  We can block here as we're already in a non-UI
            // thread.
            final String authToken = mAccountManager.blockingGetAuthToken(
                    account,
                    getString(R.string.auth_token_type_default),
                    NOTIFY_AUTH_FAILURE);
            if( authToken == null )
                throw new AuthenticationException("No authentication token available for account, "
                        + account );

            // --- Fetch from remote
            // NetworkCommunicator is application-specific, you would
            // implement it as you see fit
            NetworkCommunicator nc = new NetworkCommunicator();
            nc.setAuthToken(token);
            JSONObject json = nc.fetchAddress();

            // --- Write to local
            // Get the appropriate URI as a constant from our provider
            Uri uri = Provider.ADDRESS_URI;
            OutputStream out = null;
            try {
                out = provider.openOutputStream(uri, "w");
            } catch (FileNotFoundException e) {
                Log.w(TAG, "Content URI not available for writing, " + uri);
                throw new IOException(e);
            }
            out.write(json.toString().getBytes(Charset.forName("UTF-8")));
            out.flush();
            out.close();
        }        
    }
}

This function is where we pull all the parts together. We fetch an authentication token from the account manager; we fetch from the remote service using that token; then write whatever we’ve fetched to the provider.

The actual mechanics of your synchronisation are obviously going to be application specific; and probably considerably more involved than this.

Let’s take a moment to notice what this tells us about Android. We have accounts (with a type that ties them to our authenticator), and within that account we can store multiple tokens, themselves each of a named type (again, as suits our application). Then we have content providers. A content provider is not (directly) tied to an account, it exists on its own. We tie a provider and an account (or rather an account type) together via a synchroniser. We could easily have multiple providers all holding different information from a single account. Each of those providers is tied to the account with its own synchronisation service. Go to your Android settings page and look in the accounts section. Open up the “Google” accounts, and pick one account. You are shown the “sync” page, mine has the following potential syncs:

  • App Data
  • Calendar
  • Chrome
  • Contacts
  • Drive
  • Gmail
  • Google Currents
  • Google Photos
  • Google Play Books
  • Google Play Magazines
  • Google Play Movies
  • Google Play Music
  • Google+
  • Google+ Auto-backup
  • Keep
  • My Tracks
  • People details

Each of these is provided by a different synchronisation service, potentially in different apps. Each of the synchronisation services is updating a different content provider, or part of a content provider. Nothing stops you from using a single account for multiple apps and content stores just like Google. You can find all the providers on your device like this:

$ adb shell dumpsys | grep ContentProviderRecord

The output of dumpsys can also tell you all the synchronisers attached to a particular provider for each of these ContentProviderRecords.

$ adb shell dumpsys | grep SyncAdapterType

In fact, the whole output of dumpsys is highly educational once you’ve got a grasp of how Android operates.

This relationship between synchroniser and provider is of particular relevance when we look at the interface through which we configure our synchronisation service. The interface is via the provider itself. For example, after we create an account, we might wish to configure its sync settings. We do so like this:

public configureSync(Account account) {
    // Which provider's synchroniser are we configuring?  We've hard
    // coded one
    String providerAuthority =  getApplicationContext().getString(R.string.provider_authority);
    // Is it possible to sync?
    ContentResolver.setIsSyncable(account, providerAuthority, true);
    // Should sync be done automatically by Android when the provider
    // sends a notifyChange() with syncToNetwork set to true?
    ContentResolver.setSyncAutomatically(account, providerAuthority, true);
    // Set some sync parameters
    Bundle params = new Bundle();
    params.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, false);
    params.putBoolean(ContentResolver.SYNC_EXTRAS_DO_NOT_RETRY, false);
    params.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, false);
    // How often should automatic sync be done? (say 15 minutes)
    ContentResolver.addPeriodicSync(account, syncAuthority, params, 15*60);
    // Request a sync right now
    ContentResolver.requestSync(account, syncAuthority, params);
}

Next time we’ll look at a more capable provider, and supplying storage for that provider with SQLite.

This entry was posted in FussyLogic and tagged , , , , . Bookmark the permalink. Trackbacks are closed, but you can post a comment.

Post a Comment

You must be logged in to post a comment.