jenkins-bot has submitted this change and it was merged.

Change subject: Add user option sync adapter
......................................................................


Add user option sync adapter

Bug: T124350
Change-Id: Ibb3428ac4b35e114b3f753488f5dbc38ab029095
---
M app/src/alpha/res/values/strings_no_translate.xml
M app/src/beta/res/values/strings_no_translate.xml
M app/src/dev/res/values/strings_no_translate.xml
M app/src/main/AndroidManifest.xml
M app/src/main/java/org/wikipedia/WikipediaApp.java
M app/src/main/java/org/wikipedia/auth/AccountUtil.java
M app/src/main/java/org/wikipedia/auth/WikimediaAuthenticator.java
M app/src/main/java/org/wikipedia/database/sync/DefaultSyncRow.java
M app/src/main/java/org/wikipedia/database/sync/SyncRow.java
A app/src/main/java/org/wikipedia/database/sync/SyncRowDao.java
A app/src/main/java/org/wikipedia/dataclient/mwapi/MwPostResponse.java
M app/src/main/java/org/wikipedia/server/mwapi/MwServiceError.java
A app/src/main/java/org/wikipedia/useroption/database/UserOptionDao.java
M app/src/main/java/org/wikipedia/useroption/database/UserOptionRow.java
M 
app/src/main/java/org/wikipedia/useroption/dataclient/DefaultUserOptionDataClient.java
A app/src/main/java/org/wikipedia/useroption/sync/UserOptionContentResolver.java
A app/src/main/java/org/wikipedia/useroption/sync/UserOptionSyncAdapter.java
A app/src/main/java/org/wikipedia/useroption/sync/UserOptionSyncService.java
M app/src/main/res/values-qq/strings.xml
M app/src/main/res/values/strings.xml
A app/src/main/res/xml/user_option_sync_adapter.xml
21 files changed, 617 insertions(+), 24 deletions(-)

Approvals:
  Dbrant: Looks good to me, approved
  jenkins-bot: Verified



diff --git a/app/src/alpha/res/values/strings_no_translate.xml 
b/app/src/alpha/res/values/strings_no_translate.xml
index 821c0a9..b353daa 100644
--- a/app/src/alpha/res/values/strings_no_translate.xml
+++ b/app/src/alpha/res/values/strings_no_translate.xml
@@ -7,4 +7,6 @@
     <!-- The alpha build uses a different signature than beta and prod. -->
     <string name="account_name">Wikimedia (Alpha)</string>
     <string name="account_type">org.wikimedia.alpha</string>
+
+    <string name="user_option_sync_label">Preferences (Alpha)</string>
 </resources>
diff --git a/app/src/beta/res/values/strings_no_translate.xml 
b/app/src/beta/res/values/strings_no_translate.xml
index 9002593..e4ffb00 100644
--- a/app/src/beta/res/values/strings_no_translate.xml
+++ b/app/src/beta/res/values/strings_no_translate.xml
@@ -7,4 +7,6 @@
     <!-- TODO: remove and use the same account as prod when tokens are stored. 
-->
     <string name="account_name">Wikimedia (Beta)</string>
     <string name="account_type">org.wikimedia.beta</string>
-</resources>
+
+    <string name="user_option_sync_label">Preferences (Beta)</string>
+</resources>
\ No newline at end of file
diff --git a/app/src/dev/res/values/strings_no_translate.xml 
b/app/src/dev/res/values/strings_no_translate.xml
index 255c6c3..b0737e2 100644
--- a/app/src/dev/res/values/strings_no_translate.xml
+++ b/app/src/dev/res/values/strings_no_translate.xml
@@ -7,4 +7,6 @@
     <!-- TODO: remove and use the same account as alpha when tokens are 
stored. -->
     <string name="account_name">Wikimedia (Dev)</string>
     <string name="account_type">org.wikimedia.dev</string>
-</resources>
+
+    <string name="user_option_sync_label">Preferences (Dev)</string>
+</resources>
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a6409e0..adb8eb0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -181,6 +181,13 @@
                 android:resource="@xml/file_paths" />
         </provider>
 
+        <provider
+            android:authorities="@string/user_option_authority"
+            android:name=".useroption.database.UserOptionContentProvider"
+            android:exported="false"
+            android:syncable="true"
+            android:label="@string/user_option_sync_label" />
+
         <receiver
             android:icon="@mipmap/launcher"
             android:label="@string/widget_name_search"
@@ -221,6 +228,20 @@
         </receiver>
 
         <service
+            android:name=".useroption.sync.UserOptionSyncService"
+            android:exported="false">
+
+            <intent-filter>
+                <action android:name="android.content.SyncAdapter" />
+            </intent-filter>
+
+            <meta-data
+                android:name="android.content.SyncAdapter"
+                android:resource="@xml/user_option_sync_adapter" />
+
+        </service>
+
+        <service
             android:name=".auth.AuthenticatorService"
             android:exported="false">
 
diff --git a/app/src/main/java/org/wikipedia/WikipediaApp.java 
b/app/src/main/java/org/wikipedia/WikipediaApp.java
index 9cde2c3..099761f 100644
--- a/app/src/main/java/org/wikipedia/WikipediaApp.java
+++ b/app/src/main/java/org/wikipedia/WikipediaApp.java
@@ -42,7 +42,9 @@
 import org.wikipedia.search.RecentSearch;
 import org.wikipedia.settings.Prefs;
 import org.wikipedia.theme.Theme;
+import org.wikipedia.useroption.database.UserOptionDao;
 import org.wikipedia.useroption.database.UserOptionRow;
+import org.wikipedia.useroption.sync.UserOptionContentResolver;
 import org.wikipedia.util.ApiUtil;
 import org.wikipedia.util.ReleaseUtil;
 import org.wikipedia.util.log.L;
@@ -185,6 +187,9 @@
 
         // TODO: remove this code after all logged in users also have a system 
account or August 2016.
         AccountUtil.createAccountForLoggedInUser();
+
+        UserOptionContentResolver.requestManualSync();
+        UserOptionContentResolver.registerAppSyncObserver(this);
     }
 
     public Bus getBus() {
@@ -362,6 +367,7 @@
     public void logOut() {
         L.v("logging out");
         AccountUtil.removeAccount();
+        UserOptionDao.instance().clear();
         getEditTokenStorage().clearAllTokens();
         getCookieManager().clearAllCookies();
         getUserInfoStorage().clearUser();
@@ -458,6 +464,7 @@
         if (theme != currentTheme) {
             currentTheme = theme;
             Prefs.setThemeId(currentTheme.getMarshallingId());
+            UserOptionDao.instance().theme(theme);
             bus.post(new ThemeChangeEvent());
         }
     }
@@ -503,8 +510,11 @@
         } else if (multiplier > FONT_SIZE_MULTIPLIER_MAX) {
             multiplier = FONT_SIZE_MULTIPLIER_MAX;
         }
-        Prefs.setTextSizeMultiplier(multiplier);
-        bus.post(new ChangeTextSizeEvent());
+        if (multiplier != Prefs.getTextSizeMultiplier()) {
+            Prefs.setTextSizeMultiplier(multiplier);
+            UserOptionDao.instance().fontSize(multiplier);
+            bus.post(new ChangeTextSizeEvent());
+        }
     }
 
     public void putCrashReportProperty(String key, String value) {
diff --git a/app/src/main/java/org/wikipedia/auth/AccountUtil.java 
b/app/src/main/java/org/wikipedia/auth/AccountUtil.java
index 9c58e40..3365b16 100644
--- a/app/src/main/java/org/wikipedia/auth/AccountUtil.java
+++ b/app/src/main/java/org/wikipedia/auth/AccountUtil.java
@@ -12,6 +12,7 @@
 import org.wikipedia.R;
 import org.wikipedia.WikipediaApp;
 import org.wikipedia.login.User;
+import org.wikipedia.useroption.sync.UserOptionContentResolver;
 import org.wikipedia.util.ApiUtil;
 import org.wikipedia.util.log.L;
 
@@ -39,6 +40,8 @@
 
                 response.onResult(bundle);
             }
+
+            UserOptionContentResolver.requestManualSync();
         } else {
             if (response != null) {
                 response.onError(AccountManager.ERROR_CODE_REMOTE_EXCEPTION, 
"");
@@ -65,6 +68,10 @@
                 accountManager().removeAccount(account, null, null);
             }
         }
+    }
+
+    public static boolean supported(@NonNull Account account) {
+        return account.equals(AccountUtil.account());
     }
 
     @Nullable
@@ -101,4 +108,4 @@
     }
 
     private AccountUtil() { }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/wikipedia/auth/WikimediaAuthenticator.java 
b/app/src/main/java/org/wikipedia/auth/WikimediaAuthenticator.java
index 11091b4..d00f6b9 100644
--- a/app/src/main/java/org/wikipedia/auth/WikimediaAuthenticator.java
+++ b/app/src/main/java/org/wikipedia/auth/WikimediaAuthenticator.java
@@ -5,17 +5,21 @@
 import android.accounts.AccountAuthenticatorResponse;
 import android.accounts.AccountManager;
 import android.accounts.NetworkErrorException;
+import android.content.ContentResolver;
 import android.content.Context;
 import android.content.Intent;
 import android.os.Bundle;
 import android.support.annotation.NonNull;
 import android.support.annotation.Nullable;
 
+import org.wikipedia.BuildConfig;
 import org.wikipedia.R;
 import org.wikipedia.analytics.LoginFunnel;
 import org.wikipedia.login.LoginActivity;
 
 public class WikimediaAuthenticator extends AbstractAccountAuthenticator {
+    private static final String[] SYNC_AUTHORITIES = 
{BuildConfig.USER_OPTION_AUTHORITY};
+
     @NonNull private final Context context;
 
     public WikimediaAuthenticator(@NonNull Context context) {
@@ -104,4 +108,23 @@
 
         return bundle;
     }
-}
\ No newline at end of file
+
+    @Override
+    public Bundle getAccountRemovalAllowed(AccountAuthenticatorResponse 
response,
+                                           Account account) throws 
NetworkErrorException {
+        Bundle result = super.getAccountRemovalAllowed(response, account);
+
+        if (result.containsKey(AccountManager.KEY_BOOLEAN_RESULT)
+                && !result.containsKey(AccountManager.KEY_INTENT)) {
+            boolean allowed = 
result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT);
+
+            if (allowed) {
+                for (String auth : SYNC_AUTHORITIES) {
+                    ContentResolver.cancelSync(account, auth);
+                }
+            }
+        }
+
+        return result;
+    }
+}
diff --git a/app/src/main/java/org/wikipedia/database/sync/DefaultSyncRow.java 
b/app/src/main/java/org/wikipedia/database/sync/DefaultSyncRow.java
index 4bd332a..b7152a7 100644
--- a/app/src/main/java/org/wikipedia/database/sync/DefaultSyncRow.java
+++ b/app/src/main/java/org/wikipedia/database/sync/DefaultSyncRow.java
@@ -1,6 +1,7 @@
 package org.wikipedia.database.sync;
 
 import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
 
 public class DefaultSyncRow implements SyncRow {
     public static final int NO_TRANSACTION_ID = 0;
@@ -47,8 +48,11 @@
     }
 
     @Override
-    public boolean isTransaction(@NonNull SyncRow row) {
-        return transactionId() == row.transactionId();
+    public boolean completeable(@Nullable SyncRow old) {
+        boolean newer = old == null || transactionId() == NO_TRANSACTION_ID;
+        boolean response = old != null && transactionId() == 
old.transactionId();
+        boolean recordable = !(old == null && status() == SyncStatus.DELETED);
+        return (newer || response) && recordable;
     }
 
     @Override
diff --git a/app/src/main/java/org/wikipedia/database/sync/SyncRow.java 
b/app/src/main/java/org/wikipedia/database/sync/SyncRow.java
index 050da57..4677041 100644
--- a/app/src/main/java/org/wikipedia/database/sync/SyncRow.java
+++ b/app/src/main/java/org/wikipedia/database/sync/SyncRow.java
@@ -1,6 +1,7 @@
 package org.wikipedia.database.sync;
 
 import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
 
 public interface SyncRow {
     @NonNull SyncStatus status();
@@ -9,6 +10,6 @@
 
     void resetTransaction(@NonNull SyncStatus status);
     void startTransaction();
-    boolean isTransaction(@NonNull SyncRow row);
+    boolean completeable(@Nullable SyncRow old);
     void completeTransaction(long timestamp);
 }
\ No newline at end of file
diff --git a/app/src/main/java/org/wikipedia/database/sync/SyncRowDao.java 
b/app/src/main/java/org/wikipedia/database/sync/SyncRowDao.java
new file mode 100644
index 0000000..0283343
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/database/sync/SyncRowDao.java
@@ -0,0 +1,184 @@
+package org.wikipedia.database.sync;
+
+import android.database.Cursor;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+import org.wikipedia.database.DatabaseClient;
+import org.wikipedia.useroption.database.UserOptionDatabaseTable;
+
+import java.util.ArrayList;
+import java.util.Collection;
+
+public abstract class SyncRowDao<T extends SyncRow> {
+    @NonNull private final DatabaseClient<T> client;
+    /**
+     * @param client Database client singleton. No writes should be performed 
to the table outside
+     *               of SyncRowDao.
+     */
+    public SyncRowDao(@NonNull DatabaseClient<T> client) {
+        this.client = client;
+    }
+
+    protected synchronized void upsert(@NonNull T item) {
+        T local = queryItem(item);
+        switch (local == null ? SyncStatus.ADDED : local.status()) {
+            case SYNCHRONIZED:
+            case OUTDATED:
+            case MODIFIED:
+                modifyTransaction(item);
+                break;
+            case ADDED:
+            case DELETED:
+                addTransaction(item);
+                break;
+            default:
+                throw new RuntimeException("status=" + item.status());
+        }
+    }
+
+    protected synchronized void update(@NonNull T item) {
+        T local = queryItem(item);
+        switch (local == null ? SyncStatus.SYNCHRONIZED : local.status()) {
+            case SYNCHRONIZED:
+            case MODIFIED:
+            case ADDED:
+            case DELETED:
+                insertTransaction(item, SyncStatus.OUTDATED);
+                break;
+            case OUTDATED:
+                break;
+            default:
+                throw new RuntimeException("status=" + item.status());
+        }
+    }
+
+    protected synchronized void delete(@NonNull T item) {
+        T local = queryItem(item);
+        switch (local == null ? SyncStatus.DELETED : local.status()) {
+            case SYNCHRONIZED:
+            case OUTDATED:
+            case MODIFIED:
+            case ADDED:
+                delete(item);
+                break;
+            case DELETED:
+                break;
+            default:
+                throw new RuntimeException("status=" + item.status());
+        }
+    }
+
+    /**
+     * Delete all table rows but don't update service state. For example, a 
user logs out and all
+     * private data stored locally should be removed. If the sync adapter 
account is not removed,
+     * the data may be repopulated.
+     */
+    public synchronized void clear() {
+        client.deleteAll();
+    }
+
+    public synchronized void reconcile(@NonNull T item) {
+        completeTransaction(item, System.currentTimeMillis());
+
+        // TODO: delete items no longer present in the database. The passed in 
list of items is
+        //       expected to be the full list of items available on the 
service. After upserting,
+        //       delete anything older than the current timestamp.
+    }
+
+    @NonNull public synchronized Collection<T> startTransaction() {
+        Collection<T> items = querySyncable();
+        for (T item : items) {
+            item.startTransaction();
+            insertItem(item);
+        }
+        return items;
+    }
+
+    public synchronized void resetTransaction(@NonNull T item) {
+        if (!completable(item)) {
+            return;
+        }
+
+        item.resetTransaction(item.status());
+        insertItem(item);
+    }
+
+    public void completeTransaction(@NonNull T item) {
+        long timestamp = System.currentTimeMillis();
+        completeTransaction(item, timestamp);
+    }
+
+    public synchronized void completeTransaction(@NonNull T item, long 
timestamp) {
+        if (!completable(item)) {
+            return;
+        }
+
+        switch (item.status()) {
+            case SYNCHRONIZED:
+            case OUTDATED:
+            case MODIFIED:
+            case ADDED:
+                item.completeTransaction(timestamp);
+                insertItem(item);
+                break;
+            case DELETED:
+                removeItem(item);
+                break;
+            default:
+                throw new RuntimeException("status=" + item.status());
+        }
+    }
+
+    private boolean completable(@NonNull T item) {
+        T local = queryItem(item);
+        return item.completeable(local);
+    }
+
+    @NonNull private Collection<T> querySyncable() {
+        String[] selectionArgs = null;
+        String selection = UserOptionDatabaseTable.Col.SYNC_STATUS.getName() + 
" != " + SyncStatus.SYNCHRONIZED.code() + " and "
+                + UserOptionDatabaseTable.Col.SYNC_TRANSACTION_ID.getName() + 
" == " + DefaultSyncRow.NO_TRANSACTION_ID;
+        String sortOrder = null;
+        Cursor cursor = client.select(selection, selectionArgs, sortOrder);
+        return cursorToCollection(cursor);
+    }
+
+    @NonNull private Collection<T> cursorToCollection(@NonNull Cursor cursor) {
+        Collection<T> ret = new ArrayList<>();
+        while (cursor.moveToNext()) {
+            ret.add(client.fromCursor(cursor));
+        }
+        return ret;
+    }
+
+    private void addTransaction(@NonNull T item) {
+        insertTransaction(item, SyncStatus.ADDED);
+    }
+
+    private void modifyTransaction(@NonNull T item) {
+        insertTransaction(item, SyncStatus.MODIFIED);
+    }
+
+    private void insertTransaction(@NonNull T item, @NonNull SyncStatus 
status) {
+        item.resetTransaction(status);
+        insertItem(item);
+    }
+
+    @Nullable protected T queryItem(@NonNull T item) {
+        String[] selectionArgs = client.getPrimaryKeySelectionArgs(item);
+        String selection = client.getPrimaryKeySelection(item, selectionArgs);
+        String sortOrder = null;
+        Cursor cursor = client.select(selection, selectionArgs, sortOrder);
+        return cursor.moveToNext() ? client.fromCursor(cursor) : null;
+    }
+
+    private synchronized void removeItem(@NonNull T item) {
+        String[] selectionArgs = client.getPrimaryKeySelectionArgs(item);
+        client.delete(item, selectionArgs);
+    }
+
+    protected synchronized void insertItem(@NonNull T item) {
+        client.persist(item);
+    }
+}
diff --git 
a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwPostResponse.java 
b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwPostResponse.java
new file mode 100644
index 0000000..1a2f713
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwPostResponse.java
@@ -0,0 +1,26 @@
+package org.wikipedia.dataclient.mwapi;
+
+import android.support.annotation.Nullable;
+
+import org.wikipedia.server.mwapi.MwServiceError;
+
+public abstract class MwPostResponse {
+    @Nullable private String servedby;
+    @Nullable private MwServiceError error;
+
+    @Nullable public String code() {
+        return error == null ? null : error.getTitle();
+    }
+
+    @Nullable public String info() {
+        return error == null ? null : error.getDetails();
+    }
+
+    public boolean success(@Nullable String result) {
+        return error == null && "success".equals(result);
+    }
+
+    public boolean badToken() {
+        return error != null && error.badToken();
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/wikipedia/server/mwapi/MwServiceError.java 
b/app/src/main/java/org/wikipedia/server/mwapi/MwServiceError.java
index 3b43383..a3da521 100644
--- a/app/src/main/java/org/wikipedia/server/mwapi/MwServiceError.java
+++ b/app/src/main/java/org/wikipedia/server/mwapi/MwServiceError.java
@@ -22,6 +22,10 @@
         return docref;
     }
 
+    public boolean badToken() {
+        return "badtoken".equals(code);
+    }
+
     @Override
     public String toString() {
         return "MwServiceError{"
diff --git 
a/app/src/main/java/org/wikipedia/useroption/database/UserOptionDao.java 
b/app/src/main/java/org/wikipedia/useroption/database/UserOptionDao.java
new file mode 100644
index 0000000..7511ccb
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/useroption/database/UserOptionDao.java
@@ -0,0 +1,106 @@
+package org.wikipedia.useroption.database;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+import org.wikipedia.WikipediaApp;
+import org.wikipedia.concurrency.SaneAsyncTask;
+import org.wikipedia.database.sync.SyncRowDao;
+import org.wikipedia.theme.Theme;
+import org.wikipedia.useroption.UserOption;
+import org.wikipedia.useroption.sync.UserOptionContentResolver;
+
+import java.util.Collection;
+
+public final class UserOptionDao extends SyncRowDao<UserOptionRow> {
+    public interface Callback<T> {
+        void success(@Nullable T item);
+    }
+
+    private static final String THEME_KEY = "userjs-app-pref-theme";
+    private static final String FONT_SIZE_KEY = "userjs-app-pref-font-size";
+
+    private static UserOptionDao INSTANCE = new UserOptionDao();
+
+    public static UserOptionDao instance() {
+        return INSTANCE;
+    }
+
+    public void theme(@NonNull Theme theme) {
+        new UpsertTask(new UserOptionRow(THEME_KEY, 
String.valueOf(theme.isLight()))).execute();
+    }
+
+    public void theme(final Callback<Theme> callback) {
+        new QueryTask(THEME_KEY) {
+            @Override
+            public void onFinish(UserOptionRow result) {
+                callback.success(result == null
+                        ? null
+                        : Boolean.valueOf(result.val()) ? Theme.LIGHT : 
Theme.DARK);
+            }
+        }.execute();
+    }
+
+    public void fontSize(int size) {
+        new UpsertTask(new UserOptionRow(FONT_SIZE_KEY, 
String.valueOf(size))).execute();
+    }
+
+    public void fontSize(final Callback<Integer> callback) {
+        new QueryTask(FONT_SIZE_KEY) {
+            @Override
+            public void onFinish(UserOptionRow result) {
+                super.onFinish(result);
+                callback.success(result == null ? null : 
Integer.valueOf(result.val()));
+            }
+        }.execute();
+    }
+
+    @Nullable private UserOptionRow queryItem(@NonNull String key) {
+        return queryItem(new UserOptionRow(key));
+    }
+
+    public void reconcileOptions(@NonNull Collection<UserOption> options) {
+        for (UserOption option : options) {
+            reconcile(new UserOptionRow(option));
+        }
+    }
+
+    private UserOptionDao() {
+        
super(WikipediaApp.getInstance().getDatabaseClient(UserOptionRow.class));
+    }
+
+    // TODO: replace AsyncTasks with SQLBrite.
+
+    private class UpsertTask extends SaneAsyncTask<Void> {
+        @NonNull private final UserOptionRow row;
+
+        UpsertTask(@NonNull UserOptionRow row) {
+            this.row = row;
+        }
+
+        @Override
+        public Void performTask() throws Throwable {
+            upsert(row);
+            return null;
+        }
+
+        @Override
+        public void onFinish(Void result) {
+            super.onFinish(result);
+            UserOptionContentResolver.requestManualUpload();
+        }
+    }
+
+    private class QueryTask extends SaneAsyncTask<UserOptionRow> {
+        private final String key;
+
+        QueryTask(String key) {
+            this.key = key;
+        }
+
+        @Override
+        public UserOptionRow performTask() throws Throwable {
+            return queryItem(key);
+        }
+    }
+}
\ No newline at end of file
diff --git 
a/app/src/main/java/org/wikipedia/useroption/database/UserOptionRow.java 
b/app/src/main/java/org/wikipedia/useroption/database/UserOptionRow.java
index 8a0454b..5734a36 100644
--- a/app/src/main/java/org/wikipedia/useroption/database/UserOptionRow.java
+++ b/app/src/main/java/org/wikipedia/useroption/database/UserOptionRow.java
@@ -61,8 +61,8 @@
     }
 
     @Override
-    public boolean isTransaction(@NonNull SyncRow row) {
-        return sync.isTransaction(row);
+    public boolean completeable(@NonNull SyncRow row) {
+        return sync.completeable(row);
     }
 
     @Override
diff --git 
a/app/src/main/java/org/wikipedia/useroption/dataclient/DefaultUserOptionDataClient.java
 
b/app/src/main/java/org/wikipedia/useroption/dataclient/DefaultUserOptionDataClient.java
index dc1f918..e43c511 100644
--- 
a/app/src/main/java/org/wikipedia/useroption/dataclient/DefaultUserOptionDataClient.java
+++ 
b/app/src/main/java/org/wikipedia/useroption/dataclient/DefaultUserOptionDataClient.java
@@ -8,6 +8,7 @@
 import org.wikipedia.Site;
 import org.wikipedia.WikipediaApp;
 import org.wikipedia.dataclient.RestAdapterFactory;
+import org.wikipedia.dataclient.mwapi.MwPostResponse;
 import org.wikipedia.dataclient.mwapi.MwQueryResponse;
 import org.wikipedia.editing.FetchEditTokenTask;
 import org.wikipedia.useroption.UserOption;
@@ -38,12 +39,12 @@
 
     @Override
     public void post(@NonNull UserOption option) {
-        client.post(getToken(), option.key(), option.val()).check();
+        client.post(getToken(), option.key(), option.val()).check(site);
     }
 
     @Override
     public void delete(@NonNull UserOption option) {
-        client.delete(getToken(), option.key()).check();
+        client.delete(getToken(), option.key()).check(site);
     }
 
     @NonNull private String getToken() {
@@ -72,7 +73,7 @@
         }.execute();
     }
 
-    private WikipediaApp app() {
+    private static WikipediaApp app() {
         return WikipediaApp.getInstance();
     }
 
@@ -101,22 +102,21 @@
                                      @Query("change") @NonNull String key);
     }
 
-    private static class PostResponse {
+    private static class PostResponse extends MwPostResponse {
         private String options;
-
-        public boolean success() {
-            return "success".equals(options);
-        }
 
         public String result() {
             return options;
         }
 
-        public void check() {
-            if (!success()) {
-                // TODO: pass actual URL (here and elsewhere). This class is 
populated by Retrofit and
-                //       doesn't seem to be able to hold references to the 
outter class' instance members.
-                throw RetrofitError.unexpectedError("", new 
RuntimeException("Bad response=" + result()));
+        public void check(@NonNull Site site) {
+            if (!success(options)) {
+                if (badToken()) {
+                    app().getEditTokenStorage().token(site, null);
+                }
+
+                throw RetrofitError.unexpectedError(site.host(),
+                        new RuntimeException("Bad response=" + result()));
             }
         }
     }
diff --git 
a/app/src/main/java/org/wikipedia/useroption/sync/UserOptionContentResolver.java
 
b/app/src/main/java/org/wikipedia/useroption/sync/UserOptionContentResolver.java
new file mode 100644
index 0000000..453e217
--- /dev/null
+++ 
b/app/src/main/java/org/wikipedia/useroption/sync/UserOptionContentResolver.java
@@ -0,0 +1,84 @@
+package org.wikipedia.useroption.sync;
+
+import android.accounts.Account;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+import org.wikipedia.BuildConfig;
+import org.wikipedia.WikipediaApp;
+import org.wikipedia.auth.AccountUtil;
+import org.wikipedia.theme.Theme;
+import org.wikipedia.useroption.database.UserOptionDao;
+import org.wikipedia.useroption.database.UserOptionRow;
+import org.wikipedia.util.log.L;
+
+public final class UserOptionContentResolver {
+    public static void requestManualUpload() {
+        requestManualSync(true);
+    }
+
+    public static void requestManualSync() {
+        requestManualSync(false);
+    }
+
+    public static void requestManualSync(boolean uploadOnly) {
+        Bundle bundle = new Bundle();
+        bundle.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true);
+        bundle.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true);
+        bundle.putBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD, uploadOnly);
+
+        requestSync(bundle);
+    }
+
+    public static void registerAppSyncObserver(@NonNull Context context) {
+        Uri uri = UserOptionRow.DATABASE_TABLE.getBaseContentURI();
+        UserOptionContentObserver observer = new UserOptionContentObserver();
+        context.getContentResolver().registerContentObserver(uri, true, 
observer);
+    }
+
+    private static void requestSync(@NonNull Bundle bundle) {
+        Account account = AccountUtil.account();
+        if (account == null) {
+            L.i("no account");
+            return;
+        }
+
+        ContentResolver.requestSync(account, 
BuildConfig.USER_OPTION_AUTHORITY, bundle);
+    }
+
+    private UserOptionContentResolver() { }
+
+    private static class UserOptionContentObserver extends ContentObserver {
+        UserOptionContentObserver() {
+            super(new Handler());
+        }
+
+        @Override
+        public void onChange(boolean selfChange) {
+            super.onChange(selfChange);
+
+            UserOptionDao.instance().theme(new UserOptionDao.Callback<Theme>() 
{
+                @Override
+                public void success(@Nullable Theme item) {
+                    if (item != null) {
+                        WikipediaApp.getInstance().setCurrentTheme(item);
+                    }
+                }
+            });
+            UserOptionDao.instance().fontSize(new 
UserOptionDao.Callback<Integer>() {
+                @Override
+                public void success(@Nullable Integer item) {
+                    if (item != null) {
+                        WikipediaApp.getInstance().setFontSizeMultiplier(item);
+                    }
+                }
+            });
+        }
+    }
+}
\ No newline at end of file
diff --git 
a/app/src/main/java/org/wikipedia/useroption/sync/UserOptionSyncAdapter.java 
b/app/src/main/java/org/wikipedia/useroption/sync/UserOptionSyncAdapter.java
new file mode 100644
index 0000000..8986dd2
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/useroption/sync/UserOptionSyncAdapter.java
@@ -0,0 +1,76 @@
+package org.wikipedia.useroption.sync;
+
+import android.accounts.Account;
+import android.content.AbstractThreadedSyncAdapter;
+import android.content.ContentProviderClient;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.SyncResult;
+import android.os.Bundle;
+
+import org.wikipedia.auth.AccountUtil;
+import org.wikipedia.database.sync.SyncStatus;
+import org.wikipedia.useroption.UserOption;
+import org.wikipedia.useroption.database.UserOptionDao;
+import org.wikipedia.useroption.database.UserOptionRow;
+import org.wikipedia.useroption.dataclient.UserInfo;
+import org.wikipedia.useroption.dataclient.UserOptionDataClientSingleton;
+import org.wikipedia.util.log.L;
+
+import java.util.Collection;
+
+import retrofit.RetrofitError;
+
+public class UserOptionSyncAdapter extends AbstractThreadedSyncAdapter {
+    public UserOptionSyncAdapter(Context context, boolean autoInitialize) {
+        super(context, autoInitialize);
+    }
+
+    @Override
+    public void onPerformSync(Account account, Bundle extras, String authority,
+                              ContentProviderClient provider, SyncResult 
syncResult) {
+        if (!AccountUtil.supported(account)) {
+            L.i("unexpected account=" + account);
+            ++syncResult.stats.numAuthExceptions;
+            return;
+        }
+
+        boolean uploadOnly = 
extras.getBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD);
+
+        try {
+            upload();
+            if (!uploadOnly) {
+                download();
+            }
+        } catch (RetrofitError e) {
+            L.d(e);
+            ++syncResult.stats.numIoExceptions;
+        }
+    }
+
+    private void download() {
+        UserInfo info = UserOptionDataClientSingleton.instance().get();
+        Collection<UserOption> options = info.userjsOptions();
+        L.i("downloaded " + options.size() + " option(s)");
+        UserOptionDao.instance().reconcileOptions(options);
+    }
+
+    private void upload() {
+        Collection<UserOptionRow> options = 
UserOptionDao.instance().startTransaction();
+        L.i("uploading " + options.size() + " option(s)");
+        for (UserOptionRow option : options) {
+            try {
+                if (option.status() == SyncStatus.DELETED) {
+                    UserOptionDataClientSingleton.instance().delete(option);
+                } else {
+                    UserOptionDataClientSingleton.instance().post(option);
+                }
+            } catch (RetrofitError e) {
+                UserOptionDao.instance().resetTransaction(option);
+                throw e;
+            }
+
+            UserOptionDao.instance().completeTransaction(option);
+        }
+    }
+}
\ No newline at end of file
diff --git 
a/app/src/main/java/org/wikipedia/useroption/sync/UserOptionSyncService.java 
b/app/src/main/java/org/wikipedia/useroption/sync/UserOptionSyncService.java
new file mode 100644
index 0000000..7c0b235
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/useroption/sync/UserOptionSyncService.java
@@ -0,0 +1,29 @@
+package org.wikipedia.useroption.sync;
+
+import android.app.Service;
+import android.content.AbstractThreadedSyncAdapter;
+import android.content.Intent;
+import android.os.IBinder;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+public class UserOptionSyncService extends Service {
+    @NonNull private static final Object SYNC_ADAPTER_LOCK = new Object();
+    @Nullable private static AbstractThreadedSyncAdapter SYNC_ADAPTER;
+
+    @Override
+    public void onCreate() {
+        super.onCreate();
+        synchronized (SYNC_ADAPTER_LOCK) {
+            if (SYNC_ADAPTER == null) {
+                SYNC_ADAPTER = new 
UserOptionSyncAdapter(getApplicationContext(), true);
+            }
+        }
+    }
+
+    @Nullable
+    @Override
+    public IBinder onBind(Intent intent) {
+        return SYNC_ADAPTER == null ? null : 
SYNC_ADAPTER.getSyncAdapterBinder();
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/res/values-qq/strings.xml 
b/app/src/main/res/values-qq/strings.xml
index 95ceb1b..5574e29 100644
--- a/app/src/main/res/values-qq/strings.xml
+++ b/app/src/main/res/values-qq/strings.xml
@@ -381,4 +381,5 @@
   <string name="article_menu_bar_bookmark">Describes the action performed when 
pressing the bookmark button in the page toolbar underneath the leading 
image.</string>
   <string name="article_menu_bar_share">Describes the action performed when 
pressing the share button in the page toolbar underneath the leading 
image.</string>
   <string name="article_menu_bar_navigate">Describes the action performed when 
pressing the navigate button in the page toolbar underneath the leading 
image.</string>
+  <string name="user_option_sync_label">Checkbox title for Wikimedia account 
preference synchronization.</string>
 </resources>
diff --git a/app/src/main/res/values/strings.xml 
b/app/src/main/res/values/strings.xml
index af15834..f561488 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -307,4 +307,8 @@
     <string name="article_menu_bar_share">Share the article link</string>
     <string name="article_menu_bar_navigate">Navigate to the location of the 
article</string>
     <!-- /Article menu bar -->
+
+    <!-- User options -->
+    <string name="user_option_sync_label">Preferences</string>
+    <!-- /User options -->
 </resources>
diff --git a/app/src/main/res/xml/user_option_sync_adapter.xml 
b/app/src/main/res/xml/user_option_sync_adapter.xml
new file mode 100644
index 0000000..5d52882
--- /dev/null
+++ b/app/src/main/res/xml/user_option_sync_adapter.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sync-adapter
+    xmlns:android="http://schemas.android.com/apk/res/android";
+    android:contentAuthority="@string/user_option_authority"
+    android:accountType="@string/account_type"
+    android:supportsUploading="true"
+    android:isAlwaysSyncable="true" />
\ No newline at end of file

-- 
To view, visit https://gerrit.wikimedia.org/r/272802
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: Ibb3428ac4b35e114b3f753488f5dbc38ab029095
Gerrit-PatchSet: 7
Gerrit-Project: apps/android/wikipedia
Gerrit-Branch: master
Gerrit-Owner: Niedzielski <[email protected]>
Gerrit-Reviewer: BearND <[email protected]>
Gerrit-Reviewer: Brion VIBBER <[email protected]>
Gerrit-Reviewer: Dbrant <[email protected]>
Gerrit-Reviewer: Mholloway <[email protected]>
Gerrit-Reviewer: Niedzielski <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to