Convert the LoginClient to kotlin (#5479)

* Convert the result classes to kotlin

* Convert response and callback to kotlin

* Cleanup code-quality warnings before converting

* Converted the LoginClient to kotlin

* Updated the UserExtendedInfoClientTest to be kotlin, and live in the correct spot
This commit is contained in:
Paul Hawke 2024-01-26 21:39:00 -06:00 committed by GitHub
parent 0541aacdff
commit 02ce017c98
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 370 additions and 450 deletions

View file

@ -31,11 +31,10 @@ import fr.free.nrw.commons.auth.login.LoginResult;
import fr.free.nrw.commons.databinding.ActivityLoginBinding;
import fr.free.nrw.commons.utils.ActivityUtils;
import java.util.Locale;
import org.wikipedia.AppAdapter;
import org.wikipedia.dataclient.ServiceFactory;
import org.wikipedia.dataclient.WikiSite;
import org.wikipedia.dataclient.mwapi.MwQueryResponse;
import fr.free.nrw.commons.auth.login.LoginClient.LoginCallback;
import fr.free.nrw.commons.auth.login.LoginCallback;
import javax.inject.Inject;
import javax.inject.Named;

View file

@ -8,8 +8,8 @@ import org.wikipedia.dataclient.SharedPreferenceCookieManager
import org.wikipedia.dataclient.WikiSite
import org.wikipedia.dataclient.mwapi.MwQueryResponse
import fr.free.nrw.commons.auth.login.LoginClient
import fr.free.nrw.commons.auth.login.LoginClient.LoginCallback
import fr.free.nrw.commons.auth.login.LoginClient.LoginFailedException
import fr.free.nrw.commons.auth.login.LoginCallback
import fr.free.nrw.commons.auth.login.LoginFailedException
import fr.free.nrw.commons.auth.login.LoginResult
import retrofit2.Call
import retrofit2.Response
@ -129,7 +129,7 @@ class CsrfTokenClient(
) = LoginClient()
.request(csrfWikiSite, username, password, object : LoginCallback {
override fun success(loginResult: LoginResult) {
if (loginResult.pass()) {
if (loginResult.pass) {
sessionManager.updateAccount(loginResult)
retryCallback()
} else {

View file

@ -0,0 +1,8 @@
package fr.free.nrw.commons.auth.login
interface LoginCallback {
fun success(loginResult: LoginResult)
fun twoFactorPrompt(caught: Throwable, token: String?)
fun passwordResetPrompt(token: String?)
fun error(caught: Throwable)
}

View file

@ -1,259 +0,0 @@
package fr.free.nrw.commons.auth.login;
import android.annotation.SuppressLint;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.gson.annotations.SerializedName;
import org.apache.commons.lang3.StringUtils;
import org.wikipedia.dataclient.Service;
import org.wikipedia.dataclient.ServiceFactory;
import org.wikipedia.dataclient.WikiSite;
import org.wikipedia.dataclient.mwapi.ListUserResponse;
import org.wikipedia.dataclient.mwapi.MwQueryResponse;
import org.wikipedia.dataclient.mwapi.MwServiceError;
import org.wikipedia.util.log.L;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.schedulers.Schedulers;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
/**
* Responsible for making login related requests to the server.
*/
public class LoginClient {
@Nullable private Call<MwQueryResponse> tokenCall;
@Nullable private Call<LoginResponse> loginCall;
/**
* userLanguage
* It holds the value of the user's device language code.
* For example, if user's device language is English it will hold En
* The value will be fetched when the user clicks Login Button in the LoginActivity
*/
@NonNull private String userLanguage;
public interface LoginCallback {
void success(@NonNull LoginResult result);
void twoFactorPrompt(@NonNull Throwable caught, @Nullable String token);
void passwordResetPrompt(@Nullable String token);
void error(@NonNull Throwable caught);
}
public void request(@NonNull final WikiSite wiki, @NonNull final String userName,
@NonNull final String password, @NonNull final LoginCallback cb) {
cancel();
tokenCall = ServiceFactory.get(wiki, LoginInterface.class).getLoginToken();
tokenCall.enqueue(new Callback<MwQueryResponse>() {
@Override public void onResponse(@NonNull Call<MwQueryResponse> call,
@NonNull Response<MwQueryResponse> response) {
login(wiki, userName, password, null, null, response.body().query().loginToken(),
userLanguage, cb);
}
@Override
public void onFailure(@NonNull Call<MwQueryResponse> call, @NonNull Throwable caught) {
if (call.isCanceled()) {
return;
}
cb.error(caught);
}
});
}
public void login(@NonNull final WikiSite wiki, @NonNull final String userName, @NonNull final String password,
@Nullable final String retypedPassword, @Nullable final String twoFactorCode,
@Nullable final String loginToken, @NonNull final String userLanguage, @NonNull final LoginCallback cb) {
this.userLanguage = userLanguage;
loginCall = TextUtils.isEmpty(twoFactorCode) && TextUtils.isEmpty(retypedPassword)
? ServiceFactory.get(wiki, LoginInterface.class).postLogIn(userName, password, loginToken, userLanguage, Service.WIKIPEDIA_URL)
: ServiceFactory.get(wiki, LoginInterface.class).postLogIn(userName, password, retypedPassword, twoFactorCode, loginToken,
userLanguage, true);
loginCall.enqueue(new Callback<LoginResponse>() {
@Override
public void onResponse(@NonNull Call<LoginResponse> call, @NonNull Response<LoginResponse> response) {
LoginResponse loginResponse = response.body();
LoginResult loginResult = loginResponse.toLoginResult(wiki, password);
if (loginResult != null) {
if (loginResult.pass() && !TextUtils.isEmpty(loginResult.getUserName())) {
// The server could do some transformations on user names, e.g. on some
// wikis is uppercases the first letter.
String actualUserName = loginResult.getUserName();
getExtendedInfo(wiki, actualUserName, loginResult, cb);
} else if ("UI".equals(loginResult.getStatus())) {
if (loginResult instanceof LoginOAuthResult) {
cb.twoFactorPrompt(new LoginFailedException(loginResult.getMessage()), loginToken);
} else if (loginResult instanceof LoginResetPasswordResult) {
cb.passwordResetPrompt(loginToken);
} else {
cb.error(new LoginFailedException(loginResult.getMessage()));
}
} else {
cb.error(new LoginFailedException(loginResult.getMessage()));
}
} else {
cb.error(new IOException("Login failed. Unexpected response."));
}
}
@Override
public void onFailure(@NonNull Call<LoginResponse> call, @NonNull Throwable t) {
if (call.isCanceled()) {
return;
}
cb.error(t);
}
});
}
public void loginBlocking(@NonNull final WikiSite wiki, @NonNull final String userName,
@NonNull final String password, @Nullable final String twoFactorCode) throws Throwable {
Response<MwQueryResponse> tokenResponse = ServiceFactory.get(wiki, LoginInterface.class).getLoginToken().execute();
if (tokenResponse.body() == null || TextUtils.isEmpty(tokenResponse.body().query().loginToken())) {
throw new IOException("Unexpected response when getting login token.");
}
String loginToken = tokenResponse.body().query().loginToken();
Call<LoginResponse> tempLoginCall = StringUtils.defaultIfEmpty(twoFactorCode, "").isEmpty()
? ServiceFactory.get(wiki, LoginInterface.class).postLogIn(userName, password, loginToken, userLanguage, Service.WIKIPEDIA_URL)
: ServiceFactory.get(wiki, LoginInterface.class).postLogIn(userName, password, null, twoFactorCode, loginToken,
userLanguage, true);
Response<LoginResponse> response = tempLoginCall.execute();
LoginResponse loginResponse = response.body();
if (loginResponse == null) {
throw new IOException("Unexpected response when logging in.");
}
LoginResult loginResult = loginResponse.toLoginResult(wiki, password);
if (loginResult == null) {
throw new IOException("Unexpected response when logging in.");
}
if ("UI".equals(loginResult.getStatus())) {
if (loginResult instanceof LoginOAuthResult) {
// TODO: Find a better way to boil up the warning about 2FA
throw new LoginFailedException(loginResult.getMessage());
} else {
throw new LoginFailedException(loginResult.getMessage());
}
} else if (!loginResult.pass() || TextUtils.isEmpty(loginResult.getUserName())) {
throw new LoginFailedException(loginResult.getMessage());
}
}
@SuppressLint("CheckResult")
private void getExtendedInfo(@NonNull final WikiSite wiki, @NonNull String userName,
@NonNull final LoginResult loginResult, @NonNull final LoginCallback cb) {
ServiceFactory.get(wiki, LoginInterface.class).getUserInfo(userName)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(response -> {
ListUserResponse user = response.query().getUserResponse(userName);
int id = response.query().userInfo().id();
loginResult.setUserId(id);
loginResult.setGroups(user.getGroups());
cb.success(loginResult);
L.v("Found user ID " + id + " for " + wiki.subdomain());
}, caught -> {
L.e("Login succeeded but getting group information failed. " + caught);
cb.error(caught);
});
}
public void cancel() {
cancelTokenRequest();
cancelLogin();
}
private void cancelTokenRequest() {
if (tokenCall == null) {
return;
}
tokenCall.cancel();
tokenCall = null;
}
private void cancelLogin() {
if (loginCall == null) {
return;
}
loginCall.cancel();
loginCall = null;
}
public static final class LoginResponse {
@SuppressWarnings("unused") @SerializedName("error") @Nullable
private MwServiceError error;
@SuppressWarnings("unused") @SerializedName("clientlogin") @Nullable
private ClientLogin clientLogin;
@Nullable public MwServiceError getError() {
return error;
}
@Nullable LoginResult toLoginResult(@NonNull WikiSite site, @NonNull String password) {
return clientLogin != null ? clientLogin.toLoginResult(site, password) : null;
}
private static class ClientLogin {
@SuppressWarnings("unused,NullableProblems") @NonNull private String status;
@SuppressWarnings("unused") @Nullable private List<Request> requests;
@SuppressWarnings("unused") @Nullable private String message;
@SuppressWarnings("unused") @SerializedName("username") @Nullable private String userName;
LoginResult toLoginResult(@NonNull WikiSite site, @NonNull String password) {
String userMessage = message;
if ("UI".equals(status)) {
if (requests != null) {
for (Request req : requests) {
if ("MediaWiki\\Extension\\OATHAuth\\Auth\\TOTPAuthenticationRequest".equals(req.id())) {
return new LoginOAuthResult(site, status, userName, password, message);
} else if ("MediaWiki\\Auth\\PasswordAuthenticationRequest".equals(req.id())) {
return new LoginResetPasswordResult(site, status, userName, password, message);
}
}
}
} else if (!"PASS".equals(status) && !"FAIL".equals(status)) {
//TODO: String resource -- Looks like needed for others in this class too
userMessage = "An unknown error occurred.";
}
return new LoginResult(site, status, userName, password, userMessage);
}
}
private static class Request {
@SuppressWarnings("unused") @Nullable private String id;
//@SuppressWarnings("unused") @Nullable private JsonObject metadata;
@SuppressWarnings("unused") @Nullable private String required;
@SuppressWarnings("unused") @Nullable private String provider;
@SuppressWarnings("unused") @Nullable private String account;
@SuppressWarnings("unused") @Nullable private Map<String, RequestField> fields;
@Nullable String id() {
return id;
}
}
private static class RequestField {
@SuppressWarnings("unused") @Nullable private String type;
@SuppressWarnings("unused") @Nullable private String label;
@SuppressWarnings("unused") @Nullable private String help;
}
}
public static class LoginFailedException extends Throwable {
public LoginFailedException(String message) {
super(message);
}
}
}

View file

@ -0,0 +1,172 @@
package fr.free.nrw.commons.auth.login
import android.text.TextUtils
import fr.free.nrw.commons.auth.login.LoginResult.OAuthResult
import fr.free.nrw.commons.auth.login.LoginResult.ResetPasswordResult
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import org.wikipedia.dataclient.Service
import org.wikipedia.dataclient.ServiceFactory
import org.wikipedia.dataclient.WikiSite
import org.wikipedia.dataclient.mwapi.MwQueryResponse
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import timber.log.Timber
import java.io.IOException
/**
* Responsible for making login related requests to the server.
*/
class LoginClient {
private var tokenCall: Call<MwQueryResponse?>? = null
private var loginCall: Call<LoginResponse?>? = null
/**
* userLanguage
* It holds the value of the user's device language code.
* For example, if user's device language is English it will hold En
* The value will be fetched when the user clicks Login Button in the LoginActivity
*/
private var userLanguage = ""
fun request(wiki: WikiSite, userName: String, password: String, cb: LoginCallback) {
cancel()
tokenCall = ServiceFactory.get(wiki, LoginInterface::class.java).getLoginToken()
tokenCall!!.enqueue(object : Callback<MwQueryResponse?> {
override fun onResponse(call: Call<MwQueryResponse?>, response: Response<MwQueryResponse?>) {
login(wiki, userName, password, null, null,
response.body()!!.query()!!.loginToken(), userLanguage, cb)
}
override fun onFailure(call: Call<MwQueryResponse?>, caught: Throwable) {
if (call.isCanceled) {
return
}
cb.error(caught)
}
})
}
fun login(
wiki: WikiSite, userName: String, password: String, retypedPassword: String?,
twoFactorCode: String?, loginToken: String?, userLanguage: String, cb: LoginCallback
) {
this.userLanguage = userLanguage
loginCall = if (twoFactorCode.isNullOrEmpty() && retypedPassword.isNullOrEmpty()) {
ServiceFactory.get(wiki, LoginInterface::class.java)
.postLogIn(userName, password, loginToken, userLanguage, Service.WIKIPEDIA_URL)
} else {
ServiceFactory.get(wiki, LoginInterface::class.java).postLogIn(
userName, password, retypedPassword, twoFactorCode, loginToken, userLanguage, true
)
}
loginCall!!.enqueue(object : Callback<LoginResponse?> {
override fun onResponse(
call: Call<LoginResponse?>,
response: Response<LoginResponse?>
) {
val loginResult = response.body()?.toLoginResult(wiki, password)
if (loginResult != null) {
if (loginResult.pass && !loginResult.userName.isNullOrEmpty()) {
// The server could do some transformations on user names, e.g. on some
// wikis is uppercases the first letter.
getExtendedInfo(wiki, loginResult.userName, loginResult, cb)
} else if ("UI" == loginResult.status) {
when (loginResult) {
is OAuthResult -> cb.twoFactorPrompt(
LoginFailedException(loginResult.message),
loginToken
)
is ResetPasswordResult -> cb.passwordResetPrompt(loginToken)
is LoginResult.Result -> cb.error(
LoginFailedException(loginResult.message)
)
}
} else {
cb.error(LoginFailedException(loginResult.message))
}
} else {
cb.error(IOException("Login failed. Unexpected response."))
}
}
override fun onFailure(call: Call<LoginResponse?>, t: Throwable) {
if (call.isCanceled) {
return
}
cb.error(t)
}
})
}
@Throws(Throwable::class)
fun loginBlocking(wiki: WikiSite, userName: String, password: String, twoFactorCode: String?) {
val tokenResponse = ServiceFactory.get(wiki, LoginInterface::class.java).getLoginToken().execute()
if (tokenResponse.body()?.query()?.loginToken().isNullOrEmpty()) {
throw IOException("Unexpected response when getting login token.")
}
val loginToken = tokenResponse.body()?.query()?.loginToken()
val tempLoginCall = if (twoFactorCode.isNullOrEmpty()) {
ServiceFactory.get(wiki, LoginInterface::class.java).postLogIn(
userName, password, loginToken, userLanguage, Service.WIKIPEDIA_URL
)
} else {
ServiceFactory.get(wiki, LoginInterface::class.java).postLogIn(
userName, password, null, twoFactorCode, loginToken, userLanguage, true
)
}
val response = tempLoginCall.execute()
val loginResponse = response.body() ?: throw IOException("Unexpected response when logging in.")
val loginResult = loginResponse.toLoginResult(wiki, password) ?: throw IOException("Unexpected response when logging in.")
if ("UI" == loginResult.status) {
if (loginResult is OAuthResult) {
// TODO: Find a better way to boil up the warning about 2FA
throw LoginFailedException(loginResult.message)
}
throw LoginFailedException(loginResult.message)
}
if (!loginResult.pass || TextUtils.isEmpty(loginResult.userName)) {
throw LoginFailedException(loginResult.message)
}
}
private fun getExtendedInfo(
wiki: WikiSite, userName: String, loginResult: LoginResult, cb: LoginCallback
) = ServiceFactory.get(wiki, LoginInterface::class.java).getUserInfo(userName)
.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
.subscribe({ response: MwQueryResponse? ->
loginResult.userId = response?.query()?.userInfo()?.id() ?: 0
loginResult.groups = response?.query()?.getUserResponse(userName)?.groups ?: emptySet()
cb.success(loginResult)
Timber.v(
"Found user ID %s for %s",
response?.query()?.userInfo()?.id(),
wiki.subdomain()
)
}, { caught: Throwable ->
Timber.e(caught, "Login succeeded but getting group information failed. ")
cb.error(caught)
})
fun cancel() {
tokenCall?.let {
it.cancel()
tokenCall = null
}
loginCall?.let {
it.cancel()
loginCall = null
}
}
}

View file

@ -0,0 +1,3 @@
package fr.free.nrw.commons.auth.login
class LoginFailedException(message: String?) : Throwable(message)

View file

@ -25,7 +25,7 @@ interface LoginInterface {
@Field("logintoken") token: String?,
@Field("uselang") userLanguage: String?,
@Field("loginreturnurl") url: String?
): Call<LoginClient.LoginResponse?>
): Call<LoginResponse?>
@Headers("Cache-Control: no-cache")
@FormUrlEncoded
@ -38,7 +38,7 @@ interface LoginInterface {
@Field("logintoken") token: String?,
@Field("uselang") userLanguage: String?,
@Field("logincontinue") loginContinue: Boolean
): Call<LoginClient.LoginResponse?>
): Call<LoginResponse?>
@GET(Service.MW_API_PREFIX + "action=query&meta=userinfo&list=users&usprop=groups|cancreate")
fun getUserInfo(@Query("ususers") userName: String): Observable<MwQueryResponse?>

View file

@ -1,14 +0,0 @@
package fr.free.nrw.commons.auth.login;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.wikipedia.dataclient.WikiSite;
public class LoginOAuthResult extends LoginResult {
public LoginOAuthResult(@NonNull WikiSite site, @NonNull String status, @Nullable String userName,
@Nullable String password, @Nullable String message) {
super(site, status, userName, password, message);
}
}

View file

@ -1,13 +0,0 @@
package fr.free.nrw.commons.auth.login;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.wikipedia.dataclient.WikiSite;
public class LoginResetPasswordResult extends LoginResult {
public LoginResetPasswordResult(@NonNull WikiSite site, @NonNull String status, @Nullable String userName,
@Nullable String password, @Nullable String message) {
super(site, status, userName, password, message);
}
}

View file

@ -0,0 +1,64 @@
package fr.free.nrw.commons.auth.login
import com.google.gson.annotations.SerializedName
import fr.free.nrw.commons.auth.login.LoginResult.OAuthResult
import fr.free.nrw.commons.auth.login.LoginResult.ResetPasswordResult
import fr.free.nrw.commons.auth.login.LoginResult.Result
import org.wikipedia.dataclient.WikiSite
import org.wikipedia.dataclient.mwapi.MwServiceError
class LoginResponse {
@SerializedName("error")
val error: MwServiceError? = null
@SerializedName("clientlogin")
private val clientLogin: ClientLogin? = null
fun toLoginResult(site: WikiSite, password: String): LoginResult? {
return clientLogin?.toLoginResult(site, password)
}
}
internal class ClientLogin {
private val status: String? = null
private val requests: List<Request>? = null
private val message: String? = null
@SerializedName("username")
private val userName: String? = null
fun toLoginResult(site: WikiSite, password: String): LoginResult {
var userMessage = message
if ("UI" == status) {
if (requests != null) {
for (req in requests) {
if ("MediaWiki\\Extension\\OATHAuth\\Auth\\TOTPAuthenticationRequest" == req.id()) {
return OAuthResult(site, status, userName, password, message)
} else if ("MediaWiki\\Auth\\PasswordAuthenticationRequest" == req.id()) {
return ResetPasswordResult(site, status, userName, password, message)
}
}
}
} else if ("PASS" != status && "FAIL" != status) {
//TODO: String resource -- Looks like needed for others in this class too
userMessage = "An unknown error occurred."
}
return Result(site, status ?: "", userName, password, userMessage)
}
}
internal class Request {
private val id: String? = null
private val required: String? = null
private val provider: String? = null
private val account: String? = null
private val fields: Map<String, RequestField>? = null
fun id(): String? = id
}
internal class RequestField {
private val type: String? = null
private val label: String? = null
private val help: String? = null
}

View file

@ -1,73 +0,0 @@
package fr.free.nrw.commons.auth.login;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.wikipedia.dataclient.WikiSite;
import java.util.Collections;
import java.util.Set;
public class LoginResult {
@NonNull private final WikiSite site;
@NonNull private final String status;
@Nullable private final String userName;
@Nullable private final String password;
@Nullable private final String message;
private int userId;
@NonNull private Set<String> groups = Collections.emptySet();
public LoginResult(@NonNull WikiSite site, @NonNull String status, @Nullable String userName,
@Nullable String password, @Nullable String message) {
this.site = site;
this.status = status;
this.userName = userName;
this.password = password;
this.message = message;
}
@NonNull public WikiSite getSite() {
return site;
}
@NonNull public String getStatus() {
return status;
}
public boolean pass() {
return "PASS".equals(status);
}
public boolean fail() {
return "FAIL".equals(status);
}
@Nullable public String getUserName() {
return userName;
}
@Nullable public String getPassword() {
return password;
}
@Nullable public String getMessage() {
return message;
}
public void setUserId(int id) {
this.userId = id;
}
public int getUserId() {
return userId;
}
public void setGroups(@NonNull Set<String> groups) {
this.groups = groups;
}
@NonNull public Set<String> getGroups() {
return groups;
}
}

View file

@ -0,0 +1,39 @@
package fr.free.nrw.commons.auth.login
import org.wikipedia.dataclient.WikiSite
sealed class LoginResult(
val site: WikiSite,
val status: String,
val userName: String?,
val password: String?,
val message: String?
) {
var userId = 0
var groups = emptySet<String>()
val pass: Boolean get() = "PASS" == status
class Result(
site: WikiSite,
status: String,
userName: String?,
password: String?,
message: String?
): LoginResult(site, status, userName, password, message)
class OAuthResult(
site: WikiSite,
status: String,
userName: String?,
password: String?,
message: String?
) : LoginResult(site, status, userName, password, message)
class ResetPasswordResult(
site: WikiSite,
status: String,
userName: String?,
password: String?,
message: String?
) : LoginResult(site, status, userName, password, message)
}