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>() { + }.getType(); + private final OkHttpClient okHttpClient; private final HttpUrl wikiMediaToolforgeUrl; private final String sparqlQueryUrl; private final String campaignsUrl; private final String commonsBaseUrl; + private final JsonKvStore defaultKvStore; private Gson gson; @@ -60,12 +64,14 @@ public class OkHttpJsonApiClient { String sparqlQueryUrl, String campaignsUrl, String commonsBaseUrl, + JsonKvStore defaultKvStore, Gson gson) { this.okHttpClient = okHttpClient; this.wikiMediaToolforgeUrl = wikiMediaToolforgeUrl; this.sparqlQueryUrl = sparqlQueryUrl; this.campaignsUrl = campaignsUrl; this.commonsBaseUrl = commonsBaseUrl; + this.defaultKvStore = defaultKvStore; this.gson = gson; } @@ -228,6 +234,56 @@ public class OkHttpJsonApiClient { }); } + /** + * The method returns the picture of the day + * + * @return Media object corresponding to the picture of the day + */ + @Nullable + public Single getPictureOfTheDay() { + String date = CommonsDateUtil.getIso8601DateFormatShort().format(new Date()); + Timber.d("Current date is %s", date); + String template = "Template:Potd/" + date; + return getMedia(template, true); + } + + /** + * Fetches Media object from the imageInfo API + * + * @param titles the tiles to be searched for. Can be filename or template name + * @param useGenerator specifies if a image generator parameter needs to be passed or not + * @return + */ + public Single getMedia(String titles, boolean useGenerator) { + HttpUrl.Builder urlBuilder = HttpUrl + .parse(commonsBaseUrl) + .newBuilder() + .addQueryParameter("action", "query") + .addQueryParameter("format", "json") + .addQueryParameter("formatversion", "2") + .addQueryParameter("titles", titles); + + if (useGenerator) { + urlBuilder.addQueryParameter("generator", "images"); + } + + Request request = new Request.Builder() + .url(appendMediaProperties(urlBuilder).build()) + .build(); + + return Single.fromCallable(() -> { + Response response = okHttpClient.newCall(request).execute(); + if (response.body() != null && response.isSuccessful()) { + String json = response.body().string(); + MwQueryResponse mwQueryPage = gson.fromJson(json, MwQueryResponse.class); + if (mwQueryPage.success() && mwQueryPage.query().firstPage() != null) { + return Media.from(mwQueryPage.query().firstPage()); + } + } + return null; + }); + } + /** * Whenever imageInfo is fetched, these common properties can be specified for the API call * https://www.mediawiki.org/wiki/API:Imageinfo @@ -248,4 +304,124 @@ public class OkHttpJsonApiClient { return builder; } + + /** + * This method takes the keyword and queryType 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 queryType queryType can be "search" OR "category" + * @param keyword the search keyword. Can be either category name or search query + * @return + */ + @Nullable + public Single> getMediaList(String queryType, String keyword) { + HttpUrl.Builder urlBuilder = HttpUrl + .parse(commonsBaseUrl) + .newBuilder() + .addQueryParameter("action", "query") + .addQueryParameter("format", "json") + .addQueryParameter("formatversion", "2"); + + + if (queryType.equals("search")) { + appendSearchParam(keyword, urlBuilder); + } else { + appendCategoryParams(keyword, urlBuilder); + } + + appendQueryContinueValues(keyword, urlBuilder); + + Request request = new Request.Builder() + .url(appendMediaProperties(urlBuilder).build()) + .build(); + + return Single.fromCallable(() -> { + Response response = okHttpClient.newCall(request).execute(); + List mediaList = new ArrayList<>(); + if (response.body() != null && response.isSuccessful()) { + String json = response.body().string(); + MwQueryResponse mwQueryResponse = gson.fromJson(json, MwQueryResponse.class); + if (null == mwQueryResponse + || null == mwQueryResponse.query() + || null == mwQueryResponse.query().pages()) { + return mediaList; + } + putContinueValues(keyword, mwQueryResponse.continuation()); + + List pages = mwQueryResponse.query().pages(); + for (MwQueryPage page : pages) { + Media media = Media.from(page); + if (media != null) { + mediaList.add(media); + } + } + } + return mediaList; + }); + } + + /** + * Append params for search query. + * + * @param query the search query to be sent to the API + * @param urlBuilder builder for HttpUrl + */ + private void appendSearchParam(String query, HttpUrl.Builder urlBuilder) { + urlBuilder.addQueryParameter("generator", "search") + .addQueryParameter("gsrwhat", "text") + .addQueryParameter("gsrnamespace", "6") + .addQueryParameter("gsrlimit", "25") + .addQueryParameter("gsrsearch", query); + } + + /** + * It takes a urlBuilder and appends all the continue values as query parameters + * + * @param query + * @param urlBuilder + */ + private void appendQueryContinueValues(String query, HttpUrl.Builder urlBuilder) { + Map continueValues = getContinueValues(query); + if (continueValues != null && continueValues.size() > 0) { + for (Map.Entry entry : continueValues.entrySet()) { + urlBuilder.addQueryParameter(entry.getKey(), entry.getValue()); + } + } + } + + /** + * Append parameters for category image generator + * + * @param categoryName name of the category + * @param urlBuilder HttpUrl builder + */ + private void appendCategoryParams(String categoryName, HttpUrl.Builder urlBuilder) { + urlBuilder.addQueryParameter("generator", "categorymembers") + .addQueryParameter("gcmtype", "file") + .addQueryParameter("gcmtitle", categoryName) + .addQueryParameter("gcmsort", "timestamp")//property to sort by;timestamp + .addQueryParameter("gcmdir", "desc")//in which direction to sort;descending + .addQueryParameter("gcmlimit", "10"); + } + + /** + * Stores the continue values for action=query + * These values are sent to the server in the subsequent call to fetch results after this point + * + * @param keyword + * @param values + */ + private void putContinueValues(String keyword, Map values) { + defaultKvStore.putJson("query_continue_" + keyword, values); + } + + /** + * Retrieves a map of continue values from shared preferences. + * These values are appended to the next API call + * + * @param keyword + * @return + */ + private Map getContinueValues(String keyword) { + return defaultKvStore.getJson("query_continue_" + keyword, mapType); + } } diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/UploadResult.java b/app/src/main/java/fr/free/nrw/commons/mwapi/UploadResult.java new file mode 100644 index 000000000..98a476ffa --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/UploadResult.java @@ -0,0 +1,90 @@ +package fr.free.nrw.commons.mwapi; + +import org.jetbrains.annotations.NotNull; + +import java.util.Date; + +public class UploadResult { + private String errorCode; + private String resultStatus; + private Date dateUploaded; + private String imageUrl; + private String canonicalFilename; + + /** + * Minimal constructor + * + * @param resultStatus Upload result status + * @param errorCode Upload error code + */ + UploadResult(String resultStatus, String errorCode) { + this.resultStatus = resultStatus; + this.errorCode = errorCode; + } + + /** + * Full-fledged constructor + * @param resultStatus Upload result status + * @param dateUploaded Uploaded date + * @param canonicalFilename Uploaded file name + * @param imageUrl Uploaded image file name + */ + UploadResult(String resultStatus, Date dateUploaded, String canonicalFilename, String imageUrl) { + this.resultStatus = resultStatus; + this.dateUploaded = dateUploaded; + this.canonicalFilename = canonicalFilename; + this.imageUrl = imageUrl; + } + + @NotNull + @Override + public String toString() { + return "UploadResult{" + + "errorCode='" + errorCode + '\'' + + ", resultStatus='" + resultStatus + '\'' + + ", dateUploaded='" + (dateUploaded == null ? "" : dateUploaded.toString()) + '\'' + + ", imageUrl='" + imageUrl + '\'' + + ", canonicalFilename='" + canonicalFilename + '\'' + + '}'; + } + + /** + * Gets uploaded date + * @return Upload date + */ + public Date getDateUploaded() { + return dateUploaded; + } + + /** + * Gets image url + * @return Uploaded image url + */ + public String getImageUrl() { + return imageUrl; + } + + /** + * Gets canonical file name + * @return Uploaded file name + */ + public String getCanonicalFilename() { + return canonicalFilename; + } + + /** + * Gets upload error code + * @return Error code + */ + public String getErrorCode() { + return errorCode; + } + + /** + * Gets upload result status + * @return Upload result status + */ + public String getResultStatus() { + return resultStatus; + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/UploadStash.java b/app/src/main/java/fr/free/nrw/commons/mwapi/UploadStash.java new file mode 100644 index 000000000..f74b67298 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/UploadStash.java @@ -0,0 +1,70 @@ +package fr.free.nrw.commons.mwapi; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class UploadStash { + @NonNull + private String errorCode; + @NonNull + private String resultStatus; + @NonNull + private String filename; + @NonNull + private String filekey; + + @NonNull + public final String getErrorCode() { + return this.errorCode; + } + + @NonNull + public final String getResultStatus() { + return this.resultStatus; + } + + @NonNull + public final String getFilename() { + return this.filename; + } + + @NonNull + public final String getFilekey() { + return this.filekey; + } + + public UploadStash(@NonNull String errorCode, @NonNull String resultStatus, @NonNull String filename, @NonNull String filekey) { + this.errorCode = errorCode; + this.resultStatus = resultStatus; + this.filename = filename; + this.filekey = filekey; + } + + public String toString() { + return "UploadStash(errorCode=" + this.errorCode + ", resultStatus=" + this.resultStatus + ", filename=" + this.filename + ", filekey=" + this.filekey + ")"; + } + + public int hashCode() { + return ((this.errorCode.hashCode() * 31 + this.resultStatus.hashCode() + ) * 31 + this.filename.hashCode() + ) * 31 + this.filekey.hashCode(); + } + + public boolean equals(@Nullable Object obj) { + if (this != obj) { + if (obj instanceof UploadStash) { + UploadStash that = (UploadStash)obj; + if (this.errorCode.equals(that.errorCode) + && this.resultStatus.equals(that.resultStatus) + && this.filename.equals(that.filename) + && this.filekey.equals(that.filekey)) { + return true; + } + } + + return false; + } else { + return true; + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/notification/Notification.java b/app/src/main/java/fr/free/nrw/commons/notification/Notification.java index 8052cfaa7..fec22f34d 100644 --- a/app/src/main/java/fr/free/nrw/commons/notification/Notification.java +++ b/app/src/main/java/fr/free/nrw/commons/notification/Notification.java @@ -1,9 +1,5 @@ package fr.free.nrw.commons.notification; -import org.wikipedia.util.DateUtil; - -import fr.free.nrw.commons.utils.CommonsDateUtil; - /** * Created by root on 18.12.2017. */ @@ -12,44 +8,33 @@ public class Notification { public NotificationType notificationType; public String notificationText; public String date; + public String description; public String link; public String iconUrl; + public String dateWithYear; public String notificationId; - public Notification(NotificationType notificationType, - String notificationText, - String date, - String link, - String iconUrl, - String notificationId) { + public Notification(NotificationType notificationType, String notificationText, String date, String description, String link, String iconUrl, String dateWithYear, String notificationId) { this.notificationType = notificationType; this.notificationText = notificationText; this.date = date; + this.description = description; this.link = link; this.iconUrl = iconUrl; + this.dateWithYear = dateWithYear; this.notificationId=notificationId; } - public static Notification from(org.wikipedia.notifications.Notification wikiNotification) { - org.wikipedia.notifications.Notification.Contents contents = wikiNotification.getContents(); - String notificationLink = contents == null || contents.getLinks() == null - || contents.getLinks().getPrimary() == null ? "" : contents.getLinks().getPrimary().getUrl(); - return new Notification(NotificationType.UNKNOWN, - contents == null ? "" : contents.getCompactHeader(), - DateUtil.getMonthOnlyDateString(wikiNotification.getTimestamp()), - notificationLink, - "", - String.valueOf(wikiNotification.id())); - } - @Override public String toString() { return "Notification" + "notificationType='" + notificationType + '\'' + ", notificationText='" + notificationText + '\'' + ", date='" + date + '\'' + + ", description='" + description + '\'' + ", link='" + link + '\'' + ", iconUrl='" + iconUrl + '\'' + + ", dateWithYear=" + dateWithYear + ", notificationId='" + notificationId + '\'' + '}'; } diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificationActivity.java b/app/src/main/java/fr/free/nrw/commons/notification/NotificationActivity.java index e29ab2595..505de1682 100644 --- a/app/src/main/java/fr/free/nrw/commons/notification/NotificationActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/notification/NotificationActivity.java @@ -5,6 +5,11 @@ import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Bundle; +import androidx.constraintlayout.widget.ConstraintLayout; +import com.google.android.material.snackbar.Snackbar; +import androidx.recyclerview.widget.DividerItemDecoration; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; @@ -14,17 +19,10 @@ import android.widget.RelativeLayout; import android.widget.TextView; import android.widget.Toast; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.material.snackbar.Snackbar; import com.pedrogomez.renderers.RVRendererAdapter; import java.util.Collections; import java.util.List; -import java.util.concurrent.Callable; import javax.inject.Inject; @@ -36,9 +34,7 @@ import fr.free.nrw.commons.theme.NavigationBaseActivity; import fr.free.nrw.commons.utils.NetworkUtils; import fr.free.nrw.commons.utils.ViewUtil; import io.reactivex.Observable; -import io.reactivex.ObservableSource; import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; import timber.log.Timber; @@ -82,25 +78,25 @@ public class NotificationActivity extends NavigationBaseActivity { @SuppressLint("CheckResult") public void removeNotification(Notification notification) { - Disposable disposable = Observable.defer((Callable>) - () -> controller.markAsRead(notification)) + compositeDisposable.add(Observable.fromCallable(() -> controller.markAsRead(notification)) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(result -> { - if (result) { + if (result){ notificationList.remove(notification); setAdapter(notificationList); adapter.notifyDataSetChanged(); Snackbar snackbar = Snackbar - .make(relativeLayout, "Notification marked as read", Snackbar.LENGTH_LONG); + .make(relativeLayout,"Notification marked as read", Snackbar.LENGTH_LONG); snackbar.show(); - if (notificationList.size() == 0) { + if (notificationList.size()==0){ setEmptyView(); relativeLayout.setVisibility(View.GONE); no_notification.setVisibility(View.VISIBLE); } - } else { + } + else { adapter.notifyDataSetChanged(); setAdapter(notificationList); Toast.makeText(NotificationActivity.this, "There was some error!", Toast.LENGTH_SHORT).show(); @@ -111,8 +107,7 @@ public class NotificationActivity extends NavigationBaseActivity { throwable.printStackTrace(); ViewUtil.showShortSnackbar(relativeLayout, R.string.error_notifications); progressBar.setVisibility(View.GONE); - }); - compositeDisposable.add(disposable); + })); } @@ -145,8 +140,11 @@ public class NotificationActivity extends NavigationBaseActivity { private void addNotifications(boolean archived) { Timber.d("Add notifications"); if (mNotificationWorkerFragment == null) { - progressBar.setVisibility(View.VISIBLE); - compositeDisposable.add(controller.getNotifications(archived) + compositeDisposable.add(Observable.fromCallable(() -> { + progressBar.setVisibility(View.VISIBLE); + return controller.getNotifications(archived); + + }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(notificationList -> { diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificationClient.java b/app/src/main/java/fr/free/nrw/commons/notification/NotificationClient.java deleted file mode 100644 index 8643ceaf4..000000000 --- a/app/src/main/java/fr/free/nrw/commons/notification/NotificationClient.java +++ /dev/null @@ -1,45 +0,0 @@ -package fr.free.nrw.commons.notification; - -import org.wikipedia.csrf.CsrfTokenClient; -import org.wikipedia.dataclient.Service; - -import java.util.List; - -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Singleton; - -import io.reactivex.Observable; -import io.reactivex.Single; - -import static fr.free.nrw.commons.di.NetworkingModule.NAMED_COMMONS_CSRF; - -@Singleton -public class NotificationClient { - - private final Service service; - private final CsrfTokenClient csrfTokenClient; - - @Inject - public NotificationClient(@Named("commons-service") Service service, @Named(NAMED_COMMONS_CSRF) CsrfTokenClient csrfTokenClient) { - this.service = service; - this.csrfTokenClient = csrfTokenClient; - } - - public Single> getNotifications(boolean archived) { - return service.getAllNotifications("wikidatawiki|commonswiki|enwiki", archived ? "read" : "!read", null) - .map(mwQueryResponse -> mwQueryResponse.query().notifications().list()) - .flatMap(Observable::fromIterable) - .map(notification -> Notification.from(notification)) - .toList(); - } - - public Observable markNotificationAsRead(String notificationId) { - try { - return service.markRead(csrfTokenClient.getTokenBlocking(), notificationId, "") - .map(mwQueryResponse -> mwQueryResponse.success()); - } catch (Throwable throwable) { - return Observable.just(false); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificationController.java b/app/src/main/java/fr/free/nrw/commons/notification/NotificationController.java index 5d0ac010b..112735127 100644 --- a/app/src/main/java/fr/free/nrw/commons/notification/NotificationController.java +++ b/app/src/main/java/fr/free/nrw/commons/notification/NotificationController.java @@ -1,12 +1,14 @@ package fr.free.nrw.commons.notification; +import java.io.IOException; +import java.util.ArrayList; import java.util.List; import javax.inject.Inject; import javax.inject.Singleton; -import io.reactivex.Observable; -import io.reactivex.Single; +import fr.free.nrw.commons.auth.SessionManager; +import fr.free.nrw.commons.mwapi.MediaWikiApi; /** * Created by root on 19.12.2017. @@ -14,19 +16,27 @@ import io.reactivex.Single; @Singleton public class NotificationController { - private NotificationClient notificationClient; - + private MediaWikiApi mediaWikiApi; + private SessionManager sessionManager; @Inject - public NotificationController(NotificationClient notificationClient) { - this.notificationClient = notificationClient; + public NotificationController(MediaWikiApi mediaWikiApi, SessionManager sessionManager) { + this.mediaWikiApi = mediaWikiApi; + this.sessionManager = sessionManager; } - public Single> getNotifications(boolean archived) { - return notificationClient.getNotifications(archived); + public List getNotifications(boolean archived) throws IOException { + if (mediaWikiApi.validateLogin()) { + return mediaWikiApi.getNotifications(archived); + } else { + Boolean authTokenValidated = sessionManager.revalidateAuthToken(); + if (authTokenValidated != null && authTokenValidated) { + return mediaWikiApi.getNotifications(archived); + } + } + return new ArrayList<>(); } - - Observable markAsRead(Notification notification) { - return notificationClient.markNotificationAsRead(notification.notificationId); + public boolean markAsRead(Notification notification) throws IOException{ + return mediaWikiApi.markNotificationAsRead(notification); } } diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificationUtils.java b/app/src/main/java/fr/free/nrw/commons/notification/NotificationUtils.java new file mode 100644 index 000000000..1c92103cd --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/notification/NotificationUtils.java @@ -0,0 +1,293 @@ +package fr.free.nrw.commons.notification; + +import android.annotation.SuppressLint; +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import java.util.ArrayList; +import java.util.List; + +import fr.free.nrw.commons.BuildConfig; +import fr.free.nrw.commons.R; + +import static fr.free.nrw.commons.notification.NotificationType.UNKNOWN; + +public class NotificationUtils { + + private static final String COMMONS_WIKI = "commonswiki"; + private static final String WIKIDATA_WIKI = "wikidatawiki"; + private static final String WIKIPEDIA_WIKI = "enwiki"; + + /** + * Returns true if the wiki attribute corresponds to commonswiki + * @param document + * @return boolean representing whether the wiki attribute corresponds to commonswiki + */ + public static boolean isCommonsNotification(Node document) { + if (document == null || !document.hasAttributes()) { + return false; + } + Element element = (Element) document; + return COMMONS_WIKI.equals(element.getAttribute("wiki")); + } + + /** + * Returns true if the wiki attribute corresponds to wikidatawiki + * @param document + * @return boolean representing whether the wiki attribute corresponds to wikidatawiki + */ + public static boolean isWikidataNotification(Node document) { + if (document == null || !document.hasAttributes()) { + return false; + } + Element element = (Element) document; + return WIKIDATA_WIKI.equals(element.getAttribute("wiki")); + } + + /** + * Returns true if the wiki attribute corresponds to enwiki + * @param document + * @return + */ + public static boolean isWikipediaNotification(Node document) { + if (document == null || !document.hasAttributes()) { + return false; + } + Element element = (Element) document; + return WIKIPEDIA_WIKI.equals(element.getAttribute("wiki")); + } + + /** + * Returns document notification type + * @param document + * @return the document's NotificationType + */ + public static NotificationType getNotificationType(Node document) { + Element element = (Element) document; + String type = element.getAttribute("type"); + return NotificationType.handledValueOf(type); + } + + public static String getNotificationId(Node document) { + Element element = (Element) document; + return element.getAttribute("id"); + } + + public static List getNotificationsFromBundle(Context context, Node document) { + Element bundledNotifications = getBundledNotifications(document); + NodeList childNodes = bundledNotifications.getChildNodes(); + + List notifications = new ArrayList<>(); + for (int i = 0; i < childNodes.getLength(); i++) { + Node node = childNodes.item(i); + if (isUsefulNotification(node)) { + notifications.add(getNotificationFromApiResult(context, node)); + } + } + return notifications; + } + + @NonNull + public static List getNotificationsFromList(Context context, NodeList childNodes) { + List notifications = new ArrayList<>(); + for (int i = 0; i < childNodes.getLength(); i++) { + Node node = childNodes.item(i); + if (isUsefulNotification(node)) { + if (isBundledNotification(node)) { + notifications.addAll(getNotificationsFromBundle(context, node)); + } else { + notifications.add(getNotificationFromApiResult(context, node)); + } + } + } + + return notifications; + } + + /** + * Currently the app is interested in showing notifications just from the following three wikis: commons, wikidata, wikipedia + * This function returns true only if the notification belongs to any of the above wikis and is of a known notification type + * @param node + * @return whether a notification is from one of Commons, Wikidata or Wikipedia + */ + private static boolean isUsefulNotification(Node node) { + return (isCommonsNotification(node) + || isWikidataNotification(node) + || isWikipediaNotification(node)) + && !getNotificationType(node).equals(UNKNOWN); + } + + public static boolean isBundledNotification(Node document) { + Element bundleElement = getBundledNotifications(document); + if (bundleElement == null) { + return false; + } + + return bundleElement.getChildNodes().getLength() > 0; + } + + private static Element getBundledNotifications(Node document) { + return (Element) getNode(document, "bundledNotifications"); + } + + public static Notification getNotificationFromApiResult(Context context, Node document) { + NotificationType type = getNotificationType(document); + + String notificationText = ""; + String link = getPrimaryLink(document); + String description = getNotificationDescription(document); + String iconUrl = getNotificationIconUrl(document); + + switch (type) { + case THANK_YOU_EDIT: + notificationText = getThankYouEditDescription(document); + break; + case EDIT_USER_TALK: + notificationText = getNotificationText(document); + break; + case MENTION: + notificationText = getMentionMessage(context, document); + description = getMentionDescription(document); + break; + case WELCOME: + notificationText = getWelcomeMessage(context, document); + break; + } + return new Notification(type, notificationText, getTimestamp(document), description, link, iconUrl, getTimestampWithYear(document), + getNotificationId(document)); + } + + private static String getNotificationText(Node document) { + String notificationBody = getNotificationBody(document); + if (notificationBody == null || notificationBody.trim().equals("")) { + return getNotificationHeader(document); + } + return notificationBody; + } + + private static String getNotificationHeader(Node document) { + Node body = getNode(getModel(document), "header"); + if (body != null) { + String textContent = body.getTextContent(); + return textContent.replace("", "").replace("", ""); + } else { + return ""; + } + } + + private static String getNotificationBody(Node document) { + Node body = getNode(getModel(document), "body"); + if (body != null) { + String textContent = body.getTextContent(); + return textContent.replace("", "").replace("", ""); + } else { + return ""; + } + } + + private static String getMentionDescription(Node document) { + Node body = getNode(getModel(document), "body"); + return body != null ? body.getTextContent() : ""; + } + + /** + * Gets the header node returned in the XML document to form the description for thank you edits + * @param document + * @return + */ + private static String getThankYouEditDescription(Node document) { + Node body = getNode(getModel(document), "header"); + return body != null ? body.getTextContent() : ""; + } + + private static String getNotificationIconUrl(Node document) { + String format = "%s%s"; + Node iconUrl = getNode(getModel(document), "iconUrl"); + if (iconUrl == null) { + return null; + } else { + String url = iconUrl.getTextContent(); + return String.format(format, BuildConfig.COMMONS_URL, url); + } + } + + public static String getMentionMessage(Context context, Node document) { + String format = context.getString(R.string.notifications_mention); + return String.format(format, getAgent(document), getNotificationDescription(document)); + } + + @SuppressLint("StringFormatMatches") + public static String getUserTalkMessage(Context context, Node document) { + String format = context.getString(R.string.notifications_talk_page_message); + return String.format(format, getAgent(document)); + } + + @SuppressLint("StringFormatInvalid") + public static String getWelcomeMessage(Context context, Node document) { + String welcomeMessageFormat = context.getString(R.string.notifications_welcome); + return String.format(welcomeMessageFormat, getAgent(document)); + } + + private static String getPrimaryLink(Node document) { + Node links = getNode(getModel(document), "links"); + Element primaryLink = (Element) getNode(links, "primary"); + if (primaryLink != null) { + return primaryLink.getAttribute("url"); + } + return ""; + } + + private static Node getModel(Node document) { + return getNode(document, "_.2A."); + } + + private static String getAgent(Node document) { + Element agentElement = (Element) getNode(document, "agent"); + if (agentElement != null) { + return agentElement.getAttribute("name"); + } + return ""; + } + + private static String getTimestamp(Node document) { + Element timestampElement = (Element) getNode(document, "timestamp"); + if (timestampElement != null) { + return timestampElement.getAttribute("date"); + } + return ""; + } + + private static String getTimestampWithYear(Node document) { + Element timestampElement = (Element) getNode(document, "timestamp"); + if (timestampElement != null) { + return timestampElement.getAttribute("utcunix"); + } + return ""; + } + + private static String getNotificationDescription(Node document) { + Element titleElement = (Element) getNode(document, "title"); + if (titleElement != null) { + return titleElement.getAttribute("text"); + } + return ""; + } + + @Nullable + public static Node getNode(Node node, String nodeName) { + NodeList childNodes = node.getChildNodes(); + for (int i = 0; i < childNodes.getLength(); i++) { + Node nodeItem = childNodes.item(i); + Element item = (Element) nodeItem; + if (item.getTagName().equals(nodeName)) { + return nodeItem; + } + } + return null; + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.java b/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.java index 2f33d1f03..b067d7ef1 100644 --- a/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.java @@ -26,16 +26,16 @@ import butterknife.BindView; 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.delete.DeleteHelper; import fr.free.nrw.commons.mwapi.MediaWikiApi; -import fr.free.nrw.commons.theme.NavigationBaseActivity; import fr.free.nrw.commons.utils.DialogUtil; import fr.free.nrw.commons.utils.ViewUtil; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; import io.reactivex.schedulers.Schedulers; -public class ReviewActivity extends NavigationBaseActivity { +public class ReviewActivity extends AuthenticatedActivity { @BindView(R.id.pager_indicator_review) public CirclePageIndicator pagerIndicator; @@ -94,6 +94,15 @@ public class ReviewActivity extends NavigationBaseActivity { return media; } + @Override + protected void onAuthCookieAcquired(String authCookie) { + + } + + @Override + protected void onAuthFailure() { + } + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewController.java b/app/src/main/java/fr/free/nrw/commons/review/ReviewController.java index 1aaeb6a81..c98c7ed3e 100644 --- a/app/src/main/java/fr/free/nrw/commons/review/ReviewController.java +++ b/app/src/main/java/fr/free/nrw/commons/review/ReviewController.java @@ -4,6 +4,8 @@ import android.annotation.SuppressLint; import android.app.Activity; import android.app.NotificationManager; import android.content.Context; +import android.view.Gravity; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -11,23 +13,17 @@ import androidx.core.app.NotificationCompat; import org.wikipedia.dataclient.mwapi.MwQueryPage; -import java.util.ArrayList; -import java.util.concurrent.Callable; - import javax.inject.Inject; -import javax.inject.Named; import javax.inject.Singleton; import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.Media; import fr.free.nrw.commons.R; -import fr.free.nrw.commons.actions.PageEditClient; -import fr.free.nrw.commons.actions.ThanksClient; +import fr.free.nrw.commons.auth.SessionManager; import fr.free.nrw.commons.delete.DeleteHelper; import fr.free.nrw.commons.di.ApplicationlessInjection; -import fr.free.nrw.commons.utils.ViewUtil; +import fr.free.nrw.commons.mwapi.MediaWikiApi; import io.reactivex.Observable; -import io.reactivex.ObservableSource; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.schedulers.Schedulers; import timber.log.Timber; @@ -36,15 +32,13 @@ import timber.log.Timber; public class ReviewController { private static final int NOTIFICATION_SEND_THANK = 0x102; private static final int NOTIFICATION_CHECK_CATEGORY = 0x101; - protected static ArrayList categories; - @Inject - ThanksClient thanksClient; private final DeleteHelper deleteHelper; @Nullable MwQueryPage.Revision firstRevision; // TODO: maybe we can expand this class to include fileName @Inject - @Named("commons-page-edit") - PageEditClient pageEditClient; + MediaWikiApi mwApi; + @Inject + SessionManager sessionManager; private NotificationManager notificationManager; private NotificationCompat.Builder notificationBuilder; private Media media; @@ -95,16 +89,40 @@ public class ReviewController { .getCommonsApplicationComponent() .inject(this); - ViewUtil.showShortToast(context, context.getString(R.string.check_category_toast, media.getDisplayTitle())); + Toast toast = new Toast(context); + toast.setGravity(Gravity.CENTER, 0, 0); + toast = Toast.makeText(context, context.getString(R.string.check_category_toast, media.getDisplayTitle()), Toast.LENGTH_SHORT); + toast.show(); - publishProgress(context, 0); - String summary = context.getString(R.string.check_category_edit_summary); - Observable.defer((Callable>) () -> - pageEditClient.appendEdit(media.getFilename(), "\n{{subst:chc}}\n", summary)) + Observable.fromCallable(() -> { + publishProgress(context, 0); + + String editToken; + String authCookie; + String summary = context.getString(R.string.check_category_edit_summary); + + authCookie = sessionManager.getAuthCookie(); + mwApi.setAuthCookie(authCookie); + + try { + editToken = mwApi.getEditToken(); + + if (editToken == null) { + return false; + } + publishProgress(context, 1); + + mwApi.appendEdit(editToken, "\n{{subst:chc}}\n", media.getFilename(), summary); + publishProgress(context, 2); + } catch (Exception e) { + Timber.d(e); + return false; + } + return true; + }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe((result) -> { - publishProgress(context, 2); String message; String title; @@ -118,7 +136,15 @@ public class ReviewController { reviewCallback.onFailure(); } - showNotification(title, message); + notificationBuilder.setDefaults(NotificationCompat.DEFAULT_ALL) + .setContentTitle(title) + .setStyle(new NotificationCompat.BigTextStyle() + .bigText(message)) + .setSmallIcon(R.drawable.ic_launcher) + .setProgress(0, 0, false) + .setOngoing(false) + .setPriority(NotificationCompat.PRIORITY_HIGH); + notificationManager.notify(NOTIFICATION_CHECK_CATEGORY, notificationBuilder.build()); }, Timber::e); } @@ -146,20 +172,39 @@ public class ReviewController { .getInstance(context) .getCommonsApplicationComponent() .inject(this); - ViewUtil.showShortToast(context, context.getString(R.string.send_thank_toast, media.getDisplayTitle())); + Toast toast = new Toast(context); + toast.setGravity(Gravity.CENTER, 0, 0); + toast = Toast.makeText(context, context.getString(R.string.send_thank_toast, media.getDisplayTitle()), Toast.LENGTH_SHORT); + toast.show(); - publishProgress(context, 0); - if (firstRevision == null) { - return; - } + Observable.fromCallable(() -> { + publishProgress(context, 0); - Observable.defer((Callable>) () -> thanksClient.thank(firstRevision.getRevisionId())) + String editToken; + String authCookie; + authCookie = sessionManager.getAuthCookie(); + mwApi.setAuthCookie(authCookie); + + try { + editToken = mwApi.getEditToken(); + if (editToken == null) { + return false; + } + publishProgress(context, 1); + assert firstRevision != null; + mwApi.thank(editToken, firstRevision.getRevisionId()); + publishProgress(context, 2); + } catch (Exception e) { + Timber.d(e); + return false; + } + return true; + }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe((result) -> { - publishProgress(context, 2); - String message; - String title; + String message = ""; + String title = ""; if (result) { title = context.getString(R.string.send_thank_success_title); message = context.getString(R.string.send_thank_success_message, media.getDisplayTitle()); @@ -168,23 +213,19 @@ public class ReviewController { message = context.getString(R.string.send_thank_failure_message, media.getDisplayTitle()); } - showNotification(title, message); + notificationBuilder.setDefaults(NotificationCompat.DEFAULT_ALL) + .setContentTitle(title) + .setStyle(new NotificationCompat.BigTextStyle() + .bigText(message)) + .setSmallIcon(R.drawable.ic_launcher) + .setProgress(0, 0, false) + .setOngoing(false) + .setPriority(NotificationCompat.PRIORITY_HIGH); + notificationManager.notify(NOTIFICATION_SEND_THANK, notificationBuilder.build()); }, Timber::e); } - private void showNotification(String title, String message) { - notificationBuilder.setDefaults(NotificationCompat.DEFAULT_ALL) - .setContentTitle(title) - .setStyle(new NotificationCompat.BigTextStyle() - .bigText(message)) - .setSmallIcon(R.drawable.ic_launcher) - .setProgress(0, 0, false) - .setOngoing(false) - .setPriority(NotificationCompat.PRIORITY_HIGH); - notificationManager.notify(NOTIFICATION_SEND_THANK, notificationBuilder.build()); - } - public interface ReviewCallback { void onSuccess(); diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewHelper.java b/app/src/main/java/fr/free/nrw/commons/review/ReviewHelper.java index 45880a160..03f67bfcf 100644 --- a/app/src/main/java/fr/free/nrw/commons/review/ReviewHelper.java +++ b/app/src/main/java/fr/free/nrw/commons/review/ReviewHelper.java @@ -14,7 +14,6 @@ import javax.inject.Inject; import javax.inject.Singleton; import fr.free.nrw.commons.Media; -import fr.free.nrw.commons.media.MediaClient; import fr.free.nrw.commons.mwapi.MediaWikiApi; import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; import io.reactivex.Observable; @@ -25,21 +24,24 @@ public class ReviewHelper { private static final String[] imageExtensions = new String[]{".jpg", ".jpeg", ".png"}; - private final MediaClient mediaClient; + private final OkHttpJsonApiClient okHttpJsonApiClient; + private final MediaWikiApi mediaWikiApi; private final ReviewInterface reviewInterface; @Inject - public ReviewHelper(MediaClient mediaClient, ReviewInterface reviewInterface) { - this.mediaClient = mediaClient; + public ReviewHelper(OkHttpJsonApiClient okHttpJsonApiClient, + MediaWikiApi mediaWikiApi, + ReviewInterface reviewInterface) { + this.okHttpJsonApiClient = okHttpJsonApiClient; + this.mediaWikiApi = mediaWikiApi; this.reviewInterface = reviewInterface; } /** - * Fetches recent changes from MediaWiki AP + * Fetches recent changes from MediaWiki API * Calls the API to get 10 changes in the last 1 hour * Earlier we were getting changes for the last 30 days but as the API returns just 10 results * its best to fetch for just last 1 hour. - * * @return */ private Observable getRecentChanges() { @@ -79,25 +81,23 @@ public class ReviewHelper { /** * Returns a proper Media object if the file is not already nominated for deletion * Else it returns an empty Media object - * * @param recentChange * @return */ private Single getRandomMediaFromRecentChange(RecentChange recentChange) { return Single.just(recentChange) - .flatMap(change -> mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + change.getTitle())) + .flatMap(change -> mediaWikiApi.pageExists("Commons:Deletion_requests/" + change.getTitle())) .flatMap(isDeleted -> { if (isDeleted) { return Single.just(new Media("")); } - return mediaClient.getMedia(recentChange.getTitle()); + return okHttpJsonApiClient.getMedia(recentChange.getTitle(), false); }); } /** * Gets the first revision of the file from filename - * * @param filename * @return */ @@ -110,7 +110,6 @@ public class ReviewHelper { * Checks if the change is reviewable or not. * - checks the type and revisionId of the change * - checks supported image extensions - * * @param recentChange * @return */ diff --git a/app/src/main/java/fr/free/nrw/commons/upload/FileUtils.java b/app/src/main/java/fr/free/nrw/commons/upload/FileUtils.java index 1770bb3e2..9e4c9d334 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/FileUtils.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/FileUtils.java @@ -140,7 +140,7 @@ public class FileUtils { return mimeType; } - static String getFileExt(String fileName) { + public static String getFileExt(String fileName) { //Default filePath extension String extension = ".jpg"; @@ -151,7 +151,7 @@ public class FileUtils { return extension; } - static FileInputStream getFileInputStream(String filePath) throws FileNotFoundException { + public static FileInputStream getFileInputStream(String filePath) throws FileNotFoundException { return new FileInputStream(filePath); } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/ImageProcessingService.java b/app/src/main/java/fr/free/nrw/commons/upload/ImageProcessingService.java index 3baaf9959..a397823dc 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/ImageProcessingService.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/ImageProcessingService.java @@ -10,7 +10,6 @@ import java.io.IOException; import javax.inject.Inject; import javax.inject.Singleton; -import fr.free.nrw.commons.media.MediaClient; import fr.free.nrw.commons.mwapi.MediaWikiApi; import fr.free.nrw.commons.nearby.Place; import fr.free.nrw.commons.utils.ImageUtils; @@ -35,20 +34,18 @@ public class ImageProcessingService { private final MediaWikiApi mwApi; private final ReadFBMD readFBMD; private final EXIFReader EXIFReader; - private final MediaClient mediaClient; private final Context context; @Inject public ImageProcessingService(FileUtilsWrapper fileUtilsWrapper, ImageUtilsWrapper imageUtilsWrapper, MediaWikiApi mwApi, ReadFBMD readFBMD, EXIFReader EXIFReader, - MediaClient mediaClient, Context context) { + Context context) { this.fileUtilsWrapper = fileUtilsWrapper; this.imageUtilsWrapper = imageUtilsWrapper; this.mwApi = mwApi; this.readFBMD = readFBMD; this.EXIFReader = EXIFReader; - this.mediaClient = mediaClient; this.context = context; } @@ -131,7 +128,7 @@ public class ImageProcessingService { return Single.just(EMPTY_TITLE); } - return mediaClient.checkPageExistsUsingTitle("File:" + uploadItem.getFileName()) + return Single.fromCallable(() -> mwApi.fileExistsWithName(uploadItem.getFileName())) .map(doesFileExist -> { Timber.d("Result for valid title is %s", doesFileExist); return doesFileExist ? FILE_NAME_EXISTS : IMAGE_OK; @@ -149,7 +146,7 @@ public class ImageProcessingService { return Single.fromCallable(() -> fileUtilsWrapper.getFileInputStream(filePath)) .map(fileUtilsWrapper::getSHA1) - .flatMap(mediaClient::checkFileExistsUsingSha) + .map(mwApi::existingFile) .map(b -> { Timber.d("Result for duplicate image %s", b); return b ? ImageUtils.IMAGE_DUPLICATE : ImageUtils.IMAGE_OK; diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.java deleted file mode 100644 index 87f1b69ed..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.java +++ /dev/null @@ -1,67 +0,0 @@ -package fr.free.nrw.commons.upload; - -import android.content.Context; -import android.net.Uri; - -import org.wikipedia.csrf.CsrfTokenClient; - -import java.io.File; - -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Singleton; - -import fr.free.nrw.commons.contributions.Contribution; -import io.reactivex.Observable; -import okhttp3.MediaType; -import okhttp3.MultipartBody; -import okhttp3.RequestBody; - -import static fr.free.nrw.commons.di.NetworkingModule.NAMED_COMMONS_CSRF; - -@Singleton -public class UploadClient { - - private final UploadInterface uploadInterface; - private final CsrfTokenClient csrfTokenClient; - - @Inject - public UploadClient(UploadInterface uploadInterface, @Named(NAMED_COMMONS_CSRF) CsrfTokenClient csrfTokenClient) { - this.uploadInterface = uploadInterface; - this.csrfTokenClient = csrfTokenClient; - } - - Observable uploadFileToStash(Context context, String filename, File file) { - RequestBody requestFile = RequestBody - .create(MediaType.parse(FileUtils.getMimeType(context, Uri.parse(file.getPath()))), file); - - MultipartBody.Part filePart = MultipartBody.Part.createFormData("file", filename, requestFile); - RequestBody fileNameRequestBody = RequestBody.create(okhttp3.MultipartBody.FORM, filename); - RequestBody tokenRequestBody; - try { - tokenRequestBody = RequestBody.create(MultipartBody.FORM, csrfTokenClient.getTokenBlocking()); - return uploadInterface.uploadFileToStash(fileNameRequestBody, tokenRequestBody, filePart) - .map(stashUploadResponse -> stashUploadResponse.getUpload()); - } catch (Throwable throwable) { - throwable.printStackTrace(); - return Observable.error(throwable); - } - } - - Observable uploadFileFromStash(Context context, - Contribution contribution, - String uniqueFileName, - String fileKey) { - try { - return uploadInterface - .uploadFileFromStash(csrfTokenClient.getTokenBlocking(), - contribution.getPageContents(context), - contribution.getEditSummary(), - uniqueFileName, - fileKey).map(uploadResponse -> uploadResponse.getUpload()); - } catch (Throwable throwable) { - throwable.printStackTrace(); - return Observable.error(throwable); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadInterface.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadInterface.java deleted file mode 100644 index af3ffd77d..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadInterface.java +++ /dev/null @@ -1,34 +0,0 @@ -package fr.free.nrw.commons.upload; - -import androidx.annotation.NonNull; - -import io.reactivex.Observable; -import okhttp3.MultipartBody; -import okhttp3.RequestBody; -import retrofit2.http.Field; -import retrofit2.http.FormUrlEncoded; -import retrofit2.http.Headers; -import retrofit2.http.Multipart; -import retrofit2.http.POST; -import retrofit2.http.Part; - -import static org.wikipedia.dataclient.Service.MW_API_PREFIX; - -public interface UploadInterface { - - @Multipart - @POST(MW_API_PREFIX + "action=upload&stash=1&ignorewarnings=1") - Observable uploadFileToStash(@Part("filename") RequestBody filename, - @Part("token") RequestBody token, - @Part MultipartBody.Part filePart); - - @Headers("Cache-Control: no-cache") - @POST(MW_API_PREFIX + "action=upload&ignorewarnings=1") - @FormUrlEncoded - @NonNull - Observable uploadFileFromStash(@NonNull @Field("token") String token, - @NonNull @Field("text") String text, - @NonNull @Field("comment") String comment, - @NonNull @Field("filename") String filename, - @NonNull @Field("filekey") String filekey); -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadResponse.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadResponse.java deleted file mode 100644 index 80d612c69..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package fr.free.nrw.commons.upload; - -public class UploadResponse { - private final UploadResult upload; - - public UploadResponse(UploadResult upload) { - this.upload = upload; - } - - public UploadResult getUpload() { - return upload; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadResult.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadResult.java deleted file mode 100644 index 1dea597d4..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadResult.java +++ /dev/null @@ -1,39 +0,0 @@ -package fr.free.nrw.commons.upload; - -import org.wikipedia.gallery.ImageInfo; - -public class UploadResult { - private final String result; - private final String filekey; - private final String filename; - private final String sessionkey; - private final ImageInfo imageinfo; - - public UploadResult(String result, String filekey, String filename, String sessionkey, ImageInfo imageinfo) { - this.result = result; - this.filekey = filekey; - this.filename = filename; - this.sessionkey = sessionkey; - this.imageinfo = imageinfo; - } - - public String getResult() { - return result; - } - - public String getFilekey() { - return filekey; - } - - public String getSessionkey() { - return sessionkey; - } - - public ImageInfo getImageinfo() { - return imageinfo; - } - - public String getFilename() { - return filename; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadService.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadService.java index c9e5a2ee8..39addbdee 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadService.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadService.java @@ -8,10 +8,9 @@ import android.content.Intent; import android.graphics.BitmapFactory; import android.net.Uri; import android.os.Bundle; -import android.widget.Toast; - import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; +import android.widget.Toast; import java.io.File; import java.io.FileInputStream; @@ -34,11 +33,9 @@ import fr.free.nrw.commons.contributions.Contribution; import fr.free.nrw.commons.contributions.ContributionDao; import fr.free.nrw.commons.contributions.ContributionsContentProvider; import fr.free.nrw.commons.contributions.MainActivity; -import fr.free.nrw.commons.media.MediaClient; import fr.free.nrw.commons.mwapi.MediaWikiApi; -import fr.free.nrw.commons.utils.CommonsDateUtil; import fr.free.nrw.commons.wikidata.WikidataEditService; -import io.reactivex.Observable; +import io.reactivex.Single; import io.reactivex.schedulers.Schedulers; import timber.log.Timber; @@ -57,8 +54,6 @@ public class UploadService extends HandlerService { @Inject WikidataEditService wikidataEditService; @Inject SessionManager sessionManager; @Inject ContributionDao contributionDao; - @Inject UploadClient uploadClient; - @Inject MediaClient mediaClient; private NotificationManagerCompat notificationManager; private NotificationCompat.Builder curNotification; @@ -209,9 +204,9 @@ public class UploadService extends HandlerService { return; } String notificationTag = localUri.toString(); - File file1; + try { - file1 = new File(localUri.getPath()); + File file1 = new File(localUri.getPath()); fileInputStream = new FileInputStream(file1); } catch (FileNotFoundException e) { Timber.d("File not found"); @@ -234,8 +229,22 @@ public class UploadService extends HandlerService { contribution ); - Observable.fromCallable(() -> "Temp_" + contribution.hashCode() + filename) - .flatMap(stashFilename -> uploadClient.uploadFileToStash(getApplicationContext(), stashFilename, file1)) + Single.fromCallable(() -> { + if (!mwApi.validateLogin()) { + // Need to revalidate! + if (sessionManager.revalidateAuthToken()) { + Timber.d("Successfully revalidated token!"); + } else { + Timber.d("Unable to revalidate :("); + stopForeground(true); + sessionManager.forceLogin(UploadService.this); + throw new RuntimeException(getString(R.string.authentication_failed)); + } + } + return "Temp_" + contribution.hashCode() + filename; + }).flatMap(stashFilename -> mwApi.uploadFile( + stashFilename, fileInputStream, contribution.getDataLength(), + localUri, contribution.getContentProviderUri(), notificationUpdater)) .subscribeOn(Schedulers.io()) .observeOn(Schedulers.io()) .doFinally(() -> { @@ -254,20 +263,22 @@ public class UploadService extends HandlerService { Timber.d("Stash upload response 1 is %s", uploadStash.toString()); - String resultStatus = uploadStash.getResult(); + String resultStatus = uploadStash.getResultStatus(); if (!resultStatus.equals("Success")) { Timber.d("Contribution upload failed. Wikidata entity won't be edited"); showFailedNotification(contribution); - return Observable.never(); + return Single.never(); } else { - Timber.d("making sure of uniqueness of name: %s", filename); - String uniqueFilename = findUniqueFilename(filename); - unfinishedUploads.add(uniqueFilename); - return uploadClient.uploadFileFromStash( - getApplicationContext(), - contribution, - uniqueFilename, - uploadStash.getFilekey()); + synchronized (unfinishedUploads) { + Timber.d("making sure of uniqueness of name: %s", filename); + String uniqueFilename = findUniqueFilename(filename); + unfinishedUploads.add(uniqueFilename); + return mwApi.uploadFileFinalize( + uniqueFilename, + uploadStash.getFilekey(), + contribution.getPageContents(getApplicationContext()), + contribution.getEditSummary()); + } } }) .subscribe(uploadResult -> { @@ -275,20 +286,19 @@ public class UploadService extends HandlerService { notificationManager.cancel(notificationTag, NOTIFICATION_UPLOAD_IN_PROGRESS); - String resultStatus = uploadResult.getResult(); + String resultStatus = uploadResult.getResultStatus(); if (!resultStatus.equals("Success")) { Timber.d("Contribution upload failed. Wikidata entity won't be edited"); showFailedNotification(contribution); } else { - String canonicalFilename = "File:" + uploadResult.getFilename(); + String canonicalFilename = uploadResult.getCanonicalFilename(); Timber.d("Contribution upload success. Initiating Wikidata edit for entity id %s", contribution.getWikiDataEntityId()); wikidataEditService.createClaimWithLogging(contribution.getWikiDataEntityId(), canonicalFilename); contribution.setFilename(canonicalFilename); - contribution.setImageUrl(uploadResult.getImageinfo().getOriginalUrl()); + contribution.setImageUrl(uploadResult.getImageUrl()); contribution.setState(Contribution.STATE_COMPLETED); - contribution.setDateUploaded(CommonsDateUtil.getIso8601DateFormatShort() - .parse(uploadResult.getImageinfo().getTimestamp())); + contribution.setDateUploaded(uploadResult.getDateUploaded()); contributionDao.save(contribution); } }, throwable -> { @@ -327,7 +337,7 @@ public class UploadService extends HandlerService { sequenceFileName = regexMatcher.replaceAll("$1 " + sequenceNumber + "$2"); } } - if (!mediaClient.checkPageExistsUsingTitle(sequenceFileName).blockingGet() + if (!mwApi.fileExistsWithName(sequenceFileName) && !unfinishedUploads.contains(sequenceFileName)) { break; } diff --git a/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.java b/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.java index 719e226cf..4174fa68b 100644 --- a/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.java +++ b/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.java @@ -27,7 +27,6 @@ import androidx.annotation.Nullable; import fr.free.nrw.commons.R; import fr.free.nrw.commons.contributions.MainActivity; import fr.free.nrw.commons.di.ApplicationlessInjection; -import fr.free.nrw.commons.media.MediaClient; import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; @@ -43,7 +42,7 @@ public class PicOfDayAppWidget extends AppWidgetProvider { private CompositeDisposable compositeDisposable = new CompositeDisposable(); - @Inject MediaClient mediaClient; + @Inject OkHttpJsonApiClient okHttpJsonApiClient; void updateAppWidget(Context context, AppWidgetManager appWidgetManager, int appWidgetId) { RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.pic_of_day_app_widget); @@ -68,7 +67,7 @@ public class PicOfDayAppWidget extends AppWidgetProvider { RemoteViews views, AppWidgetManager appWidgetManager, int appWidgetId) { - compositeDisposable.add(mediaClient.getPictureOfTheDay() + compositeDisposable.add(okHttpJsonApiClient.getPictureOfTheDay() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataClient.java b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataClient.java deleted file mode 100644 index 74c05564c..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataClient.java +++ /dev/null @@ -1,40 +0,0 @@ -package fr.free.nrw.commons.wikidata; - -import org.wikipedia.csrf.CsrfTokenClient; -import org.wikipedia.dataclient.Service; - -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Singleton; - -import io.reactivex.Observable; - -import static fr.free.nrw.commons.di.NetworkingModule.NAMED_WIKI_DATA_CSRF; - -@Singleton -public class WikidataClient { - - private final Service service; - private final CsrfTokenClient csrfTokenClient; - - @Inject - public WikidataClient(@Named("wikidata-service") Service service, - @Named(NAMED_WIKI_DATA_CSRF) CsrfTokenClient csrfTokenClient) { - this.service = service; - this.csrfTokenClient = csrfTokenClient; - } - - public Observable createClaim(String entityId, String property, String snaktype, String value) { - try { - return service.postCreateClaim(entityId, snaktype, property, value, "en", csrfTokenClient.getTokenBlocking()) - .map(mwPostResponse -> { - if (mwPostResponse.getSuccessVal() == 1) { - return 1L; - } - return -1L; - }); - } catch (Throwable throwable) { - return Observable.just(-1L); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java index 8bb753cf9..eac1f7cde 100644 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java @@ -10,10 +10,10 @@ import javax.inject.Named; import javax.inject.Singleton; import fr.free.nrw.commons.R; -import fr.free.nrw.commons.actions.PageEditClient; import fr.free.nrw.commons.kvstore.JsonKvStore; import fr.free.nrw.commons.mwapi.MediaWikiApi; import fr.free.nrw.commons.utils.ViewUtil; +import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.schedulers.Schedulers; import timber.log.Timber; @@ -26,26 +26,20 @@ import timber.log.Timber; @Singleton public class WikidataEditService { - private final static String COMMONS_APP_TAG = "wikimedia-commons-app"; - private final static String COMMONS_APP_EDIT_REASON = "Add tag for edits made using Android Commons app"; - private final Context context; + private final MediaWikiApi mediaWikiApi; private final WikidataEditListener wikidataEditListener; private final JsonKvStore directKvStore; - private final WikidataClient wikidataClient; - private final PageEditClient wikiDataPageEditClient; @Inject public WikidataEditService(Context context, + MediaWikiApi mediaWikiApi, WikidataEditListener wikidataEditListener, - @Named("default_preferences") JsonKvStore directKvStore, - WikidataClient wikidataClient, - @Named("wikidata-page-edit") PageEditClient wikiDataPageEditClient) { + @Named("default_preferences") JsonKvStore directKvStore) { this.context = context; + this.mediaWikiApi = mediaWikiApi; this.wikidataEditListener = wikidataEditListener; this.directKvStore = directKvStore; - this.wikidataClient = wikidataClient; - this.wikiDataPageEditClient = wikiDataPageEditClient; } /** @@ -83,20 +77,13 @@ public class WikidataEditService { private void editWikidataProperty(String wikidataEntityId, String fileName) { Timber.d("Upload successful with wiki data entity id as %s", wikidataEntityId); Timber.d("Attempting to edit Wikidata property %s", wikidataEntityId); - - String propertyValue = getFileName(fileName); - - Timber.d(propertyValue); - wikidataClient.createClaim(wikidataEntityId, "P18", "value", propertyValue) - .flatMap(revisionId -> { - if (revisionId != -1) { - return wikiDataPageEditClient.addEditTag(revisionId, COMMONS_APP_TAG, COMMONS_APP_EDIT_REASON); - } - throw new RuntimeException("Unable to edit wikidata item"); - }) + Observable.fromCallable(() -> { + String propertyValue = getFileName(fileName); + return mediaWikiApi.wikidataCreateClaim(wikidataEntityId, "P18", "value", propertyValue); + }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(revisionId -> handleClaimResult(wikidataEntityId, String.valueOf(revisionId)), throwable -> { + .subscribe(revisionId -> handleClaimResult(wikidataEntityId, revisionId), throwable -> { Timber.e(throwable, "Error occurred while making claim"); ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); }); @@ -108,12 +95,31 @@ public class WikidataEditService { wikidataEditListener.onSuccessfulWikidataEdit(); } showSuccessToast(); + logEdit(revisionId); } else { Timber.d("Unable to make wiki data edit for entity %s", wikidataEntityId); ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); } } + /** + * Log the Wikidata edit by adding Wikimedia Commons App tag to the edit + * @param revisionId + */ + @SuppressLint("CheckResult") + private void logEdit(String revisionId) { + Observable.fromCallable(() -> mediaWikiApi.addWikidataEditTag(revisionId)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(result -> { + if (result) { + Timber.d("Wikidata edit was tagged successfully"); + } else { + Timber.d("Wikidata edit couldn't be tagged"); + } + }, throwable -> Timber.e(throwable, "Error occurred while adding tag to the edit")); + } + /** * Show a success toast when the edit is made successfully */ diff --git a/app/src/test/kotlin/fr/free/nrw/commons/MediaDataExtractorTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/MediaDataExtractorTest.kt index d66dc4e33..cf87f9cd6 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/MediaDataExtractorTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/MediaDataExtractorTest.kt @@ -1,6 +1,5 @@ package fr.free.nrw.commons -import fr.free.nrw.commons.media.MediaClient import fr.free.nrw.commons.mwapi.MediaResult import fr.free.nrw.commons.mwapi.MediaWikiApi import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient @@ -24,7 +23,7 @@ class MediaDataExtractorTest { internal var mwApi: MediaWikiApi? = null @Mock - internal var mediaClient: MediaClient? = null + internal var okHttpJsonApiClient: OkHttpJsonApiClient? = null @InjectMocks var mediaDataExtractor: MediaDataExtractor? = null @@ -43,10 +42,10 @@ class MediaDataExtractorTest { */ @Test fun fetchMediaDetails() { - `when`(mediaClient?.getMedia(ArgumentMatchers.anyString())) + `when`(okHttpJsonApiClient?.getMedia(ArgumentMatchers.anyString(), ArgumentMatchers.anyBoolean())) .thenReturn(Single.just(mock(Media::class.java))) - `when`(mediaClient?.checkPageExistsUsingTitle(ArgumentMatchers.anyString())) + `when`(mwApi?.pageExists(ArgumentMatchers.anyString())) .thenReturn(Single.just(true)) val mediaResult = mock(MediaResult::class.java) diff --git a/app/src/test/kotlin/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesControllerTest.java b/app/src/test/kotlin/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesControllerTest.java index 468eff1d1..6e0e95fa1 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesControllerTest.java +++ b/app/src/test/kotlin/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesControllerTest.java @@ -13,7 +13,6 @@ import java.util.List; 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.Single; @@ -30,7 +29,7 @@ import static org.mockito.Mockito.when; public class BookmarkPicturesControllerTest { @Mock - MediaClient mediaClient; + OkHttpJsonApiClient okHttpJsonApiClient; @Mock BookmarkPicturesDao bookmarkDao; @@ -47,7 +46,7 @@ public class BookmarkPicturesControllerTest { Media mockMedia = getMockMedia(); when(bookmarkDao.getAllBookmarks()) .thenReturn(getMockBookmarkList()); - when(mediaClient.getMedia(anyString())) + when(okHttpJsonApiClient.getMedia(anyString(), anyBoolean())) .thenReturn(Single.just(mockMedia)); } @@ -76,9 +75,9 @@ public class BookmarkPicturesControllerTest { */ @Test public void loadBookmarkedPicturesForNullMedia() { - when(mediaClient.getMedia("File:Test1.jpg")) + when(okHttpJsonApiClient.getMedia("File:Test1.jpg", false)) .thenReturn(Single.error(new NullPointerException("Error occurred"))); - when(mediaClient.getMedia("File:Test2.jpg")) + when(okHttpJsonApiClient.getMedia("File:Test2.jpg", false)) .thenReturn(Single.just(getMockMedia())); List bookmarkedPictures = bookmarkPicturesController.loadBookmarkedPictures().blockingGet(); assertEquals(1, bookmarkedPictures.size()); diff --git a/app/src/test/kotlin/fr/free/nrw/commons/category/CategoryClientTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/category/CategoryClientTest.kt deleted file mode 100644 index 7c26cc323..000000000 --- a/app/src/test/kotlin/fr/free/nrw/commons/category/CategoryClientTest.kt +++ /dev/null @@ -1,153 +0,0 @@ -package fr.free.nrw.commons.category - -import io.reactivex.Observable -import junit.framework.Assert.* -import org.junit.Before -import org.junit.Test -import org.mockito.* -import org.wikipedia.dataclient.mwapi.MwQueryPage -import org.wikipedia.dataclient.mwapi.MwQueryResponse -import org.wikipedia.dataclient.mwapi.MwQueryResult - -class CategoryClientTest { - @Mock - internal var categoryInterface: CategoryInterface? = null - - @InjectMocks - var categoryClient: CategoryClient? = null - - @Before - @Throws(Exception::class) - fun setUp() { - MockitoAnnotations.initMocks(this) - } - - @Test - fun searchCategoriesFound() { - val mwQueryPage = Mockito.mock(MwQueryPage::class.java) - Mockito.`when`(mwQueryPage.title()).thenReturn("Category:Test") - val mwQueryResult = Mockito.mock(MwQueryResult::class.java) - Mockito.`when`(mwQueryResult.pages()).thenReturn(listOf(mwQueryPage)) - val mockResponse = Mockito.mock(MwQueryResponse::class.java) - Mockito.`when`(mockResponse.query()).thenReturn(mwQueryResult) - - Mockito.`when`(categoryInterface!!.searchCategories(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())) - .thenReturn(Observable.just(mockResponse)) - - val actualCategoryName = categoryClient!!.searchCategories("tes", 10).blockingFirst() - assertEquals("Test", actualCategoryName) - - val actualCategoryName2 = categoryClient!!.searchCategories("tes", 10, 10).blockingFirst() - assertEquals("Test", actualCategoryName2) - } - - @Test - fun searchCategoriesNull() { - val mwQueryResult = Mockito.mock(MwQueryResult::class.java) - Mockito.`when`(mwQueryResult.pages()).thenReturn(null) - val mockResponse = Mockito.mock(MwQueryResponse::class.java) - Mockito.`when`(mockResponse.query()).thenReturn(mwQueryResult) - - Mockito.`when`(categoryInterface!!.searchCategories(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())) - .thenReturn(Observable.just(mockResponse)) - - categoryClient!!.searchCategories("tes", 10).subscribe( - { fail("SearchCategories returned element when it shouldn't have.") }, - { s -> throw s }) - categoryClient!!.searchCategories("tes", 10, 10).subscribe( - { fail("SearchCategories returned element when it shouldn't have.") }, - { s -> throw s }) - } - @Test - fun searchCategoriesForPrefixFound() { - val mwQueryPage = Mockito.mock(MwQueryPage::class.java) - Mockito.`when`(mwQueryPage.title()).thenReturn("Category:Test") - val mwQueryResult = Mockito.mock(MwQueryResult::class.java) - Mockito.`when`(mwQueryResult.pages()).thenReturn(listOf(mwQueryPage)) - val mockResponse = Mockito.mock(MwQueryResponse::class.java) - Mockito.`when`(mockResponse.query()).thenReturn(mwQueryResult) - - Mockito.`when`(categoryInterface!!.searchCategoriesForPrefix(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())) - .thenReturn(Observable.just(mockResponse)) - - val actualCategoryName = categoryClient!!.searchCategoriesForPrefix("tes", 10).blockingFirst() - assertEquals("Test", actualCategoryName) - val actualCategoryName2 = categoryClient!!.searchCategoriesForPrefix("tes", 10, 10).blockingFirst() - assertEquals("Test", actualCategoryName2) - } - - @Test - fun searchCategoriesForPrefixNull() { - val mwQueryResult = Mockito.mock(MwQueryResult::class.java) - Mockito.`when`(mwQueryResult.pages()).thenReturn(null) - val mockResponse = Mockito.mock(MwQueryResponse::class.java) - Mockito.`when`(mockResponse.query()).thenReturn(mwQueryResult) - - Mockito.`when`(categoryInterface!!.searchCategoriesForPrefix(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())) - .thenReturn(Observable.just(mockResponse)) - categoryClient!!.searchCategoriesForPrefix("tes", 10).subscribe( - { fail("SearchCategories returned element when it shouldn't have.") }, - { s -> throw s }) - categoryClient!!.searchCategoriesForPrefix("tes", 10, 10).subscribe( - { fail("SearchCategories returned element when it shouldn't have.") }, - { s -> throw s }) - } - @Test - fun getParentCategoryListFound() { - val mwQueryPage = Mockito.mock(MwQueryPage::class.java) - Mockito.`when`(mwQueryPage.title()).thenReturn("Category:Test") - val mwQueryResult = Mockito.mock(MwQueryResult::class.java) - Mockito.`when`(mwQueryResult.pages()).thenReturn(listOf(mwQueryPage)) - val mockResponse = Mockito.mock(MwQueryResponse::class.java) - Mockito.`when`(mockResponse.query()).thenReturn(mwQueryResult) - - Mockito.`when`(categoryInterface!!.getParentCategoryList(ArgumentMatchers.anyString())) - .thenReturn(Observable.just(mockResponse)) - - val actualCategoryName = categoryClient!!.getParentCategoryList("tes").blockingFirst() - assertEquals("Test", actualCategoryName) - } - - @Test - fun getParentCategoryListNull() { - val mwQueryResult = Mockito.mock(MwQueryResult::class.java) - Mockito.`when`(mwQueryResult.pages()).thenReturn(null) - val mockResponse = Mockito.mock(MwQueryResponse::class.java) - Mockito.`when`(mockResponse.query()).thenReturn(mwQueryResult) - - Mockito.`when`(categoryInterface!!.getParentCategoryList(ArgumentMatchers.anyString())) - .thenReturn(Observable.just(mockResponse)) - categoryClient!!.getParentCategoryList("tes").subscribe( - { fail("SearchCategories returned element when it shouldn't have.") }, - { s -> throw s }) - } - @Test - fun getSubCategoryListFound() { - val mwQueryPage = Mockito.mock(MwQueryPage::class.java) - Mockito.`when`(mwQueryPage.title()).thenReturn("Category:Test") - val mwQueryResult = Mockito.mock(MwQueryResult::class.java) - Mockito.`when`(mwQueryResult.pages()).thenReturn(listOf(mwQueryPage)) - val mockResponse = Mockito.mock(MwQueryResponse::class.java) - Mockito.`when`(mockResponse.query()).thenReturn(mwQueryResult) - - Mockito.`when`(categoryInterface!!.getSubCategoryList(ArgumentMatchers.anyString())) - .thenReturn(Observable.just(mockResponse)) - - val actualCategoryName = categoryClient!!.getSubCategoryList("tes").blockingFirst() - assertEquals("Test", actualCategoryName) - } - - @Test - fun getSubCategoryListNull() { - val mwQueryResult = Mockito.mock(MwQueryResult::class.java) - Mockito.`when`(mwQueryResult.pages()).thenReturn(null) - val mockResponse = Mockito.mock(MwQueryResponse::class.java) - Mockito.`when`(mockResponse.query()).thenReturn(mwQueryResult) - - Mockito.`when`(categoryInterface!!.getSubCategoryList(ArgumentMatchers.anyString())) - .thenReturn(Observable.just(mockResponse)) - categoryClient!!.getSubCategoryList("tes").subscribe( - { fail("SearchCategories returned element when it shouldn't have.") }, - { s -> throw s }) - } -} \ No newline at end of file diff --git a/app/src/test/kotlin/fr/free/nrw/commons/delete/DeleteHelperTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/delete/DeleteHelperTest.kt index 8df1971a1..46d7dfbfc 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/delete/DeleteHelperTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/delete/DeleteHelperTest.kt @@ -1,22 +1,19 @@ package fr.free.nrw.commons.delete +import android.accounts.Account import android.content.Context import fr.free.nrw.commons.Media -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.utils.ViewUtilWrapper -import io.reactivex.Observable import org.junit.Assert.* import org.junit.Before import org.junit.Test -import org.mockito.ArgumentMatchers import org.mockito.InjectMocks import org.mockito.Mock import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations -import org.wikipedia.AppAdapter -import javax.inject.Inject -import javax.inject.Named /** * Tests for delete helper @@ -24,15 +21,17 @@ import javax.inject.Named class DeleteHelperTest { @Mock - @field:[Inject Named("commons-page-edit")] - internal var pageEditClient: PageEditClient? = null + internal var mwApi: MediaWikiApi? = null @Mock - internal var context: Context? = null + internal var sessionManager: SessionManager? = null @Mock internal var notificationHelper: NotificationHelper? = null + @Mock + internal var context: Context? = null + @Mock internal var viewUtil: ViewUtilWrapper? = null @@ -55,13 +54,9 @@ class DeleteHelperTest { */ @Test fun makeDeletion() { - `when`(pageEditClient?.prependEdit(ArgumentMatchers.anyString(), ArgumentMatchers.anyString(), ArgumentMatchers.anyString())) - .thenReturn(Observable.just(true)) - `when`(pageEditClient?.appendEdit(ArgumentMatchers.anyString(), ArgumentMatchers.anyString(), ArgumentMatchers.anyString())) - .thenReturn(Observable.just(true)) - `when`(pageEditClient?.edit(ArgumentMatchers.anyString(), ArgumentMatchers.anyString(), ArgumentMatchers.anyString())) - .thenReturn(Observable.just(true)) - + `when`(mwApi?.editToken).thenReturn("token") + `when`(sessionManager?.authCookie).thenReturn("Mock cookie") + `when`(sessionManager?.currentAccount).thenReturn(Account("TestUser", "Test")) `when`(media?.displayTitle).thenReturn("Test file") `when`(media?.filename).thenReturn("Test file.jpg") @@ -73,45 +68,16 @@ class DeleteHelperTest { /** * Test a failed deletion */ - @Test(expected = RuntimeException::class) - fun makeDeletionForPrependEditFailure() { - `when`(pageEditClient?.prependEdit(ArgumentMatchers.anyString(), ArgumentMatchers.anyString(), ArgumentMatchers.anyString())) - .thenReturn(Observable.just(false)) - `when`(pageEditClient?.appendEdit(ArgumentMatchers.anyString(), ArgumentMatchers.anyString(), ArgumentMatchers.anyString())) - .thenReturn(Observable.just(true)) - `when`(pageEditClient?.edit(ArgumentMatchers.anyString(), ArgumentMatchers.anyString(), ArgumentMatchers.anyString())) - .thenReturn(Observable.just(true)) + @Test + fun makeDeletionForNullToken() { + `when`(mwApi?.editToken).thenReturn(null) + `when`(sessionManager?.authCookie).thenReturn("Mock cookie") + `when`(sessionManager?.currentAccount).thenReturn(Account("TestUser", "Test")) `when`(media?.displayTitle).thenReturn("Test file") `when`(media?.filename).thenReturn("Test file.jpg") - deleteHelper?.makeDeletion(context, media, "Test reason")?.blockingGet() - } - - @Test(expected = RuntimeException::class) - fun makeDeletionForEditFailure() { - `when`(pageEditClient?.prependEdit(ArgumentMatchers.anyString(), ArgumentMatchers.anyString(), ArgumentMatchers.anyString())) - .thenReturn(Observable.just(true)) - `when`(pageEditClient?.appendEdit(ArgumentMatchers.anyString(), ArgumentMatchers.anyString(), ArgumentMatchers.anyString())) - .thenReturn(Observable.just(true)) - `when`(pageEditClient?.edit(ArgumentMatchers.anyString(), ArgumentMatchers.anyString(), ArgumentMatchers.anyString())) - .thenReturn(Observable.just(false)) - `when`(media?.displayTitle).thenReturn("Test file") - `when`(media?.filename).thenReturn("Test file.jpg") - - deleteHelper?.makeDeletion(context, media, "Test reason")?.blockingGet() - } - - @Test(expected = RuntimeException::class) - fun makeDeletionForAppendEditFailure() { - `when`(pageEditClient?.prependEdit(ArgumentMatchers.anyString(), ArgumentMatchers.anyString(), ArgumentMatchers.anyString())) - .thenReturn(Observable.just(true)) - `when`(pageEditClient?.appendEdit(ArgumentMatchers.anyString(), ArgumentMatchers.anyString(), ArgumentMatchers.anyString())) - .thenReturn(Observable.just(false)) - `when`(pageEditClient?.edit(ArgumentMatchers.anyString(), ArgumentMatchers.anyString(), ArgumentMatchers.anyString())) - .thenReturn(Observable.just(true)) - `when`(media?.displayTitle).thenReturn("Test file") - `when`(media?.filename).thenReturn("Test file.jpg") - - deleteHelper?.makeDeletion(context, media, "Test reason")?.blockingGet() + val makeDeletion = deleteHelper?.makeDeletion(context, media, "Test reason")?.blockingGet() + assertNotNull(makeDeletion) + assertFalse(makeDeletion!!) } } \ No newline at end of file diff --git a/app/src/test/kotlin/fr/free/nrw/commons/media/MediaClientTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/media/MediaClientTest.kt deleted file mode 100644 index 7403ae99a..000000000 --- a/app/src/test/kotlin/fr/free/nrw/commons/media/MediaClientTest.kt +++ /dev/null @@ -1,199 +0,0 @@ -package fr.free.nrw.commons.media - -import fr.free.nrw.commons.Media -import fr.free.nrw.commons.utils.CommonsDateUtil -import io.reactivex.Observable -import junit.framework.Assert.* -import org.junit.Before -import org.junit.Test -import org.mockito.* -import org.mockito.Mockito.`when` -import org.mockito.Mockito.mock -import org.wikipedia.dataclient.mwapi.ImageDetails -import org.wikipedia.dataclient.mwapi.MwQueryPage -import org.wikipedia.dataclient.mwapi.MwQueryResponse -import org.wikipedia.dataclient.mwapi.MwQueryResult -import org.wikipedia.gallery.ImageInfo -import org.mockito.ArgumentCaptor -import java.util.* -import org.mockito.Captor - - - - -class MediaClientTest { - - @Mock - internal var mediaInterface: MediaInterface? = null - - @InjectMocks - var mediaClient: MediaClient? = null - - @Before - @Throws(Exception::class) - fun setUp() { - MockitoAnnotations.initMocks(this) - } - - @Test - fun checkPageExistsUsingTitle() { - val mwQueryPage = mock(MwQueryPage::class.java) - `when`(mwQueryPage.pageId()).thenReturn(10) - val mwQueryResult = mock(MwQueryResult::class.java) - `when`(mwQueryResult.firstPage()).thenReturn(mwQueryPage) - `when`(mwQueryResult.pages()).thenReturn(listOf(mwQueryPage)) - val mockResponse = mock(MwQueryResponse::class.java) - `when`(mockResponse.query()).thenReturn(mwQueryResult) - - `when`(mediaInterface!!.checkPageExistsUsingTitle(ArgumentMatchers.anyString())) - .thenReturn(Observable.just(mockResponse)) - - val checkPageExistsUsingTitle = mediaClient!!.checkPageExistsUsingTitle("File:Test.jpg").blockingGet() - assertTrue(checkPageExistsUsingTitle) - } - - @Test - fun checkPageNotExistsUsingTitle() { - val mwQueryPage = mock(MwQueryPage::class.java) - `when`(mwQueryPage.pageId()).thenReturn(0) - val mwQueryResult = mock(MwQueryResult::class.java) - `when`(mwQueryResult.firstPage()).thenReturn(mwQueryPage) - `when`(mwQueryResult.pages()).thenReturn(listOf()) - val mockResponse = mock(MwQueryResponse::class.java) - `when`(mockResponse.query()).thenReturn(mwQueryResult) - - `when`(mediaInterface!!.checkPageExistsUsingTitle(ArgumentMatchers.anyString())) - .thenReturn(Observable.just(mockResponse)) - - val checkPageExistsUsingTitle = mediaClient!!.checkPageExistsUsingTitle("File:Test.jpg").blockingGet() - assertFalse(checkPageExistsUsingTitle) - } - - @Test - fun checkFileExistsUsingSha() { - val mwQueryPage = mock(MwQueryPage::class.java) - val mwQueryResult = mock(MwQueryResult::class.java) - `when`(mwQueryResult.allImages()).thenReturn(listOf(mock(ImageDetails::class.java))) - `when`(mwQueryResult.firstPage()).thenReturn(mwQueryPage) - `when`(mwQueryResult.pages()).thenReturn(listOf(mwQueryPage)) - val mockResponse = mock(MwQueryResponse::class.java) - `when`(mockResponse.query()).thenReturn(mwQueryResult) - - `when`(mediaInterface!!.checkFileExistsUsingSha(ArgumentMatchers.anyString())) - .thenReturn(Observable.just(mockResponse)) - - val checkFileExistsUsingSha = mediaClient!!.checkFileExistsUsingSha("abcde").blockingGet() - assertTrue(checkFileExistsUsingSha) - } - - @Test - fun checkFileNotExistsUsingSha() { - val mwQueryPage = mock(MwQueryPage::class.java) - val mwQueryResult = mock(MwQueryResult::class.java) - `when`(mwQueryResult.allImages()).thenReturn(listOf()) - `when`(mwQueryResult.firstPage()).thenReturn(mwQueryPage) - `when`(mwQueryResult.pages()).thenReturn(listOf(mwQueryPage)) - val mockResponse = mock(MwQueryResponse::class.java) - `when`(mockResponse.query()).thenReturn(mwQueryResult) - - `when`(mediaInterface!!.checkFileExistsUsingSha(ArgumentMatchers.anyString())) - .thenReturn(Observable.just(mockResponse)) - - val checkFileExistsUsingSha = mediaClient!!.checkFileExistsUsingSha("abcde").blockingGet() - assertFalse(checkFileExistsUsingSha) - } - - @Test - fun getMedia() { - val imageInfo = ImageInfo() - - val mwQueryPage = mock(MwQueryPage::class.java) - `when`(mwQueryPage.title()).thenReturn("Test") - `when`(mwQueryPage.imageInfo()).thenReturn(imageInfo) - - val mwQueryResult = mock(MwQueryResult::class.java) - `when`(mwQueryResult.firstPage()).thenReturn(mwQueryPage) - val mockResponse = mock(MwQueryResponse::class.java) - `when`(mockResponse.query()).thenReturn(mwQueryResult) - - `when`(mediaInterface!!.getMedia(ArgumentMatchers.anyString())) - .thenReturn(Observable.just(mockResponse)) - - assertEquals("Test", mediaClient!!.getMedia("abcde").blockingGet().filename) - } - - @Test - fun getMediaNull() { - val imageInfo = ImageInfo() - - val mwQueryPage = mock(MwQueryPage::class.java) - `when`(mwQueryPage.title()).thenReturn("Test") - `when`(mwQueryPage.imageInfo()).thenReturn(imageInfo) - - val mwQueryResult = mock(MwQueryResult::class.java) - `when`(mwQueryResult.firstPage()).thenReturn(null) - val mockResponse = mock(MwQueryResponse::class.java) - `when`(mockResponse.query()).thenReturn(mwQueryResult) - - `when`(mediaInterface!!.getMedia(ArgumentMatchers.anyString())) - .thenReturn(Observable.just(mockResponse)) - - assertEquals(Media.EMPTY, mediaClient!!.getMedia("abcde").blockingGet()) - } - @Captor - private val filenameCaptor: ArgumentCaptor? = null - - @Test - fun getPictureOfTheDay() { - val template = "Template:Potd/" + CommonsDateUtil.getIso8601DateFormatShort().format(Date()) - - val imageInfo = ImageInfo() - - val mwQueryPage = mock(MwQueryPage::class.java) - `when`(mwQueryPage.title()).thenReturn("Test") - `when`(mwQueryPage.imageInfo()).thenReturn(imageInfo) - - val mwQueryResult = mock(MwQueryResult::class.java) - `when`(mwQueryResult.firstPage()).thenReturn(mwQueryPage) - val mockResponse = mock(MwQueryResponse::class.java) - `when`(mockResponse.query()).thenReturn(mwQueryResult) - - `when`(mediaInterface!!.getMediaWithGenerator(filenameCaptor!!.capture())) - .thenReturn(Observable.just(mockResponse)) - - assertEquals("Test", mediaClient!!.getPictureOfTheDay().blockingGet().filename) - assertEquals(template, filenameCaptor.value); - } - - @Captor - private val continuationCaptor: ArgumentCaptor>? = null - - @Test - fun getMediaListFromCategoryTwice() { - val mockContinuation= mapOf(Pair("gcmcontinue", "test")) - val imageInfo = ImageInfo() - - val mwQueryPage = mock(MwQueryPage::class.java) - `when`(mwQueryPage.title()).thenReturn("Test") - `when`(mwQueryPage.imageInfo()).thenReturn(imageInfo) - - val mwQueryResult = mock(MwQueryResult::class.java) - `when`(mwQueryResult.pages()).thenReturn(listOf(mwQueryPage)) - - val mockResponse = mock(MwQueryResponse::class.java) - `when`(mockResponse.query()).thenReturn(mwQueryResult) - `when`(mockResponse.continuation()).thenReturn(mockContinuation) - - `when`(mediaInterface!!.getMediaListFromCategory(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt(), - continuationCaptor!!.capture())) - .thenReturn(Observable.just(mockResponse)) - val media1 = mediaClient!!.getMediaListFromCategory("abcde").blockingGet().get(0) - val media2 = mediaClient!!.getMediaListFromCategory("abcde").blockingGet().get(0) - - assertEquals(continuationCaptor.allValues[0], emptyMap()) - assertEquals(continuationCaptor.allValues[1], mockContinuation) - - assertEquals(media1.filename, "Test") - assertEquals(media2.filename, "Test") - } -} \ No newline at end of file diff --git a/app/src/test/kotlin/fr/free/nrw/commons/modifications/ModifierSequenceDaoTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/modifications/ModifierSequenceDaoTest.kt new file mode 100644 index 000000000..3187d48ff --- /dev/null +++ b/app/src/test/kotlin/fr/free/nrw/commons/modifications/ModifierSequenceDaoTest.kt @@ -0,0 +1,156 @@ +package fr.free.nrw.commons.modifications + +import android.content.ContentProviderClient +import android.content.ContentValues +import android.database.MatrixCursor +import android.database.sqlite.SQLiteDatabase +import android.net.Uri +import android.os.RemoteException +import com.nhaarman.mockito_kotlin.* +import fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.TestCommonsApplication +import fr.free.nrw.commons.modifications.ModificationsContentProvider.BASE_URI +import fr.free.nrw.commons.modifications.ModifierSequenceDao.Table.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [21], application = TestCommonsApplication::class) +class ModifierSequenceDaoTest { + + private val mediaUrl = "http://example.com/" + private val columns = arrayOf(COLUMN_ID, COLUMN_MEDIA_URI, COLUMN_DATA) + private val client: ContentProviderClient = mock() + private val database: SQLiteDatabase = mock() + private val contentValuesCaptor = argumentCaptor() + + private lateinit var testObject: ModifierSequenceDao + + @Before + fun setUp() { + testObject = ModifierSequenceDao { client } + } + + @Test + fun createFromCursorWithEmptyModifiers() { + testObject.fromCursor(createCursor("")).let { + assertEquals(mediaUrl, it.mediaUri.toString()) + assertEquals(BASE_URI.buildUpon().appendPath("1").toString(), it.contentUri.toString()) + assertTrue(it.modifiers.isEmpty()) + } + } + + @Test + fun createFromCursorWtihCategoryModifier() { + val cursor = createCursor("{\"name\": \"CategoriesModifier\", \"data\": {}}") + + val seq = testObject.fromCursor(cursor) + + assertEquals(1, seq.modifiers.size) + assertTrue(seq.modifiers[0] is CategoryModifier) + } + + @Test + fun createFromCursorWithRemoveModifier() { + val cursor = createCursor("{\"name\": \"TemplateRemoverModifier\", \"data\": {}}") + + val seq = testObject.fromCursor(cursor) + + assertEquals(1, seq.modifiers.size) + assertTrue(seq.modifiers[0] is TemplateRemoveModifier) + } + + @Test + fun deleteSequence() { + whenever(client.delete(isA(), isNull(), isNull())).thenReturn(1) + val seq = testObject.fromCursor(createCursor("")) + + testObject.delete(seq) + + verify(client).delete(eq(seq.contentUri), isNull(), isNull()) + } + + @Test(expected = RuntimeException::class) + fun deleteTranslatesRemoteExceptions() { + whenever(client.delete(isA(), isNull(), isNull())).thenThrow(RemoteException("")) + val seq = testObject.fromCursor(createCursor("")) + + testObject.delete(seq) + } + + @Test + fun saveExistingSequence() { + val modifierJson = "{\"name\":\"CategoriesModifier\",\"data\":{}}" + val expectedData = "{\"modifiers\":[$modifierJson]}" + val cursor = createCursor(modifierJson) + val seq = testObject.fromCursor(cursor) + + testObject.save(seq) + + verify(client).update(eq(seq.contentUri), contentValuesCaptor.capture(), isNull(), isNull()) + contentValuesCaptor.firstValue.let { + assertEquals(2, it.size()) + assertEquals(mediaUrl, it.get(COLUMN_MEDIA_URI)) + assertEquals(expectedData, it.get(COLUMN_DATA)) + } + } + + @Test + fun saveNewSequence() { + val expectedContentUri = BASE_URI.buildUpon().appendPath("1").build() + whenever(client.insert(isA(), isA())).thenReturn(expectedContentUri) + val seq = ModifierSequence(Uri.parse(mediaUrl)) + + testObject.save(seq) + + assertEquals(expectedContentUri.toString(), seq.contentUri.toString()) + verify(client).insert(eq(ModificationsContentProvider.BASE_URI), contentValuesCaptor.capture()) + contentValuesCaptor.firstValue.let { + assertEquals(2, it.size()) + assertEquals(mediaUrl, it.get(COLUMN_MEDIA_URI)) + assertEquals("{\"modifiers\":[]}", it.get(COLUMN_DATA)) + } + } + + @Test(expected = RuntimeException::class) + fun saveTranslatesRemoteExceptions() { + whenever(client.insert(isA(), isA())).thenThrow(RemoteException("")) + testObject.save(ModifierSequence(Uri.parse(mediaUrl))) + } + + @Test + fun createTable() { + onCreate(database) + verify(database).execSQL(CREATE_TABLE_STATEMENT) + } + + @Test + fun updateTable() { + onUpdate(database, 1, 2) + + inOrder(database) { + verify(database).execSQL(DROP_TABLE_STATEMENT) + verify(database).execSQL(CREATE_TABLE_STATEMENT) + } + } + + @Test + fun deleteTable() { + onDelete(database) + + inOrder(database) { + verify(database).execSQL(DROP_TABLE_STATEMENT) + verify(database).execSQL(CREATE_TABLE_STATEMENT) + } + } + + private fun createCursor(modifierJson: String) = MatrixCursor(columns, 1).apply { + addRow(listOf("1", mediaUrl, "{\"modifiers\": [$modifierJson]}")) + moveToFirst() + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/fr/free/nrw/commons/mwapi/ApacheHttpClientMediaWikiApiTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/mwapi/ApacheHttpClientMediaWikiApiTest.kt index 42519646f..907b07c74 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/mwapi/ApacheHttpClientMediaWikiApiTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/mwapi/ApacheHttpClientMediaWikiApiTest.kt @@ -2,6 +2,7 @@ package fr.free.nrw.commons.mwapi import android.os.Build import androidx.test.core.app.ApplicationProvider +import com.google.gson.Gson import fr.free.nrw.commons.TestCommonsApplication import fr.free.nrw.commons.kvstore.JsonKvStore import fr.free.nrw.commons.utils.ConfigUtils @@ -37,7 +38,7 @@ class ApacheHttpClientMediaWikiApiTest { wikidataServer = MockWebServer() okHttpClient = OkHttpClient() sharedPreferences = mock(JsonKvStore::class.java) - testObject = ApacheHttpClientMediaWikiApi("http://" + server.hostName + ":" + server.port + "/") + testObject = ApacheHttpClientMediaWikiApi(ApplicationProvider.getApplicationContext(), "http://" + server.hostName + ":" + server.port + "/", "http://" + wikidataServer.hostName + ":" + wikidataServer.port + "/", sharedPreferences, Gson()) } @After @@ -45,6 +46,199 @@ class ApacheHttpClientMediaWikiApiTest { server.shutdown() } + @Test + fun authCookiesAreHandled() { + assertEquals("", testObject.authCookie) + + testObject.authCookie = "cookie=chocolate-chip" + + assertEquals("cookie=chocolate-chip", testObject.authCookie) + } + + @Test + fun simpleLoginWithWrongPassword() { + server.enqueue(MockResponse().setBody("")) + server.enqueue(MockResponse().setBody("")) + + val result = testObject.login("foo", "bar") + + assertBasicRequestParameters(server, "POST").let { loginTokenRequest -> + parseBody(loginTokenRequest.body.readUtf8()).let { body -> + assertEquals("xml", body["format"]) + assertEquals("query", body["action"]) + assertEquals("login", body["type"]) + assertEquals("tokens", body["meta"]) + } + } + + assertBasicRequestParameters(server, "POST").let { loginRequest -> + parseBody(loginRequest.body.readUtf8()).let { body -> + assertEquals("1", body["rememberMe"]) + assertEquals("foo", body["username"]) + assertEquals("bar", body["password"]) + assertEquals("baz", body["logintoken"]) + assertEquals("https://commons.wikimedia.org", body["loginreturnurl"]) + assertEquals("xml", body["format"]) + } + } + + assertEquals("wrongpassword", result) + } + + @Test + fun simpleLogin() { + server.enqueue(MockResponse().setBody("")) + server.enqueue(MockResponse().setBody("")) + + val result = testObject.login("foo", "bar") + + assertBasicRequestParameters(server, "POST").let { loginTokenRequest -> + parseBody(loginTokenRequest.body.readUtf8()).let { body -> + assertEquals("xml", body["format"]) + assertEquals("query", body["action"]) + assertEquals("login", body["type"]) + assertEquals("tokens", body["meta"]) + } + } + + assertBasicRequestParameters(server, "POST").let { loginRequest -> + parseBody(loginRequest.body.readUtf8()).let { body -> + assertEquals("1", body["rememberMe"]) + assertEquals("foo", body["username"]) + assertEquals("bar", body["password"]) + assertEquals("baz", body["logintoken"]) + assertEquals("https://commons.wikimedia.org", body["loginreturnurl"]) + assertEquals("xml", body["format"]) + } + } + + assertEquals("PASS", result) + } + + @Test + fun twoFactorLogin() { + server.enqueue(MockResponse().setBody("")) + server.enqueue(MockResponse().setBody("")) + + val result = testObject.login("foo", "bar", "2fa") + + assertBasicRequestParameters(server, "POST").let { loginTokenRequest -> + parseBody(loginTokenRequest.body.readUtf8()).let { body -> + assertEquals("xml", body["format"]) + assertEquals("query", body["action"]) + assertEquals("login", body["type"]) + assertEquals("tokens", body["meta"]) + } + } + + assertBasicRequestParameters(server, "POST").let { loginRequest -> + parseBody(loginRequest.body.readUtf8()).let { body -> + assertEquals("true", body["rememberMe"]) + assertEquals("foo", body["username"]) + assertEquals("bar", body["password"]) + assertEquals("baz", body["logintoken"]) + assertEquals("true", body["logincontinue"]) + assertEquals("2fa", body["OATHToken"]) + assertEquals("xml", body["format"]) + } + } + + assertEquals("PASS", result) + } + + @Test + fun validateLoginForLoggedInUser() { + server.enqueue(MockResponse().setBody("")) + + val result = testObject.validateLogin() + + assertBasicRequestParameters(server, "GET").let { loginTokenRequest -> + parseQueryParams(loginTokenRequest).let { body -> + assertEquals("xml", body["format"]) + assertEquals("query", body["action"]) + assertEquals("userinfo", body["meta"]) + } + } + + assertTrue(result) + } + + @Test + fun validateLoginForLoggedOutUser() { + server.enqueue(MockResponse().setBody("")) + + val result = testObject.validateLogin() + + assertBasicRequestParameters(server, "GET").let { loginTokenRequest -> + parseQueryParams(loginTokenRequest).let { params -> + assertEquals("xml", params["format"]) + assertEquals("query", params["action"]) + assertEquals("userinfo", params["meta"]) + } + } + + assertFalse(result) + } + + @Test + fun editToken() { + server.enqueue(MockResponse().setBody("")) + + val result = testObject.editToken + + assertBasicRequestParameters(server, "POST").let { editTokenRequest -> + parseBody(editTokenRequest.body.readUtf8()).let { body -> + assertEquals("query", body["action"]) + assertEquals("tokens", body["meta"]) + } + } + + assertEquals("baz", result) + } + + @Test + fun getWikidataEditToken() { + server.enqueue(MockResponse().setBody("")) + wikidataServer.enqueue(MockResponse().setBody("")) + + val result = testObject.wikidataCsrfToken + + assertBasicRequestParameters(server, "GET").let { centralAuthTokenRequest -> + parseQueryParams(centralAuthTokenRequest).let { params -> + assertEquals("xml", params["format"]) + assertEquals("centralauthtoken", params["action"]) + } + } + + assertBasicRequestParameters(wikidataServer, "POST").let { editTokenRequest -> + parseBody(editTokenRequest.body.readUtf8()).let { body -> + assertEquals("query", body["action"]) + assertEquals("abc", body["centralauthtoken"]) + assertEquals("tokens", body["meta"]) + } + } + + assertEquals("baz", result) + } + + @Test + fun fileExistsWithName_FileNotFound() { + server.enqueue(MockResponse().setBody(" ")) + + val result = testObject.fileExistsWithName("foo") + + assertBasicRequestParameters(server, "GET").let { request -> + parseQueryParams(request).let { params -> + assertEquals("xml", params["format"]) + assertEquals("query", params["action"]) + assertEquals("imageinfo", params["prop"]) + assertEquals("File:foo", params["titles"]) + } + } + + assertFalse(result) + } + @Test fun isUserBlockedFromCommonsForInfinitelyBlockedUser() { server.enqueue(MockResponse().setBody("")) diff --git a/app/src/test/kotlin/fr/free/nrw/commons/mwapi/OkHttpJsonApiClientTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/mwapi/OkHttpJsonApiClientTest.kt index 2a1d49bfa..822277028 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/mwapi/OkHttpJsonApiClientTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/mwapi/OkHttpJsonApiClientTest.kt @@ -4,6 +4,7 @@ import com.google.gson.Gson import fr.free.nrw.commons.Media import fr.free.nrw.commons.TestCommonsApplication import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient.mapType import fr.free.nrw.commons.utils.CommonsDateUtil import junit.framework.Assert.assertEquals import okhttp3.HttpUrl @@ -17,6 +18,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito +import org.mockito.Mockito.`when` import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import java.util.* @@ -53,7 +55,7 @@ class OkHttpJsonApiClientTest { val sparqlUrl = "http://" + sparqlServer.hostName + ":" + sparqlServer.port + "/" val campaignsUrl = "http://" + campaignsServer.hostName + ":" + campaignsServer.port + "/" val serverUrl = "http://" + server.hostName + ":" + server.port + "/" - testObject = OkHttpJsonApiClient(okHttpClient, HttpUrl.get(toolsForgeUrl), sparqlUrl, campaignsUrl, serverUrl, Gson()) + testObject = OkHttpJsonApiClient(okHttpClient, HttpUrl.get(toolsForgeUrl), sparqlUrl, campaignsUrl, serverUrl, sharedPreferences, Gson()) } /** @@ -67,6 +69,231 @@ class OkHttpJsonApiClientTest { campaignsServer.shutdown() } + /** + * Test response for category images + */ + @Test + fun getCategoryImages() { + server.enqueue(getFirstPageOfImages()) + testFirstPageQuery() + } + + /** + * test paginated response for category images + */ + @Test + fun getCategoryImagesWithContinue() { + server.enqueue(getFirstPageOfImages()) + server.enqueue(getSecondPageOfImages()) + testFirstPageQuery() + + `when`(sharedPreferences.getJson>("query_continue_Watercraft moored off shore", mapType)) + .thenReturn(hashMapOf(Pair("gcmcontinue", "testvalue"), Pair("continue", "gcmcontinue||"))) + + + val categoryImagesContinued = testObject.getMediaList("category", "Watercraft moored off shore")!!.blockingGet() + + assertBasicRequestParameters(server, "GET").let { request -> + parseQueryParams(request).let { body -> + Assert.assertEquals("json", body["format"]) + Assert.assertEquals("2", body["formatversion"]) + Assert.assertEquals("query", body["action"]) + Assert.assertEquals("categorymembers", body["generator"]) + Assert.assertEquals("file", body["gcmtype"]) + Assert.assertEquals("Watercraft moored off shore", body["gcmtitle"]) + Assert.assertEquals("timestamp", body["gcmsort"]) + Assert.assertEquals("desc", body["gcmdir"]) + Assert.assertEquals("testvalue", body["gcmcontinue"]) + Assert.assertEquals("gcmcontinue||", body["continue"]) + Assert.assertEquals("imageinfo", body["prop"]) + Assert.assertEquals("url|extmetadata", body["iiprop"]) + Assert.assertEquals("DateTime|Categories|GPSLatitude|GPSLongitude|ImageDescription|DateTimeOriginal|Artist|LicenseShortName|LicenseUrl", body["iiextmetadatafilter"]) + } + } + + assertEquals(categoryImagesContinued.size, 2) + } + + /** + * Test response for search images + */ + @Test + fun getSearchImages() { + server.enqueue(getFirstPageOfImages()) + testFirstPageSearchQuery() + } + + /** + * Test response for paginated search + */ + @Test + fun getSearchImagesWithContinue() { + server.enqueue(getFirstPageOfSearchImages()) + server.enqueue(getSecondPageOfSearchImages()) + testFirstPageSearchQuery() + + `when`(sharedPreferences.getJson>("query_continue_Watercraft moored off shore", mapType)) + .thenReturn(hashMapOf(Pair("gsroffset", "25"), Pair("continue", "gsroffset||"))) + + + val categoryImagesContinued = testObject.getMediaList("search", "Watercraft moored off shore")!!.blockingGet() + + assertBasicRequestParameters(server, "GET").let { request -> + parseQueryParams(request).let { body -> + Assert.assertEquals("json", body["format"]) + Assert.assertEquals("2", body["formatversion"]) + Assert.assertEquals("query", body["action"]) + Assert.assertEquals("search", body["generator"]) + Assert.assertEquals("text", body["gsrwhat"]) + Assert.assertEquals("6", body["gsrnamespace"]) + Assert.assertEquals("25", body["gsrlimit"]) + Assert.assertEquals("Watercraft moored off shore", body["gsrsearch"]) + Assert.assertEquals("25", body["gsroffset"]) + Assert.assertEquals("gsroffset||", body["continue"]) + Assert.assertEquals("imageinfo", body["prop"]) + Assert.assertEquals("url|extmetadata", body["iiprop"]) + Assert.assertEquals("DateTime|Categories|GPSLatitude|GPSLongitude|ImageDescription|DateTimeOriginal|Artist|LicenseShortName|LicenseUrl", body["iiextmetadatafilter"]) + } + } + + assertEquals(categoryImagesContinued.size, 2) + } + + /** + * Test response for getting media without generator + */ + @Test + fun getMedia() { + server.enqueue(getMediaList("", "", "", 1)) + + val media = testObject.getMedia("Test.jpg", false)!!.blockingGet() + + assertBasicRequestParameters(server, "GET").let { request -> + parseQueryParams(request).let { body -> + Assert.assertEquals("json", body["format"]) + Assert.assertEquals("2", body["formatversion"]) + Assert.assertEquals("query", body["action"]) + Assert.assertEquals("Test.jpg", body["titles"]) + Assert.assertEquals("imageinfo", body["prop"]) + Assert.assertEquals("url|extmetadata", body["iiprop"]) + Assert.assertEquals("DateTime|Categories|GPSLatitude|GPSLongitude|ImageDescription|DateTimeOriginal|Artist|LicenseShortName|LicenseUrl", body["iiextmetadatafilter"]) + } + } + + assert(media is Media) + } + + /** + * Test response for getting media with generator + * Equivalent of testing POTD + */ + @Test + fun getImageWithGenerator() { + val template = "Template:Potd/" + CommonsDateUtil.getIso8601DateFormatShort().format(Date()) + server.enqueue(getMediaList("", "", "", 1)) + + val media = testObject.getMedia(template, true)!!.blockingGet() + + assertBasicRequestParameters(server, "GET").let { request -> + parseQueryParams(request).let { body -> + Assert.assertEquals("json", body["format"]) + Assert.assertEquals("2", body["formatversion"]) + Assert.assertEquals("query", body["action"]) + Assert.assertEquals(template, body["titles"]) + Assert.assertEquals("images", body["generator"]) + Assert.assertEquals("imageinfo", body["prop"]) + Assert.assertEquals("url|extmetadata", body["iiprop"]) + Assert.assertEquals("DateTime|Categories|GPSLatitude|GPSLongitude|ImageDescription|DateTimeOriginal|Artist|LicenseShortName|LicenseUrl", body["iiextmetadatafilter"]) + } + } + + assert(media is Media) + } + + /** + * Test response for getting picture of the day + */ + @Test + fun getPictureOfTheDay() { + val template = "Template:Potd/" + CommonsDateUtil.getIso8601DateFormatShort().format(Date()) + server.enqueue(getMediaList("", "", "", 1)) + + val media = testObject.pictureOfTheDay?.blockingGet() + + assertBasicRequestParameters(server, "GET").let { request -> + parseQueryParams(request).let { body -> + Assert.assertEquals("json", body["format"]) + Assert.assertEquals("2", body["formatversion"]) + Assert.assertEquals("query", body["action"]) + Assert.assertEquals(template, body["titles"]) + Assert.assertEquals("images", body["generator"]) + Assert.assertEquals("imageinfo", body["prop"]) + Assert.assertEquals("url|extmetadata", body["iiprop"]) + Assert.assertEquals("DateTime|Categories|GPSLatitude|GPSLongitude|ImageDescription|DateTimeOriginal|Artist|LicenseShortName|LicenseUrl", body["iiextmetadatafilter"]) + } + } + + assert(media is Media) + } + + private fun testFirstPageSearchQuery() { + val categoryImages = testObject.getMediaList("search", "Watercraft moored off shore")!!.blockingGet() + + assertBasicRequestParameters(server, "GET").let { request -> + parseQueryParams(request).let { body -> + Assert.assertEquals("json", body["format"]) + Assert.assertEquals("2", body["formatversion"]) + Assert.assertEquals("query", body["action"]) + Assert.assertEquals("search", body["generator"]) + Assert.assertEquals("text", body["gsrwhat"]) + Assert.assertEquals("6", body["gsrnamespace"]) + Assert.assertEquals("25", body["gsrlimit"]) + Assert.assertEquals("Watercraft moored off shore", body["gsrsearch"]) + Assert.assertEquals("imageinfo", body["prop"]) + Assert.assertEquals("url|extmetadata", body["iiprop"]) + Assert.assertEquals("DateTime|Categories|GPSLatitude|GPSLongitude|ImageDescription|DateTimeOriginal|Artist|LicenseShortName|LicenseUrl", body["iiextmetadatafilter"]) + } + } + assertEquals(categoryImages.size, 2) + } + + private fun testFirstPageQuery() { + val categoryImages = testObject.getMediaList("category", "Watercraft moored off shore")?.blockingGet() + + assertBasicRequestParameters(server, "GET").let { request -> + parseQueryParams(request).let { body -> + Assert.assertEquals("json", body["format"]) + Assert.assertEquals("2", body["formatversion"]) + Assert.assertEquals("query", body["action"]) + Assert.assertEquals("categorymembers", body["generator"]) + Assert.assertEquals("file", body["gcmtype"]) + Assert.assertEquals("Watercraft moored off shore", body["gcmtitle"]) + Assert.assertEquals("timestamp", body["gcmsort"]) + Assert.assertEquals("desc", body["gcmdir"]) + Assert.assertEquals("imageinfo", body["prop"]) + Assert.assertEquals("url|extmetadata", body["iiprop"]) + Assert.assertEquals("DateTime|Categories|GPSLatitude|GPSLongitude|ImageDescription|DateTimeOriginal|Artist|LicenseShortName|LicenseUrl", body["iiextmetadatafilter"]) + } + } + assertEquals(categoryImages?.size, 2) + } + + private fun getFirstPageOfImages(): MockResponse { + return getMediaList("gcmcontinue", "testvalue", "gcmcontinue||", 2) + } + + private fun getSecondPageOfImages(): MockResponse { + return getMediaList("gcmcontinue", "testvalue2", "gcmcontinue||", 2) + } + + private fun getFirstPageOfSearchImages(): MockResponse { + return getMediaList("gsroffset", "25", "gsroffset||", 2) + } + + private fun getSecondPageOfSearchImages(): MockResponse { + return getMediaList("gsroffset", "25", "gsroffset||", 2) + } + /** * Generate a MockResponse object which contains a list of media pages */ diff --git a/app/src/test/kotlin/fr/free/nrw/commons/review/ReviewHelperTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/review/ReviewHelperTest.kt index f796e2cff..ebd5d4baf 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/review/ReviewHelperTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/review/ReviewHelperTest.kt @@ -1,7 +1,6 @@ package fr.free.nrw.commons.review import fr.free.nrw.commons.Media -import fr.free.nrw.commons.media.MediaClient import fr.free.nrw.commons.mwapi.MediaWikiApi import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient import io.reactivex.Observable @@ -29,7 +28,9 @@ class ReviewHelperTest { @Mock internal var reviewInterface: ReviewInterface? = null @Mock - internal var mediaClient: MediaClient? = null + internal var okHttpJsonApiClient: OkHttpJsonApiClient? = null + @Mock + internal var mediaWikiApi: MediaWikiApi? = null @InjectMocks var reviewHelper: ReviewHelper? = null @@ -64,7 +65,7 @@ class ReviewHelperTest { val media = mock(Media::class.java) `when`(media.filename).thenReturn("File:Test.jpg") - `when`(mediaClient?.getMedia(ArgumentMatchers.anyString())) + `when`(okHttpJsonApiClient?.getMedia(ArgumentMatchers.anyString(), ArgumentMatchers.anyBoolean())) .thenReturn(Single.just(media)) } @@ -73,7 +74,7 @@ class ReviewHelperTest { */ @Test fun getRandomMedia() { - `when`(mediaClient?.checkPageExistsUsingTitle(ArgumentMatchers.anyString())) + `when`(mediaWikiApi?.pageExists(ArgumentMatchers.anyString())) .thenReturn(Single.just(false)) val randomMedia = reviewHelper?.randomMedia?.blockingGet() @@ -88,7 +89,7 @@ class ReviewHelperTest { */ @Test(expected = RuntimeException::class) fun getRandomMediaWithWithAllMediaNominatedForDeletion() { - `when`(mediaClient?.checkPageExistsUsingTitle(ArgumentMatchers.anyString())) + `when`(mediaWikiApi?.pageExists(ArgumentMatchers.anyString())) .thenReturn(Single.just(true)) val media = reviewHelper?.randomMedia?.blockingGet() assertNull(media) @@ -100,11 +101,11 @@ class ReviewHelperTest { */ @Test fun getRandomMediaWithWithOneMediaNominatedForDeletion() { - `when`(mediaClient?.checkPageExistsUsingTitle("Commons:Deletion_requests/File:Test1.jpeg")) + `when`(mediaWikiApi?.pageExists("Commons:Deletion_requests/File:Test1.jpeg")) .thenReturn(Single.just(true)) - `when`(mediaClient?.checkPageExistsUsingTitle("Commons:Deletion_requests/File:Test2.png")) + `when`(mediaWikiApi?.pageExists("Commons:Deletion_requests/File:Test2.png")) .thenReturn(Single.just(false)) - `when`(mediaClient?.checkPageExistsUsingTitle("Commons:Deletion_requests/File:Test3.jpg")) + `when`(mediaWikiApi?.pageExists("Commons:Deletion_requests/File:Test3.jpg")) .thenReturn(Single.just(true)) val media = reviewHelper?.randomMedia?.blockingGet() diff --git a/app/src/test/kotlin/fr/free/nrw/commons/upload/ImageProcessingServiceTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/upload/ImageProcessingServiceTest.kt index a7cfdba88..93cda48c2 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/upload/ImageProcessingServiceTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/upload/ImageProcessingServiceTest.kt @@ -2,7 +2,6 @@ package fr.free.nrw.commons.upload import android.net.Uri import fr.free.nrw.commons.location.LatLng -import fr.free.nrw.commons.media.MediaClient import fr.free.nrw.commons.mwapi.MediaWikiApi import fr.free.nrw.commons.nearby.Place import fr.free.nrw.commons.utils.ImageUtils @@ -29,8 +28,6 @@ class u { internal var readFBMD: ReadFBMD?=null @Mock internal var readEXIF: EXIFReader?=null - @Mock - internal var mediaClient: MediaClient? = null @InjectMocks var imageProcessingService: ImageProcessingService? = null @@ -58,7 +55,6 @@ class u { `when`(uploadItem.title).thenReturn(mockTitle) `when`(uploadItem.place).thenReturn(mockPlace) - `when`(uploadItem.fileName).thenReturn("File:jpg") `when`(fileUtilsWrapper!!.getFileInputStream(ArgumentMatchers.anyString())) .thenReturn(mock(FileInputStream::class.java)) @@ -78,10 +74,10 @@ class u { .thenReturn(mock(FileInputStream::class.java)) `when`(fileUtilsWrapper!!.getSHA1(any(FileInputStream::class.java))) .thenReturn("fileSha") - `when`(mediaClient!!.checkFileExistsUsingSha(ArgumentMatchers.anyString())) - .thenReturn(Single.just(false)) - `when`(mediaClient?.checkPageExistsUsingTitle(ArgumentMatchers.anyString())) - .thenReturn(Single.just(false)) + `when`(mwApi!!.existingFile(ArgumentMatchers.anyString())) + .thenReturn(false) + `when`(mwApi!!.fileExistsWithName(ArgumentMatchers.anyString())) + .thenReturn(false) `when`(readFBMD?.processMetadata(ArgumentMatchers.any(),ArgumentMatchers.any())) .thenReturn(Single.just(ImageUtils.IMAGE_OK)) `when`(readEXIF?.processMetadata(ArgumentMatchers.anyString())) @@ -97,8 +93,8 @@ class u { @Test fun validateImageForDuplicateImage() { - `when`(mediaClient!!.checkFileExistsUsingSha(ArgumentMatchers.anyString())) - .thenReturn(Single.just(true)) + `when`(mwApi!!.existingFile(ArgumentMatchers.anyString())) + .thenReturn(true) val validateImage = imageProcessingService!!.validateImage(uploadItem, false) assertEquals(ImageUtils.IMAGE_DUPLICATE, validateImage.blockingGet()) } @@ -127,16 +123,16 @@ class u { @Test fun validateImageForFileNameExistsWithCheckTitleOff() { - `when`(mediaClient?.checkPageExistsUsingTitle(ArgumentMatchers.anyString())) - .thenReturn(Single.just(true)) + `when`(mwApi!!.fileExistsWithName(ArgumentMatchers.anyString())) + .thenReturn(true) val validateImage = imageProcessingService!!.validateImage(uploadItem, false) assertEquals(ImageUtils.IMAGE_OK, validateImage.blockingGet()) } @Test fun validateImageForFileNameExistsWithCheckTitleOn() { - `when`(mediaClient?.checkPageExistsUsingTitle(ArgumentMatchers.anyString())) - .thenReturn(Single.just(true)) + `when`(mwApi!!.fileExistsWithName(ArgumentMatchers.nullable(String::class.java))) + .thenReturn(true) val validateImage = imageProcessingService!!.validateImage(uploadItem, true) assertEquals(ImageUtils.FILE_NAME_EXISTS, validateImage.blockingGet()) } diff --git a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadPresenterTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadPresenterTest.kt index b54ed3b6d..9800d5f92 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadPresenterTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadPresenterTest.kt @@ -64,7 +64,6 @@ class UploadPresenterTest { verify(repository)?.prepareService() verify(view)?.showProgress(false) verify(view)?.showMessage(ArgumentMatchers.any(Int::class.java)) - verify(view)?.finish() true } }