/*
* Copyright (c) 2015 Ha Duy Trung
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.github.hidroh.materialistic.accounts;
import android.content.Context;
import android.net.Uri;
import android.support.v4.util.Pair;
import android.text.TextUtils;
import android.widget.Toast;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.inject.Inject;
import io.github.hidroh.materialistic.AppUtils;
import io.github.hidroh.materialistic.R;
import okhttp3.Call;
import okhttp3.FormBody;
import okhttp3.HttpUrl;
import okhttp3.Request;
import okhttp3.Response;
import rx.Observable;
import rx.Scheduler;
import rx.android.schedulers.AndroidSchedulers;
public class UserServicesClient implements UserServices {
private static final String BASE_WEB_URL = "https://news.ycombinator.com";
private static final String LOGIN_PATH = "login";
private static final String VOTE_PATH = "vote";
private static final String COMMENT_PATH = "comment";
private static final String SUBMIT_PATH = "submit";
private static final String ITEM_PATH = "item";
private static final String SUBMIT_POST_PATH = "r";
private static final String LOGIN_PARAM_ACCT = "acct";
private static final String LOGIN_PARAM_PW = "pw";
private static final String LOGIN_PARAM_CREATING = "creating";
private static final String LOGIN_PARAM_GOTO = "goto";
private static final String ITEM_PARAM_ID = "id";
private static final String VOTE_PARAM_ID = "id";
private static final String VOTE_PARAM_HOW = "how";
private static final String COMMENT_PARAM_PARENT = "parent";
private static final String COMMENT_PARAM_TEXT = "text";
private static final String SUBMIT_PARAM_TITLE = "title";
private static final String SUBMIT_PARAM_URL = "url";
private static final String SUBMIT_PARAM_TEXT = "text";
private static final String SUBMIT_PARAM_FNID = "fnid";
private static final String SUBMIT_PARAM_FNOP = "fnop";
private static final String VOTE_DIR_UP = "up";
private static final String DEFAULT_REDIRECT = "news";
private static final String CREATING_TRUE = "t";
private static final String DEFAULT_FNOP = "submit-page";
private static final String DEFAULT_SUBMIT_REDIRECT = "newest";
private static final String REGEX_INPUT = "<\\s*input[^>]*>";
private static final String REGEX_VALUE = "value[^\"]*\"([^\"]*)\"";
private static final String REGEX_CREATE_ERROR_BODY = "<body>([^<]*)";
private static final String HEADER_LOCATION = "location";
private static final String HEADER_COOKIE = "cookie";
private static final String HEADER_SET_COOKIE = "set-cookie";
private final Call.Factory mCallFactory;
private final Scheduler mIoScheduler;
@Inject
public UserServicesClient(Call.Factory callFactory, Scheduler ioScheduler) {
mCallFactory = callFactory;
mIoScheduler = ioScheduler;
}
@Override
public void login(String username, String password, boolean createAccount, Callback callback) {
execute(postLogin(username, password, createAccount))
.flatMap(response -> {
if (response.code() == HttpURLConnection.HTTP_OK) {
return Observable.error(new UserServices.Exception(parseLoginError(response)));
}
return Observable.just(response.code() == HttpURLConnection.HTTP_MOVED_TEMP);
})
.observeOn(AndroidSchedulers.mainThread())
.subscribe(callback::onDone, callback::onError);
}
@Override
public boolean voteUp(Context context, String itemId, Callback callback) {
Pair<String, String> credentials = AppUtils.getCredentials(context);
if (credentials == null) {
return false;
}
Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show();
execute(postVote(credentials.first, credentials.second, itemId))
.map(response -> response.code() == HttpURLConnection.HTTP_MOVED_TEMP)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(callback::onDone, callback::onError);
return true;
}
@Override
public void reply(Context context, String parentId, String text, Callback callback) {
Pair<String, String> credentials = AppUtils.getCredentials(context);
if (credentials == null) {
callback.onDone(false);
return;
}
execute(postReply(parentId, text, credentials.first, credentials.second))
.map(response -> response.code() == HttpURLConnection.HTTP_MOVED_TEMP)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(callback::onDone, callback::onError);
}
@Override
public void submit(Context context, String title, String content, boolean isUrl, Callback callback) {
Pair<String, String> credentials = AppUtils.getCredentials(context);
if (credentials == null) {
callback.onDone(false);
return;
}
/**
* The flow:
* POST /submit with acc, pw
* if 302 to /login, considered failed
* POST /r with fnid, fnop, title, url or text
* if 302 to /newest, considered successful
* if 302 to /x, considered error, maybe duplicate or invalid input
* if 200 or anything else, considered error
*/
// fetch submit page with given credentials
execute(postSubmitForm(credentials.first, credentials.second))
.flatMap(response -> response.code() != HttpURLConnection.HTTP_MOVED_TEMP ?
Observable.just(response) :
Observable.error(new IOException()))
.flatMap(response -> {
try {
return Observable.just(new String[]{
response.header(HEADER_SET_COOKIE),
response.body().string()
});
} catch (IOException e) {
return Observable.error(e);
} finally {
response.close();
}
})
.map(array -> {
array[1] = getInputValue(array[1], SUBMIT_PARAM_FNID);
return array;
})
.flatMap(array -> !TextUtils.isEmpty(array[1]) ?
Observable.just(array) :
Observable.error(new IOException()))
.flatMap(array -> execute(postSubmit(title, content, isUrl, array[0], array[1])))
.flatMap(response -> response.code() == HttpURLConnection.HTTP_MOVED_TEMP ?
Observable.just(Uri.parse(response.header(HEADER_LOCATION))) :
Observable.error(new IOException()))
.flatMap(uri -> TextUtils.equals(uri.getPath(), DEFAULT_SUBMIT_REDIRECT) ?
Observable.just(true) :
Observable.error(buildException(uri)))
.observeOn(AndroidSchedulers.mainThread())
.subscribe(callback::onDone, callback::onError);
}
private Request postLogin(String username, String password, boolean createAccount) {
FormBody.Builder formBuilder = new FormBody.Builder()
.add(LOGIN_PARAM_ACCT, username)
.add(LOGIN_PARAM_PW, password)
.add(LOGIN_PARAM_GOTO, DEFAULT_REDIRECT);
if (createAccount) {
formBuilder.add(LOGIN_PARAM_CREATING, CREATING_TRUE);
}
return new Request.Builder()
.url(HttpUrl.parse(BASE_WEB_URL)
.newBuilder()
.addPathSegment(LOGIN_PATH)
.build())
.post(formBuilder.build())
.build();
}
private Request postVote(String username, String password, String itemId) {
return new Request.Builder()
.url(HttpUrl.parse(BASE_WEB_URL)
.newBuilder()
.addPathSegment(VOTE_PATH)
.build())
.post(new FormBody.Builder()
.add(LOGIN_PARAM_ACCT, username)
.add(LOGIN_PARAM_PW, password)
.add(VOTE_PARAM_ID, itemId)
.add(VOTE_PARAM_HOW, VOTE_DIR_UP)
.build())
.build();
}
private Request postReply(String parentId, String text, String username, String password) {
return new Request.Builder()
.url(HttpUrl.parse(BASE_WEB_URL)
.newBuilder()
.addPathSegment(COMMENT_PATH)
.build())
.post(new FormBody.Builder()
.add(LOGIN_PARAM_ACCT, username)
.add(LOGIN_PARAM_PW, password)
.add(COMMENT_PARAM_PARENT, parentId)
.add(COMMENT_PARAM_TEXT, text)
.build())
.build();
}
private Request postSubmitForm(String username, String password) {
return new Request.Builder()
.url(HttpUrl.parse(BASE_WEB_URL)
.newBuilder()
.addPathSegment(SUBMIT_PATH)
.build())
.post(new FormBody.Builder()
.add(LOGIN_PARAM_ACCT, username)
.add(LOGIN_PARAM_PW, password)
.build())
.build();
}
private Request postSubmit(String title, String content, boolean isUrl, String cookie, String fnid) {
Request.Builder builder = new Request.Builder()
.url(HttpUrl.parse(BASE_WEB_URL)
.newBuilder()
.addPathSegment(SUBMIT_POST_PATH)
.build())
.post(new FormBody.Builder()
.add(SUBMIT_PARAM_FNID, fnid)
.add(SUBMIT_PARAM_FNOP, DEFAULT_FNOP)
.add(SUBMIT_PARAM_TITLE, title)
.add(isUrl ? SUBMIT_PARAM_URL : SUBMIT_PARAM_TEXT, content)
.build());
if (!TextUtils.isEmpty(cookie)) {
builder.addHeader(HEADER_COOKIE, cookie);
}
return builder.build();
}
private Observable<Response> execute(Request request) {
return Observable.defer(() -> {
try {
return Observable.just(mCallFactory.newCall(request).execute());
} catch (IOException e) {
return Observable.error(e);
}
}).subscribeOn(mIoScheduler);
}
private Throwable buildException(Uri uri) {
switch (uri.getPath()) {
case ITEM_PATH:
UserServices.Exception exception = new UserServices.Exception(R.string.item_exist);
String itemId = uri.getQueryParameter(ITEM_PARAM_ID);
if (!TextUtils.isEmpty(itemId)) {
exception.data = AppUtils.createItemUri(itemId);
}
return exception;
default:
return new IOException();
}
}
private String getInputValue(String html, String name) {
// extract <input ... >
Matcher matcherInput = Pattern.compile(REGEX_INPUT).matcher(html);
while (matcherInput.find()) {
String input = matcherInput.group();
if (input.contains(name)) {
// extract value="..."
Matcher matcher = Pattern.compile(REGEX_VALUE).matcher(input);
return matcher.find() ? matcher.group(1) : null; // return first match if any
}
}
return null;
}
private String parseLoginError(Response response) {
try {
Matcher matcher = Pattern.compile(REGEX_CREATE_ERROR_BODY).matcher(response.body().string());
return matcher.find() ? matcher.group(1).replaceAll("\\n|\\r|\\t|\\s+", " ").trim() : null;
} catch (IOException e) {
return null;
}
}
}