
Synchronization in Android applications. Part two
- Tutorial

Colleagues, good afternoon. We continue the topic begun in the last article , where we examined the mechanism for creating an account on the device. This was the first prerequisite for using the SyncAdapter Framework.
The second condition is the presence of ContentProvider , the writing process of which is chewed in the documentation. To be honest, I don’t really like how it is described there: everything seems bulky and complicated. Therefore, we will cycle a little and once again we will experience this topic. We could get by with the stub provider, but we are serious people and will use the full power of this tool.
In the comments to the previous part, a request flashed to consider the case when we do not need authorization, but only synchronization. We will consider such a case. As an example, let's take and write a simple rss reader for reading our beloved habr and not only. Yes, it’s so trite.
The application will have the ability to add / remove feeds, view a list of news and open them in a browser. We will visualize the synchronization process and start it using the SwipeRefreshLayout class recently added to the support-library . You can read what it is and how to use it here .
To configure automatic synchronization at certain intervals, we need a settings screen for this stuff. It is desirable that access to it be not only from the application, but also from the system screen of our account (as in the screenshot to the article). We use PreferenceFragments for this . We have decided on the functionality, let's get started.
Account
You already know how to add an account to the application from the previous part. But for our application we do not need authorization, respectively, we will replace Authenticator with an empty implementation.
Authenticator.java
public class Authenticator extends AbstractAccountAuthenticator {
public Authenticator(Context context) {
super(context);
}
@Override
public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
throw new UnsupportedOperationException();
}
@Override
public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType,
String[] requiredFeatures, Bundle options)
throws NetworkErrorException {
throw new UnsupportedOperationException();
}
@Override
public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options)
throws NetworkErrorException {
throw new UnsupportedOperationException();
}
@Override
public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType,
Bundle options) throws NetworkErrorException {
throw new UnsupportedOperationException();
}
@Override
public String getAuthTokenLabel(String authTokenType) {
throw new UnsupportedOperationException();
}
@Override
public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType,
Bundle options) throws NetworkErrorException {
throw new UnsupportedOperationException();
}
@Override
public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features)
throws NetworkErrorException {
throw new UnsupportedOperationException();
}
}
We will need to modify the res / xml / authenticator.xml file a little to add the ability to go to the synchronization settings screen. Add the parameter android: accountPreferences with the file from which these Preferences should be pulled up. When you click on the "Synchronization" element, the SyncSettingsActivity of our application will open.
authenticator.xml
account_prefs.xml
ContentProvider
Our provider will be a wrapper over SQLite database, in which we will store news. Let us dwell a little and consider its implementation in more detail. The provider can work with two types of Uri:
content: // authority / table - select all values from the table
content: // authority / table / _id - select one value by primary key
in the onCreate method using PackageManager.getProviderInfowe get authority for this provider and register them with SQLiteUriMatcher. What happens in the methods: the provider takes the name of the table from uri, then a specific implementation of SQLiteTableProvider (provider for the table) is taken from SCHEMA for this table. The corresponding methods are called on the SQLiteTableProvider (in fact, the call is proxied). This approach allows for each table to customize the work with data. Depending on the results, ContentResolver (and with it our application) receives a notification about data changes. For uri like content: // authority / table / _idwhere clause is rewritten to ensure operation on the primary key. If desired, you can slightly twist this provider and put it in the library class. As practice shows, such an implementation is enough for 90% of the tasks (the remaining 10 are full text search, like nocase search).
SQLiteContentProvider.java
public class SQLiteContentProvider extends ContentProvider {
private static final String DATABASE_NAME = "newsfeed.db";
private static final int DATABASE_VERSION = 1;
private static final String MIME_DIR = "vnd.android.cursor.dir/";
private static final String MIME_ITEM = "vnd.android.cursor.item/";
private static final Map SCHEMA = new ConcurrentHashMap<>();
static {
SCHEMA.put(FeedProvider.TABLE_NAME, new FeedProvider());
SCHEMA.put(NewsProvider.TABLE_NAME, new NewsProvider());
}
private final SQLiteUriMatcher mUriMatcher = new SQLiteUriMatcher();
private SQLiteOpenHelper mHelper;
private static ProviderInfo getProviderInfo(Context context, Class provider, int flags)
throws PackageManager.NameNotFoundException {
return context.getPackageManager()
.getProviderInfo(new ComponentName(context.getPackageName(), provider.getName()), flags);
}
private static String getTableName(Uri uri) {
return uri.getPathSegments().get(0);
}
@Override
public boolean onCreate() {
try {
final ProviderInfo pi = getProviderInfo(getContext(), getClass(), 0);
final String[] authorities = TextUtils.split(pi.authority, ";");
for (final String authority : authorities) {
mUriMatcher.addAuthority(authority);
}
mHelper = new SQLiteOpenHelperImpl(getContext());
return true;
} catch (PackageManager.NameNotFoundException e) {
throw new SQLiteException(e.getMessage());
}
}
@Override
public Cursor query(Uri uri, String[] columns, String where, String[] whereArgs, String orderBy) {
final int matchResult = mUriMatcher.match(uri);
if (matchResult == SQLiteUriMatcher.NO_MATCH) {
throw new SQLiteException("Unknown uri " + uri);
}
final String tableName = getTableName(uri);
final SQLiteTableProvider tableProvider = SCHEMA.get(tableName);
if (tableProvider == null) {
throw new SQLiteException("No such table " + tableName);
}
if (matchResult == SQLiteUriMatcher.MATCH_ID) {
where = BaseColumns._ID + "=?";
whereArgs = new String[]{uri.getLastPathSegment()};
}
final Cursor cursor = tableProvider.query(mHelper.getReadableDatabase(), columns, where, whereArgs, orderBy);
cursor.setNotificationUri(getContext().getContentResolver(), uri);
return cursor;
}
@Override
public String getType(Uri uri) {
final int matchResult = mUriMatcher.match(uri);
if (matchResult == SQLiteUriMatcher.NO_MATCH) {
throw new SQLiteException("Unknown uri " + uri);
} else if (matchResult == SQLiteUriMatcher.MATCH_ID) {
return MIME_ITEM + getTableName(uri);
}
return MIME_DIR + getTableName(uri);
}
@Override
public Uri insert(Uri uri, ContentValues values) {
final int matchResult = mUriMatcher.match(uri);
if (matchResult == SQLiteUriMatcher.NO_MATCH) {
throw new SQLiteException("Unknown uri " + uri);
}
final String tableName = getTableName(uri);
final SQLiteTableProvider tableProvider = SCHEMA.get(tableName);
if (tableProvider == null) {
throw new SQLiteException("No such table " + tableName);
}
if (matchResult == SQLiteUriMatcher.MATCH_ID) {
final int affectedRows = updateInternal(
tableProvider.getBaseUri(), tableProvider,
values, BaseColumns._ID + "=?",
new String[]{uri.getLastPathSegment()}
);
if (affectedRows > 0) {
return uri;
}
}
final long lastId = tableProvider.insert(mHelper.getWritableDatabase(), values);
getContext().getContentResolver().notifyChange(tableProvider.getBaseUri(), null);
final Bundle extras = new Bundle();
extras.putLong(SQLiteOperation.KEY_LAST_ID, lastId);
tableProvider.onContentChanged(getContext(), SQLiteOperation.INSERT, extras);
return uri;
}
@Override
public int delete(Uri uri, String where, String[] whereArgs) {
final int matchResult = mUriMatcher.match(uri);
if (matchResult == SQLiteUriMatcher.NO_MATCH) {
throw new SQLiteException("Unknown uri " + uri);
}
final String tableName = getTableName(uri);
final SQLiteTableProvider tableProvider = SCHEMA.get(tableName);
if (tableProvider == null) {
throw new SQLiteException("No such table " + tableName);
}
if (matchResult == SQLiteUriMatcher.MATCH_ID) {
where = BaseColumns._ID + "=?";
whereArgs = new String[]{uri.getLastPathSegment()};
}
final int affectedRows = tableProvider.delete(mHelper.getWritableDatabase(), where, whereArgs);
if (affectedRows > 0) {
getContext().getContentResolver().notifyChange(uri, null);
final Bundle extras = new Bundle();
extras.putLong(SQLiteOperation.KEY_AFFECTED_ROWS, affectedRows);
tableProvider.onContentChanged(getContext(), SQLiteOperation.DELETE, extras);
}
return affectedRows;
}
@Override
public int update(Uri uri, ContentValues values, String where, String[] whereArgs) {
final int matchResult = mUriMatcher.match(uri);
if (matchResult == SQLiteUriMatcher.NO_MATCH) {
throw new SQLiteException("Unknown uri " + uri);
}
final String tableName = getTableName(uri);
final SQLiteTableProvider tableProvider = SCHEMA.get(tableName);
if (tableProvider == null) {
throw new SQLiteException("No such table " + tableName);
}
if (matchResult == SQLiteUriMatcher.MATCH_ID) {
where = BaseColumns._ID + "=?";
whereArgs = new String[]{uri.getLastPathSegment()};
}
return updateInternal(tableProvider.getBaseUri(), tableProvider, values, where, whereArgs);
}
private int updateInternal(Uri uri, SQLiteTableProvider provider,
ContentValues values, String where, String[] whereArgs) {
final int affectedRows = provider.update(mHelper.getWritableDatabase(), values, where, whereArgs);
if (affectedRows > 0) {
getContext().getContentResolver().notifyChange(uri, null);
final Bundle extras = new Bundle();
extras.putLong(SQLiteOperation.KEY_AFFECTED_ROWS, affectedRows);
provider.onContentChanged(getContext(), SQLiteOperation.UPDATE, extras);
}
return affectedRows;
}
private static final class SQLiteOpenHelperImpl extends SQLiteOpenHelper {
public SQLiteOpenHelperImpl(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
db.beginTransactionNonExclusive();
try {
for (final SQLiteTableProvider table : SCHEMA.values()) {
table.onCreate(db);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
db.beginTransactionNonExclusive();
try {
for (final SQLiteTableProvider table : SCHEMA.values()) {
table.onUpgrade(db, oldVersion, newVersion);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
}
}
Now you need to register the provider in AndroidManifest.xml and pay attention to the android parameter : syncable = "true" . This flag indicates that our provider supports synchronization.
AndroidManifest.xml
Also of interest is the FeedProvider class, the SQLiteTableProvider implementation for working with news feeds. When you insert (!) Into this table (subscribing to a new feed), forced synchronization will be called. The onContentChanged method is responsible for this , which twitches from SQLiteContentProvider when data changes (insert / update / delete). A trigger ( onCreate ) will be created for the table , which will delete news related to the feed. Why is it worth invoking synchronization only on insertion? To avoid looping, because our provider will update the table (add a title, a link to a picture, publication date, etc.). Additional synchronization parameters are passed through syncExtras.
FeedProvider.java
public class FeedProvider extends SQLiteTableProvider {
public static final String TABLE_NAME = "feeds";
public static final Uri URI = Uri.parse("content://com.elegion.newsfeed/" + TABLE_NAME);
public FeedProvider() {
super(TABLE_NAME);
}
public static long getId(Cursor c) {
return c.getLong(c.getColumnIndex(Columns._ID));
}
public static String getIconUrl(Cursor c) {
return c.getString(c.getColumnIndex(Columns.IMAGE_URL));
}
public static String getTitle(Cursor c) {
return c.getString(c.getColumnIndex(Columns.TITLE));
}
public static String getLink(Cursor c) {
return c.getString(c.getColumnIndex(Columns.LINK));
}
public static long getPubDate(Cursor c) {
return c.getLong(c.getColumnIndex(Columns.PUB_DATE));
}
public static String getRssLink(Cursor c) {
return c.getString(c.getColumnIndex(Columns.RSS_LINK));
}
@Override
public Uri getBaseUri() {
return URI;
}
@Override
public void onContentChanged(Context context, int operation, Bundle extras) {
if (operation == INSERT) {
extras.keySet();
final Bundle syncExtras = new Bundle();
syncExtras.putLong(SyncAdapter.KEY_FEED_ID, extras.getLong(KEY_LAST_ID, -1));
ContentResolver.requestSync(AppDelegate.sAccount, AppDelegate.AUTHORITY, syncExtras);
}
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL("create table if not exists " + TABLE_NAME +
"(" + Columns._ID + " integer primary key on conflict replace, "
+ Columns.TITLE + " text, "
+ Columns.LINK + " text, "
+ Columns.IMAGE_URL + " text, "
+ Columns.LANGUAGE + " text, "
+ Columns.PUB_DATE + " integer, "
+ Columns.RSS_LINK + " text unique on conflict ignore)");
db.execSQL("create trigger if not exists after delete on " + TABLE_NAME +
" begin " +
" delete from " + NewsProvider.TABLE_NAME + " where " + NewsProvider.Columns.FEED_ID + "=old." + Columns._ID + ";" +
" end;");
}
public interface Columns extends BaseColumns {
String TITLE = "title";
String LINK = "link";
String IMAGE_URL = "imageUrl";
String LANGUAGE = "language";
String PUB_DATE = "pubDate";
String RSS_LINK = "rssLink";
}
}
Behind the sim, the rabbit mink ends and the looking glass begins.
Syncadapter
Before breaking into the process of creating SyncAdapter'a, let's think about why this is needed at all, what are the advantages. If you believe the documentation, then at least we will get:
- Check the status and start synchronization when network availability.
- A scheduler that synchronizes by criteria and / or schedule.
- Automatically start synchronization, if for some reason it failed the last time.
- Save battery power, as the system will be less likely to switch the radio module. Plus, synchronization will not start at a critical charge level.
- Integration into the system settings interface.
Already good, right? We add that when using ContentProvider, we can start synchronization when the data in it changes. This completely eliminates the need for us to track data changes in the application and synchronize in “manual mode”.
The process of integrating this stuff is very similar to the process of integrating your account into the application. We need an implementation of AbstractThreadedSyncAdapter and Service for integration into the system. AbstractThreadedSyncAdapter has just one abstract method onPerformSyncin which all the magic happens. What exactly is going on here? Depending on the transmitted extras parameters (remember syncExtras in FeedProvider.onContentChanged), either one tape or all is synchronized. In general, we select the feed from the database, parse rss by reference and add it to our database using the ContentProviderClient provider . SyncResult syncResult is used to inform the system about the status (number of updates, errors, etc.) of synchronization .
Syncadapter.java
public class SyncAdapter extends AbstractThreadedSyncAdapter {
public static final String KEY_FEED_ID = "com.elegion.newsfeed.sync.KEY_FEED_ID";
public SyncAdapter(Context context) {
super(context, true);
}
@Override
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider,
SyncResult syncResult) {
final long feedId = extras.getLong(KEY_FEED_ID, -1);
if (feedId > 0) {
syncFeeds(provider, syncResult, FeedProvider.Columns._ID + "=?", new String[]{String.valueOf(feedId)});
} else {
syncFeeds(provider, syncResult, null, null);
}
}
private void syncFeeds(ContentProviderClient provider, SyncResult syncResult, String where, String[] whereArgs) {
try {
final Cursor feeds = provider.query(
FeedProvider.URI, new String[]{
FeedProvider.Columns._ID,
FeedProvider.Columns.RSS_LINK
}, where, whereArgs, null
);
try {
if (feeds.moveToFirst()) {
do {
syncFeed(feeds.getString(0), feeds.getString(1), provider, syncResult);
} while (feeds.moveToNext());
}
} finally {
feeds.close();
}
} catch (RemoteException e) {
Log.e(SyncAdapter.class.getName(), e.getMessage(), e);
++syncResult.stats.numIoExceptions;
}
}
private void syncFeed(String feedId, String feedUrl, ContentProviderClient provider, SyncResult syncResult) {
try {
final HttpURLConnection cn = (HttpURLConnection) new URL(feedUrl).openConnection();
try {
final RssFeedParser parser = new RssFeedParser(cn.getInputStream());
try {
parser.parse(feedId, provider, syncResult);
} finally {
parser.close();
}
} finally {
cn.disconnect();
}
} catch (IOException e) {
Log.e(SyncAdapter.class.getName(), e.getMessage(), e);
++syncResult.stats.numIoExceptions;
}
}
}
Implementation of SyncService is also very simple. All we need is to give an IBinder object to the system to communicate with our SyncAdapter. In order for the system to understand what kind of adapter we are registering for, we need an xml-meta file sync_adapter.xml, as well as register all this stuff in AndroidManifest.xml.
SyncService.java
public class SyncService extends Service {
private static SyncAdapter sSyncAdapter;
@Override
public void onCreate() {
super.onCreate();
if (sSyncAdapter == null) {
sSyncAdapter = new SyncAdapter(getApplicationContext());
}
}
@Override
public IBinder onBind(Intent intent) {
return sSyncAdapter.getSyncAdapterBinder();
}
}
sync_adapter.xml
AndroidManifest.xml
And now an example

This is how the window with the list of tapes will look. As you recall, we agreed to use SwipeRefreshLayout to force synchronization and visualization of this process. List of ribbons FeedList.java and a list of news NewsList.java will be inherited from a common parent SwipeToRefreshList.java .
To track the status of synchronization, you need to register an Observer in the ContentResolver (the SwipeToRefreshList.onResume () method ). To do this, use the ContentResolver.addStatusChangeListener method . In the SwipeToRefreshList.onStatusChanged method , check the synchronization status using the ContentResolver.isSyncActive methodand pass this result to the SwipeToRefreshList.onSyncStatusChanged method , which will be overridden by the heirs. All that this method will do is hide / show the progress bar of SwipeRefreshLayout . Since SyncStatusObserver.onStatusChanged is called from a separate thread, we wrap the result in a handler. The SwipeToRefreshList.onRefresh method in descendants starts forced synchronization using ContentResolver.requestSync .
All lists are loaded and displayed using CursorLoader + CursorAdapter, which also work great in conjunction with ContentProvider, eliminating the need to keep track of the relevance of lists. As soon as a new item is added to the provider, all CursorLoaders will receive notifications and update the data in CursorAdapter'ah.
SwipeToRefreshList.java
public class SwipeToRefreshList extends Fragment implements SwipeRefreshLayout.OnRefreshListener, SyncStatusObserver,
AdapterView.OnItemClickListener, SwipeToDismissCallback {
private SwipeRefreshLayout mRefresher;
private ListView mListView;
private Object mSyncMonitor;
private SwipeToDismissController mSwipeToDismissController;
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
final View view = inflater.inflate(R.layout.fmt_swipe_to_refresh_list, container, false);
mListView = (ListView) view.findViewById(android.R.id.list);
return (mRefresher = (SwipeRefreshLayout) view);
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
mRefresher.setColorScheme(
android.R.color.holo_blue_light,
android.R.color.holo_red_light,
android.R.color.holo_green_light,
android.R.color.holo_orange_light
);
mSwipeToDismissController = new SwipeToDismissController(mListView, this);
}
@Override
public void onResume() {
super.onResume();
mRefresher.setOnRefreshListener(this);
mSyncMonitor = ContentResolver.addStatusChangeListener(
ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE
| ContentResolver.SYNC_OBSERVER_TYPE_PENDING,
this
);
mListView.setOnItemClickListener(this);
mListView.setOnTouchListener(mSwipeToDismissController);
mListView.setOnScrollListener(mSwipeToDismissController);
}
@Override
public void onPause() {
mRefresher.setOnRefreshListener(null);
ContentResolver.removeStatusChangeListener(mSyncMonitor);
mListView.setOnItemClickListener(null);
mListView.setOnTouchListener(null);
mListView.setOnScrollListener(null);
super.onPause();
}
@Override
public final void onRefresh() {
onRefresh(AppDelegate.sAccount);
}
@Override
public final void onStatusChanged(int which) {
mRefresher.post(new Runnable() {
@Override
public void run() {
onSyncStatusChanged(AppDelegate.sAccount, ContentResolver
.isSyncActive(AppDelegate.sAccount, AppDelegate.AUTHORITY));
}
});
}
@Override
public void onItemClick(AdapterView parent, View view, int position, long id) {
}
@Override
public boolean canDismissView(View view, int position) {
return false;
}
@Override
public void dismissView(View view, int position) {
}
public void setListAdapter(ListAdapter adapter) {
final DataSetObserver dataSetObserver = mSwipeToDismissController.getDataSetObserver();
final ListAdapter oldAdapter = mListView.getAdapter();
if (oldAdapter != null) {
oldAdapter.unregisterDataSetObserver(dataSetObserver);
}
mListView.setAdapter(adapter);
adapter.registerDataSetObserver(dataSetObserver);
}
protected void onRefresh(Account account) {
}
protected void onSyncStatusChanged(Account account, boolean isSyncActive) {
}
protected void setRefreshing(boolean refreshing) {
mRefresher.setRefreshing(refreshing);
}
}

So, with forced synchronization sorted out. But the juice itself is automatic synchronization. Remember, we added support for the settings screen to our account? Good practice is not to force the user to take unnecessary actions. Therefore, access to this screen is duplicated by a button in the action bar.
What he is - is visible on the left. Technically, this is an activity with one PreferenceFragment ( SyncSettings.java ), the settings of which are taken from res / xml / sync_prefs.xml .
Changing parameters is tracked in the onSharedPreferenceChanged method (implementation of OnSharedPreferenceChangeListener ). To enable periodic synchronization, there is a method ContentResolver.addPeriodicSync, to disable, oddly enough, ContentResolver.removePeriodicSync . To update the synchronization interval, the ContentResolver.addPeriodicSync method is also used . Because, as the documentation for this method says: "If there is already another periodic sync scheduled with the account, authority and extras then a new periodic sync won't be added, instead the frequency of the previous one will be updated." ( if synchronization is already planned, extra and authority will not be added to the new synchronization, instead the interval of the previous one will be updated).
sync_prefs.xml
SyncSettings.java
public class SyncSettings extends PreferenceFragment implements SharedPreferences.OnSharedPreferenceChangeListener {
private static final String KEY_AUTO_SYNC = "com.elegion.newsfeed.KEY_AUTO_SYNC";
private static final String KEY_AUTO_SYNC_INTERVAL = "com.elegion.newsfeed.KEY_AUTO_SYNC_INTERVAL";
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
addPreferencesFromResource(R.xml.sync_prefs);
final ListPreference interval = (ListPreference) getPreferenceManager()
.findPreference(KEY_AUTO_SYNC_INTERVAL);
interval.setSummary(interval.getEntry());
}
@Override
public void onResume() {
super.onResume();
getPreferenceManager().getSharedPreferences()
.registerOnSharedPreferenceChangeListener(this);
}
@Override
public void onPause() {
getPreferenceManager().getSharedPreferences()
.unregisterOnSharedPreferenceChangeListener(this);
super.onPause();
}
@Override
public void onSharedPreferenceChanged(SharedPreferences prefs, String key) {
if (TextUtils.equals(KEY_AUTO_SYNC, key)) {
if (prefs.getBoolean(key, false)) {
final long interval = Long.parseLong(prefs.getString(
KEY_AUTO_SYNC_INTERVAL,
getString(R.string.auto_sync_interval_default)
));
ContentResolver.addPeriodicSync(AppDelegate.sAccount, AppDelegate.AUTHORITY, Bundle.EMPTY, interval);
} else {
ContentResolver.removePeriodicSync(AppDelegate.sAccount, AppDelegate.AUTHORITY, new Bundle());
}
} else if (TextUtils.equals(KEY_AUTO_SYNC_INTERVAL, key)) {
final ListPreference interval = (ListPreference) getPreferenceManager().findPreference(key);
interval.setSummary(interval.getEntry());
ContentResolver.addPeriodicSync(
AppDelegate.sAccount, AppDelegate.AUTHORITY,
Bundle.EMPTY, Long.parseLong(interval.getValue())
);
}
}
}
Having collected all this in a heap, we get a working application, with all the goodies that the Android system provides us. Behind the scenes there is a lot of everything tasty, but this is enough to understand the power of SyncAdapter Framework.
That’s all, it seems. Full project sources can be found here . Thank you for attention. Constructive criticism is welcome.
Synchronization in Android applications. Part one.