package com.vaguehope.onosendai.provider.successwhale;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;
import java.net.URLEncoder;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import local.apache.InputStreamPart;
import local.apache.MultipartEntity;
import local.apache.Part;
import local.apache.StringPart;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.HttpClient;
import org.apache.http.client.ResponseHandler;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONTokener;
import org.xml.sax.SAXException;
import android.net.http.AndroidHttpClient;
import com.vaguehope.onosendai.config.Account;
import com.vaguehope.onosendai.model.Meta;
import com.vaguehope.onosendai.model.Tweet;
import com.vaguehope.onosendai.model.TweetList;
import com.vaguehope.onosendai.provider.ServiceRef;
import com.vaguehope.onosendai.storage.KvStore;
import com.vaguehope.onosendai.update.KvKeys;
import com.vaguehope.onosendai.util.HttpClientFactory;
import com.vaguehope.onosendai.util.ImageMetadata;
import com.vaguehope.onosendai.util.IoHelper;
import com.vaguehope.onosendai.util.LogWrapper;
/**
* https://github.com/ianrenton/successwhale-api/blob/master/APIDOCS.md
*/
public class SuccessWhale {
private static final String BASE_URL = "https://api.successwhale.com:443";
private static final String API_AUTH = "/v3/authenticate.json";
private static final String API_COLUMNS = "/v3/columns.xml";
private static final String API_SOURCES = "/v3/sources.xml";
private static final String API_FEED = "/v3/feed.xml";
private static final String API_THREAD = "/v3/thread.xml";
private static final String API_POSTTOACCOUNTS = "/v3/posttoaccounts.xml";
private static final String API_ITEM = "/v3/item";
private static final String API_ACTION = "/v3/action";
private static final String API_BANNED_PHRASES = "/v3/bannedphrases.json";
static final LogWrapper LOG = new LogWrapper("SW");
private final KvStore kvStore;
private final Account account;
private final HttpClientFactory httpClientFactory;
private String token;
public SuccessWhale (final KvStore kvStore, final Account account, final HttpClientFactory httpClientFactory) {
if (kvStore == null) throw new IllegalArgumentException("kvStore can not be null.");
if (account == null) throw new IllegalArgumentException("account can not be null.");
if (httpClientFactory == null) throw new IllegalArgumentException("httpClientFactory can not be null.");
this.kvStore = kvStore;
this.account = account;
this.httpClientFactory = httpClientFactory;
}
private HttpClient getHttpClient () throws IOException {
return this.httpClientFactory.getHttpClient();
}
Account getAccount () {
return this.account;
}
private interface SwCall<T> {
T invoke (HttpClient client) throws IOException;
String describeFailure (Exception e);
}
private <T> T authenticated (final SwCall<T> call) throws SuccessWhaleException {
if (this.token == null) readAuthFromKvStore();
if (this.token == null) authenticate();
try {
try {
return call.invoke(getHttpClient());
}
catch (final NotAuthorizedException e) {
LOG.i("Stored auth token rejected, reauthenticating.");
this.token = null;
writeAuthToKvStore();
authenticate();
return call.invoke(getHttpClient());
}
}
catch (final InvalidRequestException e) {
throw new SuccessWhaleException(call.describeFailure(e), e, true);
}
catch (final IOException e) {
throw new SuccessWhaleException(call.describeFailure(e), e);
}
}
private void readAuthFromKvStore () {
final String t = this.kvStore.getValue(KvKeys.swAuthToken(getAccount()));
if (t != null && !t.isEmpty()) this.token = t;
}
private void writeAuthToKvStore () {
this.kvStore.storeValue(KvKeys.swAuthToken(getAccount()), this.token);
}
static void checkReponseCode (final HttpResponse response) throws IOException {
final int code = response.getStatusLine().getStatusCode();
if (code == 401) { // NOSONAR not a magic number.
throw new NotAuthorizedException();
}
else if (code >= 400 && code < 500) { // NOSONAR not a magic number.
throw new InvalidRequestException(response);
}
else if (code < 200 || code >= 300) { // NOSONAR not a magic number.
throw new SuccessWhaleException(response);
}
}
/**
* FIXME lock against multiple calls.
*/
private void authenticate () throws SuccessWhaleException {
final String username = this.account.getAccessToken();
final String password = this.account.getAccessSecret();
try {
final HttpPost post = new HttpPost(BASE_URL + API_AUTH);
final List<NameValuePair> params = new ArrayList<NameValuePair>(2);
params.add(new BasicNameValuePair("username", username));
params.add(new BasicNameValuePair("password", password));
post.setEntity(new UrlEncodedFormEntity(params, "UTF-8"));
this.token = getHttpClient().execute(post, new AuthHandler());
LOG.i("Authenticated username='%s'.", username);
writeAuthToKvStore();
}
catch (final IOException e) {
throw new SuccessWhaleException("Auth failed for user '" + username + "': " + e.toString(), e);
}
}
public void testLogin () throws SuccessWhaleException {
getPostToAccounts();
}
public SuccessWhaleColumns getColumns () throws SuccessWhaleException {
return authenticated(new SwCall<SuccessWhaleColumns>() {
@Override
public SuccessWhaleColumns invoke (final HttpClient client) throws IOException {
final HttpGet req = new HttpGet(makeAuthedUrl(API_COLUMNS));
AndroidHttpClient.modifyRequestToAcceptGzipResponse(req);
return client.execute(req, new ColumnsHandler(getAccount()));
}
@Override
public String describeFailure (final Exception e) {
return "Failed to fetch columns: " + e.toString();
}
});
}
public SuccessWhaleSources getSources () throws SuccessWhaleException {
return authenticated(new SwCall<SuccessWhaleSources>() {
@Override
public SuccessWhaleSources invoke (final HttpClient client) throws IOException {
final HttpGet req = new HttpGet(makeAuthedUrl(API_SOURCES));
AndroidHttpClient.modifyRequestToAcceptGzipResponse(req);
return client.execute(req, SourcesHandler.INSTANCE);
}
@Override
public String describeFailure (final Exception e) {
return "Failed to fetch sources: " + e.toString();
}
});
}
public TweetList getFeed (final SuccessWhaleFeed feed, final String sinceId, final Collection<Meta> extraMetas) throws SuccessWhaleException {
return authenticated(new SwCall<TweetList>() {
private String url;
@Override
public TweetList invoke (final HttpClient client) throws IOException {
this.url = makeAuthedUrl(API_FEED, "&sources=", URLEncoder.encode(feed.getSources(), "UTF-8"));
// FIXME disabling this until SW finds a way to accept it on mixed feeds [issue 89].
// if (sinceId != null) this.url += "&since_id=" + sinceId;
final HttpGet req = new HttpGet(this.url);
AndroidHttpClient.modifyRequestToAcceptGzipResponse(req);
return client.execute(req, new FeedHandler(getAccount(), extraMetas));
}
@Override
public String describeFailure (final Exception e) {
return "Failed to fetch feed '" + feed + "' from '" + this.url + "': " + e.toString();
}
});
}
public TweetList getThread (final String serviceType, final String serviceSid, final String forSid) throws SuccessWhaleException {
return authenticated(new SwCall<TweetList>() {
@Override
public TweetList invoke (final HttpClient client) throws IOException {
final String url = makeAuthedUrl(API_THREAD, "&service=", serviceType, "&uid=" + serviceSid, "&postid=", forSid);
final HttpGet req = new HttpGet(url);
AndroidHttpClient.modifyRequestToAcceptGzipResponse(req);
final TweetList thread = client.execute(req, new FeedHandler(getAccount(), null));
return removeItem(thread, forSid);
}
@Override
public String describeFailure (final Exception e) {
return "Failed to fetch thread for sid='" + forSid + "': " + e.toString();
}
});
}
public List<ServiceRef> getPostToAccounts () throws SuccessWhaleException {
return authenticated(new SwCall<List<ServiceRef>>() {
@Override
public List<ServiceRef> invoke (final HttpClient client) throws IOException {
return client.execute(new HttpGet(makeAuthedUrl(API_POSTTOACCOUNTS)), new PostToAccountsHandler(SuccessWhale.this));
}
@Override
public String describeFailure (final Exception e) {
return "Failed to fetch post to accounts: " + e.toString();
}
});
}
public List<ServiceRef> getPostToAccountsCached () {
final String key = KvKeys.swPta(getAccount());
try {
final String cached = this.kvStore.getValue(key);
if (cached == null || cached.isEmpty()) return null;
return new PostToAccountsXml(new StringReader(cached)).getAccounts();
}
catch (final SAXException e) {
LOG.e("Failed to parse cached post to accounts. Clearing cache.", e);
this.kvStore.storeValue(key, null);
return null;
}
}
protected void writePostToAccountsToCache (final String data) {
this.kvStore.storeValue(KvKeys.swPta(getAccount()), data);
}
public void post (final Set<ServiceRef> postToSvc, final String body, final String inReplyToSid, final ImageMetadata image) throws SuccessWhaleException {
authenticated(new SwCall<Void>() {
@Override
public Void invoke (final HttpClient client) throws IOException {
attemptPost(client, postToSvc, body, inReplyToSid, image);
return null;
}
@Override
public String describeFailure (final Exception e) {
return "Failed to post via SuccessWhale: " + e.toString();
}
});
}
protected void attemptPost (final HttpClient client, final Set<ServiceRef> postToSvc, final String body, final String inReplyToSid, final ImageMetadata image) throws IOException {
InputStream attachmentIs = null;
try {
final HttpPost post = new HttpPost(BASE_URL + API_ITEM);
final List<Part> parts = new ArrayList<Part>();
parts.add(new StringPart("token", SuccessWhale.this.token));
parts.add(new StringPart("text", body));
final StringBuilder accounts = new StringBuilder();
for (final ServiceRef svc : postToSvc) {
if (accounts.length() > 0) accounts.append(":");
accounts.append(svc.getRawType()).append("/").append(svc.getUid());
}
parts.add(new StringPart("accounts", accounts.toString()));
if (inReplyToSid != null && !inReplyToSid.isEmpty()) {
parts.add(new StringPart("in_reply_to_id", inReplyToSid));
}
if (image != null && image.exists()) {
attachmentIs = image.open();
parts.add(new InputStreamPart("file", image.getName(), image.getSize(), attachmentIs));
}
post.setEntity(new MultipartEntity(parts.toArray(new Part[] {})));
client.execute(post, new CheckStatusOnlyHandler());
}
finally {
IoHelper.closeQuietly(attachmentIs);
}
}
public void itemAction (final ServiceRef svc, final String itemSid, final ItemAction itemAction) throws SuccessWhaleException {
authenticated(new SwCall<Void>() {
@Override
public Void invoke (final HttpClient client) throws IOException {
attemptItemAction(client, svc, itemSid, itemAction);
return null;
}
@Override
public String describeFailure (final Exception e) {
return "Item action failed: " + e.toString();
}
});
}
protected void attemptItemAction (final HttpClient client, final ServiceRef svc, final String itemSid, final ItemAction itemAction) throws IOException {
final HttpPost post = new HttpPost(BASE_URL + API_ACTION);
final List<NameValuePair> params = new ArrayList<NameValuePair>(4);
addAuthParams(params);
params.add(new BasicNameValuePair("service", svc.getRawType()));
params.add(new BasicNameValuePair("uid", svc.getUid()));
params.add(new BasicNameValuePair("postid", itemSid));
params.add(new BasicNameValuePair("action", itemAction.getAction()));
post.setEntity(new UrlEncodedFormEntity(params, "UTF-8"));
client.execute(post, new CheckStatusOnlyHandler());
}
public List<String> getBannedPhrases () throws SuccessWhaleException {
return authenticated(new SwCall<List<String>>() {
@Override
public List<String> invoke (final HttpClient client) throws IOException {
final HttpGet req = new HttpGet(makeAuthedUrl(API_BANNED_PHRASES));
AndroidHttpClient.modifyRequestToAcceptGzipResponse(req);
return client.execute(req, BannedPhrasesHandler.INSTANCE);
}
@Override
public String describeFailure (final Exception e) {
return "Failed to fetch banned phrases: " + e.toString();
}
});
}
public void setBannedPhrases (final List<String> bannedPhrases) throws SuccessWhaleException {
authenticated(new SwCall<Void>() {
@Override
public Void invoke (final HttpClient client) throws IOException {
final HttpPost post = new HttpPost(BASE_URL + API_BANNED_PHRASES);
final List<NameValuePair> params = new ArrayList<NameValuePair>(4);
addAuthParams(params);
params.add(new BasicNameValuePair("bannedphrases", new JSONArray(bannedPhrases).toString()));
post.setEntity(new UrlEncodedFormEntity(params, "UTF-8"));
client.execute(post, new CheckStatusOnlyHandler());
return null;
}
@Override
public String describeFailure (final Exception e) {
return "Failed to set banned phrases: " + e.toString();
}
});
}
String makeAuthedUrl (final String api, final String... params) {
final StringBuilder u = new StringBuilder().append(BASE_URL).append(api).append("?")
.append("&token=").append(this.token);
if (params != null) {
for (final String param : params) {
u.append(param);
}
}
return u.toString();
}
void addAuthParams (final List<NameValuePair> params) {
params.add(new BasicNameValuePair("token", this.token));
}
static TweetList removeItem (final TweetList thread, final String sid) {
if (thread.count() < 1 || sid == null) return thread;
for (final Tweet t : thread.getTweets()) {
if (sid.equals(t.getSid())) {
final List<Tweet> newList = new ArrayList<Tweet>(thread.getTweets());
newList.remove(t);
return new TweetList(newList);
}
}
return thread;
}
private static class AuthHandler implements ResponseHandler<String> {
public AuthHandler () {}
@Override
public String handleResponse (final HttpResponse response) throws IOException {
checkReponseCode(response);
try {
final String authRespRaw = EntityUtils.toString(response.getEntity());
final JSONObject authResp = (JSONObject) new JSONTokener(authRespRaw).nextValue();
if (!authResp.getBoolean("success")) {
throw new IOException("Auth rejected: " + authResp.getString("error"));
}
return authResp.getString("token");
}
catch (final JSONException e) {
throw new IOException("Response unparsable: " + e.toString(), e);
}
}
}
private static class PostToAccountsHandler implements ResponseHandler<List<ServiceRef>> {
private final SuccessWhale sw;
public PostToAccountsHandler (final SuccessWhale sw) {
this.sw = sw;
}
@Override
public List<ServiceRef> handleResponse (final HttpResponse response) throws IOException {
checkReponseCode(response);
try {
final byte[] data = EntityUtils.toByteArray(response.getEntity());
final List<ServiceRef> accounts = new PostToAccountsXml(new ByteArrayInputStream(data)).getAccounts();
if (this.sw != null) this.sw.writePostToAccountsToCache(new String(data, Charset.forName("UTF-8")));
return accounts;
}
catch (final SAXException e) {
throw new IOException("Failed to parse response: " + e.toString(), e);
}
}
}
private static class ColumnsHandler implements ResponseHandler<SuccessWhaleColumns> {
private final Account account;
public ColumnsHandler (final Account account) {
this.account = account;
}
@Override
public SuccessWhaleColumns handleResponse (final HttpResponse response) throws IOException {
checkReponseCode(response);
try {
return new ColumnsXml(this.account, AndroidHttpClient.getUngzippedContent(response.getEntity())).getColumns();
}
catch (final SAXException e) {
throw new IOException("Failed to parse response: " + e.toString(), e);
}
}
}
private enum SourcesHandler implements ResponseHandler<SuccessWhaleSources> {
INSTANCE;
@Override
public SuccessWhaleSources handleResponse (final HttpResponse response) throws IOException {
checkReponseCode(response);
try {
return new SourcesXml(AndroidHttpClient.getUngzippedContent(response.getEntity())).getSources();
}
catch (final SAXException e) {
throw new IOException("Failed to parse response: " + e.toString(), e);
}
}
}
private static class FeedHandler implements ResponseHandler<TweetList> {
private final Account account;
private final Collection<Meta> extraMetas;
public FeedHandler (final Account account, final Collection<Meta> extraMetas) {
this.account = account;
this.extraMetas = extraMetas;
}
@Override
public TweetList handleResponse (final HttpResponse response) throws IOException {
checkReponseCode(response);
try {
final HttpEntity entity = response.getEntity();
LOG.d("Feed content encoding: '%s', headers: %s.", entity.getContentEncoding(), Arrays.asList(response.getAllHeaders()));
return new SuccessWhaleFeedXml(this.account, AndroidHttpClient.getUngzippedContent(entity), this.extraMetas).getTweets();
}
catch (final SAXException e) {
throw new IOException("Failed to parse response: " + e.toString(), e);
}
}
}
private enum BannedPhrasesHandler implements ResponseHandler<List<String>> {
INSTANCE;
@Override
public List<String> handleResponse (final HttpResponse response) throws ClientProtocolException, IOException {
checkReponseCode(response);
final String raw = IoHelper.toString(AndroidHttpClient.getUngzippedContent(response.getEntity()));
try {
final JSONArray arr = ((JSONObject) new JSONTokener(raw).nextValue()).getJSONArray("bannedphrases");
final List<String> ret = new ArrayList<String>();
for (int i = 0; i < arr.length(); i++) {
ret.add(arr.getString(i));
}
return ret;
}
catch (final JSONException e) {
throw new IOException("Failed to parse response: " + e.toString(), e);
}
}
}
private static class CheckStatusOnlyHandler implements ResponseHandler<Void> {
public CheckStatusOnlyHandler () {}
@Override
public Void handleResponse (final HttpResponse response) throws IOException {
checkReponseCode(response);
return null;
}
}
}