diff --git a/app/build.gradle b/app/build.gradle
index fe2900dac..d73a27bd6 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,6 +1,7 @@
plugins {
id 'com.github.triplet.play' version '2.2.1' apply false
}
+
apply from: '../gitutils.gradle'
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
@@ -30,8 +31,8 @@ dependencies {
implementation 'com.jakewharton.rxbinding2:rxbinding-design:2.1.1'
implementation 'com.facebook.fresco:fresco:1.13.0'
implementation 'com.drewnoakes:metadata-extractor:2.11.0'
+ implementation 'com.dmitrybrant:wikimedia-android-data-client:0.0.18'
implementation 'org.apache.commons:commons-lang3:3.8.1'
- implementation 'com.dmitrybrant:wikimedia-android-data-client:0.0.25'
// UI
implementation 'fr.avianey.com.viewpagerindicator:library:2.4.1.1@aar'
@@ -191,7 +192,6 @@ android {
buildConfigField "String", "IMAGE_URL_BASE", "\"https://upload.wikimedia.org/wikipedia/commons\""
buildConfigField "String", "HOME_URL", "\"https://commons.wikimedia.org/wiki/\""
buildConfigField "String", "COMMONS_URL", "\"https://commons.wikimedia.org\""
- buildConfigField "String", "WIKIDATA_URL", "\"https://wikidata.org\""
buildConfigField "String", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.org/wiki/\""
buildConfigField "String", "SIGNUP_LANDING_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Special:CreateAccount&returnto=Main+Page&returntoquery=welcome%3Dyes\""
buildConfigField "String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Main_Page&welcome=yes\""
@@ -223,7 +223,6 @@ android {
buildConfigField "String", "IMAGE_URL_BASE", "\"https://upload.beta.wmflabs.org/wikipedia/commons\""
buildConfigField "String", "HOME_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/\""
buildConfigField "String", "COMMONS_URL", "\"https://commons.wikimedia.beta.wmflabs.org\""
- buildConfigField "String", "WIKIDATA_URL", "\"https://wikidata.org\""
buildConfigField "String", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/wiki/\""
buildConfigField "String", "SIGNUP_LANDING_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Special:CreateAccount&returnto=Main+Page&returntoquery=welcome%3Dyes\""
buildConfigField "String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Main_Page&welcome=yes\""
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ad88b720c..b2e434bd4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -152,8 +152,7 @@
+ android:exported="true">
@@ -162,6 +161,17 @@
android:name="android.content.SyncAdapter"
android:resource="@xml/contributions_sync_adapter" />
+
+
+
+
+
+
+
-
+
CREATOR = new Creator() {
@Override
public Media createFromParcel(Parcel parcel) {
@@ -158,9 +156,9 @@ public class Media implements Parcelable {
page.title(),
"",
0,
- safeParseDate(metadata.dateTime()),
- safeParseDate(metadata.dateTime()),
- StringUtil.fromHtml(metadata.artist()).toString()
+ safeParseDate(metadata.dateTimeOriginal().value()),
+ safeParseDate(metadata.dateTime().value()),
+ StringUtil.fromHtml(metadata.artist().value()).toString()
);
if (!StringUtils.isBlank(imageInfo.getThumbUrl())) {
@@ -172,17 +170,17 @@ public class Media implements Parcelable {
language = "default";
}
- media.setDescriptions(Collections.singletonMap(language, metadata.imageDescription()));
- media.setCategories(MediaDataExtractorUtil.extractCategoriesFromList(metadata.getCategories()));
- String latitude = metadata.getGpsLatitude();
- String longitude = metadata.getGpsLongitude();
+ media.setDescriptions(Collections.singletonMap(language, metadata.imageDescription().value()));
+ media.setCategories(MediaDataExtractorUtil.extractCategoriesFromList(metadata.categories().value()));
+ String latitude = metadata.gpsLatitude().value();
+ String longitude = metadata.gpsLongitude().value();
if (!StringUtils.isBlank(latitude) && !StringUtils.isBlank(longitude)) {
LatLng latLng = new LatLng(Double.parseDouble(latitude), Double.parseDouble(longitude), 0);
media.setCoordinates(latLng);
}
- media.setLicenseInformation(metadata.licenseShortName(), metadata.licenseUrl());
+ media.setLicenseInformation(metadata.licenseShortName().value(), metadata.licenseUrl().value());
return media;
}
diff --git a/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.java b/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.java
index c2e301826..4779e0455 100644
--- a/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.java
+++ b/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.java
@@ -1,11 +1,9 @@
package fr.free.nrw.commons;
-import androidx.core.text.HtmlCompat;
-
import javax.inject.Inject;
import javax.inject.Singleton;
-import fr.free.nrw.commons.media.MediaClient;
+import androidx.core.text.HtmlCompat;
import fr.free.nrw.commons.mwapi.MediaWikiApi;
import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient;
import io.reactivex.Single;
@@ -21,15 +19,12 @@ import timber.log.Timber;
public class MediaDataExtractor {
private final MediaWikiApi mediaWikiApi;
private final OkHttpJsonApiClient okHttpJsonApiClient;
- private final MediaClient mediaClient;
@Inject
public MediaDataExtractor(MediaWikiApi mwApi,
- OkHttpJsonApiClient okHttpJsonApiClient,
- MediaClient mediaClient) {
+ OkHttpJsonApiClient okHttpJsonApiClient) {
this.okHttpJsonApiClient = okHttpJsonApiClient;
this.mediaWikiApi = mwApi;
- this.mediaClient = mediaClient;
}
/**
@@ -40,7 +35,7 @@ public class MediaDataExtractor {
*/
public Single fetchMediaDetails(String filename) {
Single mediaSingle = getMediaFromFileName(filename);
- Single pageExistsSingle = mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + filename);
+ Single pageExistsSingle = mediaWikiApi.pageExists("Commons:Deletion_requests/" + filename);
Single discussionSingle = getDiscussion(filename);
return Single.zip(mediaSingle, pageExistsSingle, discussionSingle, (media, deletionStatus, discussion) -> {
media.setDiscussion(discussion);
@@ -57,7 +52,7 @@ public class MediaDataExtractor {
* @return return data rich Media object
*/
public Single getMediaFromFileName(String filename) {
- return mediaClient.getMedia(filename);
+ return okHttpJsonApiClient.getMedia(filename, false);
}
/**
diff --git a/app/src/main/java/fr/free/nrw/commons/actions/PageEditClient.java b/app/src/main/java/fr/free/nrw/commons/actions/PageEditClient.java
deleted file mode 100644
index e2adfb5e6..000000000
--- a/app/src/main/java/fr/free/nrw/commons/actions/PageEditClient.java
+++ /dev/null
@@ -1,58 +0,0 @@
-package fr.free.nrw.commons.actions;
-
-import org.wikipedia.csrf.CsrfTokenClient;
-import org.wikipedia.dataclient.Service;
-
-import io.reactivex.Observable;
-import timber.log.Timber;
-
-public class PageEditClient {
-
- private final CsrfTokenClient csrfTokenClient;
- private final PageEditInterface pageEditInterface;
- private final Service service;
-
- public PageEditClient(CsrfTokenClient csrfTokenClient,
- PageEditInterface pageEditInterface,
- Service service) {
- this.csrfTokenClient = csrfTokenClient;
- this.pageEditInterface = pageEditInterface;
- this.service = service;
- }
-
- public Observable edit(String pageTitle, String text, String summary) {
- try {
- return pageEditInterface.postEdit(pageTitle, summary, text, csrfTokenClient.getTokenBlocking())
- .map(editResponse -> editResponse.edit().editSucceeded());
- } catch (Throwable throwable) {
- return Observable.just(false);
- }
- }
-
- public Observable appendEdit(String pageTitle, String appendText, String summary) {
- try {
- return pageEditInterface.postAppendEdit(pageTitle, summary, appendText, csrfTokenClient.getTokenBlocking())
- .map(editResponse -> editResponse.edit().editSucceeded());
- } catch (Throwable throwable) {
- return Observable.just(false);
- }
- }
-
- public Observable prependEdit(String pageTitle, String prependText, String summary) {
- try {
- return pageEditInterface.postPrependEdit(pageTitle, summary, prependText, csrfTokenClient.getTokenBlocking())
- .map(editResponse -> editResponse.edit().editSucceeded());
- } catch (Throwable throwable) {
- return Observable.just(false);
- }
- }
-
- public Observable addEditTag(long revisionId, String tagName, String reason) {
- try {
- return service.addEditTag(String.valueOf(revisionId), tagName, reason, csrfTokenClient.getTokenBlocking())
- .map(mwPostResponse -> mwPostResponse.getSuccessVal());
- } catch (Throwable throwable) {
- return Observable.just(-1);
- }
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/actions/PageEditInterface.java b/app/src/main/java/fr/free/nrw/commons/actions/PageEditInterface.java
deleted file mode 100644
index 537ec4d4f..000000000
--- a/app/src/main/java/fr/free/nrw/commons/actions/PageEditInterface.java
+++ /dev/null
@@ -1,41 +0,0 @@
-package fr.free.nrw.commons.actions;
-
-import androidx.annotation.NonNull;
-
-import org.wikipedia.edit.Edit;
-
-import io.reactivex.Observable;
-import retrofit2.http.Field;
-import retrofit2.http.FormUrlEncoded;
-import retrofit2.http.Headers;
-import retrofit2.http.POST;
-
-import static org.wikipedia.dataclient.Service.MW_API_PREFIX;
-
-public interface PageEditInterface {
-
- @FormUrlEncoded
- @Headers("Cache-Control: no-cache")
- @POST(MW_API_PREFIX + "action=edit")
- @NonNull
- Observable postEdit(@NonNull @Field("title") String title,
- @NonNull @Field("summary") String summary,
- @NonNull @Field("text") String text,
- @NonNull @Field("token") String token);
-
- @FormUrlEncoded
- @Headers("Cache-Control: no-cache")
- @POST(MW_API_PREFIX + "action=edit")
- @NonNull Observable postAppendEdit(@NonNull @Field("title") String title,
- @NonNull @Field("summary") String summary,
- @NonNull @Field("appendtext") String text,
- @NonNull @Field("token") String token);
-
- @FormUrlEncoded
- @Headers("Cache-Control: no-cache")
- @POST(MW_API_PREFIX + "action=edit")
- @NonNull Observable postPrependEdit(@NonNull @Field("title") String title,
- @NonNull @Field("summary") String summary,
- @NonNull @Field("prependtext") String text,
- @NonNull @Field("token") String token);
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.java b/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.java
deleted file mode 100644
index c4f96e7eb..000000000
--- a/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.java
+++ /dev/null
@@ -1,36 +0,0 @@
-package fr.free.nrw.commons.actions;
-
-import org.wikipedia.csrf.CsrfTokenClient;
-import org.wikipedia.dataclient.Service;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-import javax.inject.Singleton;
-
-import fr.free.nrw.commons.CommonsApplication;
-import io.reactivex.Observable;
-
-@Singleton
-public class ThanksClient {
-
- private final CsrfTokenClient csrfTokenClient;
- private final Service service;
-
- @Inject
- public ThanksClient(@Named("commons-csrf") CsrfTokenClient csrfTokenClient,
- @Named("commons-service") Service service) {
- this.csrfTokenClient = csrfTokenClient;
- this.service = service;
- }
-
- public Observable thank(long revisionId) {
- try {
- return service.thank(String.valueOf(revisionId), null,
- csrfTokenClient.getTokenBlocking(),
- CommonsApplication.getInstance().getUserAgent())
- .map(mwQueryResponse -> mwQueryResponse.getSuccessVal() == 1);
- } catch (Throwable throwable) {
- return Observable.just(false);
- }
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.java b/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.java
index 7616b4515..65e211c83 100644
--- a/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.java
+++ b/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.java
@@ -3,8 +3,8 @@ package fr.free.nrw.commons.auth;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.content.Context;
-
import androidx.annotation.Nullable;
+
import fr.free.nrw.commons.BuildConfig;
import timber.log.Timber;
@@ -12,8 +12,10 @@ public class AccountUtil {
public static final String AUTH_COOKIE = "authCookie";
public static final String AUTH_TOKEN_TYPE = "CommonsAndroid";
+ private final Context context;
- public AccountUtil() {
+ public AccountUtil(Context context) {
+ this.context = context;
}
/**
@@ -47,4 +49,5 @@ public class AccountUtil {
private static AccountManager accountManager(Context context) {
return AccountManager.get(context);
}
+
}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/AuthenticatedActivity.java b/app/src/main/java/fr/free/nrw/commons/auth/AuthenticatedActivity.java
index 426a84630..3bb1ab9a1 100644
--- a/app/src/main/java/fr/free/nrw/commons/auth/AuthenticatedActivity.java
+++ b/app/src/main/java/fr/free/nrw/commons/auth/AuthenticatedActivity.java
@@ -12,19 +12,48 @@ import io.reactivex.Observable;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.schedulers.Schedulers;
+import static fr.free.nrw.commons.auth.AccountUtil.AUTH_COOKIE;
+
public abstract class AuthenticatedActivity extends NavigationBaseActivity {
@Inject
protected SessionManager sessionManager;
@Inject
MediaWikiApi mediaWikiApi;
+ private String authCookie;
+
+ protected void requestAuthToken() {
+ if (authCookie != null) {
+ onAuthCookieAcquired(authCookie);
+ return;
+ }
+ authCookie = sessionManager.getAuthCookie();
+ if (authCookie != null) {
+ onAuthCookieAcquired(authCookie);
+ }
+ }
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
+
+ if (savedInstanceState != null) {
+ authCookie = savedInstanceState.getString(AUTH_COOKIE);
+ }
+
showBlockStatus();
}
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putString(AUTH_COOKIE, authCookie);
+ }
+
+ protected abstract void onAuthCookieAcquired(String authCookie);
+
+ protected abstract void onAuthFailure();
+
/**
* Makes API call to check if user is blocked from Commons. If the user is blocked, a snackbar
* is created to notify the user
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java
index 6915d075a..8487e9a95 100644
--- a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java
+++ b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java
@@ -1,6 +1,9 @@
package fr.free.nrw.commons.auth;
+import android.accounts.Account;
import android.accounts.AccountAuthenticatorActivity;
+import android.accounts.AccountAuthenticatorResponse;
+import android.accounts.AccountManager;
import android.app.ProgressDialog;
import android.content.Context;
import android.content.Intent;
@@ -17,6 +20,14 @@ import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
+import com.google.android.material.textfield.TextInputLayout;
+
+import java.io.IOException;
+import java.util.Locale;
+
+import javax.inject.Inject;
+import javax.inject.Named;
+
import androidx.annotation.ColorRes;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
@@ -24,17 +35,6 @@ import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.app.NavUtils;
import androidx.core.content.ContextCompat;
-
-import com.google.android.material.textfield.TextInputLayout;
-
-import org.wikipedia.AppAdapter;
-import org.wikipedia.dataclient.WikiSite;
-import org.wikipedia.login.LoginClient;
-import org.wikipedia.login.LoginResult;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-
import butterknife.BindView;
import butterknife.ButterKnife;
import butterknife.OnClick;
@@ -52,17 +52,16 @@ import fr.free.nrw.commons.mwapi.MediaWikiApi;
import fr.free.nrw.commons.theme.NavigationBaseActivity;
import fr.free.nrw.commons.utils.ConfigUtils;
import fr.free.nrw.commons.utils.ViewUtil;
-import io.reactivex.Completable;
+import io.reactivex.Observable;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable;
-import io.reactivex.functions.Action;
import io.reactivex.schedulers.Schedulers;
import timber.log.Timber;
import static android.view.KeyEvent.KEYCODE_ENTER;
import static android.view.View.VISIBLE;
import static android.view.inputmethod.EditorInfo.IME_ACTION_DONE;
-import static fr.free.nrw.commons.di.NetworkingModule.NAMED_COMMONS_WIKI_SITE;
+import static fr.free.nrw.commons.auth.AccountUtil.AUTH_TOKEN_TYPE;
public class LoginActivity extends AccountAuthenticatorActivity {
@@ -72,17 +71,10 @@ public class LoginActivity extends AccountAuthenticatorActivity {
@Inject
SessionManager sessionManager;
- @Inject
- @Named(NAMED_COMMONS_WIKI_SITE)
- WikiSite commonsWikiSite;
-
@Inject
@Named("default_preferences")
JsonKvStore applicationKvStore;
- @Inject
- LoginClient loginClient;
-
@BindView(R.id.login_button)
Button loginButton;
@@ -112,6 +104,13 @@ public class LoginActivity extends AccountAuthenticatorActivity {
private LoginTextWatcher textWatcher = new LoginTextWatcher();
private CompositeDisposable compositeDisposable = new CompositeDisposable();
+ private Boolean loginCurrentlyInProgress = false;
+ private Boolean errorMessageShown = false;
+ private String resultantError;
+ private static final String RESULTANT_ERROR = "resultantError";
+ private static final String ERROR_MESSAGE_SHOWN = "errorMessageShown";
+ private static final String LOGGING_IN = "loggingIn";
+
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@@ -212,8 +211,10 @@ public class LoginActivity extends AccountAuthenticatorActivity {
}
if (sessionManager.getCurrentAccount() != null
- && sessionManager.isUserLoggedIn()) {
+ && sessionManager.isUserLoggedIn()
+ && sessionManager.getCachedAuthCookie() != null) {
applicationKvStore.putBoolean("login_skipped", false);
+ sessionManager.revalidateAuthToken();
startMainActivity();
}
@@ -243,6 +244,7 @@ public class LoginActivity extends AccountAuthenticatorActivity {
@OnClick(R.id.login_button)
public void performLogin() {
+ loginCurrentlyInProgress = true;
Timber.d("Login to start!");
final String username = usernameEdit.getText().toString();
final String rawUsername = usernameEdit.getText().toString().trim();
@@ -250,37 +252,23 @@ public class LoginActivity extends AccountAuthenticatorActivity {
String twoFactorCode = twoFactorEdit.getText().toString();
showLoggingProgressBar();
- doLogin(username, password, twoFactorCode);
- }
-
- private void doLogin(String username, String password, String twoFactorCode) {
- progressDialog.show();
-
- Action action = () -> {
- try {
- loginClient.loginBlocking(commonsWikiSite, username, password, twoFactorCode);
- } catch (Throwable throwable) {
- throwable.printStackTrace();
- }
- };
-
- compositeDisposable.add(Completable.fromAction(action)
+ compositeDisposable.add(Observable.fromCallable(() -> login(username, password, twoFactorCode))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
- .subscribe(() -> onLoginSuccess(username, password),
- error -> {
- if (error instanceof LoginClient.LoginFailedException) {
- LoginClient.LoginFailedException exception = (LoginClient.LoginFailedException) error;
- if (exception.getMessage().equals("2FA")) {
- askUserForTwoFactorAuth();
- }
- }
- if (!progressDialog.isShowing()) {
- return;
- }
- progressDialog.dismiss();
- showMessageAndCancelDialog(R.string.error_occurred);
- }));
+ .subscribe(result -> handleLogin(username, rawUsername, password, result)));
+ }
+
+ private String login(String username, String password, String twoFactorCode) {
+ try {
+ if (twoFactorCode.isEmpty()) {
+ return mwApi.login(username, password);
+ } else {
+ return mwApi.login(username, password, twoFactorCode);
+ }
+ } catch (IOException e) {
+ // Do something better!
+ return "NetworkFailure";
+ }
}
/**
@@ -293,6 +281,18 @@ public class LoginActivity extends AccountAuthenticatorActivity {
finish();
}
+ private void handleLogin(String username, String rawUsername, String password, String result) {
+ Timber.d("Login done!");
+ if (result.equals("PASS")) {
+ handlePassResult(username, rawUsername, password);
+ } else {
+ loginCurrentlyInProgress = false;
+ errorMessageShown = true;
+ resultantError = result;
+ handleOtherResults(result);
+ }
+ }
+
private void showLoggingProgressBar() {
progressDialog = new ProgressDialog(this);
progressDialog.setIndeterminate(true);
@@ -302,19 +302,67 @@ public class LoginActivity extends AccountAuthenticatorActivity {
progressDialog.show();
}
- private void onLoginSuccess(String username, String password) {
- if (!progressDialog.isShowing()) {
- // no longer attached to activity!
- return;
- }
- sessionManager.setUserLoggedIn(true);
- LoginResult loginResult = new LoginResult(commonsWikiSite, "PASS", username, password, "");
- AppAdapter.get().updateAccount(loginResult);
- progressDialog.dismiss();
+ private void handlePassResult(String username, String rawUsername, String password) {
showSuccessAndDismissDialog();
+ requestAuthToken();
+ AccountAuthenticatorResponse response = null;
+
+ Bundle extras = getIntent().getExtras();
+ if (extras != null) {
+ Timber.d("Bundle of extras: %s", extras);
+ response = extras.getParcelable(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE);
+ if (response != null) {
+ Bundle authResult = new Bundle();
+ authResult.putString(AccountManager.KEY_ACCOUNT_NAME, username);
+ authResult.putString(AccountManager.KEY_ACCOUNT_TYPE, BuildConfig.ACCOUNT_TYPE);
+ response.onResult(authResult);
+ }
+ }
+
+ sessionManager.createAccount(response, username, rawUsername, password);
startMainActivity();
}
+ protected void requestAuthToken() {
+ AccountManager accountManager = AccountManager.get(this);
+ Account curAccount = sessionManager.getCurrentAccount();
+ if (curAccount != null) {
+ accountManager.setAuthToken(curAccount, AUTH_TOKEN_TYPE, mwApi.getAuthCookie());
+ }
+ }
+
+ /**
+ * Match known failure message codes and provide messages.
+ *
+ * @param result String
+ */
+ private void handleOtherResults(String result) {
+ if (result.equals("NetworkFailure")) {
+ // Matches NetworkFailure which is created by the doInBackground method
+ showMessageAndCancelDialog(R.string.login_failed_network);
+ } else if (result.toLowerCase(Locale.getDefault()).contains("nosuchuser".toLowerCase()) || result.toLowerCase().contains("noname".toLowerCase())) {
+ // Matches nosuchuser, nosuchusershort, noname
+ showMessageAndCancelDialog(R.string.login_failed_wrong_credentials);
+ emptySensitiveEditFields();
+ } else if (result.toLowerCase(Locale.getDefault()).contains("wrongpassword".toLowerCase())) {
+ // Matches wrongpassword, wrongpasswordempty
+ showMessageAndCancelDialog(R.string.login_failed_wrong_credentials);
+ emptySensitiveEditFields();
+ } else if (result.toLowerCase(Locale.getDefault()).contains("throttle".toLowerCase())) {
+ // Matches unknown throttle error codes
+ showMessageAndCancelDialog(R.string.login_failed_throttled);
+ } else if (result.toLowerCase(Locale.getDefault()).contains("userblocked".toLowerCase())) {
+ // Matches login-userblocked
+ showMessageAndCancelDialog(R.string.login_failed_blocked);
+ } else if (result.equals("2FA")) {
+ askUserForTwoFactorAuth();
+ } else {
+ // Occurs with unhandled login failure codes
+ Timber.d("Login failed with reason: %s", result);
+ showMessageAndCancelDialog(R.string.login_failed_generic);
+ }
+ }
+
@Override
protected void onStart() {
super.onStart();
@@ -354,6 +402,30 @@ public class LoginActivity extends AccountAuthenticatorActivity {
return getDelegate().getMenuInflater();
}
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putBoolean(LOGGING_IN, loginCurrentlyInProgress);
+ outState.putBoolean(ERROR_MESSAGE_SHOWN, errorMessageShown);
+ outState.putString(RESULTANT_ERROR, resultantError);
+ }
+
+ @Override
+ protected void onRestoreInstanceState(Bundle savedInstanceState) {
+ super.onRestoreInstanceState(savedInstanceState);
+ loginCurrentlyInProgress = savedInstanceState.getBoolean(LOGGING_IN, false);
+ errorMessageShown = savedInstanceState.getBoolean(ERROR_MESSAGE_SHOWN, false);
+ if (loginCurrentlyInProgress) {
+ performLogin();
+ }
+ if (errorMessageShown) {
+ resultantError = savedInstanceState.getString(RESULTANT_ERROR);
+ if (resultantError != null) {
+ handleOtherResults(resultantError);
+ }
+ }
+ }
+
public void askUserForTwoFactorAuth() {
progressDialog.dismiss();
twoFactorContainer.setVisibility(VISIBLE);
@@ -368,18 +440,16 @@ public class LoginActivity extends AccountAuthenticatorActivity {
}
}
- public void showMessageAndCancelDialog(String error) {
- showMessage(error, R.color.secondaryDarkColor);
- if (progressDialog != null) {
- progressDialog.cancel();
- }
- }
-
public void showSuccessAndDismissDialog() {
showMessage(R.string.login_success, R.color.primaryDarkColor);
progressDialog.dismiss();
}
+ public void emptySensitiveEditFields() {
+ passwordEdit.setText("");
+ twoFactorEdit.setText("");
+ }
+
public void startMainActivity() {
NavigationBaseActivity.startActivityWithFlags(this, MainActivity.class, Intent.FLAG_ACTIVITY_SINGLE_TOP);
finish();
@@ -391,12 +461,6 @@ public class LoginActivity extends AccountAuthenticatorActivity {
errorMessageContainer.setVisibility(VISIBLE);
}
- private void showMessage(String message, @ColorRes int colorResId) {
- errorMessage.setText(message);
- errorMessage.setTextColor(ContextCompat.getColor(this, colorResId));
- errorMessageContainer.setVisibility(VISIBLE);
- }
-
private AppCompatDelegate getDelegate() {
if (delegate == null) {
delegate = AppCompatDelegate.create(this, null);
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java
index 04519e128..7462869b1 100644
--- a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java
+++ b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java
@@ -1,16 +1,14 @@
package fr.free.nrw.commons.auth;
import android.accounts.Account;
+import android.accounts.AccountAuthenticatorResponse;
import android.accounts.AccountManager;
+import android.content.ContentResolver;
import android.content.Context;
-import android.os.Build;
-import android.text.TextUtils;
+import android.os.Bundle;
-import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-import org.wikipedia.login.LoginResult;
-
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
@@ -22,6 +20,10 @@ import io.reactivex.Completable;
import io.reactivex.Observable;
import timber.log.Timber;
+import static android.accounts.AccountManager.ERROR_CODE_REMOTE_EXCEPTION;
+import static android.accounts.AccountManager.KEY_ACCOUNT_NAME;
+import static android.accounts.AccountManager.KEY_ACCOUNT_TYPE;
+
/**
* Manage the current logged in user session.
*/
@@ -32,6 +34,7 @@ public class SessionManager {
private Account currentAccount; // Unlike a savings account... ;-)
private JsonKvStore defaultKvStore;
private static final String KEY_RAWUSERNAME = "rawusername";
+ private Bundle userdata = new Bundle();
@Inject
public SessionManager(Context context,
@@ -43,40 +46,43 @@ public class SessionManager {
this.defaultKvStore = defaultKvStore;
}
- private boolean createAccount(@NonNull String userName, @NonNull String password) {
- Account account = getCurrentAccount();
- if (account == null || TextUtils.isEmpty(account.name) || !account.name.equals(userName)) {
- removeAccount();
- account = new Account(userName, BuildConfig.ACCOUNT_TYPE);
- return accountManager().addAccountExplicitly(account, password, null);
- }
- return true;
- }
+ /**
+ * Creata a new account
+ *
+ * @param response
+ * @param username
+ * @param rawusername
+ * @param password
+ */
+ public void createAccount(@Nullable AccountAuthenticatorResponse response,
+ String username, String rawusername, String password) {
- private void removeAccount() {
- Account account = getCurrentAccount();
- if (account != null) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
- accountManager().removeAccountExplicitly(account);
- } else {
- //noinspection deprecation
- accountManager().removeAccount(account, null, null);
+ Account account = new Account(username, BuildConfig.ACCOUNT_TYPE);
+ userdata.putString(KEY_RAWUSERNAME, rawusername);
+ boolean created = accountManager().addAccountExplicitly(account, password, userdata);
+
+ Timber.d("account creation " + (created ? "successful" : "failure"));
+
+ if (created) {
+ if (response != null) {
+ Bundle bundle = new Bundle();
+ bundle.putString(KEY_ACCOUNT_NAME, username);
+ bundle.putString(KEY_ACCOUNT_TYPE, BuildConfig.ACCOUNT_TYPE);
+
+
+ response.onResult(bundle);
}
- }
- }
- public void updateAccount(LoginResult result) {
- boolean accountCreated = createAccount(result.getUserName(), result.getPassword());
- if (accountCreated) {
- setPassword(result.getPassword());
+ } else {
+ if (response != null) {
+ response.onError(ERROR_CODE_REMOTE_EXCEPTION, "");
+ }
+ Timber.d("account creation failure");
}
- }
- private void setPassword(@NonNull String password) {
- Account account = getCurrentAccount();
- if (account != null) {
- accountManager().setPassword(account, password);
- }
+ // FIXME: If the user turns it off, it shouldn't be auto turned back on
+ ContentResolver.setSyncAutomatically(account, BuildConfig.CONTRIBUTION_AUTHORITY, true); // Enable sync by default!
+ ContentResolver.setSyncAutomatically(account, BuildConfig.MODIFICATION_AUTHORITY, true); // Enable sync by default!
}
/**
@@ -101,7 +107,7 @@ public class SessionManager {
}
@Nullable
- private String getRawUserName() {
+ public String getRawUserName() {
Account account = getCurrentAccount();
return account == null ? null : accountManager().getUserData(account, KEY_RAWUSERNAME);
}
@@ -121,12 +127,44 @@ public class SessionManager {
return AccountManager.get(context);
}
- public boolean isUserLoggedIn() {
- return defaultKvStore.getBoolean("isUserLoggedIn", false);
+ public Boolean revalidateAuthToken() {
+ AccountManager accountManager = AccountManager.get(context);
+ Account curAccount = getCurrentAccount();
+
+ if (curAccount == null) {
+ return false; // This should never happen
+ }
+
+ accountManager.invalidateAuthToken(BuildConfig.ACCOUNT_TYPE, null);
+ String authCookie = getAuthCookie();
+
+ if (authCookie == null) {
+ return false;
+ }
+
+ mediaWikiApi.setAuthCookie(authCookie);
+ return true;
}
- void setUserLoggedIn(boolean isLoggedIn) {
- defaultKvStore.putBoolean("isUserLoggedIn", isLoggedIn);
+ public String getAuthCookie() {
+ if (!isUserLoggedIn()) {
+ Timber.e("User is not logged in");
+ return null;
+ } else {
+ String authCookie = getCachedAuthCookie();
+ if (authCookie == null) {
+ Timber.e("Auth cookie is null even after login");
+ }
+ return authCookie;
+ }
+ }
+
+ public String getCachedAuthCookie() {
+ return defaultKvStore.getString("getAuthCookie", null);
+ }
+
+ public boolean isUserLoggedIn() {
+ return defaultKvStore.getBoolean("isUserLoggedIn", false);
}
public void forceLogin(Context context) {
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesController.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesController.java
index 695da9cfd..be6ffb7f5 100644
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesController.java
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesController.java
@@ -10,7 +10,6 @@ import javax.inject.Singleton;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.bookmarks.Bookmark;
-import fr.free.nrw.commons.media.MediaClient;
import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient;
import io.reactivex.Observable;
import io.reactivex.ObservableSource;
@@ -20,14 +19,15 @@ import io.reactivex.functions.Function;
@Singleton
public class BookmarkPicturesController {
- private final MediaClient mediaClient;
+ private final OkHttpJsonApiClient okHttpJsonApiClient;
private final BookmarkPicturesDao bookmarkDao;
private List currentBookmarks;
@Inject
- public BookmarkPicturesController(MediaClient mediaClient, BookmarkPicturesDao bookmarkDao) {
- this.mediaClient = mediaClient;
+ public BookmarkPicturesController(OkHttpJsonApiClient okHttpJsonApiClient,
+ BookmarkPicturesDao bookmarkDao) {
+ this.okHttpJsonApiClient = okHttpJsonApiClient;
this.bookmarkDao = bookmarkDao;
currentBookmarks = new ArrayList<>();
}
@@ -47,7 +47,7 @@ public class BookmarkPicturesController {
private Observable getMediaFromBookmark(Bookmark bookmark) {
Media dummyMedia = new Media("");
- return mediaClient.getMedia(bookmark.getMediaName())
+ return okHttpJsonApiClient.getMedia(bookmark.getMediaName(), false)
.map(media -> media == null ? dummyMedia : media)
.onErrorReturn(throwable -> dummyMedia)
.toObservable();
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.java b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.java
index 5e62e000c..21c39bf97 100644
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.java
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.java
@@ -23,7 +23,6 @@ public class CategoriesModel{
private static final int SEARCH_CATS_LIMIT = 25;
private final MediaWikiApi mwApi;
- private final CategoryClient categoryClient;
private final CategoryDao categoryDao;
private final JsonKvStore directKvStore;
@@ -33,11 +32,9 @@ public class CategoriesModel{
@Inject GpsCategoryModel gpsCategoryModel;
@Inject
public CategoriesModel(MediaWikiApi mwApi,
- CategoryClient categoryClient,
CategoryDao categoryDao,
@Named("default_preferences") JsonKvStore directKvStore) {
this.mwApi = mwApi;
- this.categoryClient = categoryClient;
this.categoryDao = categoryDao;
this.directKvStore = directKvStore;
this.categoriesCache = new HashMap<>();
@@ -137,8 +134,8 @@ public class CategoriesModel{
}
//otherwise, search API for matching categories
- return categoryClient
- .searchCategoriesForPrefix(term, SEARCH_CATS_LIMIT)
+ return mwApi
+ .allCategories(term, SEARCH_CATS_LIMIT)
.map(name -> new CategoryItem(name, false));
}
@@ -201,7 +198,7 @@ public class CategoriesModel{
* @return
*/
private Observable getTitleCategories(String title) {
- return categoryClient.searchCategories(title, SEARCH_CATS_LIMIT)
+ return mwApi.searchTitles(title, SEARCH_CATS_LIMIT)
.map(name -> new CategoryItem(name, false));
}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.java
deleted file mode 100644
index 329c3635a..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.java
+++ /dev/null
@@ -1,125 +0,0 @@
-package fr.free.nrw.commons.category;
-
-
-import androidx.annotation.NonNull;
-
-import org.wikipedia.dataclient.mwapi.MwQueryPage;
-import org.wikipedia.dataclient.mwapi.MwQueryResponse;
-import org.wikipedia.dataclient.mwapi.MwQueryResult;
-
-import java.util.List;
-
-import javax.inject.Inject;
-import javax.inject.Singleton;
-
-import io.reactivex.Observable;
-import timber.log.Timber;
-
-/**
- * Category Client to handle custom calls to Commons MediaWiki APIs
- */
-@Singleton
-public class CategoryClient {
-
- private final CategoryInterface CategoryInterface;
-
- @Inject
- public CategoryClient(CategoryInterface CategoryInterface) {
- this.CategoryInterface = CategoryInterface;
- }
-
- /**
- * Searches for categories containing the specified string.
- *
- * @param filter The string to be searched
- * @param itemLimit How many results are returned
- * @param offset Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result
- * @return
- */
- public Observable searchCategories(String filter, int itemLimit, int offset) {
- return responseToCategoryName(CategoryInterface.searchCategories(filter, itemLimit, offset));
-
- }
-
- /**
- * Searches for categories containing the specified string.
- *
- * @param filter The string to be searched
- * @param itemLimit How many results are returned
- * @return
- */
- public Observable searchCategories(String filter, int itemLimit) {
- return searchCategories(filter, itemLimit, 0);
-
- }
-
- /**
- * Searches for categories starting with the specified string.
- *
- * @param prefix The prefix to be searched
- * @param itemLimit How many results are returned
- * @param offset Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result
- * @return
- */
- public Observable searchCategoriesForPrefix(String prefix, int itemLimit, int offset) {
- return responseToCategoryName(CategoryInterface.searchCategoriesForPrefix(prefix, itemLimit, offset));
- }
-
- /**
- * Searches for categories starting with the specified string.
- *
- * @param prefix The prefix to be searched
- * @param itemLimit How many results are returned
- * @return
- */
- public Observable searchCategoriesForPrefix(String prefix, int itemLimit) {
- return searchCategoriesForPrefix(prefix, itemLimit, 0);
- }
-
-
- /**
- * The method takes categoryName as input and returns a List of Subcategories
- * It uses the generator query API to get the subcategories in a category, 500 at a time.
- *
- * @param categoryName Category name as defined on commons
- * @return Observable emitting the categories returned. If our search yielded "Category:Test", "Test" is emitted.
- */
- public Observable getSubCategoryList(String categoryName) {
- return responseToCategoryName(CategoryInterface.getSubCategoryList(categoryName));
- }
-
- /**
- * The method takes categoryName as input and returns a List of parent categories
- * It uses the generator query API to get the parent categories of a category, 500 at a time.
- *
- * @param categoryName Category name as defined on commons
- * @return
- */
- @NonNull
- public Observable getParentCategoryList(String categoryName) {
- return responseToCategoryName(CategoryInterface.getParentCategoryList(categoryName));
- }
-
- /**
- * Internal function to reduce code reuse. Extracts the categories returned from MwQueryResponse.
- *
- * @param responseObservable The query response observable
- * @return Observable emitting the categories returned. If our search yielded "Category:Test", "Test" is emitted.
- */
- private Observable responseToCategoryName(Observable responseObservable) {
- return responseObservable
- .flatMap(mwQueryResponse -> {
- MwQueryResult query;
- List pages;
- if ((query = mwQueryResponse.query()) == null ||
- (pages = query.pages()) == null) {
- Timber.d("No categories returned.");
- return Observable.empty();
- } else
- return Observable.fromIterable(pages);
- })
- .map(MwQueryPage::title)
- .doOnEach(s -> Timber.d("Category returned: %s", s))
- .map(cat -> cat.replace("Category:", ""));
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImageController.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryImageController.java
new file mode 100644
index 000000000..5ab5ea6b3
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryImageController.java
@@ -0,0 +1,30 @@
+package fr.free.nrw.commons.category;
+
+import java.util.List;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
+import fr.free.nrw.commons.Media;
+import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient;
+import io.reactivex.Single;
+
+@Singleton
+public class CategoryImageController {
+
+ private OkHttpJsonApiClient okHttpJsonApiClient;
+
+ @Inject
+ public CategoryImageController(OkHttpJsonApiClient okHttpJsonApiClient) {
+ this.okHttpJsonApiClient = okHttpJsonApiClient;
+ }
+
+ /**
+ * Takes a category name as input and calls the API to get a list of images for that category
+ * @param categoryName
+ * @return
+ */
+ public Single> getCategoryImages(String categoryName) {
+ return okHttpJsonApiClient.getMediaList("category", categoryName);
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImageUtils.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryImageUtils.java
new file mode 100644
index 000000000..cd97511cd
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryImageUtils.java
@@ -0,0 +1,39 @@
+package fr.free.nrw.commons.category;
+
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class CategoryImageUtils {
+
+ /**
+ * The method iterates over the child nodes to return a list of Subcategory name
+ * sorted alphabetically
+ * @param childNodes
+ * @return
+ */
+ public static List getSubCategoryList(NodeList childNodes) {
+ List subCategories = new ArrayList<>();
+ for (int i = 0; i < childNodes.getLength(); i++) {
+ Node node = childNodes.item(i);
+ subCategories.add(getFileName(node));
+ }
+ Collections.sort(subCategories);
+ return subCategories;
+ }
+
+ /**
+ * Extracts the filename of the uploaded image
+ * @param document
+ * @return
+ */
+ private static String getFileName(Node document) {
+ Element element = (Element) document;
+ return element.getAttribute("title");
+ }
+
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesActivity.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesActivity.java
index 9006745d4..001f817b3 100644
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesActivity.java
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesActivity.java
@@ -2,19 +2,20 @@ package fr.free.nrw.commons.category;
import android.content.Context;
import android.content.Intent;
+import android.database.DataSetObserver;
import android.os.Bundle;
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentTransaction;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.widget.AdapterView;
-import androidx.fragment.app.FragmentManager;
-import androidx.fragment.app.FragmentTransaction;
-
import butterknife.ButterKnife;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
+import fr.free.nrw.commons.auth.AuthenticatedActivity;
import fr.free.nrw.commons.explore.SearchActivity;
import fr.free.nrw.commons.media.MediaDetailPagerFragment;
import fr.free.nrw.commons.theme.NavigationBaseActivity;
@@ -27,7 +28,7 @@ import fr.free.nrw.commons.theme.NavigationBaseActivity;
*/
public class CategoryImagesActivity
- extends NavigationBaseActivity
+ extends AuthenticatedActivity
implements FragmentManager.OnBackStackChangedListener,
MediaDetailPagerFragment.MediaDetailProvider,
AdapterView.OnItemClickListener{
@@ -37,6 +38,16 @@ public class CategoryImagesActivity
private CategoryImagesListFragment categoryImagesListFragment;
private MediaDetailPagerFragment mediaDetails;
+ @Override
+ protected void onAuthCookieAcquired(String authCookie) {
+
+ }
+
+ @Override
+ protected void onAuthFailure() {
+
+ }
+
/**
* This method is called on backPressed of anyFragment in the activity.
* We are changing the icon here from back to hamburger icon.
@@ -58,6 +69,7 @@ public class CategoryImagesActivity
supportFragmentManager = getSupportFragmentManager();
setCategoryImagesFragment();
supportFragmentManager.addOnBackStackChangedListener(this);
+ requestAuthToken();
initDrawer();
setPageTitle();
}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesListFragment.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesListFragment.java
index 2e4f6e0a1..2a846413d 100644
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesListFragment.java
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesListFragment.java
@@ -27,7 +27,6 @@ import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.explore.categories.ExploreActivity;
import fr.free.nrw.commons.kvstore.JsonKvStore;
-import fr.free.nrw.commons.media.MediaClient;
import fr.free.nrw.commons.utils.NetworkUtils;
import fr.free.nrw.commons.utils.ViewUtil;
import io.reactivex.android.schedulers.AndroidSchedulers;
@@ -57,7 +56,7 @@ public class CategoryImagesListFragment extends DaggerFragment {
private boolean isLoading = true;
private String categoryName = null;
- @Inject MediaClient mediaClient;
+ @Inject CategoryImageController controller;
@Inject
@Named("default_preferences")
JsonKvStore categoryKvStore;
@@ -117,7 +116,7 @@ public class CategoryImagesListFragment extends DaggerFragment {
isLoading = true;
progressBar.setVisibility(VISIBLE);
- compositeDisposable.add(mediaClient.getMediaListFromCategory(categoryName)
+ compositeDisposable.add(controller.getCategoryImages(categoryName)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
@@ -223,7 +222,7 @@ public class CategoryImagesListFragment extends DaggerFragment {
}
progressBar.setVisibility(VISIBLE);
- compositeDisposable.add(mediaClient.getMediaListFromCategory(categoryName)
+ compositeDisposable.add(controller.getCategoryImages(categoryName)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.java
deleted file mode 100644
index f03506ed4..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.java
+++ /dev/null
@@ -1,47 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import org.wikipedia.dataclient.mwapi.MwQueryResponse;
-
-import io.reactivex.Observable;
-import retrofit2.http.GET;
-import retrofit2.http.Query;
-
-/**
- * Interface for interacting with Commons category related APIs
- */
-public interface CategoryInterface {
-
- /**
- * Searches for categories with the specified name.
- *
- * @param filter The string to be searched
- * @param itemLimit How many results are returned
- * @return
- */
- @GET("w/api.php?action=query&format=json&formatversion=2"
- + "&generator=search&gsrnamespace=14")
- Observable searchCategories(@Query("gsrsearch") String filter,
- @Query("gsrlimit") int itemLimit, @Query("gsroffset") int offset);
-
- /**
- * Searches for categories starting with the specified prefix.
- *
- * @param prefix The string to be searched
- * @param itemLimit How many results are returned
- * @return
- */
- @GET("w/api.php?action=query&format=json&formatversion=2"
- + "&generator=allcategories")
- Observable searchCategoriesForPrefix(@Query("gacprefix") String prefix,
- @Query("gaclimit") int itemLimit, @Query("gacoffset") int offset);
-
- @GET("w/api.php?action=query&format=json&formatversion=2"
- + "&generator=categorymembers&gcmtype=subcat"
- + "&prop=info&gcmlimit=500")
- Observable getSubCategoryList(@Query("gcmtitle") String categoryName);
-
- @GET("w/api.php?action=query&format=json&formatversion=2"
- + "&generator=categories&prop=info&gcllimit=500")
- Observable getParentCategoryList(@Query("titles") String categoryName);
-
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/SubCategoryListFragment.java b/app/src/main/java/fr/free/nrw/commons/category/SubCategoryListFragment.java
index 379caf4c1..40570b5f8 100644
--- a/app/src/main/java/fr/free/nrw/commons/category/SubCategoryListFragment.java
+++ b/app/src/main/java/fr/free/nrw/commons/category/SubCategoryListFragment.java
@@ -53,7 +53,7 @@ public class SubCategoryListFragment extends CommonsDaggerSupportFragment {
TextView categoriesNotFoundView;
private String categoryName = null;
- @Inject CategoryClient categoryClient;
+ @Inject MediaWikiApi mwApi;
private RVRendererAdapter categoriesAdapter;
private boolean isParentCategory = true;
@@ -86,7 +86,7 @@ public class SubCategoryListFragment extends CommonsDaggerSupportFragment {
}
/**
- * Checks for internet connection and then initializes the recycler view with all(max 500) categories of the searched query
+ * Checks for internet connection and then initializes the recycler view with 25 categories of the searched query
* Clearing categoryAdapter every time new keyword is searched so that user can see only new results
*/
public void initSubCategoryList() {
@@ -96,19 +96,17 @@ public class SubCategoryListFragment extends CommonsDaggerSupportFragment {
return;
}
progressBar.setVisibility(View.VISIBLE);
- if (isParentCategory) {
- compositeDisposable.add(categoryClient.getParentCategoryList("Category:"+categoryName)
+ if (!isParentCategory){
+ compositeDisposable.add(Observable.fromCallable(() -> mwApi.getSubCategoryList(categoryName))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
- .collect(ArrayList::new, ArrayList::add)
.subscribe(this::handleSuccess, this::handleError));
- } else {
- compositeDisposable.add(categoryClient.getSubCategoryList("Category:"+categoryName)
+ }else {
+ compositeDisposable.add(Observable.fromCallable(() -> mwApi.getParentCategoryList(categoryName))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
- .collect(ArrayList::new, ArrayList::add)
.subscribe(this::handleSuccess, this::handleError));
}
}
diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java
index 56feafacd..12c9ae602 100644
--- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java
+++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java
@@ -85,8 +85,8 @@ public class ContributionsFragment
private ContributionsListFragment contributionsListFragment;
private MediaDetailPagerFragment mediaDetailPagerFragment;
- private static final String CONTRIBUTION_LIST_FRAGMENT_TAG = "ContributionListFragmentTag";
- static final String MEDIA_DETAIL_PAGER_FRAGMENT_TAG = "MediaDetailFragmentTag";
+ public static final String CONTRIBUTION_LIST_FRAGMENT_TAG = "ContributionListFragmentTag";
+ public static final String MEDIA_DETAIL_PAGER_FRAGMENT_TAG = "MediaDetailFragmentTag";
@BindView(R.id.card_view_nearby) public NearbyNotificationCardView nearbyNotificationCardView;
@BindView(R.id.campaigns_view) CampaignView campaignView;
@@ -257,7 +257,7 @@ public class ContributionsFragment
operations on first time fragment attached to an activity. Then they will be retained
until fragment life time ends.
*/
- if (!isFragmentAttachedBefore) {
+ if (((MainActivity)getActivity()).isAuthCookieAcquired && !isFragmentAttachedBefore) {
onAuthCookieAcquired(((MainActivity)getActivity()).uploadServiceIntent);
isFragmentAttachedBefore = true;
@@ -268,7 +268,7 @@ public class ContributionsFragment
* Replace FrameLayout with ContributionsListFragment, user will see contributions list. Creates
* new one if null.
*/
- private void showContributionsListFragment() {
+ public void showContributionsListFragment() {
// show tabs on contribution list is visible
((MainActivity) getActivity()).showTabs();
// show nearby card view on contributions list is visible
@@ -289,7 +289,7 @@ public class ContributionsFragment
* Replace FrameLayout with MediaDetailPagerFragment, user will see details of selected media.
* Creates new one if null.
*/
- private void showMediaDetailPagerFragment() {
+ public void showMediaDetailPagerFragment() {
// hide tabs on media detail view is visible
((MainActivity)getActivity()).hideTabs();
// hide nearby card view on media detail is visible
@@ -308,7 +308,7 @@ public class ContributionsFragment
* Called when onAuthCookieAcquired is called on authenticated parent activity
* @param uploadServiceIntent
*/
- void onAuthCookieAcquired(Intent uploadServiceIntent) {
+ public void onAuthCookieAcquired(Intent uploadServiceIntent) {
// Since we call onAuthCookieAcquired method from onAttach, isAdded is still false. So don't use it
if (getActivity() != null) { // If fragment is attached to parent activity
@@ -324,7 +324,7 @@ public class ContributionsFragment
* mediaDetailPagerFragment, and preserve previous state in back stack.
* Called when user selects a contribution.
*/
- private void showDetail(int i) {
+ public void showDetail(int i) {
if (mediaDetailPagerFragment == null || !mediaDetailPagerFragment.isVisible()) {
mediaDetailPagerFragment = new MediaDetailPagerFragment();
showMediaDetailPagerFragment();
diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java
index 455e365d4..f37debe7b 100644
--- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java
+++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java
@@ -1,5 +1,8 @@
package fr.free.nrw.commons.contributions;
+import static android.view.View.GONE;
+import static android.view.View.VISIBLE;
+
import android.content.res.Configuration;
import android.os.Bundle;
import android.view.LayoutInflater;
@@ -10,29 +13,21 @@ import android.view.animation.AnimationUtils;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.TextView;
-
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.LayoutManager;
-
-import com.google.android.material.floatingactionbutton.FloatingActionButton;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-
import butterknife.BindView;
import butterknife.ButterKnife;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.contributions.ContributionsListAdapter.Callback;
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment;
import fr.free.nrw.commons.kvstore.JsonKvStore;
-import fr.free.nrw.commons.wikidata.WikidataClient;
-
-import static android.view.View.GONE;
-import static android.view.View.VISIBLE;
+import javax.inject.Inject;
+import javax.inject.Named;
/**
* Created by root on 01.06.2018.
@@ -58,8 +53,6 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment {
@Inject @Named("default_preferences") JsonKvStore kvStore;
@Inject ContributionController controller;
- @Inject
- WikidataClient wikidataClient;
private Animation fab_close;
private Animation fab_open;
@@ -170,7 +163,6 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment {
/**
* Responsible to set progress bar invisible and visible
- *
* @param shouldShow True when contributions list should be hidden.
*/
public void showProgress(boolean shouldShow) {
@@ -178,7 +170,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment {
}
public void showNoContributionsUI(boolean shouldShow) {
- noContributionsYet.setVisibility(shouldShow ? VISIBLE : GONE);
+ noContributionsYet.setVisibility(shouldShow?VISIBLE:GONE);
}
public void onDataSetChanged() {
diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java
index 90e27af33..56946018c 100644
--- a/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java
+++ b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java
@@ -2,7 +2,6 @@ package fr.free.nrw.commons.contributions;
import android.annotation.SuppressLint;
import android.app.AlertDialog;
-import android.content.ContentResolver;
import android.content.Intent;
import android.os.Bundle;
import android.view.LayoutInflater;
@@ -13,23 +12,23 @@ import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;
-import androidx.core.view.GravityCompat;
-import androidx.drawerlayout.widget.DrawerLayout;
-import androidx.fragment.app.Fragment;
-import androidx.fragment.app.FragmentManager;
-import androidx.fragment.app.FragmentPagerAdapter;
-import androidx.viewpager.widget.ViewPager;
-
import com.google.android.material.tabs.TabLayout;
import java.util.List;
import javax.inject.Inject;
+import androidx.core.view.GravityCompat;
+import androidx.drawerlayout.widget.DrawerLayout;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentPagerAdapter;
+import androidx.viewpager.widget.ViewPager;
import butterknife.BindView;
import butterknife.ButterKnife;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.R;
+import fr.free.nrw.commons.auth.AuthenticatedActivity;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.location.LocationServiceManager;
import fr.free.nrw.commons.nearby.NearbyFragment;
@@ -38,15 +37,15 @@ import fr.free.nrw.commons.notification.Notification;
import fr.free.nrw.commons.notification.NotificationActivity;
import fr.free.nrw.commons.notification.NotificationController;
import fr.free.nrw.commons.quiz.QuizChecker;
-import fr.free.nrw.commons.theme.NavigationBaseActivity;
import fr.free.nrw.commons.upload.UploadService;
+import io.reactivex.Observable;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.schedulers.Schedulers;
import timber.log.Timber;
import static android.content.ContentResolver.requestSync;
-public class MainActivity extends NavigationBaseActivity implements FragmentManager.OnBackStackChangedListener {
+public class MainActivity extends AuthenticatedActivity implements FragmentManager.OnBackStackChangedListener {
@Inject
SessionManager sessionManager;
@@ -64,6 +63,7 @@ public class MainActivity extends NavigationBaseActivity implements FragmentMana
public Intent uploadServiceIntent;
+ public boolean isAuthCookieAcquired = false;
public ContributionsActivityPagerAdapter contributionsActivityPagerAdapter;
public static final int CONTRIBUTIONS_TAB_POSITION = 0;
@@ -82,10 +82,10 @@ public class MainActivity extends NavigationBaseActivity implements FragmentMana
setContentView(R.layout.activity_contributions);
ButterKnife.bind(this);
+ requestAuthToken();
initDrawer();
setTitle(getString(R.string.navigation_item_home)); // Should I create a new string variable with another name instead?
- initMain();
if (savedInstanceState != null ) {
onOrientationChanged = true; // Will be used in nearby fragment to determine significant update of map
@@ -103,15 +103,16 @@ public class MainActivity extends NavigationBaseActivity implements FragmentMana
outState.putInt("viewPagerCurrentItem", viewPager.getCurrentItem());
}
- private void initMain() {
- //Do not remove this, this triggers the sync service
- ContentResolver.setSyncAutomatically(sessionManager.getCurrentAccount(),BuildConfig.CONTRIBUTION_AUTHORITY,true);
+ @Override
+ protected void onAuthCookieAcquired(String authCookie) {
+ // Do a sync everytime we get here!
requestSync(sessionManager.getCurrentAccount(), BuildConfig.CONTRIBUTION_AUTHORITY, new Bundle());
uploadServiceIntent = new Intent(this, UploadService.class);
uploadServiceIntent.setAction(UploadService.ACTION_START_SERVICE);
startService(uploadServiceIntent);
addTabsAndFragments();
+ isAuthCookieAcquired = true;
if (contributionsActivityPagerAdapter.getItem(0) != null) {
((ContributionsFragment)contributionsActivityPagerAdapter.getItem(0)).onAuthCookieAcquired(uploadServiceIntent);
}
@@ -231,9 +232,14 @@ public class MainActivity extends NavigationBaseActivity implements FragmentMana
}
}
+ @Override
+ protected void onAuthFailure() {
+
+ }
+
@Override
public void onBackPressed() {
- DrawerLayout drawer = findViewById(R.id.drawer_layout);
+ DrawerLayout drawer = (DrawerLayout) findViewById(R.id.drawer_layout);
String contributionsFragmentTag = ((ContributionsActivityPagerAdapter) viewPager.getAdapter()).makeFragmentName(R.id.pager, 0);
String nearbyFragmentTag = ((ContributionsActivityPagerAdapter) viewPager.getAdapter()).makeFragmentName(R.id.pager, 1);
if (drawer.isDrawerOpen(GravityCompat.START)) {
@@ -299,7 +305,7 @@ public class MainActivity extends NavigationBaseActivity implements FragmentMana
@SuppressLint("CheckResult")
private void setNotificationCount() {
- compositeDisposable.add(notificationController.getNotifications(false)
+ compositeDisposable.add(Observable.fromCallable(() -> notificationController.getNotifications(false))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::initNotificationViews,
diff --git a/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.java b/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.java
index b90acd95d..449361474 100644
--- a/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.java
+++ b/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.java
@@ -9,6 +9,7 @@ import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesDao;
import fr.free.nrw.commons.category.CategoryDao;
import fr.free.nrw.commons.contributions.ContributionDao;
import fr.free.nrw.commons.explore.recentsearches.RecentSearchesDao;
+import fr.free.nrw.commons.modifications.ModifierSequenceDao;
public class DBOpenHelper extends SQLiteOpenHelper {
@@ -26,6 +27,7 @@ public class DBOpenHelper extends SQLiteOpenHelper {
@Override
public void onCreate(SQLiteDatabase sqLiteDatabase) {
ContributionDao.Table.onCreate(sqLiteDatabase);
+ ModifierSequenceDao.Table.onCreate(sqLiteDatabase);
CategoryDao.Table.onCreate(sqLiteDatabase);
BookmarkPicturesDao.Table.onCreate(sqLiteDatabase);
BookmarkLocationsDao.Table.onCreate(sqLiteDatabase);
@@ -35,6 +37,7 @@ public class DBOpenHelper extends SQLiteOpenHelper {
@Override
public void onUpgrade(SQLiteDatabase sqLiteDatabase, int from, int to) {
ContributionDao.Table.onUpdate(sqLiteDatabase, from, to);
+ ModifierSequenceDao.Table.onUpdate(sqLiteDatabase, from, to);
CategoryDao.Table.onUpdate(sqLiteDatabase, from, to);
BookmarkPicturesDao.Table.onUpdate(sqLiteDatabase, from, to);
BookmarkLocationsDao.Table.onUpdate(sqLiteDatabase, from, to);
diff --git a/app/src/main/java/fr/free/nrw/commons/delete/DeleteHelper.java b/app/src/main/java/fr/free/nrw/commons/delete/DeleteHelper.java
index 3cf6c6f95..a5d7e7ef7 100644
--- a/app/src/main/java/fr/free/nrw/commons/delete/DeleteHelper.java
+++ b/app/src/main/java/fr/free/nrw/commons/delete/DeleteHelper.java
@@ -11,22 +11,19 @@ import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Locale;
-import java.util.concurrent.Callable;
import javax.inject.Inject;
-import javax.inject.Named;
import javax.inject.Singleton;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.actions.PageEditClient;
+import fr.free.nrw.commons.auth.SessionManager;
+import fr.free.nrw.commons.mwapi.MediaWikiApi;
import fr.free.nrw.commons.notification.NotificationHelper;
import fr.free.nrw.commons.review.ReviewController;
import fr.free.nrw.commons.utils.ViewUtilWrapper;
-import io.reactivex.Observable;
import io.reactivex.Single;
-import io.reactivex.SingleSource;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.schedulers.Schedulers;
import timber.log.Timber;
@@ -38,20 +35,20 @@ import static fr.free.nrw.commons.notification.NotificationHelper.NOTIFICATION_D
*/
@Singleton
public class DeleteHelper {
+ private final MediaWikiApi mwApi;
+ private final SessionManager sessionManager;
private final NotificationHelper notificationHelper;
- private final PageEditClient pageEditClient;
private final ViewUtilWrapper viewUtil;
- private final String username;
@Inject
- public DeleteHelper(NotificationHelper notificationHelper,
- @Named("commons-page-edit") PageEditClient pageEditClient,
- ViewUtilWrapper viewUtil,
- @Named("username") String username) {
+ public DeleteHelper(MediaWikiApi mwApi,
+ SessionManager sessionManager,
+ NotificationHelper notificationHelper,
+ ViewUtilWrapper viewUtil) {
+ this.mwApi = mwApi;
+ this.sessionManager = sessionManager;
this.notificationHelper = notificationHelper;
- this.pageEditClient = pageEditClient;
this.viewUtil = viewUtil;
- this.username = username;
}
/**
@@ -62,11 +59,10 @@ public class DeleteHelper {
* @return
*/
public Single makeDeletion(Context context, Media media, String reason) {
- viewUtil.showShortToast(context, "Trying to nominate " + media.getDisplayTitle() + " for deletion");
-
- return delete(media, reason)
- .flatMapSingle(result -> Single.just(showDeletionNotification(context, media, result)))
- .firstOrError();
+ viewUtil.showShortToast(context, context.getString((R.string.delete_helper_make_deletion_toast), media.getDisplayTitle()));
+ return Single.fromCallable(() -> delete(media, reason))
+ .flatMap(result -> Single.fromCallable(() ->
+ showDeletionNotification(context, media, result)));
}
/**
@@ -75,9 +71,14 @@ public class DeleteHelper {
* @param reason
* @return
*/
- private Observable delete(Media media, String reason) {
- Timber.d("thread is delete %s", Thread.currentThread().getName());
+ private boolean delete(Media media, String reason) {
+ String editToken;
+ String authCookie;
String summary = "Nominating " + media.getFilename() + " for deletion.";
+
+ authCookie = sessionManager.getAuthCookie();
+ mwApi.setAuthCookie(authCookie);
+
Calendar calendar = Calendar.getInstance();
String fileDeleteString = "{{delete|reason=" + reason +
"|subpage=" + media.getFilename() +
@@ -98,23 +99,26 @@ public class DeleteHelper {
String userPageString = "\n{{subst:idw|" + media.getFilename() +
"}} ~~~~";
- return pageEditClient.prependEdit(media.getFilename(), fileDeleteString + "\n", summary)
- .flatMap(result -> {
- if (result) {
- return pageEditClient.edit("Commons:Deletion_requests/" + media.getFilename(), subpageString + "\n", summary);
- }
- throw new RuntimeException("Failed to nominate for deletion");
- }).flatMap(result -> {
- if (result) {
- return pageEditClient.appendEdit("Commons:Deletion_requests/" + date, logPageString + "\n", summary);
- }
- throw new RuntimeException("Failed to nominate for deletion");
- }).flatMap(result -> {
- if (result) {
- return pageEditClient.appendEdit("User_Talk:" + username, userPageString + "\n", summary);
- }
- throw new RuntimeException("Failed to nominate for deletion");
- });
+ try {
+ editToken = mwApi.getEditToken();
+
+ if(editToken == null) {
+ return false;
+ }
+
+ mwApi.prependEdit(editToken, fileDeleteString + "\n",
+ media.getFilename(), summary);
+ mwApi.edit(editToken, subpageString + "\n",
+ "Commons:Deletion_requests/" + media.getFilename(), summary);
+ mwApi.appendEdit(editToken, logPageString + "\n",
+ "Commons:Deletion_requests/" + date, summary);
+ mwApi.appendEdit(editToken, userPageString + "\n",
+ "User_Talk:" + media.getCreator(), summary);
+ } catch (Exception e) {
+ Timber.e(e);
+ return false;
+ }
+ return true;
}
private boolean showDeletionNotification(Context context, Media media, boolean result) {
@@ -187,12 +191,7 @@ public class DeleteHelper {
}
}
- Timber.d("thread is askReasonAndExecute %s", Thread.currentThread().getName());
-
- String finalReason = reason;
-
- Single.defer((Callable>) () ->
- makeDeletion(context, media, finalReason))
+ makeDeletion(context, media, reason)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(aBoolean -> {
diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.java b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.java
index 242f889df..22ac5a40e 100644
--- a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.java
+++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.java
@@ -1,5 +1,6 @@
package fr.free.nrw.commons.di;
+import fr.free.nrw.commons.contributions.ContributionsModule;
import javax.inject.Singleton;
import dagger.Component;
@@ -9,8 +10,8 @@ import dagger.android.support.AndroidSupportInjectionModule;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.auth.LoginActivity;
import fr.free.nrw.commons.contributions.ContributionViewHolder;
-import fr.free.nrw.commons.contributions.ContributionsModule;
import fr.free.nrw.commons.contributions.ContributionsSyncAdapter;
+import fr.free.nrw.commons.modifications.ModificationsSyncAdapter;
import fr.free.nrw.commons.nearby.PlaceRenderer;
import fr.free.nrw.commons.review.ReviewController;
import fr.free.nrw.commons.settings.SettingsFragment;
@@ -35,6 +36,8 @@ public interface CommonsApplicationComponent extends AndroidInjector mwApi.searchCategory(query,queryList.size()))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.doOnSubscribe(disposable -> saveQuery(query))
- .collect(ArrayList::new, ArrayList::add)
.subscribe(this::handleSuccess, this::handleError));
}
/**
- * Adds 25 more results to existing search results
+ * Adds more results to existing search results
*/
public void addCategoriesToList(String query) {
- if(isLoadingCategories) return;
- isLoadingCategories=true;
this.query = query;
bottomProgressBar.setVisibility(View.VISIBLE);
progressBar.setVisibility(GONE);
- compositeDisposable.add(categoryClient.searchCategories(query,25, queryList.size())
+ compositeDisposable.add(Observable.fromCallable(() -> mwApi.searchCategory(query,queryList.size()))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
- .collect(ArrayList::new, ArrayList::add)
.subscribe(this::handlePaginationSuccess, this::handleError));
}
/**
* Handles the success scenario
* it initializes the recycler view by adding items to the adapter
+ * @param mediaList
*/
private void handlePaginationSuccess(List mediaList) {
queryList.addAll(mediaList);
@@ -176,7 +169,6 @@ public class SearchCategoryFragment extends CommonsDaggerSupportFragment {
bottomProgressBar.setVisibility(GONE);
categoriesAdapter.addAll(mediaList);
categoriesAdapter.notifyDataSetChanged();
- isLoadingCategories=false;
}
@@ -184,6 +176,7 @@ public class SearchCategoryFragment extends CommonsDaggerSupportFragment {
/**
* Handles the success scenario
* it initializes the recycler view by adding items to the adapter
+ * @param mediaList
*/
private void handleSuccess(List mediaList) {
queryList = mediaList;
@@ -201,6 +194,7 @@ public class SearchCategoryFragment extends CommonsDaggerSupportFragment {
/**
* Logs and handles API error scenario
+ * @param throwable
*/
private void handleError(Throwable throwable) {
Timber.e(throwable, "Error occurred while loading queried categories");
@@ -219,7 +213,7 @@ public class SearchCategoryFragment extends CommonsDaggerSupportFragment {
private void initErrorView() {
progressBar.setVisibility(GONE);
categoriesNotFoundView.setVisibility(VISIBLE);
- categoriesNotFoundView.setText(getString(R.string.categories_not_found));
+ categoriesNotFoundView.setText(getString(R.string.categories_not_found, query));
}
/**
diff --git a/app/src/main/java/fr/free/nrw/commons/explore/images/SearchImageFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/images/SearchImageFragment.java
index 895b2ff13..eb32cfb64 100644
--- a/app/src/main/java/fr/free/nrw/commons/explore/images/SearchImageFragment.java
+++ b/app/src/main/java/fr/free/nrw/commons/explore/images/SearchImageFragment.java
@@ -24,6 +24,7 @@ import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import butterknife.BindView;
import butterknife.ButterKnife;
+import com.pedrogomez.renderers.RVRendererAdapter;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment;
@@ -31,11 +32,18 @@ import fr.free.nrw.commons.explore.SearchActivity;
import fr.free.nrw.commons.explore.recentsearches.RecentSearch;
import fr.free.nrw.commons.explore.recentsearches.RecentSearchesDao;
import fr.free.nrw.commons.kvstore.JsonKvStore;
-import fr.free.nrw.commons.media.MediaClient;
+import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient;
import fr.free.nrw.commons.utils.NetworkUtils;
import fr.free.nrw.commons.utils.ViewUtil;
import io.reactivex.android.schedulers.AndroidSchedulers;
+import io.reactivex.disposables.Disposable;
import io.reactivex.schedulers.Schedulers;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import javax.inject.Inject;
+import javax.inject.Named;
import timber.log.Timber;
import static android.view.View.GONE;
@@ -61,7 +69,7 @@ public class SearchImageFragment extends CommonsDaggerSupportFragment {
@Inject RecentSearchesDao recentSearchesDao;
@Inject
- MediaClient mediaClient;
+ OkHttpJsonApiClient okHttpJsonApiClient;
@Inject
@Named("default_preferences")
JsonKvStore defaultKvStore;
@@ -140,7 +148,7 @@ public class SearchImageFragment extends CommonsDaggerSupportFragment {
bottomProgressBar.setVisibility(GONE);
queryList.clear();
imagesAdapter.clear();
- compositeDisposable.add(mediaClient.getMediaListFromSearch(query)
+ compositeDisposable.add(okHttpJsonApiClient.getMediaList("search", query)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
@@ -157,7 +165,7 @@ public class SearchImageFragment extends CommonsDaggerSupportFragment {
this.query = query;
bottomProgressBar.setVisibility(View.VISIBLE);
progressBar.setVisibility(GONE);
- compositeDisposable.add(mediaClient.getMediaListFromSearch(query)
+ compositeDisposable.add(okHttpJsonApiClient.getMediaList("search", query)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
@@ -220,7 +228,7 @@ public class SearchImageFragment extends CommonsDaggerSupportFragment {
private void initErrorView() {
progressBar.setVisibility(GONE);
imagesNotFoundView.setVisibility(VISIBLE);
- imagesNotFoundView.setText(getString(R.string.images_not_found));
+ imagesNotFoundView.setText(getString(R.string.images_not_found, query));
}
/**
diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaClient.java b/app/src/main/java/fr/free/nrw/commons/media/MediaClient.java
deleted file mode 100644
index 58e014d42..000000000
--- a/app/src/main/java/fr/free/nrw/commons/media/MediaClient.java
+++ /dev/null
@@ -1,164 +0,0 @@
-package fr.free.nrw.commons.media;
-
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import org.wikipedia.dataclient.mwapi.MwQueryPage;
-import org.wikipedia.dataclient.mwapi.MwQueryResponse;
-import org.wikipedia.dataclient.mwapi.MwQueryResult;
-
-import java.util.Date;
-import org.wikipedia.dataclient.mwapi.MwQueryResponse;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-import javax.inject.Inject;
-import javax.inject.Singleton;
-
-import fr.free.nrw.commons.Media;
-import fr.free.nrw.commons.utils.CommonsDateUtil;
-import io.reactivex.Observable;
-import io.reactivex.Single;
-import okhttp3.HttpUrl;
-import okhttp3.Request;
-import okhttp3.Response;
-import timber.log.Timber;
-import io.reactivex.Observable;
-import io.reactivex.Single;
-
-/**
- * Media Client to handle custom calls to Commons MediaWiki APIs
- */
-@Singleton
-public class MediaClient {
-
- private final MediaInterface mediaInterface;
-
- //OkHttpJsonApiClient used JsonKvStore for this. I don't know why.
- private Map> continuationStore;
-
- @Inject
- public MediaClient(MediaInterface mediaInterface) {
- this.mediaInterface = mediaInterface;
- this.continuationStore = new HashMap<>();
- }
-
- /**
- * Checks if a page exists on Commons
- * The same method can be used to check for file or talk page
- *
- * @param title File:Test.jpg or Commons:Deletion_requests/File:Test1.jpeg
- */
- public Single checkPageExistsUsingTitle(String title) {
- return mediaInterface.checkPageExistsUsingTitle(title)
- .map(mwQueryResponse -> mwQueryResponse
- .query().firstPage().pageId() > 0)
- .singleOrError();
- }
-
- /**
- * Take the fileSha and returns whether a file with a matching SHA exists or not
- *
- * @param fileSha SHA of the file to be checked
- */
- public Single checkFileExistsUsingSha(String fileSha) {
- return mediaInterface.checkFileExistsUsingSha(fileSha)
- .map(mwQueryResponse -> mwQueryResponse
- .query().allImages().size() > 0)
- .singleOrError();
- }
-
- /**
- * This method takes the category as input and returns a list of Media objects filtered using image generator query
- * It uses the generator query API to get the images searched using a query, 10 at a time.
- *
- * @param category the search category. Must start with "Category:"
- * @return
- */
- public Single> getMediaListFromCategory(String category) {
- return responseToMediaList(
- continuationStore.containsKey("category_" + category) ?
- mediaInterface.getMediaListFromCategory(category, 10, continuationStore.get("category_" + category)) : //if true
- mediaInterface.getMediaListFromCategory(category, 10, Collections.emptyMap()),
- "category_" + category); //if false
-
- }
-
- /**
- * This method takes a keyword as input and returns a list of Media objects filtered using image generator query
- * It uses the generator query API to get the images searched using a query, 10 at a time.
- *
- * @param keyword the search keyword
- * @return
- */
- public Single> getMediaListFromSearch(String keyword) {
- return responseToMediaList(
- continuationStore.containsKey("search_" + keyword) ?
- mediaInterface.getMediaListFromSearch(keyword, 10, continuationStore.get("search_" + keyword)) : //if true
- mediaInterface.getMediaListFromSearch(keyword, 10, Collections.emptyMap()), //if false
- "search_" + keyword);
-
- }
-
- private Single> responseToMediaList(Observable response, String key) {
- return response.flatMap(mwQueryResponse -> {
- if (null == mwQueryResponse
- || null == mwQueryResponse.query()
- || null == mwQueryResponse.query().pages()) {
- return Observable.empty();
- }
- continuationStore.put(key, mwQueryResponse.continuation());
- return Observable.fromIterable(mwQueryResponse.query().pages());
- })
- .map(Media::from)
- .collect(ArrayList::new, List::add);
- }
-
- /**
- * Fetches Media object from the imageInfo API
- *
- * @param titles the tiles to be searched for. Can be filename or template name
- * @return
- */
- public Single getMedia(String titles) {
- return mediaInterface.getMedia(titles)
- .flatMap(mwQueryResponse -> {
- if (null == mwQueryResponse
- || null == mwQueryResponse.query()
- || null == mwQueryResponse.query().firstPage()) {
- return Observable.empty();
- }
- return Observable.just(mwQueryResponse.query().firstPage());
- })
- .map(Media::from)
- .single(Media.EMPTY);
- }
-
- /**
- * The method returns the picture of the day
- *
- * @return Media object corresponding to the picture of the day
- */
- @NonNull
- public Single getPictureOfTheDay() {
- String date = CommonsDateUtil.getIso8601DateFormatShort().format(new Date());
- Timber.d("Current date is %s", date);
- String template = "Template:Potd/" + date;
- return mediaInterface.getMediaWithGenerator(template)
- .flatMap(mwQueryResponse -> {
- if (null == mwQueryResponse
- || null == mwQueryResponse.query()
- || null == mwQueryResponse.query().firstPage()) {
- return Observable.empty();
- }
- return Observable.just(mwQueryResponse.query().firstPage());
- })
- .map(Media::from)
- .single(Media.EMPTY);
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaInterface.java b/app/src/main/java/fr/free/nrw/commons/media/MediaInterface.java
deleted file mode 100644
index c54e33e62..000000000
--- a/app/src/main/java/fr/free/nrw/commons/media/MediaInterface.java
+++ /dev/null
@@ -1,89 +0,0 @@
-package fr.free.nrw.commons.media;
-
-import org.jetbrains.annotations.NotNull;
-import org.wikipedia.dataclient.mwapi.MwQueryResponse;
-
-import java.util.Map;
-
-import io.reactivex.Observable;
-import retrofit2.http.GET;
-import retrofit2.http.Query;
-import retrofit2.http.QueryMap;
-
-/**
- * Interface for interacting with Commons media related APIs
- */
-public interface MediaInterface {
- /**
- * Checks if a page exists or not.
- *
- * @param title the title of the page to be checked
- * @return
- */
- @GET("w/api.php?action=query&format=json&formatversion=2")
- Observable checkPageExistsUsingTitle(@Query("titles") String title);
-
- /**
- * Check if file exists
- *
- * @param aisha1 the SHA of the media file to be checked
- * @return
- */
- @GET("w/api.php?action=query&format=json&formatversion=2&list=allimages")
- Observable checkFileExistsUsingSha(@Query("aisha1") String aisha1);
-
- /**
- * This method retrieves a list of Media objects filtered using image generator query
- *
- * @param category the category name. Must start with "Category:"
- * @param itemLimit how many images are returned
- * @param continuation the continuation string from the previous query or empty map
- * @return
- */
- @GET("w/api.php?action=query&format=json&formatversion=2" + //Basic parameters
- "&generator=categorymembers&gcmtype=file&gcmsort=timestamp&gcmdir=desc" + //Category parameters
- "&prop=imageinfo&iiprop=url|extmetadata&iiurlwidth=640" + //Media property parameters
- "&iiextmetadatafilter=DateTime|Categories|GPSLatitude|GPSLongitude|ImageDescription|DateTimeOriginal" +
- "|Artist|LicenseShortName|LicenseUrl")
- Observable getMediaListFromCategory(@Query("gcmtitle") String category, @Query("gcmlimit") int itemLimit, @QueryMap Map continuation);
-
- /**
- * This method retrieves a list of Media objects filtered using image generator query
- *
- * @param keyword the searched keyword
- * @param itemLimit how many images are returned
- * @param continuation the continuation string from the previous query
- * @return
- */
- @GET("w/api.php?action=query&format=json&formatversion=2" + //Basic parameters
- "&generator=search&gsrwhat=text&gsrnamespace=6" + //Search parameters
- "&prop=imageinfo&iiprop=url|extmetadata&iiurlwidth=640" + //Media property parameters
- "&iiextmetadatafilter=DateTime|Categories|GPSLatitude|GPSLongitude|ImageDescription|DateTimeOriginal" +
- "|Artist|LicenseShortName|LicenseUrl")
- Observable getMediaListFromSearch(@Query("gsrsearch") String keyword, @Query("gsrlimit") int itemLimit, @QueryMap Map continuation);
-
- /**
- * Fetches Media object from the imageInfo API
- *
- * @param title the tiles to be searched for. Can be filename or template name
- * @return
- */
- @GET("w/api.php?action=query&format=json&formatversion=2" +
- "&prop=imageinfo&iiprop=url|extmetadata&iiurlwidth=640" +
- "&iiextmetadatafilter=DateTime|Categories|GPSLatitude|GPSLongitude|ImageDescription|DateTimeOriginal" +
- "|Artist|LicenseShortName|LicenseUrl")
- Observable getMedia(@Query("titles") String title);
-
- /**
- * Fetches Media object from the imageInfo API
- * Passes an image generator parameter
- *
- * @param title the tiles to be searched for. Can be filename or template name
- * @return
- */
- @GET("w/api.php?action=query&format=json&formatversion=2&generator=images" +
- "&prop=imageinfo&iiprop=url|extmetadata&iiurlwidth=640" +
- "&iiextmetadatafilter=DateTime|Categories|GPSLatitude|GPSLongitude|ImageDescription|DateTimeOriginal" +
- "|Artist|LicenseShortName|LicenseUrl")
- Observable getMediaWithGenerator(@Query("titles") String title);
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/modifications/CategoryModifier.java b/app/src/main/java/fr/free/nrw/commons/modifications/CategoryModifier.java
new file mode 100644
index 000000000..657207eca
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/modifications/CategoryModifier.java
@@ -0,0 +1,48 @@
+package fr.free.nrw.commons.modifications;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+public class CategoryModifier extends PageModifier {
+
+ public static String PARAM_CATEGORIES = "categories";
+
+ public static String MODIFIER_NAME = "CategoriesModifier";
+
+ public CategoryModifier(String... categories) {
+ super(MODIFIER_NAME);
+ JSONArray categoriesArray = new JSONArray();
+ for (String category: categories) {
+ categoriesArray.put(category);
+ }
+ try {
+ params.putOpt(PARAM_CATEGORIES, categoriesArray);
+ } catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public CategoryModifier(JSONObject data) {
+ super(MODIFIER_NAME);
+ this.params = data;
+ }
+
+ @Override
+ public String doModification(String pageName, String pageContents) {
+ JSONArray categories;
+ categories = params.optJSONArray(PARAM_CATEGORIES);
+
+ StringBuilder categoriesString = new StringBuilder();
+ for (int i = 0; i < categories.length(); i++) {
+ String category = categories.optString(i);
+ categoriesString.append("\n[[Category:").append(category).append("]]");
+ }
+ return pageContents + categoriesString.toString();
+ }
+
+ @Override
+ public String getEditSummary() {
+ return "Added " + params.optJSONArray(PARAM_CATEGORIES).length() + " categories.";
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/modifications/ModificationsContentProvider.java b/app/src/main/java/fr/free/nrw/commons/modifications/ModificationsContentProvider.java
new file mode 100644
index 000000000..a51a48210
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/modifications/ModificationsContentProvider.java
@@ -0,0 +1,161 @@
+package fr.free.nrw.commons.modifications;
+
+import android.content.ContentValues;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteQueryBuilder;
+import android.net.Uri;
+import androidx.annotation.NonNull;
+import android.text.TextUtils;
+
+import javax.inject.Inject;
+
+import fr.free.nrw.commons.BuildConfig;
+import fr.free.nrw.commons.data.DBOpenHelper;
+import fr.free.nrw.commons.di.CommonsDaggerContentProvider;
+import timber.log.Timber;
+
+import static fr.free.nrw.commons.modifications.ModifierSequenceDao.Table.TABLE_NAME;
+
+public class ModificationsContentProvider extends CommonsDaggerContentProvider {
+
+ private static final int MODIFICATIONS = 1;
+ private static final int MODIFICATIONS_ID = 2;
+
+ public static final String BASE_PATH = "modifications";
+
+ public static final Uri BASE_URI = Uri.parse("content://" + BuildConfig.MODIFICATION_AUTHORITY + "/" + BASE_PATH);
+
+ private static final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
+ static {
+ uriMatcher.addURI(BuildConfig.MODIFICATION_AUTHORITY, BASE_PATH, MODIFICATIONS);
+ uriMatcher.addURI(BuildConfig.MODIFICATION_AUTHORITY, BASE_PATH + "/#", MODIFICATIONS_ID);
+ }
+
+ public static Uri uriForId(int id) {
+ return Uri.parse(BASE_URI.toString() + "/" + id);
+ }
+
+ @Inject DBOpenHelper dbOpenHelper;
+
+ @Override
+ public Cursor query(@NonNull Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
+ queryBuilder.setTables(TABLE_NAME);
+
+ int uriType = uriMatcher.match(uri);
+
+ switch (uriType) {
+ case MODIFICATIONS:
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI" + uri);
+ }
+
+ SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
+
+ Cursor cursor = queryBuilder.query(db, projection, selection, selectionArgs, null, null, sortOrder);
+ cursor.setNotificationUri(getContext().getContentResolver(), uri);
+
+ return cursor;
+ }
+
+ @Override
+ public String getType(@NonNull Uri uri) {
+ return null;
+ }
+
+ @Override
+ public Uri insert(@NonNull Uri uri, ContentValues contentValues) {
+ int uriType = uriMatcher.match(uri);
+ SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
+ long id;
+ switch (uriType) {
+ case MODIFICATIONS:
+ id = sqlDB.insert(TABLE_NAME, null, contentValues);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI: " + uri);
+ }
+ getContext().getContentResolver().notifyChange(uri, null);
+ return Uri.parse(BASE_URI + "/" + id);
+ }
+
+ @Override
+ public int delete(@NonNull Uri uri, String s, String[] strings) {
+ int uriType = uriMatcher.match(uri);
+ SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
+ switch (uriType) {
+ case MODIFICATIONS_ID:
+ String id = uri.getLastPathSegment();
+ sqlDB.delete(TABLE_NAME,
+ "_id = ?",
+ new String[] { id }
+ );
+ return 1;
+ default:
+ throw new IllegalArgumentException("Unknown URI: " + uri);
+ }
+ }
+
+ @Override
+ public int bulkInsert(@NonNull Uri uri, @NonNull ContentValues[] values) {
+ Timber.d("Hello, bulk insert! (ModificationsContentProvider)");
+ int uriType = uriMatcher.match(uri);
+ SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
+ sqlDB.beginTransaction();
+ switch (uriType) {
+ case MODIFICATIONS:
+ for (ContentValues value: values) {
+ Timber.d("Inserting! %s", value);
+ sqlDB.insert(TABLE_NAME, null, value);
+ }
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI: " + uri);
+ }
+ sqlDB.setTransactionSuccessful();
+ sqlDB.endTransaction();
+ getContext().getContentResolver().notifyChange(uri, null);
+ return values.length;
+ }
+
+ @Override
+ public int update(@NonNull Uri uri, ContentValues contentValues, String selection, String[] selectionArgs) {
+ /*
+ SQL Injection warnings: First, note that we're not exposing this to the outside world (exported="false")
+ Even then, we should make sure to sanitize all user input appropriately. Input that passes through ContentValues
+ should be fine. So only issues are those that pass in via concating.
+
+ In here, the only concat created argument is for id. It is cast to an int, and will error out otherwise.
+ */
+ int uriType = uriMatcher.match(uri);
+ SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
+ int rowsUpdated;
+ switch (uriType) {
+ case MODIFICATIONS:
+ rowsUpdated = sqlDB.update(TABLE_NAME,
+ contentValues,
+ selection,
+ selectionArgs);
+ break;
+ case MODIFICATIONS_ID:
+ int id = Integer.valueOf(uri.getLastPathSegment());
+
+ if (TextUtils.isEmpty(selection)) {
+ rowsUpdated = sqlDB.update(TABLE_NAME,
+ contentValues,
+ ModifierSequenceDao.Table.COLUMN_ID + " = ?",
+ new String[] { String.valueOf(id) } );
+ } else {
+ throw new IllegalArgumentException("Parameter `selection` should be empty when updating an ID");
+ }
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI: " + uri + " with type " + uriType);
+ }
+ getContext().getContentResolver().notifyChange(uri, null);
+ return rowsUpdated;
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/modifications/ModificationsSyncAdapter.java b/app/src/main/java/fr/free/nrw/commons/modifications/ModificationsSyncAdapter.java
new file mode 100644
index 000000000..6043f6d03
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/modifications/ModificationsSyncAdapter.java
@@ -0,0 +1,145 @@
+package fr.free.nrw.commons.modifications;
+
+import android.accounts.Account;
+import android.content.AbstractThreadedSyncAdapter;
+import android.content.ContentProviderClient;
+import android.content.Context;
+import android.content.SyncResult;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.os.RemoteException;
+
+import java.io.IOException;
+
+import javax.inject.Inject;
+
+import fr.free.nrw.commons.BuildConfig;
+import fr.free.nrw.commons.auth.SessionManager;
+import fr.free.nrw.commons.contributions.Contribution;
+import fr.free.nrw.commons.contributions.ContributionDao;
+import fr.free.nrw.commons.di.ApplicationlessInjection;
+import fr.free.nrw.commons.mwapi.MediaWikiApi;
+import timber.log.Timber;
+
+public class ModificationsSyncAdapter extends AbstractThreadedSyncAdapter {
+
+ @Inject MediaWikiApi mwApi;
+ @Inject ContributionDao contributionDao;
+ @Inject ModifierSequenceDao modifierSequenceDao;
+ @Inject
+ SessionManager sessionManager;
+
+ public ModificationsSyncAdapter(Context context, boolean autoInitialize) {
+ super(context, autoInitialize);
+ }
+
+ @Override
+ public void onPerformSync(Account account, Bundle bundle, String s, ContentProviderClient contentProviderClient, SyncResult syncResult) {
+ // This code is fraught with possibilities of race conditions, but lalalalala I can't hear you!
+ ApplicationlessInjection
+ .getInstance(getContext()
+ .getApplicationContext())
+ .getCommonsApplicationComponent()
+ .inject(this);
+
+ Cursor allModifications;
+ try {
+ allModifications = contentProviderClient.query(ModificationsContentProvider.BASE_URI, null, null, null, null);
+ } catch (RemoteException e) {
+ throw new RuntimeException(e);
+ }
+
+ // Exit early if nothing to do
+ if (allModifications == null || allModifications.getCount() == 0) {
+ Timber.d("No modifications to perform");
+ return;
+ }
+
+ String authCookie = sessionManager.getAuthCookie();
+ if (isNullOrWhiteSpace(authCookie)) {
+ Timber.d("Could not authenticate :(");
+ return;
+ }
+
+ mwApi.setAuthCookie(authCookie);
+ String editToken;
+
+ try {
+ editToken = mwApi.getEditToken();
+ } catch (IOException e) {
+ Timber.d("Can not retreive edit token!");
+ return;
+ }
+
+ allModifications.moveToFirst();
+
+ Timber.d("Found %d modifications to execute", allModifications.getCount());
+
+ ContentProviderClient contributionsClient = null;
+ try {
+ contributionsClient = getContext().getContentResolver().acquireContentProviderClient(BuildConfig.CONTRIBUTION_AUTHORITY);
+
+ while (!allModifications.isAfterLast()) {
+ ModifierSequence sequence = modifierSequenceDao.fromCursor(allModifications);
+ Contribution contrib;
+ Cursor contributionCursor;
+
+ if (contributionsClient == null) {
+ Timber.e("ContributionsClient is null. This should not happen!");
+ return;
+ }
+
+ try {
+ contributionCursor = contributionsClient.query(sequence.getMediaUri(), null, null, null, null);
+ } catch (RemoteException e) {
+ throw new RuntimeException(e);
+ }
+
+ if (contributionCursor != null) {
+ contributionCursor.moveToFirst();
+ }
+
+ contrib = contributionDao.fromCursor(contributionCursor);
+
+ if (contrib != null && contrib.getState() == Contribution.STATE_COMPLETED) {
+ String pageContent;
+ try {
+ pageContent = mwApi.revisionsByFilename(contrib.getFilename());
+ } catch (IOException e) {
+ Timber.d("Network messed up on modifications sync!");
+ continue;
+ }
+
+ Timber.d("Page content is %s", pageContent);
+ String processedPageContent = sequence.executeModifications(contrib.getFilename(), pageContent);
+
+ String editResult;
+ try {
+ editResult = mwApi.edit(editToken, processedPageContent, contrib.getFilename(), sequence.getEditSummary());
+ } catch (IOException e) {
+ Timber.d("Network messed up on modifications sync!");
+ continue;
+ }
+
+ Timber.d("Response is %s", editResult);
+
+ if (!"Success".equals(editResult)) {
+ // FIXME: Log this somewhere else
+ Timber.d("Non success result! %s", editResult);
+ } else {
+ modifierSequenceDao.delete(sequence);
+ }
+ }
+ allModifications.moveToNext();
+ }
+ } finally {
+ if (contributionsClient != null) {
+ contributionsClient.release();
+ }
+ }
+ }
+
+ private boolean isNullOrWhiteSpace(String value) {
+ return value == null || value.trim().isEmpty();
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/modifications/ModificationsSyncService.java b/app/src/main/java/fr/free/nrw/commons/modifications/ModificationsSyncService.java
new file mode 100644
index 000000000..bf6878622
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/modifications/ModificationsSyncService.java
@@ -0,0 +1,27 @@
+package fr.free.nrw.commons.modifications;
+
+import android.app.Service;
+import android.content.Intent;
+import android.os.IBinder;
+
+public class ModificationsSyncService extends Service {
+
+ private static final Object sSyncAdapterLock = new Object();
+
+ private static ModificationsSyncAdapter sSyncAdapter = null;
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ synchronized (sSyncAdapterLock) {
+ if (sSyncAdapter == null) {
+ sSyncAdapter = new ModificationsSyncAdapter(this, true);
+ }
+ }
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return sSyncAdapter.getSyncAdapterBinder();
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/modifications/ModifierSequence.java b/app/src/main/java/fr/free/nrw/commons/modifications/ModifierSequence.java
new file mode 100644
index 000000000..f563b1a7b
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/modifications/ModifierSequence.java
@@ -0,0 +1,64 @@
+package fr.free.nrw.commons.modifications;
+
+import android.net.Uri;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+
+public class ModifierSequence {
+ private Uri mediaUri;
+ private ArrayList modifiers;
+ private Uri contentUri;
+
+ public ModifierSequence(Uri mediaUri) {
+ this.mediaUri = mediaUri;
+ modifiers = new ArrayList<>();
+ }
+
+ ModifierSequence(Uri mediaUri, JSONObject data) {
+ this(mediaUri);
+ JSONArray modifiersJSON = data.optJSONArray("modifiers");
+ for (int i = 0; i < modifiersJSON.length(); i++) {
+ modifiers.add(PageModifier.fromJSON(modifiersJSON.optJSONObject(i)));
+ }
+ }
+
+ Uri getMediaUri() {
+ return mediaUri;
+ }
+
+ public void queueModifier(PageModifier modifier) {
+ modifiers.add(modifier);
+ }
+
+ String executeModifications(String pageName, String pageContents) {
+ for (PageModifier modifier: modifiers) {
+ pageContents = modifier.doModification(pageName, pageContents);
+ }
+ return pageContents;
+ }
+
+ String getEditSummary() {
+ StringBuilder editSummary = new StringBuilder();
+ for (PageModifier modifier: modifiers) {
+ editSummary.append(modifier.getEditSummary()).append(" ");
+ }
+ editSummary.append("Using [[COM:MOA|Commons Mobile App]]");
+ return editSummary.toString();
+ }
+
+ ArrayList getModifiers() {
+ return modifiers;
+ }
+
+ Uri getContentUri() {
+ return contentUri;
+ }
+
+ void setContentUri(Uri contentUri) {
+ this.contentUri = contentUri;
+ }
+
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/modifications/ModifierSequenceDao.java b/app/src/main/java/fr/free/nrw/commons/modifications/ModifierSequenceDao.java
new file mode 100644
index 000000000..957656a24
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/modifications/ModifierSequenceDao.java
@@ -0,0 +1,124 @@
+package fr.free.nrw.commons.modifications;
+
+import android.content.ContentProviderClient;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.os.RemoteException;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import javax.inject.Inject;
+import javax.inject.Named;
+import javax.inject.Provider;
+
+public class ModifierSequenceDao {
+
+ private final Provider clientProvider;
+
+ @Inject
+ public ModifierSequenceDao(@Named("modification") Provider clientProvider) {
+ this.clientProvider = clientProvider;
+ }
+
+ public void save(ModifierSequence sequence) {
+ ContentProviderClient db = clientProvider.get();
+ try {
+ if (sequence.getContentUri() == null) {
+ sequence.setContentUri(db.insert(ModificationsContentProvider.BASE_URI, toContentValues(sequence)));
+ } else {
+ db.update(sequence.getContentUri(), toContentValues(sequence), null, null);
+ }
+ } catch (RemoteException e) {
+ throw new RuntimeException(e);
+ } finally {
+ db.release();
+ }
+ }
+
+ public void delete(ModifierSequence sequence) {
+ ContentProviderClient db = clientProvider.get();
+ try {
+ db.delete(sequence.getContentUri(), null, null);
+ } catch (RemoteException e) {
+ throw new RuntimeException(e);
+ } finally {
+ db.release();
+ }
+ }
+
+ ModifierSequence fromCursor(Cursor cursor) {
+ // Hardcoding column positions!
+ ModifierSequence ms;
+ try {
+ ms = new ModifierSequence(Uri.parse(cursor.getString(cursor.getColumnIndex(Table.COLUMN_MEDIA_URI))),
+ new JSONObject(cursor.getString(cursor.getColumnIndex(Table.COLUMN_DATA))));
+ } catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ ms.setContentUri( ModificationsContentProvider.uriForId(cursor.getInt(cursor.getColumnIndex(Table.COLUMN_ID))));
+
+ return ms;
+ }
+
+ private JSONObject toJSON(ModifierSequence sequence) {
+ JSONObject data = new JSONObject();
+ try {
+ JSONArray modifiersJSON = new JSONArray();
+ for (PageModifier modifier: sequence.getModifiers()) {
+ modifiersJSON.put(modifier.toJSON());
+ }
+ data.put("modifiers", modifiersJSON);
+ return data;
+ } catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private ContentValues toContentValues(ModifierSequence sequence) {
+ ContentValues cv = new ContentValues();
+ cv.put(Table.COLUMN_MEDIA_URI, sequence.getMediaUri().toString());
+ cv.put(Table.COLUMN_DATA, toJSON(sequence).toString());
+ return cv;
+ }
+
+ public static class Table {
+ static final String TABLE_NAME = "modifications";
+
+ static final String COLUMN_ID = "_id";
+ static final String COLUMN_MEDIA_URI = "mediauri";
+ static final String COLUMN_DATA = "data";
+
+ // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
+ public static final String[] ALL_FIELDS = {
+ COLUMN_ID,
+ COLUMN_MEDIA_URI,
+ COLUMN_DATA
+ };
+
+ static final String DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS " + TABLE_NAME;
+
+ static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " ("
+ + "_id INTEGER PRIMARY KEY,"
+ + "mediauri STRING,"
+ + "data STRING"
+ + ");";
+
+ public static void onCreate(SQLiteDatabase db) {
+ db.execSQL(CREATE_TABLE_STATEMENT);
+ }
+
+ public static void onUpdate(SQLiteDatabase db, int from, int to) {
+ db.execSQL(DROP_TABLE_STATEMENT);
+ onCreate(db);
+ }
+
+ public static void onDelete(SQLiteDatabase db) {
+ db.execSQL(DROP_TABLE_STATEMENT);
+ onCreate(db);
+ }
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/modifications/PageModifier.java b/app/src/main/java/fr/free/nrw/commons/modifications/PageModifier.java
new file mode 100644
index 000000000..8bc278b0f
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/modifications/PageModifier.java
@@ -0,0 +1,41 @@
+package fr.free.nrw.commons.modifications;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+public abstract class PageModifier {
+
+ public static PageModifier fromJSON(JSONObject data) {
+ String name = data.optString("name");
+ if (name.equals(CategoryModifier.MODIFIER_NAME)) {
+ return new CategoryModifier(data.optJSONObject("data"));
+ } else if (name.equals(TemplateRemoveModifier.MODIFIER_NAME)) {
+ return new TemplateRemoveModifier(data.optJSONObject("data"));
+ }
+
+ return null;
+ }
+
+ protected String name;
+ protected JSONObject params;
+
+ protected PageModifier(String name) {
+ this.name = name;
+ params = new JSONObject();
+ }
+
+ public abstract String doModification(String pageName, String pageContents);
+
+ public abstract String getEditSummary();
+
+ public JSONObject toJSON() {
+ JSONObject data = new JSONObject();
+ try {
+ data.putOpt("name", name);
+ data.put("data", params);
+ } catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ return data;
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/modifications/TemplateRemoveModifier.java b/app/src/main/java/fr/free/nrw/commons/modifications/TemplateRemoveModifier.java
new file mode 100644
index 000000000..cddcfbacf
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/modifications/TemplateRemoveModifier.java
@@ -0,0 +1,94 @@
+package fr.free.nrw.commons.modifications;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class TemplateRemoveModifier extends PageModifier {
+
+ public static final String MODIFIER_NAME = "TemplateRemoverModifier";
+
+ public static final String PARAM_TEMPLATE_NAME = "template";
+
+ public static final Pattern PATTERN_TEMPLATE_OPEN = Pattern.compile("\\{\\{");
+ public static final Pattern PATTERN_TEMPLATE_CLOSE = Pattern.compile("\\}\\}");
+
+ public TemplateRemoveModifier(String templateName) {
+ super(MODIFIER_NAME);
+ try {
+ params.putOpt(PARAM_TEMPLATE_NAME, templateName);
+ } catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public TemplateRemoveModifier(JSONObject data) {
+ super(MODIFIER_NAME);
+ this.params = data;
+ }
+
+ @Override
+ public String doModification(String pageName, String pageContents) {
+ String templateRawName = params.optString(PARAM_TEMPLATE_NAME);
+ // Wikitext title normalizing rules. Spaces and _ equivalent
+ // They also 'condense' - any number of them reduce to just one (just like HTML)
+ String templateNormalized = templateRawName.trim().replaceAll("(\\s|_)+", "(\\s|_)+");
+
+ // Not supporting {{ inside and HTML comments yet
+ // (Thanks to marktraceur for reminding me of the HTML comments exception)
+ Pattern templateStartPattern = Pattern.compile("\\{\\{" + templateNormalized, Pattern.CASE_INSENSITIVE);
+ Matcher matcher = templateStartPattern.matcher(pageContents);
+
+ while (matcher.find()) {
+ int braceCount = 1;
+ int startIndex = matcher.start();
+ int curIndex = matcher.end();
+ Matcher openMatch = PATTERN_TEMPLATE_OPEN.matcher(pageContents);
+ Matcher closeMatch = PATTERN_TEMPLATE_CLOSE.matcher(pageContents);
+
+ while (curIndex < pageContents.length()) {
+ boolean openFound = openMatch.find(curIndex);
+ boolean closeFound = closeMatch.find(curIndex);
+
+ if (openFound && (!closeFound || openMatch.start() < closeMatch.start())) {
+ braceCount++;
+ curIndex = openMatch.end();
+ } else if (closeFound) {
+ braceCount--;
+ curIndex = closeMatch.end();
+ } else if (braceCount > 0) {
+ // The template never closes, so...remove nothing
+ curIndex = startIndex;
+ break;
+ }
+
+ if (braceCount == 0) {
+ // The braces have all been closed!
+ break;
+ }
+ }
+
+ // Strip trailing whitespace
+ while (curIndex < pageContents.length()) {
+ if (pageContents.charAt(curIndex) == ' ' || pageContents.charAt(curIndex) == '\n') {
+ curIndex++;
+ } else {
+ break;
+ }
+ }
+
+ // I am so going to hell for this, sigh
+ pageContents = pageContents.substring(0, startIndex) + pageContents.substring(curIndex);
+ matcher = templateStartPattern.matcher(pageContents);
+ }
+
+ return pageContents;
+ }
+
+ @Override
+ public String getEditSummary() {
+ return "Removed template " + params.optString(PARAM_TEMPLATE_NAME) + ".";
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/ApacheHttpClientMediaWikiApi.java b/app/src/main/java/fr/free/nrw/commons/mwapi/ApacheHttpClientMediaWikiApi.java
index b356184d8..8f5b4213f 100644
--- a/app/src/main/java/fr/free/nrw/commons/mwapi/ApacheHttpClientMediaWikiApi.java
+++ b/app/src/main/java/fr/free/nrw/commons/mwapi/ApacheHttpClientMediaWikiApi.java
@@ -1,11 +1,12 @@
package fr.free.nrw.commons.mwapi;
+import android.content.Context;
+import android.net.Uri;
import android.text.TextUtils;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
import com.google.gson.Gson;
+import org.apache.commons.lang3.StringUtils;
import org.apache.http.conn.ClientConnectionManager;
import org.apache.http.conn.scheme.PlainSocketFactory;
import org.apache.http.conn.scheme.Scheme;
@@ -16,19 +17,34 @@ import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
import org.apache.http.params.BasicHttpParams;
import org.apache.http.params.CoreProtocolPNames;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
import org.wikipedia.util.DateUtil;
import java.io.IOException;
+import java.io.InputStream;
import java.text.ParseException;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.Callable;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.CommonsApplication;
-
-import fr.free.nrw.commons.BuildConfig;
-import fr.free.nrw.commons.CommonsApplication;
-
+import fr.free.nrw.commons.R;
+import fr.free.nrw.commons.auth.AccountUtil;
+import fr.free.nrw.commons.category.CategoryImageUtils;
+import fr.free.nrw.commons.category.QueryContinue;
+import fr.free.nrw.commons.kvstore.JsonKvStore;
+import fr.free.nrw.commons.notification.Notification;
+import fr.free.nrw.commons.notification.NotificationUtils;
+import fr.free.nrw.commons.utils.ViewUtil;
+import io.reactivex.Observable;
import io.reactivex.Single;
import timber.log.Timber;
@@ -38,8 +54,19 @@ import timber.log.Timber;
public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
private AbstractHttpClient httpClient;
private CustomMwApi api;
+ private CustomMwApi wikidataApi;
+ private Context context;
+ private JsonKvStore defaultKvStore;
+ private Gson gson;
- public ApacheHttpClientMediaWikiApi(String apiURL) {
+ private final String ERROR_CODE_BAD_TOKEN = "badtoken";
+
+ public ApacheHttpClientMediaWikiApi(Context context,
+ String apiURL,
+ String wikidatApiURL,
+ JsonKvStore defaultKvStore,
+ Gson gson) {
+ this.context = context;
BasicHttpParams params = new BasicHttpParams();
SchemeRegistry schemeRegistry = new SchemeRegistry();
schemeRegistry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
@@ -52,6 +79,217 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
httpClient.addRequestInterceptor(NetworkInterceptors.getHttpRequestInterceptor());
}
api = new CustomMwApi(apiURL, httpClient);
+ wikidataApi = new CustomMwApi(wikidatApiURL, httpClient);
+ this.defaultKvStore = defaultKvStore;
+ this.gson = gson;
+ }
+
+ /**
+ * @param username String
+ * @param password String
+ * @return String as returned by this.getErrorCodeToReturn()
+ * @throws IOException On api request IO issue
+ */
+ public String login(String username, String password) throws IOException {
+ String loginToken = getLoginToken();
+ Timber.d("Login token is %s", loginToken);
+ return getErrorCodeToReturn(api.action("clientlogin")
+ .param("rememberMe", "1")
+ .param("username", username)
+ .param("password", password)
+ .param("logintoken", loginToken)
+ .param("loginreturnurl", "https://commons.wikimedia.org")
+ .post());
+ }
+
+ /**
+ * @param username String
+ * @param password String
+ * @param twoFactorCode String
+ * @return String as returned by this.getErrorCodeToReturn()
+ * @throws IOException On api request IO issue
+ */
+ public String login(String username, String password, String twoFactorCode) throws IOException {
+ String loginToken = getLoginToken();
+ Timber.d("Login token is %s", loginToken);
+ return getErrorCodeToReturn(api.action("clientlogin")
+ .param("rememberMe", "true")
+ .param("username", username)
+ .param("password", password)
+ .param("logintoken", loginToken)
+ .param("logincontinue", "true")
+ .param("OATHToken", twoFactorCode)
+ .post());
+ }
+
+ private String getLoginToken() throws IOException {
+ return api.action("query")
+ .param("action", "query")
+ .param("meta", "tokens")
+ .param("type", "login")
+ .post()
+ .getString("/api/query/tokens/@logintoken");
+ }
+
+ /**
+ * @param loginCustomApiResult CustomApiResult Any clientlogin api result
+ * @return String On success: "PASS"
+ * continue: "2FA" (More information required for 2FA)
+ * failure: A failure message code (defined by mediawiki)
+ * misc: genericerror-UI, genericerror-REDIRECT, genericerror-RESTART
+ */
+ private String getErrorCodeToReturn(CustomApiResult loginCustomApiResult) {
+ String status = loginCustomApiResult.getString("/api/clientlogin/@status");
+ if (status.equals("PASS")) {
+ api.isLoggedIn = true;
+ setAuthCookieOnLogin(true);
+ return status;
+ } else if (status.equals("FAIL")) {
+ setAuthCookieOnLogin(false);
+ return loginCustomApiResult.getString("/api/clientlogin/@messagecode");
+ } else if (
+ status.equals("UI")
+ && loginCustomApiResult.getString("/api/clientlogin/requests/_v/@id").equals("TOTPAuthenticationRequest")
+ && loginCustomApiResult.getString("/api/clientlogin/requests/_v/@provider").equals("Two-factor authentication (OATH).")
+ ) {
+ setAuthCookieOnLogin(false);
+ return "2FA";
+ }
+
+ // UI, REDIRECT, RESTART
+ return "genericerror-" + status;
+ }
+
+ private void setAuthCookieOnLogin(boolean isLoggedIn) {
+ if (isLoggedIn) {
+ defaultKvStore.putBoolean("isUserLoggedIn", true);
+ defaultKvStore.putString("getAuthCookie", api.getAuthCookie());
+ } else {
+ defaultKvStore.putBoolean("isUserLoggedIn", false);
+ defaultKvStore.remove("getAuthCookie");
+ }
+ }
+
+ @Override
+ public String getAuthCookie() {
+ return api.getAuthCookie();
+ }
+
+ @Override
+ public void setAuthCookie(String authCookie) {
+ api.setAuthCookie(authCookie);
+ }
+
+ @Override
+ public boolean validateLogin() throws IOException {
+ boolean validateLoginResp = api.validateLogin();
+ Timber.d("Validate login response is %s", validateLoginResp);
+ return validateLoginResp;
+ }
+
+ @Override
+ public String getEditToken() throws IOException {
+ String editToken = api.action("query")
+ .param("meta", "tokens")
+ .post()
+ .getString("/api/query/tokens/@csrftoken");
+ Timber.d("MediaWiki edit token is %s", editToken);
+ return editToken;
+ }
+
+ @Override
+ public String getCentralAuthToken() throws IOException {
+ CustomApiResult result = api.action("centralauthtoken").get();
+ String centralAuthToken = result.getString("/api/centralauthtoken/@centralauthtoken");
+
+ Timber.d("MediaWiki Central auth token is %s", centralAuthToken);
+
+ if ((centralAuthToken == null || centralAuthToken.isEmpty())
+ && "notLoggedIn".equals(result.getString("api/error/@code"))) {
+ Timber.d("Central auth token isn't valid. Trying to fetch a fresh token");
+ api.removeAllCookies();
+ String loginResultCode = login(AccountUtil.getUserName(context), AccountUtil.getPassword(context));
+ if (loginResultCode.equals("PASS")) {
+ return getCentralAuthToken();
+ } else if (loginResultCode.equals("2FA")) {
+ Timber.e("Cannot refresh session for 2FA enabled user. Login required");
+ } else {
+ Timber.e("Error occurred in refreshing session. Error code is %s", loginResultCode);
+ }
+ } else {
+ Timber.e("Error occurred while fetching auth token. Error code is %s and message is %s",
+ result.getString("api/error/@code"),
+ result.getString("api/error/@info"));
+ }
+ return centralAuthToken;
+ }
+
+ @Override
+ public boolean fileExistsWithName(String fileName) throws IOException {
+ return api.action("query")
+ .param("prop", "imageinfo")
+ .param("titles", "File:" + fileName)
+ .get()
+ .getNodes("/api/query/pages/page/imageinfo").size() > 0;
+ }
+
+ @Override
+ public Single pageExists(String pageName) {
+ return Single.fromCallable(() -> Double.parseDouble(api.action("query")
+ .param("titles", pageName)
+ .get()
+ .getString("/api/query/pages/page/@_idx")) != -1);
+ }
+
+ @Override
+ public boolean thank(String editToken, long revision) throws IOException {
+ CustomApiResult res = api.action("thank")
+ .param("rev", revision)
+ .param("token", editToken)
+ .param("source", CommonsApplication.getInstance().getUserAgent())
+ .post();
+ String r = res.getString("/api/result/@success");
+ // Does this correctly check the success/failure?
+ // The docs https://www.mediawiki.org/wiki/Extension:Thanks seems unclear about that.
+ return r.equals("success");
+ }
+
+ @Override
+ @Nullable
+ public String edit(String editToken, String processedPageContent, String filename, String summary) throws IOException {
+ return api.action("edit")
+ .param("title", filename)
+ .param("token", getEditToken())
+ .param("text", processedPageContent)
+ .param("summary", summary)
+ .post()
+ .getString("/api/edit/@result");
+ }
+
+
+
+ @Override
+ @Nullable
+ public String appendEdit(String editToken, String processedPageContent, String filename, String summary) throws IOException {
+ return api.action("edit")
+ .param("title", filename)
+ .param("token", getEditToken())
+ .param("appendtext", processedPageContent)
+ .param("summary", summary)
+ .post()
+ .getString("/api/edit/@result");
+ }
+
+ @Override
+ @Nullable
+ public String prependEdit(String editToken, String processedPageContent, String filename, String summary) throws IOException {
+ return api.action("edit")
+ .param("title", filename)
+ .param("token", getEditToken())
+ .param("prependtext", processedPageContent)
+ .param("summary", summary)
+ .post()
+ .getString("/api/edit/@result");
}
@Override
@@ -83,6 +321,188 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
});
}
+ @Override
+ @NonNull
+ public Observable searchCategories(String filterValue, int searchCatsLimit) {
+ List categories = new ArrayList<>();
+ return Single.fromCallable(() -> {
+ List categoryNodes = null;
+ try {
+ categoryNodes = api.action("query")
+ .param("format", "xml")
+ .param("list", "search")
+ .param("srwhat", "text")
+ .param("srnamespace", "14")
+ .param("srlimit", searchCatsLimit)
+ .param("srsearch", filterValue)
+ .get()
+ .getNodes("/api/query/search/p/@title");
+ } catch (IOException e) {
+ Timber.e(e, "Failed to obtain searchCategories");
+ }
+
+ if (categoryNodes == null) {
+ return new ArrayList();
+ }
+
+ for (CustomApiResult categoryNode : categoryNodes) {
+ String cat = categoryNode.getDocument().getTextContent();
+ String catString = cat.replace("Category:", "");
+ if (!categories.contains(catString)) {
+ categories.add(catString);
+ }
+ }
+
+ return categories;
+ }).flatMapObservable(Observable::fromIterable);
+ }
+
+ @Override
+ @NonNull
+ public Observable allCategories(String filterValue, int searchCatsLimit) {
+ return Single.fromCallable(() -> {
+ ArrayList categoryNodes = null;
+ try {
+ categoryNodes = api.action("query")
+ .param("list", "allcategories")
+ .param("acprefix", filterValue)
+ .param("aclimit", searchCatsLimit)
+ .get()
+ .getNodes("/api/query/allcategories/c");
+ } catch (IOException e) {
+ Timber.e(e, "Failed to obtain allCategories");
+ }
+
+ if (categoryNodes == null) {
+ return new ArrayList();
+ }
+
+ List categories = new ArrayList<>();
+ for (CustomApiResult categoryNode : categoryNodes) {
+ categories.add(categoryNode.getDocument().getTextContent());
+ }
+
+ return categories;
+ }).flatMapObservable(Observable::fromIterable);
+ }
+
+ @Override
+ public String getWikidataCsrfToken() throws IOException {
+ String wikidataCsrfToken = wikidataApi.action("query")
+ .param("action", "query")
+ .param("centralauthtoken", getCentralAuthToken())
+ .param("meta", "tokens")
+ .post()
+ .getString("/api/query/tokens/@csrftoken");
+ Timber.d("Wikidata csrf token is %s", wikidataCsrfToken);
+ return wikidataCsrfToken;
+ }
+
+ /**
+ * Creates a new claim using the wikidata API
+ * https://www.mediawiki.org/wiki/Wikibase/API
+ * @param entityId the wikidata entity to be edited
+ * @param property the property to be edited, for eg P18 for images
+ * @param snaktype the type of value stored for that property
+ * @param value the actual value to be stored for the property, for eg filename in case of P18
+ * @return returns revisionId if the claim is successfully created else returns null
+ * @throws IOException
+ */
+ @Nullable
+ @Override
+ public String wikidataCreateClaim(String entityId, String property, String snaktype, String value) throws IOException {
+ Timber.d("Filename is %s", value);
+ CustomApiResult result = wikidataApi.action("wbcreateclaim")
+ .param("entity", entityId)
+ .param("centralauthtoken", getCentralAuthToken())
+ .param("token", getWikidataCsrfToken())
+ .param("snaktype", snaktype)
+ .param("property", property)
+ .param("value", value)
+ .post();
+
+ if (result == null || result.getNode("api") == null) {
+ return null;
+ }
+
+ Node node = result.getNode("api").getDocument();
+ Element element = (Element) node;
+
+ if (element != null && element.getAttribute("success").equals("1")) {
+ return result.getString("api/pageinfo/@lastrevid");
+ } else {
+ Timber.e(result.getString("api/error/@code") + " " + result.getString("api/error/@info"));
+ }
+ return null;
+ }
+
+ /**
+ * Adds the wikimedia-commons-app tag to the edits made on wikidata
+ * @param revisionId
+ * @return
+ * @throws IOException
+ */
+ @Nullable
+ @Override
+ public boolean addWikidataEditTag(String revisionId) throws IOException {
+ CustomApiResult result = wikidataApi.action("tag")
+ .param("revid", revisionId)
+ .param("centralauthtoken", getCentralAuthToken())
+ .param("token", getWikidataCsrfToken())
+ .param("add", "wikimedia-commons-app")
+ .param("reason", "Add tag for edits made using Android Commons app")
+ .post();
+
+ if (result == null || result.getNode("api") == null) {
+ return false;
+ }
+
+ if ("success".equals(result.getString("api/tag/result/@status"))) {
+ return true;
+ } else {
+ Timber.e("Error occurred in creating claim. Error code is: %s and message is %s",
+ result.getString("api/error/@code"),
+ result.getString("api/error/@info"));
+ }
+ return false;
+ }
+
+ @Override
+ @NonNull
+ public Observable searchTitles(String title, int searchCatsLimit) {
+ return Single.fromCallable((Callable>) () -> {
+ ArrayList categoryNodes;
+
+ try {
+ categoryNodes = api.action("query")
+ .param("format", "xml")
+ .param("list", "search")
+ .param("srwhat", "text")
+ .param("srnamespace", "14")
+ .param("srlimit", searchCatsLimit)
+ .param("srsearch", title)
+ .get()
+ .getNodes("/api/query/search/p/@title");
+ } catch (IOException e) {
+ Timber.e(e, "Failed to obtain searchTitles");
+ return Collections.emptyList();
+ }
+
+ if (categoryNodes == null) {
+ return Collections.emptyList();
+ }
+
+ List titleCategories = new ArrayList<>();
+ for (CustomApiResult categoryNode : categoryNodes) {
+ String cat = categoryNode.getDocument().getTextContent();
+ String catString = cat.replace("Category:", "");
+ titleCategories.add(catString);
+ }
+
+ return titleCategories;
+ }).flatMapObservable(Observable::fromIterable);
+ }
+
@Override
@NonNull
public LogEventResult logEvents(String user, String lastModified, String queryContinue, int limit) throws IOException {
@@ -131,9 +551,282 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
.getString("/api/query/pages/page/revisions/rev");
}
+ @Override
+ @NonNull
+ public List getNotifications(boolean archived) {
+ CustomApiResult notificationNode = null;
+ String notfilter;
+ try {
+ if (archived) {
+ notfilter = "read";
+ }else {
+ notfilter = "!read";
+ }
+ String language=Locale.getDefault().getLanguage();
+ if(StringUtils.isBlank(language)){
+ //if no language is set we use the default user language defined on wikipedia
+ language="user";
+ }
+ notificationNode = api.action("query")
+ .param("notprop", "list")
+ .param("format", "xml")
+ .param("meta", "notifications")
+ .param("notformat", "model")
+ .param("notwikis", "wikidatawiki|commonswiki|enwiki")
+ .param("notfilter", notfilter)
+ .param("uselang", language)
+ .get()
+ .getNode("/api/query/notifications/list");
+ } catch (IOException e) {
+ Timber.e(e, "Failed to obtain searchCategories");
+ }
+
+ if (notificationNode == null
+ || notificationNode.getDocument() == null
+ || notificationNode.getDocument().getChildNodes() == null
+ || notificationNode.getDocument().getChildNodes().getLength() == 0) {
+ return new ArrayList<>();
+ }
+ NodeList childNodes = notificationNode.getDocument().getChildNodes();
+ return NotificationUtils.getNotificationsFromList(context, childNodes);
+ }
+
+ @Override
+ public boolean markNotificationAsRead(Notification notification) throws IOException {
+ Timber.d("Trying to mark notification as read: %s", notification.toString());
+ String result = api.action("echomarkread")
+ .param("token", getEditToken())
+ .param("list", notification.notificationId)
+ .post()
+ .getString("/api/query/echomarkread/@result");
+
+ if (StringUtils.isBlank(result)) {
+ return false;
+ }
+
+ return result.equals("success");
+ }
+
/**
+ * The method takes categoryName as input and returns a List of Subcategories
+ * It uses the generator query API to get the subcategories in a category, 500 at a time.
+ * Uses the query continue values for fetching paginated responses
+ * @param categoryName Category name as defined on commons
+ * @return
+ */
+ @Override
+ @NonNull
+ public List getSubCategoryList(String categoryName) {
+ CustomApiResult apiResult = null;
+ try {
+ CustomMwApi.RequestBuilder requestBuilder = api.action("query")
+ .param("generator", "categorymembers")
+ .param("format", "xml")
+ .param("gcmtype","subcat")
+ .param("gcmtitle", categoryName)
+ .param("prop", "info")
+ .param("gcmlimit", "500")
+ .param("iiprop", "url|extmetadata");
+
+ apiResult = requestBuilder.get();
+ } catch (IOException e) {
+ Timber.e(e, "Failed to obtain searchCategories");
+ }
+
+ if (apiResult == null) {
+ return new ArrayList<>();
+ }
+
+ CustomApiResult categoryImagesNode = apiResult.getNode("/api/query/pages");
+ if (categoryImagesNode == null
+ || categoryImagesNode.getDocument() == null
+ || categoryImagesNode.getDocument().getChildNodes() == null
+ || categoryImagesNode.getDocument().getChildNodes().getLength() == 0) {
+ return new ArrayList<>();
+ }
+
+ NodeList childNodes = categoryImagesNode.getDocument().getChildNodes();
+ return CategoryImageUtils.getSubCategoryList(childNodes);
+ }
+
+ /**
+ * The method takes categoryName as input and returns a List of parent categories
+ * It uses the generator query API to get the parent categories of a category, 500 at a time.
+ * @param categoryName Category name as defined on commons
+ * @return
+ */
+ @Override
+ @NonNull
+ public List getParentCategoryList(String categoryName) {
+ CustomApiResult apiResult = null;
+ try {
+ CustomMwApi.RequestBuilder requestBuilder = api.action("query")
+ .param("generator", "categories")
+ .param("format", "xml")
+ .param("titles", categoryName)
+ .param("prop", "info")
+ .param("cllimit", "500")
+ .param("iiprop", "url|extmetadata");
+
+ apiResult = requestBuilder.get();
+ } catch (IOException e) {
+ Timber.e(e, "Failed to obtain parent Categories");
+ }
+
+ if (apiResult == null) {
+ return new ArrayList<>();
+ }
+
+ CustomApiResult categoryImagesNode = apiResult.getNode("/api/query/pages");
+ if (categoryImagesNode == null
+ || categoryImagesNode.getDocument() == null
+ || categoryImagesNode.getDocument().getChildNodes() == null
+ || categoryImagesNode.getDocument().getChildNodes().getLength() == 0) {
+ return new ArrayList<>();
+ }
+
+ NodeList childNodes = categoryImagesNode.getDocument().getChildNodes();
+ return CategoryImageUtils.getSubCategoryList(childNodes);
+ }
+
+ /**
+ * This method takes search keyword as input and returns a list of categories objects filtered using search query
+ * It uses the generator query API to get the categories searched using a query, 25 at a time.
+ * @param query keyword to search categories on commons
+ * @return
+ */
+ @Override
+ @NonNull
+ public List searchCategory(String query, int offset) {
+ List categoryNodes = null;
+ try {
+ categoryNodes = api.action("query")
+ .param("format", "xml")
+ .param("list", "search")
+ .param("srwhat", "text")
+ .param("srnamespace", "14")
+ .param("srlimit", "25")
+ .param("sroffset",offset)
+ .param("srsearch", query)
+ .get()
+ .getNodes("/api/query/search/p/@title");
+ } catch (IOException e) {
+ Timber.e(e, "Failed to obtain searchCategories");
+ }
+
+ if (categoryNodes == null) {
+ return new ArrayList<>();
+ }
+
+ List categories = new ArrayList<>();
+ for (CustomApiResult categoryNode : categoryNodes) {
+ String catName = categoryNode.getDocument().getTextContent();
+ categories.add(catName);
+ }
+ return categories;
+ }
+
+
+ /**
+ * For APIs that return paginated responses, MediaWiki APIs uses the QueryContinue to facilitate fetching of subsequent pages
+ * https://www.mediawiki.org/wiki/API:Raw_query_continue
+ * After fetching images a page of image for a particular category, shared defaultKvStore are updated with the latest QueryContinue Values
+ * @param keyword
+ * @param queryContinue
+ */
+ private void setQueryContinueValues(String keyword, QueryContinue queryContinue) {
+ defaultKvStore.putString(keyword, gson.toJson(queryContinue));
+ }
+
+ /**
+ * Before making a paginated API call, this method is called to get the latest query continue values to be used
+ * @param keyword
+ * @return
+ */
+ @Nullable
+ private QueryContinue getQueryContinueValues(String keyword) {
+ String queryContinueString = defaultKvStore.getString(keyword, null);
+ return gson.fromJson(queryContinueString, QueryContinue.class);
+ }
+
+ @Override
+ public boolean existingFile(String fileSha1) throws IOException {
+ return api.action("query")
+ .param("format", "xml")
+ .param("list", "allimages")
+ .param("aisha1", fileSha1)
+ .get()
+ .getNodes("/api/query/allimages/img").size() > 0;
+ }
+
+ @Override
+ @NonNull
+ public Single uploadFile(
+ String filename,
+ @NonNull InputStream file,
+ long dataLength,
+ Uri fileUri,
+ Uri contentProviderUri,
+ ProgressListener progressListener) {
+ return Single.fromCallable(() -> {
+ CustomApiResult result = api.uploadToStash(filename, file, dataLength, getEditToken(), progressListener::onProgress);
+
+ Timber.wtf("Result: " + result.toString());
+
+ String resultStatus = result.getString("/api/upload/@result");
+ if (!resultStatus.equals("Success")) {
+ String errorCode = result.getString("/api/error/@code");
+ Timber.e(errorCode);
+
+ if (errorCode.equals(ERROR_CODE_BAD_TOKEN)) {
+ ViewUtil.showLongToast(context, R.string.bad_token_error_proposed_solution);
+ }
+ return new UploadStash(errorCode, resultStatus, filename, "");
+ } else {
+ String filekey = result.getString("/api/upload/@filekey");
+ return new UploadStash("", resultStatus, filename, filekey);
+ }
+ });
+ }
+
+
+ @Override
+ @NonNull
+ public Single uploadFileFinalize(
+ String filename,
+ String filekey,
+ String pageContents,
+ String editSummary) throws IOException {
+ return Single.fromCallable(() -> {
+ CustomApiResult result = api.uploadFromStash(
+ filename, filekey, pageContents, editSummary,
+ getEditToken());
+
+ Timber.d("Result: %s", result.toString());
+
+ String resultStatus = result.getString("/api/upload/@result");
+ if (!resultStatus.equals("Success")) {
+ String errorCode = result.getString("/api/error/@code");
+ Timber.e(errorCode);
+
+ if (errorCode.equals(ERROR_CODE_BAD_TOKEN)) {
+ ViewUtil.showLongToast(context, R.string.bad_token_error_proposed_solution);
+ }
+ return new UploadResult(resultStatus, errorCode);
+ } else {
+ Date dateUploaded = parseMWDate(result.getString("/api/upload/imageinfo/@timestamp"));
+ String canonicalFilename = "File:" + result.getString("/api/upload/@filename")
+ .replace("_", " ")
+ .trim(); // Title vs Filename
+ String imageUrl = result.getString("/api/upload/imageinfo/@url");
+ return new UploadResult(resultStatus, dateUploaded, canonicalFilename, imageUrl);
+ }
+ });
+ }
+
+ /**
+
* Checks to see if a user is currently blocked from Commons
- *
* @return whether or not the user is blocked from Commons
*/
@Override
diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/MediaWikiApi.java b/app/src/main/java/fr/free/nrw/commons/mwapi/MediaWikiApi.java
index a4330d102..e38c4dc0f 100644
--- a/app/src/main/java/fr/free/nrw/commons/mwapi/MediaWikiApi.java
+++ b/app/src/main/java/fr/free/nrw/commons/mwapi/MediaWikiApi.java
@@ -1,22 +1,94 @@
package fr.free.nrw.commons.mwapi;
+import android.net.Uri;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-
-import java.io.IOException;
-
+import fr.free.nrw.commons.notification.Notification;
+import io.reactivex.Observable;
import io.reactivex.Single;
public interface MediaWikiApi {
+ String getAuthCookie();
+
+ void setAuthCookie(String authCookie);
+
+ String login(String username, String password) throws IOException;
+
+ String login(String username, String password, String twoFactorCode) throws IOException;
+
+ boolean validateLogin() throws IOException;
+
+ String getEditToken() throws IOException;
+
+ String getWikidataCsrfToken() throws IOException;
+
+ String getCentralAuthToken() throws IOException;
+
+ boolean fileExistsWithName(String fileName) throws IOException;
+
+ Single pageExists(String pageName);
+
+ List getSubCategoryList(String categoryName);
+
+ List getParentCategoryList(String categoryName);
+
+ @NonNull
+ List searchCategory(String title, int offset);
+
+ @NonNull
+ Single uploadFile(String filename, InputStream file,
+ long dataLength, Uri fileUri, Uri contentProviderUri,
+ final ProgressListener progressListener);
+
+ @NonNull
+ Single uploadFileFinalize(String filename, String filekey,
+ String pageContents, String editSummary) throws IOException;
+ @Nullable
+ String edit(String editToken, String processedPageContent, String filename, String summary) throws IOException;
+
+ @Nullable
+ String prependEdit(String editToken, String processedPageContent, String filename, String summary) throws IOException;
+
+ @Nullable
+ String appendEdit(String editToken, String processedPageContent, String filename, String summary) throws IOException;
+
+ @Nullable
+ String wikidataCreateClaim(String entityId, String property, String snaktype, String value) throws IOException;
+
+ @Nullable
+ boolean addWikidataEditTag(String revisionId) throws IOException;
+
Single parseWikicode(String source);
@NonNull
Single fetchMediaByFilename(String filename);
+ @NonNull
+ Observable searchCategories(String filterValue, int searchCatsLimit);
+
+ @NonNull
+ Observable allCategories(String filter, int searchCatsLimit);
+
+ @NonNull
+ List getNotifications(boolean archived) throws IOException;
+
+ @NonNull
+ boolean markNotificationAsRead(Notification notification) throws IOException;
+
+ @NonNull
+ Observable searchTitles(String title, int searchCatsLimit);
+
@Nullable
String revisionsByFilename(String filename) throws IOException;
+ boolean existingFile(String fileSha1) throws IOException;
+
@NonNull
LogEventResult logEvents(String user, String lastModified, String queryContinue, int limit) throws IOException;
@@ -26,6 +98,8 @@ public interface MediaWikiApi {
// Single getCampaigns();
+ boolean thank(String editToken, long revision) throws IOException;
+
interface ProgressListener {
void onProgress(long transferred, long total);
}
diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.java b/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.java
index 631452569..061351c1f 100644
--- a/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.java
+++ b/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.java
@@ -46,11 +46,15 @@ import timber.log.Timber;
public class OkHttpJsonApiClient {
private static final String THUMB_SIZE = "640";
+ public static final Type mapType = new TypeToken