ZOFTINO.COM

Sync Data Between Device and Server in Android

Android provides sync adapter framework which enables apps to create components to sync data between web server and device in the back ground. Android system uses other android system components like android accounts, bound services and content providers to provide data transfer framework.

While it is possible and easy to design and create data sync components without using android sync adapter framework, using sync adapter framework provides certain features which take time and resources to develop on your own.

Features of Android Sync Adapter Framework

Depending on the type of data and how it is protected, servers which provide data may require clients to authenticate themselves before they can access data. Android sync adapter framework uses android accounts framework to simplify capturing credentials and authenticating user in order to access server data.

Data transfer tasks can be scheduled to run automatically at scheduled times or in response to events. Sync adapter framework runs data transfer component only when there is a network connection on device.

Using sync adapter framework for data transfer reduces battery usage because of centralized management of data transfer jobs as jobs from all apps can be run at the same time.

Components Involved in Creating Data Transfer Feature Using Sync Adapter Framework

To utilize sync adapter framework, you need to provide sync adapter, authenticator and content provider components. If you don’t use login, you have to provide stub authenticator. Similarly, if you don’t save data in content provider, you must provide stub content provider.

Sync Adapter Component

The component that contains data transfer code is called sync adapter component. Sync adapter component consists of sync adapter class, bound service and sync adapter xml file.

Sync adapter class is created by extending AbstractThreadedSyncAdapter class and implementing onPerformSync() method. Bound service provides interface for sync adapter framework to interact with sync adapter class. Sync adapter xml file provides information about sync adapter related to loading and scheduling.

Authenticator Component

Sync adapter framework expects authenticator component as it assumes that login is required to access server data. If your app does not use login, you can just provide dummy authenticator component.

Authenticator component works with android accounts framework. Authenticator component consists of authenticator class created by extending and implementing AbstractAccountAuthenticator abstract class, bound service used to interact with authenticator class, accounts metadata file and configuration in manifest.xml file.

To know more about android accounts, you can read my previous post android accounts, account manager and custom account type.

Content Provider

Sync adapter framework expects content provider. Content provider is used to store and share data on the device. If you don’t want to store data in content provide and store it in other format, you need to provide dummy content provider.

Content provider class is created by extending and implementing ContentProvider class.

To know more about content providers, read my previous post content provider.

Running Sync Adapter

Sync adapter can be run at a certain time each day or in regular intervals. For example, to run sync adapter after some time has elapsed, you need call ContentResolver.addPeriodicSync() method passing interval and other parameters. Method addPeriodicSync() can be called from main activity. Calling addPeriodicSync() multiple times won’t create multiple periodic sync request. Only one periodic sync request is scheduled for the account, authority and extras combination.

Sync adapter can also be run on demand in response to UI events by calling ContentResolver.requestSync().In the similar way, sync adapter can be run in response to data changes on the device and on server by calling ContentResolver.requestSync().

Data Transfer Using Sync Adapter Framework Example

I’ll show how to create data transfer component using sync adapter framework by taking cashback app example. In this example, data transfer component downloads and processes latest cashback data feed from server and adds, updates and deletes data in content provider on the device.

And also example shows how to run sync adapter by using activity which runs sync adapter component periodically and on demand.

Sync Adapter Class

package com.zoftino.content;


import android.accounts.Account;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.ContentProviderOperation;
import android.content.ContentResolver;
import android.content.Context;
import android.content.SyncResult;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;

public class CashbackSyncAdapter extends AbstractThreadedSyncAdapter {

    private static final String CASHBACK_TABLE = "cashback_t";

    public static final Uri AUTHORITY_URI = Uri.parse("content://com.zoftino.sync.cashback");

    public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, CASHBACK_TABLE);

    private static final String CASHBACK_URL = "http://testcashback.cashback/";

    private final ContentResolver mContentResolver;

    private static final String[] PROJECTION = new String[]{
            "_id",
            "STORE",
            "CASHBACK"
    };

    public CashbackSyncAdapter(Context context, boolean autoInitialize) {
        super(context, autoInitialize);
        mContentResolver = context.getContentResolver();
    }

    public CashbackSyncAdapter(Context context, boolean autoInitialize, boolean allowParallelSyncs) {
        super(context, autoInitialize, allowParallelSyncs);
        mContentResolver = context.getContentResolver();
    }

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

        try {
            final URL location = new URL(CASHBACK_URL);
            InputStream stream = null;

            try {
                Log.i("cashback sync adaper", "onPerformSync running");

                // stream = downloadUrl(location);
                // String feedData = readInput(stream);

                //syn adapter can be run without downloading using test data
                String feedData = getTestData();

                Map<Long, CashbackEntity> cashbackFeed = processCashbackFeed(feedData);

                addUpdateDeleteLocalData(cashbackFeed, syncResult);

            } finally {
                if (stream != null) {
                    stream.close();
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
            return;
        }

    }

    public void addUpdateDeleteLocalData(Map<Long, CashbackEntity> cashbackData, final SyncResult syncResult)
            throws Exception {

        final ContentResolver contentResolver = getContext().getContentResolver();
        ArrayList<ContentProviderOperation> batch = new ArrayList<ContentProviderOperation>();

        Cursor c = contentResolver.query(CONTENT_URI, PROJECTION, null, null, null);

        long id;
        String store;
        String cashback;

        if (c != null) {
            while (c.moveToNext()) {
                syncResult.stats.numEntries++;
                id = c.getLong(0);
                store = c.getString(1);
                cashback = c.getString(2);

                CashbackEntity cashbackExist = cashbackData.get(id);
                if (cashbackExist != null) {

                    cashbackData.remove(id);

                    Uri existingUri = CONTENT_URI.buildUpon()
                            .appendPath(Long.toString(id)).build();
                    //update record as local data is different
                    if ((cashbackExist.getStore() != null && !cashbackExist.getStore().equals(store)) ||
                            (cashbackExist.getCashback() != null && !cashbackExist.getCashback().equals(cashback))) {

                        batch.add(ContentProviderOperation.newUpdate(existingUri)
                                .withValue("STORE", cashbackExist.getStore())
                                .withValue("CASHBACK", cashbackExist.getCashback())
                                .build());
                        syncResult.stats.numUpdates++;
                    }
                } else {
                    //delete local record as it does not exist in server
                    Uri deleteUri = CONTENT_URI.buildUpon()
                            .appendPath(Long.toString(id)).build();
                    batch.add(ContentProviderOperation.newDelete(deleteUri).build());
                    syncResult.stats.numDeletes++;
                }
            }
            c.close();
        }

        // Add New records
        for (CashbackEntity ce : cashbackData.values()) {

            batch.add(ContentProviderOperation.newInsert(CONTENT_URI)
                    .withValue("_id", ce.id)
                    .withValue("STORE", ce.getStore())
                    .withValue("CASHBACK", ce.getCashback())
                    .build());
            syncResult.stats.numInserts++;

        }

        mContentResolver.applyBatch("com.zoftino.sync.cashback", batch);

    }

    private Map<Long, CashbackEntity> processCashbackFeed(String feed) {

        Map<Long, CashbackEntity> cashbackFeed = new HashMap<Long, CashbackEntity>();

        String[] cbrecs = feed.split(",");
        for (String cbrec : cbrecs) {
            String[] cbdata = cbrec.split("\\|");

            CashbackEntity cbe = new CashbackEntity();
            cbe.setId(Long.valueOf(cbdata[0]));
            cbe.setStore(cbdata[1]);
            cbe.setCashback(cbdata[2]);

            cashbackFeed.put(cbe.getId(), cbe);

        }
        Log.i("cashback sync adaper", "total recs in map " + cashbackFeed.keySet().size());
        return cashbackFeed;
    }

    private InputStream downloadUrl(final URL url) throws IOException {
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        conn.setReadTimeout(20000);
        conn.setConnectTimeout(15000);
        conn.setRequestMethod("GET");
        conn.setDoInput(true);

        conn.connect();
        return conn.getInputStream();
    }

    private String readInput(InputStream is) {
        String str = "";
        StringBuffer buf = new StringBuffer();

        try {
            BufferedReader reader = new BufferedReader(new InputStreamReader(is));
            if (is != null) {
                while ((str = reader.readLine()) != null) {
                    buf.append(str + ",");
                }
            }
        } catch (Exception e) {

        } finally {
            try {
                is.close();
            } catch (Throwable ignore) {
            }
        }
        return buf.toString();
    }

    private String getTestData() {
        String cashbackFedd = "1|fashion store|Upto 2% cashback,";
        cashbackFedd = cashbackFedd + "2|shoes store|Upto 21% cashback,";
        cashbackFedd = cashbackFedd + "3|electronics store|Upto 12% cashback,";
        cashbackFedd = cashbackFedd + "4|travel store|Upto 24% cashback,";
        cashbackFedd = cashbackFedd + "6|mobiles store|Upto 32% cashback,";
        cashbackFedd = cashbackFedd + "7|blah store|Upto 52% cashback,";
        cashbackFedd = cashbackFedd + "8|xtz store|Upto 23% cashback";
        cashbackFedd = cashbackFedd + "9|xyz store|Upto 23% cashback";
        return cashbackFedd;
    }
}

Sync Adapter Bound Service

 package com.zoftino.content;

import android.app.Service;
import android.content.Intent;
import android.os.IBinder;

public class CashbackSyncAdapterService extends Service {

    private static final Object sSyncAdapterLock = new Object();
    private static CashbackSyncAdapter sSyncAdapter = null;

    @Override
    public void onCreate() {
        super.onCreate();

        synchronized (sSyncAdapterLock) {
            if (sSyncAdapter == null) {
                sSyncAdapter = new CashbackSyncAdapter(getApplicationContext(), true);
            }
        }
    }
    @Override
    public void onDestroy() {
        super.onDestroy();
    }
    @Override
    public IBinder onBind(Intent intent) {
        return sSyncAdapter.getSyncAdapterBinder();
    }
}

Sync Adapter Metadata Xml

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

Content Provider

 package com.zoftino.content;

import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.Context;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.sqlite.SQLiteCursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.net.Uri;
import android.support.annotation.Nullable;

public class CashbackContentProvider  extends ContentProvider {

    private CashbackSQLiteOpenHelper sqLiteOpenHelper;

    private static final String CASHBACK_DBNAME = "zoftino_cashback";

    private static final String CASHBACK_TABLE = "cashback_t";

    private SQLiteDatabase cbDB;

    private static final String SQL_CREATE_CASHBACK = "CREATE TABLE " +
            CASHBACK_TABLE +
            "(" +
            "_id INTEGER PRIMARY KEY, " +
            "STORE TEXT, " +
            "CASHBACK TEXT)";

    private static final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
    static {
        uriMatcher.addURI("com.zoftino.sync.cashback", CASHBACK_TABLE, 1);
    }
    @Override
    public boolean onCreate() {
        sqLiteOpenHelper = new CashbackSQLiteOpenHelper( getContext(), CASHBACK_DBNAME, SQL_CREATE_CASHBACK);
        return true;
    }

    @Nullable
    @Override
    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
                        String sortOrder) {

        String tableNme = "";
        switch(uriMatcher.match(uri)){
            case 1 :
                tableNme = CASHBACK_TABLE;
                break;
            default:
                return null;
        }

        cbDB = sqLiteOpenHelper.getWritableDatabase();

        Cursor cursor = (SQLiteCursor) cbDB.query(tableNme, projection, selection, selectionArgs,
                null, null, sortOrder);
        return cursor;
    }

    @Nullable
    @Override
    public String getType(Uri uri) {
        return null;
    }

    @Nullable
    @Override
    public Uri insert(Uri uri, ContentValues contentValues) {

        try {
            String tableNme = "";
            switch (uriMatcher.match(uri)) {
                case 1:
                    tableNme = CASHBACK_TABLE;
                    break;
                default:
                    return null;
            }

            cbDB = sqLiteOpenHelper.getWritableDatabase();
            long rowid = cbDB.insert(tableNme, null, contentValues);
            return getContentUriRow(rowid);
        }catch (Exception e){
            e.printStackTrace();
        }
        return null;
    }

    @Override
    public int delete(Uri uri, String where, String[] selectionArgs) {
        String tableNme = CASHBACK_TABLE;

        cbDB = sqLiteOpenHelper.getWritableDatabase();

        return cbDB.delete(tableNme, where, selectionArgs);
    }

    @Override
    public int update(Uri uri, ContentValues contentValues, String where, String[] selectionArgs) {
        String tableNme = CASHBACK_TABLE;

        cbDB = sqLiteOpenHelper.getWritableDatabase();
        return cbDB.update(tableNme,contentValues,where,selectionArgs );
    }
    private Uri getContentUriRow(long rowid){
        return Uri.fromParts("com.zoftino.sync.cashback", CASHBACK_TABLE, Long.toString(rowid));
    }
    public class CashbackSQLiteOpenHelper extends SQLiteOpenHelper {

        private String sql;
        CashbackSQLiteOpenHelper(Context context, String dbName, String msql) {
            super(context, dbName, null, 1);
            sql = msql;
        }
        @Override
        public void onCreate(SQLiteDatabase db) {
            db.execSQL(sql);
        }

        @Override
        public void onUpgrade(SQLiteDatabase sqLiteDatabase, int i, int i1) {  }
    }
}

Authenticator

 package com.zoftino.content;


import android.accounts.AbstractAccountAuthenticator;
import android.accounts.Account;
import android.accounts.AccountAuthenticatorResponse;
import android.accounts.NetworkErrorException;
import android.content.Context;
import android.os.Bundle;

import static android.accounts.AccountManager.KEY_BOOLEAN_RESULT;

public class CashbackAuthenticator extends AbstractAccountAuthenticator {


    private Context context;
    public CashbackAuthenticator(Context ctx){
        super(ctx);
        context = ctx;
    }
    @Override
    public Bundle editProperties(AccountAuthenticatorResponse accountAuthenticatorResponse, String s) {
        return null;
    }

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

        return null;
    }

    @Override
    public Bundle confirmCredentials(AccountAuthenticatorResponse accountAuthenticatorResponse, Account account, Bundle bundle) throws NetworkErrorException {
        return null;
    }

    @Override
    public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException {
        return null;
    }

    @Override
    public String getAuthTokenLabel(String s) {
        return null;
    }

    @Override
    public Bundle updateCredentials(AccountAuthenticatorResponse accountAuthenticatorResponse, Account account, String s, Bundle bundle) throws NetworkErrorException {
        return null;
    }

    @Override
    public Bundle hasFeatures(AccountAuthenticatorResponse accountAuthenticatorResponse, Account account, String[] strings) throws NetworkErrorException {
        final Bundle result = new Bundle();
        result.putBoolean(KEY_BOOLEAN_RESULT, false);
        return result;
    }
}

Authenticator Metadata File

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

Authenticator Bound Service

 package com.zoftino.content;

import android.accounts.Account;
import android.app.Service;
import android.content.Intent;
import android.os.IBinder;

public class CashbackAccountService extends Service {

    private static final String ACCOUNT_TYPE = "com.zoftino.sync";
    public static final String ACCOUNT_NAME = "zoftino_sync";
    private CashbackAuthenticator mAuthenticator;

    public static Account GetAccount() {
        final String accountName = ACCOUNT_NAME;
        return new Account(accountName, ACCOUNT_TYPE);
    }

    @Override
    public void onCreate() {
        mAuthenticator = new CashbackAuthenticator(this);
    }

    @Override
    public void onDestroy() {
    }

    @Override
    public IBinder onBind(Intent intent) {
        return mAuthenticator.getIBinder();
    }
}
 

Manifest.xml

 <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.zoftino.content">
    <uses-permission android:name="android.permission.INTERNET"></<uses-permission>
    <uses-permission android:name="android.permission.READ_SYNC_STATS"></<uses-permission>
    <uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS"></<uses-permission>
    <uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS"></<uses-permission>
    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <activity android:name=".CashbackSyncAdapterRunActivity"></activity>

    <provider
            android:name=".CashbackContentProvider"
            android:authorities="com.zoftino.sync.cashback"
            android:exported="false"
            android:syncable="true"></provider>
    <service android:name=".CashbackAccountService">
        <intent-filter>
            <action android:name="android.accounts.AccountAuthenticator"></action>
        </intent-filter>
        <meta-data android:name="android.accounts.AccountAuthenticator"
            android:resource="@xml/cashback_authenticator"></meta-data>
    </service>
    <service android:name=".CashbackSyncAdapterService"
        android:exported="true">
        <intent-filter>
            <action android:name="android.content.SyncAdapter"></action>
        </intent-filter>
        <meta-data android:name="android.content.SyncAdapter"
            android:resource="@xml/cashback_syncadapter"></meta-data>
    </service>

</application>

</manifest>

Activity Runs Sync Adapter on-Demand and Periodically

 package com.zoftino.content;


import android.Manifest;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.content.ContentResolver;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.support.v4.app.ActivityCompat;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.view.View;

public class CashbackSyncAdapterRunActivity extends AppCompatActivity {

    public static final String AUTHORITY = "com.zoftino.sync.cashback";

    public static final String ACCOUNT_TYPE = "com.zoftino.sync";

    public static final String ACCOUNT = "cashbacksync";

    public static final int SYNC_INTERVAL = 25000;

    private Account mAccount;
    private ContentResolver mContentResolver;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_syncadapter_run);

        mAccount = CreateSyncAccount(this);

        mContentResolver = getContentResolver();

        ContentResolver.addPeriodicSync(
         mAccount,
         AUTHORITY,
         Bundle.EMPTY,
         SYNC_INTERVAL); 

    }

    public void runSyncAdapter(View v) {
        Bundle bundle = new Bundle();
        bundle.putBoolean(
                ContentResolver.SYNC_EXTRAS_MANUAL, true);
        bundle.putBoolean(
                ContentResolver.SYNC_EXTRAS_EXPEDITED, true);

        ContentResolver.requestSync(mAccount, AUTHORITY, bundle);
    }

    public Account CreateSyncAccount(Context context) {
        Account newAccount = new Account(ACCOUNT, ACCOUNT_TYPE);
        AccountManager accountManager =
                (AccountManager) context.getSystemService(ACCOUNT_SERVICE);


        if (ActivityCompat.checkSelfPermission(this, Manifest.permission.GET_ACCOUNTS) != PackageManager.PERMISSION_GRANTED) {
            ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.GET_ACCOUNTS}, 31);
        }
        Account accounts[] = accountManager.getAccountsByType(ACCOUNT_TYPE);
        if (accounts == null || accounts.length < 1) {
            if (accountManager.addAccountExplicitly(newAccount, null, null)) {
                return newAccount;
            } else {
                Log.i("sync activity", "error creating account");
            }
        } else {
            return accounts[0];
        }
        return null;
    }
}