Коллеги, добрый день. Продолжим тему, начатую в прошлой статье, где мы рассмотрели механизм создания аккаунта на устройстве. Это было первым необходимым условием для использования SyncAdapter Framework'а.
Вторым условием является наличие ContentProvider'а, процесс написания которого разжеван в документации. Признаться честно, мне не очень нравится как там это описано: все кажется громоздким и сложным. Поэтому немного повелосипедим и еще разок пережуем эту тему. Можно было бы обойтись и провайдером-заглушкой, но мы люди серьезные и будем использовать всю мощь этого инструмента.
В комментариях к предыдущей части промелькнула просьба рассмотреть случай, когда нам не нужна авторизация, а только синхронизация. Такой случай и рассмотрим. В качестве примера возьмем и напишем простую rss читалку для чтения нашего любимого хабра и не только. Да вот так банально.
В приложении будет возможность добавлять/удалять ленты, просматривать список новостей и открывать их в браузере. Визуализировать процесс синхронизации и ее запуск будем с помощью добавленного недавно в support-library класса SwipeRefreshLayout. Почитать, что это и как использовать, можно тут.
Чтобы настраивать автоматическую синхронизацию через определенные интервалы времени, нам потребуется экран настроек этого добра. Желательно, чтобы доступ к нему был не только из приложения, но и из системного экрана нашего аккаунта (как на скриншоте к статье). Используем для этого PreferenceFragment'ы. С функциональностью определились, приступим.
Account
Как добавить аккаунт в приложение вы уже знаете из предыдущей части. Но для нашего приложения нам не потребуется авторизация, соответственно, заменим Authenticator пустой реализацией.
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();
}
}
Нам потребуется немного модифицировать файл res/xml/authenticator.xml, чтобы добавить ему возможность перехода на экран настроек синхронизации. Добавим параметр android:accountPreferences с указанием файла, из которого эти самые Preferences нужно подтянуть. При клике на элемент «Синхронизация» будет открываться SyncSettingsActivity нашего приложения.
authenticator.xml
<?xml version="1.0" encoding="utf-8"?>
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
android:accountPreferences="@xml/account_prefs"
android:accountType="com.elegion.newsfeed.account"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:smallIcon="@drawable/ic_launcher" />
account_prefs.xml
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
android:persistent="true">
<PreferenceCategory android:title="@string/general_settings" />
<PreferenceScreen
android:key="com.elegion.newsfeed.KEY_ACCOUNT_SYNC"
android:summary="@string/sync_settings_summary"
android:title="@string/sync">
<intent
android:action="com.elegion.newsfeed.ACTION_SYNC_SETTINGS"
android:targetClass="com.elegion.newsfeed.activity.SyncSettingsActivity"
android:targetPackage="com.elegion.newsfeed" />
</PreferenceScreen>
</PreferenceScreen>
ContentProvider
Наш провайдер будет оберткой над SQLite базой данных, в которой мы будем хранить новости. Остановимся немного и подробнее рассмотрим его реализацию. Провайдер умеет работать с двумя типами Uri:
content://authority/table — выборка всех значений из таблицы
content://authority/table/_id — выборка одного значения по primary key
в методе onCreate с помощью PackageManager.getProviderInfo мы получаем authority для этого провайдера и регистрируем их в SQLiteUriMatcher. Что происходит в методах: провайдер берет из uri название таблицы, затем из SCHEMA для этой таблицы берется конкретная реализация SQLiteTableProvider (провайдера для таблицы). У SQLiteTableProvider вызываются соответствующие методы (по сути, происходит проксирование вызова). Такой подход позволяет для каждой таблицы кастомизировать работу с данными. В зависимости от результатов, ContentResolver (а с ним и наше приложение) получает уведомление об изменении данных. Для uri типа content://authority/table/_id переписывается условие where, чтобы обеспечить работу по первичному ключу. При желании, можно немного докрутить этот провайдер и вынести в библиотечный класс. Как показывает практика, такой реализации достаточно для 90% задач (остальные 10 — 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<String, SQLiteTableProvider> 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<? extends ContentProvider> 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();
}
}
}
}
Теперь нужно прописать провайдер в AndroidManifest.xml и обратить внимание на параметр android:syncable=«true». Этот флаг говорит о том, что наш провайдер поддерживает синхронизацию.
AndroidManifest.xml
<provider
android:name=".sqlite.SQLiteContentProvider"
android:authorities="com.elegion.newsfeed"
android:exported="false"
android:syncable="true" />
Также представляет интерес класс FeedProvider — реализация SQLiteTableProvider для работы с лентами новостей. При вставке (!) в эту таблицу (подписка на новую ленту) будет вызываться принудительная синхронизация. За это отвечает метод onContentChanged, который дергается из SQLiteContentProvider при изменении данных (insert/update/delete). Для таблицы будет создан триггер (onCreate), который будет удалять связанные с лентой новости. Почему стоит вызывать синхронизацию только при вставке? Чтобы избежать зацикливания, потому что наш провайдер будет обновлять таблицу (добавлять заголовок, ссылку на картинку, дату публикации и т.д.). Дополнительные параметры синхронизации передаются через 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";
}
}
За сим кроличья норка заканчивается, и начинается зазеркалье.
SyncAdapter
Перед тем как ворваться в процесс создания SyncAdapter'а, давайте подумаем, зачем вообще это нужно, какие преимущества дает. Если верить документации, то, как минимум, мы получим:
- Проверку состояния и запуск синхронизации при доступности сети.
- Планировщик, который выполнит синхронизацию по критериям и/или расписанию.
- Автоматический запуск синхронизации, если она по каким-то причинам не удалась в прошлый раз.
- Экономию заряда батареи, так как система будет реже переключать радио модуль. Плюс синхронизация не запустится при критическом уровне заряда.
- Интеграцию в интерфейс настроек системы.
Уже неплохо, правда? Добавим, что при использовании ContentProvider'а, мы можем запускать синхронизацию при изменении данных в нем. Это полностью снимает с нас необходимость отслеживать изменение данных в приложении и выполнять синхронизацию в «ручном режиме».
Процесс интеграции этого добра очень похож на процесс интеграции своего аккаунта в приложение. Нам потребуется реализация AbstractThreadedSyncAdapter и Service для интеграции в систему. AbstractThreadedSyncAdapter имеет всего один абстрактный метод onPerformSync, в котором и происходит вся магия. Что же именно тут происходит? В зависимости от переданных extras-параметров (помните syncExtras в FeedProvider.onContentChanged) синхронизируется или одна лента или все. В общем, мы выбираем из базы ленты, парсим rss по ссылке и складываем в нашу базу с помощью ContentProviderClient provider. Для информирования системы о статусе (количестве обновлений, ошибок и т.д.) синхронизации используется SyncResult syncResult.
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;
}
}
}
Реализация SyncService тоже очень проста. Все, что нам нужно это отдать IBinder объект системе, для связи с нашим SyncAdapter'ом. Чтобы система поняла, что за адаптер мы регистрируем, понадобится xml-мета файл sync_adapter.xml, а также прописать все это добро в 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
<?xml version="1.0" encoding="utf-8"?>
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="com.elegion.newsfeed.account"
android:allowParallelSyncs="false"
android:contentAuthority="com.elegion.newsfeed"
android:isAlwaysSyncable="true"
android:supportsUploading="false"
android:userVisible="true" />
AndroidManifest.xml
<service
android:name=".sync.SyncService"
android:exported="false"
android:process=":sync">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_adapter" />
</service>
А теперь пример
Вот так будет выглядеть окно со списком лент. Как вы помните, мы договорились использовать SwipeRefreshLayout для принудительной синхронизации и визуализации этого процесса. Список лент FeedList.java и список новостей NewsList.java будут наследоваться от общего родителя SwipeToRefreshList.java.
Для отслеживания статуса синхронизации, необходимо зарегистрировать Observer в ContentResolver'е (метод SwipeToRefreshList.onResume()). Для этого служит метод ContentResolver.addStatusChangeListener. В методе SwipeToRefreshList.onStatusChanged проверяем статус синхронизации с помощью метода ContentResolver.isSyncActive и передаем этот результат в метод SwipeToRefreshList.onSyncStatusChanged, который будет переопределен наследниками. Все, что будет делать этот метод — прятать/показывать полоску прогресса у SwipeRefreshLayout. Так как SyncStatusObserver.onStatusChanged вызывается из отдельного потока, оборачиваем результат в хэндлер. Метод SwipeToRefreshList.onRefresh в потомках запускает принудительную синхронизацию с помощью ContentResolver.requestSync.
Все списки загружаются и отображаются с помощью CursorLoader + CursorAdapter, которые тоже замечательно работают в связке с ContentProvider'ом, избавляя нас от необходимости следить за актуальностью списков. Как только новый элемент будет добавлен в провайдер, все CursorLoader'ы получат уведомления и актуализируют данные в CursorAdapter'ах.
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);
}
}
Итак, с принудительной синхронизацией разобрались. Но самый сок — синхронизация автоматическая. Помните, мы добавляли в наш аккаунт поддержку экрана настроек? Хорошая практика — не заставлять пользователя совершать лишних действий. Поэтому доступ к этому экрану продублирован кнопкой в экшен баре.
Что он из себя представляет — видно слева. Технически же — это активити с одним PreferenceFragment'ом (SyncSettings.java), настройки которого берутся из res/xml/sync_prefs.xml.
Изменение параметров отслеживаем в методе onSharedPreferenceChanged (реализация OnSharedPreferenceChangeListener). Для включения периодической синхронизации существует метод ContentResolver.addPeriodicSync, для отключения, как ни странно, — ContentResolver.removePeriodicSync. Для обновления интервала синхронизации используется так же метод ContentResolver.addPeriodicSync. Потому что, как говорит документация к этому методу: «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.» (если синхронизация уже запланирована, extra и authority не будут добавлены в новую синхронизацию, вместо этого будет обновлен интервал предыдущей).
sync_prefs.xml
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceCategory
android:key="com.elegion.newsfeed.KEY_SYNC"
android:title="@string/sync">
<CheckBoxPreference
android:defaultValue="false"
android:key="com.elegion.newsfeed.KEY_AUTO_SYNC"
android:summary="@string/auto_sync_summary"
android:title="@string/auto_sync" />
<ListPreference
android:defaultValue="@string/auto_sync_interval_default"
android:dependency="com.elegion.newsfeed.KEY_AUTO_SYNC"
android:entries="@array/auto_sync_intervals"
android:entryValues="@array/auto_sync_interval_values"
android:key="com.elegion.newsfeed.KEY_AUTO_SYNC_INTERVAL"
android:title="@string/auto_sync_interval" />
</PreferenceCategory>
</PreferenceScreen>
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())
);
}
}
}
Собрав все это в кучу, мы получаем рабочее приложение, со всеми плюшками, которые предоставляет нам система Android. За кадром осталось много всего вкусного, но и этого достаточно, чтобы понять мощь SyncAdapter Framework'а.
Вот, вроде бы и все. Полные исходники проекта можно взять тут. Благодарю за внимание. Конструктивная критика приветствуется.
Синхронизация в Android приложениях. Часть первая.