mirror of
https://github.com/commons-app/apps-android-commons.git
synced 2025-10-27 12:53:55 +01:00
Make 2FA login work with commons
This commit is contained in:
parent
022ac948a8
commit
1ea0a1ebeb
9 changed files with 271 additions and 209 deletions
|
|
@ -19,6 +19,8 @@ import static fr.free.nrw.commons.modifications.ModificationsContentProvider.MOD
|
||||||
public class AccountUtil {
|
public class AccountUtil {
|
||||||
|
|
||||||
public static final String ACCOUNT_TYPE = "fr.free.nrw.commons";
|
public static final String ACCOUNT_TYPE = "fr.free.nrw.commons";
|
||||||
|
public static final String AUTH_COOKIE = "authCookie";
|
||||||
|
public static final String AUTH_TOKEN_TYPE = "CommonsAndroid";
|
||||||
private final Context context;
|
private final Context context;
|
||||||
|
|
||||||
public AccountUtil(Context context) {
|
public AccountUtil(Context context) {
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,35 @@
|
||||||
package fr.free.nrw.commons.auth;
|
package fr.free.nrw.commons.auth;
|
||||||
|
|
||||||
import android.accounts.Account;
|
|
||||||
import android.accounts.AccountManager;
|
|
||||||
import android.accounts.AccountManagerFuture;
|
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
|
||||||
import javax.inject.Inject;
|
import javax.inject.Inject;
|
||||||
|
|
||||||
|
import fr.free.nrw.commons.mwapi.MediaWikiApi;
|
||||||
import fr.free.nrw.commons.theme.NavigationBaseActivity;
|
import fr.free.nrw.commons.theme.NavigationBaseActivity;
|
||||||
import io.reactivex.Single;
|
|
||||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||||
import io.reactivex.schedulers.Schedulers;
|
import io.reactivex.schedulers.Schedulers;
|
||||||
import timber.log.Timber;
|
|
||||||
|
|
||||||
import static android.accounts.AccountManager.KEY_ACCOUNT_NAME;
|
import static fr.free.nrw.commons.auth.AccountUtil.AUTH_COOKIE;
|
||||||
import static fr.free.nrw.commons.auth.AccountUtil.ACCOUNT_TYPE;
|
|
||||||
|
|
||||||
public abstract class AuthenticatedActivity extends NavigationBaseActivity {
|
public abstract class AuthenticatedActivity extends NavigationBaseActivity {
|
||||||
|
|
||||||
@Inject SessionManager sessionManager;
|
@Inject SessionManager sessionManager;
|
||||||
|
@Inject
|
||||||
|
MediaWikiApi mediaWikiApi;
|
||||||
private String authCookie;
|
private String authCookie;
|
||||||
|
|
||||||
|
|
||||||
private void getAuthCookie(Account account, AccountManager accountManager) {
|
|
||||||
Single.fromCallable(() -> accountManager.blockingGetAuthToken(account, "", false))
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.doOnError(Timber::e)
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(
|
|
||||||
this:: onAuthCookieAcquired,
|
|
||||||
throwable -> onAuthFailure());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void addAccount(AccountManager accountManager) {
|
|
||||||
Single.just(accountManager.addAccount(ACCOUNT_TYPE, null, null,
|
|
||||||
null, AuthenticatedActivity.this, null, null))
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.map(AccountManagerFuture::getResult)
|
|
||||||
.doOnEvent((bundle, throwable) -> {
|
|
||||||
if (!bundle.containsKey(KEY_ACCOUNT_NAME)) {
|
|
||||||
throw new RuntimeException("Bundle doesn't contain account-name key: "
|
|
||||||
+ KEY_ACCOUNT_NAME);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.map(bundle -> bundle.getString(KEY_ACCOUNT_NAME))
|
|
||||||
.doOnError(Timber::e)
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(s -> {
|
|
||||||
Account[] allAccounts = accountManager.getAccountsByType(ACCOUNT_TYPE);
|
|
||||||
Account curAccount = allAccounts[0];
|
|
||||||
getAuthCookie(curAccount, accountManager);
|
|
||||||
},
|
|
||||||
throwable -> onAuthFailure());
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void requestAuthToken() {
|
protected void requestAuthToken() {
|
||||||
if (authCookie != null) {
|
if (authCookie != null) {
|
||||||
onAuthCookieAcquired(authCookie);
|
onAuthCookieAcquired(authCookie);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
AccountManager accountManager = AccountManager.get(this);
|
sessionManager.getAndSetAuthCookie()
|
||||||
Account curAccount = sessionManager.getCurrentAccount();
|
.subscribeOn(Schedulers.newThread())
|
||||||
if (curAccount == null) {
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
addAccount(accountManager);
|
.subscribe(cookie -> {
|
||||||
} else {
|
authCookie = cookie;
|
||||||
getAuthCookie(curAccount, accountManager);
|
onAuthCookieAcquired(authCookie);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -74,14 +37,14 @@ public abstract class AuthenticatedActivity extends NavigationBaseActivity {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
|
||||||
if (savedInstanceState != null) {
|
if (savedInstanceState != null) {
|
||||||
authCookie = savedInstanceState.getString("authCookie");
|
authCookie = savedInstanceState.getString(AUTH_COOKIE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onSaveInstanceState(Bundle outState) {
|
protected void onSaveInstanceState(Bundle outState) {
|
||||||
super.onSaveInstanceState(outState);
|
super.onSaveInstanceState(outState);
|
||||||
outState.putString("authCookie", authCookie);
|
outState.putString(AUTH_COOKIE, authCookie);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract void onAuthCookieAcquired(String authCookie);
|
protected abstract void onAuthCookieAcquired(String authCookie);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package fr.free.nrw.commons.auth;
|
package fr.free.nrw.commons.auth;
|
||||||
|
|
||||||
|
import android.accounts.Account;
|
||||||
import android.accounts.AccountAuthenticatorActivity;
|
import android.accounts.AccountAuthenticatorActivity;
|
||||||
|
import android.accounts.AccountAuthenticatorResponse;
|
||||||
|
import android.accounts.AccountManager;
|
||||||
import android.app.ProgressDialog;
|
import android.app.ProgressDialog;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
|
|
@ -8,6 +11,7 @@ import android.os.Bundle;
|
||||||
import android.support.annotation.ColorRes;
|
import android.support.annotation.ColorRes;
|
||||||
import android.support.annotation.NonNull;
|
import android.support.annotation.NonNull;
|
||||||
import android.support.annotation.StringRes;
|
import android.support.annotation.StringRes;
|
||||||
|
import android.support.design.widget.TextInputLayout;
|
||||||
import android.support.v4.app.NavUtils;
|
import android.support.v4.app.NavUtils;
|
||||||
import android.support.v4.content.ContextCompat;
|
import android.support.v4.content.ContextCompat;
|
||||||
import android.support.v7.app.AppCompatDelegate;
|
import android.support.v7.app.AppCompatDelegate;
|
||||||
|
|
@ -21,6 +25,8 @@ import android.widget.Button;
|
||||||
import android.widget.EditText;
|
import android.widget.EditText;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
import javax.inject.Inject;
|
import javax.inject.Inject;
|
||||||
import javax.inject.Named;
|
import javax.inject.Named;
|
||||||
|
|
||||||
|
|
@ -36,10 +42,16 @@ import fr.free.nrw.commons.contributions.ContributionsActivity;
|
||||||
import fr.free.nrw.commons.di.ApplicationlessInjection;
|
import fr.free.nrw.commons.di.ApplicationlessInjection;
|
||||||
import fr.free.nrw.commons.mwapi.MediaWikiApi;
|
import fr.free.nrw.commons.mwapi.MediaWikiApi;
|
||||||
import fr.free.nrw.commons.theme.NavigationBaseActivity;
|
import fr.free.nrw.commons.theme.NavigationBaseActivity;
|
||||||
|
import io.reactivex.Observable;
|
||||||
|
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||||
|
import io.reactivex.schedulers.Schedulers;
|
||||||
import timber.log.Timber;
|
import timber.log.Timber;
|
||||||
|
|
||||||
import static android.view.KeyEvent.KEYCODE_ENTER;
|
import static android.view.KeyEvent.KEYCODE_ENTER;
|
||||||
|
import static android.view.View.VISIBLE;
|
||||||
import static android.view.inputmethod.EditorInfo.IME_ACTION_DONE;
|
import static android.view.inputmethod.EditorInfo.IME_ACTION_DONE;
|
||||||
|
import static fr.free.nrw.commons.auth.AccountUtil.ACCOUNT_TYPE;
|
||||||
|
import static fr.free.nrw.commons.auth.AccountUtil.AUTH_TOKEN_TYPE;
|
||||||
|
|
||||||
public class LoginActivity extends AccountAuthenticatorActivity {
|
public class LoginActivity extends AccountAuthenticatorActivity {
|
||||||
|
|
||||||
|
|
@ -58,6 +70,7 @@ public class LoginActivity extends AccountAuthenticatorActivity {
|
||||||
@BindView(R.id.loginTwoFactor) EditText twoFactorEdit;
|
@BindView(R.id.loginTwoFactor) EditText twoFactorEdit;
|
||||||
@BindView(R.id.error_message_container) ViewGroup errorMessageContainer;
|
@BindView(R.id.error_message_container) ViewGroup errorMessageContainer;
|
||||||
@BindView(R.id.error_message) TextView errorMessage;
|
@BindView(R.id.error_message) TextView errorMessage;
|
||||||
|
@BindView(R.id.two_factor_container)TextInputLayout twoFactorContainer;
|
||||||
ProgressDialog progressDialog;
|
ProgressDialog progressDialog;
|
||||||
private AppCompatDelegate delegate;
|
private AppCompatDelegate delegate;
|
||||||
private LoginTextWatcher textWatcher = new LoginTextWatcher();
|
private LoginTextWatcher textWatcher = new LoginTextWatcher();
|
||||||
|
|
@ -122,14 +135,109 @@ public class LoginActivity extends AccountAuthenticatorActivity {
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
private LoginTask getLoginTask() {
|
private void performLogin() {
|
||||||
return new LoginTask(
|
Timber.d("Login to start!");
|
||||||
this,
|
final String username = canonicializeUsername(usernameEdit.getText().toString());
|
||||||
canonicializeUsername(usernameEdit.getText().toString()),
|
final String password = passwordEdit.getText().toString();
|
||||||
passwordEdit.getText().toString(),
|
String twoFactorCode = twoFactorEdit.getText().toString();
|
||||||
twoFactorEdit.getText().toString(),
|
|
||||||
accountUtil, mwApi, defaultPrefs
|
showLoggingProgressBar();
|
||||||
);
|
Observable.fromCallable(() -> login(username, password, twoFactorCode))
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(result -> handleLogin(username, 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleLogin(String username, String password, String result) {
|
||||||
|
Timber.d("Login done!");
|
||||||
|
if (result.equals("PASS")) {
|
||||||
|
handlePassResult(username, password);
|
||||||
|
} else {
|
||||||
|
handleOtherResults(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showLoggingProgressBar() {
|
||||||
|
progressDialog = new ProgressDialog(this);
|
||||||
|
progressDialog.setIndeterminate(true);
|
||||||
|
progressDialog.setTitle(getString(R.string.logging_in_title));
|
||||||
|
progressDialog.setMessage(getString(R.string.logging_in_message));
|
||||||
|
progressDialog.setCanceledOnTouchOutside(false);
|
||||||
|
progressDialog.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handlePassResult(String username, 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, ACCOUNT_TYPE);
|
||||||
|
response.onResult(authResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accountUtil.createAccount(response, username, 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().contains("nosuchuser".toLowerCase()) || result.toLowerCase().contains("noname".toLowerCase())) {
|
||||||
|
// Matches nosuchuser, nosuchusershort, noname
|
||||||
|
showMessageAndCancelDialog(R.string.login_failed_username);
|
||||||
|
emptySensitiveEditFields();
|
||||||
|
} else if (result.toLowerCase().contains("wrongpassword".toLowerCase())) {
|
||||||
|
// Matches wrongpassword, wrongpasswordempty
|
||||||
|
showMessageAndCancelDialog(R.string.login_failed_password);
|
||||||
|
emptySensitiveEditFields();
|
||||||
|
} else if (result.toLowerCase().contains("throttle".toLowerCase())) {
|
||||||
|
// Matches unknown throttle error codes
|
||||||
|
showMessageAndCancelDialog(R.string.login_failed_throttled);
|
||||||
|
} else if (result.toLowerCase().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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -182,7 +290,8 @@ public class LoginActivity extends AccountAuthenticatorActivity {
|
||||||
|
|
||||||
public void askUserForTwoFactorAuth() {
|
public void askUserForTwoFactorAuth() {
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
twoFactorEdit.setVisibility(View.VISIBLE);
|
twoFactorContainer.setVisibility(VISIBLE);
|
||||||
|
twoFactorEdit.setVisibility(VISIBLE);
|
||||||
showMessageAndCancelDialog(R.string.login_failed_2fa_needed);
|
showMessageAndCancelDialog(R.string.login_failed_2fa_needed);
|
||||||
} else {
|
} else {
|
||||||
showMessageAndCancelDialog(R.string.login_failed_2fa_not_supported);
|
showMessageAndCancelDialog(R.string.login_failed_2fa_not_supported);
|
||||||
|
|
@ -209,12 +318,6 @@ public class LoginActivity extends AccountAuthenticatorActivity {
|
||||||
finish();
|
finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void performLogin() {
|
|
||||||
Timber.d("Login to start!");
|
|
||||||
LoginTask task = getLoginTask();
|
|
||||||
task.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void signUp() {
|
private void signUp() {
|
||||||
Intent intent = new Intent(this, SignupActivity.class);
|
Intent intent = new Intent(this, SignupActivity.class);
|
||||||
startActivity(intent);
|
startActivity(intent);
|
||||||
|
|
@ -238,7 +341,7 @@ public class LoginActivity extends AccountAuthenticatorActivity {
|
||||||
private void showMessage(@StringRes int resId, @ColorRes int colorResId) {
|
private void showMessage(@StringRes int resId, @ColorRes int colorResId) {
|
||||||
errorMessage.setText(getString(resId));
|
errorMessage.setText(getString(resId));
|
||||||
errorMessage.setTextColor(ContextCompat.getColor(this, colorResId));
|
errorMessage.setTextColor(ContextCompat.getColor(this, colorResId));
|
||||||
errorMessageContainer.setVisibility(View.VISIBLE);
|
errorMessageContainer.setVisibility(VISIBLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
private AppCompatDelegate getDelegate() {
|
private AppCompatDelegate getDelegate() {
|
||||||
|
|
@ -260,7 +363,7 @@ public class LoginActivity extends AccountAuthenticatorActivity {
|
||||||
@Override
|
@Override
|
||||||
public void afterTextChanged(Editable editable) {
|
public void afterTextChanged(Editable editable) {
|
||||||
boolean enabled = usernameEdit.getText().length() != 0 && passwordEdit.getText().length() != 0
|
boolean enabled = usernameEdit.getText().length() != 0 && passwordEdit.getText().length() != 0
|
||||||
&& (BuildConfig.DEBUG || twoFactorEdit.getText().length() != 0 || twoFactorEdit.getVisibility() != View.VISIBLE);
|
&& (BuildConfig.DEBUG || twoFactorEdit.getText().length() != 0 || twoFactorEdit.getVisibility() != VISIBLE);
|
||||||
loginButton.setEnabled(enabled);
|
loginButton.setEnabled(enabled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,16 @@ package fr.free.nrw.commons.auth;
|
||||||
|
|
||||||
import android.accounts.Account;
|
import android.accounts.Account;
|
||||||
import android.accounts.AccountManager;
|
import android.accounts.AccountManager;
|
||||||
import android.accounts.AuthenticatorException;
|
|
||||||
import android.accounts.OperationCanceledException;
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
|
|
||||||
import fr.free.nrw.commons.mwapi.MediaWikiApi;
|
import fr.free.nrw.commons.mwapi.MediaWikiApi;
|
||||||
import io.reactivex.Completable;
|
import io.reactivex.Completable;
|
||||||
import io.reactivex.Observable;
|
import io.reactivex.Observable;
|
||||||
|
import io.reactivex.schedulers.Schedulers;
|
||||||
|
import timber.log.Timber;
|
||||||
|
|
||||||
import static fr.free.nrw.commons.auth.AccountUtil.ACCOUNT_TYPE;
|
import static fr.free.nrw.commons.auth.AccountUtil.ACCOUNT_TYPE;
|
||||||
|
import static fr.free.nrw.commons.auth.AccountUtil.AUTH_TOKEN_TYPE;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manage the current logged in user session.
|
* Manage the current logged in user session.
|
||||||
|
|
@ -51,14 +50,31 @@ public class SessionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
accountManager.invalidateAuthToken(ACCOUNT_TYPE, mediaWikiApi.getAuthCookie());
|
accountManager.invalidateAuthToken(ACCOUNT_TYPE, mediaWikiApi.getAuthCookie());
|
||||||
try {
|
getAndSetAuthCookie().subscribeOn(Schedulers.io())
|
||||||
String authCookie = accountManager.blockingGetAuthToken(curAccount, "", false);
|
.subscribe(authCookie -> {
|
||||||
mediaWikiApi.setAuthCookie(authCookie);
|
mediaWikiApi.setAuthCookie(authCookie);
|
||||||
return true;
|
});
|
||||||
} catch (OperationCanceledException | NullPointerException | IOException | AuthenticatorException e) {
|
return true;
|
||||||
e.printStackTrace();
|
}
|
||||||
return false;
|
|
||||||
}
|
public Observable<String> getAndSetAuthCookie() {
|
||||||
|
AccountManager accountManager = AccountManager.get(context);
|
||||||
|
Account curAccount = getCurrentAccount();
|
||||||
|
return Observable.fromCallable(() -> {
|
||||||
|
String authCookie = accountManager.blockingGetAuthToken(curAccount, AUTH_TOKEN_TYPE, false);
|
||||||
|
if (authCookie == null) {
|
||||||
|
Timber.d("Media wiki auth cookie is %s", mediaWikiApi.getAuthCookie());
|
||||||
|
authCookie = mediaWikiApi.getAuthCookie();
|
||||||
|
//authCookie = currentAccount.name + "|" + currentAccount.type + "|" + mediaWikiApi.getUserAgent();
|
||||||
|
//mediaWikiApi.setAuthCookie(authCookie);
|
||||||
|
|
||||||
|
}
|
||||||
|
Timber.d("Auth cookie is %s", authCookie);
|
||||||
|
return authCookie;
|
||||||
|
}).onErrorReturn(throwable-> {
|
||||||
|
Timber.e(throwable, "Auth cookie is still null :(");
|
||||||
|
return null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public Completable clearAllAccounts() {
|
public Completable clearAllAccounts() {
|
||||||
|
|
|
||||||
|
|
@ -5,53 +5,37 @@ import android.accounts.Account;
|
||||||
import android.accounts.AccountAuthenticatorResponse;
|
import android.accounts.AccountAuthenticatorResponse;
|
||||||
import android.accounts.AccountManager;
|
import android.accounts.AccountManager;
|
||||||
import android.accounts.NetworkErrorException;
|
import android.accounts.NetworkErrorException;
|
||||||
|
import android.content.ContentResolver;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.support.annotation.NonNull;
|
import android.support.annotation.NonNull;
|
||||||
import android.support.annotation.Nullable;
|
import android.support.annotation.Nullable;
|
||||||
|
|
||||||
import java.io.IOException;
|
import fr.free.nrw.commons.contributions.ContributionsContentProvider;
|
||||||
|
import fr.free.nrw.commons.modifications.ModificationsContentProvider;
|
||||||
|
|
||||||
import fr.free.nrw.commons.mwapi.MediaWikiApi;
|
|
||||||
|
|
||||||
import static android.accounts.AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION;
|
|
||||||
import static android.accounts.AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE;
|
|
||||||
import static android.accounts.AccountManager.KEY_ACCOUNT_NAME;
|
|
||||||
import static android.accounts.AccountManager.KEY_ACCOUNT_TYPE;
|
|
||||||
import static android.accounts.AccountManager.KEY_AUTHTOKEN;
|
|
||||||
import static android.accounts.AccountManager.KEY_BOOLEAN_RESULT;
|
|
||||||
import static android.accounts.AccountManager.KEY_ERROR_CODE;
|
|
||||||
import static android.accounts.AccountManager.KEY_ERROR_MESSAGE;
|
|
||||||
import static android.accounts.AccountManager.KEY_INTENT;
|
|
||||||
import static fr.free.nrw.commons.auth.AccountUtil.ACCOUNT_TYPE;
|
import static fr.free.nrw.commons.auth.AccountUtil.ACCOUNT_TYPE;
|
||||||
import static fr.free.nrw.commons.auth.LoginActivity.PARAM_USERNAME;
|
import static fr.free.nrw.commons.auth.AccountUtil.AUTH_TOKEN_TYPE;
|
||||||
|
|
||||||
public class WikiAccountAuthenticator extends AbstractAccountAuthenticator {
|
public class WikiAccountAuthenticator extends AbstractAccountAuthenticator {
|
||||||
|
private static final String[] SYNC_AUTHORITIES = {ContributionsContentProvider.AUTHORITY, ModificationsContentProvider.AUTHORITY};
|
||||||
|
|
||||||
|
@NonNull
|
||||||
private final Context context;
|
private final Context context;
|
||||||
private MediaWikiApi mediaWikiApi;
|
|
||||||
|
|
||||||
WikiAccountAuthenticator(Context context, MediaWikiApi mwApi) {
|
public WikiAccountAuthenticator(@NonNull Context context) {
|
||||||
super(context);
|
super(context);
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.mediaWikiApi = mwApi;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Bundle unsupportedOperation() {
|
@Override
|
||||||
|
public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
|
||||||
Bundle bundle = new Bundle();
|
Bundle bundle = new Bundle();
|
||||||
bundle.putInt(KEY_ERROR_CODE, ERROR_CODE_UNSUPPORTED_OPERATION);
|
bundle.putString("test", "editProperties");
|
||||||
|
|
||||||
// HACK: the docs indicate that this is a required key bit it's not displayed to the user.
|
|
||||||
bundle.putString(KEY_ERROR_MESSAGE, "");
|
|
||||||
|
|
||||||
return bundle;
|
return bundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean supportedAccountType(@Nullable String type) {
|
|
||||||
return ACCOUNT_TYPE.equals(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Bundle addAccount(@NonNull AccountAuthenticatorResponse response,
|
public Bundle addAccount(@NonNull AccountAuthenticatorResponse response,
|
||||||
@NonNull String accountType, @Nullable String authTokenType,
|
@NonNull String accountType, @Nullable String authTokenType,
|
||||||
|
|
@ -59,86 +43,48 @@ public class WikiAccountAuthenticator extends AbstractAccountAuthenticator {
|
||||||
throws NetworkErrorException {
|
throws NetworkErrorException {
|
||||||
|
|
||||||
if (!supportedAccountType(accountType)) {
|
if (!supportedAccountType(accountType)) {
|
||||||
return unsupportedOperation();
|
Bundle bundle = new Bundle();
|
||||||
|
bundle.putString("test", "addAccount");
|
||||||
|
return bundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
return addAccount(response);
|
return addAccount(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Bundle addAccount(AccountAuthenticatorResponse response) {
|
|
||||||
Intent Intent = new Intent(context, LoginActivity.class);
|
|
||||||
Intent.putExtra(KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
|
|
||||||
|
|
||||||
Bundle bundle = new Bundle();
|
|
||||||
bundle.putParcelable(KEY_INTENT, Intent);
|
|
||||||
|
|
||||||
return bundle;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Bundle confirmCredentials(@NonNull AccountAuthenticatorResponse response,
|
public Bundle confirmCredentials(@NonNull AccountAuthenticatorResponse response,
|
||||||
@NonNull Account account, @Nullable Bundle options)
|
@NonNull Account account, @Nullable Bundle options)
|
||||||
throws NetworkErrorException {
|
throws NetworkErrorException {
|
||||||
return unsupportedOperation();
|
Bundle bundle = new Bundle();
|
||||||
|
bundle.putString("test", "confirmCredentials");
|
||||||
|
return bundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
|
public Bundle getAuthToken(@NonNull AccountAuthenticatorResponse response,
|
||||||
return unsupportedOperation();
|
@NonNull Account account, @NonNull String authTokenType,
|
||||||
}
|
@Nullable Bundle options)
|
||||||
|
throws NetworkErrorException {
|
||||||
private String getAuthCookie(String username, String password) throws IOException {
|
Bundle bundle = new Bundle();
|
||||||
//TODO add 2fa support here
|
bundle.putString("test", "getAuthToken");
|
||||||
String result = mediaWikiApi.login(username, password);
|
|
||||||
if (result.equals("PASS")) {
|
|
||||||
return mediaWikiApi.getAuthCookie();
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account,
|
|
||||||
String authTokenType, Bundle options) throws NetworkErrorException {
|
|
||||||
// Extract the username and password from the Account Manager, and ask
|
|
||||||
// the server for an appropriate AuthToken.
|
|
||||||
final AccountManager am = AccountManager.get(context);
|
|
||||||
final String password = am.getPassword(account);
|
|
||||||
if (password != null) {
|
|
||||||
String authCookie;
|
|
||||||
try {
|
|
||||||
authCookie = getAuthCookie(account.name, password);
|
|
||||||
} catch (IOException e) {
|
|
||||||
// Network error!
|
|
||||||
e.printStackTrace();
|
|
||||||
throw new NetworkErrorException(e);
|
|
||||||
}
|
|
||||||
if (authCookie != null) {
|
|
||||||
final Bundle result = new Bundle();
|
|
||||||
result.putString(KEY_ACCOUNT_NAME, account.name);
|
|
||||||
result.putString(KEY_ACCOUNT_TYPE, ACCOUNT_TYPE);
|
|
||||||
result.putString(KEY_AUTHTOKEN, authCookie);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we get here, then we couldn't access the user's password - so we
|
|
||||||
// need to re-prompt them for their credentials. We do that by creating
|
|
||||||
// an intent to display our AuthenticatorActivity panel.
|
|
||||||
final Intent intent = new Intent(context, LoginActivity.class);
|
|
||||||
intent.putExtra(PARAM_USERNAME, account.name);
|
|
||||||
intent.putExtra(KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
|
|
||||||
final Bundle bundle = new Bundle();
|
|
||||||
bundle.putParcelable(KEY_INTENT, intent);
|
|
||||||
return bundle;
|
return bundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
@Override
|
@Override
|
||||||
public String getAuthTokenLabel(@NonNull String authTokenType) {
|
public String getAuthTokenLabel(@NonNull String authTokenType) {
|
||||||
//Note: the wikipedia app actually returns a string here....
|
return supportedAccountType(authTokenType) ? AUTH_TOKEN_TYPE : null;
|
||||||
//return supportedAccountType(authTokenType) ? context.getString(R.string.wikimedia) : null;
|
}
|
||||||
return null;
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public Bundle updateCredentials(@NonNull AccountAuthenticatorResponse response,
|
||||||
|
@NonNull Account account, @Nullable String authTokenType,
|
||||||
|
@Nullable Bundle options)
|
||||||
|
throws NetworkErrorException {
|
||||||
|
Bundle bundle = new Bundle();
|
||||||
|
bundle.putString("test", "updateCredentials");
|
||||||
|
return bundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
|
|
@ -147,16 +93,50 @@ public class WikiAccountAuthenticator extends AbstractAccountAuthenticator {
|
||||||
@NonNull Account account, @NonNull String[] features)
|
@NonNull Account account, @NonNull String[] features)
|
||||||
throws NetworkErrorException {
|
throws NetworkErrorException {
|
||||||
Bundle bundle = new Bundle();
|
Bundle bundle = new Bundle();
|
||||||
bundle.putBoolean(KEY_BOOLEAN_RESULT, false);
|
bundle.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false);
|
||||||
return bundle;
|
return bundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nullable
|
private boolean supportedAccountType(@Nullable String type) {
|
||||||
@Override
|
return ACCOUNT_TYPE.equals(type);
|
||||||
public Bundle updateCredentials(@NonNull AccountAuthenticatorResponse response,
|
|
||||||
@NonNull Account account, @Nullable String authTokenType,
|
|
||||||
@Nullable Bundle options) throws NetworkErrorException {
|
|
||||||
return unsupportedOperation();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Bundle addAccount(AccountAuthenticatorResponse response) {
|
||||||
|
Intent intent = new Intent(context, LoginActivity.class);
|
||||||
|
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
|
||||||
|
|
||||||
|
Bundle bundle = new Bundle();
|
||||||
|
bundle.putParcelable(AccountManager.KEY_INTENT, intent);
|
||||||
|
|
||||||
|
return bundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Bundle unsupportedOperation() {
|
||||||
|
Bundle bundle = new Bundle();
|
||||||
|
bundle.putInt(AccountManager.KEY_ERROR_CODE, AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION);
|
||||||
|
|
||||||
|
// HACK: the docs indicate that this is a required key bit it's not displayed to the user.
|
||||||
|
bundle.putString(AccountManager.KEY_ERROR_MESSAGE, "");
|
||||||
|
|
||||||
|
return bundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Bundle getAccountRemovalAllowed(AccountAuthenticatorResponse response,
|
||||||
|
Account account) throws NetworkErrorException {
|
||||||
|
Bundle result = super.getAccountRemovalAllowed(response, account);
|
||||||
|
|
||||||
|
if (result.containsKey(AccountManager.KEY_BOOLEAN_RESULT)
|
||||||
|
&& !result.containsKey(AccountManager.KEY_INTENT)) {
|
||||||
|
boolean allowed = result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT);
|
||||||
|
|
||||||
|
if (allowed) {
|
||||||
|
for (String auth : SYNC_AUTHORITIES) {
|
||||||
|
ContentResolver.cancelSync(account, auth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,26 @@
|
||||||
package fr.free.nrw.commons.auth;
|
package fr.free.nrw.commons.auth;
|
||||||
|
|
||||||
|
import android.accounts.AbstractAccountAuthenticator;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
|
import android.support.annotation.Nullable;
|
||||||
import javax.inject.Inject;
|
|
||||||
|
|
||||||
import fr.free.nrw.commons.di.CommonsDaggerService;
|
import fr.free.nrw.commons.di.CommonsDaggerService;
|
||||||
import fr.free.nrw.commons.mwapi.MediaWikiApi;
|
|
||||||
|
|
||||||
import static android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT;
|
|
||||||
|
|
||||||
public class WikiAccountAuthenticatorService extends CommonsDaggerService {
|
public class WikiAccountAuthenticatorService extends CommonsDaggerService {
|
||||||
|
|
||||||
@Inject MediaWikiApi mwApi;
|
@Nullable
|
||||||
private WikiAccountAuthenticator wikiAccountAuthenticator = null;
|
private AbstractAccountAuthenticator authenticator;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public IBinder onBind(Intent intent) {
|
public void onCreate() {
|
||||||
if (!intent.getAction().equals(ACTION_AUTHENTICATOR_INTENT)) {
|
super.onCreate();
|
||||||
return null;
|
authenticator = new WikiAccountAuthenticator(this);
|
||||||
}
|
|
||||||
|
|
||||||
if (wikiAccountAuthenticator == null) {
|
|
||||||
wikiAccountAuthenticator = new WikiAccountAuthenticator(this, mwApi);
|
|
||||||
}
|
|
||||||
return wikiAccountAuthenticator.getIBinder();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public IBinder onBind(Intent intent) {
|
||||||
|
return authenticator == null ? null : authenticator.getIBinder();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
package fr.free.nrw.commons.modifications;
|
package fr.free.nrw.commons.modifications;
|
||||||
|
|
||||||
import android.accounts.Account;
|
import android.accounts.Account;
|
||||||
import android.accounts.AccountManager;
|
|
||||||
import android.accounts.AuthenticatorException;
|
|
||||||
import android.accounts.OperationCanceledException;
|
|
||||||
import android.content.AbstractThreadedSyncAdapter;
|
import android.content.AbstractThreadedSyncAdapter;
|
||||||
import android.content.ContentProviderClient;
|
import android.content.ContentProviderClient;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
|
@ -16,7 +13,7 @@ import java.io.IOException;
|
||||||
|
|
||||||
import javax.inject.Inject;
|
import javax.inject.Inject;
|
||||||
|
|
||||||
import fr.free.nrw.commons.CommonsApplication;
|
import fr.free.nrw.commons.auth.SessionManager;
|
||||||
import fr.free.nrw.commons.contributions.Contribution;
|
import fr.free.nrw.commons.contributions.Contribution;
|
||||||
import fr.free.nrw.commons.contributions.ContributionDao;
|
import fr.free.nrw.commons.contributions.ContributionDao;
|
||||||
import fr.free.nrw.commons.contributions.ContributionsContentProvider;
|
import fr.free.nrw.commons.contributions.ContributionsContentProvider;
|
||||||
|
|
@ -29,6 +26,8 @@ public class ModificationsSyncAdapter extends AbstractThreadedSyncAdapter {
|
||||||
@Inject MediaWikiApi mwApi;
|
@Inject MediaWikiApi mwApi;
|
||||||
@Inject ContributionDao contributionDao;
|
@Inject ContributionDao contributionDao;
|
||||||
@Inject ModifierSequenceDao modifierSequenceDao;
|
@Inject ModifierSequenceDao modifierSequenceDao;
|
||||||
|
@Inject
|
||||||
|
SessionManager sessionManager;
|
||||||
|
|
||||||
public ModificationsSyncAdapter(Context context, boolean autoInitialize) {
|
public ModificationsSyncAdapter(Context context, boolean autoInitialize) {
|
||||||
super(context, autoInitialize);
|
super(context, autoInitialize);
|
||||||
|
|
@ -56,16 +55,7 @@ public class ModificationsSyncAdapter extends AbstractThreadedSyncAdapter {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String authCookie;
|
String authCookie = sessionManager.getAndSetAuthCookie().blockingSingle();
|
||||||
try {
|
|
||||||
authCookie = AccountManager.get(getContext()).blockingGetAuthToken(account, "", false);
|
|
||||||
} catch (OperationCanceledException | AuthenticatorException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
} catch (IOException e) {
|
|
||||||
Timber.d("Could not authenticate :(");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNullOrWhiteSpace(authCookie)) {
|
if (isNullOrWhiteSpace(authCookie)) {
|
||||||
Timber.d("Could not authenticate :(");
|
Timber.d("Could not authenticate :(");
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -69,11 +69,17 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
|
||||||
final SSLSocketFactory sslSocketFactory = SSLSocketFactory.getSocketFactory();
|
final SSLSocketFactory sslSocketFactory = SSLSocketFactory.getSocketFactory();
|
||||||
schemeRegistry.register(new Scheme("https", sslSocketFactory, 443));
|
schemeRegistry.register(new Scheme("https", sslSocketFactory, 443));
|
||||||
ClientConnectionManager cm = new ThreadSafeClientConnManager(params, schemeRegistry);
|
ClientConnectionManager cm = new ThreadSafeClientConnManager(params, schemeRegistry);
|
||||||
params.setParameter(CoreProtocolPNames.USER_AGENT, "Commons/" + BuildConfig.VERSION_NAME + " (https://mediawiki.org/wiki/Apps/Commons) Android/" + Build.VERSION.RELEASE);
|
params.setParameter(CoreProtocolPNames.USER_AGENT, getUserAgent());
|
||||||
httpClient = new DefaultHttpClient(cm, params);
|
httpClient = new DefaultHttpClient(cm, params);
|
||||||
api = new MWApi(apiURL, httpClient);
|
api = new MWApi(apiURL, httpClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@NonNull
|
||||||
|
public String getUserAgent() {
|
||||||
|
return "Commons/" + BuildConfig.VERSION_NAME + " (https://mediawiki.org/wiki/Apps/Commons) Android/" + Build.VERSION.RELEASE;
|
||||||
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
public void setWikiMediaToolforgeUrl(String wikiMediaToolforgeUrl) {
|
public void setWikiMediaToolforgeUrl(String wikiMediaToolforgeUrl) {
|
||||||
this.wikiMediaToolforgeUrl = wikiMediaToolforgeUrl;
|
this.wikiMediaToolforgeUrl = wikiMediaToolforgeUrl;
|
||||||
|
|
@ -86,11 +92,13 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
|
||||||
* @throws IOException On api request IO issue
|
* @throws IOException On api request IO issue
|
||||||
*/
|
*/
|
||||||
public String login(String username, String password) throws IOException {
|
public String login(String username, String password) throws IOException {
|
||||||
|
String loginToken = getLoginToken();
|
||||||
|
Timber.d("Login token is %s", loginToken);
|
||||||
return getErrorCodeToReturn(api.action("clientlogin")
|
return getErrorCodeToReturn(api.action("clientlogin")
|
||||||
.param("rememberMe", "1")
|
.param("rememberMe", "1")
|
||||||
.param("username", username)
|
.param("username", username)
|
||||||
.param("password", password)
|
.param("password", password)
|
||||||
.param("logintoken", getLoginToken())
|
.param("logintoken", loginToken)
|
||||||
.param("loginreturnurl", "https://commons.wikimedia.org")
|
.param("loginreturnurl", "https://commons.wikimedia.org")
|
||||||
.post());
|
.post());
|
||||||
}
|
}
|
||||||
|
|
@ -103,12 +111,14 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
|
||||||
* @throws IOException On api request IO issue
|
* @throws IOException On api request IO issue
|
||||||
*/
|
*/
|
||||||
public String login(String username, String password, String twoFactorCode) throws IOException {
|
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")
|
return getErrorCodeToReturn(api.action("clientlogin")
|
||||||
.param("rememberMe", "1")
|
.param("rememberMe", "true")
|
||||||
.param("username", username)
|
.param("username", username)
|
||||||
.param("password", password)
|
.param("password", password)
|
||||||
.param("logintoken", getLoginToken())
|
.param("logintoken", loginToken)
|
||||||
.param("logincontinue", "1")
|
.param("logincontinue", "true")
|
||||||
.param("OATHToken", twoFactorCode)
|
.param("OATHToken", twoFactorCode)
|
||||||
.post());
|
.post());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import io.reactivex.Observable;
|
||||||
import io.reactivex.Single;
|
import io.reactivex.Single;
|
||||||
|
|
||||||
public interface MediaWikiApi {
|
public interface MediaWikiApi {
|
||||||
|
String getUserAgent();
|
||||||
|
|
||||||
String getAuthCookie();
|
String getAuthCookie();
|
||||||
|
|
||||||
void setAuthCookie(String authCookie);
|
void setAuthCookie(String authCookie);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue