/*******************************************************************************
* This file is part of RedReader.
*
* RedReader is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* RedReader is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with RedReader. If not, see <http://www.gnu.org/licenses/>.
******************************************************************************/
package org.quantumbadger.redreader.reddit.api;
import android.content.Context;
import android.net.Uri;
import android.os.SystemClock;
import android.util.Base64;
import org.quantumbadger.redreader.R;
import org.quantumbadger.redreader.account.RedditAccount;
import org.quantumbadger.redreader.account.RedditAccountManager;
import org.quantumbadger.redreader.cache.CacheRequest;
import org.quantumbadger.redreader.common.Constants;
import org.quantumbadger.redreader.common.General;
import org.quantumbadger.redreader.common.RRError;
import org.quantumbadger.redreader.http.HTTPBackend;
import org.quantumbadger.redreader.jsonwrap.JsonBufferedObject;
import org.quantumbadger.redreader.jsonwrap.JsonValue;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.util.ArrayList;
import java.util.concurrent.atomic.AtomicReference;
public final class RedditOAuth {
private static final String REDIRECT_URI = "http://rr_oauth_redir";
private static final String CLIENT_ID = "m_zCW1Dixs9WLA";
private static final String ALL_SCOPES
= "identity,edit,flair,history,modconfig,modflair,modlog,modposts,modwiki,mysubreddits,privatemessages,"
+ "read,report,save,submit,subscribe,vote,wikiedit,wikiread";
private static final String ACCESS_TOKEN_URL = "https://www.reddit.com/api/v1/access_token";
public static class Token {
public final String token;
public Token(final String token) {
this.token = token;
}
@Override
public String toString() {
return token;
}
}
public static final class AccessToken extends Token {
private final long mMonotonicTimestamp;
public AccessToken(final String token) {
super(token);
mMonotonicTimestamp = SystemClock.elapsedRealtime();
}
public boolean isExpired() {
final long halfHourInMs = 30 * 60 * 1000;
return mMonotonicTimestamp + halfHourInMs < SystemClock.elapsedRealtime();
}
}
public static final class RefreshToken extends Token {
public RefreshToken(final String token) {
super(token);
}
}
private enum FetchRefreshTokenResultStatus {
SUCCESS,
USER_REFUSED_PERMISSION,
INVALID_REQUEST,
INVALID_RESPONSE,
CONNECTION_ERROR,
UNKNOWN_ERROR
}
private enum FetchUserInfoResultStatus {
SUCCESS,
INVALID_RESPONSE,
CONNECTION_ERROR,
UNKNOWN_ERROR
}
private static final class FetchRefreshTokenResult {
public final FetchRefreshTokenResultStatus status;
public final RRError error;
public final RefreshToken refreshToken;
public final AccessToken accessToken;
public FetchRefreshTokenResult(final FetchRefreshTokenResultStatus status, final RRError error) {
this.status = status;
this.error = error;
this.refreshToken = null;
this.accessToken = null;
}
public FetchRefreshTokenResult(final RefreshToken refreshToken, final AccessToken accessToken) {
this.status = FetchRefreshTokenResultStatus.SUCCESS;
this.error = null;
this.refreshToken = refreshToken;
this.accessToken = accessToken;
}
}
private static final class FetchUserInfoResult {
public final FetchUserInfoResultStatus status;
public final RRError error;
public final String username;
public FetchUserInfoResult(final FetchUserInfoResultStatus status, final RRError error) {
this.status = status;
this.error = error;
this.username = null;
}
public FetchUserInfoResult(final String username) {
this.status = FetchUserInfoResultStatus.SUCCESS;
this.error = null;
this.username = username;
}
}
public static Uri getPromptUri() {
final Uri.Builder uri = Uri.parse("https://www.reddit.com/api/v1/authorize.compact").buildUpon();
uri.appendQueryParameter("response_type", "code");
uri.appendQueryParameter("duration", "permanent");
uri.appendQueryParameter("state", "Texas");
uri.appendQueryParameter("redirect_uri", REDIRECT_URI);
uri.appendQueryParameter("client_id", CLIENT_ID);
uri.appendQueryParameter("scope", ALL_SCOPES);
return uri.build();
}
private static FetchRefreshTokenResult handleRefreshTokenError(
final Throwable exception,
final Integer httpStatus,
final Context context,
final String uri) {
if(httpStatus != null && httpStatus != 200) {
return new FetchRefreshTokenResult(
FetchRefreshTokenResultStatus.UNKNOWN_ERROR,
new RRError(
context.getString(R.string.error_unknown_title),
context.getString(R.string.message_cannotlogin),
null,
httpStatus,
uri
)
);
} else if(exception != null && exception instanceof IOException) {
return new FetchRefreshTokenResult(
FetchRefreshTokenResultStatus.CONNECTION_ERROR,
new RRError(
context.getString(R.string.error_connection_title),
context.getString(R.string.error_connection_message),
exception,
null,
uri
)
);
} else {
return new FetchRefreshTokenResult(
FetchRefreshTokenResultStatus.UNKNOWN_ERROR,
new RRError(
context.getString(R.string.error_unknown_title),
context.getString(R.string.error_unknown_message),
exception,
null,
uri
)
);
}
}
private static FetchAccessTokenResult handleAccessTokenError(
final Throwable exception,
final Integer httpStatus,
final Context context,
final String uri) {
if(httpStatus != null && httpStatus != 200) {
return new FetchAccessTokenResult(
FetchAccessTokenResultStatus.UNKNOWN_ERROR,
new RRError(
context.getString(R.string.error_unknown_title),
context.getString(R.string.message_cannotlogin),
null,
httpStatus,
uri
)
);
} else if(exception != null && exception instanceof IOException) {
return new FetchAccessTokenResult(
FetchAccessTokenResultStatus.CONNECTION_ERROR,
new RRError(
context.getString(R.string.error_connection_title),
context.getString(R.string.error_connection_message),
exception,
null,
uri
)
);
} else {
return new FetchAccessTokenResult(
FetchAccessTokenResultStatus.UNKNOWN_ERROR,
new RRError(
context.getString(R.string.error_unknown_title),
context.getString(R.string.error_unknown_message),
exception,
null,
uri
)
);
}
}
private static FetchRefreshTokenResult fetchRefreshTokenSynchronous(final Context context, final Uri redirectUri) {
final String error = redirectUri.getQueryParameter("error");
if(error != null) {
if(error.equals("access_denied")) {
return new FetchRefreshTokenResult(
FetchRefreshTokenResultStatus.USER_REFUSED_PERMISSION,
new RRError(
context.getString(R.string.error_title_login_user_denied_permission),
context.getString(R.string.error_message_login_user_denied_permission)
)
);
} else {
return new FetchRefreshTokenResult(
FetchRefreshTokenResultStatus.INVALID_REQUEST,
new RRError(
context.getString(R.string.error_title_login_unknown_reddit_error, error),
context.getString(R.string.error_unknown_message)
));
}
}
final String code = redirectUri.getQueryParameter("code");
if(code == null) {
return new FetchRefreshTokenResult(
FetchRefreshTokenResultStatus.INVALID_RESPONSE,
new RRError(
context.getString(R.string.error_unknown_title),
context.getString(R.string.error_unknown_message)
)
);
}
final String uri = ACCESS_TOKEN_URL;
final ArrayList<HTTPBackend.PostField> postFields = new ArrayList<>(3);
postFields.add(new HTTPBackend.PostField("grant_type", "authorization_code"));
postFields.add(new HTTPBackend.PostField("code", code));
postFields.add(new HTTPBackend.PostField("redirect_uri", REDIRECT_URI));
try {
final HTTPBackend.Request request = HTTPBackend.getBackend().prepareRequest(
context,
new HTTPBackend.RequestDetails(
General.uriFromString(uri),
postFields));
request.addHeader("Authorization", "Basic " + Base64.encodeToString((CLIENT_ID + ":").getBytes(), Base64.URL_SAFE | Base64.NO_WRAP));
final AtomicReference<FetchRefreshTokenResult> result = new AtomicReference<>();
request.executeInThisThread(new HTTPBackend.Listener() {
@Override
public void onError(final @CacheRequest.RequestFailureType int failureType, final Throwable exception, final Integer httpStatus) {
result.set(handleRefreshTokenError(exception, httpStatus, context, uri));
}
@Override
public void onSuccess(final String mimetype, final Long bodyBytes, final InputStream body) {
try {
final JsonValue jsonValue = new JsonValue(body);
jsonValue.buildInThisThread();
final JsonBufferedObject responseObject = jsonValue.asObject();
final RefreshToken refreshToken = new RefreshToken(responseObject.getString("refresh_token"));
final AccessToken accessToken = new AccessToken(responseObject.getString("access_token"));
result.set(new FetchRefreshTokenResult(refreshToken, accessToken));
} catch(IOException e) {
result.set(new FetchRefreshTokenResult(
FetchRefreshTokenResultStatus.CONNECTION_ERROR,
new RRError(
context.getString(R.string.error_connection_title),
context.getString(R.string.error_connection_message),
e,
null,
uri
)
));
} catch(Throwable t) {
throw new RuntimeException(t);
}
}
});
return result.get();
} catch(Throwable t) {
return new FetchRefreshTokenResult(
FetchRefreshTokenResultStatus.UNKNOWN_ERROR,
new RRError(
context.getString(R.string.error_unknown_title),
context.getString(R.string.error_unknown_message),
t,
null,
uri
)
);
}
}
private static FetchUserInfoResult fetchUserInfoSynchronous(final Context context, final AccessToken accessToken) {
final URI uri = Constants.Reddit.getUri(Constants.Reddit.PATH_ME);
try {
final HTTPBackend.Request request
= HTTPBackend.getBackend().prepareRequest(context, new HTTPBackend.RequestDetails(uri, null));
request.addHeader("Authorization", "bearer " + accessToken.token);
final AtomicReference<FetchUserInfoResult> result = new AtomicReference<>();
request.executeInThisThread(new HTTPBackend.Listener() {
@Override
public void onError(final @CacheRequest.RequestFailureType int failureType, final Throwable exception, final Integer httpStatus) {
if(httpStatus != null && httpStatus != 200) {
result.set(new FetchUserInfoResult(
FetchUserInfoResultStatus.CONNECTION_ERROR,
new RRError(
context.getString(R.string.error_unknown_title),
context.getString(R.string.error_unknown_message),
null,
httpStatus,
uri.toString()
)
));
} else {
result.set(new FetchUserInfoResult(
FetchUserInfoResultStatus.UNKNOWN_ERROR,
new RRError(
context.getString(R.string.error_unknown_title),
context.getString(R.string.error_unknown_message),
exception,
null,
uri.toString()
)
));
}
}
@Override
public void onSuccess(final String mimetype, final Long bodyBytes, final InputStream body) {
try {
final JsonValue jsonValue = new JsonValue(body);
jsonValue.buildInThisThread();
final JsonBufferedObject responseObject = jsonValue.asObject();
final String username = responseObject.getString("name");
if(username == null || username.length() == 0) {
result.set(new FetchUserInfoResult(
FetchUserInfoResultStatus.INVALID_RESPONSE,
new RRError(
context.getString(R.string.error_unknown_title),
context.getString(R.string.error_unknown_message),
null,
null,
uri.toString()
)
));
return;
}
result.set(new FetchUserInfoResult(username));
} catch(IOException e) {
result.set(new FetchUserInfoResult(
FetchUserInfoResultStatus.CONNECTION_ERROR,
new RRError(
context.getString(R.string.error_connection_title),
context.getString(R.string.error_connection_message),
e,
null,
uri.toString()
)
));
} catch(Throwable t) {
throw new RuntimeException(t);
}
}
});
return result.get();
} catch(Throwable t) {
return new FetchUserInfoResult(
FetchUserInfoResultStatus.UNKNOWN_ERROR,
new RRError(
context.getString(R.string.error_unknown_title),
context.getString(R.string.error_unknown_message),
t,
null,
uri.toString()
)
);
}
}
public enum LoginError {
SUCCESS,
USER_REFUSED_PERMISSION,
CONNECTION_ERROR,
UNKNOWN_ERROR;
static LoginError fromFetchRefreshTokenStatus(FetchRefreshTokenResultStatus status) {
switch(status) {
case SUCCESS: return SUCCESS;
case USER_REFUSED_PERMISSION: return USER_REFUSED_PERMISSION;
case INVALID_REQUEST: return UNKNOWN_ERROR;
case INVALID_RESPONSE: return UNKNOWN_ERROR;
case CONNECTION_ERROR: return CONNECTION_ERROR;
case UNKNOWN_ERROR: return UNKNOWN_ERROR;
}
return UNKNOWN_ERROR;
}
static LoginError fromFetchUserInfoStatus(FetchUserInfoResultStatus status) {
switch(status) {
case SUCCESS: return SUCCESS;
case INVALID_RESPONSE: return UNKNOWN_ERROR;
case CONNECTION_ERROR: return CONNECTION_ERROR;
case UNKNOWN_ERROR: return UNKNOWN_ERROR;
}
return UNKNOWN_ERROR;
}
}
public interface LoginListener {
void onLoginSuccess(RedditAccount account);
void onLoginFailure(LoginError error, RRError details);
}
public static void loginAsynchronous(
final Context context,
final Uri redirectUri,
final LoginListener listener) {
new Thread() {
@Override
public void run() {
try {
final FetchRefreshTokenResult fetchRefreshTokenResult
= fetchRefreshTokenSynchronous(context, redirectUri);
if(fetchRefreshTokenResult.status != FetchRefreshTokenResultStatus.SUCCESS) {
listener.onLoginFailure(
LoginError.fromFetchRefreshTokenStatus(fetchRefreshTokenResult.status),
fetchRefreshTokenResult.error);
return;
}
final FetchUserInfoResult fetchUserInfoResult
= fetchUserInfoSynchronous(context, fetchRefreshTokenResult.accessToken);
if(fetchUserInfoResult.status != FetchUserInfoResultStatus.SUCCESS) {
listener.onLoginFailure(
LoginError.fromFetchUserInfoStatus(fetchUserInfoResult.status),
fetchUserInfoResult.error);
return;
}
final RedditAccount account = new RedditAccount(
fetchUserInfoResult.username,
fetchRefreshTokenResult.refreshToken,
0);
account.setAccessToken(fetchRefreshTokenResult.accessToken);
final RedditAccountManager accountManager = RedditAccountManager.getInstance(context);
accountManager.addAccount(account);
accountManager.setDefaultAccount(account);
listener.onLoginSuccess(account);
} catch(Throwable t) {
listener.onLoginFailure(
LoginError.UNKNOWN_ERROR,
new RRError(
context.getString(R.string.error_unknown_title),
context.getString(R.string.error_unknown_message),
t
)
);
}
}
}.start();
}
public enum FetchAccessTokenResultStatus {
SUCCESS,
INVALID_REQUEST,
INVALID_RESPONSE,
CONNECTION_ERROR,
UNKNOWN_ERROR
}
public static final class FetchAccessTokenResult {
public final FetchAccessTokenResultStatus status;
public final RRError error;
public final AccessToken accessToken;
public FetchAccessTokenResult(final FetchAccessTokenResultStatus status, final RRError error) {
this.status = status;
this.error = error;
this.accessToken = null;
}
public FetchAccessTokenResult(final AccessToken accessToken) {
this.status = FetchAccessTokenResultStatus.SUCCESS;
this.error = null;
this.accessToken = accessToken;
}
}
public static FetchAccessTokenResult fetchAccessTokenSynchronous(final Context context, final RefreshToken refreshToken) {
final String uri = ACCESS_TOKEN_URL;
final ArrayList<HTTPBackend.PostField> postFields = new ArrayList<>(2);
postFields.add(new HTTPBackend.PostField("grant_type", "refresh_token"));
postFields.add(new HTTPBackend.PostField("refresh_token", refreshToken.token));
try {
final HTTPBackend.Request request = HTTPBackend.getBackend().prepareRequest(context, new HTTPBackend.RequestDetails(
General.uriFromString(uri),
postFields));
request.addHeader("Authorization", "Basic " + Base64.encodeToString((CLIENT_ID + ":").getBytes(), Base64.URL_SAFE | Base64.NO_WRAP));
final AtomicReference<FetchAccessTokenResult> result = new AtomicReference<>();
request.executeInThisThread(new HTTPBackend.Listener() {
@Override
public void onError(final @CacheRequest.RequestFailureType int failureType, final Throwable exception, final Integer httpStatus) {
result.set(handleAccessTokenError(exception, httpStatus, context, uri));
}
@Override
public void onSuccess(final String mimetype, final Long bodyBytes, final InputStream body) {
try {
final JsonValue jsonValue = new JsonValue(body);
jsonValue.buildInThisThread();
final JsonBufferedObject responseObject = jsonValue.asObject();
final String accessTokenString = responseObject.getString("access_token");
if(accessTokenString == null) {
throw new RuntimeException("Null access token: " + responseObject.getString("error"));
}
final AccessToken accessToken = new AccessToken(accessTokenString);
result.set(new FetchAccessTokenResult(accessToken));
} catch(IOException e) {
result.set(new FetchAccessTokenResult(
FetchAccessTokenResultStatus.CONNECTION_ERROR,
new RRError(
context.getString(R.string.error_connection_title),
context.getString(R.string.error_connection_message),
e,
null,
uri
)
));
} catch(Throwable t) {
throw new RuntimeException(t);
}
}
});
return result.get();
} catch(Throwable t) {
return new FetchAccessTokenResult(
FetchAccessTokenResultStatus.UNKNOWN_ERROR,
new RRError(
context.getString(R.string.error_unknown_title),
context.getString(R.string.error_unknown_message),
t,
null,
uri
)
);
}
}
public static FetchAccessTokenResult fetchAnonymousAccessTokenSynchronous(final Context context) {
final String uri = ACCESS_TOKEN_URL;
final ArrayList<HTTPBackend.PostField> postFields = new ArrayList<>(2);
postFields.add(new HTTPBackend.PostField("grant_type", "https://oauth.reddit.com/grants/installed_client"));
postFields.add(new HTTPBackend.PostField("device_id", "DO_NOT_TRACK_THIS_DEVICE"));
try {
final HTTPBackend.Request request = HTTPBackend.getBackend().prepareRequest(context, new HTTPBackend.RequestDetails(
General.uriFromString(uri),
postFields));
request.addHeader("Authorization", "Basic " + Base64.encodeToString((CLIENT_ID + ":").getBytes(), Base64.URL_SAFE | Base64.NO_WRAP));
final AtomicReference<FetchAccessTokenResult> result = new AtomicReference<>();
request.executeInThisThread(new HTTPBackend.Listener() {
@Override
public void onError(final @CacheRequest.RequestFailureType int failureType, final Throwable exception, final Integer httpStatus) {
result.set(handleAccessTokenError(exception, httpStatus, context, uri));
}
@Override
public void onSuccess(final String mimetype, final Long bodyBytes, final InputStream body) {
try {
final JsonValue jsonValue = new JsonValue(body);
jsonValue.buildInThisThread();
final JsonBufferedObject responseObject = jsonValue.asObject();
final String accessTokenString = responseObject.getString("access_token");
if(accessTokenString == null) {
throw new RuntimeException("Null access token: " + responseObject.getString("error"));
}
final AccessToken accessToken = new AccessToken(accessTokenString);
result.set(new FetchAccessTokenResult(accessToken));
} catch(IOException e) {
result.set(new FetchAccessTokenResult(
FetchAccessTokenResultStatus.CONNECTION_ERROR,
new RRError(
context.getString(R.string.error_connection_title),
context.getString(R.string.error_connection_message),
e,
null,
uri
)
));
} catch(Throwable t) {
throw new RuntimeException(t);
}
}
});
return result.get();
} catch(Throwable t) {
return new FetchAccessTokenResult(
FetchAccessTokenResultStatus.UNKNOWN_ERROR,
new RRError(
context.getString(R.string.error_unknown_title),
context.getString(R.string.message_cannotlogin),
t,
null,
uri
)
);
}
}
}