package org.xmlrpc.android;
import android.content.Context;
import android.text.TextUtils;
import android.util.Xml;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.conn.scheme.Scheme;
import org.apache.http.entity.FileEntity;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.params.HttpConnectionParams;
import org.apache.http.params.HttpParams;
import org.apache.http.params.HttpProtocolParams;
import org.apache.http.util.EntityUtils;
import org.wordpress.android.WordPress;
import org.wordpress.android.models.AccountHelper;
import org.wordpress.android.util.AppLog;
import org.wordpress.android.util.AppLog.T;
import org.wordpress.android.util.CoreEvents;
import org.wordpress.android.util.StringUtils;
import org.wordpress.android.util.WPUrlUtils;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlPullParserFactory;
import org.xmlpull.v1.XmlSerializer;
import org.xmlrpc.android.ApiHelper.Method;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileWriter;
import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.SequenceInputStream;
import java.io.StringWriter;
import java.net.URI;
import java.net.URL;
import java.security.GeneralSecurityException;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLPeerUnverifiedException;
import de.greenrobot.event.EventBus;
/**
* A WordPress XMLRPC Client.
* Based on android-xmlrpc: code.google.com/p/android-xmlrpc/
* Async support based on aXMLRPC: https://github.com/timroes/aXMLRPC
*/
public class XMLRPCClient implements XMLRPCClientInterface {
public static final int DEFAULT_CONNECTION_TIMEOUT_MS = 30000;
public static final int DEFAULT_SOCKET_TIMEOUT_MS = 60000;
public interface OnBytesUploadedListener {
public void onBytesUploaded(long uploadedBytes);
}
private static final String TAG_METHOD_CALL = "methodCall";
private static final String TAG_METHOD_NAME = "methodName";
private static final String TAG_METHOD_RESPONSE = "methodResponse";
private static final String TAG_PARAMS = "params";
private static final String TAG_PARAM = "param";
private static final String TAG_FAULT = "fault";
private static final String TAG_FAULT_CODE = "faultCode";
private static final String TAG_FAULT_STRING = "faultString";
private Map<Long,Caller> backgroundCalls = new HashMap<Long, Caller>();
private DefaultHttpClient mClient;
private OnBytesUploadedListener mOnBytesUploadedListener;
private HttpPost mPostMethod;
private XmlSerializer mSerializer;
private HttpParams mHttpParams;
private LoggedInputStream mLoggedInputStream;
private boolean mIsWpcom;
/**
* XMLRPCClient constructor. Creates new instance based on server URI
* @param uri xml-rpc server URI
*/
public XMLRPCClient(URI uri, String httpuser, String httppasswd) {
mPostMethod = new HttpPost(uri);
mPostMethod.addHeader("Content-Type", "text/xml");
mPostMethod.addHeader("charset", "UTF-8");
mPostMethod.addHeader("User-Agent", WordPress.getUserAgent());
addWPComAuthorizationHeaderIfNeeded();
mHttpParams = mPostMethod.getParams();
HttpProtocolParams.setUseExpectContinue(mHttpParams, false);
UsernamePasswordCredentials credentials = null;
if (!TextUtils.isEmpty(httpuser) && !TextUtils.isEmpty(httppasswd)) {
credentials = new UsernamePasswordCredentials(httpuser, httppasswd);
}
mClient = instantiateClientForUri(uri, credentials);
mSerializer = Xml.newSerializer();
}
public String getResponse() {
if (mLoggedInputStream == null) {
return "";
}
return mLoggedInputStream.getResponseDocument();
}
private class ConnectionClient extends DefaultHttpClient {
public ConnectionClient(int port) throws IOException, GeneralSecurityException {
super();
TrustUserSSLCertsSocketFactory tasslf = new TrustUserSSLCertsSocketFactory();
Scheme scheme = new Scheme("https", tasslf, port);
getConnectionManager().getSchemeRegistry().register(scheme);
}
}
private DefaultHttpClient instantiateClientForUri(URI uri, UsernamePasswordCredentials usernamePasswordCredentials) {
DefaultHttpClient client = null;
if (WPUrlUtils.isWordPressCom(uri)) {
mIsWpcom = true;
}
if (mIsWpcom) {
//wpcom blog or self-hosted blog on plain HTTP
client = new DefaultHttpClient();
} else {
int port = uri.getPort();
if (port == -1) {
port = 443;
}
try {
client = new ConnectionClient(port);
} catch (GeneralSecurityException e) {
AppLog.e(T.API, "Cannot create the DefaultHttpClient object with our TrustUserSSLCertsSocketFactory", e);
client = null;
} catch (IOException e) {
AppLog.e(T.API, "Cannot create the DefaultHttpClient object with our TrustUserSSLCertsSocketFactory", e);
client = null;
}
if (client == null) {
client = new DefaultHttpClient();
}
}
HttpConnectionParams.setConnectionTimeout(client.getParams(), DEFAULT_CONNECTION_TIMEOUT_MS);
HttpConnectionParams.setSoTimeout(client.getParams(), DEFAULT_SOCKET_TIMEOUT_MS);
// Setup HTTP Basic Auth if necessary
if (usernamePasswordCredentials != null) {
BasicCredentialsProvider cP = new BasicCredentialsProvider();
cP.setCredentials(AuthScope.ANY, usernamePasswordCredentials);
client.setCredentialsProvider(cP);
}
return client;
}
public void addQuickPostHeader(String type) {
mPostMethod.addHeader("WP-QUICK-POST", type);
}
/**
* Convenience constructor. Creates new instance based on server String address
* @param url server url
*/
public XMLRPCClient(String url, String httpuser, String httppasswd) {
this(URI.create(url), httpuser, httppasswd);
}
/**
* Convenience XMLRPCClient constructor. Creates new instance based on server URL
* @param url server URL
*/
public XMLRPCClient(URL url, String httpuser, String httppasswd) {
this(URI.create(url.toExternalForm()), httpuser, httppasswd);
}
/**
* Set WP.com auth header
* @param authToken authorization token
*/
public void setAuthorizationHeader(String authToken) {
if( authToken != null)
mPostMethod.addHeader("Authorization", String.format("Bearer %s", authToken));
else
mPostMethod.removeHeaders("Authorization");
}
/**
* Call method with optional parameters. This is general method.
* If you want to call your method with 0-8 parameters, you can use more
* convenience call methods
*
* @param method name of method to call
* @param params parameters to pass to method (may be null if method has no parameters)
* @return deserialized method return value
* @throws XMLRPCException
*/
public Object call(String method, Object[] params) throws XMLRPCException, IOException, XmlPullParserException {
return call(method, params, null);
}
/**
* Convenience method call with no parameters
*
* @param method name of method to call
* @return deserialized method return value
* @throws XMLRPCException
*/
public Object call(String method) throws XMLRPCException, IOException, XmlPullParserException {
return call(method, null, null);
}
public Object call(String method, Object[] params, File tempFile) throws XMLRPCException, IOException, XmlPullParserException {
return new Caller().callXMLRPC(method, params, tempFile);
}
/**
* Convenience call for callAsync with two paramaters
*
* @param listener, methodName, parameters
* @return unique id of this async call
* @throws XMLRPCException
*/
public long callAsync(XMLRPCCallback listener, String methodName, Object[] params) {
return callAsync(listener, methodName, params, null);
}
/**
* Asynchronous XMLRPC call
*
* @param listener, XMLRPC methodName, XMLRPC parameters, File for large uploads
* @return unique id of this async call
* @throws XMLRPCException
*/
public long callAsync(XMLRPCCallback listener, String methodName, Object[] params, File tempFile) {
long id = System.currentTimeMillis();
new Caller(listener, id, methodName, params, tempFile).start();
return id;
}
/**
* Cancel the current call
*/
public void cancel() {
mPostMethod.abort();
}
@SuppressWarnings("unchecked")
public static Object parseXMLRPCResponse(InputStream is, HttpEntity entity)
throws XMLRPCException, IOException, XmlPullParserException, NumberFormatException {
// setup pull parser
XmlPullParser pullParser = XmlPullParserFactory.newInstance().newPullParser();
// Many WordPress configs can output junk before the xml response (php warnings for example), this cleans it.
int bomCheck = -1;
int stopper = 0;
while ((bomCheck = is.read()) != -1 && stopper <= 5000) {
stopper++;
String snippet = "";
// 60 == '<' character
if (bomCheck == 60) {
for (int i = 0; i < 4; i++) {
byte[] chunk = new byte[1];
is.read(chunk);
snippet += new String(chunk, "UTF-8");
}
if (snippet.equals("?xml")) {
// it's all good, add xml tag back and start parsing
String start = "<" + snippet;
List<InputStream> streams = Arrays.asList(new ByteArrayInputStream(start.getBytes()), is);
is = new SequenceInputStream(Collections.enumeration(streams));
break;
} else {
// keep searching...
List<InputStream> streams = Arrays.asList(new ByteArrayInputStream(snippet.getBytes()), is);
is = new SequenceInputStream(Collections.enumeration(streams));
}
}
}
pullParser.setInput(is, "UTF-8");
// lets start pulling...
pullParser.nextTag();
pullParser.require(XmlPullParser.START_TAG, null, TAG_METHOD_RESPONSE);
pullParser.nextTag(); // either TAG_PARAMS (<params>) or TAG_FAULT (<fault>)
String tag = pullParser.getName();
if (tag.equals(TAG_PARAMS)) {
// normal response
pullParser.nextTag(); // TAG_PARAM (<param>)
pullParser.require(XmlPullParser.START_TAG, null, TAG_PARAM);
pullParser.nextTag(); // TAG_VALUE (<value>)
// no parser.require() here since its called in XMLRPCSerializer.deserialize() below
// deserialize result
Object obj = XMLRPCSerializer.deserialize(pullParser);
consumeHttpEntity(entity);
return obj;
} else if (tag.equals(TAG_FAULT)) {
// fault response
pullParser.nextTag(); // TAG_VALUE (<value>)
// no parser.require() here since its called in XMLRPCSerializer.deserialize() below
// deserialize fault result
Map<String, Object> map = (Map<String, Object>) XMLRPCSerializer.deserialize(pullParser);
consumeHttpEntity(entity);
//Check that required tags are in the response
if (!map.containsKey(TAG_FAULT_STRING) || !map.containsKey(TAG_FAULT_CODE)) {
throw new XMLRPCException("Bad XMLRPC Fault response received - <faultCode> and/or <faultString> missing!");
}
String faultString = String.valueOf(map.get(TAG_FAULT_STRING));
int faultCode;
try {
faultCode = (int) map.get(TAG_FAULT_CODE);
} catch (NumberFormatException | ClassCastException e) {
throw new XMLRPCException("Bad XMLRPC Fault response received - <faultCode> value is not a valid integer");
}
throw new XMLRPCFault(faultString, faultCode);
} else {
consumeHttpEntity(entity);
throw new XMLRPCException("Bad tag <" + tag + "> in XMLRPC response - neither <params> nor <fault>");
}
}
/**
* Deallocate Http Entity and close streams
*/
private static void consumeHttpEntity(HttpEntity entity) {
// Ideally we should use EntityUtils.consume(), introduced in apache http utils 4.1 - not available in
// Android yet
if (entity != null) {
try {
entity.consumeContent();
} catch (IOException e) {
// ignore exception (could happen if Content-Length is wrong)
}
}
}
public void preparePostMethod(String method, Object[] params, File tempFile) throws IOException, XMLRPCException, IllegalArgumentException, IllegalStateException {
// prepare POST body
if (method.equals(Method.UPLOAD_FILE)) {
if (!tempFile.exists() && !tempFile.mkdirs()) {
throw new XMLRPCException("Path to file could not be created.");
}
FileWriter fileWriter = new FileWriter(tempFile);
mSerializer.setOutput(fileWriter);
mSerializer.startDocument(null, null);
mSerializer.startTag(null, TAG_METHOD_CALL);
// set method name
mSerializer.startTag(null, TAG_METHOD_NAME).text(method).endTag(null, TAG_METHOD_NAME);
if (params != null && params.length != 0) {
// set method params
mSerializer.startTag(null, TAG_PARAMS);
for (int i = 0; i < params.length; i++) {
mSerializer.startTag(null, TAG_PARAM).startTag(null, XMLRPCSerializer.TAG_VALUE);
XMLRPCSerializer.serialize(mSerializer, params[i]);
mSerializer.endTag(null, XMLRPCSerializer.TAG_VALUE).endTag(null, TAG_PARAM);
}
mSerializer.endTag(null, TAG_PARAMS);
}
mSerializer.endTag(null, TAG_METHOD_CALL);
mSerializer.endDocument();
fileWriter.flush();
fileWriter.close();
FileEntity fEntity = new FileEntity(tempFile, "text/xml; charset=\"UTF-8\"") {
// Hook in a CountingOutputStream to keep track of bytes uploaded
@Override
public void writeTo(final OutputStream outstream) throws IOException {
super.writeTo(new CountingOutputStream(outstream));
}
};
fEntity.setContentType("text/xml");
mPostMethod.setEntity(fEntity);
} else {
StringWriter bodyWriter = new StringWriter();
mSerializer.setOutput(bodyWriter);
mSerializer.startDocument(null, null);
mSerializer.startTag(null, TAG_METHOD_CALL);
// set method name
mSerializer.startTag(null, TAG_METHOD_NAME).text(method).endTag(null, TAG_METHOD_NAME);
if (params != null && params.length != 0) {
// set method params
mSerializer.startTag(null, TAG_PARAMS);
for (int i = 0; i < params.length; i++) {
mSerializer.startTag(null, TAG_PARAM).startTag(null, XMLRPCSerializer.TAG_VALUE);
if (method.equals("metaWeblog.editPost") || method.equals("metaWeblog.newPost")) {
XMLRPCSerializer.serialize(mSerializer, params[i]);
} else {
XMLRPCSerializer.serialize(mSerializer, params[i]);
}
mSerializer.endTag(null, XMLRPCSerializer.TAG_VALUE).endTag(null, TAG_PARAM);
}
mSerializer.endTag(null, TAG_PARAMS);
}
mSerializer.endTag(null, TAG_METHOD_CALL);
mSerializer.endDocument();
HttpEntity entity = new StringEntity(bodyWriter.toString());
mPostMethod.setEntity(entity);
}
}
/**
* The Caller class is used to make asynchronous calls to the server.
* For synchronous calls the Thread function of this class isn't used.
*/
private class Caller extends Thread {
private XMLRPCCallback listener;
private long threadId;
private String methodName;
private Object[] params;
private File tempFile;
/**
* Create a new Caller for asynchronous use.
*
* @param listener The listener to notice about the response or an error.
* @param threadId An id that will be send to the listener.
* @param methodName The method name to call.
* @param params The parameters of the call or null.
*/
public Caller(XMLRPCCallback listener, long threadId, String methodName, Object[] params, File tempFile) {
this.listener = listener;
this.threadId = threadId;
this.methodName = methodName;
this.params = params;
this.tempFile = tempFile;
}
/**
* Create a new Caller for synchronous use.
* If the caller has been created with this constructor you cannot use the
* start method to start it as a thread. But you can call the call method
* on it for synchronous use.
*/
public Caller() { }
/**
* The run method is invoked when the thread gets started.
* This will only work, if the Caller has been created with parameters.
* It execute the call method and notify the listener about the result.
*/
@Override
public void run() {
if(listener == null)
return;
try {
backgroundCalls.put(threadId, this);
Object o = this.callXMLRPC(methodName, params, tempFile);
listener.onSuccess(threadId, o);
} catch(CancelException ex) {
// Don't notify the listener, if the call has been canceled.
} catch (Exception ex) {
listener.onFailure(threadId, ex);
} finally {
backgroundCalls.remove(threadId);
}
}
/**
* Call method with optional parameters
*
* @param method name of method to call
* @param params parameters to pass to method (may be null if method has no parameters)
* @return deserialized method return value
* @throws XMLRPCException
*/
private Object callXMLRPC(String method, Object[] params, File tempFile)
throws XMLRPCException, IOException, XmlPullParserException {
mLoggedInputStream = null;
try {
preparePostMethod(method, params, tempFile);
// execute HTTP POST request
HttpResponse response = mClient.execute(mPostMethod);
if (response.getStatusLine() == null) // StatusLine is null. We can't read the response code.
throw new XMLRPCException( "HTTP Status code is missing!" );
int statusCode = response.getStatusLine().getStatusCode();
HttpEntity entity = response.getEntity();
if (entity == null) {
//This is an error since the parser will fail here.
throw new XMLRPCException( "HTTP status code: " + statusCode + " was returned AND no response from the server." );
}
if (statusCode == HttpStatus.SC_OK) {
mLoggedInputStream = new LoggedInputStream(entity.getContent());
return XMLRPCClient.parseXMLRPCResponse(mLoggedInputStream, entity);
}
String statusLineReasonPhrase = StringUtils.notNullStr(response.getStatusLine().getReasonPhrase());
try {
String responseString = EntityUtils.toString(entity, "UTF-8");
if (TextUtils.isEmpty(responseString)) {
AppLog.e(T.API, "No HTTP error document document from the server");
} else {
AppLog.e(T.API, "HTTP error document received from the server: " + responseString);
}
if (statusCode == HttpStatus.SC_INTERNAL_SERVER_ERROR) {
//Try to intercept out of memory error here and show a better error message.
if (!TextUtils.isEmpty(responseString) && responseString.contains("php fatal error") &&
responseString.contains("bytes exhausted")) {
String newErrorMsg;
if (method.equals(Method.UPLOAD_FILE)) {
newErrorMsg =
"The server doesn't have enough memory to upload this file. You may need to increase the PHP memory limit on your site.";
} else {
newErrorMsg =
"The server doesn't have enough memory to fulfill the request. You may need to increase the PHP memory limit on your site.";
}
throw new XMLRPCException( statusLineReasonPhrase + ".\n\n" + newErrorMsg);
}
}
} catch (Exception e) {
// eat all the exceptions here, we dont want to crash the app when trying to show a
// better error message.
}
throw new XMLRPCException( "HTTP status code: " + statusCode + " was returned. " + statusLineReasonPhrase);
} catch (XMLRPCFault e) {
if (mLoggedInputStream!=null) {
AppLog.w(T.API, "Response document received from the server: " + mLoggedInputStream.getResponseDocument());
}
// Detect login issues and broadcast a message if the error is known
switch (e.getFaultCode()) {
case 403:
// Ignore 403 error from certain methods known for replying with incorrect error code on
// lacking permissions
if ("wp.getPostFormats".equals(method) || "wp.getCommentStatusList".equals(method)
|| "wp.getPostStatusList".equals(method) || "wp.getPageStatusList".equals(method)) {
break;
}
EventBus.getDefault().post(new CoreEvents.InvalidCredentialsDetected());
break;
case 425:
EventBus.getDefault().post(new CoreEvents.TwoFactorAuthenticationDetected());
break;
//TODO: Check the login limit here
default:
break;
}
throw e;
} catch (XmlPullParserException e) {
AppLog.e(T.API, "Error while parsing the XML-RPC response document received from the server.", e);
if (mLoggedInputStream!=null) {
AppLog.e(T.API, "Response document received from the server: " + mLoggedInputStream.getResponseDocument());
}
checkXMLRPCErrorMessage(e);
throw e;
} catch (NumberFormatException e) {
//we can catch NumberFormatException here and re-throw an XMLRPCException.
//The response document is not a valid XML-RPC document after all.
AppLog.e(T.API, "Error while parsing the XML-RPC response document received from the server.", e);
if (mLoggedInputStream!=null) {
AppLog.e(T.API, "Response document received from the server: " + mLoggedInputStream.getResponseDocument());
}
throw new XMLRPCException("The response received contains an invalid number. " + e.getMessage());
} catch (XMLRPCException e) {
if (mLoggedInputStream!=null) {
AppLog.e(T.API, "Response document received from the server: " + mLoggedInputStream.getResponseDocument());
}
checkXMLRPCErrorMessage(e);
throw e;
} catch (SSLHandshakeException e) {
if (mIsWpcom) {
AppLog.e(T.NUX, "SSLHandshakeException failed. Erroneous SSL certificate detected on wordpress.com");
} else {
AppLog.w(T.NUX, "SSLHandshakeException failed. Erroneous SSL certificate detected.");
EventBus.getDefault().post(new CoreEvents.InvalidSslCertificateDetected());
}
throw e;
} catch (SSLPeerUnverifiedException e) {
if (mIsWpcom) {
AppLog.e(T.NUX, "SSLPeerUnverifiedException failed. Erroneous SSL certificate detected on wordpress.com");
} else {
AppLog.w(T.NUX, "SSLPeerUnverifiedException failed. Erroneous SSL certificate detected.");
EventBus.getDefault().post(new CoreEvents.InvalidSslCertificateDetected());
}
throw e;
} catch (IOException e) {
throw e;
} finally {
deleteTempFile(method, tempFile);
try {
if (mLoggedInputStream != null) {
mLoggedInputStream.close();
}
} catch (Exception e) {
}
}
}
}
/**
* Detect login issues and broadcast a message if the error is known, App Activities should listen to these
* broadcasted events and present user action to take
*
* @return true if error is known and event broadcasted, false else
*/
private boolean checkXMLRPCErrorMessage(Exception exception) {
String errorMessage = exception.getMessage().toLowerCase();
if ((errorMessage.contains("code: 503") || errorMessage.contains("code 503")) &&
(errorMessage.contains("limit reached") || errorMessage.contains("login limit"))) {
EventBus.getDefault().post(new CoreEvents.LoginLimitDetected());
return true;
}
return false;
}
private void deleteTempFile(String method, File tempFile) {
if (tempFile != null) {
if ((method.equals(Method.UPLOAD_FILE))){ //get rid of the temp file
tempFile.delete();
}
}
}
private void addWPComAuthorizationHeaderIfNeeded() {
Context ctx = WordPress.getContext();
if (ctx == null) return;
if (isDotComXMLRPCEndpoint(mPostMethod.getURI())) {
String token = AccountHelper.getDefaultAccount().getAccessToken();
if (!TextUtils.isEmpty(token)) {
setAuthorizationHeader(token);
}
}
}
// Return true if wpcom XML-RPC Endpoint is called on a secure connection (https).
public boolean isDotComXMLRPCEndpoint(URI clientUri) {
if (clientUri == null) return false;
String path = clientUri.getPath();
String host = clientUri.getHost();
String protocol = clientUri.getScheme();
if (path == null || host == null || protocol == null) {
return false;
}
return path.equals("/xmlrpc.php") && WPUrlUtils.safeToAddWordPressComAuthToken(clientUri) && protocol.equals("https");
}
private class CancelException extends RuntimeException {
private static final long serialVersionUID = 1L;
}
private class CountingOutputStream extends FilterOutputStream {
private long mTotalBytes;
CountingOutputStream(final OutputStream out) {
super(out);
}
@Override
public void write(int b) throws IOException {
out.write(b);
}
@Override
public void write(byte[] b) throws IOException {
out.write(b);
mTotalBytes += b.length;
if (mOnBytesUploadedListener != null) {
mOnBytesUploadedListener.onBytesUploaded(mTotalBytes);
}
}
@Override
public void write(byte[] b, int off, int len) throws IOException {
out.write(b, off, len);
mTotalBytes += len;
if (mOnBytesUploadedListener != null) {
mOnBytesUploadedListener.onBytesUploaded(mTotalBytes);
}
}
}
public void setOnBytesUploadedListener(OnBytesUploadedListener listener) {
mOnBytesUploadedListener = listener;
}
}